@hyperframes/engine 0.5.0-alpha.9 → 0.5.0
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/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/services/audioMixer.d.ts.map +1 -1
- package/dist/services/audioMixer.js +4 -6
- package/dist/services/audioMixer.js.map +1 -1
- package/dist/services/browserManager.d.ts +3 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +44 -3
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +31 -0
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/fileServer.d.ts.map +1 -1
- package/dist/services/fileServer.js +1 -60
- package/dist/services/fileServer.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +103 -13
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +7 -5
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +20 -0
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +20 -0
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +95 -7
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts +4 -1
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +5 -2
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/types.d.ts +27 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/alphaBlit.d.ts +1 -1
- package/dist/utils/alphaBlit.d.ts.map +1 -1
- package/dist/utils/alphaBlit.js +15 -6
- package/dist/utils/alphaBlit.js.map +1 -1
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +17 -1
- package/dist/utils/ffprobe.js.map +1 -1
- package/dist/utils/htmlTemplate.d.ts.map +1 -1
- package/dist/utils/htmlTemplate.js +1 -8
- package/dist/utils/htmlTemplate.js.map +1 -1
- package/dist/utils/parityContract.d.ts +1 -2
- package/dist/utils/parityContract.d.ts.map +1 -1
- package/dist/utils/parityContract.js +1 -34
- package/dist/utils/parityContract.js.map +1 -1
- package/package.json +2 -2
- package/src/config.test.ts +38 -0
- package/src/config.ts +27 -1
- package/src/index.ts +2 -0
- package/src/services/audioMixer.ts +4 -6
- package/src/services/browserManager.test.ts +79 -0
- package/src/services/browserManager.ts +55 -4
- package/src/services/chunkEncoder.ts +36 -0
- package/src/services/fileServer.ts +1 -68
- package/src/services/frameCapture.ts +130 -12
- package/src/services/screenshotService.ts +9 -7
- package/src/services/streamingEncoder.ts +25 -0
- package/src/services/videoFrameExtractor.test.ts +117 -1
- package/src/services/videoFrameExtractor.ts +100 -7
- package/src/services/videoFrameInjector.ts +15 -3
- package/src/types.ts +28 -6
- package/src/utils/alphaBlit.test.ts +10 -0
- package/src/utils/alphaBlit.ts +15 -7
- package/src/utils/ffprobe.test.ts +40 -0
- package/src/utils/ffprobe.ts +16 -1
- package/src/utils/htmlTemplate.ts +1 -9
- package/src/utils/parityContract.ts +1 -35
|
@@ -234,16 +234,48 @@ export async function releaseBrowser(
|
|
|
234
234
|
await browser.close().catch(() => {});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
export function forceReleaseBrowser(browser: Browser): void {
|
|
238
|
+
if (pooledBrowser && pooledBrowser === browser) {
|
|
239
|
+
pooledBrowserRefCount = 0;
|
|
240
|
+
pooledBrowser = null;
|
|
241
|
+
}
|
|
242
|
+
const proc = (
|
|
243
|
+
browser as unknown as {
|
|
244
|
+
process?: () => { kill: (signal?: NodeJS.Signals) => boolean; killed?: boolean } | null;
|
|
245
|
+
}
|
|
246
|
+
).process?.();
|
|
247
|
+
if (proc && !proc.killed) {
|
|
248
|
+
try {
|
|
249
|
+
proc.kill("SIGKILL");
|
|
250
|
+
} catch {
|
|
251
|
+
// Best-effort cleanup.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
browser.disconnect();
|
|
256
|
+
} catch {
|
|
257
|
+
// Best-effort cleanup.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
237
261
|
export interface BuildChromeArgsOptions {
|
|
238
262
|
width: number;
|
|
239
263
|
height: number;
|
|
240
264
|
captureMode?: CaptureMode;
|
|
265
|
+
platform?: NodeJS.Platform;
|
|
241
266
|
}
|
|
242
267
|
|
|
268
|
+
const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
|
|
269
|
+
|
|
243
270
|
export function buildChromeArgs(
|
|
244
271
|
options: BuildChromeArgsOptions,
|
|
245
|
-
config?: Partial<Pick<EngineConfig, "disableGpu" | "chromePath">>,
|
|
272
|
+
config?: Partial<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
|
|
246
273
|
): string[] {
|
|
274
|
+
const platform = options.platform ?? process.platform;
|
|
275
|
+
const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
|
|
276
|
+
const browserGpuMode = gpuDisabled
|
|
277
|
+
? "software"
|
|
278
|
+
: (config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode);
|
|
247
279
|
// Chrome flags tuned for headless rendering performance. The set below is a
|
|
248
280
|
// fairly standard "headless-for-capture" configuration — similar profiles
|
|
249
281
|
// appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own
|
|
@@ -252,10 +284,10 @@ export function buildChromeArgs(
|
|
|
252
284
|
"--no-sandbox",
|
|
253
285
|
"--disable-setuid-sandbox",
|
|
254
286
|
"--disable-dev-shm-usage",
|
|
287
|
+
CANVAS_DRAW_ELEMENT_FEATURE_FLAG,
|
|
255
288
|
"--enable-webgl",
|
|
256
289
|
"--ignore-gpu-blocklist",
|
|
257
|
-
|
|
258
|
-
"--use-angle=swiftshader",
|
|
290
|
+
...getBrowserGpuArgs(browserGpuMode, platform),
|
|
259
291
|
"--font-render-hinting=none",
|
|
260
292
|
"--force-color-profile=srgb",
|
|
261
293
|
`--window-size=${options.width},${options.height}`,
|
|
@@ -301,9 +333,28 @@ export function buildChromeArgs(
|
|
|
301
333
|
);
|
|
302
334
|
}
|
|
303
335
|
|
|
304
|
-
const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
|
|
305
336
|
if (gpuDisabled) {
|
|
306
337
|
chromeArgs.push("--disable-gpu");
|
|
307
338
|
}
|
|
308
339
|
return chromeArgs;
|
|
309
340
|
}
|
|
341
|
+
|
|
342
|
+
function getBrowserGpuArgs(
|
|
343
|
+
mode: EngineConfig["browserGpuMode"],
|
|
344
|
+
platform: NodeJS.Platform,
|
|
345
|
+
): string[] {
|
|
346
|
+
if (mode === "software") {
|
|
347
|
+
return ["--use-gl=angle", "--use-angle=swiftshader"];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
switch (platform) {
|
|
351
|
+
case "darwin":
|
|
352
|
+
return ["--use-gl=angle", "--use-angle=metal", "--enable-gpu-rasterization"];
|
|
353
|
+
case "win32":
|
|
354
|
+
return ["--use-gl=angle", "--use-angle=d3d11", "--enable-gpu-rasterization"];
|
|
355
|
+
case "linux":
|
|
356
|
+
return ["--use-gl=egl", "--enable-gpu-rasterization"];
|
|
357
|
+
default:
|
|
358
|
+
return ["--enable-gpu-rasterization"];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -139,12 +139,43 @@ export function buildEncoderArgs(
|
|
|
139
139
|
else args.push("-global_quality", String(quality));
|
|
140
140
|
break;
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
// Same B-frame story as the SW branch below — nvenc emits B-frames
|
|
144
|
+
// by default (qsv via b_strategy, vaapi too), and the negative-DTS
|
|
145
|
+
// freeze hits the same downstream players. The unconditional
|
|
146
|
+
// `-avoid_negative_ts make_zero` near the bottom of this function
|
|
147
|
+
// covers the mux level, but we belt-and-suspenders the encoder too
|
|
148
|
+
// so even tools that consume the chunk file directly (without going
|
|
149
|
+
// through our mux step) play correctly. videotoolbox doesn't accept
|
|
150
|
+
// `-bf` so it's skipped — videotoolbox h264 also doesn't emit
|
|
151
|
+
// negative DTS in practice on macOS Sonoma+.
|
|
152
|
+
if (
|
|
153
|
+
codec === "h264" &&
|
|
154
|
+
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
|
|
155
|
+
) {
|
|
156
|
+
args.push("-bf", "0");
|
|
157
|
+
if (gpuEncoder === "qsv") {
|
|
158
|
+
args.push("-b_strategy", "0");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
142
161
|
} else {
|
|
143
162
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
144
163
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
145
164
|
if (bitrate) args.push("-b:v", bitrate);
|
|
146
165
|
else args.push("-crf", String(quality));
|
|
147
166
|
|
|
167
|
+
// Disable B-frames. Standard h264 with B-frames produces negative DTS
|
|
168
|
+
// at the start of the stream (the first B-frame's decode order is
|
|
169
|
+
// "before" the first I-frame's presentation time). VS Code's video
|
|
170
|
+
// preview, several browser <video> pipelines, and some HW decoders
|
|
171
|
+
// freeze on the first frame when DTS is negative, so audio plays alone.
|
|
172
|
+
// -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
|
|
173
|
+
// source. Quality cost is ~5–10% larger files at the same CRF — a
|
|
174
|
+
// worthwhile trade for "the file plays everywhere".
|
|
175
|
+
if (codec === "h264") {
|
|
176
|
+
args.push("-bf", "0");
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
// Encoder-specific params: anti-banding + color space tagging.
|
|
149
180
|
// aq-mode=3 redistributes bits to dark flat areas (gradients).
|
|
150
181
|
// For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
|
|
@@ -239,6 +270,8 @@ export function buildEncoderArgs(
|
|
|
239
270
|
args.push("-pix_fmt", pixelFormat);
|
|
240
271
|
}
|
|
241
272
|
|
|
273
|
+
args.push("-avoid_negative_ts", "make_zero");
|
|
274
|
+
|
|
242
275
|
args.push("-y", outputPath);
|
|
243
276
|
return args;
|
|
244
277
|
}
|
|
@@ -510,6 +543,9 @@ export async function muxVideoWithAudio(
|
|
|
510
543
|
} else {
|
|
511
544
|
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
|
|
512
545
|
}
|
|
546
|
+
// PTS bases can diverge during mux and reintroduce negative DTS. See
|
|
547
|
+
// buildEncoderArgs for the full reasoning on why that breaks playback.
|
|
548
|
+
args.push("-avoid_negative_ts", "make_zero");
|
|
513
549
|
args.push("-shortest", "-y", outputPath);
|
|
514
550
|
|
|
515
551
|
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
@@ -10,6 +10,7 @@ import { Hono } from "hono";
|
|
|
10
10
|
import { serve } from "@hono/node-server";
|
|
11
11
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
12
12
|
import { join, extname } from "node:path";
|
|
13
|
+
import { injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
|
13
14
|
|
|
14
15
|
const MIME_TYPES: Record<string, string> = {
|
|
15
16
|
".html": "text/html; charset=utf-8",
|
|
@@ -34,74 +35,6 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
34
35
|
".otf": "font/otf",
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
function stripEmbeddedRuntimeScripts(html: string): string {
|
|
38
|
-
if (!html) return html;
|
|
39
|
-
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
|
40
|
-
const runtimeSrcMarkers = [
|
|
41
|
-
"hyperframe.runtime.iife.js",
|
|
42
|
-
"hyperframes-runtime.modular.inline.js",
|
|
43
|
-
"data-hyperframes-preview-runtime",
|
|
44
|
-
];
|
|
45
|
-
const runtimeInlineMarkers = [
|
|
46
|
-
"__hyperframeRuntimeBootstrapped",
|
|
47
|
-
"__hyperframeRuntime",
|
|
48
|
-
"__hyperframeRuntimeTeardown",
|
|
49
|
-
"window.__player =",
|
|
50
|
-
"window.__playerReady",
|
|
51
|
-
"window.__renderReady",
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
const shouldStrip = (block: string): boolean => {
|
|
55
|
-
const lowered = block.toLowerCase();
|
|
56
|
-
for (const marker of runtimeSrcMarkers) {
|
|
57
|
-
if (lowered.includes(marker.toLowerCase())) {
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const marker of runtimeInlineMarkers) {
|
|
62
|
-
if (block.includes(marker)) {
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return false;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function injectScriptsIntoHtml(
|
|
73
|
-
html: string,
|
|
74
|
-
headScripts: string[],
|
|
75
|
-
bodyScripts: string[],
|
|
76
|
-
stripEmbedded: boolean,
|
|
77
|
-
): string {
|
|
78
|
-
if (stripEmbedded) {
|
|
79
|
-
html = stripEmbeddedRuntimeScripts(html);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (headScripts.length > 0) {
|
|
83
|
-
const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
|
|
84
|
-
if (html.includes("</head>")) {
|
|
85
|
-
html = html.replace("</head>", () => `${headTags}\n</head>`);
|
|
86
|
-
} else if (html.includes("<body")) {
|
|
87
|
-
html = html.replace("<body", () => `${headTags}\n<body`);
|
|
88
|
-
} else {
|
|
89
|
-
html = headTags + "\n" + html;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (bodyScripts.length > 0) {
|
|
94
|
-
const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
|
|
95
|
-
if (html.includes("</body>")) {
|
|
96
|
-
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
|
|
97
|
-
} else {
|
|
98
|
-
html = html + "\n" + bodyTags;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return html;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
38
|
export interface FileServerOptions {
|
|
106
39
|
projectDir: string;
|
|
107
40
|
compiledDir?: string;
|
|
@@ -16,6 +16,7 @@ import { quantizeTimeToFrame } from "@hyperframes/core";
|
|
|
16
16
|
import {
|
|
17
17
|
acquireBrowser,
|
|
18
18
|
releaseBrowser,
|
|
19
|
+
forceReleaseBrowser,
|
|
19
20
|
buildChromeArgs,
|
|
20
21
|
resolveHeadlessShellPath,
|
|
21
22
|
type CaptureMode,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
30
31
|
import type {
|
|
31
32
|
CaptureOptions,
|
|
33
|
+
CaptureVideoMetadataHint,
|
|
32
34
|
CaptureResult,
|
|
33
35
|
CaptureBufferResult,
|
|
34
36
|
CapturePerfSummary,
|
|
@@ -73,6 +75,26 @@ export interface CaptureSession {
|
|
|
73
75
|
// Circular buffer for browser console messages dumped on render failure diagnostics.
|
|
74
76
|
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
|
|
75
77
|
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
|
|
78
|
+
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
79
|
+
|
|
80
|
+
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
83
|
+
await Promise.race([
|
|
84
|
+
promise.then(
|
|
85
|
+
() => undefined,
|
|
86
|
+
() => undefined,
|
|
87
|
+
),
|
|
88
|
+
new Promise<void>((resolve) => {
|
|
89
|
+
timer = setTimeout(() => {
|
|
90
|
+
timedOut = true;
|
|
91
|
+
resolve();
|
|
92
|
+
}, CAPTURE_SESSION_CLOSE_TIMEOUT_MS);
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
if (timer) clearTimeout(timer);
|
|
96
|
+
return !timedOut;
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
export async function createCaptureSession(
|
|
78
100
|
serverUrl: string,
|
|
@@ -133,6 +155,22 @@ export async function createCaptureSession(
|
|
|
133
155
|
w.__name = <T>(fn: T, _name: string): T => fn;
|
|
134
156
|
}
|
|
135
157
|
});
|
|
158
|
+
// Inject render-time variable overrides before any page script runs, so the
|
|
159
|
+
// runtime helper `getVariables()` returns the merged result on its first
|
|
160
|
+
// call. Pass the JSON string and parse inside the page so we don't require
|
|
161
|
+
// any JSON-incompatible value to round-trip through Puppeteer's serializer.
|
|
162
|
+
if (options.variables && Object.keys(options.variables).length > 0) {
|
|
163
|
+
const variablesJson = JSON.stringify(options.variables);
|
|
164
|
+
await page.evaluateOnNewDocument((json: string) => {
|
|
165
|
+
type WindowWithVariables = Window & { __hfVariables?: Record<string, unknown> };
|
|
166
|
+
try {
|
|
167
|
+
(window as WindowWithVariables).__hfVariables = JSON.parse(json);
|
|
168
|
+
} catch {
|
|
169
|
+
// The CLI validated the JSON before this point — a parse failure here
|
|
170
|
+
// means the page swapped JSON.parse, which is the page's problem.
|
|
171
|
+
}
|
|
172
|
+
}, variablesJson);
|
|
173
|
+
}
|
|
136
174
|
const browserVersion = await browser.version();
|
|
137
175
|
const expectedMajor = config?.expectedChromiumMajor;
|
|
138
176
|
if (Number.isFinite(expectedMajor)) {
|
|
@@ -221,6 +259,64 @@ async function pollPageExpression(
|
|
|
221
259
|
return Boolean(await page.evaluate(expression));
|
|
222
260
|
}
|
|
223
261
|
|
|
262
|
+
async function applyVideoMetadataHints(
|
|
263
|
+
page: Page,
|
|
264
|
+
hints: readonly CaptureVideoMetadataHint[] | undefined,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
if (!hints || hints.length === 0) return;
|
|
267
|
+
|
|
268
|
+
await page.evaluate(
|
|
269
|
+
(metadataHints: CaptureVideoMetadataHint[]) => {
|
|
270
|
+
for (const hint of metadataHints) {
|
|
271
|
+
if (
|
|
272
|
+
!hint.id ||
|
|
273
|
+
!Number.isFinite(hint.width) ||
|
|
274
|
+
!Number.isFinite(hint.height) ||
|
|
275
|
+
hint.width <= 0 ||
|
|
276
|
+
hint.height <= 0
|
|
277
|
+
) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const video = document.getElementById(hint.id) as HTMLVideoElement | null;
|
|
282
|
+
if (!video) continue;
|
|
283
|
+
|
|
284
|
+
if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width));
|
|
285
|
+
if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height));
|
|
286
|
+
|
|
287
|
+
const computed = window.getComputedStyle(video);
|
|
288
|
+
if (
|
|
289
|
+
!video.style.aspectRatio &&
|
|
290
|
+
(!computed.aspectRatio || computed.aspectRatio === "auto")
|
|
291
|
+
) {
|
|
292
|
+
video.style.aspectRatio = `${hint.width} / ${hint.height}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[...hints],
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Promise<void> {
|
|
301
|
+
const hasTailwindReady = await page.evaluate(
|
|
302
|
+
`(() => { const ready = window.__tailwindReady; return !!ready && typeof ready.then === "function"; })()`,
|
|
303
|
+
);
|
|
304
|
+
if (!hasTailwindReady) return;
|
|
305
|
+
|
|
306
|
+
const ready = await Promise.race([
|
|
307
|
+
page.evaluate(
|
|
308
|
+
`Promise.resolve(window.__tailwindReady).then(() => true, () => false)`,
|
|
309
|
+
) as Promise<boolean>,
|
|
310
|
+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
if (!ready) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
224
320
|
export async function initializeSession(session: CaptureSession): Promise<void> {
|
|
225
321
|
const { page, serverUrl } = session;
|
|
226
322
|
|
|
@@ -290,24 +386,34 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
290
386
|
);
|
|
291
387
|
}
|
|
292
388
|
|
|
293
|
-
|
|
294
|
-
|
|
389
|
+
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
390
|
+
|
|
391
|
+
// Wait for all video elements to have decoded their CURRENT frame, not
|
|
392
|
+
// just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is
|
|
393
|
+
// actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we
|
|
394
|
+
// only know the dimensions, and the first <video> screenshot can come
|
|
395
|
+
// back as a black/blank rectangle. This bites compositions with two
|
|
396
|
+
// <video> elements of different codecs (h264 mp4 + VP9 webm) where the
|
|
397
|
+
// faster decoder lets the readiness check pass while the slower one
|
|
398
|
+
// hasn't painted, producing a black "first frame" for the slower clip.
|
|
295
399
|
// skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
|
|
296
|
-
// sources) whose frames come from ffmpeg out-of-band
|
|
297
|
-
//
|
|
400
|
+
// sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
|
|
401
|
+
// supply intrinsic dimensions for skipped videos whose layout depends on
|
|
402
|
+
// aspect ratio, while Chromium may still fail to decode/load metadata.
|
|
298
403
|
const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
|
|
299
404
|
const videosReady = await pollPageExpression(
|
|
300
405
|
page,
|
|
301
|
-
`(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >=
|
|
406
|
+
`(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
|
|
302
407
|
pageReadyTimeout,
|
|
303
408
|
);
|
|
304
409
|
if (!videosReady) {
|
|
305
410
|
throw new Error(
|
|
306
|
-
`[FrameCapture] video
|
|
411
|
+
`[FrameCapture] video first frame not decoded after ${pageReadyTimeout}ms. Video elements must reach readyState >= 2 (HAVE_CURRENT_DATA) before capture starts.`,
|
|
307
412
|
);
|
|
308
413
|
}
|
|
309
414
|
|
|
310
415
|
await page.evaluate(`document.fonts?.ready`);
|
|
416
|
+
await waitForOptionalTailwindReady(page, pageReadyTimeout);
|
|
311
417
|
|
|
312
418
|
// For PNG captures, force the page background fully transparent so the
|
|
313
419
|
// captured screenshots carry a real alpha channel. Must run AFTER
|
|
@@ -382,15 +488,15 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
382
488
|
);
|
|
383
489
|
}
|
|
384
490
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
//
|
|
491
|
+
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
492
|
+
|
|
493
|
+
// Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
|
|
388
494
|
const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
|
|
389
495
|
const videoDeadline =
|
|
390
496
|
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
|
|
391
497
|
while (Date.now() < videoDeadline) {
|
|
392
498
|
const videosReady = await page.evaluate(
|
|
393
|
-
`(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >=
|
|
499
|
+
`(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
|
|
394
500
|
);
|
|
395
501
|
if (videosReady) break;
|
|
396
502
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -398,6 +504,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
398
504
|
|
|
399
505
|
// Font check (no rAF dependency — uses fonts.ready API directly)
|
|
400
506
|
await page.evaluate(`document.fonts?.ready`);
|
|
507
|
+
await waitForOptionalTailwindReady(page, pageReadyTimeout);
|
|
401
508
|
|
|
402
509
|
// Stop warmup
|
|
403
510
|
warmupRunning = false;
|
|
@@ -607,11 +714,22 @@ export async function closeCaptureSession(session: CaptureSession): Promise<void
|
|
|
607
714
|
// but browserReleased=false → second call no-ops on page and retries browser.
|
|
608
715
|
// This matches the orchestrator's intent for HDR cleanup.
|
|
609
716
|
if (!session.pageReleased && session.page) {
|
|
610
|
-
await session.page.close()
|
|
717
|
+
const pageClosed = await waitForCloseWithTimeout(session.page.close());
|
|
718
|
+
if (!pageClosed) {
|
|
719
|
+
console.warn("[FrameCapture] Timed out closing page; forcing browser process shutdown");
|
|
720
|
+
forceReleaseBrowser(session.browser);
|
|
721
|
+
session.browserReleased = true;
|
|
722
|
+
}
|
|
611
723
|
session.pageReleased = true;
|
|
612
724
|
}
|
|
613
725
|
if (!session.browserReleased && session.browser) {
|
|
614
|
-
await
|
|
726
|
+
const browserClosed = await waitForCloseWithTimeout(
|
|
727
|
+
releaseBrowser(session.browser, session.config),
|
|
728
|
+
);
|
|
729
|
+
if (!browserClosed) {
|
|
730
|
+
console.warn("[FrameCapture] Timed out closing browser; forcing browser process shutdown");
|
|
731
|
+
forceReleaseBrowser(session.browser);
|
|
732
|
+
}
|
|
615
733
|
session.browserReleased = true;
|
|
616
734
|
}
|
|
617
735
|
session.isInitialized = false;
|
|
@@ -446,13 +446,15 @@ export async function injectVideoFramesBatch(
|
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
448
|
img.decoding = "sync";
|
|
449
|
-
img.src
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
449
|
+
if (img.getAttribute("src") !== item.dataUri) {
|
|
450
|
+
img.src = item.dataUri;
|
|
451
|
+
pendingDecodes.push(
|
|
452
|
+
img
|
|
453
|
+
.decode()
|
|
454
|
+
.catch(() => undefined)
|
|
455
|
+
.then(() => undefined),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
456
458
|
img.style.opacity = String(computedOpacity);
|
|
457
459
|
img.style.visibility = "visible";
|
|
458
460
|
// Hide the native <video> with visibility only — never clobber inline
|
|
@@ -221,12 +221,33 @@ export function buildStreamingArgs(
|
|
|
221
221
|
else args.push("-global_quality", String(quality));
|
|
222
222
|
break;
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, qsv,
|
|
226
|
+
// vaapi) and produce the same negative-DTS freeze for downstream players.
|
|
227
|
+
// See chunkEncoder.buildEncoderArgs for the full explanation.
|
|
228
|
+
if (
|
|
229
|
+
codec === "h264" &&
|
|
230
|
+
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
|
|
231
|
+
) {
|
|
232
|
+
args.push("-bf", "0");
|
|
233
|
+
if (gpuEncoder === "qsv") {
|
|
234
|
+
args.push("-b_strategy", "0");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
224
237
|
} else {
|
|
225
238
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
226
239
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
227
240
|
if (bitrate) args.push("-b:v", bitrate);
|
|
228
241
|
else args.push("-crf", String(quality));
|
|
229
242
|
|
|
243
|
+
// Mirrors chunkEncoder: disable B-frames for h264 so PTS == DTS, no
|
|
244
|
+
// negative DTS at stream start. Without this, files freeze on the
|
|
245
|
+
// first frame in VS Code preview, several browsers, and some HW
|
|
246
|
+
// decoders. See chunkEncoder.buildEncoderArgs for the full reasoning.
|
|
247
|
+
if (codec === "h264") {
|
|
248
|
+
args.push("-bf", "0");
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
// Encoder-specific params: anti-banding + color space tagging.
|
|
231
252
|
// For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
|
|
232
253
|
// mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages —
|
|
@@ -313,6 +334,10 @@ export function buildStreamingArgs(
|
|
|
313
334
|
args.push("-pix_fmt", pixelFormat);
|
|
314
335
|
}
|
|
315
336
|
|
|
337
|
+
// Belt-and-suspenders against negative DTS at stream start. See chunkEncoder
|
|
338
|
+
// for the full explanation; same playback compatibility class.
|
|
339
|
+
args.push("-avoid_negative_ts", "make_zero");
|
|
340
|
+
|
|
316
341
|
args.push("-y", outputPath);
|
|
317
342
|
return args;
|
|
318
343
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { createHash } from "node:crypto";
|
|
4
12
|
import { join } from "node:path";
|
|
5
13
|
import { tmpdir } from "node:os";
|
|
@@ -9,6 +17,9 @@ import {
|
|
|
9
17
|
parseImageElements,
|
|
10
18
|
extractAllVideoFrames,
|
|
11
19
|
createFrameLookupTable,
|
|
20
|
+
resolveProjectRelativeSrc,
|
|
21
|
+
codecMayHaveAlpha,
|
|
22
|
+
decoderForCodec,
|
|
12
23
|
type VideoElement,
|
|
13
24
|
type ExtractedFrames,
|
|
14
25
|
} from "./videoFrameExtractor.js";
|
|
@@ -23,6 +34,111 @@ import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
|
23
34
|
// synthesized VFR fixture.
|
|
24
35
|
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
|
|
25
36
|
|
|
37
|
+
// Codec-based alpha defaulting replaces tag-based detection (the
|
|
38
|
+
// alpha_mode/ALPHA_MODE case bug — see ffprobe.test.ts for the regression
|
|
39
|
+
// pin on that). The extractor uses these helpers for two decisions:
|
|
40
|
+
// 1. whether to force the alpha-aware decoder (libvpx-vp9 for VP9, libvpx
|
|
41
|
+
// for VP8)
|
|
42
|
+
// 2. whether to default the cached frame format to PNG (with alpha) vs JPG
|
|
43
|
+
// The "default to capable" trade is small file-size growth on opaque VP9
|
|
44
|
+
// content for correctness on alpha-having content even when the sidecar tag
|
|
45
|
+
// is missing or muxed with the wrong case.
|
|
46
|
+
describe("codec alpha capability", () => {
|
|
47
|
+
it("flags VP9, VP8, and ProRes as alpha-capable", () => {
|
|
48
|
+
expect(codecMayHaveAlpha("vp9")).toBe(true);
|
|
49
|
+
expect(codecMayHaveAlpha("VP9")).toBe(true);
|
|
50
|
+
expect(codecMayHaveAlpha("vp8")).toBe(true);
|
|
51
|
+
expect(codecMayHaveAlpha("prores")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not flag h264 / h265 / mpeg4 (no alpha in their bitstreams)", () => {
|
|
55
|
+
expect(codecMayHaveAlpha("h264")).toBe(false);
|
|
56
|
+
expect(codecMayHaveAlpha("h265")).toBe(false);
|
|
57
|
+
expect(codecMayHaveAlpha("hevc")).toBe(false);
|
|
58
|
+
expect(codecMayHaveAlpha("mpeg4")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("treats undefined / empty input as non-alpha", () => {
|
|
62
|
+
expect(codecMayHaveAlpha(undefined)).toBe(false);
|
|
63
|
+
expect(codecMayHaveAlpha("")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns the alpha-aware decoder name for VP9 and VP8", () => {
|
|
67
|
+
expect(decoderForCodec("vp9")).toBe("libvpx-vp9");
|
|
68
|
+
expect(decoderForCodec("VP9")).toBe("libvpx-vp9");
|
|
69
|
+
expect(decoderForCodec("vp8")).toBe("libvpx");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Regression: a long-standing footgun where `<video src="../assets/foo">`
|
|
74
|
+
// inside a sub-composition silently dropped the video from extraction. The
|
|
75
|
+
// browser's URL resolver clamps `..` at the served origin's root (so the
|
|
76
|
+
// page renders fine in the studio), but `path.join(projectDir, "../assets/foo")`
|
|
77
|
+
// normalizes to <parentOfProjectDir>/assets/foo, which doesn't exist —
|
|
78
|
+
// extraction skipped, no frame injection, rendered output shows the video's
|
|
79
|
+
// first decoded frame for the whole clip duration. The resolver now mirrors
|
|
80
|
+
// browser semantics by clamping any traversal that escapes the project root.
|
|
81
|
+
describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
|
|
82
|
+
let tmp: string;
|
|
83
|
+
|
|
84
|
+
beforeAll(() => {
|
|
85
|
+
tmp = mkdtempSync(join(tmpdir(), "hf-resolver-"));
|
|
86
|
+
mkdirSync(join(tmp, "project", "assets"), { recursive: true });
|
|
87
|
+
writeFileSync(join(tmp, "project", "assets", "foo.mp4"), "");
|
|
88
|
+
});
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns the literal join when the file exists at projectDir/src", () => {
|
|
94
|
+
const projectDir = join(tmp, "project");
|
|
95
|
+
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir)).toBe(
|
|
96
|
+
join(projectDir, "assets/foo.mp4"),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("clamps a leading `../` so `../assets/foo.mp4` resolves to assets/foo.mp4", () => {
|
|
101
|
+
const projectDir = join(tmp, "project");
|
|
102
|
+
expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe(
|
|
103
|
+
join(projectDir, "assets/foo.mp4"),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("clamps multiple leading `../../../` segments", () => {
|
|
108
|
+
const projectDir = join(tmp, "project");
|
|
109
|
+
expect(resolveProjectRelativeSrc("../../../assets/foo.mp4", projectDir)).toBe(
|
|
110
|
+
join(projectDir, "assets/foo.mp4"),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("clamps mid-path traversal that escapes baseDir (not just leading `..`)", () => {
|
|
115
|
+
// `assets/../../foo.mp4` collapses past projectDir via path.join — this
|
|
116
|
+
// case used to silently escape; the resolver now strips embedded `..`
|
|
117
|
+
// segments and re-anchors at the project root.
|
|
118
|
+
const projectDir = join(tmp, "project");
|
|
119
|
+
expect(resolveProjectRelativeSrc("assets/../../assets/foo.mp4", projectDir)).toBe(
|
|
120
|
+
join(projectDir, "assets/foo.mp4"),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns the (non-existent) base-dir path on miss so callers get a stable error message", () => {
|
|
125
|
+
const projectDir = join(tmp, "project");
|
|
126
|
+
expect(resolveProjectRelativeSrc("../assets/missing.mp4", projectDir)).toBe(
|
|
127
|
+
join(projectDir, "../assets/missing.mp4"),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("prefers compiled-dir over base-dir when the file exists in both", () => {
|
|
132
|
+
const projectDir = join(tmp, "project");
|
|
133
|
+
const compiledDir = join(tmp, "compiled");
|
|
134
|
+
mkdirSync(join(compiledDir, "assets"), { recursive: true });
|
|
135
|
+
writeFileSync(join(compiledDir, "assets", "foo.mp4"), "");
|
|
136
|
+
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir, compiledDir)).toBe(
|
|
137
|
+
join(compiledDir, "assets/foo.mp4"),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
26
142
|
describe("parseVideoElements", () => {
|
|
27
143
|
it("parses videos without an id or data-start attribute", () => {
|
|
28
144
|
const videos = parseVideoElements('<video src="clip.mp4"></video>');
|