Skip to content

Commit c9f43eb

Browse files
fix(engine): preserve source frame identity above 99,999 (#3503)
* fix(engine): preserve extracted frame identity * fix(producer): order legacy distributed frames numerically
1 parent 4f00336 commit c9f43eb

7 files changed

Lines changed: 226 additions & 34 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { basename, join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
import {
7+
ExtractedFrameSequenceError,
8+
extractedFrameIndex,
9+
framePathsFromDirectory,
10+
} from "./extractedFrameIndex.js";
11+
12+
const roots: string[] = [];
13+
14+
function frameDir(): string {
15+
const root = mkdtempSync(join(tmpdir(), "hf-extracted-frame-index-"));
16+
roots.push(root);
17+
return root;
18+
}
19+
20+
function seed(root: string, ...files: string[]): void {
21+
for (const file of files) writeFileSync(join(root, file), file);
22+
}
23+
24+
afterEach(() => {
25+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
26+
});
27+
28+
describe("extractedFrameIndex", () => {
29+
it("derives frame identity across the five-to-six-digit boundary", () => {
30+
expect(extractedFrameIndex("frame_99999.jpg", "jpg")).toBe(99_998);
31+
expect(extractedFrameIndex("frame_100000.jpg", "jpg")).toBe(99_999);
32+
});
33+
34+
it("refuses malformed, zero, and wrong-format frame candidates", () => {
35+
expect(() => extractedFrameIndex("frame_bad.jpg", "jpg")).toThrow(ExtractedFrameSequenceError);
36+
expect(() => extractedFrameIndex("frame_00000.jpg", "jpg")).toThrow(
37+
ExtractedFrameSequenceError,
38+
);
39+
expect(() => extractedFrameIndex("frame_00001.png", "jpg")).toThrow(
40+
ExtractedFrameSequenceError,
41+
);
42+
});
43+
});
44+
45+
describe("framePathsFromDirectory", () => {
46+
it("maps by the filename ordinal instead of directory or lexical position", () => {
47+
const root = frameDir();
48+
seed(
49+
root,
50+
...Array.from({ length: 10 }, (_, index) => `frame_${index + 1}.jpg`).reverse(),
51+
"notes.txt",
52+
);
53+
54+
const paths = framePathsFromDirectory(root, "jpg");
55+
56+
expect(paths.size).toBe(10);
57+
expect(basename(paths.get(8)!)).toBe("frame_9.jpg");
58+
expect(basename(paths.get(9)!)).toBe("frame_10.jpg");
59+
});
60+
61+
it("fails loudly when two filenames claim the same numeric frame", () => {
62+
const root = frameDir();
63+
seed(root, "frame_1.jpg", "frame_00001.jpg");
64+
65+
expect(() => framePathsFromDirectory(root, "jpg")).toThrow(/duplicate.*frame index 0/i);
66+
});
67+
68+
it("fails loudly instead of shifting later frames across a gap", () => {
69+
const root = frameDir();
70+
seed(root, "frame_00001.jpg", "frame_00003.jpg");
71+
72+
expect(() => framePathsFromDirectory(root, "jpg")).toThrow(/missing.*frame index 1/i);
73+
});
74+
75+
it("fails on frame-prefixed malformed candidates but ignores unrelated files", () => {
76+
const root = frameDir();
77+
seed(root, "frame_00001.jpg", "frame_bad.jpg", "notes.txt");
78+
79+
expect(() => framePathsFromDirectory(root, "jpg")).toThrow(/invalid.*frame filename/i);
80+
});
81+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { readdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
export type ExtractedFrameFormat = "jpg" | "png";
5+
6+
export const FRAME_FILENAME_PREFIX = "frame_";
7+
8+
export class ExtractedFrameSequenceError extends Error {
9+
constructor(message: string) {
10+
super(message);
11+
this.name = "ExtractedFrameSequenceError";
12+
}
13+
}
14+
15+
export function extractedFrameIndex(file: string, format: ExtractedFrameFormat): number {
16+
const match = new RegExp(`^${FRAME_FILENAME_PREFIX}(\\d+)\\.${format}$`).exec(file);
17+
if (!match) {
18+
throw new ExtractedFrameSequenceError(`Invalid extracted frame filename: ${file}`);
19+
}
20+
const ordinal = Number.parseInt(match[1]!, 10);
21+
if (!Number.isSafeInteger(ordinal) || ordinal < 1) {
22+
throw new ExtractedFrameSequenceError(`Invalid extracted frame ordinal: ${file}`);
23+
}
24+
return ordinal - 1;
25+
}
26+
27+
export function framePathsFromDirectory(
28+
outputDir: string,
29+
format: ExtractedFrameFormat,
30+
): Map<number, string> {
31+
const suffix = `.${format}`;
32+
const indexed = new Map<number, string>();
33+
for (const file of readdirSync(outputDir)) {
34+
if (!file.startsWith(FRAME_FILENAME_PREFIX) || !file.endsWith(suffix)) continue;
35+
const index = extractedFrameIndex(file, format);
36+
if (indexed.has(index)) {
37+
throw new ExtractedFrameSequenceError(
38+
`Duplicate extracted frame index ${index}: ${indexed.get(index)} and ${file}`,
39+
);
40+
}
41+
indexed.set(index, join(outputDir, file));
42+
}
43+
44+
const ordered = new Map<number, string>();
45+
for (let index = 0; index < indexed.size; index += 1) {
46+
const path = indexed.get(index);
47+
if (!path) {
48+
throw new ExtractedFrameSequenceError(
49+
`Missing extracted frame index ${index} in ${outputDir}`,
50+
);
51+
}
52+
ordered.set(index, path);
53+
}
54+
return ordered;
55+
}

packages/engine/src/services/extractionCache.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
partialCacheEntryDir,
2626
publishCacheEntry,
2727
readKeyStat,
28+
rehydrateCacheEntry,
2829
type CacheKeyInput,
2930
} from "./extractionCache.js";
3031

@@ -76,6 +77,42 @@ describe("extractionCache constants", () => {
7677
});
7778
});
7879

80+
describe("rehydrateCacheEntry frame identity", () => {
81+
it("fails loudly when a complete cache has a frame-number gap", () => {
82+
const { tmpRoot } = makeCacheRoot();
83+
try {
84+
writeFileSync(join(tmpRoot, "frame_00001.jpg"), "one");
85+
writeFileSync(join(tmpRoot, "frame_00003.jpg"), "three");
86+
87+
expect(() =>
88+
rehydrateCacheEntry(
89+
{ dir: tmpRoot, keyHash: "a".repeat(64) },
90+
{
91+
videoId: "video-gap",
92+
srcPath: "/video.mp4",
93+
fps: 30,
94+
format: "jpg",
95+
metadata: {
96+
durationSeconds: 1,
97+
videoStreamDurationSeconds: 1,
98+
width: 1920,
99+
height: 1080,
100+
fps: 30,
101+
videoCodec: "h264",
102+
hasAudio: false,
103+
isVFR: false,
104+
hasAlpha: false,
105+
colorSpace: null,
106+
},
107+
},
108+
),
109+
).toThrow(/missing.*frame index 1/i);
110+
} finally {
111+
removeCacheRoot(tmpRoot);
112+
}
113+
});
114+
});
115+
79116
describe("computeCacheKey", () => {
80117
let tmpRoot: string;
81118
let sourceFile: string;

packages/engine/src/services/extractionCache.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ import {
4646
} from "node:fs";
4747
import { join } from "node:path";
4848
import type { VideoMetadata } from "../utils/ffprobe.js";
49+
import { FRAME_FILENAME_PREFIX, framePathsFromDirectory } from "./extractedFrameIndex.js";
4950

5051
/** Filename prefix for extracted frames. Shared with the extractor. */
51-
export const FRAME_FILENAME_PREFIX = "frame_";
52+
export { FRAME_FILENAME_PREFIX } from "./extractedFrameIndex.js";
5253

5354
/** Sentinel filename written after a cache entry is fully populated. */
5455
export const COMPLETE_SENTINEL = ".hf-complete";
@@ -508,14 +509,7 @@ export function rehydrateCacheEntry(
508509
options: RehydrateOptions,
509510
): RehydratedFrames {
510511
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${options.format}`;
511-
const framePaths = new Map<number, string>();
512-
const suffix = `.${options.format}`;
513-
const files = readdirSync(entry.dir)
514-
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(suffix))
515-
.sort();
516-
files.forEach((file, idx) => {
517-
framePaths.set(idx, join(entry.dir, file));
518-
});
512+
const framePaths = framePathsFromDirectory(entry.dir, options.format);
519513
return {
520514
videoId: options.videoId,
521515
srcPath: options.srcPath,

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Videos are replaced with <img> elements during capture.
77
*/
88

9-
import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync } from "fs";
9+
import { copyFileSync, existsSync, linkSync, mkdirSync, rmSync } from "fs";
1010
import { isAbsolute, join, posix, resolve, sep } from "path";
1111
import { parseHTML } from "linkedom";
1212
import {
@@ -55,6 +55,7 @@ import {
5555
type CacheEntry,
5656
type CacheFrameFormat,
5757
} from "./extractionCache.js";
58+
import { framePathsFromDirectory } from "./extractedFrameIndex.js";
5859

5960
export interface VideoElement {
6061
id: string;
@@ -802,13 +803,7 @@ export async function extractVideoFramesRange(
802803
);
803804
}
804805

805-
const framePaths = new Map<number, string>();
806-
const files = readdirSync(videoOutputDir)
807-
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(`.${format}`))
808-
.sort();
809-
files.forEach((file, index) => {
810-
framePaths.set(index, join(videoOutputDir, file));
811-
});
806+
const framePaths = framePathsFromDirectory(videoOutputDir, format);
812807
if (framePaths.size === 0 && duration > 0) {
813808
throw new VideoSourceExtractionError(
814809
"zero_output",
@@ -1180,24 +1175,14 @@ type SupersetGroupPlan = {
11801175
members: SupersetMemberPlan[];
11811176
};
11821177

1183-
function extractedFrameFileNames(outputDir: string, format: CacheFrameFormat): string[] {
1184-
const suffix = `.${format}`;
1185-
return readdirSync(outputDir)
1186-
.filter((file) => file.startsWith(FRAME_FILENAME_PREFIX) && file.endsWith(suffix))
1187-
.sort();
1188-
}
1189-
11901178
function extractedFramesFromDirectory(
11911179
work: PreparedExtraction,
11921180
outputDir: string,
11931181
srcPath: string,
11941182
fps: number,
11951183
): ExtractedFrames {
11961184
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${work.format}`;
1197-
const framePaths = new Map<number, string>();
1198-
extractedFrameFileNames(outputDir, work.format).forEach((file, index) => {
1199-
framePaths.set(index, join(outputDir, file));
1200-
});
1185+
const framePaths = framePathsFromDirectory(outputDir, work.format);
12011186
return {
12021187
videoId: work.video.id,
12031188
srcPath,

packages/producer/src/services/distributed/rebuildExtractedFrames.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,34 @@ describe("rebuildExtractedFramesFromPlanDir", () => {
178178
}
179179
});
180180

181+
it("orders mixed-width dense-v1 filenames by numeric ordinal", () => {
182+
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-v1-mixed-width-"));
183+
try {
184+
const frameNames = Array.from({ length: 10 }, (_, index) => `frame_${index + 1}.jpg`);
185+
makeFramesDir(planDir, "vid-v1-mixed-width", frameNames.toReversed());
186+
187+
const [extracted] = rebuildExtractedFramesFromPlanDir(planDir, [
188+
{
189+
videoId: "vid-v1-mixed-width",
190+
srcPath: "/v1-mixed-width.mp4",
191+
framePattern: "frame_%05d.jpg",
192+
fps: 30,
193+
totalFrames: frameNames.length,
194+
metadata: VIDEO_METADATA_STUB,
195+
},
196+
]);
197+
198+
expect(extracted!.framePaths.get(8)).toBe(
199+
join(planDir, "video-frames", "vid-v1-mixed-width", "frame_9.jpg"),
200+
);
201+
expect(extracted!.framePaths.get(9)).toBe(
202+
join(planDir, "video-frames", "vid-v1-mixed-width", "frame_10.jpg"),
203+
);
204+
} finally {
205+
rmSync(planDir, { recursive: true, force: true });
206+
}
207+
});
208+
181209
it("preserves original indexes for a sparse v2 chunk materialization", () => {
182210
const planDir = mkdtempSync(join(tmpdir(), "hf-rebuild-frames-sparse-"));
183211
try {

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,13 @@ export async function beginFrameSessionNeedsScreenshotFallback(
324324
return !(await probe(session.page, timeoutMs, probeTick, session.beginFrameIntervalMs));
325325
}
326326

327+
function frameNumberFromFileName(name: string): number | null {
328+
const match = /(\d+)(?=\.[^.]+$)/.exec(name);
329+
if (!match) return null;
330+
const frameNumber = Number(match[1]);
331+
return Number.isSafeInteger(frameNumber) ? frameNumber : null;
332+
}
333+
327334
/**
328335
* Rebuild the engine's in-memory `ExtractedFrames[]` from the on-disk
329336
* planDir layout. `<planDir>/video-frames/<videoId>/` holds the numbered
@@ -355,22 +362,27 @@ export function rebuildExtractedFramesFromPlanDir(
355362
);
356363
}
357364
// framePattern looks like `frame_%05d.jpg`; sprintf isn't available at
358-
// runtime so list-and-sort the directory. Sorted-by-name matches
359-
// sorted-by-frame-index because the extractor writes zero-padded
360-
// monotonic indices.
365+
// runtime so list the directory and order numeric names by their ordinal.
366+
// Width changes once FFmpeg passes the padding minimum, so lexical order
367+
// would interleave frame_100000 before frame_10001.
361368
const ext = (extname(v.framePattern) || ".jpg").toLowerCase();
362369
const frames = readdirSync(outputDir)
363370
.filter((name) => name.toLowerCase().endsWith(ext))
364-
.sort();
371+
.sort((left, right) => {
372+
const leftNumber = frameNumberFromFileName(left);
373+
const rightNumber = frameNumberFromFileName(right);
374+
if (leftNumber === null || rightNumber === null) return left.localeCompare(right);
375+
return leftNumber - rightNumber || left.localeCompare(right);
376+
});
365377
const framePaths = new Map<number, string>();
366378
for (let i = 0; i < frames.length; i++) {
367379
const frameName = frames[i];
368380
if (!frameName) continue;
369381
// V1 plans preserve the historical sorted-position behavior even for
370382
// unusual zero-based filenames. V2 materialization is sparse, so only
371383
// that mode derives the original index from ffmpeg's 1-based filename.
372-
const numbered = indexMode === "sparse-v2" ? /(\d+)(?=\.[^.]+$)/.exec(frameName) : null;
373-
const frameIndex = numbered ? Number(numbered[1]) - 1 : i;
384+
const frameNumber = indexMode === "sparse-v2" ? frameNumberFromFileName(frameName) : null;
385+
const frameIndex = frameNumber === null ? i : frameNumber - 1;
374386
framePaths.set(frameIndex, join(outputDir, frameName));
375387
}
376388
result.push({

0 commit comments

Comments
 (0)