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(() => {
-
- diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 2d8dc01..4d87fd1 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -10224,6 +10224,32 @@ export function registerAllTests(): void { fakeRect } + async function mountCameraCanvasWithKonva() { + const VueKonva = (await import('vue-konva')).default + const host = document.createElement('div') + host.setAttribute('data-test-host', '') + host.style.width = '800px' + host.style.height = '600px' + document.body.appendChild(host) + const app = createApp({ render() { return h(CanvasStage, {}) } }) + app.use(VueKonva) + app.directive('tooltip', vTooltip) + app.mount(host) + return { + host, + app, + unmount() { + app.unmount() + host.remove() + }, + } + } + + async function getCameraKonvaStage() { + const Konva = (await import('konva')).default + return Konva.stages[Konva.stages.length - 1] + } + test('stage camera button enables camera without resizing the editor stage', async () => { const s = makeFreshStore() s.scene.canvas.width = 640 @@ -10285,6 +10311,7 @@ export function registerAllTests(): void { expect(s.scene.camera?.keyframes.length).toBe(1) expect(s.scene.camera?.keyframes[0].props.x).toBe(24) expect(s.scene.camera?.keyframes[0].props.width).toBe(320) + expect(s.scene.camera?.keyframes[0].props.height).toBe(180) expect(s.scene.canvas.width).toBe(800) expect(s.scene.canvas.height).toBe(450) void yInput @@ -10293,6 +10320,63 @@ export function registerAllTests(): void { } }) + test('stage camera frame has one lower-right aspect resize handle', async () => { + const s = makeFreshStore() + s.scene.canvas.width = 800 + s.scene.canvas.height = 450 + s.editor.canvasZoom = 1 + s.enableCamera() + s.selectCamera(true) + const handle = await mountCameraCanvasWithKonva() + try { + await nextTick() + await tick(60) + const stage = await getCameraKonvaStage() as unknown as { + find: (sel: string) => unknown[] + findOne: (sel: string) => unknown + } + const frame = stage.findOne('#camera-frame') as { + strokeWidth: () => number + dash: () => number[] + } + expect(frame).toBeTruthy() + expect(frame.strokeWidth()).toBeLessThan(2.5) + const dash = frame.dash() + expect(dash.length).toBe(2) + expect(dash[0]).toBeLessThan(10) + expect(dash[0] / dash[1]).toBeCloseTo(4 / 3, 4) + + const handles = stage.find('.camera-resize-handle') as Array<{ + x: (v?: number) => number + y: (v?: number) => number + width: () => number + height: () => number + fire: (type: string, evt: unknown, bubble: boolean) => void + }> + expect(handles.length).toBe(1) + const grip = handles[0] + const half = grip.width() / 2 + expect(grip.height()).toBe(grip.width()) + + grip.fire('dragstart', { target: grip, type: 'dragstart' }, true) + grip.x(400 - half) + grip.y(225 - half) + grip.fire('dragmove', { target: grip, type: 'dragmove' }, true) + await nextTick() + + const kf = s.scene.camera?.keyframes[0] + expect(s.scene.camera?.keyframes.length).toBe(1) + expect(kf?.props.x).toBe(0) + expect(kf?.props.y).toBe(0) + expect(kf?.props.width).toBe(400) + expect(kf?.props.height).toBe(225) + expect(s.scene.canvas.width).toBe(800) + expect(s.scene.canvas.height).toBe(450) + } finally { + handle.unmount() + } + }) + test('timeline camera row selects, hides, locks, and adds camera keyframes', async () => { const s = makeFreshStore() s.setDuration(10) @@ -10340,6 +10424,103 @@ export function registerAllTests(): void { expect(s.scene.camera?.keyframes.length).toBe(1) expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 5) < 0.1).toBe(true) expect(Math.abs(s.playhead - 5) < 0.1).toBe(true) + + await nextTick() + const cameraKf = handle.host.querySelector('.camera-track .camera-kf') as HTMLElement + expect(cameraKf).toBeTruthy() + const captured = new Set() + ;(cameraKf as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => { + captured.add(id) + } + ;(cameraKf as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => { + captured.delete(id) + } + ;(cameraKf as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => + captured.has(id) + + cameraKf.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + clientX: 500, + }), + ) + cameraKf.dispatchEvent( + new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + pointerId: 1, + clientX: 700, + }), + ) + cameraKf.dispatchEvent( + new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + pointerId: 1, + clientX: 700, + }), + ) + cameraKf.click() + await nextTick() + + expect(s.scene.camera?.keyframes.length).toBe(1) + expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 7) < 0.1).toBe(true) + expect(Math.abs(s.playhead - 7) < 0.1).toBe(true) + + lane.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 900 }), + ) + await nextTick() + expect(s.scene.camera?.keyframes.length).toBe(2) + const secondCameraKf = s.scene.camera?.keyframes.find((kf) => Math.abs(kf.time - 9) < 0.1) + expect(secondCameraKf).toBeTruthy() + + const cameraKfs = Array.from( + handle.host.querySelectorAll('.camera-track .camera-kf'), + ) + const secondKfEl = cameraKfs[cameraKfs.length - 1] + secondKfEl.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 320, + clientY: 180, + }), + ) + await nextTick() + let menu = handle.host.querySelector('.kf-menu') as HTMLElement + expect(menu).toBeTruthy() + const easeIn = Array.from(menu.querySelectorAll('.kf-menu-item')).find( + (button) => button.textContent?.includes(i18nT('timeline.easingEaseIn')), + ) + expect(easeIn).toBeTruthy() + expect(easeIn?.disabled).toBe(false) + easeIn!.click() + await nextTick() + expect(secondCameraKf?.easing).toBe('easeIn') + expect(handle.host.querySelector('.camera-track .kf-seg.easing-easeIn')).toBeTruthy() + + secondKfEl.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 320, + clientY: 180, + }), + ) + await nextTick() + menu = handle.host.querySelector('.kf-menu') as HTMLElement + const deleteButton = Array.from(menu.querySelectorAll('.kf-menu-item')).find( + (button) => button.textContent?.includes(i18nT('timeline.delete')), + ) + expect(deleteButton).toBeTruthy() + deleteButton!.click() + await nextTick() + expect(s.scene.camera?.keyframes.length).toBe(1) + expect(s.scene.camera?.keyframes.some((kf) => kf.id === secondCameraKf?.id)).toBe(false) } finally { handle.unmount() }