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

+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"