/**
 * Capture real product screenshots for the landing page.
 *
 * Flow: host a scripted mock LLM (speaks BOTH Anthropic SSE and OpenAI chat-completions
 * SSE, so whichever client AgentHub routes to gets a valid stream) -> start the Web
 * server against a temp data root serving the built web dist -> drive a genuine
 * "build an Agent app" conversation (tools actually execute in the workspace) ->
 * screenshot chat / trace view / evaluation center, per UI language (zh / en, each
 * with its own user so sidebars stay monolingual) and per theme (light / dark), into
 * src/assets/shots/ as <page>-<lang>-<theme>.webp (12 files, re-encoded to WebP
 * inside Chromium to keep the repo small).
 *
 * Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and
 * Playwright's chromium. Run: `node scripts/capture-shots.mjs`.
 */
import http from "node:http";
import { spawn } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "../../..");
const OUT_DIR = path.resolve(HERE, "../src/assets/shots");
const MOCK_PORT = 8941;
const SRV_PORT = 8940;
const BASE = `http://127.0.0.1:${SRV_PORT}`;
// Pins the otherwise-random seeded admin password (PENGUIN_SEED_ADMIN_PASSWORD below).
const ADMIN_PASSWORD = "penguin-0000";
const MOCK = `http://127.0.0.1:${MOCK_PORT}`;

// ---------------------------------------------------------------------------
// Scripted conversation: the Agent builds an Agent application from scratch.
// Commands are shared across languages (code is code) and really execute.
// ---------------------------------------------------------------------------

const CMD_COLLECT = `git clone --depth 1 https://github.com/ericbuess/claude-code-docs \\
  claude-code-expert/corpus/claude-code-docs
find claude-code-expert/corpus -type f ! -name '*.md' -delete
rm -rf claude-code-expert/corpus/claude-code-docs/.git
ls claude-code-expert/corpus/claude-code-docs | head -6`;

const CMD_APP = `mkdir -p claude-code-expert/src claude-code-expert/public
cat > claude-code-expert/package.json <<'EOF'
{
  "name": "claude-code-expert",
  "private": true,
  "type": "module",
  "scripts": { "start": "tsx src/rag.ts" },
  "dependencies": { "@prismshadow/penguin-core": "^0.1.0", "tsx": "^4" }
}
EOF
cat > claude-code-expert/src/rag.ts <<'EOF'
// BM25 retrieval over corpus/ + a Session that answers with clickable [n] citations.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";

const walk = (d) =>
  fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
    e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
const chunks = walk("corpus").flatMap((f) =>
  fs.readFileSync(f, "utf8").split(/\\n(?=#{1,3} )/).map((text) => ({ source: f, text })));

const tok = (s) => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];
const docs = chunks.map((c) => tok(c.text));
const avg = docs.reduce((n, d) => n + d.length, 0) / Math.max(docs.length, 1);
const df = new Map();
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) ?? 0) + 1);
// BM25 (k1=1.2, b=0.75): idf weights rare terms, term frequency saturates, long chunks are penalized.
const score = (q, i) => {
  const d = docs[i];
  let s = 0;
  for (const w of new Set(tok(q))) {
    const f = d.filter((x) => x === w).length;
    if (!f) continue;
    const n = df.get(w) ?? 0;
    s += Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)) * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * d.length / avg));
  }
  return s;
};
const agent = await createAgent({ root: "penguin_data" });

http.createServer(async (req, res) => {
  if (req.method !== "POST") { res.end(fs.readFileSync("public/index.html")); return; }
  let body = "";
  for await (const p of req) body += p;
  const { question } = JSON.parse(body);
  const hits = chunks.map((c, i) => [score(question, i), c]).filter(([s]) => s > 0)
    .sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, c]) => c);
  res.writeHead(200, { "content-type": "text/event-stream" });
  const ctx = hits.map((c, i) => "[" + (i + 1) + "] " + c.source + "\\n" + c.text).join("\\n\\n");
  const session = await agent.createSession({ workspaceDir: process.cwd() });
  for await (const m of session.run([userText(ctx + "\\n\\nQ: " + question)], {
    approve: async () => "deny",
  })) {
    if (isModelMessage(m) && m.payload.type === "partial_text" && m.payload.event_type === "delta")
      res.write("data: " + JSON.stringify({ delta: m.payload.text }) + "\\n\\n");
  }
  res.write("data: " + JSON.stringify({ sources: hits.map((c) => c.source) }) + "\\n\\n");
  session.dispose();
  res.end();
}).listen(4630);
EOF
cat > claude-code-expert/public/index.html <<'EOF'
<!doctype html>
<meta charset="utf-8" />
<title>Claude Code docs expert</title>
<body style="max-width:640px;margin:3rem auto;font-family:system-ui;line-height:1.5">
<h3>Claude Code docs expert</h3>
<div id="log"></div>
<input id="q" style="width:100%;padding:.5rem" placeholder="Ask about Claude Code…" autofocus />
<script>
q.addEventListener("keydown", async (e) => {
  if (e.key !== "Enter" || !q.value.trim()) return;
  const question = q.value; q.value = "";
  const p = document.createElement("p"); log.append(p);
  const res = await fetch("/api/ask", { method: "POST", body: JSON.stringify({ question }) });
  const reader = res.body.getReader(), dec = new TextDecoder();
  for (let r; !(r = await reader.read()).done; )
    for (const chunk of dec.decode(r.value).split("\\n\\n")) {
      if (!chunk.startsWith("data:")) continue;
      const d = JSON.parse(chunk.slice(5));
      if (d.delta) p.textContent += d.delta;
      if (d.sources) p.textContent += "  Sources: " + d.sources.join(", ");
    }
});
</script>
</body>
EOF
wc -l claude-code-expert/src/rag.ts`;

