- 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>
363 lines
14 KiB
HTML
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>
|