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:
1 parent
c5b1dd566a
commit
87de1344c4
6 files changed
+587
-39
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user