const TREE = `\`\`\`text
claude-code-expert/
├── package.json
├── corpus/claude-code-docs/   # the collected docs
├── src/rag.ts
└── public/index.html
\`\`\``;

/** Per-language script: user prompt marker -> turns + session title. */
const SCRIPTS = {
  zh: {
    marker: "配置专家",
    prompt:
      "收集 https://github.com/ericbuess/claude-code-docs 的文档，做一个化身 Claude Code 配置专家、回答带来源引用的 RAG 问答应用。",
    title: "构建 Claude Code 文档专家",
    turns: [
      {
        thinking:
          "一句话需求，penguin-sdk 技能给出了完整配方：先采集语料，再建 BM25 索引，回答经 Session 流式生成并带 [n] 引用直达原文。",
        text: "先把文档采集进语料库：",
        cmd: CMD_COLLECT,
      },
      {
        thinking:
          "语料就绪。写应用：本地 BM25 检索（零 embedding 依赖）+ 引用回答 + Penguin 风格聊天界面，引用链接到 /corpus 原文。",
        text: "语料就绪，写检索与引用回答的应用入口：",
        cmd: CMD_APP,
      },
      {
        text: `Claude Code 文档专家已就绪：

${TREE}

- 检索：本地 BM25 索引全部文档片段，中文提问同样支持；
- 回答：每次提问经 Session 流式生成，引用 [1][2] 可点击直达 \`corpus/\` 原文；
- 界面：Penguin 风格聊天页，空态内置示例问题；
- 运行：\`cd claude-code-expert && npm install && npm start\`，浏览器打开 http://localhost:4630。`,
      },
    ],
  },
  en: {
    marker: "configuration expert",
    prompt:
      "Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG app that answers Claude Code questions as a configuration expert, citing its sources.",
    // Stay under core's TITLE_MAX_CHARS (30): a longer title gets hard-clipped
    // mid-word in the shots (e.g. "…docs exper").
    title: "Build Claude Code docs expert",
    turns: [
      {
        thinking:
          "One sentence is enough — the penguin-sdk skill has the full recipe: collect the corpus, build a BM25 index, answer through a Session with [n] citations linking to the originals.",
        text: "Collecting the docs into the corpus first:",
        cmd: CMD_COLLECT,
      },
      {
        thinking:
          "Corpus in place. Now the app: local BM25 retrieval (no embedding credential), cited answers, and a Penguin-style chat UI with citations linking to /corpus originals.",
        text: "Corpus ready — now the retrieval and cited-answer entry:",
        cmd: CMD_APP,
      },
      {
        text: `The Claude Code docs expert is ready:

${TREE}

- Retrieval: a local BM25 index over every doc chunk, Chinese questions included;
- Answers: each question streams through a Session, with [1][2] citations that link straight to the \`corpus/\` originals;
- UI: a Penguin-style chat page with example questions in the empty state;
- Run: \`cd claude-code-expert && npm install && npm start\`, then open http://localhost:4630.`,
      },
    ],
  },
};

