This commit is contained in:
th committed 2026-04-19 17:53:36 +02:00
commit c6863f2bfc
34 files changed
+6909

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } 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
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: {
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
},
selectedLayerId: null,
playhead: 0,
playing: false,
}),
getters: {
lastKeyframeTime(state): number {
let max = 0
for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
return Math.min(state.scene.duration, max)
},
effectiveEnd(state): number {
let max = 0
for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return state.scene.duration
return Math.min(max, state.scene.duration)
},
},
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 && (asset.kind === 'rect' || asset.kind === 'image')) {
layer.offsetX = asset.width / 2
layer.offsetY = asset.height / 2
}
this.scene.layers.push(layer)
this.selectedLayerId = layer.id
return layer
},
removeLayer(id: string) {
this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
if (this.selectedLayerId === id) this.selectedLayerId = null
},
selectLayer(id: string | null) {
this.selectedLayerId = id
},
setPlayhead(t: number) {
this.playhead = Math.max(0, Math.min(this.scene.duration, t))
},
setDuration(seconds: number) {
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.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (existing) {
existing.props = { ...existing.props, ...props }
return
}
const snapshot = sampleLayer(layer, time)
const kf: Keyframe = {
id: uid(),
time,
props: { ...snapshot, ...props },
easing: 'linear',
}
layer.keyframes.push(kf)
layer.keyframes.sort((a, b) => a.time - b.time)
},
removeKeyframeAt(layerId: string, time: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON)
},
setKeyframeEasing(layerId: string, time: number, easing: Easing) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (kf) kf.easing = easing
},
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === keyframeId)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
layer.keyframes.sort((a, b) => a.time - b.time)
},
shiftLayerPivot(
layerId: string,
dWorldX: number,
dWorldY: number,
dLocalX: number,
dLocalY: number,
) {
const layer = this.scene.layers.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
for (const kf of layer.keyframes) {
if (kf.props.x !== undefined) kf.props.x += dWorldX
if (kf.props.y !== undefined) kf.props.y += dWorldY
}
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return null
const source = layer.keyframes.find((k) => k.id === keyframeId)
if (!source) return null
const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1)
const duration = this.scene.duration
const occupied = (t: number) =>
layer.keyframes.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,
}
layer.keyframes.push(newKf)
layer.keyframes.sort((a, b) => a.time - b.time)
return newKf.id
},
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
}
},
},
})