From 74dfa0d5fa86e13792f6cd038a90aa1626dd7ea0 Mon Sep 17 00:00:00 2001 From: Mahdy Arief Date: Sat, 28 Mar 2026 01:25:40 +0700 Subject: [PATCH] feat: Implement initial video editor with timeline, effects, audio waveforms, and export capabilities. --- .../video-editor/AudioSettingsPanel.tsx | 217 +++++++++++++ src/components/video-editor/SettingsPanel.tsx | 87 ++++- src/components/video-editor/VideoEditor.tsx | 297 ++++++++++++++++-- .../video-editor/editorPreferences.ts | 12 + .../video-editor/projectPersistence.ts | 14 + src/components/video-editor/timeline/Item.tsx | 108 ++++++- src/components/video-editor/timeline/Row.tsx | 19 +- .../video-editor/timeline/TimelineEditor.tsx | 231 +++++++++++++- src/components/video-editor/types.ts | 4 + src/lib/exporter/audioEncoder.ts | 32 +- src/lib/exporter/types.ts | 3 + src/lib/exporter/videoExporter.ts | 3 + src/utils/audioWaveform.ts | 44 +++ 13 files changed, 1018 insertions(+), 53 deletions(-) create mode 100644 src/components/video-editor/AudioSettingsPanel.tsx create mode 100644 src/utils/audioWaveform.ts diff --git a/src/components/video-editor/AudioSettingsPanel.tsx b/src/components/video-editor/AudioSettingsPanel.tsx new file mode 100644 index 00000000..07fe5931 --- /dev/null +++ b/src/components/video-editor/AudioSettingsPanel.tsx @@ -0,0 +1,217 @@ +import { Volume2, VolumeX, Trash2, Music } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; +import { generateWaveform } from "@/utils/audioWaveform"; +import { useEffect, useState } from "react"; +import type { AudioRegion } from "./types"; + +interface AudioSettingsPanelProps { + audio: AudioRegion; + onVolumeChange: (volume: number) => void; + onMutedChange: (muted: boolean) => void; + onSoloedChange: (soloed: boolean) => void; + onFadeInMsChange: (ms: number) => void; + onFadeOutMsChange: (ms: number) => void; + onDelete: () => void; +} + +function formatFadeTime(ms: number): string { + if (ms === 0) return "Off"; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function AudioSettingsPanel({ + audio, + onVolumeChange, + onMutedChange, + onSoloedChange, + onFadeInMsChange, + onFadeOutMsChange, + onDelete, +}: AudioSettingsPanelProps) { + const [waveform, setWaveform] = useState(null); + + useEffect(() => { + if (audio.audioPath) { + generateWaveform(audio.audioPath, 120).then(setWaveform); + } + }, [audio.audioPath]); + + const clipDurationMs = audio.endMs - audio.startMs; + const maxFadeMs = Math.max(0, Math.floor(clipDurationMs / 2)); + const volumePct = Math.round(audio.volume * 100); + const isMaster = audio.id === "master"; + + // Mute and Solo are mutually exclusive + const handleMuteToggle = () => { + const nextMuted = !audio.muted; + onMutedChange(nextMuted); + if (nextMuted && audio.soloed) onSoloedChange(false); + }; + + const handleSoloToggle = () => { + const nextSoloed = !audio.soloed; + onSoloedChange(nextSoloed); + if (nextSoloed && audio.muted) onMutedChange(false); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ {isMaster ? "Original Audio" : "Audio Region"} +

+ {!isMaster && ( +

+ {audio.audioPath.split(/[\\/]/).pop()} +

+ )} + {isMaster && ( +

+ Adjust the volume of the video's audio +

+ )} +
+
+ + Active + +
+ + {/* Waveform — only for audio regions with a dedicated audio path */} + {waveform && !isMaster && ( +
+
+ + {waveform.map((peak, i) => ( + + ))} + +
+
+ )} + + {/* Mute / Solo — only for audio regions, not master */} + {!isMaster && ( +
+ + +
+ )} + + {/* Volume */} +
+
+
+ + Volume +
+ 100 + ? "text-amber-400 bg-amber-500/10" + : "text-[#2563EB] bg-[#2563EB]/10" + )} + > + {volumePct}% + +
+ onVolumeChange(value / 100)} + min={0} + max={200} + step={1} + /> + {volumePct > 100 && ( +

+ Amplifying above 100% may clip the audio. +

+ )} +
+ + {/* Fades — only for audio regions */} + {!isMaster && ( +
+ Fades +
+
+
+ Fade In + {formatFadeTime(audio.fadeInMs || 0)} +
+ onFadeInMsChange(v)} min={0} max={maxFadeMs} step={50} /> +
+
+
+ Fade Out + {formatFadeTime(audio.fadeOutMs || 0)} +
+ onFadeOutMsChange(v)} min={0} max={maxFadeMs} step={50} /> +
+
+
+ )} + + {/* Delete — only for audio regions */} + {!isMaster && ( + + )} +
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 14554058..b5fdd118 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { MessageSquare, Palette, Trash2, Upload, X } from "lucide-react"; +import { MessageSquare, Music, Palette, Trash2, Upload, X } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -26,11 +26,13 @@ import parchedCursorUrl from "../../assets/cursors/parched/default.png"; import turtleCursorUrl from "../../assets/cursors/turtle/default.png"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; +import { AudioSettingsPanel } from "./AudioSettingsPanel"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { SliderControl } from "./SliderControl"; import type { AnnotationRegion, AnnotationType, + AudioRegion, AutoCaptionAnimation, AutoCaptionSettings, CaptionCue, @@ -111,6 +113,7 @@ export type EditorEffectSection = | "cursor" | "captions" | "webcam" + | "audio" | "zoom" | "frame" | "crop"; @@ -233,7 +236,24 @@ interface SettingsPanelProps { selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; + audioRegions?: AudioRegion[]; + selectedAudioId?: string | null; + onAudioVolumeChange?: (id: string, volume: number) => void; + onAudioMutedChange?: (id: string, muted: boolean) => void; + onAudioSoloedChange?: (id: string, soloed: boolean) => void; + onAudioFadeInMsChange?: (id: string, ms: number) => void; + onAudioFadeOutMsChange?: (id: string, ms: number) => void; + onAudioDelete?: (id: string) => void; timeSelection?: { startMs: number; endMs: number } | null; + isMasterSelected?: boolean; + masterAudioVolume?: number; + masterAudioMuted?: boolean; + masterAudioSoloed?: boolean; + videoDuration?: number; + videoPath?: string; + onMasterAudioVolumeChange?: (volume: number) => void; + onMasterAudioMutedChange?: (muted: boolean) => void; + onMasterAudioSoloedChange?: (soloed: boolean) => void; } export default SettingsPanel; @@ -591,7 +611,24 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + audioRegions = [], + selectedAudioId, + onAudioVolumeChange, + onAudioMutedChange, + onAudioSoloedChange, + onAudioFadeInMsChange, + onAudioFadeOutMsChange, + onAudioDelete, timeSelection, + isMasterSelected, + masterAudioVolume = 1, + masterAudioMuted = false, + masterAudioSoloed = false, + videoDuration, + videoPath, + onMasterAudioVolumeChange, + onMasterAudioMutedChange, + onMasterAudioSoloedChange, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { t } = useI18n(); @@ -1782,6 +1819,54 @@ export function SettingsPanel({ return sceneSectionContent; case "captions": return captionsSectionContent; + case "audio": { + const selectedAudio = audioRegions?.find((a) => a.id === selectedAudioId); + if (selectedAudio) { + return ( + onAudioVolumeChange?.(selectedAudio.id, volume)} + onMutedChange={(muted) => onAudioMutedChange?.(selectedAudio.id, muted)} + onSoloedChange={(soloed) => onAudioSoloedChange?.(selectedAudio.id, soloed)} + onFadeInMsChange={(ms) => onAudioFadeInMsChange?.(selectedAudio.id, ms)} + onFadeOutMsChange={(ms) => onAudioFadeOutMsChange?.(selectedAudio.id, ms)} + onDelete={() => onAudioDelete?.(selectedAudio.id)} + /> + ); + } + + if (isMasterSelected) { + const masterAudioMock: AudioRegion = { + id: "master", + startMs: 0, + endMs: (videoDuration || 0) * 1000, + volume: masterAudioVolume, + muted: masterAudioMuted, + soloed: masterAudioSoloed, + audioPath: videoPath || "", + fadeInMs: 0, + fadeOutMs: 0, + }; + return ( + {})} + onMutedChange={onMasterAudioMutedChange || (() => {})} + onSoloedChange={onMasterAudioSoloedChange || (() => {})} + onFadeInMsChange={() => {}} + onFadeOutMsChange={() => {}} + onDelete={() => {}} + /> + ); + } + + return ( +
+ +

Select an audio region to edit its settings

+
+ ); + } case "cursor": return (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cb7fee01..8e1ef9a9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -6,6 +6,7 @@ import { FolderOpen, Languages, MousePointer2, + Music, Redo2, Save, Sparkles, @@ -130,6 +131,11 @@ type EditorHistorySnapshot = { selectedAnnotationId: string | null; selectedAudioId: string | null; selectedCaptionId: string | null; + masterAudioMuted: boolean; + masterAudioSoloed: boolean; + masterAudioVolume: number; + audioTrackVolume: number; + isMasterSelected: boolean; }; type PendingExportSave = { @@ -402,6 +408,11 @@ export default function VideoEditor() { const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDropdown, setShowExportDropdown] = useState(false); + const [masterAudioMuted, setMasterAudioMuted] = useState(false); + const [masterAudioSoloed, setMasterAudioSoloed] = useState(false); + const [masterAudioVolume, setMasterAudioVolume] = useState(1); + const [audioTrackVolume, setAudioTrackVolume] = useState(1); + const [isMasterSelected, setIsMasterSelected] = useState(false); const [previewVolume, setPreviewVolume] = useState(1); const [aspectRatio, setAspectRatio] = useState(initialEditorPreferences.aspectRatio); const [activeEffectSection, setActiveEffectSection] = useState("scene"); @@ -423,8 +434,14 @@ export default function VideoEditor() { const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); + const [isAudioEngineReady, setIsAudioEngineReady] = useState(false); const videoPlaybackRef = useRef(null); + const audioContextRef = useRef(null); + const masterGainRef = useRef(null); + const audioRegionNodesRef = useRef>(new Map()); + const videoAudioNodeRef = useRef<{ source: MediaElementAudioSourceNode; gain: GainNode } | null>(null); + const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); const nextZoomIdRef = useRef(1); @@ -798,6 +815,7 @@ export default function VideoEditor() { label: t("settings.sections.captions", "Captions"), icon: Captions, }, + { id: "audio" as const, label: t("settings.sections.audio", "Audio"), icon: Music }, ], [t], ); @@ -973,6 +991,11 @@ export default function VideoEditor() { selectedAnnotationId, selectedAudioId, selectedCaptionId, + masterAudioMuted, + masterAudioSoloed, + masterAudioVolume, + audioTrackVolume, + isMasterSelected, }; }, [ zoomRegions, @@ -987,6 +1010,11 @@ export default function VideoEditor() { selectedAnnotationId, selectedAudioId, selectedCaptionId, + masterAudioMuted, + masterAudioSoloed, + masterAudioVolume, + audioTrackVolume, + isMasterSelected, ]); const applyHistorySnapshot = useCallback( @@ -1004,6 +1032,11 @@ export default function VideoEditor() { setSelectedAnnotationId(snapshot.selectedAnnotationId); setSelectedAudioId(snapshot.selectedAudioId); setSelectedCaptionId(snapshot.selectedCaptionId); + setMasterAudioMuted(snapshot.masterAudioMuted); + setMasterAudioSoloed(snapshot.masterAudioSoloed); + setMasterAudioVolume(snapshot.masterAudioVolume); + setAudioTrackVolume(snapshot.audioTrackVolume); + setIsMasterSelected(snapshot.isMasterSelected); nextZoomIdRef.current = deriveNextId( "zoom", @@ -1128,6 +1161,11 @@ export default function VideoEditor() { setGifFrameRate(normalizedEditor.gifFrameRate); setGifLoop(normalizedEditor.gifLoop); setGifSizePreset(normalizedEditor.gifSizePreset); + setMasterAudioMuted(normalizedEditor.masterAudioMuted); + setMasterAudioSoloed(normalizedEditor.masterAudioSoloed); + setMasterAudioVolume(normalizedEditor.masterAudioVolume); + setAudioTrackVolume(normalizedEditor.audioTrackVolume); + setIsMasterSelected(normalizedEditor.isMasterSelected ?? false); setSelectedZoomId(null); setSelectedTrimId(null); @@ -1366,6 +1404,8 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + masterAudioMuted, + masterAudioSoloed, whisperExecutablePath, whisperModelPath, whisperSelectedModel: autoCaptionSettings.selectedModel, @@ -1482,6 +1522,19 @@ export default function VideoEditor() { toast.success("Whisper executable selected"); }, []); + const handleSelectMaster = useCallback((selected: boolean) => { + setIsMasterSelected(selected); + if (selected) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + setSelectedCaptionId(null); + setActiveEffectSection("audio"); + } + }, []); + const handleDownloadWhisperModel = useCallback(async () => { if (whisperModelDownloadStatus === "downloading") { return; @@ -1789,6 +1842,30 @@ export default function VideoEditor() { }; }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); + useEffect(() => { + const video = videoPlaybackRef.current?.video; + if (!video || !audioContextRef.current || (videoAudioNodeRef.current && videoAudioNodeRef.current.source.mediaElement === video)) { + return; + } + + try { + // If we had an old source node for a different video, disconnect it + if (videoAudioNodeRef.current) { + videoAudioNodeRef.current.source.disconnect(); + videoAudioNodeRef.current.gain.disconnect(); + } + + const source = audioContextRef.current.createMediaElementSource(video); + const gain = audioContextRef.current.createGain(); + source.connect(gain); + gain.connect(masterGainRef.current!); + videoAudioNodeRef.current = { source, gain }; + console.log("[VideoEditor] Video audio routed through Web Audio API"); + } catch (e) { + console.warn("[VideoEditor] Could not route video audio", e); + } + }, [videoPlaybackRef.current?.video, previewVersion, isAudioEngineReady]); + useEffect(() => { let mounted = true; @@ -1820,6 +1897,25 @@ export default function VideoEditor() { }; }, [videoPath]); + // Apply master volume to the master gain node + useEffect(() => { + if (masterGainRef.current) { + masterGainRef.current.gain.setTargetAtTime(masterAudioVolume * previewVolume, 0, 0.03); + } + }, [masterAudioVolume, previewVolume]); + + // Handle video master solo/mute + useEffect(() => { + if (videoAudioNodeRef.current) { + const hasGlobalSolo = masterAudioSoloed || audioRegions.some((r) => r.soloed); + let vol = 1; + if (masterAudioMuted || (hasGlobalSolo && !masterAudioSoloed)) { + vol = 0; + } + videoAudioNodeRef.current.gain.gain.setTargetAtTime(vol, 0, 0.03); + } + }, [masterAudioMuted, masterAudioSoloed, audioRegions]); + const normalizedCursorTelemetry = useMemo(() => { if (cursorTelemetry.length === 0) { return [] as CursorTelemetryPoint[]; @@ -1938,11 +2034,34 @@ export default function VideoEditor() { autoSuggestedVideoPathRef.current = videoPath; }, [videoPath, duration, effectiveCursorTelemetry, loopCursor, zoomRegions.length]); + const initAudioContext = useCallback(() => { + if (audioContextRef.current) { + if (audioContextRef.current.state === 'suspended') { + audioContextRef.current.resume().then(() => setIsAudioEngineReady(true)); + } + return; + } + + try { + const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const masterGain = ctx.createGain(); + masterGain.connect(ctx.destination); + audioContextRef.current = ctx; + masterGainRef.current = masterGain; + setIsAudioEngineReady(true); + console.log("[VideoEditor] Web Audio API context initialized"); + } catch (e) { + console.error("[VideoEditor] Failed to initialize AudioContext", e); + } + }, []); + function togglePlayPause() { const playback = videoPlaybackRef.current; const video = playback?.video; if (!playback || !video) return; + initAudioContext(); + if (!video.paused && !video.ended) { playback.pause(); } else { @@ -1964,6 +2083,7 @@ export default function VideoEditor() { setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); } }, []); @@ -1975,6 +2095,7 @@ export default function VideoEditor() { setSelectedAudioId(null); setSelectedSpeedId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); } }, []); @@ -1986,6 +2107,7 @@ export default function VideoEditor() { setSelectedAudioId(null); setSelectedSpeedId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); } }, []); @@ -2033,6 +2155,7 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedAnnotationId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); }, []); const handleZoomSpanChange = useCallback((id: string, span: Span) => { @@ -2122,6 +2245,7 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedAudioId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); } }, []); @@ -2173,6 +2297,8 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedSpeedId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); + setActiveEffectSection("audio"); } }, []); @@ -2192,6 +2318,7 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedSpeedId(null); setSelectedCaptionId(null); + setIsMasterSelected(false); }, []); const handleAudioSpanChange = useCallback((id: string, span: Span) => { @@ -2218,6 +2345,26 @@ export default function VideoEditor() { [selectedAudioId], ); + const handleAudioVolumeChange = useCallback((id: string, volume: number) => { + setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, volume } : r))); + }, []); + + const handleAudioMutedChange = useCallback((id: string, muted: boolean) => { + setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, muted } : r))); + }, []); + + const handleAudioSoloedChange = useCallback((id: string, soloed: boolean) => { + setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, soloed } : r))); + }, []); + + const handleAudioFadeInMsChange = useCallback((id: string, fadeInMs: number) => { + setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, fadeInMs } : r))); + }, []); + + const handleAudioFadeOutMsChange = useCallback((id: string, fadeOutMs: number) => { + setAudioRegions((prev) => prev.map((r) => (r.id === id ? { ...r, fadeOutMs } : r))); + }, []); + const handleCaptionSpanChange = useCallback((id: string, span: Span) => { setAutoCaptions((prev) => prev.map((caption) => @@ -2240,6 +2387,7 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedSpeedId(null); setSelectedAudioId(null); + setIsMasterSelected(false); } }, []); @@ -2271,7 +2419,8 @@ export default function VideoEditor() { setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); - setSelectedCaptionId(null); // Added + setSelectedCaptionId(null); + setIsMasterSelected(false); }, []); const handleAnnotationSpanChange = useCallback((id: string, span: Span) => { @@ -2493,40 +2642,75 @@ export default function VideoEditor() { useEffect(() => { const existing = audioElementsRef.current; - const currentIds = new Set(audioRegions.map((r) => r.id)); + const unused = new Set(existing.keys()); - // Remove old audio elements - for (const [id, audio] of existing) { - if (!currentIds.has(id)) { - audio.pause(); - audio.src = ""; - existing.delete(id); - } - } + const hasAudioContext = !!(audioContextRef.current && masterGainRef.current && isAudioEngineReady); - // Create/update audio elements for (const region of audioRegions) { + unused.delete(region.id); let audio = existing.get(region.id); if (!audio) { audio = new Audio(); - audio.preload = "auto"; + // Ensure cross-origin is handled to avoid CORS issues with AudioContext + audio.crossOrigin = "anonymous"; existing.set(region.id, audio); } const expectedSrc = toFileUrl(region.audioPath); if (audio.src !== expectedSrc) { audio.src = expectedSrc; } - audio.volume = Math.max(0, Math.min(1, region.volume * previewVolume)); + + // Route through Web Audio API if ready + if (hasAudioContext && !audioRegionNodesRef.current.has(region.id)) { + try { + const source = audioContextRef.current!.createMediaElementSource(audio); + const gain = audioContextRef.current!.createGain(); + source.connect(gain); + gain.connect(masterGainRef.current!); + audioRegionNodesRef.current.set(region.id, { source, gain }); + console.log(`[VideoEditor] Audio region ${region.id} routed through GainNode`); + } catch (e) { + console.warn(`[VideoEditor] Failed to route audio ${region.id}:`, e); + } + } + + // Initial volume setup — masterAudioMuted only affects the video element, not audio regions + const hasGlobalSolo = audioRegions.some((r) => r.soloed); + let baseVolume = region.volume * audioTrackVolume * previewVolume * masterAudioVolume; + if (region.muted || (hasGlobalSolo && !region.soloed)) { + baseVolume = 0; + } + + const nodeEntry = audioRegionNodesRef.current.get(region.id); + if (nodeEntry) { + audio.volume = 1; + nodeEntry.gain.gain.setTargetAtTime(baseVolume, 0, 0.03); + } else { + // Fallback to native volume (limited to 100%) + audio.volume = Math.max(0, Math.min(1, baseVolume)); + } } return () => { - for (const audio of existing.values()) { - audio.pause(); - audio.src = ""; + for (const id of unused) { + const audio = existing.get(id); + if (audio) { + audio.pause(); + audio.src = ""; + } + existing.delete(id); + + const nodeEntry = audioRegionNodesRef.current.get(id); + if (nodeEntry) { + try { + nodeEntry.source.disconnect(); + nodeEntry.gain.disconnect(); + } catch (e) { /* ignore */ } + audioRegionNodesRef.current.delete(id); + } } - existing.clear(); }; - }, [audioRegions, previewVolume]); + }, [audioRegions, previewVolume, masterAudioVolume, audioTrackVolume, isAudioEngineReady]); // Sync audio playback with video currentTime and isPlaying state useEffect(() => { @@ -2538,9 +2722,39 @@ export default function VideoEditor() { const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs; if (isPlaying && isInRegion) { + // Calculate fade multiplier + let fadeMultiplier = 1; + if (region.fadeInMs && currentTimeMs < region.startMs + region.fadeInMs) { + fadeMultiplier = (currentTimeMs - region.startMs) / region.fadeInMs; + } else if (region.fadeOutMs && currentTimeMs > region.endMs - region.fadeOutMs) { + fadeMultiplier = (region.endMs - currentTimeMs) / region.fadeOutMs; + } + fadeMultiplier = Math.max(0, Math.min(1, fadeMultiplier)); + + // masterAudioMuted only affects the video element — not audio regions + const hasGlobalSolo = audioRegions.some((r) => r.soloed); + let baseVolume = region.volume * audioTrackVolume * previewVolume * masterAudioVolume; + if (region.muted || (hasGlobalSolo && !region.soloed)) { + baseVolume = 0; + } + + const targetVolume = baseVolume * fadeMultiplier; + const nodeEntry = audioRegionNodesRef.current.get(region.id); + + if (nodeEntry) { + // Use Web Audio gain automation for smooth high-precision fade + nodeEntry.gain.gain.setTargetAtTime(targetVolume, 0, 0.03); + } else { + // Fallback to native volume (limited to 1.0) + const fallbackValue = Math.max(0, Math.min(1, targetVolume)); + if (Math.abs(audio.volume - fallbackValue) > 0.01) { + audio.volume = fallbackValue; + } + } + const audioOffset = (currentTimeMs - region.startMs) / 1000; - // Only seek if significantly out of sync (> 200ms) - if (Math.abs(audio.currentTime - audioOffset) > 0.2) { + // Only seek if significantly out of sync (> 20ms) - tightened for Web Audio era + if (Math.abs(audio.currentTime - audioOffset) > 0.02) { audio.currentTime = audioOffset; } if (audio.paused) { @@ -2552,7 +2766,7 @@ export default function VideoEditor() { } } } - }, [isPlaying, currentTime, audioRegions]); + }, [isPlaying, currentTime, audioRegions, previewVolume, masterAudioVolume, audioTrackVolume]); const showExportSuccessToast = useCallback((filePath: string) => { toast.success(`Exported successfully to ${filePath}`, { @@ -2762,6 +2976,9 @@ export default function VideoEditor() { cursorClickBounceDuration, cursorSway, audioRegions, + masterAudioVolume: masterAudioVolume ?? 1, + audioTrackVolume: audioTrackVolume ?? 1, + masterAudioMuted: masterAudioMuted ?? false, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -3370,6 +3587,13 @@ export default function VideoEditor() { > { + const hasGlobalSolo = masterAudioSoloed || audioRegions.some((r) => r.soloed); + if (masterAudioMuted || (hasGlobalSolo && !masterAudioSoloed)) { + return 0; + } + return previewVolume * masterAudioVolume; + })()} aspectRatio={aspectRatio} ref={videoPlaybackRef} videoPath={videoPath || ""} @@ -3420,7 +3644,6 @@ export default function VideoEditor() { cursorClickBounce={cursorClickBounce} cursorClickBounceDuration={cursorClickBounceDuration} cursorSway={cursorSway} - volume={previewVolume} /> @@ -3459,6 +3682,15 @@ export default function VideoEditor() {
@@ -3611,9 +3847,26 @@ export default function VideoEditor() { } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} + audioRegions={audioRegions} + selectedAudioId={selectedAudioId} + onAudioVolumeChange={handleAudioVolumeChange} + onAudioMutedChange={handleAudioMutedChange} + onAudioSoloedChange={handleAudioSoloedChange} + onAudioFadeInMsChange={handleAudioFadeInMsChange} + onAudioFadeOutMsChange={handleAudioFadeOutMsChange} + onAudioDelete={handleAudioDelete} selectedCaptionId={selectedCaptionId} onSelectCaption={setSelectedCaptionId} timeSelection={timeSelection} + isMasterSelected={isMasterSelected} + masterAudioVolume={masterAudioVolume} + masterAudioMuted={masterAudioMuted} + masterAudioSoloed={masterAudioSoloed} + videoDuration={duration} + videoPath={videoPath || undefined} + onMasterAudioVolumeChange={setMasterAudioVolume} + onMasterAudioMutedChange={setMasterAudioMuted} + onMasterAudioSoloedChange={setMasterAudioSoloed} /> diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 7c318945..0a5d132e 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -33,6 +33,9 @@ type PersistedEditorControls = Pick< | "gifFrameRate" | "gifLoop" | "gifSizePreset" + | "masterAudioMuted" + | "masterAudioSoloed" + | "masterAudioVolume" >; type PartialEditorControls = Partial; @@ -82,6 +85,9 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, + masterAudioMuted: DEFAULT_EDITOR_CONTROLS.masterAudioMuted, + masterAudioSoloed: DEFAULT_EDITOR_CONTROLS.masterAudioSoloed, + masterAudioVolume: DEFAULT_EDITOR_CONTROLS.masterAudioVolume, customAspectWidth: "16", customAspectHeight: "9", customWallpapers: [], @@ -161,6 +167,9 @@ function normalizeEditorControls( gifFrameRate: raw.gifFrameRate ?? fallback.gifFrameRate, gifLoop: raw.gifLoop ?? fallback.gifLoop, gifSizePreset: raw.gifSizePreset ?? fallback.gifSizePreset, + masterAudioMuted: raw.masterAudioMuted ?? fallback.masterAudioMuted, + masterAudioSoloed: raw.masterAudioSoloed ?? fallback.masterAudioSoloed, + masterAudioVolume: raw.masterAudioVolume ?? fallback.masterAudioVolume, }; const normalized = normalizeProjectEditor(candidate); @@ -197,6 +206,9 @@ function normalizeEditorControls( gifFrameRate: normalized.gifFrameRate, gifLoop: normalized.gifLoop, gifSizePreset: normalized.gifSizePreset, + masterAudioMuted: normalized.masterAudioMuted, + masterAudioSoloed: normalized.masterAudioSoloed, + masterAudioVolume: normalized.masterAudioVolume, }; } diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 3c871e11..35325bc2 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -94,6 +94,11 @@ export interface ProjectEditorState { gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + masterAudioMuted: boolean; + masterAudioSoloed: boolean; + masterAudioVolume: number; + audioTrackVolume: number; + isMasterSelected?: boolean; } export interface EditorProjectData { @@ -404,6 +409,10 @@ export function normalizeProjectEditor(editor: Partial): Pro endMs, audioPath: typeof region.audioPath === "string" ? region.audioPath : "", volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, + muted: typeof region.muted === "boolean" ? region.muted : false, + soloed: typeof region.soloed === "boolean" ? region.soloed : false, + fadeInMs: isFiniteNumber(region.fadeInMs) ? clamp(region.fadeInMs, 0, 10000) : 0, + fadeOutMs: isFiniteNumber(region.fadeOutMs) ? clamp(region.fadeOutMs, 0, 10000) : 0, }; }) : []; @@ -683,6 +692,11 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.gifSizePreset === "original" ? editor.gifSizePreset : "medium", + masterAudioMuted: typeof editor.masterAudioMuted === "boolean" ? editor.masterAudioMuted : false, + masterAudioSoloed: typeof editor.masterAudioSoloed === "boolean" ? editor.masterAudioSoloed : false, + masterAudioVolume: isFiniteNumber(editor.masterAudioVolume) ? clamp(editor.masterAudioVolume, 0, 2) : 1, + audioTrackVolume: isFiniteNumber(editor.audioTrackVolume) ? clamp(editor.audioTrackVolume, 0, 2) : 1, + isMasterSelected: Boolean(editor.isMasterSelected), }; } diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index efc1c16e..2bb63071 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,9 +1,10 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; import { Gauge, MessageSquare, Music, Scissors, ZoomIn } from "lucide-react"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import glassStyles from "./ItemGlass.module.css"; +import { generateWaveform } from "@/utils/audioWaveform"; interface ItemProps { id: string; @@ -14,7 +15,13 @@ interface ItemProps { onSelect?: () => void; zoomDepth?: number; speedValue?: number; + audioPath?: string; variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption' | 'caption-range'; + isDraggable?: boolean; + isResizable?: boolean; + muted?: boolean; + fadeInMs?: number; + fadeOutMs?: number; } // Map zoom depth to multiplier labels @@ -45,8 +52,14 @@ export default function Item({ onSelect, zoomDepth = 1, speedValue, + audioPath, variant = "zoom", children, + isDraggable = true, + isResizable = true, + muted = false, + fadeInMs, + fadeOutMs, }: ItemProps) { const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, @@ -54,6 +67,8 @@ export default function Item({ data: { rowId }, }); + const durationMs = span.end - span.start; + const isZoom = variant === 'zoom'; const isTrim = variant === 'trim'; const isSpeed = variant === 'speed'; @@ -61,6 +76,16 @@ export default function Item({ const isCaption = variant === 'caption'; const isCaptionRange = variant === 'caption-range'; + const [waveform, setWaveform] = useState(null); + + useEffect(() => { + if (isAudio && audioPath) { + generateWaveform(audioPath).then((peaks) => { + setWaveform(peaks); + }); + } + }, [isAudio, audioPath]); + const glassClass = isZoom ? glassStyles.glassGreen : isTrim @@ -101,8 +126,8 @@ export default function Item({
onSelect?.()} className="group h-full" > @@ -111,7 +136,8 @@ export default function Item({ className={cn( glassClass, "w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative", - isSelected && glassStyles.selected + isSelected && glassStyles.selected, + muted && "opacity-40 grayscale-[0.5]" )} style={{ height: "100%", minHeight: 22, color: '#fff', minWidth: 24 }} onClick={(event) => { @@ -119,19 +145,67 @@ export default function Item({ onSelect?.(); }} > -
-
+ {/* Waveform Background for Audio */} + {isAudio && waveform && ( +
+ + {waveform.map((peak, i) => ( + + ))} + +
+ )} + + {/* Fade Visualizations */} + {isAudio && (fadeInMs || fadeOutMs) && ( +
+ {fadeInMs && fadeInMs > 0 && ( +
+ )} +
+ {fadeOutMs && fadeOutMs > 0 && ( +
+ )} +
+ )} + + {isResizable && ( + <> +
+
+ + )} {/* Content */} -
-
+
+
{isZoom ? ( <> @@ -156,7 +230,7 @@ export default function Item({ ) : isAudio ? ( <> - + {children} diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 434aab29..ee487038 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -7,9 +7,10 @@ interface RowProps extends RowDefinition { hint?: string; isEmpty?: boolean; labelColor?: string; + controls?: React.ReactNode; } -export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) { +export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666', controls }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -17,12 +18,20 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = ' className="border-b border-[#18181b] bg-[#18181b] relative flex-1 min-h-[26px]" style={{ ...rowWrapperStyle, marginBottom: 2 }} > - {label && ( + {(label || controls) && (
- {label} + {label && ( +
+ {label} +
+ )} + {controls}
)} {isEmpty && hint && ( diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index e87272e5..3bd3178c 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -31,6 +31,7 @@ const ZOOM_ROW_ID = "row-zoom"; const TRIM_ROW_ID = "row-trim"; const ANNOTATION_ROW_ID = "row-annotation"; const SPEED_ROW_ID = "row-speed"; +const ORIGINAL_AUDIO_ROW_ID = "row-original-audio"; const AUDIO_ROW_ID = "row-audio"; const CAPTION_ROW_ID = "row-caption"; const FALLBACK_RANGE_MS = 1000; @@ -40,6 +41,7 @@ const DRAG_THRESHOLD_PX = 5; interface TimelineEditorProps { videoDuration: number; + videoPath?: string; currentTime: number; onSeek?: (time: number) => void; cursorTelemetry?: CursorTelemetryPoint[]; @@ -72,9 +74,19 @@ interface TimelineEditorProps { audioRegions?: AudioRegion[]; onAudioAdded?: (span: Span, audioPath: string) => void; onAudioSpanChange?: (id: string, span: Span) => void; + onAudioMutedChange?: (id: string, muted: boolean) => void; + onAudioSoloedChange?: (id: string, soloed: boolean) => void; onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; + masterAudioMuted?: boolean; + onMasterAudioMutedChange?: (muted: boolean) => void; + masterAudioSoloed?: boolean; + onMasterAudioSoloedChange?: (soloed: boolean) => void; + masterAudioVolume?: number; + audioTrackVolume?: number; + onMasterAudioVolumeChange?: (volume: number) => void; + onAudioTrackVolumeChange?: (volume: number) => void; autoCaptions?: CaptionCue[]; onCaptionSpanChange?: (id: string, span: Span) => void; selectedCaptionId?: string | null; @@ -86,6 +98,8 @@ interface TimelineEditorProps { isCropped?: boolean; timeSelection?: TimeSelection | null; onTimeSelectionChange?: (selection: TimeSelection | null) => void; + isMasterSelected?: boolean; + onSelectMaster?: (selected: boolean) => void; } interface TimelineScaleConfig { @@ -101,7 +115,12 @@ interface TimelineRenderItem { label: string; zoomDepth?: number; speedValue?: number; + audioPath?: string; variant: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption'; + muted?: boolean; + soloed?: boolean; + fadeInMs?: number; + fadeOutMs?: number; } const SCALE_CANDIDATES = [ @@ -202,6 +221,7 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) { return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`; } + function formatPlayheadTime(ms: number): string { const s = ms / 1000; const min = Math.floor(s / 60); @@ -466,6 +486,15 @@ function Timeline({ onSelectAnnotation, onSelectSpeed, onSelectAudio, + onAudioMutedChange, + onAudioSoloedChange, + audioRegions, + masterAudioMuted = false, + onMasterAudioMutedChange, + masterAudioSoloed = false, + onMasterAudioSoloedChange, + masterAudioVolume = 1, + audioTrackVolume = 1, selectedZoomId, selectedTrimId, selectedAnnotationId, @@ -478,6 +507,8 @@ function Timeline({ keyframes = [], timeSelection, onTimeSelectionChange, + isMasterSelected = false, + onSelectMaster, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -488,6 +519,17 @@ function Timeline({ onSelectAnnotation?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onAudioMutedChange?: (id: string, muted: boolean) => void; + onAudioSoloedChange?: (id: string, soloed: boolean) => void; + audioRegions?: AudioRegion[]; + masterAudioMuted?: boolean; + onMasterAudioMutedChange?: (muted: boolean) => void; + masterAudioSoloed?: boolean; + onMasterAudioSoloedChange?: (soloed: boolean) => void; + masterAudioVolume?: number; + audioTrackVolume?: number; + onMasterAudioVolumeChange?: (volume: number) => void; + onAudioTrackVolumeChange?: (volume: number) => void; selectedZoomId: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; @@ -500,6 +542,8 @@ function Timeline({ keyframes?: { id: string; time: number }[]; timeSelection?: TimeSelection | null; onTimeSelectionChange?: (selection: TimeSelection | null) => void; + isMasterSelected?: boolean; + onSelectMaster?: (selected: boolean) => void; }) { const { setTimelineRef, style, sidebarWidth = 0, range, pixelsToValue, valueToPixels } = useTimelineContext(); const localTimelineRef = useRef(null); @@ -610,6 +654,7 @@ function Timeline({ onSelectSpeed?.(null); onSelectAudio?.(null); onSelectCaption?.(null); + onSelectMaster?.(false); onClearBlockSelection?.(); const rect = e.currentTarget.getBoundingClientRect(); @@ -637,9 +682,119 @@ function Timeline({ const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID); const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID); const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID); + const originalAudioItems = items.filter(item => item.rowId === ORIGINAL_AUDIO_ROW_ID); const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID); const captionItems = items.filter(item => item.rowId === CAPTION_ROW_ID); + const handleAllAudioMute = useCallback(() => { + if (!audioRegions || audioRegions.length === 0) return; + const allMuted = audioRegions.every((r) => r.muted); + audioRegions.forEach((r) => { + onAudioMutedChange?.(r.id, !allMuted); + if (!allMuted && r.soloed) onAudioSoloedChange?.(r.id, false); + }); + }, [audioRegions, onAudioMutedChange, onAudioSoloedChange]); + + const handleAllAudioSolo = useCallback(() => { + if (!audioRegions || audioRegions.length === 0) return; + const anySoloed = audioRegions.some((r) => r.soloed); + audioRegions.forEach((r) => { + onAudioSoloedChange?.(r.id, !anySoloed); + if (!anySoloed && r.muted) onAudioMutedChange?.(r.id, false); + }); + }, [audioRegions, onAudioSoloedChange, onAudioMutedChange]); + + const anyAudioMuted = useMemo(() => audioRegions && audioRegions.length > 0 && audioRegions.every((r) => r.muted), [audioRegions]); + const anyAudioSoloed = useMemo(() => audioRegions && audioRegions.length > 0 && audioRegions.some((r) => r.soloed), [audioRegions]); + + const masterAudioControls = ( +
+ + + {masterAudioVolume !== 1 && ( + {Math.round(masterAudioVolume * 100)}% + )} +
+ ); + + const audioControls = ( +
+ + + {audioTrackVolume !== 1 && ( + {Math.round(audioTrackVolume * 100)}% + )} +
+ ); + return (
- + + {originalAudioItems.map((item) => ( + onSelectMaster?.(true)} + variant="audio" + audioPath={item.audioPath} + isDraggable={false} + isResizable={false} + muted={item.muted} + > + {item.label} + + ))} + + + {audioItems.map((item) => ( onSelectAudio?.(item.id)} variant="audio" + audioPath={item.audioPath} + muted={item.muted} + fadeInMs={item.fadeInMs} + fadeOutMs={item.fadeOutMs} > {item.label} ))} - {captionItems.map((item) => ( 0 ? [{ + id: 'original-video-audio', + rowId: ORIGINAL_AUDIO_ROW_ID, + span: { start: 0, end: totalMs }, + label: 'Original Audio', + variant: 'audio', + audioPath: videoPath, + muted: masterAudioMuted + }] : []; + const audios: TimelineRenderItem[] = [ ...audioRegions.map((audio): TimelineRenderItem => ({ id: audio.id, rowId: AUDIO_ROW_ID, span: { start: audio.startMs, end: audio.endMs }, label: audio.audioPath.split(/[\\/]/).pop() || 'Audio', - variant: 'audio' + variant: 'audio', + audioPath: audio.audioPath, + muted: audio.muted, + soloed: audio.soloed, + fadeInMs: audio.fadeInMs, + fadeOutMs: audio.fadeOutMs })), ...autoCaptions.map((cue): TimelineRenderItem => ({ id: cue.id, @@ -1622,8 +1832,8 @@ export default function TimelineEditor({ })) ]; - return [...zooms, ...trims, ...annotations, ...speeds, ...audios]; - }, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions]); + return [...zooms, ...trims, ...annotations, ...speeds, ...videoAudio, ...audios]; + }, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions, videoPath, totalMs, masterAudioMuted]); // Flat list of all non-annotation region spans for neighbour-clamping during drag/resize const allRegionSpans = useMemo(() => { @@ -1905,7 +2115,16 @@ export default function TimelineEditor({ onSelectAnnotation={handleSelectAnnotation} onSelectSpeed={handleSelectSpeed} onSelectAudio={handleSelectAudio} + onAudioMutedChange={onAudioMutedChange} + onAudioSoloedChange={onAudioSoloedChange} + audioRegions={audioRegions} onSelectCaption={handleSelectCaption} + masterAudioMuted={masterAudioMuted} + onMasterAudioMutedChange={onMasterAudioMutedChange} + masterAudioSoloed={masterAudioSoloed} + onMasterAudioSoloedChange={onMasterAudioSoloedChange} + masterAudioVolume={masterAudioVolume} + audioTrackVolume={audioTrackVolume} selectedZoomId={selectedZoomId} selectedTrimId={selectedTrimId} selectedAnnotationId={selectedAnnotationId} @@ -1917,6 +2136,8 @@ export default function TimelineEditor({ keyframes={keyframes} timeSelection={timeSelection} onTimeSelectionChange={onTimeSelectionChange} + isMasterSelected={isMasterSelected} + onSelectMaster={onSelectMaster} />
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ae4ca1d4..d0b1045f 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -247,6 +247,10 @@ export interface AudioRegion { endMs: number; audioPath: string; volume: number; + muted?: boolean; + soloed?: boolean; + fadeInMs?: number; + fadeOutMs?: number; } diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index a40c4740..975a99e0 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -25,6 +25,9 @@ export class AudioProcessor { speedRegions?: SpeedRegion[], readEndSec?: number, audioRegions?: AudioRegion[], + masterAudioVolume = 1, + audioTrackVolume = 1, + masterAudioMuted = false, ): Promise { const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [] const sortedSpeedRegions = speedRegions @@ -43,6 +46,9 @@ export class AudioProcessor { sortedTrims, sortedSpeedRegions, sortedAudioRegions, + masterAudioVolume, + audioTrackVolume, + masterAudioMuted, ) if (!this.cancelled) { await this.muxRenderedAudioBlob(renderedAudioBlob, muxer) @@ -279,6 +285,9 @@ export class AudioProcessor { trimRegions: TrimRegion[], speedRegions: SpeedRegion[], audioRegions: AudioRegion[], + masterAudioVolume: number, + audioTrackVolume: number, + masterAudioMuted: boolean, ): Promise { const mediaSource = await resolveMediaElementSource(videoUrl) const media = document.createElement('audio') @@ -331,7 +340,8 @@ export class AudioProcessor { const regionSourceNode = audioContext.createMediaElementSource(audioEl) const gainNode = audioContext.createGain() - gainNode.gain.value = Math.max(0, Math.min(1, region.volume)) + // Initial volume (will be updated in the tick loop for fades) + gainNode.gain.value = 0 regionSourceNode.connect(gainNode) gainNode.connect(destinationNode) @@ -404,20 +414,36 @@ export class AudioProcessor { // Sync external audio regions with the video timeline position for (const entry of audioRegionElements) { - const { media: audioEl, region } = entry + const { media: audioEl, region, gainNode } = entry const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs if (isInRegion) { const audioOffset = (currentTimeMs - region.startMs) / 1000 + + // Apply fade-in / fade-out multiplier + let fadeMultiplier = 1; + if (region.fadeInMs && currentTimeMs < region.startMs + region.fadeInMs) { + fadeMultiplier = (currentTimeMs - region.startMs) / region.fadeInMs; + } else if (region.fadeOutMs && currentTimeMs > region.endMs - region.fadeOutMs) { + fadeMultiplier = (region.endMs - currentTimeMs) / region.fadeOutMs; + } + fadeMultiplier = Math.max(0, Math.min(1, fadeMultiplier)); + + // Apply total volume including global settings + const totalVolume = masterAudioMuted ? 0 : region.volume * audioTrackVolume * masterAudioVolume * fadeMultiplier; + gainNode.gain.setTargetAtTime(totalVolume, audioContext.currentTime, 0.015); + if (audioEl.paused) { audioEl.currentTime = audioOffset audioEl.play().catch(() => {}) - } else if (Math.abs(audioEl.currentTime - audioOffset) > 0.3) { + } else if (Math.abs(audioEl.currentTime - audioOffset) > 0.1) { + // Tightened sync for export audioEl.currentTime = audioOffset } } else { if (!audioEl.paused) { audioEl.pause() + gainNode.gain.value = 0 } } } diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index a92d28db..2a17bb4e 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -4,6 +4,9 @@ export interface ExportConfig { frameRate: number; bitrate: number; codec?: string; + masterAudioVolume?: number; + audioTrackVolume?: number; + masterAudioMuted?: boolean; } export interface ExportProgress { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 2e1e105d..307e90fe 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -207,6 +207,9 @@ export class VideoExporter { this.config.speedRegions, undefined, this.config.audioRegions, + this.config.masterAudioVolume, + this.config.audioTrackVolume, + this.config.masterAudioMuted, ), "audio processing", ); diff --git a/src/utils/audioWaveform.ts b/src/utils/audioWaveform.ts new file mode 100644 index 00000000..7db89bac --- /dev/null +++ b/src/utils/audioWaveform.ts @@ -0,0 +1,44 @@ +/** + * Extracts waveform peaks from an audio file. + * We decode the audio file using the Web Audio API and calculate the max peaks for each sample. + */ + +import { toFileUrl } from "@/components/video-editor/projectPersistence"; + +const waveformCache = new Map(); + +export async function generateWaveform(audioPath: string, samples = 200): Promise { + const cacheKey = `${audioPath}:${samples}`; + if (waveformCache.has(cacheKey)) { + return waveformCache.get(cacheKey)!; + } + + try { + const response = await fetch(toFileUrl(audioPath)); + const arrayBuffer = await response.arrayBuffer(); + + // Use an offline audio context to decode the data + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + const channelData = audioBuffer.getChannelData(0); // Use the first channel + const blockSize = Math.floor(channelData.length / samples); + const peaks: number[] = []; + + for (let i = 0; i < samples; i++) { + const start = i * blockSize; + let max = 0; + for (let j = 0; j < blockSize; j++) { + const value = Math.abs(channelData[start + j]); + if (value > max) max = value; + } + peaks.push(max); + } + + waveformCache.set(cacheKey, peaks); + return peaks; + } catch (error) { + console.error('Failed to generate waveform:', error); + return new Array(samples).fill(0); + } +}