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:
thandClaude Opus 4.7 committed 2026-04-24 16:20:18 +02:00
1 parent 21cf6e9a12
commit 56cecabaae
6 files changed
+543 -32

No files matched your search

+333
View File
@@ -6467,6 +6467,73 @@ export function registerAllTests(): void {
handle.unmount()
}
})
test('right-click on a group row → Rename group → prompt → renameGroup', async () => {
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('G1')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.group-track .track-name',
) as HTMLElement
expect(row).toBeTruthy()
row.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, clientX: 10, clientY: 10,
}),
)
await nextTick()
const menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const renameBtn = findMenuItem(menu, i18nT('timeline.renameGroup'))
expect(renameBtn).toBeTruthy()
renameBtn!.click()
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('Hero group')
await nextTick()
expect(group.name).toBe('Hero group')
} finally {
handle.unmount()
}
})
test('dblclick on a group row opens the rename prompt', async () => {
// Groups also support rename via double-click on the track
// name. Covers the second entry point so it stays wired up.
const { dialogState, resolveDialog } = await import(
'@/components/ui/dialogs'
)
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
const groupId = s.groupSelectedLayers('Old')!
const group = s.scene.groups!.find((g) => g.id === groupId)!
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const row = handle.host.querySelector(
'.track.group-track .track-name',
) as HTMLElement
row.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
)
expect(dialogState.value?.kind).toBe('prompt')
resolveDialog('New name')
await nextTick()
expect(group.name).toBe('New name')
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline group eye button', () => {
@@ -6496,6 +6563,133 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline playhead-magnetic snap', () => {
// Snapping on → dragged clip blocks + clip trim handles stick to
// the playhead (and to keyframes).
/** Measure pps from the clip lane's layout width. The clip lane is
* styled width: laneWidth = duration * effectivePxPerSec, with
* NO minimum floor — unlike the clip-block itself, whose width
* is max(6px, …) and over-reports pps for short clips. */
function measurePxPerSec(root: HTMLElement, sceneDuration: number): number {
const lane = root.querySelector('.clip-lane') as HTMLElement
const w = lane.getBoundingClientRect().width
return w / sceneDuration
}
/** Dispatch a clean pointerdown / pointermove / pointerup sequence
* that drags an element by `deltaPx` horizontally from its
* centre. Real mouse pointers use pointerId 1; reusing that
* keeps setPointerCapture happy across handlers. */
function dragX(el: HTMLElement, deltaPx: number) {
const r = el.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: cx, clientY: cy,
}))
el.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1,
clientX: cx + deltaPx, clientY: cy,
}))
el.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: cx + deltaPx, clientY: cy,
}))
}
test('dragging a clip block lands exactly on the playhead when close', async () => {
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10 // big enough that block width is always > 6px min
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.setPlayhead(6)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
expect(pps > 0).toBe(true)
// Land ~0.03s off the playhead = well under the 8px snap
// threshold at any realistic pps.
const targetStart = 5.97
const deltaPx = (targetStart - ci.startTime) * pps
dragX(block, deltaPx)
await nextTick()
expect(ci.startTime).toBe(6)
} finally {
handle.unmount()
}
})
test('dragging the right handle of a clip snaps its end to the playhead', async () => {
// Reproduces the user request literally: "pull the end of a
// clip — it has to snap to the needle."
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10 // right edge at startTime+10
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.setPlayhead(3)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
const rightHandle = block.querySelector(
'.clip-handle.end',
) as HTMLElement
expect(rightHandle).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
const targetEdge = 3.05
const deltaPx = (targetEdge - 10) * pps
dragX(rightHandle, deltaPx)
await nextTick()
// Right edge = 0 + 10 - trimEnd, snapped to 3 → trimEnd = 7.
expect(ci.trimEnd).toBeCloseTo(7, 5)
} finally {
handle.unmount()
}
})
test('dragging the left handle of a clip snaps its start to the playhead', async () => {
const sceneDur = 20
const s = makeFreshStore()
s.setDuration(sceneDur)
const clip = s.addClip('C')
clip.duration = 10
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 1, track.id)!
s.setPlayhead(4)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
const leftHandle = block.querySelector(
'.clip-handle.start',
) as HTMLElement
expect(leftHandle).toBeTruthy()
const pps = measurePxPerSec(handle.host, sceneDur)
const targetEdge = 3.95
const deltaPx = (targetEdge - 1) * pps
dragX(leftHandle, deltaPx)
await nextTick()
// Left edge snapped to 4 → startTime = 4, trimStart absorbs
// the 3s shift (initialTrimStart 0 + delta 3).
expect(ci.startTime).toBeCloseTo(4, 5)
expect(ci.trimStart).toBeCloseTo(3, 5)
} finally {
handle.unmount()
}
})
})
suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
@@ -8806,6 +9000,145 @@ export function registerAllTests(): void {
})
})
suite('render — grouped clip tracks respect group masks', () => {
// Regression: the render.ts group branch used to extract only
// layer children from group.childIds, dropping clip tracks
// silently. A group containing only clips rendered as empty in
// mask-preview and frame-based exports — even when a SIBLING
// group had a mask, the clip group was invisible too because
// the renderer simply walked past its contents.
function setupWithClipTrackInGroup() {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
})
// Clip with a single full-stage green rect, instantiated on a
// clip track tucked inside group A.
const clip = s.addClip('C')
clip.duration = 1
s.enterClip(clip.id)
const inner = s.addLayer(rectAsset.id)!
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
s.exitClip()
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
// Wrap the track in group A (no mask inside — just content).
const groupA = s.addTrackGroup('A', track.id)
groupA.childIds.push(track.id)
const order = s.scene.trackOrder!
const ti = order.indexOf(track.id)
if (ti >= 0) order.splice(ti, 1)
return { s }
}
test('group containing only clip tracks renders its instance content', () => {
const { s } = setupWithClipTrackInGroup()
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// The clip's green rect should cover the canvas — at least one
// interior pixel is green with full alpha.
const px = ctx.getImageData(50, 50, 1, 1).data
expect(px[1] > 200).toBe(true)
expect(px[3] > 200).toBe(true)
})
test('sibling group with a mask does not erase an unmasked group next to it', () => {
// User-reported scenario: group A (clip) + group G (element B
// + mask C). Before the fix, mask-preview showed nothing for
// A; afterwards, A's content is independent of G's mask.
const { s } = setupWithClipTrackInGroup()
// Group G holds a single red layer + a small positive mask,
// both at the centre. Without the fix A's green would stay
// off-screen because the render path dropped its clip track.
const red = s.addAsset({
kind: 'rect', name: 'B', width: 40, height: 40, fill: '#ff0000',
})
const bLayer = s.addLayer(red.id)!
s.upsertKeyframe(bLayer.id, 0, { x: 50, y: 50 })
const maskAsset = s.addAsset({
kind: 'circle', name: 'M', radius: 10, fill: '#ffffff',
})
const cLayer = s.addLayer(maskAsset.id)!
s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(cLayer.id, true, 'positive')
// Wrap B + C in a fresh group G. selectLayer + groupSelectedLayers
// groups them together, leaving group A untouched.
s.selectLayer(bLayer.id)
s.toggleLayerSelection(cLayer.id)
const gId = s.groupSelectedLayers('G')!
expect(gId).toBeTruthy()
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// A pixel far from the mask-centre (e.g. corner 10,10) is
// outside G's mask — B would be clipped there. But group A's
// green clip covers the whole canvas and is unaffected by G's
// mask, so the pixel must be green (and opaque).
const corner = ctx.getImageData(10, 10, 1, 1).data
expect(corner[1] > 200).toBe(true)
expect(corner[3] > 200).toBe(true)
})
test('mask inside a group clips clip-track content in that same group', () => {
// The group-local mask still has to apply to clip instances
// that sit alongside it — otherwise grouping a clip with a
// mask would silently render the clip unmasked.
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
})
const clip = s.addClip('C')
clip.duration = 1
s.enterClip(clip.id)
const inner = s.addLayer(rect.id)!
s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
s.exitClip()
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)
// Build a mask circle, then group (clip track + mask) together.
const maskAsset = s.addAsset({
kind: 'circle', name: 'M', radius: 15, fill: '#ffffff',
})
const m = s.addLayer(maskAsset.id)!
s.upsertKeyframe(m.id, 0, { x: 50, y: 50 })
s.setLayerMask(m.id, true, 'positive')
const g = s.addTrackGroup('G', track.id)
g.childIds.push(m.id)
g.childIds.push(track.id)
const order = s.scene.trackOrder!
// Remove the clip track + mask layer from the top-level order
// so only the group sits there.
for (const id of [track.id, m.id]) {
const i = order.indexOf(id)
if (i >= 0) order.splice(i, 1)
}
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the mask — green kept.
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[1] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside the mask — cleared.
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
})
suite('scene store — markers', () => {
test('addMarker defaults the name when none provided', () => {
const s = makeFreshStore()