From b9a4b5310934190503cf9aa3755e88850167e6a2 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 8 Jun 2026 00:33:57 +0200
Subject: [PATCH] Add camera resize and keyframe controls
---
src/components/CanvasStage.vue | 85 ++++++++++++-
src/components/FullscreenPreview.vue | 5 +-
src/components/PropertiesPanel.vue | 20 ++-
src/components/Timeline.vue | 154 +++++++++++++++++++++--
src/devtools/tests.ts | 181 +++++++++++++++++++++++++++
5 files changed, 428 insertions(+), 17 deletions(-)
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 6c1e38c..d6cd9f5 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -789,15 +789,45 @@ const cameraFrameConfig = computed(() => {
height: frame.height,
fillEnabled: false,
stroke: selected ? '#38bdf8' : '#0ea5e9',
- strokeWidth: (selected ? 2.5 : 1.5) / zoom.value,
+ strokeWidth: (selected ? 1.4 : 1) / zoom.value,
strokeScaleEnabled: false,
- dash: [10 / zoom.value, 6 / zoom.value],
- hitStrokeWidth: Math.max(10 / zoom.value, 8),
+ dash: [4 / zoom.value, 3 / zoom.value],
+ hitStrokeWidth: Math.max(8 / zoom.value, 6),
listening: true,
draggable: selected && !camera.locked,
}
})
+const cameraResizeDragStart = ref(null)
+
+function cameraOutputAspect(): number {
+ const canvas = store.scene.canvas
+ const width = Number.isFinite(canvas.width) && canvas.width > 0 ? canvas.width : 1
+ const height = Number.isFinite(canvas.height) && canvas.height > 0 ? canvas.height : 1
+ return width / height
+}
+
+const cameraResizeHandleConfig = computed(() => {
+ const frame = cameraFrame.value
+ const camera = store.scene.camera
+ if (!frame || !camera || !store.selectedCamera || camera.locked) return null
+ const size = 10 / zoom.value
+ return {
+ id: 'camera-resize-handle',
+ name: 'camera-resize-handle',
+ x: frame.x + frame.width - size / 2,
+ y: frame.y + frame.height - size / 2,
+ width: size,
+ height: size,
+ fill: '#f8fafc',
+ stroke: '#38bdf8',
+ strokeWidth: 1 / zoom.value,
+ strokeScaleEnabled: false,
+ draggable: true,
+ listening: true,
+ }
+})
+
function selectCameraFromStage(e?: Konva.KonvaEventObject) {
if (e) e.cancelBubble = true
store.selectCamera(true)
@@ -817,6 +847,47 @@ function onCameraDragEnd(e: Konva.KonvaEventObject) {
onCameraDragMove(e)
}
+function onCameraResizeStart(e: Konva.KonvaEventObject) {
+ e.cancelBubble = true
+ if (store.scene.camera?.locked) return
+ store.selectCamera(true)
+ cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
+}
+
+function onCameraResizeDrag(e: Konva.KonvaEventObject) {
+ e.cancelBubble = true
+ const camera = store.scene.camera
+ const start = cameraResizeDragStart.value ?? cameraFrame.value
+ if (!camera || camera.locked || !start) return
+
+ const node = e.target
+ const handleSize = cameraResizeHandleConfig.value?.width ?? 10 / zoom.value
+ const aspect = cameraOutputAspect()
+ const widthFromPointer = Math.max(1, node.x() + handleSize / 2 - start.x)
+ const heightFromPointer = Math.max(1, node.y() + handleSize / 2 - start.y)
+ const widthDelta = Math.abs(widthFromPointer - start.width) / Math.max(1, start.width)
+ const heightDelta = Math.abs(heightFromPointer - start.height) / Math.max(1, start.height)
+ const width = Math.max(
+ 1,
+ Math.round(widthDelta >= heightDelta ? widthFromPointer : heightFromPointer * aspect),
+ )
+ const height = Math.max(1, Math.round(width / aspect))
+
+ node.x(start.x + width - handleSize / 2)
+ node.y(start.y + height - handleSize / 2)
+ store.upsertCameraKeyframe(store.playhead, {
+ x: Math.round(start.x),
+ y: Math.round(start.y),
+ width,
+ height,
+ })
+}
+
+function onCameraResizeEnd(e: Konva.KonvaEventObject) {
+ onCameraResizeDrag(e)
+ cameraResizeDragStart.value = null
+}
+
function toggleCameraEnabled() {
if (store.scene.camera?.enabled) {
store.disableCamera()
@@ -4793,6 +4864,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@dragmove="onCameraDragMove"
@dragend="onCameraDragEnd"
/>
+
{
if (isCameraEnabled(store.scene)) {
- const a = cameraAspect(store.scene, store.playhead)
+ const time = props.modelValue ? localTime.value : store.playhead
+ const a = cameraAspect(store.scene, time)
return `${a} / 1`
}
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
@@ -56,6 +57,7 @@ async function startPreview() {
const end = rangeOut.value
const head = store.playhead
const initialTime = head < start || head >= end ? start : head
+ localTime.value = initialTime
mountAt(el, initialTime, true)
requestBrowserFullscreen()
}
@@ -66,6 +68,7 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
+ localTime.value = time
player = mountPlayer(el, store.project, {
loop: true,
onFrame(t: number, paused: boolean) {
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
index 9d1b82f..3202a3e 100644
--- a/src/components/PropertiesPanel.vue
+++ b/src/components/PropertiesPanel.vue
@@ -136,6 +136,16 @@ function setCameraProp(patch: Partial) {
store.upsertCameraKeyframe(store.playhead, patch)
}
+function cameraOutputAspect(): number {
+ const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
+ ? store.scene.canvas.width
+ : 1
+ const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
+ ? store.scene.canvas.height
+ : 1
+ return width / height
+}
+
const cameraX = computed({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
@@ -146,11 +156,17 @@ const cameraY = computed({
})
const cameraWidth = computed({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
- set: (v) => setCameraProp({ width: Math.max(1, v) }),
+ set: (v) => {
+ const width = Math.max(1, v)
+ setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
+ },
})
const cameraHeight = computed({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
- set: (v) => setCameraProp({ height: Math.max(1, v) }),
+ set: (v) => {
+ const height = Math.max(1, v)
+ setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
+ },
})
function removeCameraKeyframeHere() {
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index bd8c587..06a7919 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -819,6 +819,11 @@ function onCameraLaneDblClick(e: MouseEvent) {
function onCameraKeyframeClick(e: MouseEvent, time: number) {
e.stopPropagation()
+ if (cameraKfDrag.value?.moved) {
+ cameraKfDrag.value = null
+ return
+ }
+ cameraKfDrag.value = null
store.selectCamera(true)
store.setPlayhead(time)
}
@@ -830,6 +835,94 @@ function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
store.removeCameraKeyframe(keyframeId)
}
+function onCameraKeyframeContextMenu(e: MouseEvent, keyframeId: string) {
+ e.preventDefault()
+ e.stopPropagation()
+ const camera = store.scene.camera
+ if (!camera || camera.locked) return
+ const kf = camera.keyframes.find((item) => item.id === keyframeId)
+ if (!kf) return
+ const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
+ menu.value = {
+ target: 'camera',
+ keyframeId: kf.id,
+ time: kf.time,
+ x: e.clientX,
+ y: e.clientY,
+ currentEasing: kf.easing,
+ isFirst: sorted[0]?.id === kf.id,
+ isLast: sorted[sorted.length - 1]?.id === kf.id,
+ isHeld: !!kf.hold,
+ isEnd: false,
+ hasEnd: false,
+ }
+ store.selectCamera(true)
+}
+
+interface CameraKfDrag {
+ keyframeId: string
+ lane: HTMLElement
+ pointerId: number
+ startX: number
+ moved: boolean
+ initialTime: number
+}
+
+const cameraKfDrag = ref(null)
+
+function onCameraKeyframePointerDown(e: PointerEvent, keyframeId: string) {
+ if (e.button !== 0) return
+ const camera = store.scene.camera
+ if (!camera || camera.locked) return
+ const kfEl = e.currentTarget as HTMLElement
+ const lane = kfEl.parentElement
+ const kf = camera.keyframes.find((item) => item.id === keyframeId)
+ if (!lane || !kf) return
+
+ e.stopPropagation()
+ kfEl.setPointerCapture(e.pointerId)
+ store.selectCamera(true)
+ cameraKfDrag.value = {
+ keyframeId,
+ lane,
+ pointerId: e.pointerId,
+ startX: e.clientX,
+ moved: false,
+ initialTime: kf.time,
+ }
+}
+
+function onCameraKeyframePointerMove(e: PointerEvent) {
+ const d = cameraKfDrag.value
+ if (!d || d.pointerId !== e.pointerId) return
+ if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
+ d.moved = true
+ const rect = d.lane.getBoundingClientRect()
+ if (rect.width <= 0) return
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
+ const rawNewTime = ratio * duration.value
+ const newTime = snapToCandidates(
+ rawNewTime,
+ [...allKeyframeTimesExcluding(new Set([d.keyframeId])), store.playhead],
+ {
+ enabled: snapEnabled.value,
+ pxPerSec: effectivePxPerSec.value,
+ pixelThreshold: SNAP_PX,
+ duration: duration.value,
+ },
+ )
+ store.setCameraKeyframeTime(d.keyframeId, newTime)
+ store.setPlayhead(newTime)
+}
+
+function onCameraKeyframePointerUp(e: PointerEvent) {
+ const d = cameraKfDrag.value
+ if (!d || d.pointerId !== e.pointerId) return
+ const el = e.currentTarget as HTMLElement
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
+ if (!d.moved) cameraKfDrag.value = null
+}
+
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) {
@@ -1306,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
+ for (const k of store.scene.camera?.keyframes ?? []) {
+ if (!ignoreIds.has(k.id)) out.push(k.time)
+ }
return out
}
@@ -1418,6 +1514,14 @@ interface KfSegment {
tail?: boolean
}
+interface KfSegmentSource {
+ id: string
+ time: number
+ easing: Easing
+ hold?: boolean
+ kind?: 'normal' | 'end'
+}
+
/** Render-time filter for the kf v-for. Hides end kfs that share a
* time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a
@@ -1435,7 +1539,7 @@ function displayKfs(kfs: Keyframe[]): Keyframe[] {
})
}
-function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
+function segmentsOf(kfs: KfSegmentSource[], duration: number, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
// End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through
@@ -1571,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
]
interface KfMenuState {
- layerId: string
+ target: 'layer' | 'camera'
+ layerId?: string
keyframeId: string
time: number
x: number
@@ -1610,6 +1715,7 @@ function onKeyframeContextMenu(
.sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = {
+ target: 'layer',
layerId,
keyframeId: kf.id,
time: kf.time,
@@ -1627,7 +1733,11 @@ function onKeyframeContextMenu(
function toggleHold() {
if (!menu.value) return
- store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
+ if (menu.value.target === 'camera') {
+ store.setCameraKeyframeHold(menu.value.keyframeId, !menu.value.isHeld)
+ } else if (menu.value.layerId) {
+ store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
+ }
menu.value = null
}
@@ -1639,7 +1749,7 @@ function toggleHold() {
* we delete the existing one before promoting the new one. */
function toggleEndKind() {
const m = menu.value
- if (!m) return
+ if (!m || m.target !== 'layer' || !m.layerId) return
const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
@@ -1663,21 +1773,30 @@ function toggleEndKind() {
function applyEasing(easing: Easing) {
if (!menu.value) return
- store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
+ if (menu.value.target === 'camera') {
+ store.setCameraKeyframeEasing(menu.value.keyframeId, easing)
+ } else if (menu.value.layerId) {
+ store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
+ }
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
- store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
+ if (menu.value.target === 'camera') {
+ store.removeCameraKeyframe(menu.value.keyframeId)
+ } else if (menu.value.layerId) {
+ store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
+ }
menu.value = null
}
function duplicateFromMenu() {
- if (!menu.value) return
- const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
+ if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
+ const layerId = menu.value.layerId
+ const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
if (newId) {
- const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
+ const layer = store.activeLayers.find((l) => l.id === layerId)
const all = layer
? [
...layer.keyframes,
@@ -3629,6 +3748,13 @@ onBeforeUnmount(() => {
@dblclick="onCameraLaneDblClick"
>
+
{
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
+ @pointerdown="onCameraKeyframePointerDown($event, kf.id)"
+ @pointermove="onCameraKeyframePointerMove"
+ @pointerup="onCameraKeyframePointerUp"
+ @pointercancel="onCameraKeyframePointerUp"
@click="onCameraKeyframeClick($event, kf.time)"
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
+ @contextmenu="onCameraKeyframeContextMenu($event, kf.id)"
/>
@@ -4368,6 +4499,7 @@ onBeforeUnmount(() => {
-
- |