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:
1 parent
373a835576
commit
92d58937e3
2 files changed
+270
-5
No files matched your search
+267
-5
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user