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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 { t } from '@/i18n'
|
||||
|
||||
@@ -21,6 +21,11 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
||||
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
|
||||
const ghost = document.createElement('img')
|
||||
ghost.src = item.url
|
||||
@@ -83,11 +88,15 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
v-for="(item, i) in clipart.items"
|
||||
:key="item.name + i"
|
||||
class="tile"
|
||||
:class="{ 'audio-tile': item.kind === 'audio' }"
|
||||
v-tooltip="item.name"
|
||||
draggable="true"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,6 +310,24 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
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 {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -36,6 +36,10 @@ async function startPreview() {
|
||||
await nextTick()
|
||||
const el = frameEl.value
|
||||
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
|
||||
showControls.value = false
|
||||
const start = rangeIn.value
|
||||
|
||||
+465
-3
@@ -19,15 +19,19 @@ import {
|
||||
PhMaskHappy,
|
||||
PhCircle,
|
||||
PhImage,
|
||||
PhMusicNote,
|
||||
PhPauseCircle,
|
||||
PhPencilSimpleLine,
|
||||
PhPlus,
|
||||
PhPolygon,
|
||||
PhSpeakerSlash,
|
||||
PhSquare,
|
||||
PhTextT,
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { importAudioFile } from '@/lib/fileLoader'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
@@ -38,6 +42,8 @@ import { showLocked } from '@/composables/useShowLocked'
|
||||
import type {
|
||||
AnimatableProps,
|
||||
Asset,
|
||||
AudioAsset,
|
||||
AudioClip,
|
||||
Clip,
|
||||
ClipInstance,
|
||||
Easing,
|
||||
@@ -92,13 +98,25 @@ interface TimelineScriptTrack {
|
||||
color: string | null
|
||||
}
|
||||
|
||||
interface TimelineAudioTrack {
|
||||
type: 'audio'
|
||||
audioTrackId: string
|
||||
name: string
|
||||
orderIndex: number
|
||||
locked: boolean
|
||||
muted: boolean
|
||||
color: string | null
|
||||
}
|
||||
|
||||
type TimelineTrack =
|
||||
| TimelineLayerTrack
|
||||
| TimelineClipTrack
|
||||
| TimelineGroupTrack
|
||||
| TimelineScriptTrack
|
||||
| TimelineAudioTrack
|
||||
|
||||
const store = useSceneStore()
|
||||
const clipart = useClipartStore()
|
||||
|
||||
const duration = computed(() =>
|
||||
store.activeButtonAssetId ? 5 : store.activeDuration,
|
||||
@@ -162,6 +180,25 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
|
||||
}
|
||||
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
|
||||
// up the new default that matches the assets-sidebar column width (260px),
|
||||
// which keeps the timeline track-name column visually aligned with it.
|
||||
@@ -737,6 +774,19 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
})
|
||||
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)
|
||||
if (grp) {
|
||||
out.push({
|
||||
@@ -795,6 +845,9 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
if (tr.type === 'script') {
|
||||
return !tr.locked
|
||||
}
|
||||
if (tr.type === 'audio') {
|
||||
return !tr.locked
|
||||
}
|
||||
// group: hide when locked
|
||||
return !lockedGroupIds.has(tr.groupId)
|
||||
})
|
||||
@@ -804,6 +857,7 @@ function trackDndId(track: TimelineTrack): string {
|
||||
if (track.type === 'layer') return track.layerId
|
||||
if (track.type === 'clip') return track.clipTrackId
|
||||
if (track.type === 'script') return track.scriptTrackId
|
||||
if (track.type === 'audio') return track.audioTrackId
|
||||
return track.groupId
|
||||
}
|
||||
|
||||
@@ -1261,13 +1315,14 @@ function duplicateFromMenu() {
|
||||
}
|
||||
|
||||
interface TrackMenuState {
|
||||
kind: 'layer' | 'clip' | 'group' | 'script'
|
||||
kind: 'layer' | 'clip' | 'group' | 'script' | 'audio'
|
||||
trackId: string
|
||||
x: number
|
||||
y: number
|
||||
separated: boolean
|
||||
currentColor: string | null
|
||||
locked: boolean
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
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 --------------------------------------
|
||||
|
||||
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) {
|
||||
if (!trackMenu.value) return
|
||||
store.setTrackColor(trackMenu.value.trackId, color)
|
||||
@@ -1990,6 +2256,8 @@ function deleteTrack() {
|
||||
store.removeLayer(trackMenu.value.trackId)
|
||||
} else if (trackMenu.value.kind === 'clip') {
|
||||
store.removeClipTrack(trackMenu.value.trackId)
|
||||
} else if (trackMenu.value.kind === 'audio') {
|
||||
store.removeAudioTrack(trackMenu.value.trackId)
|
||||
} else if (trackMenu.value.kind === 'script') {
|
||||
store.removeScriptTrack(trackMenu.value.trackId)
|
||||
} else {
|
||||
@@ -2040,6 +2308,26 @@ async function renameFromMenu() {
|
||||
if (next === null) return
|
||||
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) {
|
||||
@@ -2324,6 +2612,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhCode :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!inClipEdit"
|
||||
class="snap-btn"
|
||||
v-tooltip="t('timeline.addAudioTrackTooltip')"
|
||||
@click="store.addAudioTrack()"
|
||||
>
|
||||
<PhMusicNote :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="snap-btn add-kf-btn"
|
||||
v-tooltip="t('timeline.addKeyframeTooltip')"
|
||||
@@ -2426,7 +2722,9 @@ onBeforeUnmount(() => {
|
||||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||||
: track.type === 'script'
|
||||
? `s:${track.scriptTrackId}`
|
||||
: `gr:${track.groupId}`
|
||||
: track.type === 'audio'
|
||||
? `a:${track.audioTrackId}`
|
||||
: `gr:${track.groupId}`
|
||||
">
|
||||
<!-- Layer track -->
|
||||
<div
|
||||
@@ -2784,6 +3082,61 @@ onBeforeUnmount(() => {
|
||||
</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 -->
|
||||
<div
|
||||
v-else-if="track.type === 'group'"
|
||||
@@ -3019,6 +3372,23 @@ onBeforeUnmount(() => {
|
||||
{{ t('timeline.renameScriptTrack') }}
|
||||
</button>
|
||||
</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-if="trackMenu.kind === 'layer'">
|
||||
<div class="kf-menu-divider"></div>
|
||||
@@ -3075,7 +3445,9 @@ onBeforeUnmount(() => {
|
||||
? t('timeline.deleteClipTrack')
|
||||
: trackMenu.kind === 'script'
|
||||
? t('timeline.deleteScriptTrack')
|
||||
: t('timeline.ungroup')
|
||||
: trackMenu.kind === 'audio'
|
||||
? t('timeline.deleteAudioTrack')
|
||||
: t('timeline.ungroup')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -4204,6 +4576,96 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
Reference in new issue
Block a user