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

+234 -8
View File
@@ -1,5 +1,5 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { useSceneStore } from '@/stores/scene'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -549,10 +549,10 @@ export function registerAllTests(): void {
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 600 })
s.setPlayhead(s.effectiveEnd)
s.setPlayhead(s.playbackOut)
await tick()
s.togglePlay()
expect(s.playhead).toBe(0)
expect(s.playhead).toBe(s.playbackIn)
expect(s.playing).toBe(true)
s.pause()
await tick()
@@ -570,17 +570,17 @@ export function registerAllTests(): void {
await tick()
})
test('tick stops at effectiveEnd', async () => {
test('tick stops at playbackOut', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
s.play()
await tick()
s.tick(10)
s.tick(s.scene.duration + 1)
await tick()
expect(s.playing).toBe(false)
expect(s.playhead).toBe(s.effectiveEnd)
expect(s.playhead).toBe(s.playbackOut)
})
})
@@ -1577,14 +1577,14 @@ export function registerAllTests(): void {
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables positive mask by default', () => {
test('setLayerMask(true) enables negative mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('positive')
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
@@ -2761,4 +2761,230 @@ export function registerAllTests(): void {
expect(maskOutside[3]).toBe(0)
})
})
suite('scene store — playback range (in/out)', () => {
test('defaults: playbackIn=0, playbackOut=duration', () => {
const s = makeFreshStore()
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(s.scene.duration)
expect(s.scene.inTime).toBe(undefined)
expect(s.scene.outTime).toBe(undefined)
})
test('setPlaybackRange clamps values into [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(-5, 20)
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(10)
})
test('setPlaybackRange keeps out >= in', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(6, null)
s.setPlaybackRange(null, 3)
// out is clamped up to in (6), not down to 3
expect(s.playbackOut).toBe(6)
})
test('setPlaybackRange clamps playhead into new range', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlayhead(1)
s.setPlaybackRange(4, 8)
expect(s.playhead).toBe(4)
s.setPlayhead(9)
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
// push it with setPlaybackRange again:
s.setPlaybackRange(4, 7)
expect(s.playhead).toBe(7)
})
test('setPlaybackRange null for both leaves scene alone', () => {
const s = makeFreshStore()
s.setPlaybackRange(2, 5)
s.setPlaybackRange(null, null)
expect(s.playbackIn).toBe(2)
expect(s.playbackOut).toBe(5)
})
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, 7)
s.setPlayhead(1) // before in
await tick()
s.togglePlay()
expect(s.playhead).toBe(3)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick stops exactly at playbackOut within range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(2, 5)
s.setPlayhead(2)
s.play()
await tick()
s.tick(4) // would put playhead at 6, past out=5
await tick()
expect(s.playhead).toBe(5)
expect(s.playing).toBe(false)
})
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, null)
expect(s.scene.inTime).toBe(3)
s.setPlaybackRange(0, null)
expect(s.scene.inTime).toBe(undefined)
})
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(null, 7)
expect(s.scene.outTime).toBe(7)
s.setPlaybackRange(null, 10)
expect(s.scene.outTime).toBe(undefined)
})
})
suite('scene store — blank scene & normalization', () => {
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
const s = createBlankScene()
expect(s.version).toBe(1)
expect(s.canvas.width).toBe(1280)
expect(s.canvas.height).toBe(720)
expect(s.canvas.background).toBe('#ffffff')
expect(s.duration).toBe(10)
expect(s.fps).toBe(60)
expect(s.assets).toHaveLength(0)
expect(s.layers).toHaveLength(0)
})
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
s.addLayer(a.id)
s.setPlayhead(3)
s.play()
await tick()
s.loadScene(createBlankScene())
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.scene.canvas.background).toBe('#ffffff')
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('loadScene fills in canvas.background when missing', () => {
const s = makeFreshStore()
const broken = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(broken)
expect(s.scene.canvas.background).toBe('#ffffff')
})
})
suite('scene store — mask defaults', () => {
test('setLayerMask(true) defaults to negative mask mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('render — top-level masks cascade', () => {
test('top-level positive mask clears content outside the mask shape', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Full-stage red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask layer above it in the stack (top-level, NO group)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside mask circle → red kept
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask circle → cleared
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
test('top-level negative mask cuts a hole in the content below', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 15,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the circle → hole (cleared)
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[3]).toBe(0)
// Outside circle → red kept
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
})
}