0.18.3: grouped clip tracks render, playhead is a snap magnet
- Fix mask-preview / frame-export dropping any group whose children are clip tracks. render.ts's old group branch only pulled Layer children out of group.childIds; new drawTrackGroup walks both layer AND clip-track ids and still applies group-local masks to the composite. Same fix mirrored into the HTML export runtime in exportPlayer.ts. - Snapping magnet for the playhead: snapTime now includes store.playhead in its candidate list by default. Clip drags, trim handles, keyframe drags, marker drags and in/out brackets stick to the needle when snap is on. Scrub handlers pass ignorePlayhead:true so the needle doesn't stick to its own prior position during a scrub. - Clip trim handles derive the snapped edge first and back out to trimStart/trimEnd, so the edge lands exactly on the magnet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
21cf6e9a12
commit
56cecabaae
6 files changed
+543
-32
No files matched your search
+44
-18
@@ -331,8 +331,19 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
|
||||
return out
|
||||
}
|
||||
|
||||
function snapTime(t: number, ignoreKeyframeId?: string): number {
|
||||
return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
|
||||
interface SnapTimeOpts {
|
||||
ignoreKeyframeId?: string
|
||||
/** True when snapping the playhead itself — omits the playhead
|
||||
* from the candidate list so the scrub doesn't stick to its own
|
||||
* previous position. Clip drags, clip trims, keyframe drags and
|
||||
* marker drags all pick up the playhead as a magnet by default. */
|
||||
ignorePlayhead?: boolean
|
||||
}
|
||||
|
||||
function snapTime(t: number, opts: SnapTimeOpts = {}): number {
|
||||
const candidates = allKeyframeTimes(opts.ignoreKeyframeId)
|
||||
if (!opts.ignorePlayhead) candidates.push(store.playhead)
|
||||
return snapToCandidates(t, candidates, {
|
||||
enabled: snapEnabled.value,
|
||||
pxPerSec: effectivePxPerSec.value,
|
||||
pixelThreshold: SNAP_PX,
|
||||
@@ -425,7 +436,7 @@ function seekFromEvent(e: PointerEvent, el: HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
store.setPlayhead(snapTime(ratio * duration.value))
|
||||
store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
|
||||
}
|
||||
|
||||
function onScrubPointerDown(e: PointerEvent) {
|
||||
@@ -585,7 +596,7 @@ function onLaneClick(e: MouseEvent) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
store.setPlayhead(snapTime(ratio * duration.value))
|
||||
store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
|
||||
}
|
||||
|
||||
const canCreateKf = computed(() => {
|
||||
@@ -602,7 +613,10 @@ function addKeyframeForSelected() {
|
||||
if (!id) return
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer || isLayerIdLocked(id)) return
|
||||
const time = snapTime(store.playhead)
|
||||
// Snap the playhead itself to the nearest keyframe before adding —
|
||||
// the current playhead position is NOT a candidate (it's the value
|
||||
// we're rounding).
|
||||
const time = snapTime(store.playhead, { ignorePlayhead: true })
|
||||
const sampled = sampleLayer(layer, time)
|
||||
const props: Partial<AnimatableProps> = {
|
||||
scaleX: sampled.scaleX,
|
||||
@@ -1087,9 +1101,11 @@ function onKeyframePointerMove(e: PointerEvent) {
|
||||
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
|
||||
const ignoreIds = new Set<string>()
|
||||
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
|
||||
// Include the playhead as a snap magnet so dragged keyframes
|
||||
// stick to it the same way they stick to sibling keyframes.
|
||||
const snappedPrimary = snapToCandidates(
|
||||
rawNewTime,
|
||||
allKeyframeTimesExcluding(ignoreIds),
|
||||
[...allKeyframeTimesExcluding(ignoreIds), store.playhead],
|
||||
{
|
||||
enabled: snapEnabled.value,
|
||||
pxPerSec: effectivePxPerSec.value,
|
||||
@@ -1854,7 +1870,11 @@ function onClipBlockPointerMove(e: PointerEvent) {
|
||||
const pps = effectivePxPerSec.value
|
||||
if (pps <= 0) return
|
||||
const deltaSec = deltaPx / pps
|
||||
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
||||
// Snap the clip's left edge to the playhead (and keyframes) when
|
||||
// snapping is on. The cascade in setClipInstancePosition still
|
||||
// runs afterwards, so colliding with a neighbour on the track
|
||||
// behaves the same as it did before.
|
||||
const newStart = Math.max(0, snapTime(m.initialStartTime + deltaSec))
|
||||
store.setClipInstancePosition(m.instanceId, newStart)
|
||||
}
|
||||
|
||||
@@ -2500,28 +2520,34 @@ function onClipHandlePointerMove(e: PointerEvent) {
|
||||
const pps = effectivePxPerSec.value
|
||||
if (pps <= 0) return
|
||||
const rawDelta = (e.clientX - t.startX) / pps
|
||||
const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
|
||||
const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
|
||||
if (!clip) return
|
||||
if (t.side === 'start') {
|
||||
// Clamp the delta locally so pulling the handle past the natural
|
||||
// start doesn't shift startTime — previously trimStart clamped to
|
||||
// 0 in the store but startTime still moved, which slid the whole
|
||||
// clip. Bounds: trimStart stays in [0, maxBudget - trimEnd] and
|
||||
// we also can't shift startTime below 0.
|
||||
const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
|
||||
const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
|
||||
if (!clip) return
|
||||
// Compute the target left-edge time, snap it to the playhead /
|
||||
// keyframes, then derive the delta. Clamping below makes sure
|
||||
// we don't shift startTime past 0 or eat past the clip's
|
||||
// available budget — same bounds as before.
|
||||
const targetEdge = t.initialStartTime + rawDelta
|
||||
const snappedEdge = snapTime(targetEdge)
|
||||
const snappedDelta = snappedEdge - t.initialStartTime
|
||||
const MIN_VISIBLE = 0.05
|
||||
const maxBudget = clip.duration - MIN_VISIBLE
|
||||
const minDelta = Math.max(-t.initialTrimStart, -t.initialStartTime)
|
||||
const maxDelta = maxBudget - t.initialTrimStart - t.initialTrimEnd
|
||||
const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
|
||||
const delta = Math.min(Math.max(snappedDelta, minDelta), maxDelta)
|
||||
store.trimClipInstance(t.instanceId, {
|
||||
trimStart: t.initialTrimStart + delta,
|
||||
startTime: t.initialStartTime + delta,
|
||||
})
|
||||
} else {
|
||||
// shift right edge right = less trimEnd (reveal more)
|
||||
// shift right edge right = less trimEnd (reveal more). Snap the
|
||||
// edge position, then derive trimEnd.
|
||||
const initialRightEdge = t.initialStartTime + clip.duration - t.initialTrimEnd
|
||||
const snappedEdge = snapTime(initialRightEdge + rawDelta)
|
||||
const newTrimEnd = clip.duration - (snappedEdge - t.initialStartTime)
|
||||
store.trimClipInstance(t.instanceId, {
|
||||
trimEnd: t.initialTrimEnd - rawDelta,
|
||||
trimEnd: newTrimEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user