Skip to content

Commit 194fb69

Browse files
fix(capture,audio): close the three contract gaps raised in review
Review on #2892 (Rames, Magi) found the fixes correct inside the changed files but incomplete at the contract level. All three hold up against source; two of the three were reachable in production, and the plate one was self-inflicted by this PR. **The plate guard checked a stale height.** `scrollHeight` was measured before the scroll traversal and handed to the guard, but the plate is deliberately shot *after* it so lazy content has loaded — and lazy loading grows the document. The guard's input therefore read low on exactly the long pages it exists for, letting the check pass and a clipped plate through, undetectable downstream because the skill only teaches the tile fallback when the file is *absent*. `captureFullPagePlate` now measures the height itself at call time, and verifies what Chrome actually produced by reading the PNG's IHDR before writing, since the capture can trigger another round of loading. Over the cap, nothing is emitted. **Assembly dropped the flag again.** `bgm_pending` survived into `audio_meta.json` but `assemble-index.mjs` rebuilt its audio object from three named keys, so at the step that actually builds the film "not ready yet" still looked like "silent by design" — this PR's own framing of the defect, one layer further down. The flag rides along now, and a pending bed with no file raises an anomaly instead of quietly assembling a silent cut against a storyboard that promises music. **The sibling adapters had both audio bugs, and there were two of them.** The review named `faceless-explainer`; `pr-to-video` carries the same file. Its own test asserts the two are byte-identical ("intentionally identical across the reusing skills"), so fixing one alone broke that test — which is what caught the second copy. Both now carry the absence-sentinel filter and the surviving `bgm_pending`, and `faceless-explainer` gets the same five regression tests. Also from review (Miga): the sticky-restore in `finally` is wrapped, so a page that broke mid-capture cannot replace the real error with a cleanup one. Validation: `vitest run src/capture` — 95 pass (5 new) · product-launch audio 13 pass · faceless-explainer audio 10 pass (5 new, incl. the byte-identity contract) · `bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
1 parent 37961e3 commit 194fb69

7 files changed

Lines changed: 277 additions & 40 deletions

File tree

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

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,62 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { Page } from "puppeteer-core";
6-
import { captureFullPagePlate, MAX_PLATE_HEIGHT_PX } from "./screenshotCapture.js";
6+
import { captureFullPagePlate, MAX_PLATE_HEIGHT_PX, pngHeight } from "./screenshotCapture.js";
7+
8+
// A real 1920x800 PNG header, so the produced-height guard sees something valid.
9+
function pngBuffer(height: number, width = 1920): Buffer {
10+
const buf = Buffer.alloc(24);
11+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
12+
buf.writeUInt32BE(13, 8);
13+
buf.write("IHDR", 12, "ascii");
14+
buf.writeUInt32BE(width, 16);
15+
buf.writeUInt32BE(height, 20);
16+
return buf;
17+
}
718

