Skip to content

Commit 3df8a8f

Browse files
committed
test(producer): pin rebuildExtractedFramesFromPlanDir 0-based framePaths contract
Regression guard for the off-by-one fix in HF#1730. The pre-fix code indexed framePaths 1-based while the consumer (getFrameAtTime in engine/videoFrameExtractor.ts:958) reads 0-based, dropping every <video>'s first-paint frame in distributed chunk-lambda renders. Asserts framePaths.get(0) resolves to the first extracted frame, and framePaths.get(N-1) resolves to the last — pre-fix the keys were shifted to [1..N], so get(0) returned undefined and get(N) resolved. Verified to fail against the previous i+1 indexing. Also exports rebuildExtractedFramesFromPlanDir (was module-local) so the test can call it directly. Pure logic worth testing in isolation — the bug only reproduces under distributed mode and the existing renderChunk.test.ts already pays a multi-second Chrome smoke probe in its module-level beforeAll, so the regression check lives in its own file (rebuildExtractedFrames.test.ts) and runs Chrome-free in ~10ms. The function's doc comment said "1-based framePaths" — updated to "0-based" with a pointer to the consumer site and the bug context. Per Miguel's REQUEST_CHANGES on HF#1730. — Jerrai (https://claude.com/claude-code)
1 parent 6fa46f9 commit 3df8a8f

2 files changed

Lines changed: 165 additions & 3 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Regression guard for HF#1731 / HF#1730 — pins the 0-based `framePaths`
3+
* key convention on `rebuildExtractedFramesFromPlanDir`.
4+
*
5+
* Why a separate file from `renderChunk.test.ts`: that file's top-level
6+
* `beforeAll` boots a Chrome smoke probe so the byte-identical-retry
7+
* assertions can soft-skip on hosts where chrome-headless-shell can't
8+
* initialize. The probe is a 5-15s tax on the whole module even when no
9+
* Chrome-dependent test runs. This file is pure-filesystem and stays
10+
* Chrome-free so the regression check runs in ~10ms on every PR.
11+
*
12+
* The bug: the consumer of `framePaths` is
13+
* `videoFrameExtractor.ts:getFrameAtTime`, which computes
14+
* `Math.floor(localTime * fps + 1e-9)` (0-based) and reads
15+
* `framePaths.get(frameIndex)`. The pre-fix code in
16+
* `rebuildExtractedFramesFromPlanDir` wrote `framePaths.set(i + 1, …)`
17+
* instead of `framePaths.set(i, …)`, so `framePaths.get(0)` returned
18+
* `undefined` at every `<video>`'s first-paint frame: the vid silently
19+
* dropped out of activePayloads, the injector didn't fire, and
20+
* BeginFrame screenshotted an empty composition (Y≈22 black flash for
21+
* one frame at each first-paint boundary). Symptom only reproduced in
22+
* distributed mode — local single-process renders take a different
23+
* code path (`videoFrameExtractor.ts:317`) that was already 0-based.
24+
*
25+
* This test must FAIL against the previous `i + 1` indexing and PASS
26+
* against the fix. Don't soften it into a "key set has expected size"
27+
* shape — the bug was specifically that `get(0)` returned undefined,
28+
* so assert that directly.
29+
*/
30+
31+
import { describe, expect, it } from "bun:test";
32+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33+
import { tmpdir } from "node:os";
34+
import { join } from "node:path";
35+
import { rebuildExtractedFramesFromPlanDir } from "./renderChunk.js";
36+
import type { PlanVideosJson } from "./shared.js";
37+
38+
function makeFramesDir(planDir: string, videoId: string, frameNames: string[]): void {
39+
const outputDir = join(planDir, "video-frames", videoId);
40+
mkdirSync(outputDir, { recursive: true });
41+
for (const name of frameNames) {
42+
// Contents don't matter — the function only lists + sorts the dir
43+
// and maps names to absolute paths. A single byte keeps the test
44+
// fast and stays well clear of any filesystem reservation quirks.
45+
writeFileSync(join(outputDir, name), "x", "utf-8");
46+
}
47+
}
48+
49+
// The function never reads inside `metadata`, it just forwards it onto
50+
// the returned `ExtractedFrames`. A bare cast spares us the full
51+
// `VideoMetadata` shape per test.
52+
const VIDEO_METADATA_STUB = {} as PlanVideosJson["extracted"][number]["metadata"];
53+
54+
describe("rebuildExtractedFramesFromPlanDir", () => {
55+
it("indexes framePaths 0-based (regression guard for HF#1731)", () => {
56+
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-0based-"));
57+
try {
58+
const videoId = "vid-0";
59+
// Zero-padded monotonic names — same shape `extractVideoFramesRange`
60+
// produces (`frame_%05d.jpg`).
61+
const frameNames = [
62+
"frame_00001.jpg",
63+
"frame_00002.jpg",
64+
"frame_00003.jpg",
65+
"frame_00004.jpg",
66+
"frame_00005.jpg",
67+
];
68+
makeFramesDir(planDir, videoId, frameNames);
69+
70+
const result = rebuildExtractedFramesFromPlanDir(planDir, [
71+
{
72+
videoId,
73+
srcPath: "/does/not/matter.mp4",
74+
framePattern: "frame_%05d.jpg",
75+
fps: 30,
76+
totalFrames: frameNames.length,
77+
metadata: VIDEO_METADATA_STUB,
78+
},
79+
]);
80+
81+
expect(result).toHaveLength(1);
82+
const extracted = result[0]!;
83+
expect(extracted.framePaths.size).toBe(frameNames.length);
84+
85+
// The load-bearing assertion. `getFrameAtTime` calls
86+
// `framePaths.get(0)` for every video's first-paint frame
87+
// (localTime === 0 → frameIndex === 0). The pre-fix code's `i + 1`
88+
// indexing meant this returned `undefined` and the vid silently
89+
// dropped from activePayloads — that's HF#1731.
90+
const first = extracted.framePaths.get(0);
91+
expect(first).toBeDefined();
92+
expect(first).toBe(join(planDir, "video-frames", videoId, "frame_00001.jpg"));
93+
94+
// Last frame at index N-1 (0-based) must resolve; index N must not.
95+
// Together with `get(0)` defined this fully pins the 0-based
96+
// contract — the pre-fix shape would have `get(N)` defined and
97+
// `get(0)` undefined.
98+
const last = extracted.framePaths.get(frameNames.length - 1);
99+
expect(last).toBe(join(planDir, "video-frames", videoId, "frame_00005.jpg"));
100+
expect(extracted.framePaths.get(frameNames.length)).toBeUndefined();
101+
} finally {
102+
rmSync(planDir, { recursive: true, force: true });
103+
}
104+
});
105+
106+
it("preserves 0-based indexing across multiple videos in the same planDir", () => {
107+
// Multi-video shape — the HF#1731 repro was a back-to-back composition
108+
// (v1: 0-4s, v2: 4-8s, v3: 8-12s) and EVERY vid's first-paint frame
109+
// was PRISTINE black. The function must produce the 0-based contract
110+
// for every video in the manifest, not just the first.
111+
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-multi-"));
112+
try {
113+
makeFramesDir(planDir, "vid-a", ["frame_00001.jpg", "frame_00002.jpg"]);
114+
makeFramesDir(planDir, "vid-b", ["frame_00001.jpg", "frame_00002.jpg", "frame_00003.jpg"]);
115+
116+
const result = rebuildExtractedFramesFromPlanDir(planDir, [
117+
{
118+
videoId: "vid-a",
119+
srcPath: "/a.mp4",
120+
framePattern: "frame_%05d.jpg",
121+
fps: 30,
122+
totalFrames: 2,
123+
metadata: VIDEO_METADATA_STUB,
124+
},
125+
{
126+
videoId: "vid-b",
127+
srcPath: "/b.mp4",
128+
framePattern: "frame_%05d.jpg",
129+
fps: 30,
130+
totalFrames: 3,
131+
metadata: VIDEO_METADATA_STUB,
132+
},
133+
]);
134+
135+
expect(result).toHaveLength(2);
136+
// Every video's `framePaths.get(0)` must resolve — pre-fix, all of
137+
// them returned undefined and every vid's first-paint dropped.
138+
expect(result[0]!.framePaths.get(0)).toBe(
139+
join(planDir, "video-frames", "vid-a", "frame_00001.jpg"),
140+
);
141+
expect(result[1]!.framePaths.get(0)).toBe(
142+
join(planDir, "video-frames", "vid-b", "frame_00001.jpg"),
143+
);
144+
// Cleanup ownership flag — chunk workers don't own the planDir's
145+
// video-frames tree (the controller does); a flip would cause the
146+
// injector cleanup to rm bytes another worker may still be reading.
147+
expect(result[0]!.ownedByLookup).toBe(false);
148+
expect(result[1]!.ownedByLookup).toBe(false);
149+
} finally {
150+
rmSync(planDir, { recursive: true, force: true });
151+
}
152+
});
153+
});

