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;
}
+160 -1
View File
@@ -2,17 +2,19 @@
import { computed, ref, watch } from 'vue'
import {
PhClock,
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -43,6 +45,126 @@ const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
const activeTab = ref<TabId>('document')
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
async function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
@@ -146,6 +268,42 @@ function applyTheme(t: Theme) {
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<div class="clipart-row">
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
<div class="clipart-value">
<PhFilmReel :size="13" />
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
:title="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
</button>
</div>
</div>
<div class="clipart-actions">
<button class="primary-btn" @click="pickBgVideoFile">
{{ t('settings.bgVideoPickFile') }}
</button>
<button class="ghost-btn" @click="pickBgVideoUrl">
{{ t('settings.bgVideoPickUrl') }}
</button>
</div>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
</section>
<section v-else-if="activeTab === 'timeline'">
@@ -165,6 +323,7 @@ function applyTheme(t: Theme) {
</p>
</section>
<section v-else-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
+96 -14
View File
@@ -14,6 +14,7 @@ const canvasEl = ref<HTMLCanvasElement | null>(null)
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref<Record<string, HTMLImageElement>>({})
const localTime = ref(0)
@@ -28,7 +29,8 @@ const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
)
const sceneEnd = computed(() => store.effectiveEnd)
const rangeIn = computed(() => store.playbackIn)
const rangeOut = computed(() => store.playbackOut)
let raf = 0
let lastFrame = 0
@@ -70,12 +72,27 @@ function draw() {
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
const end = sceneEnd.value
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
if (end > 0) {
let next = localTime.value + dt
if (next >= end) next = 0
localTime.value = next
const start = rangeIn.value
const end = rangeOut.value
if (end > start) {
const v = bgVideoEl.value
if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
// Slave localTime to the video element so drift can't build up and
// cause compensating seeks — those seeks are what makes the video
// stutter visibly.
let vt = v.currentTime
if (vt >= end || vt < start) {
vt = start
v.currentTime = vt
}
localTime.value = vt
} else {
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
let next = localTime.value + dt
if (next >= end) next = start
else if (next < start) next = start
localTime.value = next
}
}
}
lastFrame = now
@@ -95,12 +112,27 @@ function stopLoop() {
}
async function startPreview() {
localTime.value = store.playhead
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
localTime.value = head < start || head >= end ? start : head
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
// The playing watcher may have fired before the <video> ref was bound;
// make sure the background video is actually started now that the DOM
// is mounted.
const v = bgVideoEl.value
if (v) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore autoplay errors */
}
}
await requestBrowserFullscreen()
}
@@ -152,9 +184,11 @@ function onRootMouseLeave() {
}
const progressPct = computed(() => {
const end = sceneEnd.value
if (!end) return 0
return Math.max(0, Math.min(1, localTime.value / end)) * 100
const start = rangeIn.value
const end = rangeOut.value
const len = end - start
if (len <= 0) return 0
return Math.max(0, Math.min(1, (localTime.value - start) / len)) * 100
})
function seekFromEvent(e: PointerEvent) {
@@ -163,8 +197,9 @@ function seekFromEvent(e: PointerEvent) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const end = sceneEnd.value
localTime.value = ratio * end
const start = rangeIn.value
const end = rangeOut.value
localTime.value = start + ratio * (end - start)
draw()
}
@@ -227,6 +262,31 @@ watch(
},
)
watch(playing, async (p) => {
const v = bgVideoEl.value
if (!v) return
if (p) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore */
}
} else {
v.pause()
v.currentTime = localTime.value
}
})
// Keep the video frame aligned with localTime when we are NOT letting the
// video drive time (paused or actively scrubbing). During normal playback
// the RAF loop reads v.currentTime, so we must not seek here.
watch(localTime, (t) => {
const v = bgVideoEl.value
if (!v) return
if (playing.value && !scrubbing.value) return
v.currentTime = t
})
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
@@ -256,7 +316,7 @@ watch(frameEl, (el, _old, onCleanup) => {
})
const timeLabel = computed(() => {
const end = sceneEnd.value || 0
const end = rangeOut.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
})
</script>
@@ -274,6 +334,15 @@ const timeLabel = computed(() => {
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="fs-bg-video"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<canvas ref="canvasEl" />
</div>
@@ -324,6 +393,7 @@ const timeLabel = computed(() => {
}
.fs-frame {
position: relative;
width: min(100vw, 100vh * (16 / 9));
max-width: 100%;
max-height: 100%;
@@ -332,10 +402,22 @@ const timeLabel = computed(() => {
justify-content: center;
}
.fs-bg-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
z-index: 0;
}
.fs-frame canvas {
display: block;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
.fs-controls {
+10 -10
View File
@@ -593,21 +593,21 @@ function removeKeyframeHere() {
<button
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'positive') === 'positive' }"
:title="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'negative', danger: selected.maskMode === 'negative' }"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
:title="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
:title="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
</div>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
+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;
}