- background video layer (document-level), syncs to playhead in editor and fullscreen - playback in/out brackets on the scrub ruler; i/o set them, r rewinds to in-point - togglePlay, tick, fullscreen loop, WebM + server FFmpeg export all honor [in, out] - top-level mask layers cascade over everything below until a group boundary - fullscreen preview slaves localTime to the bg video so it stops stuttering - welcome "new project" now actually loads a blank scene (was a no-op) - default canvas background hardened to white via normalizeScene - new keyframe button (+) in the name-spacer, backspace/delete removes selected kfs - drag-end auto-selects the created/shifted keyframe so the path is visible - animation path updates live during drag and spans every selected keyframe - track rows show an icon per asset kind (rect/circle/image/text/polygon/freeform) - default mask mode is negative, buttons read − then + - sticky track-name columns raised above playhead/brackets; orange bracket styling - tests for playback range, blank scene, mask defaults, cascading top-level masks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
394 lines
15 KiB
HTML
394 lines
15 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();
|
|
if(!scene.backgroundVideo){
|
|
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); }
|
|
var hasTopMask=false;
|
|
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
|
|
var mainCtx=ctx, topOff=null;
|
|
if(hasTopMask){
|
|
topOff=document.createElement('canvas');
|
|
topOff.width=canvas.width; topOff.height=canvas.height;
|
|
var topOffCtx=topOff.getContext('2d');
|
|
if(topOffCtx){
|
|
var tmT=ctx.getTransform();
|
|
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
|
|
ctx=topOffCtx;
|
|
} else { topOff=null; }
|
|
}
|
|
for(var oi=rawOrder.length-1;oi>=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, am, time);
|
|
ctx.restore();
|
|
continue;
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
if(topOff){
|
|
ctx=mainCtx;
|
|
ctx.save();
|
|
ctx.setTransform(1,0,0,1,0,0);
|
|
ctx.drawImage(topOff,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>
|