diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6dc7d396..fdc19ccb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -649,7 +649,16 @@ interface Window { error?: string; canceled?: boolean; }>; - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{ + success: boolean; + kind?: "media" | "project"; + path?: string; + project?: unknown; + extension?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; openWhisperExecutablePicker: () => Promise<{ success: boolean; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 9d9b7f06..fe93afd7 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -1,207 +1,255 @@ +import path from "node:path"; import { dialog, ipcMain } from "electron"; -import { setCurrentProjectPath } from "../state"; +import { generateAutoCaptionsFromVideo } from "../captions/generate"; import { - getWhisperSmallModelStatus, - downloadWhisperSmallModel, deleteWhisperSmallModel, + downloadWhisperSmallModel, + getWhisperSmallModelStatus, sendWhisperModelDownloadProgress, } from "../captions/whisper"; -import { generateAutoCaptionsFromVideo } from "../captions/generate"; +import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; +import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager"; +import { setCurrentProjectPath } from "../state"; import { approveUserPath, getRecordingsDir } from "../utils"; +const VIDEO_FILE_EXTENSIONS = ["webm", "mp4", "mov", "avi", "mkv"]; +const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS]; + +type OpenVideoFilePickerOptions = { + includeProjects?: boolean; +}; + export function registerCaptionHandlers() { - ipcMain.handle('open-video-file-picker', async () => { - try { - const recordingsDir = await getRecordingsDir() - const result = await dialog.showOpenDialog({ - title: 'Select Video File', - defaultPath: recordingsDir, - filters: [ - { name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }); + ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { + try { + const includeProjects = Boolean(options?.includeProjects); + const recordingsDir = await getRecordingsDir(); + const result = await dialog.showOpenDialog({ + title: includeProjects ? "Import Media or Recordly Project" : "Select Video File", + defaultPath: recordingsDir, + filters: [ + ...(includeProjects + ? [ + { + name: "Media or Recordly Projects", + extensions: [ + ...VIDEO_FILE_EXTENSIONS, + ...PROJECT_FILE_EXTENSIONS, + ], + }, + ] + : []), + { name: "Video Files", extensions: VIDEO_FILE_EXTENSIONS }, + ...(includeProjects + ? [{ name: "Recordly Projects", extensions: PROJECT_FILE_EXTENSIONS }] + : []), + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true }; - } + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } - approveUserPath(result.filePaths[0]) - setCurrentProjectPath(null) - return { - success: true, - path: result.filePaths[0] - }; - } catch (error) { - console.error('Failed to open file picker:', error); - return { - success: false, - message: 'Failed to open file picker', - error: String(error) - }; - } - }); + const selectedPath = result.filePaths[0]; - ipcMain.handle('open-audio-file-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Audio File', - filters: [ - { name: 'Audio Files', extensions: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile'] - }); + if (includeProjects && hasProjectFileExtension(selectedPath)) { + const projectResult = await loadProjectFromPath(selectedPath); + return projectResult.success + ? { ...projectResult, kind: "project" } + : projectResult; + } - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true }; - } + approveUserPath(selectedPath); + setCurrentProjectPath(null); + return { + success: true, + kind: "media", + path: selectedPath, + extension: path.extname(selectedPath).replace(/^\./, "").toLowerCase(), + }; + } catch (error) { + console.error("Failed to open file picker:", error); + return { + success: false, + message: "Failed to open file picker", + error: String(error), + }; + } + }); - approveUserPath(result.filePaths[0]) - return { - success: true, - path: result.filePaths[0] - }; - } 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("open-audio-file-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Audio File", + filters: [ + { + name: "Audio Files", + extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"], + }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); - ipcMain.handle('open-whisper-executable-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Whisper Executable', - filters: [ - { name: 'Executables', extensions: process.platform === 'win32' ? ['exe', 'cmd', 'bat'] : ['*'] }, - { name: 'All Files', extensions: ['*'] }, - ], - properties: ['openFile'], - }) + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true } - } + approveUserPath(result.filePaths[0]); + return { + success: true, + path: result.filePaths[0], + }; + } catch (error) { + console.error("Failed to open audio file picker:", error); + return { + success: false, + message: "Failed to open audio file picker", + error: String(error), + }; + } + }); - approveUserPath(result.filePaths[0]) - return { success: true, path: result.filePaths[0] } - } catch (error) { - console.error('Failed to open Whisper executable picker:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("open-whisper-executable-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Whisper Executable", + filters: [ + { + name: "Executables", + extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"], + }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); - ipcMain.handle('open-whisper-model-picker', async () => { - try { - const result = await dialog.showOpenDialog({ - title: 'Select Whisper Model', - filters: [ - { name: 'Whisper Models', extensions: ['bin'] }, - { name: 'All Files', extensions: ['*'] }, - ], - properties: ['openFile'], - }) + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } - if (result.canceled || result.filePaths.length === 0) { - return { success: false, canceled: true } - } + approveUserPath(result.filePaths[0]); + return { success: true, path: result.filePaths[0] }; + } catch (error) { + console.error("Failed to open Whisper executable picker:", error); + return { success: false, error: String(error) }; + } + }); - approveUserPath(result.filePaths[0]) - return { success: true, path: result.filePaths[0] } - } catch (error) { - console.error('Failed to open Whisper model picker:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("open-whisper-model-picker", async () => { + try { + const result = await dialog.showOpenDialog({ + title: "Select Whisper Model", + filters: [ + { name: "Whisper Models", extensions: ["bin"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); - ipcMain.handle('get-whisper-small-model-status', async () => { - try { - return await getWhisperSmallModelStatus() - } catch (error) { - return { success: false, exists: false, path: null, error: String(error) } - } - }) + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } - ipcMain.handle('download-whisper-small-model', async (event) => { - try { - const existing = await getWhisperSmallModelStatus() - if (existing.exists) { - sendWhisperModelDownloadProgress(event.sender, { - status: 'downloaded', - progress: 100, - path: existing.path, - }) - return { success: true, path: existing.path, alreadyDownloaded: true } - } + approveUserPath(result.filePaths[0]); + return { success: true, path: result.filePaths[0] }; + } catch (error) { + console.error("Failed to open Whisper model picker:", error); + return { success: false, error: String(error) }; + } + }); - const modelPath = await downloadWhisperSmallModel(event.sender) - return { success: true, path: modelPath } - } catch (error) { - console.error('Failed to download Whisper small model:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("get-whisper-small-model-status", async () => { + try { + return await getWhisperSmallModelStatus(); + } catch (error) { + return { success: false, exists: false, path: null, error: String(error) }; + } + }); - ipcMain.handle('delete-whisper-small-model', async (event) => { - try { - await deleteWhisperSmallModel() - sendWhisperModelDownloadProgress(event.sender, { - status: 'idle', - progress: 0, - path: null, - }) - return { success: true } - } catch (error) { - console.error('Failed to delete Whisper small model:', error) - // Verify whether the file was actually removed despite the error - const status = await getWhisperSmallModelStatus() - if (!status.exists) { - // File is gone — treat as success - sendWhisperModelDownloadProgress(event.sender, { - status: 'idle', - progress: 0, - path: null, - }) - return { success: true } - } - sendWhisperModelDownloadProgress(event.sender, { - status: 'error', - progress: 0, - path: null, - error: String(error), - }) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("download-whisper-small-model", async (event) => { + try { + const existing = await getWhisperSmallModelStatus(); + if (existing.exists) { + sendWhisperModelDownloadProgress(event.sender, { + status: "downloaded", + progress: 100, + path: existing.path, + }); + return { success: true, path: existing.path, alreadyDownloaded: true }; + } - ipcMain.handle('generate-auto-captions', async (_, options: { - videoPath: string - whisperExecutablePath: string - whisperModelPath: string - language?: string - }) => { - try { - const result = await generateAutoCaptionsFromVideo(options) - return { - success: true, - cues: result.cues, - message: result.audioSourceLabel === 'recording' - ? `Generated ${result.cues.length} caption cues.` - : `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`, - } - } catch (error) { - console.error('Failed to generate auto captions:', error) - return { - success: false, - error: String(error), - message: 'Failed to generate auto captions', - } - } - }) + const modelPath = await downloadWhisperSmallModel(event.sender); + return { success: true, path: modelPath }; + } catch (error) { + console.error("Failed to download Whisper small model:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("delete-whisper-small-model", async (event) => { + try { + await deleteWhisperSmallModel(); + sendWhisperModelDownloadProgress(event.sender, { + status: "idle", + progress: 0, + path: null, + }); + return { success: true }; + } catch (error) { + console.error("Failed to delete Whisper small model:", error); + // Verify whether the file was actually removed despite the error + const status = await getWhisperSmallModelStatus(); + if (!status.exists) { + // File is gone — treat as success + sendWhisperModelDownloadProgress(event.sender, { + status: "idle", + progress: 0, + path: null, + }); + return { success: true }; + } + sendWhisperModelDownloadProgress(event.sender, { + status: "error", + progress: 0, + path: null, + error: String(error), + }); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle( + "generate-auto-captions", + async ( + _, + options: { + videoPath: string; + whisperExecutablePath: string; + whisperModelPath: string; + language?: string; + }, + ) => { + try { + const result = await generateAutoCaptionsFromVideo(options); + return { + success: true, + cues: result.cues, + message: + result.audioSourceLabel === "recording" + ? `Generated ${result.cues.length} caption cues.` + : `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`, + }; + } catch (error) { + console.error("Failed to generate auto captions:", error); + return { + success: false, + error: String(error), + message: "Failed to generate auto captions", + }; + } + }, + ); } diff --git a/electron/preload.ts b/electron/preload.ts index 6f941e80..c339ad3c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -671,8 +671,8 @@ contextBridge.exposeInMainWorld("electronAPI", { captionSidecar, ); }, - openVideoFilePicker: () => { - return ipcRenderer.invoke("open-video-file-picker"); + openVideoFilePicker: (options?: { includeProjects?: boolean }) => { + return ipcRenderer.invoke("open-video-file-picker", options); }, openAudioFilePicker: () => { return ipcRenderer.invoke("open-audio-file-picker"); diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 90acfe57..7dba168e 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -19,8 +19,12 @@ export function useLaunchWindowActions() { }, []); const openVideoFile = useCallback(async () => { - const result = await window.electronAPI.openVideoFilePicker(); + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); if (result.canceled) return; + if (result.success && result.kind === "project") { + await window.electronAPI.switchToEditor(); + return; + } if (result.success && result.path) { await window.electronAPI.setCurrentVideoPath(result.path); await window.electronAPI.switchToEditor(); diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index e9e034fd..ab71fd6b 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -15,6 +15,7 @@ type ProjectBrowserDialogProps = { onOpenChange: (open: boolean) => void; entries: ProjectLibraryEntry[]; onOpenProject: (projectPath: string) => void; + onImportFile?: () => void; anchorRef?: React.RefObject; preferredDirection?: "up" | "down" | "auto"; onPanelHeightChange?: (height: number) => void; @@ -25,6 +26,7 @@ export default function ProjectBrowserDialog({ onOpenChange, entries, onOpenProject, + onImportFile, anchorRef, preferredDirection = "auto", onPanelHeightChange, @@ -172,10 +174,21 @@ export default function ProjectBrowserDialog({ ref={panelRef} role="dialog" aria-label="Projects" - className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150" + className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150" > -
-
Projects
+
+
+ Projects +
+ {onImportFile ? ( + + ) : null}
{visibleEntries.length > 0 ? ( @@ -242,8 +255,19 @@ export default function ProjectBrowserDialog({ style={{ top: `${position.top}px`, left: `${position.left}px` }} className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150" > -
-
Projects
+
+
+ Projects +
+ {onImportFile ? ( + + ) : null}
void; +}; + async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -382,6 +386,9 @@ export default function VideoEditor() { const [isEditingProjectName, setIsEditingProjectName] = useState(false); const [projectNameDraft, setProjectNameDraft] = useState(""); const [isSavingProjectName, setIsSavingProjectName] = useState(false); + const [projectSaveDialogOpen, setProjectSaveDialogOpen] = useState(false); + const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState(""); + const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -648,6 +655,7 @@ export default function VideoEditor() { const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); const projectNameInputRef = useRef(null); + const projectSaveDialogInputRef = useRef(null); const nextZoomIdRef = useRef(1); const nextClipIdRef = useRef(1); const nextAudioIdRef = useRef(1); @@ -668,6 +676,7 @@ export default function VideoEditor() { const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); const projectAutosaveTimeoutRef = useRef(null); + const pendingProjectSaveDialogRef = useRef(null); const projectSaveQueueRef = useRef>(Promise.resolve()); const smokeExportReadyStateRef = useRef>({}); const [historyVersion, setHistoryVersion] = useState(0); @@ -1734,6 +1743,21 @@ export default function VideoEditor() { }; }, [isEditingProjectName]); + useEffect(() => { + if (!projectSaveDialogOpen) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [projectSaveDialogOpen]); + const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -2127,6 +2151,25 @@ export default function VideoEditor() { ); }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); + const resolveProjectSaveDialog = useCallback((saved: boolean) => { + const pendingDialog = pendingProjectSaveDialogRef.current; + pendingProjectSaveDialogRef.current = null; + setProjectSaveDialogOpen(false); + setIsSavingProjectDialog(false); + pendingDialog?.resolve(saved); + }, []); + + const openProjectSaveDialog = useCallback((initialName: string) => { + pendingProjectSaveDialogRef.current?.resolve(false); + setProjectSaveDialogDraft(initialName); + setProjectSaveDialogOpen(true); + setIsSavingProjectDialog(false); + + return new Promise((resolve) => { + pendingProjectSaveDialogRef.current = { resolve }; + }); + }, []); + const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null, timeOffsetMs?: number) => { if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) { @@ -2831,6 +2874,14 @@ export default function VideoEditor() { } } + if (forceSaveAs || !targetProjectPath) { + if (options?.silent) { + return false; + } + + return openProjectSaveDialog(projectDisplayName || fileNameBase); + } + const thumbnailDataUrl = shouldCaptureThumbnail ? await captureProjectThumbnail() : undefined; @@ -2891,6 +2942,8 @@ export default function VideoEditor() { currentProjectSnapshot, currentPersistedEditorState, lastSavedSnapshot?.projectId, + openProjectSaveDialog, + projectDisplayName, queueProjectSave, refreshProjectLibrary, remountPreview, @@ -3013,6 +3066,38 @@ export default function VideoEditor() { ], ); + const handleProjectSaveDialogSubmit = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedProjectName = projectSaveDialogDraft.trim(); + + if (!trimmedProjectName) { + toast.error("Project name is required"); + projectSaveDialogInputRef.current?.focus(); + return; + } + + setIsSavingProjectDialog(true); + let saved = false; + try { + saved = await saveProjectWithName(trimmedProjectName); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsSavingProjectDialog(false); + } + + if (saved) { + resolveProjectSaveDialog(true); + return; + } + + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + }, + [projectSaveDialogDraft, resolveProjectSaveDialog, saveProjectWithName], + ); + /** * Resets the inline project-name editor back to the current saved display name. */ @@ -3080,6 +3165,74 @@ export default function VideoEditor() { [applyLoadedProject, refreshProjectLibrary], ); + const handleImportMediaOrProject = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); + + if (result.canceled) { + return; + } + + if (!result.success) { + toast.error(result.message || "Failed to import file"); + return; + } + + if (result.kind === "project" || result.project) { + const restored = await applyLoadedProject(result.project, result.path ?? null); + if (!restored) { + toast.error("Invalid project file format"); + return; + } + + setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded"); + return; + } + + if (!result.path) { + toast.error("No media file selected"); + return; + } + + const sourcePath = fromFileUrl(result.path); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + setVideoSourcePath(sourcePath); + setVideoPath(sourceVideoUrl); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms + ? sourceVideoUrl + : null; + setWebcam((prev) => ({ + ...prev, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + applySessionPresentation(null); + await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false }); + setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success("Media imported"); + }, [ + applyLoadedProject, + applySessionPresentation, + autoApplyFreshRecordingAutoZooms, + refreshProjectLibrary, + resetSourceScopedEditorState, + ]); + const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { setProjectBrowserOpen(false); @@ -3398,7 +3551,9 @@ export default function VideoEditor() { const handlePreviewSkipBack = useCallback(() => { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; - const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50); + const previous = [...keyframes] + .reverse() + .find((keyframe) => keyframe.time < currentMs - 50); handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); }, [handleSeek, timelinePlayheadTime]); @@ -3406,9 +3561,7 @@ export default function VideoEditor() { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); - handleSeek( - next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5), - ); + handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); }, [handleSeek, timelineDuration, timelinePlayheadTime]); const handleSelectZoom = useCallback((id: string | null) => { @@ -5215,21 +5368,84 @@ export default function VideoEditor() { volume={ audio.shouldMutePreviewVideo || audio.isCurrentClipMuted ? 0 - : Math.max( - 0, - Math.min(1, previewVolume * audio.embeddedSourcePreviewGain), - ) + : Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain)) } suspendRendering={suspendRendering} /> ); + const projectSaveDialog = ( + { + if (open) { + setProjectSaveDialogOpen(true); + return; + } + + if (!isSavingProjectDialog) { + resolveProjectSaveDialog(false); + } + }} + > + +
void handleProjectSaveDialogSubmit(event)}> + + {t("editor.project.saveTitle", "Save Project")} + + {t( + "editor.project.saveDescription", + "Name this project. It will be saved in your Recordly Projects folder.", + )} + + +
+ +
+ setProjectSaveDialogDraft(event.target.value)} + disabled={isSavingProjectDialog} + className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" + aria-label={t("editor.project.saveNameLabel", "Project name")} + /> + + .recordly + +
+
+ + + + +
+
+
+ ); + const projectBrowser = ( { + void handleImportMediaOrProject(); + }} onOpenProject={(projectPath) => { void handleOpenProjectFromLibrary(projectPath); }} @@ -5269,6 +5485,7 @@ export default function VideoEditor() {
Loading video...
{projectBrowser} + {projectSaveDialog} {nativeCaptureUnavailableDialog}
@@ -5289,6 +5506,7 @@ export default function VideoEditor() {
{projectBrowser} + {projectSaveDialog} {nativeCaptureUnavailableDialog}
@@ -5736,7 +5954,9 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} - showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + showCaptionSidecarOption={ + hasCaptionsForSidecar && exportFormat === "mp4" + } includeCaptionSidecar={includeCaptionSidecar} onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} mp4OutputDimensions={mp4OutputDimensions} @@ -6415,6 +6635,7 @@ export default function VideoEditor() { ) : null} {projectBrowser} + {projectSaveDialog} {nativeCaptureUnavailableDialog} diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts index 78fcee84..16858f12 100644 --- a/src/components/video-editor/projectDirtyState.ts +++ b/src/components/video-editor/projectDirtyState.ts @@ -42,12 +42,43 @@ function areDeepEqual(left: unknown, right: unknown): boolean { return true; } +function omitTransientWebcamMediaFields(project: EditorProjectData | null) { + if (!project?.editor || typeof project.editor !== "object") { + return project; + } + + const editor = project.editor as Record; + const webcam = editor.webcam; + if (!isComparableObject(webcam)) { + return project; + } + + const { + enabled: _enabled, + sourcePath: _sourcePath, + timeOffsetMs: _timeOffsetMs, + ...persistentWebcamFields + } = webcam; + + return { + ...project, + editor: { + ...editor, + webcam: persistentWebcamFields, + }, + }; +} + export function hasUnsavedProjectChanges( currentProjectSnapshot: EditorProjectData | null, lastSavedSnapshot: EditorProjectData | null, ): boolean { + const comparableCurrentSnapshot = omitTransientWebcamMediaFields(currentProjectSnapshot); + const comparableLastSavedSnapshot = omitTransientWebcamMediaFields(lastSavedSnapshot); + return Boolean( - currentProjectSnapshot && - (!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)), + comparableCurrentSnapshot && + (!comparableLastSavedSnapshot || + !areDeepEqual(comparableCurrentSnapshot, comparableLastSavedSnapshot)), ); } diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 8e1cc6bf..ab1af945 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -6,7 +6,7 @@ import { ZOOM_OUT_EARLY_START_MS, } from "./constants"; import { clampFocusToScale } from "./focusUtils"; -import { clamp01, cubicBezier, easeOutZoom } from "./mathUtils"; +import { clamp01, easeOutZoom } from "./mathUtils"; const CHAINED_ZOOM_PAN_GAP_MS = 1350; const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; @@ -34,14 +34,6 @@ type ConnectedPanTransition = { endScale: number; }; -function lerp(start: number, end: number, amount: number) { - return start + (end - start) * amount; -} - -function easeConnectedPan(value: number) { - return cubicBezier(0.1, 0.0, 0.2, 1.0, value); -} - export function computeRegionStrength( region: ZoomRegion, timeMs: number, @@ -79,13 +71,6 @@ export function computeRegionStrength( return 1 - easeOutZoom(progress); } -function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus { - return { - cx: lerp(start.cx, end.cx, amount), - cy: lerp(start.cy, end.cy, amount), - }; -} - function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus { return clampFocusToScale(region.focus, zoomScale); } @@ -199,44 +184,6 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP return null; } -function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) { - for (const pair of connectedPairs) { - const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair; - - if (timeMs < transitionStart || timeMs > transitionEnd) { - continue; - } - - const transitionProgress = easeConnectedPan( - clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)), - ); - const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth]; - const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth]; - const transitionScale = lerp(currentScale, nextScale, transitionProgress); - const currentFocus = getResolvedFocus(currentRegion, currentScale); - const nextFocus = getResolvedFocus(nextRegion, nextScale); - const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress); - - return { - region: { - ...nextRegion, - focus: transitionFocus, - }, - strength: 1, - blendedScale: transitionScale, - transition: { - progress: transitionProgress, - startFocus: currentFocus, - endFocus: nextFocus, - startScale: currentScale, - endScale: nextScale, - }, - }; - } - - return null; -} - export function findDominantRegion( regions: ZoomRegion[], timeMs: number,