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
+586 -38

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.10.1",
"version": "0.10.2",
"type": "module",
"scripts": {
"dev": "vite",
+233 -35
View File
@@ -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"
>
<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;
+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 {
+127
View File
@@ -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)
}
}
+135
View File
@@ -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 /
+2
View File
@@ -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',