diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 88dd2659..53d42c7b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -481,7 +481,9 @@ interface SettingsPanelProps { onClipDelete?: (id: string) => void; selectedAudioId?: string | null; selectedAudioVolume?: number | null; + selectedAudioNormalize?: boolean | null; onAudioVolumeChange?: (volume: number) => void; + onAudioNormalizeChange?: (normalize: boolean) => void; onAudioDelete?: (id: string) => void; shadowIntensity?: number; onShadowChange?: (intensity: number) => void; @@ -881,7 +883,9 @@ export function SettingsPanel({ onClipDelete, selectedAudioId, selectedAudioVolume, + selectedAudioNormalize, onAudioVolumeChange, + onAudioNormalizeChange, onAudioDelete, shadowIntensity = 0.67, onShadowChange, @@ -3049,16 +3053,16 @@ export function SettingsPanel({ ); - const audioSectionContent = ( -
+ const audioSectionContent = ( +
{tSettings("audio.volumeTitle", "Audio")} {Math.round((selectedAudioVolume ?? 1) * 100)}%
- onAudioVolumeChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} - /> -
- ); + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> +
+ + {tSettings("audio.normalize", "Normalize")} + + onAudioNormalizeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+
+ ); const clipSectionContent = (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 5ca4c864..83992392 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3776,16 +3776,17 @@ export default function VideoEditor() { } }, []); - const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { - const id = `audio-${nextAudioIdRef.current++}`; - const newRegion: AudioRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - audioPath, - volume: 1, - trackIndex, - }; + const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { + const id = `audio-${nextAudioIdRef.current++}`; + const newRegion: AudioRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + audioPath, + volume: 1, + normalize: false, + trackIndex, + }; setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); setSelectedZoomId(null); @@ -3835,15 +3836,29 @@ export default function VideoEditor() { [selectedAudioId], ); - const handleAudioDelete = useCallback( - (id: string) => { + const handleAudioDelete = useCallback( + (id: string) => { setAudioRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAudioId === id) { setSelectedAudioId(null); } }, - [selectedAudioId], - ); + [selectedAudioId], + ); + + const handleAudioNormalizeChange = useCallback( + (normalize: boolean) => { + if (!selectedAudioId) { + return; + } + setAudioRegions((prev) => + prev.map((region) => + region.id === selectedAudioId ? { ...region, normalize } : region, + ), + ); + }, + [selectedAudioId], + ); const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -5761,14 +5776,21 @@ export default function VideoEditor() { audio.onSelectedClipSourceAudioTrackNormalizeChange } selectedAudioId={selectedAudioId} - selectedAudioVolume={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.volume ?? null) - : null - } - onAudioVolumeChange={handleAudioVolumeChange} - onAudioDelete={handleAudioDelete} + selectedAudioVolume={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.volume ?? null) + : null + } + selectedAudioNormalize={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.normalize ?? false) + : null + } + onAudioVolumeChange={handleAudioVolumeChange} + onAudioNormalizeChange={handleAudioNormalizeChange} + onAudioDelete={handleAudioDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} backgroundBlur={backgroundBlur} diff --git a/src/components/video-editor/audio/sourceAudioTracks.ts b/src/components/video-editor/audio/sourceAudioTracks.ts index 8b9998c3..b3ea2cee 100644 --- a/src/components/video-editor/audio/sourceAudioTracks.ts +++ b/src/components/video-editor/audio/sourceAudioTracks.ts @@ -1,9 +1,5 @@ +import { getSourceTrackIdFromPath } from "@/lib/exporter/sourceTrackRoutingPolicy"; + export const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; export const SOURCE_AUDIO_NORMALIZE_GAIN = 1.35; - -export function getSourceTrackIdFromPath(audioPath: string): "mic" | "system" | "mixed" { - const normalized = audioPath.toLowerCase(); - if (normalized.includes(".mic.")) return "mic"; - if (normalized.includes(".system.")) return "system"; - return "mixed"; -} +export { getSourceTrackIdFromPath }; diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts index 3d05c858..78482460 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -1,4 +1,5 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { buildResolvedAudioPlan } from "@/lib/exporter/audioRoutingEngine"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { clampMediaTimeToDuration, @@ -40,6 +41,24 @@ export function useAudioPreviewSync({ getSourceTrackPreviewGain, onSourceFallbackLoadError, }: UseAudioPreviewSyncParams) { + const resolvedPlan = useMemo( + () => + buildResolvedAudioPlan({ + videoResource: null, + sourceAudioFallbackPaths: previewSourceAudioFallbackPaths, + audioRegions, + }), + [audioRegions, previewSourceAudioFallbackPaths], + ); + const resolvedUserTracks = useMemo( + () => resolvedPlan.tracks.filter((track) => track.kind === "user"), + [resolvedPlan], + ); + const resolvedSourceTracks = useMemo( + () => resolvedPlan.tracks.filter((track) => track.kind !== "user"), + [resolvedPlan], + ); + const audioElementsRef = useRef>(new Map()); const audioElementRevokersRef = useRef void>>(new Map()); const audioElementResourcesRef = useRef>(new Map()); @@ -84,7 +103,7 @@ export function useAudioPreviewSync({ useEffect(() => { let cancelled = false; const existing = audioElementsRef.current; - const currentIds = new Set(audioRegions.map((r) => r.id)); + const currentIds = new Set(resolvedUserTracks.map((track) => track.id)); for (const [id, audio] of existing) { if (!currentIds.has(id)) { @@ -97,51 +116,51 @@ export function useAudioPreviewSync({ } } - for (const region of audioRegions) { - let audio = existing.get(region.id); + for (const track of resolvedUserTracks) { + let audio = existing.get(track.id); if (!audio) { audio = new Audio(); audio.preload = "auto"; - existing.set(region.id, audio); + existing.set(track.id, audio); } - if (audioElementResourcesRef.current.get(region.id) !== region.audioPath) { + if (audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path) { audio.pause(); audio.src = ""; - audioElementRevokersRef.current.get(region.id)?.(); - audioElementRevokersRef.current.delete(region.id); - audioElementResourcesRef.current.set(region.id, region.audioPath); + audioElementRevokersRef.current.get(track.id)?.(); + audioElementRevokersRef.current.delete(track.id); + audioElementResourcesRef.current.set(track.id, track.sourceRef.path); void (async () => { - const resolved = await resolveMediaElementSource(region.audioPath); - const latestAudio = existing.get(region.id); + const resolved = await resolveMediaElementSource(track.sourceRef.path); + const latestAudio = existing.get(track.id); if ( cancelled || latestAudio !== audio || - audioElementResourcesRef.current.get(region.id) !== region.audioPath + audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path ) { resolved.revoke(); return; } - audioElementRevokersRef.current.set(region.id, resolved.revoke); + audioElementRevokersRef.current.set(track.id, resolved.revoke); latestAudio.src = resolved.src; })(); } - audio.volume = Math.max(0, Math.min(1, region.volume * previewVolume)); + audio.volume = Math.max(0, Math.min(1, track.gain * previewVolume)); } return () => { cancelled = true; }; - }, [audioRegions, previewVolume]); + }, [previewVolume, resolvedUserTracks]); useEffect(() => { let cancelled = false; const existing = sourceAudioElementsRef.current; - const currentIds = new Set(previewSourceAudioFallbackPaths); + const currentIds = new Set(resolvedSourceTracks.map((track) => track.sourceRef.path)); for (const [id, audio] of existing) { if (!currentIds.has(id)) { @@ -158,7 +177,8 @@ export function useAudioPreviewSync({ } } - for (const audioPath of previewSourceAudioFallbackPaths) { + for (const track of resolvedSourceTracks) { + const audioPath = track.sourceRef.path; let audio = existing.get(audioPath); if (!audio) { audio = new Audio(); @@ -237,7 +257,7 @@ export function useAudioPreviewSync({ : Math.max(0, Math.min(1, previewVolume)); } - if (previewSourceAudioFallbackPaths.length === 0) { + if (resolvedSourceTracks.length === 0) { lastSourceAudioSyncTimeRef.current = null; } @@ -248,7 +268,7 @@ export function useAudioPreviewSync({ getSourceTrackPreviewGain, isCurrentClipMuted, onSourceFallbackLoadError, - previewSourceAudioFallbackPaths, + resolvedSourceTracks, previewVolume, ]); @@ -303,15 +323,17 @@ export function useAudioPreviewSync({ ); const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1; - for (const region of audioRegions) { - const audio = audioElementsRef.current.get(region.id); + for (const track of resolvedUserTracks) { + const audio = audioElementsRef.current.get(track.id); if (!audio) continue; - const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs; + const startMs = track.timelineBinding.startMs; + const endMs = track.timelineBinding.endMs; + const isInRegion = currentTimeMs >= startMs && currentTimeMs < endMs; if (isPlaying && isInRegion) { enablePitchPreservingPlayback(audio); - const audioOffset = (currentTimeMs - region.startMs) / 1000; + const audioOffset = (currentTimeMs - startMs) / 1000; if (Math.abs(audio.currentTime - audioOffset) > 0.2) { audio.currentTime = audioOffset; } @@ -330,10 +352,10 @@ export function useAudioPreviewSync({ audio.pause(); } } - }, [audioRegions, timelineTime, effectiveSpeedRegions, isPlaying]); + }, [effectiveSpeedRegions, isPlaying, resolvedUserTracks, timelineTime]); useEffect(() => { - if (previewSourceAudioFallbackPaths.length === 0) { + if (resolvedSourceTracks.length === 0) { lastSourceAudioSyncTimeRef.current = null; return; } @@ -418,12 +440,12 @@ export function useAudioPreviewSync({ isCurrentClipMuted, isPlaying, previewVolume, - previewSourceAudioFallbackPaths, + resolvedSourceTracks, sourceAudioFallbackStartDelayMsByPath, ]); useEffect(() => { - if (!isPlaying || previewSourceAudioFallbackPaths.length === 0) { + if (!isPlaying || resolvedSourceTracks.length === 0) { return; } void ensureSourceAudioRunning().then(() => { @@ -433,5 +455,5 @@ export function useAudioPreviewSync({ } } }); - }, [isPlaying, previewSourceAudioFallbackPaths]); + }, [isPlaying, resolvedSourceTracks.length]); } diff --git a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts index 34ed0ee3..54b2d751 100644 --- a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts +++ b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts @@ -24,6 +24,19 @@ export interface UseSourceAudioTrackSettingsResult { onSelectedClipSourceAudioTrackNormalizeChange: (id: string, normalize: boolean) => void; } +function isSameTrackMeta(left: SourceAudioTrackMeta, right: SourceAudioTrackMeta): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftTrack = left[index]; + const rightTrack = right[index]; + if (!leftTrack || !rightTrack) return false; + if (leftTrack.id !== rightTrack.id || leftTrack.label !== rightTrack.label) { + return false; + } + } + return true; +} + export function useSourceAudioTrackSettings({ selectedClipId, activeClipId, @@ -55,13 +68,31 @@ export function useSourceAudioTrackSettings({ }, [defaultSourceAudioTrackSettings, selectedClipId, sourceAudioTrackSettingsByClip]); const onSourceAudioTracksMetaChange = useCallback((tracks: SourceAudioTrackMeta) => { - setSourceAudioTrackMeta(tracks); + setSourceAudioTrackMeta((prev) => (isSameTrackMeta(prev, tracks) ? prev : tracks)); setDefaultSourceAudioTrackSettings((prev) => { const next: SourceAudioTrackSettings = {}; for (const track of tracks) { next[track.id] = prev[track.id] ?? { volume: 1, normalize: false }; } - return next; + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length !== nextKeys.length) { + return next; + } + for (const key of nextKeys) { + const prevSetting = prev[key]; + const nextSetting = next[key]; + if (!prevSetting || !nextSetting) { + return next; + } + if ( + prevSetting.volume !== nextSetting.volume || + prevSetting.normalize !== nextSetting.normalize + ) { + return next; + } + } + return prev; }); }, []); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 49f74348..4ba24920 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -654,16 +654,17 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - return { - id: region.id, - startMs, - endMs, - audioPath: typeof region.audioPath === "string" ? region.audioPath : "", - volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, - trackIndex: isFiniteNumber(region.trackIndex) - ? Math.max(0, Math.floor(region.trackIndex)) - : 0, - }; + return { + id: region.id, + startMs, + endMs, + audioPath: typeof region.audioPath === "string" ? region.audioPath : "", + volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, + normalize: Boolean(region.normalize), + trackIndex: isFiniteNumber(region.trackIndex) + ? Math.max(0, Math.floor(region.trackIndex)) + : 0, + }; }) : []; diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index a1617779..806593c5 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -263,16 +263,18 @@ function AudioItemWithWaveform({ return { start: 0, end: duration }; }, [waveformSpan.end, waveformSpan.start]); return ( - + {item.label} ); diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts index 1fc9a66a..3464b8ad 100644 --- a/src/components/video-editor/timeline/core/timelineTypes.ts +++ b/src/components/video-editor/timeline/core/timelineTypes.ts @@ -33,6 +33,8 @@ export interface TimelineRenderItem { span: Span; label: string; audioPath?: string; + audioGain?: number; + audioNormalize?: boolean; zoomDepth?: number; zoomMode?: ZoomMode; speedValue?: number; diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index 600859a5..05073c97 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -70,6 +70,8 @@ export function buildTimelineItems(params: { span: { start: region.startMs, end: region.endMs }, label: getAudioLabel(region), audioPath: region.audioPath, + audioGain: region.volume, + audioNormalize: Boolean(region.normalize), variant: "audio", })); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 838b4646..3e18f720 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -480,6 +480,7 @@ export interface AudioRegion { endMs: number; audioPath: string; volume: number; + normalize?: boolean; trackIndex?: number; } diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index c6f014f8..9e077f25 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -6,13 +6,14 @@ import type { SourceAudioTrackSettings, TrimRegion, } from "@/components/video-editor/types"; +import { + buildResolvedAudioPlan, + getSourceTrackIdFromPath, +} from "@/lib/exporter/audioRoutingEngine"; import { estimateCompanionAudioStartDelaySeconds } from "@/lib/mediaTiming"; import { resolveMediaElementSource } from "./localMediaSource"; import type { VideoMuxer } from "./muxer"; -import { - getSourceTrackIdFromPath, - resolveSourceTrackRoutingPolicy, -} from "./sourceTrackRoutingPolicy"; +import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy"; const AUDIO_BITRATE = 128_000; const DECODE_BACKPRESSURE_LIMIT = 20; @@ -22,6 +23,7 @@ const MP4_AUDIO_CODEC = "mp4a.40.2"; const OFFLINE_AUDIO_SAMPLE_RATE = 48_000; const OFFLINE_ENCODE_CHUNK_FRAMES = 1024; const OFFLINE_CHUNK_DURATION_SEC = 30; +const USER_AUDIO_NORMALIZE_GAIN = 1.35; interface TimelineSlice { sourceStartMs: number; @@ -600,13 +602,28 @@ export class AudioProcessor { if (this.cancelled) throw new Error("Export cancelled"); this.onProgress?.(0); - const routingPolicy = resolveSourceTrackRoutingPolicy( - videoUrl, + const resolvedPlan = buildResolvedAudioPlan({ + videoResource: videoUrl, sourceAudioFallbackPaths, - ); + audioRegions, + sourceTrackGainById: { + mic: Math.max(0, Math.min(2, sourceAudioTrackSettings?.mic?.volume ?? 1)), + system: Math.max(0, Math.min(2, sourceAudioTrackSettings?.system?.volume ?? 1)), + mixed: Math.max(0, Math.min(2, sourceAudioTrackSettings?.mixed?.volume ?? 1)), + }, + embeddedGain: Math.max( + 0, + Math.min( + 2, + sourceAudioTrackSettings?.mixed?.volume ?? + sourceAudioTrackSettings?.system?.volume ?? + 1, + ), + ), + }); // Decode embedded source audio separately from companion sidecars. - const mainBuffer = routingPolicy.includeEmbeddedInExport + const mainBuffer = resolvedPlan.includeEmbeddedInExport ? await this.decodeAudioFromUrl(videoUrl) : null; const mainBufferGainSettings = @@ -622,8 +639,8 @@ export class AudioProcessor { []; const refDuration = mainBuffer?.duration ?? - (routingPolicy.playbackPaths.length > 0 ? await this.getMediaDurationSec(videoUrl) : 0); - for (const audioPath of routingPolicy.playbackPaths) { + (resolvedPlan.playbackPaths.length > 0 ? await this.getMediaDurationSec(videoUrl) : 0); + for (const audioPath of resolvedPlan.playbackPaths) { if (this.cancelled) throw new Error("Export cancelled"); const buffer = await this.decodeAudioFromUrl(audioPath); if (!buffer) continue; @@ -662,7 +679,7 @@ export class AudioProcessor { let sourceDurationSec: number; if (mainBufferEntry?.buffer) { sourceDurationSec = mainBufferEntry.buffer.duration; - } else if (routingPolicy.playbackPaths.length > 0 || regionEntries.length > 0) { + } else if (resolvedPlan.playbackPaths.length > 0 || regionEntries.length > 0) { sourceDurationSec = await this.getMediaDurationSec(videoUrl); } else { sourceDurationSec = primaryBuffer?.duration ?? 0; @@ -892,7 +909,8 @@ export class AudioProcessor { if (duration <= 0.001) return; const gainNode = ctx.createGain(); - gainNode.gain.value = Math.max(0, Math.min(1, region.volume)); + const normalizeGain = region.normalize ? USER_AUDIO_NORMALIZE_GAIN : 1; + gainNode.gain.value = Math.max(0, Math.min(1, region.volume * normalizeGain)); gainNode.connect(ctx.destination); const source = ctx.createBufferSource(); diff --git a/src/lib/exporter/audioRoutingEngine.ts b/src/lib/exporter/audioRoutingEngine.ts new file mode 100644 index 00000000..a9a1a002 --- /dev/null +++ b/src/lib/exporter/audioRoutingEngine.ts @@ -0,0 +1,129 @@ +import type { AudioRegion } from "@/components/video-editor/types"; +import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; + +export type SourceTrackId = "mic" | "system" | "mixed"; +export type ResolvedAudioTrackKind = "user" | "system" | "mic" | "mixed" | "embedded"; +const USER_AUDIO_NORMALIZE_GAIN = 1.35; + +export interface ResolvedAudioTrack { + id: string; + kind: ResolvedAudioTrackKind; + sourceRef: { + path: string; + startDelayMs: number; + }; + gain: number; + timelineBinding: { + startMs: number; + endMs: number; + }; +} + +export interface ResolvedAudioPlan { + hasEmbeddedSourceAudio: boolean; + pathsByTrack: Partial>; + playbackPaths: string[]; + muteEmbeddedPreview: boolean; + includeEmbeddedInExport: boolean; + tracks: ResolvedAudioTrack[]; + masterGain: number; +} + +export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId { + const normalized = audioPath.toLowerCase(); + if (normalized.includes(".mic.")) return "mic"; + if (normalized.includes(".system.")) return "system"; + return "mixed"; +} + +function clampGain(value: number, max: number) { + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.min(max, value)); +} + +export function buildResolvedAudioPlan(input: { + videoResource: string | null | undefined; + sourceAudioFallbackPaths: string[] | null | undefined; + audioRegions?: AudioRegion[]; + sourceTrackGainById?: Partial>; + embeddedGain?: number; + masterGain?: number; +}): ResolvedAudioPlan { + const { hasEmbeddedSourceAudio, externalAudioPaths } = resolveSourceAudioFallbackPaths( + input.videoResource, + input.sourceAudioFallbackPaths, + ); + + const pathsByTrack: Partial> = {}; + for (const path of externalAudioPaths) { + const trackId = getSourceTrackIdFromPath(path); + if (!pathsByTrack[trackId]) { + pathsByTrack[trackId] = path; + } + } + + const hasDedicatedTracks = Boolean(pathsByTrack.system || pathsByTrack.mic); + const playbackPaths: string[] = []; + if (pathsByTrack.system) playbackPaths.push(pathsByTrack.system); + if (pathsByTrack.mic) playbackPaths.push(pathsByTrack.mic); + if (!hasDedicatedTracks && pathsByTrack.mixed) playbackPaths.push(pathsByTrack.mixed); + + const includeEmbeddedInExport = !pathsByTrack.system && !pathsByTrack.mixed; + const resolvedRegions = (input.audioRegions ?? []).slice().sort((a, b) => a.startMs - b.startMs); + const tracks: ResolvedAudioTrack[] = resolvedRegions.map((region) => ({ + id: `user:${region.id}`, + kind: "user", + sourceRef: { + path: region.audioPath, + startDelayMs: 0, + }, + gain: clampGain(region.volume * (region.normalize ? USER_AUDIO_NORMALIZE_GAIN : 1), 1), + timelineBinding: { + startMs: Math.max(0, region.startMs), + endMs: Math.max(0, region.endMs), + }, + })); + + for (const audioPath of playbackPaths) { + const trackId = getSourceTrackIdFromPath(audioPath); + tracks.push({ + id: `${trackId}:${audioPath}`, + kind: trackId, + sourceRef: { + path: audioPath, + startDelayMs: 0, + }, + gain: clampGain(input.sourceTrackGainById?.[trackId] ?? 1, 2), + timelineBinding: { + startMs: 0, + endMs: Number.POSITIVE_INFINITY, + }, + }); + } + + if (hasEmbeddedSourceAudio && input.videoResource) { + tracks.push({ + id: `embedded:${input.videoResource}`, + kind: "embedded", + sourceRef: { + path: input.videoResource, + startDelayMs: 0, + }, + gain: clampGain(input.embeddedGain ?? input.sourceTrackGainById?.mixed ?? 1, 2), + timelineBinding: { + startMs: 0, + endMs: Number.POSITIVE_INFINITY, + }, + }); + } + + return { + hasEmbeddedSourceAudio, + pathsByTrack, + playbackPaths, + muteEmbeddedPreview: hasDedicatedTracks, + includeEmbeddedInExport, + tracks, + masterGain: clampGain(input.masterGain ?? 1, 1), + }; +} diff --git a/src/lib/exporter/sourceTrackRoutingPolicy.ts b/src/lib/exporter/sourceTrackRoutingPolicy.ts index 67601362..f09e00ea 100644 --- a/src/lib/exporter/sourceTrackRoutingPolicy.ts +++ b/src/lib/exporter/sourceTrackRoutingPolicy.ts @@ -1,13 +1,10 @@ -import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; +import { + buildResolvedAudioPlan, + getSourceTrackIdFromPath, + type SourceTrackId, +} from "./audioRoutingEngine"; -export type SourceTrackId = "mic" | "system" | "mixed"; - -export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId { - const normalized = audioPath.toLowerCase(); - if (normalized.includes(".mic.")) return "mic"; - if (normalized.includes(".system.")) return "system"; - return "mixed"; -} +export { getSourceTrackIdFromPath, type SourceTrackId }; export interface SourceTrackRoutingPolicy { hasEmbeddedSourceAudio: boolean; @@ -21,30 +18,16 @@ export function resolveSourceTrackRoutingPolicy( videoResource: string | null | undefined, sourceAudioFallbackPaths: string[] | null | undefined, ): SourceTrackRoutingPolicy { - const { hasEmbeddedSourceAudio, externalAudioPaths } = resolveSourceAudioFallbackPaths( + const plan = buildResolvedAudioPlan({ videoResource, sourceAudioFallbackPaths, - ); - - const pathsByTrack: Partial> = {}; - for (const path of externalAudioPaths) { - const trackId = getSourceTrackIdFromPath(path); - if (!pathsByTrack[trackId]) { - pathsByTrack[trackId] = path; - } - } - - const hasDedicatedTracks = Boolean(pathsByTrack.system || pathsByTrack.mic); - const playbackPaths: string[] = []; - if (pathsByTrack.system) playbackPaths.push(pathsByTrack.system); - if (pathsByTrack.mic) playbackPaths.push(pathsByTrack.mic); - if (!hasDedicatedTracks && pathsByTrack.mixed) playbackPaths.push(pathsByTrack.mixed); + }); return { - hasEmbeddedSourceAudio, - pathsByTrack, - playbackPaths, - muteEmbeddedPreview: hasDedicatedTracks, - includeEmbeddedInExport: !pathsByTrack.system && !pathsByTrack.mixed, + hasEmbeddedSourceAudio: plan.hasEmbeddedSourceAudio, + pathsByTrack: plan.pathsByTrack, + playbackPaths: plan.playbackPaths, + muteEmbeddedPreview: plan.muteEmbeddedPreview, + includeEmbeddedInExport: plan.includeEmbeddedInExport, }; }