import { defineStore } from 'pinia' import type { AnimatableProps, Asset, Clip, ClipInstance, ClipTrack, Easing, Keyframe, Layer, 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 } 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, }), 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') { layer.offsetX = 0 layer.offsetY = 0 } } 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 } } }, 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 } }, }, })