mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
add: project persistence of audio settings
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
},
|
||||
"audio": {
|
||||
"sourceTracksTitle": "Clip Source Audio",
|
||||
"systemLabel": "Source System",
|
||||
"micLabel": "Source Mic",
|
||||
"mixedLabel": "Source",
|
||||
"volumeTitle": "Volume",
|
||||
"normalize": "Normalize",
|
||||
"deleteRegion": "Delete Audio"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -197,6 +197,15 @@
|
||||
"saveProject": "保存项目",
|
||||
"exportVideo": "导出{{format}}",
|
||||
"reportBug": "报告问题",
|
||||
"starOnGithub": "在 GitHub 上加星"
|
||||
"starOnGithub": "在 GitHub 上点赞"
|
||||
},
|
||||
"audio": {
|
||||
"sourceTracksTitle": "片段原始音频",
|
||||
"systemLabel": "系统声音",
|
||||
"micLabel": "麦克风",
|
||||
"mixedLabel": "来源",
|
||||
"volumeTitle": "音量",
|
||||
"normalize": "标准化",
|
||||
"deleteRegion": "删除音频"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user