feat: 5-frame button assets + timeline/sidebar polish

Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
  pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
  separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
  the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
  renderButtonThumbnail now accepts an images map so image-kind layers
  inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
  height.

Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
  clip-edit (back button to "Main timeline" + active scope chip);
  removed the inline exit button from the timeline scrub-bar.

Timeline
- Track-name column default bumped to 260px to match the assets sidebar
  above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
  scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
  name-spacer when the playhead is in view (no half-clip at t=0),
  below when the playhead has scrolled off the left edge (so the
  toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
  via clip-path path().

Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
  renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
  so items line up flush-left; toggle items place their active ✓ on
  the right (kf-menu-trailing-check).

Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
  recorded, instead of only at the end of the run.

Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
  accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
  scrolled off-screen, still visible when playhead is far to the
  right of the scroll offset).
- Updated button state-scrubber expectations to match
  BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
  that hit-mode parks at normal=1 rather than 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 19:19:39 +02:00
1 parent 31604052ca
commit c3764201b8
16 files changed
+2560 -349

No files matched your search

+189 -6
View File
@@ -4,10 +4,13 @@ import type {
AnimatableProps,
Asset,
BackgroundVideo,
ButtonAsset,
ButtonState,
Clip,
ClipInstance,
ClipTrack,
Easing,
HitShape,
Keyframe,
Layer,
Marker,
@@ -17,7 +20,7 @@ import type {
ScriptTrack,
TrackGroup,
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import {
buildMarkerMap,
@@ -32,12 +35,24 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : neve
const KEYFRAME_EPSILON = 1e-3
/** The button editor has two orthogonal sub-modes: state editing (4 content
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
* state as onion-skin). Switching modes keeps each mode's last selection. */
export type ButtonEditMode = 'states' | 'hit'
interface State {
scene: Scene
selectedLayerId: string | null
playhead: number
playing: boolean
activeClipId: string | null
/** When set, the Stage + Timeline are inside a Button-asset edit session. */
activeButtonAssetId: string | null
/** 'states' drives the state scrubber; 'hit' swaps to hit-shape editing. */
activeButtonEditMode: ButtonEditMode | null
/** Which of the 4 content states is currently active. Only meaningful while
* `activeButtonEditMode === 'states'`; preserved across mode toggles. */
activeButtonState: ButtonState | null
drawingMode: 'freeform' | null
drawingAssetId: string | null
drawingLayerId: string | null
@@ -117,6 +132,23 @@ function hasKeys(obj: Partial<AnimatableProps>): boolean {
return false
}
/**
* Scan every container a layer can live in — scene-level, clip layers,
* button-asset state stacks — and return the layer with the matching id.
* Used by mutation helpers that don't know the caller's edit scope.
*/
function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
for (const l of scene.layers) if (l.id === layerId) return l
for (const clip of scene.clips ?? []) {
for (const l of clip.layers) if (l.id === layerId) return l
}
for (const asset of scene.assets) {
if (asset.kind !== 'button') continue
for (const l of asset.layers) if (l.id === layerId) return l
}
return null
}
function normalizeScene(scene: Scene): Scene {
scene.clips ||= []
scene.clipTracks ||= []
@@ -131,6 +163,7 @@ function normalizeScene(scene: Scene): Scene {
for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id)
for (const g of scene.groups) known.add(g.id)
for (const st of scene.scriptTracks) known.add(st.id)
// Cleanup: group childIds should only reference existing layers/clipTracks
const layerAndClipIds = new Set<string>()
@@ -165,6 +198,9 @@ function normalizeScene(scene: Scene): Scene {
for (const g of scene.groups) {
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
}
for (const st of scene.scriptTracks) {
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id)
}
return scene
}
@@ -187,6 +223,9 @@ export const useSceneStore = defineStore('scene', {
playhead: 0,
playing: false,
activeClipId: null,
activeButtonAssetId: null,
activeButtonEditMode: null,
activeButtonState: null,
drawingMode: null,
drawingAssetId: null,
drawingLayerId: null,
@@ -197,7 +236,19 @@ export const useSceneStore = defineStore('scene', {
const clips = state.scene.clips ?? []
return clips.find((c) => c.id === state.activeClipId) ?? null
},
activeButtonAsset(state): ButtonAsset | null {
if (!state.activeButtonAssetId) return null
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId)
return asset && asset.kind === 'button' ? asset : null
},
activeLayers(): Layer[] {
const bAsset = this.activeButtonAsset
if (bAsset) {
// Hit mode shares the same layer list — the canvas switches to
// hit-shape editing but tracks stay on the button's layers so the
// user can still see/select them.
return bAsset.layers
}
const clip = this.activeClip
return clip ? clip.layers : this.scene.layers
},
@@ -257,14 +308,24 @@ export const useSceneStore = defineStore('scene', {
this.scene.assets.push(created)
return created
},
addLayer(assetId: string, name?: string): Layer {
addLayer(assetId: string, name?: string): Layer | null {
// Hit-area edit has no content-layer container — new layers from
// the sidebar/drop path would silently leak into the shared states
// list. Reject here so hit-mode truly owns its own (hit-shape) track
// list and nothing bleeds across.
if (this.activeButtonEditMode === 'hit') return null
const asset = this.scene.assets.find((a) => a.id === assetId)
// Default position = center of the active canvas. In button-asset
// edit that's the button's smaller internal canvas, so the new
// layer lands inside the visible (and unclipped) area.
const canvas = this.activeButtonAsset?.canvas ?? this.scene.canvas
const base: AnimatableProps = {
...DEFAULT_BASE_PROPS,
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
x: canvas.width / 2,
y: canvas.height / 2,
}
if (asset && 'fill' in asset) base.fill = asset.fill
const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES.normal : 0
const layer: Layer = {
id: uid(),
name: name ?? asset?.name ?? 'Layer',
@@ -274,7 +335,7 @@ export const useSceneStore = defineStore('scene', {
keyframes: [
{
id: uid(),
time: 0,
time: initialTime,
props: { ...base },
easing: 'linear',
},
@@ -287,6 +348,10 @@ export const useSceneStore = defineStore('scene', {
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
layer.offsetX = 0
layer.offsetY = 0
} else if (asset.kind === 'button') {
// Place the button centered on (x, y) using Konva's offset mechanism.
layer.offsetX = asset.canvas.width / 2
layer.offsetY = asset.canvas.height / 2
}
if (asset.kind === 'polygon') {
layer.keyframes[0].props = {
@@ -296,7 +361,9 @@ export const useSceneStore = defineStore('scene', {
}
}
this.activeLayers.unshift(layer)
if (!this.activeClipId) {
// Only top-level scene layers appear in trackOrder. Clip-edit and
// button-asset-edit layers live inside their own containers.
if (!this.activeClipId && !this.activeButtonAssetId) {
const order = (this.scene.trackOrder ||= [])
if (!order.includes(layer.id)) order.unshift(layer.id)
}
@@ -1136,6 +1203,116 @@ export const useSceneStore = defineStore('scene', {
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
},
// -------- Button assets --------------------------------------------
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
const asset: ButtonAsset = {
id: uid(),
kind: 'button',
name: name?.trim() || 'Button',
canvas: {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
},
layers: [],
hitArea: [],
}
this.scene.assets.push(asset)
return asset
},
setButtonAssetCanvas(assetId: string, width: number, height: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
asset.canvas = {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
}
},
enterButtonAssetEdit(assetId: string) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
this.activeButtonAssetId = assetId
this.activeButtonEditMode = 'states'
this.activeButtonState = 'normal'
this.activeClipId = null
this.selectedLayerId = null
this.playhead = BUTTON_STATE_TIMES.normal
this.playing = false
},
setButtonEditMode(mode: ButtonEditMode) {
if (!this.activeButtonAssetId) return
this.activeButtonEditMode = mode
this.selectedLayerId = null
if (mode === 'states') {
// Landing back in states-mode: keep the last content state (or
// default to normal) and sync the playhead to its kf-time.
const st = this.activeButtonState ?? 'normal'
this.activeButtonState = st
this.playhead = BUTTON_STATE_TIMES[st]
} else {
// Hit mode onion-skins normal, so park the playhead at normal's
// time. No mutations can happen here — layers are inert.
this.playhead = BUTTON_STATE_TIMES.normal
}
},
setActiveButtonState(state: ButtonState) {
if (!this.activeButtonAssetId) return
// Scrubbing a state implies the user is in states-mode.
this.activeButtonEditMode = 'states'
this.activeButtonState = state
// States ARE keyframes — scrubbing moves the playhead to that
// state's fixed time so existing kf mutation paths target it.
this.playhead = BUTTON_STATE_TIMES[state]
this.selectedLayerId = null
},
exitButtonAssetEdit() {
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.playhead = 0
this.playing = false
},
addButtonHitShape(assetId: string, shape: HitShape): HitShape | null {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return null
asset.hitArea.push(shape)
return shape
},
removeButtonHitShape(assetId: string, index: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
if (index < 0 || index >= asset.hitArea.length) return
asset.hitArea.splice(index, 1)
},
updateButtonHitShape(
assetId: string,
index: number,
patch: Partial<HitShape>,
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'button') return
const current = asset.hitArea[index]
if (!current) return
// Preserve kind — patch can only override numeric fields.
asset.hitArea[index] = { ...current, ...patch } as HitShape
},
// -------- Button layer per-instance (on click / disabled) ----------
setLayerOnClick(layerId: string, actions: Action[]) {
const layer = findLayerAnywhere(this.scene, layerId)
if (!layer) return
if (actions.length) layer.onClick = actions.map((a) => ({ ...a }))
else delete layer.onClick
},
setLayerButtonDisabled(layerId: string, disabled: boolean) {
const layer = findLayerAnywhere(this.scene, layerId)
if (!layer) return
if (disabled) layer.buttonDisabled = true
else delete layer.buttonDisabled
},
setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
@@ -1208,6 +1385,9 @@ export const useSceneStore = defineStore('scene', {
this.playhead = 0
this.playing = false
this.activeClipId = null
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
},
addClip(name?: string): Clip {
const clip: Clip = {
@@ -1370,6 +1550,9 @@ export const useSceneStore = defineStore('scene', {
const clip = this.scene.clips?.find((c) => c.id === id)
if (!clip) return
this.activeClipId = id
this.activeButtonAssetId = null
this.activeButtonEditMode = null
this.activeButtonState = null
this.selectedLayerId = null
this.playhead = 0
this.playing = false