2d847e154f
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
337 lines
14 KiB
JavaScript
337 lines
14 KiB
JavaScript
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);
|
||
});
|