819
// The mocks declare their parameters so `mock.calls[i][0]` is a real slot — a zero-arg
920
// vi.fn() types its call tuple as [] and indexing it is a compile error.
10-
function fakePage(overrides: Record<string, unknown> = {}) {
11-
const evaluate = vi.fn(async (_script?: unknown) => undefined);
12-
const screenshot = vi.fn(async (_opts?: unknown) => Buffer.from("PNG-BYTES"));
21+
// `docHeight` is what the in-function measurement returns; the plate reads the page height
22+
// itself now rather than trusting a value the caller measured before scrolling.
23+
function fakePage(
24+
{ docHeight = 8000, plateHeight = 8000 }: { docHeight?: number; plateHeight?: number } = {},
25+
overrides: Record<string, unknown> = {},
26+
) {
27+
const evaluate = vi.fn(async (script?: unknown) =>
28+
String(script).includes("scrollHeight") ? docHeight : undefined,
29+
);
30+
const screenshot = vi.fn(async (_opts?: unknown) => pngBuffer(plateHeight));
1331
return { page: { evaluate, screenshot, ...overrides } as unknown as Page, evaluate, screenshot };
1432
}
1533

1634
describe("captureFullPagePlate — the scroll shot's plate", () => {
1735
it("writes one full-page png and returns its relative path", async () => {
1836
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
19-
const { page, screenshot } = fakePage();
37+
const { page, screenshot } = fakePage({ docHeight: 10962, plateHeight: 10962 });
2038

21-
const out = await captureFullPagePlate(page, dir, 10962);
39+
const out = await captureFullPagePlate(page, dir);
2240

2341
expect(out).toBe("screenshots/full-page.png");
2442
expect(screenshot).toHaveBeenCalledWith({ type: "png", fullPage: true });
25-
expect(readFileSync(join(dir, "full-page.png"), "utf8")).toBe("PNG-BYTES");
43+
expect(pngHeight(readFileSync(join(dir, "full-page.png")))).toBe(10962);
2644
});
2745

2846
it("stays 1x: it never touches the viewport's deviceScaleFactor", async () => {
2947
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
3048
const setViewport = vi.fn(async () => undefined);
31-
const { page } = fakePage({ setViewport });
49+
const { page } = fakePage({}, { setViewport });
3250

33-
await captureFullPagePlate(page, dir, 4000);
51+
await captureFullPagePlate(page, dir);
3452

3553
// A 2x plate would exceed the cap on exactly the long pages that want a scroll shot.
3654
expect(setViewport).not.toHaveBeenCalled();
3755
});
3856

3957
it("skips a page taller than Chrome can capture, instead of writing a clipped plate", async () => {
4058
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
41-
const { page, screenshot } = fakePage();
59+
const { page, screenshot } = fakePage({ docHeight: MAX_PLATE_HEIGHT_PX + 1 });
4260

43-
const out = await captureFullPagePlate(page, dir, MAX_PLATE_HEIGHT_PX + 1);
61+
const out = await captureFullPagePlate(page, dir);
4462

4563
expect(out).toBeNull();
4664
expect(screenshot).not.toHaveBeenCalled();
@@ -51,22 +69,24 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
5169
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
5270
const { page, evaluate, screenshot } = fakePage();
5371

54-
await captureFullPagePlate(page, dir, 8000);
72+
await captureFullPagePlate(page, dir);
5573

5674
const scripts = evaluate.mock.calls.map((c) => String(c[0]));
57-
expect(scripts).toHaveLength(2);
58-
// Neutralise first — a fixed header would otherwise bake in mid-plate.
59-
expect(scripts[0]).toContain("'fixed'");
60-
expect(scripts[0]).toContain("'sticky'");
61-
expect(scripts[0]).toContain("data-hf-plate-position");
62-
// Then hand the page back unchanged: the caller keeps reading the DOM after this.
63-
expect(scripts[1]).toContain("removeAttribute");
75+
// height probe, neutralise, restore
76+
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'");
6481
expect(scripts[1]).toContain("data-hf-plate-position");
65-
expect(evaluate.mock.invocationCallOrder[0]).toBeLessThan(
82+
// Then hand the page back unchanged: the caller keeps reading the DOM after this.
83+
expect(scripts[2]).toContain("removeAttribute");
84+
expect(scripts[2]).toContain("data-hf-plate-position");
85+
expect(evaluate.mock.invocationCallOrder[1]).toBeLessThan(
6686
screenshot.mock.invocationCallOrder[0]!,
6787
);
6888
expect(screenshot.mock.invocationCallOrder[0]).toBeLessThan(
69-
evaluate.mock.invocationCallOrder[1]!,
89+
evaluate.mock.invocationCallOrder[2]!,
7090
);
7191
});
7292

@@ -75,11 +95,63 @@ describe("captureFullPagePlate — the scroll shot's plate", () => {
7595
const screenshot = vi.fn(async (_opts?: unknown) => {
7696
throw new Error("capture failed");
7797
});
78-
const { page, evaluate } = fakePage({ screenshot });
98+
const { page, evaluate } = fakePage({}, { screenshot });
7999

80-
await expect(captureFullPagePlate(page, dir, 8000)).rejects.toThrow("capture failed");
100+
await expect(captureFullPagePlate(page, dir)).rejects.toThrow("capture failed");
81101
// A page left with every sticky element forced static would corrupt the extraction
82102
// passes that run after this one.
83103
expect(String(evaluate.mock.calls.at(-1)?.[0])).toContain("removeAttribute");
84104
});
85105
});
106+
107+
describe("captureFullPagePlate — guards against a silently clipped plate", () => {
108+
it("measures the height itself, after lazy content has grown the page", async () => {
109+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
110+
// A page that measured 9000 before scrolling but is 20000 once lazy images land: the
111+
// pre-scroll number would have passed the guard and emitted a clipped plate.
112+
const { page, screenshot } = fakePage({ docHeight: 20000 });
113+
114+
expect(await captureFullPagePlate(page, dir)).toBeNull();
115+
expect(screenshot).not.toHaveBeenCalled();
116+
});
117+
118+
it("discards a plate Chrome clipped, even when the measurement passed", async () => {
119+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
120+
// Measurement said 16000, but the capture itself triggered more loading and came back
121+
// over the cap. Emitting it would be undetectable downstream.
122+
const { page } = fakePage({ docHeight: 16000, plateHeight: MAX_PLATE_HEIGHT_PX + 500 });
123+
124+
expect(await captureFullPagePlate(page, dir)).toBeNull();
125+
expect(existsSync(join(dir, "full-page.png"))).toBe(false);
126+
});
127+
128+
it("survives a restore that throws — the real error is what propagates", async () => {
129+
const dir = mkdtempSync(join(tmpdir(), "hf-plate-"));
130+
let call = 0;
131+
const evaluate = vi.fn(async (script?: unknown) => {
132+
call++;
133+
if (String(script).includes("scrollHeight")) return 8000;
134+
if (String(script).includes("removeAttribute")) throw new Error("page crashed");
135+
return undefined;
136+
});
137+
const screenshot = vi.fn(async (_opts?: unknown) => {
138+
throw new Error("capture failed");
139+
});
140+
const page = { evaluate, screenshot } as unknown as Page;
141+
142+
// Without the try/catch in `finally`, "page crashed" would mask "capture failed".
143+
await expect(captureFullPagePlate(page, dir)).rejects.toThrow("capture failed");
144+
expect(call).toBeGreaterThanOrEqual(3);
145+
});
146+
});
147+
148+
describe("pngHeight", () => {
149+
it("reads the height out of the IHDR chunk", () => {
150+
expect(pngHeight(pngBuffer(10962))).toBe(10962);
151+
});
152+
153+
it("returns null for anything that is not a PNG", () => {
154+
expect(pngHeight(Buffer.from("not a png at all, definitely not"))).toBeNull();
155+
expect(pngHeight(Buffer.alloc(4))).toBeNull();
156+
});
157+
});

packages/cli/src/capture/screenshotCapture.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ import { join } from "node:path";
2727
*/
2828
export const MAX_PLATE_HEIGHT_PX = 16384;
2929

30+
/**
31+
* Pixel height Chrome actually produced, read from the PNG's IHDR chunk: 8-byte signature,
32+
* then 4 length + 4 type + 4 width + 4 height. Null when the buffer isn't a PNG.
33+
*/
34+
export function pngHeight(buf: Uint8Array): number | null {
35+
// Byte math rather than Buffer helpers: page.screenshot() resolves to a Uint8Array.
36+
if (buf.length < 24) return null;
37+
if (buf[12] !== 0x49 || buf[13] !== 0x48 || buf[14] !== 0x44 || buf[15] !== 0x52) return null; // "IHDR"
38+
return ((buf[20]! << 24) | (buf[21]! << 16) | (buf[22]! << 8) | buf[23]!) >>> 0;
39+
}
40+
3041
/**
3142
* One tall image of the whole document — the plate a scroll shot slides its viewport over.
3243
*
@@ -48,9 +59,15 @@ export const MAX_PLATE_HEIGHT_PX = 16384;
4859
export async function captureFullPagePlate(
4960
page: Page,
5061
screenshotsDir: string,
51-
scrollHeight: number,
5262
): Promise<string | null> {
53-
if (scrollHeight > MAX_PLATE_HEIGHT_PX) return 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;
5471

5572
// Record the inline value before overwriting so the page is handed back unchanged — the
5673
// caller keeps using it (asset extraction, DOM reads) after this returns.
@@ -65,15 +82,27 @@ export async function captureFullPagePlate(
6582
);
6683
try {
6784
const buffer = await page.screenshot({ type: "png", fullPage: true });
85+
// Confirm what Chrome produced instead of trusting the measurement: the capture itself can
86+
// trigger another round of lazy loading. A clipped plate is undetectable downstream — the
87+
// skill only teaches the tile fallback when the file is *absent* — so emit nothing rather
88+
// than something silently wrong.
89+
const produced = pngHeight(buffer);
90+
if (produced != null && produced > MAX_PLATE_HEIGHT_PX) return null;
6891
writeFileSync(join(screenshotsDir, "full-page.png"), buffer);
6992
return "screenshots/full-page.png";
7093
} finally {
71-
await page.evaluate(
72-
`document.querySelectorAll('[data-hf-plate-position]').forEach((el) => {
73-
el.style.position = el.getAttribute('data-hf-plate-position');
74-
el.removeAttribute('data-hf-plate-position');
75-
})`,
76-
);
94+
// A page that broke mid-capture will fail this too; letting that escape would replace the
95+
// real error with a cleanup one. Nothing to restore if the page is already gone.
96+
try {
97+
await page.evaluate(
98+
`document.querySelectorAll('[data-hf-plate-position]').forEach((el) => {
99+
el.style.position = el.getAttribute('data-hf-plate-position');
100+
el.removeAttribute('data-hf-plate-position');
101+
})`,
102+
);
103+
} catch {
104+
/* page unusable — the restore is moot */
105+
}
77106
}
78107
}
79108

