From d525d60fc04ad24ae1966295ae6abcefc2212ae4 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 19 Oct 2026 17:50:01 +0800 Subject: [PATCH] fix(poster): bound preview loading and allow failed thumbnails to retry --- .../components/poster/PosterTemplatePanel.tsx | 135 +++++++++++------- apps/web/src/lib/poster-design/fonts.ts | 22 +-- scripts/verify-poster-previews.mjs | 79 ++++++++++ tests/browser/poster-previews.browser.tsx | 36 +++++ tests/browser/poster-previews.html | 9 ++ 5 files changed, 222 insertions(+), 59 deletions(-) create mode 100644 scripts/verify-poster-previews.mjs create mode 100644 tests/browser/poster-previews.browser.tsx create mode 100644 tests/browser/poster-previews.html diff --git a/apps/web/src/components/poster/PosterTemplatePanel.tsx b/apps/web/src/components/poster/PosterTemplatePanel.tsx index fba7afb4..dff2d525 100644 --- a/apps/web/src/components/poster/PosterTemplatePanel.tsx +++ b/apps/web/src/components/poster/PosterTemplatePanel.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useRef, useState } from "react"; -import { Search } from "lucide-react"; +import { RefreshCw, Search } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { @@ -29,7 +29,9 @@ const Preview = memo(function Preview({ }) { const host = useRef(null); const [visible, setVisible] = useState(false), - [url, setUrl] = useState(); + [url, setUrl] = useState(), + [status, setStatus] = useState<"loading" | "ready" | "error">("loading"), + [attempt, setAttempt] = useState(0); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { @@ -45,59 +47,92 @@ const Preview = memo(function Preview({ }, []); useEffect(() => { if (!visible) return; - let cancelled = false, - preview: string | undefined; - void buildPosterDesign({ - id: template.id, - title: zh ? "把想法\n变成作品" : "MAKE IDEAS\nWORTH SHARING", - subtitle: zh ? "一个好想法,值得被看见。" : "Ideas deserve to be seen.", - badge: zh ? "创作笔记" : "CREATIVE NOTES", - width, - height, - zh, - }) - .then(renderPosterThumbnail) - .then((next) => { - preview = next; - if (cancelled) URL.revokeObjectURL(next); - else setUrl(next); - }) - .catch(() => { - if (!cancelled) setUrl(undefined); - }); + let cancelled = false, expired = false, preview: string | undefined; + setUrl(undefined); + setStatus("loading"); + const timer = setTimeout(() => { + expired = true; + setStatus("error"); + console.warn("Poster template preview timed out", template.id); + }, 15_000); + void (async () => { + try { + const document = await buildPosterDesign({ + id: template.id, + title: zh ? "把想法\n变成作品" : "MAKE IDEAS\nWORTH SHARING", + subtitle: zh ? "一个好想法,值得被看见。" : "Ideas deserve to be seen.", + badge: zh ? "创作笔记" : "CREATIVE NOTES", + width, + height, + zh, + }); + if (cancelled || expired) return; + const next = await renderPosterThumbnail(document); + if (cancelled || expired) URL.revokeObjectURL(next); + else { + preview = next; + setUrl(next); + setStatus("ready"); + } + } catch (error) { + if (!cancelled && !expired) { + setStatus("error"); + console.warn("Poster template preview failed", template.id, error); + } + } finally { + clearTimeout(timer); + } + })(); return () => { cancelled = true; + clearTimeout(timer); if (preview) URL.revokeObjectURL(preview); }; - }, [visible, width, height, zh, template.id]); + }, [visible, width, height, zh, template.id, attempt]); return ( - +
+ + {status === "error" && ( + + )} +
); }); export function PosterTemplatePanel({ diff --git a/apps/web/src/lib/poster-design/fonts.ts b/apps/web/src/lib/poster-design/fonts.ts index 2a16498d..7d504609 100644 --- a/apps/web/src/lib/poster-design/fonts.ts +++ b/apps/web/src/lib/poster-design/fonts.ts @@ -4,15 +4,19 @@ import { cache } from "fabric"; import { POSTER_FONTS } from "@edgeever/shared"; export { POSTER_FONTS }; export async function loadPosterFont(family: string, text = "", { deadline = true }: { deadline?: boolean } = {}) { - await loadOfflineFonts(text || "EdgeEver 中文"); - const registrationFamily = family.split(",")[0].trim().replace(/^["']|["']$/g, ""); - const id = findCatalogFont(registrationFamily); - if (!id) return; - const pending = loadCatalogFont(id, text || `${FONT_CATALOG[id].zh} ${FONT_CATALOG[id].en}`, { family: registrationFamily }); - await (deadline ? withFontDeadline(pending) : pending); - cache.clearFontCache(family); - // Fabric keys glyph metrics by the complete runtime fallback stack. - cache.clearFontCache(`${registrationFamily}, "EdgeEver Offline Sans", sans-serif`); + // The deadline must include bundled fallback preparation: a damaged local + // asset can fall through to slow remote mirrors before catalog loading starts. + const prepare = async () => { + await loadOfflineFonts(text || "EdgeEver 中文"); + const registrationFamily = family.split(",")[0].trim().replace(/^["']|["']$/g, ""); + const id = findCatalogFont(registrationFamily); + if (!id) return; + await loadCatalogFont(id, text || `${FONT_CATALOG[id].zh} ${FONT_CATALOG[id].en}`, { family: registrationFamily }); + cache.clearFontCache(family); + // Fabric keys glyph metrics by the complete runtime fallback stack. + cache.clearFontCache(`${registrationFamily}, "EdgeEver Offline Sans", sans-serif`); + }; + await (deadline ? withFontDeadline(prepare()) : prepare()); } export const loadDesignFonts = (families: string[], text: string) => Promise.all([...new Set(families)].map(family => loadPosterFont(family, text).catch(() => { /* Templates still render with the offline default. */ }))); diff --git a/scripts/verify-poster-previews.mjs b/scripts/verify-poster-previews.mjs new file mode 100644 index 00000000..4bf17da9 --- /dev/null +++ b/scripts/verify-poster-previews.mjs @@ -0,0 +1,79 @@ +// Run against `bun run dev`. Uses installed Google Chrome and isolated fixture +// pages, without opening a user profile or touching workspace notes. +import assert from "node:assert/strict"; +import { chromium } from "@playwright/test"; +const origin = process.env.EDGE_EVER_BROWSER_TEST_URL ?? "http://127.0.0.1:5173"; +const fixture = `${origin}/@fs${process.cwd()}/tests/browser/poster-previews.html`; +const browser = await chromium.launch({ channel: "chrome", headless: true }); +const open = async (setup) => { + const page = await browser.newPage(); + if (setup) await page.addInitScript(setup); + await page.goto(fixture); + await page.waitForFunction(() => window.posterPreviewTest); + return page; +}; +const retryFirst = page => page.locator("[data-poster-template]").first().locator("..").getByRole("button", { name: "重试预览", exact: true }); +const firstReady = page => page.waitForFunction(() => { + const cards = [...document.querySelectorAll("[data-poster-template]")].slice(0, 6); + return cards.length === 6 && cards.every(card => card.querySelector("img")?.naturalWidth > 0); +}, null, { timeout: 14_000 }); +try { + const page = await open(); + const results = await page.evaluate(() => Promise.all(window.posterPreviewTest.templateIds.map(window.posterPreviewTest.checkPoster))); + assert.deepEqual(results.filter(result => !result.ok), [], "All template thumbnails must render"); + await firstReady(page); + assert.equal(await page.locator('[role="tooltip"]').count(), 0); + console.log(`PASS: all ${results.length} templates render; visible cards show previews`); + + await page.screenshot({ path: "/tmp/edgeever-poster-previews-fixed.png" }); + await page.close(); + + // Use fresh pages so a previous render cannot satisfy an error assertion. + const failed = await open(() => { + window.originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function(callback) { callback(null); }; + }); + await retryFirst(failed).waitFor({ timeout: 14_000 }); + assert.equal(await failed.locator('[data-poster-template]').first().getAttribute("aria-busy"), "false"); + await failed.evaluate(() => { HTMLCanvasElement.prototype.toBlob = window.originalToBlob; }); + await retryFirst(failed).focus(); + await failed.keyboard.press("Enter"); + await failed.locator('[data-poster-template]').first().locator('img').waitFor(); + console.log("PASS: canvas export failure ends loading; retry recovers"); + await failed.close(); + + // A canvas callback that never arrives must also leave loading within 15s. + const stalled = await open(() => { + window.originalToBlob = HTMLCanvasElement.prototype.toBlob; + window.lateExports = []; + HTMLCanvasElement.prototype.toBlob = function(callback) { window.lateExports.push([this, callback]); }; + }); + await retryFirst(stalled).waitFor({ timeout: 18_000 }); + await stalled.evaluate(() => { HTMLCanvasElement.prototype.toBlob = window.originalToBlob; }); + await retryFirst(stalled).click(); + await stalled.locator('[data-poster-template]').first().locator('img').waitFor(); + // Deliver the previous attempt's result late: it must be released, not replace + // the successful retry or switch the card back to a failed/loading state. + await stalled.evaluate(async () => { + const before = document.querySelector('[data-poster-template] img')?.getAttribute('src'); + await Promise.all(window.lateExports.map(([canvas, callback]) => new Promise(resolve => { + window.originalToBlob.call(canvas, blob => { callback(blob); resolve(); }); + }))); + await new Promise(resolve => setTimeout(resolve, 100)); + if (document.querySelector('[data-poster-template] img')?.getAttribute('src') !== before) throw new Error('Late preview replaced retry'); + }); + console.log("PASS: stalled export times out; retry succeeds; late results are ignored"); + await stalled.close(); + + const slow = await browser.newPage(); + // Leave both local font assets and remote font requests unanswered. This + // reproduced blank previews before the whole font preparation was bounded. + await slow.route("**/*", route => ["xhr", "font"].includes(route.request().resourceType()) ? undefined : route.continue()); + await slow.goto(fixture); + await slow.waitForFunction(() => window.posterPreviewTest); + await firstReady(slow); + console.log("PASS: stalled font requests still produce fallback thumbnails"); + await slow.close(); +} finally { + await browser.close(); +} diff --git a/tests/browser/poster-previews.browser.tsx b/tests/browser/poster-previews.browser.tsx new file mode 100644 index 00000000..4120ad4b --- /dev/null +++ b/tests/browser/poster-previews.browser.tsx @@ -0,0 +1,36 @@ +// Local Chrome fixture: real template builders, fonts, Fabric canvas and React +// panel; never reads or changes notes. Driven by verify-poster-previews.mjs. +import React from "react"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import i18next from "i18next"; +import { enUS } from "../../packages/shared/src/i18n/en-US"; +import { PosterTemplatePanel } from "../../apps/web/src/components/poster/PosterTemplatePanel"; +import { buildPosterDesign, DESIGN_TEMPLATES } from "../../apps/web/src/lib/poster-design/templates"; +import { renderPosterThumbnail } from "../../apps/web/src/lib/poster-canvas"; +import { installEditorBodyFontFaces } from "../../apps/web/src/lib/editor-body-font-faces"; +import "../../apps/web/src/styles/globals.css"; + +installEditorBodyFontFaces(); +const i18n = i18next.createInstance(); +await i18n.init({ lng: "en", resources: { en: { translation: enUS } }, initAsync: false }); +const root = createRoot(document.getElementById("fixture")!); +const mount = (width = 1080, zh = true) => root.render( + +
+ {}} /> +
+
, +); +const checkPoster = async (id: string) => { + try { + const doc = await buildPosterDesign({ id, title: "把想法\n变成作品", subtitle: "一个好想法,值得被看见。", badge: "创作笔记", width: 1080, height: 1440, zh: true }); + const url = await renderPosterThumbnail(doc); + URL.revokeObjectURL(url); + return { id, ok: true }; + } catch (error) { + return { id, ok: false, error: String(error) }; + } +}; +Object.assign(window, { posterPreviewTest: { mount, checkPoster, templateIds: DESIGN_TEMPLATES.map(template => template.id) } }); +mount(); diff --git a/tests/browser/poster-previews.html b/tests/browser/poster-previews.html new file mode 100644 index 00000000..2d788c45 --- /dev/null +++ b/tests/browser/poster-previews.html @@ -0,0 +1,9 @@ + +Poster preview regression checks +