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=last.time)return merge(layer.base,last.props); for(var i=0;i=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;ipy)!==(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=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=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; seiprev && fr.time<=next) toFire.push(fr); } toFire.sort(function(a,b){return a.time-b.time;}); for(var ffj=0; ffj=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=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;imx){ 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=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=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;tivisibleClip)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 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 ` RUWIO — Example

RUWIO Export

Load the runtime once, then embed as many scenes on the page as you like.

Embed on your own page

<!-- 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>
` } 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) }