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,
|