From b57994c754d95c8e70fe33ceedb1e927ce785f94 Mon Sep 17 00:00:00 2001 From: th Date: Wed, 22 Apr 2026 07:17:06 +0200 Subject: [PATCH] =?UTF-8?q?0.9.0:=20scenes=20=E2=80=94=20multi-page=20proj?= =?UTF-8?q?ects=20with=20right-side=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/App.vue | 41 +-- src/components/AssetSidebar.vue | 3 +- src/components/SceneDrawer.vue | 367 +++++++++++++++++++++++ src/components/WelcomeDialog.vue | 5 +- src/composables/useHistory.ts | 31 +- src/devtools/tests.ts | 303 +++++++++++++++++-- src/i18n/messages.ts | 21 ++ src/lib/projectFile.ts | 46 ++- src/lib/projectStore.ts | 26 +- src/stores/scene.ts | 479 ++++++++++++++++++++++++------- src/types/scene.ts | 45 +++ 12 files changed, 1193 insertions(+), 176 deletions(-) create mode 100644 src/components/SceneDrawer.vue diff --git a/package.json b/package.json index 8168baa..f1f35a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.8.7", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index bb2f7fb..8d6f616 100644 --- a/src/App.vue +++ b/src/App.vue @@ -21,6 +21,7 @@ import AboutDialog from './components/AboutDialog.vue' import AssetSidebar from './components/AssetSidebar.vue' import CanvasStage from './components/CanvasStage.vue' import ClipartDrawer from './components/ClipartDrawer.vue' +import SceneDrawer from './components/SceneDrawer.vue' import DocumentSettings from './components/DocumentSettings.vue' import ExportDialog from './components/ExportDialog.vue' import FullscreenPreview from './components/FullscreenPreview.vue' @@ -49,7 +50,7 @@ import { } from './lib/projectStore' import { importImageFile } from './lib/fileLoader' import { useClipartStore } from './stores/clipart' -import { createBlankScene, useSceneStore } from './stores/scene' +import { createBlankProject, useSceneStore } from './stores/scene' const settingsOpen = ref(false) const exportOpen = ref(false) @@ -165,7 +166,7 @@ function stop() { } async function saveProject() { - const scene = JSON.parse(JSON.stringify(store.scene)) + const project = JSON.parse(JSON.stringify(store.project)) let name = currentProjectName.value if (!name) { const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() }) @@ -178,7 +179,7 @@ async function saveProject() { if (entered === null) return name = entered.trim() || suggested } - const rec = await saveProjectToStore(scene, { + const rec = await saveProjectToStore(project, { id: currentProjectId.value ?? undefined, name, }) @@ -208,8 +209,8 @@ async function renameProject() { // it. Unsaved projects just get the in-memory name — the next save // picks it up. if (currentProjectId.value) { - const scene = JSON.parse(JSON.stringify(store.scene)) - const rec = await saveProjectToStore(scene, { + const project = JSON.parse(JSON.stringify(store.project)) + const rec = await saveProjectToStore(project, { id: currentProjectId.value, name: trimmed, }) @@ -218,10 +219,10 @@ async function renameProject() { } function exportProjectFile() { - const scene = JSON.parse(JSON.stringify(store.scene)) + const project = JSON.parse(JSON.stringify(store.project)) const base = currentProjectName.value ?? 'ruwio' const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio' - downloadProject(scene, `${safe}.json`) + downloadProject(project, `${safe}.json`) } async function importFromFile() { @@ -238,9 +239,9 @@ async function importFromFile() { if (!ok) return } try { - const scene = await readProjectFile(file) - store.loadScene(scene) - history.resetTo(scene) + const project = await readProjectFile(file) + store.loadProject(project) + history.resetTo(project) currentProjectId.value = null currentProjectName.value = file.name.replace(/\.[^.]+$/, '') loadError.value = null @@ -255,20 +256,23 @@ async function importFromFile() { } } -async function openFromRecent(project: StoredProject) { - const cloned = JSON.parse(JSON.stringify(project.scene)) - store.loadScene(cloned) +async function openFromRecent(stored: StoredProject) { + // `stored.project` is guaranteed by projectStore's migration (legacy + // `scene`-only records get lifted to Project shape on read). + if (!stored.project) return + const cloned = JSON.parse(JSON.stringify(stored.project)) + store.loadProject(cloned) history.resetTo(cloned) - currentProjectId.value = project.id - currentProjectName.value = project.name - setLastOpenedId(project.id) + currentProjectId.value = stored.id + currentProjectName.value = stored.name + setLastOpenedId(stored.id) loadError.value = null await markClean() } async function startNewProject() { - const blank = createBlankScene() - store.loadScene(blank) + const blank = createBlankProject() + store.loadProject(blank) history.resetTo(blank) currentProjectId.value = null currentProjectName.value = null @@ -563,6 +567,7 @@ onBeforeUnmount(() => { + diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index eaa5f36..14096ec 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -188,8 +188,7 @@ async function deleteButtonAsset(id: string) { variant: 'danger', }) if (!ok) return - // Remove the asset; any layers referencing it will render as nothing. - store.scene.assets = store.scene.assets.filter((a) => a.id !== id) + store.removeAsset(id) if (store.activeButtonAssetId === id) store.exitButtonAssetEdit() } diff --git a/src/components/SceneDrawer.vue b/src/components/SceneDrawer.vue new file mode 100644 index 0000000..21ce6b3 --- /dev/null +++ b/src/components/SceneDrawer.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue index 8f3fdbf..4b68988 100644 --- a/src/components/WelcomeDialog.vue +++ b/src/components/WelcomeDialog.vue @@ -69,7 +69,10 @@ function formatDate(ts: number): string { } function layerCount(p: StoredProject): number { - return p.scene?.layers?.length ?? 0 + // Sum across all pages. Legacy `scene`-only records are migrated to + // `project` on read, so we only need to look at `project.scenes`. + if (!p.project) return 0 + return p.project.scenes.reduce((n, s) => n + s.layers.length, 0) } function openProject(p: StoredProject) { diff --git a/src/composables/useHistory.ts b/src/composables/useHistory.ts index bbbc716..385b276 100644 --- a/src/composables/useHistory.ts +++ b/src/composables/useHistory.ts @@ -1,6 +1,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue' -import { useSceneStore } from '@/stores/scene' -import type { Scene } from '@/types/scene' +import { projectFromLegacyScene, useSceneStore } from '@/stores/scene' +import type { Project, Scene } from '@/types/scene' export const HISTORY_MAX = 100 export const HISTORY_DEBOUNCE_MS = 400 @@ -12,7 +12,9 @@ export interface HistoryApi { redo: () => void canUndo: ComputedRef canRedo: ComputedRef - resetTo: (scene?: Scene) => void + /** Re-seed the stack from a fresh state. Accepts either a Project + * (new format) or a legacy Scene (auto-wrapped) for caller convenience. */ + resetTo: (state?: Project | Scene) => void commitNow: () => void start: () => void stop: () => void @@ -33,7 +35,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {} let debounceTimer: ReturnType | null = null let unsub: (() => void) | null = null - const snap = () => JSON.stringify(store.scene) + const snap = () => JSON.stringify(store.project) function pushNow() { if (suspend) return @@ -60,7 +62,11 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {} function apply(json: string) { suspend = true - store.scene = JSON.parse(json) + // Snapshot replacement preserves transient editor state (playhead, + // selection, activeClipId, …). setPlayhead immediately re-clamps it + // into the swapped project's range — so undoing back to a shorter + // duration brings the playhead inside the new bounds. + store.applyProjectSnapshot(JSON.parse(json) as Project) store.setPlayhead(store.playhead) if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) { store.selectLayer(null) @@ -91,12 +97,21 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {} apply(stack.value[index.value]) } - function resetTo(scene?: Scene) { + function resetTo(input?: Project | Scene) { if (debounceTimer) { clearTimeout(debounceTimer) debounceTimer = null } - const json = scene ? JSON.stringify(scene) : snap() + let json: string + if (!input) { + json = snap() + } else if ('scenes' in input && Array.isArray((input as Project).scenes)) { + json = JSON.stringify(input) + } else { + // Legacy Scene shape — wrap into a one-page Project so the snapshot + // format stays homogeneous across the stack. + json = JSON.stringify(projectFromLegacyScene(input as Scene)) + } stack.value = [json] index.value = 0 } @@ -108,7 +123,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {} stack.value = [snap()] index.value = 0 unsub = watch( - () => store.scene, + () => store.project, () => schedulePush(), { deep: true, flush: 'post' }, ) diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index cbe8467..6521b0c 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -1,5 +1,10 @@ import { createApp, h, nextTick, type Component, type Slot } from 'vue' -import { createBlankScene, useSceneStore } from '@/stores/scene' +import { + createBlankProject, + createBlankScene, + projectFromLegacyScene, + useSceneStore, +} from '@/stores/scene' import { buildMarkerMap, evalAction, @@ -1113,23 +1118,24 @@ export function registerAllTests(): void { s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 }) s.setDuration(7) await tick() - const text = serializeProject(s.scene) + const text = serializeProject(s.project) const parsed = parseProject(text) expect(parsed.duration).toBe(7) - expect(parsed.layers).toHaveLength(1) expect(parsed.assets).toHaveLength(1) - expect(parsed.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400) + const page = parsed.scenes[0] + expect(page.layers).toHaveLength(1) + expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400) }) - test('loadScene replaces store state visibly', async () => { + test('loadProject replaces store state visibly', async () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' }) s.addLayer(a.id)! await tick() - const dump = serializeProject(s.scene) + const dump = serializeProject(s.project) s.$reset() await tick() - s.loadScene(parseProject(dump)) + s.loadProject(parseProject(dump)) await tick() expect(s.scene.layers).toHaveLength(1) expect(s.scene.assets).toHaveLength(1) @@ -1175,11 +1181,11 @@ export function registerAllTests(): void { // files on disk. const s = makeFreshStore() s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' }) - const legacyPayload = JSON.parse(serializeProject(s.scene)) + const legacyPayload = JSON.parse(serializeProject(s.project)) expect(legacyPayload.magic).toBe('animatr-project') // And a fully-formed legacy file parses without error. - const scene = parseProject(JSON.stringify(legacyPayload)) - expect(scene.assets.some((a) => a.name === 'Legacy')).toBe(true) + const parsed = parseProject(JSON.stringify(legacyPayload)) + expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true) }) test('parseProject error mentions RUWIO, not Animatr', () => { @@ -1195,6 +1201,202 @@ export function registerAllTests(): void { expect(msg).toContain('RUWIO') expect(msg.toLowerCase().includes('animatr')).toBe(false) }) + + test('parseProject wraps legacy { scene } payload into Project', () => { + // Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new + // parser lifts those into a one-page Project so users don't have + // to re-save. Canvas + assets + layers must all land intact. + const s = makeFreshStore() + s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' }) + const legacy = { + magic: 'animatr-project', + fileVersion: 1, + scene: JSON.parse(JSON.stringify(s.scene)), + } + const parsed = parseProject(JSON.stringify(legacy)) + expect(Array.isArray(parsed.scenes)).toBe(true) + expect(parsed.scenes).toHaveLength(1) + expect(parsed.activeSceneId).toBe(parsed.scenes[0].id) + expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true) + expect(parsed.canvas.width).toBe(s.project.canvas.width) + }) + }) + + suite('scene store — project & scenes', () => { + test('merged `scene` getter mirrors the active page\'s arrays by reference', () => { + // Reads through store.scene must share the SAME array references + // as the active ScenePage so `store.scene.layers.push(x)` hits + // `activeScene.layers`. If they drift, mutations silently vanish. + const s = makeFreshStore() + expect(s.scene.layers).toBe(s.activeScene.layers) + expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances) + expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder) + // Shared fields (asset library, canvas) come from the project. + expect(s.scene.assets).toBe(s.project.assets) + expect(s.scene.canvas).toBe(s.project.canvas) + expect(s.scene.clips).toBe(s.project.clips) + }) + + test('createBlankProject seeds exactly one page named "Scene 1"', () => { + const p = createBlankProject() + expect(p.scenes).toHaveLength(1) + expect(p.scenes[0].name).toBe('Scene 1') + expect(p.activeSceneId).toBe(p.scenes[0].id) + }) + + test('addScene appends, activates, and picks a sensible default name', () => { + const s = makeFreshStore() + expect(s.project.scenes).toHaveLength(1) + const two = s.addScene() + expect(s.project.scenes).toHaveLength(2) + expect(two.name).toBe('Scene 2') + expect(s.project.activeSceneId).toBe(two.id) + const three = s.addScene() + expect(three.name).toBe('Scene 3') + }) + + test('addScene respects an explicit trimmed name', () => { + const s = makeFreshStore() + const intro = s.addScene(' Intro ') + expect(intro.name).toBe('Intro') + }) + + test('switching scenes swaps layer stacks without touching the shared library', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' }) + const l1 = s.addLayer(a.id, 'in-scene-1')! + const two = s.addScene('Two') + // After switch: active page has no layers; the asset library stays. + expect(s.scene.layers).toHaveLength(0) + expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true) + const l2 = s.addLayer(a.id, 'in-scene-2')! + // Flip back to scene 1 and confirm its layer is intact. + s.switchScene(s.project.scenes[0].id) + expect(s.scene.layers).toHaveLength(1) + expect(s.scene.layers[0].id).toBe(l1.id) + // And scene 2 still owns its own layer. + s.switchScene(two.id) + expect(s.scene.layers).toHaveLength(1) + expect(s.scene.layers[0].id).toBe(l2.id) + }) + + test('switchScene clears editor context (selection, clip-edit, playback, drawing)', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + s.play() + const clip = s.addClip('C') + s.activeClipId = clip.id + const two = s.addScene() + // addScene already routed through switchScene — cross-check. + expect(s.selectedLayerId).toBeNull() + expect(s.selectedLayerIds).toHaveLength(0) + expect(s.playing).toBe(false) + expect(s.activeClipId).toBeNull() + expect(s.activeButtonAssetId).toBeNull() + expect(s.project.activeSceneId).toBe(two.id) + }) + + test('switchScene returns false for an unknown id and leaves state alone', () => { + const s = makeFreshStore() + const before = s.project.activeSceneId + const ok = s.switchScene('nope-no-such-scene') + expect(ok).toBe(false) + expect(s.project.activeSceneId).toBe(before) + }) + + test('renameScene trims and rejects empty names', () => { + const s = makeFreshStore() + const id = s.project.activeSceneId + expect(s.renameScene(id, ' Hello ')).toBe(true) + expect(s.project.scenes[0].name).toBe('Hello') + expect(s.renameScene(id, ' ')).toBe(false) + expect(s.project.scenes[0].name).toBe('Hello') + }) + + test('removeScene refuses to delete the last remaining page', () => { + const s = makeFreshStore() + const only = s.project.activeSceneId + expect(s.removeScene(only)).toBe(false) + expect(s.project.scenes).toHaveLength(1) + }) + + test('removeScene on the last page activates its predecessor', () => { + // [A, B, C] active=C → remove C → [A, B] active=B. + // The "neighbour" policy slides the active marker back one slot + // when there's nothing further down the list. + const s = makeFreshStore() + const a = s.project.scenes[0].id + const b = s.addScene('B').id + const c = s.addScene('C').id + expect(s.project.activeSceneId).toBe(c) + expect(s.removeScene(c)).toBe(true) + expect(s.project.activeSceneId).toBe(b) + expect(s.project.scenes.map((p) => p.id)).toEqual([a, b]) + }) + + test('removeScene on a middle page activates the next one', () => { + // [A, B, C] active=B → remove B → [A, C] active=C. + // Forward is the default direction; previous is only used when + // there is no next. + const s = makeFreshStore() + const a = s.project.scenes[0].id + const b = s.addScene('B').id + const c = s.addScene('C').id + s.switchScene(b) + expect(s.project.activeSceneId).toBe(b) + expect(s.removeScene(b)).toBe(true) + expect(s.project.activeSceneId).toBe(c) + expect(s.project.scenes.map((p) => p.id)).toEqual([a, c]) + }) + + test('removeScene on an inactive page leaves the active one untouched', () => { + const s = makeFreshStore() + const a = s.project.scenes[0].id + const b = s.addScene('B').id + const c = s.addScene('C').id + expect(s.project.activeSceneId).toBe(c) + expect(s.removeScene(b)).toBe(true) + expect(s.project.activeSceneId).toBe(c) + expect(s.project.scenes.map((p) => p.id)).toEqual([a, c]) + }) + + test('duplicateScene deep-clones with fresh ids and activates the copy', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' }) + const l = s.addLayer(a.id, 'Orig')! + s.upsertKeyframe(l.id, 1, { x: 20 }) + const origId = s.project.activeSceneId + const origLayerId = l.id + const origKfIds = new Set(l.keyframes.map((k) => k.id)) + + const copy = s.duplicateScene(origId)! + expect(copy.id === origId).toBe(false) + expect(copy.name).toBe(`${s.project.scenes[0].name} copy`) + expect(s.project.activeSceneId).toBe(copy.id) + // Clone sits directly after the original. + const idx = s.project.scenes.findIndex((p) => p.id === origId) + expect(s.project.scenes[idx + 1].id).toBe(copy.id) + // Fresh layer + keyframe ids. + const clonedLayer = copy.layers[0] + expect(clonedLayer.id === origLayerId).toBe(false) + for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false) + // Deep-cloned — mutating the clone leaves original untouched. + clonedLayer.base.x = 999 + const origPage = s.project.scenes.find((p) => p.id === origId)! + expect(origPage.layers[0].base.x === 999).toBe(false) + }) + + test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => { + const s = makeFreshStore() + s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' }) + const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page') + expect(project.scenes).toHaveLength(1) + expect(project.scenes[0].name).toBe('My Page') + expect(project.activeSceneId).toBe(project.scenes[0].id) + expect(project.assets.some((a) => a.name === 'LG')).toBe(true) + }) }) suite('history — undo/redo', () => { @@ -1291,6 +1493,67 @@ export function registerAllTests(): void { h.stop() }) + test('undo after switchScene restores the previous activeSceneId', async () => { + // History snapshots the whole project including activeSceneId, so + // a scene switch is itself an undoable step. If this regresses the + // user will notice a sudden loss of "undo back to where I was". + const s = makeFreshStore() + const h = createHistory(s, { debounceMs: 10 }) + h.start() + const a = s.project.scenes[0].id + const b = s.addScene('B').id + await tick(40) + // Now switch back to A; that's a fresh snapshottable step. + s.switchScene(a) + await tick(40) + expect(s.project.activeSceneId).toBe(a) + h.undo() + await tick(20) + expect(s.project.activeSceneId).toBe(b) + h.stop() + }) + + test('undo after addScene removes the just-added page', async () => { + const s = makeFreshStore() + const h = createHistory(s, { debounceMs: 10 }) + h.start() + expect(s.project.scenes).toHaveLength(1) + const origId = s.project.activeSceneId + s.addScene('B') + await tick(40) + expect(s.project.scenes).toHaveLength(2) + h.undo() + await tick(20) + expect(s.project.scenes).toHaveLength(1) + expect(s.project.activeSceneId).toBe(origId) + h.stop() + }) + + test('undo after removeScene restores the deleted page content', async () => { + const s = makeFreshStore() + const h = createHistory(s, { debounceMs: 10 }) + h.start() + const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' }) + const two = s.addScene('Two') + s.addLayer(a.id, 'only-in-two') + await tick(40) + expect(s.scene.layers).toHaveLength(1) + const twoId = two.id + // Flip away, then delete. Both are undoable steps. + s.switchScene(s.project.scenes[0].id) + await tick(40) + s.removeScene(twoId) + await tick(40) + expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false) + h.undo() + await tick(20) + const restored = s.project.scenes.find((p) => p.id === twoId) + expect(restored).toBeTruthy() + expect(restored!.layers).toHaveLength(1) + expect(restored!.layers[0].name).toBe('only-in-two') + h.stop() + }) + test('max history cap drops oldest entries', async () => { const s = makeFreshStore() const h = createHistory(s, { debounceMs: 10, max: 3 }) @@ -1533,13 +1796,13 @@ export function registerAllTests(): void { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' }) s.addLayer(a.id)! - const rec = await saveProjectToStore(s.scene, { name: 'test-save-get' }) + const rec = await saveProjectToStore(s.project, { name: 'test-save-get' }) try { const back = await getStoredProject(rec.id) expect(back).toBeTruthy() expect(back!.name).toBe('test-save-get') - expect(back!.scene.assets).toHaveLength(1) - expect(back!.scene.layers).toHaveLength(1) + expect(back!.project!.assets).toHaveLength(1) + expect(back!.project!.scenes[0].layers).toHaveLength(1) } finally { await deleteStoredProject(rec.id) } @@ -1549,9 +1812,9 @@ export function registerAllTests(): void { const s = makeFreshStore() const olderId = `test-older-${Date.now()}` const newerId = `test-newer-${Date.now()}` - const older = await saveProjectToStore(s.scene, { id: olderId, name: 'older' }) + const older = await saveProjectToStore(s.project, { id: olderId, name: 'older' }) await tick(5) - const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' }) + const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' }) try { const list = await listRecentProjects() const newerIdx = list.findIndex((p) => p.id === newer.id) @@ -1568,9 +1831,9 @@ export function registerAllTests(): void { test('save with same id updates existing record instead of adding', async () => { const s = makeFreshStore() const id = `test-update-${Date.now()}` - await saveProjectToStore(s.scene, { id, name: 'v1' }) + await saveProjectToStore(s.project, { id, name: 'v1' }) const before = (await listRecentProjects()).filter((p) => p.id === id).length - await saveProjectToStore(s.scene, { id, name: 'v2' }) + await saveProjectToStore(s.project, { id, name: 'v2' }) try { const list = await listRecentProjects() const matches = list.filter((p) => p.id === id) @@ -1584,7 +1847,7 @@ export function registerAllTests(): void { test('deleteStoredProject removes entry', async () => { const s = makeFreshStore() - const rec = await saveProjectToStore(s.scene, { name: 'to-delete' }) + const rec = await saveProjectToStore(s.project, { name: 'to-delete' }) await deleteStoredProject(rec.id) const back = await getStoredProject(rec.id) expect(back).toBeNull() @@ -1853,7 +2116,7 @@ export function registerAllTests(): void { s.scene.canvas.width = 100 s.scene.canvas.height = 100 s.scene.canvas.background = 'transparent' - s.scene.duration = 5 + s.setDuration(5) const rect = s.addAsset({ kind: 'rect', @@ -1918,7 +2181,7 @@ export function registerAllTests(): void { s.scene.canvas.width = 100 s.scene.canvas.height = 100 s.scene.canvas.background = 'transparent' - s.scene.duration = 5 + s.setDuration(5) const rect = s.addAsset({ kind: 'rect', diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index f555acf..1b98596 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -406,6 +406,27 @@ export const en = { 'Point to a local folder with your SVG/PNG/JPG clipart. The app remembers the folder; permissions may be re-asked after browser restart.', }, + scenes: { + title: 'Scenes', + open: 'Open scenes', + close: 'Close scenes', + add: 'Add scene', + rename: 'Rename scene', + renameDialog: { + title: 'Rename scene', + confirm: 'Rename', + }, + duplicate: 'Duplicate', + delete: 'Delete', + deleteDialog: { + title: 'Delete scene?', + message: 'Remove "{name}" from this project. This cannot be undone.', + confirm: 'Delete', + }, + count: '{n} scenes | {n} scene | {n} scenes', + locked: 'A project must have at least one scene.', + }, + dialog: { confirm: 'Confirm', input: 'Input', diff --git a/src/lib/projectFile.ts b/src/lib/projectFile.ts index 7ee1066..cd6953d 100644 --- a/src/lib/projectFile.ts +++ b/src/lib/projectFile.ts @@ -1,6 +1,7 @@ -import type { Scene } from '@/types/scene' +import type { Project, Scene } from '@/types/scene' +import { projectFromLegacyScene } from '@/stores/scene' -const FILE_VERSION = 1 +const FILE_VERSION = 2 // The on-disk magic stays `animatr-project` so older exports keep loading. // Only user-visible filenames & strings have rebranded to RUWIO. const MAGIC = 'animatr-project' @@ -9,15 +10,19 @@ export interface ProjectFile { magic: typeof MAGIC fileVersion: number savedAt: string - scene: Scene + /** v2+: the new top-level container. Always present in current saves. */ + project?: Project + /** v1 (legacy): a single Scene was the project. Kept around so old + * files keep loading; new saves never set this. */ + scene?: Scene } -export function serializeProject(scene: Scene): string { +export function serializeProject(project: Project): string { const payload: ProjectFile = { magic: MAGIC, fileVersion: FILE_VERSION, savedAt: new Date().toISOString(), - scene, + project, } return JSON.stringify(payload, null, 2) } @@ -29,7 +34,9 @@ export class ProjectFileError extends Error { } } -export function parseProject(text: string): Scene { +/** Parse a project file. Returns a Project regardless of whether the file + * was saved in the v1 (single Scene) or v2+ (Project) format. */ +export function parseProject(text: string): Project { let data: unknown try { data = JSON.parse(text) @@ -43,9 +50,26 @@ export function parseProject(text: string): Scene { if (obj.magic !== MAGIC) { throw new ProjectFileError('Not a RUWIO project file (magic missing).') } + + // v2+ payload: project field present. + if (obj.project && typeof obj.project === 'object') { + const p = obj.project as Record + if (!p.canvas || typeof p.canvas !== 'object') { + throw new ProjectFileError('Project data incomplete: canvas missing.') + } + if (!Array.isArray(p.scenes)) { + throw new ProjectFileError('Project data incomplete: scenes missing.') + } + if (!Array.isArray(p.assets)) { + throw new ProjectFileError('Project data incomplete: assets missing.') + } + return obj.project as Project + } + + // v1 payload: lift scene into a single-page project. const scene = obj.scene if (!scene || typeof scene !== 'object') { - throw new ProjectFileError('Project file has no scene.') + throw new ProjectFileError('Project file has no project or scene.') } const s = scene as Record if (!s.canvas || typeof s.canvas !== 'object') { @@ -57,11 +81,11 @@ export function parseProject(text: string): Scene { if (!Array.isArray(s.assets)) { throw new ProjectFileError('Scene data incomplete: assets missing.') } - return scene as Scene + return projectFromLegacyScene(scene as Scene) } -export function downloadProject(scene: Scene, filename = 'projekt.ruwio.json') { - const source = serializeProject(scene) +export function downloadProject(project: Project, filename = 'projekt.ruwio.json') { + const source = serializeProject(project) const blob = new Blob([source], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -87,7 +111,7 @@ export function pickProjectFile(): Promise { }) } -export async function readProjectFile(file: File): Promise { +export async function readProjectFile(file: File): Promise { const text = await file.text() return parseProject(text) } diff --git a/src/lib/projectStore.ts b/src/lib/projectStore.ts index c6dbd09..1519916 100644 --- a/src/lib/projectStore.ts +++ b/src/lib/projectStore.ts @@ -1,4 +1,5 @@ -import type { Scene } from '@/types/scene' +import type { Project, Scene } from '@/types/scene' +import { projectFromLegacyScene } from '@/stores/scene' const DB_NAME = 'animatr' const DB_VERSION = 1 @@ -8,7 +9,11 @@ const MAX_RECENT = 8 export interface StoredProject { id: string name: string - scene: Scene + /** v2+: the full project. Always populated for new saves. */ + project?: Project + /** v1 (legacy): older records held a single Scene. Loader migrates + * these into a one-page Project on read; new saves never write this. */ + scene?: Scene savedAt: number thumbnail?: string } @@ -48,11 +53,22 @@ function uid(): string { return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4) } +/** In-place migration: older records stored `scene`; newer ones hold + * `project`. Normalise to `project` so callers don't have to branch. */ +function migrate(record: StoredProject): StoredProject { + if (!record.project && record.scene) { + record.project = projectFromLegacyScene(record.scene, record.name || 'Scene 1') + delete record.scene + } + return record +} + export async function listRecentProjects(): Promise { try { const all = await tx('readonly', (s) => s.getAll() as IDBRequest) return (all ?? []) .slice() + .map(migrate) .sort((a, b) => b.savedAt - a.savedAt) .slice(0, MAX_RECENT) } catch { @@ -67,13 +83,13 @@ export interface SaveOptions { } export async function saveProjectToStore( - scene: Scene, + project: Project, opts: SaveOptions = {}, ): Promise { const record: StoredProject = { id: opts.id ?? uid(), name: opts.name?.trim() || defaultName(), - scene: JSON.parse(JSON.stringify(scene)), + project: JSON.parse(JSON.stringify(project)), savedAt: Date.now(), thumbnail: opts.thumbnail, } @@ -90,7 +106,7 @@ export async function getStoredProject(id: string): Promise('readonly', (s) => s.get(id) as IDBRequest, ) - return rec ?? null + return rec ? migrate(rec) : null } catch { return null } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 05bc5c8..fd2452d 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -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() - 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() - 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() - 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() + 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) diff --git a/src/types/scene.ts b/src/types/scene.ts index c204a1f..cb0df4b 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -299,6 +299,51 @@ export interface Scene { markers?: Marker[] } +/** + * A page-like sub-scene within a Project. Holds only what differs between + * pages (layer stack, tracks, clip placements, scripts, markers, optional + * background video). Canvas, duration, fps, in/out, asset library and + * clip library live one level up on the Project so all pages stay in sync. + */ +export interface ScenePage { + id: string + name: string + layers: Layer[] + clipInstances: ClipInstance[] + clipTracks: ClipTrack[] + scriptTracks: ScriptTrack[] + scriptFrames: ScriptFrame[] + markers: Marker[] + groups: TrackGroup[] + trackOrder: string[] + backgroundVideo?: BackgroundVideo +} + +/** + * The top-level container persisted in a `.ruwio` file. Holds shared + * project-wide settings + a list of `ScenePage`s. The store exposes a + * `scene` getter that returns a merged `Scene`-shaped view of the active + * page + project-wide fields, so legacy read sites (`store.scene.canvas`, + * `store.scene.layers`, …) keep working unchanged. + */ +export interface Project { + version: 1 + name?: string + canvas: { + width: number + height: number + background: string + } + duration: number + fps: number + inTime?: number + outTime?: number + assets: Asset[] + clips: Clip[] + scenes: ScenePage[] + activeSceneId: string +} + export const DEFAULT_BASE_PROPS: AnimatableProps = { x: 0, y: 0,