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",
"private": true,
"version": "0.10.2",
"version": "0.10.3",
"type": "module",
"scripts": {
"dev": "vite",
+32 -5
View File
@@ -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
+11 -9
View File
@@ -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
+60 -5
View File
@@ -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 <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)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — 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)
})
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', () => {
// The editor treats scene-switch as a navigation gesture. The
// fullscreen preview is the one that auto-plays on switch (via
@@ -6381,11 +6436,10 @@ export function registerAllTests(): void {
await tick()
})
test('a scripted jumpToScene switches the active scene and keeps playing', async () => {
// User-facing contract: a script track fires a scene-switch in the
// middle of a running timeline — the active scene flips and the
// timeline keeps running on the new page. All pages share
// duration/fps, so the playhead stays put and continues onward.
test('a scripted jumpToScene switches the scene, keeps playing, and rewinds to the in-point', async () => {
// Chapter-transition semantics: the incoming scene starts at
// the project's in-point no matter where the outgoing scene was
// paused. Keeps playback running across the switch.
const s = makeFreshStore()
s.setDuration(10)
const aId = s.project.activeSceneId
@@ -6397,6 +6451,7 @@ export function registerAllTests(): void {
s.tick(3)
expect(s.project.activeSceneId).toBe(bId)
expect(s.playing).toBe(true)
expect(s.playhead).toBe(s.playbackIn)
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(){
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;
return out;
}
function sample(layer,time){
var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length)return merge(layer.base);
if(time<=kfs[0].time)return merge(layer.base,kfs[0].props);
// Interpolate a single keyframe track against 'base' at 'time'.
// Mirrors the editor's sampleKeyframeSet so separated position /
// rotation tracks play identically.
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];
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++){
var a=kfs[i],b=kfs[i+1];
if(time>=a.time&&time<=b.time){
if(a.hold){
if(time>=b.time)return merge(layer.base,b.props);
return merge(layer.base,a.props);
if(time>=b.time) return merge(base,b.props);
return merge(base,a.props);
}
var raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw);
var pa=merge(layer.base,a.props);
var pb=merge(layer.base,b.props);
var pa=merge(base,a.props);
var pb=merge(base,b.props);
var outSample=lerpProps(pa,pb,t);
if(b.ctrlX!=null && b.ctrlY!=null){
var om=1-t;
@@ -112,7 +125,23 @@ const ENGINE_SOURCE = `(function(){
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){
@@ -128,10 +157,80 @@ const ENGINE_SOURCE = `(function(){
})).then(function(){return imgs;});
}
function mount(container,scene,opts){
function mount(container,payload,opts){
if(!container)return;
opts=opts||{};
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
// full timeline (scene.duration) — matches the editor's playbackOut
// getter. Using the last keyframe as a fallback made still-frame
@@ -142,21 +241,31 @@ const ENGINE_SOURCE = `(function(){
var sceneEnd=rangeOut;
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
var bgVideo=null;
if(scene.backgroundVideo && scene.backgroundVideo.src){
bgVideo=document.createElement('video');
bgVideo.src=scene.backgroundVideo.src;
bgVideo.muted=true;
bgVideo.playsInline=true;
bgVideo.loop=!!loop;
bgVideo.preload='auto';
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
container.appendChild(bgVideo);
// Background videos: create one <video> element per scene that has
// a bg video, preload them all, toggle display on scene-switch so
// the active one plays and the others idle without reloading.
var bgVideos={};
for(var bvi=0; bvi<project.scenes.length; bvi++){
var bvs=project.scenes[bvi];
if(bvs.backgroundVideo && bvs.backgroundVideo.src){
var bv=document.createElement('video');
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 ---
var audioPool={};
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 audioTracksById={};
(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; });
/**
* 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.
var buttonInstancesInFrame=[];
function resolveButtonState(layer){
@@ -406,12 +559,13 @@ const ENGINE_SOURCE = `(function(){
return;
}
case 'jumpToScene': {
// Exports only carry one scene at a time, so jumpToScene is a
// warn-and-continue in standalone bundles. The in-editor
// fullscreen preview passes an onJumpToScene hook which routes
// the jump back to the store and re-mounts at the same time.
// Host-driven swap (fullscreen preview routes through the
// store and re-mounts). Fall back to the runtime's own
// multi-scene switch for standalone bundles.
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;
}
case 'script': {
@@ -860,8 +1014,75 @@ const ENGINE_SOURCE = `(function(){
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
imgs=loaded;
// --- Preloader ---
// 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);
});
return {
@@ -881,7 +1102,10 @@ const ENGINE_SOURCE = `(function(){
canvas.removeEventListener('mouseup', onStageUp);
ro.disconnect();
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();
}
};
@@ -938,8 +1162,9 @@ export interface MountOptions {
* button-click) `jumpToScene` calls this instead of the runtime's
* own warn-and-skip path. The host is responsible for swapping the
* Scene the player renders (typically by mutating the store and
* re-mounting with the new active page). Exported bundles omit this
* — they carry a single scene and warn on jumpToScene. */
* re-mounting with the new active page). Exported bundles omit
* this — the runtime then switches scenes internally by walking
* `project.scenes` and toggling the preloaded audio/video pools. */
onJumpToScene?: (sceneId: string) => void
}
@@ -956,46 +1181,41 @@ function ensureEngine() {
export function mountPlayer(
container: HTMLElement,
scene: Scene,
payload: ExportPayload,
opts?: MountOptions,
): PlayerHandle {
ensureEngine()
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts)
return cachedApi.mount(container, JSON.parse(JSON.stringify(payload)), opts)
}
// ---- Export helpers ----
function jsonForExport(scene: Scene): string {
function jsonForExport(payload: ExportPayload): string {
// Data-URL background videos and audio assets are stripped from the
// export — local media can't be embedded reasonably, and doing so
// would bloat the bundle into uselessness. The user has to host the
// file externally and point `backgroundVideo.src` / `AudioAsset.src`
// at that URL before exporting.
// files externally and point `backgroundVideo.src` / `AudioAsset.src`
// 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[] = []
let clone: Scene = scene
let out: Project = project
const bg = scene.backgroundVideo
if (bg && bg.src && bg.src.startsWith('data:')) {
warnings.push('background video')
const { backgroundVideo, ...rest } = scene
void backgroundVideo
clone = rest as Scene
}
const audioAssets = clone.assets.filter(
// Strip data-URL audio assets at the shared asset list.
const dataAudio = out.assets.filter(
(a) => a.kind === 'audio' && a.src && a.src.startsWith('data:'),
)
if (audioAssets.length > 0) {
warnings.push(
`${audioAssets.length} audio asset(s) without external URL`,
)
// Replace the `src` with an empty string so the exported bundle's
// 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) =>
if (dataAudio.length > 0) {
warnings.push(`${dataAudio.length} audio asset(s) without external URL`)
out = {
...out,
assets: out.assets.map((a) =>
a.kind === 'audio' && a.src && a.src.startsWith('data:')
? { ...a, src: '' }
: 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) {
window.console.warn(
'[ruwio] export: stripped ' +
@@ -1010,7 +1245,38 @@ function jsonForExport(scene: Scene): string {
' — 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 {
@@ -1024,8 +1290,8 @@ export function generateRuntimeScript(): string {
)
}
export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string {
const sceneJson = jsonForExport(scene)
export function generateSceneScript(payload: ExportPayload, options: ExportOptions = {}): string {
const sceneJson = jsonForExport(payload)
const optsJson = JSON.stringify({ loop: options.loop !== false })
return (
'/* 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 (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE +
'\n' +
generateSceneScriptBody(scene, options)
generateSceneScriptBody(payload, options)
)
}
function generateSceneScriptBody(scene: Scene, options: ExportOptions): string {
const sceneJson = jsonForExport(scene)
function generateSceneScriptBody(payload: ExportPayload, options: ExportOptions): string {
const sceneJson = jsonForExport(payload)
const optsJson = JSON.stringify({ loop: options.loop !== false })
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')
}
export function downloadSceneExport(scene: Scene, filename = 'animation.js') {
triggerDownload(generateSceneScript(scene), filename, 'application/javascript')
export function downloadSceneExport(payload: ExportPayload, filename = 'animation.js') {
triggerDownload(generateSceneScript(payload), filename, 'application/javascript')
}
export function downloadBundledExport(scene: Scene, filename = 'animation-bundled.js') {
triggerDownload(generateBundledScript(scene), filename, 'application/javascript')
export function downloadBundledExport(payload: ExportPayload, filename = 'animation-bundled.js') {
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 sceneBoot = generateSceneScriptBody(scene, options)
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
return `<!doctype html>
<html lang="en">
<head>
@@ -1159,8 +1426,8 @@ ${sceneBoot}
`
}
export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') {
const source = generateExampleHtml(scene)
export function downloadExampleHtml(payload: ExportPayload, filename = 'animation-example.html') {
const source = generateExampleHtml(payload)
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,
* button-edit, selections, drawing) so the new page isn't shown
* through the lens of a context that belongs to another page.
* Playback state is left alone — in the editor, flipping scenes
* is a navigation gesture and shouldn't force play. The fullscreen
* preview takes care of auto-playing on its own (the activeSceneId
* watcher re-mounts the preview player with play enabled). */
switchScene(id: string): boolean {
* Playback state is left alone by default — in the editor,
* flipping scenes is a navigation gesture and shouldn't force
* play or rewind. Script-fired or button-fired jumps use
* `{ resetPlayhead: true }` instead so the incoming scene starts
* 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
this.project.activeSceneId = id
this.activeClipId = null
@@ -1990,6 +1992,9 @@ export const useSceneStore = defineStore('scene', {
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
if (options?.resetPlayhead) {
this.playhead = this.playbackIn
}
return true
},
renameScene(id: string, name: string): boolean {
@@ -2623,7 +2628,10 @@ export const useSceneStore = defineStore('scene', {
self.pause()
},
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) {
// eslint-disable-next-line no-console