diff --git a/electron/ipc/project/thumbnailFreshness.test.ts b/electron/ipc/project/thumbnailFreshness.test.ts index 69355243..c70c576b 100644 --- a/electron/ipc/project/thumbnailFreshness.test.ts +++ b/electron/ipc/project/thumbnailFreshness.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { expect, it } from "vitest"; import { hasFreshProjectThumbnail } from "./thumbnailFreshness"; -it("rejects legacy, stale, missing and broken previews while accepting a fresh high-resolution PNG", async () => { +it("rejects legacy, stale, missing and broken previews while accepting a fresh current-size or larger PNG", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preview-")); const file = path.join(dir, "preview.png"); try { @@ -15,6 +15,10 @@ it("rejects legacy, stale, missing and broken previews while accepting a fresh h header.writeUInt32BE(180, 20); await fs.writeFile(file, header); expect(await hasFreshProjectThumbnail(file, 0)).toBe(false); + header.writeUInt32BE(640, 16); + header.writeUInt32BE(480, 20); + await fs.writeFile(file, header); + expect(await hasFreshProjectThumbnail(file, 0)).toBe(true); header.writeUInt32BE(1600, 16); header.writeUInt32BE(1200, 20); await fs.writeFile(file, header); diff --git a/electron/ipc/project/thumbnailFreshness.ts b/electron/ipc/project/thumbnailFreshness.ts index 00db1986..89f33cf6 100644 --- a/electron/ipc/project/thumbnailFreshness.ts +++ b/electron/ipc/project/thumbnailFreshness.ts @@ -1,7 +1,11 @@ import fs from "node:fs/promises"; +import { + PROJECT_THUMBNAIL_WIDTH, + PROJECT_THUMBNAIL_HEIGHT, +} from "../../../src/lib/projectThumbnail"; // Older releases wrote 320px previews. Hide those and previews predating edits; -// the editor replaces them with a full-resolution render when returning home. +// the editor replaces them with a current-size render when returning home. export async function hasFreshProjectThumbnail(thumbnailPath: string, projectModifiedAt: number) { let file: Awaited> | undefined; try { @@ -13,8 +17,8 @@ export async function hasFreshProjectThumbnail(thumbnailPath: string, projectMod return ( bytesRead === 24 && header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && - header.readUInt32BE(16) >= 1600 && - header.readUInt32BE(20) >= 1200 + header.readUInt32BE(16) >= PROJECT_THUMBNAIL_WIDTH && + header.readUInt32BE(20) >= PROJECT_THUMBNAIL_HEIGHT ); } catch { return false; diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index 199b15d7..f789678e 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -1,5 +1,6 @@ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */ 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"; import type { useAppearanceState } from "../state/useAppearanceState"; @@ -112,8 +113,8 @@ export function useProjectLibraryController({ } const canvas = document.createElement("canvas"); - const targetWidth = 640; - const targetHeight = 480; + const targetWidth = PROJECT_THUMBNAIL_WIDTH; + const targetHeight = PROJECT_THUMBNAIL_HEIGHT; canvas.width = targetWidth; canvas.height = targetHeight; diff --git a/src/lib/projectThumbnail.ts b/src/lib/projectThumbnail.ts new file mode 100644 index 00000000..1882c214 --- /dev/null +++ b/src/lib/projectThumbnail.ts @@ -0,0 +1,3 @@ +/** Shared by thumbnail generation and the persisted-preview freshness check. */ +export const PROJECT_THUMBNAIL_WIDTH = 640; +export const PROJECT_THUMBNAIL_HEIGHT = 480; diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 2e517e00..fc51e905 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { hasFreshProjectThumbnail } from "../../electron/ipc/project/thumbnailFreshness"; import { expect, test } from "@playwright/test"; import { installDesktopBridge } from "./bridge"; @@ -140,8 +143,30 @@ test("home dashboard explains an empty library", async ({ page }) => { test("autosave creates one untitled project, stays idle without edits, and refreshes its preview on exit without a saved toast", async ({ page, -}) => { +}, testInfo) => { await installDesktopBridge(page); + await page.addInitScript(() => { + const save = window.electronAPI.saveProjectFile; + window.electronAPI.saveProjectFile = async (...args) => { + const result = await save(...args); + if (args[3]) document.documentElement.dataset.savedThumbnail = args[3]; + return result; + }; + window.electronAPI.listProjectFiles = async () => ({ + success: true, + projects: [], + entries: [ + { + path: "/projects/preview.recordly", + name: "Generated preview", + updatedAt: 1, + thumbnailPath: document.documentElement.dataset.savedThumbnail ?? null, + isCurrent: true, + isInProjectsDirectory: true, + }, + ], + }); + }); await page.goto("/?windowType=editor"); await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1"); await expect(page.getByRole("button", { name: "Rename project" })).toContainText( @@ -158,6 +183,19 @@ test("autosave creates one untitled project, stays idle without edits, and refre "data-saved-thumbnail", /^data:image\/png;base64,/, ); + const thumbnail = await page.locator("html").getAttribute("data-saved-thumbnail"); + const file = testInfo.outputPath("generated-preview.png"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, Buffer.from(thumbnail!.split(",")[1], "base64")); + // Exercise the actual main-process acceptance check against renderer output. + expect(await hasFreshProjectThumbnail(file, 0)).toBe(true); + const image = page + .getByRole("button", { name: "Generated preview", exact: true }) + .locator("img"); + await expect(image).toBeVisible(); + await expect + .poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)) + .toBe(640); await expect(page.getByText(/Project saved/)).toHaveCount(0); });