Files
RUWIO/server/render.html
T
thandClaude Opus 4.7 ddea7d348f 0.5.0: RUWIO rebrand, vertex bezier, animation paths, welcome focus
- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG),
  welcome dialog header and about dialog switch from the PhSparkle
  glyph to the real logo. Redundant name text next to the logo is
  removed across the board.
- About dialog: logo spans the full dialog width, the bottom Close
  button is gone (close via header X stays).
- Vertex bezier for polygons: each vertex can be Corner / Curve / Split
  curve via right-click. Curve mirrors tangent handles symmetrically,
  split curve keeps them independent. Tangents are fully animatable
  per keyframe — lerp mirrored across all four render pipelines.
- Stage path overlay: selecting a keyframe draws the tween arc between
  the previous and selected keyframe with draggable per-frame dots that
  curve the path via quadratic bezier control points. Other segments
  on the same track render as dashed ghost lines so the full curve is
  visible. Path dots grow + show grab cursor on hover.
- Vertex box-select: click-drag on empty stage while a polygon is
  selected boxes vertices. Shift-click toggles individual vertices
  into/out of the selection. Multi-vertex drag moves all selected at
  once; right-click on a selected vertex applies Corner/Curve/Split
  to all. Tangent handles only render when exactly one vertex is
  selected.
- Welcome dialog focus: opens with the most recent project focused.
  Enter opens it; Tab cycles only through the dialog's real buttons
  (delete icons pulled out of tab order, scrollable body removed from
  tab order). Export-current removed from welcome (not useful on
  startup).
- Modal focus trap: all modals now keep Tab inside the dialog.
- Fill / stroke transparency toggle restores the last real color when
  flipped back on, with a layer-base / asset fallback chain so there's
  always a visible color to restore to. A watcher keeps the restore
  cache in sync with the sampled value.

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

363 lines
14 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html,body { margin:0; padding:0; overflow:hidden; background:transparent; }
canvas { display:block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function(){
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(b,p){ var o={}; for(var k in b) o[k]=b[k]; if(p) for(var k2 in p) o[k2]=p[k2]; return o; }
function lerp(a,b,t){ return a+(b-a)*t; }
function parseHex(h){
if(!h) return null;
var s=String(h).trim().replace('#','');
if(s.length===3) s=s[0]+s[0]+s[1]+s[1]+s[2]+s[2];
if(s.length<6) return null;
var n=parseInt(s.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 o={
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) o.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) o.fill=b.fill;
else if(a.fill) o.fill=a.fill;
if(a.points && b.points && a.points.length===b.points.length){
var lp=[];
for(var pi=0;pi<a.points.length;pi++){
var pa=a.points[pi], pb=b.points[pi];
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);
}
o.points=lp;
} else if(b.points) o.points=b.points;
else if(a.points) o.points=a.points;
if(a.stroke && b.stroke) o.stroke=lerpColor(a.stroke,b.stroke,t);
else if(b.stroke) o.stroke=b.stroke;
else if(a.stroke) o.stroke=a.stroke;
if(a.strokeWidth!=null && b.strokeWidth!=null) o.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t);
else if(b.strokeWidth!=null) o.strokeWidth=b.strokeWidth;
else if(a.strokeWidth!=null) o.strokeWidth=a.strokeWidth;
return o;
}
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 outS=lerpProps(pa,pb,t);
if(b.ctrlX!=null && b.ctrlY!=null){
var om=1-t;
outS.x=om*om*pa.x+2*om*t*b.ctrlX+t*t*pb.x;
outS.y=om*om*pa.y+2*om*t*b.ctrlY+t*t*pb.y;
}
return outS;
}
}
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(res){
var img=new Image();
img.onload=function(){ imgs[a.id]=img; res(); };
img.onerror=function(){ res(); };
img.src=a.src;
});
})).then(function(){ return imgs; });
}
var canvas=document.getElementById('c');
var ctx=canvas.getContext('2d');
var scene=null, images={};
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, am, time){
var asset=am[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 mrX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrX,-mrY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcX=layer.offsetX!=null?layer.offsetX:0;
var mcY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath(); ctxm.arc(-mcX,-mcY,asset.radius,0,Math.PI*2); ctxm.fill();
} else if(asset.kind==='text'){
var mtX=layer.offsetX!=null?layer.offsetX:0;
var mtY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif';
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtX,-mtY);
} else {
var mpX=layer.offsetX!=null?layer.offsetX:0;
var mpY=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,mpX,mpY,true);
ctxm.fill();
}
}
ctxm.restore();
}
function drawLayerNode(layer, am, time){
var asset=am[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, sBl=p.shadowBlur||0, 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 fill = p.fill || asset.fill;
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)';
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; 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);
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; 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';
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; 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);
if(pClosed && fill && fill!=='transparent'){
ctx.fillStyle=fill;
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=images[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 drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height;
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
var layerById={};
for(var li=0;li<scene.layers.length;li++) layerById[scene.layers[li].id]=scene.layers[li];
var clipById={}; var clips=scene.clips||[];
for(var ci=0;ci<clips.length;ci++) clipById[clips[ci].id]=clips[ci];
var instByTrack={}; var instances=scene.clipInstances||[];
for(var ii=0;ii<instances.length;ii++){
var iE=instances[ii];
(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 oj=0;oj<scene.layers.length;oj++) rawOrder.push(scene.layers[oj].id); }
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ drawLayerNode(lyr, am, time); continue; }
var tInsts=instByTrack[id]||[];
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
var inst=tInsts[ti];
var clipRef=clipById[inst.clipId];
if(!clipRef) continue;
var tS=inst.trimStart||0;
var tE=inst.trimEnd||0;
var visibleClip=Math.max(0, clipRef.duration - tS - tE);
var offsetIntoBlock=time - inst.startTime;
if(offsetIntoBlock<0 || offsetIntoBlock>visibleClip) continue;
var localT = tS + offsetIntoBlock;
for(var cll=clipRef.layers.length-1; cll>=0; cll--){
drawLayerNode(clipRef.layers[cll], am, 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++) drawLayerNode(gContent[gi3], am, 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++) drawLayerNode(gContent[gi4], am, 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, am, time);
mctx.restore();
}
ctx=savedCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(mcanvas,0,0);
ctx.restore();
}
}
}
ctx.restore();
}
window.__animatrInit=function(s){
scene=s;
canvas.width=s.canvas.width;
canvas.height=s.canvas.height;
document.body.style.width=s.canvas.width+'px';
document.body.style.height=s.canvas.height+'px';
return loadImages(s.assets).then(function(imgs){
images=imgs;
drawFrame(0);
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
})();
</script>
</body>
</html>