Files
selfrelease 2d847e154f chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
2026-06-13 20:55:44 +08:00

337 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 =
`<div style="font-size:18px;line-height:1.65;letter-spacing:0.5px">${text}</div>` +
(sub
? `<div style="font-size:11px;color:#c7baa0;letter-spacing:2px;margin-top:4px">${sub}</div>`
: "");
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);
});