0.10.0: audio tracks — assets, per-scene lanes, editor + export playback

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 11:53:41 +02:00
1 parent b9f2ba9e47
commit 04f867547d
14 files changed
+2057 -42

No files matched your search

+829 -15
View File
@@ -43,6 +43,10 @@ import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue'
import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
@@ -1055,6 +1059,54 @@ export function registerAllTests(): void {
expect(html).toContain('ruwio-runtime.js')
expect(html.indexOf('animatr-runtime.js')).toBe(-1)
})
test('generated scene script strips data-URL audio assets and warns', () => {
// Exports carry a single scene over the wire — embedding a data-URL
// mp3 in that payload would balloon the bundle. `jsonForExport`
// empties the src so the runtime still has the asset record (for
// name / duration lookups by audio clips) but won't try to load
// unshippable data.
const s = makeFreshStore()
s.addAudioAsset({ name: 'song', src: 'data:audio/mp3;base64,AAAA', duration: 4 })
let warned = false
const originalWarn = console.warn
console.warn = (...args: unknown[]) => {
const msg = args.map(String).join(' ')
if (msg.toLowerCase().includes('audio')) warned = true
}
try {
const sceneScript = generateSceneScript(s.scene)
expect(sceneScript.indexOf('data:audio/mp3')).toBe(-1)
// The asset record itself IS still present (its id + name + duration),
// just with an emptied src.
expect(sceneScript.indexOf('"kind":"audio"')).toBeGreaterThan(-1)
expect(warned).toBe(true)
} finally {
console.warn = originalWarn
}
})
test('generated scene script passes through external-URL audio intact', () => {
const s = makeFreshStore()
const ext = 'https://cdn.example.com/intro.mp3'
s.addAudioAsset({ name: 'intro', src: ext, duration: 3 })
const sceneScript = generateSceneScript(s.scene)
expect(sceneScript).toContain(ext)
})
test('opening the fullscreen preview pauses the editor playback', async () => {
// Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback.
const s = makeFreshStore()
s.play()
expect(s.playing).toBe(true)
const handle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
handle.unmount()
})
})
suite('playback — transport', () => {
@@ -1282,12 +1334,11 @@ export function registerAllTests(): void {
expect(s.scene.layers[0].id).toBe(l2.id)
})
test('switchScene clears editor context (selection, clip-edit, drawing) and auto-plays', () => {
test('switchScene clears editor context (selection, clip-edit, drawing)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
s.pause()
const clip = s.addClip('C')
s.activeClipId = clip.id
const two = s.addScene()
@@ -1297,25 +1348,26 @@ export function registerAllTests(): void {
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.project.activeSceneId).toBe(two.id)
// Switching always auto-plays on the new page — even if the
// editor was paused beforehand.
expect(s.playing).toBe(true)
})
test('switchScene auto-plays even when the editor was paused', () => {
test('switchScene leaves playback state alone — editor navigation does not force play', () => {
// The editor treats scene-switch as a navigation gesture. The
// fullscreen preview is the one that auto-plays on switch (via
// its activeSceneId watcher). If this contract regresses, the
// user would find drawer clicks silently starting playback.
const s = makeFreshStore()
s.pause()
const second = s.addScene('Two')
const firstId = s.project.scenes[0].id
// Paused → stays paused across a switch.
s.pause()
expect(s.playing).toBe(false)
s.switchScene(firstId)
expect(s.playing).toBe(true)
expect(s.playing).toBe(false)
expect(s.project.activeSceneId).toBe(firstId)
// And the inverse direction — Two plays too.
s.pause()
// Playing → stays playing.
s.play()
s.switchScene(second.id)
expect(s.playing).toBe(true)
expect(s.project.activeSceneId).toBe(second.id)
})
test('switchScene returns false for an unknown id and leaves state alone', () => {
@@ -3836,6 +3888,44 @@ export function registerAllTests(): void {
expect(overflowY).toBe('hidden')
handle.unmount()
})
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
// The tile grid is gated on `clipart.handle` (real folder-picker
// handle in prod). A truthy stub is enough for the template to
// skip past the empty-state branches.
s.supported = true
s.handle = {} as unknown as FileSystemDirectoryHandle
s.folderName = 'stub'
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
s.items = [
{ name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio', kind: 'audio' },
{ name: 'img.png', file: mockFile, url: 'blob:dummy-img', kind: 'image' },
]
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const tiles = handle.host.querySelectorAll('.tile')
expect(tiles.length).toBe(2)
const audioTile = handle.host.querySelector('.tile.audio-tile')
expect(audioTile).toBeTruthy()
// The audio tile uses an icon wrapper instead of an <img>.
expect(audioTile!.querySelector('.audio-icon')).toBeTruthy()
expect(audioTile!.querySelector('img')).toBeNull()
// The label still carries the filename.
const label = audioTile!.querySelector('.tile-label')
expect(label?.textContent?.trim()).toBe('ding.mp3')
// Clean up the stub state so later tests get a fresh store —
// especially `open`: without closing the drawer, the main app's
// ClipartDrawer stays visually expanded underneath subsequent
// mountComponent calls, which can lead to confusing stale UI.
s.items = []
s.handle = null
s.folderName = null
s.setOpen(false)
handle.unmount()
})
})
suite('ui — SceneDrawer', () => {
@@ -3890,21 +3980,23 @@ export function registerAllTests(): void {
handle.unmount()
})
test('clicking a non-active row switches to that page and auto-plays', async () => {
test('clicking a non-active row switches to that page without forcing play', async () => {
// In the editor, flipping scenes is pure navigation. Auto-play is
// reserved for the fullscreen preview — the drawer must not start
// playback on its own, otherwise the user's paused state dies
// behind their back.
const s = makeFreshStore()
const firstId = s.project.activeSceneId
const second = s.addScene('Two')
// Go back to Scene 1 and pause so we can observe the auto-play flip.
s.switchScene(firstId)
s.pause()
const handle = mountComponent(SceneDrawer, {})
await nextTick()
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
// Row index 1 = Two (non-active).
rows[1].click()
await nextTick()
expect(s.project.activeSceneId).toBe(second.id)
expect(s.playing).toBe(true)
expect(s.playing).toBe(false)
handle.unmount()
})
@@ -4051,6 +4143,323 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('FX')
s.addAudioClip(asset.id, 0, track.id)
s.addAudioClip(asset.id, 3, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const lanes = handle.host.querySelectorAll('.audio-track')
expect(lanes.length).toBe(1)
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block')
expect(blocks.length).toBe(2)
handle.unmount()
})
test('audio-clip-block left position scales with startTime (not hard-coded pps)', async () => {
// The timeline's px-per-second varies with zoom / fit — avoid
// pinning a specific pixel value and only assert the ordering:
// a later startTime produces a larger `left`, a startTime of 0
// sits flush at 0.
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'x', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack('A')
s.addAudioClip(asset.id, 0, track.id)
s.addAudioClip(asset.id, 3, track.id)
const handle = mountComponent(Timeline, {})
await nextTick()
const blocks = Array.from(
handle.host.querySelectorAll('.audio-track .audio-clip-block'),
) as HTMLElement[]
expect(blocks).toHaveLength(2)
const left0 = parseFloat(blocks[0].style.left)
const left3 = parseFloat(blocks[1].style.left)
expect(left0).toBe(0)
expect(left3 > 0).toBe(true)
handle.unmount()
})
test('muting a track adds the .muted class', async () => {
const s = makeFreshStore()
const track = s.addAudioTrack('A')
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track') as HTMLElement
expect(lane.classList.contains('muted')).toBe(false)
s.setAudioTrackMuted(track.id, true)
await nextTick()
expect(lane.classList.contains('muted')).toBe(true)
handle.unmount()
})
test('audio track co-exists with other track kinds in the tracks container', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id, 'layer-one')
s.addAudioTrack('A1')
const handle = mountComponent(Timeline, {})
await nextTick()
// At least two rows in the tracks column; one of them is the audio lane.
const rows = handle.host.querySelectorAll('.tracks .track')
expect(rows.length >= 2).toBe(true)
expect(handle.host.querySelector('.audio-track')).toBeTruthy()
handle.unmount()
})
test('pointer-drag on an audio-clip-block moves the clip in time', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
const track = s.addAudioTrack('T')
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
expect(block).toBeTruthy()
// The harness doesn't do layout, so we pin the component's pps
// by reading the rendered left value before the drag (startTime=1
// maps to `pps` pixels). That pins the conversion both ways.
const leftBefore = parseFloat(block.style.left)
const pps = leftBefore // because startTime was 1s
expect(pps > 0).toBe(true)
// Patch setPointerCapture / releasePointerCapture / hasPointerCapture
// on the block so pointer capture calls don't throw in the test
// harness (elements aren't attached in a way that supports it).
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
const startX = 200
const down = new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX, clientY: 10,
})
block.dispatchEvent(down)
// Drag right by 2*pps px → 2s forward; new startTime should be ~3s.
const move = new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
})
block.dispatchEvent(move)
const up = new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 2 * pps, clientY: 10,
})
block.dispatchEvent(up)
await nextTick()
expect(Math.abs(clip.startTime - 3) < 0.05).toBe(true)
handle.unmount()
})
test('dragging clamps startTime at 0 (no negative positions)', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 5 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 1, track.id)!
const handle = mountComponent(Timeline, {})
await nextTick()
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
const captured = new Set<number>()
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
block.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 500 }),
)
block.dispatchEvent(
// Big leftward drag — way past the left edge.
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
block.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
await nextTick()
expect(clip.startTime).toBe(0)
handle.unmount()
})
test('drag stops at the neighbour\'s edge when the move would overlap', async () => {
// Two clips on the same track: A at t=0 (length 2), B at t=5.
// Drag B leftward; it must not be pushed into A's 0..2 window.
// The last valid position is startTime=2 (exactly abutting A).
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id) // A
const b = s.addAudioClip(asset.id, 5, track.id)! // B
const handle = mountComponent(Timeline, {})
await nextTick()
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
expect(blocks.length).toBe(2)
const blockB = blocks[1]
const captured = new Set<number>()
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(blockB as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(blockB as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
blockB.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 500 }),
)
// Big leftward drag — would end up at some negative position if
// unconstrained, but overlap with A blocks further movement.
blockB.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
blockB.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
)
await nextTick()
// B never entered A's window; it stayed at its last valid pos
// (≥ 2). Depending on the number of move events (just one here),
// it'll actually have stopped at 5 since the first evaluated
// newStart would already collide. Either way: no overlap.
expect(b.startTime >= 2 - 1e-9).toBe(true)
// And most importantly: A stayed put.
expect(s.activeScene.audioClips.find((c) => c.id !== b.id)!.startTime).toBe(0)
handle.unmount()
})
// Helper: pin the audio-lane's bounding rect to a known shape AND
// compute clientX values from the live `scene.duration` so our t
// targets are immune to whatever `duration.value` resolves to in
// the test harness. pixelTimeFromEvent uses `ratio * duration`, so
// clientX = (targetT / duration) * width gives `t ≈ targetT`.
function stubAudioLane(
lane: HTMLElement,
width = 1000,
): { timeToX: (t: number, duration: number) => number } {
const fakeRect = {
left: 0, top: 0, width, height: 20,
right: width, bottom: 20, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
fakeRect
return {
timeToX: (t, duration) => Math.round((t / duration) * width),
}
}
test('drop on a free spot adds a new clip to the lane', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id) // occupies t=0..2
expect(s.activeScene.audioClips).toHaveLength(1)
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
const { timeToX } = stubAudioLane(lane)
const dt = new DataTransfer()
dt.setData('application/x-animatr-audio-asset', asset.id)
const drop = new DragEvent('drop', {
bubbles: true, cancelable: true, dataTransfer: dt,
clientX: timeToX(5, s.scene.duration), clientY: 10,
})
lane.dispatchEvent(drop)
await nextTick()
expect(s.activeScene.audioClips).toHaveLength(2)
const newClip = s.activeScene.audioClips[1]
expect(Math.abs(newClip.startTime - 5) < 0.1).toBe(true)
handle.unmount()
})
test('drop that would overlap an existing clip is silently refused', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 3 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 1, track.id) // occupies t=1..4
expect(s.activeScene.audioClips).toHaveLength(1)
const handle = mountComponent(Timeline, {})
await nextTick()
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
const { timeToX } = stubAudioLane(lane)
const dt = new DataTransfer()
dt.setData('application/x-animatr-audio-asset', asset.id)
// t=2 lands in the middle of the existing clip.
const drop = new DragEvent('drop', {
bubbles: true, cancelable: true, dataTransfer: dt,
clientX: timeToX(2, s.scene.duration), clientY: 10,
})
lane.dispatchEvent(drop)
await nextTick()
expect(s.activeScene.audioClips).toHaveLength(1)
handle.unmount()
})
})
suite('ui — AssetSidebar audio section', () => {
test('lists audio assets with a duration label under the Audio heading', async () => {
const s = makeFreshStore()
s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 2.75 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const audioRows = handle.host.querySelectorAll('.audio-asset')
expect(audioRows.length).toBe(1)
const sub = audioRows[0].querySelector('.clip-sub')
expect(sub?.textContent?.trim()).toBe('2.75s')
handle.unmount()
})
test('empty state shows the "no audio imported" placeholder', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const empties = Array.from(handle.host.querySelectorAll('.empty')).map((el) =>
el.textContent?.trim(),
)
expect(empties.some((txt) => (txt ?? '').toLowerCase().includes('audio'))).toBe(true)
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 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const row = handle.host.querySelector('.audio-asset') as HTMLElement
expect(row).toBeTruthy()
const dt = new DataTransfer()
const ev = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })
row.dispatchEvent(ev)
expect(dt.getData('application/x-animatr-audio-asset')).toBe(asset.id)
handle.unmount()
})
test('dragover with OS files toggles the .drop-hot outline on the panel', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const panel = handle.host.querySelector('.panel') as HTMLElement
expect(panel.classList.contains('drop-hot')).toBe(false)
const dt = new DataTransfer()
// `Files` is the well-known type DataTransfer.types reports for
// OS-initiated file drags. No need to actually attach a file for
// the dragover — the handler only sniffs `types`.
dt.items.add(new File(['x'], 'mock.mp3', { type: 'audio/mpeg' }))
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })
panel.dispatchEvent(over)
await nextTick()
expect(panel.classList.contains('drop-hot')).toBe(true)
const leave = new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt })
panel.dispatchEvent(leave)
await nextTick()
expect(panel.classList.contains('drop-hot')).toBe(false)
handle.unmount()
})
})
suite('polygon — vertex bezier actions', () => {
function makeSquare() {
const s = makeFreshStore()
@@ -4966,6 +5375,411 @@ export function registerAllTests(): void {
})
})
suite('scene store — audio model', () => {
function addAsset(s: ReturnType<typeof makeFreshStore>, name = 'beep', duration = 4) {
return s.addAudioAsset({ name, src: 'data:audio/mp3;base64,AAAA', duration })
}
test('addAudioAsset pushes an asset of kind "audio" into the shared library', () => {
const s = makeFreshStore()
const asset = addAsset(s, ' Intro ', 3.5)
expect(asset.kind).toBe('audio')
expect(asset.name).toBe('Intro') // name is trimmed
expect(asset.duration).toBe(3.5)
expect(s.project.assets.some((a) => a.id === asset.id)).toBe(true)
})
test('addAudioAsset clamps a negative duration to 0', () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'weird', src: 'x', duration: -5 })
expect(asset.duration).toBe(0)
})
test('addAudioTrack appends and lands in trackOrder', () => {
const s = makeFreshStore()
const t = s.addAudioTrack('Music')
expect(s.activeScene.audioTracks).toHaveLength(1)
expect(s.activeScene.audioTracks[0].name).toBe('Music')
expect(s.activeScene.trackOrder.includes(t.id)).toBe(true)
})
test('removeAudioTrack cascades its clips and trackOrder entry', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t = s.addAudioTrack()
s.addAudioClip(asset.id, 1, t.id)
s.addAudioClip(asset.id, 3, t.id)
expect(s.activeScene.audioClips).toHaveLength(2)
s.removeAudioTrack(t.id)
expect(s.activeScene.audioTracks).toHaveLength(0)
expect(s.activeScene.audioClips).toHaveLength(0)
expect(s.activeScene.trackOrder.includes(t.id)).toBe(false)
})
test('addAudioClip returns null for unknown asset or unknown track', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
expect(s.addAudioClip('no-such-asset', 0, t.id)).toBe(null)
const a = addAsset(s)
expect(s.addAudioClip(a.id, 0, 'no-such-track')).toBe(null)
expect(s.activeScene.audioClips).toHaveLength(0)
})
test('addAudioClip refuses to place an image asset as audio (guards kind)', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
const img = s.addAsset({
kind: 'rect',
name: 'R',
width: 10,
height: 10,
fill: '#000',
})
expect(s.addAudioClip(img.id, 0, t.id)).toBe(null)
})
test('moveAudioClip updates startTime and optional track; negative startTime clamps to 0', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t1 = s.addAudioTrack('one')
const t2 = s.addAudioTrack('two')
const c = s.addAudioClip(asset.id, 1, t1.id)!
s.moveAudioClip(c.id, -3, t2.id)
expect(c.startTime).toBe(0)
expect(c.audioTrackId).toBe(t2.id)
})
test('trimAudioClip clamps trims so the audible block keeps at least 0.05s', () => {
const s = makeFreshStore()
const asset = addAsset(s, 'x', 4) // asset length 4s
const t = s.addAudioTrack()
const c = s.addAudioClip(asset.id, 0, t.id)!
// Over-ambitious trim from BOTH ends: 3s off the head + 2s off
// the tail on a 4s asset (total 5s, way over the 3.95s budget).
// We don't pin which side absorbs the clamp — only the invariant
// that the audible window stays ≥ MIN_VISIBLE and neither trim
// turns negative.
s.trimAudioClip(c.id, { trimStart: 3, trimEnd: 2 })
const audible = asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0)
expect(audible >= 0.05 - 1e-9).toBe(true)
expect((c.trimStart ?? 0) >= 0).toBe(true)
expect((c.trimEnd ?? 0) >= 0).toBe(true)
})
test('removeAudioClip drops the clip without touching siblings', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const t = s.addAudioTrack()
const a = s.addAudioClip(asset.id, 0, t.id)!
const b = s.addAudioClip(asset.id, 2, t.id)!
s.removeAudioClip(a.id)
expect(s.activeScene.audioClips).toHaveLength(1)
expect(s.activeScene.audioClips[0].id).toBe(b.id)
})
test('removeAsset of an audio asset cascades clips across every scene page', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const firstTrack = s.addAudioTrack()
s.addAudioClip(asset.id, 0, firstTrack.id)
// Add a second scene, and place a clip there too.
const two = s.addScene('Two')
const secondTrack = s.addAudioTrack()
s.addAudioClip(asset.id, 0, secondTrack.id)
expect(s.activeScene.audioClips).toHaveLength(1)
s.removeAsset(asset.id)
// Both pages must have their clips purged, not just the active one.
for (const page of s.project.scenes) {
expect(page.audioClips.filter((c) => c.audioAssetId === asset.id).length).toBe(0)
}
void two
})
test('setAudioTrackMuted flips the muted flag', () => {
const s = makeFreshStore()
const t = s.addAudioTrack()
expect(!!t.muted).toBe(false)
s.setAudioTrackMuted(t.id, true)
expect(t.muted).toBe(true)
s.setAudioTrackMuted(t.id, false)
expect(t.muted).toBe(false)
})
test('duplicateTrack on an audio track clones clips with fresh ids and sits below the original', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const orig = s.addAudioTrack('A')
s.addAudioClip(asset.id, 0, orig.id)
s.addAudioClip(asset.id, 2, orig.id)
const origClipIds = new Set(s.activeScene.audioClips.map((c) => c.id))
const newId = s.duplicateTrack(orig.id)
expect(newId).toBeTruthy()
expect(newId === orig.id).toBe(false)
const order = s.activeScene.trackOrder
expect(order.indexOf(newId!)).toBe(order.indexOf(orig.id) + 1)
const clones = s.activeScene.audioClips.filter((c) => c.audioTrackId === newId)
expect(clones).toHaveLength(2)
for (const c of clones) expect(origClipIds.has(c.id)).toBe(false)
})
test('duplicateScene also clones audio tracks + clips with fresh cross-refs', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const track = s.addAudioTrack('A')
s.addAudioClip(asset.id, 1, track.id)
const origPageId = s.project.activeSceneId
const copy = s.duplicateScene(origPageId)!
// The clone has its own track id and its own clip id, but still
// references the (shared) asset.
expect(copy.audioTracks[0].id === track.id).toBe(false)
expect(copy.audioClips[0].audioTrackId).toBe(copy.audioTracks[0].id)
expect(copy.audioClips[0].audioAssetId).toBe(asset.id)
})
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
const s = makeFreshStore()
const asset = addAsset(s)
const orphan = {
id: 'ghost-clip',
audioAssetId: asset.id,
audioTrackId: 'ghost-track',
startTime: 0,
}
s.activeScene.audioClips.push(orphan)
// Roundtrip through loadProject re-normalises.
s.loadProject(JSON.parse(JSON.stringify(s.project)))
expect(s.activeScene.audioClips.some((c) => c.id === 'ghost-clip')).toBe(false)
})
})
suite('composable — useAudioRuntime (createAudioRuntime)', () => {
interface StubAudio {
src: string
currentTime: number
paused: boolean
preload: string
played: number
paused_count: number
play(): Promise<void>
pause(): void
}
function makeAudioFactory() {
const created: StubAudio[] = []
const factory = () => {
const el: StubAudio = {
src: '',
currentTime: 0,
paused: true,
preload: '',
played: 0,
paused_count: 0,
play() {
this.paused = false
this.played++
return Promise.resolve()
},
pause() {
if (!this.paused) this.paused_count++
this.paused = true
},
}
created.push(el)
return el as unknown as HTMLAudioElement
}
return { factory, created }
}
test('play → the overlapping clip\'s element gets play() and a seek to audioTime', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack('T')
s.addAudioClip(asset.id, 2, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(3) // inside the 2..7 window
s.play()
await tick()
rt.reconcile()
expect(created.length).toBe(1)
const el = created[0]
expect(el.paused).toBe(false)
expect(el.played).toBe(1)
// audioTime = playhead - startTime + trimStart = 3 - 2 + 0 = 1
expect(Math.abs(el.currentTime - 1) < 0.01).toBe(true)
rt.dispose()
})
test('pause → all currently-playing elements get pause()', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(created[0].paused).toBe(false)
s.pause()
await tick()
expect(created[0].paused).toBe(true)
rt.dispose()
})
test('playhead outside a clip\'s window keeps its element paused', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 3 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 4, track.id) // audible 4..7
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1) // before the clip
s.play()
await tick()
rt.reconcile()
// Element exists (pool seeded) but hasn't been play()'d.
expect(created.length).toBe(1)
expect(created[0].played).toBe(0)
expect(created[0].paused).toBe(true)
rt.dispose()
})
test('muting a track keeps its elements paused even while playing', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
s.setAudioTrackMuted(track.id, true)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1)
s.play()
await tick()
rt.reconcile()
expect(created[0].played).toBe(0)
// Un-mute → element plays on next reconcile.
s.setAudioTrackMuted(track.id, false)
await tick()
rt.reconcile()
expect(created[0].played).toBe(1)
rt.dispose()
})
test('trimStart offsets audioTime by exactly trimStart seconds', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 10 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
s.trimAudioClip(clip.id, { trimStart: 2 })
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.setPlayhead(1) // 1s into the clip from the timeline's POV
s.play()
await tick()
rt.reconcile()
// audioTime = playhead - startTime + trimStart = 1 - 0 + 2 = 3
expect(Math.abs(created[0].currentTime - 3) < 0.01).toBe(true)
rt.dispose()
})
test('removing a clip evicts its element from the pool', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
const clip = s.addAudioClip(asset.id, 0, track.id)!
const { factory } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.has(clip.id)).toBe(true)
s.removeAudioClip(clip.id)
await tick()
rt.reconcile()
expect(rt.pool.has(clip.id)).toBe(false)
rt.dispose()
})
test('switching scenes drops elements whose clips left with the old page', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const trackA = s.addAudioTrack('A')
const clipA = s.addAudioClip(asset.id, 0, trackA.id)!
const { factory } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.has(clipA.id)).toBe(true)
s.addScene('Two') // auto-switches; new page has no audio clips yet
await tick()
rt.reconcile()
expect(rt.pool.has(clipA.id)).toBe(false)
expect(rt.pool.size).toBe(0)
rt.dispose()
})
test('dispose() pauses every element and empties the pool', async () => {
const s = makeFreshStore()
s.setDuration(10)
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;X', duration: 5 })
const track = s.addAudioTrack()
s.addAudioClip(asset.id, 0, track.id)
const { factory, created } = makeAudioFactory()
const rt = createAudioRuntime(s, { audioFactory: factory })
s.play()
await tick()
rt.reconcile()
expect(rt.pool.size).toBe(1)
rt.dispose()
expect(rt.pool.size).toBe(0)
expect(created[0].paused).toBe(true)
})
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 /
// unmount. This is the integration-level check that the
// onBeforeUnmount → dispose hook is actually reached.
const { defineComponent } = await import('vue')
const { useAudioRuntime } = await import('@/composables/useAudioRuntime')
const factory = () => {
const el = {
src: '',
currentTime: 0,
paused: true,
preload: '',
play() {
this.paused = false
return Promise.resolve()
},
pause() {
this.paused = true
},
}
return el as unknown as HTMLAudioElement
}
const Harness = defineComponent({
setup() {
useAudioRuntime({ audioFactory: factory })
return () => h('div', { 'data-test-audio-host': '' })
},
})
const handle = mountComponent(Harness, {})
await nextTick()
expect(handle.host.querySelector('[data-test-audio-host]')).toBeTruthy()
// Unmount should not throw even if nothing in the pool.
handle.unmount()
})
})
suite('actions — evalAction with mock runtime', () => {
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
calls: string[]