@@ -212,7 +241,7 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P
212241
// dropped because 1/8 agents read it and the contact sheet covered the same ground — that
213242
// was about it as a *comprehension* artifact. The scroll shot is a different consumer: it
214243
// needs one continuous plate, which no set of viewport tiles can substitute for.)
215-
const plate = await captureFullPagePlate(page, screenshotsDir, scrollHeight);
244+
const plate = await captureFullPagePlate(page, screenshotsDir);
216245
if (plate) filePaths.push(plate);
217246
} catch {
218247
/* scroll screenshots are non-critical */

skills-manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"files": 140
77
},
88
"faceless-explainer": {
9-
"hash": "b772a9b6c8118c2c",
9+
"hash": "b458fdb62c5e1402",
1010
"files": 22
1111
},
1212
"figma": {
@@ -58,11 +58,11 @@
5858
"files": 132
5959
},
6060
"pr-to-video": {
61-
"hash": "44a9877e7ea1289e",
61+
"hash": "01dc26f444bc20cd",
6262
"files": 29
6363
},
6464
"product-launch-video": {
65-
"hash": "ead12de8df2ed55d",
65+
"hash": "4412edf071681ceb",
6666
"files": 26
6767
},
6868
"remotion-to-hyperframes": {

skills/faceless-explainer/scripts/audio.mjs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,19 @@ function toProductLaunchMeta(neutral) {
105105
duration_s: neutral.bgm.duration_s ?? null,
106106
}
107107
: null;
108+
// bgm_pending must survive the neutral → skill translation. A detached generate
109+
// (Lyria/MusicGen) leaves `bgm: null, bgm_pending: true` until the track lands; dropping the
110+
// flag makes "not ready yet" indistinguishable from "silent by design", so a later
111+
// `fetch-sfx` snapshot turns a still-generating bed into no music at all with no signal.
112+
const bgmPending = !!neutral.bgm_pending;
108113
const sfx = (neutral.sfx ?? []).map((s) => ({
109114
frame: Number(s.id),
110115
file: s.file,
111116
offset_s: s.offset_s ?? 0,
112117
duration_s: s.duration_s ?? 1,
113118
volume: s.volume ?? 0.35,
114119
}));
115-
return { bgm, voices, sfx };
120+
return { bgm, bgm_pending: bgmPending, voices, sfx };
116121
}
117122

