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

+465 -3
View File
@@ -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;