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:
1 parent
69de48ea8d
commit
d4c4830fe2
17 files changed
+1332
-153
No files matched your search
+80
-10
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+52
-5
@@ -345,8 +345,12 @@ export function drawSceneFrame(
|
||||
ctx.rect(0, 0, lw, lh)
|
||||
ctx.clip()
|
||||
}
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
// A background video takes over the backdrop — keep the stage area
|
||||
// transparent so the DOM video underneath is visible.
|
||||
if (!scene.backgroundVideo) {
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
}
|
||||
|
||||
const assetMap = new Map<string, Asset>()
|
||||
for (const a of scene.assets) assetMap.set(a.id, a)
|
||||
@@ -367,11 +371,47 @@ export function drawSceneFrame(
|
||||
const rawOrder = scene.trackOrder?.length
|
||||
? scene.trackOrder
|
||||
: scene.layers.map((l) => l.id)
|
||||
|
||||
// Top-level mask layers (i.e. not inside a group) mask everything below
|
||||
// them in the stack until the very bottom. Render everything into an
|
||||
// offscreen buffer so we can apply each mask to whatever has been drawn
|
||||
// so far; we only pay for this when at least one top-level mask exists.
|
||||
const hasTopLevelMask = rawOrder.some((id) => {
|
||||
const l = layerById.get(id)
|
||||
return !!l && !!l.isMask
|
||||
})
|
||||
|
||||
let drawCtx: CanvasRenderingContext2D = ctx
|
||||
let off: HTMLCanvasElement | null = null
|
||||
if (hasTopLevelMask) {
|
||||
off = document.createElement('canvas')
|
||||
off.width = ctx.canvas.width
|
||||
off.height = ctx.canvas.height
|
||||
const oc = off.getContext('2d')
|
||||
if (oc) {
|
||||
const m = ctx.getTransform()
|
||||
oc.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
||||
drawCtx = oc
|
||||
} else {
|
||||
off = null
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = rawOrder.length - 1; i >= 0; i--) {
|
||||
const id = rawOrder[i]
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
drawLayers(ctx, [layer], assetMap, images, time)
|
||||
if (layer.isMask) {
|
||||
// Apply this mask to everything already drawn into the offscreen
|
||||
// buffer (the entire stack below it).
|
||||
drawCtx.save()
|
||||
drawCtx.globalCompositeOperation =
|
||||
layer.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(drawCtx, layer, assetMap, time)
|
||||
drawCtx.restore()
|
||||
} else {
|
||||
drawLayers(drawCtx, [layer], assetMap, images, time)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const instances = instancesByTrack.get(id) ?? []
|
||||
@@ -384,7 +424,7 @@ export function drawSceneFrame(
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = time - inst.startTime
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -395,9 +435,16 @@ export function drawSceneFrame(
|
||||
const cl = layerById.get(group.childIds[k])
|
||||
if (cl) childLayers.push(cl)
|
||||
}
|
||||
drawMaskedGroup(ctx, childLayers, assetMap, images, time)
|
||||
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time)
|
||||
}
|
||||
}
|
||||
|
||||
if (off) {
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.drawImage(off, 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
+14
-5
@@ -12,6 +12,14 @@ function effectiveEnd(scene: Scene): number {
|
||||
return Math.min(max, scene.duration)
|
||||
}
|
||||
|
||||
function playbackRange(scene: Scene): { start: number; end: number } {
|
||||
const autoEnd = effectiveEnd(scene)
|
||||
const start = Math.max(0, Math.min(scene.duration, scene.inTime ?? 0))
|
||||
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
||||
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export interface VideoExportResult {
|
||||
blob: Blob
|
||||
mimeType: string
|
||||
@@ -56,8 +64,10 @@ export async function renderSceneToVideo(
|
||||
if (!ctx) throw new Error('2D context unavailable')
|
||||
|
||||
const images = await loadSceneImages(scene)
|
||||
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
||||
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
||||
|
||||
drawSceneFrame(ctx, scene, 0, images)
|
||||
drawSceneFrame(ctx, scene, rangeIn, images)
|
||||
|
||||
const stream = canvas.captureStream(scene.fps)
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
@@ -76,7 +86,6 @@ export async function renderSceneToVideo(
|
||||
|
||||
recorder.start()
|
||||
const startedAt = performance.now()
|
||||
const renderDuration = effectiveEnd(scene)
|
||||
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
let raf = 0
|
||||
@@ -93,10 +102,10 @@ export async function renderSceneToVideo(
|
||||
return
|
||||
}
|
||||
const elapsed = (performance.now() - startedAt) / 1000
|
||||
const t = Math.min(elapsed, renderDuration)
|
||||
const t = rangeIn + Math.min(elapsed, rangeLen)
|
||||
drawSceneFrame(ctx, scene, t, images)
|
||||
opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1)
|
||||
if (elapsed >= renderDuration) {
|
||||
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
|
||||
if (elapsed >= rangeLen) {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user