import { defineStore } from 'pinia' import type { AnimatableProps, Asset, Clip, ClipInstance, ClipTrack, Easing, Keyframe, Layer, MaskMode, Scene, TrackGroup, } from '@/types/scene' import { DEFAULT_BASE_PROPS } from '@/types/scene' import { sampleLayer } from '@/lib/interpolate' const uid = () => Math.random().toString(36).slice(2, 10) type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : never) : never const KEYFRAME_EPSILON = 1e-3 interface State { scene: Scene selectedLayerId: string | null playhead: number playing: boolean activeClipId: string | null drawingMode: 'freeform' | null drawingAssetId: string | null drawingLayerId: string | null } function allKeyframes(layer: Layer): Keyframe[] { const out: Keyframe[] = [...layer.keyframes] if (layer.positionKeyframes) out.push(...layer.positionKeyframes) if (layer.rotationKeyframes) out.push(...layer.rotationKeyframes) return out } function findKf( layer: Layer, keyframeId: string, ): { kf: Keyframe; arr: Keyframe[] } | null { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue const kf = arr.find((k) => k.id === keyframeId) if (kf) return { kf, arr } } return null } function sortKfs(arr: Keyframe[]) { arr.sort((a, b) => a.time - b.time) } function upsertInto( arr: Keyframe[], time: number, props: Partial, snapshotProps: Partial | null, ) { const existing = arr.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) if (existing) { existing.props = { ...existing.props, ...props } return } const kf: Keyframe = { id: uid(), time, props: snapshotProps ? { ...snapshotProps, ...props } : { ...props }, easing: 'linear', } arr.push(kf) sortKfs(arr) } const POSITION_KEYS = new Set(['x', 'y']) const ROTATION_KEYS = new Set(['rotation']) function pickProps( src: Partial, keys: Set, ): Partial { const out: Partial = {} for (const [k, v] of Object.entries(src)) { if (keys.has(k) && v !== undefined) (out as Record)[k] = v } return out } function omitProps( src: Partial, keys: Set, ): Partial { const out: Partial = {} for (const [k, v] of Object.entries(src)) { if (!keys.has(k) && v !== undefined) (out as Record)[k] = v } return out } function hasKeys(obj: Partial): boolean { for (const _ in obj) return true return false } function normalizeScene(scene: Scene): Scene { scene.clips ||= [] scene.clipTracks ||= [] scene.clipInstances ||= [] scene.groups ||= [] const known = new Set() for (const l of scene.layers) known.add(l.id) for (const t of scene.clipTracks) known.add(t.id) for (const g of scene.groups) known.add(g.id) // Cleanup: group childIds should only reference existing layers/clipTracks const layerAndClipIds = new Set() for (const l of scene.layers) layerAndClipIds.add(l.id) for (const t of scene.clipTracks) layerAndClipIds.add(t.id) for (const g of scene.groups) { g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id)) } // Dedupe: any given track can only be in one group const inGroup = new Set() for (const g of scene.groups) { g.childIds = g.childIds.filter((id) => { if (inGroup.has(id)) return false inGroup.add(id) return true }) } if (!scene.trackOrder) scene.trackOrder = [] scene.trackOrder = scene.trackOrder.filter((id) => known.has(id)) for (const l of scene.layers) { if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) { scene.trackOrder.unshift(l.id) } } for (const t of scene.clipTracks) { if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) { scene.trackOrder.push(t.id) } } for (const g of scene.groups) { if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id) } return scene } export const useSceneStore = defineStore('scene', { state: (): State => ({ scene: normalizeScene({ version: 1, canvas: { width: 1280, height: 720, background: '#ffffff' }, duration: 10, fps: 60, assets: [], layers: [], }), selectedLayerId: null, playhead: 0, playing: false, activeClipId: null, drawingMode: null, drawingAssetId: null, drawingLayerId: null, }), getters: { activeClip(state): Clip | null { if (!state.activeClipId) return null const clips = state.scene.clips ?? [] return clips.find((c) => c.id === state.activeClipId) ?? null }, activeLayers(): Layer[] { const clip = this.activeClip return clip ? clip.layers : this.scene.layers }, activeDuration(): number { const clip = this.activeClip return clip ? clip.duration : this.scene.duration }, lastKeyframeTime(): number { let max = 0 for (const layer of this.activeLayers) { for (const kf of allKeyframes(layer)) { if (kf.time > max) max = kf.time } } return Math.min(this.activeDuration, max) }, effectiveEnd(): number { let max = 0 for (const layer of this.activeLayers) { for (const kf of allKeyframes(layer)) { if (kf.time > max) max = kf.time } } if (!this.activeClipId) { const clipById = new Map() for (const c of this.scene.clips ?? []) clipById.set(c.id, c) for (const inst of this.scene.clipInstances ?? []) { const clip = clipById.get(inst.clipId) if (!clip) continue const visible = Math.max( 0, clip.duration - (inst.trimStart ?? 0) - (inst.trimEnd ?? 0), ) const end = inst.startTime + visible if (end > max) max = end } } if (max <= 0) return this.activeDuration return Math.min(max, this.activeDuration) }, }, actions: { addAsset(asset: AssetInit): Asset { const created = { ...asset, id: uid() } as Asset this.scene.assets.push(created) return created }, addLayer(assetId: string, name?: string): Layer { const asset = this.scene.assets.find((a) => a.id === assetId) const base: AnimatableProps = { ...DEFAULT_BASE_PROPS, x: this.scene.canvas.width / 2, y: this.scene.canvas.height / 2, } if (asset && 'fill' in asset) base.fill = asset.fill const layer: Layer = { id: uid(), name: name ?? asset?.name ?? 'Layer', assetId, visible: true, base, keyframes: [ { id: uid(), time: 0, props: { ...base }, easing: 'linear', }, ], } if (asset) { if (asset.kind === 'rect' || asset.kind === 'image') { layer.offsetX = asset.width / 2 layer.offsetY = asset.height / 2 } else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') { layer.offsetX = 0 layer.offsetY = 0 } if (asset.kind === 'polygon') { layer.keyframes[0].props = { ...layer.keyframes[0].props, points: asset.points.map((p) => ({ x: p.x, y: p.y })), } } } this.activeLayers.unshift(layer) if (!this.activeClipId) { const order = (this.scene.trackOrder ||= []) if (!order.includes(layer.id)) order.unshift(layer.id) } this.selectedLayerId = layer.id return layer }, removeLayer(id: string) { const layers = this.activeLayers const idx = layers.findIndex((l) => l.id === id) if (idx >= 0) layers.splice(idx, 1) if (!this.activeClipId && this.scene.trackOrder) { this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) } if (this.selectedLayerId === id) this.selectedLayerId = null }, moveLayer(fromIndex: number, toIndex: number) { const arr = this.activeLayers if (fromIndex < 0 || fromIndex >= arr.length) return if (toIndex < 0 || toIndex > arr.length) return const clamped = Math.min(toIndex, arr.length - 1) if (fromIndex === clamped) return const [moved] = arr.splice(fromIndex, 1) arr.splice(clamped, 0, moved) }, moveTrack(fromIndex: number, toIndex: number) { const order = this.scene.trackOrder ?? [] if (fromIndex < 0 || fromIndex >= order.length) return if (toIndex < 0 || toIndex > order.length) return const clamped = Math.min(toIndex, order.length - 1) if (fromIndex === clamped) return const [moved] = order.splice(fromIndex, 1) order.splice(clamped, 0, moved) }, selectLayer(id: string | null) { this.selectedLayerId = id }, setPlayhead(t: number) { this.playhead = Math.max(0, Math.min(this.activeDuration, t)) }, setDuration(seconds: number) { const clip = this.activeClip if (clip) { clip.duration = Math.max(0.1, seconds) if (this.playhead > clip.duration) this.playhead = clip.duration } else { this.scene.duration = Math.max(0.1, seconds) if (this.playhead > this.scene.duration) this.playhead = this.scene.duration } }, setFps(fps: number) { this.scene.fps = Math.max(1, Math.round(fps)) }, setCanvasSize(width: number, height: number) { this.scene.canvas.width = Math.max(1, Math.round(width)) this.scene.canvas.height = Math.max(1, Math.round(height)) }, setCanvasBackground(color: string) { this.scene.canvas.background = color }, upsertKeyframe(layerId: string, time: number, props: Partial) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (!layer.separated) { const snapshot = sampleLayer(layer, time) upsertInto(layer.keyframes, time, props, snapshot) return } const posProps = pickProps(props, POSITION_KEYS) const rotProps = pickProps(props, ROTATION_KEYS) const otherProps = omitProps(props, new Set(['x', 'y', 'rotation'])) if (hasKeys(posProps)) { const arr = (layer.positionKeyframes ||= []) upsertInto(arr, time, posProps, null) } if (hasKeys(rotProps)) { const arr = (layer.rotationKeyframes ||= []) upsertInto(arr, time, rotProps, null) } if (hasKeys(otherProps)) { upsertInto(layer.keyframes, time, otherProps, null) } }, removeKeyframeAt(layerId: string, time: number) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const filter = (kfs?: Keyframe[]) => kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs layer.keyframes = filter(layer.keyframes) ?? [] if (layer.positionKeyframes) layer.positionKeyframes = filter(layer.positionKeyframes) if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes) }, removeKeyframe(layerId: string, keyframeId: string) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return const idx = found.arr.indexOf(found.kf) if (idx >= 0) found.arr.splice(idx, 1) }, setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (found) found.kf.easing = easing }, setKeyframeHold(layerId: string, keyframeId: string, hold: boolean) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return if (hold) found.kf.hold = true else delete found.kf.hold }, setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime)) sortKfs(found.arr) }, shiftLayerPivot( layerId: string, dWorldX: number, dWorldY: number, dLocalX: number, dLocalY: number, ) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return layer.offsetX = (layer.offsetX ?? 0) + dLocalX layer.offsetY = (layer.offsetY ?? 0) + dLocalY layer.base.x += dWorldX layer.base.y += dWorldY const shift = (kfs?: Keyframe[]) => { if (!kfs) return for (const kf of kfs) { if (kf.props.x !== undefined) kf.props.x += dWorldX if (kf.props.y !== undefined) kf.props.y += dWorldY } } shift(layer.keyframes) shift(layer.positionKeyframes) }, duplicateKeyframe(layerId: string, keyframeId: string): string | null { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return null const found = findKf(layer, keyframeId) if (!found) return null const { kf: source, arr } = found const fps = this.scene.fps > 0 ? this.scene.fps : 60 const offset = Math.max(3 / fps, 0.1) const duration = this.activeDuration const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) let target = source.time + offset while (occupied(target) && target < duration) target += offset if (target > duration) target = duration if (occupied(target)) { target = source.time - offset while (occupied(target) && target > 0) target -= offset if (target < 0) target = 0 if (occupied(target)) return null } const newKf: Keyframe = { id: uid(), time: target, props: { ...source.props }, easing: source.easing, } arr.push(newKf) sortKfs(arr) return newKf.id }, setTrackColor(trackId: string, color: string | null) { const layer = this.activeLayers.find((l) => l.id === trackId) if (layer) { if (color === null) delete layer.color else layer.color = color return } const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) if (clipTrack) { if (color === null) delete clipTrack.color else clipTrack.color = color return } const group = this.scene.groups?.find((g) => g.id === trackId) if (group) { if (color === null) delete group.color else group.color = color } }, shiftKeyframesPosition( refs: Array<{ layerId: string; kind: 'main' | 'position' | 'rotation'; keyframeId: string }>, dx: number, dy: number, ) { for (const ref of refs) { if (ref.kind === 'rotation') continue const layer = this.activeLayers.find((l) => l.id === ref.layerId) if (!layer) continue const arr = ref.kind === 'position' ? layer.positionKeyframes : layer.keyframes const kf = arr?.find((k) => k.id === ref.keyframeId) if (!kf) continue const sampled = sampleLayer(layer, kf.time) const curX = kf.props.x ?? sampled.x const curY = kf.props.y ?? sampled.y kf.props = { ...kf.props, x: curX + dx, y: curY + dy } } }, movePolygonVertex(layerId: string, pointIndex: number, x: number, y: number, time?: number) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const asset = this.scene.assets.find((a) => a.id === layer.assetId) if (!asset || asset.kind !== 'polygon') return if (pointIndex < 0 || pointIndex >= asset.points.length) return const t = time ?? this.playhead for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (kf.props.points) continue const samp = sampleLayer(layer, kf.time) const basePts = samp.points ?? asset.points kf.props = { ...kf.props, points: basePts.map((p) => ({ x: p.x, y: p.y })), } } } const sampled = sampleLayer(layer, t) const current = sampled.points ?? asset.points const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y })) this.upsertKeyframe(layerId, t, { points: next }) }, addPolygonPoint(assetId: string, atIndex: number, point: { x: number; y: number }) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return const insertAt = Math.max(0, Math.min(atIndex, asset.points.length)) asset.points.splice(insertAt, 0, { x: point.x, y: point.y }) const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue const pts = kf.props.points.slice() pts.splice(insertAt, 0, { x: point.x, y: point.y }) kf.props = { ...kf.props, points: pts } } } } }, setPolygonPointCount(assetId: string, targetCount: number) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return const target = Math.max(3, Math.min(64, Math.round(targetCount))) if (asset.points.length === target) return const resize = (pts: { x: number; y: number }[]) => { const arr = pts.map((p) => ({ x: p.x, y: p.y })) while (arr.length > target && arr.length > 3) arr.pop() while (arr.length < target) { const last = arr[arr.length - 1] const first = arr[0] arr.push({ x: (last.x + first.x) / 2, y: (last.y + first.y) / 2 }) } return arr } asset.points = resize(asset.points) const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue kf.props = { ...kf.props, points: resize(kf.props.points) } } } } }, removePolygonPoint(assetId: string, pointIndex: number) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return if (asset.points.length <= 3) return if (pointIndex < 0 || pointIndex >= asset.points.length) return asset.points.splice(pointIndex, 1) const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue const pts = kf.props.points.slice() if (pointIndex < pts.length) pts.splice(pointIndex, 1) kf.props = { ...kf.props, points: pts } } } } }, startFreeformDrawing() { if (this.drawingMode) return // If the selected layer is already an open polygon, resume drawing on it const selId = this.selectedLayerId if (selId) { const layer = this.activeLayers.find((l) => l.id === selId) const asset = layer ? this.scene.assets.find((a) => a.id === layer.assetId) : null if (layer && asset && asset.kind === 'polygon' && asset.closed === false) { this.drawingMode = 'freeform' this.drawingAssetId = asset.id this.drawingLayerId = layer.id return } } const asset = this.addAsset({ kind: 'polygon', name: 'Freeform', points: [], fill: 'transparent', closed: false, stroke: '#000000', strokeWidth: 2, }) as Extract const layer: Layer = { id: uid(), name: asset.name, assetId: asset.id, visible: true, base: { ...DEFAULT_BASE_PROPS }, keyframes: [ { id: uid(), time: 0, props: { ...DEFAULT_BASE_PROPS, points: [] }, easing: 'linear', }, ], offsetX: 0, offsetY: 0, } this.activeLayers.unshift(layer) if (!this.activeClipId) { ;(this.scene.trackOrder ||= []).unshift(layer.id) } this.drawingMode = 'freeform' this.drawingAssetId = asset.id this.drawingLayerId = layer.id this.selectedLayerId = layer.id }, addDrawingPoint(x: number, y: number) { if (this.drawingMode !== 'freeform') return const assetId = this.drawingAssetId const layerId = this.drawingLayerId if (!assetId || !layerId) return const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return asset.points.push({ x, y }) const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue kf.props = { ...kf.props, points: [...kf.props.points, { x, y }], } } } // Ensure the first keyframe of the drawing layer always has a points array if (layer.id === layerId && layer.keyframes[0] && !layer.keyframes[0].props.points) { layer.keyframes[0].props = { ...layer.keyframes[0].props, points: asset.points.map((p) => ({ x: p.x, y: p.y })), } } } }, finishDrawing() { const layerId = this.drawingLayerId const assetId = this.drawingAssetId this.drawingMode = null this.drawingAssetId = null this.drawingLayerId = null // If we have fewer than 2 points, drop it — not a usable shape if (!assetId || !layerId) return const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return if (asset.points.length < 2) { this.removeLayer(layerId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId) } }, cancelDrawing() { const layerId = this.drawingLayerId const assetId = this.drawingAssetId this.drawingMode = null this.drawingAssetId = null this.drawingLayerId = null if (layerId) this.removeLayer(layerId) if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId) }, reversePolygonPoints(assetId: string) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return asset.points.reverse() const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue kf.props = { ...kf.props, points: [...kf.props.points].reverse() } } } } }, setPolygonClosed(assetId: string, closed: boolean) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return asset.closed = closed }, insertPolygonPointOnEdge(assetId: string, atIndex: number, tOnEdge: number) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return const n = asset.points.length if (atIndex < 1 || atIndex > n) return const prev = asset.points[atIndex - 1] const next = asset.points[atIndex % n] asset.points.splice(atIndex, 0, { x: prev.x + (next.x - prev.x) * tOnEdge, y: prev.y + (next.y - prev.y) * tOnEdge, }) const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue for (const kf of arr) { if (!kf.props.points) continue const pts = kf.props.points const p = pts[atIndex - 1] const q = pts[atIndex % pts.length] const next = pts.slice() next.splice(atIndex, 0, { x: p.x + (q.x - p.x) * tOnEdge, y: p.y + (q.y - p.y) * tOnEdge, }) kf.props = { ...kf.props, points: next } } } } }, setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (isMask) { layer.isMask = true if (mode) layer.maskMode = mode else if (!layer.maskMode) layer.maskMode = 'positive' } else { delete layer.isMask delete layer.maskMode } }, setLayerMaskMode(layerId: string, mode: MaskMode) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return layer.maskMode = mode }, setTrackLocked(trackId: string, locked: boolean) { const layer = this.activeLayers.find((l) => l.id === trackId) if (layer) { if (locked) layer.locked = true else delete layer.locked return } const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) if (clipTrack) { if (locked) clipTrack.locked = true else delete clipTrack.locked } }, setLayerSeparated(layerId: string, separated: boolean) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (!!layer.separated === separated) return if (separated) { const posKfs: Keyframe[] = [] const rotKfs: Keyframe[] = [] const remaining: Keyframe[] = [] for (const kf of layer.keyframes) { const posProps = pickProps(kf.props, POSITION_KEYS) const rotProps = pickProps(kf.props, ROTATION_KEYS) const rest = omitProps(kf.props, new Set(['x', 'y', 'rotation'])) if (hasKeys(posProps)) { posKfs.push({ id: uid(), time: kf.time, props: posProps, easing: kf.easing }) } if (hasKeys(rotProps)) { rotKfs.push({ id: uid(), time: kf.time, props: rotProps, easing: kf.easing }) } if (hasKeys(rest)) { remaining.push({ id: uid(), time: kf.time, props: rest, easing: kf.easing }) } } sortKfs(posKfs) sortKfs(rotKfs) sortKfs(remaining) layer.positionKeyframes = posKfs layer.rotationKeyframes = rotKfs layer.keyframes = remaining layer.separated = true } else { interface Bucket { props: Partial easing: Easing } const byTime = new Map() const addAll = (kfs?: Keyframe[]) => { if (!kfs) return for (const kf of kfs) { const key = Math.round(kf.time * 1e6) let bucket = byTime.get(key) if (!bucket) { bucket = { props: {}, easing: kf.easing } byTime.set(key, bucket) } bucket.props = { ...bucket.props, ...kf.props } } } addAll(layer.keyframes) addAll(layer.positionKeyframes) addAll(layer.rotationKeyframes) const merged: Keyframe[] = [] for (const [key, bucket] of byTime.entries()) { merged.push({ id: uid(), time: key / 1e6, props: bucket.props, easing: bucket.easing, }) } sortKfs(merged) layer.keyframes = merged layer.positionKeyframes = undefined layer.rotationKeyframes = undefined layer.separated = false } }, loadScene(scene: Scene) { this.scene = normalizeScene(scene) this.selectedLayerId = null this.playhead = 0 this.playing = false this.activeClipId = null }, addClip(name?: string): Clip { const clip: Clip = { id: uid(), name: name?.trim() || 'Clip', duration: Math.max(1, Math.min(5, this.scene.duration)), layers: [], } ;(this.scene.clips ||= []).push(clip) if (!this.scene.clipTracks?.length) this.addClipTrack() return clip }, removeClip(id: string) { if (this.scene.clips) { this.scene.clips = this.scene.clips.filter((c) => c.id !== id) } if (this.scene.clipInstances) { this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id) } if (this.activeClipId === id) { this.activeClipId = null this.selectedLayerId = null this.playhead = 0 } }, renameClip(id: string, name: string) { const clip = this.scene.clips?.find((c) => c.id === id) if (clip) clip.name = name.trim() || clip.name }, addClipTrack(name?: string): ClipTrack { const track: ClipTrack = { id: uid(), name } ;(this.scene.clipTracks ||= []).push(track) ;(this.scene.trackOrder ||= []).push(track.id) return track }, removeClipTrack(id: string) { if (this.scene.clipTracks) { this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id) } if (this.scene.clipInstances) { this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id) } if (this.scene.trackOrder) { this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) } }, addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null { const clip = this.scene.clips?.find((c) => c.id === clipId) if (!clip) return null const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId) if (!track) return null const instance: ClipInstance = { id: uid(), clipId, startTime: Math.max(0, startTime), clipTrackId, } ;(this.scene.clipInstances ||= []).push(instance) return instance }, moveClipInstance(id: string, startTime: number, clipTrackId?: string) { const inst = this.scene.clipInstances?.find((ci) => ci.id === id) if (!inst) return inst.startTime = Math.max(0, startTime) if (clipTrackId) { const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId) if (track) inst.clipTrackId = clipTrackId } }, trimClipInstance( id: string, patch: { startTime?: number; trimStart?: number; trimEnd?: number }, ) { const inst = this.scene.clipInstances?.find((ci) => ci.id === id) if (!inst) return const clip = this.scene.clips?.find((c) => c.id === inst.clipId) if (!clip) return const MIN_VISIBLE = 0.05 let trimStart = patch.trimStart ?? inst.trimStart ?? 0 let trimEnd = patch.trimEnd ?? inst.trimEnd ?? 0 let startTime = patch.startTime ?? inst.startTime trimStart = Math.max(0, trimStart) trimEnd = Math.max(0, trimEnd) const maxBudget = clip.duration - MIN_VISIBLE if (trimStart + trimEnd > maxBudget) { if (patch.trimStart !== undefined && patch.trimEnd === undefined) { trimStart = Math.max(0, maxBudget - trimEnd) } else if (patch.trimEnd !== undefined && patch.trimStart === undefined) { trimEnd = Math.max(0, maxBudget - trimStart) } else { trimStart = Math.min(trimStart, maxBudget) trimEnd = Math.max(0, maxBudget - trimStart) } } inst.trimStart = trimStart inst.trimEnd = trimEnd inst.startTime = Math.max(0, startTime) }, removeClipInstance(id: string) { if (!this.scene.clipInstances) return this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id) }, addTrackGroup(name?: string): TrackGroup { const group: TrackGroup = { id: uid(), name: name?.trim() || 'Group', childIds: [], } ;(this.scene.groups ||= []).push(group) ;(this.scene.trackOrder ||= []).unshift(group.id) return group }, removeTrackGroup(id: string) { const group = this.scene.groups?.find((g) => g.id === id) if (!group) return const order = this.scene.trackOrder ?? [] const idx = order.indexOf(id) if (idx >= 0) order.splice(idx, 1, ...group.childIds) this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id) }, renameGroup(id: string, name: string) { const group = this.scene.groups?.find((g) => g.id === id) if (!group) return const n = name.trim() if (n) group.name = n }, toggleGroupCollapsed(id: string) { const group = this.scene.groups?.find((g) => g.id === id) if (!group) return group.collapsed = !group.collapsed }, moveTrackIntoGroup(trackId: string, groupId: string, atIndex?: number) { const target = this.scene.groups?.find((g) => g.id === groupId) if (!target) return if (trackId === groupId) return // Remove from top-level trackOrder const order = this.scene.trackOrder ?? [] const ti = order.indexOf(trackId) if (ti >= 0) order.splice(ti, 1) // Remove from any other group's childIds for (const g of this.scene.groups ?? []) { const gi = g.childIds.indexOf(trackId) if (gi >= 0) g.childIds.splice(gi, 1) } const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length)) target.childIds.splice(at, 0, trackId) }, moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) { for (const g of this.scene.groups ?? []) { const gi = g.childIds.indexOf(trackId) if (gi >= 0) g.childIds.splice(gi, 1) } const order = (this.scene.trackOrder ||= []) if (!order.includes(trackId)) { const at = toTopLevelIndex === undefined ? 0 : Math.max(0, Math.min(toTopLevelIndex, order.length)) order.splice(at, 0, trackId) } }, enterClip(id: string) { const clip = this.scene.clips?.find((c) => c.id === id) if (!clip) return this.activeClipId = id this.selectedLayerId = null this.playhead = 0 this.playing = false }, exitClip() { this.activeClipId = null this.selectedLayerId = null this.playhead = 0 this.playing = false }, play() { if (this.playing) return this.playing = true }, pause() { this.playing = false }, togglePlay() { if (this.playing) this.pause() else { const end = this.effectiveEnd if (end <= 0) return if (this.playhead >= end) this.playhead = 0 this.play() } }, tick(deltaSeconds: number) { if (!this.playing) return const end = this.effectiveEnd const next = this.playhead + deltaSeconds if (next >= end) { this.playhead = end this.playing = false } else { this.playhead = next } }, }, })