diff --git a/src/components/video-editor/AudioSettingsPanel.tsx b/src/components/video-editor/AudioSettingsPanel.tsx new file mode 100644 index 00000000..da1b7c06 --- /dev/null +++ b/src/components/video-editor/AudioSettingsPanel.tsx @@ -0,0 +1,221 @@ +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(() => { + let active = true; + if (audio.audioPath) { + generateWaveform(audio.audioPath, 120).then(result => { + if (active) setWaveform(result); + }); + } + return () => { active = false; }; + }, [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 d0b2d656..7e714555 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { 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"; @@ -208,8 +211,13 @@ interface SettingsPanelProps { onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; onAnnotationStyleChange?: (id: string, style: Partial) => void; onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; + onAnnotationBlurIntensityChange?: (id: string, blurIntensity: number) => void; onAnnotationDelete?: (id: string) => void; + onSeek?: (time: number) => void; autoCaptions?: CaptionCue[]; + onAutoCaptionsChange?: (captions: CaptionCue[]) => void; + selectedCaptionId?: string | null; + onSelectCaption?: (id: string | null) => void; autoCaptionSettings?: AutoCaptionSettings; whisperExecutablePath?: string | null; whisperModelPath?: string | null; @@ -221,12 +229,30 @@ interface SettingsPanelProps { onPickWhisperModel?: () => void; onGenerateAutoCaptions?: () => void; onClearAutoCaptions?: () => void; - onDownloadWhisperSmallModel?: () => void; - onDeleteWhisperSmallModel?: () => void; + autoCaptionProgress?: number; + onDownloadWhisperModel?: () => void; + onDeleteWhisperModel?: () => void; selectedSpeedId?: string | null; 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; + 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; @@ -278,8 +304,24 @@ const CAPTION_LANGUAGE_OPTIONS = [ { value: "zh", label: "Chinese" }, { value: "ja", label: "Japanese" }, { value: "ko", label: "Korean" }, + { value: "id", label: "Indonesian" }, ] as const; +export type WhisperModelInfo = { + value: "tiny" | "base" | "small" | "medium" | "large" | "custom"; + label: string; + size: string; +}; + +const WHISPER_MODEL_OPTIONS: WhisperModelInfo[] = [ + { value: "tiny", label: "Tiny", size: "75 MB" }, + { value: "base", label: "Base", size: "142 MB" }, + { value: "small", label: "Small", size: "466 MB" }, + { value: "medium", label: "Medium", size: "1.5 GB" }, + { value: "large", label: "Large (v3)", size: "2.9 GB" }, + { value: "custom", label: "Custom", size: "Local File" }, +]; + function loadPreviewImage(url: string) { return new Promise((resolve, reject) => { const image = new Image(); @@ -545,8 +587,12 @@ export function SettingsPanel({ onAnnotationTypeChange, onAnnotationStyleChange, onAnnotationFigureDataChange, + onAnnotationBlurIntensityChange, onAnnotationDelete, + onSeek, autoCaptions = [], + onAutoCaptionsChange, + autoCaptionProgress = 0, autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, whisperModelPath, whisperModelDownloadStatus = "idle", @@ -556,15 +602,35 @@ export function SettingsPanel({ onPickWhisperModel, onGenerateAutoCaptions, onClearAutoCaptions, - onDownloadWhisperSmallModel, - onDeleteWhisperSmallModel, + onDownloadWhisperModel, + onDeleteWhisperModel, + selectedCaptionId, + onSelectCaption, selectedSpeedId, selectedSpeedValue, onSpeedChange, onSpeedDelete, + audioRegions = [], + selectedAudioId, + onAudioVolumeChange, + onAudioMutedChange, + onAudioSoloedChange, + onAudioFadeInMsChange, + onAudioFadeOutMsChange, + onAudioDelete, + isMasterSelected, + masterAudioVolume = 1, + masterAudioMuted = false, + masterAudioSoloed = false, + videoDuration, + videoPath, + onMasterAudioVolumeChange, + onMasterAudioMutedChange, + onMasterAudioSoloedChange, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { t } = useI18n(); + const isBackgroundPanel = panelMode === "background"; const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [builtInWallpapers, setBuiltInWallpapers] = @@ -1179,6 +1245,11 @@ export function SettingsPanel({ ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined } + onBlurIntensityChange={ + onAnnotationBlurIntensityChange + ? (intensity) => onAnnotationBlurIntensityChange(selectedAnnotation.id, intensity) + : undefined + } onDelete={() => onAnnotationDelete(selectedAnnotation.id)} /> ); @@ -1375,16 +1446,6 @@ export function SettingsPanel({
-
- -
{tSettings("captions.language", "Language")} @@ -1405,6 +1466,44 @@ export function SettingsPanel({
+
+
Model
+ +
+ {autoCaptionSettings.selectedModel === "custom" && ( +
+ + {whisperModelPath && ( +

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

+ )} +
+ )}
{whisperModelDownloadStatus === "downloading" ? ( @@ -1420,23 +1519,26 @@ export function SettingsPanel({ - ) : ( + ) : autoCaptionSettings.selectedModel !== "custom" ? ( + ) : ( +
+ No local model selected +
)}
+
+ + {autoCaptions.length > 0 && ( +
+
+ + {selectedCaptionId ? "Edit Selected Cue" : "Select Cue on Timeline"} + +
+ +
+ {selectedCaptionId ? ( + (() => { + const index = autoCaptions.findIndex((c) => c.id === selectedCaptionId); + const cue = autoCaptions[index]; + if (!cue) return null; + + return ( +
+
+ + +
+