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
+170
-6
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user