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
+260
-72
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user