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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 11:53:41 +02:00
1 parent b9f2ba9e47
commit 04f867547d
14 files changed
+2057 -42

No files matched your search

+118 -3
View File
@@ -7,6 +7,7 @@ import {
PhImage,
PhImageSquare,
PhListBullets,
PhMusicNote,
PhPencilSimple,
PhPencilSimpleLine,
PhPlus,
@@ -18,7 +19,7 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importImageFile } from '@/lib/fileLoader'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
@@ -245,9 +246,57 @@ async function handleFileSelected(event: Event) {
}
async function importFile(file: File) {
if (isAudioFile(file)) {
await importAudioFile(store, file)
return
}
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() {
fileInput.value?.click()
}
@@ -259,12 +308,39 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
text: PhTextT,
polygon: PhPolygon,
button: PhCursorClick,
audio: PhMusicNote,
}
const imageAssets = computed(() =>
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' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
@@ -301,8 +377,11 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<template>
<div
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"
@dragover="onPanelDragOver"
@dragleave="onPanelDragLeave"
@drop="onPanelDrop"
>
<h3>Assets</h3>
@@ -340,7 +419,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<input
ref="fileInput"
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
style="display: none"
@change="handleFileSelected"
@@ -483,6 +562,37 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</li>
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
</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>
</template>
@@ -500,6 +610,11 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
filter: saturate(0.5);
}
.panel.drop-hot {
outline: 2px dashed var(--accent);
outline-offset: -6px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;