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:
1 parent
b9f2ba9e47
commit
04f867547d
14 files changed
+2057
-42
No files matched your search
+829
-15
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user