function scriptFor(flat) {
  return flat.includes(SCRIPTS.en.marker) ? SCRIPTS.en : SCRIPTS.zh;
}

// ---------------------------------------------------------------------------
// Mock LLM: Anthropic SSE on */messages, OpenAI chunks on */chat/completions.
// ---------------------------------------------------------------------------

function sse(res, event, data) {
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

function anthropicReply(res, body) {
  const flat = JSON.stringify(body.messages ?? []);
  const script = scriptFor(flat);
  const isTitle = flat.includes("concise title");
  const toolResults = flat.split('"tool_result"').length - 1;
  const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];
  const msgCount = (body.messages ?? []).length;

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  sse(res, "message_start", {
    type: "message_start",
    message: {
      id: `msg_shot_${Date.now()}`,
      type: "message",
      role: "assistant",
      model: "deepseek-v4-pro",
      content: [],
      stop_reason: null,
      stop_sequence: null,
      usage: {
        input_tokens: 380,
        output_tokens: 0,
        cache_read_input_tokens: 2400 * msgCount,
        cache_creation_input_tokens: 620,
      },
    },
  });

  const block = (index, start, deltas, extra) => {
    sse(res, "content_block_start", { type: "content_block_start", index, content_block: start });
    for (const d of deltas)
      sse(res, "content_block_delta", { type: "content_block_delta", index, delta: d });
    if (extra)
      sse(res, "content_block_delta", { type: "content_block_delta", index, delta: extra });
    sse(res, "content_block_stop", { type: "content_block_stop", index });
  };
  const finish = (stopReason, outputTokens) => {
    sse(res, "message_delta", {
      type: "message_delta",
      delta: { stop_reason: stopReason, stop_sequence: null },
      usage: { output_tokens: outputTokens },
    });
    sse(res, "message_stop", { type: "message_stop" });
    res.end();
  };
  const textDeltas = (text) =>
    (text.match(/[\s\S]{1,24}/g) ?? []).map((t) => ({ type: "text_delta", text: t }));

  if (isTitle) {
    block(0, { type: "text", text: "" }, [{ type: "text_delta", text: script.title }]);
    finish("end_turn", 8);
    return;
  }

  let index = 0;
  if (turn.thinking) {
    block(
      index++,
      { type: "thinking", thinking: "" },
      turn.thinking.match(/[\s\S]{1,18}/g).map((t) => ({ type: "thinking_delta", thinking: t })),
      { type: "signature_delta", signature: "sig_shot" },
    );
  }
  if (turn.text) block(index++, { type: "text", text: "" }, textDeltas(turn.text));
  if (turn.cmd) {
    const json = JSON.stringify({ cmd: turn.cmd });
    block(
      index++,
      { type: "tool_use", id: `toolu_shot_${toolResults + 1}`, name: "exec_command", input: {} },
      (json.match(/[\s\S]{1,32}/g) ?? []).map((partial_json) => ({
        type: "input_json_delta",
        partial_json,
      })),
    );
    finish("tool_use", 160);
  } else {
    finish("end_turn", 420);
  }
}

