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
+234
-8
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user