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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "0.17.0",
"version": "0.18.0",
"type": "module",
"scripts": {
"dev": "vite",
+54 -3
View File
@@ -39,6 +39,7 @@ import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
const store = useSceneStore()
const clipart = useClipartStore()
@@ -737,6 +738,10 @@ interface RenderedClipLayer extends RenderedLayer {
interface RenderedClipInstance {
instance: ClipInstance
/** Union bbox of every clip layer across its full animation — the
* stable frame that the transformer wraps. Null when the clip has
* no measurable layers (template falls back to a placeholder). */
bounds: Rect | null
items: RenderedClipLayer[]
}
@@ -807,6 +812,8 @@ const stackItems = computed<StackItem[]>(() => {
for (const l of store.scene.layers) layerById.set(l.id, l)
const clipById = new Map<string, NonNullable<typeof store.scene.clips>[number]>()
for (const c of store.scene.clips ?? []) clipById.set(c.id, c)
const clipTrackById = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
for (const t of store.scene.clipTracks ?? []) clipTrackById.set(t.id, t)
const instancesByTrack = new Map<string, NonNullable<typeof store.scene.clipInstances>>()
for (const ci of store.scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
@@ -838,6 +845,11 @@ const stackItems = computed<StackItem[]>(() => {
})
continue
}
// Editor-only hide for an entire clip track — skip every instance
// on it in the Konva stack so the stage matches the eye-icon
// state. Exports still see the instances unless the user opts
// into strip-hidden.
if (clipTrackById.get(id)?.editorHidden) continue
const instances = instancesByTrack.get(id) ?? []
for (const inst of instances) {
const clip = clipById.get(inst.clipId)
@@ -865,9 +877,18 @@ const stackItems = computed<StackItem[]>(() => {
props: sampleLayer(cl, localTime),
})
}
if (items.length) {
out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
}
// Always push the bundle when the instance is in time-range,
// even if no items are renderable at this playhead: the frame
// rect still needs to render so the clip can be selected (and
// the transformer framed) on empty frames.
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds: computeClipBounds(clip, assetMap.value),
items,
},
})
}
}
return out
@@ -1261,6 +1282,30 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
}
}
/** Invisible frame rect sized to the clip's union bbox. Sits as the
* first child of the ci-group so:
* - the group's client rect is dominated by this stable rectangle,
* keeping transformer handles at the clip's outer constraints
* instead of jittering with animated inner content;
* - clicks anywhere in the clip's spatial footprint (including
* empty playhead frames) still hit this rect, bubble to the group
* and select the instance. `fill: 'black' + opacity: 0` renders
* nothing but still paints the hit graph, so listening works. */
function clipFrameConfig(instanceId: string, bounds: Rect | null) {
const b = bounds ?? CLIP_FALLBACK_BOUNDS
return {
id: `ci-frame:${instanceId}`,
name: 'ci-frame',
x: b.x,
y: b.y,
width: Math.max(1, b.width),
height: Math.max(1, b.height),
fill: 'black',
opacity: 0,
listening: true,
}
}
function onClipInstanceDragEnd(
e: Konva.KonvaEventObject<DragEvent>,
instanceId: string,
@@ -3342,6 +3387,12 @@ function onResizeHandlePointerUp(e: PointerEvent) {
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
>
<v-rect
:config="clipFrameConfig(
entry.bundle.instance.id,
entry.bundle.bounds,
)"
/>
<template
v-for="sub in entry.bundle.items"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
+23
View File
@@ -42,6 +42,7 @@ const skipHidden = ref(false)
const hasHiddenLayers = computed(() => {
if (store.scene.layers.some((l) => l.editorHidden)) return true
if (store.scene.clipTracks?.some((t) => t.editorHidden)) return true
for (const clip of store.scene.clips ?? []) {
if (clip.layers.some((l) => l.editorHidden)) return true
}
@@ -51,6 +52,26 @@ const hasHiddenLayers = computed(() => {
return false
})
/** Strip every instance that belongs to an editor-hidden clip track,
* then drop the track itself. Shared between project + single-scene
* clones so both payload shapes stay in sync. */
function stripHiddenClipTracks(page: {
clipTracks?: { id: string; editorHidden?: boolean }[]
clipInstances?: { clipTrackId: string }[]
}) {
if (!page.clipTracks?.length) return
const hiddenIds = new Set(
page.clipTracks.filter((t) => t.editorHidden).map((t) => t.id),
)
if (!hiddenIds.size) return
if (page.clipInstances) {
page.clipInstances = page.clipInstances.filter(
(ci) => !hiddenIds.has(ci.clipTrackId),
)
}
page.clipTracks = page.clipTracks.filter((t) => !hiddenIds.has(t.id))
}
function projectClone() {
// Project-shaped payload for standalone static exports — the
// embedded runtime handles multi-scene playback natively.
@@ -58,6 +79,7 @@ function projectClone() {
if (!skipHidden.value) return project
for (const page of project.scenes) {
page.layers = page.layers.filter((l: Layer) => !l.editorHidden)
stripHiddenClipTracks(page)
}
if (Array.isArray(project.clips)) {
for (const clip of project.clips) {
@@ -82,6 +104,7 @@ function activeSceneClone() {
const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
stripHiddenClipTracks(scene)
if (Array.isArray(scene.clips)) {
for (const clip of scene.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
+28 -2
View File
@@ -1862,9 +1862,17 @@ function onClipBlockPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
const wasMoved = m.moved
const instanceId = m.instanceId
clipMove.value = null
// suppress click/dblclick if we actually dragged
if (wasMoved) e.preventDefault()
if (wasMoved) {
// suppress click/dblclick if we actually dragged
e.preventDefault()
return
}
// Pure click: select the instance so the canvas transformer attaches
// to its wrapper group. Without this, users have no way to grab a
// clip whose current-playhead content happens to be empty.
store.selectClipInstance(instanceId)
}
const clipDragOverDropzone = ref(false)
@@ -3419,6 +3427,24 @@ onBeforeUnmount(() => {
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhFilmStrip :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
class="eye-btn"
:class="{ hidden: !!clipTrackById.get(track.clipTrackId)?.editorHidden }"
v-tooltip="clipTrackById.get(track.clipTrackId)?.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="
store.setClipTrackEditorHidden(
track.clipTrackId,
!clipTrackById.get(track.clipTrackId)?.editorHidden,
)
"
@mousedown.stop
@dragstart.prevent.stop
>
<component
:is="clipTrackById.get(track.clipTrackId)?.editorHidden ? PhEyeSlash : PhEye"
:size="12"
/>
</button>
<button
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
class="lock-btn"
+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()
+118
View File
@@ -0,0 +1,118 @@
/**
* Clip-local bounding box. Used by the stage to frame a ClipInstance
* with a stable rectangle instead of letting the transformer chase
* whatever child happens to be visible at the current playhead. The
* rect covers the union of every layer's footprint across its
* animation so handles stay put while inner content moves, and clicks
* in the clip's footprint hit the instance even on empty frames.
*/
import type { Asset, Clip } from '@/types/scene'
import { sampleLayer } from './interpolate'
export interface Rect {
x: number
y: number
width: number
height: number
}
/** Rendered footprint of an asset in its own local coords, measured
* from the asset origin (0,0). Text is hard to size statically — it
* depends on font metrics — so we fall back to the asset's declared
* width/height when present and a 40×40 placeholder otherwise. */
function assetFootprint(asset: Asset): { w: number; h: number } {
if (asset.kind === 'rect' || asset.kind === 'image' || asset.kind === 'button') {
return { w: asset.width, h: asset.height }
}
if (asset.kind === 'circle') {
const d = Math.max(1, asset.radius * 2)
return { w: d, h: d }
}
if (asset.kind === 'text') {
const w = 'width' in asset && typeof asset.width === 'number' ? asset.width : asset.fontSize * 4
const h = asset.fontSize * 1.4
return { w, h }
}
if (asset.kind === 'polygon') {
if (!asset.points?.length) return { w: 40, h: 40 }
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const p of asset.points) {
if (p.x < minX) minX = p.x
if (p.y < minY) minY = p.y
if (p.x > maxX) maxX = p.x
if (p.y > maxY) maxY = p.y
}
return { w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) }
}
return { w: 40, h: 40 }
}
/** Axis-aligned union of every layer's visual footprint across every
* keyframe time it touches. Rotation is handled by inflating each
* sampled AABB to its enclosing circle — overshoots by up to
* sqrt(2) but keeps the math trivial and the bbox never clips the
* rotated content. Returns null when the clip has no layers we can
* size; callers fall back to a small placeholder rect so the
* transformer still has something to grab. */
export function computeClipBounds(
clip: Clip,
assetsById: Map<string, Asset>,
): Rect | null {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let any = false
for (const layer of clip.layers) {
if (!layer.visible) continue
const asset = assetsById.get(layer.assetId)
if (!asset) continue
const { w, h } = assetFootprint(asset)
const offX = layer.offsetX ?? w / 2
const offY = layer.offsetY ?? h / 2
const times = new Set<number>()
times.add(0)
for (const kf of layer.keyframes) times.add(kf.time)
for (const kf of layer.positionKeyframes ?? []) times.add(kf.time)
for (const kf of layer.rotationKeyframes ?? []) times.add(kf.time)
for (const t of times) {
const props = sampleLayer(layer, t)
const sx = Math.abs(props.scaleX ?? 1)
const sy = Math.abs(props.scaleY ?? 1)
const lx = -offX * sx
const rx = (w - offX) * sx
const ty = -offY * sy
const by = (h - offY) * sy
const x0 = Math.min(lx, rx)
const x1 = Math.max(lx, rx)
const y0 = Math.min(ty, by)
const y1 = Math.max(ty, by)
const bw = x1 - x0
const bh = y1 - y0
const r = Math.hypot(bw, bh) / 2
const cx = (props.x ?? 0) + (x0 + x1) / 2
const cy = (props.y ?? 0) + (y0 + y1) / 2
if (cx - r < minX) minX = cx - r
if (cy - r < minY) minY = cy - r
if (cx + r > maxX) maxX = cx + r
if (cy + r > maxY) maxY = cy + r
any = true
}
}
if (!any) return null
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
/** Placeholder bounds used when the clip has no layers we could
* measure. Small, centered on origin so the transformer has a visible
* handle cluster to grab. */
export const CLIP_FALLBACK_BOUNDS: Rect = {
x: -20,
y: -20,
width: 40,
height: 40,
}
+7
View File
@@ -399,6 +399,8 @@ export function drawSceneFrame(
arr.push(ci)
instancesByTrack.set(ci.clipTrackId, arr)
}
const clipTrackById = new Map<string, NonNullable<typeof scene.clipTracks>[number]>()
for (const t of scene.clipTracks ?? []) clipTrackById.set(t.id, t)
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
for (const c of scene.clips ?? []) clipById.set(c.id, c)
const groupById = new Map<string, NonNullable<typeof scene.groups>[number]>()
@@ -450,6 +452,11 @@ export function drawSceneFrame(
}
continue
}
// Editor-only hide for an entire clip track — match the stage's
// stackItems so mask-preview and sidebar thumbnails agree with the
// Konva canvas. exportPlayer's runtime ignores this flag; frame-
// based exports honour it by going through this same path.
if (clipTrackById.get(id)?.editorHidden) continue
const instances = instancesByTrack.get(id) ?? []
if (instances.length) {
for (const inst of instances) {
+17
View File
@@ -1812,6 +1812,23 @@ export const useSceneStore = defineStore('scene', {
if (hidden) layer.editorHidden = true
else delete layer.editorHidden
},
setClipTrackEditorHidden(trackId: string, hidden: boolean) {
const track = this.scene.clipTracks?.find((t) => t.id === trackId)
if (!track) return
if (hidden) track.editorHidden = true
else delete track.editorHidden
// Dropping the track out of the editor mustn't leave a stale
// selection pointing at one of its instances — the transformer
// would stay attached to a now-invisible ci-group.
if (hidden && this.selectedClipInstanceId) {
const stillSelected = this.scene.clipInstances?.find(
(ci) => ci.id === this.selectedClipInstanceId,
)
if (stillSelected && stillSelected.clipTrackId === trackId) {
this.selectedClipInstanceId = null
}
}
},
// -------- Markers --------------------------------------------------
+6
View File
@@ -218,6 +218,12 @@ export interface ClipTrack {
color?: string | null
colorOverridden?: boolean
locked?: boolean
/** Editor-only hide, same semantics as `Layer.editorHidden`. When
* true, in-editor render paths (Konva stage, sidebar thumbnails,
* mask-preview overlay) skip every instance on this track. Exports
* still render the instances unless the user opts into strip-hidden
* on the export dialog. */
editorHidden?: boolean
}
export interface TrackGroup {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.17.0'
export const APP_VERSION = '0.18.0'
export const APP_NAME = 'RUWIO'