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

+170 -6
View File
@@ -4,6 +4,9 @@ import type {
Action,
AnimatableProps,
Asset,
AudioAsset,
AudioClip,
AudioTrack,
BackgroundVideo,
ButtonAsset,
ButtonState,
@@ -206,12 +209,15 @@ function normalizeScenePage(page: ScenePage): ScenePage {
page.markers ||= []
page.groups ||= []
page.trackOrder ||= []
page.audioTracks ||= []
page.audioClips ||= []
const known = new Set<string>()
for (const l of page.layers) known.add(l.id)
for (const t of page.clipTracks) known.add(t.id)
for (const g of page.groups) known.add(g.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>()
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) {
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) {
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) {
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
}
@@ -291,6 +307,8 @@ export function createBlankScenePage(name: string, id?: string): ScenePage {
markers: [],
groups: [],
trackOrder: [],
audioTracks: [],
audioClips: [],
}
}
@@ -324,6 +342,8 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
groups: scene.groups ?? [],
trackOrder: scene.trackOrder ?? [],
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
}
return normalizeProject({
version: 1,
@@ -416,6 +436,8 @@ export const useSceneStore = defineStore('scene', {
groups: page.groups,
trackOrder: page.trackOrder,
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks,
audioClips: page.audioClips,
}
},
activeClip(): Clip | null {
@@ -1572,6 +1594,114 @@ export const useSceneStore = defineStore('scene', {
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 --------------------------------------------
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,
* button-edit, selections, drawing) so the new page isn't shown
* through the lens of a context that belongs to another page.
* Every switch auto-starts playback — clicking a scene in the
* drawer or having a script/button jump to one always hands the
* user a running timeline on the new page. */
* Playback state is left alone — in the editor, flipping scenes
* is a navigation gesture and shouldn't force play. The fullscreen
* preview takes care of auto-playing on its own (the activeSceneId
* watcher re-mounts the preview player with play enabled). */
switchScene(id: string): boolean {
if (!this.project.scenes.find((s) => s.id === id)) return false
this.project.activeSceneId = id
@@ -1815,7 +1946,6 @@ export const useSceneStore = defineStore('scene', {
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
this.playing = true
return true
},
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 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) {
g.id = remapId(g.id)
g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid)
@@ -1889,6 +2020,11 @@ export const useSceneStore = defineStore('scene', {
id: uid(),
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() }))
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
* it remain — they'll just render as nothing. Use this from UI
* components instead of mutating `project.assets` inline. */
* it remain — they'll just render as nothing. Audio clips, however,
* 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) {
const asset = this.project.assets.find((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 {
@@ -2116,6 +2260,26 @@ export const useSceneStore = defineStore('scene', {
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
},
removeClipTrack(id: string) {