Improve project autosave and media path handling

This commit is contained in:
webadderall
2026-04-27 20:07:32 +10:00
parent a4f475228a
commit 98e4c7cade
8 changed files with 249 additions and 104 deletions
+5 -2
View File
@@ -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 };
+16 -1
View File
@@ -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");
});
});
+43 -10
View File
@@ -68,19 +68,36 @@ export async function isAllowedLocalMediaPath(candidatePath: string) {
return isAllowedLocalReadPath(normalizedCandidatePath);
}
async function collectApprovedLocalReadPaths(filePath?: string | null): Promise<string[]> {
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<string | null | undefined>) {
const nextApprovedPaths = new Set<string>();
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);
}
+8 -4
View File
@@ -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 }
})
+4 -4
View File
@@ -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");
+162 -81
View File
@@ -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<string, unknown>,
@@ -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<CropRegion | null>(null);
const mp4SupportRequestRef = useRef(0);
const smokeExportStartedRef = useRef(false);
const projectAutosaveTimeoutRef = useRef<number | null>(null);
const projectSaveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const [historyVersion, setHistoryVersion] = useState(0);
const timelineRef = useRef<TimelineEditorHandle>(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<boolean>) => {
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.
*/
@@ -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",
+9
View File
@@ -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;
}