mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
Generate safe thumbnails in background and recover failed close saves
This commit is contained in:
1 parent
1910427cd1
commit
cdd64d5fe1
11 files changed
+376
-52
No files matched your search
Vendored
+3
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in new issue
Block a user