diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml index 6da25d0d6..8fc965781 100644 --- a/.github/workflows/discord.yaml +++ b/.github/workflows/discord.yaml @@ -305,18 +305,21 @@ jobs: return; } - if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) { + if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft", "closed"].includes(action)) { + const isMergedAction = action === "closed" ? !!pr.merged : false; + const isClosedAction = action === "closed"; const statusTag = desiredStatusTag({ draft: action === "converted_to_draft" ? true : pr.draft, reviewState, - merged: false, - closed: false, + merged: isMergedAction, + closed: isClosedAction, }); const mappedLabelTags = tagIdsFromLabels(labels); const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; await patchDiscordThread(threadId, { name: trimThreadName(`PR #${number} - ${title}`), ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + ...(isMergedAction ? { archived: true, locked: true } : {}), }); } @@ -347,14 +350,6 @@ jobs: }; } else if (action === "closed") { const isMerged = !!pr.merged; - const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true }); - const mappedLabelTags = tagIdsFromLabels(labels); - const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; - await patchDiscordThread(threadId, { - ...(appliedTags.length ? { applied_tags: appliedTags } : {}), - ...(isMerged ? { archived: true, locked: true } : {}), - }); - updateMessage = isMerged ? `✅ PR #${number} was merged` : `🛑 PR #${number} was closed without merge`; diff --git a/.gitignore b/.gitignore index b2be27c33..31c66a209 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ dist-ssr *.sw? release/** *.kiro/ +.claude/ +CLAUDE.md # npx electron-builder --mac --win # Playwright diff --git a/README.md b/README.md index 074eaa742..da40dd46c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Ask DeepWiki   - + Join Discord

diff --git a/electron-builder.json5 b/electron-builder.json5 index 18498df64..6e65e001a 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -24,6 +24,10 @@ { "from": "public/wallpapers", "to": "assets/wallpapers" + }, + { + "from": "public/audio", + "to": "assets/audio" } ], diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index b2a37205b..6f0a9de4b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -77,6 +77,13 @@ interface Window { fileName: string, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + openAudioFilePicker: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + canceled?: boolean; + }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( session: import("../src/lib/recordingSession").RecordingSession | null, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index be20fcdeb..1fd0efcbb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -25,6 +25,15 @@ const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); const RECORDING_SESSION_SUFFIX = ".session.json"; const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); +const ALLOWED_IMPORT_AUDIO_EXTENSIONS = new Set([ + ".mp3", + ".wav", + ".m4a", + ".aac", + ".ogg", + ".flac", + ".webm", +]); /** * Paths explicitly approved by the user via file picker dialogs or project loads. @@ -56,6 +65,10 @@ function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +function hasAllowedImportAudioExtension(filePath: string): boolean { + return ALLOWED_IMPORT_AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + async function approveReadableVideoPath( filePath?: string | null, trustedDirs?: string[], @@ -97,6 +110,33 @@ async function approveReadableVideoPath( return normalizedPath; } +async function approveReadableAudioPath(filePath?: string | null): Promise { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return null; + } + + if (isPathAllowed(normalizedPath)) { + return normalizedPath; + } + + if (!hasAllowedImportAudioExtension(normalizedPath)) { + return null; + } + + try { + const stats = await fs.stat(normalizedPath); + if (!stats.isFile()) { + return null; + } + } catch { + return null; + } + + approveFilePath(normalizedPath); + return normalizedPath; +} + function resolveRecordingOutputPath(fileName: string): string { const trimmed = fileName.trim(); if (!trimmed) { @@ -769,6 +809,47 @@ export function registerIpcHandlers( } }); + ipcMain.handle("open-audio-file-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select audio", + defaultPath: app.getPath("music"), + filters: [ + { + name: "Audio Files", + extensions: ["mp3", "wav", "m4a", "aac", "ogg", "flac", "webm"], + }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + const approvedPath = await approveReadableAudioPath(result.filePaths[0]); + if (!approvedPath) { + return { + success: false, + message: "Selected file is not a supported audio file", + }; + } + + return { + success: true, + path: approvedPath, + }; + } catch (error) { + console.error("Failed to open audio file picker:", error); + return { + success: false, + message: "Failed to open audio file picker", + error: String(error), + }; + } + }); + ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { try { // shell.showItemInFolder doesn't return a value, it throws on error diff --git a/electron/preload.ts b/electron/preload.ts index eeca25cd4..900393129 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -67,6 +67,9 @@ contextBridge.exposeInMainWorld("electronAPI", { openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); }, + openAudioFilePicker: () => { + return ipcRenderer.invoke("open-audio-file-picker"); + }, setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, diff --git a/package-lock.json b/package-lock.json index ba40beb4d..70b2a4414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "dnd-timeline": "^2.2.0", "emoji-picker-react": "^4.16.1", "fix-webm-duration": "^1.0.6", + "fuse.js": "^7.3.0", "gif.js": "^0.2.0", "gsap": "^3.13.0", "lucide-react": "^0.545.0", @@ -9588,6 +9589,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", diff --git a/package.json b/package.json index d41fd4005..2024e9477 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dnd-timeline": "^2.2.0", "emoji-picker-react": "^4.16.1", "fix-webm-duration": "^1.0.6", + "fuse.js": "^7.3.0", "gif.js": "^0.2.0", "gsap": "^3.13.0", "lucide-react": "^0.545.0", diff --git a/public/audio/hooks/annotation.mp3 b/public/audio/hooks/annotation.mp3 new file mode 100644 index 000000000..56d54b80f Binary files /dev/null and b/public/audio/hooks/annotation.mp3 differ diff --git a/public/audio/hooks/blur.wav b/public/audio/hooks/blur.wav new file mode 100644 index 000000000..105051b59 Binary files /dev/null and b/public/audio/hooks/blur.wav differ diff --git a/public/audio/hooks/speed.mp3 b/public/audio/hooks/speed.mp3 new file mode 100644 index 000000000..a4c3ec6f3 Binary files /dev/null and b/public/audio/hooks/speed.mp3 differ diff --git a/public/audio/hooks/trim.wav b/public/audio/hooks/trim.wav new file mode 100644 index 000000000..26bbc6460 Binary files /dev/null and b/public/audio/hooks/trim.wav differ diff --git a/public/audio/hooks/zoom.wav b/public/audio/hooks/zoom.wav new file mode 100644 index 000000000..6e966b47c Binary files /dev/null and b/public/audio/hooks/zoom.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-alarm-001.mp3 b/public/audio/library/hooks/openscreen-sfx-alarm-001.mp3 new file mode 100644 index 000000000..a87012142 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-alarm-001.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-alert.mp3 b/public/audio/library/hooks/openscreen-sfx-alert.mp3 new file mode 100644 index 000000000..6c25c7b86 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-alert.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-beep-001.wav b/public/audio/library/hooks/openscreen-sfx-beep-001.wav new file mode 100644 index 000000000..5649b0a7f Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-beep-001.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-camera-001.wav b/public/audio/library/hooks/openscreen-sfx-camera-001.wav new file mode 100644 index 000000000..61ffe3a5c Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-camera-001.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-camera-004.mp3 b/public/audio/library/hooks/openscreen-sfx-camera-004.mp3 new file mode 100644 index 000000000..38d0dda8c Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-camera-004.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-click-001.wav b/public/audio/library/hooks/openscreen-sfx-click-001.wav new file mode 100644 index 000000000..c844a4a05 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-click-001.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-click-002.mp3 b/public/audio/library/hooks/openscreen-sfx-click-002.mp3 new file mode 100644 index 000000000..6fedc84d0 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-click-002.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-clock-001.mp3 b/public/audio/library/hooks/openscreen-sfx-clock-001.mp3 new file mode 100644 index 000000000..c43761671 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-clock-001.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-fade-out-005.wav b/public/audio/library/hooks/openscreen-sfx-fade-out-005.wav new file mode 100644 index 000000000..d3d6a9ea0 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-fade-out-005.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-glitch-001.mp3 b/public/audio/library/hooks/openscreen-sfx-glitch-001.mp3 new file mode 100644 index 000000000..6c0b5110a Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-glitch-001.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-glitch-002.mp3 b/public/audio/library/hooks/openscreen-sfx-glitch-002.mp3 new file mode 100644 index 000000000..9dc138f97 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-glitch-002.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-misc-002.mp3 b/public/audio/library/hooks/openscreen-sfx-misc-002.mp3 new file mode 100644 index 000000000..4e1854e2d Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-misc-002.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-misc-014.wav b/public/audio/library/hooks/openscreen-sfx-misc-014.wav new file mode 100644 index 000000000..f1a97c77a Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-misc-014.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-money-002.mp3 b/public/audio/library/hooks/openscreen-sfx-money-002.mp3 new file mode 100644 index 000000000..ad950c200 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-money-002.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-money-003.mp3 b/public/audio/library/hooks/openscreen-sfx-money-003.mp3 new file mode 100644 index 000000000..04abc8e6c Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-money-003.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-notification-001.mp3 b/public/audio/library/hooks/openscreen-sfx-notification-001.mp3 new file mode 100644 index 000000000..0f12ea150 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-notification-001.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-notification-003.mp3 b/public/audio/library/hooks/openscreen-sfx-notification-003.mp3 new file mode 100644 index 000000000..0c841ff98 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-notification-003.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-notification-004.mp3 b/public/audio/library/hooks/openscreen-sfx-notification-004.mp3 new file mode 100644 index 000000000..eaf6be7b8 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-notification-004.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-reveal-013.mp3 b/public/audio/library/hooks/openscreen-sfx-reveal-013.mp3 new file mode 100644 index 000000000..4813da0f2 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-reveal-013.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-typing-001.mp3 b/public/audio/library/hooks/openscreen-sfx-typing-001.mp3 new file mode 100644 index 000000000..3d410d3eb Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-typing-001.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-typing-007.mp3 b/public/audio/library/hooks/openscreen-sfx-typing-007.mp3 new file mode 100644 index 000000000..4c634a1a4 Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-typing-007.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-whoosh-001.wav b/public/audio/library/hooks/openscreen-sfx-whoosh-001.wav new file mode 100644 index 000000000..6e966b47c Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-whoosh-001.wav differ diff --git a/public/audio/library/hooks/openscreen-sfx-whoosh-004.mp3 b/public/audio/library/hooks/openscreen-sfx-whoosh-004.mp3 new file mode 100644 index 000000000..9be25001f Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-whoosh-004.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-whoosh-005.mp3 b/public/audio/library/hooks/openscreen-sfx-whoosh-005.mp3 new file mode 100644 index 000000000..223f1f4ab Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-whoosh-005.mp3 differ diff --git a/public/audio/library/hooks/openscreen-sfx-whoosh-006.mp3 b/public/audio/library/hooks/openscreen-sfx-whoosh-006.mp3 new file mode 100644 index 000000000..39c147eed Binary files /dev/null and b/public/audio/library/hooks/openscreen-sfx-whoosh-006.mp3 differ diff --git a/public/audio/library/music/openscreen-ambient-001.mp3 b/public/audio/library/music/openscreen-ambient-001.mp3 new file mode 100644 index 000000000..623d067e3 Binary files /dev/null and b/public/audio/library/music/openscreen-ambient-001.mp3 differ diff --git a/public/audio/library/music/openscreen-ambient-002.mp3 b/public/audio/library/music/openscreen-ambient-002.mp3 new file mode 100644 index 000000000..14bcf3d00 Binary files /dev/null and b/public/audio/library/music/openscreen-ambient-002.mp3 differ diff --git a/public/audio/library/music/openscreen-corporate-001.mp3 b/public/audio/library/music/openscreen-corporate-001.mp3 new file mode 100644 index 000000000..d8d5e97fc Binary files /dev/null and b/public/audio/library/music/openscreen-corporate-001.mp3 differ diff --git a/public/audio/library/music/openscreen-corporate-002.mp3 b/public/audio/library/music/openscreen-corporate-002.mp3 new file mode 100644 index 000000000..29a7a4f5c Binary files /dev/null and b/public/audio/library/music/openscreen-corporate-002.mp3 differ diff --git a/public/audio/library/music/openscreen-lofi-001.mp3 b/public/audio/library/music/openscreen-lofi-001.mp3 new file mode 100644 index 000000000..904d879ce Binary files /dev/null and b/public/audio/library/music/openscreen-lofi-001.mp3 differ diff --git a/public/audio/library/music/openscreen-lofi-002.mp3 b/public/audio/library/music/openscreen-lofi-002.mp3 new file mode 100644 index 000000000..76a6315ee Binary files /dev/null and b/public/audio/library/music/openscreen-lofi-002.mp3 differ diff --git a/public/audio/library/music/openscreen-track-001.mp3 b/public/audio/library/music/openscreen-track-001.mp3 new file mode 100644 index 000000000..08fbc36f6 Binary files /dev/null and b/public/audio/library/music/openscreen-track-001.mp3 differ diff --git a/public/audio/library/music/openscreen-upbeat-001.mp3 b/public/audio/library/music/openscreen-upbeat-001.mp3 new file mode 100644 index 000000000..0bf61735a Binary files /dev/null and b/public/audio/library/music/openscreen-upbeat-001.mp3 differ diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 2914584b2..05e78a431 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -314,7 +314,7 @@ export function LaunchWindow() { }; return ( -
+
{systemLocaleSuggestion && (
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 && ( + +
+
+ {t("audio.backgroundMusic")} +
+
+ {backgroundMusicName ?? t("audio.noFileSelected")} +
+
+ + {backgroundMusicPath && ( + + )} + + + +
+
+ {t("audio.timelineRegions")} +
+
+ {t("audio.regionCount", { + count: String(backgroundMusicRegions.length), + })} +
+
{t("audio.trimHint")}
+
+ +
+
+ + { + setMusicLibraryQuery(event.target.value); + musicListRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }} + placeholder={t("audio.searchMusicPlaceholder")} + className="w-full h-8 pl-8 pr-7 rounded-md bg-black/20 border border-white/10 text-[11px] text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-[#34B27B]/60" + /> + {musicLibraryQuery && ( + + )} +
+ +
+ {filteredMusicLibrary.slice(0, 80).map((track) => ( +
{ + void handleMusicLibraryPreview(track.id, track.url); + }} + > +
+
+ {previewingMusicTrackId === track.id ? ( + + ) : ( + + )} +
+ + {track.name} + +
+ +
+ ))} + {filteredMusicLibrary.length === 0 && ( +
+ {t("audio.noSearchResults")} +
+ )} +
+
+ +
+
+
+
+ {t("audio.volume")} +
+ + {Math.round(backgroundMusicVolume * 100)}% + +
+ onBackgroundMusicVolumeChange?.(values[0])} + onValueCommit={() => onBackgroundMusicVolumeCommit?.()} + min={0} + max={1} + step={0.01} + disabled={!backgroundMusicPath} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("audio.fadeIn")} +
+ + {backgroundMusicFadeIn.toFixed(1)}s + +
+ onBackgroundMusicFadeInChange?.(values[0])} + onValueCommit={() => onBackgroundMusicFadeInCommit?.()} + min={0} + max={10} + step={0.5} + disabled={!backgroundMusicPath} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("audio.fadeOut")} +
+ + {backgroundMusicFadeOut.toFixed(1)}s + +
+ onBackgroundMusicFadeOutChange?.(values[0])} + onValueCommit={() => onBackgroundMusicFadeOutCommit?.()} + min={0} + max={10} + step={0.5} + disabled={!backgroundMusicPath} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ + {selectedMusicRegionId && onSelectedMusicRegionDelete && ( + + )} +
+ + +
+
+
+ {t("audio.hooks.title")} +
+ + {Math.round(audioHooksVolume * 100)}% + +
+
+
{t("audio.hooks.assignTo")}
+ +
+ onAudioHooksVolumeChange?.(values[0])} + onValueCommit={() => onAudioHooksVolumeCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3.5 [&_[role=slider]]:w-3.5" + /> + +
+ {hookOptions.map((hook) => ( +
+ {hook.label} + + onAudioHooksChange?.({ ...audioHooks, [hook.key]: checked }) + } + /> +
+ ))} +
+
+ + { + setHookLibraryQuery(event.target.value); + hooksListRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }} + placeholder={t("audio.searchHookPlaceholder")} + className="w-full h-8 pl-8 pr-7 rounded-md bg-black/25 border border-white/15 text-[11px] text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-[#34B27B]/60" + /> + {hookLibraryQuery && ( + + )} +
+ {(hookSoundLayers[selectedHookTarget] ?? []).length > 0 && ( +
+
+ {t("audio.hooks.assigned")} +
+ {(hookSoundLayers[selectedHookTarget] ?? []).map((url) => ( +
+
+ {url.split("/").pop()} +
+ +
+ ))} +
+ )} +
+ {filteredHooksLibrary.slice(0, 80).map((track) => ( +
{ + void handleHookLibraryPreview(track.id, track.url); + }} + > +
+
+ {previewingHookTrackId === track.id ? ( + + ) : ( + + )} +
+ + {track.name} + +
+
+ + +
+
+ ))} + {filteredHooksLibrary.length === 0 && ( +
+ {t("audio.noSearchResults")} +
+ )} +
+
+
+ + + + - + -
+
)} -
-
- - -
- - {exportFormat === "mp4" && ( -
+ {showEmbeddedExportSection && ( +
+
-
- )} - {exportFormat === "gif" && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
+ {exportFormat === "mp4" && ( +
+ + +
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - + )} + + {exportFormat === "gif" && ( +
- {t("gifSettings.loop")} - +
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {t("gifSettings.loop")} + +
-
- )} + )} - {unsavedExport && ( + {unsavedExport && ( + + )} - )} - -
- - +
+ + +
-
+ )}
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d21d13c9..148ff4956 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,6 +1,7 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages, Save, Video } from "lucide-react"; +import { Bug, Download, FolderOpen, Languages, Save, Star, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FaDiscord } from "react-icons/fa"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; import { @@ -13,6 +14,7 @@ import { } from "@/components/ui/dialog"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { resolveAudioSourceUrl, useAudioPreview } from "@/hooks/useAudioPreview"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; @@ -38,6 +40,7 @@ import { isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; +import { ExportSettingsPopup } from "./ExportSettingsPopup"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, @@ -76,6 +79,10 @@ import { import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants"; +const OPENSCREEN_GITHUB_URL = "https://github.com/siddharthvaddem/openscreen"; +const OPENSCREEN_REPORT_BUG_URL = "https://github.com/siddharthvaddem/openscreen/issues/new/choose"; +const OPENSCREEN_DISCORD_INVITE_URL = "https://discord.gg/B9W8BJ2V5U"; + export default function VideoEditor() { const { state: editorState, @@ -91,6 +98,14 @@ export default function VideoEditor() { trimRegions, speedRegions, annotationRegions, + hookRegions, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + audioHooksVolume, cropRegion, wallpaper, shadowIntensity, @@ -124,12 +139,15 @@ export default function VideoEditor() { const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); + const [selectedMusicRegionId, setSelectedMusicRegionId] = useState(null); + const [selectedHookRegionId, setSelectedHookRegionId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [selectedBlurId, setSelectedBlurId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); + const [showExportSettingsPopup, setShowExportSettingsPopup] = useState(false); const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); @@ -151,6 +169,8 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); + const nextMusicTrimIdRef = useRef(1); + const nextHookRegionIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); @@ -171,6 +191,63 @@ export default function VideoEditor() { [annotationRegions], ); + const handleSelectMusicRegion = useCallback((id: string | null) => { + setSelectedMusicRegionId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedHookRegionId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + } + }, []); + + const handleSelectHookRegion = useCallback((id: string | null) => { + setSelectedHookRegionId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedMusicRegionId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + } + }, []); + + const { + hookSoundLayers, + setHookSoundLayers, + handlePickBackgroundMusic, + handleMusicTrackSelect, + handleRemoveBackgroundMusic, + handleMusicRegionAdded, + handleMusicRegionSpanChange, + handleMusicRegionDelete, + handleHookTrackAdd, + handleHookTrackRemove, + handleHookTimelineAdd, + } = useAudioPreview({ + pushState, + currentTimeRef, + durationRef, + nextMusicTrimIdRef, + nextHookRegionIdRef, + audioHooks, + audioHooksVolume, + hookRegions, + zoomRegions, + trimRegions, + speedRegions, + annotationOnlyRegions, + blurRegions, + currentTime, + isPlaying, + selectedMusicRegionId, + onSelectMusicRegion: handleSelectMusicRegion, + onSelectHookRegion: handleSelectHookRegion, + }); + const currentProjectMedia = useMemo(() => { const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!screenVideoPath) { @@ -217,6 +294,13 @@ export default function VideoEditor() { pushState({ wallpaper: normalizedEditor.wallpaper, + backgroundMusicPath: normalizedEditor.backgroundMusicPath, + backgroundMusicRegions: normalizedEditor.backgroundMusicRegions, + backgroundMusicVolume: normalizedEditor.backgroundMusicVolume, + backgroundMusicFadeIn: normalizedEditor.backgroundMusicFadeIn, + backgroundMusicFadeOut: normalizedEditor.backgroundMusicFadeOut, + audioHooks: normalizedEditor.audioHooks, + audioHooksVolume: normalizedEditor.audioHooksVolume, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, motionBlurAmount: normalizedEditor.motionBlurAmount, @@ -227,6 +311,7 @@ export default function VideoEditor() { trimRegions: normalizedEditor.trimRegions, speedRegions: normalizedEditor.speedRegions, annotationRegions: normalizedEditor.annotationRegions, + hookRegions: normalizedEditor.hookRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, webcamMaskShape: normalizedEditor.webcamMaskShape, @@ -238,10 +323,13 @@ export default function VideoEditor() { setGifFrameRate(normalizedEditor.gifFrameRate); setGifLoop(normalizedEditor.gifLoop); setGifSizePreset(normalizedEditor.gifSizePreset); + setHookSoundLayers(normalizedEditor.hookSoundLayers); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); + setSelectedMusicRegionId(null); + setSelectedHookRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); @@ -257,10 +345,18 @@ export default function VideoEditor() { "speed", normalizedEditor.speedRegions.map((region) => region.id), ); + nextMusicTrimIdRef.current = deriveNextId( + "music", + normalizedEditor.backgroundMusicRegions.map((region) => region.id), + ); nextAnnotationIdRef.current = deriveNextId( "annotation", normalizedEditor.annotationRegions.map((region) => region.id), ); + nextHookRegionIdRef.current = deriveNextId( + "hook", + normalizedEditor.hookRegions.map((region) => region.id), + ); nextAnnotationZIndexRef.current = normalizedEditor.annotationRegions.reduce( (max, region) => Math.max(max, region.zIndex), @@ -277,7 +373,7 @@ export default function VideoEditor() { ); return true; }, - [pushState], + [pushState, setHookSoundLayers], ); const currentProjectSnapshot = useMemo(() => { @@ -286,6 +382,14 @@ export default function VideoEditor() { } return createProjectSnapshot(currentProjectMedia, { wallpaper, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + hookSoundLayers, + audioHooksVolume, shadowIntensity, showBlur, motionBlurAmount, @@ -296,9 +400,11 @@ export default function VideoEditor() { trimRegions, speedRegions, annotationRegions, + hookRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, @@ -309,6 +415,14 @@ export default function VideoEditor() { }, [ currentProjectMedia, wallpaper, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + hookSoundLayers, + audioHooksVolume, shadowIntensity, showBlur, motionBlurAmount, @@ -319,6 +433,7 @@ export default function VideoEditor() { trimRegions, speedRegions, annotationRegions, + hookRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, @@ -328,6 +443,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + webcamSizePreset, ]); const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot); @@ -429,6 +545,14 @@ export default function VideoEditor() { const projectData = createProjectData(currentProjectMedia, { wallpaper, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + hookSoundLayers, + audioHooksVolume, shadowIntensity, showBlur, motionBlurAmount, @@ -439,6 +563,7 @@ export default function VideoEditor() { trimRegions, speedRegions, annotationRegions, + hookRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, @@ -485,6 +610,14 @@ export default function VideoEditor() { currentProjectMedia, currentProjectPath, wallpaper, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + hookSoundLayers, + audioHooksVolume, shadowIntensity, showBlur, motionBlurAmount, @@ -495,6 +628,7 @@ export default function VideoEditor() { trimRegions, speedRegions, annotationRegions, + hookRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, @@ -638,10 +772,41 @@ export default function VideoEditor() { video.currentTime = time; } + const handleHookRegionSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + hookRegions: prev.hookRegions.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + })); + }, + [pushState], + ); + + const handleHookRegionDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + hookRegions: prev.hookRegions.filter((region) => region.id !== id), + })); + if (selectedHookRegionId === id) { + setSelectedHookRegionId(null); + } + }, + [pushState, selectedHookRegionId], + ); + const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { setSelectedTrimId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } @@ -651,6 +816,8 @@ export default function VideoEditor() { setSelectedTrimId(id); if (id) { setSelectedZoomId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } @@ -661,6 +828,8 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedBlurId(null); } }, []); @@ -670,6 +839,8 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedAnnotationId(null); setSelectedSpeedId(null); } @@ -688,6 +859,7 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); + setSelectedHookRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -707,6 +879,7 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); + setSelectedHookRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -724,6 +897,7 @@ export default function VideoEditor() { pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); setSelectedTrimId(id); setSelectedZoomId(null); + setSelectedHookRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -835,6 +1009,8 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } @@ -855,6 +1031,8 @@ export default function VideoEditor() { setSelectedSpeedId(id); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); + setSelectedMusicRegionId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -923,6 +1101,7 @@ export default function VideoEditor() { setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); setSelectedBlurId(null); }, [pushState], @@ -951,6 +1130,7 @@ export default function VideoEditor() { setSelectedAnnotationId(null); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedHookRegionId(null); setSelectedSpeedId(null); }, [pushState], @@ -1073,10 +1253,12 @@ export default function VideoEditor() { if (type === "blur" && selectedAnnotationId === id) { setSelectedAnnotationId(null); setSelectedBlurId(id); + setSelectedHookRegionId(null); setSelectedSpeedId(null); } else if (type !== "blur" && selectedBlurId === id) { setSelectedBlurId(null); setSelectedAnnotationId(id); + setSelectedHookRegionId(null); } }, [pushState, selectedAnnotationId, selectedBlurId], @@ -1277,6 +1459,21 @@ export default function VideoEditor() { } }, [selectedSpeedId, speedRegions]); + useEffect(() => { + if ( + selectedMusicRegionId && + !backgroundMusicRegions.some((region) => region.id === selectedMusicRegionId) + ) { + setSelectedMusicRegionId(null); + } + }, [selectedMusicRegionId, backgroundMusicRegions]); + + useEffect(() => { + if (selectedHookRegionId && !hookRegions.some((region) => region.id === selectedHookRegionId)) { + setSelectedHookRegionId(null); + } + }, [selectedHookRegionId, hookRegions]); + const handleShowExportedFile = useCallback(async (filePath: string) => { try { const result = await window.electronAPI.revealInFolder(filePath); @@ -1506,6 +1703,15 @@ export default function VideoEditor() { const exporter = new VideoExporter({ videoUrl: videoPath, webcamVideoUrl: webcamVideoPath || undefined, + backgroundAudioUrl: resolveAudioSourceUrl(backgroundMusicPath), + backgroundAudioRegions: backgroundMusicRegions, + backgroundAudioVolume: backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + audioHooksVolume, + hookSoundLayers, + hookRegions, width: exportWidth, height: exportHeight, frameRate: 60, @@ -1590,14 +1796,23 @@ export default function VideoEditor() { motionBlurAmount, borderRadius, padding, + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + audioHooks, + audioHooksVolume, cropRegion, annotationRegions, + hookRegions, isPlaying, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, + hookSoundLayers, exportQuality, handleExportSaved, cursorTelemetry, @@ -1676,6 +1891,10 @@ export default function VideoEditor() { } }, []); + const handleOpenExternal = useCallback((url: string) => { + window.electronAPI?.openExternalUrl(url); + }, []); + if (loading) { return (
@@ -1780,6 +1999,43 @@ export default function VideoEditor() { {ts("project.save")}
+
+ + + + +
@@ -1857,6 +2113,11 @@ export default function VideoEditor() { onBlurDataChange={handleBlurDataPreviewChange} onBlurDataCommit={commitState} cursorTelemetry={cursorTelemetry} + backgroundMusicPath={resolveAudioSourceUrl(backgroundMusicPath)} + backgroundMusicRegions={backgroundMusicRegions} + backgroundMusicVolume={backgroundMusicVolume} + backgroundMusicFadeIn={backgroundMusicFadeIn} + backgroundMusicFadeOut={backgroundMusicFadeOut} />
@@ -1909,6 +2170,17 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} selectedSpeedId={selectedSpeedId} onSelectSpeed={handleSelectSpeed} + hookRegions={hookRegions} + onHookSpanChange={handleHookRegionSpanChange} + onHookDelete={handleHookRegionDelete} + selectedHookId={selectedHookRegionId} + onSelectHook={handleSelectHookRegion} + musicRegions={backgroundMusicRegions} + onMusicAdded={handleMusicRegionAdded} + onMusicSpanChange={handleMusicRegionSpanChange} + onMusicDelete={handleMusicRegionDelete} + selectedMusicId={selectedMusicRegionId} + onSelectMusic={handleSelectMusicRegion} annotationRegions={annotationOnlyRegions} onAnnotationAdded={handleAnnotationAdded} onAnnotationSpanChange={handleAnnotationSpanChange} @@ -1972,6 +2244,31 @@ export default function VideoEditor() { padding={padding} onPaddingChange={(v) => updateState({ padding: v })} onPaddingCommit={commitState} + backgroundMusicPath={backgroundMusicPath} + backgroundMusicVolume={backgroundMusicVolume} + backgroundMusicFadeIn={backgroundMusicFadeIn} + backgroundMusicFadeOut={backgroundMusicFadeOut} + onBackgroundMusicPick={handlePickBackgroundMusic} + onBackgroundMusicRemove={handleRemoveBackgroundMusic} + onBackgroundMusicVolumeChange={(v) => updateState({ backgroundMusicVolume: v })} + onBackgroundMusicVolumeCommit={commitState} + onBackgroundMusicFadeInChange={(v) => updateState({ backgroundMusicFadeIn: v })} + onBackgroundMusicFadeInCommit={commitState} + onBackgroundMusicFadeOutChange={(v) => updateState({ backgroundMusicFadeOut: v })} + onBackgroundMusicFadeOutCommit={commitState} + onMusicTrackSelect={handleMusicTrackSelect} + backgroundMusicRegions={backgroundMusicRegions} + selectedMusicRegionId={selectedMusicRegionId} + onSelectedMusicRegionDelete={handleMusicRegionDelete} + audioHooks={audioHooks} + audioHooksVolume={audioHooksVolume} + onAudioHooksChange={(hooks) => pushState({ audioHooks: hooks })} + onAudioHooksVolumeChange={(v) => updateState({ audioHooksVolume: v })} + onAudioHooksVolumeCommit={commitState} + hookSoundLayers={hookSoundLayers} + onHookTrackAdd={handleHookTrackAdd} + onHookTrackRemove={handleHookTrackRemove} + onHookTimelineAdd={handleHookTimelineAdd} cropRegion={cropRegion} onCropChange={(r) => pushState({ cropRegion: r })} aspectRatio={aspectRatio} @@ -2051,10 +2348,45 @@ export default function VideoEditor() { onZoomDurationChange={(zoomIn, zoomOut) => selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut) } + showEmbeddedExportSection={false} />
+ setShowExportSettingsPopup(false)} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), + )} + onExport={() => { + setShowExportSettingsPopup(false); + handleOpenExportDialog(); + }} + unsavedExport={unsavedExport} + onSaveUnsavedExport={handleSaveUnsavedExport} + /> + setShowExportDialog(false)} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index b798641e4..541786254 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -87,6 +87,11 @@ interface VideoPlaybackProps { onZoomFocusChange: (id: string, focus: ZoomFocus) => void; onZoomFocusDragEnd?: () => void; isPlaying: boolean; + backgroundMusicPath?: string; + backgroundMusicRegions?: TrimRegion[]; + backgroundMusicVolume?: number; + backgroundMusicFadeIn?: number; + backgroundMusicFadeOut?: number; showShadow?: boolean; shadowIntensity?: number; showBlur?: boolean; @@ -145,6 +150,11 @@ const VideoPlayback = forwardRef( onZoomFocusChange, onZoomFocusDragEnd, isPlaying, + backgroundMusicPath, + backgroundMusicRegions = [], + backgroundMusicVolume = 0.35, + backgroundMusicFadeIn = 0, + backgroundMusicFadeOut = 0, showShadow, shadowIntensity = 0, showBlur, @@ -172,6 +182,7 @@ const VideoPlayback = forwardRef( ref, ) => { const videoRef = useRef(null); + const backgroundMusicRef = useRef(null); const webcamVideoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -1176,6 +1187,75 @@ const VideoPlayback = forwardRef( webcamVideo.currentTime = 0; }, [webcamVideoPath]); + useEffect(() => { + const backgroundMusic = backgroundMusicRef.current; + if (!backgroundMusic || !backgroundMusicPath) { + return; + } + + backgroundMusic.pause(); + backgroundMusic.currentTime = 0; + }, [backgroundMusicPath]); + + useEffect(() => { + const backgroundMusic = backgroundMusicRef.current; + if (!backgroundMusic || !backgroundMusicPath) { + return; + } + + const backgroundDuration = backgroundMusic.duration; + const hasValidDuration = Number.isFinite(backgroundDuration) && backgroundDuration > 0; + const loopedCurrentTime = hasValidDuration + ? ((currentTime % backgroundDuration) + backgroundDuration) % backgroundDuration + : currentTime; + + const isInRegion = + backgroundMusicRegions.length === 0 || + backgroundMusicRegions.some( + (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs, + ); + + let targetVolume = 0; + if (isInRegion) { + targetVolume = Math.min(1, Math.max(0, backgroundMusicVolume)); + const videoDuration = videoRef.current?.duration ?? 0; + if (backgroundMusicFadeIn > 0 && currentTime < backgroundMusicFadeIn) { + targetVolume *= currentTime / backgroundMusicFadeIn; + } + if (backgroundMusicFadeOut > 0 && videoDuration > 0) { + const remaining = videoDuration - currentTime; + if (remaining < backgroundMusicFadeOut) { + targetVolume *= Math.max(0, remaining / backgroundMusicFadeOut); + } + } + } + backgroundMusic.volume = targetVolume; + + if (!isPlaying) { + backgroundMusic.pause(); + if (Math.abs(backgroundMusic.currentTime - loopedCurrentTime) > 0.05) { + backgroundMusic.currentTime = loopedCurrentTime; + } + return; + } + + if (Math.abs(backgroundMusic.currentTime - loopedCurrentTime) > 0.2) { + backgroundMusic.currentTime = loopedCurrentTime; + } + + backgroundMusic.play().catch(() => { + // Ignore autoplay restoration failures. + }); + }, [ + backgroundMusicPath, + backgroundMusicRegions, + backgroundMusicVolume, + backgroundMusicFadeIn, + backgroundMusicFadeOut, + currentTime, + isPlaying, + ]); + useEffect(() => { let mounted = true; (async () => { @@ -1466,6 +1546,9 @@ const VideoPlayback = forwardRef( }} onError={() => onError("Failed to load video")} /> + {backgroundMusicPath && ( +
); }, diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 14dc2409b..512954d93 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -31,6 +31,24 @@ describe("projectPersistence media compatibility", () => { }, { wallpaper: "/wallpapers/wallpaper1.jpg", + backgroundMusicPath: null, + backgroundMusicRegions: [], + backgroundMusicVolume: 0.35, + audioHooks: { + zoom: false, + trim: false, + speed: false, + annotation: false, + blur: false, + }, + hookSoundLayers: { + zoom: [], + trim: [], + speed: [], + annotation: [], + blur: [], + }, + audioHooksVolume: 0.35, shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, @@ -41,6 +59,7 @@ describe("projectPersistence media compatibility", () => { trimRegions: [], speedRegions: [], annotationRegions: [], + hookRegions: [], aspectRatio: "16:9", webcamLayoutPreset: "picture-in-picture", webcamMaskShape: "circle", @@ -169,6 +188,24 @@ it("creates stable snapshots for identical project state", () => { }; const editor = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper1.jpg", + backgroundMusicPath: null, + backgroundMusicRegions: [], + backgroundMusicVolume: 0.35, + audioHooks: { + zoom: false, + trim: false, + speed: false, + annotation: false, + blur: false, + }, + hookSoundLayers: { + zoom: [], + trim: [], + speed: [], + annotation: [], + blur: [], + }, + audioHooksVolume: 0.35, shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, @@ -179,6 +216,7 @@ it("creates stable snapshots for identical project state", () => { trimRegions: [], speedRegions: [], annotationRegions: [], + hookRegions: [], aspectRatio: "16:9", webcamLayoutPreset: "picture-in-picture", webcamMaskShape: "circle", diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index c085e0d4c..593628d85 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -5,11 +5,14 @@ import { normalizeProjectMedia } from "@/lib/recordingSession"; import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, + type AudioHooksConfig, + type AudioHookType, type CropRegion, clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, + DEFAULT_AUDIO_HOOKS, DEFAULT_BLUR_BLOCK_SIZE, DEFAULT_BLUR_DATA, DEFAULT_BLUR_FREEHAND_POINTS, @@ -22,6 +25,7 @@ import { DEFAULT_WEBCAM_POSITION, DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + type HookRegion, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -49,6 +53,14 @@ export const PROJECT_VERSION = 2; export interface ProjectEditorState { wallpaper: string; + backgroundMusicPath: string | null; + backgroundMusicRegions: TrimRegion[]; + backgroundMusicVolume: number; + backgroundMusicFadeIn: number; + backgroundMusicFadeOut: number; + audioHooks: AudioHooksConfig; + hookSoundLayers: Record; + audioHooksVolume: number; shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; @@ -59,6 +71,7 @@ export interface ProjectEditorState { trimRegions: TrimRegion[]; speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; + hookRegions: HookRegion[]; aspectRatio: AspectRatio; webcamLayoutPreset: WebcamLayoutPreset; webcamMaskShape: WebcamMaskShape; @@ -106,6 +119,66 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function normalizeOptionalString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeAudioHooks(value: unknown): AudioHooksConfig { + if (!value || typeof value !== "object") { + return { ...DEFAULT_AUDIO_HOOKS }; + } + + const raw = value as Partial; + return { + zoom: typeof raw.zoom === "boolean" ? raw.zoom : DEFAULT_AUDIO_HOOKS.zoom, + trim: typeof raw.trim === "boolean" ? raw.trim : DEFAULT_AUDIO_HOOKS.trim, + speed: typeof raw.speed === "boolean" ? raw.speed : DEFAULT_AUDIO_HOOKS.speed, + annotation: + typeof raw.annotation === "boolean" ? raw.annotation : DEFAULT_AUDIO_HOOKS.annotation, + blur: typeof raw.blur === "boolean" ? raw.blur : DEFAULT_AUDIO_HOOKS.blur, + }; +} + +function normalizeHookSoundLayers(value: unknown): Record { + const defaults: Record = { + zoom: [], + trim: [], + speed: [], + annotation: [], + blur: [], + }; + + if (!value || typeof value !== "object") { + return defaults; + } + + const raw = value as Partial>; + return { + zoom: Array.isArray(raw.zoom) + ? raw.zoom.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : [], + trim: Array.isArray(raw.trim) + ? raw.trim.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : [], + speed: Array.isArray(raw.speed) + ? raw.speed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : [], + annotation: Array.isArray(raw.annotation) + ? raw.annotation.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0, + ) + : [], + blur: Array.isArray(raw.blur) + ? raw.blur.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : [], + }; +} + function isFileUrl(value: string): boolean { return /^file:\/\//i.test(value); } @@ -269,6 +342,59 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; + const normalizedBackgroundMusicRegions: TrimRegion[] = Array.isArray( + (editor as { backgroundMusicRegions?: unknown }).backgroundMusicRegions, + ) + ? ((editor as { backgroundMusicRegions?: unknown }).backgroundMusicRegions as unknown[]) + .filter((region): region is TrimRegion => + Boolean(region && typeof (region as TrimRegion).id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + return { + id: region.id, + startMs, + endMs, + }; + }) + : []; + + const normalizedHookRegions: HookRegion[] = Array.isArray( + (editor as { hookRegions?: unknown }).hookRegions, + ) + ? ((editor as { hookRegions?: unknown }).hookRegions as unknown[]) + .filter((region): region is HookRegion => + Boolean(region && typeof (region as HookRegion).id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1200; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + const hookType = + region.hookType === "zoom" || + region.hookType === "trim" || + region.hookType === "speed" || + region.hookType === "annotation" || + region.hookType === "blur" + ? (region.hookType as AudioHookType) + : undefined; + + return { + id: region.id, + startMs, + endMs, + soundUrl: typeof region.soundUrl === "string" ? region.soundUrl : "", + label: typeof region.label === "string" ? region.label : undefined, + hookType, + }; + }) + .filter((region) => region.soundUrl.length > 0) + : []; + const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions) ? editor.speedRegions .filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string")) @@ -426,6 +552,32 @@ export function normalizeProjectEditor(editor: Partial): Pro return { wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], + backgroundMusicPath: normalizeOptionalString( + (editor as { backgroundMusicPath?: unknown }).backgroundMusicPath, + ), + backgroundMusicRegions: normalizedBackgroundMusicRegions, + backgroundMusicVolume: isFiniteNumber( + (editor as { backgroundMusicVolume?: unknown }).backgroundMusicVolume, + ) + ? clamp((editor as { backgroundMusicVolume: number }).backgroundMusicVolume, 0, 1) + : 0.35, + backgroundMusicFadeIn: isFiniteNumber( + (editor as { backgroundMusicFadeIn?: unknown }).backgroundMusicFadeIn, + ) + ? clamp((editor as { backgroundMusicFadeIn: number }).backgroundMusicFadeIn, 0, 30) + : 0, + backgroundMusicFadeOut: isFiniteNumber( + (editor as { backgroundMusicFadeOut?: unknown }).backgroundMusicFadeOut, + ) + ? clamp((editor as { backgroundMusicFadeOut: number }).backgroundMusicFadeOut, 0, 30) + : 0, + audioHooks: normalizeAudioHooks((editor as { audioHooks?: unknown }).audioHooks), + hookSoundLayers: normalizeHookSoundLayers( + (editor as { hookSoundLayers?: unknown }).hookSoundLayers, + ), + audioHooksVolume: isFiniteNumber((editor as { audioHooksVolume?: unknown }).audioHooksVolume) + ? clamp((editor as { audioHooksVolume: number }).audioHooksVolume, 0, 1) + : 0.35, shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) @@ -447,6 +599,7 @@ export function normalizeProjectEditor(editor: Partial): Pro trimRegions: normalizedTrimRegions, speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, + hookRegions: normalizedHookRegions, aspectRatio: normalizedAspectRatio, webcamLayoutPreset: normalizedWebcamLayoutPreset, webcamMaskShape: diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index d89de941a..41ec96e2a 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,6 +1,6 @@ import type { Span } from "dnd-timeline"; import { useItem, useTimelineContext } from "dnd-timeline"; -import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react"; +import { Gauge, MessageSquare, Music2, Scissors, Sparkles, ZoomIn } from "lucide-react"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { @@ -22,7 +22,7 @@ interface ItemProps { zoomOutDurationMs?: number; speedValue?: number; onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void; - variant?: "zoom" | "trim" | "annotation" | "speed" | "blur"; + variant?: "zoom" | "trim" | "annotation" | "speed" | "blur" | "music" | "hook"; } // Map zoom depth to multiplier labels @@ -56,8 +56,8 @@ export default function Item({ zoomOutDurationMs, speedValue, variant = "zoom", - children, onZoomDurationChange, + children, }: ItemProps) { const { pixelsToValue } = useTimelineContext(); const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ @@ -69,6 +69,8 @@ export default function Item({ const isZoom = variant === "zoom"; const isTrim = variant === "trim"; const isSpeed = variant === "speed"; + const isMusic = variant === "music"; + const isHook = variant === "hook"; const glassClass = isZoom ? glassStyles.glassGreen @@ -76,21 +78,29 @@ export default function Item({ ? glassStyles.glassRed : isSpeed ? glassStyles.glassAmber - : glassStyles.glassYellow; + : isHook + ? glassStyles.glassBlue + : isMusic + ? glassStyles.glassBlue + : glassStyles.glassYellow; - const endCapColor = isZoom ? "#21916A" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046"; + const endCapColor = isZoom + ? "#21916A" + : isTrim + ? "#ef4444" + : isSpeed + ? "#d97706" + : isHook + ? "#06b6d4" + : isMusic + ? "#38bdf8" + : "#B4A046"; const timeLabel = useMemo( () => `${formatMs(span.start)} – ${formatMs(span.end)}`, [span.start, span.end], ); - // Minimum clickable width on the outer wrapper. - // Kept small (6px) so items visually distinguish their real positions; - // users should zoom in to interact with sub-second items precisely. - const MIN_ITEM_PX = 6; - const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX }; - const { zoomIn, zoomOut } = useMemo(() => { if (!isZoom) return { zoomIn: 0, zoomOut: 0 }; return getDurations({ @@ -101,6 +111,13 @@ export default function Item({ }); }, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]); + // Minimum clickable width on the outer wrapper. + // Kept small (6px) so items visually distinguish their real positions; + // users should zoom in to interact with sub-second items precisely. + const MIN_ITEM_PX = 6; + const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX }; + const regionDuration = Math.max(1, span.end - span.start); + return (
{isZoom && ( <> - {/* Transition In Marker */}
- {/* Draggable handle for Transition In */}
{ @@ -154,7 +169,7 @@ export default function Item({ const deltaMs = pixelsToValue(deltaPx); const newDuration = Math.max( 0, - Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut), + Math.min(initialZoomIn + deltaMs, regionDuration - initialZoomOut), ); onZoomDurationChange?.(id, newDuration, initialZoomOut); }; @@ -169,18 +184,16 @@ export default function Item({ window.addEventListener("pointerup", onPointerUp); }} /> - {/* Transition Out Marker */}
- {/* Draggable handle for Transition Out */}
{ @@ -194,11 +207,11 @@ export default function Item({ const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS; const onPointerMove = (moveEvent: PointerEvent) => { - const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored + const deltaPx = startX - moveEvent.clientX; const deltaMs = pixelsToValue(deltaPx); const newDuration = Math.max( 0, - Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn), + Math.min(initialZoomOut + deltaMs, regionDuration - initialZoomIn), ); onZoomDurationChange?.(id, initialZoomIn, newDuration); }; @@ -261,6 +274,20 @@ export default function Item({ {speedValue !== undefined ? `${speedValue}×` : "Speed"} + ) : isMusic ? ( + <> + + + Music + + + ) : isHook ? ( + <> + + + Hook + + ) : ( <> diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 19c71651d..0012242cd 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -110,6 +110,34 @@ z-index: 10; } +.glassBlue { + position: relative; + border-radius: 8px; + -corner-smoothing: antialiased; + background: rgba(56, 189, 248, 0.15); + border: 1px solid rgba(56, 189, 248, 0.3); + box-shadow: 0 2px 12px 0 rgba(56, 189, 248, 0.1) inset; + margin: 2px 0; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glassBlue:hover { + background: rgba(56, 189, 248, 0.25); + border-color: rgba(56, 189, 248, 0.5); + box-shadow: 0 4px 20px 0 rgba(56, 189, 248, 0.2) inset; +} + +.glassBlue.selected { + background: rgba(56, 189, 248, 0.35); + border-color: #38bdf8; + box-shadow: + 0 0 0 1px #38bdf8, + 0 4px 20px 0 rgba(56, 189, 248, 0.3) inset; + z-index: 10; +} + .zoomEndCap { position: absolute; top: 0; @@ -134,6 +162,11 @@ opacity: 1; } +.glassBlue:hover .zoomEndCap, +.glassBlue.selected .zoomEndCap { + opacity: 1; +} + .zoomEndCap.left { left: 0; cursor: ew-resize; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 81e621823..074bcd595 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -5,8 +5,10 @@ import { ChevronDown, Gauge, MessageSquare, + Music2, Plus, Scissors, + SlidersHorizontal, WandSparkles, ZoomIn, } from "lucide-react"; @@ -16,8 +18,11 @@ import { v4 as uuidv4 } from "uuid"; import { Button } from "@/components/ui/button"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; @@ -30,6 +35,7 @@ import { TutorialHelp } from "../TutorialHelp"; import type { AnnotationRegion, CursorTelemetryPoint, + HookRegion, SpeedRegion, TrimRegion, ZoomFocus, @@ -46,10 +52,27 @@ const TRIM_ROW_ID = "row-trim"; const ANNOTATION_ROW_ID = "row-annotation"; const BLUR_ROW_ID = "row-blur"; const SPEED_ROW_ID = "row-speed"; +const HOOK_ROW_ID = "row-hook"; +const MUSIC_ROW_ID = "row-music"; +const TIMELINE_ROW_VISIBILITY_STORAGE_KEY = "timeline-row-visibility"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; const SUGGESTION_SPACING_MS = 1800; +type TimelineRowKey = "zoom" | "trim" | "annotation" | "blur" | "speed" | "hook" | "music"; + +type TimelineRowVisibility = Record; + +const DEFAULT_ROW_VISIBILITY: TimelineRowVisibility = { + zoom: true, + trim: true, + annotation: true, + blur: true, + speed: true, + hook: true, + music: true, +}; + interface TimelineEditorProps { videoDuration: number; currentTime: number; @@ -59,7 +82,7 @@ interface TimelineEditorProps { onZoomAdded: (span: Span) => void; onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; - onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; + onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void; onZoomDelete: (id: string) => void; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; @@ -87,6 +110,17 @@ interface TimelineEditorProps { onSpeedDelete?: (id: string) => void; selectedSpeedId?: string | null; onSelectSpeed?: (id: string | null) => void; + hookRegions?: HookRegion[]; + onHookSpanChange?: (id: string, span: Span) => void; + onHookDelete?: (id: string) => void; + selectedHookId?: string | null; + onSelectHook?: (id: string | null) => void; + musicRegions?: TrimRegion[]; + onMusicAdded?: (span: Span) => void; + onMusicSpanChange?: (id: string, span: Span) => void; + onMusicDelete?: (id: string) => void; + selectedMusicId?: string | null; + onSelectMusic?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; } @@ -103,10 +137,10 @@ interface TimelineRenderItem { span: Span; label: string; zoomDepth?: number; - speedValue?: number; zoomInDurationMs?: number; zoomOutDurationMs?: number; - variant: "zoom" | "trim" | "annotation" | "speed" | "blur"; + speedValue?: number; + variant: "zoom" | "trim" | "annotation" | "speed" | "blur" | "music" | "hook"; } const SCALE_CANDIDATES = [ @@ -537,12 +571,17 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectHook, + onSelectMusic, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedHookId, + selectedMusicId, onZoomDurationChange, + rowVisibility, keyframes = [], }: { items: TimelineRenderItem[]; @@ -555,12 +594,17 @@ function Timeline({ onSelectAnnotation?: (id: string | null) => void; onSelectBlur?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; + onSelectHook?: (id: string | null) => void; + onSelectMusic?: (id: string | null) => void; selectedZoomId: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; selectedBlurId?: string | null; selectedSpeedId?: string | null; - onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void; + selectedHookId?: string | null; + selectedMusicId?: string | null; + onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void; + rowVisibility: TimelineRowVisibility; keyframes?: { id: string; time: number }[]; }) { const t = useScopedT("timeline"); @@ -586,6 +630,8 @@ function Timeline({ onSelectAnnotation?.(null); onSelectBlur?.(null); onSelectSpeed?.(null); + onSelectHook?.(null); + onSelectMusic?.(null); const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -605,6 +651,8 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectHook, + onSelectMusic, videoDurationMs, sidebarWidth, range.start, @@ -657,6 +705,8 @@ function Timeline({ const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID); const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID); + const hookItems = items.filter((item) => item.rowId === HOOK_ROW_ID); + const musicItems = items.filter((item) => item.rowId === MUSIC_ROW_ID); return (
- - {zoomItems.map((item) => ( - onSelectZoom?.(item.id)} - zoomDepth={item.zoomDepth} - zoomInDurationMs={item.zoomInDurationMs} - zoomOutDurationMs={item.zoomOutDurationMs} - onZoomDurationChange={onZoomDurationChange} - variant="zoom" - > - {item.label} - - ))} - - - - {trimItems.map((item) => ( - onSelectTrim?.(item.id)} - variant="trim" - > - {item.label} - - ))} - - - - {annotationItems.map((item) => ( - onSelectAnnotation?.(item.id)} - variant="annotation" - > - {item.label} - - ))} - - - - {blurItems.map((item) => ( - onSelectBlur?.(item.id)} - variant={item.variant} - > - {item.label} - - ))} - - - - {speedItems.map((item) => ( - onSelectSpeed?.(item.id)} - variant="speed" - speedValue={item.speedValue} - > - {item.label} - - ))} - + {rowVisibility.zoom && ( + + {zoomItems.map((item) => ( + onSelectZoom?.(item.id)} + zoomDepth={item.zoomDepth} + zoomInDurationMs={item.zoomInDurationMs} + zoomOutDurationMs={item.zoomOutDurationMs} + onZoomDurationChange={onZoomDurationChange} + variant="zoom" + > + {item.label} + + ))} + + )} + + {rowVisibility.trim && ( + + {trimItems.map((item) => ( + onSelectTrim?.(item.id)} + variant="trim" + > + {item.label} + + ))} + + )} + + {rowVisibility.annotation && ( + + {annotationItems.map((item) => ( + onSelectAnnotation?.(item.id)} + variant="annotation" + > + {item.label} + + ))} + + )} + + {rowVisibility.blur && ( + + {blurItems.map((item) => ( + onSelectBlur?.(item.id)} + variant={item.variant} + > + {item.label} + + ))} + + )} + + {rowVisibility.speed && ( + + {speedItems.map((item) => ( + onSelectSpeed?.(item.id)} + variant="speed" + speedValue={item.speedValue} + > + {item.label} + + ))} + + )} + + {rowVisibility.hook && ( + + {hookItems.map((item) => ( + onSelectHook?.(item.id)} + variant="hook" + > + {item.label} + + ))} + + )} + + {rowVisibility.music && ( + + {musicItems.map((item) => ( + onSelectMusic?.(item.id)} + variant="music" + > + {item.label} + + ))} + + )}
); } @@ -806,6 +906,17 @@ export default function TimelineEditor({ onSpeedDelete, selectedSpeedId, onSelectSpeed, + hookRegions = [], + onHookSpanChange, + onHookDelete, + selectedHookId, + onSelectHook, + musicRegions = [], + onMusicAdded, + onMusicSpanChange, + onMusicDelete, + selectedMusicId, + onSelectMusic, aspectRatio, onAspectRatioChange, }: TimelineEditorProps) { @@ -824,6 +935,7 @@ export default function TimelineEditor({ const [range, setRange] = useState(() => createInitialRange(totalMs)); const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); + const [rowVisibility, setRowVisibility] = useState(DEFAULT_ROW_VISIBILITY); const [scrollLabels, setScrollLabels] = useState({ pan: "Scroll", zoom: "Ctrl + Scroll", @@ -831,6 +943,64 @@ export default function TimelineEditor({ const timelineContainerRef = useRef(null); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + try { + const raw = window.localStorage.getItem(TIMELINE_ROW_VISIBILITY_STORAGE_KEY); + if (!raw) { + return; + } + + const parsed = JSON.parse(raw) as Partial; + setRowVisibility({ + ...DEFAULT_ROW_VISIBILITY, + ...parsed, + music: true, + }); + } catch { + setRowVisibility(DEFAULT_ROW_VISIBILITY); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(TIMELINE_ROW_VISIBILITY_STORAGE_KEY, JSON.stringify(rowVisibility)); + }, [rowVisibility]); + + const rowVisibilityOptions = useMemo( + () => [ + { key: "zoom" as const, labelKey: "rows.zoom" }, + { key: "trim" as const, labelKey: "rows.trim" }, + { key: "annotation" as const, labelKey: "rows.annotation" }, + { key: "blur" as const, labelKey: "rows.blur" }, + { key: "speed" as const, labelKey: "rows.speed" }, + { key: "hook" as const, labelKey: "rows.hook" }, + { key: "music" as const, labelKey: "rows.music" }, + ], + [], + ); + + const toggleRowVisibility = useCallback((key: TimelineRowKey, checked: boolean) => { + if (key === "music") { + return; + } + + setRowVisibility((previous) => { + const next = { ...previous, [key]: checked }; + const visibleCount = Object.values(next).filter(Boolean).length; + if (visibleCount === 0) { + return previous; + } + return next; + }); + }, []); + useEffect(() => { formatShortcut(["mod", "Scroll"]).then((zoom) => { setScrollLabels({ pan: "Scroll", zoom }); @@ -896,6 +1066,18 @@ export default function TimelineEditor({ onSelectSpeed(null); }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]); + const deleteSelectedHook = useCallback(() => { + if (!selectedHookId || !onHookDelete || !onSelectHook) return; + onHookDelete(selectedHookId); + onSelectHook(null); + }, [selectedHookId, onHookDelete, onSelectHook]); + + const deleteSelectedMusic = useCallback(() => { + if (!selectedMusicId || !onMusicDelete || !onSelectMusic) return; + onMusicDelete(selectedMusicId); + onSelectMusic(null); + }, [selectedMusicId, onMusicDelete, onSelectMusic]); + useEffect(() => { setRange(createInitialRange(totalMs)); }, [totalMs]); @@ -906,9 +1088,13 @@ export default function TimelineEditor({ const zoomRegionsRef = useRef(zoomRegions); const trimRegionsRef = useRef(trimRegions); const speedRegionsRef = useRef(speedRegions); + const hookRegionsRef = useRef(hookRegions); + const musicRegionsRef = useRef(musicRegions); zoomRegionsRef.current = zoomRegions; trimRegionsRef.current = trimRegions; speedRegionsRef.current = speedRegions; + hookRegionsRef.current = hookRegions; + musicRegionsRef.current = musicRegions; useEffect(() => { if (totalMs === 0 || safeMinDurationMs <= 0) { @@ -950,8 +1136,40 @@ export default function TimelineEditor({ onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); } }); + + hookRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onHookSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + + musicRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onMusicSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); // Only re-run when the timeline scale changes, not on every region edit - }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]); + }, [ + totalMs, + safeMinDurationMs, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onHookSpanChange, + onMusicSpanChange, + ]); const hasOverlap = useCallback( (newSpan: Span, excludeId?: string): boolean => { @@ -961,8 +1179,10 @@ export default function TimelineEditor({ const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); const isBlurItem = blurRegions.some((r) => r.id === excludeId); const isSpeedItem = speedRegions.some((r) => r.id === excludeId); + const isHookItem = hookRegions.some((r) => r.id === excludeId); + const isMusicItem = musicRegions.some((r) => r.id === excludeId); - if (isAnnotationItem || isBlurItem) { + if (isAnnotationItem || isBlurItem || isHookItem) { return false; } @@ -987,9 +1207,21 @@ export default function TimelineEditor({ return checkOverlap(speedRegions); } + if (isMusicItem) { + return checkOverlap(musicRegions); + } + return false; }, - [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions], + [ + zoomRegions, + trimRegions, + annotationRegions, + blurRegions, + speedRegions, + hookRegions, + musicRegions, + ], ); // At least 5% of the timeline or 1000ms, whichever is larger, so the region @@ -1200,6 +1432,43 @@ export default function TimelineEditor({ t, ]); + const handleAddMusic = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onMusicAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const sorted = [...musicRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + if (isOverlapping || gapToNext <= 0) { + toast.error(t("errors.cannotPlaceMusic"), { + description: t("errors.musicExistsAtLocation"), + }); + return; + } + + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + onMusicAdded({ start: startPos, end: startPos + actualDuration }); + }, [ + videoDuration, + totalMs, + currentTimeMs, + musicRegions, + onMusicAdded, + defaultRegionDurationMs, + t, + ]); + const handleAddAnnotation = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { return; @@ -1297,6 +1566,10 @@ export default function TimelineEditor({ deleteSelectedBlur(); } else if (selectedSpeedId) { deleteSelectedSpeed(); + } else if (selectedHookId) { + deleteSelectedHook(); + } else if (selectedMusicId) { + deleteSelectedMusic(); } } }; @@ -1315,12 +1588,16 @@ export default function TimelineEditor({ deleteSelectedAnnotation, deleteSelectedBlur, deleteSelectedSpeed, + deleteSelectedHook, + deleteSelectedMusic, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedHookId, + selectedMusicId, annotationRegions, currentTime, onSelectAnnotation, @@ -1398,16 +1675,42 @@ export default function TimelineEditor({ variant: "speed", })); - return [...zooms, ...trims, ...annotations, ...blurs, ...speeds]; - }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]); + const hooks: TimelineRenderItem[] = hookRegions.map((region, index) => ({ + id: region.id, + rowId: HOOK_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: region.label || t("labels.hookItem", { index: String(index + 1) }), + variant: "hook", + })); + + const music: TimelineRenderItem[] = musicRegions.map((region, index) => ({ + id: region.id, + rowId: MUSIC_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: t("labels.musicItem", { index: String(index + 1) }), + variant: "music", + })); + + return [...zooms, ...trims, ...annotations, ...blurs, ...speeds, ...hooks, ...music]; + }, [ + zoomRegions, + trimRegions, + annotationRegions, + blurRegions, + speedRegions, + hookRegions, + musicRegions, + t, + ]); // Flat list of all non-annotation region spans for neighbour-clamping during drag/resize const allRegionSpans = useMemo(() => { const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); - return [...zooms, ...trims, ...speeds]; - }, [zoomRegions, trimRegions, speedRegions]); + const music = musicRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); + return [...zooms, ...trims, ...speeds, ...music]; + }, [zoomRegions, trimRegions, speedRegions, musicRegions]); const handleItemSpanChange = useCallback( (id: string, span: Span) => { @@ -1418,6 +1721,10 @@ export default function TimelineEditor({ onTrimSpanChange?.(id, span); } else if (speedRegions.some((r) => r.id === id)) { onSpeedSpanChange?.(id, span); + } else if (hookRegions.some((r) => r.id === id)) { + onHookSpanChange?.(id, span); + } else if (musicRegions.some((r) => r.id === id)) { + onMusicSpanChange?.(id, span); } else if (annotationRegions.some((r) => r.id === id)) { onAnnotationSpanChange?.(id, span); } else if (blurRegions.some((r) => r.id === id)) { @@ -1428,11 +1735,15 @@ export default function TimelineEditor({ zoomRegions, trimRegions, speedRegions, + hookRegions, + musicRegions, annotationRegions, blurRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, + onHookSpanChange, + onMusicSpanChange, onAnnotationSpanChange, onBlurSpanChange, ], @@ -1520,8 +1831,53 @@ export default function TimelineEditor({ > +
+ + + + + + + {t("controls.timelineVisibility")} + + + {rowVisibilityOptions.map((option) => ( + { + if (typeof checked === "boolean") { + toggleRowVisibility(option.key, checked); + } + }} + className="text-slate-300 hover:text-white focus:text-white data-[highlighted]:text-white hover:bg-white/10 focus:bg-white/10 data-[highlighted]:bg-white/10 data-[disabled]:opacity-50" + > + {t(option.labelKey)} + + ))} + + +