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

+52 -5
View File
@@ -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()
}