Generate safe thumbnails in background and recover failed close saves

This commit is contained in:
webadderall committed 2026-10-03 12:31:46 +10:00
1 parent 1910427cd1
commit cdd64d5fe1
11 files changed
+376 -52

No files matched your search

+3
View File
@@ -859,6 +859,9 @@ interface Window {
trashProjectFiles: (
paths: string[],
) => Promise<{ success: boolean; deleted: string[]; errors: string[] }>;
onProjectThumbnailReady: (
callback: (ready: { path: string; thumbnailPath: string; updatedAt: number }) => void,
) => () => void;
listProjectFiles: () => Promise<{
success: boolean;
projectsDir?: string | null;
+82 -1
View File
@@ -190,7 +190,11 @@ describe("local media path policy", () => {
);
const before = await fs.readFile(projectPath, "utf8");
const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot);
expect(entry?.thumbnailPath).toBe(manager.getProjectThumbnailPath(projectPath));
expect(entry?.thumbnailPath).toBeNull();
await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
expect((await manager.buildProjectLibraryEntry(projectPath, tempRoot))?.thumbnailPath).toBe(
manager.getProjectThumbnailPath(projectPath),
);
expect(generate).toHaveBeenCalledWith(source);
await manager.buildProjectLibraryEntry(projectPath, tempRoot);
expect(generate).toHaveBeenCalledTimes(1);
@@ -283,4 +287,81 @@ describe("local media path policy", () => {
expect(result.success).toBe(true);
await expect(resolveApprovedLocalMediaPath(audioPath)).resolves.toBe(resolvedAudioPath);
});
it("returns a library entry while a thumbnail decode is still pending", async () => {
let release!: (data: Buffer) => void;
const pending = new Promise<Buffer>((resolve) => {
release = resolve;
});
const generate = vi.fn(() => pending);
vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate }));
const manager = await import("./manager");
const source = path.join(tempRoot, "slow.mp4");
const projectPath = path.join(tempRoot, "slow.recordly");
await fs.writeFile(source, "video");
await fs.writeFile(
projectPath,
JSON.stringify({ version: 2, videoPath: source, editor: {} }),
);
const ready = vi.fn();
const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot, ready);
expect(entry?.path).toBe(projectPath);
expect(entry?.thumbnailPath).toBeNull();
const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
release(Buffer.from("fixture thumbnail"));
await task;
expect(generate).toHaveBeenCalledTimes(1);
expect(ready).toHaveBeenCalledWith(expect.objectContaining({ path: projectPath }));
});
it("replaces a thumbnail symlink without overwriting its target", async () => {
vi.doMock("./firstFrameThumbnail", () => ({
createProjectFirstFrameThumbnail: vi.fn().mockResolvedValue(Buffer.from("new poster")),
}));
const manager = await import("./manager");
const source = path.join(tempRoot, "source.mp4");
const projectPath = path.join(tempRoot, "linked.recordly");
const target = path.join(tempRoot, "private.txt");
await fs.writeFile(source, "video");
await fs.writeFile(
projectPath,
JSON.stringify({ version: 2, videoPath: source, editor: {} }),
);
await fs.writeFile(target, "must remain intact");
const thumbnail = manager.getProjectThumbnailPath(projectPath);
try {
await fs.symlink(target, thumbnail);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EPERM") return;
throw error;
}
await manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
expect(await fs.readFile(target, "utf8")).toBe("must remain intact");
expect((await fs.lstat(thumbnail)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(thumbnail, "utf8")).toBe("new poster");
});
it("discards a thumbnail when the project changes during decoding", async () => {
let release!: (data: Buffer) => void;
const pending = new Promise<Buffer>((resolve) => {
release = resolve;
});
const generate = vi.fn(() => pending);
vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate }));
const manager = await import("./manager");
const source = path.join(tempRoot, "source.mp4");
const projectPath = path.join(tempRoot, "edited.recordly");
await fs.writeFile(source, "video");
await fs.writeFile(
projectPath,
JSON.stringify({ version: 2, videoPath: source, editor: {} }),
);
const task = manager.refreshProjectThumbnail(projectPath, await fs.stat(projectPath));
await vi.waitFor(() => expect(generate).toHaveBeenCalled());
await fs.appendFile(projectPath, " ");
release(Buffer.from("obsolete poster"));
expect(await task).toBeNull();
await expect(fs.stat(manager.getProjectThumbnailPath(projectPath))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
+61 -21
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { createProjectFirstFrameThumbnail } from "./firstFrameThumbnail";
import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
import type { ProjectPreviewData } from "../../../src/types/projectPreview";
import { hasFreshProjectThumbnail } from "./thumbnailFreshness";
import { existsSync, constants as fsConstants, realpathSync } from "node:fs";
import { type Stats, existsSync, constants as fsConstants, realpathSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
@@ -323,9 +324,58 @@ export async function rememberRecentProject(projectPath: string) {
await saveRecentProjectPaths([projectPath, ...existingPaths]);
}
export type ProjectThumbnailReady = { path: string; thumbnailPath: string; updatedAt: number };
const thumbnailJobs = new Map<string, Promise<ProjectThumbnailReady | null>>();
function sameProjectRevision(left: Stats, right: Stats) {
return (
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs &&
left.size === right.size &&
left.ino === right.ino &&
left.dev === right.dev
);
}
export function refreshProjectThumbnail(projectPath: string, revision: Stats) {
const pending = thumbnailJobs.get(projectPath);
if (pending) return pending;
const task = (async (): Promise<ProjectThumbnailReady | null> => {
const thumbnailPath = getProjectThumbnailPath(projectPath);
let temporary: string | undefined;
try {
if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
const project = parseJsonWithByteOrderMark(await fs.readFile(projectPath, "utf-8"));
if (!isLoadableProjectData(project)) return null;
const media = await resolveProjectMediaSources(project);
if (!media.success) return null;
const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath);
if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
if (!(await hasFreshProjectThumbnail(thumbnailPath, revision.mtimeMs))) {
temporary = `${thumbnailPath}.${randomUUID()}.tmp`;
await fs.writeFile(temporary, thumbnail, { flag: "wx" });
// Stamp the source revision, rather than generation time, so a later edit invalidates it.
await fs.utimes(temporary, new Date(), Math.ceil(revision.mtimeMs) / 1000);
if (!sameProjectRevision(revision, await fs.stat(projectPath))) return null;
// Rename replaces a sidecar symlink itself; it never writes through its target.
await fs.rename(temporary, thumbnailPath);
}
return { path: projectPath, thumbnailPath, updatedAt: revision.mtimeMs };
} catch {
// Offline, corrupt or deleted media must not hold up the library.
return null;
} finally {
if (temporary) await fs.rm(temporary, { force: true }).catch(() => undefined);
}
})().finally(() => thumbnailJobs.delete(projectPath));
thumbnailJobs.set(projectPath, task);
return task;
}
export async function buildProjectLibraryEntry(
projectPath: string,
projectsDir: string,
onThumbnailReady?: (ready: ProjectThumbnailReady) => void,
): Promise<ProjectLibraryEntry | null> {
try {
const normalizedPath = normalizePath(projectPath);
@@ -339,25 +389,13 @@ export async function buildProjectLibraryEntry(
}
const thumbnailPath = getProjectThumbnailPath(normalizedPath);
let thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs);
const thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs);
if (!thumbnailExists) {
try {
const project = parseJsonWithByteOrderMark(
await fs.readFile(normalizedPath, "utf-8"),
);
if (isLoadableProjectData(project)) {
const media = await resolveProjectMediaSources(project);
if (media.success) {
const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath);
if (!(await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs))) {
await fs.writeFile(thumbnailPath, thumbnail);
}
thumbnailExists = true;
}
}
} catch {
// Missing/offline source media must not hide a project from the library.
}
void refreshProjectThumbnail(normalizedPath, stats)
.then((ready) => {
if (ready) onThumbnailReady?.(ready);
})
.catch(() => undefined);
}
return {
@@ -384,7 +422,9 @@ export async function buildProjectLibraryEntry(
}
}
export async function listProjectLibraryEntries() {
export async function listProjectLibraryEntries(
onThumbnailReady?: (ready: ProjectThumbnailReady) => void,
) {
const projectsDir = await getProjectsDir();
const projectPaths: string[] = [];
@@ -409,7 +449,7 @@ export async function listProjectLibraryEntries() {
const entries = (
await Promise.all(
candidatePaths.map((candidatePath) =>
buildProjectLibraryEntry(candidatePath, projectsDir),
buildProjectLibraryEntry(candidatePath, projectsDir, onThumbnailReady),
),
)
)
+5 -2
View File
@@ -748,9 +748,12 @@ export function registerProjectHandlers() {
}
});
ipcMain.handle("list-project-files", async () => {
ipcMain.handle("list-project-files", async (event) => {
try {
const library = await listProjectLibraryEntries();
const library = await listProjectLibraryEntries((ready) => {
if (!event.sender.isDestroyed())
event.sender.send("project-library-thumbnail-ready", ready);
});
return {
success: true,
projectsDir: library.projectsDir,
+36 -22
View File
@@ -1,3 +1,4 @@
import { createSaveBeforeCloseController } from "./saveBeforeClose";
import { clearRecordingTrashUndo } from "./ipc/recording/library";
import fs from "node:fs/promises";
import path from "node:path";
@@ -6,6 +7,7 @@ import {
app,
BrowserWindow,
desktopCapturer,
dialog,
webContents as electronWebContents,
ipcMain,
Menu,
@@ -813,7 +815,39 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
});
let savingBeforeClose = false;
const closeAfterSave = () => {
if (process.platform === "win32" && !isAppQuitting) closeEditorWindowToHud(editorWindow);
else closeEditorWindowBypassingUnsavedPrompt(editorWindow);
};
let closeSaveRequestId = 0;
const closeSave = createSaveBeforeCloseController({
requestSave: () =>
editorWindow.webContents.send("request-save-before-close", ++closeSaveRequestId),
subscribe: (done) => {
const saved = (event: Electron.IpcMainEvent, success: unknown, requestId: unknown) => {
if (event.sender === editorWindow.webContents && requestId === closeSaveRequestId)
done(success === true);
};
ipcMain.on("save-before-close-done", saved);
return () => ipcMain.removeListener("save-before-close-done", saved);
},
offerDiscard: async () => {
const result = await dialog.showMessageBox(editorWindow, {
type: "warning",
buttons: ["Discard & Close", "Cancel"],
defaultId: 1,
cancelId: 1,
title: "Project could not be saved",
message: "Saving failed or did not respond. Discard unsaved changes and close?",
});
return result.response === 0;
},
close: closeAfterSave,
cancel: () => {
isAppQuitting = false;
},
});
editorWindow.once("closed", closeSave.dispose);
editorWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) {
if (process.platform === "win32" && !isForceClosing && !isAppQuitting) {
@@ -822,28 +856,8 @@ function createEditorWindowWrapper() {
}
return;
}
event.preventDefault();
if (editorWindow.webContents.isDestroyed() || savingBeforeClose) return;
savingBeforeClose = true;
const saved = (sender: Electron.IpcMainEvent, success: boolean) => {
if (sender.sender !== editorWindow.webContents) return;
ipcMain.removeListener("save-before-close-done", saved);
editorWindow.removeListener("closed", cancelPendingSave);
savingBeforeClose = false;
if (!success) {
isAppQuitting = false;
return;
}
if (process.platform === "win32" && !isAppQuitting)
closeEditorWindowToHud(editorWindow);
else closeEditorWindowBypassingUnsavedPrompt(editorWindow);
};
const cancelPendingSave = () => ipcMain.removeListener("save-before-close-done", saved);
ipcMain.on("save-before-close-done", saved);
editorWindow.once("closed", cancelPendingSave);
editorWindow.webContents.send("request-save-before-close");
closeSave.start();
});
return editorWindow;
+12 -2
View File
@@ -862,6 +862,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
getProjectsDirectory: () => {
return ipcRenderer.invoke("get-projects-directory");
},
onProjectThumbnailReady: (
callback: (ready: { path: string; thumbnailPath: string; updatedAt: number }) => void,
) => {
const listener = (
_event: Electron.IpcRendererEvent,
ready: { path: string; thumbnailPath: string; updatedAt: number },
) => callback(ready);
ipcRenderer.on("project-library-thumbnail-ready", listener);
return () => ipcRenderer.removeListener("project-library-thumbnail-ready", listener);
},
listProjectFiles: () => {
return ipcRenderer.invoke("list-project-files");
},
@@ -1059,14 +1069,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean>) => {
const listener = async () => {
const listener = async (_event: Electron.IpcRendererEvent, requestId: unknown) => {
let saved = false;
try {
saved = await callback();
} catch {
saved = false;
}
ipcRenderer.send("save-before-close-done", saved);
ipcRenderer.send("save-before-close-done", saved, requestId);
};
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
+71
View File
@@ -0,0 +1,71 @@
import { afterEach, expect, it, vi } from "vitest";
import { createSaveBeforeCloseController } from "./saveBeforeClose";
afterEach(() => vi.useRealTimers());
function fixture(discard = false) {
let reply!: (saved: boolean) => void;
const unsubscribe = vi.fn();
const options = {
requestSave: vi.fn(),
subscribe: vi.fn((done: (saved: boolean) => void) => {
reply = done;
return unsubscribe;
}),
offerDiscard: vi.fn().mockResolvedValue(discard),
close: vi.fn(),
cancel: vi.fn(),
timeoutMs: 100,
};
return {
options,
unsubscribe,
reply: (saved: boolean) => reply(saved),
controller: createSaveBeforeCloseController(options),
};
}
it("closes once after saving and removes the listener", () => {
const f = fixture();
f.controller.start();
f.controller.start();
f.reply(true);
f.reply(true);
expect(f.options.requestSave).toHaveBeenCalledTimes(1);
expect(f.options.close).toHaveBeenCalledTimes(1);
expect(f.unsubscribe).toHaveBeenCalledTimes(1);
expect(f.options.offerDiscard).not.toHaveBeenCalled();
});
it.each([true, false])("offers discard/cancel after failed save (discard=%s)", async (discard) => {
const f = fixture(discard);
f.controller.start();
f.reply(false);
await vi.waitFor(() =>
expect(discard ? f.options.close : f.options.cancel).toHaveBeenCalledTimes(1),
);
expect(f.unsubscribe).toHaveBeenCalledTimes(1);
if (!discard) {
f.controller.start();
expect(f.options.requestSave).toHaveBeenCalledTimes(2);
}
});
it("recovers a hung renderer on timeout and ignores late replies", async () => {
vi.useFakeTimers();
const f = fixture();
f.controller.start();
await vi.advanceTimersByTimeAsync(100);
f.reply(true);
expect(f.options.offerDiscard).toHaveBeenCalledTimes(1);
expect(f.options.cancel).toHaveBeenCalledTimes(1);
expect(f.options.close).not.toHaveBeenCalled();
f.controller.start();
expect(f.options.requestSave).toHaveBeenCalledTimes(2);
});
it("removes a pending timeout/listener when the window is destroyed", async () => {
vi.useFakeTimers();
const f = fixture();
f.controller.start();
f.controller.dispose();
await vi.advanceTimersByTimeAsync(100);
f.reply(true);
expect(f.unsubscribe).toHaveBeenCalledTimes(1);
expect(f.options.close).not.toHaveBeenCalled();
expect(f.options.offerDiscard).not.toHaveBeenCalled();
});
+65
View File
@@ -0,0 +1,65 @@
type Options = {
requestSave: () => void;
subscribe: (done: (saved: boolean) => void) => () => void;
offerDiscard: () => Promise<boolean>;
close: () => void;
cancel: () => void;
timeoutMs?: number;
};
/** Saves before closing, with a cancellable recovery path for failed or missing replies. */
export function createSaveBeforeCloseController(options: Options) {
let pending = false;
let recovering = false;
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: (() => void) | undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = undefined;
unsubscribe?.();
unsubscribe = undefined;
};
const done = (saved: boolean) => {
if (!pending || recovering || disposed) return;
cleanup();
if (saved) {
pending = false;
options.close();
return;
}
recovering = true;
void Promise.resolve()
.then(options.offerDiscard)
.then((discard) => {
if (disposed) return;
if (discard) options.close();
else options.cancel();
})
.catch(() => {
if (!disposed) options.cancel();
})
.finally(() => {
pending = false;
recovering = false;
});
};
return {
start() {
if (pending || disposed) return;
pending = true;
try {
unsubscribe = options.subscribe(done);
timer = setTimeout(() => done(false), options.timeoutMs ?? 30_000);
options.requestSave();
} catch {
done(false);
}
},
dispose() {
disposed = true;
pending = false;
cleanup();
},
};
}
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */
import { type RefObject, useCallback } from "react";
import { type RefObject, useCallback, useEffect, useRef } from "react";
import { PROJECT_THUMBNAIL_WIDTH, PROJECT_THUMBNAIL_HEIGHT } from "@/lib/projectThumbnail";
import { FrameRenderer } from "@/lib/exporter/frameRenderer";
import { toFileUrl } from "../projectPersistence";
@@ -77,6 +77,9 @@ export function useProjectLibraryController({
speedRegions,
zoomRegions,
} = timeline;
const thumbnailUpdates = useRef(
new Map<string, { thumbnailPath: string; updatedAt: number }>(),
);
const refreshProjectLibrary = useCallback(async () => {
setProjectLibraryLoading(true);
try {
@@ -85,7 +88,18 @@ export function useProjectLibraryController({
throw new Error(result.error || "Failed to load project library");
}
setProjectLibraryEntries(result.entries);
setProjectLibraryEntries(
result.entries.map((entry) => {
const ready = thumbnailUpdates.current.get(entry.path);
return ready?.updatedAt === entry.updatedAt
? { ...entry, thumbnailPath: ready.thumbnailPath }
: entry;
}),
);
const paths = new Set(result.entries.map((entry) => entry.path));
for (const key of thumbnailUpdates.current.keys()) {
if (!paths.has(key)) thumbnailUpdates.current.delete(key);
}
} catch (projectLibraryError) {
console.warn("Unable to refresh project library:", projectLibraryError);
} finally {
@@ -93,6 +107,21 @@ export function useProjectLibraryController({
}
}, []);
useEffect(
() =>
window.electronAPI.onProjectThumbnailReady?.((ready) => {
thumbnailUpdates.current.set(ready.path, ready);
setProjectLibraryEntries((entries) =>
entries.map((entry) =>
entry.path === ready.path && entry.updatedAt === ready.updatedAt
? { ...entry, thumbnailPath: ready.thumbnailPath }
: entry,
),
);
}),
[setProjectLibraryEntries],
);
const captureProjectThumbnail = useCallback(async () => {
const previewHandle = videoPlaybackRef.current;
const previewVideo = previewHandle?.video ?? null;
+1
View File
@@ -93,6 +93,7 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
setCurrentRecordingSession: success,
setHasUnsavedChanges: success,
onAuthCallbackUrl: subscribe,
onProjectThumbnailReady: subscribe,
getPendingAuthCallbackUrl: async () => null,
ackAuthCallbackUrl: success,
onMenuSaveProject: subscribe,
+9 -2
View File
@@ -4,7 +4,9 @@ import { hasFreshProjectThumbnail } from "../../electron/ipc/project/thumbnailFr
import { expect, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test("Home opens with a wireframe while saving and returns to the main library", async ({ page }) => {
test("Home opens with a wireframe while saving and returns to the main library", async ({
page,
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1");
@@ -30,9 +32,14 @@ test("Home opens with a wireframe while saving and returns to the main library",
await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(home).not.toBeVisible();
await page.evaluate(() => {
delete document.documentElement.dataset.savePending;
});
await page.getByRole("button", { name: "Home", exact: true }).click();
await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible();
await expect(home.getByLabel("Loading projects")).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-save-pending", "true");
await page.evaluate(() => window.dispatchEvent(new Event("test-release-save")));
await expect(home.getByLabel("Loading projects")).toHaveCount(0);
});
test("home dashboard searches, sorts, opens projects and returns to the editor", async ({