From fcb8edbb886f1c52ef5173614ca7f2c0ff9dc31d Mon Sep 17 00:00:00 2001 From: th Date: Wed, 22 Apr 2026 17:43:44 +0200 Subject: [PATCH] 0.10.3: preloader + multi-scene export parity, jumpToScene rewinds Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/ExportDialog.vue | 37 ++- src/components/FullscreenPreview.vue | 20 +- src/devtools/tests.ts | 65 ++++- src/lib/exportPlayer.ts | 419 ++++++++++++++++++++++----- src/stores/scene.ts | 20 +- 6 files changed, 461 insertions(+), 102 deletions(-) diff --git a/package.json b/package.json index 40a1222..8f5c960 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.10.2", + "version": "0.10.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue index a25b06b..12641ae 100644 --- a/src/components/ExportDialog.vue +++ b/src/components/ExportDialog.vue @@ -47,7 +47,34 @@ const hasHiddenLayers = computed(() => { return false }) -function sceneClone() { +function projectClone() { + // Project-shaped payload for standalone static exports — the + // embedded runtime handles multi-scene playback natively. + const project = JSON.parse(JSON.stringify(store.project)) + if (!skipHidden.value) return project + for (const page of project.scenes) { + page.layers = page.layers.filter((l: Layer) => !l.editorHidden) + } + if (Array.isArray(project.clips)) { + for (const clip of project.clips) { + clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden) + } + } + if (Array.isArray(project.assets)) { + for (const asset of project.assets as ButtonAsset[]) { + if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) { + asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden) + } + } + } + return project +} + +function activeSceneClone() { + // Scene-shaped payload for linear render paths (webm/mp4 server). + // Video is a single timeline — we only render the currently active + // page. A user who wants a different scene rendered needs to switch + // it in the drawer first. const scene = JSON.parse(JSON.stringify(store.scene)) if (!skipHidden.value) return scene scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden) @@ -67,7 +94,7 @@ function sceneClone() { } function exportScene() { - downloadSceneExport(sceneClone()) + downloadSceneExport(projectClone()) emit('update:modelValue', false) } @@ -77,7 +104,7 @@ function exportRuntime() { } function exportHtml() { - downloadExampleHtml(sceneClone()) + downloadExampleHtml(projectClone()) emit('update:modelValue', false) } @@ -92,7 +119,7 @@ async function exportWebm() { } store.pause() try { - await downloadSceneAsVideo(sceneClone(), { + await downloadSceneAsVideo(activeSceneClone(), { signal: controller.signal, onProgress: (p) => { if (progress.value) progress.value.progress = p @@ -120,7 +147,7 @@ async function exportMp4Server() { } store.pause() try { - await downloadFromServer(sceneClone(), { + await downloadFromServer(activeSceneClone(), { signal: controller.signal, onProgress: (p) => { if (progress.value) progress.value.progress = p diff --git a/src/components/FullscreenPreview.vue b/src/components/FullscreenPreview.vue index b203adc..cc62360 100644 --- a/src/components/FullscreenPreview.vue +++ b/src/components/FullscreenPreview.vue @@ -56,17 +56,17 @@ async function startPreview() { * player and rebuild at the same playhead so visual continuity stays * intact across the switch. */ function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) { - player = mountPlayer(el, store.scene, { + player = mountPlayer(el, store.project, { loop: true, onFrame(t: number, paused: boolean) { localTime.value = t playing.value = !paused }, onJumpToScene(sceneId: string) { - // Route the runtime's jump into the store. The watcher below - // picks up the activeSceneId change and re-mounts the player - // against the new page at the current time. - store.switchScene(sceneId) + // Route the runtime's jump into the store and restart the + // target scene at its in-point — script/button jumps are + // "transition to new chapter", not "keep scrubbing". + store.switchScene(sceneId, { resetPlayhead: true }) }, }) player.seek(time) @@ -79,10 +79,12 @@ watch( if (!props.modelValue) return const el = frameEl.value if (!el) return - // Every scene switch auto-plays on the new page — matches the - // store-level switchScene contract. We keep the current playhead - // so animation continuity stays intact across the swap. - const keepTime = player ? player.getTime() : store.playhead + // Seek the remounted player to whatever `store.playhead` is after + // the switch. Drawer clicks leave it unchanged; scripted/button + // jumps reset it to the in-point via resetPlayhead. Reading the + // store (not the old player's currentT) means both paths land on + // the expected frame. + const keepTime = store.playhead destroyPlayer() nextTick(() => { if (!props.modelValue) return diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index d93f414..a0c69ac 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -1018,6 +1018,48 @@ export function registerAllTests(): void { expect(r).toContain('ctx.scale(isx2,isy2)') }) + test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => { + // Loading big images, video metadata and audio `canplaythrough` + // must settle before the rAF kicks off — otherwise the first few + // frames stutter while the browser decodes. Structural pins: + // - A spinner