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

+251 -5
View File
@@ -15,7 +15,14 @@ import {
PhEyeSlash,
PhMagnet,
PhMaskHappy,
PhCircle,
PhImage,
PhPauseCircle,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
@@ -27,6 +34,7 @@ import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composabl
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Asset,
Clip,
ClipInstance,
Easing,
@@ -311,6 +319,51 @@ function onScrubPointerUp(e: PointerEvent) {
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function bracketTimeFromEvent(e: PointerEvent, el: HTMLElement): number {
const ruler = el.parentElement as HTMLElement | null
if (!ruler) return 0
const rect = ruler.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return snapTime(ratio * duration.value)
}
function onInBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onInBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(Math.min(t, store.playbackOut), null)
}
function onInBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onOutBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onOutBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(null, Math.max(t, store.playbackIn))
}
function onOutBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onLaneClick(e: MouseEvent) {
if (kfDrag.value) return
const el = e.currentTarget as HTMLElement
@@ -320,6 +373,34 @@ function onLaneClick(e: MouseEvent) {
store.setPlayhead(snapTime(ratio * duration.value))
}
const canCreateKf = computed(() => {
const id = store.selectedLayerId
if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id)
return !!layer && !layer.locked
})
function addKeyframeForSelected() {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return
const time = snapTime(store.playhead)
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
scaleX: sampled.scaleX,
scaleY: sampled.scaleY,
opacity: sampled.opacity,
}
if (sampled.fill) props.fill = sampled.fill
if (!layer.separated) {
props.x = sampled.x
props.y = sampled.y
props.rotation = sampled.rotation
}
store.upsertKeyframe(id, time, props)
}
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
e.stopPropagation()
const el = e.currentTarget as HTMLElement
@@ -392,6 +473,23 @@ function emitLayerTrack(
}
}
const assetById = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
return m
})
function assetIconFor(layer: Layer) {
const asset = assetById.value.get(layer.assetId)
if (!asset) return null
if (asset.kind === 'rect') return PhSquare
if (asset.kind === 'circle') return PhCircle
if (asset.kind === 'image') return PhImage
if (asset.kind === 'text') return PhTextT
if (asset.kind === 'polygon') return asset.closed === false ? PhPencilSimpleLine : PhPolygon
return null
}
const lockedGroupChildIds = computed(() => {
const s = new Set<string>()
for (const g of store.scene.groups ?? []) {
@@ -1453,9 +1551,25 @@ function onKeyDown(e: KeyboardEvent) {
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return false
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
return
}
if ((e.key === 'Backspace' || e.key === 'Delete') && selectedKfs.value.length) {
if (isEditableTarget(e.target)) return
e.preventDefault()
const targets = [...selectedKfs.value]
selectedKfs.value = []
for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
}
}
@@ -1591,6 +1705,14 @@ onBeforeUnmount(() => {
>
<PhFolderPlus :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
:title="t('timeline.addKeyframeTooltip')"
:disabled="!canCreateKf"
@click="addKeyframeForSelected"
>
<PhPlus :size="12" :weight="'bold'" />
</button>
</div>
<div
class="scrub-ruler"
@@ -1610,6 +1732,37 @@ onBeforeUnmount(() => {
<span class="tick-line"></span>
<span class="tick-label">{{ formatTick(t) }}</span>
</span>
<span
v-if="store.playbackIn > 0"
class="range-dim dim-before"
:style="{ width: pxFromTime(store.playbackIn) + 'px' }"
></span>
<span
v-if="store.playbackOut < duration"
class="range-dim dim-after"
:style="{
left: pxFromTime(store.playbackOut) + 'px',
width: pxFromTime(duration - store.playbackOut) + 'px',
}"
></span>
<span
class="range-bracket bracket-in"
:title="t('timeline.inPoint')"
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
@pointerdown="onInBracketPointerDown"
@pointermove="onInBracketPointerMove"
@pointerup="onInBracketPointerUp"
@pointercancel="onInBracketPointerUp"
></span>
<span
class="range-bracket bracket-out"
:title="t('timeline.outPoint')"
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
@pointerdown="onOutBracketPointerDown"
@pointermove="onOutBracketPointerMove"
@pointerup="onOutBracketPointerUp"
@pointercancel="onOutBracketPointerUp"
></span>
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
@@ -1693,6 +1846,12 @@ onBeforeUnmount(() => {
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
<component
v-else-if="track.isParent && assetIconFor(track.layer)"
:is="assetIconFor(track.layer)"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
@@ -2184,7 +2343,7 @@ onBeforeUnmount(() => {
.name-spacer {
position: sticky;
left: 0;
z-index: 2;
z-index: 30;
width: var(--track-name-width);
background: var(--panel);
border-right: 1px solid var(--border);
@@ -2221,6 +2380,17 @@ onBeforeUnmount(() => {
border-color: var(--accent-soft);
}
.snap-btn:disabled {
opacity: 0.4;
cursor: default;
background: var(--bg);
color: var(--text-dim);
}
.snap-btn.add-kf-btn {
margin-left: auto;
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
@@ -2300,6 +2470,76 @@ onBeforeUnmount(() => {
font-variant-numeric: tabular-nums;
}
.range-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 1;
}
.range-dim.dim-before {
left: 0;
}
.range-bracket {
position: absolute;
top: 0;
bottom: 0;
width: 10px;
cursor: ew-resize;
z-index: 19;
touch-action: none;
}
.range-bracket::before,
.range-bracket::after {
content: '';
position: absolute;
background: var(--range-bracket);
transition: background 120ms var(--ease);
}
.range-bracket::before {
top: 0;
bottom: 0;
width: 2px;
}
.range-bracket::after {
top: 0;
width: 8px;
height: 6px;
}
.range-bracket.bracket-in::before {
left: 0;
}
.range-bracket.bracket-in::after {
left: 0;
border-radius: 0 2px 2px 0;
}
.range-bracket.bracket-in {
transform: translateX(-0.5px);
}
.range-bracket.bracket-out::before {
right: 0;
}
.range-bracket.bracket-out::after {
right: 0;
border-radius: 2px 0 0 2px;
}
.range-bracket.bracket-out {
transform: translateX(calc(0.5px - 10px));
}
.range-bracket:hover::before,
.range-bracket:hover::after {
background: var(--range-hover);
}
.scrub-handle {
position: absolute;
top: 0;
@@ -2319,6 +2559,13 @@ onBeforeUnmount(() => {
margin-top: 2px;
clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: ew-resize;
transition: background 120ms var(--ease);
}
.scrub-handle-head:hover {
background: var(--range-hover);
}
.scrub-handle-line {
@@ -2519,7 +2766,7 @@ onBeforeUnmount(() => {
.track-name {
position: sticky;
left: 0;
z-index: 5;
z-index: 30;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
@@ -2632,9 +2879,7 @@ onBeforeUnmount(() => {
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
/* Must render above the sticky track-name column (z-index 5) and the
resize strip (z-index 3) so the playhead stays visible at time=0 or
when scrolled. */
/* Above segments/keyframes but below sticky label columns (z-index 30). */
z-index: 20;
}
@@ -2913,6 +3158,7 @@ onBeforeUnmount(() => {
font-size: 12px;
color: var(--text);
gap: 4px;
padding-left: 7px;
cursor: pointer;
}