Add output camera framing

This commit is contained in:
th committed 2026-06-07 23:12:46 +02:00
1 parent 616ced17ce
commit 23a66c6fdf
19 files changed
+1955 -43

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="theme-color" content="#17171b" /> <meta name="theme-color" content="#17171b" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/> />
<title>RUWIO</title> <title>RUWIO</title>
</head> </head>
+59
View File
@@ -102,6 +102,56 @@
} }
return merge(layer.base); return merge(layer.base);
} }
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){ function loadImages(assets){
var imgs={}; var imgs={};
var list=assets.filter(function(a){return a.kind==='image';}); var list=assets.filter(function(a){return a.kind==='image';});
@@ -267,8 +317,17 @@
function drawFrame(time){ function drawFrame(time){
if(!scene) return; if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height; var lw=scene.canvas.width, lh=scene.canvas.height;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save(); ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip(); ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
if(useCamera){
ctx.scale(lw/cam.width, lh/cam.height);
ctx.translate(-cam.x,-cam.y);
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
}
if(!scene.backgroundVideo){ if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); ctx.fillRect(0,0,lw,lh);
+33
View File
@@ -71,6 +71,39 @@ function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
} }
} }
} }
if (scene.camera != null) {
const camera = scene.camera
if (typeof camera !== 'object') return { ok: false, error: 'camera must be an object' }
if (camera.enabled) {
const cw = Number(camera.base?.width)
const ch = Number(camera.base?.height)
if (!Number.isFinite(cw) || cw <= 0 || !Number.isFinite(ch) || ch <= 0) {
return { ok: false, error: 'enabled camera must have positive width and height' }
}
}
if (camera.keyframes != null) {
if (!Array.isArray(camera.keyframes)) {
return { ok: false, error: 'camera keyframes must be an array' }
}
keyframes += camera.keyframes.length
if (keyframes > limits.maxKeyframes) {
return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
}
for (const kf of camera.keyframes) {
if (!Number.isFinite(Number(kf?.time))) {
return { ok: false, error: 'keyframe times must be finite numbers' }
}
const w = kf?.props?.width
const h = kf?.props?.height
if (w != null && (!Number.isFinite(Number(w)) || Number(w) <= 0)) {
return { ok: false, error: 'camera keyframe width must be positive' }
}
if (h != null && (!Number.isFinite(Number(h)) || Number(h) <= 0)) {
return { ok: false, error: 'camera keyframe height must be positive' }
}
}
}
}
const duration = Number(scene.duration) const duration = Number(scene.duration)
if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) { if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) {
return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` } return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` }
+31
View File
@@ -56,6 +56,37 @@ test('rejects invalid crf and preset', () => {
assert.equal(validateRenderRequest(scene(), { preset: 'turbo' }).ok, false) assert.equal(validateRenderRequest(scene(), { preset: 'turbo' }).ok, false)
}) })
test('validates enabled camera data', () => {
const valid = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 320, height: 180 },
keyframes: [{ time: 0.5, props: { x: 10 }, easing: 'linear' }],
},
}))
assert.equal(valid.ok, true)
const invalidSize = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 0, height: 180 },
keyframes: [],
},
}))
assert.equal(invalidSize.ok, false)
assert.match(invalidSize.error, /camera/)
const invalidKf = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 320, height: 180 },
keyframes: [{ time: Number.NaN, props: {}, easing: 'linear' }],
},
}))
assert.equal(invalidKf.ok, false)
assert.match(invalidKf.error, /keyframe/)
})
test('computes playback range from in/out or last keyframe', () => { test('computes playback range from in/out or last keyframe', () => {
assert.deepEqual(playbackRange(scene({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 }) assert.deepEqual(playbackRange(scene({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 })
assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 }) assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 })
+78
View File
@@ -30,12 +30,14 @@ import {
shouldRotateClipCopies, shouldRotateClipCopies,
} from '@/lib/kaleido' } from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render' import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import { sampleCamera } from '@/lib/camera'
import type { import type {
Action, Action,
AnimatableProps, AnimatableProps,
Asset, Asset,
ButtonAsset, ButtonAsset,
ButtonState, ButtonState,
CameraProps,
ClipInstance, ClipInstance,
HitShape, HitShape,
Keyframe, Keyframe,
@@ -762,6 +764,67 @@ const clipGroupConfig = computed(() => {
} }
}) })
const cameraVisibleInEditor = computed(() => {
if (store.activeClipId || store.activeButtonAssetId || store.activeSvgAssetId) return false
const camera = store.scene.camera
return !!camera && camera.overlayVisible !== false
})
const cameraFrame = computed<CameraProps | null>(() => {
if (!cameraVisibleInEditor.value) return null
return sampleCamera(store.scene, store.playhead)
})
const cameraFrameConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera) return null
const selected = store.selectedCamera
return {
id: 'camera-frame',
name: 'camera-frame',
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
fillEnabled: false,
stroke: selected ? '#38bdf8' : '#0ea5e9',
strokeWidth: (selected ? 2.5 : 1.5) / zoom.value,
strokeScaleEnabled: false,
dash: [10 / zoom.value, 6 / zoom.value],
hitStrokeWidth: Math.max(10 / zoom.value, 8),
listening: true,
draggable: selected && !camera.locked,
}
})
function selectCameraFromStage(e?: Konva.KonvaEventObject<MouseEvent>) {
if (e) e.cancelBubble = true
store.selectCamera(true)
}
function onCameraDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const camera = store.scene.camera
if (!camera || camera.locked) return
const node = e.target
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(node.x()),
y: Math.round(node.y()),
})
}
function onCameraDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraDragMove(e)
}
function toggleCameraEnabled() {
if (store.scene.camera?.enabled) {
store.disableCamera()
return
}
store.enableCamera()
}
/** Horizontal + vertical centre-crosshair line configs. Null when /** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre * the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2, * is exactly where layer keyframes default to (canvas.width/2,
@@ -4723,6 +4786,13 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-line :config="centerCrosshairConfigs.horizontal" /> <v-line :config="centerCrosshairConfigs.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" /> <v-line :config="centerCrosshairConfigs.vertical" />
</template> </template>
<v-rect
v-if="cameraFrameConfig"
:config="cameraFrameConfig"
@mousedown="selectCameraFromStage"
@dragmove="onCameraDragMove"
@dragend="onCameraDragEnd"
/>
<v-rect <v-rect
v-for="h in canvasResizeHandles" v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`" :key="`canvas-resize-${h.edge}`"
@@ -4926,6 +4996,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
> >
<PhCursorClick :size="13" /> <PhCursorClick :size="13" />
</button> </button>
<button
class="zc-btn camera-btn"
:class="{ active: store.scene.camera?.enabled, selected: store.selectedCamera }"
v-tooltip="store.scene.camera?.enabled ? t('canvas.cameraOff') : t('canvas.cameraOn')"
@click="toggleCameraEnabled"
>
<PhFrameCorners :size="13" />
</button>
<button <button
class="zc-btn motion-paths-btn" class="zc-btn motion-paths-btn"
:class="{ active: showMotionPaths }" :class="{ active: showMotionPaths }"
+8 -1
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue' import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { cameraAspect, isCameraEnabled } from '@/lib/camera'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer' import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat' import { useTimeFormat } from '@/composables/useTimeFormat'
@@ -26,7 +27,13 @@ let isClosing = false
let player: PlayerHandle | null = null let player: PlayerHandle | null = null
const aspect = computed( const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`, () => {
if (isCameraEnabled(store.scene)) {
const a = cameraAspect(store.scene, store.playhead)
return `${a} / 1`
}
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
},
) )
const rangeIn = computed(() => store.playbackIn) const rangeIn = computed(() => store.playbackIn)
+126 -2
View File
@@ -17,8 +17,9 @@ import {
PhX, PhX,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene' import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue' import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue' import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue' import FontPicker from './FontPicker.vue'
@@ -97,6 +98,65 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null, selected.value ? sampleLayer(selected.value, store.playhead) : null,
) )
const selectedCameraTrack = computed(() =>
store.selectedCamera ? (store.scene.camera ?? null) : null,
)
const sampledCamera = computed<CameraProps | null>(() =>
selectedCameraTrack.value ? sampleCamera(store.scene, store.playhead) : null,
)
const cameraHasKeyframeHere = computed(() => {
const camera = selectedCameraTrack.value
if (!camera) return false
return camera.keyframes.some((kf) => Math.abs(kf.time - store.playhead) < 1e-3)
})
const cameraEnabled = computed<boolean>({
get: () => selectedCameraTrack.value?.enabled ?? false,
set: (v) => {
if (v) store.enableCamera()
else store.disableCamera()
},
})
const cameraOverlayVisible = computed<boolean>({
get: () => selectedCameraTrack.value?.overlayVisible !== false,
set: (v) => store.setCameraOverlayVisible(v),
})
const cameraLocked = computed<boolean>({
get: () => selectedCameraTrack.value?.locked === true,
set: (v) => store.setCameraLocked(v),
})
function setCameraProp(patch: Partial<CameraProps>) {
const camera = selectedCameraTrack.value
if (!camera || camera.locked) return
store.upsertCameraKeyframe(store.playhead, patch)
}
const cameraX = computed<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
})
const cameraY = computed<number>({
get: () => Math.round(sampledCamera.value?.y ?? 0),
set: (v) => setCameraProp({ y: v }),
})
const cameraWidth = computed<number>({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
set: (v) => setCameraProp({ width: Math.max(1, v) }),
})
const cameraHeight = computed<number>({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
set: (v) => setCameraProp({ height: Math.max(1, v) }),
})
function removeCameraKeyframeHere() {
store.removeCameraKeyframeAt(store.playhead)
}
/** True when the user has more than one layer in the selection. /** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every * Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and * editable selected layer, kind-specific sections collapse, and
@@ -599,7 +659,71 @@ function deleteSelection() {
<template> <template>
<div class="panel"> <div class="panel">
<h3>Properties</h3> <h3>Properties</h3>
<template v-if="selected && sampled && editableLayers.length > 0"> <template v-if="selectedCameraTrack && sampledCamera">
<div class="multi-banner">{{ t('properties.camera.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.camera.frame') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
<button
class="toggle-btn"
:class="{ active: cameraEnabled }"
@click="cameraEnabled = !cameraEnabled"
>
{{ cameraEnabled ? 'On' : 'Off' }}
</button>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
<button
class="toggle-btn"
:class="{ active: cameraOverlayVisible }"
@click="cameraOverlayVisible = !cameraOverlayVisible"
>
{{ cameraOverlayVisible ? 'On' : 'Off' }}
</button>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
<button
class="toggle-btn"
:class="{ active: cameraLocked }"
@click="cameraLocked = !cameraLocked"
>
{{ cameraLocked ? 'On' : 'Off' }}
</button>
</div>
<div class="row">
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
<label>Y<input type="number" v-model.number="cameraY" :disabled="cameraLocked" /></label>
</div>
<div class="row">
<label>{{ t('properties.camera.width') }}<input type="number" min="1" v-model.number="cameraWidth" :disabled="cameraLocked" /></label>
<label>{{ t('properties.camera.height') }}<input type="number" min="1" v-model.number="cameraHeight" :disabled="cameraLocked" /></label>
</div>
<div class="kf-state">
<span>
{{ cameraHasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button
v-if="cameraHasKeyframeHere"
class="kf-btn"
v-tooltip="t('properties.removeKeyframe')"
:disabled="cameraLocked"
@click="removeCameraKeyframeHere"
>
<PhMinus :size="12" />
</button>
</div>
</div>
</section>
</template>
<template v-else-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner"> <div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }} {{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div> </div>
+107
View File
@@ -16,6 +16,7 @@ import {
PhLockOpen, PhLockOpen,
PhEye, PhEye,
PhEyeSlash, PhEyeSlash,
PhFrameCorners,
PhMagnet, PhMagnet,
PhMaskHappy, PhMaskHappy,
PhCircle, PhCircle,
@@ -39,6 +40,7 @@ import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDra
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource' import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform' import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { sampleCamera } from '@/lib/camera'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui' import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue' import ScriptFrameDialog from './ScriptFrameDialog.vue'
@@ -347,6 +349,9 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
if (layer.rotationKeyframes) if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time) for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
} }
for (const k of store.scene.camera?.keyframes ?? []) {
if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out return out
} }
@@ -786,6 +791,45 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
store.upsertKeyframe(track.layerId, time, props) store.upsertKeyframe(track.layerId, time, props)
} }
const cameraTimelineVisible = computed(
() =>
!store.activeClipId &&
!store.activeButtonAssetId &&
!store.activeSvgAssetId &&
!!store.scene.camera,
)
function onCameraTrackClick() {
store.selectCamera(true)
}
function onCameraLaneDblClick(e: MouseEvent) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
store.setPlayhead(time)
store.selectCamera(true)
store.upsertCameraKeyframe(time, sampleCamera(store.scene, time))
}
function onCameraKeyframeClick(e: MouseEvent, time: number) {
e.stopPropagation()
store.selectCamera(true)
store.setPlayhead(time)
}
function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
store.removeCameraKeyframe(keyframeId)
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) { function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation() e.stopPropagation()
if (kfDrag.value?.moved) { if (kfDrag.value?.moved) {
@@ -3534,6 +3578,69 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp" @pointercancel="onTracksPointerUp"
> >
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div> <div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<div
v-if="cameraTimelineVisible"
class="track camera-track"
:class="{ inactive: !store.selectedCamera, locked: !!store.scene.camera?.locked }"
>
<span
class="track-name parent"
:class="{ selected: store.selectedCamera }"
@click="onCameraTrackClick"
>
<PhFrameCorners :size="12" class="name-badge" />
<span class="track-label">{{ t('timeline.cameraTrackName') }}</span>
<button
class="eye-btn"
:class="{ hidden: store.scene.camera?.overlayVisible === false }"
v-tooltip="
store.scene.camera?.overlayVisible === false
? t('timeline.showInEditor')
: t('timeline.hideInEditor')
"
@click.stop="store.setCameraOverlayVisible(store.scene.camera?.overlayVisible === false)"
@mousedown.stop
>
<component :is="store.scene.camera?.overlayVisible === false ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
class="lock-btn"
:class="{ locked: store.scene.camera?.locked }"
v-tooltip="store.scene.camera?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setCameraLocked(!store.scene.camera?.locked)"
@mousedown.stop
>
<span class="lock-icon rest">
<component :is="store.scene.camera?.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="store.scene.camera?.locked ? PhLockOpen : PhLock"
:size="12"
:weight="store.scene.camera?.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane camera-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onCameraLaneDblClick"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="kf in store.scene.camera?.keyframes ?? []"
:key="kf.id"
class="kf camera-kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
@click="onCameraKeyframeClick($event, kf.time)"
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
/>
</div>
</div>
<template v-for="(track, idx) in tracks" :key=" <template v-for="(track, idx) in tracks" :key="
track.type === 'layer' track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}` ? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
+1
View File
@@ -12,6 +12,7 @@ export function useDeselectAll() {
return function deselectAll() { return function deselectAll() {
store.selectLayer(null) store.selectLayer(null)
store.selectClipInstance(null) store.selectClipInstance(null)
store.selectCamera(false)
selectedKfs.value = [] selectedKfs.value = []
} }
} }
+22 -4
View File
@@ -1397,7 +1397,7 @@ export function registerAllTests(): void {
expect(sceneScript).toContain(ext) expect(sceneScript).toContain(ext)
}) })
test('opening the fullscreen preview pauses the editor playback', async () => { test('opening the fullscreen preview pauses the editor playback and mounts runtime canvas', async () => {
// Two runtimes fighting for the same audio output = garbled. // Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own // FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback. // useAudioRuntime quiets down while the preview owns playback.
@@ -1408,6 +1408,24 @@ export function registerAllTests(): void {
await nextTick() await nextTick()
await nextTick() await nextTick()
expect(s.playing).toBe(false) expect(s.playing).toBe(false)
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount()
})
test('opening the fullscreen preview mounts runtime canvas with camera enabled', async () => {
const s = makeFreshStore()
s.enableCamera()
s.setCameraBaseProps({ x: 10, y: 20, width: 320, height: 180 })
const handle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount() handle.unmount()
}) })
}) })
@@ -15551,7 +15569,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT') expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work. // Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'34'") expect(src).toContain("'35'")
}) })
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => { test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15929,7 +15947,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(') expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the // Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing. // later layer-kaleido-pivot landing.
expect(src).toContain("'34'") expect(src).toContain("'35'")
}) })
}) })
@@ -16461,7 +16479,7 @@ export function registerAllTests(): void {
const src = generateRuntimeScript() const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(') expect(src).toContain('function layerArmTf(')
// Bumped on this work. // Bumped on this work.
expect(src).toContain("'34'") expect(src).toContain("'35'")
}) })
}) })
} }
+12
View File
@@ -193,6 +193,15 @@ export const en = {
noLayer: 'No layer selected.', noLayer: 'No layer selected.',
lockedLayer: lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.', 'Selected layer is locked. Unlock the track (or its group) to edit properties.',
camera: {
banner: 'Output camera selected',
frame: 'Camera Frame',
enabled: 'Enabled',
overlay: 'Overlay',
locked: 'Locked',
width: 'Width',
height: 'Height',
},
fill: 'Fill', fill: 'Fill',
transparent: 'Transparent', transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page', eyedropper: 'Pick a colour from anywhere on the page',
@@ -339,6 +348,7 @@ export const en = {
renameGroup: 'Rename group', renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track', renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column', resizeTrackColumn: 'Drag to resize track name column',
cameraTrackName: 'Camera',
clipTrackName: 'Clip track', clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit', toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)', snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -402,6 +412,8 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets', stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair', centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair', centerCrosshairOff: 'Hide centre crosshair',
cameraOn: 'Enable output camera',
cameraOff: 'Disable output camera',
motionPaths: { motionPaths: {
label: 'Show motion paths', label: 'Show motion paths',
help: 'Draws the position-keyframe trajectory of the selected layer on the canvas. Click a dot to seek to that keyframe.', help: 'Draws the position-keyframe trajectory of the selected layer on the canvas. Click a dot to seek to that keyframe.',
+192
View File
@@ -0,0 +1,192 @@
import type { CameraKeyframe, CameraProps, CameraTrack, Easing, Scene } from '@/types/scene'
import { drawSceneFrame } from './render'
const CAMERA_MIN_SIZE = 1
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => 1 - (1 - t) ** 2,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
}
function uid() {
return Math.random().toString(36).slice(2, 10)
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function positive(value: unknown, fallback: number): number {
return Math.max(CAMERA_MIN_SIZE, finite(value, fallback))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function mergeCameraProps(base: CameraProps, props: Partial<CameraProps> | undefined): CameraProps {
return {
x: finite(props?.x, base.x),
y: finite(props?.y, base.y),
width: positive(props?.width, base.width),
height: positive(props?.height, base.height),
}
}
function lerpCameraProps(a: CameraProps, b: CameraProps, t: number): CameraProps {
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
width: positive(lerp(a.width, b.width, t), a.width),
height: positive(lerp(a.height, b.height, t), a.height),
}
}
export function defaultCameraProps(scene: Pick<Scene, 'canvas'>): CameraProps {
return {
x: 0,
y: 0,
width: positive(scene.canvas?.width, 1280),
height: positive(scene.canvas?.height, 720),
}
}
export function createDefaultCameraTrack(
scene: Pick<Scene, 'canvas'>,
enabled = true,
): CameraTrack {
return {
id: uid(),
name: 'Camera',
enabled,
base: defaultCameraProps(scene),
keyframes: [],
overlayVisible: true,
locked: false,
}
}
export function normalizeCameraTrack(
scene: Pick<Scene, 'canvas'>,
camera: unknown,
): CameraTrack | undefined {
if (!camera || typeof camera !== 'object') return undefined
const raw = camera as Partial<CameraTrack>
const fallback = defaultCameraProps(scene)
const base = mergeCameraProps(fallback, raw.base)
const keyframes = Array.isArray(raw.keyframes)
? raw.keyframes.flatMap((kf): CameraKeyframe[] => {
if (!kf || typeof kf !== 'object') return []
const rawKf = kf as Partial<CameraKeyframe>
const props = mergeCameraProps(base, rawKf.props)
const storedProps: Partial<CameraProps> = {}
if (rawKf.props && typeof rawKf.props === 'object') {
const p = rawKf.props as Partial<CameraProps>
if (typeof p.x === 'number' && Number.isFinite(p.x)) storedProps.x = props.x
if (typeof p.y === 'number' && Number.isFinite(p.y)) storedProps.y = props.y
if (typeof p.width === 'number' && Number.isFinite(p.width)) storedProps.width = props.width
if (typeof p.height === 'number' && Number.isFinite(p.height)) storedProps.height = props.height
}
return [
{
id: typeof rawKf.id === 'string' && rawKf.id ? rawKf.id : uid(),
time: Math.max(0, finite(rawKf.time, 0)),
props: storedProps,
easing:
rawKf.easing === 'easeIn' ||
rawKf.easing === 'easeOut' ||
rawKf.easing === 'easeInOut' ||
rawKf.easing === 'linear'
? rawKf.easing
: 'linear',
hold: rawKf.hold === true,
},
]
})
: []
keyframes.sort((a, b) => a.time - b.time)
return {
id: typeof raw.id === 'string' && raw.id ? raw.id : uid(),
name: typeof raw.name === 'string' ? raw.name : 'Camera',
enabled: raw.enabled === true,
base,
keyframes,
overlayVisible: raw.overlayVisible !== false,
locked: raw.locked === true,
}
}
export function isCameraEnabled(scene: Scene): boolean {
const camera = normalizeCameraTrack(scene, scene.camera)
return !!camera?.enabled
}
export function sampleCamera(scene: Scene, time: number): CameraProps {
const camera = normalizeCameraTrack(scene, scene.camera)
if (!camera) return defaultCameraProps(scene)
const base = mergeCameraProps(defaultCameraProps(scene), camera.base)
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
if (!sorted.length) return base
if (time <= sorted[0].time) return mergeCameraProps(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return mergeCameraProps(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return mergeCameraProps(base, b.props)
return mergeCameraProps(base, a.props)
}
const span = Math.max(1e-9, b.time - a.time)
const t = easings[b.easing]((time - a.time) / span)
return lerpCameraProps(mergeCameraProps(base, a.props), mergeCameraProps(base, b.props), t)
}
}
return base
}
export function cameraAspect(scene: Scene, time = 0): number {
const camera = sampleCamera(scene, time)
return camera.width / camera.height
}
export function renderSceneFrameForOutput(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: {
width?: number
height?: number
useCamera?: boolean
cropStage?: boolean
},
): void {
const outputWidth = Math.max(1, Math.round(options?.width ?? scene.canvas.width))
const outputHeight = Math.max(1, Math.round(options?.height ?? scene.canvas.height))
const useCamera = options?.useCamera ?? isCameraEnabled(scene)
if (!useCamera) {
ctx.save()
const scaleX = outputWidth / scene.canvas.width
const scaleY = outputHeight / scene.canvas.height
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
ctx.restore()
return
}
const camera = sampleCamera(scene, time)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, outputWidth, outputHeight)
ctx.restore()
ctx.save()
ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
ctx.translate(-camera.x, -camera.y)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
ctx.restore()
}
+81 -9
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes) return Array.isArray((p as Partial<Project>).scenes)
} }
const RUNTIME_VERSION = '34' const RUNTIME_VERSION = '35'
/** Materialize the engine source. The font map is rebuilt on every /** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage * call so user-uploaded custom fonts (which live in localStorage
@@ -207,6 +207,57 @@ const ENGINE_TEMPLATE = `(function(){
return result; return result;
} }
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){ function loadImages(assets){
var imgs={}; var imgs={};
var list=assets.filter(function(a){return a.kind==='image';}); var list=assets.filter(function(a){return a.kind==='image';});
@@ -248,7 +299,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: page.trackOrder || [], trackOrder: page.trackOrder || [],
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks || [], audioTracks: page.audioTracks || [],
audioClips: page.audioClips || [] audioClips: page.audioClips || [],
camera: page.camera
}; };
} }
if(payload && payload.scenes && payload.scenes.length){ if(payload && payload.scenes && payload.scenes.length){
@@ -283,7 +335,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: legacy.trackOrder || [], trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo, backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [], audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || [] audioClips: legacy.audioClips || [],
camera: legacy.camera
}], }],
activeSceneId: '__legacy__' activeSceneId: '__legacy__'
}; };
@@ -1110,17 +1163,22 @@ const ENGINE_TEMPLATE = `(function(){
var cw=size.w,ch=size.h; var cw=size.w,ch=size.h;
if(!cw||!ch)return; if(!cw||!ch)return;
buttonInstancesInFrame.length=0; buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh); var useCamera=!!(scene.camera && scene.camera.enabled);
var ox=(cw-lw*s)/2; var cam=useCamera?sampleCamera(scene,time):null;
var oy=(ch-lh*s)/2; var vw=useCamera?cam.width:lw;
var vh=useCamera?cam.height:lh;
var s=Math.min(cw/vw,ch/vh);
var ox=(cw-vw*s)/2;
var oy=(ch-vh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0); ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch); ctx.clearRect(0,0,cw,ch);
ctx.save(); ctx.save();
ctx.translate(ox,oy); ctx.translate(ox,oy);
ctx.scale(s,s); ctx.scale(s,s);
ctx.beginPath(); ctx.beginPath();
ctx.rect(0,0,lw,lh); ctx.rect(0,0,vw,vh);
ctx.clip(); ctx.clip();
if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){ if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); ctx.fillRect(0,0,lw,lh);
@@ -1513,12 +1571,27 @@ function sceneToLegacyProject(scene: Scene): Project {
backgroundVideo: scene.backgroundVideo, backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [], audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [], audioClips: scene.audioClips ?? [],
camera: scene.camera,
}, },
], ],
activeSceneId: '__legacy__', activeSceneId: '__legacy__',
} }
} }
function exportPreviewAspect(payload: ExportPayload): string {
const project = isProject(payload) ? payload : sceneToLegacyProject(payload)
const page =
project.scenes.find((s) => s.id === project.activeSceneId) ??
project.scenes[0]
const camera = page?.camera
if (camera?.enabled) {
const width = Math.max(1, camera.base?.width ?? project.canvas.width)
const height = Math.max(1, camera.base?.height ?? project.canvas.height)
return `${width} / ${height}`
}
return `${project.canvas.width} / ${project.canvas.height}`
}
export interface ExportOptions { export interface ExportOptions {
loop?: boolean loop?: boolean
} }
@@ -1571,8 +1644,7 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string { export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = engineSource() const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options) const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas const aspect = exportPreviewAspect(payload)
const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload) const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds const usedFonts = usedFontIds
+6 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render' import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
/** End-of-playback time: clamp to the scene's last keyframe if the /** End-of-playback time: clamp to the scene's last keyframe if the
* scene explicitly ends earlier than its nominal duration. Matches * scene explicitly ends earlier than its nominal duration. Matches
@@ -81,14 +82,6 @@ export async function renderFrames(
canvas.height = height canvas.height = height
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable') if (!ctx) throw new Error('2D context unavailable')
// Scale scene coords into target pixels. drawSceneFrame runs its
// own save/restore so the transform survives across frames, and
// any internal `setTransform(1,0,0,1,0,0)` (top-level mask
// compositing) is sandwiched inside its own save/restore too.
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene) const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene) const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn) const rangeLen = Math.max(0, rangeOut - rangeIn)
@@ -106,7 +99,10 @@ export async function renderFrames(
throw new DOMException('aborted', 'AbortError') throw new DOMException('aborted', 'AbortError')
} }
const t = rangeIn + i / fps const t = rangeIn + i / fps
drawSceneFrame(ctx, scene, Math.min(t, rangeOut), images) renderSceneFrameForOutput(ctx, scene, Math.min(t, rangeOut), images, {
width,
height,
})
await onFrame({ await onFrame({
scene, scene,
canvas, canvas,
+11 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render' import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
export type StillImageFormat = 'png' | 'jpg' export type StillImageFormat = 'png' | 'jpg'
@@ -45,17 +46,17 @@ export async function renderSceneToStillImage(
const needsOpaqueBackdrop = const needsOpaqueBackdrop =
format === 'jpg' && (!sceneBg || sceneBg === 'transparent') format === 'jpg' && (!sceneBg || sceneBg === 'transparent')
const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null) const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null)
if (backdrop) {
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
}
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene) const images = await loadSceneImages(scene)
drawSceneFrame(ctx, scene, Math.max(0, time), images) renderSceneFrameForOutput(ctx, scene, Math.max(0, time), images, { width, height })
if (backdrop) {
ctx.save()
ctx.globalCompositeOperation = 'destination-over'
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
ctx.restore()
}
const mime = format === 'jpg' ? 'image/jpeg' : 'image/png' const mime = format === 'jpg' ? 'image/jpeg' : 'image/png'
const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render' import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
function effectiveEnd(scene: Scene): number { function effectiveEnd(scene: Scene): number {
let max = 0 let max = 0
@@ -67,7 +68,10 @@ export async function renderSceneToVideo(
const { start: rangeIn, end: rangeOut } = playbackRange(scene) const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn) const rangeLen = Math.max(0, rangeOut - rangeIn)
drawSceneFrame(ctx, scene, rangeIn, images) renderSceneFrameForOutput(ctx, scene, rangeIn, images, {
width: canvas.width,
height: canvas.height,
})
const stream = canvas.captureStream(scene.fps) const stream = canvas.captureStream(scene.fps)
const recorder = new MediaRecorder(stream, { const recorder = new MediaRecorder(stream, {
@@ -103,7 +107,10 @@ export async function renderSceneToVideo(
} }
const elapsed = (performance.now() - startedAt) / 1000 const elapsed = (performance.now() - startedAt) / 1000
const t = rangeIn + Math.min(elapsed, rangeLen) const t = rangeIn + Math.min(elapsed, rangeLen)
drawSceneFrame(ctx, scene, t, images) renderSceneFrameForOutput(ctx, scene, t, images, {
width: canvas.width,
height: canvas.height,
})
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1) opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
if (elapsed >= rangeLen) { if (elapsed >= rangeLen) {
try { try {
+129 -3
View File
@@ -10,6 +10,8 @@ import type {
BackgroundVideo, BackgroundVideo,
ButtonAsset, ButtonAsset,
ButtonState, ButtonState,
CameraKeyframe,
CameraProps,
Clip, Clip,
ClipInstance, ClipInstance,
ClipTrack, ClipTrack,
@@ -29,6 +31,12 @@ import type {
TrackGroup, TrackGroup,
} from '@/types/scene' } from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene' import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import {
createDefaultCameraTrack,
defaultCameraProps,
normalizeCameraTrack,
sampleCamera,
} from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit' import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import { import {
@@ -247,6 +255,8 @@ interface State {
* `selectedLayerId`: selecting a clip clears the layer selection * `selectedLayerId`: selecting a clip clears the layer selection
* and vice versa. */ * and vice versa. */
selectedClipInstanceId: string | null selectedClipInstanceId: string | null
/** Special editor selection for the page-level output camera. */
selectedCamera: boolean
playhead: number playhead: number
playing: boolean playing: boolean
/** True between `play()` and the next `tick()`. Lets the first tick /** True between `play()` and the next `tick()`. Lets the first tick
@@ -407,7 +417,10 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
/** Per-page housekeeping: ensure all arrays exist, clean up orphan /** Per-page housekeeping: ensure all arrays exist, clean up orphan
* childIds in groups, dedupe, build trackOrder for any pristine track, * childIds in groups, dedupe, build trackOrder for any pristine track,
* and migrate pre-`colorOverridden` color flags. */ * and migrate pre-`colorOverridden` color flags. */
function normalizeScenePage(page: ScenePage): ScenePage { function normalizeScenePage(
page: ScenePage,
canvas: Pick<Scene, 'canvas'>['canvas'] = { width: 1280, height: 720, background: '#ffffff' },
): ScenePage {
page.layers ||= [] page.layers ||= []
page.clipInstances ||= [] page.clipInstances ||= []
page.clipTracks ||= [] page.clipTracks ||= []
@@ -418,6 +431,11 @@ function normalizeScenePage(page: ScenePage): ScenePage {
page.trackOrder ||= [] page.trackOrder ||= []
page.audioTracks ||= [] page.audioTracks ||= []
page.audioClips ||= [] page.audioClips ||= []
if (page.camera !== undefined) {
const normalized = normalizeCameraTrack({ canvas }, page.camera)
if (normalized) page.camera = normalized
else delete page.camera
}
const known = new Set<string>() const known = new Set<string>()
for (const l of page.layers) known.add(l.id) for (const l of page.layers) known.add(l.id)
@@ -557,7 +575,7 @@ function normalizeProject(project: Project): Project {
if (project.scenes.length === 0) { if (project.scenes.length === 0) {
project.scenes.push(createBlankScenePage('Scene 1')) project.scenes.push(createBlankScenePage('Scene 1'))
} }
for (const page of project.scenes) normalizeScenePage(page) for (const page of project.scenes) normalizeScenePage(page, project.canvas)
if (!project.scenes.find((s) => s.id === project.activeSceneId)) { if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
project.activeSceneId = project.scenes[0].id project.activeSceneId = project.scenes[0].id
} }
@@ -614,6 +632,7 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
backgroundVideo: scene.backgroundVideo, backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [], audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [], audioClips: scene.audioClips ?? [],
camera: scene.camera,
} }
return normalizeProject({ return normalizeProject({
version: 1, version: 1,
@@ -653,6 +672,7 @@ export function createBlankScene(): Scene {
groups: page.groups, groups: page.groups,
trackOrder: page.trackOrder, trackOrder: page.trackOrder,
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
camera: page.camera,
} }
} }
@@ -662,6 +682,7 @@ export const useSceneStore = defineStore('scene', {
selectedLayerId: null, selectedLayerId: null,
selectedLayerIds: [], selectedLayerIds: [],
selectedClipInstanceId: null, selectedClipInstanceId: null,
selectedCamera: false,
playhead: 0, playhead: 0,
playing: false, playing: false,
playStartPending: false, playStartPending: false,
@@ -719,6 +740,7 @@ export const useSceneStore = defineStore('scene', {
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks, audioTracks: page.audioTracks,
audioClips: page.audioClips, audioClips: page.audioClips,
camera: page.camera,
} }
}, },
activeClip(): Clip | null { activeClip(): Clip | null {
@@ -1030,15 +1052,115 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) { selectLayer(id: string | null) {
this.selectedLayerId = id this.selectedLayerId = id
this.selectedLayerIds = id ? [id] : [] this.selectedLayerIds = id ? [id] : []
if (id) this.selectedClipInstanceId = null if (id) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
}, },
selectClipInstance(id: string | null) { selectClipInstance(id: string | null) {
this.selectedClipInstanceId = id this.selectedClipInstanceId = id
if (id) { if (id) {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedCamera = false
} }
}, },
selectCamera(selected = true) {
this.selectedCamera = selected
if (selected) {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
}
},
ensureCamera(enabled = true) {
if (!this.activeScene.camera) {
this.activeScene.camera = createDefaultCameraTrack(this.scene, enabled)
} else {
const normalized = normalizeCameraTrack(this.scene, this.activeScene.camera)
this.activeScene.camera = normalized ?? createDefaultCameraTrack(this.scene, enabled)
}
return this.activeScene.camera
},
enableCamera() {
const camera = this.ensureCamera(true)
camera.enabled = true
camera.overlayVisible = camera.overlayVisible !== false
this.selectCamera(true)
},
disableCamera() {
if (!this.activeScene.camera) return
this.activeScene.camera.enabled = false
},
setCameraOverlayVisible(visible: boolean) {
const camera = this.ensureCamera(false)
camera.overlayVisible = visible
},
setCameraLocked(locked: boolean) {
const camera = this.ensureCamera(false)
camera.locked = locked
},
setCameraBaseProps(props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
camera.base = { ...defaultCameraProps(this.scene), ...camera.base, ...props }
camera.base.width = Math.max(1, camera.base.width)
camera.base.height = Math.max(1, camera.base.height)
},
upsertCameraKeyframe(time: number, props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
if (camera.locked) return null
const t = Math.max(0, Math.min(this.scene.duration, time))
const existing = camera.keyframes.find((kf) => Math.abs(kf.time - t) < KEYFRAME_EPSILON)
const sampled = sampleCamera(this.scene, t)
const nextProps = { ...sampled, ...props }
nextProps.width = Math.max(1, nextProps.width)
nextProps.height = Math.max(1, nextProps.height)
if (existing) {
existing.props = { ...existing.props, ...nextProps }
return existing
}
const kf: CameraKeyframe = {
id: uid(),
time: t,
props: nextProps,
easing: 'linear',
}
camera.keyframes.push(kf)
camera.keyframes.sort((a, b) => a.time - b.time)
return kf
},
removeCameraKeyframe(id: string) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter((kf) => kf.id !== id)
},
removeCameraKeyframeAt(time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter(
(kf) => Math.abs(kf.time - time) >= KEYFRAME_EPSILON,
)
},
setCameraKeyframeTime(id: string, time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, time))
camera.keyframes.sort((a, b) => a.time - b.time)
},
setCameraKeyframeEasing(id: string, easing: Easing) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.easing = easing
},
setCameraKeyframeHold(id: string, hold: boolean) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.hold = hold
},
/** Shift-click behaviour: add the layer to the selection if it's /** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to * not already there; otherwise remove it. The anchor moves to
* the most-recently-clicked id so the Properties panel follows * the most-recently-clicked id so the Properties panel follows
@@ -1052,6 +1174,7 @@ export const useSceneStore = defineStore('scene', {
if (g.locked && g.childIds.includes(id)) return if (g.locked && g.childIds.includes(id)) return
} }
const existing = this.selectedLayerIds.indexOf(id) const existing = this.selectedLayerIds.indexOf(id)
this.selectedCamera = false
if (existing >= 0) { if (existing >= 0) {
this.selectedLayerIds.splice(existing, 1) this.selectedLayerIds.splice(existing, 1)
this.selectedLayerId = this.selectedLayerId =
@@ -2706,6 +2829,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
this.playhead = 0 this.playhead = 0
this.playing = false this.playing = false
this.activeClipId = null this.activeClipId = null
@@ -2791,6 +2915,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
this.drawingMode = null this.drawingMode = null
this.drawingAssetId = null this.drawingAssetId = null
this.drawingLayerId = null this.drawingLayerId = null
@@ -3450,6 +3575,7 @@ export const useSceneStore = defineStore('scene', {
) )
if (inst && childIds.has(inst.clipTrackId)) { if (inst && childIds.has(inst.clipTrackId)) {
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
} }
} }
// Dispatch the right remove action per child. Each call cleans // Dispatch the right remove action per child. Each call cleans
+30
View File
@@ -57,6 +57,34 @@ export interface Keyframe {
ctrlY?: number ctrlY?: number
} }
export interface CameraProps {
/** Top-left stage coordinate of the visible output rectangle. */
x: number
y: number
width: number
height: number
}
export interface CameraKeyframe {
id: string
time: number
props: Partial<CameraProps>
easing: Easing
hold?: boolean
}
export interface CameraTrack {
id: string
name?: string
enabled: boolean
base: CameraProps
keyframes: CameraKeyframe[]
/** Editor guide only. Hidden overlays still render in preview/export. */
overlayVisible?: boolean
/** Editor lock only. Locked cameras still render when enabled. */
locked?: boolean
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg' export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled' export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
@@ -445,6 +473,7 @@ export interface Scene {
markers?: Marker[] markers?: Marker[]
audioTracks?: AudioTrack[] audioTracks?: AudioTrack[]
audioClips?: AudioClip[] audioClips?: AudioClip[]
camera?: CameraTrack
} }
/** /**
@@ -467,6 +496,7 @@ export interface ScenePage {
backgroundVideo?: BackgroundVideo backgroundVideo?: BackgroundVideo
audioTracks: AudioTrack[] audioTracks: AudioTrack[]
audioClips: AudioClip[] audioClips: AudioClip[]
camera?: CameraTrack
} }
/** /**