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>
1676 lines
62 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
}
|
|
|
|
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><!-- once per page -->
|
|
<script src="ruwio-runtime.js"></script>
|
|
|
|
<!-- per animation: one container + scene script -->
|
|
<div style="width: 800px; height: 450px;">
|
|
<script src="animation.js"></script>
|
|
</div>
|
|
|
|
<div style="width: 400px; height: 225px;">
|
|
<script src="animation-2.js"></script>
|
|
</div></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)
|
|
}
|