From 92d58937e3f0be325873e655e2a8bfff5b26609f Mon Sep 17 00:00:00 2001 From: th Date: Mon, 20 Apr 2026 20:49:59 +0200 Subject: [PATCH] macro-clips slice 3: timeline rendering for scripts and button instances - TimelineScriptTrack added to the track union; tracks computed emits one per ScriptTrack in trackOrder, with the show-locked filter honoring its locked flag - script track row: drag handle + PhCode name badge + lock button; lane renders each ScriptFrame as an orange dot with optional action-count badge - button instances render inside clip-track lanes as 4 cells labeled N/H/P/D, colored per state, with a striped pattern on disabled and a fixed visual width per cell - new scrub-bar button to add a script track (PhCode icon) - type narrowing tightened so inGroup checks skip script/group tracks explicitly rather than relying on the negation Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Timeline.vue | 272 +++++++++++++++++++++++++++++++++++- src/i18n/messages.ts | 3 + 2 files changed, 270 insertions(+), 5 deletions(-) diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index a096ac2..6fcb262 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -5,6 +5,7 @@ import { PhCaretDown, PhCaretRight, PhCheck, + PhCode, PhCopy, PhDotsSixVertical, PhFolder, @@ -35,6 +36,7 @@ import { showLocked } from '@/composables/useShowLocked' import type { AnimatableProps, Asset, + ButtonInstance, Clip, ClipInstance, Easing, @@ -78,7 +80,20 @@ interface TimelineGroupTrack { childCount: number } -type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack +interface TimelineScriptTrack { + type: 'script' + scriptTrackId: string + name: string + orderIndex: number + locked: boolean + color: string | null +} + +type TimelineTrack = + | TimelineLayerTrack + | TimelineClipTrack + | TimelineGroupTrack + | TimelineScriptTrack const store = useSceneStore() @@ -106,6 +121,32 @@ const groupById = computed(() => { for (const g of store.scene.groups ?? []) m.set(g.id, g) return m }) +const scriptTrackById = computed(() => { + const m = new Map[number]>() + for (const t of store.scene.scriptTracks ?? []) m.set(t.id, t) + return m +}) +const scriptFramesByTrack = computed>(() => { + const out: Record> = {} + for (const f of store.scene.scriptFrames ?? []) { + ;(out[f.trackId] ||= []).push(f) + } + return out +}) +const buttonClipById = computed(() => { + const m = new Map[number]>() + for (const c of store.scene.buttonClips ?? []) m.set(c.id, c) + return m +}) +const buttonInstancesByTrack = computed< + Record> +>(() => { + const out: Record> = {} + for (const bi of store.scene.buttonInstances ?? []) { + ;(out[bi.clipTrackId] ||= []).push(bi) + } + return out +}) const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth' const TRACK_NAME_WIDTH_DEFAULT = 140 @@ -611,6 +652,18 @@ const tracks = computed(() => { out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false }) return } + const st = scriptTrackById.value.get(id) + if (st) { + out.push({ + type: 'script', + scriptTrackId: st.id, + name: st.name ?? t('timeline.scriptTrackName'), + orderIndex, + locked: !!st.locked, + color: st.color ?? null, + }) + return + } const grp = groupById.value.get(id) if (grp) { out.push({ @@ -666,6 +719,9 @@ const tracks = computed(() => { if (lockedGroupChildIds.has(tr.clipTrackId)) return false return true } + if (tr.type === 'script') { + return !tr.locked + } // group: hide when locked return !lockedGroupIds.has(tr.groupId) }) @@ -674,6 +730,7 @@ const tracks = computed(() => { function trackDndId(track: TimelineTrack): string { if (track.type === 'layer') return track.layerId if (track.type === 'clip') return track.clipTrackId + if (track.type === 'script') return track.scriptTrackId return track.groupId } @@ -704,7 +761,7 @@ function isDropBefore(track: TimelineTrack): boolean { if (trackParentGroupId(track) !== dropWithinGroupId.value) return false return dropIndex.value === trackChildIndex(track) } - if (track.type !== 'group' && track.inGroup) return false + if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false return dropIndex.value === track.orderIndex } @@ -719,7 +776,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined) if (nextTrack && trackChildIndex(nextTrack) === ci) return false return true } - if (track.type !== 'group' && track.inGroup) return false + if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false if (dropIndex.value !== track.orderIndex + 1) return false if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false return true @@ -1420,6 +1477,18 @@ function clipBlockStyle(ci: ClipInstance) { } } +const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const +const BUTTON_CELL_SEC = 0.5 // visual width of each of the 4 state cells +function buttonInstanceStyle(bi: ButtonInstance) { + // Four cells, each with a fixed per-cell visual width. Kept as a fixed + // (time-like) span so the block scales with timeline zoom. + const totalSec = BUTTON_CELL_SEC * 4 + return { + left: pxFromTime(bi.startTime) + 'px', + width: Math.max(80, pxFromTime(totalSec)) + 'px', + } +} + function trimTooltip(ci: ClipInstance): string { const name = clipById.value.get(ci.clipId)?.name ?? '?' const dur = visibleDurationOf(ci).toFixed(2) @@ -1809,6 +1878,14 @@ onBeforeUnmount(() => { > + + +
+ + + + {{ frame.actions.length }} +