0.9.0: scenes — multi-page projects with right-side drawer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
517705bf55
commit
b57994c754
12 files changed
+1193
-176
No files matched your search
+369
-110
@@ -16,7 +16,9 @@ import type {
|
||||
Layer,
|
||||
Marker,
|
||||
MaskMode,
|
||||
Project,
|
||||
Scene,
|
||||
ScenePage,
|
||||
ScriptFrame,
|
||||
ScriptTrack,
|
||||
TrackGroup,
|
||||
@@ -64,7 +66,15 @@ function snapToButtonStateTime(t: number): number {
|
||||
export type ButtonEditMode = 'states' | 'hit'
|
||||
|
||||
interface State {
|
||||
scene: Scene
|
||||
/** 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`. */
|
||||
@@ -184,32 +194,33 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeScene(scene: Scene): Scene {
|
||||
scene.clips ||= []
|
||||
scene.clipTracks ||= []
|
||||
scene.clipInstances ||= []
|
||||
scene.groups ||= []
|
||||
scene.scriptTracks ||= []
|
||||
scene.scriptFrames ||= []
|
||||
scene.markers ||= []
|
||||
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
|
||||
/** 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 ||= []
|
||||
|
||||
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)
|
||||
for (const st of scene.scriptTracks) known.add(st.id)
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
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))
|
||||
}
|
||||
// Dedupe: any given track can only be in one group
|
||||
const inGroup = new Set<string>()
|
||||
for (const g of scene.groups) {
|
||||
for (const g of page.groups) {
|
||||
g.childIds = g.childIds.filter((id) => {
|
||||
if (inGroup.has(id)) return false
|
||||
inGroup.add(id)
|
||||
@@ -217,60 +228,147 @@ function normalizeScene(scene: Scene): Scene {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
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 scene.clipTracks) {
|
||||
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
|
||||
scene.trackOrder.push(t.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 scene.groups) {
|
||||
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
|
||||
for (const g of page.groups) {
|
||||
if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id)
|
||||
}
|
||||
for (const st of scene.scriptTracks) {
|
||||
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id)
|
||||
for (const st of page.scriptTracks) {
|
||||
if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
|
||||
}
|
||||
|
||||
// Migration: scenes saved before `colorOverridden` existed must treat
|
||||
// any track that already carries a `color` as user-set. Without this,
|
||||
// the first subsequent group-color change would silently overwrite
|
||||
// pre-existing per-track colors.
|
||||
for (const l of scene.layers) {
|
||||
for (const l of page.layers) {
|
||||
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true
|
||||
}
|
||||
for (const t of scene.clipTracks) {
|
||||
for (const t of page.clipTracks) {
|
||||
if (t.color != null && t.colorOverridden === undefined) t.colorOverridden = true
|
||||
}
|
||||
for (const st of scene.scriptTracks) {
|
||||
for (const st of page.scriptTracks) {
|
||||
if (st.color != null && st.colorOverridden === undefined) st.colorOverridden = true
|
||||
}
|
||||
for (const g of scene.groups) {
|
||||
for (const g of page.groups) {
|
||||
if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true
|
||||
}
|
||||
return scene
|
||||
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 createBlankScene(): Scene {
|
||||
return normalizeScene({
|
||||
export function createBlankScenePage(name: string, id?: string): ScenePage {
|
||||
return {
|
||||
id: id ?? uid(),
|
||||
name,
|
||||
layers: [],
|
||||
clipInstances: [],
|
||||
clipTracks: [],
|
||||
scriptTracks: [],
|
||||
scriptFrames: [],
|
||||
markers: [],
|
||||
groups: [],
|
||||
trackOrder: [],
|
||||
}
|
||||
}
|
||||
|
||||
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: [],
|
||||
layers: [],
|
||||
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,
|
||||
}
|
||||
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 => ({
|
||||
scene: createBlankScene(),
|
||||
project: createBlankProject(),
|
||||
selectedLayerId: null,
|
||||
selectedLayerIds: [],
|
||||
selectedClipInstanceId: null,
|
||||
@@ -285,14 +383,48 @@ export const useSceneStore = defineStore('scene', {
|
||||
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
|
||||
/** 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]
|
||||
},
|
||||
activeButtonAsset(state): ButtonAsset | null {
|
||||
if (!state.activeButtonAssetId) return null
|
||||
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId)
|
||||
/** 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,
|
||||
}
|
||||
},
|
||||
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[] {
|
||||
@@ -429,8 +561,8 @@ export const useSceneStore = defineStore('scene', {
|
||||
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.activeClipId) {
|
||||
this.activeScene.trackOrder = this.activeScene.trackOrder.filter((x) => x !== id)
|
||||
}
|
||||
if (this.selectedLayerId === id) {
|
||||
this.selectedLayerId = null
|
||||
@@ -500,41 +632,41 @@ export const useSceneStore = defineStore('scene', {
|
||||
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
|
||||
this.project.duration = Math.max(0.1, seconds)
|
||||
if (this.playhead > this.project.duration) this.playhead = this.project.duration
|
||||
}
|
||||
},
|
||||
setFps(fps: number) {
|
||||
this.scene.fps = Math.max(1, Math.round(fps))
|
||||
this.project.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))
|
||||
this.project.canvas.width = Math.max(1, Math.round(width))
|
||||
this.project.canvas.height = Math.max(1, Math.round(height))
|
||||
},
|
||||
setCanvasBackground(color: string) {
|
||||
this.scene.canvas.background = color
|
||||
this.project.canvas.background = color
|
||||
},
|
||||
setBackgroundVideo(video: BackgroundVideo | null) {
|
||||
if (video && video.src) this.scene.backgroundVideo = { ...video }
|
||||
else delete this.scene.backgroundVideo
|
||||
if (video && video.src) this.activeScene.backgroundVideo = { ...video }
|
||||
else delete this.activeScene.backgroundVideo
|
||||
},
|
||||
setPlaybackRange(inTime: number | null, outTime: number | null) {
|
||||
const dur = this.scene.duration
|
||||
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.scene.outTime === undefined) delete this.scene.inTime
|
||||
else this.scene.inTime = v
|
||||
if (v === 0 && this.project.outTime === undefined) delete this.project.inTime
|
||||
else this.project.inTime = v
|
||||
}
|
||||
if (hasOut) {
|
||||
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
|
||||
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
|
||||
else this.scene.outTime = v
|
||||
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.scene.inTime ?? 0
|
||||
const hi = this.scene.outTime ?? dur
|
||||
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
|
||||
},
|
||||
@@ -1043,7 +1175,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
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)
|
||||
this.project.assets = this.project.assets.filter((a) => a.id !== assetId)
|
||||
}
|
||||
},
|
||||
cancelDrawing() {
|
||||
@@ -1053,7 +1185,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.drawingAssetId = null
|
||||
this.drawingLayerId = null
|
||||
if (layerId) this.removeLayer(layerId)
|
||||
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
||||
if (assetId) this.project.assets = this.project.assets.filter((a) => a.id !== assetId)
|
||||
},
|
||||
setPolygonVertexType(
|
||||
assetId: string,
|
||||
@@ -1361,8 +1493,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
return m
|
||||
},
|
||||
removeMarker(id: string) {
|
||||
if (!this.scene.markers) return
|
||||
this.scene.markers = this.scene.markers.filter((m) => m.id !== id)
|
||||
this.activeScene.markers = this.activeScene.markers.filter((m) => m.id !== id)
|
||||
},
|
||||
renameMarker(id: string, name: string): boolean {
|
||||
const markers = this.scene.markers
|
||||
@@ -1398,15 +1529,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
return track
|
||||
},
|
||||
removeScriptTrack(id: string) {
|
||||
if (this.scene.scriptTracks) {
|
||||
this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id)
|
||||
}
|
||||
if (this.scene.scriptFrames) {
|
||||
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.trackId !== id)
|
||||
}
|
||||
if (this.scene.trackOrder) {
|
||||
this.scene.trackOrder = this.scene.trackOrder.filter((tid) => tid !== id)
|
||||
}
|
||||
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)
|
||||
@@ -1433,8 +1559,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
return frame
|
||||
},
|
||||
removeScriptFrame(id: string) {
|
||||
if (!this.scene.scriptFrames) return
|
||||
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id)
|
||||
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)
|
||||
@@ -1627,17 +1752,155 @@ export const useSceneStore = defineStore('scene', {
|
||||
layer.separated = false
|
||||
}
|
||||
},
|
||||
loadScene(scene: Scene) {
|
||||
this.scene = normalizeScene(scene)
|
||||
/** 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. */
|
||||
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
|
||||
this.playing = false
|
||||
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 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.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. Use this from UI
|
||||
* components instead of mutating `project.assets` inline. */
|
||||
removeAsset(id: string) {
|
||||
this.project.assets = this.project.assets.filter((a) => a.id !== id)
|
||||
},
|
||||
|
||||
addClip(name?: string): Clip {
|
||||
const clip: Clip = {
|
||||
id: uid(),
|
||||
@@ -1702,9 +1965,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
// group they were part of. Clearing in one pass keeps Pinia's
|
||||
// reactivity from thrashing on intermediate states.
|
||||
const idSet = new Set(ids)
|
||||
this.scene.layers = this.scene.layers.filter((l) => !idSet.has(l.id))
|
||||
this.scene.trackOrder = order.filter((x) => !idSet.has(x))
|
||||
for (const g of this.scene.groups ?? []) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1733,16 +1997,17 @@ export const useSceneStore = defineStore('scene', {
|
||||
return clip.id
|
||||
},
|
||||
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)
|
||||
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.selectedLayerIds = []
|
||||
this.playhead = 0
|
||||
}
|
||||
},
|
||||
@@ -1851,15 +2116,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
return null
|
||||
},
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -1914,8 +2174,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
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)
|
||||
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
|
||||
@@ -2020,10 +2279,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
removeTrackGroup(id: string) {
|
||||
const group = this.scene.groups?.find((g) => g.id === id)
|
||||
if (!group) return
|
||||
const order = this.scene.trackOrder ?? []
|
||||
const order = this.activeScene.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)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user