Files
RUWIO/src/lib/exportPlayer.ts
T
thandClaude Opus 4.7 276d621d20 0.3.0: freeform drawing, stroke properties, polygon edit gestures
- Freeform drawing tool: new sidebar button enters a click-to-draw mode
  that appends points, shows a live ghost preview line, and exits via
  Enter or double-click (near the start point closes the path). Works
  end-to-end through all four render pipelines.
- Polygon authoring gestures: double-click on a path inserts a vertex on
  the nearest edge; double-click on an open polygon's endpoint resumes
  the drawing session from that end, reversing point order when needed.
- Stroke + closed-path properties: all shape types (rect, circle, text,
  polygon) expose stroke color, stroke width, and (for polygons) a
  closed toggle. Fill and stroke each support a "transparent" switch so
  shapes can render as outline-only or fill-only.
- Mask preview no longer inherits the user's stroke — the dashed ghost
  keeps its own outline regardless of stroke-width changes.
- Playhead no longer dims on inactive timeline tracks; only keyframes,
  segments and clip blocks fade.
- Properties panel point-count control for polygons, plus per-keyframe
  points animation that correctly interpolates once a baseline exists.
- Pivot drag hides the rotation handle until release.
- Transformer attach retries on a short timer to avoid races where the
  Konva node hasn't mounted yet when selection changes.

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

607 lines
21 KiB
TypeScript

import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '1'
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++){
lp.push({x:lerp(a.points[lpi].x,b.points[lpi].x,t),y:lerp(a.points[lpi].y,b.points[lpi].y,t)});
}
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;
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);
return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
}
}
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 effectiveEnd(scene){
var max=0;
for(var i=0;i<scene.layers.length;i++){
var kfs=scene.layers[i].keyframes;
for(var j=0;j<kfs.length;j++){ if(kfs[j].time>max) max=kfs[j].time; }
}
var instances=scene.clipInstances||[];
var clips=scene.clips||[];
var clipById={};
for(var ci=0;ci<clips.length;ci++) clipById[clips[ci].id]=clips[ci];
for(var ii=0;ii<instances.length;ii++){
var inst=instances[ii];
var cl=clipById[inst.clipId];
if(!cl) continue;
var trimS=inst.trimStart||0;
var trimE=inst.trimEnd||0;
var end=inst.startTime+Math.max(0,cl.duration-trimS-trimE);
if(end>max) max=end;
}
if(max<=0) return scene.duration;
return Math.min(max,scene.duration);
}
function mount(container,scene,opts){
if(!container)return;
opts=opts||{};
var loop=opts.loop!==false;
var sceneEnd=effectiveEnd(scene);
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
var canvas=document.createElement('canvas');
canvas.style.cssText='display:block;width:100%;height:100%;';
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;
function frame(now){
if(!start)start=now;
var t=(now-start)/1000;
if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; }
if(loop) t=t%sceneEnd;
else if(t>sceneEnd) t=sceneEnd;
draw(t);
requestAnimationFrame(frame);
}
function drawMaskShape(ctxm,layer,time){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') 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){
ctxm.beginPath();
ctxm.moveTo(mpPts[0].x-mpOffX,mpPts[0].y-mpOffY);
for(var mpI=1;mpI<mpPts.length;mpI++)ctxm.lineTo(mpPts[mpI].x-mpOffX,mpPts[mpI].y-mpOffY);
ctxm.closePath();
ctxm.fill();
}
}
ctxm.restore();
}
function drawLayer(layer,time){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)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)';
ctx.beginPath();
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
for(var pI=1;pI<pPts.length;pI++)ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
if(pClosed) ctx.closePath();
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);
}
}
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;
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();
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); }
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ 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;
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
}
}
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();
}
}
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
imgs=loaded;
requestAnimationFrame(frame);
});
}
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);
})();
`
function jsonForExport(scene: Scene): string {
return JSON.stringify(scene)
}
export interface ExportOptions {
loop?: boolean
}
export function generateRuntimeScript(): string {
return (
'/* Animatr 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 (
'/* Animatr scene — generated ' + new Date().toISOString() + '\n' +
' * Requires animatr-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 (
'/* Animatr 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 = 'animatr-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>Animatr — 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>Animatr 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>&lt;!-- once per page --&gt;
&lt;script src="animatr-runtime.js"&gt;&lt;/script&gt;
&lt;!-- per animation: one container + scene script --&gt;
&lt;div style="width: 800px; height: 450px;"&gt;
&lt;script src="animation.js"&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;div style="width: 400px; height: 225px;"&gt;
&lt;script src="animation-2.js"&gt;&lt;/script&gt;
&lt;/div&gt;</pre>
</div>
</body>
</html>
`
}
export function downloadExampleHtml(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)
}