- Clipart library: new left-edge drawer (5px idle, expands on hover, 400px when open). User picks a local folder via the File System Access API in settings; directory handle is persisted in IndexedDB so the library survives reloads. Dragging a thumbnail onto the stage creates an image asset + layer at the drop point. - Animation paths on the stage: selecting a keyframe draws the tween path from the previous keyframe to the selected one as a solid polyline with per-frame draggable dots. All other segments on the same position-track render as dashed ghost lines so the full curve progression is visible. - Path dots grow on hover (2.5 → 6 px), show a grab cursor, and have a wider hit area so they're easier to grab. - Bezier-curved position interpolation: keyframes gain optional ctrlX/ctrlY fields. When set, x/y tween along a quadratic bezier through the control point instead of a straight line. Dragging a path dot solves for the control so the curve passes through the drag point at its frame progress. Mirrored across all four render pipelines. - Tests: new suites for polygon actions, freeform drawing lifecycle, polygon + stroke rendering (15 new tests). Fixed the mask-render tests that relied on a default-transparent canvas background. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1074 lines
37 KiB
TypeScript
1074 lines
37 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import type {
|
|
AnimatableProps,
|
|
Asset,
|
|
Clip,
|
|
ClipInstance,
|
|
ClipTrack,
|
|
Easing,
|
|
Keyframe,
|
|
Layer,
|
|
MaskMode,
|
|
Scene,
|
|
TrackGroup,
|
|
} from '@/types/scene'
|
|
import { DEFAULT_BASE_PROPS } from '@/types/scene'
|
|
import { sampleLayer } from '@/lib/interpolate'
|
|
|
|
const uid = () => Math.random().toString(36).slice(2, 10)
|
|
|
|
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
|
|
|
|
const KEYFRAME_EPSILON = 1e-3
|
|
|
|
interface State {
|
|
scene: Scene
|
|
selectedLayerId: string | null
|
|
playhead: number
|
|
playing: boolean
|
|
activeClipId: string | null
|
|
drawingMode: 'freeform' | null
|
|
drawingAssetId: string | null
|
|
drawingLayerId: string | null
|
|
}
|
|
|
|
function allKeyframes(layer: Layer): Keyframe[] {
|
|
const out: Keyframe[] = [...layer.keyframes]
|
|
if (layer.positionKeyframes) out.push(...layer.positionKeyframes)
|
|
if (layer.rotationKeyframes) out.push(...layer.rotationKeyframes)
|
|
return out
|
|
}
|
|
|
|
function findKf(
|
|
layer: Layer,
|
|
keyframeId: string,
|
|
): { kf: Keyframe; arr: Keyframe[] } | null {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
const kf = arr.find((k) => k.id === keyframeId)
|
|
if (kf) return { kf, arr }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function sortKfs(arr: Keyframe[]) {
|
|
arr.sort((a, b) => a.time - b.time)
|
|
}
|
|
|
|
function upsertInto(
|
|
arr: Keyframe[],
|
|
time: number,
|
|
props: Partial<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
|
|
}
|
|
|
|
function normalizeScene(scene: Scene): Scene {
|
|
scene.clips ||= []
|
|
scene.clipTracks ||= []
|
|
scene.clipInstances ||= []
|
|
scene.groups ||= []
|
|
|
|
const known = new Set<string>()
|
|
for (const l of scene.layers) known.add(l.id)
|
|
for (const t of scene.clipTracks) known.add(t.id)
|
|
for (const g of scene.groups) known.add(g.id)
|
|
|
|
// Cleanup: group childIds should only reference existing layers/clipTracks
|
|
const layerAndClipIds = new Set<string>()
|
|
for (const l of scene.layers) layerAndClipIds.add(l.id)
|
|
for (const t of scene.clipTracks) layerAndClipIds.add(t.id)
|
|
for (const g of scene.groups) {
|
|
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
|
|
}
|
|
// Dedupe: any given track can only be in one group
|
|
const inGroup = new Set<string>()
|
|
for (const g of scene.groups) {
|
|
g.childIds = g.childIds.filter((id) => {
|
|
if (inGroup.has(id)) return false
|
|
inGroup.add(id)
|
|
return true
|
|
})
|
|
}
|
|
|
|
if (!scene.trackOrder) scene.trackOrder = []
|
|
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
|
|
|
|
for (const l of scene.layers) {
|
|
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
|
|
scene.trackOrder.unshift(l.id)
|
|
}
|
|
}
|
|
for (const t of scene.clipTracks) {
|
|
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
|
|
scene.trackOrder.push(t.id)
|
|
}
|
|
}
|
|
for (const g of scene.groups) {
|
|
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
|
|
}
|
|
return scene
|
|
}
|
|
|
|
|
|
export const useSceneStore = defineStore('scene', {
|
|
state: (): State => ({
|
|
scene: normalizeScene({
|
|
version: 1,
|
|
canvas: { width: 1280, height: 720, background: '#ffffff' },
|
|
duration: 10,
|
|
fps: 60,
|
|
assets: [],
|
|
layers: [],
|
|
}),
|
|
selectedLayerId: null,
|
|
playhead: 0,
|
|
playing: false,
|
|
activeClipId: null,
|
|
drawingMode: null,
|
|
drawingAssetId: null,
|
|
drawingLayerId: null,
|
|
}),
|
|
getters: {
|
|
activeClip(state): Clip | null {
|
|
if (!state.activeClipId) return null
|
|
const clips = state.scene.clips ?? []
|
|
return clips.find((c) => c.id === state.activeClipId) ?? null
|
|
},
|
|
activeLayers(): Layer[] {
|
|
const clip = this.activeClip
|
|
return clip ? clip.layers : this.scene.layers
|
|
},
|
|
activeDuration(): number {
|
|
const clip = this.activeClip
|
|
return clip ? clip.duration : this.scene.duration
|
|
},
|
|
lastKeyframeTime(): number {
|
|
let max = 0
|
|
for (const layer of this.activeLayers) {
|
|
for (const kf of allKeyframes(layer)) {
|
|
if (kf.time > max) max = kf.time
|
|
}
|
|
}
|
|
return Math.min(this.activeDuration, max)
|
|
},
|
|
effectiveEnd(): number {
|
|
let max = 0
|
|
for (const layer of this.activeLayers) {
|
|
for (const kf of allKeyframes(layer)) {
|
|
if (kf.time > max) max = kf.time
|
|
}
|
|
}
|
|
if (!this.activeClipId) {
|
|
const clipById = new Map<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)
|
|
},
|
|
},
|
|
actions: {
|
|
addAsset(asset: AssetInit): Asset {
|
|
const created = { ...asset, id: uid() } as Asset
|
|
this.scene.assets.push(created)
|
|
return created
|
|
},
|
|
addLayer(assetId: string, name?: string): Layer {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
const base: AnimatableProps = {
|
|
...DEFAULT_BASE_PROPS,
|
|
x: this.scene.canvas.width / 2,
|
|
y: this.scene.canvas.height / 2,
|
|
}
|
|
if (asset && 'fill' in asset) base.fill = asset.fill
|
|
const layer: Layer = {
|
|
id: uid(),
|
|
name: name ?? asset?.name ?? 'Layer',
|
|
assetId,
|
|
visible: true,
|
|
base,
|
|
keyframes: [
|
|
{
|
|
id: uid(),
|
|
time: 0,
|
|
props: { ...base },
|
|
easing: 'linear',
|
|
},
|
|
],
|
|
}
|
|
if (asset) {
|
|
if (asset.kind === 'rect' || asset.kind === 'image') {
|
|
layer.offsetX = asset.width / 2
|
|
layer.offsetY = asset.height / 2
|
|
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
|
|
layer.offsetX = 0
|
|
layer.offsetY = 0
|
|
}
|
|
if (asset.kind === 'polygon') {
|
|
layer.keyframes[0].props = {
|
|
...layer.keyframes[0].props,
|
|
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
|
|
}
|
|
}
|
|
}
|
|
this.activeLayers.unshift(layer)
|
|
if (!this.activeClipId) {
|
|
const order = (this.scene.trackOrder ||= [])
|
|
if (!order.includes(layer.id)) order.unshift(layer.id)
|
|
}
|
|
this.selectedLayerId = layer.id
|
|
return layer
|
|
},
|
|
removeLayer(id: string) {
|
|
const layers = this.activeLayers
|
|
const idx = layers.findIndex((l) => l.id === id)
|
|
if (idx >= 0) layers.splice(idx, 1)
|
|
if (!this.activeClipId && this.scene.trackOrder) {
|
|
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
|
}
|
|
if (this.selectedLayerId === id) this.selectedLayerId = null
|
|
},
|
|
moveLayer(fromIndex: number, toIndex: number) {
|
|
const arr = this.activeLayers
|
|
if (fromIndex < 0 || fromIndex >= arr.length) return
|
|
if (toIndex < 0 || toIndex > arr.length) return
|
|
const clamped = Math.min(toIndex, arr.length - 1)
|
|
if (fromIndex === clamped) return
|
|
const [moved] = arr.splice(fromIndex, 1)
|
|
arr.splice(clamped, 0, moved)
|
|
},
|
|
moveTrack(fromIndex: number, toIndex: number) {
|
|
const order = this.scene.trackOrder ?? []
|
|
if (fromIndex < 0 || fromIndex >= order.length) return
|
|
if (toIndex < 0 || toIndex > order.length) return
|
|
const clamped = Math.min(toIndex, order.length - 1)
|
|
if (fromIndex === clamped) return
|
|
const [moved] = order.splice(fromIndex, 1)
|
|
order.splice(clamped, 0, moved)
|
|
},
|
|
selectLayer(id: string | null) {
|
|
this.selectedLayerId = id
|
|
},
|
|
setPlayhead(t: number) {
|
|
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
|
|
},
|
|
setDuration(seconds: number) {
|
|
const clip = this.activeClip
|
|
if (clip) {
|
|
clip.duration = Math.max(0.1, seconds)
|
|
if (this.playhead > clip.duration) this.playhead = clip.duration
|
|
} else {
|
|
this.scene.duration = Math.max(0.1, seconds)
|
|
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
|
|
}
|
|
},
|
|
setFps(fps: number) {
|
|
this.scene.fps = Math.max(1, Math.round(fps))
|
|
},
|
|
setCanvasSize(width: number, height: number) {
|
|
this.scene.canvas.width = Math.max(1, Math.round(width))
|
|
this.scene.canvas.height = Math.max(1, Math.round(height))
|
|
},
|
|
setCanvasBackground(color: string) {
|
|
this.scene.canvas.background = color
|
|
},
|
|
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
if (!layer.separated) {
|
|
const snapshot = sampleLayer(layer, time)
|
|
upsertInto(layer.keyframes, time, props, snapshot)
|
|
return
|
|
}
|
|
const posProps = pickProps(props, POSITION_KEYS)
|
|
const rotProps = pickProps(props, ROTATION_KEYS)
|
|
const otherProps = omitProps(props, new Set(['x', 'y', 'rotation']))
|
|
if (hasKeys(posProps)) {
|
|
const arr = (layer.positionKeyframes ||= [])
|
|
upsertInto(arr, time, posProps, null)
|
|
}
|
|
if (hasKeys(rotProps)) {
|
|
const arr = (layer.rotationKeyframes ||= [])
|
|
upsertInto(arr, time, rotProps, null)
|
|
}
|
|
if (hasKeys(otherProps)) {
|
|
upsertInto(layer.keyframes, time, otherProps, null)
|
|
}
|
|
},
|
|
removeKeyframeAt(layerId: string, time: number) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
const filter = (kfs?: Keyframe[]) =>
|
|
kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs
|
|
layer.keyframes = filter(layer.keyframes) ?? []
|
|
if (layer.positionKeyframes) layer.positionKeyframes = filter(layer.positionKeyframes)
|
|
if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes)
|
|
},
|
|
removeKeyframe(layerId: string, keyframeId: string) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
const found = findKf(layer, keyframeId)
|
|
if (!found) return
|
|
const idx = found.arr.indexOf(found.kf)
|
|
if (idx >= 0) found.arr.splice(idx, 1)
|
|
},
|
|
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
|
|
found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime))
|
|
sortKfs(found.arr)
|
|
},
|
|
shiftLayerPivot(
|
|
layerId: string,
|
|
dWorldX: number,
|
|
dWorldY: number,
|
|
dLocalX: number,
|
|
dLocalY: number,
|
|
) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
layer.offsetX = (layer.offsetX ?? 0) + dLocalX
|
|
layer.offsetY = (layer.offsetY ?? 0) + dLocalY
|
|
layer.base.x += dWorldX
|
|
layer.base.y += dWorldY
|
|
const shift = (kfs?: Keyframe[]) => {
|
|
if (!kfs) return
|
|
for (const kf of kfs) {
|
|
if (kf.props.x !== undefined) kf.props.x += dWorldX
|
|
if (kf.props.y !== undefined) kf.props.y += dWorldY
|
|
}
|
|
}
|
|
shift(layer.keyframes)
|
|
shift(layer.positionKeyframes)
|
|
},
|
|
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return null
|
|
const found = findKf(layer, keyframeId)
|
|
if (!found) return null
|
|
const { kf: source, arr } = found
|
|
const fps = this.scene.fps > 0 ? this.scene.fps : 60
|
|
const offset = Math.max(3 / fps, 0.1)
|
|
const duration = this.activeDuration
|
|
const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
|
|
let target = source.time + offset
|
|
while (occupied(target) && target < duration) target += offset
|
|
if (target > duration) target = duration
|
|
if (occupied(target)) {
|
|
target = source.time - offset
|
|
while (occupied(target) && target > 0) target -= offset
|
|
if (target < 0) target = 0
|
|
if (occupied(target)) return null
|
|
}
|
|
const newKf: Keyframe = {
|
|
id: uid(),
|
|
time: target,
|
|
props: { ...source.props },
|
|
easing: source.easing,
|
|
}
|
|
arr.push(newKf)
|
|
sortKfs(arr)
|
|
return newKf.id
|
|
},
|
|
setTrackColor(trackId: string, color: string | null) {
|
|
const layer = this.activeLayers.find((l) => l.id === trackId)
|
|
if (layer) {
|
|
if (color === null) delete layer.color
|
|
else layer.color = color
|
|
return
|
|
}
|
|
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
|
|
if (clipTrack) {
|
|
if (color === null) delete clipTrack.color
|
|
else clipTrack.color = color
|
|
return
|
|
}
|
|
const group = this.scene.groups?.find((g) => g.id === trackId)
|
|
if (group) {
|
|
if (color === null) delete group.color
|
|
else group.color = color
|
|
}
|
|
},
|
|
shiftKeyframesPosition(
|
|
refs: Array<{ layerId: string; kind: 'main' | 'position' | 'rotation'; keyframeId: string }>,
|
|
dx: number,
|
|
dy: number,
|
|
) {
|
|
for (const ref of refs) {
|
|
if (ref.kind === 'rotation') continue
|
|
const layer = this.activeLayers.find((l) => l.id === ref.layerId)
|
|
if (!layer) continue
|
|
const arr =
|
|
ref.kind === 'position' ? layer.positionKeyframes : layer.keyframes
|
|
const kf = arr?.find((k) => k.id === ref.keyframeId)
|
|
if (!kf) continue
|
|
const sampled = sampleLayer(layer, kf.time)
|
|
const curX = kf.props.x ?? sampled.x
|
|
const curY = kf.props.y ?? sampled.y
|
|
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
|
|
}
|
|
},
|
|
movePolygonVertex(layerId: string, pointIndex: number, x: number, y: number, time?: number) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
|
const t = time ?? this.playhead
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (kf.props.points) continue
|
|
const samp = sampleLayer(layer, kf.time)
|
|
const basePts = samp.points ?? asset.points
|
|
kf.props = {
|
|
...kf.props,
|
|
points: basePts.map((p) => ({ x: p.x, y: p.y })),
|
|
}
|
|
}
|
|
}
|
|
const sampled = sampleLayer(layer, t)
|
|
const current = sampled.points ?? asset.points
|
|
const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y }))
|
|
this.upsertKeyframe(layerId, t, { points: next })
|
|
},
|
|
addPolygonPoint(assetId: string, atIndex: number, point: { x: number; y: number }) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
|
|
asset.points.splice(insertAt, 0, { x: point.x, y: point.y })
|
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
|
for (const layer of layersUsing) {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (!kf.props.points) continue
|
|
const pts = kf.props.points.slice()
|
|
pts.splice(insertAt, 0, { x: point.x, y: point.y })
|
|
kf.props = { ...kf.props, points: pts }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
setPolygonPointCount(assetId: string, targetCount: number) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
const target = Math.max(3, Math.min(64, Math.round(targetCount)))
|
|
if (asset.points.length === target) return
|
|
const resize = (pts: { x: number; y: number }[]) => {
|
|
const arr = pts.map((p) => ({ x: p.x, y: p.y }))
|
|
while (arr.length > target && arr.length > 3) arr.pop()
|
|
while (arr.length < target) {
|
|
const last = arr[arr.length - 1]
|
|
const first = arr[0]
|
|
arr.push({ x: (last.x + first.x) / 2, y: (last.y + first.y) / 2 })
|
|
}
|
|
return arr
|
|
}
|
|
asset.points = resize(asset.points)
|
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
|
for (const layer of layersUsing) {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (!kf.props.points) continue
|
|
kf.props = { ...kf.props, points: resize(kf.props.points) }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
removePolygonPoint(assetId: string, pointIndex: number) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
if (asset.points.length <= 3) return
|
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
|
asset.points.splice(pointIndex, 1)
|
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
|
for (const layer of layersUsing) {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (!kf.props.points) continue
|
|
const pts = kf.props.points.slice()
|
|
if (pointIndex < pts.length) pts.splice(pointIndex, 1)
|
|
kf.props = { ...kf.props, points: pts }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
startFreeformDrawing() {
|
|
if (this.drawingMode) return
|
|
// If the selected layer is already an open polygon, resume drawing on it
|
|
const selId = this.selectedLayerId
|
|
if (selId) {
|
|
const layer = this.activeLayers.find((l) => l.id === selId)
|
|
const asset = layer ? this.scene.assets.find((a) => a.id === layer.assetId) : null
|
|
if (layer && asset && asset.kind === 'polygon' && asset.closed === false) {
|
|
this.drawingMode = 'freeform'
|
|
this.drawingAssetId = asset.id
|
|
this.drawingLayerId = layer.id
|
|
return
|
|
}
|
|
}
|
|
const asset = this.addAsset({
|
|
kind: 'polygon',
|
|
name: 'Freeform',
|
|
points: [],
|
|
fill: 'transparent',
|
|
closed: false,
|
|
stroke: '#000000',
|
|
strokeWidth: 2,
|
|
}) as Extract<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.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
|
}
|
|
},
|
|
cancelDrawing() {
|
|
const layerId = this.drawingLayerId
|
|
const assetId = this.drawingAssetId
|
|
this.drawingMode = null
|
|
this.drawingAssetId = null
|
|
this.drawingLayerId = null
|
|
if (layerId) this.removeLayer(layerId)
|
|
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
|
},
|
|
reversePolygonPoints(assetId: string) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
asset.points.reverse()
|
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
|
for (const layer of layersUsing) {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (!kf.props.points) continue
|
|
kf.props = { ...kf.props, points: [...kf.props.points].reverse() }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
setPolygonClosed(assetId: string, closed: boolean) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
asset.closed = closed
|
|
},
|
|
insertPolygonPointOnEdge(assetId: string, atIndex: number, tOnEdge: number) {
|
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
|
if (!asset || asset.kind !== 'polygon') return
|
|
const n = asset.points.length
|
|
if (atIndex < 1 || atIndex > n) return
|
|
const prev = asset.points[atIndex - 1]
|
|
const next = asset.points[atIndex % n]
|
|
asset.points.splice(atIndex, 0, {
|
|
x: prev.x + (next.x - prev.x) * tOnEdge,
|
|
y: prev.y + (next.y - prev.y) * tOnEdge,
|
|
})
|
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
|
for (const layer of layersUsing) {
|
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
|
if (!arr) continue
|
|
for (const kf of arr) {
|
|
if (!kf.props.points) continue
|
|
const pts = kf.props.points
|
|
const p = pts[atIndex - 1]
|
|
const q = pts[atIndex % pts.length]
|
|
const next = pts.slice()
|
|
next.splice(atIndex, 0, {
|
|
x: p.x + (q.x - p.x) * tOnEdge,
|
|
y: p.y + (q.y - p.y) * tOnEdge,
|
|
})
|
|
kf.props = { ...kf.props, points: next }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
if (isMask) {
|
|
layer.isMask = true
|
|
if (mode) layer.maskMode = mode
|
|
else if (!layer.maskMode) layer.maskMode = 'positive'
|
|
} else {
|
|
delete layer.isMask
|
|
delete layer.maskMode
|
|
}
|
|
},
|
|
setLayerMaskMode(layerId: string, mode: MaskMode) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
layer.maskMode = mode
|
|
},
|
|
setTrackLocked(trackId: string, locked: boolean) {
|
|
const layer = this.activeLayers.find((l) => l.id === trackId)
|
|
if (layer) {
|
|
if (locked) layer.locked = true
|
|
else delete layer.locked
|
|
return
|
|
}
|
|
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
|
|
if (clipTrack) {
|
|
if (locked) clipTrack.locked = true
|
|
else delete clipTrack.locked
|
|
}
|
|
},
|
|
setLayerSeparated(layerId: string, separated: boolean) {
|
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
if (!!layer.separated === separated) return
|
|
if (separated) {
|
|
const posKfs: Keyframe[] = []
|
|
const rotKfs: Keyframe[] = []
|
|
const remaining: Keyframe[] = []
|
|
for (const kf of layer.keyframes) {
|
|
const posProps = pickProps(kf.props, POSITION_KEYS)
|
|
const rotProps = pickProps(kf.props, ROTATION_KEYS)
|
|
const rest = omitProps(kf.props, new Set(['x', 'y', 'rotation']))
|
|
if (hasKeys(posProps)) {
|
|
posKfs.push({ id: uid(), time: kf.time, props: posProps, easing: kf.easing })
|
|
}
|
|
if (hasKeys(rotProps)) {
|
|
rotKfs.push({ id: uid(), time: kf.time, props: rotProps, easing: kf.easing })
|
|
}
|
|
if (hasKeys(rest)) {
|
|
remaining.push({ id: uid(), time: kf.time, props: rest, easing: kf.easing })
|
|
}
|
|
}
|
|
sortKfs(posKfs)
|
|
sortKfs(rotKfs)
|
|
sortKfs(remaining)
|
|
layer.positionKeyframes = posKfs
|
|
layer.rotationKeyframes = rotKfs
|
|
layer.keyframes = remaining
|
|
layer.separated = true
|
|
} else {
|
|
interface Bucket {
|
|
props: Partial<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
|
|
}
|
|
},
|
|
loadScene(scene: Scene) {
|
|
this.scene = normalizeScene(scene)
|
|
this.selectedLayerId = null
|
|
this.playhead = 0
|
|
this.playing = false
|
|
this.activeClipId = null
|
|
},
|
|
addClip(name?: string): Clip {
|
|
const clip: Clip = {
|
|
id: uid(),
|
|
name: name?.trim() || 'Clip',
|
|
duration: Math.max(1, Math.min(5, this.scene.duration)),
|
|
layers: [],
|
|
}
|
|
;(this.scene.clips ||= []).push(clip)
|
|
if (!this.scene.clipTracks?.length) this.addClipTrack()
|
|
return clip
|
|
},
|
|
removeClip(id: string) {
|
|
if (this.scene.clips) {
|
|
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
|
|
}
|
|
if (this.scene.clipInstances) {
|
|
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id)
|
|
}
|
|
if (this.activeClipId === id) {
|
|
this.activeClipId = null
|
|
this.selectedLayerId = null
|
|
this.playhead = 0
|
|
}
|
|
},
|
|
renameClip(id: string, name: string) {
|
|
const clip = this.scene.clips?.find((c) => c.id === id)
|
|
if (clip) clip.name = name.trim() || clip.name
|
|
},
|
|
addClipTrack(name?: string): ClipTrack {
|
|
const track: ClipTrack = { id: uid(), name }
|
|
;(this.scene.clipTracks ||= []).push(track)
|
|
;(this.scene.trackOrder ||= []).push(track.id)
|
|
return track
|
|
},
|
|
removeClipTrack(id: string) {
|
|
if (this.scene.clipTracks) {
|
|
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)
|
|
}
|
|
if (this.scene.clipInstances) {
|
|
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id)
|
|
}
|
|
if (this.scene.trackOrder) {
|
|
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
|
}
|
|
},
|
|
addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null {
|
|
const clip = this.scene.clips?.find((c) => c.id === clipId)
|
|
if (!clip) return null
|
|
const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
|
if (!track) return null
|
|
const instance: ClipInstance = {
|
|
id: uid(),
|
|
clipId,
|
|
startTime: Math.max(0, startTime),
|
|
clipTrackId,
|
|
}
|
|
;(this.scene.clipInstances ||= []).push(instance)
|
|
return instance
|
|
},
|
|
moveClipInstance(id: string, startTime: number, clipTrackId?: string) {
|
|
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
|
|
if (!inst) return
|
|
inst.startTime = Math.max(0, startTime)
|
|
if (clipTrackId) {
|
|
const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
|
if (track) inst.clipTrackId = clipTrackId
|
|
}
|
|
},
|
|
trimClipInstance(
|
|
id: string,
|
|
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
|
|
) {
|
|
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
|
|
if (!inst) return
|
|
const clip = this.scene.clips?.find((c) => c.id === inst.clipId)
|
|
if (!clip) return
|
|
const MIN_VISIBLE = 0.05
|
|
let trimStart = patch.trimStart ?? inst.trimStart ?? 0
|
|
let trimEnd = patch.trimEnd ?? inst.trimEnd ?? 0
|
|
let startTime = patch.startTime ?? inst.startTime
|
|
trimStart = Math.max(0, trimStart)
|
|
trimEnd = Math.max(0, trimEnd)
|
|
const maxBudget = clip.duration - MIN_VISIBLE
|
|
if (trimStart + trimEnd > maxBudget) {
|
|
if (patch.trimStart !== undefined && patch.trimEnd === undefined) {
|
|
trimStart = Math.max(0, maxBudget - trimEnd)
|
|
} else if (patch.trimEnd !== undefined && patch.trimStart === undefined) {
|
|
trimEnd = Math.max(0, maxBudget - trimStart)
|
|
} else {
|
|
trimStart = Math.min(trimStart, maxBudget)
|
|
trimEnd = Math.max(0, maxBudget - trimStart)
|
|
}
|
|
}
|
|
inst.trimStart = trimStart
|
|
inst.trimEnd = trimEnd
|
|
inst.startTime = Math.max(0, startTime)
|
|
},
|
|
removeClipInstance(id: string) {
|
|
if (!this.scene.clipInstances) return
|
|
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
|
|
},
|
|
addTrackGroup(name?: string): TrackGroup {
|
|
const group: TrackGroup = {
|
|
id: uid(),
|
|
name: name?.trim() || 'Group',
|
|
childIds: [],
|
|
}
|
|
;(this.scene.groups ||= []).push(group)
|
|
;(this.scene.trackOrder ||= []).unshift(group.id)
|
|
return group
|
|
},
|
|
removeTrackGroup(id: string) {
|
|
const group = this.scene.groups?.find((g) => g.id === id)
|
|
if (!group) return
|
|
const order = this.scene.trackOrder ?? []
|
|
const idx = order.indexOf(id)
|
|
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
|
|
this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id)
|
|
},
|
|
renameGroup(id: string, name: string) {
|
|
const group = this.scene.groups?.find((g) => g.id === id)
|
|
if (!group) return
|
|
const n = name.trim()
|
|
if (n) group.name = n
|
|
},
|
|
toggleGroupCollapsed(id: string) {
|
|
const group = this.scene.groups?.find((g) => g.id === id)
|
|
if (!group) return
|
|
group.collapsed = !group.collapsed
|
|
},
|
|
moveTrackIntoGroup(trackId: string, groupId: string, atIndex?: number) {
|
|
const target = this.scene.groups?.find((g) => g.id === groupId)
|
|
if (!target) return
|
|
if (trackId === groupId) return
|
|
// Remove from top-level trackOrder
|
|
const order = this.scene.trackOrder ?? []
|
|
const ti = order.indexOf(trackId)
|
|
if (ti >= 0) order.splice(ti, 1)
|
|
// Remove from any other group's childIds
|
|
for (const g of this.scene.groups ?? []) {
|
|
const gi = g.childIds.indexOf(trackId)
|
|
if (gi >= 0) g.childIds.splice(gi, 1)
|
|
}
|
|
const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length))
|
|
target.childIds.splice(at, 0, trackId)
|
|
},
|
|
moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) {
|
|
for (const g of this.scene.groups ?? []) {
|
|
const gi = g.childIds.indexOf(trackId)
|
|
if (gi >= 0) g.childIds.splice(gi, 1)
|
|
}
|
|
const order = (this.scene.trackOrder ||= [])
|
|
if (!order.includes(trackId)) {
|
|
const at = toTopLevelIndex === undefined ? 0 : Math.max(0, Math.min(toTopLevelIndex, order.length))
|
|
order.splice(at, 0, trackId)
|
|
}
|
|
},
|
|
enterClip(id: string) {
|
|
const clip = this.scene.clips?.find((c) => c.id === id)
|
|
if (!clip) return
|
|
this.activeClipId = id
|
|
this.selectedLayerId = null
|
|
this.playhead = 0
|
|
this.playing = false
|
|
},
|
|
exitClip() {
|
|
this.activeClipId = null
|
|
this.selectedLayerId = null
|
|
this.playhead = 0
|
|
this.playing = false
|
|
},
|
|
play() {
|
|
if (this.playing) return
|
|
this.playing = true
|
|
},
|
|
pause() {
|
|
this.playing = false
|
|
},
|
|
togglePlay() {
|
|
if (this.playing) this.pause()
|
|
else {
|
|
const end = this.effectiveEnd
|
|
if (end <= 0) return
|
|
if (this.playhead >= end) this.playhead = 0
|
|
this.play()
|
|
}
|
|
},
|
|
tick(deltaSeconds: number) {
|
|
if (!this.playing) return
|
|
const end = this.effectiveEnd
|
|
const next = this.playhead + deltaSeconds
|
|
if (next >= end) {
|
|
this.playhead = end
|
|
this.playing = false
|
|
} else {
|
|
this.playhead = next
|
|
}
|
|
},
|
|
},
|
|
})
|