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:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
@@ -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%;
|
||||
|
||||
Reference in new issue
Block a user