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

+260 -72
View File
@@ -7,7 +7,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
import type { AnimatableProps, Asset, Keyframe, Layer, Point } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -20,6 +20,7 @@ const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
@@ -85,14 +86,88 @@ const clipGroupConfig = computed(() => {
}
})
const backgroundConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
fill: store.scene.canvas.background,
listening: false,
}))
const backgroundConfig = computed(() => {
// Always set `fill` — toggling it in/out of the config across scene
// reloads can leave Konva's Rect with a stale attribute. Use a
// transparent fill when a background video is present so the DOM
// video shows through.
const fill = store.scene.backgroundVideo
? 'transparent'
: store.scene.canvas.background || '#ffffff'
return {
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
listening: false,
fill,
}
})
const bgVideoStyle = computed(() => {
const w = store.scene.canvas.width * zoom.value
const h = store.scene.canvas.height * zoom.value
return {
left: `${viewX.value}px`,
top: `${viewY.value}px`,
width: `${w}px`,
height: `${h}px`,
}
})
// Playhead sync for the background video — only resync current time on bigger
// deltas so regular playback stays smooth.
const BG_VIDEO_SYNC_THRESHOLD = 0.12
watch(
() => store.playing,
async (playing) => {
const v = bgVideoEl.value
if (!v) return
if (playing) {
v.currentTime = store.playhead
try {
await v.play()
} catch {
/* autoplay might be blocked if the tab has no user gesture yet */
}
} else {
v.pause()
v.currentTime = store.playhead
}
},
)
watch(
() => store.playhead,
(t) => {
const v = bgVideoEl.value
if (!v) return
const diff = Math.abs(v.currentTime - t)
if (!store.playing) {
v.currentTime = t
return
}
if (diff > BG_VIDEO_SYNC_THRESHOLD) v.currentTime = t
},
)
// When the video element mounts (or the src changes) do an initial sync.
watch(
() => store.scene.backgroundVideo?.src,
async () => {
await nextTick()
const v = bgVideoEl.value
if (!v) return
v.currentTime = store.playhead
if (store.playing) {
try {
await v.play()
} catch {
/* ignored */
}
}
},
)
const canvasBorderConfig = computed(() => ({
x: 0,
@@ -1288,11 +1363,15 @@ interface PathSegment {
flat: number[]
}
interface ActiveSegment {
keyframeId: string
frames: PathFrame[]
}
interface PathOverlay {
layerId: string
activeKeyframeId: string | null
ghostSegments: PathSegment[]
activeFrames: PathFrame[]
activeSegments: ActiveSegment[]
}
function sampleSegment(layer: Layer, from: number, to: number, fps: number): number[] {
@@ -1313,26 +1392,60 @@ const animationPath = computed<PathOverlay | null>(() => {
if (sel.length === 0) return null
const first = sel[0]
if (first.kind === 'rotation') return null
const layer = store.activeLayers.find((l) => l.id === first.layerId)
if (!layer) return null
const arr = first.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const origLayer = store.activeLayers.find((l) => l.id === first.layerId)
if (!origLayer) return null
const arr = first.kind === 'position' ? origLayer.positionKeyframes : origLayer.keyframes
if (!arr) return null
const sorted = [...arr].sort((a, b) => a.time - b.time)
const idx = sorted.findIndex((k) => k.id === first.keyframeId)
if (idx < 0) return null
// While the user drags this layer's object, clone its keyframes so the
// path reflects the live position without mutating the store. If a kf
// at the playhead already exists, override its x/y; otherwise splice
// in a synthetic one so the path gets a live endpoint.
const live = liveLayerPose.value
let layer = origLayer
let workingArr = arr
if (live && live.layerId === origLayer.id) {
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (existing) {
existing.props.x = live.x
existing.props.y = live.y
} else {
cloned.push({
id: '__live__',
time: store.playhead,
props: { x: live.x, y: live.y },
easing: 'linear',
})
}
cloned.sort((a, b) => a.time - b.time)
workingArr = cloned
layer =
first.kind === 'position'
? { ...origLayer, positionKeyframes: cloned }
: { ...origLayer, keyframes: cloned }
}
// Every selected keyframe on this layer+kind gets an interactive
// active segment (the incoming curve). Other segments stay ghost.
const selectedIds = new Set<string>()
for (const s of sel) {
if (s.layerId !== first.layerId) continue
if (s.kind !== first.kind) continue
selectedIds.add(s.keyframeId)
}
const sorted = [...workingArr].sort((a, b) => a.time - b.time)
const fps = store.scene.fps || 60
const ghostSegments: PathSegment[] = []
// All segments between consecutive keyframes become ghost segments,
// except the one leading to the selected keyframe which gets interactive dots.
let activeFrames: PathFrame[] = []
let activeKeyframeId: string | null = null
const activeSegments: ActiveSegment[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
const dt = b.time - a.time
if (dt <= 0) continue
if (b.id === first.keyframeId) {
if (selectedIds.has(b.id)) {
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
const frames: PathFrame[] = []
for (let k = 0; k <= n; k++) {
@@ -1341,19 +1454,17 @@ const animationPath = computed<PathOverlay | null>(() => {
const s = sampleLayer(layer, time)
frames.push({ index: k, x: s.x, y: s.y, u })
}
activeFrames = frames
activeKeyframeId = b.id
activeSegments.push({ keyframeId: b.id, frames })
} else {
const flat = sampleSegment(layer, a.time, b.time, fps)
if (flat.length >= 4) ghostSegments.push({ flat })
}
}
if (!ghostSegments.length && activeFrames.length === 0) return null
if (!ghostSegments.length && activeSegments.length === 0) return null
return {
layerId: layer.id,
activeKeyframeId,
ghostSegments,
activeFrames,
activeSegments,
}
})
@@ -1376,38 +1487,65 @@ const animationGhostSegments = computed(() => {
}))
})
const animationPathLineConfig = computed(() => {
const animationPathLines = computed(() => {
const path = animationPath.value
if (!path || path.activeFrames.length < 2) return null
const flat: number[] = []
for (const f of path.activeFrames) flat.push(f.x, f.y)
return {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
if (!path) return []
return path.activeSegments
.filter((s) => s.frames.length >= 2)
.map((s) => {
const flat: number[] = []
for (const f of s.frames) flat.push(f.x, f.y)
return {
key: `apl-${s.keyframeId}`,
config: {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
},
}
})
})
interface PathDot {
key: string
keyframeId: string
frameIndex: number
frameTotal: number
x: number
y: number
isEnd: boolean
}
const animationPathDots = computed<PathDot[]>(() => {
const path = animationPath.value
if (!path) return []
const out: PathDot[] = []
for (const seg of path.activeSegments) {
const last = seg.frames.length - 1
for (const f of seg.frames) {
out.push({
key: `pf-${seg.keyframeId}-${f.index}`,
keyframeId: seg.keyframeId,
frameIndex: f.index,
frameTotal: last,
x: f.x,
y: f.y,
isEnd: f.index === 0 || f.index === last,
})
}
}
return out
})
const animationPathDots = computed(() => {
const path = animationPath.value
if (!path || !path.activeKeyframeId) return []
return path.activeFrames.map((f) => ({
key: `pf-${path.activeKeyframeId}-${f.index}`,
frameIndex: f.index,
x: f.x,
y: f.y,
isEnd: f.index === 0 || f.index === path.activeFrames.length - 1,
}))
})
const hoveredPathDot = ref<string | null>(null)
const hoveredPathDot = ref<number | null>(null)
function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameIndex: number }) {
const hovered = !d.isEnd && hoveredPathDot.value === d.frameIndex
function animationDotConfig(d: PathDot) {
const hoverKey = `${d.keyframeId}:${d.frameIndex}`
const hovered = !d.isEnd && hoveredPathDot.value === hoverKey
const baseRadius = d.isEnd ? 4 : 2.5
const radius = hovered ? 6 : baseRadius
return {
@@ -1424,9 +1562,9 @@ function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameInde
}
}
function onPathDotMouseEnter(frameIndex: number, isEnd: boolean) {
if (isEnd) return
hoveredPathDot.value = frameIndex
function onPathDotMouseEnter(d: PathDot) {
if (d.isEnd) return
hoveredPathDot.value = `${d.keyframeId}:${d.frameIndex}`
const stage = stageRef.value?.getStage()
const cont = stage?.container()
if (cont) cont.style.cursor = 'grab'
@@ -1439,26 +1577,24 @@ function onPathDotMouseLeave() {
if (cont) cont.style.cursor = ''
}
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, frameIndex: number) {
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, d: PathDot) {
const path = animationPath.value
if (!path || !path.activeKeyframeId) return
if (!path) return
const node = e.target
const wx = node.x()
const wy = node.y()
const total = path.activeFrames.length - 1
if (total <= 0) return
const uRaw = frameIndex / total
if (d.frameTotal <= 0) return
const uRaw = d.frameIndex / d.frameTotal
if (uRaw <= 0 || uRaw >= 1) return
const layer = store.activeLayers.find((l) => l.id === path.layerId)
if (!layer) return
const arr = selectedKfs.value[0]?.kind === 'position'
? layer.positionKeyframes
: layer.keyframes
const kf = arr?.find((k) => k.id === path.activeKeyframeId)
const sel = selectedKfs.value.find((s) => s.keyframeId === d.keyframeId)
const arr = sel?.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const kf = arr?.find((k) => k.id === d.keyframeId)
if (!kf) return
const easing = kf.easing
const t = applyEasing(uRaw, easing)
store.setKeyframeControlFromPoint(path.layerId, path.activeKeyframeId, t, wx, wy)
store.setKeyframeControlFromPoint(path.layerId, d.keyframeId, t, wx, wy)
}
function applyEasing(t: number, easing: string): number {
@@ -1744,7 +1880,7 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
@@ -1753,10 +1889,40 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
}
liveLayerPose.value = null
}
function anySelectedKfAtPlayhead(): boolean {
for (const k of selectedKfs.value) {
const l = store.activeLayers.find((x) => x.id === k.layerId)
if (!l) continue
const arr =
k.kind === 'position'
? l.positionKeyframes
: k.kind === 'rotation'
? l.rotationKeyframes
: l.keyframes
const kf = arr?.find((kf) => kf.id === k.keyframeId)
if (kf && Math.abs(kf.time - store.playhead) < 1e-3) return true
}
return false
}
function selectKfAtPlayhead(layer: Layer, kind: 'main' | 'position' | 'rotation') {
const arr =
kind === 'position'
? layer.positionKeyframes
: kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
if (!arr) return
const kf = arr.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (!kf) return
selectedKfs.value = [{ layerId: layer.id, kind, keyframeId: kf.id }]
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
@@ -2086,6 +2252,16 @@ onBeforeUnmount(() => {
<template>
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="bg-video"
:style="bgVideoStyle"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<v-stage
ref="stageRef"
:config="stageConfig"
@@ -2217,15 +2393,19 @@ onBeforeUnmount(() => {
:key="g.key"
:config="g.config"
/>
<v-line v-if="animationPathLineConfig" :config="animationPathLineConfig" />
<v-line
v-for="line in animationPathLines"
:key="line.key"
:config="line.config"
/>
<v-circle
v-for="d in animationPathDots"
:key="d.key"
:config="animationDotConfig(d)"
@mouseenter="onPathDotMouseEnter(d.frameIndex, d.isEnd)"
@mouseenter="onPathDotMouseEnter(d)"
@mouseleave="onPathDotMouseLeave"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
/>
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
@@ -2325,6 +2505,14 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.bg-video {
position: absolute;
object-fit: cover;
pointer-events: none;
user-select: none;
z-index: 0;
}
.stage-container.drawing {
cursor: crosshair;
}