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:
1 parent
a858f94bb7
commit
6784b76607
10 files changed
+764
-7
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 --------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.17.0'
|
||||
export const APP_VERSION = '0.18.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user