0.6.0: background video, in/out range, top-level masks, track icons

- 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>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 20:19:34 +02:00
1 parent 69de48ea8d
commit d4c4830fe2
17 files changed
+1332 -153

No files matched your search

+34 -3
View File
@@ -269,8 +269,10 @@
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);
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];
@@ -285,10 +287,32 @@
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){ drawLayerNode(lyr, am, time); continue; }
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++){
@@ -341,6 +365,13 @@
}
}
}
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){
+12 -3
View File
@@ -103,10 +103,19 @@ function effectiveEnd(scene) {
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60)
const sceneEnd = effectiveEnd(scene)
const duration = Number(options.duration || sceneEnd)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
const duration = Number(options.duration || rangeLen)
const totalFrames = Math.max(1, Math.round(duration * fps))
const crf = String(options.crf ?? 18)
const preset = String(options.preset ?? 'fast')
@@ -162,7 +171,7 @@ async function runJob(job, scene, options) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
throw new Error('aborted')
}
const t = Math.min(i / fps, duration)
const t = rangeIn + Math.min(i / fps, duration)
await page.evaluate((time) => window.__animatrSeek(time), t)
const buf = await page.screenshot({
type: 'png',