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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user