From 6784b766076f2a05d07dec1598cc929bff12bfbc Mon Sep 17 00:00:00 2001 From: th Date: Fri, 24 Apr 2026 14:11:43 +0200 Subject: [PATCH] 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) --- package.json | 2 +- src/components/CanvasStage.vue | 57 +++- src/components/ExportDialog.vue | 23 ++ src/components/Timeline.vue | 30 +- src/devtools/tests.ts | 509 ++++++++++++++++++++++++++++++++ src/lib/clipBounds.ts | 118 ++++++++ src/lib/render.ts | 7 + src/stores/scene.ts | 17 ++ src/types/scene.ts | 6 + src/version.ts | 2 +- 10 files changed, 764 insertions(+), 7 deletions(-) create mode 100644 src/lib/clipBounds.ts diff --git a/package.json b/package.json index ab2782d..d0b7ff3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index c9b311d..81eb633 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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(() => { for (const l of store.scene.layers) layerById.set(l.id, l) const clipById = new Map[number]>() for (const c of store.scene.clips ?? []) clipById.set(c.id, c) + const clipTrackById = new Map[number]>() + for (const t of store.scene.clipTracks ?? []) clipTrackById.set(t.id, t) const instancesByTrack = new Map>() for (const ci of store.scene.clipInstances ?? []) { const arr = instancesByTrack.get(ci.clipTrackId) ?? [] @@ -838,6 +845,11 @@ const stackItems = computed(() => { }) 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(() => { 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, instanceId: string, @@ -3342,6 +3387,12 @@ function onResizeHandlePointerUp(e: PointerEvent) { @dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)" @transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)" > +