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

+80 -10
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '1'
const RUNTIME_VERSION = '2'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -140,11 +140,26 @@ const ENGINE_SOURCE = `(function(){
if(!container)return;
opts=opts||{};
var loop=opts.loop!==false;
var sceneEnd=effectiveEnd(scene);
// Honour explicit in/out points; fall back to 0 and the animation's own end.
var rangeIn=Math.max(0, Math.min(scene.duration, scene.inTime==null?0:scene.inTime));
var autoEnd=effectiveEnd(scene);
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?autoEnd:scene.outTime));
var sceneEnd=rangeOut;
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
var bgVideo=null;
if(scene.backgroundVideo && scene.backgroundVideo.src){
bgVideo=document.createElement('video');
bgVideo.src=scene.backgroundVideo.src;
bgVideo.muted=true;
bgVideo.playsInline=true;
bgVideo.loop=!!loop;
bgVideo.preload='auto';
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
container.appendChild(bgVideo);
}
var canvas=document.createElement('canvas');
canvas.style.cssText='display:block;width:100%;height:100%;';
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
container.appendChild(canvas);
var ctx=canvas.getContext('2d');
var dpr=window.devicePixelRatio||1;
@@ -162,13 +177,28 @@ const ENGINE_SOURCE = `(function(){
scene.assets.forEach(function(a){assetMap[a.id]=a;});
var imgs={};
var start=0;
var bgStarted=false;
function syncBgVideo(t){
if(!bgVideo) return;
if(!bgStarted){
bgStarted=true;
try{ bgVideo.play(); }catch(e){}
}
if(Math.abs(bgVideo.currentTime-t)>0.2){
bgVideo.currentTime=t;
}
}
var rangeLen=Math.max(0, rangeOut-rangeIn);
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;
var elapsed=(now-start)/1000;
var t=rangeIn;
if(rangeLen<=0){ draw(t); syncBgVideo(t); requestAnimationFrame(frame); return; }
if(loop) t=rangeIn+(elapsed%rangeLen);
else if(elapsed>=rangeLen) t=rangeOut;
else t=rangeIn+elapsed;
draw(t);
syncBgVideo(t);
requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
@@ -364,8 +394,10 @@ const ENGINE_SOURCE = `(function(){
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 layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={};
@@ -381,10 +413,32 @@ const ENGINE_SOURCE = `(function(){
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); }
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){ drawLayer(lyr,time); continue; }
if(lyr){
if(lyr.isMask){
ctx.save();
ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(ctx,lyr,time);
ctx.restore();
continue;
}
drawLayer(lyr,time); continue;
}
var tInsts=instByTrack[id]||[];
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
@@ -437,6 +491,13 @@ const ENGINE_SOURCE = `(function(){
}
}
}
if(topOff){
ctx=mainCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(topOff,0,0);
ctx.restore();
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
@@ -476,6 +537,15 @@ const SCENE_BOOT_SOURCE = `(function(){
`
function jsonForExport(scene: Scene): string {
// Data-URL background videos are stripped from the export — local video
// files can't be embedded reasonably. The user needs to host the media
// externally and set a URL in settings before exporting.
const bg = scene.backgroundVideo
if (bg && bg.src && bg.src.startsWith('data:')) {
const { backgroundVideo, ...rest } = scene
void backgroundVideo
return JSON.stringify(rest)
}
return JSON.stringify(scene)
}