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

+42 -172
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -10,13 +10,9 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const canvasEl = ref<HTMLCanvasElement | null>(null)
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref<Record<string, HTMLImageElement>>({})
const localTime = ref(0)
const playing = ref(false)
const showControls = ref(false)
@@ -24,6 +20,7 @@ const scrubbing = ref(false)
const HOVER_ZONE_PX = 160
let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
@@ -32,117 +29,44 @@ const aspect = computed(
const rangeIn = computed(() => store.playbackIn)
const rangeOut = computed(() => store.playbackOut)
let raf = 0
let lastFrame = 0
let ro: ResizeObserver | null = null
function resize() {
if (!canvasEl.value || !frameEl.value) return
const rect = frameEl.value.getBoundingClientRect()
viewportSize.value = { w: rect.width, h: rect.height }
const dpr = window.devicePixelRatio || 1
canvasEl.value.width = Math.max(1, Math.round(rect.width * dpr))
canvasEl.value.height = Math.max(1, Math.round(rect.height * dpr))
draw()
}
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
const cw = viewportSize.value.w
const ch = viewportSize.value.h
if (!cw || !ch) return
const scale = Math.min(cw / lw, ch / lh)
const ox = (cw - lw * scale) / 2
const oy = (ch - lh * scale) / 2
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cw, ch)
ctx.save()
ctx.translate(ox, oy)
ctx.scale(scale, scale)
drawSceneFrame(ctx, store.scene, localTime.value, images.value)
ctx.restore()
}
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
const start = rangeIn.value
const end = rangeOut.value
if (end > start) {
const v = bgVideoEl.value
if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
// Slave localTime to the video element so drift can't build up and
// cause compensating seeks — those seeks are what makes the video
// stutter visibly.
let vt = v.currentTime
if (vt >= end || vt < start) {
vt = start
v.currentTime = vt
}
localTime.value = vt
} else {
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
let next = localTime.value + dt
if (next >= end) next = start
else if (next < start) next = start
localTime.value = next
}
}
}
lastFrame = now
draw()
raf = requestAnimationFrame(loop)
}
function startLoop() {
cancelAnimationFrame(raf)
lastFrame = 0
raf = requestAnimationFrame(loop)
}
function stopLoop() {
cancelAnimationFrame(raf)
raf = 0
}
// ---- lifecycle ----
async function startPreview() {
destroyPlayer()
await nextTick()
const el = frameEl.value
if (!el) return
playing.value = true
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
localTime.value = head < start || head >= end ? start : head
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
// The playing watcher may have fired before the <video> ref was bound;
// make sure the background video is actually started now that the DOM
// is mounted.
const v = bgVideoEl.value
if (v) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore autoplay errors */
}
}
await requestBrowserFullscreen()
const initialTime = head < start || head >= end ? start : head
player = mountPlayer(el, store.scene, {
loop: true,
onFrame(time: number, paused: boolean) {
localTime.value = time
playing.value = !paused
},
})
player.seek(initialTime)
requestBrowserFullscreen()
}
function stopPreview() {
stopLoop()
destroyPlayer()
playing.value = false
showControls.value = false
exitBrowserFullscreen()
}
function destroyPlayer() {
if (player) {
player.destroy()
player = null
}
}
async function requestBrowserFullscreen() {
const el = rootEl.value
if (!el) return
@@ -183,6 +107,8 @@ function onRootMouseLeave() {
if (!scrubbing.value) showControls.value = false
}
// ---- transport controls ----
const progressPct = computed(() => {
const start = rangeIn.value
const end = rangeOut.value
@@ -193,20 +119,20 @@ const progressPct = computed(() => {
function seekFromEvent(e: PointerEvent) {
const el = progressTrackEl.value
if (!el) return
if (!el || !player) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const start = rangeIn.value
const end = rangeOut.value
localTime.value = start + ratio * (end - start)
draw()
player.seek(start + ratio * (end - start))
}
function onProgressPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
scrubbing.value = true
if (player) player.pause()
seekFromEvent(e)
}
@@ -226,12 +152,14 @@ function close() {
}
function toggle() {
playing.value = !playing.value
if (player) player.toggle()
}
function rewind() {
localTime.value = 0
playing.value = true
if (player) {
player.seek(rangeIn.value)
player.play()
}
}
function onKeyDown(e: KeyboardEvent) {
@@ -255,38 +183,6 @@ watch(
},
)
watch(
() => [store.scene.canvas.width, store.scene.canvas.height],
() => {
if (props.modelValue) resize()
},
)
watch(playing, async (p) => {
const v = bgVideoEl.value
if (!v) return
if (p) {
v.currentTime = localTime.value
try {
await v.play()
} catch {
/* ignore */
}
} else {
v.pause()
v.currentTime = localTime.value
}
})
// Keep the video frame aligned with localTime when we are NOT letting the
// video drive time (paused or actively scrubbing). During normal playback
// the RAF loop reads v.currentTime, so we must not seek here.
watch(localTime, (t) => {
const v = bgVideoEl.value
if (!v) return
if (playing.value && !scrubbing.value) return
v.currentTime = t
})
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
@@ -296,23 +192,8 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
stopLoop()
destroyPlayer()
exitBrowserFullscreen()
if (ro) ro.disconnect()
ro = null
})
watch(frameEl, (el, _old, onCleanup) => {
if (ro) ro.disconnect()
ro = null
if (el) {
ro = new ResizeObserver(() => resize())
ro.observe(el)
}
onCleanup(() => {
if (ro) ro.disconnect()
ro = null
})
})
const timeLabel = computed(() => {
@@ -333,18 +214,7 @@ const timeLabel = computed(() => {
@mouseleave="onRootMouseLeave"
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="fs-bg-video"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<canvas ref="canvasEl" />
</div>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }"></div>
<div class="fs-controls" @mousedown.stop>
<div
@@ -402,7 +272,7 @@ const timeLabel = computed(() => {
justify-content: center;
}
.fs-bg-video {
.fs-frame :deep(video) {
position: absolute;
inset: 0;
width: 100%;
@@ -412,7 +282,7 @@ const timeLabel = computed(() => {
z-index: 0;
}
.fs-frame canvas {
.fs-frame :deep(canvas) {
display: block;
width: 100%;
height: 100%;