@hyperframes/engine 0.5.0-alpha.8 → 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
|
@@ -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
|
}
|
|
@@ -14,7 +14,16 @@ import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotSe
|
|
|
14
14
|
import { type BeforeCaptureHook } from "./frameCapture.js";
|
|
15
15
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
export interface VideoFrameInjectorOptions extends Partial<
|
|
18
|
+
Pick<EngineConfig, "frameDataUriCacheLimit">
|
|
19
|
+
> {
|
|
20
|
+
frameSrcResolver?: (framePath: string) => string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createFrameSourceCache(
|
|
24
|
+
cacheLimit: number,
|
|
25
|
+
frameSrcResolver?: (framePath: string) => string | null,
|
|
26
|
+
) {
|
|
18
27
|
const cache = new Map<string, string>();
|
|
19
28
|
const inFlight = new Map<string, Promise<string>>();
|
|
20
29
|
|
|
@@ -33,6 +42,9 @@ function createFrameDataUriCache(cacheLimit: number) {
|
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
async function get(framePath: string): Promise<string> {
|
|
45
|
+
const servedSrc = frameSrcResolver?.(framePath);
|
|
46
|
+
if (servedSrc) return servedSrc;
|
|
47
|
+
|
|
36
48
|
const cached = cache.get(framePath);
|
|
37
49
|
if (cached) {
|
|
38
50
|
remember(framePath, cached);
|
|
@@ -67,7 +79,7 @@ function createFrameDataUriCache(cacheLimit: number) {
|
|
|
67
79
|
*/
|
|
68
80
|
export function createVideoFrameInjector(
|
|
69
81
|
frameLookup: FrameLookupTable | null,
|
|
70
|
-
config?:
|
|
82
|
+
config?: VideoFrameInjectorOptions,
|
|
71
83
|
): BeforeCaptureHook | null {
|
|
72
84
|
if (!frameLookup) return null;
|
|
73
85
|
|
|
@@ -75,7 +87,7 @@ export function createVideoFrameInjector(
|
|
|
75
87
|
32,
|
|
76
88
|
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
|
|
77
89
|
);
|
|
78
|
-
const frameCache =
|
|
90
|
+
const frameCache = createFrameSourceCache(cacheLimit, config?.frameSrcResolver);
|
|
79
91
|
const lastInjectedFrameByVideo = new Map<string, number>();
|
|
80
92
|
|
|
81
93
|
return async (page: Page, time: number) => {
|
package/src/types.ts
CHANGED
|
@@ -85,18 +85,40 @@ export interface CaptureOptions {
|
|
|
85
85
|
format?: "jpeg" | "png";
|
|
86
86
|
quality?: number;
|
|
87
87
|
deviceScaleFactor?: number;
|
|
88
|
+
/**
|
|
89
|
+
* FFmpeg-probed intrinsic dimensions for videos whose frames are injected
|
|
90
|
+
* out-of-band. Applied before the readiness wait so layout that depends on
|
|
91
|
+
* video aspect ratio (e.g. `height:auto`) stays stable even if Chromium never
|
|
92
|
+
* loads native metadata.
|
|
93
|
+
*/
|
|
94
|
+
videoMetadataHints?: readonly CaptureVideoMetadataHint[];
|
|
88
95
|
/**
|
|
89
96
|
* Video element IDs to exclude from the in-page readiness check that waits
|
|
90
97
|
* for `video.readyState >= 1` before capture starts.
|
|
91
98
|
*
|
|
92
|
-
* Use for videos whose frames are supplied out-of-band
|
|
93
|
-
* frame
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* decode (HEVC on Linux `headless-shell`) cause a fatal timeout even
|
|
97
|
-
* though we never asked the browser to play the video.
|
|
99
|
+
* Use for videos whose frames are supplied out-of-band, including standard
|
|
100
|
+
* FFmpeg frame injection and native HDR extraction. Pair with
|
|
101
|
+
* `videoMetadataHints` for any skipped video whose CSS layout may depend on
|
|
102
|
+
* intrinsic media dimensions.
|
|
98
103
|
*/
|
|
99
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>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CaptureVideoMetadataHint {
|
|
119
|
+
id: string;
|
|
120
|
+
width: number;
|
|
121
|
+
height: number;
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
export interface CaptureResult {
|
|
@@ -511,6 +511,16 @@ describe("blitRgba8OverRgb48le", () => {
|
|
|
511
511
|
expect(canvas.readUInt16LE(4)).toBe(0);
|
|
512
512
|
});
|
|
513
513
|
|
|
514
|
+
it("fully opaque DOM with srgb transfer expands 8-bit channels to 16-bit SDR", () => {
|
|
515
|
+
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
|
|
516
|
+
const dom = makeDomRgba(1, 1, 255, 128, 1, 255);
|
|
517
|
+
blitRgba8OverRgb48le(dom, canvas, 1, 1, "srgb");
|
|
518
|
+
|
|
519
|
+
expect(canvas.readUInt16LE(0)).toBe(65535);
|
|
520
|
+
expect(canvas.readUInt16LE(2)).toBe(128 * 257);
|
|
521
|
+
expect(canvas.readUInt16LE(4)).toBe(257);
|
|
522
|
+
});
|
|
523
|
+
|
|
514
524
|
it("sRGB→HLG: black stays black, white stays white", () => {
|
|
515
525
|
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
|
516
526
|
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
package/src/utils/alphaBlit.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function decodePngToRgb48le(buf: Buffer): { width: number; height: number
|
|
|
249
249
|
* bt2020). For neutral/near-neutral content (text, UI) the gamut difference
|
|
250
250
|
* is negligible.
|
|
251
251
|
*/
|
|
252
|
-
function
|
|
252
|
+
function buildSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
|
|
253
253
|
const lut = new Uint16Array(256);
|
|
254
254
|
|
|
255
255
|
// HLG OETF constants (Rec. 2100)
|
|
@@ -267,6 +267,11 @@ function buildSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
|
|
267
267
|
const sdrNits = 203.0;
|
|
268
268
|
|
|
269
269
|
for (let i = 0; i < 256; i++) {
|
|
270
|
+
if (transfer === "srgb") {
|
|
271
|
+
lut[i] = i * 257;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
270
275
|
// sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
|
|
271
276
|
const v = i / 255;
|
|
272
277
|
const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
@@ -288,12 +293,15 @@ function buildSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
|
|
288
293
|
return lut;
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
const
|
|
292
|
-
const
|
|
296
|
+
const SRGB_TO_SRGB_16 = buildSrgbToSignalLut("srgb");
|
|
297
|
+
const SRGB_TO_HLG = buildSrgbToSignalLut("hlg");
|
|
298
|
+
const SRGB_TO_PQ = buildSrgbToSignalLut("pq");
|
|
293
299
|
|
|
294
300
|
/** Select the correct sRGB→HDR LUT for the given transfer function. */
|
|
295
|
-
function
|
|
296
|
-
|
|
301
|
+
function getSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
|
|
302
|
+
if (transfer === "pq") return SRGB_TO_PQ;
|
|
303
|
+
if (transfer === "hlg") return SRGB_TO_HLG;
|
|
304
|
+
return SRGB_TO_SRGB_16;
|
|
297
305
|
}
|
|
298
306
|
|
|
299
307
|
// ── Alpha compositing ─────────────────────────────────────────────────────────
|
|
@@ -317,10 +325,10 @@ export function blitRgba8OverRgb48le(
|
|
|
317
325
|
canvas: Buffer,
|
|
318
326
|
width: number,
|
|
319
327
|
height: number,
|
|
320
|
-
transfer: "hlg" | "pq" = "hlg",
|
|
328
|
+
transfer: "hlg" | "pq" | "srgb" = "hlg",
|
|
321
329
|
): void {
|
|
322
330
|
const pixelCount = width * height;
|
|
323
|
-
const lut =
|
|
331
|
+
const lut = getSrgbToSignalLut(transfer);
|
|
324
332
|
|
|
325
333
|
for (let i = 0; i < pixelCount; i++) {
|
|
326
334
|
const da = domRgba[i * 4 + 3] ?? 0;
|
|
@@ -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
|
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
function parseHTMLContent(html: string): Document {
|
|
4
|
-
const trimmed = html.trimStart().toLowerCase();
|
|
5
|
-
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
|
|
6
|
-
return parseHTML(html).document;
|
|
7
|
-
}
|
|
8
|
-
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
|
9
|
-
}
|
|
1
|
+
import { parseHTMLContent } from "@hyperframes/core/compiler";
|
|
10
2
|
|
|
11
3
|
function getSingleMeaningfulChild(container: Element): Element | null {
|
|
12
4
|
let child: Element | null = null;
|
|
@@ -1,35 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
"width",
|
|
3
|
-
"height",
|
|
4
|
-
"top",
|
|
5
|
-
"left",
|
|
6
|
-
"right",
|
|
7
|
-
"bottom",
|
|
8
|
-
"inset",
|
|
9
|
-
"object-fit",
|
|
10
|
-
"object-position",
|
|
11
|
-
"z-index",
|
|
12
|
-
"opacity",
|
|
13
|
-
"visibility",
|
|
14
|
-
"filter",
|
|
15
|
-
"mix-blend-mode",
|
|
16
|
-
"backdrop-filter",
|
|
17
|
-
"border-radius",
|
|
18
|
-
"overflow",
|
|
19
|
-
"clip-path",
|
|
20
|
-
"mask",
|
|
21
|
-
"mask-image",
|
|
22
|
-
"mask-size",
|
|
23
|
-
"mask-position",
|
|
24
|
-
"mask-repeat",
|
|
25
|
-
"transform",
|
|
26
|
-
"transform-origin",
|
|
27
|
-
"box-sizing",
|
|
28
|
-
] as const;
|
|
29
|
-
|
|
30
|
-
export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
|
|
31
|
-
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
|
32
|
-
const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
|
|
33
|
-
const frameIndex = Math.floor(safeTime * safeFps + 1e-9);
|
|
34
|
-
return frameIndex / safeFps;
|
|
35
|
-
}
|
|
1
|
+
export { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "@hyperframes/core";
|