function openaiReply(res, body) {
  const flat = JSON.stringify(body.messages ?? []);
  const script = scriptFor(flat);
  const isTitle = flat.includes("concise title");
  const toolResults = flat.split('"role":"tool"').length - 1;
  const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  const chunk = (delta, finishReason = null, usage) => {
    const payload = {
      id: "chatcmpl-shot",
      object: "chat.completion.chunk",
      created: Math.floor(Date.now() / 1000),
      model: "deepseek-v4-pro",
      choices: [{ index: 0, delta, finish_reason: finishReason }],
    };
    if (usage) payload.usage = usage;
    res.write(`data: ${JSON.stringify(payload)}\n\n`);
  };
  const usage = {
    prompt_tokens: 5200,
    completion_tokens: turn.cmd ? 180 : 420,
    total_tokens: 5620,
    prompt_cache_hit_tokens: 4300,
    prompt_cache_miss_tokens: 900,
  };

  chunk({ role: "assistant" });
  if (isTitle) {
    chunk({ content: script.title });
    chunk({}, "stop", usage);
    res.write("data: [DONE]\n\n");
    res.end();
    return;
  }
  if (turn.thinking) {
    for (const t of turn.thinking.match(/[\s\S]{1,18}/g)) chunk({ reasoning_content: t });
  }
  if (turn.text) {
    for (const t of turn.text.match(/[\s\S]{1,24}/g)) chunk({ content: t });
  }
  if (turn.cmd) {
    chunk({
      tool_calls: [
        {
          index: 0,
          id: `call_shot_${toolResults + 1}`,
          type: "function",
          function: { name: "exec_command", arguments: "" },
        },
      ],
    });
    const json = JSON.stringify({ cmd: turn.cmd });
    for (const part of json.match(/[\s\S]{1,32}/g)) {
      chunk({ tool_calls: [{ index: 0, function: { arguments: part } }] });
    }
    chunk({}, "tool_calls", usage);
  } else {
    chunk({}, "stop", usage);
  }
  res.write("data: [DONE]\n\n");
  res.end();
}

function startMock() {
  const server = http.createServer((req, res) => {
    if (req.method !== "POST") {
      res.writeHead(404).end();
      return;
    }
    let body = "";
    req.on("data", (c) => (body += c));
    req.on("end", () => {
      let json = {};
      try {
        json = JSON.parse(body);
      } catch {}
      if (req.url?.includes("chat/completions")) return openaiReply(res, json);
      if (req.url?.includes("messages")) return anthropicReply(res, json);
      console.log(`[mock] unexpected path ${req.url}`);
      res.writeHead(404).end();
    });
  });
  return new Promise((resolve) => server.listen(MOCK_PORT, "127.0.0.1", () => resolve(server)));
}

// ---------------------------------------------------------------------------
// Server + API helpers.
// ---------------------------------------------------------------------------

async function waitFor(url, tries = 60) {
  for (let i = 0; i < tries; i++) {
    try {
      const res = await fetch(url);
      if (res.ok) return;
    } catch {}
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`server not ready: ${url}`);
}

