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

+57 -13
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type {
AnimatableProps,
Asset,
BackgroundVideo,
Clip,
ClipInstance,
ClipTrack,
@@ -111,6 +112,7 @@ function normalizeScene(scene: Scene): Scene {
scene.clipTracks ||= []
scene.clipInstances ||= []
scene.groups ||= []
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
const known = new Set<string>()
for (const l of scene.layers) known.add(l.id)
@@ -154,16 +156,20 @@ function normalizeScene(scene: Scene): Scene {
}
export function createBlankScene(): Scene {
return normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
})
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
}),
scene: createBlankScene(),
selectedLayerId: null,
playhead: 0,
playing: false,
@@ -219,6 +225,18 @@ export const useSceneStore = defineStore('scene', {
if (max <= 0) return this.activeDuration
return Math.min(max, this.activeDuration)
},
/** Effective in-point for playback/export. Defaults to 0, clamped to duration. */
playbackIn(): number {
if (this.activeClipId) return 0
const v = this.scene.inTime ?? 0
return Math.max(0, Math.min(this.scene.duration, v))
},
/** Effective out-point for playback/export. Defaults to duration, clamped >=in. */
playbackOut(): number {
if (this.activeClipId) return this.activeDuration
const v = this.scene.outTime ?? this.scene.duration
return Math.max(this.playbackIn, Math.min(this.scene.duration, v))
},
},
actions: {
addAsset(asset: AssetInit): Asset {
@@ -325,6 +343,30 @@ export const useSceneStore = defineStore('scene', {
setCanvasBackground(color: string) {
this.scene.canvas.background = color
},
setBackgroundVideo(video: BackgroundVideo | null) {
if (video && video.src) this.scene.backgroundVideo = { ...video }
else delete this.scene.backgroundVideo
},
setPlaybackRange(inTime: number | null, outTime: number | null) {
const dur = this.scene.duration
const hasIn = inTime !== null && inTime !== undefined
const hasOut = outTime !== null && outTime !== undefined
if (hasIn) {
const v = Math.max(0, Math.min(dur, inTime!))
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime
else this.scene.inTime = v
}
if (hasOut) {
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
else this.scene.outTime = v
}
// Re-clamp playhead into the new range
const lo = this.scene.inTime ?? 0
const hi = this.scene.outTime ?? dur
if (this.playhead < lo) this.playhead = lo
else if (this.playhead > hi) this.playhead = hi
},
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
@@ -935,7 +977,7 @@ export const useSceneStore = defineStore('scene', {
if (isMask) {
layer.isMask = true
if (mode) layer.maskMode = mode
else if (!layer.maskMode) layer.maskMode = 'positive'
else if (!layer.maskMode) layer.maskMode = 'negative'
} else {
delete layer.isMask
delete layer.maskMode
@@ -1219,15 +1261,17 @@ export const useSceneStore = defineStore('scene', {
togglePlay() {
if (this.playing) this.pause()
else {
const end = this.effectiveEnd
if (end <= 0) return
if (this.playhead >= end) this.playhead = 0
const end = this.playbackOut
const start = this.playbackIn
if (end <= start) return
// If the playhead sits outside the in/out range, snap to the in-point.
if (this.playhead < start || this.playhead >= end) this.playhead = start
this.play()
}
},
tick(deltaSeconds: number) {
if (!this.playing) return
const end = this.effectiveEnd
const end = this.playbackOut
const next = this.playhead + deltaSeconds
if (next >= end) {
this.playhead = end