mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(engine): add HDR video output pipeline (#265)
## Summary Adds the ability to render HDR video output (H.265 10-bit, BT.2020) from HyperFrames compositions. When the renderer detects HDR source video, it automatically switches to the HDR output pipeline — no flags needed. ## What it does - **Auto-detection** — Probes each video source with `ffprobe`. If any has bt2020/PQ/HLG color metadata, the output switches to H.265 10-bit with correct color tags. SDR-only compositions are unaffected (H.264, bt709). - **HLG pass-through** — Native HLG pixels from FFmpeg extraction are piped directly to the encoder without conversion. This avoids brightness loss from HLG→linear→PQ conversion (which requires an OOTF system gamma we can't reliably apply). - **Encoder HDR support** — Both chunk and streaming encoders accept HDR presets: `libx265`, `yuv420p10le`, BT.2020 color primaries, `hvc1` codec tag (required for Apple playback). - **WebGPU HDR capture (gated)** — A complete WebGPU float16 readback pipeline is implemented and tested but gated behind headed Chrome (headless doesn't expose WebGPU). Ready for future use with WebGPU canvas content. - **HDR utilities** — `detectTransfer()` (PQ vs HLG), `getHdrEncoderColorParams()`, `analyzeCompositionHdr()`. 15 unit tests. ## Key design decisions | Decision | Why | |----------|-----| | No `--hdr` flag | SDR content encoded as HDR causes orange shift in browsers. Auto-detect eliminates this. | | HLG pass-through (not HLG→PQ) | Conversion loses brightness without OOTF. Pass-through matches source exactly. | | `hvc1` codec tag | Apple QuickTime requires `hvc1` (not `hev1`) for HEVC playback. | | 1-hour streaming timeout | HDR capture at ~6fps needs more time than the default 10-minute FFmpeg timeout. | ## Files changed | File | What changed | |------|-------------| | `packages/engine/src/utils/hdr.ts` | **NEW** — HDR detection, transfer types, encoder params (15 tests) | | `packages/engine/src/services/hdrCapture.ts` | **NEW** — WebGPU readback, HLG conversion, PQ encode | | `packages/engine/src/services/streamingEncoder.ts` | HDR presets, raw rgb48le input, color tags | | `packages/engine/src/services/chunkEncoder.ts` | HDR presets, conditional color tags | | `packages/producer/src/services/renderOrchestrator.ts` | Auto-detection loop, HDR pass-through capture path | ## How to test Render a composition with an HDR video source. The output should be H.265 10-bit with HDR metadata visible in `ffprobe` (bt2020, arib-std-b67 or smpte2084). Plays correctly in QuickTime and on HDR displays. ## Stack position **2 of 6** — Stacked on #258 (SDR/HDR normalization). Provides the encoder infrastructure that phases 1-5 build on. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
},
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -98,13 +98,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -113,7 +114,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -144,6 +145,7 @@
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"@types/node": "^22.10.1",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"esbuild": "^0.27.2",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.2",
|
||||
@@ -151,7 +153,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -163,7 +165,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.5",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -781,6 +783,8 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "3.2.1", "tinyrainbow": "2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface EngineConfig {
|
||||
/** Timeout for FFmpeg streaming encode (ms). Default: 600_000 */
|
||||
ffmpegStreamingTimeout: number;
|
||||
|
||||
// ── HDR ──────────────────────────────────────────────────────────────
|
||||
/** HDR output transfer function. false = SDR output (default). */
|
||||
hdr: { transfer: "hlg" | "pq" } | false;
|
||||
/** Auto-detect HDR from video sources when hdr is not explicitly set. */
|
||||
hdrAutoDetect: boolean;
|
||||
|
||||
// ── Media ────────────────────────────────────────────────────────────
|
||||
audioGain: number;
|
||||
frameDataUriCacheLimit: number;
|
||||
@@ -96,6 +102,9 @@ export const DEFAULT_CONFIG: EngineConfig = {
|
||||
ffmpegProcessTimeout: 300_000,
|
||||
ffmpegStreamingTimeout: 600_000,
|
||||
|
||||
hdr: false,
|
||||
hdrAutoDetect: true,
|
||||
|
||||
audioGain: 1.35,
|
||||
frameDataUriCacheLimit: 256,
|
||||
|
||||
@@ -170,6 +179,13 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
DEFAULT_CONFIG.ffmpegStreamingTimeout,
|
||||
),
|
||||
|
||||
hdr: (() => {
|
||||
const raw = env("PRODUCER_HDR_TRANSFER");
|
||||
if (raw === "hlg" || raw === "pq") return { transfer: raw };
|
||||
return undefined;
|
||||
})(),
|
||||
hdrAutoDetect: envBool("PRODUCER_HDR_AUTO_DETECT", DEFAULT_CONFIG.hdrAutoDetect),
|
||||
|
||||
audioGain: envNum("PRODUCER_AUDIO_GAIN", DEFAULT_CONFIG.audioGain),
|
||||
frameDataUriCacheLimit: Math.max(
|
||||
32,
|
||||
|
||||
@@ -153,3 +153,23 @@ export {
|
||||
} from "./utils/ffprobe.js";
|
||||
|
||||
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
|
||||
export {
|
||||
initHdrReadback,
|
||||
uploadAndReadbackHdrFrame,
|
||||
convertHdrFrameToRgb48le,
|
||||
float16ToPqRgb,
|
||||
buildHdrChromeArgs,
|
||||
launchHdrBrowser,
|
||||
} from "./services/hdrCapture.js";
|
||||
|
||||
export {
|
||||
isHdrColorSpace,
|
||||
detectTransfer,
|
||||
getHdrEncoderColorParams,
|
||||
analyzeCompositionHdr,
|
||||
type HdrTransfer,
|
||||
type HdrEncoderColorParams,
|
||||
type CompositionHdrInfo,
|
||||
} from "./utils/hdr.js";
|
||||
export type { VideoColorSpace } from "./utils/ffprobe.js";
|
||||
|
||||
@@ -247,3 +247,91 @@ describe("buildEncoderArgs color space", () => {
|
||||
expect(args).not.toContain("-video_track_timescale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEncoderPreset HDR", () => {
|
||||
it("returns h265 with 10-bit for HDR HLG", () => {
|
||||
const preset = getEncoderPreset("standard", "mp4", { transfer: "hlg" });
|
||||
expect(preset.codec).toBe("h265");
|
||||
expect(preset.pixelFormat).toBe("yuv420p10le");
|
||||
expect(preset.hdr).toEqual({ transfer: "hlg" });
|
||||
});
|
||||
|
||||
it("returns h265 with 10-bit for HDR PQ", () => {
|
||||
const preset = getEncoderPreset("high", "mp4", { transfer: "pq" });
|
||||
expect(preset.codec).toBe("h265");
|
||||
expect(preset.pixelFormat).toBe("yuv420p10le");
|
||||
expect(preset.hdr).toEqual({ transfer: "pq" });
|
||||
});
|
||||
|
||||
it("avoids ultrafast preset for HDR (upgrades to fast)", () => {
|
||||
const preset = getEncoderPreset("draft", "mp4", { transfer: "hlg" });
|
||||
expect(preset.preset).toBe("fast");
|
||||
});
|
||||
|
||||
it("ignores HDR for webm format", () => {
|
||||
const preset = getEncoderPreset("standard", "webm", { transfer: "hlg" });
|
||||
expect(preset.codec).toBe("vp9");
|
||||
expect(preset.hdr).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores HDR for mov format", () => {
|
||||
const preset = getEncoderPreset("standard", "mov", { transfer: "pq" });
|
||||
expect(preset.codec).toBe("prores");
|
||||
expect(preset.hdr).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEncoderArgs HDR color space", () => {
|
||||
const baseOptions = { fps: 30, width: 1920, height: 1080 };
|
||||
const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
|
||||
|
||||
it("keeps bt709 color tags when HDR flag is set but frames are still Chrome sRGB captures", () => {
|
||||
// HDR flag gives H.265 + 10-bit encoding but pixels are still sRGB/bt709.
|
||||
// Tagging as bt2020 causes orange shift — so we tag truthfully as bt709.
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||
const paramIdx = args.indexOf("-x265-params");
|
||||
expect(args[paramIdx + 1]).toContain("colorprim=bt709");
|
||||
expect(args[paramIdx + 1]).toContain("transfer=bt709");
|
||||
});
|
||||
|
||||
it("uses bt709 when HDR is not set", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||
});
|
||||
|
||||
it("uses range conversion (not colorspace) for HDR CPU encoding", () => {
|
||||
// Chrome screenshots are sRGB — we don't convert primaries (causes color shifts).
|
||||
// Just range-convert and let the bt2020 container metadata + 10-bit handle the rest.
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
expect(vfIdx).toBeGreaterThan(-1);
|
||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||
expect(args[vfIdx + 1]).not.toContain("colorspace");
|
||||
});
|
||||
|
||||
it("uses same range conversion for SDR CPU encoding", () => {
|
||||
const args = buildEncoderArgs(
|
||||
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
|
||||
inputArgs,
|
||||
"out.mp4",
|
||||
);
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSy
|
||||
import { join, dirname } from "path";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
|
||||
import { type HdrTransfer } from "../utils/hdr.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
||||
|
||||
@@ -21,15 +22,24 @@ export const ENCODER_PRESETS = {
|
||||
high: { preset: "slow", quality: 15, codec: "h264" as const },
|
||||
};
|
||||
|
||||
export interface EncoderPreset {
|
||||
preset: string;
|
||||
quality: number;
|
||||
codec: "h264" | "h265" | "vp9" | "prores";
|
||||
pixelFormat: string;
|
||||
hdr?: { transfer: HdrTransfer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encoder preset for a given quality and output format.
|
||||
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264;
|
||||
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
|
||||
* MOV uses ProRes 4444 with alpha for editor-compatible transparency.
|
||||
*/
|
||||
export function getEncoderPreset(
|
||||
quality: "draft" | "standard" | "high",
|
||||
format: "mp4" | "webm" | "mov" = "mp4",
|
||||
): { preset: string; quality: number; codec: "h264" | "vp9" | "prores"; pixelFormat: string } {
|
||||
hdr?: { transfer: HdrTransfer },
|
||||
): EncoderPreset {
|
||||
const base = ENCODER_PRESETS[quality];
|
||||
if (format === "webm") {
|
||||
return {
|
||||
@@ -47,6 +57,15 @@ export function getEncoderPreset(
|
||||
pixelFormat: "yuva444p10le",
|
||||
};
|
||||
}
|
||||
if (hdr) {
|
||||
return {
|
||||
preset: base.preset === "ultrafast" ? "fast" : base.preset,
|
||||
quality: base.quality,
|
||||
codec: "h265",
|
||||
pixelFormat: "yuv420p10le",
|
||||
hdr,
|
||||
};
|
||||
}
|
||||
return { ...base, pixelFormat: "yuv420p" };
|
||||
}
|
||||
|
||||
@@ -109,9 +128,8 @@ export function buildEncoderArgs(
|
||||
if (bitrate) args.push("-b:v", bitrate);
|
||||
else args.push("-crf", String(quality));
|
||||
|
||||
// Encoder-specific params: anti-banding + bt709 color space.
|
||||
// Encoder-specific params: anti-banding + color space tagging.
|
||||
// aq-mode=3 redistributes bits to dark flat areas (gradients).
|
||||
// colorprim/transfer/colormatrix embed bt709 in the H.264/H.265 VUI.
|
||||
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
|
||||
const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||
if (preset === "ultrafast") {
|
||||
@@ -120,6 +138,10 @@ export function buildEncoderArgs(
|
||||
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
|
||||
}
|
||||
}
|
||||
// Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
|
||||
if (codec === "h265") {
|
||||
args.push("-tag:v", "hvc1");
|
||||
}
|
||||
} else if (codec === "vp9") {
|
||||
args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
|
||||
args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
|
||||
@@ -134,8 +156,12 @@ export function buildEncoderArgs(
|
||||
return [...args, "-y", outputPath];
|
||||
}
|
||||
|
||||
// BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709.
|
||||
// Tags the output so players interpret colors correctly across devices.
|
||||
// Color space metadata — tags the output so players interpret colors correctly.
|
||||
// Chrome screenshots are always sRGB/bt709 pixels regardless of --hdr flag.
|
||||
// We tag truthfully as bt709 even for HDR output — the --hdr flag gives
|
||||
// H.265 + 10-bit encoding (better quality/compression) without lying about
|
||||
// the color space. Tagging as bt2020 when pixels are bt709 causes browsers
|
||||
// to apply the wrong color transform, producing visible orange/warm shifts.
|
||||
if (codec === "h264" || codec === "h265") {
|
||||
args.push(
|
||||
"-colorspace:v",
|
||||
@@ -148,15 +174,15 @@ export function buildEncoderArgs(
|
||||
"tv",
|
||||
);
|
||||
|
||||
// Convert full-range RGB input (Chrome screenshots) to limited/TV range for H.264.
|
||||
// VAAPI already has a -vf chain for hwupload; prepend range conversion to it.
|
||||
// Range conversion: Chrome's full-range RGB → limited/TV range.
|
||||
if (gpuEncoder === "vaapi") {
|
||||
// Replace the existing VAAPI -vf with one that includes range conversion
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
if (vfIdx !== -1) {
|
||||
args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
|
||||
}
|
||||
} else if (!shouldUseGpu) {
|
||||
// Range conversion: Chrome screenshots are full-range RGB.
|
||||
// The scale filter handles both 8-bit and 10-bit correctly.
|
||||
args.push("-vf", "scale=in_range=pc:out_range=tv");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HdrTransfer } from "../utils/hdr.js";
|
||||
|
||||
export interface EncoderOptions {
|
||||
fps: number;
|
||||
width: number;
|
||||
@@ -8,6 +10,7 @@ export interface EncoderOptions {
|
||||
bitrate?: string;
|
||||
pixelFormat?: string;
|
||||
useGpu?: boolean;
|
||||
hdr?: { transfer: HdrTransfer };
|
||||
}
|
||||
|
||||
export interface EncodeResult {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { float16ToPqRgb } from "./hdrCapture.js";
|
||||
|
||||
// IEEE 754 half-precision (float16) bit patterns used to feed
|
||||
// `float16ToPqRgb`. Encoding rule: sign(1) | exp(5) | frac(10).
|
||||
const F16_ZERO = 0x0000; // +0.0
|
||||
const F16_HALF = 0x3800; // +0.5 (exp=14, frac=0 → 2^-1)
|
||||
const F16_ONE = 0x3c00; // +1.0 (exp=15, frac=0 → 2^0 — SDR white)
|
||||
// PQ caps at 10000 nits and SDR_NITS = 203, so the linear input must exceed
|
||||
// ~58x SDR white before linearToPQ(L) clips at 1.0. 1024 is well above that.
|
||||
const F16_OVERBRIGHT = 0x6400; // +1024.0 (exp=25, frac=0 → 2^10)
|
||||
|
||||
function makeFloat16Frame(
|
||||
width: number,
|
||||
height: number,
|
||||
pixel: { r: number; g: number; b: number; a: number },
|
||||
bytesPerRow: number = width * 8,
|
||||
): Buffer {
|
||||
// Row-padded layout matches WebGPU readback: bytesPerRow ≥ width * 8 (4
|
||||
// channels × 2 bytes), with garbage bytes after each row's pixel data.
|
||||
const buf = Buffer.alloc(height * bytesPerRow);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * bytesPerRow + x * 8;
|
||||
buf.writeUInt16LE(pixel.r, idx);
|
||||
buf.writeUInt16LE(pixel.g, idx + 2);
|
||||
buf.writeUInt16LE(pixel.b, idx + 4);
|
||||
buf.writeUInt16LE(pixel.a, idx + 6);
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("float16ToPqRgb", () => {
|
||||
it("returns a buffer of width * height * 6 bytes (rgb48le)", () => {
|
||||
const frame = makeFloat16Frame(4, 3, { r: 0, g: 0, b: 0, a: 0 });
|
||||
const out = float16ToPqRgb(frame, 32, 4, 3);
|
||||
expect(out.length).toBe(4 * 3 * 6);
|
||||
});
|
||||
|
||||
it("encodes float16 black to PQ zero (linearToPQ(0) ≈ 0 after uint16 quantization)", () => {
|
||||
const frame = makeFloat16Frame(2, 2, {
|
||||
r: F16_ZERO,
|
||||
g: F16_ZERO,
|
||||
b: F16_ZERO,
|
||||
a: F16_ZERO,
|
||||
});
|
||||
const out = float16ToPqRgb(frame, 16, 2, 2);
|
||||
for (let i = 0; i < out.length; i += 2) {
|
||||
expect(out.readUInt16LE(i)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps overbright float16 input to PQ 65535 (linearToPQ(>>1.0) → 1.0)", () => {
|
||||
// ~1024 linear is well past the 58x-SDR PQ saturation point; output caps
|
||||
// at 1.0 → 65535 in uint16.
|
||||
const frame = makeFloat16Frame(2, 2, {
|
||||
r: F16_OVERBRIGHT,
|
||||
g: F16_OVERBRIGHT,
|
||||
b: F16_OVERBRIGHT,
|
||||
a: F16_ZERO,
|
||||
});
|
||||
const out = float16ToPqRgb(frame, 16, 2, 2);
|
||||
for (let pixel = 0; pixel < 4; pixel++) {
|
||||
const dst = pixel * 6;
|
||||
expect(out.readUInt16LE(dst)).toBe(65535);
|
||||
expect(out.readUInt16LE(dst + 2)).toBe(65535);
|
||||
expect(out.readUInt16LE(dst + 4)).toBe(65535);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves channel ordering R, G, B (alpha is discarded)", () => {
|
||||
// Distinct float16 values per channel verify the function doesn't
|
||||
// mix them up. Alpha is set high but should not appear in the output.
|
||||
const frame = makeFloat16Frame(1, 1, {
|
||||
r: F16_ONE,
|
||||
g: F16_HALF,
|
||||
b: F16_ZERO,
|
||||
a: F16_ONE,
|
||||
});
|
||||
const out = float16ToPqRgb(frame, 8, 1, 1);
|
||||
const r = out.readUInt16LE(0);
|
||||
const g = out.readUInt16LE(2);
|
||||
const b = out.readUInt16LE(4);
|
||||
expect(r).toBeGreaterThan(g);
|
||||
expect(g).toBeGreaterThan(b);
|
||||
expect(b).toBe(0);
|
||||
});
|
||||
|
||||
it("is monotonic: higher float16 input produces higher PQ output", () => {
|
||||
const dark = makeFloat16Frame(1, 1, { r: F16_ZERO, g: 0, b: 0, a: 0 });
|
||||
const mid = makeFloat16Frame(1, 1, { r: F16_HALF, g: 0, b: 0, a: 0 });
|
||||
const bright = makeFloat16Frame(1, 1, { r: F16_ONE, g: 0, b: 0, a: 0 });
|
||||
const r0 = float16ToPqRgb(dark, 8, 1, 1).readUInt16LE(0);
|
||||
const r1 = float16ToPqRgb(mid, 8, 1, 1).readUInt16LE(0);
|
||||
const r2 = float16ToPqRgb(bright, 8, 1, 1).readUInt16LE(0);
|
||||
expect(r0).toBe(0);
|
||||
expect(r1).toBeGreaterThan(r0);
|
||||
expect(r2).toBeGreaterThan(r1);
|
||||
});
|
||||
|
||||
it("is deterministic across calls with the same input", () => {
|
||||
const frame = makeFloat16Frame(3, 2, {
|
||||
r: F16_HALF,
|
||||
g: F16_ONE,
|
||||
b: F16_ZERO,
|
||||
a: F16_ONE,
|
||||
});
|
||||
const a = float16ToPqRgb(frame, 24, 3, 2);
|
||||
const b = float16ToPqRgb(frame, 24, 3, 2);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles padded bytesPerRow (WebGPU 256-byte alignment)", () => {
|
||||
// WebGPU readback pads rows to 256-byte multiples. For a 4-pixel-wide
|
||||
// frame the actual pixel data is 32 bytes but bytesPerRow is 256.
|
||||
const width = 4;
|
||||
const height = 2;
|
||||
const bytesPerRow = 256;
|
||||
const frame = makeFloat16Frame(
|
||||
width,
|
||||
height,
|
||||
{ r: F16_HALF, g: F16_HALF, b: F16_HALF, a: 0 },
|
||||
bytesPerRow,
|
||||
);
|
||||
const out = float16ToPqRgb(frame, bytesPerRow, width, height);
|
||||
expect(out.length).toBe(width * height * 6);
|
||||
// Every R component should be the same non-zero value (uniform input).
|
||||
const expected = out.readUInt16LE(0);
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
for (let pixel = 0; pixel < width * height; pixel++) {
|
||||
expect(out.readUInt16LE(pixel * 6)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores garbage bytes in the row padding region", () => {
|
||||
// Stuff junk into the trailing padding to make sure the PQ encoder
|
||||
// walks via bytesPerRow stride and not via raw buffer position.
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
const bytesPerRow = 64;
|
||||
const frame = makeFloat16Frame(
|
||||
width,
|
||||
height,
|
||||
{ r: F16_ZERO, g: F16_ZERO, b: F16_ZERO, a: F16_ZERO },
|
||||
bytesPerRow,
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const padStart = y * bytesPerRow + width * 8;
|
||||
for (let i = padStart; i < (y + 1) * bytesPerRow; i++) {
|
||||
frame[i] = 0xff;
|
||||
}
|
||||
}
|
||||
const out = float16ToPqRgb(frame, bytesPerRow, width, height);
|
||||
for (let i = 0; i < out.length; i += 2) {
|
||||
expect(out.readUInt16LE(i)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
/**
|
||||
* HDR Capture Service
|
||||
*
|
||||
* Captures HDR video frames via WebGPU float16 readback.
|
||||
*
|
||||
* The pipeline:
|
||||
* 1. FFmpeg extracts raw HDR pixels (rgba64le) from video sources
|
||||
* 2. Node converts HLG/PQ signal → linear light → float16
|
||||
* 3. writeTexture uploads float16 data to WebGPU rgba16float texture
|
||||
* 4. (Optional) WebGPU shader applies GSAP CSS transform
|
||||
* 5. readback extracts float16 RGBA via base64 transfer
|
||||
* 6. Node converts linear float16 → PQ signal → pipe to FFmpeg H.265
|
||||
*
|
||||
* Requirements:
|
||||
* - Headed Chrome (not headless) — WebGPU unavailable in headless mode
|
||||
* - GPU access (Metal on macOS, Vulkan+NVIDIA on Linux)
|
||||
*
|
||||
* Performance: ~6 fps at 1080x1920 via base64 transfer.
|
||||
*/
|
||||
|
||||
import type { Page, Browser, PuppeteerNode } from "puppeteer-core";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
// ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
|
||||
|
||||
const PQ_M1 = 0.1593017578125;
|
||||
const PQ_M2 = 78.84375;
|
||||
const PQ_C1 = 0.8359375;
|
||||
const PQ_C2 = 18.8515625;
|
||||
const PQ_C3 = 18.6875;
|
||||
const PQ_MAX_NITS = 10000.0;
|
||||
const SDR_NITS = 203.0;
|
||||
|
||||
function linearToPQ(L: number): number {
|
||||
const Lp = Math.max(0, (L * SDR_NITS) / PQ_MAX_NITS);
|
||||
const Lm1 = Math.pow(Lp, PQ_M1);
|
||||
return Math.pow((PQ_C1 + PQ_C2 * Lm1) / (1.0 + PQ_C3 * Lm1), PQ_M2);
|
||||
}
|
||||
|
||||
function float16Decode(h: number): number {
|
||||
const sign = (h >> 15) & 1;
|
||||
const exp = (h >> 10) & 0x1f;
|
||||
const frac = h & 0x3ff;
|
||||
if (exp === 0) return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024);
|
||||
if (exp === 31) return frac ? NaN : sign ? -Infinity : Infinity;
|
||||
return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024);
|
||||
}
|
||||
|
||||
// ── Browser-side interface ────────────────────────────────────────────────────
|
||||
|
||||
interface HdrCaptureRuntime {
|
||||
uploadAndReadback(float16Base64: string): Promise<{ base64: string; bytesPerRow: number }>;
|
||||
}
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inject the WebGPU HDR readback runtime into the page.
|
||||
*
|
||||
* Creates an rgba16float render texture that accepts writeTexture uploads
|
||||
* and provides readback via base64 transfer.
|
||||
*/
|
||||
export async function initHdrReadback(page: Page, width: number, height: number): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
async (w: number, h: number): Promise<boolean> => {
|
||||
if (!navigator.gpu) return false;
|
||||
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter) return false;
|
||||
|
||||
const device = await adapter.requestDevice();
|
||||
|
||||
const bytesPerPixel = 8; // rgba16float = 4 channels × 2 bytes
|
||||
const bytesPerRow = Math.ceil((w * bytesPerPixel) / 256) * 256;
|
||||
|
||||
// Render texture — includes COPY_DST for writeTexture uploads
|
||||
const renderTexture = device.createTexture({
|
||||
size: [w, h],
|
||||
format: "rgba16float",
|
||||
usage:
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.TEXTURE_BINDING,
|
||||
});
|
||||
|
||||
const readBuffer = device.createBuffer({
|
||||
size: bytesPerRow * h,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
const captureRuntime = {
|
||||
device,
|
||||
renderTexture,
|
||||
readBuffer,
|
||||
bytesPerRow,
|
||||
width: w,
|
||||
height: h,
|
||||
|
||||
/**
|
||||
* Upload pre-converted float16 RGBA data and read it back.
|
||||
* The float16 data must be row-aligned to bytesPerRow.
|
||||
*
|
||||
* Input: base64-encoded Uint16Array (float16 RGBA, row-padded)
|
||||
* Output: base64-encoded readback of the same texture
|
||||
*/
|
||||
async uploadAndReadback(
|
||||
float16Base64: string,
|
||||
): Promise<{ base64: string; bytesPerRow: number }> {
|
||||
// Decode base64 → Uint8Array
|
||||
const binary = atob(float16Base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
// Upload to texture
|
||||
device.queue.writeTexture(
|
||||
{ texture: renderTexture },
|
||||
bytes.buffer,
|
||||
{ bytesPerRow, rowsPerImage: h },
|
||||
[w, h],
|
||||
);
|
||||
|
||||
// Readback
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyTextureToBuffer(
|
||||
{ texture: renderTexture },
|
||||
{ buffer: readBuffer, bytesPerRow },
|
||||
[w, h],
|
||||
);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
|
||||
await readBuffer.mapAsync(GPUMapMode.READ);
|
||||
const readBytes = new Uint8Array(readBuffer.getMappedRange().slice(0));
|
||||
readBuffer.unmap();
|
||||
|
||||
// Base64 encode in chunks
|
||||
let b64 = "";
|
||||
const chunkSize = 32768;
|
||||
for (let i = 0; i < readBytes.length; i += chunkSize) {
|
||||
const slice = readBytes.subarray(i, Math.min(i + chunkSize, readBytes.length));
|
||||
b64 += String.fromCharCode(...slice);
|
||||
}
|
||||
|
||||
return { base64: btoa(b64), bytesPerRow };
|
||||
},
|
||||
};
|
||||
|
||||
(window as unknown as Record<string, unknown>).__hfHdrCapture = captureRuntime;
|
||||
return true;
|
||||
},
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HDR frame conversion ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert raw rgba64le pixels (from FFmpeg) to a base64 string for FFmpeg encoding.
|
||||
*
|
||||
* For HLG sources: the pixel values are already HLG-encoded. We pass them through
|
||||
* as-is (normalized to 16-bit) and tag the output as HLG. No OETF conversion needed —
|
||||
* the HLG signal values ARE the correct encoding. Converting to linear and back to
|
||||
* PQ produces worse results because every viewer's PQ→display tone-mapping differs
|
||||
* from its HLG→display tone-mapping.
|
||||
*
|
||||
* The WebGPU round-trip is skipped for pass-through — the pixels go directly from
|
||||
* FFmpeg extraction to FFmpeg encoding. WebGPU is only needed when transforms
|
||||
* (scale, rotate, opacity from GSAP) must be applied to the HDR pixels.
|
||||
*/
|
||||
export function convertHdrFrameToRgb48le(
|
||||
rawRgba64le: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): Buffer {
|
||||
const input = new Uint16Array(
|
||||
rawRgba64le.buffer,
|
||||
rawRgba64le.byteOffset,
|
||||
rawRgba64le.byteLength / 2,
|
||||
);
|
||||
|
||||
// Convert RGBA → RGB (drop alpha) for rgb48le output
|
||||
const output = Buffer.alloc(width * height * 6);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = (y * width + x) * 4;
|
||||
const dstIdx = (y * width + x) * 6;
|
||||
output.writeUInt16LE(input[srcIdx] ?? 0, dstIdx);
|
||||
output.writeUInt16LE(input[srcIdx + 1] ?? 0, dstIdx + 2);
|
||||
output.writeUInt16LE(input[srcIdx + 2] ?? 0, dstIdx + 4);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ── Frame upload + readback ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a float16 frame to WebGPU and read it back.
|
||||
* Call after converting with convertHdrFrameToFloat16Base64.
|
||||
*/
|
||||
export async function uploadAndReadbackHdrFrame(
|
||||
page: Page,
|
||||
float16Base64: string,
|
||||
): Promise<{ rawBuffer: Buffer; bytesPerRow: number }> {
|
||||
const result = await page.evaluate(
|
||||
async (b64: string): Promise<{ base64: string; bytesPerRow: number }> => {
|
||||
const hdr = (window as unknown as Record<string, unknown>).__hfHdrCapture as
|
||||
| HdrCaptureRuntime
|
||||
| undefined;
|
||||
if (!hdr) throw new Error("HDR capture not initialized");
|
||||
return hdr.uploadAndReadback(b64);
|
||||
},
|
||||
float16Base64,
|
||||
);
|
||||
|
||||
return {
|
||||
rawBuffer: Buffer.from(result.base64, "base64"),
|
||||
bytesPerRow: result.bytesPerRow,
|
||||
};
|
||||
}
|
||||
|
||||
// ── PQ conversion ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert float16 RGBA readback to PQ-encoded rgb48le for FFmpeg.
|
||||
*/
|
||||
export function float16ToPqRgb(
|
||||
rawBuffer: Buffer,
|
||||
bytesPerRow: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): Buffer {
|
||||
const data = new Uint16Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength / 2);
|
||||
const channelsPerRow = bytesPerRow / 2;
|
||||
const output = Buffer.alloc(width * height * 6);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = y * channelsPerRow + x * 4;
|
||||
const r = float16Decode(data[srcIdx] ?? 0);
|
||||
const g = float16Decode(data[srcIdx + 1] ?? 0);
|
||||
const b = float16Decode(data[srcIdx + 2] ?? 0);
|
||||
|
||||
const dstIdx = (y * width + x) * 6;
|
||||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(r)) * 65535), dstIdx);
|
||||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(g)) * 65535), dstIdx + 2);
|
||||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(b)) * 65535), dstIdx + 4);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ── Chrome launch ─────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveHeadedChromePath(): string | undefined {
|
||||
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
|
||||
if (!existsSync(baseDir)) return undefined;
|
||||
const versions = readdirSync(baseDir).sort().reverse();
|
||||
for (const version of versions) {
|
||||
const candidates = [
|
||||
join(
|
||||
baseDir,
|
||||
version,
|
||||
"chrome-mac-arm64",
|
||||
"Google Chrome for Testing.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"Google Chrome for Testing",
|
||||
),
|
||||
join(
|
||||
baseDir,
|
||||
version,
|
||||
"chrome-mac-x64",
|
||||
"Google Chrome for Testing.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"Google Chrome for Testing",
|
||||
),
|
||||
join(baseDir, version, "chrome-linux64", "chrome"),
|
||||
join(baseDir, version, "chrome-win64", "chrome.exe"),
|
||||
];
|
||||
for (const binary of candidates) {
|
||||
if (existsSync(binary)) return binary;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a headed Chrome browser with WebGPU enabled.
|
||||
*/
|
||||
export async function launchHdrBrowser(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<{ browser: Browser; page: Page }> {
|
||||
let ppt: PuppeteerNode | undefined;
|
||||
try {
|
||||
const mod = await import("puppeteer" as string);
|
||||
ppt = mod.default;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") {
|
||||
throw err;
|
||||
}
|
||||
const mod = await import("puppeteer-core");
|
||||
ppt = mod.default;
|
||||
}
|
||||
if (!ppt) throw new Error("Neither puppeteer nor puppeteer-core found");
|
||||
|
||||
const chromePath = resolveHeadedChromePath();
|
||||
if (!chromePath) {
|
||||
throw new Error(
|
||||
"[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable",
|
||||
);
|
||||
}
|
||||
|
||||
const browser = await ppt.launch({
|
||||
headless: false,
|
||||
executablePath: chromePath,
|
||||
args: buildHdrChromeArgs(width, height),
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width, height });
|
||||
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
export function buildHdrChromeArgs(width: number, height: number): string[] {
|
||||
return [
|
||||
"--enable-unsafe-webgpu",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--window-position=-10000,-10000",
|
||||
`--window-size=${width},${height}`,
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-background-media-suspend",
|
||||
"--disable-extensions",
|
||||
"--disable-component-update",
|
||||
"--disable-default-apps",
|
||||
"--disable-sync",
|
||||
"--no-zygote",
|
||||
"--force-gpu-mem-available-mb=4096",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* buildStreamingArgs unit tests.
|
||||
*
|
||||
* These tests focus on the FFmpeg CLI shape rather than spawning the encoder
|
||||
* — they're the cheap regression net for the HDR static-metadata bug
|
||||
* (side_data=[none] in the encoded MP4) reproduced by
|
||||
* packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
|
||||
* refactors of the x265-params string can silently strip
|
||||
* master-display / max-cll and ship as SDR BT.2020 again.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildStreamingArgs, type StreamingEncoderOptions } from "./streamingEncoder.js";
|
||||
import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
|
||||
|
||||
const baseHdrPq: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h265",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
pixelFormat: "yuv420p10le",
|
||||
useGpu: false,
|
||||
rawInputFormat: "rgb48le",
|
||||
hdr: { transfer: "pq" },
|
||||
};
|
||||
|
||||
const baseHdrHlg: StreamingEncoderOptions = {
|
||||
...baseHdrPq,
|
||||
hdr: { transfer: "hlg" },
|
||||
};
|
||||
|
||||
const baseSdr: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h264",
|
||||
preset: "medium",
|
||||
quality: 23,
|
||||
useGpu: false,
|
||||
};
|
||||
|
||||
function getX265ParamsValue(args: string[]): string | undefined {
|
||||
const idx = args.indexOf("-x265-params");
|
||||
return idx === -1 ? undefined : args[idx + 1];
|
||||
}
|
||||
|
||||
describe("buildStreamingArgs", () => {
|
||||
describe("HDR PQ (libx265)", () => {
|
||||
it("emits master-display and max-cll in -x265-params", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
||||
const x265 = getX265ParamsValue(args);
|
||||
expect(x265).toBeDefined();
|
||||
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
||||
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
||||
expect(x265).toContain("colorprim=bt2020");
|
||||
expect(x265).toContain("transfer=smpte2084");
|
||||
expect(x265).toContain("colormatrix=bt2020nc");
|
||||
});
|
||||
|
||||
it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
||||
expect(args).toContain("-colorspace:v");
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
||||
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
|
||||
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
||||
});
|
||||
|
||||
it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
||||
const cvIdx = args.indexOf("-c:v");
|
||||
expect(cvIdx).toBeGreaterThan(-1);
|
||||
expect(args[cvIdx + 1]).toBe("libx265");
|
||||
expect(args).toContain("-tag:v");
|
||||
expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
|
||||
});
|
||||
|
||||
it("keeps the aq-mode prefix even with master-display present", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
||||
const x265 = getX265ParamsValue(args);
|
||||
expect(x265?.startsWith("aq-mode=3")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the simpler aq-mode prefix on ultrafast preset", () => {
|
||||
const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
|
||||
const x265 = getX265ParamsValue(args);
|
||||
expect(x265?.startsWith("aq-mode=3:")).toBe(true);
|
||||
expect(x265).not.toContain("aq-strength");
|
||||
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HDR HLG (libx265)", () => {
|
||||
it("emits master-display, max-cll, and the HLG transfer", () => {
|
||||
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
||||
const x265 = getX265ParamsValue(args);
|
||||
expect(x265).toContain("transfer=arib-std-b67");
|
||||
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
||||
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
||||
});
|
||||
|
||||
it("tags the output stream with arib-std-b67", () => {
|
||||
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HDR raw input tagging", () => {
|
||||
it("tags the rawvideo input with the matching color metadata", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
||||
const inputColorTrcIdx = args.indexOf("-color_trc");
|
||||
expect(inputColorTrcIdx).toBeGreaterThan(-1);
|
||||
expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
|
||||
const inputPrimariesIdx = args.indexOf("-color_primaries");
|
||||
expect(inputPrimariesIdx).toBeGreaterThan(-1);
|
||||
expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
|
||||
// Pix_fmt of the raw input must match the buffer we hand FFmpeg.
|
||||
expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
it("does not strip the input color tags when bitrate is set instead of CRF", () => {
|
||||
const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
|
||||
const x265 = getX265ParamsValue(args);
|
||||
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
||||
expect(args).toContain("-b:v");
|
||||
expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDR fallback", () => {
|
||||
it("does NOT emit HDR mastering metadata for SDR encodes", () => {
|
||||
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
||||
const x264 = args[args.indexOf("-x264-params") + 1];
|
||||
expect(x264).toContain("colorprim=bt709");
|
||||
expect(x264).toContain("transfer=bt709");
|
||||
expect(x264).toContain("colormatrix=bt709");
|
||||
expect(x264).not.toContain("master-display");
|
||||
expect(x264).not.toContain("max-cll");
|
||||
});
|
||||
|
||||
it("tags SDR output with bt709 and tv range", () => {
|
||||
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
||||
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
||||
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("output path", () => {
|
||||
it("places the output path last after -y", () => {
|
||||
const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
|
||||
expect(args[args.length - 2]).toBe("-y");
|
||||
expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { existsSync, mkdirSync, statSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
|
||||
import { getHdrEncoderColorParams } from "../utils/hdr.js";
|
||||
import { type EncoderOptions } from "./chunkEncoder.types.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
|
||||
@@ -79,6 +80,9 @@ export interface StreamingEncoderOptions {
|
||||
pixelFormat?: string;
|
||||
useGpu?: boolean;
|
||||
imageFormat?: "jpeg" | "png";
|
||||
hdr?: { transfer: import("../utils/hdr.js").HdrTransfer };
|
||||
/** When set, use rawvideo input instead of image2pipe. For HDR PQ-encoded frames. */
|
||||
rawInputFormat?: "rgb48le";
|
||||
}
|
||||
|
||||
export interface StreamingEncoderResult {
|
||||
@@ -98,8 +102,11 @@ export interface StreamingEncoder {
|
||||
* Build FFmpeg args for streaming (image2pipe) input.
|
||||
* Reuses the same codec/quality/GPU logic as chunkEncoder's buildEncoderArgs
|
||||
* but with `-f image2pipe` instead of `-i <pattern>`.
|
||||
*
|
||||
* Exported so unit tests can assert on the constructed CLI without spawning
|
||||
* FFmpeg — see streamingEncoder.test.ts.
|
||||
*/
|
||||
function buildStreamingArgs(
|
||||
export function buildStreamingArgs(
|
||||
options: StreamingEncoderOptions,
|
||||
outputPath: string,
|
||||
gpuEncoder: GpuEncoder = null,
|
||||
@@ -116,19 +123,41 @@ function buildStreamingArgs(
|
||||
} = options;
|
||||
|
||||
// Input args: pipe from stdin
|
||||
const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
|
||||
const args: string[] = [
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
inputCodec,
|
||||
"-framerate",
|
||||
String(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-r",
|
||||
String(fps),
|
||||
];
|
||||
const args: string[] = [];
|
||||
if (options.rawInputFormat) {
|
||||
// Raw pixel input (HLG/PQ-encoded rgb48le from FFmpeg extraction).
|
||||
// Tag the input with the correct color space so FFmpeg uses the right
|
||||
// YUV matrix when converting rgb48le → yuv420p10le for encoding.
|
||||
// Without these tags FFmpeg assumes bt709 and applies the wrong matrix.
|
||||
const hdrTransfer = options.hdr?.transfer;
|
||||
const inputColorTrc =
|
||||
hdrTransfer === "pq" ? "smpte2084" : hdrTransfer === "hlg" ? "arib-std-b67" : undefined;
|
||||
args.push(
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
options.rawInputFormat,
|
||||
"-s",
|
||||
`${options.width}x${options.height}`,
|
||||
"-framerate",
|
||||
String(fps),
|
||||
);
|
||||
if (inputColorTrc) {
|
||||
args.push(
|
||||
"-color_primaries",
|
||||
"bt2020",
|
||||
"-color_trc",
|
||||
inputColorTrc,
|
||||
"-colorspace",
|
||||
"bt2020nc",
|
||||
);
|
||||
}
|
||||
args.push("-i", "-");
|
||||
} else {
|
||||
const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
|
||||
args.push("-f", "image2pipe", "-vcodec", inputCodec, "-framerate", String(fps), "-i", "-");
|
||||
}
|
||||
args.push("-r", String(fps));
|
||||
|
||||
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
||||
|
||||
@@ -169,16 +198,25 @@ function buildStreamingArgs(
|
||||
if (bitrate) args.push("-b:v", bitrate);
|
||||
else args.push("-crf", String(quality));
|
||||
|
||||
// Encoder-specific params: anti-banding + bt709 color space.
|
||||
// aq-mode=3 redistributes bits to dark flat areas (gradients).
|
||||
// colorprim/transfer/colormatrix embed bt709 in the H.264/H.265 VUI.
|
||||
// Encoder-specific params: anti-banding + color space tagging.
|
||||
// For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
|
||||
// mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages —
|
||||
// without them, players (Apple, YouTube, HDR TVs) treat the file
|
||||
// as SDR BT.2020 and tone-map incorrectly.
|
||||
const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
|
||||
const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||
const colorParams =
|
||||
options.rawInputFormat && options.hdr
|
||||
? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
|
||||
: "colorprim=bt709:transfer=bt709:colormatrix=bt709";
|
||||
if (preset === "ultrafast") {
|
||||
args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
|
||||
} else {
|
||||
args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
|
||||
}
|
||||
// Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
|
||||
if (codec === "h265") {
|
||||
args.push("-tag:v", "hvc1");
|
||||
}
|
||||
}
|
||||
} else if (codec === "vp9") {
|
||||
args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
|
||||
@@ -194,27 +232,47 @@ function buildStreamingArgs(
|
||||
return [...args, "-y", outputPath];
|
||||
}
|
||||
|
||||
// BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709.
|
||||
// Tags the output so players interpret colors correctly across devices.
|
||||
// Color space metadata.
|
||||
// When rawInputFormat is set, data comes from the WebGPU HDR pipeline
|
||||
// (PQ-encoded) — tag with bt2020/PQ truthfully.
|
||||
// Otherwise, Chrome captures sRGB — tag as bt709.
|
||||
if (codec === "h264" || codec === "h265") {
|
||||
args.push(
|
||||
"-colorspace:v",
|
||||
"bt709",
|
||||
"-color_primaries:v",
|
||||
"bt709",
|
||||
"-color_trc:v",
|
||||
"bt709",
|
||||
"-color_range",
|
||||
"tv",
|
||||
);
|
||||
if (options.rawInputFormat && options.hdr) {
|
||||
args.push(
|
||||
"-colorspace:v",
|
||||
"bt2020nc",
|
||||
"-color_primaries:v",
|
||||
"bt2020",
|
||||
"-color_trc:v",
|
||||
options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67",
|
||||
"-color_range",
|
||||
"tv",
|
||||
);
|
||||
} else {
|
||||
args.push(
|
||||
"-colorspace:v",
|
||||
"bt709",
|
||||
"-color_primaries:v",
|
||||
"bt709",
|
||||
"-color_trc:v",
|
||||
"bt709",
|
||||
"-color_range",
|
||||
"tv",
|
||||
);
|
||||
}
|
||||
|
||||
// Convert full-range RGB input (Chrome screenshots) to limited/TV range for H.264.
|
||||
if (gpuEncoder === "vaapi") {
|
||||
// Video filter for range/color conversion.
|
||||
// Raw HDR input (from WebGPU pipeline) is already PQ-encoded — no conversion needed.
|
||||
// Chrome screenshots need full→TV range conversion.
|
||||
if (options.rawInputFormat) {
|
||||
// No filter needed — PQ data goes straight to encoder
|
||||
} else if (gpuEncoder === "vaapi") {
|
||||
const vfIdx = args.indexOf("-vf");
|
||||
if (vfIdx !== -1) {
|
||||
args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
|
||||
}
|
||||
} else if (!shouldUseGpu) {
|
||||
// Range conversion: Chrome screenshots are full-range RGB.
|
||||
args.push("-vf", "scale=in_range=pc:out_range=tv");
|
||||
}
|
||||
|
||||
@@ -304,7 +362,15 @@ export async function spawnStreamingEncoder(
|
||||
if (exitStatus !== "running" || !ffmpeg.stdin || ffmpeg.stdin.destroyed) {
|
||||
return false;
|
||||
}
|
||||
return ffmpeg.stdin.write(buffer);
|
||||
// Copy the buffer before writing — Node streams hold a reference to the
|
||||
// provided buffer and drain it asynchronously. The HDR path's compositor
|
||||
// reuses pre-allocated transOutput/normalCanvas buffers across frames,
|
||||
// so without this copy the pipe would read partially-overwritten data
|
||||
// and flicker. The SDR path doesn't invoke writeFrame at all (it pipes
|
||||
// PNG files via encodeFramesFromDir), so the memcpy here is HDR-only
|
||||
// and justified by correctness.
|
||||
const copy = Buffer.from(buffer);
|
||||
return ffmpeg.stdin.write(copy);
|
||||
},
|
||||
|
||||
close: async (): Promise<StreamingEncoderResult> => {
|
||||
@@ -312,9 +378,10 @@ export async function spawnStreamingEncoder(
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
// Close stdin to signal end of input
|
||||
if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
|
||||
const stdin = ffmpeg.stdin;
|
||||
if (stdin && !stdin.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
ffmpeg.stdin!.end(() => resolve());
|
||||
stdin.end(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import { isHdrColorSpace as isHdrColorSpaceUtil } from "../utils/hdr.js";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
|
||||
export interface VideoElement {
|
||||
@@ -114,18 +116,28 @@ export async function extractVideoFramesRange(
|
||||
const framePattern = `frame_%05d.${format}`;
|
||||
const outputPattern = join(videoOutputDir, framePattern);
|
||||
|
||||
const args: string[] = [
|
||||
"-ss",
|
||||
String(startTime),
|
||||
"-i",
|
||||
videoPath,
|
||||
"-t",
|
||||
String(duration),
|
||||
"-vf",
|
||||
`fps=${fps}`,
|
||||
"-q:v",
|
||||
format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0",
|
||||
];
|
||||
// When extracting from HDR source, tone-map to SDR in FFmpeg rather than
|
||||
// letting Chrome's uncontrollable tone-mapper handle it (which washes out).
|
||||
// macOS: VideoToolbox hardware decoder does HDR→SDR natively on Apple Silicon.
|
||||
// Linux: zscale filter (when available) or colorspace filter as fallback.
|
||||
const isHdr = isHdrColorSpaceUtil(metadata.colorSpace);
|
||||
const isMacOS = process.platform === "darwin";
|
||||
|
||||
const args: string[] = [];
|
||||
if (isHdr && isMacOS) {
|
||||
args.push("-hwaccel", "videotoolbox");
|
||||
}
|
||||
args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
|
||||
|
||||
const vfFilters: string[] = [];
|
||||
if (isHdr && isMacOS) {
|
||||
// VideoToolbox tone-maps during decode; force output to bt709 SDR format
|
||||
vfFilters.push("format=nv12");
|
||||
}
|
||||
vfFilters.push(`fps=${fps}`);
|
||||
args.push("-vf", vfFilters.join(","));
|
||||
|
||||
args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
|
||||
if (format === "png") args.push("-compression_level", "6");
|
||||
args.push("-y", outputPattern);
|
||||
|
||||
@@ -195,6 +207,53 @@ export async function extractVideoFramesRange(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDR video to HDR color space (HLG / BT.2020) so it can be
|
||||
* composited alongside HDR content without looking washed out.
|
||||
*
|
||||
* Uses zscale for color space conversion with a nominal peak luminance of
|
||||
* 600 nits — high enough that SDR content doesn't appear too dark next to
|
||||
* HDR, matching the approach used by HeyGen's Rio pipeline.
|
||||
*/
|
||||
async function convertSdrToHdr(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||
): Promise<void> {
|
||||
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
||||
|
||||
const args = [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
"colorspace=all=bt2020:iall=bt709:range=tv",
|
||||
"-color_primaries",
|
||||
"bt2020",
|
||||
"-color_trc",
|
||||
"arib-std-b67",
|
||||
"-colorspace",
|
||||
"bt2020nc",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"16",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
const result = await runFfmpeg(args, { signal, timeout });
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`SDR→HDR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractAllVideoFrames(
|
||||
videos: VideoElement[],
|
||||
baseDir: string,
|
||||
@@ -208,30 +267,75 @@ export async function extractAllVideoFrames(
|
||||
const errors: Array<{ videoId: string; error: string }> = [];
|
||||
let totalFramesExtracted = 0;
|
||||
|
||||
// Process videos in parallel for better performance
|
||||
// Phase 1: Resolve paths and download remote videos
|
||||
const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
|
||||
for (const video of videos) {
|
||||
if (signal?.aborted) break;
|
||||
try {
|
||||
let videoPath = video.src;
|
||||
if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
|
||||
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
|
||||
videoPath =
|
||||
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
|
||||
}
|
||||
|
||||
if (isHttpUrl(videoPath)) {
|
||||
const downloadDir = join(options.outputDir, "_downloads");
|
||||
mkdirSync(downloadDir, { recursive: true });
|
||||
videoPath = await downloadToTemp(videoPath, downloadDir);
|
||||
}
|
||||
|
||||
if (!existsSync(videoPath)) {
|
||||
errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
|
||||
continue;
|
||||
}
|
||||
resolvedVideos.push({ video, videoPath });
|
||||
} catch (err) {
|
||||
errors.push({ videoId: video.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
|
||||
const videoColorSpaces = await Promise.all(
|
||||
resolvedVideos.map(async ({ videoPath }) => {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
return metadata.colorSpace;
|
||||
}),
|
||||
);
|
||||
|
||||
const hasAnyHdr = videoColorSpaces.some(isHdrColorSpaceUtil);
|
||||
if (hasAnyHdr) {
|
||||
const convertDir = join(options.outputDir, "_hdr_normalized");
|
||||
mkdirSync(convertDir, { recursive: true });
|
||||
|
||||
for (let i = 0; i < resolvedVideos.length; i++) {
|
||||
if (signal?.aborted) break;
|
||||
const cs = videoColorSpaces[i] ?? null;
|
||||
if (!isHdrColorSpaceUtil(cs)) {
|
||||
// SDR video in a mixed timeline — convert to HDR color space
|
||||
const entry = resolvedVideos[i];
|
||||
if (!entry) continue;
|
||||
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
|
||||
try {
|
||||
await convertSdrToHdr(entry.videoPath, convertedPath, signal, config);
|
||||
entry.videoPath = convertedPath;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
videoId: entry.video.id,
|
||||
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Extract frames (parallel)
|
||||
const results = await Promise.all(
|
||||
videos.map(async (video) => {
|
||||
resolvedVideos.map(async ({ video, videoPath }) => {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Video frame extraction cancelled");
|
||||
}
|
||||
try {
|
||||
let videoPath = video.src;
|
||||
if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
|
||||
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
|
||||
videoPath =
|
||||
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
|
||||
}
|
||||
|
||||
if (isHttpUrl(videoPath)) {
|
||||
const downloadDir = join(options.outputDir, "_downloads");
|
||||
mkdirSync(downloadDir, { recursive: true });
|
||||
videoPath = await downloadToTemp(videoPath, downloadDir);
|
||||
}
|
||||
|
||||
if (!existsSync(videoPath)) {
|
||||
return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
|
||||
}
|
||||
|
||||
let videoDuration = video.end - video.start;
|
||||
|
||||
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
|
||||
|
||||
@@ -42,6 +42,15 @@ function parseProbeJson(stdout: string): FFProbeOutput {
|
||||
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
|
||||
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
|
||||
|
||||
export interface VideoColorSpace {
|
||||
/** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
|
||||
colorTransfer: string;
|
||||
/** Color primaries, e.g. "bt709", "bt2020" */
|
||||
colorPrimaries: string;
|
||||
/** Color matrix/space, e.g. "bt709", "bt2020nc" */
|
||||
colorSpace: string;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
durationSeconds: number;
|
||||
width: number;
|
||||
@@ -51,6 +60,8 @@ export interface VideoMetadata {
|
||||
hasAudio: boolean;
|
||||
/** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
|
||||
isVFR: boolean;
|
||||
/** Color space info from the video stream. Null if ffprobe didn't report it. */
|
||||
colorSpace: VideoColorSpace | null;
|
||||
}
|
||||
|
||||
export interface AudioMetadata {
|
||||
@@ -70,6 +81,9 @@ interface FFProbeStream {
|
||||
avg_frame_rate?: string;
|
||||
sample_rate?: string;
|
||||
channels?: number;
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
color_space?: string;
|
||||
}
|
||||
|
||||
interface FFProbeFormat {
|
||||
@@ -117,6 +131,11 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
// VFR: r_frame_rate (max/nominal) differs from avg_frame_rate (actual average) by >10%
|
||||
const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
|
||||
|
||||
const colorTransfer = videoStream.color_transfer || "";
|
||||
const colorPrimaries = videoStream.color_primaries || "";
|
||||
const colorSpaceVal = videoStream.color_space || "";
|
||||
const hasColorInfo = !!(colorTransfer || colorPrimaries || colorSpaceVal);
|
||||
|
||||
return {
|
||||
durationSeconds: output.format.duration ? parseFloat(output.format.duration) : 0,
|
||||
width: videoStream.width || 0,
|
||||
@@ -125,6 +144,9 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
videoCodec: videoStream.codec_name || "unknown",
|
||||
hasAudio: output.streams.some((s) => s.codec_type === "audio"),
|
||||
isVFR,
|
||||
colorSpace: hasColorInfo
|
||||
? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
|
||||
: null,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHdrColorSpace,
|
||||
detectTransfer,
|
||||
getHdrEncoderColorParams,
|
||||
analyzeCompositionHdr,
|
||||
DEFAULT_HDR10_MASTERING,
|
||||
} from "./hdr.js";
|
||||
import type { VideoColorSpace } from "./ffprobe.js";
|
||||
|
||||
describe("isHdrColorSpace", () => {
|
||||
it("returns false for null", () => {
|
||||
expect(isHdrColorSpace(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for bt709 SDR", () => {
|
||||
expect(
|
||||
isHdrColorSpace({ colorTransfer: "bt709", colorPrimaries: "bt709", colorSpace: "bt709" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects bt2020 primaries", () => {
|
||||
expect(
|
||||
isHdrColorSpace({ colorTransfer: "bt709", colorPrimaries: "bt2020", colorSpace: "bt709" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects smpte2084 (PQ)", () => {
|
||||
expect(
|
||||
isHdrColorSpace({
|
||||
colorTransfer: "smpte2084",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects arib-std-b67 (HLG)", () => {
|
||||
expect(
|
||||
isHdrColorSpace({
|
||||
colorTransfer: "arib-std-b67",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectTransfer", () => {
|
||||
it("returns hlg for null", () => {
|
||||
expect(detectTransfer(null)).toBe("hlg");
|
||||
});
|
||||
|
||||
it("returns pq for smpte2084", () => {
|
||||
expect(
|
||||
detectTransfer({
|
||||
colorTransfer: "smpte2084",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
}),
|
||||
).toBe("pq");
|
||||
});
|
||||
|
||||
it("returns hlg for arib-std-b67", () => {
|
||||
expect(
|
||||
detectTransfer({
|
||||
colorTransfer: "arib-std-b67",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
}),
|
||||
).toBe("hlg");
|
||||
});
|
||||
|
||||
it("returns hlg for bt709 (fallback)", () => {
|
||||
expect(
|
||||
detectTransfer({ colorTransfer: "bt709", colorPrimaries: "bt709", colorSpace: "bt709" }),
|
||||
).toBe("hlg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHdrEncoderColorParams", () => {
|
||||
it("returns PQ params with mastering metadata", () => {
|
||||
const params = getHdrEncoderColorParams("pq");
|
||||
expect(params.colorTrc).toBe("smpte2084");
|
||||
expect(params.colorPrimaries).toBe("bt2020");
|
||||
expect(params.colorspace).toBe("bt2020nc");
|
||||
expect(params.pixelFormat).toBe("yuv420p10le");
|
||||
expect(params.x265ColorParams).toContain("colorprim=bt2020");
|
||||
expect(params.x265ColorParams).toContain("transfer=smpte2084");
|
||||
expect(params.x265ColorParams).toContain("colormatrix=bt2020nc");
|
||||
expect(params.mastering).toEqual(DEFAULT_HDR10_MASTERING);
|
||||
});
|
||||
|
||||
it("returns HLG params with mastering metadata", () => {
|
||||
const params = getHdrEncoderColorParams("hlg");
|
||||
expect(params.colorTrc).toBe("arib-std-b67");
|
||||
expect(params.colorPrimaries).toBe("bt2020");
|
||||
expect(params.pixelFormat).toBe("yuv420p10le");
|
||||
expect(params.x265ColorParams).toContain("transfer=arib-std-b67");
|
||||
expect(params.mastering).toEqual(DEFAULT_HDR10_MASTERING);
|
||||
});
|
||||
|
||||
// Regression guard for the side_data=[none] bug. See
|
||||
// packages/producer/scripts/hdr-smoke.ts and the bug-1 entry in
|
||||
// hdr-deferred-followups.md. Without master-display + max-cll in the
|
||||
// x265-params, downstream players (Apple QuickTime, YouTube, HDR TVs) treat
|
||||
// the file as SDR BT.2020 and tone-map incorrectly.
|
||||
it("emits master-display and max-cll for PQ", () => {
|
||||
const params = getHdrEncoderColorParams("pq");
|
||||
expect(params.x265ColorParams).toContain(
|
||||
`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`,
|
||||
);
|
||||
expect(params.x265ColorParams).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
||||
});
|
||||
|
||||
it("emits master-display and max-cll for HLG", () => {
|
||||
const params = getHdrEncoderColorParams("hlg");
|
||||
expect(params.x265ColorParams).toContain(
|
||||
`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`,
|
||||
);
|
||||
expect(params.x265ColorParams).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
||||
});
|
||||
|
||||
it("respects an explicit mastering override", () => {
|
||||
const custom = {
|
||||
masterDisplay: "G(1,2)B(3,4)R(5,6)WP(7,8)L(9,10)",
|
||||
maxCll: "500,200",
|
||||
};
|
||||
const params = getHdrEncoderColorParams("pq", custom);
|
||||
expect(params.mastering).toBe(custom);
|
||||
expect(params.x265ColorParams).toContain("master-display=G(1,2)B(3,4)R(5,6)WP(7,8)L(9,10)");
|
||||
expect(params.x265ColorParams).toContain("max-cll=500,200");
|
||||
});
|
||||
|
||||
// The DEFAULT_HDR10_MASTERING values are tagged as "P3-D65 inside BT.2020,
|
||||
// 0.0001-1000 nits, MaxCLL 1000 / MaxFALL 400". If anyone tweaks these
|
||||
// numbers without updating the docstring or the deferred-followups doc,
|
||||
// this test will fail and force a deliberate review.
|
||||
it("DEFAULT_HDR10_MASTERING matches the documented HDR10 reference", () => {
|
||||
expect(DEFAULT_HDR10_MASTERING.masterDisplay).toBe(
|
||||
"G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
|
||||
);
|
||||
expect(DEFAULT_HDR10_MASTERING.maxCll).toBe("1000,400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeCompositionHdr", () => {
|
||||
const sdr: VideoColorSpace = {
|
||||
colorTransfer: "bt709",
|
||||
colorPrimaries: "bt709",
|
||||
colorSpace: "bt709",
|
||||
};
|
||||
const hlg: VideoColorSpace = {
|
||||
colorTransfer: "arib-std-b67",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
};
|
||||
const pq: VideoColorSpace = {
|
||||
colorTransfer: "smpte2084",
|
||||
colorPrimaries: "bt2020",
|
||||
colorSpace: "bt2020nc",
|
||||
};
|
||||
|
||||
it("returns no HDR for all SDR", () => {
|
||||
expect(analyzeCompositionHdr([sdr, sdr, null])).toEqual({
|
||||
hasHdr: false,
|
||||
dominantTransfer: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects HLG", () => {
|
||||
expect(analyzeCompositionHdr([sdr, hlg])).toEqual({
|
||||
hasHdr: true,
|
||||
dominantTransfer: "hlg",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects PQ", () => {
|
||||
expect(analyzeCompositionHdr([sdr, pq])).toEqual({
|
||||
hasHdr: true,
|
||||
dominantTransfer: "pq",
|
||||
});
|
||||
});
|
||||
|
||||
it("PQ takes priority over HLG in mixed HDR", () => {
|
||||
expect(analyzeCompositionHdr([hlg, pq])).toEqual({
|
||||
hasHdr: true,
|
||||
dominantTransfer: "pq",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HDR Color Space Utilities
|
||||
*
|
||||
* Centralized HDR detection, transfer type handling, and FFmpeg color
|
||||
* parameter generation for the HDR rendering pipeline.
|
||||
*/
|
||||
|
||||
import type { VideoColorSpace } from "./ffprobe.js";
|
||||
|
||||
export type HdrTransfer = "hlg" | "pq";
|
||||
|
||||
/**
|
||||
* Check if a video's color space indicates HDR content.
|
||||
* Re-exported from videoFrameExtractor for backward compatibility.
|
||||
*/
|
||||
export function isHdrColorSpace(cs: VideoColorSpace | null): boolean {
|
||||
if (!cs) return false;
|
||||
return (
|
||||
cs.colorPrimaries.includes("bt2020") ||
|
||||
cs.colorSpace.includes("bt2020") ||
|
||||
cs.colorTransfer === "smpte2084" ||
|
||||
cs.colorTransfer === "arib-std-b67"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the HDR transfer function from a video's color space metadata.
|
||||
*
|
||||
* IMPORTANT: Callers must gate on `isHdrColorSpace(cs)` first. This function
|
||||
* assumes the input has already been classified as HDR and defaults ambiguous
|
||||
* inputs to "hlg" — calling it with an SDR color space silently returns "hlg",
|
||||
* which is wrong for SDR.
|
||||
*
|
||||
* Returns "pq" for SMPTE 2084, "hlg" for ARIB STD-B67, defaults to "hlg".
|
||||
*/
|
||||
export function detectTransfer(cs: VideoColorSpace | null): HdrTransfer {
|
||||
if (cs?.colorTransfer === "smpte2084") return "pq";
|
||||
return "hlg";
|
||||
}
|
||||
|
||||
/**
|
||||
* HDR static metadata for the encoded stream.
|
||||
*
|
||||
* `masterDisplay` is the SMPTE ST 2086 mastering-display color volume string
|
||||
* accepted by x265 (`G(Gx,Gy)B(Bx,By)R(Rx,Ry)WP(WPx,WPy)L(Lmax,Lmin)`).
|
||||
* Chromaticity values are scaled by 50000 (0.00002 cd/m² per unit) and
|
||||
* luminance values by 10000 (0.0001 cd/m² per unit).
|
||||
*
|
||||
* `maxCll` is the CTA-861.3 Content Light Level pair `MaxCLL,MaxFALL` in
|
||||
* cd/m². Without these SEI messages, downstream players (Apple QuickTime,
|
||||
* YouTube, HDR TVs) treat the stream as SDR BT.2020 and tone-map incorrectly
|
||||
* — see packages/producer/scripts/hdr-smoke.ts for the regression assertion.
|
||||
*/
|
||||
export interface HdrMasteringMetadata {
|
||||
masterDisplay: string;
|
||||
maxCll: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default HDR10 mastering metadata: P3-D65 primaries inside a BT.2020
|
||||
* container, mastered for 0.0001–1000 cd/m² with MaxCLL=1000, MaxFALL=400.
|
||||
*
|
||||
* These are conservative defaults that match how most HDR10 grading suites
|
||||
* (Premiere, DaVinci Resolve) tag content when per-frame measured values
|
||||
* aren't available. A future PR can plumb measured MaxCLL through `--hdr-opt`.
|
||||
*/
|
||||
export const DEFAULT_HDR10_MASTERING: HdrMasteringMetadata = {
|
||||
masterDisplay: "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
|
||||
maxCll: "1000,400",
|
||||
};
|
||||
|
||||
export interface HdrEncoderColorParams {
|
||||
colorPrimaries: string;
|
||||
colorTrc: string;
|
||||
colorspace: string;
|
||||
pixelFormat: string;
|
||||
/**
|
||||
* Full x265-params string including color tagging and HDR static metadata.
|
||||
* Pass directly to `-x265-params` (concatenate with other options via `:`).
|
||||
*/
|
||||
x265ColorParams: string;
|
||||
/** The mastering metadata that was baked into `x265ColorParams`. */
|
||||
mastering: HdrMasteringMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FFmpeg encoder color parameters for a given HDR transfer function.
|
||||
*
|
||||
* The returned `x265ColorParams` includes both color tagging
|
||||
* (`colorprim`/`transfer`/`colormatrix`) and HDR static metadata
|
||||
* (`master-display`/`max-cll`). Without the static metadata the encoded
|
||||
* stream is rejected as SDR by most HDR-aware players and CDNs.
|
||||
*/
|
||||
export function getHdrEncoderColorParams(
|
||||
transfer: HdrTransfer,
|
||||
mastering: HdrMasteringMetadata = DEFAULT_HDR10_MASTERING,
|
||||
): HdrEncoderColorParams {
|
||||
const colorTrc = transfer === "pq" ? "smpte2084" : "arib-std-b67";
|
||||
const tagging = `colorprim=bt2020:transfer=${colorTrc}:colormatrix=bt2020nc`;
|
||||
const metadata = `master-display=${mastering.masterDisplay}:max-cll=${mastering.maxCll}`;
|
||||
return {
|
||||
colorPrimaries: "bt2020",
|
||||
colorTrc,
|
||||
colorspace: "bt2020nc",
|
||||
pixelFormat: "yuv420p10le",
|
||||
x265ColorParams: `${tagging}:${metadata}`,
|
||||
mastering,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompositionHdrInfo {
|
||||
hasHdr: boolean;
|
||||
dominantTransfer: HdrTransfer | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a set of video color spaces to determine if the composition
|
||||
* contains HDR content and what the dominant transfer function is.
|
||||
*/
|
||||
export function analyzeCompositionHdr(
|
||||
colorSpaces: Array<VideoColorSpace | null>,
|
||||
): CompositionHdrInfo {
|
||||
let hasPq = false;
|
||||
let hasHdr = false;
|
||||
|
||||
for (const cs of colorSpaces) {
|
||||
if (!isHdrColorSpace(cs)) continue;
|
||||
hasHdr = true;
|
||||
if (cs?.colorTransfer === "smpte2084") hasPq = true;
|
||||
}
|
||||
|
||||
if (!hasHdr) return { hasHdr: false, dominantTransfer: null };
|
||||
|
||||
// PQ takes priority — it's the more common HDR10 format
|
||||
const dominantTransfer: HdrTransfer = hasPq ? "pq" : "hlg";
|
||||
return { hasHdr: true, dominantTransfer };
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"types": ["@webgpu/types"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"@types/node": "^22.10.1",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"esbuild": "^0.27.2",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createFrameLookupTable,
|
||||
type VideoElement,
|
||||
FrameLookupTable,
|
||||
type HdrTransfer,
|
||||
createCaptureSession,
|
||||
initializeSession,
|
||||
closeCaptureSession,
|
||||
@@ -54,6 +55,8 @@ import {
|
||||
spawnStreamingEncoder,
|
||||
createFrameReorderBuffer,
|
||||
type StreamingEncoder,
|
||||
convertHdrFrameToRgb48le,
|
||||
analyzeCompositionHdr,
|
||||
} from "@hyperframes/engine";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -111,18 +114,9 @@ export interface RenderConfig {
|
||||
producerConfig?: EngineConfig;
|
||||
/** Custom logger. Defaults to console-based defaultLogger. */
|
||||
logger?: ProducerLogger;
|
||||
/**
|
||||
* Override CRF (Constant Rate Factor) for the video encoder.
|
||||
* Lower values = higher quality / larger files. Range: 0–51 for H.264.
|
||||
* When set, overrides the CRF from the quality preset.
|
||||
* Mutually exclusive with `videoBitrate`.
|
||||
*/
|
||||
/** Override CRF for the video encoder. Mutually exclusive with `videoBitrate`. */
|
||||
crf?: number;
|
||||
/**
|
||||
* Target video bitrate (e.g. "10M", "5000k").
|
||||
* When set, uses bitrate-based encoding instead of CRF.
|
||||
* Mutually exclusive with `crf`.
|
||||
*/
|
||||
/** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */
|
||||
videoBitrate?: string;
|
||||
}
|
||||
|
||||
@@ -730,9 +724,10 @@ export async function executeRenderJob(
|
||||
|
||||
let frameLookup: FrameLookupTable | null = null;
|
||||
const compiledDir = join(workDir, "compiled");
|
||||
let extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null = null;
|
||||
|
||||
if (composition.videos.length > 0) {
|
||||
const extractionResult = await extractAllVideoFrames(
|
||||
extractionResult = await extractAllVideoFrames(
|
||||
composition.videos,
|
||||
projectDir,
|
||||
{ fps: job.config.fps, outputDir: join(workDir, "video-frames") },
|
||||
@@ -772,6 +767,28 @@ export async function executeRenderJob(
|
||||
perfStages.videoExtractMs = Date.now() - stage2Start;
|
||||
}
|
||||
|
||||
// ── HDR auto-detection ──────────────────────────────────────────────
|
||||
// If any extracted video source has an HDR color space, the output
|
||||
// automatically uses H.265 10-bit with the dominant transfer (PQ if any
|
||||
// PQ source is present, otherwise HLG). No flag needed.
|
||||
let effectiveHdr: { transfer: HdrTransfer } | undefined;
|
||||
if (frameLookup) {
|
||||
const colorSpaces = (extractionResult?.extracted ?? []).map((ext) => ext.metadata.colorSpace);
|
||||
const info = analyzeCompositionHdr(colorSpaces);
|
||||
if (info.hasHdr && info.dominantTransfer) {
|
||||
effectiveHdr = { transfer: info.dominantTransfer };
|
||||
}
|
||||
}
|
||||
if (effectiveHdr && outputFormat !== "mp4") {
|
||||
log.info(`[Render] HDR source detected but format is ${outputFormat} — using SDR`);
|
||||
effectiveHdr = undefined;
|
||||
}
|
||||
if (effectiveHdr) {
|
||||
log.info(
|
||||
`[Render] HDR source detected — output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage 3: Audio processing ───────────────────────────────────────
|
||||
const stage3Start = Date.now();
|
||||
updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
|
||||
@@ -829,275 +846,379 @@ export async function executeRenderJob(
|
||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
||||
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
|
||||
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
|
||||
const preset = getEncoderPreset(job.config.quality, outputFormat);
|
||||
|
||||
// User-level CRF/bitrate overrides take precedence over presets.
|
||||
const effectiveQuality = job.config.crf ?? preset.quality;
|
||||
const effectiveBitrate = job.config.videoBitrate;
|
||||
|
||||
// Shared encoder options used by both streaming and chunk encode paths.
|
||||
const baseEncoderOpts = {
|
||||
fps: job.config.fps,
|
||||
width,
|
||||
height,
|
||||
codec: preset.codec,
|
||||
preset: preset.preset,
|
||||
quality: effectiveQuality,
|
||||
bitrate: effectiveBitrate,
|
||||
pixelFormat: preset.pixelFormat,
|
||||
useGpu: job.config.useGpu,
|
||||
};
|
||||
// Only use the HDR encoder preset when there's HDR video to pass through.
|
||||
// For SDR-only compositions, --hdr is a no-op — H.265 10-bit causes browser
|
||||
// color management issues (orange shift) with no quality benefit.
|
||||
const hasHdrVideo = effectiveHdr && composition.videos.length > 0 && frameLookup;
|
||||
const encoderHdr = hasHdrVideo ? effectiveHdr : undefined;
|
||||
const preset = getEncoderPreset(job.config.quality, outputFormat, encoderHdr);
|
||||
|
||||
job.framesRendered = 0;
|
||||
|
||||
// Streaming encode mode: pipe frame buffers directly to FFmpeg stdin,
|
||||
// skipping disk writes and the separate Stage 5 encode step.
|
||||
let streamingEncoder: StreamingEncoder | null = null;
|
||||
// ── HDR pass-through path ────────────────────────────────────────────
|
||||
// When HDR output is requested AND the composition has HDR video sources,
|
||||
// extract raw HLG frames and pass them directly to FFmpeg — no conversion.
|
||||
// The HLG pixel values ARE the correct encoding. The output is tagged as
|
||||
// HLG/BT.2020 so HDR displays render it correctly.
|
||||
//
|
||||
// No WebGPU or Chrome needed for this path — it's a pure FFmpeg pipeline.
|
||||
// GSAP transforms are NOT applied (future work: WebGPU shader transforms).
|
||||
if (hasHdrVideo) {
|
||||
log.info("[Render] HDR pass-through: extracting native HLG frames from video sources");
|
||||
|
||||
if (enableStreamingEncode) {
|
||||
streamingEncoder = await spawnStreamingEncoder(
|
||||
const hdrEncoder = await spawnStreamingEncoder(
|
||||
videoOnlyPath,
|
||||
{
|
||||
...baseEncoderOpts,
|
||||
imageFormat: captureOptions.format || "jpeg",
|
||||
fps: job.config.fps,
|
||||
width,
|
||||
height,
|
||||
codec: preset.codec,
|
||||
preset: preset.preset,
|
||||
quality: preset.quality,
|
||||
pixelFormat: preset.pixelFormat,
|
||||
hdr: preset.hdr,
|
||||
rawInputFormat: "rgb48le",
|
||||
},
|
||||
abortSignal,
|
||||
{ ffmpegStreamingTimeout: 3_600_000 },
|
||||
);
|
||||
assertNotAborted();
|
||||
}
|
||||
|
||||
if (enableStreamingEncode && streamingEncoder) {
|
||||
// ── Streaming capture + encode (Stage 4 absorbs Stage 5) ──────────
|
||||
const reorderBuffer = createFrameReorderBuffer(0, job.totalFrames!);
|
||||
const currentEncoder = streamingEncoder;
|
||||
const { execSync } = await import("child_process");
|
||||
|
||||
if (workerCount > 1) {
|
||||
// Parallel capture → streaming encode
|
||||
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
|
||||
|
||||
const onFrameBuffer = async (frameIndex: number, buffer: Buffer): Promise<void> => {
|
||||
await reorderBuffer.waitForFrame(frameIndex);
|
||||
currentEncoder.writeFrame(buffer);
|
||||
reorderBuffer.advanceTo(frameIndex + 1);
|
||||
};
|
||||
|
||||
await executeParallelCapture(
|
||||
fileServer.url,
|
||||
workDir,
|
||||
tasks,
|
||||
captureOptions,
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
job.framesRendered = progress.capturedFrames;
|
||||
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
||||
const progressPct = 25 + frameProgress * 55;
|
||||
|
||||
if (
|
||||
progress.capturedFrames % 30 === 0 ||
|
||||
progress.capturedFrames === progress.totalFrames
|
||||
) {
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
|
||||
Math.round(progressPct),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
},
|
||||
onFrameBuffer,
|
||||
cfg,
|
||||
);
|
||||
|
||||
if (probeSession) {
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(probeSession);
|
||||
probeSession = null;
|
||||
}
|
||||
} else {
|
||||
// Sequential capture → streaming encode
|
||||
|
||||
const videoInjector = createVideoFrameInjector(frameLookup);
|
||||
const session =
|
||||
probeSession ??
|
||||
(await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
captureOptions,
|
||||
videoInjector,
|
||||
cfg,
|
||||
));
|
||||
if (probeSession) {
|
||||
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
|
||||
probeSession = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.isInitialized) {
|
||||
await initializeSession(session);
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < job.totalFrames!; i++) {
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
const time = i / job.config.fps;
|
||||
|
||||
for (let i = 0; i < job.totalFrames!; i++) {
|
||||
assertNotAborted();
|
||||
const time = i / job.config.fps;
|
||||
const { buffer } = await captureFrameToBuffer(session, i, time);
|
||||
await reorderBuffer.waitForFrame(i);
|
||||
currentEncoder.writeFrame(buffer);
|
||||
reorderBuffer.advanceTo(i + 1);
|
||||
job.framesRendered = i + 1;
|
||||
const activeFrames = frameLookup!.getActiveFramePayloads(time);
|
||||
|
||||
if (activeFrames.size > 0) {
|
||||
const [videoId] = activeFrames.keys();
|
||||
const video = composition.videos.find((v) => v.id === videoId);
|
||||
if (video) {
|
||||
const localTime = time - video.start + video.mediaStart;
|
||||
|
||||
let srcPath = video.src;
|
||||
const compiledDir = join(workDir, "compiled");
|
||||
if (!srcPath.startsWith("/")) {
|
||||
const fromCompiled = join(compiledDir, srcPath);
|
||||
srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
|
||||
}
|
||||
|
||||
let rawFrame: Buffer;
|
||||
try {
|
||||
rawFrame = execSync(
|
||||
`ffmpeg -ss ${localTime} -i "${srcPath}" -vframes 1 -f rawvideo -pix_fmt rgba64le -`,
|
||||
{ maxBuffer: width * height * 8 + 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
} catch {
|
||||
rawFrame = Buffer.alloc(width * height * 8);
|
||||
}
|
||||
|
||||
// Pass through HLG pixels as-is (RGBA → RGB, no color conversion)
|
||||
const rgb48Frame = convertHdrFrameToRgb48le(rawFrame, width, height);
|
||||
hdrEncoder.writeFrame(rgb48Frame);
|
||||
} else {
|
||||
hdrEncoder.writeFrame(Buffer.alloc(width * height * 6));
|
||||
}
|
||||
} else {
|
||||
hdrEncoder.writeFrame(Buffer.alloc(width * height * 6));
|
||||
}
|
||||
|
||||
job.framesRendered = i + 1;
|
||||
if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) {
|
||||
const frameProgress = (i + 1) / job.totalFrames!;
|
||||
const progress = 25 + frameProgress * 55;
|
||||
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Streaming frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(progress),
|
||||
`HDR frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(25 + frameProgress * 55),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
await closeCaptureSession(session);
|
||||
}
|
||||
} finally {
|
||||
// No browser to close — pure FFmpeg path
|
||||
}
|
||||
|
||||
// Close encoder and get result
|
||||
const encodeResult = await currentEncoder.close();
|
||||
const hdrEncodeResult = await hdrEncoder.close();
|
||||
assertNotAborted();
|
||||
|
||||
if (!encodeResult.success) {
|
||||
throw new Error(`Streaming encode failed: ${encodeResult.error}`);
|
||||
if (!hdrEncodeResult.success) {
|
||||
throw new Error(`HDR encode failed: ${hdrEncodeResult.error}`);
|
||||
}
|
||||
|
||||
perfStages.captureMs = Date.now() - stage4Start;
|
||||
perfStages.encodeMs = encodeResult.durationMs; // Overlapped with capture
|
||||
} else {
|
||||
// ── Disk-based capture (original flow) ────────────────────────────
|
||||
if (workerCount > 1) {
|
||||
// Parallel capture
|
||||
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
|
||||
perfStages.encodeMs = hdrEncodeResult.durationMs;
|
||||
} else // ── Standard capture paths (SDR or DOM-only HDR) ──────────────────
|
||||
// Streaming encode mode: pipe frame buffers directly to FFmpeg stdin,
|
||||
// skipping disk writes and the separate Stage 5 encode step.
|
||||
{
|
||||
let streamingEncoder: StreamingEncoder | null = null;
|
||||
|
||||
await executeParallelCapture(
|
||||
fileServer.url,
|
||||
workDir,
|
||||
tasks,
|
||||
captureOptions,
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
if (enableStreamingEncode) {
|
||||
streamingEncoder = await spawnStreamingEncoder(
|
||||
videoOnlyPath,
|
||||
{
|
||||
fps: job.config.fps,
|
||||
width,
|
||||
height,
|
||||
codec: preset.codec,
|
||||
preset: preset.preset,
|
||||
quality: preset.quality,
|
||||
pixelFormat: preset.pixelFormat,
|
||||
useGpu: job.config.useGpu,
|
||||
imageFormat: captureOptions.format || "jpeg",
|
||||
hdr: preset.hdr,
|
||||
},
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
job.framesRendered = progress.capturedFrames;
|
||||
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
||||
const progressPct = 25 + frameProgress * 45;
|
||||
);
|
||||
assertNotAborted();
|
||||
}
|
||||
|
||||
if (enableStreamingEncode && streamingEncoder) {
|
||||
// ── Streaming capture + encode (Stage 4 absorbs Stage 5) ──────────
|
||||
const reorderBuffer = createFrameReorderBuffer(0, job.totalFrames!);
|
||||
const currentEncoder = streamingEncoder;
|
||||
|
||||
if (workerCount > 1) {
|
||||
// Parallel capture → streaming encode
|
||||
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
|
||||
|
||||
const onFrameBuffer = async (frameIndex: number, buffer: Buffer): Promise<void> => {
|
||||
await reorderBuffer.waitForFrame(frameIndex);
|
||||
currentEncoder.writeFrame(buffer);
|
||||
reorderBuffer.advanceTo(frameIndex + 1);
|
||||
};
|
||||
|
||||
await executeParallelCapture(
|
||||
fileServer.url,
|
||||
workDir,
|
||||
tasks,
|
||||
captureOptions,
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
job.framesRendered = progress.capturedFrames;
|
||||
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
||||
const progressPct = 25 + frameProgress * 55;
|
||||
|
||||
if (
|
||||
progress.capturedFrames % 30 === 0 ||
|
||||
progress.capturedFrames === progress.totalFrames
|
||||
) {
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
|
||||
Math.round(progressPct),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
},
|
||||
onFrameBuffer,
|
||||
cfg,
|
||||
);
|
||||
|
||||
if (probeSession) {
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(probeSession);
|
||||
probeSession = null;
|
||||
}
|
||||
} else {
|
||||
// Sequential capture → streaming encode
|
||||
|
||||
const videoInjector = createVideoFrameInjector(frameLookup);
|
||||
const session =
|
||||
probeSession ??
|
||||
(await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
captureOptions,
|
||||
videoInjector,
|
||||
cfg,
|
||||
));
|
||||
if (probeSession) {
|
||||
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
|
||||
probeSession = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.isInitialized) {
|
||||
await initializeSession(session);
|
||||
}
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
|
||||
for (let i = 0; i < job.totalFrames!; i++) {
|
||||
assertNotAborted();
|
||||
const time = i / job.config.fps;
|
||||
const { buffer } = await captureFrameToBuffer(session, i, time);
|
||||
await reorderBuffer.waitForFrame(i);
|
||||
currentEncoder.writeFrame(buffer);
|
||||
reorderBuffer.advanceTo(i + 1);
|
||||
job.framesRendered = i + 1;
|
||||
|
||||
const frameProgress = (i + 1) / job.totalFrames!;
|
||||
const progress = 25 + frameProgress * 55;
|
||||
|
||||
if (
|
||||
progress.capturedFrames % 30 === 0 ||
|
||||
progress.capturedFrames === progress.totalFrames
|
||||
) {
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
|
||||
Math.round(progressPct),
|
||||
`Streaming frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(progress),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
|
||||
await mergeWorkerFrames(workDir, tasks, framesDir);
|
||||
if (probeSession) {
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(probeSession);
|
||||
probeSession = null;
|
||||
} finally {
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
await closeCaptureSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Close encoder and get result
|
||||
const encodeResult = await currentEncoder.close();
|
||||
assertNotAborted();
|
||||
|
||||
if (!encodeResult.success) {
|
||||
throw new Error(`Streaming encode failed: ${encodeResult.error}`);
|
||||
}
|
||||
|
||||
perfStages.captureMs = Date.now() - stage4Start;
|
||||
perfStages.encodeMs = encodeResult.durationMs; // Overlapped with capture
|
||||
} else {
|
||||
// Sequential capture
|
||||
// ── Disk-based capture (original flow) ────────────────────────────
|
||||
if (workerCount > 1) {
|
||||
// Parallel capture
|
||||
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
|
||||
|
||||
const videoInjector = createVideoFrameInjector(frameLookup);
|
||||
const session =
|
||||
probeSession ??
|
||||
(await createCaptureSession(
|
||||
await executeParallelCapture(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
workDir,
|
||||
tasks,
|
||||
captureOptions,
|
||||
videoInjector,
|
||||
() => createVideoFrameInjector(frameLookup),
|
||||
abortSignal,
|
||||
(progress) => {
|
||||
job.framesRendered = progress.capturedFrames;
|
||||
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
||||
const progressPct = 25 + frameProgress * 45;
|
||||
|
||||
if (
|
||||
progress.capturedFrames % 30 === 0 ||
|
||||
progress.capturedFrames === progress.totalFrames
|
||||
) {
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
|
||||
Math.round(progressPct),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
));
|
||||
if (probeSession) {
|
||||
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
|
||||
probeSession = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.isInitialized) {
|
||||
await initializeSession(session);
|
||||
}
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
|
||||
for (let i = 0; i < job.totalFrames; i++) {
|
||||
assertNotAborted();
|
||||
const time = i / job.config.fps;
|
||||
await captureFrame(session, i, time);
|
||||
job.framesRendered = i + 1;
|
||||
|
||||
const frameProgress = (i + 1) / job.totalFrames;
|
||||
const progress = 25 + frameProgress * 45;
|
||||
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Capturing frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(progress),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
await closeCaptureSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
perfStages.captureMs = Date.now() - stage4Start;
|
||||
|
||||
// ── Stage 5: Encode ─────────────────────────────────────────────────
|
||||
const stage5Start = Date.now();
|
||||
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
|
||||
|
||||
const frameExt = needsAlpha ? "png" : "jpg";
|
||||
const framePattern = `frame_%06d.${frameExt}`;
|
||||
const encoderOpts = baseEncoderOpts;
|
||||
const encodeResult = enableChunkedEncode
|
||||
? await encodeFramesChunkedConcat(
|
||||
framesDir,
|
||||
framePattern,
|
||||
videoOnlyPath,
|
||||
encoderOpts,
|
||||
chunkedEncodeSize,
|
||||
abortSignal,
|
||||
)
|
||||
: await encodeFramesFromDir(
|
||||
framesDir,
|
||||
framePattern,
|
||||
videoOnlyPath,
|
||||
encoderOpts,
|
||||
abortSignal,
|
||||
);
|
||||
assertNotAborted();
|
||||
|
||||
if (!encodeResult.success) {
|
||||
throw new Error(`Encoding failed: ${encodeResult.error}`);
|
||||
await mergeWorkerFrames(workDir, tasks, framesDir);
|
||||
if (probeSession) {
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(probeSession);
|
||||
probeSession = null;
|
||||
}
|
||||
} else {
|
||||
// Sequential capture
|
||||
|
||||
const videoInjector = createVideoFrameInjector(frameLookup);
|
||||
const session =
|
||||
probeSession ??
|
||||
(await createCaptureSession(
|
||||
fileServer.url,
|
||||
framesDir,
|
||||
captureOptions,
|
||||
videoInjector,
|
||||
cfg,
|
||||
));
|
||||
if (probeSession) {
|
||||
prepareCaptureSessionForReuse(session, framesDir, videoInjector);
|
||||
probeSession = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!session.isInitialized) {
|
||||
await initializeSession(session);
|
||||
}
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
|
||||
for (let i = 0; i < job.totalFrames; i++) {
|
||||
assertNotAborted();
|
||||
const time = i / job.config.fps;
|
||||
await captureFrame(session, i, time);
|
||||
job.framesRendered = i + 1;
|
||||
|
||||
const frameProgress = (i + 1) / job.totalFrames;
|
||||
const progress = 25 + frameProgress * 45;
|
||||
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`Capturing frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(progress),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lastBrowserConsole = session.browserConsoleBuffer;
|
||||
await closeCaptureSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
perfStages.captureMs = Date.now() - stage4Start;
|
||||
|
||||
// ── Stage 5: Encode ─────────────────────────────────────────────────
|
||||
const stage5Start = Date.now();
|
||||
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
|
||||
|
||||
const frameExt = needsAlpha ? "png" : "jpg";
|
||||
const framePattern = `frame_%06d.${frameExt}`;
|
||||
const encoderOpts = {
|
||||
fps: job.config.fps,
|
||||
width,
|
||||
height,
|
||||
codec: preset.codec,
|
||||
preset: preset.preset,
|
||||
quality: preset.quality,
|
||||
pixelFormat: preset.pixelFormat,
|
||||
useGpu: job.config.useGpu,
|
||||
hdr: preset.hdr,
|
||||
};
|
||||
const encodeResult = enableChunkedEncode
|
||||
? await encodeFramesChunkedConcat(
|
||||
framesDir,
|
||||
framePattern,
|
||||
videoOnlyPath,
|
||||
encoderOpts,
|
||||
chunkedEncodeSize,
|
||||
abortSignal,
|
||||
)
|
||||
: await encodeFramesFromDir(
|
||||
framesDir,
|
||||
framePattern,
|
||||
videoOnlyPath,
|
||||
encoderOpts,
|
||||
abortSignal,
|
||||
);
|
||||
assertNotAborted();
|
||||
|
||||
if (!encodeResult.success) {
|
||||
throw new Error(`Encoding failed: ${encodeResult.error}`);
|
||||
}
|
||||
|
||||
perfStages.encodeMs = Date.now() - stage5Start;
|
||||
}
|
||||
|
||||
perfStages.encodeMs = Date.now() - stage5Start;
|
||||
}
|
||||
} // end SDR capture paths block
|
||||
|
||||
if (probeSession !== null) {
|
||||
const remainingProbeSession: CaptureSession = probeSession;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"types": ["@webgpu/types"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
|
||||
Reference in New Issue
Block a user