diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index a65fd907..61dbdbaa 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -80,6 +80,12 @@ function normalizeProjectSaveName(projectName?: string | null) { return sanitizedName || null; } +type NamedProjectSaveMode = "rename" | "copy"; + +function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode { + return value === "copy" ? "copy" : "rename"; +} + /** * Extracts the persisted source video path from a saved project payload. */ @@ -292,8 +298,10 @@ export function registerProjectHandlers() { try { const projectsDir = await getProjectsDir() const preparedProject = ensureProjectDataHasProjectId(projectData) - const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) - ? existingProjectPath + const trustedExistingProjectPath = existingProjectPath && + path.extname(existingProjectPath).toLowerCase() === `.${PROJECT_FILE_EXTENSION}` && + (isTrustedProjectPath(existingProjectPath) || isPathInsideDirectory(existingProjectPath, projectsDir)) + ? path.resolve(existingProjectPath) : null if (trustedExistingProjectPath) { @@ -309,6 +317,13 @@ export function registerProjectHandlers() { } } + if (existingProjectPath) { + return { + success: false, + message: 'Project path is no longer trusted. Use Save As to choose a project file.', + } + } + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` @@ -351,7 +366,7 @@ export function registerProjectHandlers() { } }) - ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null, mode?: unknown) => { try { const normalizedProjectName = normalizeProjectSaveName(projectName) if (!normalizedProjectName) { @@ -362,7 +377,7 @@ export function registerProjectHandlers() { } const projectsDir = await getProjectsDir() - const preparedProject = ensureProjectDataHasProjectId(projectData) + const namedSaveMode = normalizeNamedProjectSaveMode(mode) const activeProjectPath = isTrustedProjectPath(currentProjectPath) ? currentProjectPath : null @@ -370,6 +385,22 @@ export function registerProjectHandlers() { projectsDir, `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, ) + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + activeProjectPath ? resolveComparablePath(activeProjectPath) : Promise.resolve(null), + resolveComparablePath(targetProjectPath), + ]) + const isSavingToDifferentPath = + !activeResolvedPath || activeResolvedPath !== targetResolvedPath + const preparedProject = + namedSaveMode === "copy" && isSavingToDifferentPath + ? (() => { + const projectId = randomUUID() + return { + projectId, + projectData: withProjectId(projectData, projectId), + } + })() + : ensureProjectDataHasProjectId(projectData) const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( targetProjectPath, @@ -384,13 +415,7 @@ export function registerProjectHandlers() { await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) await rememberRecentProject(targetProjectPath) - if (activeProjectPath) { - const [activeResolvedPath, targetResolvedPath] = await Promise.all([ - resolveComparablePath(activeProjectPath), - resolveComparablePath(targetProjectPath), - ]) - - if (activeResolvedPath !== targetResolvedPath) { + if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { if (unlinkError.code !== 'ENOENT') { throw unlinkError @@ -407,7 +432,6 @@ export function registerProjectHandlers() { } } await saveRecentProjectPaths(filteredRecentProjectPaths) - } } setCurrentProjectPath(targetProjectPath) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f18b931f..19e366cd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -298,6 +298,18 @@ type SaveProjectOptions = { captureThumbnail?: boolean; }; +type NamedProjectSaveMode = "rename" | "copy"; + +type PendingProjectSaveDialog = { + resolve: (saved: boolean) => void; +}; + +type PendingUnsavedChangesDialogDecision = "cancel" | "discard" | "save"; + +type PendingUnsavedChangesDialog = { + resolve: (decision: PendingUnsavedChangesDialogDecision) => void; +}; + async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -382,6 +394,13 @@ 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 [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false); + const [unsavedChangesDialogActionLabel, setUnsavedChangesDialogActionLabel] = useState( + "continue", + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -529,7 +548,7 @@ export default function VideoEditor() { const [autoCaptionSettings, setAutoCaptionSettings] = useState( DEFAULT_AUTO_CAPTION_SETTINGS, ); - const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true); + const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(false); const [whisperExecutablePath, setWhisperExecutablePath] = useState( initialEditorPreferences.whisperExecutablePath, ); @@ -648,6 +667,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 +688,8 @@ export default function VideoEditor() { const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); const projectAutosaveTimeoutRef = useRef(null); + const pendingProjectSaveDialogRef = useRef(null); + const pendingUnsavedChangesDialogRef = useRef(null); const projectSaveQueueRef = useRef>(Promise.resolve()); const smokeExportReadyStateRef = useRef>({}); const [historyVersion, setHistoryVersion] = useState(0); @@ -1734,6 +1756,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 +2164,45 @@ 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 resolveUnsavedChangesDialog = useCallback( + (decision: PendingUnsavedChangesDialogDecision) => { + const pendingDialog = pendingUnsavedChangesDialogRef.current; + pendingUnsavedChangesDialogRef.current = null; + setUnsavedChangesDialogOpen(false); + pendingDialog?.resolve(decision); + }, + [], + ); + + const openUnsavedChangesDialog = useCallback((actionLabel: string) => { + pendingUnsavedChangesDialogRef.current?.resolve("cancel"); + setUnsavedChangesDialogActionLabel(actionLabel); + setUnsavedChangesDialogOpen(true); + + return new Promise((resolve) => { + pendingUnsavedChangesDialogRef.current = { resolve }; + }); + }, []); + const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null, timeOffsetMs?: number) => { if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) { @@ -2765,7 +2841,6 @@ export default function VideoEditor() { } setAutoCaptions(result.cues); - setAutoCaptionSettings((prev) => ({ ...prev, enabled: true })); toast.success(result.message || `Generated ${result.cues.length} captions`); } catch (error) { toast.error(getErrorMessage(error)); @@ -2831,6 +2906,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 +2974,8 @@ export default function VideoEditor() { currentProjectSnapshot, currentPersistedEditorState, lastSavedSnapshot?.projectId, + openProjectSaveDialog, + projectDisplayName, queueProjectSave, refreshProjectLibrary, remountPreview, @@ -2945,7 +3030,7 @@ export default function VideoEditor() { * Saves the current project directly into the projects library under a chosen name. */ const saveProjectWithName = useCallback( - async (projectName: string) => { + async (projectName: string, mode: NamedProjectSaveMode = "rename") => { const trimmedProjectName = projectName.trim(); if (!trimmedProjectName) { toast.error("Project name is required"); @@ -2971,6 +3056,7 @@ export default function VideoEditor() { projectData, trimmedProjectName, thumbnailDataUrl, + mode, ); if (result.canceled) { @@ -3013,6 +3099,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, "copy"); + } 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. */ @@ -3036,7 +3154,7 @@ export default function VideoEditor() { setIsSavingProjectName(true); let saved = false; try { - saved = await saveProjectWithName(trimmedProjectName); + saved = await saveProjectWithName(trimmedProjectName, "rename"); } catch (error) { toast.error(getErrorMessage(error)); } finally { @@ -3054,8 +3172,32 @@ export default function VideoEditor() { [closeProjectNameEditor, projectNameDraft, saveProjectWithName], ); + const confirmReplaceSourceWithUnsavedChanges = useCallback( + async (actionLabel: string) => { + if (!hasUnsavedChanges) { + return true; + } + + const decision = await openUnsavedChangesDialog(actionLabel); + if (decision === "discard") { + return true; + } + + if (decision === "save") { + return saveProject(false); + } + + return false; + }, + [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], + ); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { + if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) { + return; + } + const result = await window.electronAPI.openProjectFileAtPath(projectPath); if (result.canceled) { @@ -3077,9 +3219,82 @@ export default function VideoEditor() { await refreshProjectLibrary(); toast.success(`Project loaded from ${result.path}`); }, - [applyLoadedProject, refreshProjectLibrary], + [applyLoadedProject, confirmReplaceSourceWithUnsavedChanges, refreshProjectLibrary], ); + const handleImportMediaOrProject = useCallback(async () => { + if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) { + return; + } + + 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, + confirmReplaceSourceWithUnsavedChanges, + refreshProjectLibrary, + resetSourceScopedEditorState, + ]); + const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { setProjectBrowserOpen(false); @@ -3398,7 +3613,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 +3623,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 +5430,126 @@ 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 unsavedChangesDialog = ( + { + if (open) { + setUnsavedChangesDialogOpen(true); + return; + } + + resolveUnsavedChangesDialog("cancel"); + }} + > + + + Unsaved changes + + {`Save your current project before you ${unsavedChangesDialogActionLabel}?`} + + + + + + + + + + ); + const projectBrowser = ( { + void handleImportMediaOrProject(); + }} onOpenProject={(projectPath) => { void handleOpenProjectFromLibrary(projectPath); }} @@ -5269,6 +5589,8 @@ export default function VideoEditor() {
Loading video...
{projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog}
@@ -5289,6 +5611,8 @@ export default function VideoEditor() { {projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog} @@ -5736,7 +6060,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 +6741,8 @@ export default function VideoEditor() { ) : null} {projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog}