0.10.3: preloader + multi-scene export parity, jumpToScene rewinds

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 17:43:44 +02:00
1 parent 87de1344c4
commit fcb8edbb88
6 files changed
+461 -102

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "animatr",
"private": true, "private": true,
"version": "0.10.2", "version": "0.10.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+32 -5
View File
@@ -47,7 +47,34 @@ const hasHiddenLayers = computed(() => {
return false 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)) const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden) scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
@@ -67,7 +94,7 @@ function sceneClone() {
} }
function exportScene() { function exportScene() {
downloadSceneExport(sceneClone()) downloadSceneExport(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -77,7 +104,7 @@ function exportRuntime() {
} }
function exportHtml() { function exportHtml() {
downloadExampleHtml(sceneClone()) downloadExampleHtml(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -92,7 +119,7 @@ async function exportWebm() {
} }
store.pause() store.pause()
try { try {
await downloadSceneAsVideo(sceneClone(), { await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal, signal: controller.signal,
onProgress: (p) => { onProgress: (p) => {
if (progress.value) progress.value.progress = p if (progress.value) progress.value.progress = p
@@ -120,7 +147,7 @@ async function exportMp4Server() {
} }
store.pause() store.pause()
try { try {
await downloadFromServer(sceneClone(), { await downloadFromServer(activeSceneClone(), {
signal: controller.signal, signal: controller.signal,
onProgress: (p) => { onProgress: (p) => {
if (progress.value) progress.value.progress = p if (progress.value) progress.value.progress = p
+11 -9
View File
@@ -56,17 +56,17 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays * player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */ * intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) { function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
player = mountPlayer(el, store.scene, { player = mountPlayer(el, store.project, {
loop: true, loop: true,
onFrame(t: number, paused: boolean) { onFrame(t: number, paused: boolean) {
localTime.value = t localTime.value = t
playing.value = !paused playing.value = !paused
}, },
onJumpToScene(sceneId: string) { onJumpToScene(sceneId: string) {
// Route the runtime's jump into the store. The watcher below // Route the runtime's jump into the store and restart the
// picks up the activeSceneId change and re-mounts the player // target scene at its in-point — script/button jumps are
// against the new page at the current time. // "transition to new chapter", not "keep scrubbing".
store.switchScene(sceneId) store.switchScene(sceneId, { resetPlayhead: true })
}, },
}) })
player.seek(time) player.seek(time)
@@ -79,10 +79,12 @@ watch(
if (!props.modelValue) return if (!props.modelValue) return
const el = frameEl.value const el = frameEl.value
if (!el) return if (!el) return
// Every scene switch auto-plays on the new page — matches the // Seek the remounted player to whatever `store.playhead` is after
// store-level switchScene contract. We keep the current playhead // the switch. Drawer clicks leave it unchanged; scripted/button
// so animation continuity stays intact across the swap. // jumps reset it to the in-point via resetPlayhead. Reading the
const keepTime = player ? player.getTime() : store.playhead // store (not the old player's currentT) means both paths land on
// the expected frame.
const keepTime = store.playhead
destroyPlayer() destroyPlayer()
nextTick(() => { nextTick(() => {
if (!props.modelValue) return if (!props.modelValue) return
+60 -5
View File
@@ -1018,6 +1018,48 @@ export function registerAllTests(): void {
expect(r).toContain('ctx.scale(isx2,isy2)') 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 <style> is injected.
// - A preloader overlay is appended.
// - Image/audio/video promises are awaited together.
// - canplaythrough is the event we wait on for audio.
const r = generateRuntimeScript()
expect(r).toContain('ruwio-spinner-style')
expect(r).toContain('ruwio-preloader')
expect(r).toContain('Promise.all')
expect(r).toContain('canplaythrough')
expect(r).toContain('preloadAudioClips')
expect(r).toContain('preloadBgVideos')
})
test('runtime honours separated position / rotation keyframes', () => {
// Parity regression: the editor samples separated position and
// rotation tracks via `sampleLayer()` when `layer.separated`;
// the runtime used to only look at the main keyframes array.
// Animations set up with Separate-toggled layers played flat
// in exports. Pin the helper and the per-branch merge.
const r = generateRuntimeScript()
expect(r).toContain('sampleKeyframeSet')
expect(r).toContain('layer.separated')
expect(r).toContain('layer.positionKeyframes')
expect(r).toContain('layer.rotationKeyframes')
})
test('runtime supports multi-scene switching (jumpToScene + applySceneSwitch)', () => {
// Exported bundles now carry the whole Project and handle
// jumpToScene internally. The runtime must expose a scene-
// switch helper and NOT fall through to the "unsupported"
// warning message anymore.
const r = generateRuntimeScript()
expect(r).toContain('applySceneSwitch')
expect(r).toContain('project.scenes')
expect(r).toContain('currentSceneId')
expect(r.indexOf('does not support multi-scene')).toBe(-1)
})
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => { test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)` // Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — the time of the last keyframe — when scene.outTime was unset. // — the time of the last keyframe — when scene.outTime was unset.
@@ -1356,6 +1398,19 @@ export function registerAllTests(): void {
expect(s.project.activeSceneId).toBe(two.id) expect(s.project.activeSceneId).toBe(two.id)
}) })
test('switchScene with resetPlayhead: true rewinds to the in-point', () => {
// Script and button jumps should restart the target scene at
// its in-point — otherwise chapter-like transitions play from
// wherever the previous scene was paused.
const s = makeFreshStore()
s.setDuration(10)
const two = s.addScene('Two')
s.setPlayhead(4.5)
expect(s.playhead).toBe(4.5)
s.switchScene(two.id, { resetPlayhead: true })
expect(s.playhead).toBe(0)
})
test('switchScene leaves playback state alone — editor navigation does not force play', () => { test('switchScene leaves playback state alone — editor navigation does not force play', () => {
// The editor treats scene-switch as a navigation gesture. The // The editor treats scene-switch as a navigation gesture. The
// fullscreen preview is the one that auto-plays on switch (via // fullscreen preview is the one that auto-plays on switch (via
@@ -6381,11 +6436,10 @@ export function registerAllTests(): void {
await tick() await tick()
}) })
test('a scripted jumpToScene switches the active scene and keeps playing', async () => { test('a scripted jumpToScene switches the scene, keeps playing, and rewinds to the in-point', async () => {
// User-facing contract: a script track fires a scene-switch in the // Chapter-transition semantics: the incoming scene starts at
// middle of a running timeline — the active scene flips and the // the project's in-point no matter where the outgoing scene was
// timeline keeps running on the new page. All pages share // paused. Keeps playback running across the switch.
// duration/fps, so the playhead stays put and continues onward.
const s = makeFreshStore() const s = makeFreshStore()
s.setDuration(10) s.setDuration(10)
const aId = s.project.activeSceneId const aId = s.project.activeSceneId
@@ -6397,6 +6451,7 @@ export function registerAllTests(): void {
s.tick(3) s.tick(3)
expect(s.project.activeSceneId).toBe(bId) expect(s.project.activeSceneId).toBe(bId)
expect(s.playing).toBe(true) expect(s.playing).toBe(true)
expect(s.playhead).toBe(s.playbackIn)
await tick() await tick()
}) })
+343 -76
View File
@@ -1,6 +1,16 @@
import type { Scene } from '@/types/scene' import type { Project, Scene } from '@/types/scene'
const RUNTIME_VERSION = '20' /** Input accepted by every export helper below. A Project exports all
* its scenes (jumpToScene works in the bundle); a single Scene is
* wrapped into a one-page Project for back-compat with the fullscreen
* preview callers. */
type ExportPayload = Project | Scene
function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '24'
const ENGINE_SOURCE = `(function(){ const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return; if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -86,23 +96,26 @@ const ENGINE_SOURCE = `(function(){
else if(a.shadowColor) out.shadowColor=a.shadowColor; else if(a.shadowColor) out.shadowColor=a.shadowColor;
return out; return out;
} }
function sample(layer,time){ // Interpolate a single keyframe track against 'base' at 'time'.
var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;}); // Mirrors the editor's sampleKeyframeSet so separated position /
if(!kfs.length)return merge(layer.base); // rotation tracks play identically.
if(time<=kfs[0].time)return merge(layer.base,kfs[0].props); function sampleKeyframeSet(kfsIn, base, time){
var kfs=(kfsIn||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return merge(base);
if(time<=kfs[0].time) return merge(base,kfs[0].props);
var last=kfs[kfs.length-1]; var last=kfs[kfs.length-1];
if(time>=last.time)return merge(layer.base,last.props); if(time>=last.time) return merge(base,last.props);
for(var i=0;i<kfs.length-1;i++){ for(var i=0;i<kfs.length-1;i++){
var a=kfs[i],b=kfs[i+1]; var a=kfs[i],b=kfs[i+1];
if(time>=a.time&&time<=b.time){ if(time>=a.time&&time<=b.time){
if(a.hold){ if(a.hold){
if(time>=b.time)return merge(layer.base,b.props); if(time>=b.time) return merge(base,b.props);
return merge(layer.base,a.props); return merge(base,a.props);
} }
var raw=(time-a.time)/(b.time-a.time); var raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw); var t=easing(b.easing,raw);
var pa=merge(layer.base,a.props); var pa=merge(base,a.props);
var pb=merge(layer.base,b.props); var pb=merge(base,b.props);
var outSample=lerpProps(pa,pb,t); var outSample=lerpProps(pa,pb,t);
if(b.ctrlX!=null && b.ctrlY!=null){ if(b.ctrlX!=null && b.ctrlY!=null){
var om=1-t; var om=1-t;
@@ -112,7 +125,23 @@ const ENGINE_SOURCE = `(function(){
return outSample; return outSample;
} }
} }
return merge(layer.base); return merge(base);
}
function sample(layer,time){
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
if(!layer.separated) return main;
// Separated mode: position and rotation come from their own tracks
// if present, merged back over the main-track values. This matches
// the editor's sampleLayer() so animations on either side of the
// Separate-toggle play identically.
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
var outSep={};
for(var k in main) outSep[k]=main[k];
outSep.x=pos.x;
outSep.y=pos.y;
outSep.rotation=rot.rotation;
return outSep;
} }
function loadImages(assets){ function loadImages(assets){
@@ -128,10 +157,80 @@ const ENGINE_SOURCE = `(function(){
})).then(function(){return imgs;}); })).then(function(){return imgs;});
} }
function mount(container,scene,opts){ function mount(container,payload,opts){
if(!container)return; if(!container)return;
opts=opts||{}; opts=opts||{};
var loop=opts.loop!==false; var loop=opts.loop!==false;
// Accept either a Project (multi-scene payload used by standalone
// exports) or a legacy Scene (used by the in-editor fullscreen
// preview via store.scene). Internally everything is a Project.
var project, currentSceneId;
function mergeSceneView(p, page){
return {
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,
audioTracks: page.audioTracks || [],
audioClips: page.audioClips || []
};
}
if(payload && payload.scenes && payload.scenes.length){
project=payload;
var initial=null;
for(var si=0; si<project.scenes.length; si++){
if(project.scenes[si].id === project.activeSceneId){ initial=project.scenes[si]; break; }
}
if(!initial) initial=project.scenes[0];
currentSceneId=initial.id;
} else {
// Legacy single-scene payload — wrap it so the rest of the
// engine can operate uniformly.
var legacy=payload || {};
project={
canvas: legacy.canvas,
duration: legacy.duration,
fps: legacy.fps,
inTime: legacy.inTime,
outTime: legacy.outTime,
assets: legacy.assets || [],
clips: legacy.clips || [],
scenes: [{
id: '__legacy__', name: '',
layers: legacy.layers || [],
clipInstances: legacy.clipInstances || [],
clipTracks: legacy.clipTracks || [],
scriptTracks: legacy.scriptTracks || [],
scriptFrames: legacy.scriptFrames || [],
markers: legacy.markers || [],
groups: legacy.groups || [],
trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || []
}],
activeSceneId: '__legacy__'
};
currentSceneId='__legacy__';
}
var scene=mergeSceneView(project, project.scenes[0]);
for(var fi=0; fi<project.scenes.length; fi++){
if(project.scenes[fi].id===currentSceneId){ scene=mergeSceneView(project, project.scenes[fi]); break; }
}
// Honour explicit in/out points. When outTime isn't set, play the // Honour explicit in/out points. When outTime isn't set, play the
// full timeline (scene.duration) — matches the editor's playbackOut // full timeline (scene.duration) — matches the editor's playbackOut
// getter. Using the last keyframe as a fallback made still-frame // getter. Using the last keyframe as a fallback made still-frame
@@ -142,21 +241,31 @@ const ENGINE_SOURCE = `(function(){
var sceneEnd=rangeOut; var sceneEnd=rangeOut;
var cs=getComputedStyle(container); var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative'; if(cs.position==='static')container.style.position='relative';
var bgVideo=null;
if(scene.backgroundVideo && scene.backgroundVideo.src){ // Background videos: create one <video> element per scene that has
bgVideo=document.createElement('video'); // a bg video, preload them all, toggle display on scene-switch so
bgVideo.src=scene.backgroundVideo.src; // the active one plays and the others idle without reloading.
bgVideo.muted=true; var bgVideos={};
bgVideo.playsInline=true; for(var bvi=0; bvi<project.scenes.length; bvi++){
bgVideo.loop=!!loop; var bvs=project.scenes[bvi];
bgVideo.preload='auto'; if(bvs.backgroundVideo && bvs.backgroundVideo.src){
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;'; var bv=document.createElement('video');
container.appendChild(bgVideo); bv.src=bvs.backgroundVideo.src;
bv.muted=true;
bv.playsInline=true;
bv.loop=!!loop;
bv.preload='auto';
bv.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
if(bvs.id !== currentSceneId) bv.style.display='none';
container.appendChild(bv);
bgVideos[bvs.id]=bv;
}
} }
var bgVideo=bgVideos[currentSceneId] || null;
// --- Audio pool --- // --- Audio pool ---
var audioPool={}; var audioPool={};
var audioAssetsById={}; var audioAssetsById={};
(scene.assets||[]).forEach(function(a){ if(a.kind==='audio') audioAssetsById[a.id]=a; }); (project.assets||[]).forEach(function(a){ if(a.kind==='audio') audioAssetsById[a.id]=a; });
var audioClips=scene.audioClips||[]; var audioClips=scene.audioClips||[];
var audioTracksById={}; var audioTracksById={};
(scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; }); (scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; });
@@ -253,6 +362,50 @@ const ENGINE_SOURCE = `(function(){
}; };
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; }); (scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
/**
* Switch the live scene view to the given scene page. All cross-
* scene state (markers, audio lookup maps, active bg video,
* button runtime state) is rebuilt. Currently-playing audio
* clips pause immediately — the next syncAudio tick resumes
* whatever overlaps the playhead on the new page.
*/
function applySceneSwitch(newId){
var next=null;
for(var ssi=0; ssi<project.scenes.length; ssi++){
if(project.scenes[ssi].id === newId){ next=project.scenes[ssi]; break; }
}
if(!next) return false;
for(var ap in audioPool){
if(Object.prototype.hasOwnProperty.call(audioPool,ap) && audioPool[ap].el && !audioPool[ap].el.paused){
audioPool[ap].el.pause();
}
}
currentSceneId=newId;
scene=mergeSceneView(project, next);
audioClips=scene.audioClips||[];
audioTracksById={};
(scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; });
runtime.markers={};
(scene.markers||[]).forEach(function(m){ runtime.markers[m.name]=m.time; });
runtime.buttonStates={};
runtime.buttonDisabled={};
for(var bvk in bgVideos){
if(Object.prototype.hasOwnProperty.call(bgVideos,bvk)){
if(bvk === currentSceneId){ bgVideos[bvk].style.display=''; }
else { bgVideos[bvk].style.display='none'; try { bgVideos[bvk].pause(); } catch(e){} }
}
}
bgVideo=bgVideos[currentSceneId] || null;
bgStarted=false;
// Restart the incoming scene at the project in-point — matches
// the editor store-side switchScene with resetPlayhead=true.
// Script or button jumps transition between scenes; the target
// should play from frame 0 instead of inheriting the paused
// frame of the previous scene.
runtime.jumpTo=runtime.rangeIn;
return true;
}
// Populated once per draw(); used for pointer hit-testing. // Populated once per draw(); used for pointer hit-testing.
var buttonInstancesInFrame=[]; var buttonInstancesInFrame=[];
function resolveButtonState(layer){ function resolveButtonState(layer){
@@ -406,12 +559,13 @@ const ENGINE_SOURCE = `(function(){
return; return;
} }
case 'jumpToScene': { case 'jumpToScene': {
// Exports only carry one scene at a time, so jumpToScene is a // Host-driven swap (fullscreen preview routes through the
// warn-and-continue in standalone bundles. The in-editor // store and re-mounts). Fall back to the runtime's own
// fullscreen preview passes an onJumpToScene hook which routes // multi-scene switch for standalone bundles.
// the jump back to the store and re-mounts at the same time.
if (onJumpToScene){ try { onJumpToScene(act.sceneId); } catch(e){ runtime.warn('jumpToScene: '+((e&&e.message)||String(e))); } return; } if (onJumpToScene){ try { onJumpToScene(act.sceneId); } catch(e){ runtime.warn('jumpToScene: '+((e&&e.message)||String(e))); } return; }
runtime.warn('jumpToScene: exported runtime does not support multi-scene playback'); if(!applySceneSwitch(act.sceneId)){
runtime.warn('jumpToScene: unknown scene id "'+act.sceneId+'"');
}
return; return;
} }
case 'script': { case 'script': {
@@ -860,8 +1014,75 @@ const ENGINE_SOURCE = `(function(){
} }
ctx.restore(); ctx.restore();
} }
loadImages(scene.assets).then(function(loaded){ // --- Preloader ---
imgs=loaded; // Overlay a spinner while images, audio and background video
// finish loading. All clips get their <audio> element created
// up-front (and waited on via canplaythrough) so playback starts
// without stutter. Timeouts cap each pending asset at 10s to
// keep broken URLs from freezing the stage forever.
if(!document.getElementById('ruwio-spinner-style')){
var spinnerStyle=document.createElement('style');
spinnerStyle.id='ruwio-spinner-style';
spinnerStyle.textContent='@keyframes ruwio-spin{to{transform:rotate(360deg);}}';
document.head.appendChild(spinnerStyle);
}
var preloader=document.createElement('div');
preloader.className='ruwio-preloader';
preloader.style.cssText='position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.35);z-index:100;';
var spinnerDot=document.createElement('div');
spinnerDot.style.cssText='width:36px;height:36px;border:3px solid rgba(255,255,255,0.3);border-top-color:rgba(255,255,255,0.9);border-radius:50%;animation:ruwio-spin 1s linear infinite;';
preloader.appendChild(spinnerDot);
container.appendChild(preloader);
function waitForElement(el, readyEvent){
return new Promise(function(resolve){
var settled=false;
var finish=function(){ if(!settled){ settled=true; resolve(); } };
el.addEventListener(readyEvent, finish, { once:true });
el.addEventListener('error', finish, { once:true });
setTimeout(finish, 10000);
});
}
function preloadAudioClips(){
// Preload EVERY clip across every scene so jumpToScene doesn't
// stutter. audioPool is keyed by clip id, so these elements are
// reused verbatim by syncAudio.
var proms=[];
for(var spi=0; spi<project.scenes.length; spi++){
var spage=project.scenes[spi];
var sclips=spage.audioClips||[];
for(var pi=0;pi<sclips.length;pi++){
var c=sclips[pi];
var a=audioAssetsById[c.audioAssetId];
if(!a || !a.src) continue;
var el=document.createElement('audio');
el.preload='auto';
el.src=a.src;
el.style.display='none';
container.appendChild(el);
audioPool[c.id]={ el: el, assetId: c.audioAssetId };
proms.push(waitForElement(el, 'canplaythrough'));
try { el.load(); } catch(e) {}
}
}
return proms;
}
function preloadBgVideos(){
var proms=[];
for(var bvk in bgVideos){
if(Object.prototype.hasOwnProperty.call(bgVideos,bvk)){
proms.push(waitForElement(bgVideos[bvk], 'loadedmetadata'));
}
}
return proms;
}
Promise.all([
loadImages(project.assets),
Promise.all(preloadBgVideos()),
Promise.all(preloadAudioClips()),
]).then(function(results){
imgs=results[0];
if(preloader.parentNode) preloader.parentNode.removeChild(preloader);
rafId=requestAnimationFrame(frame); rafId=requestAnimationFrame(frame);
}); });
return { return {
@@ -881,7 +1102,10 @@ const ENGINE_SOURCE = `(function(){
canvas.removeEventListener('mouseup', onStageUp); canvas.removeEventListener('mouseup', onStageUp);
ro.disconnect(); ro.disconnect();
canvas.remove(); canvas.remove();
if(bgVideo) bgVideo.remove(); for(var dbvk in bgVideos){
if(Object.prototype.hasOwnProperty.call(bgVideos,dbvk)) bgVideos[dbvk].remove();
}
if(preloader && preloader.parentNode) preloader.parentNode.removeChild(preloader);
disposeAllAudio(); disposeAllAudio();
} }
}; };
@@ -938,8 +1162,9 @@ export interface MountOptions {
* button-click) `jumpToScene` calls this instead of the runtime's * button-click) `jumpToScene` calls this instead of the runtime's
* own warn-and-skip path. The host is responsible for swapping the * own warn-and-skip path. The host is responsible for swapping the
* Scene the player renders (typically by mutating the store and * Scene the player renders (typically by mutating the store and
* re-mounting with the new active page). Exported bundles omit this * re-mounting with the new active page). Exported bundles omit
* — they carry a single scene and warn on jumpToScene. */ * this — the runtime then switches scenes internally by walking
* `project.scenes` and toggling the preloaded audio/video pools. */
onJumpToScene?: (sceneId: string) => void onJumpToScene?: (sceneId: string) => void
} }
@@ -956,46 +1181,41 @@ function ensureEngine() {
export function mountPlayer( export function mountPlayer(
container: HTMLElement, container: HTMLElement,
scene: Scene, payload: ExportPayload,
opts?: MountOptions, opts?: MountOptions,
): PlayerHandle { ): PlayerHandle {
ensureEngine() ensureEngine()
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts) return cachedApi.mount(container, JSON.parse(JSON.stringify(payload)), opts)
} }
// ---- Export helpers ---- // ---- Export helpers ----
function jsonForExport(scene: Scene): string { function jsonForExport(payload: ExportPayload): string {
// Data-URL background videos and audio assets are stripped from the // Data-URL background videos and audio assets are stripped from the
// export — local media can't be embedded reasonably, and doing so // export — local media can't be embedded reasonably, and doing so
// would bloat the bundle into uselessness. The user has to host the // would bloat the bundle into uselessness. The user has to host the
// file externally and point `backgroundVideo.src` / `AudioAsset.src` // files externally and point `backgroundVideo.src` / `AudioAsset.src`
// at that URL before exporting. // at real URLs before exporting.
//
// We always emit a Project. A legacy single Scene gets wrapped in a
// one-page Project so the runtime can do its multi-scene plumbing
// uniformly.
const project: Project = isProject(payload)
? (payload as Project)
: sceneToLegacyProject(payload as Scene)
const warnings: string[] = [] const warnings: string[] = []
let clone: Scene = scene let out: Project = project
const bg = scene.backgroundVideo // Strip data-URL audio assets at the shared asset list.
if (bg && bg.src && bg.src.startsWith('data:')) { const dataAudio = out.assets.filter(
warnings.push('background video')
const { backgroundVideo, ...rest } = scene
void backgroundVideo
clone = rest as Scene
}
const audioAssets = clone.assets.filter(
(a) => a.kind === 'audio' && a.src && a.src.startsWith('data:'), (a) => a.kind === 'audio' && a.src && a.src.startsWith('data:'),
) )
if (audioAssets.length > 0) { if (dataAudio.length > 0) {
warnings.push( warnings.push(`${dataAudio.length} audio asset(s) without external URL`)
`${audioAssets.length} audio asset(s) without external URL`, out = {
) ...out,
// Replace the `src` with an empty string so the exported bundle's assets: out.assets.map((a) =>
// lookups still find the asset record (duration, name) and the
// runtime can render an empty block rather than crashing on a
// missing asset.
clone = {
...clone,
assets: clone.assets.map((a) =>
a.kind === 'audio' && a.src && a.src.startsWith('data:') a.kind === 'audio' && a.src && a.src.startsWith('data:')
? { ...a, src: '' } ? { ...a, src: '' }
: a, : a,
@@ -1003,6 +1223,21 @@ function jsonForExport(scene: Scene): string {
} }
} }
// Strip data-URL backgroundVideos from every scene.
const strippedScenes = out.scenes.map((page) => {
const bg = page.backgroundVideo
if (bg && bg.src && bg.src.startsWith('data:')) {
const { backgroundVideo, ...rest } = page
void backgroundVideo
warnings.push(`background video on scene "${page.name}"`)
return rest as typeof page
}
return page
})
if (strippedScenes.some((s, i) => s !== out.scenes[i])) {
out = { ...out, scenes: strippedScenes }
}
if (warnings.length > 0 && typeof window !== 'undefined' && window.console) { if (warnings.length > 0 && typeof window !== 'undefined' && window.console) {
window.console.warn( window.console.warn(
'[ruwio] export: stripped ' + '[ruwio] export: stripped ' +
@@ -1010,7 +1245,38 @@ function jsonForExport(scene: Scene): string {
' — host the files externally and set their src fields before exporting.', ' — host the files externally and set their src fields before exporting.',
) )
} }
return JSON.stringify(clone) return JSON.stringify(out)
}
function sceneToLegacyProject(scene: Scene): Project {
return {
version: 1,
canvas: scene.canvas,
duration: scene.duration,
fps: scene.fps,
inTime: scene.inTime,
outTime: scene.outTime,
assets: scene.assets,
clips: scene.clips ?? [],
scenes: [
{
id: '__legacy__',
name: 'Scene',
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,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
},
],
activeSceneId: '__legacy__',
}
} }
export interface ExportOptions { export interface ExportOptions {
@@ -1024,8 +1290,8 @@ export function generateRuntimeScript(): string {
) )
} }
export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string { export function generateSceneScript(payload: ExportPayload, options: ExportOptions = {}): string {
const sceneJson = jsonForExport(scene) const sceneJson = jsonForExport(payload)
const optsJson = JSON.stringify({ loop: options.loop !== false }) const optsJson = JSON.stringify({ loop: options.loop !== false })
return ( return (
'/* RUWIO scene — generated ' + new Date().toISOString() + '\n' + '/* RUWIO scene — generated ' + new Date().toISOString() + '\n' +
@@ -1035,17 +1301,17 @@ export function generateSceneScript(scene: Scene, options: ExportOptions = {}):
) )
} }
export function generateBundledScript(scene: Scene, options: ExportOptions = {}): string { export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
return ( return (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' + '/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE + ENGINE_SOURCE +
'\n' + '\n' +
generateSceneScriptBody(scene, options) generateSceneScriptBody(payload, options)
) )
} }
function generateSceneScriptBody(scene: Scene, options: ExportOptions): string { function generateSceneScriptBody(payload: ExportPayload, options: ExportOptions): string {
const sceneJson = jsonForExport(scene) const sceneJson = jsonForExport(payload)
const optsJson = JSON.stringify({ loop: options.loop !== false }) const optsJson = JSON.stringify({ loop: options.loop !== false })
return SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson) return SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
} }
@@ -1054,18 +1320,19 @@ export function downloadRuntime(filename = 'ruwio-runtime.js') {
triggerDownload(generateRuntimeScript(), filename, 'application/javascript') triggerDownload(generateRuntimeScript(), filename, 'application/javascript')
} }
export function downloadSceneExport(scene: Scene, filename = 'animation.js') { export function downloadSceneExport(payload: ExportPayload, filename = 'animation.js') {
triggerDownload(generateSceneScript(scene), filename, 'application/javascript') triggerDownload(generateSceneScript(payload), filename, 'application/javascript')
} }
export function downloadBundledExport(scene: Scene, filename = 'animation-bundled.js') { export function downloadBundledExport(payload: ExportPayload, filename = 'animation-bundled.js') {
triggerDownload(generateBundledScript(scene), filename, 'application/javascript') triggerDownload(generateBundledScript(payload), filename, 'application/javascript')
} }
export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string { export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = ENGINE_SOURCE const runtime = ENGINE_SOURCE
const sceneBoot = generateSceneScriptBody(scene, options) const sceneBoot = generateSceneScriptBody(payload, options)
const aspect = `${scene.canvas.width} / ${scene.canvas.height}` const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -1159,8 +1426,8 @@ ${sceneBoot}
` `
} }
export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') { export function downloadExampleHtml(payload: ExportPayload, filename = 'animation-example.html') {
const source = generateExampleHtml(scene) const source = generateExampleHtml(payload)
triggerDownload(source, filename, 'text/html') triggerDownload(source, filename, 'text/html')
} }
+14 -6
View File
@@ -1973,11 +1973,13 @@ export const useSceneStore = defineStore('scene', {
/** Activate a page by id. Clears any drill-down state (clip-edit, /** Activate a page by id. Clears any drill-down state (clip-edit,
* button-edit, selections, drawing) so the new page isn't shown * button-edit, selections, drawing) so the new page isn't shown
* through the lens of a context that belongs to another page. * through the lens of a context that belongs to another page.
* Playback state is left alone — in the editor, flipping scenes * Playback state is left alone by default — in the editor,
* is a navigation gesture and shouldn't force play. The fullscreen * flipping scenes is a navigation gesture and shouldn't force
* preview takes care of auto-playing on its own (the activeSceneId * play or rewind. Script-fired or button-fired jumps use
* watcher re-mounts the preview player with play enabled). */ * `{ resetPlayhead: true }` instead so the incoming scene starts
switchScene(id: string): boolean { * at its own in-point rather than inheriting the previous
* scene's paused frame. */
switchScene(id: string, options?: { resetPlayhead?: boolean }): boolean {
if (!this.project.scenes.find((s) => s.id === id)) return false if (!this.project.scenes.find((s) => s.id === id)) return false
this.project.activeSceneId = id this.project.activeSceneId = id
this.activeClipId = null this.activeClipId = null
@@ -1990,6 +1992,9 @@ export const useSceneStore = defineStore('scene', {
this.drawingMode = null this.drawingMode = null
this.drawingAssetId = null this.drawingAssetId = null
this.drawingLayerId = null this.drawingLayerId = null
if (options?.resetPlayhead) {
this.playhead = this.playbackIn
}
return true return true
}, },
renameScene(id: string, name: string): boolean { renameScene(id: string, name: string): boolean {
@@ -2623,7 +2628,10 @@ export const useSceneStore = defineStore('scene', {
self.pause() self.pause()
}, },
switchScene(sceneId: string) { switchScene(sceneId: string) {
self.switchScene(sceneId) // Script/button jumps restart the target scene at its in-point.
// Drawer clicks go through store.switchScene directly and keep
// the current playhead — navigation vs. transition.
self.switchScene(sceneId, { resetPlayhead: true })
}, },
warn(msg: string) { warn(msg: string) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console