Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2592 lines
95 KiB
TypeScript
2592 lines
95 KiB
TypeScript
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 {
|
|
buildMarkerMap,
|
|
evalAction,
|
|
scriptFramesInRange,
|
|
type ActionRuntime,
|
|
} from '@/lib/actions'
|
|
|
|
const uid = () => Math.random().toString(36).slice(2, 10)
|
|
|
|
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
|
|
|
|
const KEYFRAME_EPSILON = 1e-3
|
|
|
|
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
|
|
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<AnimatableProps>,
|
|
snapshotProps: Partial<AnimatableProps> | 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<AnimatableProps>,
|
|
keys: Set<string>,
|
|
): Partial<AnimatableProps> {
|
|
const out: Partial<AnimatableProps> = {}
|
|
for (const [k, v] of Object.entries(src)) {
|
|
if (keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
function omitProps(
|
|
src: Partial<AnimatableProps>,
|
|
keys: Set<string>,
|
|
): Partial<AnimatableProps> {
|
|
const out: Partial<AnimatableProps> = {}
|
|
for (const [k, v] of Object.entries(src)) {
|
|
if (!keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
function hasKeys(obj: Partial<AnimatableProps>): 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<string>()
|
|
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)
|
|
|
|
const layerAndClipIds = new Set<string>()
|
|
for (const l of page.layers) layerAndClipIds.add(l.id)
|
|
for (const t of page.clipTracks) layerAndClipIds.add(t.id)
|
|
for (const g of page.groups) {
|
|
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
|
|
}
|
|
const inGroup = new Set<string>()
|
|
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))
|
|
for (const l of page.layers) {
|
|
if (!inGroup.has(l.id) && !page.trackOrder.includes(l.id)) {
|
|
page.trackOrder.unshift(l.id)
|
|
}
|
|
}
|
|
for (const t of page.clipTracks) {
|
|
if (!inGroup.has(t.id) && !page.trackOrder.includes(t.id)) {
|
|
page.trackOrder.push(t.id)
|
|
}
|
|
}
|
|
for (const g of page.groups) {
|
|
if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id)
|
|
}
|
|
for (const st of page.scriptTracks) {
|
|
if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
|
|
}
|
|
for (const at of page.audioTracks) {
|
|
if (!page.trackOrder.includes(at.id)) page.trackOrder.push(at.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,
|
|
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<string, Clip>()
|
|
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
|
|
const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES.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)
|
|
}
|
|
},
|
|
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))
|
|
},
|
|
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<AnimatableProps>) {
|
|
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
|
|
}
|
|
}
|
|
},
|
|
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<Asset, { kind: 'polygon' }>
|
|
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<number, { x: number; y: number }>()
|
|
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
|
|
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. */
|
|
deselectIfLocked() {
|
|
const id = this.selectedLayerId
|
|
if (!id) return
|
|
const sel = this.activeLayers.find((l) => l.id === id)
|
|
if (!sel) {
|
|
this.selectedLayerId = null
|
|
this.selectedLayerIds = []
|
|
return
|
|
}
|
|
if (sel.locked) {
|
|
this.selectedLayerId = null
|
|
this.selectedLayerIds = []
|
|
return
|
|
}
|
|
for (const g of this.scene.groups ?? []) {
|
|
if (g.locked && g.childIds.includes(id)) {
|
|
this.selectedLayerId = null
|
|
this.selectedLayerIds = []
|
|
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
|
|
},
|
|
|
|
// -------- 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
|
|
},
|
|
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)
|
|
},
|
|
|
|
// -------- 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
|
|
}
|
|
},
|
|
|
|
/** 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)
|
|
},
|
|
|
|
// -------- 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<HitShape>,
|
|
) {
|
|
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<AnimatableProps>
|
|
easing: Easing
|
|
}
|
|
const byTime = new Map<number, Bucket>()
|
|
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 — in the editor, flipping scenes
|
|
* is a navigation gesture and shouldn't force play. The fullscreen
|
|
* preview takes care of auto-playing on its own (the activeSceneId
|
|
* watcher re-mounts the preview player with play enabled). */
|
|
switchScene(id: string): 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
|
|
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<string, string>()
|
|
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
|
|
}
|
|
},
|
|
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
|
|
},
|
|
/** 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
|
|
}
|
|
},
|
|
addTrackGroup(name?: string): TrackGroup {
|
|
const group: TrackGroup = {
|
|
id: uid(),
|
|
name: name?.trim() || 'Group',
|
|
childIds: [],
|
|
}
|
|
;(this.scene.groups ||= []).push(group)
|
|
;(this.scene.trackOrder ||= []).unshift(group.id)
|
|
return group
|
|
},
|
|
/** 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.
|
|
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
|
|
}
|
|
},
|
|
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
|
|
},
|
|
pause() {
|
|
this.playing = 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.
|
|
const frames = scriptFramesInRange(this.scene.scriptFrames, prev, landed)
|
|
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) {
|
|
self.switchScene(sceneId)
|
|
},
|
|
warn(msg: string) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[ruwio]', msg)
|
|
},
|
|
}
|
|
},
|
|
},
|
|
})
|