Skip to content

Commit 6b93077

Browse files
fix(capture,audio): meet the two review asks I under-delivered on
Follow-up to 194fb69. Re-read Magi's review body rather than working from the summary, and two of the three blockers were addressed in spirit but not to the letter. **The plate probed before neutralisation, not after.** 194fb69 moved the measurement off the caller's stale value and into the function, but took it before forcing fixed/sticky elements to `static`. The review called this out specifically and is right: dropping those elements back into flow grows the document, so the probe could still read under the cap on a page that is over it once neutralised. The probe now runs after neutralisation and before the shot, inside the same `try` so restoration still happens on the early return. Added the exact case asked for — initial height under the cap, final height over it — asserting no screenshot is taken, no file is written, and the page is still handed back unmodified. **Assembly warned where the review asked it to refuse.** An anomaly in a list is not enforcement: assemble is re-run on Step 6 rework, long after the audio step's warning scrolled past, and a warning still lets a silent film out the door over a snapshot whose own JSON says the bed is generating. `assemble-index.mjs` now dies on `bgm_pending && !bgm`, with `--allow-pending-bgm` as the deliberate escape for previewing mid-generate. Pinned with three tests in a new `assemble-index.test.mjs`: refusal writes no index.html, the escape assembles and says so, and a film that is silent *by design* still assembles untouched — the distinction the flag exists to make. Validation: `vitest run src/capture` — 96 pass (6 new) · product-launch audio 13 pass · assemble-index 3 pass (new file) · faceless-explainer audio 10 pass · `bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
1 parent 194fb69 commit 6b93077

5 files changed

Lines changed: 132 additions & 22 deletions

File tree

packages/cli/src/capture/screenshotCapture.test.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
7272
await captureFullPagePlate(page, dir);
7373

7474
const scripts = evaluate.mock.calls.map((c) => String(c[0]));
75-
// height probe, neutralise, restore
75+
// neutralise, height probe, restore — the probe sits after neutralisation because
76+
// forcing fixed/sticky to `static` puts those elements back in flow and grows the page.
7677
expect(scripts).toHaveLength(3);
77-
expect(scripts[0]).toContain("scrollHeight");
78-
// Neutralise before the shot — a fixed header would otherwise bake in mid-plate.
79-
expect(scripts[1]).toContain("'fixed'");
80-
expect(scripts[1]).toContain("'sticky'");
81-
expect(scripts[1]).toContain("data-hf-plate-position");
78+
expect(scripts[0]).toContain("'fixed'");
79+
expect(scripts[0]).toContain("'sticky'");
80+
expect(scripts[0]).toContain("data-hf-plate-position");
81+
expect(scripts[1]).toContain("scrollHeight");
8282
// Then hand the page back unchanged: the caller keeps reading the DOM after this.
8383
expect(scripts[2]).toContain("removeAttribute");
8484
expect(scripts[2]).toContain("data-hf-plate-position");
@@ -155,3 +155,30 @@ describe("pngHeight", () => {
155155
expect(pngHeight(Buffer.alloc(4))).toBeNull();
156156
});
157157
});
158+
159+
describe("captureFullPagePlate — the guard sees the post-neutralisation page (Magi's case)", () => {
160+
it("skips when the initial height is under the cap but the final height is over it", async () => {
161+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
162+
// Pre-traversal the page measured 9000. Lazy content and un-fixing the sticky header push
163+
// it over the cap by the time the plate would be shot. Probing before either step would
164+
// have passed the guard and emitted a clipped plate.
165+
let neutralised = false;
166+
const evaluate = vi.fn(async (script?: unknown) => {
167+
const src = String(script);
168+
if (src.includes("'sticky'")) {
169+
neutralised = true;
170+
return undefined;
171+
}
172+
if (src.includes("scrollHeight")) return neutralised ? 20000 : 9000;
173+
return undefined;
174+
});
175+
const screenshot = vi.fn(async (_opts?: unknown) => pngBuffer(20000));
176+
const page = { evaluate, screenshot } as unknown as Page;
177+
178+
expect(await captureFullPagePlate(page, dir)).toBeNull();
179+
expect(screenshot).not.toHaveBeenCalled();
180+
expect(existsSync(join(dir, "full-page.png"))).toBe(false);
181+
// Bailing out early must still hand the page back unmodified.
182+
expect(String(evaluate.mock.calls.at(-1)?.[0])).toContain("removeAttribute");
183+
});
184+
});

