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

+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')
}