void;
+ exportFormat: ExportFormat;
+ onExportFormatChange: (format: ExportFormat) => void;
+ exportQuality: ExportQuality;
+ onExportQualityChange: (quality: ExportQuality) => void;
+ gifFrameRate: GifFrameRate;
+ onGifFrameRateChange: (rate: GifFrameRate) => void;
+ gifSizePreset: GifSizePreset;
+ onGifSizePresetChange: (preset: GifSizePreset) => void;
+ gifLoop: boolean;
+ onGifLoopChange: (enabled: boolean) => void;
+ gifOutputDimensions: { width: number; height: number };
+ onExport: () => void;
+ unsavedExport: {
+ arrayBuffer: ArrayBuffer;
+ fileName: string;
+ format: string;
+ } | null;
+ onSaveUnsavedExport?: () => void;
+}
+
+export function ExportSettingsPopup({
+ isOpen,
+ onClose,
+ exportFormat,
+ onExportFormatChange,
+ exportQuality,
+ onExportQualityChange,
+ gifFrameRate,
+ onGifFrameRateChange,
+ gifSizePreset,
+ onGifSizePresetChange,
+ gifLoop,
+ onGifLoopChange,
+ gifOutputDimensions,
+ onExport,
+ unsavedExport,
+ onSaveUnsavedExport,
+}: ExportSettingsPopupProps) {
+ const t = useScopedT("settings");
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+
+
+
+
+
{t("export.videoButton")}
+
Choose format and quality before exporting.
+
+
+
+
+
+
+
+
+
+ {exportFormat === "mp4" && (
+
+
+
+
+
+ )}
+
+ {exportFormat === "gif" && (
+
+
+
+ {GIF_FRAME_RATES.map((rate) => (
+
+ ))}
+
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
+
+ ))}
+
+
+
+
+ {gifOutputDimensions.width} x {gifOutputDimensions.height}px
+
+
+ {t("gifSettings.loop")}
+
+
+
+
+ )}
+
+ {unsavedExport && (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 4fb419364..e2027a698 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,4 +1,5 @@
import Block from "@uiw/react-color-block";
+import Fuse from "fuse.js";
import {
Bug,
Crop,
@@ -6,7 +7,9 @@ import {
Film,
Image,
Lock,
+ Music,
Palette,
+ Search,
Sparkles,
Star,
Trash2,
@@ -14,7 +17,7 @@ import {
Upload,
X,
} from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -35,6 +38,7 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
+import { AUDIO_LIBRARY } from "@/lib/audioLibrary";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
@@ -48,10 +52,13 @@ import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
+ AudioHooksConfig,
+ AudioHookType,
BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
+ TrimRegion,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
@@ -155,11 +162,37 @@ const GRADIENTS = [
"linear-gradient(to right, #0acffe 0%, #495aff 100%)",
];
+function PlayingBars({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
interface SettingsPanelProps {
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
+ selectedZoomInDuration?: number;
+ selectedZoomOutDuration?: number;
+ onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
@@ -181,6 +214,31 @@ interface SettingsPanelProps {
padding?: number;
onPaddingChange?: (padding: number) => void;
onPaddingCommit?: () => void;
+ backgroundMusicPath?: string | null;
+ backgroundMusicVolume?: number;
+ backgroundMusicFadeIn?: number;
+ backgroundMusicFadeOut?: number;
+ onBackgroundMusicPick?: () => void;
+ onBackgroundMusicRemove?: () => void;
+ onBackgroundMusicVolumeChange?: (volume: number) => void;
+ onBackgroundMusicVolumeCommit?: () => void;
+ onBackgroundMusicFadeInChange?: (value: number) => void;
+ onBackgroundMusicFadeInCommit?: () => void;
+ onBackgroundMusicFadeOutChange?: (value: number) => void;
+ onBackgroundMusicFadeOutCommit?: () => void;
+ onMusicTrackSelect?: (trackUrl: string) => void;
+ backgroundMusicRegions?: TrimRegion[];
+ selectedMusicRegionId?: string | null;
+ onSelectedMusicRegionDelete?: (id: string) => void;
+ audioHooks?: AudioHooksConfig;
+ audioHooksVolume?: number;
+ onAudioHooksChange?: (hooks: AudioHooksConfig) => void;
+ onAudioHooksVolumeChange?: (volume: number) => void;
+ onAudioHooksVolumeCommit?: () => void;
+ hookSoundLayers?: Record
;
+ onHookTrackAdd?: (hook: AudioHookType, trackUrl: string) => void;
+ onHookTrackRemove?: (hook: AudioHookType, trackUrl: string) => void;
+ onHookTimelineAdd?: (hook: AudioHookType, trackUrl: string, trackName: string) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
@@ -204,6 +262,7 @@ interface SettingsPanelProps {
format: string;
} | null;
onSaveUnsavedExport?: () => void;
+ showEmbeddedExportSection?: boolean;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
@@ -226,9 +285,6 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
- selectedZoomInDuration?: number;
- selectedZoomOutDuration?: number;
- onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
@@ -245,13 +301,6 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
-const ZOOM_SPEED_OPTIONS = [
- { label: "Instant", zoomIn: 0, zoomOut: 0 },
- { label: "Fast", zoomIn: 500, zoomOut: 350 },
- { label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
- { label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
-];
-
export function SettingsPanel({
selected,
onWallpaperChange,
@@ -278,6 +327,43 @@ export function SettingsPanel({
padding = 50,
onPaddingChange,
onPaddingCommit,
+ backgroundMusicPath = null,
+ backgroundMusicVolume = 0.35,
+ backgroundMusicFadeIn = 0,
+ backgroundMusicFadeOut = 0,
+ onBackgroundMusicPick,
+ onBackgroundMusicRemove,
+ onBackgroundMusicVolumeChange,
+ onBackgroundMusicVolumeCommit,
+ onBackgroundMusicFadeInChange,
+ onBackgroundMusicFadeInCommit,
+ onBackgroundMusicFadeOutChange,
+ onBackgroundMusicFadeOutCommit,
+ onMusicTrackSelect,
+ backgroundMusicRegions = [],
+ selectedMusicRegionId = null,
+ onSelectedMusicRegionDelete,
+ audioHooks = {
+ zoom: false,
+ trim: false,
+ speed: false,
+ annotation: false,
+ blur: false,
+ },
+ audioHooksVolume = 0.35,
+ onAudioHooksChange,
+ onAudioHooksVolumeChange,
+ onAudioHooksVolumeCommit,
+ hookSoundLayers = {
+ zoom: [],
+ trim: [],
+ speed: [],
+ annotation: [],
+ blur: [],
+ },
+ onHookTrackAdd,
+ onHookTrackRemove,
+ onHookTimelineAdd,
cropRegion,
onCropChange,
aspectRatio,
@@ -296,6 +382,7 @@ export function SettingsPanel({
onExport,
unsavedExport,
onSaveUnsavedExport,
+ showEmbeddedExportSection = true,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
@@ -318,9 +405,6 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
- selectedZoomInDuration,
- selectedZoomOutDuration,
- onZoomDurationChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
@@ -366,7 +450,16 @@ export function SettingsPanel({
const [selectedColor, setSelectedColor] = useState("#ADADAD");
const [gradient, setGradient] = useState(GRADIENTS[0]);
const [showCropModal, setShowCropModal] = useState(false);
+ const [musicLibraryQuery, setMusicLibraryQuery] = useState("");
+ const [hookLibraryQuery, setHookLibraryQuery] = useState("");
+ const [selectedHookTarget, setSelectedHookTarget] = useState("zoom");
+ const [previewingMusicTrackId, setPreviewingMusicTrackId] = useState(null);
+ const musicPreviewAudioRef = useRef(null);
+ const [previewingHookTrackId, setPreviewingHookTrackId] = useState(null);
+ const hookPreviewAudioRef = useRef(null);
const cropSnapshotRef = useRef(null);
+ const musicListRef = useRef(null);
+ const hooksListRef = useRef(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
@@ -551,6 +644,175 @@ export function SettingsPanel({
const selectedBlur = selectedBlurId
? blurRegions.find((region) => region.id === selectedBlurId)
: null;
+ const backgroundMusicName = useMemo(() => {
+ if (!backgroundMusicPath) {
+ return null;
+ }
+ const parts = backgroundMusicPath.split(/[\\/]+/);
+ return parts[parts.length - 1] || backgroundMusicPath;
+ }, [backgroundMusicPath]);
+
+ const hookOptions = useMemo>(
+ () => [
+ { key: "zoom", label: t("audio.hooks.zoom") },
+ { key: "trim", label: t("audio.hooks.trim") },
+ { key: "speed", label: t("audio.hooks.speed") },
+ { key: "annotation", label: t("audio.hooks.annotation") },
+ { key: "blur", label: t("audio.hooks.blur") },
+ ],
+ [t],
+ );
+
+ const musicLibrary = useMemo(
+ () => AUDIO_LIBRARY.filter((entry) => entry.category === "music"),
+ [],
+ );
+
+ const hooksLibrary = useMemo(
+ () => AUDIO_LIBRARY.filter((entry) => entry.category === "hook"),
+ [],
+ );
+
+ const musicFuse = useMemo(
+ () =>
+ new Fuse(musicLibrary, {
+ keys: ["name", "tags"],
+ threshold: 0.4,
+ distance: 100,
+ minMatchCharLength: 1,
+ includeScore: true,
+ }),
+ [musicLibrary],
+ );
+
+ const filteredMusicLibrary = useMemo(() => {
+ const query = musicLibraryQuery.trim();
+ if (!query) return musicLibrary;
+ return musicFuse.search(query).map((r) => r.item);
+ }, [musicLibrary, musicLibraryQuery, musicFuse]);
+
+ const hooksFuse = useMemo(
+ () =>
+ new Fuse(hooksLibrary, {
+ keys: ["name", "tags"],
+ threshold: 0.4,
+ distance: 100,
+ minMatchCharLength: 1,
+ includeScore: true,
+ }),
+ [hooksLibrary],
+ );
+
+ const filteredHooksLibrary = useMemo(() => {
+ const query = hookLibraryQuery.trim();
+ if (!query) return hooksLibrary;
+ return hooksFuse.search(query).map((r) => r.item);
+ }, [hooksLibrary, hookLibraryQuery, hooksFuse]);
+
+ const stopMusicPreview = useCallback(() => {
+ if (musicPreviewAudioRef.current) {
+ musicPreviewAudioRef.current.pause();
+ musicPreviewAudioRef.current.currentTime = 0;
+ musicPreviewAudioRef.current = null;
+ }
+ setPreviewingMusicTrackId(null);
+ }, []);
+
+ const handleMusicLibraryPreview = useCallback(
+ async (trackId: string, trackUrl: string) => {
+ if (previewingMusicTrackId === trackId && musicPreviewAudioRef.current) {
+ stopMusicPreview();
+ return;
+ }
+
+ stopMusicPreview();
+ try {
+ const relativePath = trackUrl.replace(/^\/+/, "");
+ const resolvedAssetPath = await getAssetPath(relativePath);
+ const audio = new Audio(resolvedAssetPath);
+ audio.preload = "auto";
+ audio.volume = Math.min(1, Math.max(0, backgroundMusicVolume));
+ musicPreviewAudioRef.current = audio;
+ setPreviewingMusicTrackId(trackId);
+
+ void audio.play().catch(() => {
+ stopMusicPreview();
+ toast.error("Unable to preview this track");
+ });
+
+ audio.addEventListener(
+ "ended",
+ () => {
+ if (musicPreviewAudioRef.current === audio) {
+ musicPreviewAudioRef.current = null;
+ setPreviewingMusicTrackId(null);
+ }
+ },
+ { once: true },
+ );
+ } catch {
+ stopMusicPreview();
+ toast.error("Unable to preview this track");
+ }
+ },
+ [backgroundMusicVolume, previewingMusicTrackId, stopMusicPreview],
+ );
+
+ const stopHookPreview = useCallback(() => {
+ if (hookPreviewAudioRef.current) {
+ hookPreviewAudioRef.current.pause();
+ hookPreviewAudioRef.current.currentTime = 0;
+ hookPreviewAudioRef.current = null;
+ }
+ setPreviewingHookTrackId(null);
+ }, []);
+
+ const handleHookLibraryPreview = useCallback(
+ async (trackId: string, trackUrl: string) => {
+ if (previewingHookTrackId === trackId && hookPreviewAudioRef.current) {
+ stopHookPreview();
+ return;
+ }
+
+ stopHookPreview();
+ try {
+ const relativePath = trackUrl.replace(/^\/+/, "");
+ const resolvedAssetPath = await getAssetPath(relativePath);
+ const audio = new Audio(resolvedAssetPath);
+ audio.preload = "auto";
+ audio.volume = Math.min(1, Math.max(0, audioHooksVolume));
+ hookPreviewAudioRef.current = audio;
+ setPreviewingHookTrackId(trackId);
+
+ void audio.play().catch(() => {
+ stopHookPreview();
+ toast.error("Unable to preview this sound");
+ });
+
+ audio.addEventListener(
+ "ended",
+ () => {
+ if (hookPreviewAudioRef.current === audio) {
+ hookPreviewAudioRef.current = null;
+ setPreviewingHookTrackId(null);
+ }
+ },
+ { once: true },
+ );
+ } catch {
+ stopHookPreview();
+ toast.error("Unable to preview this sound");
+ }
+ },
+ [audioHooksVolume, previewingHookTrackId, stopHookPreview],
+ );
+
+ useEffect(() => {
+ return () => {
+ stopMusicPreview();
+ stopHookPreview();
+ };
+ }, [stopHookPreview, stopMusicPreview]);
// If an annotation is selected, show annotation settings instead
if (
@@ -592,7 +854,7 @@ export function SettingsPanel({
return (
-
+
{t("zoom.level")}
@@ -666,39 +928,6 @@ export function SettingsPanel({
)}
)}
-
- {zoomEnabled && (
-
-
- {t("zoom.speed.title") || "Zoom Speed"}
-
-
- {ZOOM_SPEED_OPTIONS.map((opt) => {
- const isActive =
- selectedZoomInDuration !== undefined &&
- selectedZoomOutDuration !== undefined &&
- Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
- Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
- return (
-
- );
- })}
-
-
- )}
{zoomEnabled && (