Add camera resize and keyframe controls

This commit is contained in:
th committed 2026-06-08 00:33:57 +02:00
1 parent 3a93d6ea5d
commit b9a4b53109
5 files changed
+428 -17

No files matched your search

+82 -3
View File
@@ -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<CameraProps | null>(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<MouseEvent>) {
if (e) e.cancelBubble = true
store.selectCamera(true)
@@ -817,6 +847,47 @@ function onCameraDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraDragMove(e)
}
function onCameraResizeStart(e: Konva.KonvaEventObject<DragEvent | MouseEvent>) {
e.cancelBubble = true
if (store.scene.camera?.locked) return
store.selectCamera(true)
cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
}
function onCameraResizeDrag(e: Konva.KonvaEventObject<DragEvent>) {
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<DragEvent>) {
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"
/>
<v-rect
v-if="cameraResizeHandleConfig"
:config="cameraResizeHandleConfig"
@mousedown="onCameraResizeStart"
@dragstart="onCameraResizeStart"
@dragmove="onCameraResizeDrag"
@dragend="onCameraResizeEnd"
/>
<v-rect
v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`"
+4 -1
View File
@@ -29,7 +29,8 @@ let player: PlayerHandle | null = null
const aspect = computed(
() => {
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) {
+18 -2
View File
@@ -136,6 +136,16 @@ function setCameraProp(patch: Partial<CameraProps>) {
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<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
@@ -146,11 +156,17 @@ const cameraY = computed<number>({
})
const cameraWidth = computed<number>({
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<number>({
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() {
+143 -11
View File
@@ -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<CameraKfDrag | null>(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<string>): 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"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(store.scene.camera?.keyframes ?? [], duration)"
:key="`camera-${seg.id}`"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in store.scene.camera?.keyframes ?? []"
:key="kf.id"
@@ -3636,8 +3762,13 @@ onBeforeUnmount(() => {
: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)"
/>
</div>
</div>
@@ -4368,6 +4499,7 @@ onBeforeUnmount(() => {
<div class="kf-menu-divider"></div>
</template>
<button
v-if="menu.target === 'layer'"
class="kf-menu-item"
:class="{ active: menu.isEnd }"
@click="toggleEndKind"
@@ -4381,8 +4513,8 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
<button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
+181
View File
@@ -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<number>()
;(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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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()
}