packages/producer/src/services/distributed/renderChunk.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,19 @@ export interface ChunkResult {
166166
* Rebuild the engine's in-memory `ExtractedFrames[]` from the on-disk
167167
* planDir layout. `<planDir>/video-frames/<videoId>/` holds the numbered
168168
* frame files plan() extracted; this lists each dir and rebuilds the
169-
* 1-based `framePaths` Map that `FrameLookupTable` / `videoFrameInjector`
170-
* both index against.
169+
* 0-based `framePaths` Map that `FrameLookupTable` / `videoFrameInjector`
170+
* both index against — the consumer is
171+
* `videoFrameExtractor.ts:getFrameAtTime`, which floors `localTime * fps`
172+
* to a 0-based index and reads `framePaths.get(frameIndex)`. Any drift
173+
* from that key convention silently drops every `<video>`'s first-paint
174+
* frame; see HF#1731 / HF#1730.
175+
*
176+
* Exported so a unit test can pin the 0-based contract without spinning
177+
* up the heavyweight Docker fixture — the bug surfaces only under
178+
* distributed mode and only at video first-paint, so this primitive is
179+
* the right granularity to guard.
171180
*/
172-
function rebuildExtractedFramesFromPlanDir(
181+
export function rebuildExtractedFramesFromPlanDir(
173182
planDir: string,
174183
videos: PlanVideosJson["extracted"],
175184
): ExtractedFrames[] {

0 commit comments

Comments
 (0)