Add output camera framing
This commit is contained in:
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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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` }
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'}`
|
||||||
|
|||||||
@@ -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
@@ -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'")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user