packages/cli/src/capture/screenshotCapture.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,6 @@ export async function captureFullPagePlate(
6060
page: Page,
6161
screenshotsDir: string,
6262
): Promise<string | null> {
63-
// Measured here rather than taken from the caller: the plate is deliberately shot AFTER the
64-
// scroll traversal, and lazy content grows the document as it loads — a height measured
65-
// before scrolling reads low on exactly the long pages this guard exists for, which would
66-
// let the check pass and a clipped plate through.
67-
const docHeight = (await page.evaluate(
68-
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
69-
)) as number;
70-
if (docHeight > MAX_PLATE_HEIGHT_PX) return null;
71-
7263
// Record the inline value before overwriting so the page is handed back unchanged — the
7364
// caller keeps using it (asset extraction, DOM reads) after this returns.
7465
await page.evaluate(
@@ -81,6 +72,16 @@ export async function captureFullPagePlate(
8172
})`,
8273
);
8374
try {
75+
// Measured here — after the caller's scroll traversal AND after neutralisation — never
76+
// taken from the caller. Both steps grow the document: lazy content loads as the page is
77+
// scrolled, and forcing fixed/sticky elements to `static` drops them back into flow. A
78+
// height read before either one is low on exactly the long pages this guard exists for,
79+
// which would pass the check and let a clipped plate through.
80+
const docHeight = (await page.evaluate(
81+
`Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`,
82+
)) as number;
83+
if (docHeight > MAX_PLATE_HEIGHT_PX) return null;
84+
8485
const buffer = await page.screenshot({ type: "png", fullPage: true });
8586
// Confirm what Chrome produced instead of trusting the measurement: the capture itself can
8687
// trigger another round of lazy loading. A clipped plate is undetectable downstream — the

skills-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@
6262
"files": 29
6363
},
6464
"product-launch-video": {
65-
"hash": "4412edf071681ceb",
66-
"files": 26
65+
"hash": "a0f6b1f4c8131ed2",
66+
"files": 27
6767
},
6868
"remotion-to-hyperframes": {
6969
"hash": "3a0e6c2affb9f74e",

skills/product-launch-video/scripts/assemble-index.mjs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ const flag = (name, def) => {
6363
const i = argv.indexOf(`--${name}`);
6464
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
6565
};
66+
// Deliberate escape from the bgm_pending refusal below — for previewing while a detached
67+
// generate is still running. Off by default so a silent film can't ship by accident.
68+
const allowPendingBgm = argv.includes("--allow-pending-bgm");
6669
function die(msg) {
6770
console.error(`✗ assemble-index.mjs: ${msg}`);
6871
process.exit(1);
@@ -586,12 +589,19 @@ if (audio.bgm?.path) {
586589
anomalies.push(`bgm ${audio.bgm.path} not on disk — skipped`);
587590
}
588591
} else if (audio.bgm_pending) {
589-
// The distinction the flag exists to make: this film is not silent by design, its bed just
590-
// has not finished generating. Assembling now ships a silent cut against a storyboard that
591-
// promises music, so say it here rather than let the build read as complete.
592+
// The distinction the flag exists to make. A warning is not enough here: assemble is re-run
593+
// on Step 6 rework, long after the audio step's own warning scrolled past, and it would
594+
// happily build a silent film from a snapshot whose JSON says the bed is still generating.
595+
// Refuse by default; --allow-pending-bgm is the deliberate escape for previewing mid-generate.
596+
if (!allowPendingBgm) {
597+
die(
598+
"audio_meta.json says bgm_pending — the music bed is still generating and is NOT in this " +
599+
"assembly. Wait for the track, re-run the audio step, then assemble again. To assemble a " +
600+
"deliberately silent preview anyway, pass --allow-pending-bgm.",
601+
);
602+
}
592603
anomalies.push(
593-
"bgm is still generating (bgm_pending) — this assembly has NO music bed. Re-run the audio " +
594-
"step and assemble again once the track lands, or the film ships silent.",
604+
"bgm still generating (bgm_pending) — assembled without a bed per --allow-pending-bgm",
595605
);
596606
}
597607

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from "node:assert/strict";
2+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { spawnSync } from "node:child_process";
6+
import test from "node:test";
7+
8+
const assembleScript = new URL("./assemble-index.mjs", import.meta.url).pathname;
9+
10+
// ── bgm_pending at the assembly boundary ─────────────────────────────────────
11+
// Regression: the flag survived into audio_meta.json but assemble rebuilt its audio object
12+
// from three named keys and dropped it, so the step that actually builds the film could not
13+
// tell "not ready yet" from "silent by design" and would ship the silent one.
14+
15+
function assembleWith({ audioMeta, extraArgs = [] }) {
16+
const dir = mkdtempSync(join(tmpdir(), "product-launch-assemble-"));
17+
writeFileSync(
18+
join(dir, "STORYBOARD.md"),
19+
"---\nformat: 1920x1080\nmessage: T\n---\n\n## Frame 1 — A\n- duration: 3s\n- src: compositions/frames/01-a.html\n",
20+
);
21+
mkdirSync(join(dir, "compositions", "frames"), { recursive: true });
22+
writeFileSync(
23+
join(dir, "compositions", "frames", "01-a.html"),
24+
'<div data-composition-id="01-a" data-width="1920" data-height="1080">' +
25+
'<section class="clip" data-start="0" data-duration="3"></section></div>',
26+
);
27+
if (audioMeta) writeFileSync(join(dir, "audio_meta.json"), JSON.stringify(audioMeta));
28+
const r = spawnSync(
29+
process.execPath,
30+
[
31+
assembleScript,
32+
"--storyboard",
33+
join(dir, "STORYBOARD.md"),
34+
"--hyperframes",
35+
dir,
36+
...extraArgs,
37+
],
38+
{ encoding: "utf8" },
39+
);
40+
return { dir, r };
41+
}
42+
43+
test("assemble REFUSES while bgm_pending and no bed on disk", () => {
44+
const { dir, r } = assembleWith({
45+
audioMeta: { bgm: null, bgm_pending: true, voices: [], sfx: [] },
46+
});
47+
48+
assert.notEqual(r.status, 0, "should not assemble a silent film over a pending bed");
49+
assert.match(r.stderr, /bgm_pending/);
50+
// Refusing means producing nothing, not a half-built index.
51+
assert.equal(existsSync(join(dir, "index.html")), false);
52+
});
53+
54+
test("--allow-pending-bgm assembles anyway, and says so", () => {
55+
const { dir, r } = assembleWith({
56+
audioMeta: { bgm: null, bgm_pending: true, voices: [], sfx: [] },
57+
extraArgs: ["--allow-pending-bgm"],
58+
});
59+
60+
assert.equal(r.status, 0, r.stderr);
61+
assert.equal(existsSync(join(dir, "index.html")), true);
62+
assert.match(r.stdout + r.stderr, /pending/i);
63+
});
64+
65+
test("a film that is silent BY DESIGN still assembles untouched", () => {
66+
// The whole point of carrying the flag: this case must stay distinguishable from the above.
67+
const { dir, r } = assembleWith({ audioMeta: { bgm: null, voices: [], sfx: [] } });
68+
69+
assert.equal(r.status, 0, r.stderr);
70+
assert.equal(existsSync(join(dir, "index.html")), true);
71+
assert.doesNotMatch(r.stderr, /bgm_pending/);
72+
});

0 commit comments

Comments
 (0)