import { defineStore } from 'pinia' import { toRaw } from 'vue' import type { Action, AnimatableProps, Asset, AudioAsset, AudioClip, AudioTrack, BackgroundVideo, ButtonAsset, ButtonState, Clip, ClipInstance, ClipTrack, Easing, HitShape, Keyframe, Layer, Marker, MaskMode, Project, Scene, ScenePage, ScriptFrame, ScriptTrack, TrackGroup, } from '@/types/scene' import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene' import { sampleLayer } from '@/lib/interpolate' import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit' import { buildMarkerMap, evalAction, scriptFramesInRange, type ActionRuntime, } from '@/lib/actions' import { useButtonRuntime } from '@/composables/useButtonRuntime' const uid = () => Math.random().toString(36).slice(2, 10) type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : never) : never /** A source URL is considered SVG if it's a `data:image/svg+xml` payload. * External URLs are rare in this app (imports data-URL everything) and * splitting them would need a network fetch we don't want to run * synchronously inside an action. */ function isSvgSource(src: string | undefined): boolean { if (!src) return false return src.startsWith('data:image/svg+xml') } /** Pull the SVG text out of a data URL. Supports both the URL-encoded * (`data:image/svg+xml,`...) and base64 (`data:image/svg+xml;base64,`...) * forms produced by the importer. Returns null when the payload is * malformed so callers can silently bail out. */ function decodeSvgDataUrl(src: string): string | null { const comma = src.indexOf(',') if (comma < 0) return null const header = src.slice(0, comma) const payload = src.slice(comma + 1) try { if (header.includes(';base64')) { // atob produces a binary string; re-decode as UTF-8 so high- // byte chars (non-ASCII attribute values, embedded CSS, etc.) // come through intact instead of mojibake. const binary = atob(payload) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) return new TextDecoder('utf-8').decode(bytes) } return decodeURIComponent(payload) } catch { return null } } const KEYFRAME_EPSILON = 1e-3 /** Shared cascading push-aside helper used by the audio-clip and clip- * instance move paths. Items on the same track ahead of the moved * block get shoved forward to its new tail; items behind get shoved * backward to its new head. If the backward chain would go below 0 * the whole block is clamped forward to the first fitting slot so * nothing overlaps and nothing crosses the scene origin. * * The helper mutates `others` in place (updates `startTime` on each * item) and returns the effective newStart actually applied — which * the caller should apply to the moved item itself. */ interface CascadeItem { id: string startTime: number } function cascadePushAside( others: T[], movedCenterOld: number, movedLen: number, lenOf: (item: T) => number, desiredStart: number, ): number { const newEnd = () => newStart + movedLen // Use the moved clip's *old* center to decide which side each neighbor // belongs to. Doing the split on the pre-move position prevents the // partition from flipping partway through a drag when the user's // cursor crosses another clip. const leftSide: T[] = [] const rightSide: T[] = [] for (const o of others) { const center = o.startTime + lenOf(o) / 2 if (center < movedCenterOld) leftSide.push(o) else rightSide.push(o) } let newStart = Math.max(0, desiredStart) // Backward cascade: walk left-side clips from rightmost → leftmost, // pushing each one left to stay clear of the running ceiling. If any // would land below 0 we shift the whole moved block right by that // deficit so the stack on the left sits flush with the scene origin. leftSide.sort((a, b) => b.startTime - a.startTime) const computeLeft = (): { newStarts: Map; deficit: number } => { const map = new Map() let ceiling = newStart let maxDeficit = 0 for (const o of leftSide) { const len = lenOf(o) if (o.startTime + len <= ceiling) { // Already clear — this one stays where it is ceiling = o.startTime map.set(o.id, o.startTime) } else { const next = ceiling - len if (next < 0) maxDeficit = Math.max(maxDeficit, -next) ceiling = next map.set(o.id, next) } } return { newStarts: map, deficit: maxDeficit } } let left = computeLeft() if (left.deficit > 0) { newStart += left.deficit left = computeLeft() } for (const o of leftSide) { const s = left.newStarts.get(o.id) if (s !== undefined) o.startTime = Math.max(0, s) } // Forward cascade: walk right-side clips leftmost → rightmost, // pushing each one forward to stay past the running floor. rightSide.sort((a, b) => a.startTime - b.startTime) let floor = newEnd() for (const o of rightSide) { const len = lenOf(o) if (o.startTime >= floor) { floor = o.startTime + len } else { o.startTime = floor floor = o.startTime + len } } return newStart } const BUTTON_STATE_TIME_VALUES: readonly number[] = [ BUTTON_STATE_TIMES.normal, BUTTON_STATE_TIMES.hover, BUTTON_STATE_TIMES.pressed, BUTTON_STATE_TIMES.disabled, ] /** In button-edit mode keyframes may only land on one of the 4 state times. * Returns the nearest state time to the given raw time. */ function snapToButtonStateTime(t: number): number { let best = BUTTON_STATE_TIME_VALUES[0] let bestDelta = Math.abs(t - best) for (let i = 1; i < BUTTON_STATE_TIME_VALUES.length; i++) { const d = Math.abs(t - BUTTON_STATE_TIME_VALUES[i]) if (d < bestDelta) { best = BUTTON_STATE_TIME_VALUES[i] bestDelta = d } } return best } /** The button editor has two orthogonal sub-modes: state editing (4 content * states as kf-time slots) vs hit-area editing (hit shapes only, with normal * state as onion-skin). Switching modes keeps each mode's last selection. */ export type ButtonEditMode = 'states' | 'hit' interface State { /** The persisted top-level container. Holds shared fields (canvas, * duration, fps, in/out, asset library, clip library) plus the list * of scene pages and which one is active. The `scene` getter assembles * a Scene-shaped merged view (shared fields + active page's arrays) * so existing read sites — `store.scene.canvas`, `store.scene.layers`, * `store.scene.assets`, … — keep working unchanged. Mutations go * through actions that target either `this.project.X` (shared) or * `this.activeScene.X` (per-page) directly. */ project: Project /** Primary (anchor) selection — drives the Properties panel and * any code that expects a single focused layer. Always matches * the last entry of `selectedLayerIds`. */ selectedLayerId: string | null /** Multi-selection. Order is click order — the last entry is the * anchor. Used by group-selected / delete-selected and any * operation that needs the whole set. */ selectedLayerIds: string[] /** Selected clip-instance id — drives the canvas transformer when a * packed clip is clicked on the stage. Mutually exclusive with * `selectedLayerId`: selecting a clip clears the layer selection * and vice versa. */ selectedClipInstanceId: string | null playhead: number playing: boolean /** True between `play()` and the next `tick()`. Lets the first tick * after a play-start fire scripts whose time === current playhead * (e.g. a frame-0 script). Cleared by tick / pause / exitClip. */ playStartPending: boolean activeClipId: string | null /** When set, the Stage + Timeline are inside a Button-asset edit session. */ activeButtonAssetId: string | null /** 'states' drives the state scrubber; 'hit' swaps to hit-shape editing. */ activeButtonEditMode: ButtonEditMode | null /** Which of the 4 content states is currently active. Only meaningful while * `activeButtonEditMode === 'states'`; preserved across mode toggles. */ activeButtonState: ButtonState | 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 } /** * Scan every container a layer can live in — scene-level, clip layers, * button-asset state stacks — and return the layer with the matching id. * Used by mutation helpers that don't know the caller's edit scope. */ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null { for (const l of scene.layers) if (l.id === layerId) return l for (const clip of scene.clips ?? []) { for (const l of clip.layers) if (l.id === layerId) return l } for (const asset of scene.assets) { if (asset.kind !== 'button') continue for (const l of asset.layers) if (l.id === layerId) return l } return null } /** Per-page housekeeping: ensure all arrays exist, clean up orphan * childIds in groups, dedupe, build trackOrder for any pristine track, * and migrate pre-`colorOverridden` color flags. */ function normalizeScenePage(page: ScenePage): ScenePage { page.layers ||= [] page.clipInstances ||= [] page.clipTracks ||= [] page.scriptTracks ||= [] page.scriptFrames ||= [] page.markers ||= [] page.groups ||= [] page.trackOrder ||= [] page.audioTracks ||= [] page.audioClips ||= [] const known = new Set() for (const l of page.layers) known.add(l.id) for (const t of page.clipTracks) known.add(t.id) for (const g of page.groups) known.add(g.id) for (const st of page.scriptTracks) known.add(st.id) for (const at of page.audioTracks) known.add(at.id) // Any non-group track may live inside a group — layers, clip tracks, // script tracks and audio tracks are all first-class citizens. Groups // themselves stay at the top level (no nested groups). const groupableIds = new Set() for (const l of page.layers) groupableIds.add(l.id) for (const t of page.clipTracks) groupableIds.add(t.id) for (const st of page.scriptTracks) groupableIds.add(st.id) for (const at of page.audioTracks) groupableIds.add(at.id) for (const g of page.groups) { g.childIds = g.childIds.filter((id) => groupableIds.has(id)) } const inGroup = new Set() for (const g of page.groups) { g.childIds = g.childIds.filter((id) => { if (inGroup.has(id)) return false inGroup.add(id) return true }) } page.trackOrder = page.trackOrder.filter((id) => known.has(id)) // Fallback for legacy / corrupted scenes where some ids are known but // absent from trackOrder. Layers prepend (historically stage content // sits on top); everything else appends in DOM order with no // per-kind segregation — users can reorder freely afterwards. const inOrder = new Set(page.trackOrder) for (const l of page.layers) { if (!inGroup.has(l.id) && !inOrder.has(l.id)) { page.trackOrder.unshift(l.id) inOrder.add(l.id) } } const tail: Array<{ id: string }> = [ ...page.clipTracks, ...page.scriptTracks, ...page.audioTracks, ...page.groups, ] for (const item of tail) { if (!inGroup.has(item.id) && !inOrder.has(item.id)) { page.trackOrder.push(item.id) inOrder.add(item.id) } } // Orphan audio clips (pointing at a missing track or missing asset) would // never render anyway — drop them so serialised projects stay clean. const audioTrackIds = new Set(page.audioTracks.map((t) => t.id)) page.audioClips = page.audioClips.filter((c) => audioTrackIds.has(c.audioTrackId)) for (const l of page.layers) { if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true } for (const t of page.clipTracks) { if (t.color != null && t.colorOverridden === undefined) t.colorOverridden = true } for (const st of page.scriptTracks) { if (st.color != null && st.colorOverridden === undefined) st.colorOverridden = true } for (const g of page.groups) { if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true } for (const at of page.audioTracks) { if (at.color != null && at.colorOverridden === undefined) at.colorOverridden = true } return page } /** Project-level housekeeping. Ensures shared arrays exist and that * `activeSceneId` actually references one of the pages — falls back to * the first page (or a freshly minted one if none exist). */ function normalizeProject(project: Project): Project { project.assets ||= [] project.clips ||= [] project.scenes ||= [] if (!project.canvas.background) project.canvas.background = '#ffffff' if (project.scenes.length === 0) { project.scenes.push(createBlankScenePage('Scene 1')) } for (const page of project.scenes) normalizeScenePage(page) if (!project.scenes.find((s) => s.id === project.activeSceneId)) { project.activeSceneId = project.scenes[0].id } return project } export function createBlankScenePage(name: string, id?: string): ScenePage { return { id: id ?? uid(), name, layers: [], clipInstances: [], clipTracks: [], scriptTracks: [], scriptFrames: [], markers: [], groups: [], trackOrder: [], audioTracks: [], audioClips: [], } } export function createBlankProject(): Project { const initial = createBlankScenePage('Scene 1') return normalizeProject({ version: 1, canvas: { width: 1280, height: 720, background: '#ffffff' }, duration: 10, fps: 60, assets: [], clips: [], scenes: [initial], activeSceneId: initial.id, }) } /** Wrap an old-format Scene (v0 file: { scene }) into a one-page Project. * All shared fields lift up to the project; per-page fields stay on the * one synthesized page. */ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Project { const page: ScenePage = { id: uid(), name: sceneName, layers: scene.layers ?? [], clipInstances: scene.clipInstances ?? [], clipTracks: scene.clipTracks ?? [], scriptTracks: scene.scriptTracks ?? [], scriptFrames: scene.scriptFrames ?? [], markers: scene.markers ?? [], groups: scene.groups ?? [], trackOrder: scene.trackOrder ?? [], backgroundVideo: scene.backgroundVideo, audioTracks: scene.audioTracks ?? [], audioClips: scene.audioClips ?? [], } return normalizeProject({ version: 1, canvas: { ...scene.canvas }, duration: scene.duration, fps: scene.fps, inTime: scene.inTime, outTime: scene.outTime, assets: scene.assets ?? [], clips: scene.clips ?? [], scenes: [page], activeSceneId: page.id, }) } /** Backward-compat shim. Older callers (legacy save format, tests) still * reach for `createBlankScene()` to seed a fresh editor. Hand back a * Scene-shaped view of a fresh blank project's first page. */ export function createBlankScene(): Scene { const project = createBlankProject() const page = project.scenes[0] return { version: 1, canvas: project.canvas, duration: project.duration, fps: project.fps, inTime: project.inTime, outTime: project.outTime, assets: project.assets, clips: project.clips, layers: page.layers, clipInstances: page.clipInstances, clipTracks: page.clipTracks, scriptTracks: page.scriptTracks, scriptFrames: page.scriptFrames, markers: page.markers, groups: page.groups, trackOrder: page.trackOrder, backgroundVideo: page.backgroundVideo, } } export const useSceneStore = defineStore('scene', { state: (): State => ({ project: createBlankProject(), selectedLayerId: null, selectedLayerIds: [], selectedClipInstanceId: null, playhead: 0, playing: false, playStartPending: false, activeClipId: null, activeButtonAssetId: null, activeButtonEditMode: null, activeButtonState: null, drawingMode: null, drawingAssetId: null, drawingLayerId: null, }), getters: { /** The active page from `project.scenes`. Use this when you need to * mutate per-page fields (e.g., reassign `activeScene.layers`). */ activeScene(state): ScenePage { const p = state.project return p.scenes.find((s) => s.id === p.activeSceneId) ?? p.scenes[0] }, /** Merged Scene-shaped view: shared project fields combined with the * active page's arrays. Read-only by convention — array references * are live (so `store.scene.layers.push(x)` still hits the active * page) but assigning a primitive (`store.scene.duration = 5`) on * this view does NOT reach the project. Mutate via store actions * (setDuration, setFps, …) instead. */ scene(): Scene { const p = this.project const page = this.activeScene return { version: 1, canvas: p.canvas, duration: p.duration, fps: p.fps, inTime: p.inTime, outTime: p.outTime, assets: p.assets, clips: p.clips, layers: page.layers, clipInstances: page.clipInstances, clipTracks: page.clipTracks, scriptTracks: page.scriptTracks, scriptFrames: page.scriptFrames, markers: page.markers, groups: page.groups, trackOrder: page.trackOrder, backgroundVideo: page.backgroundVideo, audioTracks: page.audioTracks, audioClips: page.audioClips, } }, activeClip(): Clip | null { if (!this.activeClipId) return null return this.project.clips.find((c) => c.id === this.activeClipId) ?? null }, activeButtonAsset(): ButtonAsset | null { if (!this.activeButtonAssetId) return null const asset = this.project.assets.find((a) => a.id === this.activeButtonAssetId) return asset && asset.kind === 'button' ? asset : null }, activeLayers(): Layer[] { const bAsset = this.activeButtonAsset if (bAsset) { // Hit mode shares the same layer list — the canvas switches to // hit-shape editing but tracks stay on the button's layers so the // user can still see/select them. return bAsset.layers } 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) }, /** Effective in-point for playback/export. Defaults to 0, clamped to duration. */ playbackIn(): number { if (this.activeClipId) return 0 const v = this.scene.inTime ?? 0 return Math.max(0, Math.min(this.scene.duration, v)) }, /** Effective out-point for playback/export. Defaults to duration, clamped >=in. */ playbackOut(): number { if (this.activeClipId) return this.activeDuration const v = this.scene.outTime ?? this.scene.duration return Math.max(this.playbackIn, Math.min(this.scene.duration, v)) }, }, 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 | null { // Hit-area edit has no content-layer container — new layers from // the sidebar/drop path would silently leak into the shared states // list. Reject here so hit-mode truly owns its own (hit-shape) track // list and nothing bleeds across. if (this.activeButtonEditMode === 'hit') return null const asset = this.scene.assets.find((a) => a.id === assetId) // Default position = center of the active canvas. In button-asset // edit that's the button's smaller internal canvas, so the new // layer lands inside the visible (and unclipped) area. const canvas = this.activeButtonAsset?.canvas ?? this.scene.canvas const base: AnimatableProps = { ...DEFAULT_BASE_PROPS, x: canvas.width / 2, y: canvas.height / 2, } if (asset && 'fill' in asset) base.fill = asset.fill // In button-edit mode, seed the initial keyframe on the state // the user is currently viewing — not always on 'normal'. Drops // in the 'disabled' pane then stay there; the layer doesn't // silently pop onto the normal frame. const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES[this.activeButtonState ?? 'normal'] : 0 const layer: Layer = { id: uid(), name: name ?? asset?.name ?? 'Layer', assetId, visible: true, base, keyframes: [ { id: uid(), time: initialTime, 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 } else if (asset.kind === 'button') { // Place the button centered on (x, y) using Konva's offset mechanism. layer.offsetX = asset.canvas.width / 2 layer.offsetY = asset.canvas.height / 2 } 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) // Only top-level scene layers appear in trackOrder. Clip-edit and // button-asset-edit layers live inside their own containers. if (!this.activeClipId && !this.activeButtonAssetId) { const order = (this.scene.trackOrder ||= []) if (!order.includes(layer.id)) order.unshift(layer.id) } this.selectedLayerId = layer.id this.selectedLayerIds = [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.activeScene.trackOrder = this.activeScene.trackOrder.filter((x) => x !== id) } if (this.selectedLayerId === id) { this.selectedLayerId = null this.selectedLayerIds = [] } else if (this.selectedLayerIds.includes(id)) { this.selectedLayerIds = this.selectedLayerIds.filter((x) => x !== id) } }, /** Replace a single SVG-backed layer with one layer per top-level * drawable in the SVG. Each new layer lands in the stack slot of * the original and preserves every animatable field — base, * keyframes, scale, rotation, opacity. Positions are recomputed * so the parts stay visually pinned to their pre-split spots on * the stage. Returns the new layer ids, or `null` if the layer * isn't SVG-backed or the SVG has fewer than 2 splittable parts. */ splitLayerSvg(layerId: string): string[] | null { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return null const asset = this.scene.assets.find((a) => a.id === layer.assetId) if (!asset || asset.kind !== 'image') return null if (!isSvgSource(asset.src)) return null const svgText = decodeSvgDataUrl(asset.src) if (!svgText) return null const split = splitSvgRuntime(svgText) if (!split || split.parts.length < 2) return null const parts = split.parts // Insert location: wherever the original sat inside trackOrder // or inside its parent group's childIds. const order = this.scene.trackOrder ?? [] const topIdx = order.indexOf(layerId) let parentGroup: TrackGroup | null = null let childIdx = -1 if (topIdx < 0) { for (const g of this.scene.groups ?? []) { const i = g.childIds.indexOf(layerId) if (i >= 0) { parentGroup = g childIdx = i break } } } const newLayerIds: string[] = [] const newAssets: Asset[] = [] const newLayers: Layer[] = [] // Each part is a full-sized copy of the original SVG with only // one drawable in its body. That means every new layer inherits // the original's position/scale/keyframes verbatim — the part // renders exactly where it was because its SVG canvas is the // same size and coordinate space as before. const cloneKeyframes = (kfs: Keyframe[] | undefined): Keyframe[] | undefined => { if (!kfs) return kfs return kfs.map((kf) => ({ ...kf, id: uid(), props: { ...kf.props }, })) } for (const part of parts) { const partAsset: Asset = { id: uid(), kind: 'image', name: `${asset.name} · ${part.name}`, src: part.dataUrl, width: asset.width, height: asset.height, } newAssets.push(partAsset) const partLayer: Layer = { id: uid(), name: `${layer.name} · ${part.name}`, assetId: partAsset.id, visible: layer.visible, base: { ...layer.base }, keyframes: cloneKeyframes(layer.keyframes) ?? [], offsetX: layer.offsetX ?? asset.width / 2, offsetY: layer.offsetY ?? asset.height / 2, } if (layer.positionKeyframes) { partLayer.positionKeyframes = cloneKeyframes(layer.positionKeyframes) } if (layer.rotationKeyframes) { partLayer.rotationKeyframes = cloneKeyframes(layer.rotationKeyframes) } if (layer.separated) partLayer.separated = true if (layer.color !== undefined) partLayer.color = layer.color if (layer.colorOverridden !== undefined) partLayer.colorOverridden = layer.colorOverridden if (layer.locked) partLayer.locked = layer.locked if (layer.editorHidden) partLayer.editorHidden = layer.editorHidden newLayers.push(partLayer) newLayerIds.push(partLayer.id) } // Commit: push new assets, insert new layers at the original // slot, then remove the original. this.scene.assets.push(...newAssets) const layers = this.activeLayers const origIdx = layers.findIndex((l) => l.id === layerId) if (origIdx >= 0) layers.splice(origIdx, 1, ...newLayers) else layers.push(...newLayers) if (parentGroup && childIdx >= 0) { parentGroup.childIds.splice( childIdx, 1, ...newLayers.map((l) => l.id), ) } else if (topIdx >= 0) { this.scene.trackOrder!.splice(topIdx, 1, ...newLayers.map((l) => l.id)) } else { ;(this.scene.trackOrder ||= []).unshift(...newLayers.map((l) => l.id)) } if (this.selectedLayerId === layerId) { this.selectedLayerId = newLayerIds[0] ?? null this.selectedLayerIds = newLayerIds } return newLayerIds }, 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 this.selectedLayerIds = id ? [id] : [] if (id) this.selectedClipInstanceId = null }, selectClipInstance(id: string | null) { this.selectedClipInstanceId = id if (id) { this.selectedLayerId = null this.selectedLayerIds = [] } }, /** Shift-click behaviour: add the layer to the selection if it's * not already there; otherwise remove it. The anchor moves to * the most-recently-clicked id so the Properties panel follows * the user's last touch. Layers that can't be selected (locked * directly or through a locked group) are skipped. */ toggleLayerSelection(id: string) { const layer = this.activeLayers.find((l) => l.id === id) if (!layer) return if (layer.locked) return for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(id)) return } const existing = this.selectedLayerIds.indexOf(id) if (existing >= 0) { this.selectedLayerIds.splice(existing, 1) this.selectedLayerId = this.selectedLayerIds[this.selectedLayerIds.length - 1] ?? null } else { this.selectedLayerIds.push(id) 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.project.duration = Math.max(0.1, seconds) if (this.playhead > this.project.duration) this.playhead = this.project.duration } }, setFps(fps: number) { this.project.fps = Math.max(1, Math.round(fps)) }, setCanvasSize(width: number, height: number) { this.project.canvas.width = Math.max(1, Math.round(width)) this.project.canvas.height = Math.max(1, Math.round(height)) }, /** Shift every scene-world-space coordinate across the whole project * by (dx, dy). Used by the stage resize drag: when grabbing the * left/top edge the canvas origin effectively slides, and every * element needs to slide the same amount to stay visually pinned. * * What shifts: * - layer.base.{x,y} * - each keyframe.props.{x,y} on the main track * - each keyframe.props.{x,y} on separated position track * - keyframe.ctrlX / ctrlY (bezier handles live in world space) * - clipInstance.{x,y} (static per-instance offset) * * What stays put: clip-library layers (local to the clip instance * origin), button-asset internals (their own canvas), polygon point * clouds on keyframe.props.points (local to the layer origin). */ shiftSceneWorldCoords(dx: number, dy: number) { if (dx === 0 && dy === 0) return const shiftProps = (props: Partial | undefined) => { if (!props) return if (typeof props.x === 'number') props.x += dx if (typeof props.y === 'number') props.y += dy } const shiftKeyframe = (kf: Keyframe) => { shiftProps(kf.props) if (typeof kf.ctrlX === 'number') kf.ctrlX += dx if (typeof kf.ctrlY === 'number') kf.ctrlY += dy } for (const page of this.project.scenes) { for (const layer of page.layers) { shiftProps(layer.base) for (const kf of layer.keyframes ?? []) shiftKeyframe(kf) for (const kf of layer.positionKeyframes ?? []) shiftKeyframe(kf) } for (const ci of page.clipInstances ?? []) { if (typeof ci.x === 'number') ci.x += dx if (typeof ci.y === 'number') ci.y += dy } } }, setCanvasBackground(color: string) { this.project.canvas.background = color }, setBackgroundVideo(video: BackgroundVideo | null) { if (video && video.src) this.activeScene.backgroundVideo = { ...video } else delete this.activeScene.backgroundVideo }, setPlaybackRange(inTime: number | null, outTime: number | null) { const dur = this.project.duration const hasIn = inTime !== null && inTime !== undefined const hasOut = outTime !== null && outTime !== undefined if (hasIn) { const v = Math.max(0, Math.min(dur, inTime!)) if (v === 0 && this.project.outTime === undefined) delete this.project.inTime else this.project.inTime = v } if (hasOut) { const v = Math.max(this.project.inTime ?? 0, Math.min(dur, outTime!)) if (v === dur && this.project.inTime === undefined) delete this.project.outTime else this.project.outTime = v } // Re-clamp playhead into the new range const lo = this.project.inTime ?? 0 const hi = this.project.outTime ?? dur if (this.playhead < lo) this.playhead = lo else if (this.playhead > hi) this.playhead = hi }, upsertKeyframe(layerId: string, time: number, props: Partial) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return // Button-edit mode: keyframes only exist at the 4 state times. // Any upsert call (from lane-click, polygon vertex, shortcut, …) // is forced onto the nearest state. `upsertInto` already merges // with an existing keyframe at the same time, so collisions are // handled automatically. if (this.activeButtonAssetId) time = snapToButtonStateTime(time) 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) }, setKeyframeControl(layerId: string, keyframeId: string, ctrlX: number, ctrlY: number) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return found.kf.ctrlX = ctrlX found.kf.ctrlY = ctrlY }, clearKeyframeControl(layerId: string, keyframeId: string) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return delete found.kf.ctrlX delete found.kf.ctrlY }, // Given a target world point that the curve should pass through at // segment progress `u` (0..1), solve for the quadratic bezier control. setKeyframeControlFromPoint( layerId: string, keyframeId: string, u: number, targetX: number, targetY: number, ) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return if (u <= 0 || u >= 1) return // Find the previous keyframe in the same arr const arr = [...found.arr].sort((x, y) => x.time - y.time) const idx = arr.findIndex((k) => k.id === keyframeId) if (idx <= 0) return const prev = arr[idx - 1] const samp = sampleLayer(layer, prev.time) const endSamp = sampleLayer(layer, found.kf.time) const p0x = samp.x const p0y = samp.y const p1x = endSamp.x const p1y = endSamp.y const oneMinus = 1 - u // targetX = oneMinus²*p0x + 2·oneMinus·u·C + u²*p1x // => C = (target - oneMinus²·p0 - u²·p1) / (2·oneMinus·u) const denom = 2 * oneMinus * u if (Math.abs(denom) < 1e-6) return const cx = (targetX - oneMinus * oneMinus * p0x - u * u * p1x) / denom const cy = (targetY - oneMinus * oneMinus * p0y - u * u * p1y) / denom found.kf.ctrlX = cx found.kf.ctrlY = cy }, 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 let t = newTime if (this.activeButtonAssetId) { t = snapToButtonStateTime(t) // Refuse to land on a slot another keyframe already occupies — // the drag silently holds at its current time. This is a soft // rejection; the UI will re-issue setKeyframeTime as the user // continues dragging, so moving PAST a taken slot still works // once the pointer reaches an empty one. const collides = found.arr.some( (k) => k.id !== keyframeId && Math.abs(k.time - t) < KEYFRAME_EPSILON, ) if (collides) return } found.kf.time = Math.max(0, Math.min(this.activeDuration, t)) 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) }, /** Copy the referenced keyframe to a specific time. Carries easing, * hold, and control-point handles along; refuses if the target * time is already occupied in the same array. Used by the * button-editor's + buttons to fill empty state slots with the * previous (or next) keyframe's values. */ duplicateKeyframeAt( layerId: string, keyframeId: string, targetTime: number, ): 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 if (arr.some((k) => Math.abs(k.time - targetTime) < KEYFRAME_EPSILON)) return null const newKf: Keyframe = { id: uid(), time: targetTime, props: { ...source.props }, easing: source.easing, } if (source.hold) newKf.hold = true if (source.ctrlX !== undefined && source.ctrlY !== undefined) { newKf.ctrlX = source.ctrlX newKf.ctrlY = source.ctrlY } arr.push(newKf) sortKfs(arr) return newKf.id }, 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) // Button-edit mode: snap the duplicate onto the nearest free state // slot (walking outward from the source keyframe). If all 4 slots // are taken, refuse. if (this.activeButtonAssetId) { const order: number[] = [] for (const v of BUTTON_STATE_TIME_VALUES) order.push(v) order.sort((a, b) => Math.abs(a - source.time) - Math.abs(b - source.time)) const freeSlot = order.find((t) => !occupied(t)) if (freeSlot === undefined) return null const newKf: Keyframe = { id: uid(), time: freeSlot, props: { ...source.props }, easing: source.easing, } arr.push(newKf) sortKfs(arr) return newKf.id } 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) { // Group color changes cascade to every non-overridden direct child // (layers and clip-tracks — groups are never nested inside groups, // see normalizeScene). Individual (non-group) color changes always // mark the track as overridden so subsequent group-color changes // skip it. Clearing a non-group track's color (color === null) // drops the override, so the track goes back to inheriting from // its parent group — if it has one. const group = this.scene.groups?.find((g) => g.id === trackId) if (group) { if (color === null) { delete group.color delete group.colorOverridden } else { group.color = color group.colorOverridden = true } for (const id of group.childIds) { const l = this.scene.layers.find((x) => x.id === id) if (l) { if (l.colorOverridden) continue if (color === null) delete l.color else l.color = color continue } const ct = this.scene.clipTracks?.find((x) => x.id === id) if (ct) { if (ct.colorOverridden) continue if (color === null) delete ct.color else ct.color = color } } return } const parentGroup = this.scene.groups?.find((g) => g.childIds.includes(trackId)) const parentColor = parentGroup?.color ?? null const layer = this.activeLayers.find((l) => l.id === trackId) if (layer) { if (color === null) { delete layer.colorOverridden if (parentColor !== null) layer.color = parentColor else delete layer.color } else { layer.color = color layer.colorOverridden = true } return } const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) if (clipTrack) { if (color === null) { delete clipTrack.colorOverridden if (parentColor !== null) clipTrack.color = parentColor else delete clipTrack.color } else { clipTrack.color = color clipTrack.colorOverridden = true } return } const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId) if (scriptTrack) { // Script tracks are never inside a group, so no inheritance. if (color === null) { delete scriptTrack.color delete scriptTrack.colorOverridden } else { scriptTrack.color = color scriptTrack.colorOverridden = true } return } const audioTrack = this.scene.audioTracks?.find((t) => t.id === trackId) if (audioTrack) { // Audio tracks are never inside a group either. if (color === null) { delete audioTrack.color delete audioTrack.colorOverridden } else { audioTrack.color = color audioTrack.colorOverridden = true } } }, 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.project.assets = this.project.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.project.assets = this.project.assets.filter((a) => a.id !== assetId) }, setPolygonVertexType( assetId: string, indexOrIndices: number | number[], type: 'corner' | 'curve' | 'splitcurve', ) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return const list = Array.isArray(indexOrIndices) ? indexOrIndices : [indexOrIndices] for (const index of list) { if (index < 0 || index >= asset.points.length) continue const point = asset.points[index] if (type === 'corner') { delete point.type delete point.inX delete point.inY delete point.outX delete point.outY continue } const n = asset.points.length const closed = asset.closed !== false let prev: { x: number; y: number } | null = null let next: { x: number; y: number } | null = null if (index > 0) prev = asset.points[index - 1] else if (closed) prev = asset.points[n - 1] if (index < n - 1) next = asset.points[index + 1] else if (closed) next = asset.points[0] const dirX = (next?.x ?? point.x) - (prev?.x ?? point.x) const dirY = (next?.y ?? point.y) - (prev?.y ?? point.y) const k = 0.25 const baseOutX = dirX * k const baseOutY = dirY * k point.type = type if (point.outX === undefined || point.outY === undefined) { point.outX = baseOutX point.outY = baseOutY } if (point.inX === undefined || point.inY === undefined) { point.inX = -baseOutX point.inY = -baseOutY } } }, setPolygonVertexPositions( layerId: string, updates: { index: 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 (updates.length === 0) return const t = time ?? this.playhead // Backfill any existing keyframes that have points-array but lack // matching entries / tangents for consistency with movePolygonVertex. 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) => ({ ...p })), } } } const sampled = sampleLayer(layer, t) const current = sampled.points ?? asset.points const updateMap = new Map() for (const u of updates) { if (u.index < 0 || u.index >= current.length) continue updateMap.set(u.index, u) } const next = current.map((p, i) => { const u = updateMap.get(i) if (!u) return { ...p } return { ...p, x: u.x, y: u.y } }) this.upsertKeyframe(layerId, t, { points: next }) }, setPolygonVertexTangent( assetId: string, index: number, side: 'in' | 'out', dx: number, dy: number, ) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return if (index < 0 || index >= asset.points.length) return const type = asset.points[index].type if (!type || type === 'corner') return const t = this.playhead const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) for (const layer of layersUsing) { // Backfill any existing keyframes that have points but lack tangents 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.map((p, i) => { const ap = asset.points[i] if (!ap) return { ...p } return { ...p, inX: p.inX ?? ap.inX, inY: p.inY ?? ap.inY, outX: p.outX ?? ap.outX, outY: p.outY ?? ap.outY, } }), } } } const sampled = sampleLayer(layer, t) const basePts = sampled.points ?? asset.points const next = basePts.map((p, i) => { const out: typeof p = { ...p } if (i !== index) return out if (side === 'in') { out.inX = dx out.inY = dy if (type === 'curve') { out.outX = -dx out.outY = -dy } } else { out.outX = dx out.outY = dy if (type === 'curve') { out.inX = -dx out.inY = -dy } } return out }) this.upsertKeyframe(layer.id, t, { points: next }) } // Also keep asset-level defaults in sync so new keyframes start from // the latest edit when no kf.points is present yet. const basePoint = asset.points[index] if (side === 'in') { basePoint.inX = dx basePoint.inY = dy if (type === 'curve') { basePoint.outX = -dx basePoint.outY = -dy } } else { basePoint.outX = dx basePoint.outY = dy if (type === 'curve') { basePoint.inX = -dx basePoint.inY = -dy } } }, 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 = 'negative' } 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 this.deselectIfLocked() return } const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId) if (clipTrack) { if (locked) clipTrack.locked = true else delete clipTrack.locked // Drop a selected instance on this track so the transformer // doesn't stay latched to a group that no longer accepts // pointer events. this.deselectIfLocked() return } const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId) if (scriptTrack) { if (locked) scriptTrack.locked = true else delete scriptTrack.locked return } const group = this.scene.groups?.find((g) => g.id === trackId) if (group) { if (locked) group.locked = true else delete group.locked this.deselectIfLocked() } }, /** When a lock change makes the currently-selected layer inert * (directly locked or reached by a locked parent group), drop * the selection so the properties panel, keyboard shortcuts, * and Konva transformer stop offering edits that would fail * silently against an ignored click. Also handles the clip- * instance case — if the selected instance sits on a clip * track that's now locked (directly or via a parent group), * the transformer would stay latched to a non-listening group. */ deselectIfLocked() { const id = this.selectedLayerId if (id) { const sel = this.activeLayers.find((l) => l.id === id) if (!sel) { this.selectedLayerId = null this.selectedLayerIds = [] } else if (sel.locked) { this.selectedLayerId = null this.selectedLayerIds = [] } else { for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(id)) { this.selectedLayerId = null this.selectedLayerIds = [] break } } } } const ciId = this.selectedClipInstanceId if (ciId) { const inst = this.scene.clipInstances?.find((ci) => ci.id === ciId) if (!inst) { this.selectedClipInstanceId = null return } const track = this.scene.clipTracks?.find((t) => t.id === inst.clipTrackId) if (track?.locked) { this.selectedClipInstanceId = null return } for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(inst.clipTrackId)) { this.selectedClipInstanceId = null return } } } }, setLayerEditorHidden(layerId: string, hidden: boolean) { const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (hidden) layer.editorHidden = true else delete layer.editorHidden }, setClipTrackEditorHidden(trackId: string, hidden: boolean) { const track = this.scene.clipTracks?.find((t) => t.id === trackId) if (!track) return if (hidden) track.editorHidden = true else delete track.editorHidden // Dropping the track out of the editor mustn't leave a stale // selection pointing at one of its instances — the transformer // would stay attached to a now-invisible ci-group. if (hidden && this.selectedClipInstanceId) { const stillSelected = this.scene.clipInstances?.find( (ci) => ci.id === this.selectedClipInstanceId, ) if (stillSelected && stillSelected.clipTrackId === trackId) { this.selectedClipInstanceId = null } } }, setGroupEditorHidden(groupId: string, hidden: boolean) { const group = this.scene.groups?.find((g) => g.id === groupId) if (!group) return if (hidden) group.editorHidden = true else delete group.editorHidden // Editor-hide cascades to every child track; drop any selection // that now points inside the group so the transformer, keyboard // shortcuts and Properties panel stop offering edits that won't // render. if (!hidden) return const childIds = new Set(group.childIds) if (this.selectedLayerId && childIds.has(this.selectedLayerId)) { this.selectedLayerId = null this.selectedLayerIds = [] } else if (this.selectedLayerIds.length) { this.selectedLayerIds = this.selectedLayerIds.filter( (id) => !childIds.has(id), ) if (!this.selectedLayerIds.length) this.selectedLayerId = null } if (this.selectedClipInstanceId) { const sel = this.scene.clipInstances?.find( (ci) => ci.id === this.selectedClipInstanceId, ) if (sel && childIds.has(sel.clipTrackId)) { this.selectedClipInstanceId = null } } }, // -------- Markers -------------------------------------------------- addMarker(time: number, name?: string, color?: string | null): Marker { const t = Math.max(0, Math.min(this.scene.duration, time)) const markers = (this.scene.markers ||= []) const used = new Set(markers.map((m) => m.name)) let base = (name ?? '').trim() || 'Marker' let final = base let i = 2 while (used.has(final)) final = `${base} ${i++}` const m: Marker = { id: uid(), name: final, time: t } if (color != null) m.color = color markers.push(m) markers.sort((a, b) => a.time - b.time) return m }, removeMarker(id: string) { this.activeScene.markers = this.activeScene.markers.filter((m) => m.id !== id) }, renameMarker(id: string, name: string): boolean { const markers = this.scene.markers if (!markers) return false const target = markers.find((m) => m.id === id) if (!target) return false const next = name.trim() if (!next) return false if (next === target.name) return true if (markers.some((m) => m.id !== id && m.name === next)) return false target.name = next return true }, setMarkerTime(id: string, time: number) { const marker = this.scene.markers?.find((m) => m.id === id) if (!marker) return marker.time = Math.max(0, Math.min(this.scene.duration, time)) this.scene.markers?.sort((a, b) => a.time - b.time) }, setMarkerColor(id: string, color: string | null) { const marker = this.scene.markers?.find((m) => m.id === id) if (!marker) return if (color == null) delete marker.color else marker.color = color }, // -------- Script tracks & frames ----------------------------------- addScriptTrack(name?: string): ScriptTrack { const track: ScriptTrack = { id: uid(), name } ;(this.scene.scriptTracks ||= []).push(track) ;(this.scene.trackOrder ||= []).push(track.id) return track }, removeScriptTrack(id: string) { const page = this.activeScene page.scriptTracks = page.scriptTracks.filter((t) => t.id !== id) page.scriptFrames = page.scriptFrames.filter((f) => f.trackId !== id) page.trackOrder = page.trackOrder.filter((tid) => tid !== id) }, setScriptTrackName(id: string, name: string) { const track = this.scene.scriptTracks?.find((t) => t.id === id) if (track) track.name = name.trim() || undefined }, setLayerName(id: string, name: string) { const layer = this.activeLayers.find((l) => l.id === id) if (!layer) return const n = name.trim() if (n) layer.name = n }, setClipTrackName(id: string, name: string) { const track = this.scene.clipTracks?.find((t) => t.id === id) if (track) track.name = name.trim() || undefined }, addScriptFrame( trackId: string, time: number, actions: Action[] = [], label?: string, ): ScriptFrame | null { const track = this.scene.scriptTracks?.find((t) => t.id === trackId) if (!track) return null const t = Math.max(0, Math.min(this.scene.duration, time)) const frame: ScriptFrame = { id: uid(), trackId, time: t, actions: [...actions], } if (label) frame.label = label ;(this.scene.scriptFrames ||= []).push(frame) this.scene.scriptFrames.sort((a, b) => a.time - b.time) return frame }, removeScriptFrame(id: string) { this.activeScene.scriptFrames = this.activeScene.scriptFrames.filter((f) => f.id !== id) }, setScriptFrameActions(id: string, actions: Action[]) { const frame = this.scene.scriptFrames?.find((f) => f.id === id) if (frame) frame.actions = [...actions] }, setScriptFrameTime(id: string, time: number) { const frame = this.scene.scriptFrames?.find((f) => f.id === id) if (!frame) return frame.time = Math.max(0, Math.min(this.scene.duration, time)) this.scene.scriptFrames?.sort((a, b) => a.time - b.time) }, duplicateScriptFrame(frameId: string): string | null { const frames = this.scene.scriptFrames ?? [] const source = frames.find((f) => f.id === frameId) if (!source) return null const sameTrack = frames.filter((f) => f.trackId === source.trackId) const occupied = (t: number) => sameTrack.some((f) => Math.abs(f.time - t) < KEYFRAME_EPSILON) const fps = this.scene.fps > 0 ? this.scene.fps : 60 const offset = Math.max(3 / fps, 0.1) const duration = this.scene.duration // Walk forward looking for the first free slot; if the track is // packed to the end, fall back to stepping backward before giving up. let target = source.time + offset while (occupied(target) && target < duration) target += offset if (target > duration || occupied(target)) { target = source.time - offset while (occupied(target) && target > 0) target -= offset if (target < 0) target = 0 if (occupied(target)) return null } const copy: ScriptFrame = { id: uid(), trackId: source.trackId, time: target, actions: source.actions.map((a) => ({ ...a })), } if (source.label) copy.label = source.label ;(this.scene.scriptFrames ||= []).push(copy) this.scene.scriptFrames.sort((a, b) => a.time - b.time) return copy.id }, // -------- Audio tracks, clips & assets ----------------------------- /** Add a new audio asset to the shared project library. `src` may be * a data-URL (for offline editor use) or an external http(s) URL; * exports strip data-URL-backed audio assets with a warning. */ addAudioAsset(init: { name: string; src: string; duration: number }): AudioAsset { const asset: AudioAsset = { id: uid(), kind: 'audio', name: init.name.trim() || 'Audio', src: init.src, duration: Math.max(0, init.duration), } this.project.assets.push(asset) return asset }, addAudioTrack(name?: string): AudioTrack { const track: AudioTrack = { id: uid(), name } this.activeScene.audioTracks.push(track) this.activeScene.trackOrder.push(track.id) return track }, removeAudioTrack(id: string) { const page = this.activeScene page.audioTracks = page.audioTracks.filter((t) => t.id !== id) page.audioClips = page.audioClips.filter((c) => c.audioTrackId !== id) page.trackOrder = page.trackOrder.filter((x) => x !== id) }, setAudioTrackName(id: string, name: string) { const track = this.activeScene.audioTracks.find((t) => t.id === id) if (track) track.name = name.trim() || undefined }, setAudioTrackMuted(id: string, muted: boolean) { const track = this.activeScene.audioTracks.find((t) => t.id === id) if (track) track.muted = muted }, /** Place an AudioClip on the given track. Returns null if the asset * or track is unknown — guards the caller from creating orphans. */ addAudioClip( audioAssetId: string, startTime: number, audioTrackId: string, ): AudioClip | null { const asset = this.project.assets.find((a) => a.id === audioAssetId) if (!asset || asset.kind !== 'audio') return null const track = this.activeScene.audioTracks.find((t) => t.id === audioTrackId) if (!track) return null const clip: AudioClip = { id: uid(), audioAssetId, audioTrackId, startTime: Math.max(0, startTime), } this.activeScene.audioClips.push(clip) return clip }, moveAudioClip(id: string, startTime: number, audioTrackId?: string) { const clip = this.activeScene.audioClips.find((c) => c.id === id) if (!clip) return clip.startTime = Math.max(0, startTime) if (audioTrackId) { const track = this.activeScene.audioTracks.find((t) => t.id === audioTrackId) if (track) clip.audioTrackId = audioTrackId } }, /** Move a clip to `(startTime, audioTrackId)` on the active page and * cascade-push any existing clips on the target track out of the * way so no overlap survives. Clips ahead of the moved block are * shoved forward; clips behind it are shoved backward. Hitting * the scene origin clamps the moved clip forward. Returns `false` * only when the clip or target track is unknown. */ setAudioClipPosition(id: string, startTime: number, audioTrackId?: string): boolean { const page = this.activeScene const clip = page.audioClips.find((c) => c.id === id) if (!clip) return false const targetTrackId = audioTrackId ?? clip.audioTrackId const targetTrack = page.audioTracks.find((t) => t.id === targetTrackId) if (!targetTrack) return false const lenOf = (c: AudioClip): number => { const a = this.project.assets.find((x) => x.id === c.audioAssetId) if (!a || a.kind !== 'audio') return 0 return Math.max(0, a.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0)) } const clipLen = lenOf(clip) const oldCenter = clip.startTime + clipLen / 2 const others = page.audioClips.filter( (c) => c.audioTrackId === targetTrackId && c.id !== id, ) const appliedStart = cascadePushAside( others, oldCenter, clipLen, lenOf, startTime, ) clip.audioTrackId = targetTrackId clip.startTime = appliedStart return true }, /** Trim from the head and/or tail of an audio clip. Keeps the audible * block at least `MIN_VISIBLE` (0.05s) long — mirrors trimClipInstance. */ trimAudioClip( id: string, patch: { startTime?: number; trimStart?: number; trimEnd?: number }, ) { const clip = this.activeScene.audioClips.find((c) => c.id === id) if (!clip) return const asset = this.project.assets.find((a) => a.id === clip.audioAssetId) if (!asset || asset.kind !== 'audio') return const MIN_VISIBLE = 0.05 let trimStart = patch.trimStart ?? clip.trimStart ?? 0 let trimEnd = patch.trimEnd ?? clip.trimEnd ?? 0 let startTime = patch.startTime ?? clip.startTime trimStart = Math.max(0, trimStart) trimEnd = Math.max(0, trimEnd) const maxBudget = asset.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) } } clip.trimStart = trimStart clip.trimEnd = trimEnd clip.startTime = Math.max(0, startTime) }, removeAudioClip(id: string) { this.activeScene.audioClips = this.activeScene.audioClips.filter((c) => c.id !== id) }, /** Clone an audio clip onto the same track, placed directly after * the original using its visible (trim-adjusted) duration. The * copy carries trim settings and sample/loop offsets forward. */ duplicateAudioClip(id: string): string | null { const src = this.activeScene.audioClips.find((c) => c.id === id) if (!src) return null const asset = this.project.assets.find((a) => a.id === src.audioAssetId) const base = asset && asset.kind === 'audio' ? asset.duration : 1 const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0)) const copy: AudioClip = { ...structuredClone(toRaw(src)), id: uid(), startTime: src.startTime + visible, } this.activeScene.audioClips.push(copy) return copy.id }, // -------- Button assets -------------------------------------------- addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset { const asset: ButtonAsset = { id: uid(), kind: 'button', name: name?.trim() || 'Button', canvas: { width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)), }, layers: [], hitArea: [], } this.scene.assets.push(asset) return asset }, setButtonAssetCanvas(assetId: string, width: number, height: number) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'button') return asset.canvas = { width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)), } }, enterButtonAssetEdit(assetId: string) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'button') return this.activeButtonAssetId = assetId this.activeButtonEditMode = 'states' this.activeButtonState = 'normal' this.activeClipId = null this.selectedLayerId = null this.selectedLayerIds = [] this.playhead = BUTTON_STATE_TIMES.normal this.playing = false }, setButtonEditMode(mode: ButtonEditMode) { if (!this.activeButtonAssetId) return this.activeButtonEditMode = mode this.selectedLayerId = null this.selectedLayerIds = [] if (mode === 'states') { // Landing back in states-mode: keep the last content state (or // default to normal) and sync the playhead to its kf-time. const st = this.activeButtonState ?? 'normal' this.activeButtonState = st this.playhead = BUTTON_STATE_TIMES[st] } else { // Hit mode onion-skins normal, so park the playhead at normal's // time. No mutations can happen here — layers are inert. this.playhead = BUTTON_STATE_TIMES.normal } }, setActiveButtonState(state: ButtonState) { if (!this.activeButtonAssetId) return // Scrubbing a state implies the user is in states-mode. this.activeButtonEditMode = 'states' this.activeButtonState = state // States ARE keyframes — scrubbing moves the playhead to that // state's fixed time so existing kf mutation paths target it. this.playhead = BUTTON_STATE_TIMES[state] this.selectedLayerId = null this.selectedLayerIds = [] }, exitButtonAssetEdit() { this.activeButtonAssetId = null this.activeButtonEditMode = null this.activeButtonState = null this.selectedLayerId = null this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, addButtonHitShape(assetId: string, shape: HitShape): HitShape | null { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'button') return null asset.hitArea.push(shape) return shape }, removeButtonHitShape(assetId: string, index: number) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'button') return if (index < 0 || index >= asset.hitArea.length) return asset.hitArea.splice(index, 1) }, updateButtonHitShape( assetId: string, index: number, patch: Partial, ) { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'button') return const current = asset.hitArea[index] if (!current) return // Preserve kind — patch can only override numeric fields. asset.hitArea[index] = { ...current, ...patch } as HitShape }, // -------- Button layer per-instance (on click / disabled) ---------- setLayerOnClick(layerId: string, actions: Action[]) { const layer = findLayerAnywhere(this.scene, layerId) if (!layer) return if (actions.length) layer.onClick = actions.map((a) => ({ ...a })) else delete layer.onClick }, setLayerButtonDisabled(layerId: string, disabled: boolean) { const layer = findLayerAnywhere(this.scene, layerId) if (!layer) return if (disabled) layer.buttonDisabled = true else delete layer.buttonDisabled }, 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 } }, /** Replace the entire project. Resets editor context (selection, * playback, clip / button-edit drill-down). */ loadProject(project: Project) { this.project = normalizeProject(project) this.selectedLayerId = null this.selectedLayerIds = [] this.selectedClipInstanceId = null this.playhead = 0 this.playing = false this.activeClipId = null this.activeButtonAssetId = null this.activeButtonEditMode = null this.activeButtonState = null this.drawingMode = null this.drawingAssetId = null this.drawingLayerId = null }, /** Backward-compat shim: legacy callers (older project files, recents * stored as Scene, tests) still pass a Scene. Wrap into a one-page * Project and delegate. */ loadScene(scene: Scene) { this.loadProject(projectFromLegacyScene(scene)) }, /** Replace the project data only — for use by the undo/redo layer. * Unlike `loadProject`, transient editor state (playhead, selection, * activeClipId, activeButtonAssetId, drawing) is left untouched so * undoing a content change doesn't kick the user out of clip-edit * or reset where they were scrubbing. The caller is responsible for * pruning any selection that now points at a vanished id. */ applyProjectSnapshot(project: Project) { this.project = normalizeProject(project) }, // -------- Scene pages ---------------------------------------------- /** Append a fresh, empty page and activate it. The default name is * "Scene N" with N counting up from the current page count. */ addScene(name?: string): ScenePage { const final = name?.trim() || `Scene ${this.project.scenes.length + 1}` const page = createBlankScenePage(final) this.project.scenes.push(page) this.switchScene(page.id) return page }, /** Activate a page by id. Clears any drill-down state (clip-edit, * button-edit, selections, drawing) so the new page isn't shown * through the lens of a context that belongs to another page. * Playback state is left alone by default — in the editor, * flipping scenes is a navigation gesture and shouldn't force * play or rewind. Script-fired or button-fired jumps use * `{ resetPlayhead: true }` instead so the incoming scene starts * at its own in-point rather than inheriting the previous * scene's paused frame. */ switchScene(id: string, options?: { resetPlayhead?: boolean }): boolean { if (!this.project.scenes.find((s) => s.id === id)) return false this.project.activeSceneId = id this.activeClipId = null this.activeButtonAssetId = null this.activeButtonEditMode = null this.activeButtonState = null this.selectedLayerId = null this.selectedLayerIds = [] this.selectedClipInstanceId = null this.drawingMode = null this.drawingAssetId = null this.drawingLayerId = null if (options?.resetPlayhead) { this.playhead = this.playbackIn } return true }, renameScene(id: string, name: string): boolean { const page = this.project.scenes.find((s) => s.id === id) if (!page) return false const next = name.trim() if (!next) return false page.name = next return true }, /** Remove a page. Refuses to delete the last remaining page (a * project must always have at least one). If the active page is * removed, activates the neighbour. */ removeScene(id: string): boolean { const scenes = this.project.scenes if (scenes.length <= 1) return false const idx = scenes.findIndex((s) => s.id === id) if (idx < 0) return false const wasActive = this.project.activeSceneId === id scenes.splice(idx, 1) if (wasActive) { const neighbour = scenes[Math.min(idx, scenes.length - 1)] this.switchScene(neighbour.id) } return true }, /** Deep-clone the named page with fresh ids on every layer, keyframe, * clip-instance, script-frame, marker, group, and trackOrder entry. * Inserted directly after the original; activated. */ duplicateScene(id: string): ScenePage | null { const orig = this.project.scenes.find((s) => s.id === id) if (!orig) return null const cloned = structuredClone(toRaw(orig)) as ScenePage // Build a complete id-remap (every layer, group, clipTrack, scriptTrack) // so trackOrder/group childIds/scriptFrames stay internally consistent. const remap = new Map() const remapId = (oldId: string) => { let next = remap.get(oldId) if (!next) { next = uid() remap.set(oldId, next) } return next } cloned.id = uid() cloned.name = `${orig.name} copy` for (const l of cloned.layers) { l.id = remapId(l.id) l.keyframes = l.keyframes.map((k) => ({ ...k, id: uid() })) if (l.positionKeyframes) { l.positionKeyframes = l.positionKeyframes.map((k) => ({ ...k, id: uid() })) } if (l.rotationKeyframes) { l.rotationKeyframes = l.rotationKeyframes.map((k) => ({ ...k, id: uid() })) } } for (const t of cloned.clipTracks) t.id = remapId(t.id) for (const st of cloned.scriptTracks) st.id = remapId(st.id) for (const at of cloned.audioTracks) at.id = remapId(at.id) for (const g of cloned.groups) { g.id = remapId(g.id) g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid) } cloned.trackOrder = cloned.trackOrder.map((oid) => remap.get(oid) ?? oid) cloned.clipInstances = cloned.clipInstances.map((ci) => ({ ...ci, id: uid(), clipTrackId: remap.get(ci.clipTrackId) ?? ci.clipTrackId, })) cloned.scriptFrames = cloned.scriptFrames.map((f) => ({ ...f, id: uid(), trackId: remap.get(f.trackId) ?? f.trackId, })) cloned.audioClips = cloned.audioClips.map((c) => ({ ...c, id: uid(), audioTrackId: remap.get(c.audioTrackId) ?? c.audioTrackId, })) cloned.markers = cloned.markers.map((m) => ({ ...m, id: uid() })) const idx = this.project.scenes.findIndex((s) => s.id === id) this.project.scenes.splice(idx + 1, 0, cloned) this.switchScene(cloned.id) return cloned }, /** Remove an asset from the project library. Layers that referenced * it remain — they'll just render as nothing. Audio clips, however, * get purged: a dangling audio clip has no asset to play, so the * lane would just render an empty block forever. Cascade through * every scene page so non-active pages don't carry ghosts. */ removeAsset(id: string) { const asset = this.project.assets.find((a) => a.id === id) this.project.assets = this.project.assets.filter((a) => a.id !== id) if (asset && asset.kind === 'audio') { for (const page of this.project.scenes) { page.audioClips = page.audioClips.filter((c) => c.audioAssetId !== id) } } }, 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 }, /** Pull the currently-selected (unlocked, top-level) layers out of * `scene.layers`, pack them into a brand-new Clip, and drop a * single ClipInstance on a fresh ClipTrack at the topmost * selected track's stack position. The clip instance starts at * `startTime = 0` and shares the scene duration — so layer kfs * keep their absolute times and on-stage visuals are unchanged. * Returns the new clip id, or null if the selection is empty. */ convertSelectedLayersToClip(name?: string): string | null { const ids = this.selectedLayerIds.filter((id) => { const layer = this.scene.layers.find((l) => l.id === id) if (!layer) return false if (layer.locked) return false for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(id)) return false } return true }) if (ids.length === 0) return null // Topmost index in trackOrder among the layers we're about to // fold away. If a selected layer is already inside a group it // doesn't appear in trackOrder directly, so it can't anchor // the new clipTrack's position — we fall back to "top" if // nothing anchors. const order = (this.scene.trackOrder ||= []) let insertAt = Infinity for (const id of ids) { const i = order.indexOf(id) if (i >= 0 && i < insertAt) insertAt = i } // Deep-clone the layers so the clip owns independent kf/base // data. `toRaw` strips the Vue/Pinia reactive proxy first so // `structuredClone` sees the underlying plain object graph // (structuredClone refuses to walk a Vue proxy's invisible // internals in some edge cases). const clonedLayers = ids .map((id) => this.scene.layers.find((l) => l.id === id)) .filter((l): l is Layer => !!l) .map((l) => structuredClone(toRaw(l)) as Layer) const clip: Clip = { id: uid(), name: name?.trim() || 'Clip', duration: this.scene.duration, layers: clonedLayers, } ;(this.scene.clips ||= []).push(clip) // Strip the originals out of scene.layers, trackOrder, and any // group they were part of. Clearing in one pass keeps Pinia's // reactivity from thrashing on intermediate states. const idSet = new Set(ids) const page = this.activeScene page.layers = page.layers.filter((l) => !idSet.has(l.id)) page.trackOrder = order.filter((x) => !idSet.has(x)) for (const g of page.groups) { g.childIds = g.childIds.filter((x) => !idSet.has(x)) } // Fresh clip track, sat at the anchor slot instead of // appended to the end (addClipTrack's default). const track: ClipTrack = { id: uid() } ;(this.scene.clipTracks ||= []).push(track) const refreshedOrder = (this.scene.trackOrder ||= []) if (insertAt === Infinity || insertAt > refreshedOrder.length) { refreshedOrder.push(track.id) } else { refreshedOrder.splice(insertAt, 0, track.id) } const instance: ClipInstance = { id: uid(), clipId: clip.id, clipTrackId: track.id, startTime: 0, } ;(this.scene.clipInstances ||= []).push(instance) this.selectedLayerId = null this.selectedLayerIds = [] return clip.id }, removeClip(id: string) { this.project.clips = this.project.clips.filter((c) => c.id !== id) // Clip-instances of the deleted clip live on every scene page that // happened to place it — purge from all of them so we don't leave // dangling references in non-active pages. for (const page of this.project.scenes) { page.clipInstances = page.clipInstances.filter((ci) => ci.clipId !== id) } if (this.activeClipId === id) { this.activeClipId = null this.selectedLayerId = null this.selectedLayerIds = [] 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 }, /** * Duplicate a top-level track by id. Handles layer / clipTrack / * scriptTrack — clip instances and script frames belonging to the * original are cloned too. The duplicate lands directly after the * original in trackOrder (or in the same group's childIds if the * original lived inside one). Groups themselves are not supported * here; returns null for unknown ids, clip-edit / button-edit * contexts, and groups. */ duplicateTrack(id: string): string | null { if (this.activeClipId || this.activeButtonAssetId) return null const scene = this.scene // Grouped tracks live in TrackGroup.childIds and are absent from // scene.trackOrder entirely (only the group's own id appears at // top level). Duplicate stays inside whichever container held the // original. const placeAfter = (afterId: string, newId: string) => { const order = (scene.trackOrder ||= []) const idx = order.indexOf(afterId) if (idx >= 0) { order.splice(idx + 1, 0, newId) return } for (const g of scene.groups ?? []) { const gi = g.childIds.indexOf(afterId) if (gi >= 0) { g.childIds.splice(gi + 1, 0, newId) return } } order.push(newId) } const layer = scene.layers.find((l) => l.id === id) if (layer) { const cloned = structuredClone(toRaw(layer)) as Layer cloned.id = uid() cloned.keyframes = cloned.keyframes.map((k) => ({ ...k, id: uid() })) if (cloned.positionKeyframes) { cloned.positionKeyframes = cloned.positionKeyframes.map((k) => ({ ...k, id: uid() })) } if (cloned.rotationKeyframes) { cloned.rotationKeyframes = cloned.rotationKeyframes.map((k) => ({ ...k, id: uid() })) } const origIdx = scene.layers.findIndex((l) => l.id === id) scene.layers.splice(origIdx + 1, 0, cloned) placeAfter(id, cloned.id) return cloned.id } const clipTrack = scene.clipTracks?.find((t) => t.id === id) if (clipTrack) { const newTrack: ClipTrack = { ...(structuredClone(toRaw(clipTrack)) as ClipTrack), id: uid(), } ;(scene.clipTracks ||= []).push(newTrack) const instances = scene.clipInstances?.filter((ci) => ci.clipTrackId === id) ?? [] for (const ci of instances) { const clonedInst: ClipInstance = { ...(structuredClone(toRaw(ci)) as ClipInstance), id: uid(), clipTrackId: newTrack.id, } ;(scene.clipInstances ||= []).push(clonedInst) } placeAfter(id, newTrack.id) return newTrack.id } const scriptTrack = scene.scriptTracks?.find((t) => t.id === id) if (scriptTrack) { const newTrack: ScriptTrack = { ...(structuredClone(toRaw(scriptTrack)) as ScriptTrack), id: uid(), } ;(scene.scriptTracks ||= []).push(newTrack) const frames = scene.scriptFrames?.filter((f) => f.trackId === id) ?? [] for (const f of frames) { const clonedFrame: ScriptFrame = { ...(structuredClone(toRaw(f)) as ScriptFrame), id: uid(), trackId: newTrack.id, } ;(scene.scriptFrames ||= []).push(clonedFrame) } if (scene.scriptFrames) scene.scriptFrames.sort((a, b) => a.time - b.time) placeAfter(id, newTrack.id) return newTrack.id } const audioTrack = scene.audioTracks?.find((t) => t.id === id) if (audioTrack) { const newTrack: AudioTrack = { ...(structuredClone(toRaw(audioTrack)) as AudioTrack), id: uid(), } ;(scene.audioTracks ||= []).push(newTrack) const clips = scene.audioClips?.filter((c) => c.audioTrackId === id) ?? [] for (const c of clips) { const clonedClip: AudioClip = { ...(structuredClone(toRaw(c)) as AudioClip), id: uid(), audioTrackId: newTrack.id, } ;(scene.audioClips ||= []).push(clonedClip) } placeAfter(id, newTrack.id) return newTrack.id } return null }, removeClipTrack(id: string) { const page = this.activeScene page.clipTracks = page.clipTracks.filter((t) => t.id !== id) page.clipInstances = page.clipInstances.filter((ci) => ci.clipTrackId !== id) page.trackOrder = page.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 } }, /** Move a clip instance, cascading non-overlap across its track — * mirrors `setAudioClipPosition`. Preferred over `moveClipInstance` * for user-initiated drags so instances can't land on top of each * other. Returns `false` only when the instance or target track * is unknown. */ setClipInstancePosition(id: string, startTime: number, clipTrackId?: string): boolean { const page = this.activeScene const inst = page.clipInstances.find((ci) => ci.id === id) if (!inst) return false const targetTrackId = clipTrackId ?? inst.clipTrackId const targetTrack = page.clipTracks.find((t) => t.id === targetTrackId) if (!targetTrack) return false const lenOf = (ci: ClipInstance): number => { const clip = this.project.clips.find((c) => c.id === ci.clipId) if (!clip) return 0 return Math.max(0, clip.duration - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0)) } const instLen = lenOf(inst) const oldCenter = inst.startTime + instLen / 2 const others = page.clipInstances.filter( (ci) => ci.clipTrackId === targetTrackId && ci.id !== id, ) const appliedStart = cascadePushAside( others, oldCenter, instLen, lenOf, startTime, ) inst.clipTrackId = targetTrackId inst.startTime = appliedStart return true }, 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) { this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id) if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null }, /** Clone a clip instance onto the same clip track, placed directly * after the original using its visible (trim-adjusted) duration. * Per-instance transform + trim settings are carried forward. */ duplicateClipInstance(id: string): string | null { const src = this.activeScene.clipInstances.find((ci) => ci.id === id) if (!src) return null const clip = this.project.clips.find((c) => c.id === src.clipId) const base = clip?.duration ?? 1 const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0)) const copy: ClipInstance = { ...structuredClone(toRaw(src)), id: uid(), startTime: src.startTime + visible, } this.activeScene.clipInstances.push(copy) return copy.id }, /** Update the per-instance x / y / scaleX / scaleY. Missing fields * on the patch are left alone; clearing a field (setting it to * the default 0 / 1) prunes it from the stored object so the * JSON stays clean. The clip's own keyframes are untouched. */ setClipInstanceTransform( id: string, patch: Partial<{ x: number y: number scaleX: number scaleY: number rotation: number }>, ) { const inst = this.scene.clipInstances?.find((ci) => ci.id === id) if (!inst) return if (patch.x !== undefined) { if (patch.x === 0) delete inst.x else inst.x = patch.x } if (patch.y !== undefined) { if (patch.y === 0) delete inst.y else inst.y = patch.y } if (patch.scaleX !== undefined) { if (patch.scaleX === 1) delete inst.scaleX else inst.scaleX = patch.scaleX } if (patch.scaleY !== undefined) { if (patch.scaleY === 1) delete inst.scaleY else inst.scaleY = patch.scaleY } if (patch.rotation !== undefined) { // Normalise to (-180, 180] so the stored value stays human- // readable and idempotent across multiple transform sessions. let r = patch.rotation % 360 if (r > 180) r -= 360 if (r <= -180) r += 360 if (r === 0) delete inst.rotation else inst.rotation = r } }, /** Create an empty group. When `anchorTrackId` is given the group * lands directly above that track in trackOrder — resolving to * the parent group's position if the anchor sits inside one. Falls * back to the top of the stack when the anchor is missing or the * scene has no trackOrder yet. */ addTrackGroup(name?: string, anchorTrackId?: string | null): TrackGroup { const group: TrackGroup = { id: uid(), name: name?.trim() || 'Group', childIds: [], } ;(this.scene.groups ||= []).push(group) const order = (this.scene.trackOrder ||= []) let insertAt = 0 if (anchorTrackId) { const direct = order.indexOf(anchorTrackId) if (direct >= 0) { insertAt = direct } else { // Anchor is inside a group — put the new group above the // group container instead of trying to wedge in between // group children (nested groups are not supported). const parent = this.scene.groups?.find((g) => g.childIds.includes(anchorTrackId), ) if (parent) { const pi = order.indexOf(parent.id) if (pi >= 0) insertAt = pi } } } order.splice(insertAt, 0, group.id) return group }, /** Create a new group and move every currently-selected layer * into it. Returns the new group's id, or null if no layers are * selected. Skips layers that are locked (directly or via a * parent group). The new group replaces the topmost selected * track's position in the stack — so grouping a run of layers * sitting in the middle of the timeline doesn't jump the whole * group up to the top. */ groupSelectedLayers(name?: string): string | null { const ids = this.selectedLayerIds.filter((id) => { const layer = this.scene.layers.find((l) => l.id === id) if (!layer) return false if (layer.locked) return false for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(id)) return false } return true }) if (ids.length === 0) return null // Compute the topmost (smallest) trackOrder index among the // selected layers BEFORE we touch anything. This is where the // new group will land. Anything outside trackOrder (layer is // already inside another group) doesn't constrain the anchor. const order = (this.scene.trackOrder ||= []) let insertAt = Infinity for (const id of ids) { const i = order.indexOf(id) if (i >= 0 && i < insertAt) insertAt = i } const group = this.addTrackGroup(name) // addTrackGroup unshifted the group onto trackOrder[0] — pluck // it back out and splice into the anchor slot. The layers sit // at their original indices at this point; moveTrackIntoGroup // below removes them from trackOrder one by one, collapsing // the stack around the group we've just positioned. if (insertAt !== Infinity) { const gIdx = order.indexOf(group.id) if (gIdx >= 0) order.splice(gIdx, 1) order.splice(insertAt, 0, group.id) } for (const id of ids) { this.moveTrackIntoGroup(id, group.id) } return group.id }, removeTrackGroup(id: string) { const group = this.scene.groups?.find((g) => g.id === id) if (!group) return const order = this.activeScene.trackOrder const idx = order.indexOf(id) if (idx >= 0) order.splice(idx, 1, ...group.childIds) this.activeScene.groups = this.activeScene.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) // Inherit the target group's color when the moved track hasn't // been explicitly recoloured by the user. Matches the cascade // behaviour of setTrackColor so a track joining a coloured group // visually lines up with its siblings — applies to all four // groupable kinds, not just layers + clip tracks. if (target.color != null) { const l = this.scene.layers.find((x) => x.id === trackId) if (l && !l.colorOverridden) l.color = target.color const ct = this.scene.clipTracks?.find((x) => x.id === trackId) if (ct && !ct.colorOverridden) ct.color = target.color const st = this.scene.scriptTracks?.find((x) => x.id === trackId) if (st && !st.colorOverridden) st.color = target.color const at = this.scene.audioTracks?.find((x) => x.id === trackId) if (at && !at.colorOverridden) at.color = target.color } }, 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.activeButtonAssetId = null this.activeButtonEditMode = null this.activeButtonState = null this.selectedLayerId = null this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, exitClip() { this.activeClipId = null this.selectedLayerId = null this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, play() { if (this.playing) return this.playing = true this.playStartPending = true }, pause() { this.playing = false this.playStartPending = false }, togglePlay() { if (this.playing) this.pause() else { const end = this.playbackOut const start = this.playbackIn if (end <= start) return // If the playhead sits outside the in/out range, snap to the in-point. if (this.playhead < start || this.playhead >= end) this.playhead = start this.play() } }, tick(deltaSeconds: number) { if (!this.playing) return const prev = this.playhead const end = this.playbackOut const nextRaw = prev + deltaSeconds if (nextRaw >= end) { this.playhead = end this.playing = false } else { this.playhead = nextRaw } const landed = this.playhead // Forward-only firing: script frames whose time sits in (prev, landed]. // Scrubs/setPlayhead/loop-wraps never reach this path, so the rule // is naturally preserved. The first tick after play() widens the // left bound to [prev, landed] so scripts at the starting frame // (e.g. the very first time slot) also fire. const includeStart = this.playStartPending this.playStartPending = false const frames = scriptFramesInRange( this.scene.scriptFrames, prev, landed, { includeStart }, ) if (frames.length) { const rt = this.actionRuntime() for (const f of frames) { for (const action of f.actions) evalAction(action, rt) } } }, actionRuntime(): ActionRuntime { const self = this return { fps: self.scene.fps, playbackIn: self.playbackIn, playbackOut: self.playbackOut, duration: self.scene.duration, markers: buildMarkerMap(self.scene.markers), sceneIds: new Set(self.project.scenes.map((s) => s.id)), setPlayhead(t: number) { self.setPlayhead(t) }, play() { self.play() }, pause() { self.pause() }, switchScene(sceneId: string) { // Script/button jumps restart the target scene at its in-point. // Drawer clicks go through store.switchScene directly and keep // the current playhead — navigation vs. transition. self.switchScene(sceneId, { resetPlayhead: true }) }, setButtonEnabled(layerId: string, enabled: boolean) { useButtonRuntime().setDisabled(layerId, !enabled) }, warn(msg: string) { // eslint-disable-next-line no-console console.warn('[ruwio]', msg) }, } }, }, })