Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1085 lines
39 KiB
TypeScript
1085 lines
39 KiB
TypeScript
import type { Scene } from '@/types/scene'
|
|
|
|
const RUNTIME_VERSION = '19'
|
|
|
|
const ENGINE_SOURCE = `(function(){
|
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
|
|
|
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;
|
|
}
|
|
function sample(layer,time){
|
|
var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;});
|
|
if(!kfs.length)return merge(layer.base);
|
|
if(time<=kfs[0].time)return merge(layer.base,kfs[0].props);
|
|
var last=kfs[kfs.length-1];
|
|
if(time>=last.time)return merge(layer.base,last.props);
|
|
for(var i=0;i<kfs.length-1;i++){
|
|
var a=kfs[i],b=kfs[i+1];
|
|
if(time>=a.time&&time<=b.time){
|
|
if(a.hold){
|
|
if(time>=b.time)return merge(layer.base,b.props);
|
|
return merge(layer.base,a.props);
|
|
}
|
|
var raw=(time-a.time)/(b.time-a.time);
|
|
var t=easing(b.easing,raw);
|
|
var pa=merge(layer.base,a.props);
|
|
var pb=merge(layer.base,b.props);
|
|
var 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(layer.base);
|
|
}
|
|
|
|
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,scene,opts){
|
|
if(!container)return;
|
|
opts=opts||{};
|
|
var loop=opts.loop!==false;
|
|
// 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';
|
|
var bgVideo=null;
|
|
if(scene.backgroundVideo && scene.backgroundVideo.src){
|
|
bgVideo=document.createElement('video');
|
|
bgVideo.src=scene.backgroundVideo.src;
|
|
bgVideo.muted=true;
|
|
bgVideo.playsInline=true;
|
|
bgVideo.loop=!!loop;
|
|
bgVideo.preload='auto';
|
|
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
|
container.appendChild(bgVideo);
|
|
}
|
|
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; });
|
|
|
|
// 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': {
|
|
// Exports only carry one scene at a time, so jumpToScene is a
|
|
// warn-and-continue in standalone bundles. The in-editor
|
|
// fullscreen preview passes an onJumpToScene hook which routes
|
|
// the jump back to the store and re-mounts at the same time.
|
|
if (onJumpToScene){ try { onJumpToScene(act.sceneId); } catch(e){ runtime.warn('jumpToScene: '+((e&&e.message)||String(e))); } return; }
|
|
runtime.warn('jumpToScene: exported runtime does not support multi-scene playback');
|
|
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){
|
|
if(next<=prev) return;
|
|
var frames=scene.scriptFrames||[];
|
|
var toFire=[];
|
|
for(var ffi=0; ffi<frames.length; ffi++){
|
|
var fr=frames[ffi];
|
|
if(fr.time>prev && 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;
|
|
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);
|
|
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;
|
|
if(nextT>=rangeOut){
|
|
fireFrames(prev, rangeOut);
|
|
currentT = loop ? rangeIn : rangeOut;
|
|
} else {
|
|
currentT=nextT;
|
|
fireFrames(prev, currentT);
|
|
}
|
|
}
|
|
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; }
|
|
draw(currentT);
|
|
syncBgVideo(currentT);
|
|
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();
|
|
}
|
|
function drawMaskShape(ctxm,layer,time,alwaysActive){
|
|
var asset=assetMap[layer.assetId];
|
|
if(!asset) return;
|
|
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
|
if(!alwaysActive && !layerActiveAt(layer,time)) return;
|
|
var p=sample(layer,time);
|
|
ctxm.save();
|
|
ctxm.translate(p.x,p.y);
|
|
ctxm.rotate(p.rotation*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 sans-serif';
|
|
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();
|
|
}
|
|
function layerActiveAt(layer,time){
|
|
var main=layer.keyframes||[];
|
|
var pos=layer.positionKeyframes||[];
|
|
var rot=layer.rotationKeyframes||[];
|
|
var total=main.length+pos.length+rot.length;
|
|
if(total<=1) return true;
|
|
var mn=Infinity, mx=-Infinity, lastHold=false;
|
|
function scan(arr){
|
|
for(var i=0;i<arr.length;i++){
|
|
var k=arr[i], t=k.time;
|
|
if(t<mn) mn=t;
|
|
if(t>mx){ mx=t; lastHold=!!k.hold; }
|
|
else if(t===mx && k.hold){ lastHold=true; }
|
|
}
|
|
}
|
|
scan(main); scan(pos); scan(rot);
|
|
if(lastHold) mx=Infinity;
|
|
var EPS=1e-6;
|
|
return time>=mn-EPS && time<=mx+EPS;
|
|
}
|
|
function drawLayer(layer,time,alwaysActive){
|
|
var asset=assetMap[layer.assetId];
|
|
if(!asset||!layer.visible)return;
|
|
if(!alwaysActive&&!layerActiveAt(layer,time))return;
|
|
var p=sample(layer,time);
|
|
ctx.save();
|
|
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
|
ctx.translate(p.x,p.y);
|
|
ctx.rotate(p.rotation*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 sans-serif';
|
|
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;
|
|
var tS=inst2.trimStart||0;
|
|
var tE=inst2.trimEnd||0;
|
|
var visibleClip=Math.max(0,clip2.duration-tS-tE);
|
|
var offset=time-inst2.startTime;
|
|
if(offset<0||offset>visibleClip)continue;
|
|
var localT=tS+offset;
|
|
var ix2=inst2.x||0, iy2=inst2.y||0;
|
|
var isx2=(inst2.scaleX==null?1:inst2.scaleX);
|
|
var isy2=(inst2.scaleY==null?1:inst2.scaleY);
|
|
var irot2=inst2.rotation||0;
|
|
var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
|
|
if(needsXf){
|
|
ctx.save();
|
|
ctx.translate(ix2,iy2);
|
|
if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
|
|
ctx.scale(isx2,isy2);
|
|
}
|
|
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
|
drawLayer(clip2.layers[cl2],localT);
|
|
}
|
|
if(needsXf) ctx.restore();
|
|
}
|
|
continue;
|
|
}
|
|
var grp=groupById2[id];
|
|
if(grp){
|
|
var gContent=[], gMasks=[];
|
|
for(var ch=grp.childIds.length-1;ch>=0;ch--){
|
|
var cl0=layerById[grp.childIds[ch]];
|
|
if(!cl0) continue;
|
|
if(cl0.isMask) gMasks.push(cl0); else gContent.push(cl0);
|
|
}
|
|
if(!gMasks.length){
|
|
for(var gi3=0;gi3<gContent.length;gi3++) drawLayer(gContent[gi3],time);
|
|
} 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<gContent.length;gi4++) drawLayer(gContent[gi4],time);
|
|
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();
|
|
}
|
|
loadImages(scene.assets).then(function(loaded){
|
|
imgs=loaded;
|
|
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();
|
|
if(bgVideo) bgVideo.remove();
|
|
}
|
|
};
|
|
}
|
|
|
|
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
|
|
* — they carry a single scene and warn on jumpToScene. */
|
|
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)(ENGINE_SOURCE)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
cachedApi = (window as any).AnimatrPlayer
|
|
}
|
|
|
|
export function mountPlayer(
|
|
container: HTMLElement,
|
|
scene: Scene,
|
|
opts?: MountOptions,
|
|
): PlayerHandle {
|
|
ensureEngine()
|
|
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts)
|
|
}
|
|
|
|
// ---- Export helpers ----
|
|
|
|
function jsonForExport(scene: Scene): string {
|
|
// Data-URL background videos are stripped from the export — local video
|
|
// files can't be embedded reasonably. The user needs to host the media
|
|
// externally and set a URL in settings before exporting.
|
|
const bg = scene.backgroundVideo
|
|
if (bg && bg.src && bg.src.startsWith('data:')) {
|
|
const { backgroundVideo, ...rest } = scene
|
|
void backgroundVideo
|
|
return JSON.stringify(rest)
|
|
}
|
|
return JSON.stringify(scene)
|
|
}
|
|
|
|
export interface ExportOptions {
|
|
loop?: boolean
|
|
}
|
|
|
|
export function generateRuntimeScript(): string {
|
|
return (
|
|
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
|
|
ENGINE_SOURCE
|
|
)
|
|
}
|
|
|
|
export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string {
|
|
const sceneJson = jsonForExport(scene)
|
|
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(scene: Scene, options: ExportOptions = {}): string {
|
|
return (
|
|
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
|
|
ENGINE_SOURCE +
|
|
'\n' +
|
|
generateSceneScriptBody(scene, options)
|
|
)
|
|
}
|
|
|
|
function generateSceneScriptBody(scene: Scene, options: ExportOptions): string {
|
|
const sceneJson = jsonForExport(scene)
|
|
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(scene: Scene, filename = 'animation.js') {
|
|
triggerDownload(generateSceneScript(scene), filename, 'application/javascript')
|
|
}
|
|
|
|
export function downloadBundledExport(scene: Scene, filename = 'animation-bundled.js') {
|
|
triggerDownload(generateBundledScript(scene), filename, 'application/javascript')
|
|
}
|
|
|
|
export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string {
|
|
const runtime = ENGINE_SOURCE
|
|
const sceneBoot = generateSceneScriptBody(scene, options)
|
|
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
|
|
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>
|
|
|
|
<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(scene: Scene, filename = 'animation-example.html') {
|
|
const source = generateExampleHtml(scene)
|
|
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)
|
|
}
|