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

+96 -14
View File
@@ -14,6 +14,7 @@ const canvasEl = ref<HTMLCanvasElement | null>(null)
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref<Record<string, HTMLImageElement>>({})
const localTime = ref(0)
@@ -28,7 +29,8 @@ const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
)
const sceneEnd = computed(() => store.effectiveEnd)
const rangeIn = computed(() => store.playbackIn)
const rangeOut = computed(() => store.playbackOut)
let raf = 0
let lastFrame = 0
@@ -70,12 +72,27 @@ function draw() {
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
const end = sceneEnd.value
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
if (end > 0) {
let next = localTime.value + dt
if (next >= end) next = 0
localTime.value = next
const start = rangeIn.value
const end = rangeOut.value
if (end > start) {
const v = bgVideoEl.value
if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
// Slave localTime to the video element so drift can't build up and
// cause compensating seeks — those seeks are what makes the video
// stutter visibly.
let vt = v.currentTime
if (vt >= end || vt < start) {
vt = start
v.currentTime = vt
}
localTime.value = vt
} else {
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
let next = localTime.value + dt
if (next >= end) next = start
else if (next < start) next = start
localTime.value = next
}
}
}
lastFrame = now
@@ -95,12 +112,27 @@ function stopLoop() {
}
async function startPreview() {
localTime.value = store.playhead
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
localTime.value = head < start || head >= end ? start : head
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
// The playing watcher may have fired before the <video> ref was bound;
// make sure the background video is actually started now that the DOM
// is mounted.
const v = bgVideoEl.value
if (v) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore autoplay errors */
}
}
await requestBrowserFullscreen()
}
@@ -152,9 +184,11 @@ function onRootMouseLeave() {
}
const progressPct = computed(() => {
const end = sceneEnd.value
if (!end) return 0
return Math.max(0, Math.min(1, localTime.value / end)) * 100
const start = rangeIn.value
const end = rangeOut.value
const len = end - start
if (len <= 0) return 0
return Math.max(0, Math.min(1, (localTime.value - start) / len)) * 100
})
function seekFromEvent(e: PointerEvent) {
@@ -163,8 +197,9 @@ function seekFromEvent(e: PointerEvent) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const end = sceneEnd.value
localTime.value = ratio * end
const start = rangeIn.value
const end = rangeOut.value
localTime.value = start + ratio * (end - start)
draw()
}
@@ -227,6 +262,31 @@ watch(
},
)
watch(playing, async (p) => {
const v = bgVideoEl.value
if (!v) return
if (p) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore */
}
} else {
v.pause()
v.currentTime = localTime.value
}
})
// Keep the video frame aligned with localTime when we are NOT letting the
// video drive time (paused or actively scrubbing). During normal playback
// the RAF loop reads v.currentTime, so we must not seek here.
watch(localTime, (t) => {
const v = bgVideoEl.value
if (!v) return
if (playing.value && !scrubbing.value) return
v.currentTime = t
})
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
@@ -256,7 +316,7 @@ watch(frameEl, (el, _old, onCleanup) => {
})
const timeLabel = computed(() => {
const end = sceneEnd.value || 0
const end = rangeOut.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
})
</script>
@@ -274,6 +334,15 @@ const timeLabel = computed(() => {
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="fs-bg-video"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<canvas ref="canvasEl" />
</div>
@@ -324,6 +393,7 @@ const timeLabel = computed(() => {
}
.fs-frame {
position: relative;
width: min(100vw, 100vh * (16 / 9));
max-width: 100%;
max-height: 100%;
@@ -332,10 +402,22 @@ const timeLabel = computed(() => {
justify-content: center;
}
.fs-bg-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
z-index: 0;
}
.fs-frame canvas {
display: block;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
.fs-controls {