@hyperframes/engine 0.6.118 → 0.6.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
|
@@ -1,1062 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
readdirSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "node:fs";
|
|
11
|
-
import { createHash } from "node:crypto";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { spawnSync } from "node:child_process";
|
|
15
|
-
import {
|
|
16
|
-
parseVideoElements,
|
|
17
|
-
parseImageElements,
|
|
18
|
-
extractAllVideoFrames,
|
|
19
|
-
createFrameLookupTable,
|
|
20
|
-
resolveProjectRelativeSrc,
|
|
21
|
-
resolveFrameFormat,
|
|
22
|
-
codecMayHaveAlpha,
|
|
23
|
-
decoderForCodec,
|
|
24
|
-
getFrameAtTime,
|
|
25
|
-
type VideoElement,
|
|
26
|
-
type ExtractedFrames,
|
|
27
|
-
} from "./videoFrameExtractor.js";
|
|
28
|
-
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
|
29
|
-
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
30
|
-
|
|
31
|
-
// ffmpeg is not preinstalled on GitHub's ubuntu-24.04 runners. The producer
|
|
32
|
-
// regression test at packages/producer/tests/vfr-screen-recording/ runs inside
|
|
33
|
-
// Dockerfile.test (which does include ffmpeg) and is the primary CI signal
|
|
34
|
-
// for this bug. Locally and in any CI job with ffmpeg on PATH, the tests
|
|
35
|
-
// below run too — they exercise the extractor in isolation against a
|
|
36
|
-
// synthesized VFR fixture.
|
|
37
|
-
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
|
|
38
|
-
|
|
39
|
-
// Codec-based alpha defaulting replaces tag-based detection (the
|
|
40
|
-
// alpha_mode/ALPHA_MODE case bug — see ffprobe.test.ts for the regression
|
|
41
|
-
// pin on that). The extractor uses these helpers for two decisions:
|
|
42
|
-
// 1. whether to force the alpha-aware decoder (libvpx-vp9 for VP9, libvpx
|
|
43
|
-
// for VP8)
|
|
44
|
-
// 2. whether to default the cached frame format to PNG (with alpha) vs JPG
|
|
45
|
-
// The "default to capable" trade is small file-size growth on opaque VP9
|
|
46
|
-
// content for correctness on alpha-having content even when the sidecar tag
|
|
47
|
-
// is missing or muxed with the wrong case.
|
|
48
|
-
describe("codec alpha capability", () => {
|
|
49
|
-
it("flags VP9, VP8, and ProRes as alpha-capable", () => {
|
|
50
|
-
expect(codecMayHaveAlpha("vp9")).toBe(true);
|
|
51
|
-
expect(codecMayHaveAlpha("VP9")).toBe(true);
|
|
52
|
-
expect(codecMayHaveAlpha("vp8")).toBe(true);
|
|
53
|
-
expect(codecMayHaveAlpha("prores")).toBe(true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("does not flag h264 / h265 / mpeg4 (no alpha in their bitstreams)", () => {
|
|
57
|
-
expect(codecMayHaveAlpha("h264")).toBe(false);
|
|
58
|
-
expect(codecMayHaveAlpha("h265")).toBe(false);
|
|
59
|
-
expect(codecMayHaveAlpha("hevc")).toBe(false);
|
|
60
|
-
expect(codecMayHaveAlpha("mpeg4")).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("treats undefined / empty input as non-alpha", () => {
|
|
64
|
-
expect(codecMayHaveAlpha(undefined)).toBe(false);
|
|
65
|
-
expect(codecMayHaveAlpha("")).toBe(false);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("returns the alpha-aware decoder name for VP9 and VP8", () => {
|
|
69
|
-
expect(decoderForCodec("vp9")).toBe("libvpx-vp9");
|
|
70
|
-
expect(decoderForCodec("VP9")).toBe("libvpx-vp9");
|
|
71
|
-
expect(decoderForCodec("vp8")).toBe("libvpx");
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("resolveFrameFormat", () => {
|
|
76
|
-
function metadata(overrides: Partial<VideoMetadata> = {}): VideoMetadata {
|
|
77
|
-
return {
|
|
78
|
-
durationSeconds: 1,
|
|
79
|
-
width: 320,
|
|
80
|
-
height: 180,
|
|
81
|
-
fps: 30,
|
|
82
|
-
hasAudio: false,
|
|
83
|
-
videoCodec: "h264",
|
|
84
|
-
colorSpace: {
|
|
85
|
-
colorTransfer: "bt709",
|
|
86
|
-
colorPrimaries: "bt709",
|
|
87
|
-
colorSpace: "bt709",
|
|
88
|
-
},
|
|
89
|
-
isVFR: false,
|
|
90
|
-
hasAlpha: false,
|
|
91
|
-
...overrides,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
it("keeps opaque non-alpha sources on jpg by default", () => {
|
|
96
|
-
expect(resolveFrameFormat(metadata(), undefined)).toBe("jpg");
|
|
97
|
-
expect(resolveFrameFormat(metadata(), "auto")).toBe("jpg");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("honors explicit png for opaque videos", () => {
|
|
101
|
-
expect(resolveFrameFormat(metadata(), "png")).toBe("png");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("honors explicit jpg for opaque videos", () => {
|
|
105
|
-
expect(resolveFrameFormat(metadata(), "jpg")).toBe("jpg");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("forces png when alpha is present or the codec can carry alpha", () => {
|
|
109
|
-
expect(resolveFrameFormat(metadata({ hasAlpha: true }), "jpg")).toBe("png");
|
|
110
|
-
expect(resolveFrameFormat(metadata({ videoCodec: "vp9" }), "jpg")).toBe("png");
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Regression: a long-standing footgun where `<video src="../assets/foo">`
|
|
115
|
-
// inside a sub-composition silently dropped the video from extraction. The
|
|
116
|
-
// browser's URL resolver clamps `..` at the served origin's root (so the
|
|
117
|
-
// page renders fine in the studio), but `path.join(projectDir, "../assets/foo")`
|
|
118
|
-
// normalizes to <parentOfProjectDir>/assets/foo, which doesn't exist —
|
|
119
|
-
// extraction skipped, no frame injection, rendered output shows the video's
|
|
120
|
-
// first decoded frame for the whole clip duration. The resolver now mirrors
|
|
121
|
-
// browser semantics by clamping any traversal that escapes the project root.
|
|
122
|
-
describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
|
|
123
|
-
let tmp: string;
|
|
124
|
-
|
|
125
|
-
beforeAll(() => {
|
|
126
|
-
tmp = mkdtempSync(join(tmpdir(), "hf-resolver-"));
|
|
127
|
-
mkdirSync(join(tmp, "project", "assets"), { recursive: true });
|
|
128
|
-
writeFileSync(join(tmp, "project", "assets", "foo.mp4"), "");
|
|
129
|
-
});
|
|
130
|
-
afterAll(() => {
|
|
131
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("returns the literal join when the file exists at projectDir/src", () => {
|
|
135
|
-
const projectDir = join(tmp, "project");
|
|
136
|
-
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir)).toBe(
|
|
137
|
-
join(projectDir, "assets/foo.mp4"),
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("clamps a leading `../` so `../assets/foo.mp4` resolves to assets/foo.mp4", () => {
|
|
142
|
-
const projectDir = join(tmp, "project");
|
|
143
|
-
expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe(
|
|
144
|
-
join(projectDir, "assets/foo.mp4"),
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("clamps multiple leading `../../../` segments", () => {
|
|
149
|
-
const projectDir = join(tmp, "project");
|
|
150
|
-
expect(resolveProjectRelativeSrc("../../../assets/foo.mp4", projectDir)).toBe(
|
|
151
|
-
join(projectDir, "assets/foo.mp4"),
|
|
152
|
-
);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("clamps mid-path traversal that escapes baseDir (not just leading `..`)", () => {
|
|
156
|
-
// `assets/../../foo.mp4` collapses past projectDir via path.join — this
|
|
157
|
-
// case used to silently escape; the resolver now strips embedded `..`
|
|
158
|
-
// segments and re-anchors at the project root.
|
|
159
|
-
const projectDir = join(tmp, "project");
|
|
160
|
-
expect(resolveProjectRelativeSrc("assets/../../assets/foo.mp4", projectDir)).toBe(
|
|
161
|
-
join(projectDir, "assets/foo.mp4"),
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("returns the (non-existent) base-dir path on miss so callers get a stable error message", () => {
|
|
166
|
-
const projectDir = join(tmp, "project");
|
|
167
|
-
expect(resolveProjectRelativeSrc("../assets/missing.mp4", projectDir)).toBe(
|
|
168
|
-
join(projectDir, "../assets/missing.mp4"),
|
|
169
|
-
);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("prefers compiled-dir over base-dir when the file exists in both", () => {
|
|
173
|
-
const projectDir = join(tmp, "project");
|
|
174
|
-
const compiledDir = join(tmp, "compiled");
|
|
175
|
-
mkdirSync(join(compiledDir, "assets"), { recursive: true });
|
|
176
|
-
writeFileSync(join(compiledDir, "assets", "foo.mp4"), "");
|
|
177
|
-
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir, compiledDir)).toBe(
|
|
178
|
-
join(compiledDir, "assets/foo.mp4"),
|
|
179
|
-
);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("resolves percent-encoded non-Latin filenames across scripts", () => {
|
|
183
|
-
const projectDir = join(tmp, "project");
|
|
184
|
-
const cases = [
|
|
185
|
-
["arabic", "%D9%87%D9%86%D8%A7-%D9%85%D8%B1%D9%88%D8%A7.mp4"],
|
|
186
|
-
["japanese", "%E6%97%A5%E6%9C%AC%E8%AA%9E.mp4"],
|
|
187
|
-
["cyrillic", "%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82.mp4"],
|
|
188
|
-
["korean", "%ED%95%9C%EA%B8%80.mp4"],
|
|
189
|
-
] as const;
|
|
190
|
-
|
|
191
|
-
for (const [, encodedFilename] of cases) {
|
|
192
|
-
const filename = decodeURIComponent(encodedFilename);
|
|
193
|
-
writeFileSync(join(projectDir, "assets", filename), "");
|
|
194
|
-
|
|
195
|
-
expect(resolveProjectRelativeSrc(`assets/${encodedFilename}`, projectDir)).toBe(
|
|
196
|
-
join(projectDir, "assets", filename),
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("falls back to literal filenames when percent sequences are malformed", () => {
|
|
202
|
-
const projectDir = join(tmp, "project");
|
|
203
|
-
const filename = "100%-discount.mp4";
|
|
204
|
-
writeFileSync(join(projectDir, "assets", filename), "");
|
|
205
|
-
|
|
206
|
-
expect(resolveProjectRelativeSrc(`assets/${filename}`, projectDir)).toBe(
|
|
207
|
-
join(projectDir, "assets", filename),
|
|
208
|
-
);
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
describe("parseVideoElements", () => {
|
|
213
|
-
it("parses videos without an id or data-start attribute", () => {
|
|
214
|
-
const videos = parseVideoElements('<video src="clip.mp4"></video>');
|
|
215
|
-
|
|
216
|
-
expect(videos).toHaveLength(1);
|
|
217
|
-
expect(videos[0]).toMatchObject({
|
|
218
|
-
id: "hf-video-0",
|
|
219
|
-
src: "clip.mp4",
|
|
220
|
-
start: 0,
|
|
221
|
-
end: Infinity,
|
|
222
|
-
mediaStart: 0,
|
|
223
|
-
loop: false,
|
|
224
|
-
hasAudio: false,
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("preserves explicit ids and derives end from data-duration", () => {
|
|
229
|
-
const videos = parseVideoElements(
|
|
230
|
-
'<video id="hero" src="clip.mp4" data-start="2" data-duration="5" data-media-start="1.5" data-has-audio="true"></video>',
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
expect(videos).toHaveLength(1);
|
|
234
|
-
expect(videos[0]).toEqual({
|
|
235
|
-
id: "hero",
|
|
236
|
-
src: "clip.mp4",
|
|
237
|
-
start: 2,
|
|
238
|
-
end: 7,
|
|
239
|
-
mediaStart: 1.5,
|
|
240
|
-
loop: false,
|
|
241
|
-
hasAudio: true,
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("preserves looped timed video semantics for render frame lookup", () => {
|
|
246
|
-
const videos = parseVideoElements(
|
|
247
|
-
'<video id="hero" src="clip.webm" data-start="2" data-duration="5" loop></video>',
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
expect(videos[0]).toMatchObject({
|
|
251
|
-
id: "hero",
|
|
252
|
-
start: 2,
|
|
253
|
-
end: 7,
|
|
254
|
-
loop: true,
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe("FrameLookupTable", () => {
|
|
260
|
-
function fakeExtracted(totalFrames: number, fps: number): ExtractedFrames {
|
|
261
|
-
const framePaths = new Map<number, string>();
|
|
262
|
-
for (let i = 0; i < totalFrames; i += 1) {
|
|
263
|
-
framePaths.set(i, `frame-${i}.jpg`);
|
|
264
|
-
}
|
|
265
|
-
return {
|
|
266
|
-
videoId: "hero",
|
|
267
|
-
srcPath: "clip.webm",
|
|
268
|
-
outputDir: "/tmp/frames",
|
|
269
|
-
framePattern: "frame-%05d.jpg",
|
|
270
|
-
fps,
|
|
271
|
-
totalFrames,
|
|
272
|
-
metadata: {
|
|
273
|
-
durationSeconds: totalFrames / fps,
|
|
274
|
-
width: 320,
|
|
275
|
-
height: 180,
|
|
276
|
-
fps,
|
|
277
|
-
hasAudio: false,
|
|
278
|
-
videoCodec: "vp9",
|
|
279
|
-
colorSpace: {
|
|
280
|
-
colorTransfer: "bt709",
|
|
281
|
-
colorPrimaries: "bt709",
|
|
282
|
-
colorSpace: "bt709",
|
|
283
|
-
},
|
|
284
|
-
isVFR: false,
|
|
285
|
-
hasAlpha: false,
|
|
286
|
-
},
|
|
287
|
-
framePaths,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
it("wraps active frame payloads for looped clips whose display window exceeds source frames", () => {
|
|
292
|
-
const table = createFrameLookupTable(
|
|
293
|
-
[
|
|
294
|
-
{
|
|
295
|
-
id: "hero",
|
|
296
|
-
src: "clip.webm",
|
|
297
|
-
start: 0,
|
|
298
|
-
end: 5,
|
|
299
|
-
mediaStart: 0,
|
|
300
|
-
loop: true,
|
|
301
|
-
hasAudio: false,
|
|
302
|
-
},
|
|
303
|
-
],
|
|
304
|
-
[fakeExtracted(30, 30)],
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
expect(table.getActiveFramePayloads(0.5).get("hero")?.frameIndex).toBe(15);
|
|
308
|
-
expect(table.getActiveFramePayloads(1.5).get("hero")?.frameIndex).toBe(15);
|
|
309
|
-
expect(table.getActiveFramePayloads(4.5).get("hero")?.frameIndex).toBe(15);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("does not hold stale frames for non-looping clips after extracted frames end", () => {
|
|
313
|
-
const table = createFrameLookupTable(
|
|
314
|
-
[
|
|
315
|
-
{
|
|
316
|
-
id: "hero",
|
|
317
|
-
src: "clip.webm",
|
|
318
|
-
start: 0,
|
|
319
|
-
end: 5,
|
|
320
|
-
mediaStart: 0,
|
|
321
|
-
loop: false,
|
|
322
|
-
hasAudio: false,
|
|
323
|
-
},
|
|
324
|
-
],
|
|
325
|
-
[fakeExtracted(30, 30)],
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
expect(table.getActiveFramePayloads(0.5).has("hero")).toBe(true);
|
|
329
|
-
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("holds the last frame at the inclusive clip end (t === end)", () => {
|
|
333
|
-
// clip [1,3] with exactly 2s of source frames (60 @ 30fps). The frame
|
|
334
|
-
// landing on t === end used to deactivate one frame early and render blank,
|
|
335
|
-
// while the runtime keeps the element visible on its last frame.
|
|
336
|
-
const table = createFrameLookupTable(
|
|
337
|
-
[
|
|
338
|
-
{
|
|
339
|
-
id: "hero",
|
|
340
|
-
src: "clip.webm",
|
|
341
|
-
start: 1,
|
|
342
|
-
end: 3,
|
|
343
|
-
mediaStart: 0,
|
|
344
|
-
loop: false,
|
|
345
|
-
hasAudio: false,
|
|
346
|
-
},
|
|
347
|
-
],
|
|
348
|
-
[fakeExtracted(60, 30)],
|
|
349
|
-
);
|
|
350
|
-
const atEnd = table.getActiveFramePayloads(3.0).get("hero");
|
|
351
|
-
expect(atEnd?.frameIndex).toBe(59);
|
|
352
|
-
// mid-clip is unaffected
|
|
353
|
-
expect(table.getActiveFramePayloads(2.5).get("hero")?.frameIndex).toBe(45);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it("holds the last frame at the clip end even when the source is shorter than the window", () => {
|
|
357
|
-
// clip [0,5] with only 1s of source (30 @ 30fps). The mid-clip tail stays
|
|
358
|
-
// blank (source exhausted), but t === end still holds the last frame to
|
|
359
|
-
// match the runtime's inclusive visibility.
|
|
360
|
-
const table = createFrameLookupTable(
|
|
361
|
-
[
|
|
362
|
-
{
|
|
363
|
-
id: "hero",
|
|
364
|
-
src: "clip.webm",
|
|
365
|
-
start: 0,
|
|
366
|
-
end: 5,
|
|
367
|
-
mediaStart: 0,
|
|
368
|
-
loop: false,
|
|
369
|
-
hasAudio: false,
|
|
370
|
-
},
|
|
371
|
-
],
|
|
372
|
-
[fakeExtracted(30, 30)],
|
|
373
|
-
);
|
|
374
|
-
expect(table.getActiveFramePayloads(1.5).has("hero")).toBe(false);
|
|
375
|
-
expect(table.getActiveFramePayloads(5.0).get("hero")?.frameIndex).toBe(29);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it("keeps both clips active at a shared adjacent boundary, matching the runtime", () => {
|
|
379
|
-
// clip A ends at 3.0, clip B starts at 3.0. The runtime shows both at the
|
|
380
|
-
// shared instant; the active set must too.
|
|
381
|
-
const table = createFrameLookupTable(
|
|
382
|
-
[
|
|
383
|
-
{ id: "a", src: "a.webm", start: 0, end: 3, mediaStart: 0, loop: false, hasAudio: false },
|
|
384
|
-
{ id: "b", src: "b.webm", start: 3, end: 6, mediaStart: 0, loop: false, hasAudio: false },
|
|
385
|
-
],
|
|
386
|
-
// createFrameLookupTable maps each clip to extracted frames by id.
|
|
387
|
-
[
|
|
388
|
-
{ ...fakeExtracted(90, 30), videoId: "a" },
|
|
389
|
-
{ ...fakeExtracted(90, 30), videoId: "b" },
|
|
390
|
-
],
|
|
391
|
-
);
|
|
392
|
-
const payloads = table.getActiveFramePayloads(3.0);
|
|
393
|
-
expect(payloads.has("a")).toBe(true);
|
|
394
|
-
expect(payloads.has("b")).toBe(true);
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
describe("parseImageElements", () => {
|
|
399
|
-
it("parses images with data-start and data-duration", () => {
|
|
400
|
-
const images = parseImageElements(
|
|
401
|
-
'<img id="photo" src="hdr-photo.png" data-start="0" data-duration="3" />',
|
|
402
|
-
);
|
|
403
|
-
|
|
404
|
-
expect(images).toHaveLength(1);
|
|
405
|
-
expect(images[0]).toEqual({
|
|
406
|
-
id: "photo",
|
|
407
|
-
src: "hdr-photo.png",
|
|
408
|
-
start: 0,
|
|
409
|
-
end: 3,
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it("generates stable IDs for images without one", () => {
|
|
414
|
-
const images = parseImageElements(
|
|
415
|
-
'<img src="a.png" data-start="0" data-end="2" /><img src="b.png" data-start="1" data-end="4" />',
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
expect(images).toHaveLength(2);
|
|
419
|
-
expect(images[0]!.id).toBe("hf-img-0");
|
|
420
|
-
expect(images[1]!.id).toBe("hf-img-1");
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it("defaults start to 0 and end to Infinity when attributes missing", () => {
|
|
424
|
-
const images = parseImageElements('<img src="photo.png" />');
|
|
425
|
-
|
|
426
|
-
expect(images).toHaveLength(1);
|
|
427
|
-
expect(images[0]).toMatchObject({
|
|
428
|
-
src: "photo.png",
|
|
429
|
-
start: 0,
|
|
430
|
-
end: Infinity,
|
|
431
|
-
});
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("ignores img elements without src", () => {
|
|
435
|
-
const images = parseImageElements('<img data-start="0" data-end="3" />');
|
|
436
|
-
expect(images).toHaveLength(0);
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it("uses data-end over data-duration when both present", () => {
|
|
440
|
-
const images = parseImageElements(
|
|
441
|
-
'<img src="a.png" data-start="1" data-end="5" data-duration="10" />',
|
|
442
|
-
);
|
|
443
|
-
expect(images[0]!.end).toBe(5);
|
|
444
|
-
});
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
type Rgb = [number, number, number];
|
|
448
|
-
|
|
449
|
-
const UI_FIXTURE_WIDTH = 240;
|
|
450
|
-
const UI_FIXTURE_HEIGHT = 160;
|
|
451
|
-
const RED_SAMPLE_PIXELS = [
|
|
452
|
-
[70, 72],
|
|
453
|
-
[118, 82],
|
|
454
|
-
[178, 92],
|
|
455
|
-
] as const;
|
|
456
|
-
|
|
457
|
-
function readFirstFramePixel(mediaPath: string, x: number, y: number): Rgb {
|
|
458
|
-
const result = spawnSync(
|
|
459
|
-
"ffmpeg",
|
|
460
|
-
[
|
|
461
|
-
"-v",
|
|
462
|
-
"error",
|
|
463
|
-
"-i",
|
|
464
|
-
mediaPath,
|
|
465
|
-
"-frames:v",
|
|
466
|
-
"1",
|
|
467
|
-
"-f",
|
|
468
|
-
"rawvideo",
|
|
469
|
-
"-pix_fmt",
|
|
470
|
-
"rgb24",
|
|
471
|
-
"pipe:1",
|
|
472
|
-
],
|
|
473
|
-
{ maxBuffer: UI_FIXTURE_WIDTH * UI_FIXTURE_HEIGHT * 3 + 1024 },
|
|
474
|
-
);
|
|
475
|
-
if (result.status !== 0) {
|
|
476
|
-
throw new Error(`ffmpeg pixel decode failed: ${result.stderr.toString().slice(-400)}`);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const offset = (y * UI_FIXTURE_WIDTH + x) * 3;
|
|
480
|
-
return [
|
|
481
|
-
result.stdout[offset] ?? 0,
|
|
482
|
-
result.stdout[offset + 1] ?? 0,
|
|
483
|
-
result.stdout[offset + 2] ?? 0,
|
|
484
|
-
];
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function maxChannelDelta(a: Rgb, b: Rgb): number {
|
|
488
|
-
return Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Regression for saturated UI recordings: default JPEG extraction can shift
|
|
492
|
-
// high-chroma reds before browser capture. Forcing PNG should keep extracted
|
|
493
|
-
// source-video frames effectively identical to the decoded source pixels.
|
|
494
|
-
describe.skipIf(!HAS_FFMPEG)("video frame extraction format", () => {
|
|
495
|
-
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-video-frame-format-"));
|
|
496
|
-
const UI_FIXTURE = join(FIXTURE_DIR, "ui-red.mp4");
|
|
497
|
-
|
|
498
|
-
beforeAll(async () => {
|
|
499
|
-
const result = await runFfmpeg([
|
|
500
|
-
"-y",
|
|
501
|
-
"-hide_banner",
|
|
502
|
-
"-loglevel",
|
|
503
|
-
"error",
|
|
504
|
-
"-f",
|
|
505
|
-
"lavfi",
|
|
506
|
-
"-i",
|
|
507
|
-
`color=c=0xffe7ee:s=${UI_FIXTURE_WIDTH}x${UI_FIXTURE_HEIGHT}:d=1:r=1`,
|
|
508
|
-
"-vf",
|
|
509
|
-
"drawbox=x=44:y=58:w=152:h=44:color=0xdd382e@1:t=fill,drawbox=x=64:y=75:w=112:h=10:color=0xfff0f0@1:t=fill",
|
|
510
|
-
"-c:v",
|
|
511
|
-
"libx264",
|
|
512
|
-
"-preset",
|
|
513
|
-
"ultrafast",
|
|
514
|
-
"-crf",
|
|
515
|
-
"0",
|
|
516
|
-
"-pix_fmt",
|
|
517
|
-
"yuv420p",
|
|
518
|
-
"-color_primaries",
|
|
519
|
-
"bt709",
|
|
520
|
-
"-color_trc",
|
|
521
|
-
"bt709",
|
|
522
|
-
"-colorspace",
|
|
523
|
-
"bt709",
|
|
524
|
-
UI_FIXTURE,
|
|
525
|
-
]);
|
|
526
|
-
if (!result.success) {
|
|
527
|
-
throw new Error(`UI color fixture synthesis failed: ${result.stderr.slice(-400)}`);
|
|
528
|
-
}
|
|
529
|
-
}, 30_000);
|
|
530
|
-
|
|
531
|
-
afterAll(() => {
|
|
532
|
-
if (existsSync(FIXTURE_DIR)) rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
function fixtureVideo(): VideoElement {
|
|
536
|
-
return {
|
|
537
|
-
id: "ui",
|
|
538
|
-
src: UI_FIXTURE,
|
|
539
|
-
start: 0,
|
|
540
|
-
end: 1,
|
|
541
|
-
mediaStart: 0,
|
|
542
|
-
loop: false,
|
|
543
|
-
hasAudio: false,
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
it("keeps color-sensitive UI reds closer to source when extraction is forced to png", async () => {
|
|
548
|
-
const defaultOut = join(FIXTURE_DIR, "out-default");
|
|
549
|
-
const pngOut = join(FIXTURE_DIR, "out-png");
|
|
550
|
-
mkdirSync(defaultOut, { recursive: true });
|
|
551
|
-
mkdirSync(pngOut, { recursive: true });
|
|
552
|
-
|
|
553
|
-
const defaultResult = await extractAllVideoFrames([fixtureVideo()], FIXTURE_DIR, {
|
|
554
|
-
fps: 1,
|
|
555
|
-
outputDir: defaultOut,
|
|
556
|
-
});
|
|
557
|
-
const pngResult = await extractAllVideoFrames([fixtureVideo()], FIXTURE_DIR, {
|
|
558
|
-
fps: 1,
|
|
559
|
-
outputDir: pngOut,
|
|
560
|
-
format: "png",
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
expect(defaultResult.errors).toEqual([]);
|
|
564
|
-
expect(pngResult.errors).toEqual([]);
|
|
565
|
-
const defaultFrame = defaultResult.extracted[0]!.framePaths.get(0)!;
|
|
566
|
-
const pngFrame = pngResult.extracted[0]!.framePaths.get(0)!;
|
|
567
|
-
expect(defaultFrame.endsWith(".jpg")).toBe(true);
|
|
568
|
-
expect(pngFrame.endsWith(".png")).toBe(true);
|
|
569
|
-
|
|
570
|
-
let worstDefaultDelta = 0;
|
|
571
|
-
let worstPngDelta = 0;
|
|
572
|
-
for (const [x, y] of RED_SAMPLE_PIXELS) {
|
|
573
|
-
const sourcePixel = readFirstFramePixel(UI_FIXTURE, x, y);
|
|
574
|
-
worstDefaultDelta = Math.max(
|
|
575
|
-
worstDefaultDelta,
|
|
576
|
-
maxChannelDelta(sourcePixel, readFirstFramePixel(defaultFrame, x, y)),
|
|
577
|
-
);
|
|
578
|
-
worstPngDelta = Math.max(
|
|
579
|
-
worstPngDelta,
|
|
580
|
-
maxChannelDelta(sourcePixel, readFirstFramePixel(pngFrame, x, y)),
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
expect(worstPngDelta).toBeLessThanOrEqual(5);
|
|
585
|
-
expect(worstPngDelta).toBeLessThanOrEqual(worstDefaultDelta);
|
|
586
|
-
}, 60_000);
|
|
587
|
-
|
|
588
|
-
it("keeps jpg and png extraction caches separate", async () => {
|
|
589
|
-
const cacheDir = mkdtempSync(join(tmpdir(), "hf-extract-format-cache-"));
|
|
590
|
-
try {
|
|
591
|
-
const defaultOut = join(FIXTURE_DIR, "cache-default");
|
|
592
|
-
const pngOut = join(FIXTURE_DIR, "cache-png");
|
|
593
|
-
const pngHitOut = join(FIXTURE_DIR, "cache-png-hit");
|
|
594
|
-
mkdirSync(defaultOut, { recursive: true });
|
|
595
|
-
mkdirSync(pngOut, { recursive: true });
|
|
596
|
-
mkdirSync(pngHitOut, { recursive: true });
|
|
597
|
-
|
|
598
|
-
const defaultResult = await extractAllVideoFrames(
|
|
599
|
-
[fixtureVideo()],
|
|
600
|
-
FIXTURE_DIR,
|
|
601
|
-
{ fps: 1, outputDir: defaultOut },
|
|
602
|
-
undefined,
|
|
603
|
-
{ extractCacheDir: cacheDir },
|
|
604
|
-
);
|
|
605
|
-
expect(defaultResult.errors).toEqual([]);
|
|
606
|
-
expect(defaultResult.phaseBreakdown.cacheHits).toBe(0);
|
|
607
|
-
expect(defaultResult.phaseBreakdown.cacheMisses).toBe(1);
|
|
608
|
-
expect(defaultResult.extracted[0]!.framePaths.get(0)!.endsWith(".jpg")).toBe(true);
|
|
609
|
-
|
|
610
|
-
const pngMiss = await extractAllVideoFrames(
|
|
611
|
-
[fixtureVideo()],
|
|
612
|
-
FIXTURE_DIR,
|
|
613
|
-
{ fps: 1, outputDir: pngOut, format: "png" },
|
|
614
|
-
undefined,
|
|
615
|
-
{ extractCacheDir: cacheDir },
|
|
616
|
-
);
|
|
617
|
-
expect(pngMiss.errors).toEqual([]);
|
|
618
|
-
expect(pngMiss.phaseBreakdown.cacheHits).toBe(0);
|
|
619
|
-
expect(pngMiss.phaseBreakdown.cacheMisses).toBe(1);
|
|
620
|
-
expect(pngMiss.extracted[0]!.framePaths.get(0)!.endsWith(".png")).toBe(true);
|
|
621
|
-
|
|
622
|
-
const pngHit = await extractAllVideoFrames(
|
|
623
|
-
[fixtureVideo()],
|
|
624
|
-
FIXTURE_DIR,
|
|
625
|
-
{ fps: 1, outputDir: pngHitOut, format: "png" },
|
|
626
|
-
undefined,
|
|
627
|
-
{ extractCacheDir: cacheDir },
|
|
628
|
-
);
|
|
629
|
-
expect(pngHit.errors).toEqual([]);
|
|
630
|
-
expect(pngHit.phaseBreakdown.cacheHits).toBe(1);
|
|
631
|
-
expect(pngHit.phaseBreakdown.cacheMisses).toBe(0);
|
|
632
|
-
expect(pngHit.extracted[0]!.framePaths.get(0)!.endsWith(".png")).toBe(true);
|
|
633
|
-
} finally {
|
|
634
|
-
rmSync(cacheDir, { recursive: true, force: true });
|
|
635
|
-
}
|
|
636
|
-
}, 60_000);
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
// Regression test for the VFR (variable frame rate) freeze bug.
|
|
640
|
-
// Screen recordings and phone videos often have irregular timestamps.
|
|
641
|
-
// When such inputs hit `extractVideoFramesRange`'s `-ss <start> -i ... -t <dur>
|
|
642
|
-
// -vf fps=N` pipeline, the fps filter can emit fewer frames than requested —
|
|
643
|
-
// e.g. a 4-second segment at 30fps would produce ~90 frames instead of 120.
|
|
644
|
-
// FrameLookupTable.getFrameAtTime then returns null for out-of-range indices
|
|
645
|
-
// and the compositor holds the last valid frame, which the user perceives as
|
|
646
|
-
// the video freezing. extractAllVideoFrames normalizes VFR sources to CFR
|
|
647
|
-
// before extraction to fix this.
|
|
648
|
-
describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
|
649
|
-
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-vfr-test-"));
|
|
650
|
-
const VFR_FIXTURE = join(FIXTURE_DIR, "vfr_screen.mp4");
|
|
651
|
-
|
|
652
|
-
beforeAll(async () => {
|
|
653
|
-
// 10s testsrc2 at 60fps, ~40% of frames dropped via select filter and
|
|
654
|
-
// encoded with -vsync vfr so timestamps are irregular. Declared fps 60,
|
|
655
|
-
// actual average ~36 — well over the 10% threshold used by isVFR.
|
|
656
|
-
// The select expression drops four 1-second windows (frames 30-89,
|
|
657
|
-
// 180-239, 330-389, 480-539) to simulate static segments in a screen
|
|
658
|
-
// recording where no pixels changed.
|
|
659
|
-
// -g/-keyint_min 600 forces a single keyframe so mid-segment seeks in the
|
|
660
|
-
// mediaStart=3 test don't snap to an intermediate IDR and drift the count.
|
|
661
|
-
const result = await runFfmpeg([
|
|
662
|
-
"-y",
|
|
663
|
-
"-hide_banner",
|
|
664
|
-
"-loglevel",
|
|
665
|
-
"error",
|
|
666
|
-
"-f",
|
|
667
|
-
"lavfi",
|
|
668
|
-
"-i",
|
|
669
|
-
"testsrc2=s=320x180:d=10:rate=60",
|
|
670
|
-
"-vf",
|
|
671
|
-
"select='not(between(n\\,30\\,89))*not(between(n\\,180\\,239))*not(between(n\\,330\\,389))*not(between(n\\,480\\,539))'",
|
|
672
|
-
"-vsync",
|
|
673
|
-
"vfr",
|
|
674
|
-
"-c:v",
|
|
675
|
-
"libx264",
|
|
676
|
-
"-preset",
|
|
677
|
-
"ultrafast",
|
|
678
|
-
"-pix_fmt",
|
|
679
|
-
"yuv420p",
|
|
680
|
-
"-g",
|
|
681
|
-
"600",
|
|
682
|
-
"-keyint_min",
|
|
683
|
-
"600",
|
|
684
|
-
VFR_FIXTURE,
|
|
685
|
-
]);
|
|
686
|
-
if (!result.success) {
|
|
687
|
-
throw new Error(
|
|
688
|
-
`ffmpeg fixture synthesis failed (${result.exitCode}): ${result.stderr.slice(-400)}`,
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
}, 30_000);
|
|
692
|
-
|
|
693
|
-
afterAll(() => {
|
|
694
|
-
if (existsSync(FIXTURE_DIR)) rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
it("detects the synthesized fixture as VFR", async () => {
|
|
698
|
-
const md = await extractVideoMetadata(VFR_FIXTURE);
|
|
699
|
-
expect(md.isVFR).toBe(true);
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it("produces the expected frame count for a mid-file segment", async () => {
|
|
703
|
-
const outputDir = join(FIXTURE_DIR, "out-mid-segment");
|
|
704
|
-
mkdirSync(outputDir, { recursive: true });
|
|
705
|
-
|
|
706
|
-
const video: VideoElement = {
|
|
707
|
-
id: "v1",
|
|
708
|
-
src: VFR_FIXTURE,
|
|
709
|
-
start: 0,
|
|
710
|
-
end: 4,
|
|
711
|
-
mediaStart: 3,
|
|
712
|
-
loop: false,
|
|
713
|
-
hasAudio: false,
|
|
714
|
-
};
|
|
715
|
-
|
|
716
|
-
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
|
|
717
|
-
fps: 30,
|
|
718
|
-
outputDir,
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
expect(result.errors).toEqual([]);
|
|
722
|
-
expect(result.extracted).toHaveLength(1);
|
|
723
|
-
const frames = readdirSync(join(outputDir, "v1")).filter((f) => f.endsWith(".jpg"));
|
|
724
|
-
// Pre-fix behavior produced ~90 frames (a 25% shortfall).
|
|
725
|
-
// ±3 tolerance: FFmpeg's VFR→CFR normalization yields slightly different
|
|
726
|
-
// frame counts across versions (timestamp rounding in the fps filter).
|
|
727
|
-
expect(frames.length).toBeGreaterThanOrEqual(117);
|
|
728
|
-
expect(frames.length).toBeLessThanOrEqual(123);
|
|
729
|
-
|
|
730
|
-
expect(result.phaseBreakdown).toBeDefined();
|
|
731
|
-
expect(result.phaseBreakdown.extractMs).toBeGreaterThan(0);
|
|
732
|
-
expect(result.phaseBreakdown.vfrPreflightCount).toBe(1);
|
|
733
|
-
expect(result.phaseBreakdown.vfrPreflightMs).toBeGreaterThan(0);
|
|
734
|
-
}, 60_000);
|
|
735
|
-
|
|
736
|
-
it("reuses extracted frames on a warm cache hit", async () => {
|
|
737
|
-
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
738
|
-
const SRC = join(FIXTURE_DIR, "cache-src.mp4");
|
|
739
|
-
|
|
740
|
-
// Synthesize a clean CFR SDR clip — bypasses VFR preflight so the cache
|
|
741
|
-
// key is stable across the two runs.
|
|
742
|
-
const synth = await runFfmpeg([
|
|
743
|
-
"-y",
|
|
744
|
-
"-hide_banner",
|
|
745
|
-
"-loglevel",
|
|
746
|
-
"error",
|
|
747
|
-
"-f",
|
|
748
|
-
"lavfi",
|
|
749
|
-
"-i",
|
|
750
|
-
"testsrc2=s=320x180:d=2:rate=30",
|
|
751
|
-
"-c:v",
|
|
752
|
-
"libx264",
|
|
753
|
-
"-preset",
|
|
754
|
-
"ultrafast",
|
|
755
|
-
"-pix_fmt",
|
|
756
|
-
"yuv420p",
|
|
757
|
-
SRC,
|
|
758
|
-
]);
|
|
759
|
-
if (!synth.success) {
|
|
760
|
-
throw new Error(`Cache fixture synthesis failed: ${synth.stderr.slice(-400)}`);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const video: VideoElement = {
|
|
764
|
-
id: "cv1",
|
|
765
|
-
src: SRC,
|
|
766
|
-
start: 0,
|
|
767
|
-
end: 2,
|
|
768
|
-
mediaStart: 0,
|
|
769
|
-
loop: false,
|
|
770
|
-
hasAudio: false,
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
const outDirA = join(FIXTURE_DIR, "out-cache-miss");
|
|
774
|
-
mkdirSync(outDirA, { recursive: true });
|
|
775
|
-
const miss = await extractAllVideoFrames(
|
|
776
|
-
[video],
|
|
777
|
-
FIXTURE_DIR,
|
|
778
|
-
{ fps: 30, outputDir: outDirA },
|
|
779
|
-
undefined,
|
|
780
|
-
{ extractCacheDir: CACHE_DIR },
|
|
781
|
-
);
|
|
782
|
-
expect(miss.errors).toEqual([]);
|
|
783
|
-
expect(miss.phaseBreakdown.cacheHits).toBe(0);
|
|
784
|
-
expect(miss.phaseBreakdown.cacheMisses).toBe(1);
|
|
785
|
-
|
|
786
|
-
const outDirB = join(FIXTURE_DIR, "out-cache-hit");
|
|
787
|
-
mkdirSync(outDirB, { recursive: true });
|
|
788
|
-
const hit = await extractAllVideoFrames(
|
|
789
|
-
[video],
|
|
790
|
-
FIXTURE_DIR,
|
|
791
|
-
{ fps: 30, outputDir: outDirB },
|
|
792
|
-
undefined,
|
|
793
|
-
{ extractCacheDir: CACHE_DIR },
|
|
794
|
-
);
|
|
795
|
-
expect(hit.errors).toEqual([]);
|
|
796
|
-
expect(hit.phaseBreakdown.cacheHits).toBe(1);
|
|
797
|
-
expect(hit.phaseBreakdown.cacheMisses).toBe(0);
|
|
798
|
-
// extractMs on a hit is only the cache-lookup bookkeeping; asserting <50ms
|
|
799
|
-
// is loose enough to survive CI jitter but tight enough to catch a
|
|
800
|
-
// regression that accidentally triggered ffmpeg again.
|
|
801
|
-
expect(hit.phaseBreakdown.extractMs).toBeLessThan(50);
|
|
802
|
-
expect(hit.extracted).toHaveLength(1);
|
|
803
|
-
expect(hit.extracted[0]!.totalFrames).toBe(miss.extracted[0]!.totalFrames);
|
|
804
|
-
|
|
805
|
-
rmSync(CACHE_DIR, { recursive: true, force: true });
|
|
806
|
-
}, 60_000);
|
|
807
|
-
|
|
808
|
-
it("invalidates the cache when fps changes", async () => {
|
|
809
|
-
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
810
|
-
const SRC = join(FIXTURE_DIR, "cache-fps-src.mp4");
|
|
811
|
-
|
|
812
|
-
const synth = await runFfmpeg([
|
|
813
|
-
"-y",
|
|
814
|
-
"-hide_banner",
|
|
815
|
-
"-loglevel",
|
|
816
|
-
"error",
|
|
817
|
-
"-f",
|
|
818
|
-
"lavfi",
|
|
819
|
-
"-i",
|
|
820
|
-
"testsrc2=s=320x180:d=1:rate=30",
|
|
821
|
-
"-c:v",
|
|
822
|
-
"libx264",
|
|
823
|
-
"-preset",
|
|
824
|
-
"ultrafast",
|
|
825
|
-
"-pix_fmt",
|
|
826
|
-
"yuv420p",
|
|
827
|
-
SRC,
|
|
828
|
-
]);
|
|
829
|
-
if (!synth.success) {
|
|
830
|
-
throw new Error(`Cache-fps fixture synthesis failed: ${synth.stderr.slice(-400)}`);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const video: VideoElement = {
|
|
834
|
-
id: "cv2",
|
|
835
|
-
src: SRC,
|
|
836
|
-
start: 0,
|
|
837
|
-
end: 1,
|
|
838
|
-
mediaStart: 0,
|
|
839
|
-
loop: false,
|
|
840
|
-
hasAudio: false,
|
|
841
|
-
};
|
|
842
|
-
|
|
843
|
-
const outA = join(FIXTURE_DIR, "out-cache-fps-30");
|
|
844
|
-
mkdirSync(outA, { recursive: true });
|
|
845
|
-
const first = await extractAllVideoFrames(
|
|
846
|
-
[video],
|
|
847
|
-
FIXTURE_DIR,
|
|
848
|
-
{ fps: 30, outputDir: outA },
|
|
849
|
-
undefined,
|
|
850
|
-
{ extractCacheDir: CACHE_DIR },
|
|
851
|
-
);
|
|
852
|
-
expect(first.phaseBreakdown.cacheMisses).toBe(1);
|
|
853
|
-
|
|
854
|
-
const outB = join(FIXTURE_DIR, "out-cache-fps-60");
|
|
855
|
-
mkdirSync(outB, { recursive: true });
|
|
856
|
-
const second = await extractAllVideoFrames(
|
|
857
|
-
[video],
|
|
858
|
-
FIXTURE_DIR,
|
|
859
|
-
{ fps: 60, outputDir: outB },
|
|
860
|
-
undefined,
|
|
861
|
-
{ extractCacheDir: CACHE_DIR },
|
|
862
|
-
);
|
|
863
|
-
expect(second.phaseBreakdown.cacheMisses).toBe(1);
|
|
864
|
-
expect(second.phaseBreakdown.cacheHits).toBe(0);
|
|
865
|
-
|
|
866
|
-
rmSync(CACHE_DIR, { recursive: true, force: true });
|
|
867
|
-
}, 60_000);
|
|
868
|
-
|
|
869
|
-
// Regression test for the segment-scope HDR preflight fix: pre-fix,
|
|
870
|
-
// convertSdrToHdr re-encoded the entire source, so a 30-minute SDR source
|
|
871
|
-
// contributing a 2-second clip took ~200× longer than needed. Post-fix the
|
|
872
|
-
// converted file's duration matches the used segment.
|
|
873
|
-
it("bounds the SDR→HDR preflight re-encode to the used segment", async () => {
|
|
874
|
-
const SDR_LONG = join(FIXTURE_DIR, "sdr-long.mp4");
|
|
875
|
-
const HDR_SHORT = join(FIXTURE_DIR, "hdr-short.mp4");
|
|
876
|
-
|
|
877
|
-
const sdrResult = await runFfmpeg([
|
|
878
|
-
"-y",
|
|
879
|
-
"-hide_banner",
|
|
880
|
-
"-loglevel",
|
|
881
|
-
"error",
|
|
882
|
-
"-f",
|
|
883
|
-
"lavfi",
|
|
884
|
-
"-i",
|
|
885
|
-
"testsrc2=s=320x180:d=10:rate=30",
|
|
886
|
-
"-c:v",
|
|
887
|
-
"libx264",
|
|
888
|
-
"-preset",
|
|
889
|
-
"ultrafast",
|
|
890
|
-
"-pix_fmt",
|
|
891
|
-
"yuv420p",
|
|
892
|
-
SDR_LONG,
|
|
893
|
-
]);
|
|
894
|
-
if (!sdrResult.success) {
|
|
895
|
-
throw new Error(`SDR fixture synthesis failed: ${sdrResult.stderr.slice(-400)}`);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Tag as bt2020nc / smpte2084 so the preflight path considers the timeline mixed-HDR.
|
|
899
|
-
const hdrResult = await runFfmpeg([
|
|
900
|
-
"-y",
|
|
901
|
-
"-hide_banner",
|
|
902
|
-
"-loglevel",
|
|
903
|
-
"error",
|
|
904
|
-
"-f",
|
|
905
|
-
"lavfi",
|
|
906
|
-
"-i",
|
|
907
|
-
"testsrc2=s=320x180:d=2:rate=30",
|
|
908
|
-
"-c:v",
|
|
909
|
-
"libx264",
|
|
910
|
-
"-preset",
|
|
911
|
-
"ultrafast",
|
|
912
|
-
"-pix_fmt",
|
|
913
|
-
"yuv420p",
|
|
914
|
-
"-color_primaries",
|
|
915
|
-
"bt2020",
|
|
916
|
-
"-color_trc",
|
|
917
|
-
"smpte2084",
|
|
918
|
-
"-colorspace",
|
|
919
|
-
"bt2020nc",
|
|
920
|
-
HDR_SHORT,
|
|
921
|
-
]);
|
|
922
|
-
if (!hdrResult.success) {
|
|
923
|
-
throw new Error(`HDR fixture synthesis failed: ${hdrResult.stderr.slice(-400)}`);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
const outputDir = join(FIXTURE_DIR, "out-hdr-segment");
|
|
927
|
-
mkdirSync(outputDir, { recursive: true });
|
|
928
|
-
|
|
929
|
-
const videos: VideoElement[] = [
|
|
930
|
-
{ id: "sdr", src: SDR_LONG, start: 0, end: 2, mediaStart: 0, loop: false, hasAudio: false },
|
|
931
|
-
{
|
|
932
|
-
id: "hdr",
|
|
933
|
-
src: HDR_SHORT,
|
|
934
|
-
start: 2,
|
|
935
|
-
end: 4,
|
|
936
|
-
mediaStart: 0,
|
|
937
|
-
loop: false,
|
|
938
|
-
hasAudio: false,
|
|
939
|
-
},
|
|
940
|
-
];
|
|
941
|
-
|
|
942
|
-
const result = await extractAllVideoFrames(videos, FIXTURE_DIR, {
|
|
943
|
-
fps: 30,
|
|
944
|
-
outputDir,
|
|
945
|
-
});
|
|
946
|
-
expect(result.errors).toEqual([]);
|
|
947
|
-
expect(result.phaseBreakdown.hdrPreflightCount).toBe(1);
|
|
948
|
-
|
|
949
|
-
const convertedPath = join(outputDir, "_hdr_normalized", "sdr_hdr.mp4");
|
|
950
|
-
expect(existsSync(convertedPath)).toBe(true);
|
|
951
|
-
const convertedMeta = await extractVideoMetadata(convertedPath);
|
|
952
|
-
// Pre-fix duration matched the 10s source; post-fix it matches the 2s segment
|
|
953
|
-
// (±0.2s for encoder keyframe/seek alignment).
|
|
954
|
-
expect(convertedMeta.durationSeconds).toBeGreaterThan(1.8);
|
|
955
|
-
expect(convertedMeta.durationSeconds).toBeLessThan(2.5);
|
|
956
|
-
}, 60_000);
|
|
957
|
-
|
|
958
|
-
// Asserts both frame-count correctness and that we don't emit long runs of
|
|
959
|
-
// byte-identical "duplicate" frames — the user-visible "frozen screen
|
|
960
|
-
// recording" symptom. Pre-fix duplicate rate on this fixture is ~38%
|
|
961
|
-
// (116/300); on the actual reporter's ScreenCaptureKit clip, 18–44% across
|
|
962
|
-
// segments. <10% threshold leaves margin across ffmpeg versions without
|
|
963
|
-
// letting a regression slip through.
|
|
964
|
-
it("produces the full frame count and no duplicate-frame runs on the full VFR file", async () => {
|
|
965
|
-
const outputDir = join(FIXTURE_DIR, "out-full");
|
|
966
|
-
mkdirSync(outputDir, { recursive: true });
|
|
967
|
-
|
|
968
|
-
const video: VideoElement = {
|
|
969
|
-
id: "vfull",
|
|
970
|
-
src: VFR_FIXTURE,
|
|
971
|
-
start: 0,
|
|
972
|
-
end: 10,
|
|
973
|
-
mediaStart: 0,
|
|
974
|
-
loop: false,
|
|
975
|
-
hasAudio: false,
|
|
976
|
-
};
|
|
977
|
-
|
|
978
|
-
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
|
|
979
|
-
fps: 30,
|
|
980
|
-
outputDir,
|
|
981
|
-
});
|
|
982
|
-
expect(result.errors).toEqual([]);
|
|
983
|
-
|
|
984
|
-
const frameDir = join(outputDir, "vfull");
|
|
985
|
-
const frames = readdirSync(frameDir)
|
|
986
|
-
.filter((f) => f.endsWith(".jpg"))
|
|
987
|
-
.sort();
|
|
988
|
-
// ±3 tolerance: same FFmpeg VFR→CFR rounding variance as the mid-segment test.
|
|
989
|
-
expect(frames.length).toBeGreaterThanOrEqual(297);
|
|
990
|
-
expect(frames.length).toBeLessThanOrEqual(303);
|
|
991
|
-
|
|
992
|
-
let prevHash: string | null = null;
|
|
993
|
-
let duplicates = 0;
|
|
994
|
-
for (const f of frames) {
|
|
995
|
-
const hash = createHash("sha256")
|
|
996
|
-
.update(readFileSync(join(frameDir, f)))
|
|
997
|
-
.digest("hex");
|
|
998
|
-
if (hash === prevHash) duplicates += 1;
|
|
999
|
-
prevHash = hash;
|
|
1000
|
-
}
|
|
1001
|
-
const duplicateRate = duplicates / frames.length;
|
|
1002
|
-
expect(duplicateRate).toBeLessThan(0.1);
|
|
1003
|
-
}, 60_000);
|
|
1004
|
-
});
|
|
1005
|
-
|
|
1006
|
-
describe("getFrameAtTime — IEEE 754 boundary precision", () => {
|
|
1007
|
-
function makeExtracted(fps: number, totalFrames: number): ExtractedFrames {
|
|
1008
|
-
const framePaths = new Map<number, string>();
|
|
1009
|
-
for (let i = 0; i < totalFrames; i++) framePaths.set(i, `frame-${i}.jpg`);
|
|
1010
|
-
return {
|
|
1011
|
-
fps,
|
|
1012
|
-
totalFrames,
|
|
1013
|
-
framePaths,
|
|
1014
|
-
metadata: {
|
|
1015
|
-
durationSeconds: totalFrames / fps,
|
|
1016
|
-
width: 1920,
|
|
1017
|
-
height: 1080,
|
|
1018
|
-
codec: "h264",
|
|
1019
|
-
hasAudio: false,
|
|
1020
|
-
fps,
|
|
1021
|
-
},
|
|
1022
|
-
} as ExtractedFrames;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
it("does not produce duplicate frames when data-start is grid-aligned", () => {
|
|
1026
|
-
const extracted = makeExtracted(25, 351);
|
|
1027
|
-
const videoStart = 0;
|
|
1028
|
-
const seen: string[] = [];
|
|
1029
|
-
let duplicates = 0;
|
|
1030
|
-
for (let i = 0; i < 351; i++) {
|
|
1031
|
-
const globalTime = i / 25;
|
|
1032
|
-
const frame = getFrameAtTime(extracted, globalTime, videoStart);
|
|
1033
|
-
if (frame && seen.length > 0 && frame === seen[seen.length - 1]) duplicates++;
|
|
1034
|
-
if (frame) seen.push(frame);
|
|
1035
|
-
}
|
|
1036
|
-
expect(duplicates).toBe(0);
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
it("returns monotonically increasing frame indices", () => {
|
|
1040
|
-
const extracted = makeExtracted(25, 100);
|
|
1041
|
-
let lastIndex = -1;
|
|
1042
|
-
for (let i = 0; i < 100; i++) {
|
|
1043
|
-
const globalTime = i / 25;
|
|
1044
|
-
const frame = getFrameAtTime(extracted, globalTime, 0);
|
|
1045
|
-
const idx = frame ? parseInt(frame.split("-")[1]!) : -1;
|
|
1046
|
-
expect(idx).toBeGreaterThan(lastIndex);
|
|
1047
|
-
lastIndex = idx;
|
|
1048
|
-
}
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
it("handles the 0.28 * 25 boundary case (6.999999 vs 7)", () => {
|
|
1052
|
-
const extracted = makeExtracted(25, 10);
|
|
1053
|
-
const frame = getFrameAtTime(extracted, 0.28, 0);
|
|
1054
|
-
expect(frame).toBe("frame-7.jpg");
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
it("mediaStart does not offset frame index (extractor handles trim via -ss)", () => {
|
|
1058
|
-
const extracted = makeExtracted(25, 100);
|
|
1059
|
-
const frame = getFrameAtTime(extracted, 0, 0, false, 1.0);
|
|
1060
|
-
expect(frame).toBe("frame-0.jpg");
|
|
1061
|
-
});
|
|
1062
|
-
});
|