This commit is contained in:
th committed 2026-04-19 21:13:53 +02:00
1 parent cc6b11e61d
commit b977ee35a8
11 files changed
+1641 -310

No files matched your search

+231 -33
View File
@@ -1,5 +1,15 @@
import { defineStore } from 'pinia'
import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } from '@/types/scene'
import type {
AnimatableProps,
Asset,
Clip,
ClipInstance,
ClipTrack,
Easing,
Keyframe,
Layer,
Scene,
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
@@ -14,6 +24,7 @@ interface State {
selectedLayerId: string | null
playhead: number
playing: boolean
activeClipId: string | null
}
function allKeyframes(layer: Layer): Keyframe[] {
@@ -90,39 +101,86 @@ function hasKeys(obj: Partial<AnimatableProps>): boolean {
return false
}
function normalizeScene(scene: Scene): Scene {
scene.clips ||= []
scene.clipTracks ||= []
scene.clipInstances ||= []
if (!scene.trackOrder) {
scene.trackOrder = []
for (const l of scene.layers) scene.trackOrder.push(l.id)
for (const t of scene.clipTracks) scene.trackOrder.push(t.id)
} else {
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)
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
for (const l of scene.layers) if (!scene.trackOrder.includes(l.id)) scene.trackOrder.unshift(l.id)
for (const t of scene.clipTracks) if (!scene.trackOrder.includes(t.id)) scene.trackOrder.push(t.id)
}
return scene
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: {
scene: normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
},
}),
selectedLayerId: null,
playhead: 0,
playing: false,
activeClipId: null,
}),
getters: {
lastKeyframeTime(state): number {
let max = 0
for (const layer of state.scene.layers) {
for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time
}
}
return Math.min(state.scene.duration, max)
activeClip(state): Clip | null {
if (!state.activeClipId) return null
const clips = state.scene.clips ?? []
return clips.find((c) => c.id === state.activeClipId) ?? null
},
effectiveEnd(state): number {
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 state.scene.layers) {
for (const layer of this.activeLayers) {
for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return state.scene.duration
return Math.min(max, state.scene.duration)
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: {
@@ -163,16 +221,25 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = 0
}
}
this.scene.layers.unshift(layer)
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) {
this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
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.scene.layers
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)
@@ -180,15 +247,30 @@ export const useSceneStore = defineStore('scene', {
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.scene.duration, t))
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
},
setDuration(seconds: number) {
this.scene.duration = Math.max(0.1, seconds)
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
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))
@@ -201,7 +283,7 @@ export const useSceneStore = defineStore('scene', {
this.scene.canvas.background = color
},
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.scene.layers.find((l) => l.id === layerId)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (!layer.separated) {
const snapshot = sampleLayer(layer, time)
@@ -224,7 +306,7 @@ export const useSceneStore = defineStore('scene', {
}
},
removeKeyframeAt(layerId: string, time: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
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
@@ -233,7 +315,7 @@ export const useSceneStore = defineStore('scene', {
if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes)
},
removeKeyframe(layerId: string, keyframeId: string) {
const layer = this.scene.layers.find((l) => l.id === layerId)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
@@ -241,13 +323,13 @@ export const useSceneStore = defineStore('scene', {
if (idx >= 0) found.arr.splice(idx, 1)
},
setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) {
const layer = this.scene.layers.find((l) => l.id === layerId)
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.scene.layers.find((l) => l.id === layerId)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
@@ -255,11 +337,11 @@ export const useSceneStore = defineStore('scene', {
else delete found.kf.hold
},
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
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.scene.duration, newTime))
found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime))
sortKfs(found.arr)
},
shiftLayerPivot(
@@ -269,7 +351,7 @@ export const useSceneStore = defineStore('scene', {
dLocalX: number,
dLocalY: number,
) {
const layer = this.scene.layers.find((l) => l.id === layerId)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
layer.offsetX = (layer.offsetX ?? 0) + dLocalX
layer.offsetY = (layer.offsetY ?? 0) + dLocalY
@@ -286,14 +368,14 @@ export const useSceneStore = defineStore('scene', {
shift(layer.positionKeyframes)
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.scene.layers.find((l) => l.id === layerId)
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.scene.duration
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
@@ -315,7 +397,7 @@ export const useSceneStore = defineStore('scene', {
return newKf.id
},
setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.scene.layers.find((l) => l.id === layerId)
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (!!layer.separated === separated) return
if (separated) {
@@ -381,7 +463,123 @@ export const useSceneStore = defineStore('scene', {
}
},
loadScene(scene: Scene) {
this.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)
},
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