0.9.0: scenes — multi-page projects with right-side drawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 07:17:06 +02:00
1 parent 517705bf55
commit b57994c754
12 files changed
+1193 -176

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "animatr",
"private": true, "private": true,
"version": "0.8.7", "version": "0.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+23 -18
View File
@@ -21,6 +21,7 @@ import AboutDialog from './components/AboutDialog.vue'
import AssetSidebar from './components/AssetSidebar.vue' import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue' import CanvasStage from './components/CanvasStage.vue'
import ClipartDrawer from './components/ClipartDrawer.vue' import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue' import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue' import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue' import FullscreenPreview from './components/FullscreenPreview.vue'
@@ -49,7 +50,7 @@ import {
} from './lib/projectStore' } from './lib/projectStore'
import { importImageFile } from './lib/fileLoader' import { importImageFile } from './lib/fileLoader'
import { useClipartStore } from './stores/clipart' import { useClipartStore } from './stores/clipart'
import { createBlankScene, useSceneStore } from './stores/scene' import { createBlankProject, useSceneStore } from './stores/scene'
const settingsOpen = ref(false) const settingsOpen = ref(false)
const exportOpen = ref(false) const exportOpen = ref(false)
@@ -165,7 +166,7 @@ function stop() {
} }
async function saveProject() { async function saveProject() {
const scene = JSON.parse(JSON.stringify(store.scene)) const project = JSON.parse(JSON.stringify(store.project))
let name = currentProjectName.value let name = currentProjectName.value
if (!name) { if (!name) {
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() }) const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
@@ -178,7 +179,7 @@ async function saveProject() {
if (entered === null) return if (entered === null) return
name = entered.trim() || suggested name = entered.trim() || suggested
} }
const rec = await saveProjectToStore(scene, { const rec = await saveProjectToStore(project, {
id: currentProjectId.value ?? undefined, id: currentProjectId.value ?? undefined,
name, name,
}) })
@@ -208,8 +209,8 @@ async function renameProject() {
// it. Unsaved projects just get the in-memory name — the next save // it. Unsaved projects just get the in-memory name — the next save
// picks it up. // picks it up.
if (currentProjectId.value) { if (currentProjectId.value) {
const scene = JSON.parse(JSON.stringify(store.scene)) const project = JSON.parse(JSON.stringify(store.project))
const rec = await saveProjectToStore(scene, { const rec = await saveProjectToStore(project, {
id: currentProjectId.value, id: currentProjectId.value,
name: trimmed, name: trimmed,
}) })
@@ -218,10 +219,10 @@ async function renameProject() {
} }
function exportProjectFile() { function exportProjectFile() {
const scene = JSON.parse(JSON.stringify(store.scene)) const project = JSON.parse(JSON.stringify(store.project))
const base = currentProjectName.value ?? 'ruwio' const base = currentProjectName.value ?? 'ruwio'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio' const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
downloadProject(scene, `${safe}.json`) downloadProject(project, `${safe}.json`)
} }
async function importFromFile() { async function importFromFile() {
@@ -238,9 +239,9 @@ async function importFromFile() {
if (!ok) return if (!ok) return
} }
try { try {
const scene = await readProjectFile(file) const project = await readProjectFile(file)
store.loadScene(scene) store.loadProject(project)
history.resetTo(scene) history.resetTo(project)
currentProjectId.value = null currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '') currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null loadError.value = null
@@ -255,20 +256,23 @@ async function importFromFile() {
} }
} }
async function openFromRecent(project: StoredProject) { async function openFromRecent(stored: StoredProject) {
const cloned = JSON.parse(JSON.stringify(project.scene)) // `stored.project` is guaranteed by projectStore's migration (legacy
store.loadScene(cloned) // `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) history.resetTo(cloned)
currentProjectId.value = project.id currentProjectId.value = stored.id
currentProjectName.value = project.name currentProjectName.value = stored.name
setLastOpenedId(project.id) setLastOpenedId(stored.id)
loadError.value = null loadError.value = null
await markClean() await markClean()
} }
async function startNewProject() { async function startNewProject() {
const blank = createBlankScene() const blank = createBlankProject()
store.loadScene(blank) store.loadProject(blank)
history.resetTo(blank) history.resetTo(blank)
currentProjectId.value = null currentProjectId.value = null
currentProjectName.value = null currentProjectName.value = null
@@ -563,6 +567,7 @@ onBeforeUnmount(() => {
</Toast> </Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" /> <Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</div> </div>
<SceneDrawer />
<TooltipHost /> <TooltipHost />
</div> </div>
</template> </template>
+1 -2
View File
@@ -188,8 +188,7 @@ async function deleteButtonAsset(id: string) {
variant: 'danger', variant: 'danger',
}) })
if (!ok) return if (!ok) return
// Remove the asset; any layers referencing it will render as nothing. store.removeAsset(id)
store.scene.assets = store.scene.assets.filter((a) => a.id !== id)
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit() if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
} }
+367
View File
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const store = useSceneStore()
const open = ref(false)
interface MenuState {
pageId: string
x: number
y: number
}
const menu = ref<MenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null)
function toggle() {
open.value = !open.value
}
async function addScene() {
const page = store.addScene()
// Let the new row mount before renaming so a canceled rename still
// leaves the user on the freshly-created (and now active) page.
await nextTick()
renameScene(page.id)
}
async function renameScene(id: string) {
const page = store.project.scenes.find((s) => s.id === id)
if (!page) return
const next = await promptDialog({
title: t('scenes.renameDialog.title'),
defaultValue: page.name,
confirmLabel: t('scenes.renameDialog.confirm'),
})
if (next === null) return
store.renameScene(id, next)
}
function duplicateScene(id: string) {
store.duplicateScene(id)
}
async function deleteScene(id: string) {
const page = store.project.scenes.find((s) => s.id === id)
if (!page) return
if (store.project.scenes.length <= 1) return
const ok = await confirmDialog({
title: t('scenes.deleteDialog.title'),
message: t('scenes.deleteDialog.message', { name: page.name }),
confirmLabel: t('scenes.deleteDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeScene(id)
}
function onRowClick(id: string) {
if (store.project.activeSceneId === id) return
store.switchScene(id)
}
function onRowContextMenu(e: MouseEvent, id: string) {
e.preventDefault()
e.stopPropagation()
menu.value = { pageId: id, x: e.clientX, y: e.clientY }
}
function closeMenu() {
menu.value = null
}
function onDocMouseDown(e: MouseEvent) {
if (!menu.value) return
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
closeMenu()
}
watch(menu, (m) => {
if (m) document.addEventListener('mousedown', onDocMouseDown)
else document.removeEventListener('mousedown', onDocMouseDown)
})
</script>
<template>
<aside class="scene-drawer" :class="{ open }">
<button
class="drawer-tab"
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
@click="toggle"
>
<component :is="open ? PhCaretRight : PhCaretLeft" :size="14" />
</button>
<div class="drawer-panel">
<header class="drawer-header">
<PhFilmSlate :size="14" class="title-icon" />
<h3>{{ t('scenes.title') }}</h3>
<button class="add-btn" v-tooltip="t('scenes.add')" @click="addScene">
<PhPlus :size="13" weight="bold" />
</button>
</header>
<div class="scene-list">
<button
v-for="page in store.project.scenes"
:key="page.id"
class="scene-row"
:class="{ active: page.id === store.project.activeSceneId }"
@click="onRowClick(page.id)"
@dblclick="renameScene(page.id)"
@contextmenu="onRowContextMenu($event, page.id)"
>
<span class="scene-name">{{ page.name }}</span>
<span class="scene-meta">{{ page.layers.length }}</span>
</button>
</div>
</div>
<div
v-if="menu"
ref="menuRef"
class="scene-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
>
<button class="menu-item" @click="renameScene(menu.pageId); closeMenu()">
{{ t('scenes.rename') }}
</button>
<button class="menu-item" @click="duplicateScene(menu.pageId); closeMenu()">
<PhCopy :size="12" class="menu-icon" />
{{ t('scenes.duplicate') }}
</button>
<div class="menu-divider"></div>
<button
class="menu-item danger"
:disabled="store.project.scenes.length <= 1"
:title="store.project.scenes.length <= 1 ? t('scenes.locked') : undefined"
@click="deleteScene(menu.pageId); closeMenu()"
>
<PhTrash :size="12" class="menu-icon" />
{{ t('scenes.delete') }}
</button>
</div>
</aside>
</template>
<style scoped>
.scene-drawer {
display: flex;
align-items: stretch;
flex-shrink: 0;
height: 100%;
width: 5px;
transition: width 220ms var(--ease);
background: var(--border);
border-left: 1px solid var(--border);
position: relative;
overflow: hidden;
/* Mirror of ClipartDrawer: tab on the screen-edge side, panel
alongside it. Here the edge is the right-hand side, so the tab
flex order is reversed (panel first, tab second in DOM). */
flex-direction: row-reverse;
}
.scene-drawer:hover {
width: 24px;
background: var(--panel);
}
.scene-drawer.open {
width: 324px;
background: var(--panel);
}
.drawer-tab {
flex: 0 0 100%;
width: 100%;
min-width: 0;
padding: 0;
border: none;
background: transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
writing-mode: vertical-rl;
overflow: hidden;
transition: background 140ms var(--ease);
}
.drawer-tab :deep(svg) {
opacity: 0;
transition: opacity 140ms var(--ease);
}
.scene-drawer:hover .drawer-tab :deep(svg),
.scene-drawer.open .drawer-tab :deep(svg) {
opacity: 1;
}
.scene-drawer:hover .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.scene-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
background: var(--panel);
}
.drawer-panel {
/* Rigid 300px so the list layout doesn't reflow mid-animation. */
flex: 0 0 300px;
width: 300px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
}
.drawer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.title-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.drawer-header h3 {
flex: 1;
margin: 0;
font-size: 13px;
font-weight: 600;
}
.add-btn {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.add-btn:hover {
color: var(--text);
background: var(--panel-alt);
border-color: var(--accent);
}
.scene-list {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.scene-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-alt);
color: var(--text);
font-size: 12px;
cursor: pointer;
text-align: left;
}
.scene-row:hover {
border-color: var(--accent);
}
.scene-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.scene-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-meta {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--border);
}
.scene-row.active .scene-meta {
background: var(--accent);
color: var(--panel);
}
.scene-menu {
position: fixed;
z-index: 2000;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 4px;
min-width: 160px;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--panel-alt);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.danger {
color: var(--danger, #c55);
}
.menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.menu-icon {
color: var(--text-dim);
flex-shrink: 0;
}
</style>
+4 -1
View File
@@ -69,7 +69,10 @@ function formatDate(ts: number): string {
} }
function layerCount(p: StoredProject): number { 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) { function openProject(p: StoredProject) {
+23 -8
View File
@@ -1,6 +1,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue'
import { useSceneStore } from '@/stores/scene' import { projectFromLegacyScene, useSceneStore } from '@/stores/scene'
import type { Scene } from '@/types/scene' import type { Project, Scene } from '@/types/scene'
export const HISTORY_MAX = 100 export const HISTORY_MAX = 100
export const HISTORY_DEBOUNCE_MS = 400 export const HISTORY_DEBOUNCE_MS = 400
@@ -12,7 +12,9 @@ export interface HistoryApi {
redo: () => void redo: () => void
canUndo: ComputedRef<boolean> canUndo: ComputedRef<boolean>
canRedo: ComputedRef<boolean> canRedo: ComputedRef<boolean>
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 commitNow: () => void
start: () => void start: () => void
stop: () => void stop: () => void
@@ -33,7 +35,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
let unsub: (() => void) | null = null let unsub: (() => void) | null = null
const snap = () => JSON.stringify(store.scene) const snap = () => JSON.stringify(store.project)
function pushNow() { function pushNow() {
if (suspend) return if (suspend) return
@@ -60,7 +62,11 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
function apply(json: string) { function apply(json: string) {
suspend = true 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) store.setPlayhead(store.playhead)
if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) { if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) {
store.selectLayer(null) store.selectLayer(null)
@@ -91,12 +97,21 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
apply(stack.value[index.value]) apply(stack.value[index.value])
} }
function resetTo(scene?: Scene) { function resetTo(input?: Project | Scene) {
if (debounceTimer) { if (debounceTimer) {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
debounceTimer = null 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] stack.value = [json]
index.value = 0 index.value = 0
} }
@@ -108,7 +123,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
stack.value = [snap()] stack.value = [snap()]
index.value = 0 index.value = 0
unsub = watch( unsub = watch(
() => store.scene, () => store.project,
() => schedulePush(), () => schedulePush(),
{ deep: true, flush: 'post' }, { deep: true, flush: 'post' },
) )
+283 -20
View File
@@ -1,5 +1,10 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue' 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 { import {
buildMarkerMap, buildMarkerMap,
evalAction, evalAction,
@@ -1113,23 +1118,24 @@ export function registerAllTests(): void {
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 }) s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
s.setDuration(7) s.setDuration(7)
await tick() await tick()
const text = serializeProject(s.scene) const text = serializeProject(s.project)
const parsed = parseProject(text) const parsed = parseProject(text)
expect(parsed.duration).toBe(7) expect(parsed.duration).toBe(7)
expect(parsed.layers).toHaveLength(1)
expect(parsed.assets).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 s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' }) const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
s.addLayer(a.id)! s.addLayer(a.id)!
await tick() await tick()
const dump = serializeProject(s.scene) const dump = serializeProject(s.project)
s.$reset() s.$reset()
await tick() await tick()
s.loadScene(parseProject(dump)) s.loadProject(parseProject(dump))
await tick() await tick()
expect(s.scene.layers).toHaveLength(1) expect(s.scene.layers).toHaveLength(1)
expect(s.scene.assets).toHaveLength(1) expect(s.scene.assets).toHaveLength(1)
@@ -1175,11 +1181,11 @@ export function registerAllTests(): void {
// files on disk. // files on disk.
const s = makeFreshStore() const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' }) 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') expect(legacyPayload.magic).toBe('animatr-project')
// And a fully-formed legacy file parses without error. // And a fully-formed legacy file parses without error.
const scene = parseProject(JSON.stringify(legacyPayload)) const parsed = parseProject(JSON.stringify(legacyPayload))
expect(scene.assets.some((a) => a.name === 'Legacy')).toBe(true) expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
}) })
test('parseProject error mentions RUWIO, not Animatr', () => { test('parseProject error mentions RUWIO, not Animatr', () => {
@@ -1195,6 +1201,202 @@ export function registerAllTests(): void {
expect(msg).toContain('RUWIO') expect(msg).toContain('RUWIO')
expect(msg.toLowerCase().includes('animatr')).toBe(false) 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', () => { suite('history — undo/redo', () => {
@@ -1291,6 +1493,67 @@ export function registerAllTests(): void {
h.stop() 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 () => { test('max history cap drops oldest entries', async () => {
const s = makeFreshStore() const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10, max: 3 }) const h = createHistory(s, { debounceMs: 10, max: 3 })
@@ -1533,13 +1796,13 @@ export function registerAllTests(): void {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' }) const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
s.addLayer(a.id)! 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 { try {
const back = await getStoredProject(rec.id) const back = await getStoredProject(rec.id)
expect(back).toBeTruthy() expect(back).toBeTruthy()
expect(back!.name).toBe('test-save-get') expect(back!.name).toBe('test-save-get')
expect(back!.scene.assets).toHaveLength(1) expect(back!.project!.assets).toHaveLength(1)
expect(back!.scene.layers).toHaveLength(1) expect(back!.project!.scenes[0].layers).toHaveLength(1)
} finally { } finally {
await deleteStoredProject(rec.id) await deleteStoredProject(rec.id)
} }
@@ -1549,9 +1812,9 @@ export function registerAllTests(): void {
const s = makeFreshStore() const s = makeFreshStore()
const olderId = `test-older-${Date.now()}` const olderId = `test-older-${Date.now()}`
const newerId = `test-newer-${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) await tick(5)
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' }) const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
try { try {
const list = await listRecentProjects() const list = await listRecentProjects()
const newerIdx = list.findIndex((p) => p.id === newer.id) 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 () => { test('save with same id updates existing record instead of adding', async () => {
const s = makeFreshStore() const s = makeFreshStore()
const id = `test-update-${Date.now()}` 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 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 { try {
const list = await listRecentProjects() const list = await listRecentProjects()
const matches = list.filter((p) => p.id === id) const matches = list.filter((p) => p.id === id)
@@ -1584,7 +1847,7 @@ export function registerAllTests(): void {
test('deleteStoredProject removes entry', async () => { test('deleteStoredProject removes entry', async () => {
const s = makeFreshStore() 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) await deleteStoredProject(rec.id)
const back = await getStoredProject(rec.id) const back = await getStoredProject(rec.id)
expect(back).toBeNull() expect(back).toBeNull()
@@ -1853,7 +2116,7 @@ export function registerAllTests(): void {
s.scene.canvas.width = 100 s.scene.canvas.width = 100
s.scene.canvas.height = 100 s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent' s.scene.canvas.background = 'transparent'
s.scene.duration = 5 s.setDuration(5)
const rect = s.addAsset({ const rect = s.addAsset({
kind: 'rect', kind: 'rect',
@@ -1918,7 +2181,7 @@ export function registerAllTests(): void {
s.scene.canvas.width = 100 s.scene.canvas.width = 100
s.scene.canvas.height = 100 s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent' s.scene.canvas.background = 'transparent'
s.scene.duration = 5 s.setDuration(5)
const rect = s.addAsset({ const rect = s.addAsset({
kind: 'rect', kind: 'rect',
+21
View File
@@ -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.', '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: { dialog: {
confirm: 'Confirm', confirm: 'Confirm',
input: 'Input', input: 'Input',
+35 -11
View File
@@ -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. // The on-disk magic stays `animatr-project` so older exports keep loading.
// Only user-visible filenames & strings have rebranded to RUWIO. // Only user-visible filenames & strings have rebranded to RUWIO.
const MAGIC = 'animatr-project' const MAGIC = 'animatr-project'
@@ -9,15 +10,19 @@ export interface ProjectFile {
magic: typeof MAGIC magic: typeof MAGIC
fileVersion: number fileVersion: number
savedAt: string 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 = { const payload: ProjectFile = {
magic: MAGIC, magic: MAGIC,
fileVersion: FILE_VERSION, fileVersion: FILE_VERSION,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
scene, project,
} }
return JSON.stringify(payload, null, 2) 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 let data: unknown
try { try {
data = JSON.parse(text) data = JSON.parse(text)
@@ -43,9 +50,26 @@ export function parseProject(text: string): Scene {
if (obj.magic !== MAGIC) { if (obj.magic !== MAGIC) {
throw new ProjectFileError('Not a RUWIO project file (magic missing).') 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<string, unknown>
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 const scene = obj.scene
if (!scene || typeof scene !== 'object') { 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<string, unknown> const s = scene as Record<string, unknown>
if (!s.canvas || typeof s.canvas !== 'object') { if (!s.canvas || typeof s.canvas !== 'object') {
@@ -57,11 +81,11 @@ export function parseProject(text: string): Scene {
if (!Array.isArray(s.assets)) { if (!Array.isArray(s.assets)) {
throw new ProjectFileError('Scene data incomplete: assets missing.') 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') { export function downloadProject(project: Project, filename = 'projekt.ruwio.json') {
const source = serializeProject(scene) const source = serializeProject(project)
const blob = new Blob([source], { type: 'application/json' }) const blob = new Blob([source], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@@ -87,7 +111,7 @@ export function pickProjectFile(): Promise<File | null> {
}) })
} }
export async function readProjectFile(file: File): Promise<Scene> { export async function readProjectFile(file: File): Promise<Project> {
const text = await file.text() const text = await file.text()
return parseProject(text) return parseProject(text)
} }
+21 -5
View File
@@ -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_NAME = 'animatr'
const DB_VERSION = 1 const DB_VERSION = 1
@@ -8,7 +9,11 @@ const MAX_RECENT = 8
export interface StoredProject { export interface StoredProject {
id: string id: string
name: 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 savedAt: number
thumbnail?: string thumbnail?: string
} }
@@ -48,11 +53,22 @@ function uid(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4) 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<StoredProject[]> { export async function listRecentProjects(): Promise<StoredProject[]> {
try { try {
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>) const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
return (all ?? []) return (all ?? [])
.slice() .slice()
.map(migrate)
.sort((a, b) => b.savedAt - a.savedAt) .sort((a, b) => b.savedAt - a.savedAt)
.slice(0, MAX_RECENT) .slice(0, MAX_RECENT)
} catch { } catch {
@@ -67,13 +83,13 @@ export interface SaveOptions {
} }
export async function saveProjectToStore( export async function saveProjectToStore(
scene: Scene, project: Project,
opts: SaveOptions = {}, opts: SaveOptions = {},
): Promise<StoredProject> { ): Promise<StoredProject> {
const record: StoredProject = { const record: StoredProject = {
id: opts.id ?? uid(), id: opts.id ?? uid(),
name: opts.name?.trim() || defaultName(), name: opts.name?.trim() || defaultName(),
scene: JSON.parse(JSON.stringify(scene)), project: JSON.parse(JSON.stringify(project)),
savedAt: Date.now(), savedAt: Date.now(),
thumbnail: opts.thumbnail, thumbnail: opts.thumbnail,
} }
@@ -90,7 +106,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
const rec = await tx<StoredProject | undefined>('readonly', (s) => const rec = await tx<StoredProject | undefined>('readonly', (s) =>
s.get(id) as IDBRequest<StoredProject | undefined>, s.get(id) as IDBRequest<StoredProject | undefined>,
) )
return rec ?? null return rec ? migrate(rec) : null
} catch { } catch {
return null return null
} }
+369 -110
View File
@@ -16,7 +16,9 @@ import type {
Layer, Layer,
Marker, Marker,
MaskMode, MaskMode,
Project,
Scene, Scene,
ScenePage,
ScriptFrame, ScriptFrame,
ScriptTrack, ScriptTrack,
TrackGroup, TrackGroup,
@@ -64,7 +66,15 @@ function snapToButtonStateTime(t: number): number {
export type ButtonEditMode = 'states' | 'hit' export type ButtonEditMode = 'states' | 'hit'
interface State { 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 /** Primary (anchor) selection — drives the Properties panel and
* any code that expects a single focused layer. Always matches * any code that expects a single focused layer. Always matches
* the last entry of `selectedLayerIds`. */ * the last entry of `selectedLayerIds`. */
@@ -184,32 +194,33 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
return null return null
} }
function normalizeScene(scene: Scene): Scene { /** Per-page housekeeping: ensure all arrays exist, clean up orphan
scene.clips ||= [] * childIds in groups, dedupe, build trackOrder for any pristine track,
scene.clipTracks ||= [] * and migrate pre-`colorOverridden` color flags. */
scene.clipInstances ||= [] function normalizeScenePage(page: ScenePage): ScenePage {
scene.groups ||= [] page.layers ||= []
scene.scriptTracks ||= [] page.clipInstances ||= []
scene.scriptFrames ||= [] page.clipTracks ||= []
scene.markers ||= [] page.scriptTracks ||= []
if (!scene.canvas.background) scene.canvas.background = '#ffffff' page.scriptFrames ||= []
page.markers ||= []
page.groups ||= []
page.trackOrder ||= []
const known = new Set<string>() const known = new Set<string>()
for (const l of scene.layers) known.add(l.id) for (const l of page.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id) for (const t of page.clipTracks) known.add(t.id)
for (const g of scene.groups) known.add(g.id) for (const g of page.groups) known.add(g.id)
for (const st of scene.scriptTracks) known.add(st.id) for (const st of page.scriptTracks) known.add(st.id)
// Cleanup: group childIds should only reference existing layers/clipTracks
const layerAndClipIds = new Set<string>() const layerAndClipIds = new Set<string>()
for (const l of scene.layers) layerAndClipIds.add(l.id) for (const l of page.layers) layerAndClipIds.add(l.id)
for (const t of scene.clipTracks) layerAndClipIds.add(t.id) for (const t of page.clipTracks) layerAndClipIds.add(t.id)
for (const g of scene.groups) { for (const g of page.groups) {
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id)) g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
} }
// Dedupe: any given track can only be in one group
const inGroup = new Set<string>() const inGroup = new Set<string>()
for (const g of scene.groups) { for (const g of page.groups) {
g.childIds = g.childIds.filter((id) => { g.childIds = g.childIds.filter((id) => {
if (inGroup.has(id)) return false if (inGroup.has(id)) return false
inGroup.add(id) inGroup.add(id)
@@ -217,60 +228,147 @@ function normalizeScene(scene: Scene): Scene {
}) })
} }
if (!scene.trackOrder) scene.trackOrder = [] page.trackOrder = page.trackOrder.filter((id) => known.has(id))
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id)) for (const l of page.layers) {
if (!inGroup.has(l.id) && !page.trackOrder.includes(l.id)) {
for (const l of scene.layers) { page.trackOrder.unshift(l.id)
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
scene.trackOrder.unshift(l.id)
} }
} }
for (const t of scene.clipTracks) { for (const t of page.clipTracks) {
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) { if (!inGroup.has(t.id) && !page.trackOrder.includes(t.id)) {
scene.trackOrder.push(t.id) page.trackOrder.push(t.id)
} }
} }
for (const g of scene.groups) { for (const g of page.groups) {
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id) if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id)
} }
for (const st of scene.scriptTracks) { for (const st of page.scriptTracks) {
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id) if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
} }
// Migration: scenes saved before `colorOverridden` existed must treat for (const l of page.layers) {
// 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) {
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true 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 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 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 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 { export function createBlankScenePage(name: string, id?: string): ScenePage {
return normalizeScene({ 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, version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' }, canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10, duration: 10,
fps: 60, fps: 60,
assets: [], 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', { export const useSceneStore = defineStore('scene', {
state: (): State => ({ state: (): State => ({
scene: createBlankScene(), project: createBlankProject(),
selectedLayerId: null, selectedLayerId: null,
selectedLayerIds: [], selectedLayerIds: [],
selectedClipInstanceId: null, selectedClipInstanceId: null,
@@ -285,14 +383,48 @@ export const useSceneStore = defineStore('scene', {
drawingLayerId: null, drawingLayerId: null,
}), }),
getters: { getters: {
activeClip(state): Clip | null { /** The active page from `project.scenes`. Use this when you need to
if (!state.activeClipId) return null * mutate per-page fields (e.g., reassign `activeScene.layers`). */
const clips = state.scene.clips ?? [] activeScene(state): ScenePage {
return clips.find((c) => c.id === state.activeClipId) ?? null const p = state.project
return p.scenes.find((s) => s.id === p.activeSceneId) ?? p.scenes[0]
}, },
activeButtonAsset(state): ButtonAsset | null { /** Merged Scene-shaped view: shared project fields combined with the
if (!state.activeButtonAssetId) return null * active page's arrays. Read-only by convention — array references
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId) * 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 return asset && asset.kind === 'button' ? asset : null
}, },
activeLayers(): Layer[] { activeLayers(): Layer[] {
@@ -429,8 +561,8 @@ export const useSceneStore = defineStore('scene', {
const layers = this.activeLayers const layers = this.activeLayers
const idx = layers.findIndex((l) => l.id === id) const idx = layers.findIndex((l) => l.id === id)
if (idx >= 0) layers.splice(idx, 1) if (idx >= 0) layers.splice(idx, 1)
if (!this.activeClipId && this.scene.trackOrder) { if (!this.activeClipId) {
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) this.activeScene.trackOrder = this.activeScene.trackOrder.filter((x) => x !== id)
} }
if (this.selectedLayerId === id) { if (this.selectedLayerId === id) {
this.selectedLayerId = null this.selectedLayerId = null
@@ -500,41 +632,41 @@ export const useSceneStore = defineStore('scene', {
clip.duration = Math.max(0.1, seconds) clip.duration = Math.max(0.1, seconds)
if (this.playhead > clip.duration) this.playhead = clip.duration if (this.playhead > clip.duration) this.playhead = clip.duration
} else { } else {
this.scene.duration = Math.max(0.1, seconds) this.project.duration = Math.max(0.1, seconds)
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration if (this.playhead > this.project.duration) this.playhead = this.project.duration
} }
}, },
setFps(fps: number) { 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) { setCanvasSize(width: number, height: number) {
this.scene.canvas.width = Math.max(1, Math.round(width)) this.project.canvas.width = Math.max(1, Math.round(width))
this.scene.canvas.height = Math.max(1, Math.round(height)) this.project.canvas.height = Math.max(1, Math.round(height))
}, },
setCanvasBackground(color: string) { setCanvasBackground(color: string) {
this.scene.canvas.background = color this.project.canvas.background = color
}, },
setBackgroundVideo(video: BackgroundVideo | null) { setBackgroundVideo(video: BackgroundVideo | null) {
if (video && video.src) this.scene.backgroundVideo = { ...video } if (video && video.src) this.activeScene.backgroundVideo = { ...video }
else delete this.scene.backgroundVideo else delete this.activeScene.backgroundVideo
}, },
setPlaybackRange(inTime: number | null, outTime: number | null) { setPlaybackRange(inTime: number | null, outTime: number | null) {
const dur = this.scene.duration const dur = this.project.duration
const hasIn = inTime !== null && inTime !== undefined const hasIn = inTime !== null && inTime !== undefined
const hasOut = outTime !== null && outTime !== undefined const hasOut = outTime !== null && outTime !== undefined
if (hasIn) { if (hasIn) {
const v = Math.max(0, Math.min(dur, inTime!)) const v = Math.max(0, Math.min(dur, inTime!))
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime if (v === 0 && this.project.outTime === undefined) delete this.project.inTime
else this.scene.inTime = v else this.project.inTime = v
} }
if (hasOut) { if (hasOut) {
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!)) const v = Math.max(this.project.inTime ?? 0, Math.min(dur, outTime!))
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime if (v === dur && this.project.inTime === undefined) delete this.project.outTime
else this.scene.outTime = v else this.project.outTime = v
} }
// Re-clamp playhead into the new range // Re-clamp playhead into the new range
const lo = this.scene.inTime ?? 0 const lo = this.project.inTime ?? 0
const hi = this.scene.outTime ?? dur const hi = this.project.outTime ?? dur
if (this.playhead < lo) this.playhead = lo if (this.playhead < lo) this.playhead = lo
else if (this.playhead > hi) this.playhead = hi 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 || asset.kind !== 'polygon') return
if (asset.points.length < 2) { if (asset.points.length < 2) {
this.removeLayer(layerId) 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() { cancelDrawing() {
@@ -1053,7 +1185,7 @@ export const useSceneStore = defineStore('scene', {
this.drawingAssetId = null this.drawingAssetId = null
this.drawingLayerId = null this.drawingLayerId = null
if (layerId) this.removeLayer(layerId) 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( setPolygonVertexType(
assetId: string, assetId: string,
@@ -1361,8 +1493,7 @@ export const useSceneStore = defineStore('scene', {
return m return m
}, },
removeMarker(id: string) { removeMarker(id: string) {
if (!this.scene.markers) return this.activeScene.markers = this.activeScene.markers.filter((m) => m.id !== id)
this.scene.markers = this.scene.markers.filter((m) => m.id !== id)
}, },
renameMarker(id: string, name: string): boolean { renameMarker(id: string, name: string): boolean {
const markers = this.scene.markers const markers = this.scene.markers
@@ -1398,15 +1529,10 @@ export const useSceneStore = defineStore('scene', {
return track return track
}, },
removeScriptTrack(id: string) { removeScriptTrack(id: string) {
if (this.scene.scriptTracks) { const page = this.activeScene
this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id) page.scriptTracks = page.scriptTracks.filter((t) => t.id !== id)
} page.scriptFrames = page.scriptFrames.filter((f) => f.trackId !== id)
if (this.scene.scriptFrames) { page.trackOrder = page.trackOrder.filter((tid) => tid !== id)
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)
}
}, },
setScriptTrackName(id: string, name: string) { setScriptTrackName(id: string, name: string) {
const track = this.scene.scriptTracks?.find((t) => t.id === id) const track = this.scene.scriptTracks?.find((t) => t.id === id)
@@ -1433,8 +1559,7 @@ export const useSceneStore = defineStore('scene', {
return frame return frame
}, },
removeScriptFrame(id: string) { removeScriptFrame(id: string) {
if (!this.scene.scriptFrames) return this.activeScene.scriptFrames = this.activeScene.scriptFrames.filter((f) => f.id !== id)
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id)
}, },
setScriptFrameActions(id: string, actions: Action[]) { setScriptFrameActions(id: string, actions: Action[]) {
const frame = this.scene.scriptFrames?.find((f) => f.id === id) const frame = this.scene.scriptFrames?.find((f) => f.id === id)
@@ -1627,17 +1752,155 @@ export const useSceneStore = defineStore('scene', {
layer.separated = false layer.separated = false
} }
}, },
loadScene(scene: Scene) { /** Replace the entire project. Resets editor context (selection,
this.scene = normalizeScene(scene) * playback, clip / button-edit drill-down). */
loadProject(project: Project) {
this.project = normalizeProject(project)
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.playhead = 0 this.playhead = 0
this.playing = false this.playing = false
this.activeClipId = null this.activeClipId = null
this.activeButtonAssetId = null this.activeButtonAssetId = null
this.activeButtonEditMode = null this.activeButtonEditMode = null
this.activeButtonState = null this.activeButtonState = null
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
}, },
/** Backward-compat shim: legacy callers (older project files, recents
* stored as Scene, tests) still pass a Scene. Wrap into a one-page
* Project and delegate. */
loadScene(scene: Scene) {
this.loadProject(projectFromLegacyScene(scene))
},
/** Replace the project data only — for use by the undo/redo layer.
* Unlike `loadProject`, transient editor state (playhead, selection,
* activeClipId, activeButtonAssetId, drawing) is left untouched so
* undoing a content change doesn't kick the user out of clip-edit
* or reset where they were scrubbing. The caller is responsible for
* pruning any selection that now points at a vanished id. */
applyProjectSnapshot(project: Project) {
this.project = normalizeProject(project)
},
// -------- Scene pages ----------------------------------------------
/** Append a fresh, empty page and activate it. The default name is
* "Scene N" with N counting up from the current page count. */
addScene(name?: string): ScenePage {
const final = name?.trim() || `Scene ${this.project.scenes.length + 1}`
const page = createBlankScenePage(final)
this.project.scenes.push(page)
this.switchScene(page.id)
return page
},
/** Activate a page by id. Clears any drill-down state (clip-edit,
* button-edit, selections, drawing) so the new page isn't shown
* through the lens of a context that belongs to another page. */
switchScene(id: string): boolean {
if (!this.project.scenes.find((s) => s.id === id)) return false
this.project.activeSceneId = id
this.activeClipId = null
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
this.playing = false
return true
},
renameScene(id: string, name: string): boolean {
const page = this.project.scenes.find((s) => s.id === id)
if (!page) return false
const next = name.trim()
if (!next) return false
page.name = next
return true
},
/** Remove a page. Refuses to delete the last remaining page (a
* project must always have at least one). If the active page is
* removed, activates the neighbour. */
removeScene(id: string): boolean {
const scenes = this.project.scenes
if (scenes.length <= 1) return false
const idx = scenes.findIndex((s) => s.id === id)
if (idx < 0) return false
const wasActive = this.project.activeSceneId === id
scenes.splice(idx, 1)
if (wasActive) {
const neighbour = scenes[Math.min(idx, scenes.length - 1)]
this.switchScene(neighbour.id)
}
return true
},
/** Deep-clone the named page with fresh ids on every layer, keyframe,
* clip-instance, script-frame, marker, group, and trackOrder entry.
* Inserted directly after the original; activated. */
duplicateScene(id: string): ScenePage | null {
const orig = this.project.scenes.find((s) => s.id === id)
if (!orig) return null
const cloned = structuredClone(toRaw(orig)) as ScenePage
// Build a complete id-remap (every layer, group, clipTrack, scriptTrack)
// so trackOrder/group childIds/scriptFrames stay internally consistent.
const remap = new Map<string, string>()
const remapId = (oldId: string) => {
let next = remap.get(oldId)
if (!next) {
next = uid()
remap.set(oldId, next)
}
return next
}
cloned.id = uid()
cloned.name = `${orig.name} copy`
for (const l of cloned.layers) {
l.id = remapId(l.id)
l.keyframes = l.keyframes.map((k) => ({ ...k, id: uid() }))
if (l.positionKeyframes) {
l.positionKeyframes = l.positionKeyframes.map((k) => ({ ...k, id: uid() }))
}
if (l.rotationKeyframes) {
l.rotationKeyframes = l.rotationKeyframes.map((k) => ({ ...k, id: uid() }))
}
}
for (const t of cloned.clipTracks) t.id = remapId(t.id)
for (const st of cloned.scriptTracks) st.id = remapId(st.id)
for (const g of cloned.groups) {
g.id = remapId(g.id)
g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid)
}
cloned.trackOrder = cloned.trackOrder.map((oid) => remap.get(oid) ?? oid)
cloned.clipInstances = cloned.clipInstances.map((ci) => ({
...ci,
id: uid(),
clipTrackId: remap.get(ci.clipTrackId) ?? ci.clipTrackId,
}))
cloned.scriptFrames = cloned.scriptFrames.map((f) => ({
...f,
id: uid(),
trackId: remap.get(f.trackId) ?? f.trackId,
}))
cloned.markers = cloned.markers.map((m) => ({ ...m, id: uid() }))
const idx = this.project.scenes.findIndex((s) => s.id === id)
this.project.scenes.splice(idx + 1, 0, cloned)
this.switchScene(cloned.id)
return cloned
},
/** Remove an asset from the project library. Layers that referenced
* it remain — they'll just render as nothing. Use this from UI
* components instead of mutating `project.assets` inline. */
removeAsset(id: string) {
this.project.assets = this.project.assets.filter((a) => a.id !== id)
},
addClip(name?: string): Clip { addClip(name?: string): Clip {
const clip: Clip = { const clip: Clip = {
id: uid(), id: uid(),
@@ -1702,9 +1965,10 @@ export const useSceneStore = defineStore('scene', {
// group they were part of. Clearing in one pass keeps Pinia's // group they were part of. Clearing in one pass keeps Pinia's
// reactivity from thrashing on intermediate states. // reactivity from thrashing on intermediate states.
const idSet = new Set(ids) const idSet = new Set(ids)
this.scene.layers = this.scene.layers.filter((l) => !idSet.has(l.id)) const page = this.activeScene
this.scene.trackOrder = order.filter((x) => !idSet.has(x)) page.layers = page.layers.filter((l) => !idSet.has(l.id))
for (const g of this.scene.groups ?? []) { page.trackOrder = order.filter((x) => !idSet.has(x))
for (const g of page.groups) {
g.childIds = g.childIds.filter((x) => !idSet.has(x)) g.childIds = g.childIds.filter((x) => !idSet.has(x))
} }
@@ -1733,16 +1997,17 @@ export const useSceneStore = defineStore('scene', {
return clip.id return clip.id
}, },
removeClip(id: string) { removeClip(id: string) {
if (this.scene.clips) { this.project.clips = this.project.clips.filter((c) => c.id !== id)
this.scene.clips = this.scene.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
if (this.scene.clipInstances) { // dangling references in non-active pages.
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id) for (const page of this.project.scenes) {
page.clipInstances = page.clipInstances.filter((ci) => ci.clipId !== id)
} }
if (this.activeClipId === id) { if (this.activeClipId === id) {
this.activeClipId = null this.activeClipId = null
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.playhead = 0 this.playhead = 0
} }
}, },
@@ -1851,15 +2116,10 @@ export const useSceneStore = defineStore('scene', {
return null return null
}, },
removeClipTrack(id: string) { removeClipTrack(id: string) {
if (this.scene.clipTracks) { const page = this.activeScene
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id) page.clipTracks = page.clipTracks.filter((t) => t.id !== id)
} page.clipInstances = page.clipInstances.filter((ci) => ci.clipTrackId !== id)
if (this.scene.clipInstances) { page.trackOrder = page.trackOrder.filter((x) => x !== id)
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id)
}
if (this.scene.trackOrder) {
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
}
}, },
addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null { addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null {
const clip = this.scene.clips?.find((c) => c.id === clipId) 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) inst.startTime = Math.max(0, startTime)
}, },
removeClipInstance(id: string) { removeClipInstance(id: string) {
if (!this.scene.clipInstances) return this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id)
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
}, },
/** Update the per-instance x / y / scaleX / scaleY. Missing fields /** Update the per-instance x / y / scaleX / scaleY. Missing fields
@@ -2020,10 +2279,10 @@ export const useSceneStore = defineStore('scene', {
removeTrackGroup(id: string) { removeTrackGroup(id: string) {
const group = this.scene.groups?.find((g) => g.id === id) const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return if (!group) return
const order = this.scene.trackOrder ?? [] const order = this.activeScene.trackOrder
const idx = order.indexOf(id) const idx = order.indexOf(id)
if (idx >= 0) order.splice(idx, 1, ...group.childIds) 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) { renameGroup(id: string, name: string) {
const group = this.scene.groups?.find((g) => g.id === id) const group = this.scene.groups?.find((g) => g.id === id)
+45
View File
@@ -299,6 +299,51 @@ export interface Scene {
markers?: Marker[] 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 = { export const DEFAULT_BASE_PROPS: AnimatableProps = {
x: 0, x: 0,
y: 0, y: 0,