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
+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