Files
RUWIO/src/lib/exportPlayer.ts
T
thandClaude Opus 4.7 9e25cf155a 1.2.10: wiggle — deterministic procedural shake modifier per layer
Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.

Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
  layer, derived from the id so the same layer always wobbles the
  same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
  cheap, no noise-lib dependency. Phase constants are irrational
  ratios picked so the streams don't collapse into a low-period
  pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
  to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.

Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
  sample with applyWiggle. Every renderer goes through sampleLayer
  (canvas2d, Konva, button-state, clip-internal), so the modifier
  lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
  hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
  post-process branch in the engine's sample(). RUNTIME_VERSION
  31 → 32.

Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
  fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
  individual fields with magnitude clamping (≥ 0); null deletes
  the whole wiggle field.

UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
  (PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
  for position amplitude (0-64 px), rotation amplitude (0-45°),
  and frequency (0-10 Hz). Hidden for button-asset layers
  (consistent with Kaleido's hit-area constraint).

Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
  time points.
- layerSeeds stability + the three seeds are pairwise distinct;
  different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
  input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
  always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
  documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
  and bumps RUNTIME_VERSION to '32'.

Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:24:23 +02:00

1676 lines
62 KiB
TypeScript

import type { Asset, Project, Scene } from '@/types/scene'
import { AVAILABLE_FONTS, fontFaceCss, fontFamilyMapSnapshot, fontPrimary, SYSTEM_SANS } from './fonts'
/** 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 = '32'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
* and are dynamic) make it into the exported runtime. */
function engineSource(): string {
const fontMapJson = JSON.stringify(fontFamilyMapSnapshot())
return ENGINE_TEMPLATE.replace('__FONT_FAMILY_MAP__', fontMapJson)
}
const ENGINE_TEMPLATE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
var FONT_FAMILIES = __FONT_FAMILY_MAP__;
function fontFam(asset){
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
}
function easing(name, t){
if(name==='easeIn')return t*t;
if(name==='easeOut')return 1-(1-t)*(1-t);
if(name==='easeInOut')return t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2;
return t;
}
function merge(base,partial){
var o={};for(var k in base)o[k]=base[k];
if(partial)for(var k2 in partial)o[k2]=partial[k2];
return o;
}
function lerp(a,b,t){return a+(b-a)*t;}
function parseHex(hex){
if(!hex)return null;
var h=String(hex).trim().replace('#','');
if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
if(h.length<6)return null;
var n=parseInt(h.slice(0,6),16);
if(isNaN(n))return null;
return [(n>>16)&255,(n>>8)&255,n&255];
}
function formatHex(r,g,b){
function c(v){v=Math.round(v);if(v<0)v=0;if(v>255)v=255;var s=v.toString(16);return s.length<2?'0'+s:s;}
return '#'+c(r)+c(g)+c(b);
}
function lerpColor(a,b,t){
var pa=parseHex(a),pb=parseHex(b);
if(!pa||!pb)return t<0.5?a:b;
return formatHex(lerp(pa[0],pb[0],t),lerp(pa[1],pb[1],t),lerp(pa[2],pb[2],t));
}
function lerpProps(a,b,t){
var out={
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
scaleX:lerp(a.scaleX,b.scaleX,t),
scaleY:lerp(a.scaleY,b.scaleY,t),
rotation:lerp(a.rotation,b.rotation,t),
opacity:lerp(a.opacity,b.opacity,t)
};
if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill)out.fill=b.fill;
else if(a.fill)out.fill=a.fill;
if(a.points&&b.points&&a.points.length===b.points.length){
var lp=[];
for(var lpi=0;lpi<a.points.length;lpi++){
var pa=a.points[lpi], pb=b.points[lpi];
var mp={x:lerp(pa.x,pb.x,t),y:lerp(pa.y,pb.y,t)};
var keys=['inX','inY','outX','outY'];
for(var ki=0;ki<keys.length;ki++){
var K=keys[ki];
if(pa[K]!=null && pb[K]!=null) mp[K]=lerp(pa[K],pb[K],t);
else if(pb[K]!=null) mp[K]=pb[K];
else if(pa[K]!=null) mp[K]=pa[K];
}
lp.push(mp);
}
out.points=lp;
} else if(b.points) out.points=b.points;
else if(a.points) out.points=a.points;
if(a.stroke&&b.stroke) out.stroke=lerpColor(a.stroke,b.stroke,t);
else if(b.stroke) out.stroke=b.stroke;
else if(a.stroke) out.stroke=a.stroke;
if(a.strokeWidth!=null&&b.strokeWidth!=null) out.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t);
else if(b.strokeWidth!=null) out.strokeWidth=b.strokeWidth;
else if(a.strokeWidth!=null) out.strokeWidth=a.strokeWidth;
if(a.blur!=null && b.blur!=null) out.blur=lerp(a.blur,b.blur,t);
else if(b.blur!=null) out.blur=b.blur;
else if(a.blur!=null) out.blur=a.blur;
var shadowNumKeys=['shadowBlur','shadowOpacity','shadowAngle','shadowDistance'];
for(var sk=0;sk<shadowNumKeys.length;sk++){
var skName=shadowNumKeys[sk];
var sav=a[skName], sbv=b[skName];
if(sav!=null && sbv!=null) out[skName]=lerp(sav,sbv,t);
else if(sbv!=null) out[skName]=sbv;
else if(sav!=null) out[skName]=sav;
}
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.shadowColor;
return out;
}
// 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(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(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(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;
outSample.x=om*om*pa.x+2*om*t*b.ctrlX+t*t*pb.x;
outSample.y=om*om*pa.y+2*om*t*b.ctrlY+t*t*pb.y;
}
return outSample;
}
}
return merge(base);
}
// Mirror of src/lib/wiggle.ts — keep in lockstep so editor and
// exported runtime render an identical wobble at the same time.
function wiggleHash32(s){
var h=5381;
for(var i=0;i<s.length;i++){ h=((h<<5)+h+s.charCodeAt(i))|0; }
return h>>>0;
}
function wiggleSeeds(id){
var h=wiggleHash32(id);
return [(h^0x9e3779b9)>>>0, (h^0x85ebca6b)>>>0, (h^0xc2b2ae35)>>>0];
}
function wiggleNoise(seed, t){
var a=Math.sin(seed*12.9898+t*78.233);
var b=Math.sin(seed*4.31+t*11.71);
var c=Math.sin(seed*7.71+t*23.5);
return (a+b+c)/3;
}
function applyWiggle(props, layer, time){
var w=layer.wiggle;
if(!w) return props;
var amp=(w.amplitude==null?8:w.amplitude);
var rot=(w.rotation==null?2:w.rotation);
var freq=(w.frequency==null?2:w.frequency);
if(amp<=0 && rot<=0) return props;
if(freq<=0) return props;
var seeds=wiggleSeeds(layer.id);
var tt=time*freq;
var out={};
for(var k in props) out[k]=props[k];
out.x=props.x+amp*wiggleNoise(seeds[0],tt);
out.y=props.y+amp*wiggleNoise(seeds[1],tt);
out.rotation=props.rotation+rot*wiggleNoise(seeds[2],tt);
return out;
}
function sample(layer,time){
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
var result;
if(!layer.separated){
result=main;
} else {
// Separated mode: position and rotation come from their own
// tracks if present, merged back over the main-track values.
// Matches the editor's sampleLayer().
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
result={};
for(var k in main) result[k]=main[k];
result.x=pos.x;
result.y=pos.y;
result.rotation=rot.rotation;
}
if(layer.wiggle) return applyWiggle(result, layer, time);
return result;
}
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
return Promise.all(list.map(function(a){
return new Promise(function(resolve){
var img=new Image();
img.onload=function(){imgs[a.id]=img;resolve();};
img.onerror=function(){resolve();};
img.src=a.src;
});
})).then(function(){return imgs;});
}
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
// endings and trailing silence (intentionally empty space before
// the OUT mark) unreachable, so the preview looped early.
var rangeIn=Math.max(0, Math.min(scene.duration, scene.inTime==null?0:scene.inTime));
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?scene.duration:scene.outTime));
var sceneEnd=rangeOut;
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
// 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={};
(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; });
function disposeAudioClip(clipId){
var entry=audioPool[clipId];
if(!entry) return;
entry.el.pause();
entry.el.src='';
if(entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
delete audioPool[clipId];
}
function disposeAllAudio(){
for(var k in audioPool){ if(Object.prototype.hasOwnProperty.call(audioPool,k)) disposeAudioClip(k); }
}
function syncAudio(t, isPlaying){
for(var ci=0;ci<audioClips.length;ci++){
var clip=audioClips[ci];
var asset=audioAssetsById[clip.audioAssetId];
if(!asset || !asset.src){ disposeAudioClip(clip.id); continue; }
var entry=audioPool[clip.id];
if(!entry || entry.assetId !== clip.audioAssetId){
if(entry) disposeAudioClip(clip.id);
var el=document.createElement('audio');
el.preload='auto';
el.src=asset.src;
el.style.display='none';
container.appendChild(el);
entry={ el: el, assetId: clip.audioAssetId };
audioPool[clip.id]=entry;
}
var track=audioTracksById[clip.audioTrackId];
var trimS=clip.trimStart||0;
var trimE=clip.trimEnd||0;
var winLen=Math.max(0, asset.duration - trimS - trimE);
var inWindow=t>=clip.startTime && t<(clip.startTime+winLen);
var shouldPlay=isPlaying && inWindow && !(track && track.muted);
if(shouldPlay){
var audioTime=t - clip.startTime + trimS;
if(entry.el.paused){
try { entry.el.currentTime=audioTime; } catch(e) {}
var p=entry.el.play();
if(p && typeof p.catch==='function') p.catch(function(){});
} else {
var drift=Math.abs(entry.el.currentTime - audioTime);
if(drift > 0.25){ try { entry.el.currentTime=audioTime; } catch(e) {} }
}
} else if(!entry.el.paused){
entry.el.pause();
}
}
}
var canvas=document.createElement('canvas');
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
container.appendChild(canvas);
var ctx=canvas.getContext('2d');
var dpr=window.devicePixelRatio||1;
var size={w:0,h:0};
function resize(){
var r=container.getBoundingClientRect();
size.w=r.width;size.h=r.height;
canvas.width=Math.max(1,Math.round(r.width*dpr));
canvas.height=Math.max(1,Math.round(r.height*dpr));
}
var ro=new ResizeObserver(resize);
ro.observe(container);
resize();
var assetMap={};
scene.assets.forEach(function(a){assetMap[a.id]=a;});
var imgs={};
var start=0;
var bgStarted=false;
function syncBgVideo(t){
if(!bgVideo) return;
if(!bgStarted){
bgStarted=true;
try{ bgVideo.play(); }catch(e){}
}
if(Math.abs(bgVideo.currentTime-t)>0.2){
bgVideo.currentTime=t;
}
}
var rangeLen=Math.max(0, rangeOut-rangeIn);
var runtime={
fps: scene.fps||60,
rangeIn: rangeIn,
duration: scene.duration,
markers: {},
jumpTo: null,
forcePause: false,
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
warn: function(m){ if(window.console)window.console.warn('[ruwio]',m); }
};
(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){
var ov=runtime.buttonDisabled[layer.id];
var dis=ov===undefined?!!layer.buttonDisabled:ov;
if(dis) return 'disabled';
return runtime.buttonStates[layer.id] || 'normal';
}
function setButtonStateRt(layerId, st){
if(st==='normal') delete runtime.buttonStates[layerId];
else runtime.buttonStates[layerId]=st;
}
function pointInPolygon(px, py, pts){
var inside=false, n=pts.length;
for(var i=0,j=n-1;i<n;j=i++){
var xi=pts[i].x, yi=pts[i].y, xj=pts[j].x, yj=pts[j].y;
var intersect=((yi>py)!==(yj>py)) &&
(px<(xj-xi)*(py-yi)/((yj-yi)||1e-12)+xi);
if(intersect) inside=!inside;
}
return inside;
}
function hitTestShapes(x, y, shapes, w, h){
if(!shapes || !shapes.length){
return x>=0 && y>=0 && x<=w && y<=h;
}
for(var i=0;i<shapes.length;i++){
var s=shapes[i];
if(s.kind==='rect'){
if(x>=s.x && x<=s.x+s.width && y>=s.y && y<=s.y+s.height) return true;
} else if(s.kind==='circle'){
var dx=x-s.x, dy=y-s.y;
if(dx*dx+dy*dy <= s.radius*s.radius) return true;
} else if(s.kind==='polygon'){
if(s.points && s.points.length>=3 && pointInPolygon(x,y,s.points)) return true;
}
}
return false;
}
function pointerToCanvasPx(e){
var r=canvas.getBoundingClientRect();
if(!r.width || !r.height) return null;
return {
x:(e.clientX-r.left)*(canvas.width/r.width),
y:(e.clientY-r.top)*(canvas.height/r.height),
};
}
function buttonUnderPointer(dev){
for(var i=buttonInstancesInFrame.length-1;i>=0;i--){
var bi=buttonInstancesInFrame[i];
if(bi.layer.buttonDisabled && runtime.buttonDisabled[bi.layer.id]===undefined) continue;
if(runtime.buttonDisabled[bi.layer.id]===true) continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
if(hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height)) return bi;
}
return null;
}
function onStageMove(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)==='disabled') continue;
var cur=resolveButtonState(bi.layer);
if(hit && hit.layer.id===bi.layer.id){
if(cur==='normal') setButtonStateRt(bi.layer.id,'hover');
} else {
if(cur==='hover' || cur==='pressed') setButtonStateRt(bi.layer.id,'normal');
}
}
}
function onStageDown(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
if(!hit) return;
if(resolveButtonState(hit.layer)==='disabled') return;
setButtonStateRt(hit.layer.id, 'pressed');
}
function onStageUp(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)!=='pressed') continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
var inside=hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height);
setButtonStateRt(bi.layer.id, inside?'hover':'normal');
if(inside){
var acts=bi.layer.onClick||[];
for(var ai=0;ai<acts.length;ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
var lines=String(source||'').split(/\\r?\\n/);
for(var pi=0;pi<lines.length;pi++){
var ln=lines[pi];
var ci=ln.indexOf('//');
if(ci>=0) ln=ln.slice(0,ci);
ln=ln.trim();
if(!ln) continue;
var toks=ln.split(/\\s+/).filter(function(x){return x;});
var cmd=(toks[0]||'').toLowerCase();
var parsed=null, err=null;
if(cmd==='play') parsed={type:'play'};
else if(cmd==='pause') parsed={type:'pause'};
else if(cmd==='rewind') parsed={type:'rewindToLoop'};
else if(cmd==='jump'){
if(toks.length<2) err='jump: missing argument';
else {
var a=toks[1];
if(a.charAt(0)==='#'){
var fr=Number(a.slice(1));
if(!isFinite(fr)) err='jump: invalid frame '+a;
else parsed={type:'jumpToFrame',frame:fr};
} else if(a.charAt(0)==='@'){
var nm=a.slice(1);
if(!nm) err='jump: missing marker name';
else parsed={type:'jumpToMarker',name:nm};
} else {
var tm=Number(a);
if(!isFinite(tm)) err='jump: invalid time '+a;
else parsed={type:'jumpTo',time:tm};
}
}
} else {
err='unknown command "'+cmd+'"';
}
if(err) outErrs.push({line:pi+1,message:err});
else if(parsed) outActs.push(parsed);
}
return {actions:outActs, errors:outErrs};
}
function evalAction(act){
switch(act.type){
case 'play': runtime.forcePlay=true; runtime.forcePause=false; return;
case 'pause': runtime.forcePause=true; runtime.forcePlay=false; return;
case 'rewindToLoop': runtime.jumpTo=runtime.rangeIn; return;
case 'jumpTo': runtime.jumpTo=Math.max(0,Math.min(runtime.duration,act.time)); return;
case 'jumpToFrame': runtime.jumpTo=Math.max(0,Math.min(runtime.duration,act.frame/runtime.fps)); return;
case 'jumpToMarker': {
var mt=runtime.markers[act.name];
if(mt==null){ runtime.warn('Unknown marker: '+act.name); return; }
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
return;
}
case 'jumpToScene': {
// 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; }
if(!applySceneSwitch(act.sceneId)){
runtime.warn('jumpToScene: unknown scene id "'+act.sceneId+'"');
}
return;
}
case 'setButtonEnabled': {
// Runtime override lives on runtime.buttonDisabled — keyed by
// scene-layer id. True = disabled, false = enabled. Clearing
// the entry would fall back to layer.buttonDisabled, which is
// the persisted initial flag, not what the script just asked for.
runtime.buttonDisabled[act.layerId] = !act.enabled;
return;
}
case 'script': {
var parsed=parseScriptSource(act.source);
if(parsed.errors.length){
for(var sei=0; sei<parsed.errors.length; sei++){
runtime.warn('Script line '+parsed.errors[sei].line+': '+parsed.errors[sei].message);
}
}
for(var sai=0; sai<parsed.actions.length; sai++) evalAction(parsed.actions[sai]);
return;
}
}
}
function fireFrames(prev, next, includeStart){
if(next<prev) return;
if(next===prev && !includeStart) return;
var frames=scene.scriptFrames||[];
var toFire=[];
for(var ffi=0; ffi<frames.length; ffi++){
var fr=frames[ffi];
var passesLeft = includeStart ? fr.time>=prev : fr.time>prev;
if(passesLeft && fr.time<=next) toFire.push(fr);
}
toFire.sort(function(a,b){return a.time-b.time;});
for(var ffj=0; ffj<toFire.length; ffj++){
var acts=toFire[ffj].actions||[];
for(var fai=0; fai<acts.length; fai++) evalAction(acts[fai]);
}
}
var currentT=rangeIn;
var paused=false;
var lastNow=0;
// First tick after a play-start fires scripts at currentT too —
// otherwise frame-0 (or wherever the playhead sits) scripts are
// silently skipped by the open-left passage firing.
var playStartPending=true;
var rafId=0;
var onFrame=opts.onFrame||null;
var onJumpToScene=opts.onJumpToScene||null;
var destroyed=false;
function frame(now){
if(destroyed) return;
if(rangeLen<=0){
draw(currentT);
syncBgVideo(currentT);
syncAudio(currentT, !paused);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
return;
}
if(!lastNow) lastNow=now;
var dt=(now-lastNow)/1000;
lastNow=now;
if(!paused){
var prev=currentT;
var nextT=currentT+dt;
var includeStart=playStartPending;
playStartPending=false;
if(nextT>=rangeOut){
fireFrames(prev, rangeOut, includeStart);
currentT = loop ? rangeIn : rangeOut;
} else {
currentT=nextT;
fireFrames(prev, currentT, includeStart);
}
}
if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; }
if(runtime.forcePause){ paused=true; runtime.forcePause=false; }
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; playStartPending=true; }
draw(currentT);
syncBgVideo(currentT);
syncAudio(currentT, !paused);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
var n=sp.length;
if(n<2) return;
tctx.beginPath();
tctx.moveTo(sp[0].x-offX,sp[0].y-offY);
var endI=closed?n:n-1;
for(var ti=0;ti<endI;ti++){
var aPos=sp[ti];
var bIdx=(ti+1)%n;
var bPos=sp[bIdx];
var aA=ap?ap[ti]:null;
var bA=ap?ap[bIdx]:null;
var aHas=aA&&aA.type&&aA.type!=='corner';
var bHas=bA&&bA.type&&bA.type!=='corner';
if(aHas||bHas){
var aox=aHas?((aPos.outX!=null?aPos.outX:(aA.outX||0))):0;
var aoy=aHas?((aPos.outY!=null?aPos.outY:(aA.outY||0))):0;
var bix=bHas?((bPos.inX!=null?bPos.inX:(bA.inX||0))):0;
var biy=bHas?((bPos.inY!=null?bPos.inY:(bA.inY||0))):0;
tctx.bezierCurveTo(aPos.x+aox-offX,aPos.y+aoy-offY,bPos.x+bix-offX,bPos.y+biy-offY,bPos.x-offX,bPos.y-offY);
} else {
tctx.lineTo(bPos.x-offX,bPos.y-offY);
}
}
if(closed) tctx.closePath();
}
// Kaleidoscope: number of radial copies for an entity (Layer or
// ClipInstance). 1 = no kaleido. Mirrors lib/kaleido.ts.
function kalCount(e){
if(!e || !e.kaleido) return 1;
var n=Math.floor(e.kaleidoCount||0);
return n<2?1:n;
}
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
function clipKaleidoPivot(inst){
var ix=inst.x||0, iy=inst.y||0;
return {
x:(inst.kaleidoPivotX==null?ix:inst.kaleidoPivotX),
y:(inst.kaleidoPivotY==null?iy:inst.kaleidoPivotY)
};
}
function clipArmLocalT(inst, baseLocal, i, trimmedDur){
if(trimmedDur<=0) return null;
var off=inst.kaleidoOffset||0;
var trimS=inst.trimStart||0;
var offsetIntoBlock=baseLocal-trimS;
// Arm 0 (and the no-shift case) → source's own time. Arms i>0
// with a non-zero offset start later.
var shifted=(i===0||off===0)?offsetIntoBlock:(offsetIntoBlock-i*off);
var EPS=1e-9;
if(shifted < -EPS || shifted > trimmedDur+EPS) return null;
if(shifted<0) shifted=0;
if(shifted>trimmedDur) shifted=trimmedDur;
return trimS+shifted;
}
function clipArmTrans(inst, i, n){
var ix=inst.x||0, iy=inst.y||0;
if(i===0||n<=1) return {x:ix,y:iy};
var rot=inst.kaleidoRotateCopies!==false;
if(rot) return {x:ix,y:iy};
var pv=clipKaleidoPivot(inst);
var a=kalAngle(i,n)*Math.PI/180;
var dx=ix-pv.x, dy=iy-pv.y;
return {
x:pv.x+dx*Math.cos(a)-dy*Math.sin(a),
y:pv.y+dx*Math.sin(a)+dy*Math.cos(a)
};
}
function drawClipInstance(inst, clip, time){
var trimS=inst.trimStart||0;
var trimE=inst.trimEnd||0;
var visibleClip=Math.max(0,clip.duration-trimS-trimE);
var offsetIntoBlock=time-inst.startTime;
var isx=(inst.scaleX==null?1:inst.scaleX);
var isy=(inst.scaleY==null?1:inst.scaleY);
var irot=inst.rotation||0;
var n=kalCount(inst);
var rotateCopies=inst.kaleidoRotateCopies!==false;
var pv=clipKaleidoPivot(inst);
var baseLocal=trimS+offsetIntoBlock;
// Delayed arms can outlive the source's own window — keep
// rendering until the LAST arm has finished playing.
var offSec=inst.kaleidoOffset||0;
var maxArmDelay=(n>1 && offSec!==0)?Math.max(0,(n-1)*offSec):0;
if(offsetIntoBlock<0||offsetIntoBlock>visibleClip+maxArmDelay) return;
for(var i=0;i<n;i++){
var ka=kalAngle(i,n);
var lt=clipArmLocalT(inst, baseLocal, i, visibleClip);
if(lt===null) continue;
var tr=clipArmTrans(inst, i, n);
ctx.save();
if(n>1 && i>0 && rotateCopies){
ctx.translate(pv.x,pv.y);
ctx.rotate(ka*Math.PI/180);
ctx.translate(-pv.x,-pv.y);
}
ctx.translate(tr.x,tr.y);
if(irot!==0) ctx.rotate(irot*Math.PI/180);
ctx.scale(isx,isy);
for(var li=clip.layers.length-1;li>=0;li--){
drawLayer(clip.layers[li], lt);
}
ctx.restore();
}
}
function drawMaskShape(ctxm,layer,time,buttonMode){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
if(buttonMode){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
var kn=kalCount(layer);
for(var ki=0;ki<kn;ki++){
var ka=kalAngle(ki,kn);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate((p.rotation+ka)*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
ctxm.fill();
} else if(asset.kind==='text'){
var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px '+fontFam(asset);
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY);
} else {
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
var mpPts=p.points||asset.points||[];
var mpClosed=asset.closed!==false;
if(mpClosed && mpPts.length>=3){
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
ctxm.fill();
}
}
ctxm.restore();
}
}
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
// A layer is "alive" from its earliest NORMAL kf onward, ending
// either at the earliest 'end' kf or at +Infinity when none exists.
// Layers without any normal kf are static (always visible).
// End kfs only live on .keyframes (the main track).
function layerActiveAt(layer,time){
var main=layer.keyframes||[];
var pos=layer.positionKeyframes||[];
var rot=layer.rotationKeyframes||[];
var foundNormal=false, mn=Infinity;
function scanNormal(arr){
for(var i=0;i<arr.length;i++){
var k=arr[i];
if(k.kind==='end') continue;
foundNormal=true;
if(k.time<mn) mn=k.time;
}
}
scanNormal(main); scanNormal(pos); scanNormal(rot);
if(!foundNormal) return true;
var endTime=null;
for(var ei=0;ei<main.length;ei++){
var ek=main[ei];
if(ek.kind!=='end') continue;
if(endTime===null || ek.time<endTime) endTime=ek.time;
}
var mx=(endTime===null)?Infinity:endTime;
var EPS=1e-6;
return time>=mn-EPS && time<mx-EPS;
}
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
// its own state frame, not on the state frames that follow. Mirrors
// isButtonLayerActiveAt in interpolate.ts.
function buttonLayerActiveAt(layer,time){
var EPS=1e-6;
var kfs=[];
var mk=layer.keyframes||[];
for(var bki=0;bki<mk.length;bki++) kfs.push(mk[bki]);
var pk=layer.positionKeyframes||[];
for(var bkj=0;bkj<pk.length;bkj++) kfs.push(pk[bkj]);
var rk=layer.rotationKeyframes||[];
for(var bkl=0;bkl<rk.length;bkl++) kfs.push(rk[bkl]);
if(!kfs.length) return true;
kfs.sort(function(x,y){return x.time-y.time;});
for(var bkm=0;bkm<kfs.length;bkm++){
if(Math.abs(kfs[bkm].time-time)<EPS) return true;
}
for(var bkn=0;bkn<kfs.length-1;bkn++){
var a=kfs[bkn], b=kfs[bkn+1];
if(time>a.time+EPS && time<b.time-EPS) return !a.hold;
}
return false;
}
function drawLayer(layer,time,buttonMode){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
if(buttonMode){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
// Kaleido off for button-asset layers — multiplying their hit
// areas would break click routing.
var kn=(asset.kind==='button')?1:kalCount(layer);
for(var ki=0;ki<kn;ki++){
var ka=kalAngle(ki,kn);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
ctx.rotate((p.rotation+ka)*Math.PI/180);
ctx.scale(p.scaleX,p.scaleY);
var sOp=p.shadowOpacity||0;
var sBl=p.shadowBlur||0;
var sDi=p.shadowDistance||0;
if(sOp>0 && (sBl>0 || sDi>0)){
var sAn=(p.shadowAngle==null?90:p.shadowAngle)*Math.PI/180;
var sHex=(p.shadowColor||'#000000').replace('#','');
if(sHex.length===3){sHex=sHex[0]+sHex[0]+sHex[1]+sHex[1]+sHex[2]+sHex[2];}
var sNum=parseInt(sHex.slice(0,6),16);
if(isNaN(sNum))sNum=0;
ctx.shadowColor='rgba('+((sNum>>16)&255)+','+((sNum>>8)&255)+','+(sNum&255)+','+sOp+')';
ctx.shadowBlur=sBl;
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
}
var lRawStroke=p.stroke||asset.stroke;
var lSW=(p.strokeWidth!=null?p.strokeWidth:(asset.strokeWidth||0));
var lStroke=lSW>0?(lRawStroke||'#000000'):null;
var lHasStroke=!!lStroke && lStroke!=='transparent' && lSW>0;
if(asset.kind==='rect'){
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
var rFill=p.fill||asset.fill;
if(rFill && rFill!=='transparent'){
ctx.fillStyle=rFill;
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
}
if(lHasStroke){
ctx.strokeStyle=lStroke;
ctx.lineWidth=lSW;
ctx.strokeRect(-rOffX,-rOffY,asset.width,asset.height);
}
ctx.filter='none';
}else if(asset.kind==='circle'){
var cOffX=layer.offsetX!=null?layer.offsetX:0;
var cOffY=layer.offsetY!=null?layer.offsetY:0;
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
ctx.beginPath();
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
var cFill=p.fill||asset.fill;
if(cFill && cFill!=='transparent'){
ctx.fillStyle=cFill;
ctx.fill();
}
if(lHasStroke){
ctx.strokeStyle=lStroke;
ctx.lineWidth=lSW;
ctx.stroke();
}
ctx.filter='none';
}else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.font=asset.fontSize+'px '+fontFam(asset);
ctx.textBaseline='top';
var tFill=p.fill||asset.fill;
if(tFill && tFill!=='transparent'){
ctx.fillStyle=tFill;
ctx.fillText(asset.text,-tOffX,-tOffY);
}
if(lHasStroke){
ctx.strokeStyle=lStroke;
ctx.lineWidth=lSW;
ctx.strokeText(asset.text,-tOffX,-tOffY);
}
}else if(asset.kind==='polygon'){
var pOffX=layer.offsetX!=null?layer.offsetX:0;
var pOffY=layer.offsetY!=null?layer.offsetY:0;
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
var pPts=p.points||asset.points||[];
var pClosed=asset.closed!==false;
var pNeeded=pClosed?3:2;
if(pPts.length>=pNeeded){
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
tracePolyPath(ctx,pPts,asset.points,pOffX,pOffY,pClosed);
var pFill=p.fill||asset.fill;
if(pClosed && pFill && pFill!=='transparent'){
ctx.fillStyle=pFill;
ctx.fill();
}
if(lHasStroke){
ctx.strokeStyle=lStroke;
ctx.lineWidth=lSW;
ctx.lineJoin='round';
ctx.lineCap='round';
ctx.stroke();
}
ctx.filter='none';
}
}else if(asset.kind==='image'){
var img=imgs[asset.id];
if(img){
var iOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
}
}else if(asset.kind==='button'){
var bCanvas=asset.canvas||{width:0,height:0};
var bOffX=layer.offsetX!=null?layer.offsetX:bCanvas.width/2;
var bOffY=layer.offsetY!=null?layer.offsetY:bCanvas.height/2;
ctx.translate(-bOffX,-bOffY);
var bm=ctx.getTransform();
buttonInstancesInFrame.push({
layer:layer,
asset:asset,
matrix:{a:bm.a,b:bm.b,c:bm.c,d:bm.d,e:bm.e,f:bm.f}
});
var bState=resolveButtonState(layer);
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
var bLayers=asset.layers||[];
var bContent=[], bMasks=[];
for(var bli=0;bli<bLayers.length;bli++){
if(bLayers[bli].isMask) bMasks.push(bLayers[bli]); else bContent.push(bLayers[bli]);
}
if(!bMasks.length){
for(var bli2=bContent.length-1;bli2>=0;bli2--){
drawLayer(bContent[bli2], stateTime, true);
}
} else {
var bcanvas=document.createElement('canvas');
bcanvas.width=canvas.width; bcanvas.height=canvas.height;
var bctx=bcanvas.getContext('2d');
var btm=ctx.getTransform();
bctx.setTransform(btm.a,btm.b,btm.c,btm.d,btm.e,btm.f);
var savedBCtx=ctx;
ctx=bctx;
for(var bli3=bContent.length-1;bli3>=0;bli3--){
drawLayer(bContent[bli3], stateTime, true);
}
for(var bmk=0;bmk<bMasks.length;bmk++){
var bML=bMasks[bmk];
bctx.save();
bctx.globalCompositeOperation=(bML.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(bctx,bML,stateTime,true);
bctx.restore();
}
ctx=savedBCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(bcanvas,0,0);
ctx.restore();
}
}
ctx.shadowColor='transparent';
ctx.shadowBlur=0;
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
}
function draw(time){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.clip();
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={};
var clips2=scene.clips||[];
for(var ci2=0;ci2<clips2.length;ci2++)clipById2[clips2[ci2].id]=clips2[ci2];
var instByTrack={};
var instances=scene.clipInstances||[];
for(var iInst=0;iInst<instances.length;iInst++){
var iE=instances[iInst];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE);
}
var rawOrder=scene.trackOrder||[];
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj<scene.layers.length;lj++) rawOrder.push(scene.layers[lj].id); }
var hasTopMask=false;
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
var mainCtx=ctx, topOff=null;
if(hasTopMask){
topOff=document.createElement('canvas');
topOff.width=canvas.width; topOff.height=canvas.height;
var topOffCtx=topOff.getContext('2d');
if(topOffCtx){
var tmT=ctx.getTransform();
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
ctx=topOffCtx;
} else { topOff=null; }
}
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){
if(lyr.isMask){
ctx.save();
ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(ctx,lyr,time);
ctx.restore();
continue;
}
drawLayer(lyr,time); continue;
}
var tInsts=instByTrack[id]||[];
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
drawClipInstance(inst2, clip2, time);
}
continue;
}
var grp=groupById2[id];
if(grp){
// gContentIds holds layer OR clip-track child ids. Dropping
// clip tracks here used to make any group-with-only-clips
// invisible in the runtime (same bug drawTrackGroup fixes
// on the editor side).
var gContentIds=[], gMasks=[];
for(var ch=grp.childIds.length-1;ch>=0;ch--){
var cid=grp.childIds[ch];
var cl0=layerById[cid];
if(cl0){
if(cl0.isMask) gMasks.push(cl0); else gContentIds.push(cid);
continue;
}
if(instByTrack[cid]) gContentIds.push(cid);
}
var drawChildInGroup=function(cid){
var lr=layerById[cid];
if(lr){ drawLayer(lr,time); return; }
var insts3=instByTrack[cid]||[];
for(var ti3=0;ti3<insts3.length;ti3++){
var inst4=insts3[ti3];
var clip4=clipById2[inst4.clipId];
if(!clip4) continue;
drawClipInstance(inst4, clip4, time);
}
};
if(!gMasks.length){
for(var gi3=0;gi3<gContentIds.length;gi3++) drawChildInGroup(gContentIds[gi3]);
} else {
var mcanvas=document.createElement('canvas');
mcanvas.width=canvas.width; mcanvas.height=canvas.height;
var mctx=mcanvas.getContext('2d');
var tm=ctx.getTransform();
mctx.setTransform(tm.a,tm.b,tm.c,tm.d,tm.e,tm.f);
var savedCtx=ctx;
ctx=mctx;
for(var gi4=0;gi4<gContentIds.length;gi4++) drawChildInGroup(gContentIds[gi4]);
for(var gm=0;gm<gMasks.length;gm++){
var mL=gMasks[gm];
mctx.save();
mctx.globalCompositeOperation=(mL.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(mctx,mL,time);
mctx.restore();
}
ctx=savedCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(mcanvas,0,0);
ctx.restore();
}
}
}
if(topOff){
ctx=mainCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(topOff,0,0);
ctx.restore();
}
ctx.restore();
}
// --- 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 {
play: function(){ paused=false; lastNow=0; },
pause: function(){ paused=true; },
toggle: function(){ if(paused){ paused=false; lastNow=0; } else paused=true; },
isPlaying: function(){ return !paused; },
seek: function(t){ currentT=Math.max(rangeIn,Math.min(rangeOut,t)); lastNow=0; },
getTime: function(){ return currentT; },
getRangeIn: function(){ return rangeIn; },
getRangeOut: function(){ return rangeOut; },
destroy: function(){
destroyed=true;
cancelAnimationFrame(rafId);
canvas.removeEventListener('mousemove', onStageMove);
canvas.removeEventListener('mousedown', onStageDown);
canvas.removeEventListener('mouseup', onStageUp);
ro.disconnect();
canvas.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();
}
};
}
var api={ __v: '${RUNTIME_VERSION}', mount: mount };
window.AnimatrPlayer = api;
var pending = window.__animatrPending;
window.__animatrPending = null;
if (pending) for (var i=0;i<pending.length;i++) { try { pending[i](); } catch(e) { console.error(e); } }
})();
`
const SCENE_BOOT_SOURCE = `(function(){
var SCENE = __SCENE__;
var OPTIONS = __OPTIONS__;
var script = document.currentScript;
var target = null;
if (script && script.dataset && script.dataset.target) target = document.querySelector(script.dataset.target);
else if (script && script.parentElement) target = script.parentElement;
var autoOpts = {};
if (script && script.dataset && script.dataset.loop === 'false') autoOpts.loop = false;
function boot(){
if (!target || !window.AnimatrPlayer) return;
var merged = {};
for (var k in OPTIONS) merged[k] = OPTIONS[k];
for (var k2 in autoOpts) merged[k2] = autoOpts[k2];
window.AnimatrPlayer.mount(target, SCENE, merged);
}
if (window.AnimatrPlayer) boot();
else (window.__animatrPending = window.__animatrPending || []).push(boot);
})();
`
// ---- In-app mount: eval the engine once, return the same player the export uses ----
export interface PlayerHandle {
play(): void
pause(): void
toggle(): void
isPlaying(): boolean
seek(t: number): void
getTime(): number
getRangeIn(): number
getRangeOut(): number
destroy(): void
}
export interface MountOptions {
loop?: boolean
onFrame?: (time: number, paused: boolean) => void
/** In-editor hook used by the fullscreen preview: a scripted (or
* 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 — the runtime then switches scenes internally by walking
* `project.scenes` and toggling the preloaded audio/video pools. */
onJumpToScene?: (sceneId: string) => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(engineSource())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
export function mountPlayer(
container: HTMLElement,
payload: ExportPayload,
opts?: MountOptions,
): PlayerHandle {
ensureEngine()
return cachedApi.mount(container, JSON.parse(JSON.stringify(payload)), opts)
}
// ---- Export helpers ----
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
// 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 out: Project = project
// 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 (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,
),
}
}
// 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 ' +
warnings.join(', ') +
' — host the files externally and set their src fields before exporting.',
)
}
return JSON.stringify(out)
}
function collectFontIds(payload: ExportPayload): string[] {
const ids = new Set<string>()
const assets: readonly Asset[] = payload.assets ?? []
for (const a of assets) {
if (a.kind === 'text' && a.fontFamily) ids.add(a.fontFamily)
}
return Array.from(ids)
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
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 {
loop?: boolean
}
export function generateRuntimeScript(): string {
return (
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
engineSource()
)
}
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' +
' * Requires ruwio-runtime.js (v' + RUNTIME_VERSION + ') to be loaded once per page.\n' +
' */\n' +
SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
)
}
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
return (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
engineSource() +
'\n' +
generateSceneScriptBody(payload, options)
)
}
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)
}
export function downloadRuntime(filename = 'ruwio-runtime.js') {
triggerDownload(generateRuntimeScript(), filename, 'application/javascript')
}
export function downloadSceneExport(payload: ExportPayload, filename = 'animation.js') {
triggerDownload(generateSceneScript(payload), filename, 'application/javascript')
}
export function downloadBundledExport(payload: ExportPayload, filename = 'animation-bundled.js') {
triggerDownload(generateBundledScript(payload), filename, 'application/javascript')
}
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
.map(id => fonts.find(f => f.id === id))
.filter((f): f is typeof fonts[number] => !!f && !!f.file)
const builtinUsed = usedFonts.filter(f => !f.custom)
const customUsed = usedFonts.filter(f => f.custom)
const fontHint = usedFonts.length === 0 ? '' : `
<div class="usage">
<h2>Fonts used in this animation</h2>
<p style="margin-bottom: 8px;">This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the font files alongside this HTML and add the @font-face rules below to your CSS:</p>
<ul style="margin: 0 0 12px 18px; padding: 0; font-size: 12px; color: #6e6e73;">
${builtinUsed.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}${
customUsed.length > 0
? '\n' + customUsed.map(f => ` <li>${escapeHtml(f.label)} — <em>locally uploaded; the @font-face below has the file inlined as a data URL</em></li>`).join('\n')
: ''
}
</ul>
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
</div>`
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>RUWIO — Example</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 40px 20px;
background: #f5f5f7;
color: #1d1d1f;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
@media (prefers-color-scheme: dark) {
body { background: #1e1e22; color: #e5e5ea; }
pre { background: #26262c !important; color: #e5e5ea; }
.stage { background: #26262c; box-shadow: 0 0 0 1px #3a3a42; }
}
h1 { font-size: 18px; font-weight: 600; margin: 0; }
p { margin: 0; color: #6e6e73; font-size: 13px; text-align: center; }
.stage {
width: min(100%, 960px);
aspect-ratio: ${aspect};
background: #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
border-radius: 6px;
overflow: hidden;
}
.usage {
max-width: 760px;
width: 100%;
}
.usage h2 {
font-size: 13px;
font-weight: 600;
margin: 0 0 6px;
color: #6e6e73;
text-transform: uppercase;
letter-spacing: 0.05em;
}
pre {
background: #fff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px;
padding: 14px 16px;
font-size: 12px;
line-height: 1.5;
overflow: auto;
margin: 0;
}
</style>
</head>
<body>
<h1>RUWIO Export</h1>
<p>Load the runtime once, then embed as many scenes on the page as you like.</p>
<!-- Runtime: once per page -->
<script>
${runtime}
</script>
<div class="stage">
<!-- Scene: one per animation -->
<script>
${sceneBoot}
</script>
</div>
${fontHint}
<div class="usage">
<h2>Embed on your own page</h2>
<pre>&lt;!-- once per page --&gt;
&lt;script src="ruwio-runtime.js"&gt;&lt;/script&gt;
&lt;!-- per animation: one container + scene script --&gt;
&lt;div style="width: 800px; height: 450px;"&gt;
&lt;script src="animation.js"&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;div style="width: 400px; height: 225px;"&gt;
&lt;script src="animation-2.js"&gt;&lt;/script&gt;
&lt;/div&gt;</pre>
</div>
</body>
</html>
`
}
export function downloadExampleHtml(payload: ExportPayload, filename = 'animation-example.html') {
const source = generateExampleHtml(payload)
triggerDownload(source, filename, 'text/html')
}
function triggerDownload(source: string, filename: string, mime: string) {
const blob = new Blob([source], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}