Add track-aware audio placement in timeline editor

This commit is contained in:
webadderall
2026-04-24 11:49:16 +10:00
parent 05a6d3f1b6
commit c667f2efe0
2 changed files with 162 additions and 49 deletions
+11 -1
View File
@@ -5281,7 +5281,17 @@ export default function VideoEditor() {
{t("timeline.annotation.label")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => timelineRef.current?.addAudio()}
onClick={() => {
const nextTrackIndex =
audioRegions.length > 0
? Math.max(
...audioRegions.map(
(region) => region.trackIndex ?? 0,
),
) + 1
: 0;
timelineRef.current?.addAudio(nextTrackIndex);
}}
className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer"
>
{t("timeline.audio.label")}
@@ -1,5 +1,3 @@
import type { Range, Span } from "dnd-timeline";
import { useTimelineContext } from "dnd-timeline";
import {
Check,
CaretDown as ChevronDown,
@@ -11,6 +9,8 @@ import {
MagicWand as WandSparkles,
MagnifyingGlassPlus as ZoomIn,
} from "@phosphor-icons/react";
import type { Range, Span } from "dnd-timeline";
import { useTimelineContext } from "dnd-timeline";
import {
forwardRef,
type KeyboardEvent as ReactKeyboardEvent,
@@ -33,6 +33,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import {
@@ -106,6 +107,10 @@ function getAudioTrackIndex(rowId: string) {
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
function spansOverlap(left: Span, right: Span) {
return left.end > right.start && left.start < right.end;
}
interface TimelineEditorProps {
videoDuration: number;
currentTime: number;
@@ -165,7 +170,7 @@ export interface TimelineEditorHandle {
suggestZooms: () => void;
splitClip: () => void;
addAnnotation: (trackIndex?: number) => void;
addAudio: () => Promise<void>;
addAudio: (trackIndex?: number) => Promise<void>;
keyframes: { id: string; time: number }[];
}
@@ -1251,7 +1256,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
return regions.some((region) => {
if (region.id === excludeId) return false;
// True overlap: regions actually intersect (not just adjacent)
return newSpan.end > region.startMs && newSpan.start < region.endMs;
return spansOverlap(newSpan, {
start: region.startMs,
end: region.endMs,
});
});
};
@@ -1272,7 +1280,15 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}
if (isAudioItem) {
return checkOverlap(audioRegions);
const activeAudioRegion = audioRegions.find(
(region) => region.id === excludeId,
);
const activeTrackIndex = activeAudioRegion?.trackIndex ?? 0;
return checkOverlap(
audioRegions.filter(
(region) => (region.trackIndex ?? 0) === activeTrackIndex,
),
);
}
return false;
@@ -1495,56 +1511,141 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
defaultRegionDurationMs,
]);
const handleAddAudio = useCallback(async () => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
return;
}
const handleAddAudio = useCallback(
async (preferredTrackIndex?: number) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
return;
}
const result = await window.electronAPI.openAudioFilePicker();
if (!result?.success || !result.path) {
return;
}
const result = await window.electronAPI.openAudioFilePicker();
if (!result?.success || !result.path) {
return;
}
const audioPath = result.path;
const audioPath = result.path;
// Load the audio file to get its full duration
const audioDurationMs = await new Promise<number>((resolve) => {
const audio = new Audio(toFileUrl(audioPath));
audio.addEventListener("loadedmetadata", () => {
resolve(Math.round(audio.duration * 1000));
// Load the audio file through the local-media resolver so local paths work reliably.
const audioDurationMs = await new Promise<number>((resolve) => {
void (async () => {
const resolved = await resolveMediaElementSource(audioPath);
const audio = new Audio();
const cleanup = () => {
audio.removeAttribute("src");
audio.load();
resolved.revoke();
};
audio.addEventListener(
"loadedmetadata",
() => {
resolve(Math.round(audio.duration * 1000));
cleanup();
},
{ once: true },
);
audio.addEventListener(
"error",
() => {
resolve(0);
cleanup();
},
{ once: true },
);
audio.src = resolved.src;
})();
});
audio.addEventListener("error", () => {
resolve(0);
});
});
if (audioDurationMs <= 0) {
toast.error("Could not read audio file", {
description: "The selected file may be corrupted or in an unsupported format.",
});
return;
}
if (audioDurationMs <= 0) {
toast.error("Could not read audio file", {
description:
"The selected file may be corrupted or in an unsupported format.",
});
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const maxRemainingDuration = totalMs - startPos;
if (maxRemainingDuration <= 0) {
toast.error("Cannot place audio here", {
description:
"There is no remaining space at the current playhead position.",
});
return;
}
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place audio here", {
description:
"Audio region already exists at this location or not enough space available.",
});
return;
}
const desiredDuration = Math.min(audioDurationMs, maxRemainingDuration);
const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex)
? Math.max(0, Math.floor(preferredTrackIndex ?? 0))
: null;
const maxTrackIndex = audioRegions.reduce(
(max, region) => Math.max(max, region.trackIndex ?? 0),
-1,
);
const candidateTrackIndexes =
normalizedPreferredTrackIndex === null
? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index)
: [normalizedPreferredTrackIndex];
// Use full audio duration, but clamp to available gap and video length
const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos);
onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path);
}, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]);
const getGapForTrack = (trackIndex: number) => {
const trackRegions = audioRegions
.filter((region) => (region.trackIndex ?? 0) === trackIndex)
.sort((left, right) => left.startMs - right.startMs);
const desiredSpan = {
start: startPos,
end: startPos + desiredDuration,
};
const overlappingRegion = trackRegions.find((region) =>
spansOverlap(desiredSpan, { start: region.startMs, end: region.endMs }),
);
if (overlappingRegion) {
return 0;
}
const nextRegion = trackRegions.find((region) => region.startMs > startPos);
return nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
};
let selectedTrackIndex: number | null = null;
let availableGap = 0;
for (const trackIndex of candidateTrackIndexes) {
const gap = getGapForTrack(trackIndex);
if (gap >= desiredDuration) {
selectedTrackIndex = trackIndex;
availableGap = gap;
break;
}
}
if (selectedTrackIndex === null && normalizedPreferredTrackIndex === null) {
for (const trackIndex of candidateTrackIndexes) {
const gap = getGapForTrack(trackIndex);
if (gap > 0) {
selectedTrackIndex = trackIndex;
availableGap = gap;
break;
}
}
}
if (selectedTrackIndex === null || availableGap <= 0) {
toast.error("Cannot place audio here", {
description:
"Audio region already exists at this location or not enough space available.",
});
return;
}
const actualDuration = Math.min(audioDurationMs, availableGap, totalMs - startPos);
onAudioAdded(
{ start: startPos, end: startPos + actualDuration },
result.path,
selectedTrackIndex,
);
},
[videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded],
);
const handleAddAnnotation = useCallback(
(trackIndex = 0) => {
@@ -1942,7 +2043,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddAudio}
onClick={() => {
void handleAddAudio();
}}
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-[#a855f7] hover:bg-[#a855f7]/10 transition-all"