diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7342ed93..d90e4cb6 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -5281,7 +5281,17 @@ export default function VideoEditor() { {t("timeline.annotation.label")} 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")} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index c55e636f..720a6586 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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; + addAudio: (trackIndex?: number) => Promise; keyframes: { id: string; time: number }[]; } @@ -1251,7 +1256,10 @@ const TimelineEditor = forwardRef( 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( } 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( 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((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((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(