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
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+234
-36
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import {
|
||||
PhCaretDown,
|
||||
PhCaretRight,
|
||||
PhCircle,
|
||||
PhCursorClick,
|
||||
PhFilmStrip,
|
||||
@@ -33,6 +35,39 @@ const uploadError = ref<string | null>(null)
|
||||
|
||||
const { enabled: showThumbs } = useAssetThumbsPref()
|
||||
|
||||
/** Per-section collapsed state, persisted so the user's layout
|
||||
* survives reloads. Each key is false (expanded) by default —
|
||||
* that's the desired first-run behaviour per product brief. */
|
||||
type AssetSection = 'images' | 'clips' | 'buttons' | 'audio'
|
||||
const ASSET_SECTION_KEY = 'animatr:assetSectionsCollapsed'
|
||||
|
||||
function readSectionState(): Record<AssetSection, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(ASSET_SECTION_KEY)
|
||||
if (!raw) return { images: false, clips: false, buttons: false, audio: false }
|
||||
const parsed = JSON.parse(raw) as Partial<Record<AssetSection, boolean>>
|
||||
return {
|
||||
images: !!parsed.images,
|
||||
clips: !!parsed.clips,
|
||||
buttons: !!parsed.buttons,
|
||||
audio: !!parsed.audio,
|
||||
}
|
||||
} catch {
|
||||
return { images: false, clips: false, buttons: false, audio: false }
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedSections = ref<Record<AssetSection, boolean>>(readSectionState())
|
||||
|
||||
function toggleSection(key: AssetSection) {
|
||||
collapsedSections.value[key] = !collapsedSections.value[key]
|
||||
try {
|
||||
localStorage.setItem(ASSET_SECTION_KEY, JSON.stringify(collapsedSections.value))
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function addSampleRect() {
|
||||
const asset = store.addAsset({
|
||||
kind: 'rect',
|
||||
@@ -389,7 +424,24 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
@dragleave="onPanelDragLeave"
|
||||
@drop="onPanelDrop"
|
||||
>
|
||||
<h3>Assets</h3>
|
||||
<div class="assets-header">
|
||||
<h3>Assets</h3>
|
||||
<label
|
||||
class="thumbs-switch"
|
||||
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
|
||||
>
|
||||
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="thumbs-switch-input"
|
||||
:checked="showThumbs"
|
||||
@change="showThumbs = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="thumbs-switch-track">
|
||||
<span class="thumbs-switch-knob"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="upload-btn" @click="triggerUpload">
|
||||
<PhUploadSimple :size="16" />
|
||||
@@ -432,18 +484,15 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
/>
|
||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||
|
||||
<div class="lib-header">
|
||||
<h4>{{ t('sidebar.library') }}</h4>
|
||||
<button
|
||||
class="thumb-toggle"
|
||||
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
|
||||
@click="showThumbs = !showThumbs"
|
||||
>
|
||||
<PhImageSquare v-if="showThumbs" :size="13" />
|
||||
<PhListBullets v-else :size="13" />
|
||||
<!-- Images -->
|
||||
<div class="section-head-row" :class="{ collapsed: collapsedSections.images }">
|
||||
<button class="section-head" @click="toggleSection('images')">
|
||||
<component :is="collapsedSections.images ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
|
||||
<span class="section-title">{{ t('sidebar.images') }}</span>
|
||||
</button>
|
||||
<span class="section-count">{{ imageAssets.length }}</span>
|
||||
</div>
|
||||
<ul class="assets" :class="{ compact: !showThumbs }">
|
||||
<ul v-show="!collapsedSections.images" class="assets" :class="{ compact: !showThumbs }">
|
||||
<li
|
||||
v-for="asset in imageAssets"
|
||||
:key="asset.id"
|
||||
@@ -473,13 +522,18 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="lib-header clips-header">
|
||||
<h4>{{ t('sidebar.clips') }}</h4>
|
||||
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
|
||||
<!-- Clips -->
|
||||
<div class="section-head-row" :class="{ collapsed: collapsedSections.clips }">
|
||||
<button class="section-head" @click="toggleSection('clips')">
|
||||
<component :is="collapsedSections.clips ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
|
||||
<span class="section-title">{{ t('sidebar.clips') }}</span>
|
||||
</button>
|
||||
<button class="section-action" v-tooltip="t('sidebar.createClipTooltip')" @click.stop="addClip">
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
</button>
|
||||
<span class="section-count">{{ clips.length }}</span>
|
||||
</div>
|
||||
<ul class="clips">
|
||||
<ul v-show="!collapsedSections.clips" class="clips">
|
||||
<li
|
||||
v-for="clip in clips"
|
||||
:key="clip.id"
|
||||
@@ -514,13 +568,18 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="lib-header clips-header">
|
||||
<h4>{{ t('sidebar.buttons') }}</h4>
|
||||
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
|
||||
<!-- Buttons -->
|
||||
<div class="section-head-row" :class="{ collapsed: collapsedSections.buttons }">
|
||||
<button class="section-head" @click="toggleSection('buttons')">
|
||||
<component :is="collapsedSections.buttons ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
|
||||
<span class="section-title">{{ t('sidebar.buttons') }}</span>
|
||||
</button>
|
||||
<button class="section-action" v-tooltip="t('sidebar.createButtonTooltip')" @click.stop="addButton">
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
</button>
|
||||
<span class="section-count">{{ buttonAssets.length }}</span>
|
||||
</div>
|
||||
<ul class="clips" :class="{ compact: !showThumbs }">
|
||||
<ul v-show="!collapsedSections.buttons" class="clips" :class="{ compact: !showThumbs }">
|
||||
<li
|
||||
v-for="ba in buttonAssets"
|
||||
:key="ba.id"
|
||||
@@ -569,10 +628,15 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
||||
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="lib-header clips-header">
|
||||
<h4>{{ t('sidebar.audio') }}</h4>
|
||||
<!-- Audio -->
|
||||
<div class="section-head-row" :class="{ collapsed: collapsedSections.audio }">
|
||||
<button class="section-head" @click="toggleSection('audio')">
|
||||
<component :is="collapsedSections.audio ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
|
||||
<span class="section-title">{{ t('sidebar.audio') }}</span>
|
||||
</button>
|
||||
<span class="section-count">{{ audioAssets.length }}</span>
|
||||
</div>
|
||||
<ul class="clips">
|
||||
<ul v-show="!collapsedSections.audio" class="clips">
|
||||
<li
|
||||
v-for="asset in audioAssets"
|
||||
:key="asset.id"
|
||||
@@ -682,27 +746,159 @@ h4 {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lib-header {
|
||||
.assets-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumb-toggle {
|
||||
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
|
||||
* for a11y (keyboard + screen readers), the visible track + knob is
|
||||
* driven purely by the `:checked` state. */
|
||||
.thumbs-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.thumbs-switch-label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.thumbs-switch-input {
|
||||
/* Keep focus behaviour via the native input; the visual sits next to it. */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumbs-switch-track {
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background 140ms var(--ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumbs-switch-knob {
|
||||
position: absolute;
|
||||
top: 1.5px;
|
||||
left: 1.5px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
transition: left 140ms var(--ease), background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.thumbs-switch-input:checked + .thumbs-switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
|
||||
left: 13.5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.thumbs-switch:hover .thumbs-switch-track {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Section rows: unified layout — [caret + title][action?][count]. The
|
||||
* count always sits at the far right so the right column is visually
|
||||
* stable regardless of whether a row has a create-action (+) beside it. */
|
||||
.section-head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-head-row.collapsed {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-head:hover {
|
||||
background: color-mix(in srgb, var(--panel-alt) 60%, transparent);
|
||||
}
|
||||
|
||||
.section-caret {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 140ms var(--ease), color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.thumb-toggle:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
.section-action:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-dim);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assets {
|
||||
@@ -799,16 +995,21 @@ h4 {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: background 140ms var(--ease), color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.add-layer:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -818,9 +1019,6 @@ h4 {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clips-header {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.clips {
|
||||
list-style: none;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Peak data for an audio asset — a downsampled array of [0, 1]
|
||||
* amplitudes, one per bucket. Consumers stretch it across the visible
|
||||
* clip block's width. 256 buckets is plenty for the timeline scale;
|
||||
* bumping it only costs memory (peaks never redraw per-frame).
|
||||
*/
|
||||
const PEAK_BUCKETS = 256
|
||||
|
||||
type PeakSlot =
|
||||
| { state: 'pending' }
|
||||
| { state: 'ready'; peaks: Float32Array }
|
||||
| { state: 'error' }
|
||||
|
||||
/** Module-scope reactive cache keyed by audio-asset id. Shared across
|
||||
* every AudioClipBlock — two clips of the same asset render the same
|
||||
* waveform without recomputing. */
|
||||
const cache = ref<Map<string, PeakSlot>>(new Map())
|
||||
|
||||
/** Return the peak slot for an asset, kicking off a lazy decode if
|
||||
* nothing is cached yet. Callers render nothing while `pending`, the
|
||||
* actual bars once `ready`. */
|
||||
export function getWaveformSlot(assetId: string, src: string): PeakSlot | undefined {
|
||||
const existing = cache.value.get(assetId)
|
||||
if (existing) return existing
|
||||
// Mark pending so concurrent callers don't each trigger a decode.
|
||||
cache.value.set(assetId, { state: 'pending' })
|
||||
void decodePeaks(src)
|
||||
.then((peaks) => {
|
||||
// Use a fresh Map to trigger Vue reactivity — mutating the Map's
|
||||
// entry in-place doesn't notify dependent templates.
|
||||
const next = new Map(cache.value)
|
||||
next.set(assetId, { state: 'ready', peaks })
|
||||
cache.value = next
|
||||
})
|
||||
.catch(() => {
|
||||
const next = new Map(cache.value)
|
||||
next.set(assetId, { state: 'error' })
|
||||
cache.value = next
|
||||
})
|
||||
return cache.value.get(assetId)
|
||||
}
|
||||
|
||||
async function decodePeaks(src: string): Promise<Float32Array> {
|
||||
const response = await fetch(src)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
// prefixed fallback for older Safari; modern Chrome/Firefox expose
|
||||
// AudioContext directly.
|
||||
const Ctx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
if (!Ctx) throw new Error('Web Audio API unavailable')
|
||||
const ctx = new Ctx()
|
||||
try {
|
||||
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
|
||||
const channel = buffer.getChannelData(0)
|
||||
const peaks = new Float32Array(PEAK_BUCKETS)
|
||||
const bucketSize = Math.max(1, Math.floor(channel.length / PEAK_BUCKETS))
|
||||
for (let i = 0; i < PEAK_BUCKETS; i++) {
|
||||
let maxAbs = 0
|
||||
const start = i * bucketSize
|
||||
const end = Math.min(start + bucketSize, channel.length)
|
||||
for (let j = start; j < end; j++) {
|
||||
const v = Math.abs(channel[j])
|
||||
if (v > maxAbs) maxAbs = v
|
||||
}
|
||||
peaks[i] = maxAbs
|
||||
}
|
||||
return peaks
|
||||
} finally {
|
||||
void ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all cached waveforms. Wired to dispose helpers for tests and
|
||||
* to scene-load / project-load hooks where the old cache would point
|
||||
* at orphaned asset ids. */
|
||||
export function clearWaveformCache(): void {
|
||||
cache.value = new Map()
|
||||
}
|
||||
|
||||
/** Slice the peaks array to the portion visible after the clip's
|
||||
* trimStart / trimEnd. Returns a subarray view so we don't copy;
|
||||
* drawers iterate but never mutate. */
|
||||
export function slicePeaksForClip(
|
||||
peaks: Float32Array,
|
||||
assetDuration: number,
|
||||
trimStart: number,
|
||||
trimEnd: number,
|
||||
): Float32Array {
|
||||
if (assetDuration <= 0) return peaks
|
||||
const startFraction = Math.max(0, trimStart) / assetDuration
|
||||
const endFraction = Math.max(startFraction, 1 - Math.max(0, trimEnd) / assetDuration)
|
||||
const startIdx = Math.floor(peaks.length * startFraction)
|
||||
const endIdx = Math.ceil(peaks.length * endFraction)
|
||||
if (endIdx <= startIdx) return peaks.subarray(0, 1)
|
||||
return peaks.subarray(startIdx, endIdx)
|
||||
}
|
||||
|
||||
/** Render a waveform into a canvas. Draws centred bars (top/bottom
|
||||
* mirror) with the accent-tinted fill used everywhere else for audio.
|
||||
* Caller controls DPR scaling — pass `pixelRatio` to redraw sharp on
|
||||
* high-DPI displays. */
|
||||
export function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
peaks: Float32Array,
|
||||
opts: { color?: string; pixelRatio?: number } = {},
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const pr = opts.pixelRatio ?? 1
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.fillStyle = opts.color ?? 'rgba(14, 165, 233, 0.55)'
|
||||
if (peaks.length === 0) return
|
||||
const midY = h / 2
|
||||
const barW = Math.max(1, Math.round(pr))
|
||||
const colStride = barW + Math.max(0, Math.round(pr * 0.5))
|
||||
for (let x = 0; x < w; x += colStride) {
|
||||
const peakIdx = Math.floor((x / w) * peaks.length)
|
||||
const peak = peaks[peakIdx] ?? 0
|
||||
const barH = Math.max(barW, peak * h)
|
||||
ctx.fillRect(x, midY - barH / 2, barW, barH)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ import Timeline from '@/components/Timeline.vue'
|
||||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
||||
import {
|
||||
clearWaveformCache,
|
||||
drawWaveform,
|
||||
getWaveformSlot,
|
||||
slicePeaksForClip,
|
||||
} from '@/composables/useAudioWaveform'
|
||||
import { vTooltip } from '@/directives/tooltip'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
@@ -4646,6 +4652,87 @@ export function registerAllTests(): void {
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('sections are expanded by default and collapse on header click', async () => {
|
||||
try {
|
||||
localStorage.removeItem('animatr:assetSectionsCollapsed')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const s = makeFreshStore()
|
||||
s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
|
||||
const handle = mountComponent(AssetSidebar, {})
|
||||
await nextTick()
|
||||
// All four sections present in the header order.
|
||||
const titles = Array.from(handle.host.querySelectorAll('.section-head-row .section-title'))
|
||||
.map((el) => el.textContent?.trim())
|
||||
expect(titles).toEqual(['Images', 'Clips', 'Buttons', 'Audio'])
|
||||
// Default: audio list is visible.
|
||||
const audioList = handle.host.querySelector('.audio-asset')
|
||||
expect(audioList).toBeTruthy()
|
||||
// Click the Audio section header → list hidden.
|
||||
const audioHead = Array.from(handle.host.querySelectorAll('.section-head-row .section-head'))
|
||||
.find((el) => el.querySelector('.section-title')?.textContent?.trim() === 'Audio') as HTMLButtonElement
|
||||
expect(audioHead).toBeTruthy()
|
||||
audioHead.click()
|
||||
await nextTick()
|
||||
const audioRowAfter = handle.host.querySelector('.audio-asset') as HTMLElement | null
|
||||
expect(audioRowAfter).toBeTruthy()
|
||||
expect(audioRowAfter!.offsetParent).toBeNull()
|
||||
audioHead.click()
|
||||
await nextTick()
|
||||
expect((handle.host.querySelector('.audio-asset') as HTMLElement).offsetParent !== null).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('section count badge is the last element in every section row (right-aligned)', async () => {
|
||||
// Both actionless rows (Images / Audio) and action-bearing rows
|
||||
// (Clips / Buttons) must end with their count badge so the right
|
||||
// column stays visually aligned across the whole sidebar.
|
||||
try {
|
||||
localStorage.removeItem('animatr:assetSectionsCollapsed')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(AssetSidebar, {})
|
||||
await nextTick()
|
||||
const rows = handle.host.querySelectorAll('.section-head-row')
|
||||
expect(rows.length).toBe(4)
|
||||
for (const row of Array.from(rows)) {
|
||||
const last = row.lastElementChild as HTMLElement
|
||||
expect(last.classList.contains('section-count')).toBe(true)
|
||||
}
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('Library label is gone; "Thumbs" slide switch sits in the Assets header', async () => {
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(AssetSidebar, {})
|
||||
await nextTick()
|
||||
// Nothing in the sidebar still says "Library".
|
||||
expect(handle.host.textContent?.includes('Library')).toBe(false)
|
||||
// The slide switch lives in `.assets-header` next to the <h3> —
|
||||
// pin that structure so the layout doesn't drift back.
|
||||
const header = handle.host.querySelector('.assets-header')
|
||||
expect(header).toBeTruthy()
|
||||
const h3 = header!.querySelector('h3')
|
||||
expect(h3?.textContent?.trim()).toBe('Assets')
|
||||
const label = header!.querySelector('.thumbs-switch .thumbs-switch-label')
|
||||
expect(label?.textContent?.trim()).toBe('Thumbs')
|
||||
// And the underlying checkbox input toggles showThumbs.
|
||||
const input = header!.querySelector('.thumbs-switch-input') as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
const initial = input.checked
|
||||
input.checked = !initial
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
// Re-read from a fresh query — the binding updates the checkbox
|
||||
// via its `:checked` attribute on re-render.
|
||||
const input2 = handle.host.querySelector('.thumbs-switch-input') as HTMLInputElement
|
||||
expect(input2.checked).toBe(!initial)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('dragstart on an audio row sets the audio-asset DnD payload', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 1 })
|
||||
@@ -6009,6 +6096,54 @@ export function registerAllTests(): void {
|
||||
expect(created[0].paused).toBe(true)
|
||||
})
|
||||
|
||||
test('getWaveformSlot returns pending the first time, caches subsequent calls', () => {
|
||||
clearWaveformCache()
|
||||
// Un-decodable src — the promise rejects, but the immediate
|
||||
// synchronous return is what we're testing here.
|
||||
const slot1 = getWaveformSlot('asset-x', 'not-a-real-url')
|
||||
expect(slot1?.state).toBe('pending')
|
||||
// Second call while still pending returns the same pending slot
|
||||
// (no new decode kicked off).
|
||||
const slot2 = getWaveformSlot('asset-x', 'not-a-real-url')
|
||||
expect(slot2).toBe(slot1)
|
||||
clearWaveformCache()
|
||||
})
|
||||
|
||||
test('slicePeaksForClip returns only the audible range when trimmed', () => {
|
||||
const full = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
|
||||
// Asset is 8s long; trimStart=2 → drop first 25%; trimEnd=2 →
|
||||
// drop last 25%. Expect roughly the middle half of the peaks.
|
||||
const slice = slicePeaksForClip(full, 8, 2, 2)
|
||||
expect(slice.length < full.length).toBe(true)
|
||||
// Every value in the slice must be within the middle portion.
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
expect(slice[i] >= 0.3 - 1e-6 && slice[i] <= 0.6 + 1e-6).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('slicePeaksForClip with no trim returns the whole array', () => {
|
||||
const full = new Float32Array([0.1, 0.2, 0.3])
|
||||
const slice = slicePeaksForClip(full, 3, 0, 0)
|
||||
expect(slice.length).toBe(full.length)
|
||||
})
|
||||
|
||||
test('drawWaveform paints something onto the canvas (no throw on empty)', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 50
|
||||
canvas.height = 20
|
||||
// Non-empty peaks — expect SOME non-zero pixels after drawing.
|
||||
drawWaveform(canvas, new Float32Array([0.5, 0.8, 0.3]), { pixelRatio: 1 })
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
|
||||
let nonZero = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) nonZero++
|
||||
}
|
||||
expect(nonZero > 0).toBe(true)
|
||||
// And a zero-length peaks array must not throw.
|
||||
drawWaveform(canvas, new Float32Array(0))
|
||||
})
|
||||
|
||||
test('useAudioRuntime composable wires/unwires cleanly inside a component lifecycle', async () => {
|
||||
// The Vue wrapper is a thin shell over createAudioRuntime — mount
|
||||
// it via a harness component and expect no throw at mount /
|
||||
|
||||
@@ -63,8 +63,10 @@ export const en = {
|
||||
sidebar: {
|
||||
addFile: 'Add image or audio',
|
||||
library: 'Library',
|
||||
images: 'Images',
|
||||
thumbnailsHide: 'Hide thumbnails',
|
||||
thumbnailsShow: 'Show thumbnails',
|
||||
thumbsToggle: 'Thumbs',
|
||||
imageLabel: 'Image',
|
||||
addLayerTooltip: 'Insert as layer (or drag to stage)',
|
||||
noImages: 'No images imported',
|
||||
|
||||
Reference in new issue
Block a user