118123
// ── generate (TTS + BGM) ────────────────────────────────────────────────────
@@ -193,12 +198,16 @@ function runFetchSfx(argv) {
193198
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
194199

195200
// Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx.
201+
// `filter(Boolean)` alone is not enough: a storyboard that spells "no SFX here" as
202+
// `sfx: none` reaches the engine as a cue literally NAMED "none", which then fails to
203+
// resolve. The absence sentinels are part of the storyboard vocabulary, so drop them.
204+
const SFX_NONE = new Set(["none", "no", "n/a", "na", "skip", "-", "—", "–"]);
196205
const lines = [];
197206
for (const f of manifest.frames) {
198207
const names = (f.extra?.sfx ?? "")
199208
.split(",")
200209
.map((s) => s.trim())
201-
.filter(Boolean);
210+
.filter((s) => s && !SFX_NONE.has(s.toLowerCase()));
202211
if (names.length && f.number != null) lines.push({ id: pad2(f.number), sfx: names });
203212
}
204213

@@ -212,6 +221,15 @@ function runFetchSfx(argv) {
212221
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
213222
writeFileSync(outPath, JSON.stringify(meta, null, 2));
214223
console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`);
224+
// This pass rewrites audio_meta.json from the neutral sidecar. If a detached BGM generate is
225+
// still running, the bed it eventually writes is NOT folded back in — the snapshot we just
226+
// took has no music. Say so instead of leaving a silent film behind.
227+
if (meta.bgm_pending && !meta.bgm) {
228+
console.warn(
229+
"⚠ audio fetch-sfx: a detached BGM generate is still pending, so this snapshot has no bed. " +
230+
"Re-run `fetch-sfx` (or re-point audio_meta.json at the track) once it lands, before assembling.",
231+
);
232+
}
215233
}
216234

217235
// ── sync-durations (local; rewrites STORYBOARD.md) ────────────────────────────

0 commit comments

Comments
 (0)