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 |