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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user