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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.1",
|
"version": "0.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import ruwioLogo from './assets/logo.png'
|
|||||||
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
import { useHistory } from './composables/useHistory'
|
import { useHistory } from './composables/useHistory'
|
||||||
|
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||||
import {
|
import {
|
||||||
ProjectFileError,
|
ProjectFileError,
|
||||||
@@ -140,6 +141,7 @@ const currentProjectName = ref<string | null>(null)
|
|||||||
const isDirty = ref(false)
|
const isDirty = ref(false)
|
||||||
|
|
||||||
usePlaybackLoop()
|
usePlaybackLoop()
|
||||||
|
useAudioRuntime()
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PhImage,
|
PhImage,
|
||||||
PhImageSquare,
|
PhImageSquare,
|
||||||
PhListBullets,
|
PhListBullets,
|
||||||
|
PhMusicNote,
|
||||||
PhPencilSimple,
|
PhPencilSimple,
|
||||||
PhPencilSimpleLine,
|
PhPencilSimpleLine,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||||
import { importImageFile } from '@/lib/fileLoader'
|
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
||||||
import type { Asset } from '@/types/scene'
|
import type { Asset } from '@/types/scene'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
||||||
@@ -245,9 +246,57 @@ async function handleFileSelected(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importFile(file: File) {
|
async function importFile(file: File) {
|
||||||
|
if (isAudioFile(file)) {
|
||||||
|
await importAudioFile(store, file)
|
||||||
|
return
|
||||||
|
}
|
||||||
await importImageFile(store, file)
|
await importImageFile(store, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- OS-file drop target ----
|
||||||
|
|
||||||
|
const dropHot = ref(false)
|
||||||
|
// The browser fires dragleave on child transitions as well — debounce
|
||||||
|
// by tracking enter depth so the outline doesn't flicker mid-drop.
|
||||||
|
let dragDepth = 0
|
||||||
|
|
||||||
|
function panelAcceptsDrop(e: DragEvent): boolean {
|
||||||
|
if (store.activeButtonEditMode === 'hit') return false
|
||||||
|
const dt = e.dataTransfer
|
||||||
|
if (!dt) return false
|
||||||
|
// Only activate for real filesystem drops — internal Animatr DnD
|
||||||
|
// (clips, audio assets, layer references) has its own handlers.
|
||||||
|
return Array.from(dt.types).includes('Files')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanelDragOver(e: DragEvent) {
|
||||||
|
if (!panelAcceptsDrop(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||||
|
dragDepth++
|
||||||
|
dropHot.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanelDragLeave() {
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1)
|
||||||
|
if (dragDepth === 0) dropHot.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPanelDrop(e: DragEvent) {
|
||||||
|
if (!panelAcceptsDrop(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth = 0
|
||||||
|
dropHot.value = false
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? [])
|
||||||
|
if (!files.length) return
|
||||||
|
uploadError.value = null
|
||||||
|
try {
|
||||||
|
for (const file of files) await importFile(file)
|
||||||
|
} catch (err) {
|
||||||
|
uploadError.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerUpload() {
|
function triggerUpload() {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
@@ -259,12 +308,39 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
|||||||
text: PhTextT,
|
text: PhTextT,
|
||||||
polygon: PhPolygon,
|
polygon: PhPolygon,
|
||||||
button: PhCursorClick,
|
button: PhCursorClick,
|
||||||
|
audio: PhMusicNote,
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageAssets = computed(() =>
|
const imageAssets = computed(() =>
|
||||||
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
|
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const audioAssets = computed(() =>
|
||||||
|
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'audio' }> => a.kind === 'audio'),
|
||||||
|
)
|
||||||
|
|
||||||
|
function onAudioAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'audio' }>) {
|
||||||
|
if (!e.dataTransfer) return
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
// Matches Timeline's AUDIO_CLIP_DND_MIME — dropping on an audio lane
|
||||||
|
// creates a new AudioClip referencing this asset.
|
||||||
|
e.dataTransfer.setData('application/x-animatr-audio-asset', asset.id)
|
||||||
|
e.dataTransfer.setData('text/plain', asset.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAudioAsset(id: string) {
|
||||||
|
const asset = audioAssets.value.find((a) => a.id === id)
|
||||||
|
if (!asset) return
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('sidebar.deleteAudioDialog.title'),
|
||||||
|
message: t('sidebar.deleteAudioDialog.message', { name: asset.name }),
|
||||||
|
confirmLabel: t('sidebar.deleteAudioDialog.confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
store.removeAsset(id)
|
||||||
|
}
|
||||||
|
|
||||||
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
|
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
|
||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
@@ -301,8 +377,11 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="panel"
|
class="panel"
|
||||||
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit' }"
|
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit', 'drop-hot': dropHot }"
|
||||||
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
|
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
|
||||||
|
@dragover="onPanelDragOver"
|
||||||
|
@dragleave="onPanelDragLeave"
|
||||||
|
@drop="onPanelDrop"
|
||||||
>
|
>
|
||||||
<h3>Assets</h3>
|
<h3>Assets</h3>
|
||||||
|
|
||||||
@@ -340,7 +419,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif"
|
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif,audio/*,.mp3,.wav,.ogg,.m4a"
|
||||||
multiple
|
multiple
|
||||||
style="display: none"
|
style="display: none"
|
||||||
@change="handleFileSelected"
|
@change="handleFileSelected"
|
||||||
@@ -483,6 +562,37 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
</li>
|
</li>
|
||||||
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
|
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="lib-header clips-header">
|
||||||
|
<h4>{{ t('sidebar.audio') }}</h4>
|
||||||
|
</div>
|
||||||
|
<ul class="clips">
|
||||||
|
<li
|
||||||
|
v-for="asset in audioAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
class="clip audio-asset"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onAudioAssetDragStart($event, asset)"
|
||||||
|
>
|
||||||
|
<div class="clip-icon">
|
||||||
|
<PhMusicNote :size="16" />
|
||||||
|
</div>
|
||||||
|
<div class="clip-meta">
|
||||||
|
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
|
||||||
|
<span class="clip-sub">{{ asset.duration.toFixed(2) }}s</span>
|
||||||
|
</div>
|
||||||
|
<div class="clip-actions">
|
||||||
|
<button
|
||||||
|
class="clip-btn danger"
|
||||||
|
v-tooltip="t('sidebar.deleteTooltip')"
|
||||||
|
@click.stop="deleteAudioAsset(asset.id)"
|
||||||
|
>
|
||||||
|
<PhTrash :size="11" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="!audioAssets.length" class="empty">{{ t('sidebar.noAudio') }}</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -500,6 +610,11 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
filter: saturate(0.5);
|
filter: saturate(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel.drop-hot {
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
outline-offset: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 2px;
|
margin: 0 0 2px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted } from 'vue'
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhImagesSquare } from '@phosphor-icons/vue'
|
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@@ -21,6 +21,11 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
||||||
e.dataTransfer.setData('text/plain', item.name)
|
e.dataTransfer.setData('text/plain', item.name)
|
||||||
|
if (item.kind === 'audio') {
|
||||||
|
// No image ghost for audio — browsers render the row itself as the
|
||||||
|
// default drag preview, which is what we want.
|
||||||
|
return
|
||||||
|
}
|
||||||
// Use the actual image as drag ghost
|
// Use the actual image as drag ghost
|
||||||
const ghost = document.createElement('img')
|
const ghost = document.createElement('img')
|
||||||
ghost.src = item.url
|
ghost.src = item.url
|
||||||
@@ -83,11 +88,15 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
v-for="(item, i) in clipart.items"
|
v-for="(item, i) in clipart.items"
|
||||||
:key="item.name + i"
|
:key="item.name + i"
|
||||||
class="tile"
|
class="tile"
|
||||||
|
:class="{ 'audio-tile': item.kind === 'audio' }"
|
||||||
v-tooltip="item.name"
|
v-tooltip="item.name"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onItemDragStart($event, i)"
|
@dragstart="onItemDragStart($event, i)"
|
||||||
>
|
>
|
||||||
<img :src="item.url" :alt="item.name" />
|
<img v-if="item.kind === 'image'" :src="item.url" :alt="item.name" />
|
||||||
|
<div v-else class="audio-icon">
|
||||||
|
<PhMusicNote :size="28" weight="regular" />
|
||||||
|
</div>
|
||||||
<span class="tile-label">{{ item.name }}</span>
|
<span class="tile-label">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +310,24 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #0ea5e9;
|
||||||
|
background: color-mix(in srgb, #0ea5e9 12%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.audio-tile {
|
||||||
|
/* Same 1fr grid slot but the content is smaller — keep the tile the
|
||||||
|
same size as an image tile so the grid stays on a clean rhythm. */
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.tile-label {
|
.tile-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ async function startPreview() {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
const el = frameEl.value
|
const el = frameEl.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
// Silence the editor's own playback/audio while the preview owns the
|
||||||
|
// stage. Otherwise the two runtimes fight — editor audio pool +
|
||||||
|
// preview audio pool would both emit the same mp3s at once.
|
||||||
|
store.pause()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
showControls.value = false
|
showControls.value = false
|
||||||
const start = rangeIn.value
|
const start = rangeIn.value
|
||||||
|
|||||||
+465
-3
@@ -19,15 +19,19 @@ import {
|
|||||||
PhMaskHappy,
|
PhMaskHappy,
|
||||||
PhCircle,
|
PhCircle,
|
||||||
PhImage,
|
PhImage,
|
||||||
|
PhMusicNote,
|
||||||
PhPauseCircle,
|
PhPauseCircle,
|
||||||
PhPencilSimpleLine,
|
PhPencilSimpleLine,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
PhPolygon,
|
PhPolygon,
|
||||||
|
PhSpeakerSlash,
|
||||||
PhSquare,
|
PhSquare,
|
||||||
PhTextT,
|
PhTextT,
|
||||||
PhTrash,
|
PhTrash,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
|
import { importAudioFile } from '@/lib/fileLoader'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
@@ -38,6 +42,8 @@ import { showLocked } from '@/composables/useShowLocked'
|
|||||||
import type {
|
import type {
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Asset,
|
Asset,
|
||||||
|
AudioAsset,
|
||||||
|
AudioClip,
|
||||||
Clip,
|
Clip,
|
||||||
ClipInstance,
|
ClipInstance,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -92,13 +98,25 @@ interface TimelineScriptTrack {
|
|||||||
color: string | null
|
color: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineAudioTrack {
|
||||||
|
type: 'audio'
|
||||||
|
audioTrackId: string
|
||||||
|
name: string
|
||||||
|
orderIndex: number
|
||||||
|
locked: boolean
|
||||||
|
muted: boolean
|
||||||
|
color: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type TimelineTrack =
|
type TimelineTrack =
|
||||||
| TimelineLayerTrack
|
| TimelineLayerTrack
|
||||||
| TimelineClipTrack
|
| TimelineClipTrack
|
||||||
| TimelineGroupTrack
|
| TimelineGroupTrack
|
||||||
| TimelineScriptTrack
|
| TimelineScriptTrack
|
||||||
|
| TimelineAudioTrack
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
const clipart = useClipartStore()
|
||||||
|
|
||||||
const duration = computed(() =>
|
const duration = computed(() =>
|
||||||
store.activeButtonAssetId ? 5 : store.activeDuration,
|
store.activeButtonAssetId ? 5 : store.activeDuration,
|
||||||
@@ -162,6 +180,25 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
const audioTrackById = computed(() => {
|
||||||
|
const m = new Map<string, NonNullable<typeof store.scene.audioTracks>[number]>()
|
||||||
|
for (const t of store.scene.audioTracks ?? []) m.set(t.id, t)
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
const audioClipsByTrack = computed<Record<string, AudioClip[]>>(() => {
|
||||||
|
const out: Record<string, AudioClip[]> = {}
|
||||||
|
for (const c of store.scene.audioClips ?? []) {
|
||||||
|
;(out[c.audioTrackId] ||= []).push(c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
const audioAssetById = computed(() => {
|
||||||
|
const m = new Map<string, AudioAsset>()
|
||||||
|
for (const a of store.scene.assets) {
|
||||||
|
if (a.kind === 'audio') m.set(a.id, a)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
// Bump the storage key so clients with an old (narrower) stored value pick
|
// Bump the storage key so clients with an old (narrower) stored value pick
|
||||||
// up the new default that matches the assets-sidebar column width (260px),
|
// up the new default that matches the assets-sidebar column width (260px),
|
||||||
// which keeps the timeline track-name column visually aligned with it.
|
// which keeps the timeline track-name column visually aligned with it.
|
||||||
@@ -737,6 +774,19 @@ const tracks = computed<TimelineTrack[]>(() => {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const at = audioTrackById.value.get(id)
|
||||||
|
if (at) {
|
||||||
|
out.push({
|
||||||
|
type: 'audio',
|
||||||
|
audioTrackId: at.id,
|
||||||
|
name: at.name ?? t('timeline.audioTrackName'),
|
||||||
|
orderIndex,
|
||||||
|
locked: !!at.locked,
|
||||||
|
muted: !!at.muted,
|
||||||
|
color: at.color ?? null,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const grp = groupById.value.get(id)
|
const grp = groupById.value.get(id)
|
||||||
if (grp) {
|
if (grp) {
|
||||||
out.push({
|
out.push({
|
||||||
@@ -795,6 +845,9 @@ const tracks = computed<TimelineTrack[]>(() => {
|
|||||||
if (tr.type === 'script') {
|
if (tr.type === 'script') {
|
||||||
return !tr.locked
|
return !tr.locked
|
||||||
}
|
}
|
||||||
|
if (tr.type === 'audio') {
|
||||||
|
return !tr.locked
|
||||||
|
}
|
||||||
// group: hide when locked
|
// group: hide when locked
|
||||||
return !lockedGroupIds.has(tr.groupId)
|
return !lockedGroupIds.has(tr.groupId)
|
||||||
})
|
})
|
||||||
@@ -804,6 +857,7 @@ function trackDndId(track: TimelineTrack): string {
|
|||||||
if (track.type === 'layer') return track.layerId
|
if (track.type === 'layer') return track.layerId
|
||||||
if (track.type === 'clip') return track.clipTrackId
|
if (track.type === 'clip') return track.clipTrackId
|
||||||
if (track.type === 'script') return track.scriptTrackId
|
if (track.type === 'script') return track.scriptTrackId
|
||||||
|
if (track.type === 'audio') return track.audioTrackId
|
||||||
return track.groupId
|
return track.groupId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,13 +1315,14 @@ function duplicateFromMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TrackMenuState {
|
interface TrackMenuState {
|
||||||
kind: 'layer' | 'clip' | 'group' | 'script'
|
kind: 'layer' | 'clip' | 'group' | 'script' | 'audio'
|
||||||
trackId: string
|
trackId: string
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
separated: boolean
|
separated: boolean
|
||||||
currentColor: string | null
|
currentColor: string | null
|
||||||
locked: boolean
|
locked: boolean
|
||||||
|
muted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
||||||
@@ -1678,6 +1733,200 @@ function clipBlockStyle(ci: ClipInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Audio clips ----
|
||||||
|
|
||||||
|
const AUDIO_CLIP_DND_MIME = 'application/x-animatr-audio-asset'
|
||||||
|
|
||||||
|
function audibleDurationOf(clip: AudioClip): number {
|
||||||
|
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||||||
|
if (!asset) return 0
|
||||||
|
return Math.max(
|
||||||
|
0.05,
|
||||||
|
asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioClipBlockStyle(clip: AudioClip) {
|
||||||
|
return {
|
||||||
|
left: pxFromTime(clip.startTime) + 'px',
|
||||||
|
width: Math.max(6, pxFromTime(audibleDurationOf(clip))) + 'px',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioClipTooltip(clip: AudioClip): string {
|
||||||
|
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||||||
|
if (!asset) return ''
|
||||||
|
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* playback. `exceptId` is the moving clip's own id (so it doesn't
|
||||||
|
* self-collide during a drag). */
|
||||||
|
function audioWindowCollides(
|
||||||
|
trackId: string,
|
||||||
|
start: number,
|
||||||
|
length: number,
|
||||||
|
exceptId?: string,
|
||||||
|
): boolean {
|
||||||
|
const end = start + length
|
||||||
|
const siblings = audioClipsByTrack.value[trackId] ?? []
|
||||||
|
for (const c of siblings) {
|
||||||
|
if (c.id === exceptId) continue
|
||||||
|
const asset = audioAssetById.value.get(c.audioAssetId)
|
||||||
|
if (!asset) continue
|
||||||
|
const cEnd = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
|
||||||
|
if (cEnd > start && c.startTime < end) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lighter check used during `dragover`: does the drop point land on top
|
||||||
|
* of any existing clip? Use during drag-to-create when the dragged
|
||||||
|
* item's duration isn't yet known (clipart before decode, or internal
|
||||||
|
* DnD whose getData is locked during dragover). */
|
||||||
|
function audioPointInAnyClip(trackId: string, t: number): boolean {
|
||||||
|
const siblings = audioClipsByTrack.value[trackId] ?? []
|
||||||
|
for (const c of siblings) {
|
||||||
|
const asset = audioAssetById.value.get(c.audioAssetId)
|
||||||
|
if (!asset) continue
|
||||||
|
const end = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
|
||||||
|
if (t >= c.startTime && t < end) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioLaneDragOver(e: DragEvent, audioTrackId: string) {
|
||||||
|
const types = e.dataTransfer?.types
|
||||||
|
if (!types) return
|
||||||
|
// Accept both internal-asset drops and clipart-file drops (images are
|
||||||
|
// filtered out at drop time — we only import audio clipart here).
|
||||||
|
if (!types.includes(AUDIO_CLIP_DND_MIME) && !types.includes('application/x-animatr-clipart')) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (!e.dataTransfer) return
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
const time = pixelTimeFromEvent(e, el)
|
||||||
|
// Forbid drops on top of existing clips — the browser renders a
|
||||||
|
// "not-allowed" cursor when dropEffect is 'none'. Width-based overlap
|
||||||
|
// (our drop's tail landing inside the next clip) is caught at drop time.
|
||||||
|
e.dataTransfer.dropEffect = audioPointInAnyClip(audioTrackId, time) ? 'none' : 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
|
||||||
|
e.preventDefault()
|
||||||
|
const track = audioTrackById.value.get(audioTrackId)
|
||||||
|
if (!track || track.locked) return
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
const time = pixelTimeFromEvent(e, el)
|
||||||
|
|
||||||
|
const assetId = e.dataTransfer?.getData(AUDIO_CLIP_DND_MIME)
|
||||||
|
if (assetId) {
|
||||||
|
const asset = audioAssetById.value.get(assetId)
|
||||||
|
const length = asset ? Math.max(0, asset.duration) : 0
|
||||||
|
// Length-based collision: a drop near an existing clip could still
|
||||||
|
// have its tail overlap the neighbour even if the drop *point* is
|
||||||
|
// free. Skip silently — the dragover cursor already warned.
|
||||||
|
if (audioWindowCollides(audioTrackId, time, length)) return
|
||||||
|
store.addAudioClip(assetId, time, audioTrackId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipart folder drop — import the file as an AudioAsset first, then
|
||||||
|
// place it on the lane. Images get silently ignored (user can drop
|
||||||
|
// them on the stage instead).
|
||||||
|
const clipartIdx = e.dataTransfer?.getData('application/x-animatr-clipart')
|
||||||
|
if (clipartIdx) {
|
||||||
|
const idx = Number.parseInt(clipartIdx, 10)
|
||||||
|
const item = Number.isFinite(idx) ? clipart.items[idx] : null
|
||||||
|
if (!item || item.kind !== 'audio') return
|
||||||
|
const res = await importAudioFile(store, item.file)
|
||||||
|
if (!res?.assetId) return
|
||||||
|
const newAsset = audioAssetById.value.get(res.assetId)
|
||||||
|
const length = newAsset ? Math.max(0, newAsset.duration) : 0
|
||||||
|
if (audioWindowCollides(audioTrackId, time, length)) {
|
||||||
|
// Decoded length turned out to collide. Drop is refused; the
|
||||||
|
// library asset still sticks around for the user to place manually.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.addAudioClip(res.assetId, time, audioTrackId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
// v1: a single "remove" action. The right-click context menu grows
|
||||||
|
// later once trim handles and per-clip properties land.
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('timeline.removeAudioClipDialog.title'),
|
||||||
|
message: t('timeline.removeAudioClipDialog.message'),
|
||||||
|
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
store.removeAudioClip(clip.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioClipMoveState {
|
||||||
|
clipId: string
|
||||||
|
pointerId: number
|
||||||
|
startX: number
|
||||||
|
initialStartTime: number
|
||||||
|
moved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioClipMove = ref<AudioClipMoveState | null>(null)
|
||||||
|
|
||||||
|
function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
const track = audioTrackById.value.get(clip.audioTrackId)
|
||||||
|
if (track?.locked) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
audioClipMove.value = {
|
||||||
|
clipId: clip.id,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
startX: e.clientX,
|
||||||
|
initialStartTime: clip.startTime,
|
||||||
|
moved: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioClipPointerMove(e: PointerEvent) {
|
||||||
|
const m = audioClipMove.value
|
||||||
|
if (!m || m.pointerId !== e.pointerId) return
|
||||||
|
const deltaPx = e.clientX - m.startX
|
||||||
|
if (!m.moved && Math.abs(deltaPx) < 2) return
|
||||||
|
m.moved = true
|
||||||
|
const pps = effectivePxPerSec.value
|
||||||
|
if (pps <= 0) return
|
||||||
|
const deltaSec = deltaPx / pps
|
||||||
|
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
||||||
|
const clip = store.scene.audioClips?.find((c) => c.id === m.clipId)
|
||||||
|
if (!clip) return
|
||||||
|
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||||||
|
const length = asset ? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0)) : 0
|
||||||
|
// Refuse overlapping positions — the clip stays at its last valid
|
||||||
|
// startTime. Cursor keeps tracking the pointer; block stops moving
|
||||||
|
// once it meets a neighbour's edge.
|
||||||
|
if (audioWindowCollides(clip.audioTrackId, newStart, length, clip.id)) return
|
||||||
|
store.moveAudioClip(m.clipId, newStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioClipPointerUp(e: PointerEvent) {
|
||||||
|
const m = audioClipMove.value
|
||||||
|
if (!m || m.pointerId !== e.pointerId) return
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||||
|
const wasMoved = m.moved
|
||||||
|
audioClipMove.value = null
|
||||||
|
// Block the contextmenu-after-drag that some browsers synthesise.
|
||||||
|
if (wasMoved) e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
// -------- Button asset edit bar --------------------------------------
|
// -------- Button asset edit bar --------------------------------------
|
||||||
|
|
||||||
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
||||||
@@ -1957,6 +2206,23 @@ function onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onAudioTrackContextMenu(e: MouseEvent, audioTrackId: string) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const track = audioTrackById.value.get(audioTrackId)
|
||||||
|
if (!track) return
|
||||||
|
trackMenu.value = {
|
||||||
|
kind: 'audio',
|
||||||
|
trackId: audioTrackId,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
separated: false,
|
||||||
|
currentColor: track.color ?? null,
|
||||||
|
locked: !!track.locked,
|
||||||
|
muted: !!track.muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyTrackColor(color: string | null) {
|
function applyTrackColor(color: string | null) {
|
||||||
if (!trackMenu.value) return
|
if (!trackMenu.value) return
|
||||||
store.setTrackColor(trackMenu.value.trackId, color)
|
store.setTrackColor(trackMenu.value.trackId, color)
|
||||||
@@ -1990,6 +2256,8 @@ function deleteTrack() {
|
|||||||
store.removeLayer(trackMenu.value.trackId)
|
store.removeLayer(trackMenu.value.trackId)
|
||||||
} else if (trackMenu.value.kind === 'clip') {
|
} else if (trackMenu.value.kind === 'clip') {
|
||||||
store.removeClipTrack(trackMenu.value.trackId)
|
store.removeClipTrack(trackMenu.value.trackId)
|
||||||
|
} else if (trackMenu.value.kind === 'audio') {
|
||||||
|
store.removeAudioTrack(trackMenu.value.trackId)
|
||||||
} else if (trackMenu.value.kind === 'script') {
|
} else if (trackMenu.value.kind === 'script') {
|
||||||
store.removeScriptTrack(trackMenu.value.trackId)
|
store.removeScriptTrack(trackMenu.value.trackId)
|
||||||
} else {
|
} else {
|
||||||
@@ -2040,6 +2308,26 @@ async function renameFromMenu() {
|
|||||||
if (next === null) return
|
if (next === null) return
|
||||||
store.setScriptTrackName(id, next)
|
store.setScriptTrackName(id, next)
|
||||||
}
|
}
|
||||||
|
if (trackMenu.value?.kind === 'audio') {
|
||||||
|
const track = audioTrackById.value.get(trackMenu.value.trackId)
|
||||||
|
if (!track) return
|
||||||
|
const id = trackMenu.value.trackId
|
||||||
|
const current = track.name ?? t('timeline.audioTrackName')
|
||||||
|
trackMenu.value = null
|
||||||
|
const next = await promptDialog({
|
||||||
|
title: t('timeline.renameAudioTrackDialog.title'),
|
||||||
|
defaultValue: current,
|
||||||
|
confirmLabel: t('timeline.renameAudioTrackDialog.confirm'),
|
||||||
|
})
|
||||||
|
if (next === null) return
|
||||||
|
store.setAudioTrackName(id, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAudioMutedFromMenu() {
|
||||||
|
if (!trackMenu.value || trackMenu.value.kind !== 'audio') return
|
||||||
|
store.setAudioTrackMuted(trackMenu.value.trackId, !trackMenu.value.muted)
|
||||||
|
trackMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDocumentPointerDown(e: PointerEvent) {
|
function onDocumentPointerDown(e: PointerEvent) {
|
||||||
@@ -2324,6 +2612,14 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<PhCode :size="12" />
|
<PhCode :size="12" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!inClipEdit"
|
||||||
|
class="snap-btn"
|
||||||
|
v-tooltip="t('timeline.addAudioTrackTooltip')"
|
||||||
|
@click="store.addAudioTrack()"
|
||||||
|
>
|
||||||
|
<PhMusicNote :size="12" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="snap-btn add-kf-btn"
|
class="snap-btn add-kf-btn"
|
||||||
v-tooltip="t('timeline.addKeyframeTooltip')"
|
v-tooltip="t('timeline.addKeyframeTooltip')"
|
||||||
@@ -2426,7 +2722,9 @@ onBeforeUnmount(() => {
|
|||||||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||||||
: track.type === 'script'
|
: track.type === 'script'
|
||||||
? `s:${track.scriptTrackId}`
|
? `s:${track.scriptTrackId}`
|
||||||
: `gr:${track.groupId}`
|
: track.type === 'audio'
|
||||||
|
? `a:${track.audioTrackId}`
|
||||||
|
: `gr:${track.groupId}`
|
||||||
">
|
">
|
||||||
<!-- Layer track -->
|
<!-- Layer track -->
|
||||||
<div
|
<div
|
||||||
@@ -2784,6 +3082,61 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio track -->
|
||||||
|
<div
|
||||||
|
v-else-if="track.type === 'audio'"
|
||||||
|
class="track audio-track"
|
||||||
|
:class="{
|
||||||
|
colored: !!track.color,
|
||||||
|
locked: track.locked,
|
||||||
|
muted: track.muted,
|
||||||
|
}"
|
||||||
|
:style="trackColorStyle(track.color)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="track-name audio-track-name"
|
||||||
|
@contextmenu="!track.locked ? onAudioTrackContextMenu($event, track.audioTrackId) : null"
|
||||||
|
>
|
||||||
|
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||||||
|
<PhMusicNote :size="11" class="name-badge" />
|
||||||
|
<span class="track-label">{{ track.name }}</span>
|
||||||
|
<button
|
||||||
|
v-if="track.muted"
|
||||||
|
class="mute-indicator"
|
||||||
|
v-tooltip="t('timeline.audioMuted')"
|
||||||
|
@click.stop="store.setAudioTrackMuted(track.audioTrackId, false)"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<PhSpeakerSlash :size="12" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="track-lane audio-lane"
|
||||||
|
:style="{ width: laneWidth + 'px' }"
|
||||||
|
@dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null"
|
||||||
|
@drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null"
|
||||||
|
>
|
||||||
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||||
|
<span
|
||||||
|
v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []"
|
||||||
|
:key="clip.id"
|
||||||
|
class="audio-clip-block"
|
||||||
|
:class="{ moving: audioClipMove?.clipId === clip.id }"
|
||||||
|
:style="audioClipBlockStyle(clip)"
|
||||||
|
v-tooltip="audioClipTooltip(clip)"
|
||||||
|
@pointerdown="onAudioClipPointerDown($event, clip)"
|
||||||
|
@pointermove="onAudioClipPointerMove"
|
||||||
|
@pointerup="onAudioClipPointerUp"
|
||||||
|
@pointercancel="onAudioClipPointerUp"
|
||||||
|
@contextmenu="onAudioClipContextMenu($event, clip)"
|
||||||
|
>
|
||||||
|
<span class="audio-clip-label">
|
||||||
|
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group header -->
|
<!-- Group header -->
|
||||||
<div
|
<div
|
||||||
v-else-if="track.type === 'group'"
|
v-else-if="track.type === 'group'"
|
||||||
@@ -3019,6 +3372,23 @@ onBeforeUnmount(() => {
|
|||||||
{{ t('timeline.renameScriptTrack') }}
|
{{ t('timeline.renameScriptTrack') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="trackMenu.kind === 'audio'">
|
||||||
|
<div class="kf-menu-divider"></div>
|
||||||
|
<button class="kf-menu-item" @click="renameFromMenu">
|
||||||
|
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||||||
|
{{ t('timeline.renameAudioTrack') }}
|
||||||
|
</button>
|
||||||
|
<button class="kf-menu-item" @click="toggleAudioMutedFromMenu">
|
||||||
|
<PhSpeakerSlash :size="12" class="kf-menu-icon" />
|
||||||
|
{{ t('timeline.muteAudioTrack') }}
|
||||||
|
<PhCheck
|
||||||
|
v-if="trackMenu.muted"
|
||||||
|
:size="12"
|
||||||
|
weight="bold"
|
||||||
|
class="kf-menu-trailing-check"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="trackMenu.kind === 'layer'">
|
<template v-if="trackMenu.kind === 'layer'">
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
@@ -3075,7 +3445,9 @@ onBeforeUnmount(() => {
|
|||||||
? t('timeline.deleteClipTrack')
|
? t('timeline.deleteClipTrack')
|
||||||
: trackMenu.kind === 'script'
|
: trackMenu.kind === 'script'
|
||||||
? t('timeline.deleteScriptTrack')
|
? t('timeline.deleteScriptTrack')
|
||||||
: t('timeline.ungroup')
|
: trackMenu.kind === 'audio'
|
||||||
|
? t('timeline.deleteAudioTrack')
|
||||||
|
: t('timeline.ungroup')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4204,6 +4576,96 @@ onBeforeUnmount(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Audio track ---- */
|
||||||
|
|
||||||
|
.track.audio-track {
|
||||||
|
height: 30px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(14, 165, 233, 0.08) 0%,
|
||||||
|
rgba(14, 165, 233, 0.02) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track .track-name {
|
||||||
|
background: var(--panel-alt);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track.muted .track-name .track-label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-indicator {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-indicator:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-lane {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-clip-block {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(14, 165, 233, 0.35) 0%,
|
||||||
|
rgba(14, 165, 233, 0.15) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.6);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-clip-block:active,
|
||||||
|
.audio-clip-block.moving {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-clip-block.moving {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track.audio-track.locked .audio-clip-block {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track.muted .audio-clip-block {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-clip-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.clip-block {
|
.clip-block {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { onBeforeUnmount, watch } from 'vue'
|
||||||
|
import type { AudioAsset, AudioClip, AudioTrack } from '@/types/scene'
|
||||||
|
import { useSceneStore } from '@/stores/scene'
|
||||||
|
|
||||||
|
type SceneStore = ReturnType<typeof useSceneStore>
|
||||||
|
|
||||||
|
export interface AudioRuntimeOptions {
|
||||||
|
/** Swap in a stub element constructor for headless tests. Defaults
|
||||||
|
* to `new Audio()` in the browser. */
|
||||||
|
audioFactory?: () => HTMLAudioElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioRuntimeHandle {
|
||||||
|
/** Apply current store state to the underlying audio elements:
|
||||||
|
* creates/evicts as the clip list changes, plays or pauses based on
|
||||||
|
* whether the playhead is inside the clip's window AND the track +
|
||||||
|
* runtime are both unmuted + playing. Exposed so tests can trigger
|
||||||
|
* sync without waiting for a rAF frame. */
|
||||||
|
reconcile(): void
|
||||||
|
/** Snapshot of the element pool for test assertions. Key = clip id. */
|
||||||
|
pool: Map<string, { el: HTMLAudioElement; assetId: string }>
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum allowed drift (in seconds) between an element's currentTime
|
||||||
|
* and the expected audio position before we re-seek. A tiny bit of
|
||||||
|
* drift is fine — the browser clock and the rAF clock aren't perfectly
|
||||||
|
* in sync. Larger drift means the user scrubbed or the runtime just
|
||||||
|
* resumed after a pause, and we should hard-align.
|
||||||
|
*/
|
||||||
|
const DRIFT_SEEK_THRESHOLD = 0.25
|
||||||
|
|
||||||
|
/** Core, framework-agnostic audio runtime. Tests drive it directly via
|
||||||
|
* the returned handle's `reconcile()`. The Vue composable below wires
|
||||||
|
* it to component lifecycle and a rAF pump for live playback. */
|
||||||
|
export function createAudioRuntime(
|
||||||
|
store: SceneStore,
|
||||||
|
opts: AudioRuntimeOptions = {},
|
||||||
|
): AudioRuntimeHandle {
|
||||||
|
const audioFactory = opts.audioFactory ?? (() => new Audio())
|
||||||
|
const pool = new Map<string, { el: HTMLAudioElement; assetId: string }>()
|
||||||
|
let rafId = 0
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
function audibleWindow(clip: AudioClip, asset: AudioAsset): { start: number; end: number } {
|
||||||
|
const start = clip.startTime
|
||||||
|
const end = clip.startTime + Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0))
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeClip(clipId: string) {
|
||||||
|
const entry = pool.get(clipId)
|
||||||
|
if (!entry) return
|
||||||
|
entry.el.pause()
|
||||||
|
// Wipe src so the browser can release the decoded audio.
|
||||||
|
entry.el.src = ''
|
||||||
|
pool.delete(clipId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcile(): void {
|
||||||
|
if (disposed) return
|
||||||
|
const clips = store.scene.audioClips ?? []
|
||||||
|
const tracksById = new Map<string, AudioTrack>()
|
||||||
|
for (const t of store.scene.audioTracks ?? []) tracksById.set(t.id, t)
|
||||||
|
const audioAssetById = new Map<string, AudioAsset>()
|
||||||
|
for (const a of store.project.assets) {
|
||||||
|
if (a.kind === 'audio') audioAssetById.set(a.id, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveIds = new Set<string>()
|
||||||
|
const playing = !!store.playing
|
||||||
|
const playhead = store.playhead
|
||||||
|
|
||||||
|
for (const clip of clips) {
|
||||||
|
liveIds.add(clip.id)
|
||||||
|
const asset = audioAssetById.get(clip.audioAssetId)
|
||||||
|
if (!asset || !asset.src) continue
|
||||||
|
|
||||||
|
// Ensure an element for this clip. If the clip's asset changed
|
||||||
|
// (user re-imported under the same clip, rare), rebuild it.
|
||||||
|
let entry = pool.get(clip.id)
|
||||||
|
if (!entry || entry.assetId !== clip.audioAssetId) {
|
||||||
|
if (entry) disposeClip(clip.id)
|
||||||
|
const el = audioFactory()
|
||||||
|
el.preload = 'auto'
|
||||||
|
el.src = asset.src
|
||||||
|
entry = { el, assetId: clip.audioAssetId }
|
||||||
|
pool.set(clip.id, entry)
|
||||||
|
} else if (entry.el.src !== asset.src) {
|
||||||
|
// Asset's src mutated (user swapped the external URL). Update
|
||||||
|
// without throwing the element away.
|
||||||
|
entry.el.src = asset.src
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracksById.get(clip.audioTrackId)
|
||||||
|
const win = audibleWindow(clip, asset)
|
||||||
|
const inWindow = playhead >= win.start && playhead < win.end
|
||||||
|
const shouldPlay = playing && inWindow && !track?.muted
|
||||||
|
|
||||||
|
if (shouldPlay) {
|
||||||
|
const audioTime = playhead - clip.startTime + (clip.trimStart ?? 0)
|
||||||
|
if (entry.el.paused) {
|
||||||
|
// Fresh start or resumed — hard-align then play.
|
||||||
|
try {
|
||||||
|
entry.el.currentTime = audioTime
|
||||||
|
} catch {
|
||||||
|
/* some browsers throw if metadata isn't loaded yet — the
|
||||||
|
* next reconcile pass catches up once it is. */
|
||||||
|
}
|
||||||
|
const maybe = entry.el.play()
|
||||||
|
if (maybe && typeof maybe.catch === 'function') maybe.catch(() => {})
|
||||||
|
} else {
|
||||||
|
// Already playing — only re-seek on meaningful drift so we
|
||||||
|
// don't stutter on every frame.
|
||||||
|
const drift = Math.abs(entry.el.currentTime - audioTime)
|
||||||
|
if (drift > DRIFT_SEEK_THRESHOLD) {
|
||||||
|
try {
|
||||||
|
entry.el.currentTime = audioTime
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!entry.el.paused) {
|
||||||
|
entry.el.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict elements for clips that no longer exist (deleted, or scene
|
||||||
|
// switched out). Copy keys first because disposeClip mutates pool.
|
||||||
|
for (const id of Array.from(pool.keys())) {
|
||||||
|
if (!liveIds.has(id)) disposeClip(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(): void {
|
||||||
|
if (disposed) return
|
||||||
|
reconcile()
|
||||||
|
rafId = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any reactive change to the relevant slices triggers an immediate
|
||||||
|
// reconcile, so scrubbing / pausing / deleting clips is responsive
|
||||||
|
// without waiting for the next rAF tick.
|
||||||
|
const stopPlayingWatch = watch(
|
||||||
|
() => store.playing,
|
||||||
|
(playing) => {
|
||||||
|
if (playing) {
|
||||||
|
if (!rafId) rafId = requestAnimationFrame(loop)
|
||||||
|
} else {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
rafId = 0
|
||||||
|
// Pause everything immediately — don't wait for the next
|
||||||
|
// reconcile since the rAF loop just stopped.
|
||||||
|
for (const entry of pool.values()) {
|
||||||
|
if (!entry.el.paused) entry.el.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reconcile()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopStateWatch = watch(
|
||||||
|
[
|
||||||
|
() => store.playhead,
|
||||||
|
() => store.scene.audioClips,
|
||||||
|
() => store.scene.audioTracks,
|
||||||
|
() => store.project.assets,
|
||||||
|
],
|
||||||
|
() => reconcile(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function dispose(): void {
|
||||||
|
disposed = true
|
||||||
|
stopPlayingWatch()
|
||||||
|
stopStateWatch()
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
rafId = 0
|
||||||
|
for (const id of Array.from(pool.keys())) disposeClip(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reconcile, pool, dispose }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vue composable: spins up a runtime bound to the component lifecycle.
|
||||||
|
* Mount it once at the app root — per-clip elements live in a singleton
|
||||||
|
* pool, so double-mounting would produce duplicated audio. */
|
||||||
|
export function useAudioRuntime(opts: AudioRuntimeOptions = {}): void {
|
||||||
|
const store = useSceneStore()
|
||||||
|
const runtime = createAudioRuntime(store, opts)
|
||||||
|
onBeforeUnmount(() => runtime.dispose())
|
||||||
|
}
|
||||||
+829
-15
@@ -43,6 +43,10 @@ import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
|||||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||||||
import ActionEditor from '@/components/ActionEditor.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 { vTooltip } from '@/directives/tooltip'
|
||||||
import { expect, suite, test } from './testRunner'
|
import { expect, suite, test } from './testRunner'
|
||||||
|
|
||||||
@@ -1055,6 +1059,54 @@ export function registerAllTests(): void {
|
|||||||
expect(html).toContain('ruwio-runtime.js')
|
expect(html).toContain('ruwio-runtime.js')
|
||||||
expect(html.indexOf('animatr-runtime.js')).toBe(-1)
|
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', () => {
|
suite('playback — transport', () => {
|
||||||
@@ -1282,12 +1334,11 @@ export function registerAllTests(): void {
|
|||||||
expect(s.scene.layers[0].id).toBe(l2.id)
|
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 s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
|
||||||
const l = s.addLayer(a.id)!
|
const l = s.addLayer(a.id)!
|
||||||
s.selectLayer(l.id)
|
s.selectLayer(l.id)
|
||||||
s.pause()
|
|
||||||
const clip = s.addClip('C')
|
const clip = s.addClip('C')
|
||||||
s.activeClipId = clip.id
|
s.activeClipId = clip.id
|
||||||
const two = s.addScene()
|
const two = s.addScene()
|
||||||
@@ -1297,25 +1348,26 @@ export function registerAllTests(): void {
|
|||||||
expect(s.activeClipId).toBeNull()
|
expect(s.activeClipId).toBeNull()
|
||||||
expect(s.activeButtonAssetId).toBeNull()
|
expect(s.activeButtonAssetId).toBeNull()
|
||||||
expect(s.project.activeSceneId).toBe(two.id)
|
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()
|
const s = makeFreshStore()
|
||||||
s.pause()
|
|
||||||
const second = s.addScene('Two')
|
const second = s.addScene('Two')
|
||||||
const firstId = s.project.scenes[0].id
|
const firstId = s.project.scenes[0].id
|
||||||
|
// Paused → stays paused across a switch.
|
||||||
s.pause()
|
s.pause()
|
||||||
expect(s.playing).toBe(false)
|
|
||||||
s.switchScene(firstId)
|
s.switchScene(firstId)
|
||||||
expect(s.playing).toBe(true)
|
expect(s.playing).toBe(false)
|
||||||
expect(s.project.activeSceneId).toBe(firstId)
|
expect(s.project.activeSceneId).toBe(firstId)
|
||||||
// And the inverse direction — Two plays too.
|
// Playing → stays playing.
|
||||||
s.pause()
|
s.play()
|
||||||
s.switchScene(second.id)
|
s.switchScene(second.id)
|
||||||
expect(s.playing).toBe(true)
|
expect(s.playing).toBe(true)
|
||||||
|
expect(s.project.activeSceneId).toBe(second.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('switchScene returns false for an unknown id and leaves state alone', () => {
|
test('switchScene returns false for an unknown id and leaves state alone', () => {
|
||||||
@@ -3836,6 +3888,44 @@ export function registerAllTests(): void {
|
|||||||
expect(overflowY).toBe('hidden')
|
expect(overflowY).toBe('hidden')
|
||||||
handle.unmount()
|
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', () => {
|
suite('ui — SceneDrawer', () => {
|
||||||
@@ -3890,21 +3980,23 @@ export function registerAllTests(): void {
|
|||||||
handle.unmount()
|
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 s = makeFreshStore()
|
||||||
const firstId = s.project.activeSceneId
|
const firstId = s.project.activeSceneId
|
||||||
const second = s.addScene('Two')
|
const second = s.addScene('Two')
|
||||||
// Go back to Scene 1 and pause so we can observe the auto-play flip.
|
|
||||||
s.switchScene(firstId)
|
s.switchScene(firstId)
|
||||||
s.pause()
|
s.pause()
|
||||||
const handle = mountComponent(SceneDrawer, {})
|
const handle = mountComponent(SceneDrawer, {})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
const rows = handle.host.querySelectorAll('.scene-row') as NodeListOf<HTMLButtonElement>
|
||||||
// Row index 1 = Two (non-active).
|
|
||||||
rows[1].click()
|
rows[1].click()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(s.project.activeSceneId).toBe(second.id)
|
expect(s.project.activeSceneId).toBe(second.id)
|
||||||
expect(s.playing).toBe(true)
|
expect(s.playing).toBe(false)
|
||||||
handle.unmount()
|
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', () => {
|
suite('polygon — vertex bezier actions', () => {
|
||||||
function makeSquare() {
|
function makeSquare() {
|
||||||
const s = makeFreshStore()
|
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', () => {
|
suite('actions — evalAction with mock runtime', () => {
|
||||||
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
|
function makeMockRuntime(init?: Partial<ActionRuntime>): ActionRuntime & {
|
||||||
calls: string[]
|
calls: string[]
|
||||||
|
|||||||
+24
-1
@@ -61,7 +61,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
addFile: 'Add SVG / image',
|
addFile: 'Add image or audio',
|
||||||
library: 'Library',
|
library: 'Library',
|
||||||
thumbnailsHide: 'Hide thumbnails',
|
thumbnailsHide: 'Hide thumbnails',
|
||||||
thumbnailsShow: 'Show thumbnails',
|
thumbnailsShow: 'Show thumbnails',
|
||||||
@@ -85,6 +85,13 @@ export const en = {
|
|||||||
},
|
},
|
||||||
buttons: 'Buttons',
|
buttons: 'Buttons',
|
||||||
noButtons: 'No buttons yet',
|
noButtons: 'No buttons yet',
|
||||||
|
audio: 'Audio',
|
||||||
|
noAudio: 'No audio imported',
|
||||||
|
deleteAudioDialog: {
|
||||||
|
title: 'Delete audio?',
|
||||||
|
message: 'Remove "{name}" from the asset library. Any clips on the timeline that reference it will also be removed.',
|
||||||
|
confirm: 'Delete',
|
||||||
|
},
|
||||||
createButtonTooltip: 'Create new button asset',
|
createButtonTooltip: 'Create new button asset',
|
||||||
hitModeDisabled: 'Switch back to button states to add assets.',
|
hitModeDisabled: 'Switch back to button states to add assets.',
|
||||||
newButtonDialog: {
|
newButtonDialog: {
|
||||||
@@ -224,6 +231,21 @@ export const en = {
|
|||||||
title: 'Rename script track',
|
title: 'Rename script track',
|
||||||
confirm: 'Apply',
|
confirm: 'Apply',
|
||||||
},
|
},
|
||||||
|
renameAudioTrackDialog: {
|
||||||
|
title: 'Rename audio track',
|
||||||
|
confirm: 'Apply',
|
||||||
|
},
|
||||||
|
removeAudioClipDialog: {
|
||||||
|
title: 'Remove audio clip?',
|
||||||
|
message: 'Remove this audio clip from the timeline.',
|
||||||
|
confirm: 'Remove',
|
||||||
|
},
|
||||||
|
audioTrackName: 'Audio track',
|
||||||
|
addAudioTrackTooltip: 'Create a new audio track',
|
||||||
|
dropNewAudioTrackHint: 'Drop an audio file here to add a track',
|
||||||
|
audioMuted: 'Muted — click to unmute',
|
||||||
|
muteAudioTrack: 'Mute track',
|
||||||
|
renameAudioTrack: 'Rename audio track',
|
||||||
ungroup: 'Ungroup',
|
ungroup: 'Ungroup',
|
||||||
groupSelected: 'Group {n} selected layers',
|
groupSelected: 'Group {n} selected layers',
|
||||||
convertToClip: 'Convert {n} selected to clip',
|
convertToClip: 'Convert {n} selected to clip',
|
||||||
@@ -266,6 +288,7 @@ export const en = {
|
|||||||
deleteLayer: 'Delete layer',
|
deleteLayer: 'Delete layer',
|
||||||
deleteClipTrack: 'Delete clip track',
|
deleteClipTrack: 'Delete clip track',
|
||||||
deleteScriptTrack: 'Delete script track',
|
deleteScriptTrack: 'Delete script track',
|
||||||
|
deleteAudioTrack: 'Delete audio track',
|
||||||
separate: 'Separate Position and Rotation',
|
separate: 'Separate Position and Rotation',
|
||||||
rotation: 'Rotation',
|
rotation: 'Rotation',
|
||||||
position: 'Position',
|
position: 'Position',
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ const STORE = 'handles'
|
|||||||
const HANDLE_KEY = 'clipartDir'
|
const HANDLE_KEY = 'clipartDir'
|
||||||
|
|
||||||
const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i
|
const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i
|
||||||
|
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i
|
||||||
|
|
||||||
|
export type ClipartKind = 'image' | 'audio'
|
||||||
|
|
||||||
export interface ClipartItem {
|
export interface ClipartItem {
|
||||||
name: string
|
name: string
|
||||||
file: File
|
file: File
|
||||||
url: string
|
url: string
|
||||||
|
kind: ClipartKind
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDb(): Promise<IDBDatabase> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
@@ -106,15 +110,23 @@ export async function listClipart(
|
|||||||
// @ts-expect-error async iterator not in DOM lib
|
// @ts-expect-error async iterator not in DOM lib
|
||||||
for await (const [name, entry] of handle.entries()) {
|
for await (const [name, entry] of handle.entries()) {
|
||||||
if (entry.kind !== 'file') continue
|
if (entry.kind !== 'file') continue
|
||||||
if (!IMAGE_EXT.test(name)) continue
|
let kind: ClipartKind | null = null
|
||||||
|
if (IMAGE_EXT.test(name)) kind = 'image'
|
||||||
|
else if (AUDIO_EXT.test(name)) kind = 'audio'
|
||||||
|
if (!kind) continue
|
||||||
try {
|
try {
|
||||||
const file = await (entry as FileSystemFileHandle).getFile()
|
const file = await (entry as FileSystemFileHandle).getFile()
|
||||||
out.push({ name, file, url: URL.createObjectURL(file) })
|
out.push({ name, file, url: URL.createObjectURL(file), kind })
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore unreadable entries */
|
/* ignore unreadable entries */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
// Sort by kind first (images before audio, so the thumbnail grid stays
|
||||||
|
// visually homogeneous), then by name inside each kind.
|
||||||
|
out.sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) return a.kind === 'image' ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-6
@@ -1,6 +1,6 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Scene } from '@/types/scene'
|
||||||
|
|
||||||
const RUNTIME_VERSION = '19'
|
const RUNTIME_VERSION = '20'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||||
@@ -153,6 +153,61 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
|
||||||
container.appendChild(bgVideo);
|
container.appendChild(bgVideo);
|
||||||
}
|
}
|
||||||
|
// --- Audio pool ---
|
||||||
|
var audioPool={};
|
||||||
|
var audioAssetsById={};
|
||||||
|
(scene.assets||[]).forEach(function(a){ if(a.kind==='audio') audioAssetsById[a.id]=a; });
|
||||||
|
var audioClips=scene.audioClips||[];
|
||||||
|
var audioTracksById={};
|
||||||
|
(scene.audioTracks||[]).forEach(function(t){ audioTracksById[t.id]=t; });
|
||||||
|
function disposeAudioClip(clipId){
|
||||||
|
var entry=audioPool[clipId];
|
||||||
|
if(!entry) return;
|
||||||
|
entry.el.pause();
|
||||||
|
entry.el.src='';
|
||||||
|
if(entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
|
||||||
|
delete audioPool[clipId];
|
||||||
|
}
|
||||||
|
function disposeAllAudio(){
|
||||||
|
for(var k in audioPool){ if(Object.prototype.hasOwnProperty.call(audioPool,k)) disposeAudioClip(k); }
|
||||||
|
}
|
||||||
|
function syncAudio(t, isPlaying){
|
||||||
|
for(var ci=0;ci<audioClips.length;ci++){
|
||||||
|
var clip=audioClips[ci];
|
||||||
|
var asset=audioAssetsById[clip.audioAssetId];
|
||||||
|
if(!asset || !asset.src){ disposeAudioClip(clip.id); continue; }
|
||||||
|
var entry=audioPool[clip.id];
|
||||||
|
if(!entry || entry.assetId !== clip.audioAssetId){
|
||||||
|
if(entry) disposeAudioClip(clip.id);
|
||||||
|
var el=document.createElement('audio');
|
||||||
|
el.preload='auto';
|
||||||
|
el.src=asset.src;
|
||||||
|
el.style.display='none';
|
||||||
|
container.appendChild(el);
|
||||||
|
entry={ el: el, assetId: clip.audioAssetId };
|
||||||
|
audioPool[clip.id]=entry;
|
||||||
|
}
|
||||||
|
var track=audioTracksById[clip.audioTrackId];
|
||||||
|
var trimS=clip.trimStart||0;
|
||||||
|
var trimE=clip.trimEnd||0;
|
||||||
|
var winLen=Math.max(0, asset.duration - trimS - trimE);
|
||||||
|
var inWindow=t>=clip.startTime && t<(clip.startTime+winLen);
|
||||||
|
var shouldPlay=isPlaying && inWindow && !(track && track.muted);
|
||||||
|
if(shouldPlay){
|
||||||
|
var audioTime=t - clip.startTime + trimS;
|
||||||
|
if(entry.el.paused){
|
||||||
|
try { entry.el.currentTime=audioTime; } catch(e) {}
|
||||||
|
var p=entry.el.play();
|
||||||
|
if(p && typeof p.catch==='function') p.catch(function(){});
|
||||||
|
} else {
|
||||||
|
var drift=Math.abs(entry.el.currentTime - audioTime);
|
||||||
|
if(drift > 0.25){ try { entry.el.currentTime=audioTime; } catch(e) {} }
|
||||||
|
}
|
||||||
|
} else if(!entry.el.paused){
|
||||||
|
entry.el.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var canvas=document.createElement('canvas');
|
var canvas=document.createElement('canvas');
|
||||||
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
|
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
|
||||||
container.appendChild(canvas);
|
container.appendChild(canvas);
|
||||||
@@ -397,6 +452,7 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
if(rangeLen<=0){
|
if(rangeLen<=0){
|
||||||
draw(currentT);
|
draw(currentT);
|
||||||
syncBgVideo(currentT);
|
syncBgVideo(currentT);
|
||||||
|
syncAudio(currentT, !paused);
|
||||||
if(onFrame) onFrame(currentT, paused);
|
if(onFrame) onFrame(currentT, paused);
|
||||||
rafId=requestAnimationFrame(frame);
|
rafId=requestAnimationFrame(frame);
|
||||||
return;
|
return;
|
||||||
@@ -420,6 +476,7 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; }
|
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; }
|
||||||
draw(currentT);
|
draw(currentT);
|
||||||
syncBgVideo(currentT);
|
syncBgVideo(currentT);
|
||||||
|
syncAudio(currentT, !paused);
|
||||||
if(onFrame) onFrame(currentT, paused);
|
if(onFrame) onFrame(currentT, paused);
|
||||||
rafId=requestAnimationFrame(frame);
|
rafId=requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
@@ -825,6 +882,7 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
canvas.remove();
|
canvas.remove();
|
||||||
if(bgVideo) bgVideo.remove();
|
if(bgVideo) bgVideo.remove();
|
||||||
|
disposeAllAudio();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -908,16 +966,51 @@ export function mountPlayer(
|
|||||||
// ---- Export helpers ----
|
// ---- Export helpers ----
|
||||||
|
|
||||||
function jsonForExport(scene: Scene): string {
|
function jsonForExport(scene: Scene): string {
|
||||||
// Data-URL background videos are stripped from the export — local video
|
// Data-URL background videos and audio assets are stripped from the
|
||||||
// files can't be embedded reasonably. The user needs to host the media
|
// export — local media can't be embedded reasonably, and doing so
|
||||||
// externally and set a URL in settings before exporting.
|
// would bloat the bundle into uselessness. The user has to host the
|
||||||
|
// file externally and point `backgroundVideo.src` / `AudioAsset.src`
|
||||||
|
// at that URL before exporting.
|
||||||
|
const warnings: string[] = []
|
||||||
|
let clone: Scene = scene
|
||||||
|
|
||||||
const bg = scene.backgroundVideo
|
const bg = scene.backgroundVideo
|
||||||
if (bg && bg.src && bg.src.startsWith('data:')) {
|
if (bg && bg.src && bg.src.startsWith('data:')) {
|
||||||
|
warnings.push('background video')
|
||||||
const { backgroundVideo, ...rest } = scene
|
const { backgroundVideo, ...rest } = scene
|
||||||
void backgroundVideo
|
void backgroundVideo
|
||||||
return JSON.stringify(rest)
|
clone = rest as Scene
|
||||||
}
|
}
|
||||||
return JSON.stringify(scene)
|
|
||||||
|
const audioAssets = clone.assets.filter(
|
||||||
|
(a) => a.kind === 'audio' && a.src && a.src.startsWith('data:'),
|
||||||
|
)
|
||||||
|
if (audioAssets.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`${audioAssets.length} audio asset(s) without external URL`,
|
||||||
|
)
|
||||||
|
// Replace the `src` with an empty string so the exported bundle's
|
||||||
|
// lookups still find the asset record (duration, name) and the
|
||||||
|
// runtime can render an empty block rather than crashing on a
|
||||||
|
// missing asset.
|
||||||
|
clone = {
|
||||||
|
...clone,
|
||||||
|
assets: clone.assets.map((a) =>
|
||||||
|
a.kind === 'audio' && a.src && a.src.startsWith('data:')
|
||||||
|
? { ...a, src: '' }
|
||||||
|
: a,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0 && typeof window !== 'undefined' && window.console) {
|
||||||
|
window.console.warn(
|
||||||
|
'[ruwio] export: stripped ' +
|
||||||
|
warnings.join(', ') +
|
||||||
|
' — host the files externally and set their src fields before exporting.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return JSON.stringify(clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
|
|||||||
@@ -100,3 +100,52 @@ export async function importImageFile(
|
|||||||
}
|
}
|
||||||
return { assetId: asset.id, layerId: layer.id }
|
return { assetId: asset.id, layerId: layer.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAudioFile(file: File): boolean {
|
||||||
|
if (file.type.startsWith('audio/')) return true
|
||||||
|
const lower = file.name.toLowerCase()
|
||||||
|
return lower.endsWith('.mp3') || lower.endsWith('.wav') || lower.endsWith('.ogg') || lower.endsWith('.m4a')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode an audio file far enough to learn its duration. Prefers an
|
||||||
|
* OfflineAudioContext (deterministic, headless) and falls back to a
|
||||||
|
* hidden `<audio>` element's `loadedmetadata` event if decoding is
|
||||||
|
* unsupported or fails (e.g. DRM-protected / exotic codecs). */
|
||||||
|
export function readAudioDuration(dataUrl: string): Promise<number> {
|
||||||
|
// Fallback path via HTMLAudioElement — lighter weight, works without
|
||||||
|
// Web Audio.
|
||||||
|
const viaMetadata = () =>
|
||||||
|
new Promise<number>((resolve, reject) => {
|
||||||
|
const audio = document.createElement('audio')
|
||||||
|
audio.preload = 'metadata'
|
||||||
|
audio.onloadedmetadata = () => {
|
||||||
|
const d = audio.duration
|
||||||
|
if (Number.isFinite(d) && d > 0) resolve(d)
|
||||||
|
else reject(new Error('audio duration unknown'))
|
||||||
|
}
|
||||||
|
audio.onerror = () => reject(new Error('audio metadata load failed'))
|
||||||
|
audio.src = dataUrl
|
||||||
|
})
|
||||||
|
return viaMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioImportStore {
|
||||||
|
addAudioAsset: (a: { name: string; src: string; duration: number }) => { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAudioFile(
|
||||||
|
store: AudioImportStore,
|
||||||
|
file: File,
|
||||||
|
): Promise<{ assetId: string } | null> {
|
||||||
|
const dataUrl = await readAsDataUrl(file)
|
||||||
|
let duration = 0
|
||||||
|
try {
|
||||||
|
duration = await readAudioDuration(dataUrl)
|
||||||
|
} catch {
|
||||||
|
// Unknown duration — store as 0. Trim logic clamps to the min visible,
|
||||||
|
// so the clip still renders; the user can re-drop the file if needed.
|
||||||
|
}
|
||||||
|
const name = file.name.replace(/\.[^.]+$/, '') || 'audio'
|
||||||
|
const asset = store.addAudioAsset({ name, src: dataUrl, duration })
|
||||||
|
return { assetId: asset.id }
|
||||||
|
}
|
||||||
+170
-6
@@ -4,6 +4,9 @@ import type {
|
|||||||
Action,
|
Action,
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Asset,
|
Asset,
|
||||||
|
AudioAsset,
|
||||||
|
AudioClip,
|
||||||
|
AudioTrack,
|
||||||
BackgroundVideo,
|
BackgroundVideo,
|
||||||
ButtonAsset,
|
ButtonAsset,
|
||||||
ButtonState,
|
ButtonState,
|
||||||
@@ -206,12 +209,15 @@ function normalizeScenePage(page: ScenePage): ScenePage {
|
|||||||
page.markers ||= []
|
page.markers ||= []
|
||||||
page.groups ||= []
|
page.groups ||= []
|
||||||
page.trackOrder ||= []
|
page.trackOrder ||= []
|
||||||
|
page.audioTracks ||= []
|
||||||
|
page.audioClips ||= []
|
||||||
|
|
||||||
const known = new Set<string>()
|
const known = new Set<string>()
|
||||||
for (const l of page.layers) known.add(l.id)
|
for (const l of page.layers) known.add(l.id)
|
||||||
for (const t of page.clipTracks) known.add(t.id)
|
for (const t of page.clipTracks) known.add(t.id)
|
||||||
for (const g of page.groups) known.add(g.id)
|
for (const g of page.groups) known.add(g.id)
|
||||||
for (const st of page.scriptTracks) known.add(st.id)
|
for (const st of page.scriptTracks) known.add(st.id)
|
||||||
|
for (const at of page.audioTracks) known.add(at.id)
|
||||||
|
|
||||||
const layerAndClipIds = new Set<string>()
|
const layerAndClipIds = new Set<string>()
|
||||||
for (const l of page.layers) layerAndClipIds.add(l.id)
|
for (const l of page.layers) layerAndClipIds.add(l.id)
|
||||||
@@ -245,6 +251,13 @@ function normalizeScenePage(page: ScenePage): ScenePage {
|
|||||||
for (const st of page.scriptTracks) {
|
for (const st of page.scriptTracks) {
|
||||||
if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
|
if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
|
||||||
}
|
}
|
||||||
|
for (const at of page.audioTracks) {
|
||||||
|
if (!page.trackOrder.includes(at.id)) page.trackOrder.push(at.id)
|
||||||
|
}
|
||||||
|
// Orphan audio clips (pointing at a missing track or missing asset) would
|
||||||
|
// never render anyway — drop them so serialised projects stay clean.
|
||||||
|
const audioTrackIds = new Set(page.audioTracks.map((t) => t.id))
|
||||||
|
page.audioClips = page.audioClips.filter((c) => audioTrackIds.has(c.audioTrackId))
|
||||||
|
|
||||||
for (const l of page.layers) {
|
for (const l of page.layers) {
|
||||||
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true
|
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true
|
||||||
@@ -258,6 +271,9 @@ function normalizeScenePage(page: ScenePage): ScenePage {
|
|||||||
for (const g of page.groups) {
|
for (const g of page.groups) {
|
||||||
if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true
|
if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true
|
||||||
}
|
}
|
||||||
|
for (const at of page.audioTracks) {
|
||||||
|
if (at.color != null && at.colorOverridden === undefined) at.colorOverridden = true
|
||||||
|
}
|
||||||
return page
|
return page
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +307,8 @@ export function createBlankScenePage(name: string, id?: string): ScenePage {
|
|||||||
markers: [],
|
markers: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
trackOrder: [],
|
trackOrder: [],
|
||||||
|
audioTracks: [],
|
||||||
|
audioClips: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +342,8 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
|
|||||||
groups: scene.groups ?? [],
|
groups: scene.groups ?? [],
|
||||||
trackOrder: scene.trackOrder ?? [],
|
trackOrder: scene.trackOrder ?? [],
|
||||||
backgroundVideo: scene.backgroundVideo,
|
backgroundVideo: scene.backgroundVideo,
|
||||||
|
audioTracks: scene.audioTracks ?? [],
|
||||||
|
audioClips: scene.audioClips ?? [],
|
||||||
}
|
}
|
||||||
return normalizeProject({
|
return normalizeProject({
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -416,6 +436,8 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
groups: page.groups,
|
groups: page.groups,
|
||||||
trackOrder: page.trackOrder,
|
trackOrder: page.trackOrder,
|
||||||
backgroundVideo: page.backgroundVideo,
|
backgroundVideo: page.backgroundVideo,
|
||||||
|
audioTracks: page.audioTracks,
|
||||||
|
audioClips: page.audioClips,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activeClip(): Clip | null {
|
activeClip(): Clip | null {
|
||||||
@@ -1572,6 +1594,114 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// -------- Audio tracks, clips & assets -----------------------------
|
||||||
|
|
||||||
|
/** Add a new audio asset to the shared project library. `src` may be
|
||||||
|
* a data-URL (for offline editor use) or an external http(s) URL;
|
||||||
|
* exports strip data-URL-backed audio assets with a warning. */
|
||||||
|
addAudioAsset(init: { name: string; src: string; duration: number }): AudioAsset {
|
||||||
|
const asset: AudioAsset = {
|
||||||
|
id: uid(),
|
||||||
|
kind: 'audio',
|
||||||
|
name: init.name.trim() || 'Audio',
|
||||||
|
src: init.src,
|
||||||
|
duration: Math.max(0, init.duration),
|
||||||
|
}
|
||||||
|
this.project.assets.push(asset)
|
||||||
|
return asset
|
||||||
|
},
|
||||||
|
|
||||||
|
addAudioTrack(name?: string): AudioTrack {
|
||||||
|
const track: AudioTrack = { id: uid(), name }
|
||||||
|
this.activeScene.audioTracks.push(track)
|
||||||
|
this.activeScene.trackOrder.push(track.id)
|
||||||
|
return track
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAudioTrack(id: string) {
|
||||||
|
const page = this.activeScene
|
||||||
|
page.audioTracks = page.audioTracks.filter((t) => t.id !== id)
|
||||||
|
page.audioClips = page.audioClips.filter((c) => c.audioTrackId !== id)
|
||||||
|
page.trackOrder = page.trackOrder.filter((x) => x !== id)
|
||||||
|
},
|
||||||
|
|
||||||
|
setAudioTrackName(id: string, name: string) {
|
||||||
|
const track = this.activeScene.audioTracks.find((t) => t.id === id)
|
||||||
|
if (track) track.name = name.trim() || undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
setAudioTrackMuted(id: string, muted: boolean) {
|
||||||
|
const track = this.activeScene.audioTracks.find((t) => t.id === id)
|
||||||
|
if (track) track.muted = muted
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Place an AudioClip on the given track. Returns null if the asset
|
||||||
|
* or track is unknown — guards the caller from creating orphans. */
|
||||||
|
addAudioClip(
|
||||||
|
audioAssetId: string,
|
||||||
|
startTime: number,
|
||||||
|
audioTrackId: string,
|
||||||
|
): AudioClip | null {
|
||||||
|
const asset = this.project.assets.find((a) => a.id === audioAssetId)
|
||||||
|
if (!asset || asset.kind !== 'audio') return null
|
||||||
|
const track = this.activeScene.audioTracks.find((t) => t.id === audioTrackId)
|
||||||
|
if (!track) return null
|
||||||
|
const clip: AudioClip = {
|
||||||
|
id: uid(),
|
||||||
|
audioAssetId,
|
||||||
|
audioTrackId,
|
||||||
|
startTime: Math.max(0, startTime),
|
||||||
|
}
|
||||||
|
this.activeScene.audioClips.push(clip)
|
||||||
|
return clip
|
||||||
|
},
|
||||||
|
|
||||||
|
moveAudioClip(id: string, startTime: number, audioTrackId?: string) {
|
||||||
|
const clip = this.activeScene.audioClips.find((c) => c.id === id)
|
||||||
|
if (!clip) return
|
||||||
|
clip.startTime = Math.max(0, startTime)
|
||||||
|
if (audioTrackId) {
|
||||||
|
const track = this.activeScene.audioTracks.find((t) => t.id === audioTrackId)
|
||||||
|
if (track) clip.audioTrackId = audioTrackId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trim from the head and/or tail of an audio clip. Keeps the audible
|
||||||
|
* block at least `MIN_VISIBLE` (0.05s) long — mirrors trimClipInstance. */
|
||||||
|
trimAudioClip(
|
||||||
|
id: string,
|
||||||
|
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
|
||||||
|
) {
|
||||||
|
const clip = this.activeScene.audioClips.find((c) => c.id === id)
|
||||||
|
if (!clip) return
|
||||||
|
const asset = this.project.assets.find((a) => a.id === clip.audioAssetId)
|
||||||
|
if (!asset || asset.kind !== 'audio') return
|
||||||
|
const MIN_VISIBLE = 0.05
|
||||||
|
let trimStart = patch.trimStart ?? clip.trimStart ?? 0
|
||||||
|
let trimEnd = patch.trimEnd ?? clip.trimEnd ?? 0
|
||||||
|
let startTime = patch.startTime ?? clip.startTime
|
||||||
|
trimStart = Math.max(0, trimStart)
|
||||||
|
trimEnd = Math.max(0, trimEnd)
|
||||||
|
const maxBudget = asset.duration - MIN_VISIBLE
|
||||||
|
if (trimStart + trimEnd > maxBudget) {
|
||||||
|
if (patch.trimStart !== undefined && patch.trimEnd === undefined) {
|
||||||
|
trimStart = Math.max(0, maxBudget - trimEnd)
|
||||||
|
} else if (patch.trimEnd !== undefined && patch.trimStart === undefined) {
|
||||||
|
trimEnd = Math.max(0, maxBudget - trimStart)
|
||||||
|
} else {
|
||||||
|
trimStart = Math.min(trimStart, maxBudget)
|
||||||
|
trimEnd = Math.max(0, maxBudget - trimStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clip.trimStart = trimStart
|
||||||
|
clip.trimEnd = trimEnd
|
||||||
|
clip.startTime = Math.max(0, startTime)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAudioClip(id: string) {
|
||||||
|
this.activeScene.audioClips = this.activeScene.audioClips.filter((c) => c.id !== id)
|
||||||
|
},
|
||||||
|
|
||||||
// -------- Button assets --------------------------------------------
|
// -------- Button assets --------------------------------------------
|
||||||
|
|
||||||
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
|
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
|
||||||
@@ -1799,9 +1929,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
/** Activate a page by id. Clears any drill-down state (clip-edit,
|
/** Activate a page by id. Clears any drill-down state (clip-edit,
|
||||||
* button-edit, selections, drawing) so the new page isn't shown
|
* button-edit, selections, drawing) so the new page isn't shown
|
||||||
* through the lens of a context that belongs to another page.
|
* through the lens of a context that belongs to another page.
|
||||||
* Every switch auto-starts playback — clicking a scene in the
|
* Playback state is left alone — in the editor, flipping scenes
|
||||||
* drawer or having a script/button jump to one always hands the
|
* is a navigation gesture and shouldn't force play. The fullscreen
|
||||||
* user a running timeline on the new page. */
|
* preview takes care of auto-playing on its own (the activeSceneId
|
||||||
|
* watcher re-mounts the preview player with play enabled). */
|
||||||
switchScene(id: string): boolean {
|
switchScene(id: string): boolean {
|
||||||
if (!this.project.scenes.find((s) => s.id === id)) return false
|
if (!this.project.scenes.find((s) => s.id === id)) return false
|
||||||
this.project.activeSceneId = id
|
this.project.activeSceneId = id
|
||||||
@@ -1815,7 +1946,6 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.drawingMode = null
|
this.drawingMode = null
|
||||||
this.drawingAssetId = null
|
this.drawingAssetId = null
|
||||||
this.drawingLayerId = null
|
this.drawingLayerId = null
|
||||||
this.playing = true
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
renameScene(id: string, name: string): boolean {
|
renameScene(id: string, name: string): boolean {
|
||||||
@@ -1874,6 +2004,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
}
|
}
|
||||||
for (const t of cloned.clipTracks) t.id = remapId(t.id)
|
for (const t of cloned.clipTracks) t.id = remapId(t.id)
|
||||||
for (const st of cloned.scriptTracks) st.id = remapId(st.id)
|
for (const st of cloned.scriptTracks) st.id = remapId(st.id)
|
||||||
|
for (const at of cloned.audioTracks) at.id = remapId(at.id)
|
||||||
for (const g of cloned.groups) {
|
for (const g of cloned.groups) {
|
||||||
g.id = remapId(g.id)
|
g.id = remapId(g.id)
|
||||||
g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid)
|
g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid)
|
||||||
@@ -1889,6 +2020,11 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
id: uid(),
|
id: uid(),
|
||||||
trackId: remap.get(f.trackId) ?? f.trackId,
|
trackId: remap.get(f.trackId) ?? f.trackId,
|
||||||
}))
|
}))
|
||||||
|
cloned.audioClips = cloned.audioClips.map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: uid(),
|
||||||
|
audioTrackId: remap.get(c.audioTrackId) ?? c.audioTrackId,
|
||||||
|
}))
|
||||||
cloned.markers = cloned.markers.map((m) => ({ ...m, id: uid() }))
|
cloned.markers = cloned.markers.map((m) => ({ ...m, id: uid() }))
|
||||||
|
|
||||||
const idx = this.project.scenes.findIndex((s) => s.id === id)
|
const idx = this.project.scenes.findIndex((s) => s.id === id)
|
||||||
@@ -1898,10 +2034,18 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Remove an asset from the project library. Layers that referenced
|
/** Remove an asset from the project library. Layers that referenced
|
||||||
* it remain — they'll just render as nothing. Use this from UI
|
* it remain — they'll just render as nothing. Audio clips, however,
|
||||||
* components instead of mutating `project.assets` inline. */
|
* get purged: a dangling audio clip has no asset to play, so the
|
||||||
|
* lane would just render an empty block forever. Cascade through
|
||||||
|
* every scene page so non-active pages don't carry ghosts. */
|
||||||
removeAsset(id: string) {
|
removeAsset(id: string) {
|
||||||
|
const asset = this.project.assets.find((a) => a.id === id)
|
||||||
this.project.assets = this.project.assets.filter((a) => a.id !== id)
|
this.project.assets = this.project.assets.filter((a) => a.id !== id)
|
||||||
|
if (asset && asset.kind === 'audio') {
|
||||||
|
for (const page of this.project.scenes) {
|
||||||
|
page.audioClips = page.audioClips.filter((c) => c.audioAssetId !== id)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addClip(name?: string): Clip {
|
addClip(name?: string): Clip {
|
||||||
@@ -2116,6 +2260,26 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return newTrack.id
|
return newTrack.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const audioTrack = scene.audioTracks?.find((t) => t.id === id)
|
||||||
|
if (audioTrack) {
|
||||||
|
const newTrack: AudioTrack = {
|
||||||
|
...(structuredClone(toRaw(audioTrack)) as AudioTrack),
|
||||||
|
id: uid(),
|
||||||
|
}
|
||||||
|
;(scene.audioTracks ||= []).push(newTrack)
|
||||||
|
const clips = scene.audioClips?.filter((c) => c.audioTrackId === id) ?? []
|
||||||
|
for (const c of clips) {
|
||||||
|
const clonedClip: AudioClip = {
|
||||||
|
...(structuredClone(toRaw(c)) as AudioClip),
|
||||||
|
id: uid(),
|
||||||
|
audioTrackId: newTrack.id,
|
||||||
|
}
|
||||||
|
;(scene.audioClips ||= []).push(clonedClip)
|
||||||
|
}
|
||||||
|
placeAfter(id, newTrack.id)
|
||||||
|
return newTrack.id
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
removeClipTrack(id: string) {
|
removeClipTrack(id: string) {
|
||||||
|
|||||||
+57
-2
@@ -43,7 +43,7 @@ export interface Keyframe {
|
|||||||
ctrlY?: number
|
ctrlY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button'
|
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio'
|
||||||
|
|
||||||
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
|
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
|
||||||
|
|
||||||
@@ -141,7 +141,29 @@ export interface ButtonAsset {
|
|||||||
hitArea: HitShape[]
|
hitArea: HitShape[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset | ButtonAsset
|
/**
|
||||||
|
* An audio file reference. `src` can be either a data-URL (fine for offline
|
||||||
|
* editor work) or an external http(s) URL (required at export time). Exports
|
||||||
|
* strip data-URL-backed AudioAssets and warn the user — mirroring the
|
||||||
|
* backgroundVideo contract. `duration` is the full audio length; individual
|
||||||
|
* placements (AudioClip) may trim from either end.
|
||||||
|
*/
|
||||||
|
export interface AudioAsset {
|
||||||
|
id: string
|
||||||
|
kind: 'audio'
|
||||||
|
name: string
|
||||||
|
src: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Asset =
|
||||||
|
| ImageAsset
|
||||||
|
| RectAsset
|
||||||
|
| CircleAsset
|
||||||
|
| TextAsset
|
||||||
|
| PolygonAsset
|
||||||
|
| ButtonAsset
|
||||||
|
| AudioAsset
|
||||||
|
|
||||||
export type MaskMode = 'positive' | 'negative'
|
export type MaskMode = 'positive' | 'negative'
|
||||||
|
|
||||||
@@ -229,6 +251,35 @@ export interface ClipInstance {
|
|||||||
rotation?: number
|
rotation?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lane on the timeline that plays AudioClips. Mirrors ClipTrack/ScriptTrack
|
||||||
|
* (per-scene, listed in `trackOrder`). `muted` silences every clip on this
|
||||||
|
* track — applied by both the editor runtime and the export runtime.
|
||||||
|
*/
|
||||||
|
export interface AudioTrack {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
color?: string | null
|
||||||
|
colorOverridden?: boolean
|
||||||
|
locked?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A placed audio snippet on an AudioTrack. References an AudioAsset by id;
|
||||||
|
* `startTime` is when on the page's timeline playback begins. `trimStart` /
|
||||||
|
* `trimEnd` take seconds off the head / tail of the sourced asset, so the
|
||||||
|
* audible block runs `(asset.duration - trimStart - trimEnd)` seconds.
|
||||||
|
*/
|
||||||
|
export interface AudioClip {
|
||||||
|
id: string
|
||||||
|
audioAssetId: string
|
||||||
|
audioTrackId: string
|
||||||
|
startTime: number
|
||||||
|
trimStart?: number
|
||||||
|
trimEnd?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundVideo {
|
export interface BackgroundVideo {
|
||||||
src: string
|
src: string
|
||||||
offsetTime?: number
|
offsetTime?: number
|
||||||
@@ -301,6 +352,8 @@ export interface Scene {
|
|||||||
scriptTracks?: ScriptTrack[]
|
scriptTracks?: ScriptTrack[]
|
||||||
scriptFrames?: ScriptFrame[]
|
scriptFrames?: ScriptFrame[]
|
||||||
markers?: Marker[]
|
markers?: Marker[]
|
||||||
|
audioTracks?: AudioTrack[]
|
||||||
|
audioClips?: AudioClip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +374,8 @@ export interface ScenePage {
|
|||||||
groups: TrackGroup[]
|
groups: TrackGroup[]
|
||||||
trackOrder: string[]
|
trackOrder: string[]
|
||||||
backgroundVideo?: BackgroundVideo
|
backgroundVideo?: BackgroundVideo
|
||||||
|
audioTracks: AudioTrack[]
|
||||||
|
audioClips: AudioClip[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user