async function api(cookie, method, url, body) {
  const res = await fetch(`${BASE}${url}`, {
    method,
    headers: {
      "content-type": "application/json",
      ...(cookie ? { cookie } : {}),
    },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`);
  return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") };
}

async function login(userId, password) {
  const { json, setCookie } = await api(null, "POST", "/api/auth/login", { userId, password });
  if (!setCookie) throw new Error("no session cookie from login");
  return { cookie: setCookie.split(";")[0], user: json.user };
}

/** Per-language demo users so sidebars stay monolingual in the shots. */
const USERS = {
  zh: {
    userId: "demo",
    agents: [
      {
        agentId: "data_analyst",
        name: "数据分析师",
        description: "面向 CSV / Excel 的数据分析、图表与报表生成",
      },
      { agentId: "web_scout", name: "网页调研员", description: "网页检索、信息核对与调研纪要整理" },
      {
        agentId: "agent_optimizer",
        name: "Agent 优化师",
        description: "评估其他 Agent 的表现并迭代其提示词与技能",
      },
    ],
  },
  en: {
    userId: "alex",
    agents: [
      {
        agentId: "data_analyst",
        name: "Data Analyst",
        description: "CSV / Excel analysis, charts and report generation",
      },
      {
        agentId: "web_scout",
        name: "Web Scout",
        description: "Web research, fact checking and note-taking",
      },
      {
        agentId: "agent_optimizer",
        name: "Agent Optimizer",
        description: "Evaluates other Agents and iterates their prompts and Skills",
      },
    ],
  },
};

/** Provision a user with models + a few Agents; returns { cookie, password, projectId }. */
async function provisionUser(adminCookie, lang) {
  const { userId, agents } = USERS[lang];
  const initial = `${userId}12345`;
  await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => {
    if (!String(e).includes("409")) throw e;
  });
  let session = await login(userId, initial);
  // Rotate once so the initial-password banner disappears from the shots.
  let password = initial;
  try {
    await api(session.cookie, "PUT", "/api/me/password", {
      oldPassword: initial,
      newPassword: `penguin-${userId}-2026`,
    });
    password = `penguin-${userId}-2026`;
  } catch {}
  session = await login(userId, password);

  const projects = (await api(session.cookie, "GET", "/api/projects")).json;
  const projectId = projects.projects[0].projectId;

  await api(session.cookie, "PUT", `/api/projects/${projectId}/models`, {
    defaultModel: { provider: "deepseek", modelId: "deepseek-v4-pro" },
    models: [
      {
        provider: "deepseek",
        modelId: "deepseek-v4-pro",
        apiKey: "sk-demo",
        baseUrl: MOCK,
        contextWindow: 1000000,
        pricing: { cacheRead: 0.003571, cacheWrite: 0.428571, output: 0.857143 },
      },
    ],
  });

  for (const agent of agents) {
    await api(session.cookie, "POST", `/api/projects/${projectId}/agents`, agent).catch((e) => {
      if (!String(e).includes("409")) throw e;
    });
  }

  return { cookie: session.cookie, password, projectId, userId };
}

// ---------------------------------------------------------------------------
// Main.
// ---------------------------------------------------------------------------

const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-shots-"));
const wsDir = path.join(dataRoot, "workspace-apps");
mkdirSync(wsDir, { recursive: true });
mkdirSync(OUT_DIR, { recursive: true });

const mock = await startMock();
console.log(`[shots] mock LLM on ${MOCK}`);

const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], {
  env: {
    ...process.env,
    PENGUIN_HOME: path.join(dataRoot, "home"),
    PENGUIN_WEB_DB: path.join(dataRoot, "web.db"),
    PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"),
    PORT: String(SRV_PORT),
    HOST: "127.0.0.1",
    PENGUIN_SEED_ADMIN_PASSWORD: ADMIN_PASSWORD,
  },
  stdio: ["ignore", "pipe", "pipe"],
});
srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`));

const cleanup = () => {
  try {
    srv.kill();
  } catch {}
  try {
    mock.close();
  } catch {}
};
process.on("exit", cleanup);

try {
  await waitFor(`${BASE}/`);
  console.log(`[shots] server ready on ${BASE}`);

  const admin = await login("admin", ADMIN_PASSWORD);
  const browser = await chromium.launch();

  // WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which
  // keeps repo assets small (~5x lighter than PNG) with no native image deps.
  const encoderPage = await browser.newPage();
  async function saveWebp(pngBuffer, fileName) {
    const dataUrl = await encoderPage.evaluate(async (b64) => {
      const img = new Image();
      img.src = `data:image/png;base64,${b64}`;
      await img.decode();
      const canvas = document.createElement("canvas");
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext("2d").drawImage(img, 0, 0);
      return canvas.toDataURL("image/webp", 0.82);
    }, pngBuffer.toString("base64"));
    writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
    console.log(`[shots] ${fileName}`);
  }

  /** The final answer is the only turn mentioning the npm run command. */
  const DONE_MARKER = "npm install && npm start";

  for (const lang of ["zh", "en"]) {
    const user = await provisionUser(admin.cookie, lang);
    const script = SCRIPTS[lang];

    // Language-specific workspace subdir so zh/en runs don't collide on files.
    const ws = path.join(wsDir, lang);
    mkdirSync(ws, { recursive: true });

    const sess = (
      await api(
        user.cookie,
        "POST",
        `/api/projects/${user.projectId}/agents/default_agent/sessions`,
        {
          provider: "deepseek",
          modelId: "deepseek-v4-pro",
          approvalMode: "allow-all",
          workspace: ws,
        },
      )
    ).json;
    const sessionId = sess.session.sessionId;

    let firstTheme = true;
    for (const theme of ["light", "dark"]) {
      // 1280x800 @1.5x -> 1920x1200: sharp enough for the landing's ~1024px-wide
      // frames on retina, while keeping the WebP assets small.
      const context = await browser.newContext({
        viewport: { width: 1280, height: 800 },
        deviceScaleFactor: 1.5,
        locale: lang === "zh" ? "zh-CN" : "en-US",
      });
      await context.addInitScript(
        ([t, l]) => {
          localStorage.setItem("penguin.theme", t);
          localStorage.setItem("penguin.lang", l);
        },
        [theme, lang],
      );
      const page = await context.newPage();
      await page.goto(`${BASE}/login`);
      const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
        data: { userId: user.userId, password: user.password },
      });
      if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`);

      await page.goto(`${BASE}/chat/${sessionId}`);
      if (firstTheme) {
        // Drive the conversation once per language; the other theme restores it.
        const input = page.getByPlaceholder(/输入消息|Type a message/);
        await input.waitFor({ timeout: 20000 });
        await input.fill(script.prompt);
        await page.getByRole("button", { name: /发送|Send/ }).click();
        firstTheme = false;
      }
      await page.getByText(DONE_MARKER).first().waitFor({ timeout: 90000 });
      await page.waitForTimeout(2000);
      await saveWebp(await page.screenshot(), `chat-${lang}-${theme}.webp`);

      // Trace view: the product's canonical deep link carries BOTH agentId and
      // sessionId (?sessionId= alone is ignored by TracesPage's focus wiring), and
      // auto-selects the Session once its trace list loads. Waiting for the
      // execution timeline's exec_command lanes guarantees every language captures
      // the same opened trace — stats + a timeline with tool calls — never the
      // empty "select a Session" state.
      await page.goto(`${BASE}/traces?agentId=default_agent&sessionId=${sessionId}`);
      await page.getByText("exec_command").first().waitFor({ timeout: 20000 });
      await page.waitForTimeout(2000);
      await saveWebp(await page.screenshot(), `traces-${lang}-${theme}.webp`);

      // Evaluation center: open the pre-provisioned example Benchmark scoreboard.
      await page.goto(`${BASE}/benchmark`);
      await page.waitForTimeout(1500);
      await page
        .getByText("Example Benchmark")
        .first()
        .click()
        .catch(() => {});
      await page.waitForTimeout(2500);
      await saveWebp(await page.screenshot(), `benchmark-${lang}-${theme}.webp`);

      await context.close();
    }
  }

  await browser.close();
  console.log(`[shots] done -> ${OUT_DIR}`);
  process.exit(0);
} catch (err) {
  console.error("[shots] FAILED:", err);
  process.exit(1);
}
