import { chromium } from "playwright"; import fs from "fs"; import path from "path"; import { NARRATION, clean } from "./narration.mjs"; const BASE = process.env.BASE_URL ?? "http://localhost:3000"; const OUT_DIR = path.resolve("e2e/videos"); fs.mkdirSync(OUT_DIR, { recursive: true }); const log = (m) => console.log(`▶ ${m}`); const pause = (p, ms = 900) => p.waitForTimeout(ms); // 读取预生成语音时长,用于让每步停留足够长以与解说同步 const durMap = new Map(); try { const clips = JSON.parse(fs.readFileSync(path.resolve("e2e/voice/clips.json"), "utf-8")); for (const [t, v] of Object.entries(clips)) durMap.set(t, v.dur); } catch { /* 未预生成语音则不做停留对齐 */ } const dwellMs = (text) => Math.round((durMap.get(clean(text)) ?? 0) * 1000); const main = async () => { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, recordVideo: { dir: OUT_DIR, size: { width: 1440, height: 900 } }, }); const page = await context.newPage(); page.on("console", (msg) => { if (msg.type() === "error") log(`console.error: ${msg.text().slice(0, 120)}`); }); // 记录字幕出现的时间轴(相对录像开始),供后期配音对齐 const t0 = Date.now(); const narration = []; // 屏幕字幕:注入一个固定字幕条,录像里可见 const say = async (text, sub = "", voice = "") => { const spoken = clean(voice || text); narration.push({ t: Math.max(0, Date.now() - t0), text: spoken }); await page .evaluate( ({ text, sub }) => { let el = document.getElementById("__cap"); if (!el) { el = document.createElement("div"); el.id = "__cap"; el.style.cssText = "position:fixed;left:50%;bottom:60px;transform:translateX(-50%);z-index:2147483647;" + "max-width:86vw;padding:12px 26px;border-radius:9999px;text-align:center;" + "background:rgba(8,7,5,0.84);border:1px solid rgba(214,170,91,0.5);" + "box-shadow:0 10px 36px rgba(0,0,0,.55);backdrop-filter:blur(8px);" + "font-family:'Noto Serif SC',serif;color:#f2cf83;transition:opacity .2s;pointer-events:none"; document.body.appendChild(el); } el.innerHTML = `
${text}
` + (sub ? `
${sub}
` : ""); el.style.opacity = "1"; }, { text, sub } ) .catch(() => {}); }; let stepNo = 0; /** 执行一步:字幕显示解说词本身(与配音同步)→ 执行 → 停留至解说播完 */ const safe = async (label, fn) => { stepNo += 1; const startT = Date.now(); const voiceText = NARRATION[label] || label; await say(voiceText, "中华文明全图鉴 · 功能演示", voiceText); try { await fn(); log(`ok: ${label}`); } catch (e) { log(`skip: ${label} (${e.message.split("\n")[0]})`); } const need = dwellMs(voiceText) + 500; // 句尾留白 const elapsed = Date.now() - startT; if (elapsed < need) await page.waitForTimeout(need - elapsed); }; log(`打开 ${BASE}/map`); await page.goto(`${BASE}/map`, { waitUntil: "domcontentloaded", timeout: 60000 }); await page.waitForTimeout(3000); await say(NARRATION.__intro, "中华文明全图鉴 · Cultural Heritage Atlas", NARRATION.__intro); await page.waitForTimeout(Math.max(2500, dwellMs(NARRATION.__intro) + 500)); await safe("打开文物全图地图", async () => { await page.getByText("中华文明全图鉴").first().waitFor({ timeout: 20000 }); }); await pause(page, 1200); // 1. 搜索 await safe("搜索“青铜”", async () => { const input = page.getByPlaceholder("搜索文物、机构、城市…"); await input.click(); await input.pressSequentially("青铜", { delay: 120 }); await pause(page, 1500); await input.fill(""); await pause(page, 600); }); // 2. 城市分布翻页(左栏内的下一页按钮) await safe("城市分布翻页", async () => { const aside = page.locator("aside").first(); const next = aside.getByRole("button", { name: "下一页" }); for (let i = 0; i < 2; i++) { await next.click({ timeout: 4000 }); await pause(page, 900); } }); // 3. 门类筛选 await safe("门类筛选:青铜器", async () => { await page.getByRole("button", { name: "青铜器" }).first().click({ timeout: 5000 }); await pause(page, 1500); await page.getByRole("button", { name: "青铜器" }).first().click({ timeout: 5000 }); await pause(page, 800); }); // 4. 年代筛选 await safe("年代筛选:唐代", async () => { await page.getByRole("button", { name: "唐代", exact: true }).first().click({ timeout: 5000 }); await pause(page, 1500); await page.getByRole("button", { name: "唐代", exact: true }).first().click({ timeout: 5000 }); await pause(page, 800); }); // 5. 左栏收起 / 展开 await safe("左栏收起/展开", async () => { const leftToggle = page.getByTitle(/收起左栏|展开左栏/); await leftToggle.click({ timeout: 5000 }); await pause(page, 1200); await leftToggle.click({ timeout: 5000 }); await pause(page, 1000); }); // 6. 右栏收起 / 展开 await safe("右栏收起/展开", async () => { const rightToggle = page.getByTitle(/收起右栏|展开右栏/); await rightToggle.click({ timeout: 5000 }); await pause(page, 1200); await rightToggle.click({ timeout: 5000 }); await pause(page, 1000); }); // 7. 点击地图 marker 打开右栏(遍历前几个 marker,直到面板打开) let panelOpened = false; await safe("点击地图标记 · 查看馆藏", async () => { const markers = page.locator("div.cursor-pointer.flex-col"); await markers.first().waitFor({ state: "attached", timeout: 25000 }); await pause(page, 1500); const aside = page.locator("aside").last(); const total = Math.min(await markers.count(), 8); for (let i = 0; i < total; i++) { const box = await markers.nth(i).boundingBox(); if (!box) continue; await page.mouse.click(box.x + box.width / 2, box.y + box.height - 3); await pause(page, 1400); const realPanel = (await aside.getByText("机构藏品").count()) > 0 || (await page.getByRole("button", { name: /文物信息/ }).count()) > 0; if (realPanel) { panelOpened = true; break; } } if (!panelOpened) throw new Error("右栏面板未打开"); }); if (panelOpened) { // 确保进入“文物详情”视图(若是机构藏品列表则先点一件) let detailOpen = false; await safe("进入文物详情", async () => { const aside = page.locator("aside").last(); if ((await aside.getByText("机构藏品").count()) > 0) { await aside.locator("button:has(div.truncate)").first().click({ timeout: 4000 }); await pause(page, 1800); } await page.getByRole("button", { name: /文物信息/ }).waitFor({ timeout: 8000 }); detailOpen = true; }); if (!detailOpen) { log("详情未打开,跳过详情相关步骤"); } else { // 8. 同一机构展开 await safe("展开同一机构列表", async () => { await page.getByText(/还有 \d+ 件/).first().click({ timeout: 4000 }); await pause(page, 1500); }); // 9. 文物信息折叠/展开 await safe("文物信息折叠/展开", async () => { const btn = page.getByRole("button", { name: /文物信息/ }); await btn.click({ timeout: 4000 }); await pause(page, 1000); await btn.click({ timeout: 4000 }); await pause(page, 1000); }); // 10. 图片放大弹窗改到 马踏飞燕(有 mock 图)的专属环节,见下方 // 11. AI 角色对话:点起始问题 → 等流式 → 点一个下一步建议 await safe("AI 文物对话 · 流式回答与追问建议", async () => { const aside = page.locator("aside").last(); await page.getByRole("button", { name: "讲解员" }).first().click({ timeout: 4000 }); await pause(page, 800); await page.getByText("它为什么珍贵?").first().click({ timeout: 4000 }); // 等待流式回答出现 await page.locator("div.md-chat").first().waitFor({ timeout: 30000 }); await pause(page, 4000); // 等下一步建议并点击第一个 await aside.getByText("下一步 · 你可以问").waitFor({ timeout: 20000 }); await pause(page, 1200); await aside.locator('button:has-text("?")').first().click({ timeout: 4000 }); await page.locator("div.md-chat").nth(1).waitFor({ timeout: 30000 }); await pause(page, 4000); }); } } // 马踏飞燕:搜索 → 选中 → 图片放大弹窗 + 无极缩放(本地真实图片) await safe("图片放大与无极缩放(马踏飞燕)", async () => { // 关闭可能打开的详情,回到可搜索状态 const input = page.getByPlaceholder("搜索文物、机构、城市…"); await input.click({ timeout: 5000 }); await input.fill("马踏飞燕"); // 等待筛选生效、marker 数量收敛,避免点到正在被移除的旧 marker await page .waitForFunction( () => document.querySelectorAll("div.cursor-pointer.flex-col").length <= 2, null, { timeout: 8000 } ) .catch(() => {}); await pause(page, 1000); // 搜索后通常只剩该点位,点击其 marker const markers = page.locator("div.cursor-pointer.flex-col"); await markers.first().waitFor({ state: "attached", timeout: 15000 }); await pause(page, 600); const box = await markers.first().boundingBox(); await page.mouse.click(box.x + box.width / 2, box.y + box.height - 3); await pause(page, 1800); // 若弹出机构列表则点第一件 const aside = page.locator("aside").last(); if ((await aside.getByText("机构藏品").count()) > 0) { await aside.locator("button:has(div.truncate)").first().click({ timeout: 4000 }); await pause(page, 1600); } // 打开放大弹窗(点击封面图本身,"点击放大"角标是 pointer-events-none) const cover = page.locator("img.cursor-zoom-in").first(); await cover.waitFor({ state: "visible", timeout: 6000 }); const cb = await cover.boundingBox(); await page.mouse.click(cb.x + cb.width / 2, cb.y + cb.height / 2); await pause(page, 1500); // 无极缩放(滚轮放大) await page.mouse.move(720, 450); for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, -320); await pause(page, 450); } await pause(page, 1500); // 拖动平移 await page.mouse.move(720, 450); await page.mouse.down(); await page.mouse.move(560, 360, { steps: 12 }); await page.mouse.up(); await pause(page, 1500); await page.keyboard.press("Escape"); await pause(page, 1200); }); // 13. 文物南迁北归:激活路线 + 动画播放 await safe("文物南迁北归 · 重走万里守护之路", async () => { const search = page.getByPlaceholder("搜索文物、机构、城市…"); await search.fill(""); await pause(page, 700); await page.getByRole("button", { name: "文物南迁北归之路" }).click({ timeout: 6000 }); await pause(page, 2600); // 等待聚焦全程 + 路线展开 await page.getByRole("button", { name: "播放" }).click({ timeout: 6000 }); await pause(page, 12000); // 观看逐站推进 + 流动箭头 + 落点脉冲 await page.getByRole("button", { name: "退出路线" }).click({ timeout: 6000 }); await pause(page, 1200); }); // 14. 国宝海外回归:选择文物 → 专属回归之路 + 右栏定位 await safe("国宝海外回归 · 选择文物,重走回家之路", async () => { await page.getByRole("button", { name: "国宝海外回归" }).click({ timeout: 6000 }); await page.getByText("国宝海外回归 · 选择文物").first().waitFor({ timeout: 6000 }); await pause(page, 1800); await page.locator('button:has-text("现藏")').first().click({ timeout: 6000 }); await pause(page, 2600); // 路线激活 + 右栏定位 await page.getByRole("button", { name: "播放" }).click({ timeout: 6000 }); await pause(page, 7000); await page.getByRole("button", { name: "退出路线" }).click({ timeout: 6000 }); await pause(page, 1200); }); await pause(page, 1500); await say(NARRATION.__outro, "中华文明全图鉴", NARRATION.__outro); await page.waitForTimeout(Math.max(2500, dwellMs(NARRATION.__outro) + 700)); log("结束,保存录像…"); const video = page.video(); await context.close(); await browser.close(); // 录像在 context.close() 后落盘,取目录内最新的 webm await new Promise((r) => setTimeout(r, 500)); const webms = fs .readdirSync(OUT_DIR) .filter((f) => f.endsWith(".webm") && f !== "wenwumap-e2e.webm") .map((f) => ({ f, t: fs.statSync(path.join(OUT_DIR, f)).mtimeMs })) .sort((a, b) => b.t - a.t); if (webms.length) { const dest = path.join(OUT_DIR, "wenwumap-e2e.webm"); fs.copyFileSync(path.join(OUT_DIR, webms[0].f), dest); log(`录像已保存:${dest}`); } else { log("未找到录像文件"); } fs.writeFileSync(path.join(OUT_DIR, "narration.json"), JSON.stringify({ narration }, null, 2)); log(`字幕时间轴已保存:${narration.length} 条`); void video; }; main().catch((e) => { console.error(e); process.exit(1); });