Files
edgeever/scripts/prepare-poster-thumbnails.mjs

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");
}
}