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,
|
PhCaretDown,
|
||||||
PhCaretRight,
|
PhCaretRight,
|
||||||
PhCheck,
|
PhCheck,
|
||||||
|
PhCode,
|
||||||
PhCopy,
|
PhCopy,
|
||||||
PhDotsSixVertical,
|
PhDotsSixVertical,
|
||||||
PhFolder,
|
PhFolder,
|
||||||
@@ -35,6 +36,7 @@ import { showLocked } from '@/composables/useShowLocked'
|
|||||||
import type {
|
import type {
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Asset,
|
Asset,
|
||||||
|
ButtonInstance,
|
||||||
Clip,
|
Clip,
|
||||||
ClipInstance,
|
ClipInstance,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -78,7 +80,20 @@ interface TimelineGroupTrack {
|
|||||||
childCount: number
|
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()
|
const store = useSceneStore()
|
||||||
|
|
||||||
@@ -106,6 +121,32 @@ const groupById = computed(() => {
|
|||||||
for (const g of store.scene.groups ?? []) m.set(g.id, g)
|
for (const g of store.scene.groups ?? []) m.set(g.id, g)
|
||||||
return m
|
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_KEY = 'animatr:trackNameWidth'
|
||||||
const TRACK_NAME_WIDTH_DEFAULT = 140
|
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 })
|
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
|
||||||
return
|
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)
|
const grp = groupById.value.get(id)
|
||||||
if (grp) {
|
if (grp) {
|
||||||
out.push({
|
out.push({
|
||||||
@@ -666,6 +719,9 @@ const tracks = computed<TimelineTrack[]>(() => {
|
|||||||
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
|
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (tr.type === 'script') {
|
||||||
|
return !tr.locked
|
||||||
|
}
|
||||||
// group: hide when locked
|
// group: hide when locked
|
||||||
return !lockedGroupIds.has(tr.groupId)
|
return !lockedGroupIds.has(tr.groupId)
|
||||||
})
|
})
|
||||||
@@ -674,6 +730,7 @@ const tracks = computed<TimelineTrack[]>(() => {
|
|||||||
function trackDndId(track: TimelineTrack): string {
|
function trackDndId(track: TimelineTrack): string {
|
||||||
if (track.type === 'layer') return track.layerId
|
if (track.type === 'layer') return track.layerId
|
||||||
if (track.type === 'clip') return track.clipTrackId
|
if (track.type === 'clip') return track.clipTrackId
|
||||||
|
if (track.type === 'script') return track.scriptTrackId
|
||||||
return track.groupId
|
return track.groupId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +761,7 @@ function isDropBefore(track: TimelineTrack): boolean {
|
|||||||
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
|
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
|
||||||
return dropIndex.value === trackChildIndex(track)
|
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
|
return dropIndex.value === track.orderIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +776,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
|||||||
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
|
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
|
||||||
return true
|
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 (dropIndex.value !== track.orderIndex + 1) return false
|
||||||
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
||||||
return true
|
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 {
|
function trimTooltip(ci: ClipInstance): string {
|
||||||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||||||
const dur = visibleDurationOf(ci).toFixed(2)
|
const dur = visibleDurationOf(ci).toFixed(2)
|
||||||
@@ -1809,6 +1878,14 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<PhFolderPlus :size="12" />
|
<PhFolderPlus :size="12" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!inClipEdit"
|
||||||
|
class="snap-btn"
|
||||||
|
:title="t('timeline.addScriptTrackTooltip')"
|
||||||
|
@click="store.addScriptTrack()"
|
||||||
|
>
|
||||||
|
<PhCode :size="12" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="snap-btn add-kf-btn"
|
class="snap-btn add-kf-btn"
|
||||||
:title="t('timeline.addKeyframeTooltip')"
|
:title="t('timeline.addKeyframeTooltip')"
|
||||||
@@ -1905,7 +1982,9 @@ onBeforeUnmount(() => {
|
|||||||
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
|
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
|
||||||
: track.type === 'clip'
|
: track.type === 'clip'
|
||||||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||||||
: `gr:${track.groupId}`
|
: track.type === 'script'
|
||||||
|
? `s:${track.scriptTrackId}`
|
||||||
|
: `gr:${track.groupId}`
|
||||||
">
|
">
|
||||||
<!-- Layer track -->
|
<!-- Layer track -->
|
||||||
<div
|
<div
|
||||||
@@ -2166,12 +2245,90 @@ onBeforeUnmount(() => {
|
|||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group header -->
|
<!-- Group header -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="track.type === 'group'"
|
||||||
class="track group-track"
|
class="track group-track"
|
||||||
:class="{
|
:class="{
|
||||||
'drop-before': isDropBefore(track),
|
'drop-before': isDropBefore(track),
|
||||||
@@ -3194,6 +3351,111 @@ onBeforeUnmount(() => {
|
|||||||
position: relative;
|
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 {
|
.clip-block {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export const en = {
|
|||||||
dropNewTrackHint: 'Drop a clip here to add a new track',
|
dropNewTrackHint: 'Drop a clip here to add a new track',
|
||||||
addGroup: 'Add group',
|
addGroup: 'Add group',
|
||||||
addGroupTooltip: 'Create a new track 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',
|
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
|
||||||
groupChildCount: '{n} tracks',
|
groupChildCount: '{n} tracks',
|
||||||
groupEmpty: 'Drop tracks here',
|
groupEmpty: 'Drop tracks here',
|
||||||
|
|||||||
Reference in new issue
Block a user