From 98e4c7cade4399fbc491d6f8e5777c3bbc0bc98c Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:07:32 +1000 Subject: [PATCH] Improve project autosave and media path handling --- electron/electron-env.d.ts | 7 +- electron/ipc/project/manager.test.ts | 17 +- electron/ipc/project/manager.ts | 53 +++- electron/ipc/register/project.ts | 12 +- electron/preload.ts | 8 +- src/components/video-editor/VideoEditor.tsx | 243 ++++++++++++------ .../video-editor/editorPreferences.test.ts | 4 +- src/lib/exporter/localMediaSource.ts | 9 + 8 files changed, 249 insertions(+), 104 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5c5bfb47..da4b53c6 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -398,12 +398,15 @@ interface Window { message?: string; error?: string; }>; - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentVideoPath: ( + path: string, + options?: { preserveProjectPath?: boolean }, + ) => Promise<{ success: boolean }>; setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; - }) => Promise<{ success: boolean }>; + }, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>; getCurrentRecordingSession: () => Promise<{ success: boolean; session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 0236b55b..51fca45d 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -79,12 +79,13 @@ describe("local media path policy", () => { const videoPath = path.join(downloadsPath, "external-video.mp4"); await fs.mkdir(downloadsPath, { recursive: true }); await fs.writeFile(videoPath, "test-video"); + const resolvedVideoPath = await fs.realpath(videoPath); const { resolveApprovedLocalMediaPath } = await import("./manager"); const { isAllowedMediaPath } = await import("../../mediaServer"); expect(isAllowedMediaPath(videoPath)).toBe(false); - await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath); + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath); expect(isAllowedMediaPath(videoPath)).toBe(true); }); @@ -100,4 +101,18 @@ describe("local media path policy", () => { await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull(); expect(isAllowedMediaPath(textPath)).toBe(false); }); + + it("preserves an existing project thumbnail when no replacement is provided", async () => { + const projectPath = path.join(tempRoot, "Projects", "demo.recordly"); + const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`; + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + + const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager"); + const thumbnailPath = getProjectThumbnailPath(projectPath); + + await saveProjectThumbnail(projectPath, thumbnailDataUrl); + await saveProjectThumbnail(projectPath, undefined); + + await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail"); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 473f3a63..9e61605a 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -68,19 +68,36 @@ export async function isAllowedLocalMediaPath(candidatePath: string) { return isAllowedLocalReadPath(normalizedCandidatePath); } +async function collectApprovedLocalReadPaths(filePath?: string | null): Promise { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return []; + } + + const approvedPaths = [normalizePath(normalizedPath)]; + + try { + const realPath = await fs.realpath(approvedPaths[0]); + const normalizedRealPath = normalizePath(realPath); + if (!approvedPaths.includes(normalizedRealPath)) { + approvedPaths.push(normalizedRealPath); + } + } catch { + // Ignore missing files; the eventual read will surface the real error. + } + + return approvedPaths; +} + export async function rememberApprovedLocalReadPath(filePath?: string | null) { const normalizedPath = normalizeVideoSourcePath(filePath); if (!normalizedPath) { return; } - const resolvedPath = normalizePath(normalizedPath); - approvedLocalReadPaths.add(resolvedPath); - - try { - approvedLocalReadPaths.add(await fs.realpath(resolvedPath)); - } catch { - // Ignore missing files; the eventual read will surface the real error. + const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath); + for (const approvedPath of approvedPaths) { + approvedLocalReadPaths.add(approvedPath); } } @@ -101,13 +118,26 @@ export async function resolveApprovedLocalMediaPath(candidatePath: string): Prom return null; } - await rememberApprovedLocalReadPath(realPath); + await rememberApprovedLocalReadPath(candidatePath); return realPath; } export async function replaceApprovedSessionLocalReadPaths(filePaths: Array) { + const nextApprovedPaths = new Set(); + const approvedPathLists = await Promise.all( + filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)), + ); + + for (const approvedPathList of approvedPathLists) { + for (const approvedPath of approvedPathList) { + nextApprovedPaths.add(approvedPath); + } + } + approvedLocalReadPaths.clear(); - await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath))); + for (const approvedPath of nextApprovedPaths) { + approvedLocalReadPaths.add(approvedPath); + } } export async function resolveProjectMediaSources(project: unknown): Promise< @@ -196,6 +226,10 @@ export function getProjectThumbnailPath(projectPath: string) { export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) { const thumbnailPath = getProjectThumbnailPath(projectPath); + if (thumbnailDataUrl === undefined) { + return existsSync(thumbnailPath) ? thumbnailPath : null; + } + if (!thumbnailDataUrl) { await fs.rm(thumbnailPath, { force: true }).catch(() => undefined); return null; @@ -383,4 +417,3 @@ export function isTrustedProjectPath(filePath?: string | null): boolean { if (!filePath || !currentProjectPath) return false; return normalizePath(filePath) === normalizePath(currentProjectPath); } - diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 94c25b56..570d7b58 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -527,7 +527,7 @@ export function registerProjectHandlers() { return { success: false, error: String(error), message: 'Failed to open projects folder.' } } }) - ipcMain.handle('set-current-video-path', async (_, path: string) => { + ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => { setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) approveUserPath(currentVideoPath) const resolvedSession = await resolveRecordingSession(currentVideoPath) @@ -547,11 +547,13 @@ export function registerProjectHandlers() { await persistRecordingSessionManifest(resolvedSession) } - setCurrentProjectPath(null) + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null) + } return { success: true, webcamPath: resolvedSession.webcamPath ?? null } }) - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath setCurrentVideoPath(normalizedVideoPath) setCurrentRecordingSession({ @@ -563,7 +565,9 @@ export function registerProjectHandlers() { currentRecordingSession!.videoPath, currentRecordingSession!.webcamPath, ]) - setCurrentProjectPath(null) + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null) + } await persistRecordingSessionManifest(currentRecordingSession!) return { success: true } }) diff --git a/electron/preload.ts b/electron/preload.ts index e6537ec4..e41acc26 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -430,15 +430,15 @@ contextBridge.exposeInMainWorld("electronAPI", { }) => { return ipcRenderer.invoke("generate-auto-captions", options); }, - setCurrentVideoPath: (path: string) => { - return ipcRenderer.invoke("set-current-video-path", path); + setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => { + return ipcRenderer.invoke("set-current-video-path", path, options); }, setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; - }) => { - return ipcRenderer.invoke("set-current-recording-session", session); + }, options?: { preserveProjectPath?: boolean }) => { + return ipcRenderer.invoke("set-current-recording-session", session, options); }, getCurrentRecordingSession: () => { return ipcRenderer.invoke("get-current-recording-session"); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 580627ac..7dc6d812 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,13 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; +type SaveProjectOptions = { + silent?: boolean; + remountPreviewAfterSave?: boolean; + refreshLibraryAfterSave?: boolean; + captureThumbnail?: boolean; +}; + async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -251,6 +258,7 @@ async function writeSmokeExportReport( const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +const PROJECT_AUTOSAVE_DELAY_MS = 1000; function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number { switch (encodingMode) { @@ -695,6 +703,8 @@ export default function VideoEditor() { const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); + const projectAutosaveTimeoutRef = useRef(null); + const projectSaveQueueRef = useRef>(Promise.resolve()); const [historyVersion, setHistoryVersion] = useState(0); const timelineRef = useRef(null); @@ -982,6 +992,19 @@ export default function VideoEditor() { setPreviewVersion((version) => version + 1); }, []); + const clearPendingProjectAutosave = useCallback(() => { + if (projectAutosaveTimeoutRef.current !== null) { + window.clearTimeout(projectAutosaveTimeoutRef.current); + projectAutosaveTimeoutRef.current = null; + } + }, []); + + const queueProjectSave = useCallback((task: () => Promise) => { + const run = projectSaveQueueRef.current.catch(() => undefined).then(task); + projectSaveQueueRef.current = run.catch(() => undefined); + return run; + }, []); + useEffect(() => { return () => { exporterRef.current?.cancel(); @@ -999,6 +1022,10 @@ export default function VideoEditor() { window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); pendingFreshRecordingAutoSuggestTimeoutRef.current = null; } + if (projectAutosaveTimeoutRef.current !== null) { + window.clearTimeout(projectAutosaveTimeoutRef.current); + projectAutosaveTimeoutRef.current = null; + } }; }, []); @@ -1561,20 +1588,24 @@ export default function VideoEditor() { setCurrentTime(0); setDuration(0); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(await resolveVideoUrl(sourcePath)); - setCurrentProjectPath(path ?? null); - pendingFreshRecordingAutoZoomPathRef.current = null; - if (normalizedEditor.webcam.sourcePath) { - await window.electronAPI.setCurrentRecordingSession?.({ - videoPath: sourcePath, - webcamPath: normalizedEditor.webcam.sourcePath, - timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, - }); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath); - } + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(await resolveVideoUrl(sourcePath)); + setCurrentProjectPath(path ?? null); + pendingFreshRecordingAutoZoomPathRef.current = null; + if (normalizedEditor.webcam.sourcePath) { + await window.electronAPI.setCurrentRecordingSession?.({ + videoPath: sourcePath, + webcamPath: normalizedEditor.webcam.sourcePath, + timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, + }, { + preserveProjectPath: Boolean(path), + }); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + } setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); @@ -1711,9 +1742,11 @@ export default function VideoEditor() { : webcamPath ? webcam.timeOffsetMs : DEFAULT_WEBCAM_TIME_OFFSET_MS, + }, { + preserveProjectPath: Boolean(currentProjectPath), }); }, - [currentSourcePath, webcam.timeOffsetMs], + [currentProjectPath, currentSourcePath, webcam.timeOffsetMs], ); const syncActiveVideoSource = useCallback( @@ -1723,13 +1756,17 @@ export default function VideoEditor() { videoPath: sourcePath, webcamPath, timeOffsetMs: webcam.timeOffsetMs, + }, { + preserveProjectPath: Boolean(currentProjectPath), }); return; } - await window.electronAPI.setCurrentVideoPath(sourcePath); + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(currentProjectPath), + }); }, - [webcam.timeOffsetMs], + [currentProjectPath, webcam.timeOffsetMs], ); const handleUploadWebcam = useCallback(async () => { @@ -2237,83 +2274,106 @@ export default function VideoEditor() { }, []); const saveProject = useCallback( - async (forceSaveAs: boolean) => { - if (!currentSourcePath) { - toast.error("No video loaded"); - return false; - } + async (forceSaveAs: boolean, options?: SaveProjectOptions) => { + clearPendingProjectAutosave(); + return queueProjectSave(async () => { + if (!currentSourcePath) { + if (!options?.silent) { + toast.error("No video loaded"); + } + return false; + } - try { - const projectData = - currentProjectSnapshot?.videoPath === currentSourcePath - ? currentProjectSnapshot - : createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, - ); + const shouldCaptureThumbnail = options?.captureThumbnail ?? true; + const shouldRefreshLibrary = options?.refreshLibraryAfterSave ?? true; + const shouldRemountPreview = options?.remountPreviewAfterSave ?? true; - const fileNameBase = - currentSourcePath - .split(/[\\/]/) - .pop() - ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); - if (!forceSaveAs && !targetProjectPath) { - const activeProjectResult = await window.electronAPI.loadCurrentProjectFile(); - if (activeProjectResult.success && activeProjectResult.path) { - targetProjectPath = activeProjectResult.path; - setCurrentProjectPath(activeProjectResult.path); + const fileNameBase = + currentSourcePath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; + let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); + + if (!forceSaveAs && !targetProjectPath) { + const activeProjectResult = await window.electronAPI.loadCurrentProjectFile(); + if (activeProjectResult.success && activeProjectResult.path) { + targetProjectPath = activeProjectResult.path; + setCurrentProjectPath(activeProjectResult.path); + } + } + + const thumbnailDataUrl = shouldCaptureThumbnail + ? await captureProjectThumbnail() + : undefined; + + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + targetProjectPath, + thumbnailDataUrl, + ); + + if (result.canceled) { + if (!options?.silent) { + toast.info("Project save canceled"); + } + return false; + } + + if (!result.success) { + if (!options?.silent) { + 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, + ), + ), + ); + if (shouldRefreshLibrary) { + await refreshProjectLibrary(); + } + + if (!options?.silent) { + toast.success(`Project saved to ${result.path}`); + } + return true; + } finally { + if (shouldRemountPreview) { + remountPreview(); } } - - const thumbnailDataUrl = await captureProjectThumbnail(); - - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - targetProjectPath, - 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(`Project saved to ${result.path}`); - return true; - } finally { - remountPreview(); - } + }); }, [ captureProjectThumbnail, + clearPendingProjectAutosave, currentSourcePath, currentProjectPath, currentProjectSnapshot, currentPersistedEditorState, lastSavedSnapshot?.projectId, + queueProjectSave, refreshProjectLibrary, remountPreview, ], @@ -2342,6 +2402,27 @@ export default function VideoEditor() { } }, [saveProject]); + useEffect(() => { + if (!currentProjectPath || !hasUnsavedChanges) { + clearPendingProjectAutosave(); + return; + } + + projectAutosaveTimeoutRef.current = window.setTimeout(() => { + projectAutosaveTimeoutRef.current = null; + void saveProject(false, { + silent: true, + remountPreviewAfterSave: false, + refreshLibraryAfterSave: false, + captureThumbnail: false, + }); + }, PROJECT_AUTOSAVE_DELAY_MS); + + return () => { + clearPendingProjectAutosave(); + }; + }, [clearPendingProjectAutosave, currentProjectPath, hasUnsavedChanges, saveProject]); + /** * Saves the current project directly into the projects library under a chosen name. */ diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index edd0c012..2aa70013 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -240,7 +240,7 @@ describe("editorPreferences", () => { cursorClickBounceDuration: 350, cursorSway: 1.5, borderRadius: 18, - padding: 30, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, frame: DEFAULT_EDITOR_PREFERENCES.frame, aspectRatio: "4:5", exportEncodingMode: "quality", @@ -282,7 +282,7 @@ describe("editorPreferences", () => { cursorClickBounceDuration: 350, cursorSway: 1.5, borderRadius: 18, - padding: 30, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, frame: DEFAULT_EDITOR_PREFERENCES.frame, aspectRatio: "4:5", exportEncodingMode: "quality", diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index 5483fc16..a55b34ad 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -3,6 +3,7 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist const NOOP = () => undefined; const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i; const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]); +const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"]; export function isAbsoluteLocalPath(resource: string) { return ( @@ -12,6 +13,10 @@ export function isAbsoluteLocalPath(resource: string) { ); } +function isBundledAssetPath(resource: string) { + return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix)); +} + function getLocalMediaServerPath(resource: string) { if (!/^https?:\/\//i.test(resource)) { return null; @@ -44,6 +49,10 @@ export function getLocalFilePath(resource: string) { return fromFileUrl(resource); } + if (isBundledAssetPath(resource)) { + return null; + } + return isAbsoluteLocalPath(resource) ? resource : null; }