0.18.0: clip instances get stable transform frame + track hide

Each clip instance now renders an invisible frame rect sized to the
union of its layers' footprints across every keyframe. The
transformer wraps that frame so its handles stay on the clip's own
outer constraints instead of chasing the currently-visible inner
shape, and the rect doubles as a hit region so empty-playhead
frames remain selectable. A plain click on a timeline clip-block
also selects the instance (drag still takes over past the 2px
dead-zone).

Clip tracks pick up the same editor-hide affordance as layer
tracks: new eye button on the track-name, setClipTrackEditorHidden
action, and filters in the Konva stackItems + render.ts so
hidden-track instances vanish in the editor. ExportDialog's
skip-hidden opt-in now also strips hidden clip tracks.

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 14:11:43 +02:00
1 parent a858f94bb7
commit 6784b76607
10 files changed
+764 -7

No files matched your search

+509
View File
@@ -33,6 +33,7 @@ import type { PolygonAsset } from '@/types/scene'
import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/render'
import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import {
deleteStoredProject,
getStoredProject,
@@ -4301,6 +4302,72 @@ export function registerAllTests(): void {
})
})
suite('clip bounds — stable frame rect', () => {
// The stage wraps each ClipInstance in an invisible frame rect
// sized to the union of every clip layer's footprint across all
// keyframes. Keeps the canvas transformer's handles on the
// clip's own outer constraints instead of chasing a moving child
// shape, and gives empty-playhead frames a clickable hit area.
test('union bbox covers every layer at its extreme keyframes', () => {
const s = makeFreshStore()
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const clip = s.addClip('CB')
s.enterClip(clip.id)
clip.duration = 5
const l = s.addLayer(rectAsset.id)!
// Move the layer from (0,0) to (200,120) between t=0 and t=clip.duration.
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
s.upsertKeyframe(l.id, 4, { x: 200, y: 120 })
s.exitClip()
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
const bounds = computeClipBounds(clip, assets)
expect(bounds).toBeTruthy()
// The layer is 40×40 centered on its origin (default offset=20,20).
// At t=0 it sits around (0,0); at t=4 it sits around (200,120).
// Union must at least cover both extremes' centers.
expect(bounds!.x).toBeLessThan(-5)
expect(bounds!.y).toBeLessThan(-5)
expect(bounds!.x + bounds!.width).toBeGreaterThan(200 + 5)
expect(bounds!.y + bounds!.height).toBeGreaterThan(120 + 5)
})
test('clip without measurable layers returns null (caller uses fallback)', () => {
const s = makeFreshStore()
const clip = s.addClip('Empty')
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
expect(computeClipBounds(clip, assets)).toBeNull()
// Fallback is a small centred square so the transformer still
// has something to grab.
expect(CLIP_FALLBACK_BOUNDS.width).toBeGreaterThan(0)
expect(CLIP_FALLBACK_BOUNDS.height).toBeGreaterThan(0)
})
test('bounds scale with per-keyframe scaleX/scaleY', () => {
const s = makeFreshStore()
const rectAsset = s.addAsset({
kind: 'rect', name: 'R', width: 40, height: 40, fill: '#f00',
})
const clip = s.addClip('CB')
s.enterClip(clip.id)
clip.duration = 3
const l = s.addLayer(rectAsset.id)!
// Grow the layer 4× at t=2.
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, scaleX: 1, scaleY: 1 })
s.upsertKeyframe(l.id, 2, { x: 0, y: 0, scaleX: 4, scaleY: 4 })
s.exitClip()
const assets = new Map(s.scene.assets.map((a) => [a.id, a]))
const bounds = computeClipBounds(clip, assets)
expect(bounds).toBeTruthy()
// 4× a 40-wide rect = 160 across → bbox is at least that wide
// (pre-rotation padding pushes it a bit bigger, which is fine).
expect(bounds!.width).toBeGreaterThan(159)
expect(bounds!.height).toBeGreaterThan(159)
})
})
suite('svg split', () => {
// A minimal SVG with two non-overlapping paths. Absolute
// coordinates so getBBox returns predictable values under jsdom/
@@ -5965,6 +6032,155 @@ export function registerAllTests(): void {
expect(s.activeScene.clipInstances.find((x) => x.id === ci.id)).toBe(undefined)
handle.unmount()
})
test('pure click on a clip-block selects the instance', async () => {
// Regression: a clip whose current playhead content is empty can
// only be grabbed via its timeline block. Pointerdown starts a
// drag-tracker; a pointerup without motion is a pure click and
// must land on selectClipInstance so the canvas transformer
// attaches to its wrapper group.
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
expect(block).toBeTruthy()
expect(s.selectedClipInstanceId).toBe(null)
block.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
clientX: 20,
clientY: 10,
}),
)
block.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
clientX: 20,
clientY: 10,
}),
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
handle.unmount()
})
test('dragging a clip-block does not trigger selection', async () => {
// If the user actually moves the clip, the pointerup is a drag
// release — not a click — and must not also fire the click-to-
// select path. Otherwise every successful move would overwrite
// whatever the user had selected on the canvas.
const s = makeFreshStore()
s.setDuration(10)
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
s.addClipInstance(clip.id, 0, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.clip-block') as HTMLElement
block.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 2, button: 0,
clientX: 20, clientY: 10,
}),
)
// Move past the 2px dead-zone to flip `moved` true.
block.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 2,
clientX: 60, clientY: 10,
}),
)
block.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 2, button: 0,
clientX: 60, clientY: 10,
}),
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(null)
handle.unmount()
})
})
suite('scene store — clip track editor hide', () => {
test('setClipTrackEditorHidden toggles the flag on + off', () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
expect(track.editorHidden).toBe(undefined)
s.setClipTrackEditorHidden(track.id, true)
expect(track.editorHidden).toBe(true)
s.setClipTrackEditorHidden(track.id, false)
// Pruned back out so the serialized JSON stays minimal.
expect('editorHidden' in track).toBe(false)
})
test('hiding a track clears selectedClipInstanceId for instances on it', () => {
// The transformer watcher reacts to selectedClipInstanceId; if we
// don't clear it, the handles stay wrapped around a ci-group
// that's no longer in the Konva stack.
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
s.selectClipInstance(ci.id)
expect(s.selectedClipInstanceId).toBe(ci.id)
s.setClipTrackEditorHidden(track.id, true)
expect(s.selectedClipInstanceId).toBe(null)
})
test('hiding a track leaves selection on a different track alone', () => {
const s = makeFreshStore()
const clip = s.addClip('CInst')
clip.duration = 2
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack('T2')
const ci1 = s.addClipInstance(clip.id, 0, t1.id)!
const ci2 = s.addClipInstance(clip.id, 5, t2.id)!
s.selectClipInstance(ci2.id)
s.setClipTrackEditorHidden(t1.id, true)
// Only ci1 lives on the hidden track; ci2 is on t2 and must
// keep its selection.
expect(s.selectedClipInstanceId).toBe(ci2.id)
void ci1
})
})
suite('ui — Timeline clip-track eye button', () => {
test('clicking the eye toggles editorHidden on the clip track', async () => {
const s = makeFreshStore()
s.addClip('CInst')
const track = s.scene.clipTracks![0]
const handle = mountComponent(Timeline, {})
await nextTick()
const clipTrack = handle.host.querySelector('.track.clip-track') as HTMLElement
expect(clipTrack).toBeTruthy()
const eye = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye).toBeTruthy()
expect(eye.classList.contains('hidden')).toBe(false)
eye.click()
await nextTick()
expect(track.editorHidden).toBe(true)
const eye2 = clipTrack.querySelector('.track-name .eye-btn') as HTMLButtonElement
expect(eye2.classList.contains('hidden')).toBe(true)
eye2.click()
await nextTick()
expect('editorHidden' in track).toBe(false)
handle.unmount()
})
})
suite('ui — Timeline audio-clip context menu', () => {
@@ -6889,6 +7105,299 @@ export function registerAllTests(): void {
})
})
suite('ui — CanvasStage clip-instance transform', () => {
// mountComponent skips VueKonva, which is fine for DOM-overlay
// tests but not for clip-instance selection: we need real Konva
// nodes so `attachTransformer` can find #ci-group:<id> and so the
// Konva event bus bubbles mousedown from a child shape up to the
// group's @mousedown handler.
async function mountCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '800px'
host.style.height = '600px'
document.body.appendChild(host)
const app = createApp({
render() {
return h(CanvasStage, {})
},
})
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
async function getKonvaStage() {
const Konva = (await import('konva')).default
// Last stage = the one just mounted by this test. The app's
// background CanvasStage also registers a stage, so we can't
// just take stages[0].
return Konva.stages[Konva.stages.length - 1]
}
/** Seed a scene with a single clip holding one rect layer, plus
* one instance of that clip on a new clip track. Returns the
* instance + the inner layer id so tests can target them by id. */
function seedClipInstance() {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAsset({
kind: 'rect',
name: 'R',
width: 80,
height: 80,
fill: '#f33',
})
const clip = s.addClip('CInst')
clip.duration = 2
s.enterClip(clip.id)
const innerLayer = s.addLayer(asset.id)!
s.exitClip()
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
return { s, clip, ci, innerLayerId: innerLayer.id }
}
test('selectClipInstance attaches the transformer to #ci-group:<id>', async () => {
// Simplest path: programmatic selection must wire the main
// transformer to the clip-instance wrapper group. If this fails
// the transform handles never appear, regardless of how the
// selection got set (mousedown handler, keyboard, etc.).
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
s.selectClipInstance(ci.id)
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const groupId = `ci-group:${ci.id}`
const group = stage.findOne(`#${groupId}`)
expect(group).toBeTruthy()
// The main transformer (not the hit-shape one) must now wrap
// the clip-instance group.
const transformers = stage.find('Transformer')
const attachedToGroup = transformers.some((tr) => {
const nodes = (tr as unknown as { nodes: () => unknown[] }).nodes()
return nodes.length === 1 && (nodes[0] as { id: () => string }).id() === groupId
})
expect(attachedToGroup).toBe(true)
} finally {
handle.unmount()
}
})
test('mousedown on a clip-instance child shape selects the instance', async () => {
// Regression guard: after multi-clip-per-track landed nobody was
// checking that the bubbled-up @mousedown on the wrapper group
// still selects the instance. The inner shape must be listening
// so Konva bubbles the event through to the group's handler.
const { s, ci, innerLayerId } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const inner = stage.findOne(`#ci:${ci.id}:${innerLayerId}`)
expect(inner).toBeTruthy()
;(inner as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: inner, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
} finally {
handle.unmount()
}
})
test('transformend on the ci-group writes back via setClipInstanceTransform', async () => {
// End-to-end: the user dragged a handle, Konva applied the new
// scale/rotation to the group node, and on pointerup the
// transformend handler must persist those values onto the
// instance so the stored scene reflects the transform.
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
s.selectClipInstance(ci.id)
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
scaleX: (v?: number) => number
scaleY: (v?: number) => number
rotation: (v?: number) => number
x: (v?: number) => number
y: (v?: number) => number
fire: (type: string, evt: unknown, bubble: boolean) => void
}
expect(group).toBeTruthy()
group.scaleX(2)
group.scaleY(3)
group.rotation(45)
group.x(17)
group.y(-9)
group.fire(
'transformend',
{ target: group, type: 'transformend' },
true,
)
await nextTick()
expect(ci.scaleX).toBe(2)
expect(ci.scaleY).toBe(3)
expect(ci.rotation).toBe(45)
expect(ci.x).toBe(17)
expect(ci.y).toBe(-9)
} finally {
handle.unmount()
}
})
test('ci-group is framed by an invisible ci-frame rect at the clip bounds', async () => {
// The frame rect locks the transformer onto the clip's own
// outer constraints instead of the currently-visible inner
// shape. Its bbox dominates the group's client rect so handles
// stay put even when inner layers animate.
const { s, ci } = seedClipInstance()
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const frame = stage.findOne(`#ci-frame:${ci.id}`) as unknown as {
width: () => number
height: () => number
name: () => string
listening: () => boolean
} | null
expect(frame).toBeTruthy()
expect(frame!.name()).toBe('ci-frame')
expect(frame!.listening()).toBe(true)
expect(frame!.width()).toBeGreaterThan(0)
expect(frame!.height()).toBeGreaterThan(0)
// The seed clip has a single 80×80 rect, so the frame must be
// at least large enough to enclose it.
expect(frame!.width()).toBeGreaterThan(79)
expect(frame!.height()).toBeGreaterThan(79)
// Sanity: the stage's selection store hasn't flipped just by
// rendering — only an actual click should select.
expect(s.selectedClipInstanceId).toBe(null)
} finally {
handle.unmount()
}
})
test('clicking the ci-frame on an empty-content frame selects the instance', async () => {
// Regression: if the clip has no visible content at the current
// playhead, the old bundle was dropped entirely — no ci-group,
// nothing to click. With the frame rect the bundle always
// renders and empty-frame clips remain selectable.
const s = makeFreshStore()
s.setDuration(10)
const rect = s.addAsset({
kind: 'rect', name: 'R', width: 60, height: 60, fill: '#f00',
})
const clip = s.addClip('Hidden')
clip.duration = 2
s.enterClip(clip.id)
const inner = s.addLayer(rect.id)!
s.exitClip()
// Force the layer to be editorHidden or invisible at playhead 0
// so `items` comes back empty. Flipping `visible` is the
// authoritative hide — stackItems skips `!cl.visible`.
inner.visible = false
const track = s.scene.clipTracks![0]
const ci = s.addClipInstance(clip.id, 0, track.id)!
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const frame = stage.findOne(`#ci-frame:${ci.id}`)
expect(frame).toBeTruthy()
;(frame as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: frame, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci.id)
} finally {
handle.unmount()
}
})
test('editorHidden clip track drops its instances from the Konva stack', async () => {
// Matches the layer editor-hide semantics: the track stays in
// the scene file (so exports still see it) but no ci-group or
// ci-frame is rendered in the editor.
const { s, ci } = seedClipInstance()
const track = s.scene.clipTracks![0]
s.setClipTrackEditorHidden(track.id, true)
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
expect(stage.findOne(`#ci-group:${ci.id}`)).toBeFalsy()
expect(stage.findOne(`#ci-frame:${ci.id}`)).toBeFalsy()
} finally {
handle.unmount()
}
})
test('second clip instance on the same track is independently selectable', async () => {
// The user's own hunch points at multi-clip-per-track: when a
// track carries two instances, picking the second one must
// target that instance, not the first. Guards against a bundle
// / key collision that would route both clicks to the same
// group.
const { s, clip } = seedClipInstance()
const track = s.scene.clipTracks![0]
// Put the second instance at the same startTime so both are in
// range at the default playhead of 0 and both ci-groups are in
// the Konva stack.
const ci2 = s.addClipInstance(clip.id, 0, track.id)!
const handle = await mountCanvasWithKonva()
try {
await nextTick()
await tick(40)
const stage = await getKonvaStage()
const group2 = stage.findOne(`#ci-group:${ci2.id}`)
expect(group2).toBeTruthy()
;(group2 as unknown as {
fire: (type: string, evt: unknown, bubble: boolean) => void
}).fire(
'mousedown',
{ target: group2, type: 'mousedown', evt: { button: 0 } },
true,
)
await nextTick()
expect(s.selectedClipInstanceId).toBe(ci2.id)
} finally {
handle.unmount()
}
})
})
suite('ui — AssetSidebar audio section', () => {
test('lists audio assets with a duration label under the Audio heading', async () => {
const s = makeFreshStore()