add: project persistence of audio settings

This commit is contained in:
Alan Trebugeais
2026-05-09 18:07:28 +02:00
parent 934cd470fd
commit f4af06d430
17 changed files with 366 additions and 187 deletions
+5 -4
View File
@@ -499,20 +499,21 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) {
let paths: string[];
if (await hasEmbeddedAudioStream(videoPath)) {
const microphoneCompanionPaths = Array.from(
const companionPaths = Array.from(
new Set(
companionCandidates.flatMap((candidate) =>
candidate.usablePaths.filter(
(companionPath) => companionPath === candidate.micPath,
(companionPath) =>
companionPath === candidate.micPath || companionPath === candidate.systemPath,
),
),
),
);
if (microphoneCompanionPaths.length === 0) {
if (companionPaths.length === 0) {
return { paths: [], startDelayMsByPath: {} };
}
paths = [videoPath, ...microphoneCompanionPaths];
paths = [videoPath, ...companionPaths];
} else {
paths = Array.from(
new Set(companionCandidates.flatMap((candidate) => candidate.usablePaths)),
-6
View File
@@ -277,12 +277,6 @@ export async function muxNativeMacRecordingWithAudio(
await moveFileWithOverwrite(mixedOutputPath, videoPath);
console.log("[mux] Successfully muxed audio into video:", videoPath);
for (const audioPath of [systemAudioPath, microphonePath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => undefined);
}
}
}
export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) {
+1 -25
View File
@@ -474,30 +474,6 @@ export async function muxNativeWindowsVideoWithAudio(
throw error;
}
if (keepAudioSidecars) {
console.log(
`[mux-win] Keeping native audio sidecars because ${RECORDING_AUDIO_SIDECAR_DEBUG_ENV} is enabled`,
);
return {
muxed: true,
videoDurationSeconds: videoDuration,
muxTimeoutMs,
audioInputs,
audio,
outputPath: videoPath,
keptAudioSidecars: true,
};
}
for (const audioPath of [systemAudioPath, micAudioPath]) {
if (audioPath) {
await Promise.all([
fs.rm(audioPath, { force: true }).catch(() => undefined),
fs.rm(`${audioPath}.json`, { force: true }).catch(() => undefined),
]);
}
}
return {
muxed: true,
videoDurationSeconds: videoDuration,
@@ -505,6 +481,6 @@ export async function muxNativeWindowsVideoWithAudio(
audioInputs,
audio,
outputPath: videoPath,
keptAudioSidecars: false,
keptAudioSidecars: true,
};
}
@@ -139,7 +139,8 @@ export function AnnotationSettingsPanel({
};
return (
<div className="flex-[2] min-w-0 bg-editor-panel border border-foreground/10 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="flex-[2] min-w-0 bg-editor-panel border border-foreground/10 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 min-h-0 p-4 overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-foreground">
@@ -772,16 +773,6 @@ export function AnnotationSettingsPanel({
</TabsContent>
</Tabs>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotations.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-foreground/5 rounded-lg border border-foreground/5">
<div className="flex items-center gap-2 mb-2 text-muted-foreground">
<Info className="w-3.5 h-3.5" />
@@ -796,6 +787,18 @@ export function AnnotationSettingsPanel({
</ul>
</div>
</div>
<div className="flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3">
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
{t("annotations.deleteAnnotation")}
</Button>
</div>
</div>
</div>
);
}
+160 -117
View File
@@ -3049,6 +3049,28 @@ export function SettingsPanel({
</section>
);
const audioSectionContent = (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel>
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]">
{Math.round((selectedAudioVolume ?? 1) * 100)}%
</span>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={selectedAudioVolume ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.01}
onChange={(v) => onAudioVolumeChange?.(v)}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
</section>
);
const clipSectionContent = (
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
@@ -3101,44 +3123,88 @@ export function SettingsPanel({
})}
</div>
<div className="mt-2 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>
<Switch
checked={!(selectedClipMuted ?? false)}
onCheckedChange={(v) => onClipMutedChange?.(!v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
{hasClipSourceAudio && (
<div className="mt-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<div className="mt-2 flex flex-col gap-2 border-t border-foreground/5 pt-3">
<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">
{tSettings("clip.separateClipFromAudio", "Separate clip from audio")}
{selectedClipMuted
? tSettings("clip.unmuteAudio", "Unmute Audio")
: tSettings("clip.muteAudio", "Mute Audio")}
</span>
<Switch
checked={selectedClipShowSourceAudio ?? false}
onCheckedChange={(v) => onClipShowSourceAudioChange?.(v)}
checked={!(selectedClipMuted ?? false)}
onCheckedChange={(v) => onClipMutedChange?.(!v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
)}
{hasClipSourceAudio && (
<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">
{tSettings("clip.separateClipFromAudio", "Separate clip from audio")}
</span>
<Switch
checked={selectedClipShowSourceAudio ?? false}
onCheckedChange={(v) => onClipShowSourceAudioChange?.(v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
)}
</div>
{selectedClipId && (
<Button
onClick={() => {
if (selectedClipId && onClipDelete) onClipDelete(selectedClipId);
}}
variant="destructive"
size="sm"
className="mt-1 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("clip.delete", "Delete Clip")}
</Button>
)}
{selectedClipId &&
hasClipSourceAudio &&
selectedClipShowSourceAudio &&
sourceAudioTrackMeta.length > 0 && (
<div className="mt-1 flex flex-col gap-3">
{sourceAudioTrackMeta.map((track) => {
const settings = sourceAudioTrackSettings[track.id] ?? {
volume: 1,
normalize: false,
};
return (
<div
key={track.id}
className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground">
{track.label}
</span>
<span className="text-[10px] text-muted-foreground">
{Math.round(settings.volume * 100)}%
</span>
</div>
<div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("audio.normalize", "Normalize")}
</span>
<Switch
checked={settings.normalize}
onCheckedChange={(v) =>
onSourceAudioTrackNormalizeChange?.(track.id, v)
}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={settings.volume}
defaultValue={1}
min={0}
max={2}
step={0.01}
onChange={(v) => onSourceAudioTrackVolumeChange?.(track.id, v)}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) =>
parseFloat(text.replace(/%$/, "")) / 100
}
/>
</div>
);
})}
</div>
)}
</section>
);
@@ -3151,6 +3217,8 @@ export function SettingsPanel({
return zoomItemSectionContent;
case "clip":
return clipSectionContent;
case "audio":
return audioSectionContent;
case "frame":
return sceneSectionContent;
case "crop":
@@ -3592,94 +3660,69 @@ export function SettingsPanel({
<div
className={cn(
"flex-shrink-0 border-t border-foreground/10 bg-editor-header p-4 pt-3",
!selectedAudioId &&
!(selectedClipId && hasClipSourceAudio && selectedClipShowSourceAudio) &&
"hidden",
"flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3",
(() => {
if (activeEffectSection === "clip" && selectedClipId) return false;
if (activeEffectSection === "zoom" && selectedZoomId) return false;
if (activeEffectSection === "audio" && selectedAudioId) return false;
if (selectedAnnotationId) return false; // Annotation editor handles its own but let's see
return true;
})() && "hidden",
)}
>
{selectedAudioId ? (
<div>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{tSettings("audio.volumeTitle", "Audio Volume")}
</span>
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]">
{Math.round((selectedAudioVolume ?? 1) * 100)}%
</span>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={selectedAudioVolume ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.01}
onChange={(v) => onAudioVolumeChange?.(v)}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
<Button
onClick={() => selectedAudioId && onAudioDelete?.(selectedAudioId)}
variant="destructive"
size="sm"
className="mt-2 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("audio.deleteRegion", "Delete Audio")}
</Button>
</div>
) : selectedClipId && hasClipSourceAudio && selectedClipShowSourceAudio ? (
<div className="flex flex-col gap-3">
<div className="text-sm font-medium text-foreground">
{tSettings("audio.sourceTracksTitle", "Clip Source Audio")}
</div>
{sourceAudioTrackMeta.map((track) => {
const settings = sourceAudioTrackSettings[track.id] ?? {
volume: 1,
normalize: false,
};
return (
<div
key={track.id}
className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground">
{track.label}
</span>
<span className="text-[10px] text-muted-foreground">
{Math.round(settings.volume * 100)}%
</span>
</div>
<div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("audio.normalize", "Normalize")}
</span>
<Switch
checked={settings.normalize}
onCheckedChange={(v) =>
onSourceAudioTrackNormalizeChange?.(track.id, v)
}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={settings.volume}
defaultValue={1}
min={0}
max={2}
step={0.01}
onChange={(v) => onSourceAudioTrackVolumeChange?.(track.id, v)}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
</div>
);
})}
</div>
) : null}
{activeEffectSection === "clip" && selectedClipId && (
<Button
onClick={() => {
if (selectedClipId && onClipDelete) onClipDelete(selectedClipId);
}}
variant="destructive"
size="sm"
className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("clip.delete", "Delete Clip")}
</Button>
)}
{activeEffectSection === "zoom" && selectedZoomId && (
<Button
onClick={() => {
if (selectedZoomId && onZoomDelete) onZoomDelete(selectedZoomId);
}}
variant="destructive"
size="sm"
className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("zoom.deleteZoom", "Delete Zoom")}
</Button>
)}
{activeEffectSection === "audio" && selectedAudioId && (
<Button
onClick={() => {
if (selectedAudioId && onAudioDelete) onAudioDelete(selectedAudioId);
}}
variant="destructive"
size="sm"
className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("audio.deleteRegion", "Delete Audio")}
</Button>
)}
{selectedAnnotationId && (
<Button
onClick={() => {
if (selectedAnnotationId && onAnnotationDelete)
onAnnotationDelete(selectedAnnotationId);
}}
variant="destructive"
size="sm"
className="h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("annotation.delete", "Delete Annotation")}
</Button>
)}
</div>
</div>
);
@@ -197,6 +197,7 @@ import {
type ZoomMotionBlurTuning,
type ZoomRegion,
type ZoomTransitionEasing,
type SourceAudioTrackSettings,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import {
@@ -664,6 +665,12 @@ export default function VideoEditor() {
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [audioRegions, setAudioRegions] = useState<AudioRegion[]>([]);
const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null);
const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState<
Record<string, SourceAudioTrackSettings>
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState<
SourceAudioTrackSettings
>({});
const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false);
const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]);
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(
@@ -1752,6 +1759,8 @@ export default function VideoEditor() {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>;
defaultSourceAudioTrackSettings: SourceAudioTrackSettings;
}>,
) => {
return editor;
@@ -1853,6 +1862,8 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
sourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
}),
[
buildPersistedEditorState,
@@ -1913,6 +1924,8 @@ export default function VideoEditor() {
gifLoop,
gifSizePreset,
frame,
sourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
],
);
@@ -2098,6 +2111,10 @@ export default function VideoEditor() {
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
setAudioRegions(normalizedEditor.audioRegions);
setSourceAudioTrackSettingsByClip(normalizedEditor.sourceAudioTrackSettingsByClip ?? {});
setDefaultSourceAudioTrackSettings(
normalizedEditor.defaultSourceAudioTrackSettings ?? {},
);
setAutoCaptions(normalizedEditor.autoCaptions);
setAutoCaptionSettings(normalizedEditor.autoCaptionSettings);
setAspectRatio(normalizedEditor.aspectRatio);
@@ -3314,6 +3331,10 @@ export default function VideoEditor() {
clipRegions,
audioRegions,
effectiveSpeedRegions,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
currentTime,
timelineTime: timelinePlayheadTime,
duration,
@@ -3751,6 +3772,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setActiveEffectSection("audio");
}
}, []);
@@ -3768,6 +3790,7 @@ export default function VideoEditor() {
setSelectedAudioId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setActiveEffectSection("audio");
}, []);
const handleAudioSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => {
@@ -274,13 +274,27 @@ export function useAudioPreviewSync({
: SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS;
for (const audio of sourceAudioElementsRef.current.values()) {
const sourceAudioPath = audio.dataset.sourceAudioPath ?? "";
audio.volume = isCurrentClipMuted
? 0
: Math.max(0, Math.min(1, previewVolume * getSourceTrackPreviewGain(sourceAudioPath)));
enablePitchPreservingPlayback(audio);
const audioDuration = Number.isFinite(audio.duration) ? audio.duration : null;
const startDelaySeconds = estimateCompanionAudioStartDelaySeconds(
const isMicCompanionTrack = /\.mic\./i.test(sourceAudioPath);
const rawStartDelaySeconds = estimateCompanionAudioStartDelaySeconds(
duration,
audioDuration,
sourceAudioFallbackStartDelayMsByPath[audio.dataset.sourceAudioPath ?? ""],
sourceAudioFallbackStartDelayMsByPath[sourceAudioPath],
);
const maxPreviewStartDelaySeconds = isMicCompanionTrack ? 2 : 5;
const startDelaySeconds = isMicCompanionTrack
? 0
: Number.isFinite(duration) &&
(rawStartDelaySeconds >= Math.max(0, duration - 0.01) ||
rawStartDelaySeconds > Math.max(maxPreviewStartDelaySeconds, duration * 0.9))
? 0
: rawStartDelaySeconds;
const beforeAudioStart = currentTime + 0.001 < startDelaySeconds;
const targetTime = clampMediaTimeToDuration(currentTime - startDelaySeconds, audioDuration);
@@ -317,7 +331,10 @@ export function useAudioPreviewSync({
currentTime,
duration,
effectiveSpeedRegions,
getSourceTrackPreviewGain,
isCurrentClipMuted,
isPlaying,
previewVolume,
previewSourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
]);
@@ -1,18 +1,31 @@
import { useCallback, useMemo } from "react";
import React, { useCallback, useMemo } from "react";
import {
SOURCE_AUDIO_NORMALIZE_GAIN,
getSourceTrackIdFromPath,
} from "./sourceAudioTracks";
import { useSourceAudioTrackSettings } from "./useSourceAudioTrackSettings";
import { SourceAudioTrackSettings } from "../types";
interface UseClipAudioSettingsControllerParams {
selectedClipId: string | null;
activeClipId: string | null;
sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>;
setSourceAudioTrackSettingsByClip: React.Dispatch<
React.SetStateAction<Record<string, SourceAudioTrackSettings>>
>;
defaultSourceAudioTrackSettings: SourceAudioTrackSettings;
setDefaultSourceAudioTrackSettings: React.Dispatch<
React.SetStateAction<SourceAudioTrackSettings>
>;
}
export function useClipAudioSettingsController({
selectedClipId,
activeClipId,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
}: UseClipAudioSettingsControllerParams) {
const {
sourceAudioTrackMeta,
@@ -25,25 +38,45 @@ export function useClipAudioSettingsController({
} = useSourceAudioTrackSettings({
selectedClipId,
activeClipId,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
});
const previewSourceAudioTrackSettings = useMemo(
() =>
activeClipId ? activeSourceAudioTrackSettings : selectedClipSourceAudioTrackSettings,
[activeClipId, activeSourceAudioTrackSettings, selectedClipSourceAudioTrackSettings],
);
const embeddedTrackId = useMemo<"mixed" | "system">(() => {
const hasMixedTrack = sourceAudioTrackMeta.some((track) => track.id === "mixed");
if (hasMixedTrack) return "mixed";
const hasSystemTrack = sourceAudioTrackMeta.some((track) => track.id === "system");
return hasSystemTrack ? "system" : "mixed";
}, [sourceAudioTrackMeta]);
const embeddedSourcePreviewGain = useMemo(() => {
const settings = activeSourceAudioTrackSettings.mixed ?? { volume: 1, normalize: false };
const settings = previewSourceAudioTrackSettings[embeddedTrackId] ?? {
volume: 1,
normalize: false,
};
const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1;
return Math.max(0, Math.min(2, settings.volume * normalizeGain));
}, [activeSourceAudioTrackSettings]);
}, [embeddedTrackId, previewSourceAudioTrackSettings]);
const getSourceTrackPreviewGain = useCallback(
(audioPath: string) => {
const trackId = getSourceTrackIdFromPath(audioPath);
const settings = activeSourceAudioTrackSettings[trackId] ?? {
const settings = previewSourceAudioTrackSettings[trackId] ?? {
volume: 1,
normalize: false,
};
const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1;
return Math.max(0, Math.min(2, settings.volume * normalizeGain));
},
[activeSourceAudioTrackSettings],
[previewSourceAudioTrackSettings],
);
return {
@@ -1,12 +1,17 @@
import { useCallback, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import type { SourceAudioTrackSettings } from "../types";
export type SourceAudioTrackSetting = { volume: number; normalize: boolean };
export type SourceAudioTrackSettings = Record<string, SourceAudioTrackSetting>;
export type SourceAudioTrackMeta = Array<{ id: string; label: string }>;
interface UseSourceAudioTrackSettingsParams {
selectedClipId: string | null;
activeClipId: string | null;
sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>;
setSourceAudioTrackSettingsByClip: React.Dispatch<
React.SetStateAction<Record<string, SourceAudioTrackSettings>>
>;
defaultSourceAudioTrackSettings: SourceAudioTrackSettings;
setDefaultSourceAudioTrackSettings: React.Dispatch<React.SetStateAction<SourceAudioTrackSettings>>;
}
export interface UseSourceAudioTrackSettingsResult {
@@ -22,14 +27,12 @@ export interface UseSourceAudioTrackSettingsResult {
export function useSourceAudioTrackSettings({
selectedClipId,
activeClipId,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
}: UseSourceAudioTrackSettingsParams): UseSourceAudioTrackSettingsResult {
const [sourceAudioTrackMeta, setSourceAudioTrackMeta] = useState<SourceAudioTrackMeta>([]);
const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState<
Record<string, SourceAudioTrackSettings>
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState<
SourceAudioTrackSettings
>({});
const activeSourceAudioTrackSettings = useMemo(() => {
if (!activeClipId) {
@@ -1,6 +1,11 @@
import { useMemo } from "react";
import React, { useMemo } from "react";
import { resolveSourceAudioFallbackPaths } from "@/lib/exporter/sourceAudioFallback";
import type { AudioRegion, ClipRegion, SpeedRegion } from "../types";
import type {
AudioRegion,
ClipRegion,
SourceAudioTrackSettings,
SpeedRegion,
} from "../types";
import { getActiveClipIdAtSourceTime, isClipMutedById } from "./clipAudio";
import { useAudioPreviewSync } from "./useAudioPreviewSync";
import { useClipAudioSettingsController } from "./useClipAudioSettingsController";
@@ -27,6 +32,14 @@ interface UseVideoEditorAudioParams {
clipRegions: ClipRegion[];
audioRegions: AudioRegion[];
effectiveSpeedRegions: SpeedRegion[];
sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>;
setSourceAudioTrackSettingsByClip: React.Dispatch<
React.SetStateAction<Record<string, SourceAudioTrackSettings>>
>;
defaultSourceAudioTrackSettings: SourceAudioTrackSettings;
setDefaultSourceAudioTrackSettings: React.Dispatch<
React.SetStateAction<SourceAudioTrackSettings>
>;
currentTime: number;
timelineTime: number;
duration: number;
@@ -42,6 +55,10 @@ export function useVideoEditorAudio({
clipRegions,
audioRegions,
effectiveSpeedRegions,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
currentTime,
timelineTime,
duration,
@@ -65,8 +82,12 @@ export function useVideoEditorAudio({
() => resolveSourceAudioFallbackPaths(currentSourcePath, sourceAudioFallbackPaths),
[currentSourcePath, sourceAudioFallbackPaths],
);
const hasSystemCompanionPreviewTrack = previewSourceAudioFallbackPaths.some((audioPath) =>
audioPath.toLowerCase().includes(".system."),
);
const shouldMutePreviewVideo =
!hasEmbeddedSourceAudio && previewSourceAudioFallbackPaths.length > 0;
previewSourceAudioFallbackPaths.length > 0 &&
(!hasEmbeddedSourceAudio || hasSystemCompanionPreviewTrack);
const activeClipIdAtCurrentTime = useMemo(
() => getActiveClipIdAtSourceTime(currentTime, clipRegions),
@@ -90,6 +111,10 @@ export function useVideoEditorAudio({
} = useClipAudioSettingsController({
selectedClipId,
activeClipId: activeClipIdAtCurrentTime,
sourceAudioTrackSettingsByClip,
setSourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
setDefaultSourceAudioTrackSettings,
});
useAudioPreviewSync({
@@ -62,6 +62,7 @@ import {
DEFAULT_ZOOM_SMOOTHNESS,
getDefaultCaptionFontFamily,
type Padding,
SourceAudioTrackSettings,
type SpeedRegion,
type TrimRegion,
type WebcamOverlaySettings,
@@ -127,6 +128,8 @@ export interface ProjectEditorState {
autoCaptionSettings: AutoCaptionSettings;
webcam: WebcamOverlaySettings;
aspectRatio: AspectRatio;
sourceAudioTrackSettingsByClip?: Record<string, SourceAudioTrackSettings>;
defaultSourceAudioTrackSettings?: SourceAudioTrackSettings;
exportEncodingMode: ExportEncodingMode;
exportBackendPreference: ExportBackendPreference;
exportPipelineModel: ExportPipelineModel;
@@ -496,6 +499,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
endMs,
speed: isFiniteNumber(region.speed) ? region.speed : 1,
muted: typeof region.muted === "boolean" ? region.muted : false,
showSourceAudio:
typeof region.showSourceAudio === "boolean"
? region.showSourceAudio
: false,
};
})
: [];
@@ -983,6 +990,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? clamp(webcam.margin, 0, 96)
: DEFAULT_WEBCAM_MARGIN,
},
sourceAudioTrackSettingsByClip:
editor.sourceAudioTrackSettingsByClip &&
typeof editor.sourceAudioTrackSettingsByClip === "object"
? editor.sourceAudioTrackSettingsByClip
: {},
defaultSourceAudioTrackSettings:
editor.defaultSourceAudioTrackSettings &&
typeof editor.defaultSourceAudioTrackSettings === "object"
? editor.defaultSourceAudioTrackSettings
: {},
aspectRatio:
typeof editor.aspectRatio === "string" &&
(validAspectRatios.has(editor.aspectRatio as AspectRatio) ||
@@ -291,12 +291,30 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
const sourceAudioTracks = useMemo<Array<{ id: string; label: string; peaks: AudioPeaksData }>>(() => {
if (systemSidecarPeaks || micSidecarPeaks) {
const tracks: Array<{ id: string; label: string; peaks: AudioPeaksData }> = [];
if (systemSidecarPeaks) tracks.push({ id: "system", label: "Source System", peaks: systemSidecarPeaks });
if (micSidecarPeaks) tracks.push({ id: "mic", label: "Source Mic", peaks: micSidecarPeaks });
if (systemSidecarPeaks)
tracks.push({
id: "system",
label: t("audio.systemLabel", "Source System"),
peaks: systemSidecarPeaks,
});
if (micSidecarPeaks)
tracks.push({
id: "mic",
label: t("audio.micLabel", "Source Mic"),
peaks: micSidecarPeaks,
});
return tracks;
}
return sourceAudioPeaks ? [{ id: "mixed", label: "Source", peaks: sourceAudioPeaks }] : [];
}, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks]);
return sourceAudioPeaks
? [
{
id: "mixed",
label: t("audio.mixedLabel", "Source"),
peaks: sourceAudioPeaks,
},
]
: [];
}, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t]);
useEffect(() => {
onSourceAudioTracksMetaChange?.(sourceAudioTracks.map((t) => ({ id: t.id, label: t.label })));
}, [onSourceAudioTracksMetaChange, sourceAudioTracks]);
+8
View File
@@ -64,6 +64,7 @@ export type EditorEffectSection =
| "crop"
| "extensions"
| "clip"
| "audio"
| `ext:${string}`;
export type ZoomTransitionEasing = "recordly" | "glide" | "smooth" | "snappy" | "linear";
@@ -466,6 +467,13 @@ export const DEFAULT_PADDING: Padding = {
linked: true,
};
export interface SourceAudioTrackSetting {
volume: number;
normalize: boolean;
}
export type SourceAudioTrackSettings = Record<string, SourceAudioTrackSetting>;
export interface AudioRegion {
id: string;
startMs: number;
+3
View File
@@ -206,6 +206,9 @@
},
"audio": {
"sourceTracksTitle": "Clip Source Audio",
"systemLabel": "Source System",
"micLabel": "Source Mic",
"mixedLabel": "Source",
"volumeTitle": "Volume",
"normalize": "Normalize",
"deleteRegion": "Delete Audio"
+3
View File
@@ -186,6 +186,9 @@
},
"audio": {
"sourceTracksTitle": "Audio fuente del clip",
"systemLabel": "Sonido del sistema",
"micLabel": "Micrófono",
"mixedLabel": "Fuente",
"volumeTitle": "Volumen",
"normalize": "Normalizar",
"deleteRegion": "Eliminar audio"
+3
View File
@@ -186,6 +186,9 @@
},
"audio": {
"sourceTracksTitle": "Audio source du clip",
"systemLabel": "Son Système",
"micLabel": "Microphone",
"mixedLabel": "Source",
"volumeTitle": "Volume",
"normalize": "Normaliser",
"deleteRegion": "Supprimer l'audio"
+10 -1
View File
@@ -197,6 +197,15 @@
"saveProject": "保存项目",
"exportVideo": "导出{{format}}",
"reportBug": "报告问题",
"starOnGithub": "在 GitHub 上加星"
"starOnGithub": "在 GitHub 上点赞"
},
"audio": {
"sourceTracksTitle": "片段原始音频",
"systemLabel": "系统声音",
"micLabel": "麦克风",
"mixedLabel": "来源",
"volumeTitle": "音量",
"normalize": "标准化",
"deleteRegion": "删除音频"
}
}