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