2025-07-07 20:58:02 -07:00
|
|
|
const width = 768;
|
|
|
|
const cacheImageDir = path.resolve(".clover/question_images");
|
|
|
|
|
|
|
|
// Cached browser session
|
|
|
|
const getBrowser = RefCountedExpirable(
|
|
|
|
() =>
|
|
|
|
puppeteer.launch({
|
|
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
|
|
}),
|
|
|
|
(b) => b.close(),
|
|
|
|
);
|
|
|
|
|
|
|
|
export async function renderQuestionImage(question: Question) {
|
|
|
|
const html = await renderViewToString("q+a/image-embed", { question });
|
|
|
|
|
|
|
|
// this browser session will be reused if multiple images are generated
|
|
|
|
// either at the same time or within a 5-minute time span. the dispose
|
|
|
|
// symbol
|
|
|
|
using sharedBrowser = await getBrowser();
|
|
|
|
const b = sharedBrowser.value;
|
|
|
|
|
|
|
|
const p = await b.newPage();
|
|
|
|
await p.setViewport({ width, height: 400 });
|
|
|
|
await p.setContent(html);
|
|
|
|
try {
|
|
|
|
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
|
|
|
|
} catch (e) {}
|
|
|
|
const height = await p.evaluate(() => {
|
|
|
|
const e = document.querySelector("main")!;
|
|
|
|
return e.getBoundingClientRect().height;
|
|
|
|
});
|
|
|
|
const buf = await p.screenshot({
|
|
|
|
path: "screenshot.png",
|
|
|
|
type: "png",
|
|
|
|
captureBeyondViewport: true,
|
|
|
|
clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
|
|
|
|
});
|
|
|
|
await p.close();
|
|
|
|
|
|
|
|
return Buffer.from(buf);
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function getQuestionImage(
|
|
|
|
question: Question,
|
|
|
|
headOnly: boolean,
|
|
|
|
): Promise<Response> {
|
|
|
|
const hash = crypto.createHash("sha1")
|
|
|
|
.update(question.qmid + question.type + question.text)
|
|
|
|
.digest("hex");
|
|
|
|
|
|
|
|
const headers = {
|
|
|
|
"Content-Type": "image/png",
|
|
|
|
"Cache-Control": "public, max-age=31536000",
|
|
|
|
"ETag": `"${hash}"`,
|
|
|
|
"Last-Modified": question.date.toUTCString(),
|
|
|
|
};
|
|
|
|
|
|
|
|
if (headOnly) {
|
|
|
|
return new Response(null, { headers });
|
|
|
|
}
|
|
|
|
|
|
|
|
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
|
|
|
|
let buf: Buffer;
|
|
|
|
try {
|
|
|
|
buf = await fs.readFile(cachedFilePath);
|
|
|
|
} catch (e: any) {
|
|
|
|
if (e.code !== "ENOENT") throw e;
|
|
|
|
buf = await renderQuestionImage(question);
|
|
|
|
fs.writeMkdir(cachedFilePath, buf).catch(() => {});
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Response(buf, { headers });
|
|
|
|
}
|
|
|
|
|
|
|
|
import * as crypto from "node:crypto";
|
|
|
|
import * as fs from "#sitegen/fs";
|
|
|
|
import * as path from "node:path";
|
|
|
|
import * as puppeteer from "puppeteer";
|
|
|
|
import { Question } from "@/q+a/models/Question.ts";
|
|
|
|
import { RefCountedExpirable } from "#sitegen/async";
|
|
|
|
import { renderViewToString } from "#sitegen/view";
|