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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 20:49:59 +02:00
1 parent 373a835576
commit 92d58937e3
2 files changed
+270 -5

No files matched your search

+267 -5
View File
@@ -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<string, NonNullable<typeof store.scene.scriptTracks>[number]>()
for (const t of store.scene.scriptTracks ?? []) m.set(t.id, t)
return m
})
const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFrames>>(() => {
const out: Record<string, NonNullable<typeof store.scene.scriptFrames>> = {}
for (const f of store.scene.scriptFrames ?? []) {
;(out[f.trackId] ||= []).push(f)
}
return out
})
const buttonClipById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.buttonClips>[number]>()
for (const c of store.scene.buttonClips ?? []) m.set(c.id, c)
return m
})
const buttonInstancesByTrack = computed<
Record<string, NonNullable<typeof store.scene.buttonInstances>>
>(() => {
const out: Record<string, NonNullable<typeof store.scene.buttonInstances>> = {}
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<TimelineTrack[]>(() => {
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<TimelineTrack[]>(() => {
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<TimelineTrack[]>(() => {
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(() => {
>
<PhFolderPlus :size="12" />
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addScriptTrackTooltip')"
@click="store.addScriptTrack()"
>
<PhCode :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
:title="t('timeline.addKeyframeTooltip')"
@@ -1905,7 +1982,9 @@ onBeforeUnmount(() => {
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
: track.type === 'clip'
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
: `gr:${track.groupId}`
: track.type === 'script'
? `s:${track.scriptTrackId}`
: `gr:${track.groupId}`
">
<!-- Layer track -->
<div
@@ -2166,12 +2245,90 @@ onBeforeUnmount(() => {
@click.stop
/>
</span>
<span
v-for="bi in buttonInstancesByTrack[track.clipTrackId] ?? []"
:key="`bi-${bi.id}`"
class="button-instance"
:class="{ disabled: bi.disabled }"
:style="buttonInstanceStyle(bi)"
:title="bi.label ?? buttonClipById.get(bi.buttonClipId)?.name ?? ''"
>
<span
v-for="state in BUTTON_STATES"
:key="state"
class="button-instance-cell"
:data-state="state"
>{{ state[0].toUpperCase() }}</span>
</span>
</div>
</div>
<!-- Script track -->
<div
v-else-if="track.type === 'script'"
class="track script-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.scriptTrackId,
colored: !!track.color,
locked: track.locked,
}"
:style="trackColorStyle(track.color)"
@dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.locked ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
class="track-name script-track-name"
:draggable="!track.locked"
@dragstart="!track.locked ? onTrackDragStart($event, track.scriptTrackId) : null"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhCode :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="track.locked ? PhLockOpen : PhLock"
:size="12"
:weight="track.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane script-lane"
:style="{ width: laneWidth + 'px' }"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="frame in scriptFramesByTrack[track.scriptTrackId] ?? []"
:key="frame.id"
class="script-frame"
:style="{ left: pxFromTime(frame.time) + 'px' }"
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
>
<span class="script-frame-dot"></span>
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
</span>
</div>
</div>
<!-- Group header -->
<div
v-else
v-else-if="track.type === 'group'"
class="track group-track"
:class="{
'drop-before': isDropBefore(track),
@@ -3194,6 +3351,111 @@ onBeforeUnmount(() => {
position: relative;
}
/* ---- Script track ---- */
.track.script-track {
height: 26px;
background: linear-gradient(
to bottom,
rgba(245, 158, 11, 0.06) 0%,
rgba(245, 158, 11, 0.02) 100%
);
}
.script-track .track-name {
background: var(--panel-alt);
font-size: 11px;
color: var(--text-dim);
}
.script-track-name {
gap: 6px;
}
.script-lane {
position: relative;
}
.script-frame {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.script-frame-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #f59e0b;
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
}
.script-frame-count {
font-size: 9px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border-radius: 999px;
padding: 0 5px;
line-height: 13px;
min-width: 13px;
text-align: center;
}
/* ---- Button instance (4 cells) ---- */
.button-instance {
position: absolute;
top: 4px;
bottom: 4px;
display: inline-flex;
border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm);
overflow: hidden;
background: color-mix(in srgb, var(--track-color, var(--accent)) 10%, transparent);
color: var(--track-color, var(--accent));
font-size: 10px;
font-weight: 600;
}
.button-instance.disabled {
opacity: 0.5;
}
.button-instance-cell {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid color-mix(in srgb, var(--track-color, var(--accent)) 40%, transparent);
background: color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent);
letter-spacing: 0.04em;
}
.button-instance-cell:last-child {
border-right: none;
}
.button-instance-cell[data-state='hover'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
}
.button-instance-cell[data-state='pressed'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
}
.button-instance-cell[data-state='disabled'] {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent) 0 4px,
transparent 4px 8px
);
}
.clip-block {
position: absolute;
top: 4px;
+3
View File
@@ -160,6 +160,9 @@ export const en = {
dropNewTrackHint: 'Drop a clip here to add a new track',
addGroup: 'Add group',
addGroupTooltip: 'Create a new track group',
scriptTrackName: 'Script track',
addScriptTrackTooltip: 'Create a new script track',
scriptFrameTooltip: 'Script frame — {n} action(s)',
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
groupChildCount: '{n} tracks',
groupEmpty: 'Drop tracks here',