diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eff6eced..c24386a04 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -341,6 +341,19 @@ interface Window { ) => Promise<{ success: boolean; path?: string; + projectId?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + projectId?: string; message?: string; canceled?: boolean; error?: string; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index dee0af163..29f138dc9 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -1,4 +1,5 @@ import { constants as fsConstants } from "node:fs"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { dialog, ipcMain, shell } from "electron"; @@ -18,14 +19,17 @@ import { } from "../state"; import { getProjectsDir, + getProjectThumbnailPath, isAllowedLocalMediaPath, isPathInsideDirectory, isTrustedProjectPath, listProjectLibraryEntries, + loadRecentProjectPaths, loadProjectFromPath, persistRecordingsDirectorySetting, replaceApprovedSessionLocalReadPaths, rememberRecentProject, + saveRecentProjectPaths, saveProjectThumbnail, } from "../project/manager"; import { @@ -41,6 +45,161 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +/** + * Produces a filesystem-safe project base name without the project extension. + */ +function normalizeProjectSaveName(projectName?: string | null) { + if (typeof projectName !== "string") { + return null; + } + + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } + + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); + const withoutControlChars = Array.from(withoutInvalidFilesystemChars) + .filter((character) => character.charCodeAt(0) > 31) + .join(""); + const sanitizedName = withoutControlChars + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + + return sanitizedName || null; +} + +/** + * Extracts the persisted source video path from a saved project payload. + */ +function getProjectVideoPath(projectData: unknown) { + if (!projectData || typeof projectData !== "object") { + return null; + } + + const candidate = projectData as { videoPath?: unknown }; + return typeof candidate.videoPath === "string" ? candidate.videoPath : null; +} + +function getProjectId(projectData: unknown) { + if (!projectData || typeof projectData !== "object") { + return null; + } + + const candidate = projectData as { projectId?: unknown }; + return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0 + ? candidate.projectId + : null; +} + +function withProjectId(projectData: unknown, projectId: string) { + if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) { + return projectData; + } + + return { + ...projectData, + projectId, + }; +} + +function ensureProjectDataHasProjectId(projectData: unknown) { + const existingProjectId = getProjectId(projectData); + if (existingProjectId) { + return { + projectId: existingProjectId, + projectData, + }; + } + + const projectId = randomUUID(); + return { + projectId, + projectData: withProjectId(projectData, projectId), + }; +} + +async function resolveComparablePath(filePath: string) { + return fs.realpath(filePath).catch(() => path.resolve(filePath)); +} + +/** + * Prevents a named save from silently overwriting a different project file. + */ +async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath: string, + projectData: unknown, + activeProjectPath?: string | null, +) { + try { + await fs.stat(targetProjectPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return { success: true }; + } + throw error; + } + + const targetResolvedPath = await resolveComparablePath(targetProjectPath); + if (activeProjectPath) { + const activeResolvedPath = await resolveComparablePath(activeProjectPath); + if (activeResolvedPath === targetResolvedPath) { + return { success: true }; + } + } + + const incomingProjectId = getProjectId(projectData); + const incomingVideoPath = getProjectVideoPath(projectData); + + try { + const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8"); + const existingProjectData = JSON.parse(existingProjectRaw) as unknown; + const existingProjectId = getProjectId(existingProjectData); + const existingVideoPath = getProjectVideoPath(existingProjectData); + + if (existingProjectId && incomingProjectId) { + if (existingProjectId === incomingProjectId) { + return { success: true }; + } + + return { + success: false, + message: "A different project already uses this name", + }; + } + + if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) { + return { + success: false, + message: "A different project already uses this name", + }; + } + + if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) { + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } + + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } catch (error) { + console.error("Failed to verify existing named project before overwrite:", error); + return { + success: false, + message: "Unable to verify project identity for the chosen name", + }; + } +} + export function registerProjectHandlers() { ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { try { @@ -126,26 +285,26 @@ export function registerProjectHandlers() { ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { try { const projectsDir = await getProjectsDir() + const preparedProject = ensureProjectDataHasProjectId(projectData) const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) ? existingProjectPath : null if (trustedExistingProjectPath) { - await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') setCurrentProjectPath(trustedExistingProjectPath) await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl) await rememberRecentProject(trustedExistingProjectPath) return { success: true, path: trustedExistingProjectPath, + projectId: preparedProject.projectId, message: 'Project saved successfully' } } - const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') - const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) - ? safeName - : `${safeName}.${PROJECT_FILE_EXTENSION}` + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` const result = await dialog.showSaveDialog({ title: 'Save Recordly Project', @@ -165,7 +324,7 @@ export function registerProjectHandlers() { } } - await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') + await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') setCurrentProjectPath(result.filePath) await saveProjectThumbnail(result.filePath, thumbnailDataUrl) await rememberRecentProject(result.filePath) @@ -173,6 +332,7 @@ export function registerProjectHandlers() { return { success: true, path: result.filePath, + projectId: preparedProject.projectId, message: 'Project saved successfully' } } catch (error) { @@ -185,6 +345,83 @@ export function registerProjectHandlers() { } }) + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName) + if (!normalizedProjectName) { + return { + success: false, + message: 'Project name is required', + } + } + + const projectsDir = await getProjectsDir() + const preparedProject = ensureProjectDataHasProjectId(projectData) + const activeProjectPath = isTrustedProjectPath(currentProjectPath) + ? currentProjectPath + : null + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ) + + const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( + targetProjectPath, + preparedProject.projectData, + activeProjectPath, + ) + if (!overwriteCheck.success) { + return overwriteCheck + } + + await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8') + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) + await rememberRecentProject(targetProjectPath) + + if (activeProjectPath) { + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + resolveComparablePath(activeProjectPath), + resolveComparablePath(targetProjectPath), + ]) + + if (activeResolvedPath !== targetResolvedPath) { + await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { + if (unlinkError.code !== 'ENOENT') { + throw unlinkError + } + }) + await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined) + + const recentProjectPaths = await loadRecentProjectPaths() + const filteredRecentProjectPaths: string[] = [] + for (const recentProjectPath of recentProjectPaths) { + const recentResolvedPath = await resolveComparablePath(recentProjectPath) + if (recentResolvedPath !== activeResolvedPath) { + filteredRecentProjectPaths.push(recentProjectPath) + } + } + await saveRecentProjectPaths(filteredRecentProjectPaths) + } + } + + setCurrentProjectPath(targetProjectPath) + + return { + success: true, + path: targetProjectPath, + projectId: preparedProject.projectId, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save named project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + ipcMain.handle('load-project-file', async () => { try { const projectsDir = await getProjectsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 4ae5d8bf8..fe226ba0d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -407,6 +407,18 @@ contextBridge.exposeInMainWorld("electronAPI", { thumbnailDataUrl, ); }, + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => { + return ipcRenderer.invoke( + "save-project-file-named", + projectData, + projectName, + thumbnailDataUrl, + ); + }, loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 42debcad4..5c1b760bd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,7 +14,6 @@ import { Plus, PuzzlePiece, ArrowClockwise as Redo2, - FloppyDisk as Save, Scissors, SkipBack, SkipForward, @@ -485,6 +484,9 @@ export default function VideoEditor() { const [currentProjectPath, setCurrentProjectPath] = useState(null); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [isEditingProjectName, setIsEditingProjectName] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [isSavingProjectName, setIsSavingProjectName] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -631,6 +633,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); + const projectNameInputRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextClipIdRef = useRef(1); @@ -1240,6 +1243,27 @@ export default function VideoEditor() { return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); + useEffect(() => { + if (!isEditingProjectName) { + setProjectNameDraft(projectDisplayName); + } + }, [isEditingProjectName, projectDisplayName]); + + useEffect(() => { + if (!isEditingProjectName) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [isEditingProjectName]); + const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -1568,7 +1592,11 @@ export default function VideoEditor() { setLastSavedSnapshot( cloneStructured( - createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor)), + createProjectData( + sourcePath, + buildPersistedEditorState(normalizedEditor), + project.projectId ?? null, + ), ), ); await refreshProjectLibrary(); @@ -1581,8 +1609,12 @@ export default function VideoEditor() { if (!currentSourcePath) { return null; } - return createProjectData(currentSourcePath, currentPersistedEditorState); - }, [currentPersistedEditorState, currentSourcePath]); + return createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null) => { @@ -1672,12 +1704,11 @@ export default function VideoEditor() { const hasUnsavedChanges = useMemo( () => Boolean( - currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot), + currentProjectSnapshot && + (!lastSavedSnapshot || + !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)), ), - [currentProjectPath, currentProjectSnapshot, lastSavedSnapshot], + [currentProjectSnapshot, lastSavedSnapshot], ); useEffect(() => { @@ -2096,7 +2127,11 @@ export default function VideoEditor() { const projectData = currentProjectSnapshot?.videoPath === currentSourcePath ? currentProjectSnapshot - : createProjectData(currentSourcePath, currentPersistedEditorState); + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); const fileNameBase = currentSourcePath @@ -2135,7 +2170,15 @@ export default function VideoEditor() { if (result.path) { setCurrentProjectPath(result.path); } - setLastSavedSnapshot(cloneStructured(projectData)); + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); await refreshProjectLibrary(); toast.success(`Project saved to ${result.path}`); @@ -2150,6 +2193,7 @@ export default function VideoEditor() { currentProjectPath, currentProjectSnapshot, currentPersistedEditorState, + lastSavedSnapshot?.projectId, refreshProjectLibrary, remountPreview, ], @@ -2178,6 +2222,119 @@ export default function VideoEditor() { } }, [saveProject]); + /** + * Saves the current project directly into the projects library under a chosen name. + */ + const saveProjectWithName = useCallback( + async (projectName: string) => { + const trimmedProjectName = projectName.trim(); + if (!trimmedProjectName) { + toast.error("Project name is required"); + return false; + } + + if (!currentSourcePath) { + toast.error("No video loaded"); + return false; + } + + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + const thumbnailDataUrl = await captureProjectThumbnail(); + const result = await window.electronAPI.saveProjectFileNamed( + projectData, + trimmedProjectName, + thumbnailDataUrl, + ); + + if (result.canceled) { + toast.info("Project save canceled"); + return false; + } + + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return false; + } + + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), + ), + ); + await refreshProjectLibrary(); + toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); + return true; + } finally { + remountPreview(); + } + }, + [ + captureProjectThumbnail, + currentPersistedEditorState, + currentProjectSnapshot, + currentSourcePath, + lastSavedSnapshot?.projectId, + refreshProjectLibrary, + remountPreview, + ], + ); + + /** + * Resets the inline project-name editor back to the current saved display name. + */ + const closeProjectNameEditor = useCallback(() => { + setProjectNameDraft(projectDisplayName); + setIsEditingProjectName(false); + }, [projectDisplayName]); + + /** + * Commits the inline project-name editor and persists the project under that name. + */ + const handleProjectNameSubmit = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedProjectName = projectNameDraft.trim(); + if (!trimmedProjectName) { + closeProjectNameEditor(); + return; + } + + setIsSavingProjectName(true); + let saved = false; + try { + saved = await saveProjectWithName(trimmedProjectName); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsSavingProjectName(false); + } + + if (saved) { + setIsEditingProjectName(false); + return; + } + + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }, + [closeProjectNameEditor, projectNameDraft, saveProjectWithName], + ); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { const result = await window.electronAPI.openProjectFileAtPath(projectPath); @@ -2729,6 +2886,16 @@ export default function VideoEditor() { const oldClip = clipRegions.find((c) => c.id === id); const newStart = Math.round(span.start); const newEnd = Math.round(span.end); + const removedSegments = oldClip + ? [ + ...(newStart > oldClip.startMs + ? [{ startMs: oldClip.startMs, endMs: newStart }] + : []), + ...(newEnd < oldClip.endMs + ? [{ startMs: newEnd, endMs: oldClip.endMs }] + : []), + ] + : []; if (oldClip) { const startDelta = newStart - oldClip.startMs; @@ -2754,6 +2921,22 @@ export default function VideoEditor() { } } + if (removedSegments.length > 0) { + const removeTrimmedRegions = ( + regions: T[], + ): T[] => + regions.filter( + (region) => + !removedSegments.some( + (segment) => region.startMs < segment.endMs && region.endMs > segment.startMs, + ), + ); + setZoomRegions((prev) => removeTrimmedRegions(prev)); + setAnnotationRegions((prev) => removeTrimmedRegions(prev)); + setSpeedRegions((prev) => removeTrimmedRegions(prev)); + setAudioRegions((prev) => removeTrimmedRegions(prev)); + } + setClipRegions((prev) => prev.map((clip) => clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, @@ -2808,12 +2991,28 @@ export default function VideoEditor() { const handleClipDelete = useCallback( (id: string) => { + const deletedClip = clipRegions.find((clip) => clip.id === id); setClipRegions((prev) => prev.filter((clip) => clip.id !== id)); + if (deletedClip) { + const { startMs, endMs } = deletedClip; + setZoomRegions((prev) => + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), + ); + setAnnotationRegions((prev) => + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), + ); + setSpeedRegions((prev) => + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), + ); + setAudioRegions((prev) => + prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), + ); + } if (selectedClipId === id) { setSelectedClipId(null); } }, - [selectedClipId], + [clipRegions, selectedClipId], ); const handleSelectSpeed = useCallback((id: string | null) => { @@ -4138,17 +4337,6 @@ export default function VideoEditor() { return top > 0 || left > 0 || bottom > 0 || right > 0; }, [cropRegion]); - const openRecordingsFolder = useCallback(async () => { - try { - const result = await window.electronAPI.openRecordingsFolder(); - if (!result.success) { - toast.error(result.message || result.error || "Failed to open recordings folder."); - } - } catch (error) { - toast.error(`Failed to open recordings folder: ${String(error)}`); - } - }, []); - const revealExportedFile = useCallback(async () => { if (!exportedFilePath) return; @@ -4293,13 +4481,14 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > @@ -4330,48 +4519,66 @@ export default function VideoEditor() {
- - {projectDisplayName} - - - .recordly - + {isEditingProjectName ? ( +
void handleProjectNameSubmit(event)} + className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]" + > + {hasUnsavedChanges ? ( + + ) : null} + setProjectNameDraft(event.target.value)} + onBlur={() => { + if (!isSavingProjectName) { + closeProjectNameEditor(); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeProjectNameEditor(); + } + }} + disabled={isSavingProjectName} + className="min-w-[10ch] max-w-[min(40vw,360px)] bg-transparent text-sm font-semibold tracking-tight text-foreground/95 outline-none placeholder:text-muted-foreground/60 disabled:cursor-wait" + style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }} + aria-label={t("editor.project.renameInput", "Project name")} + /> + + .recordly + + + ) : ( + + )}
- - -
; } @@ -281,6 +282,7 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj if (!candidate || typeof candidate !== "object") return false; const project = candidate as Partial; if (typeof project.version !== "number") return false; + if (project.projectId !== undefined && typeof project.projectId !== "string") return false; if (typeof project.videoPath !== "string" || !project.videoPath) return false; if (!project.editor || typeof project.editor !== "object") return false; return true; @@ -861,9 +863,13 @@ export function normalizeProjectEditor(editor: Partial): Pro export function createProjectData( videoPath: string, editor: Partial, + projectId?: string | null, ): EditorProjectData { return { version: PROJECT_VERSION, + ...(typeof projectId === "string" && projectId.trim().length > 0 + ? { projectId } + : {}), videoPath, editor, }; diff --git a/src/components/video-editor/timeline/AudioWaveform.tsx b/src/components/video-editor/timeline/AudioWaveform.tsx index 1fb07b918..da6105a18 100644 --- a/src/components/video-editor/timeline/AudioWaveform.tsx +++ b/src/components/video-editor/timeline/AudioWaveform.tsx @@ -83,7 +83,7 @@ export default function AudioWaveform({ peaks }: AudioWaveformProps) { ); } diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index a59286e77..f2aba7238 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -30,7 +30,7 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = " {hint}
)} -
+
{children}