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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user