fix(ci): cut per-scene page loads out of ui-preview videos

This commit is contained in:
t8y2
2026-10-09 14:56:04 +08:00
parent 9026e2de17
commit 428e6bd69c
3 changed files with 35 additions and 13 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ carries the `ui-preview` label), the workflow does this:
- has it plan scenes: what to click, double-click, hover, type and capture;
- walks the plan in Chromium with `cursor.js` drawing the pointer, its trail and each
click. It always takes screenshots. When the plan interacts, it also records an mp4,
never a GIF. A failed step is re-planned from the page as it is (up to three times),
never a GIF — each scene on a page of its own, its load trimmed off and the takes
joined, so scene changes read as cuts rather than the app reloading. A failed step
is re-planned from the page as it is (up to three times),
after an Escape clears anything the failure left open (a menu, a dialog); what still
fails is listed honestly in the PR block, and shots taken after a failure in their
scene are marked ⚠️ both there and on the Pages player.
+1 -1
View File
@@ -147,7 +147,7 @@ video,img{display:block;width:100%;border-radius:10px;border:1px solid var(--lin
a{color:inherit}</style>
<main><h1>#${pr} 界面预览</h1><p class="m">commit ${short(sha)} · <a href="https://github.com/${repo}/pull/${pr}">回到 PR</a>${m.summary ? " · " + esc(m.summary) : ""}</p>
${warned ? `<p class="warn">⚠️ 这次录制有 ${m.errors?.length || 0} 步没能按计划执行${shots.some((x) => x.warn) ? ";带 ⚠️ 的截图拍摄于步骤失败之后,画面未必是说明文字所描述的状态" : ""}。</p>` : ""}
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。多个场景各自从应用初始状态开始。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${x.warn ? "⚠️ " : ""}${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
</main></html>`;
}
+31 -11
View File
@@ -582,19 +582,33 @@ async function main() {
if (plan.scenes.length) {
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
await rec.addInitScript({ path: path.join(here, "cursor.js") });
const page = await rec.newPage();
const began = Date.now();
let lead = 0, repairs = 0, interactive = false;
let repairs = 0, interactive = false;
// one take per scene: every scene starts from the app as it opens, and
// its video keeps only what happens once the app has settled — the
// load never reaches the final cut, so a scene change reads as a cut,
// not as the app reloading out of nowhere
const takes = [];
for (const s of plan.scenes.slice(0, 5)) {
const scene = { title: s.title || "", shots: [], derailed: false };
manifest.scenes.push(scene);
const page = await rec.newPage();
const began = Date.now();
let ready = 0;
try {
await caption(page, scene.title);
await page.goto(BASE);
await settle(page);
if (!lead) lead = Math.max(0, (Date.now() - began) / 1000 - 0.4);
await page.evaluate(([x, y]) => window.__uiPreviewAt?.(x, y), [mouse.x, mouse.y]);
} catch (e) { manifest.errors.push(`${scene.title}: open: ${e.message}`); if (manifest.leak) break; continue; }
ready = Date.now();
} catch (e) {
manifest.errors.push(`${scene.title}: open: ${e.message}`);
await page.close().catch(() => {});
if (manifest.leak) break;
continue;
}
// the scene's title readable on the settled page for a beat, before
// the first step's caption replaces it
await caption(page, scene.title);
await sleep(1100);
let steps = (s.steps || []).slice(0, 25);
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
@@ -632,17 +646,23 @@ async function main() {
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
}
}
if (manifest.leak) break;
if (manifest.leak) { await page.close().catch(() => {}); break; }
await caption(page, "");
await sleep(600);
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
await page.close();
}
const video = await page.video().path();
await rec.close();
manifest.interactive = interactive;
// a video when there's something to watch: an interaction
if (!manifest.leak && interactive) {
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", lead.toFixed(2), "-i", video, "-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4")]);
if (!manifest.leak && interactive && takes.length) {
// the takes joined, each already trimmed of its load
const args = ["-y", "-loglevel", "error"];
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]",
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30",
path.join(OUT, "preview.mp4"));
execFileSync("ffmpeg", args);
manifest.video = "preview.mp4";
manifest.poster = await poster(browser);
}