fix: videoexports with audio (system/mic/mixed/user)... everything should work

This commit is contained in:
Alan Trebugeais committed 2026-05-09 20:18:22 +02:00
1 parent d3ab348239
commit 3211e515fe
9 files changed
+105 -17

No files matched your search

+12 -7
View File
@@ -3148,14 +3148,19 @@ export function SettingsPanel({
<SectionLabel>{tSettings("audio.title", "Audio")}</SectionLabel>
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{selectedClipMuted
? tSettings("clip.unmuteAudio", "Unmute Audio")
: tSettings("clip.muteAudio", "Mute Audio")}
</span>
<div>
<span className="text-[10px] text-muted-foreground">
{tSettings("clip.mute", "Mute")}
</span>
<p className="text-[9px] text-muted-foreground/50 mt-0.5">
{selectedClipMuted
? tSettings("clip.mutedState", "Audio is muted")
: tSettings("clip.unmutedState", "Audio is playing")}
</p>
</div>
<Switch
checked={!(selectedClipMuted ?? false)}
onCheckedChange={(v) => onClipMutedChange?.(!v)}
checked={selectedClipMuted ?? false}
onCheckedChange={(v) => onClipMutedChange?.(v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
@@ -4417,6 +4417,7 @@ export default function VideoEditor() {
cursorSway,
frame,
audioRegions,
clipRegions,
sourceAudioFallbackPaths: audio.sourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath:
audio.sourceAudioFallbackStartDelayMsByPath,
@@ -5,6 +5,7 @@ import {
MusicNotes as Music,
MouseLeftClickIcon as PhMouseLeftClick,
Scissors,
SpeakerX,
MagnifyingGlassPlus as ZoomIn,
} from "@phosphor-icons/react";
import type { Span } from "dnd-timeline";
@@ -31,6 +32,7 @@ interface ItemProps {
waveformSegmentSpan?: Span;
waveformGain?: number;
waveformNormalize?: boolean;
muted?: boolean;
variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
}
@@ -69,6 +71,7 @@ export default function Item({
waveformSegmentSpan,
waveformGain = 1,
waveformNormalize = false,
muted = false,
variant = "zoom",
children,
}: ItemProps) {
@@ -170,6 +173,12 @@ export default function Item({
className="absolute inset-0 w-full h-full pointer-events-none opacity-45"
/>
)}
{/* Muted overlay for source audio track items */}
{isAudio && muted && (
<div className="absolute inset-0 z-20 flex items-center justify-center gap-1 bg-red-900/40 pointer-events-none">
<SpeakerX className="w-3 h-3 text-red-300/90 shrink-0" />
</div>
)}
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-black/70 dark:text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
@@ -402,6 +402,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
waveformSegmentSpan={liveSpanPreviewById?.[item.id] ?? item.span}
waveformGain={Math.max(0, Math.min(2, settings.volume))}
waveformNormalize={Boolean(settings.normalize)}
muted={item.muted}
>
{track.label}
</Item>
@@ -39,6 +39,7 @@ export interface TimelineRenderItem {
zoomMode?: ZoomMode;
speedValue?: number;
showSourceAudio?: boolean;
muted?: boolean;
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
}
@@ -53,6 +53,7 @@ export function buildTimelineItems(params: {
span: { start: region.startMs, end: region.endMs },
label: `Clip ${index + 1}`,
showSourceAudio: region.showSourceAudio,
muted: Boolean(region.muted),
variant: "clip",
}));
+69 -8
View File
@@ -47,6 +47,7 @@ interface PreparedOfflineRender {
mainBufferEntry: { buffer: AudioBuffer; gain: number } | null;
companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number; gain: number }>;
regionEntries: Array<{ buffer: AudioBuffer; region: AudioRegion }>;
mutedSourceOutputRangesSec: Array<{ startSec: number; endSec: number }>;
slices: TimelineSlice[];
outputDurationMs: number;
numChannels: number;
@@ -163,6 +164,7 @@ export class AudioProcessor {
sourceAudioFallbackPaths?: string[],
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>,
sourceAudioTrackSettings?: SourceAudioTrackSettings,
clipRegions?: ClipRegion[],
): Promise<void> {
const sortedTrims = trimRegions
? [...trimRegions].sort((a, b) => a.startMs - b.startMs)
@@ -206,6 +208,7 @@ export class AudioProcessor {
sortedSourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
sourceAudioTrackSettings,
clipRegions,
muxer,
);
return;
@@ -238,6 +241,7 @@ export class AudioProcessor {
routingPolicy.playbackPaths,
sourceAudioFallbackStartDelayMsByPath,
sourceAudioTrackSettings,
clipRegions,
muxer,
);
return;
@@ -285,6 +289,7 @@ export class AudioProcessor {
sourceAudioFallbackPaths?: string[],
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>,
sourceAudioTrackSettings?: SourceAudioTrackSettings,
clipRegions?: ClipRegion[],
): Promise<Blob> {
const sortedTrims = trimRegions
? [...trimRegions].sort((a, b) => a.startMs - b.startMs)
@@ -311,6 +316,7 @@ export class AudioProcessor {
sortedSourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
sourceAudioTrackSettings,
clipRegions,
);
return this.renderToWavBlobChunked(prepared);
}
@@ -587,6 +593,7 @@ export class AudioProcessor {
sourceAudioFallbackPaths: string[],
sourceAudioFallbackStartDelayMsByPath: Record<string, number> | undefined,
sourceAudioTrackSettings: SourceAudioTrackSettings | undefined,
clipRegions: ClipRegion[] | undefined,
muxer: VideoMuxer,
): Promise<void> {
const prepared = await this.prepareOfflineRender(
@@ -597,6 +604,7 @@ export class AudioProcessor {
sourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
sourceAudioTrackSettings,
clipRegions,
);
if (this.cancelled) return;
await this.renderAndEncodeChunked(prepared, muxer);
@@ -610,6 +618,7 @@ export class AudioProcessor {
sourceAudioFallbackPaths: string[],
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>,
sourceAudioTrackSettings?: SourceAudioTrackSettings,
clipRegions?: ClipRegion[],
): Promise<PreparedOfflineRender> {
if (this.cancelled) throw new Error("Export cancelled");
this.onProgress?.(0);
@@ -711,11 +720,24 @@ export class AudioProcessor {
}
const numChannels = Math.min(primaryBuffer?.numberOfChannels ?? 2, 2);
const mutedSourceOutputRangesSec = (clipRegions ?? [])
.filter(
(clip) =>
Boolean(clip.muted) &&
Number.isFinite(clip.startMs) &&
Number.isFinite(clip.endMs) &&
clip.endMs > clip.startMs,
)
.map((clip) => ({
startSec: Math.max(0, clip.startMs / 1000),
endSec: Math.max(0, clip.endMs / 1000),
}));
return {
mainBufferEntry,
companionEntries,
regionEntries,
mutedSourceOutputRangesSec,
slices,
outputDurationMs,
numChannels,
@@ -849,6 +871,7 @@ export class AudioProcessor {
prepared.mainBufferEntry.gain,
outputOffsetSec,
chunkSec,
prepared.mutedSourceOutputRangesSec,
);
}
@@ -862,6 +885,7 @@ export class AudioProcessor {
entry.gain,
outputOffsetSec,
chunkSec,
prepared.mutedSourceOutputRangesSec,
);
}
@@ -1323,6 +1347,7 @@ export class AudioProcessor {
gain = 1,
chunkOutputStartSec = 0,
chunkDurationSec = Number.POSITIVE_INFINITY,
mutedOutputRangesSec: Array<{ startSec: number; endSec: number }> = [],
): void {
let outputOffsetSec = 0;
@@ -1386,15 +1411,51 @@ export class AudioProcessor {
continue;
}
const source = ctx.createBufferSource();
const gainNode = ctx.createGain();
gainNode.gain.value = Math.max(0, Math.min(2, gain));
source.buffer = buffer;
source.playbackRate.value = slice.speed;
source.connect(gainNode);
gainNode.connect(ctx.destination);
const audibleRanges: Array<{ startSec: number; endSec: number }> = [
{
startSec: localOutputStartSec + chunkOutputStartSec,
endSec:
localOutputStartSec + chunkOutputStartSec + effectiveSourceDurationSec / slice.speed,
},
];
for (const mutedRange of mutedOutputRangesSec) {
for (let rangeIndex = audibleRanges.length - 1; rangeIndex >= 0; rangeIndex -= 1) {
const current = audibleRanges[rangeIndex];
const overlapStart = Math.max(current.startSec, mutedRange.startSec);
const overlapEnd = Math.min(current.endSec, mutedRange.endSec);
if (overlapEnd <= overlapStart) {
continue;
}
audibleRanges.splice(rangeIndex, 1);
if (current.startSec < overlapStart) {
audibleRanges.push({ startSec: current.startSec, endSec: overlapStart });
}
if (overlapEnd < current.endSec) {
audibleRanges.push({ startSec: overlapEnd, endSec: current.endSec });
}
}
}
source.start(localOutputStartSec, effectiveBufferStartSec, effectiveSourceDurationSec);
for (const audibleRange of audibleRanges) {
const audibleDurationSec = audibleRange.endSec - audibleRange.startSec;
if (audibleDurationSec <= 0.001) {
continue;
}
const source = ctx.createBufferSource();
const gainNode = ctx.createGain();
gainNode.gain.value = Math.max(0, Math.min(2, gain));
source.buffer = buffer;
source.playbackRate.value = slice.speed;
source.connect(gainNode);
gainNode.connect(ctx.destination);
const sourceOffsetSec =
effectiveBufferStartSec +
(audibleRange.startSec - (localOutputStartSec + chunkOutputStartSec)) * slice.speed;
const localStartSec = audibleRange.startSec - chunkOutputStartSec;
const sourceDurationSec = audibleDurationSec * slice.speed;
source.start(localStartSec, sourceOffsetSec, sourceDurationSec);
}
outputOffsetSec += sliceOutputDurationSec;
}
+5 -1
View File
@@ -3,6 +3,7 @@ import type {
AudioRegion,
AutoCaptionSettings,
CaptionCue,
ClipRegion,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
@@ -136,6 +137,7 @@ interface VideoExporterConfig extends ExportConfig {
zoomClassicMode?: boolean;
frame?: string | null;
audioRegions?: AudioRegion[];
clipRegions?: ClipRegion[];
sourceAudioFallbackPaths?: string[];
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>;
sourceAudioTrackSettings?: SourceAudioTrackSettings;
@@ -1195,7 +1197,8 @@ export class ModernVideoExporter {
audioRegions.length > 0 ||
sourceAudioFallbackPaths.length > 1 ||
hasTimedSourceAudioFallback ||
hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings)
hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings) ||
(this.config.clipRegions ?? []).some((clip) => Boolean(clip.muted))
) {
const sourceDurationMs = Math.max(
0,
@@ -1820,6 +1823,7 @@ export class ModernVideoExporter {
this.config.sourceAudioFallbackPaths,
this.config.sourceAudioFallbackStartDelayMsByPath,
this.config.sourceAudioTrackSettings,
this.config.clipRegions,
),
description,
"audio",
+6 -1
View File
@@ -3,6 +3,7 @@ import type {
AudioRegion,
AutoCaptionSettings,
CaptionCue,
ClipRegion,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
@@ -91,6 +92,7 @@ interface VideoExporterConfig extends ExportConfig {
zoomSmoothness?: number;
frame?: string | null;
audioRegions?: AudioRegion[];
clipRegions?: ClipRegion[];
sourceAudioFallbackPaths?: string[];
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>;
sourceAudioTrackSettings?: SourceAudioTrackSettings;
@@ -574,7 +576,8 @@ export class VideoExporter {
audioRegions.length > 0 ||
sourceAudioFallbackPaths.length > 1 ||
hasTimedSourceAudioFallback ||
hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings)
hasNonDefaultSourceTrackSettings(this.config.sourceAudioTrackSettings) ||
(this.config.clipRegions ?? []).some((clip) => Boolean(clip.muted))
) {
const sourceDurationMs = Math.max(
0,
@@ -862,6 +865,7 @@ export class VideoExporter {
this.config.sourceAudioFallbackPaths,
this.config.sourceAudioFallbackStartDelayMsByPath,
this.config.sourceAudioTrackSettings,
this.config.clipRegions,
),
"native edited audio rendering",
"audio",
@@ -959,6 +963,7 @@ export class VideoExporter {
this.config.sourceAudioFallbackPaths,
this.config.sourceAudioFallbackStartDelayMsByPath,
this.config.sourceAudioTrackSettings,
this.config.clipRegions,
),
"ffmpeg edited audio rendering",
"audio",