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).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=last.time) return merge(base,last.props); for(var i=0;i=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>>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 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=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; ssipy)!==(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': { // 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=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=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;ti0 // 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;i1 && 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=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=mn-EPS && timea.time+EPS && time0 && (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=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;ti=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 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 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() 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, '>') } 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 ? '' : `

Fonts used in this animation

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:

    ${builtinUsed.map(f => `
  • ${fontPrimary(f.id)} — ${f.file.replace(/^\//, '')}
  • `).join('\n')}${ customUsed.length > 0 ? '\n' + customUsed.map(f => `
  • ${escapeHtml(f.label)} — locally uploaded; the @font-face below has the file inlined as a data URL
  • `).join('\n') : '' }
${escapeHtml(fontFaceCss(usedFontIds))}
` return ` RUWIO — Example

RUWIO Export

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

${fontHint}

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(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) }