272 lines
15 KiB
JavaScript
272 lines
15 KiB
JavaScript
// Used by Vite on startup/build and template saves. Fresh assets need no browser.
|
|
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile, mkdir, readdir, mkdtemp, rename, rm, open, realpath } from "node:fs/promises";
|
|
import { createServer as createHttpServer } from "node:http";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import path from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
export const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
export const sources = [
|
|
"apps/web/src/lib/poster-design", "apps/web/src/lib/poster-canvas.ts",
|
|
"apps/web/package.json", "packages/shared/src/poster.ts",
|
|
"packages/shared/src/poster-design-catalog.ts", "packages/shared/src/poster-fonts.ts",
|
|
"packages/shared/src/font-catalog.ts", "packages/shared/src/font-catalog-data.ts",
|
|
"packages/shared/src/font-loader.ts", "packages/shared/src/font-defaults.ts",
|
|
"packages/shared/src/font-resources.ts", "packages/shared/src/font-assets-registry.ts",
|
|
"packages/shared/src/note-image-asset-urls.ts",
|
|
"scripts/prepare-poster-thumbnails.mjs",
|
|
"tests/browser/poster-thumbnail-generator.html", "tests/browser/poster-thumbnail-generator.ts",
|
|
];
|
|
const digest = data => createHash("sha256").update(data).digest("hex");
|
|
const languages = ["zh", "en"];
|
|
const assetName = /^[a-z0-9-]+\.(zh|en)\.webp$/;
|
|
const readJson = file => readFile(file, "utf8").then(JSON.parse).catch(error => {
|
|
if (error.code === "ENOENT") return undefined;
|
|
throw error;
|
|
});
|
|
const sourceFiles = async (root, relative) => {
|
|
const entries = await readdir(path.join(root, relative), { withFileTypes: true }).catch(error => {
|
|
if (error.code === "ENOTDIR") return undefined;
|
|
throw error;
|
|
});
|
|
if (!entries) return [relative];
|
|
return (await Promise.all(entries.filter(entry => !entry.name.startsWith(".")).map(entry => sourceFiles(root, `${relative}/${entry.name}`)))).flat();
|
|
};
|
|
export async function sourceState(root = projectRoot) {
|
|
const files = (await Promise.all(sources.map(relative => sourceFiles(root, relative)))).flat().sort();
|
|
const hashes = await Promise.all(files.map(async file => [file, digest((await readFile(path.join(root, file), "utf8")).replace(/\r\n/g, "\n"))]));
|
|
// Template/layout changes are captured in each prepared document. Changes to
|
|
// the painter or font definitions can affect pixels even with identical JSON.
|
|
const painter = hashes.filter(([file]) => file === "apps/web/src/lib/poster-canvas.ts" || /\/font[^/]*\.ts$|\/poster-fonts\.ts$/.test(file));
|
|
const web = await readJson(path.join(root, "apps/web/package.json"));
|
|
return { sourceHash: digest(JSON.stringify(hashes)), painterHash: digest(JSON.stringify([painter, web.dependencies?.fabric])) };
|
|
}
|
|
async function validAsset(output, asset) {
|
|
if (!asset || !assetName.test(asset.file)) return false;
|
|
return readFile(path.join(output, asset.file)).then(bytes => digest(bytes) === asset.sha256, () => false);
|
|
}
|
|
export async function thumbnailsCurrent(output, state) {
|
|
const manifest = await readJson(path.join(output, "manifest.json"));
|
|
if (manifest?.version !== 2 || manifest.sourceHash !== state.sourceHash || !manifest.templates?.length) return false;
|
|
const valid = await Promise.all(manifest.templates.flatMap(template => languages.map(language => validAsset(output, template[language]))));
|
|
return valid.every(Boolean);
|
|
}
|
|
|
|
async function launchBrowser(root) {
|
|
const { chromium } = await import("@playwright/test");
|
|
const executablePath = process.env.EDGE_EVER_POSTER_CHROME_EXECUTABLE;
|
|
if (executablePath) return chromium.launch({ executablePath, headless: true });
|
|
try { return await chromium.launch({ channel: "chrome", headless: true }); }
|
|
catch { /* Standard desktop/CI Chrome may not be installed. */ }
|
|
try { return await chromium.launch({ headless: true }); }
|
|
catch (error) {
|
|
if (!/executable.*doesn.t exist/i.test(String(error))) throw error;
|
|
console.log("[poster thumbnails] Preparing the build browser");
|
|
const cli = fileURLToPath(import.meta.resolve("@playwright/test/cli"));
|
|
await promisify(execFile)(process.execPath, [cli, "install", "chromium"], { cwd: root, maxBuffer: 4 * 1024 * 1024 });
|
|
return chromium.launch({ headless: true });
|
|
}
|
|
}
|
|
async function cacheGeneratorFonts(root, page) {
|
|
const directory = path.join(root, "node_modules/.cache/edgeever/poster-thumbnail-fonts");
|
|
await mkdir(directory, { recursive: true });
|
|
const cacheable = bytes => bytes.length > 48 && bytes.length <= 24_000_000 &&
|
|
([0x774f4632, 0x00010000, 0x4f54544f].includes(bytes.readUInt32BE(0)) || bytes.toString("utf8").includes("@font-face"));
|
|
await page.route("**/*", async route => {
|
|
const request = route.request();
|
|
if (!request.url().startsWith("https:") || !["xhr", "font"].includes(request.resourceType())) return route.continue();
|
|
const file = path.join(directory, `${digest(request.url())}.json`);
|
|
const respond = (body, contentType) => route.fulfill({ status: 200, body, contentType, headers: { "access-control-allow-origin": "*" } });
|
|
try {
|
|
const cached = await readJson(file).catch(() => undefined);
|
|
if (cached) {
|
|
const body = Buffer.from(cached.base64, "base64");
|
|
if (digest(body) === cached.sha256 && cacheable(body)) return respond(body, cached.contentType);
|
|
}
|
|
const response = await route.fetch({ timeout: 20_000, maxRetries: 1 });
|
|
const body = await response.body();
|
|
const contentType = response.headers()["content-type"] ?? "application/octet-stream";
|
|
if (response.ok() && cacheable(body)) {
|
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
await writeFile(temporary, JSON.stringify({ base64: body.toString("base64"), sha256: digest(body), contentType }));
|
|
await rename(temporary, file);
|
|
}
|
|
return route.fulfill({ response, body });
|
|
} catch {
|
|
await route.abort().catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function openRenderer(root) {
|
|
root = await realpath(root);
|
|
const { build } = await import("vite");
|
|
const bundle = await mkdtemp(path.join(tmpdir(), "edgeever-poster-renderer-"));
|
|
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".woff2": "font/woff2", ".ttf": "font/ttf", ".png": "image/png" };
|
|
const server = createHttpServer(async (request, response) => {
|
|
try {
|
|
const file = path.resolve(bundle, `.${decodeURIComponent(new URL(request.url, "http://localhost").pathname)}`);
|
|
if (!file.startsWith(`${bundle}${path.sep}`)) { response.writeHead(403).end(); return; }
|
|
const bytes = await readFile(file);
|
|
response.writeHead(200, { "Content-Type": types[path.extname(file)] ?? "application/octet-stream" });
|
|
response.end(bytes);
|
|
} catch { response.writeHead(404).end(); }
|
|
});
|
|
const closeServer = async () => {
|
|
if (server.listening) await new Promise((resolve, reject) => {
|
|
server.close(error => error ? reject(error) : resolve());
|
|
server.closeAllConnections?.();
|
|
});
|
|
await rm(bundle, { recursive: true, force: true });
|
|
};
|
|
let browser;
|
|
try {
|
|
// Compile a standalone generator, then serve plain static files. A Vite dev
|
|
// server/optimizer can wait on unfinished module requests when closed after
|
|
// a failed render; no live development server is needed for this job.
|
|
await build({
|
|
configFile: false, root, base: "/", logLevel: "error",
|
|
resolve: { alias: { "@": path.join(root, "apps/web/src") } },
|
|
build: { outDir: bundle, emptyOutDir: true, minify: false, rollupOptions: { input: path.join(root, "tests/browser/poster-thumbnail-generator.html") } },
|
|
});
|
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
browser = await launchBrowser(root);
|
|
const page = await browser.newPage();
|
|
page.setDefaultTimeout(120_000);
|
|
await cacheGeneratorFonts(root, page);
|
|
await page.goto(`http://127.0.0.1:${server.address().port}/tests/browser/poster-thumbnail-generator.html`, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
await page.waitForFunction(() => window.posterThumbnailGenerator, null, { timeout: 30_000 });
|
|
const bounded = async pending => {
|
|
let timer;
|
|
try {
|
|
return await Promise.race([pending, new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error("Poster thumbnail rendering timed out")), 120_000);
|
|
})]);
|
|
} finally { clearTimeout(timer); }
|
|
};
|
|
return {
|
|
list: () => bounded(page.evaluate(() => window.posterThumbnailGenerator.templates)),
|
|
prepare: (id, language) => bounded(page.evaluate(({ id, language }) => window.posterThumbnailGenerator.prepare(id, language === "zh"), { id, language })),
|
|
render: (id, language) => bounded(page.evaluate(({ id, language }) => window.posterThumbnailGenerator.render(id, language === "zh"), { id, language })),
|
|
close: async () => { try { await browser.close(); } finally { await closeServer(); } },
|
|
};
|
|
} catch (error) {
|
|
try { await browser?.close(); } finally { await closeServer(); }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function acquireLock(root) {
|
|
const file = path.join(root, "node_modules/.cache/edgeever/poster-thumbnails.lock");
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
const started = Date.now();
|
|
while (true) {
|
|
try {
|
|
const handle = await open(file, "wx");
|
|
await handle.writeFile(String(process.pid));
|
|
await handle.close();
|
|
return () => rm(file, { force: true });
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") throw error;
|
|
const pid = Number(await readFile(file, "utf8").catch(() => ""));
|
|
if (pid) {
|
|
try { process.kill(pid, 0); }
|
|
catch (error) { if (error.code === "ESRCH") { await rm(file, { force: true }); continue; } }
|
|
}
|
|
if (Date.now() - started > 300_000) throw new Error("Timed out waiting for poster thumbnail generation");
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function ensurePosterThumbnails({ root = projectRoot, output = path.join(root, "apps/web/src/assets/poster-template-previews"), renderer = openRenderer, log = console.log } = {}) {
|
|
let state = await sourceState(root);
|
|
if (await thumbnailsCurrent(output, state)) return { generated: 0, skipped: true };
|
|
const release = await acquireLock(root);
|
|
let runtime, temporary;
|
|
try {
|
|
state = await sourceState(root);
|
|
if (await thumbnailsCurrent(output, state)) return { generated: 0, skipped: true };
|
|
const previous = await readJson(path.join(output, "manifest.json"));
|
|
const oldTemplates = new Map(previous?.templates?.map(template => [template.id, template]));
|
|
log("[poster thumbnails] Checking changed templates");
|
|
runtime = await renderer(root);
|
|
const templates = await runtime.list();
|
|
if (!templates.length || new Set(templates.map(template => template.id)).size !== templates.length) throw new Error("Invalid poster template catalog");
|
|
temporary = await mkdtemp(path.join(tmpdir(), "edgeever-poster-thumbnails-"));
|
|
let generated = 0;
|
|
for (const template of templates) {
|
|
if (!/^[a-z0-9-]+$/.test(template.id)) throw new Error("Invalid poster template ID");
|
|
for (const language of languages) {
|
|
const document = await runtime.prepare(template.id, language);
|
|
const renderHash = digest(`${state.painterHash}\0${document}`);
|
|
const previousAsset = oldTemplates.get(template.id)?.[language];
|
|
if (previousAsset?.renderHash === renderHash && await validAsset(output, previousAsset)) {
|
|
template[language] = previousAsset;
|
|
continue;
|
|
}
|
|
const bytes = Buffer.from(await runtime.render(template.id, language), "base64");
|
|
if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") throw new Error(`Invalid WebP: ${template.id}`);
|
|
const file = `${template.id}.${language}.webp`;
|
|
await writeFile(path.join(temporary, file), bytes);
|
|
template[language] = { file, sha256: digest(bytes), renderHash };
|
|
generated++;
|
|
log(`[poster thumbnails] Generated ${file}`);
|
|
}
|
|
}
|
|
// A save during generation must not mark old code as synchronized. The dev
|
|
// queue retries the latest source; builds fail rather than publish it.
|
|
if ((await sourceState(root)).sourceHash !== state.sourceHash) throw new Error("Poster sources changed during generation; retrying the latest save is required");
|
|
await mkdir(output, { recursive: true });
|
|
for (const file of await readdir(temporary)) {
|
|
const staged = path.join(output, `.${file}.tmp`);
|
|
await writeFile(staged, await readFile(path.join(temporary, file)));
|
|
await rename(staged, path.join(output, file));
|
|
}
|
|
const manifest = { version: 2, ...state, width: 480, height: 640, templates };
|
|
const stagedManifest = path.join(output, ".manifest.json.tmp");
|
|
await writeFile(stagedManifest, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
await rename(stagedManifest, path.join(output, "manifest.json"));
|
|
const keep = new Set(templates.flatMap(template => languages.map(language => template[language].file)));
|
|
for (const file of await readdir(output)) if (assetName.test(file) && !keep.has(file)) await rm(path.join(output, file));
|
|
log(`[poster thumbnails] Updated ${generated} images; reused ${templates.length * 2 - generated}`);
|
|
return { generated, skipped: false };
|
|
} finally {
|
|
try { await runtime?.close(); }
|
|
finally {
|
|
try { if (temporary) await rm(temporary, { recursive: true, force: true }); }
|
|
finally { await release(); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Playwright's process/transport lifecycle is supported by Node. Keep browser
|
|
// generation out of the application's Vite/Bun process, including Docker builds.
|
|
export async function runPosterThumbnailGeneration({ root = projectRoot } = {}) {
|
|
if (await thumbnailsCurrent(path.join(root, "apps/web/src/assets/poster-template-previews"), await sourceState(root))) return { generated: 0, skipped: true };
|
|
const { stdout, stderr } = await promisify(execFile)("node", [fileURLToPath(import.meta.url), "--json", "--root", root], {
|
|
cwd: root, maxBuffer: 8 * 1024 * 1024,
|
|
});
|
|
if (stderr) process.stderr.write(stderr);
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
const rootIndex = process.argv.indexOf("--root");
|
|
const root = rootIndex < 0 ? projectRoot : path.resolve(process.argv[rootIndex + 1]);
|
|
if (process.argv.includes("--check")) {
|
|
if (!await thumbnailsCurrent(path.join(root, "apps/web/src/assets/poster-template-previews"), await sourceState(root))) throw new Error("Poster thumbnails are stale; normal dev/build will regenerate them automatically.");
|
|
console.log("Poster thumbnails are current");
|
|
} else {
|
|
const json = process.argv.includes("--json");
|
|
const result = process.versions.bun
|
|
? await runPosterThumbnailGeneration({ root })
|
|
: await ensurePosterThumbnails({ root, log: json ? message => console.error(message) : console.log });
|
|
if (json) console.log(JSON.stringify(result));
|
|
else if (result.skipped) console.log("[poster thumbnails] Already current; no renderer needed");
|
|
}
|
|
}
|