@hyperframes/engine 0.4.43 → 0.4.45
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/index.d.ts +1 -1
- 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/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +31 -0
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +28 -9
- package/dist/services/frameCapture.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/types.d.ts +11 -0
- package/dist/types.d.ts.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/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/services/audioMixer.ts +4 -6
- package/src/services/chunkEncoder.ts +36 -0
- package/src/services/frameCapture.ts +28 -9
- package/src/services/streamingEncoder.ts +25 -0
- package/src/services/videoFrameExtractor.test.ts +117 -1
- package/src/services/videoFrameExtractor.ts +100 -7
- package/src/types.ts +11 -0
- package/src/utils/ffprobe.test.ts +40 -0
- package/src/utils/ffprobe.ts +16 -1
|
@@ -155,6 +155,22 @@ export async function createCaptureSession(
|
|
|
155
155
|
w.__name = <T>(fn: T, _name: string): T => fn;
|
|
156
156
|
}
|
|
157
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
|
+
}
|
|
158
174
|
const browserVersion = await browser.version();
|
|
159
175
|
const expectedMajor = config?.expectedChromiumMajor;
|
|
160
176
|
if (Number.isFinite(expectedMajor)) {
|
|
@@ -372,8 +388,14 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
372
388
|
|
|
373
389
|
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
374
390
|
|
|
375
|
-
// Wait for all video elements to have
|
|
376
|
-
//
|
|
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.
|
|
377
399
|
// skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
|
|
378
400
|
// sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
|
|
379
401
|
// supply intrinsic dimensions for skipped videos whose layout depends on
|
|
@@ -381,12 +403,12 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
381
403
|
const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
|
|
382
404
|
const videosReady = await pollPageExpression(
|
|
383
405
|
page,
|
|
384
|
-
`(() => { 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); })()`,
|
|
385
407
|
pageReadyTimeout,
|
|
386
408
|
);
|
|
387
409
|
if (!videosReady) {
|
|
388
410
|
throw new Error(
|
|
389
|
-
`[FrameCapture] video
|
|
411
|
+
`[FrameCapture] video first frame not decoded after ${pageReadyTimeout}ms. Video elements must reach readyState >= 2 (HAVE_CURRENT_DATA) before capture starts.`,
|
|
390
412
|
);
|
|
391
413
|
}
|
|
392
414
|
|
|
@@ -468,16 +490,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
|
|
468
490
|
|
|
469
491
|
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
470
492
|
|
|
471
|
-
//
|
|
472
|
-
// Without this, frame 0 captures videos at their 300x150 default size.
|
|
473
|
-
// See screenshot-mode comment above for why skipReadinessVideoIds and
|
|
474
|
-
// videoMetadataHints are paired.
|
|
493
|
+
// Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
|
|
475
494
|
const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
|
|
476
495
|
const videoDeadline =
|
|
477
496
|
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
|
|
478
497
|
while (Date.now() < videoDeadline) {
|
|
479
498
|
const videosReady = await page.evaluate(
|
|
480
|
-
`(() => { 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); })()`,
|
|
481
500
|
);
|
|
482
501
|
if (videosReady) break;
|
|
483
502
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -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>');
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|
10
|
-
import { isAbsolute, join } from "path";
|
|
10
|
+
import { isAbsolute, join, posix, resolve, sep } from "path";
|
|
11
11
|
import { parseHTML } from "linkedom";
|
|
12
12
|
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
|
13
13
|
import {
|
|
@@ -230,8 +230,17 @@ export async function extractVideoFramesRange(
|
|
|
230
230
|
if (isHdr && isMacOS) {
|
|
231
231
|
args.push("-hwaccel", "videotoolbox");
|
|
232
232
|
}
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
// Always force the alpha-aware decoder on codecs that can carry alpha. The
|
|
234
|
+
// alternative — gating on `metadata.hasAlpha` — relies on tag detection that
|
|
235
|
+
// has at least three known failure modes: case-sensitivity across ffmpeg
|
|
236
|
+
// versions (`alpha_mode` vs `ALPHA_MODE`), missing tags from older muxers,
|
|
237
|
+
// and mp4-as-webm rewraps that drop the sidecar. A wrong negative there
|
|
238
|
+
// silently strips alpha during decode and the bug doesn't surface until
|
|
239
|
+
// the rendered video is missing layers. Codec-based default has no such
|
|
240
|
+
// ambiguity: libvpx-vp9 reads the alpha sidecar when present and decodes
|
|
241
|
+
// normally when it isn't.
|
|
242
|
+
if (codecMayHaveAlpha(metadata.videoCodec)) {
|
|
243
|
+
args.push("-c:v", decoderForCodec(metadata.videoCodec));
|
|
235
244
|
}
|
|
236
245
|
args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
|
|
237
246
|
|
|
@@ -398,9 +407,31 @@ function resolveSegmentDuration(
|
|
|
398
407
|
return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
|
|
399
408
|
}
|
|
400
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Codecs whose bitstream is allowed to carry an alpha channel. Default the
|
|
412
|
+
* extraction path to PNG output for these regardless of `metadata.hasAlpha`
|
|
413
|
+
* so a missed sidecar tag doesn't silently strip transparency. Opaque content
|
|
414
|
+
* encoded in one of these codecs pays a small file-size cost on the cached
|
|
415
|
+
* frames but stays correct on the rare case where alpha IS present and the
|
|
416
|
+
* tag was missed.
|
|
417
|
+
*/
|
|
418
|
+
const ALPHA_CAPABLE_CODECS = new Set(["vp9", "vp8", "prores"]);
|
|
419
|
+
|
|
420
|
+
export function codecMayHaveAlpha(codec: string | undefined): boolean {
|
|
421
|
+
return ALPHA_CAPABLE_CODECS.has((codec ?? "").toLowerCase());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function decoderForCodec(codec: string | undefined): string {
|
|
425
|
+
const c = (codec ?? "").toLowerCase();
|
|
426
|
+
if (c === "vp9") return "libvpx-vp9";
|
|
427
|
+
if (c === "vp8") return "libvpx";
|
|
428
|
+
return c;
|
|
429
|
+
}
|
|
430
|
+
|
|
401
431
|
function resolveFrameFormat(metadata: VideoMetadata, requested?: "jpg" | "png"): CacheFrameFormat {
|
|
402
432
|
if (requested) return requested;
|
|
403
|
-
|
|
433
|
+
if (metadata.hasAlpha || codecMayHaveAlpha(metadata.videoCodec)) return "png";
|
|
434
|
+
return "jpg";
|
|
404
435
|
}
|
|
405
436
|
|
|
406
437
|
/**
|
|
@@ -459,6 +490,54 @@ async function convertVfrToCfr(
|
|
|
459
490
|
}
|
|
460
491
|
}
|
|
461
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Resolve a relative `<video src>` to a filesystem path the way the browser
|
|
495
|
+
* resolves it as a URL. Browsers clamp `..` segments at the served origin's
|
|
496
|
+
* root; `path.join(projectDir, "../assets/foo")` does not. So a sub-comp
|
|
497
|
+
* `<video src="../assets/foo">` loads in the page (browser clamps to
|
|
498
|
+
* `<projectDir>/assets/foo`) but the filesystem-side resolver lands at
|
|
499
|
+
* `<parentOfProjectDir>/assets/foo` — file missing, extraction skipped,
|
|
500
|
+
* the rendered output shows the video's first frame for the whole clip.
|
|
501
|
+
*
|
|
502
|
+
* The clamp covers two escape patterns: leading `..` (`../assets/foo`) AND
|
|
503
|
+
* mid-path escapes (`assets/../../foo`) that `path.join` collapses past the
|
|
504
|
+
* project root silently. Both fall back to a project-rooted candidate that
|
|
505
|
+
* strips traversal from the resolved path.
|
|
506
|
+
*
|
|
507
|
+
* Returns the first existing candidate, or the base-dir join on miss so
|
|
508
|
+
* the caller's `existsSync` check produces a stable error path.
|
|
509
|
+
*/
|
|
510
|
+
export function resolveProjectRelativeSrc(
|
|
511
|
+
src: string,
|
|
512
|
+
baseDir: string,
|
|
513
|
+
compiledDir?: string,
|
|
514
|
+
): string {
|
|
515
|
+
const fromCompiled = compiledDir ? join(compiledDir, src) : null;
|
|
516
|
+
const fromBase = join(baseDir, src);
|
|
517
|
+
const candidates: string[] = [];
|
|
518
|
+
if (fromCompiled) candidates.push(fromCompiled);
|
|
519
|
+
candidates.push(fromBase);
|
|
520
|
+
// If the joined result escapes the project root (either via leading `..`
|
|
521
|
+
// or mid-path traversal that path.join collapsed past baseDir), retry
|
|
522
|
+
// with the basename re-anchored at the project root. This mirrors the
|
|
523
|
+
// browser URL clamp without relying on a particular `..` shape.
|
|
524
|
+
const baseAbs = resolve(baseDir);
|
|
525
|
+
const fromBaseAbs = resolve(fromBase);
|
|
526
|
+
if (!fromBaseAbs.startsWith(baseAbs + sep) && fromBaseAbs !== baseAbs) {
|
|
527
|
+
// Normalize first (`assets/../../assets/foo.mp4` → `../assets/foo.mp4`)
|
|
528
|
+
// then strip any remaining leading `..` segments. Stripping `..` from the
|
|
529
|
+
// raw input would leave dangling siblings (`assets/../../assets/foo`
|
|
530
|
+
// would become `assets/assets/foo` instead of `assets/foo`).
|
|
531
|
+
const normalized = posix.normalize(src.replace(/\\/g, "/"));
|
|
532
|
+
const stripped = normalized.replace(/^(\.\.\/)+/, "");
|
|
533
|
+
if (stripped && stripped !== src && !stripped.startsWith("..")) {
|
|
534
|
+
if (compiledDir) candidates.push(join(compiledDir, stripped));
|
|
535
|
+
candidates.push(join(baseDir, stripped));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return candidates.find(existsSync) ?? fromBase;
|
|
539
|
+
}
|
|
540
|
+
|
|
462
541
|
export async function extractAllVideoFrames(
|
|
463
542
|
videos: VideoElement[],
|
|
464
543
|
baseDir: string,
|
|
@@ -487,6 +566,9 @@ export async function extractAllVideoFrames(
|
|
|
487
566
|
// Phase 1: Resolve paths and download remote videos
|
|
488
567
|
const phase1Start = Date.now();
|
|
489
568
|
const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
|
|
569
|
+
// Dedupe missing-src warnings: a composition with N <video> elements all
|
|
570
|
+
// pointing at the same broken src should only print one warning, not N.
|
|
571
|
+
const warnedSrcs = new Set<string>();
|
|
490
572
|
for (const video of videos) {
|
|
491
573
|
if (signal?.aborted) break;
|
|
492
574
|
try {
|
|
@@ -496,9 +578,7 @@ export async function extractAllVideoFrames(
|
|
|
496
578
|
// baseDir and produce duplicated, nonexistent paths
|
|
497
579
|
// (e.g. C:\tmp\hf-vfr-test-X\C:\tmp\hf-vfr-test-X\vfr_screen.mp4).
|
|
498
580
|
if (!isAbsolute(videoPath) && !isHttpUrl(videoPath)) {
|
|
499
|
-
|
|
500
|
-
videoPath =
|
|
501
|
-
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
|
|
581
|
+
videoPath = resolveProjectRelativeSrc(video.src, baseDir, compiledDir);
|
|
502
582
|
}
|
|
503
583
|
|
|
504
584
|
if (isHttpUrl(videoPath)) {
|
|
@@ -508,6 +588,19 @@ export async function extractAllVideoFrames(
|
|
|
508
588
|
}
|
|
509
589
|
|
|
510
590
|
if (!existsSync(videoPath)) {
|
|
591
|
+
// Loud: silent miss leaves the rendered video frozen at frame 0 with
|
|
592
|
+
// no error in stdout — extremely confusing for authors. Dedupe by
|
|
593
|
+
// src so 50 broken videos pointing at the same path don't spam.
|
|
594
|
+
if (!warnedSrcs.has(video.src)) {
|
|
595
|
+
warnedSrcs.add(video.src);
|
|
596
|
+
process.stderr.write(
|
|
597
|
+
`[hyperframes:render] WARNING: video src="${video.src}" ` +
|
|
598
|
+
`could not be resolved on disk (looked for ${videoPath}). ` +
|
|
599
|
+
`The rendered output will show this video's first frame for the entire clip duration. ` +
|
|
600
|
+
`If your <video> lives inside a sub-composition, prefer project-root-relative paths ` +
|
|
601
|
+
`(e.g. src="assets/foo.mp4") over "../assets/foo.mp4".\n`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
511
604
|
errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
|
|
512
605
|
continue;
|
|
513
606
|
}
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,17 @@ export interface CaptureOptions {
|
|
|
102
102
|
* intrinsic media dimensions.
|
|
103
103
|
*/
|
|
104
104
|
skipReadinessVideoIds?: readonly string[];
|
|
105
|
+
/**
|
|
106
|
+
* Render-time variable overrides for the composition. The engine injects
|
|
107
|
+
* these as `window.__hfVariables` via `evaluateOnNewDocument` before any
|
|
108
|
+
* page script runs, so the runtime helper `getVariables()` returns the
|
|
109
|
+
* merged result of declared defaults (`data-composition-variables`) and
|
|
110
|
+
* these overrides on its first call.
|
|
111
|
+
*
|
|
112
|
+
* The CLI populates this from `--variables '<json>'` /
|
|
113
|
+
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
|
114
|
+
*/
|
|
115
|
+
variables?: Record<string, unknown>;
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
export interface CaptureVideoMetadataHint {
|
|
@@ -225,6 +225,46 @@ describe("ffprobe missing-binary fallback", () => {
|
|
|
225
225
|
expect(meta.hasAlpha).toBe(true);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
// Regression: newer libavformat builds (and the output of `hyperframes
|
|
229
|
+
// remove-background` itself) write the VP9-alpha sidecar tag as
|
|
230
|
+
// `ALPHA_MODE` (uppercase). The lowercase-only check classified those
|
|
231
|
+
// files as having no alpha, the producer extracted them as JPGs, and
|
|
232
|
+
// the injected <img> overlays were fully opaque rectangles that hid
|
|
233
|
+
// every static element below them on the z-stack. The bug was silent —
|
|
234
|
+
// studio preview rendered correctly via native <video> playback while
|
|
235
|
+
// production renders covered headlines and captions with the avatar.
|
|
236
|
+
it("extractMediaMetadata detects ALPHA_MODE (uppercase) streams from newer ffmpeg builds", async () => {
|
|
237
|
+
const { spawn } = createSpawnSpy([
|
|
238
|
+
{
|
|
239
|
+
kind: "exit",
|
|
240
|
+
code: 0,
|
|
241
|
+
stdout: JSON.stringify({
|
|
242
|
+
streams: [
|
|
243
|
+
{
|
|
244
|
+
codec_type: "video",
|
|
245
|
+
codec_name: "vp9",
|
|
246
|
+
width: 320,
|
|
247
|
+
height: 180,
|
|
248
|
+
r_frame_rate: "30/1",
|
|
249
|
+
avg_frame_rate: "30/1",
|
|
250
|
+
pix_fmt: "yuv420p",
|
|
251
|
+
tags: { ALPHA_MODE: "1" },
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
format: { duration: "1.5" },
|
|
255
|
+
}),
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
vi.resetModules();
|
|
259
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
260
|
+
|
|
261
|
+
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
|
|
262
|
+
const meta = await extractMediaMetadataMocked("/tmp/alpha-uppercase.webm");
|
|
263
|
+
|
|
264
|
+
expect(meta.videoCodec).toBe("vp9");
|
|
265
|
+
expect(meta.hasAlpha).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
228
268
|
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
|
|
229
269
|
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
|
230
270
|
vi.resetModules();
|
package/src/utils/ffprobe.ts
CHANGED
|
@@ -203,6 +203,21 @@ function extractStillImageMetadata(filePath: string): StillImageMetadata | null
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Read an ffprobe tag case-insensitively. ffmpeg/libavformat versions disagree
|
|
208
|
+
* on tag casing — VP9 alpha is `alpha_mode` in older builds and `ALPHA_MODE`
|
|
209
|
+
* in newer ones; HDR tags vary similarly. Use this for any sidecar tag where
|
|
210
|
+
* you want to be resilient across muxer versions.
|
|
211
|
+
*/
|
|
212
|
+
function readTagCI(tags: Record<string, string | undefined> | undefined, name: string): string {
|
|
213
|
+
if (!tags) return "";
|
|
214
|
+
const target = name.toLowerCase();
|
|
215
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
216
|
+
if (key.toLowerCase() === target && typeof value === "string") return value;
|
|
217
|
+
}
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
|
|
206
221
|
function parseFrameRate(frameRateStr: string | undefined): number {
|
|
207
222
|
if (!frameRateStr) return 0;
|
|
208
223
|
const parts = frameRateStr.split("/");
|
|
@@ -277,7 +292,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
|
|
|
277
292
|
: null;
|
|
278
293
|
const colorSpace = ffprobeColorSpace ?? stillImageMeta?.colorSpace ?? null;
|
|
279
294
|
const pixelFormat = videoStream.pix_fmt || "";
|
|
280
|
-
const alphaMode = videoStream.tags
|
|
295
|
+
const alphaMode = readTagCI(videoStream.tags, "alpha_mode");
|
|
281
296
|
const hasAlpha =
|
|
282
297
|
/(^|[^a-z])yuva|rgba|argb|bgra|gbrap|gray[a-z0-9]*a/i.test(pixelFormat) || alphaMode === "1";
|
|
283
298
|
|