0.10.2: audio waveforms + sidebar section polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 13:45:34 +02:00
1 parent c5b1dd566a
commit 87de1344c4
6 files changed
+587 -39

No files matched your search

+88 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { importAudioFile } from '@/lib/fileLoader'
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
@@ -1760,6 +1761,42 @@ function audioClipTooltip(clip: AudioClip): string {
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
}
/** Resolved peaks for a clip's asset — undefined while the decoder
* is still working (first render shows an empty block; waveform
* fades in once computation finishes). */
function clipPeaks(clip: AudioClip): Float32Array | undefined {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset || !asset.src) return undefined
const slot = getWaveformSlot(asset.id, asset.src)
if (!slot || slot.state !== 'ready') return undefined
return slicePeaksForClip(slot.peaks, asset.duration, clip.trimStart ?? 0, clip.trimEnd ?? 0)
}
/** Canvas ref-callback: redraw on every Vue update so the bars track
* the clip's current width (resizes on zoom + trim changes). Cheap
* because peaks are cached and the draw itself is a few hundred
* fillRects. */
function renderWaveformCanvas(clip: AudioClip) {
return (raw: Element | ComponentPublicInstance | null) => {
if (!raw || !(raw instanceof HTMLCanvasElement)) return
const el = raw
const peaks = clipPeaks(clip)
if (!peaks) return
const parent = el.parentElement
if (!parent) return
const pr = window.devicePixelRatio || 1
const cssW = Math.max(1, parent.clientWidth - 2)
const cssH = Math.max(1, parent.clientHeight - 4)
const pxW = Math.max(1, Math.round(cssW * pr))
const pxH = Math.max(1, Math.round(cssH * pr))
if (el.width !== pxW) el.width = pxW
if (el.height !== pxH) el.height = pxH
el.style.width = cssW + 'px'
el.style.height = cssH + 'px'
drawWaveform(el, peaks, { pixelRatio: pr })
}
}
/** Would a hypothetical audible block spanning [start, start+length] on
* `trackId` collide with an existing clip? Clips on the same track
* must not overlap — the user has to pick a second track for parallel
@@ -3211,6 +3248,11 @@ onBeforeUnmount(() => {
@pointercancel="onAudioClipPointerUp"
@contextmenu="onAudioClipContextMenu($event, clip)"
>
<canvas
v-if="clipPeaks(clip)"
class="audio-clip-wave"
:ref="renderWaveformCanvas(clip)"
/>
<span class="audio-clip-label">
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span>
@@ -3824,6 +3866,29 @@ onBeforeUnmount(() => {
.snap-btn.add-kf-btn {
margin-left: auto;
/* Softer-than-the-other-toolbar-buttons treatment: no border, round
* hit-area, accent-filled on hover — matches the sidebar's section
* `+` buttons so the two "create new thing" affordances read the
* same visually. */
border: none;
border-radius: 50%;
background: transparent;
}
.snap-btn.add-kf-btn:hover:not(:disabled) {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn.active {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn:disabled {
background: transparent;
}
.mode-toggle {
@@ -3835,7 +3900,15 @@ onBeforeUnmount(() => {
}
.mode-btn {
padding: 3px 8px;
/* Match the height of the sibling .snap-btn icons so the timeline
* toolbar sits on one visual baseline — the wrapper .mode-toggle's
* 1px border is already accounted for by `box-sizing: border-box`
* higher up, so the inner 20px hit area stays flush with neighbours. */
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 8px;
min-width: 22px;
background: transparent;
border: none;
@@ -4729,6 +4802,15 @@ onBeforeUnmount(() => {
user-select: none;
}
.audio-clip-wave {
position: absolute;
left: 1px;
right: 1px;
top: 2px;
pointer-events: none;
opacity: 0.85;
}
.audio-clip-block:active,
.audio-clip-block.moving {
cursor: grabbing;
@@ -4758,12 +4840,16 @@ onBeforeUnmount(() => {
}
.audio-clip-label {
position: relative;
font-size: 10px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
/* Tiny shadow keeps the text legible over the waveform fill. */
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.clip-block {