Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ my-video/
packages/studio/data/
.desloppify/
.worktrees/

# Git worktrees
.worktrees/
20 changes: 12 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"devDependencies": {
"@types/node": "^22.10.1",
"@webgpu/types": "^0.1.69",
"typescript": "^5.7.2",
"vitest": "^3.2.4"
},
Expand Down
16 changes: 16 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,6 +102,9 @@ export const DEFAULT_CONFIG: EngineConfig = {
ffmpegProcessTimeout: 300_000,
ffmpegStreamingTimeout: 600_000,

hdr: false,
hdrAutoDetect: true,

audioGain: 1.35,
frameDataUriCacheLimit: 256,

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
88 changes: 88 additions & 0 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("uses bt709 color tags for HDR output (Chrome captures sRGB)", () => {
// 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");
});
});
44 changes: 35 additions & 9 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,15 +22,24 @@ export const ENCODER_PRESETS = {
high: { preset: "slow", quality: 18, 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 {
Expand All @@ -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" };
}

Expand Down Expand Up @@ -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") {
Expand All @@ -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");
Expand All @@ -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",
Expand All @@ -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");
}

Expand Down
3 changes: 3 additions & 0 deletions packages/engine/src/services/chunkEncoder.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { HdrTransfer } from "../utils/hdr.js";

export interface EncoderOptions {
fps: number;
width: number;
Expand All @@ -8,6 +10,7 @@ export interface EncoderOptions {
bitrate?: string;
pixelFormat?: string;
useGpu?: boolean;
hdr?: { transfer: HdrTransfer };
}

export interface EncodeResult {
Expand Down
Loading
Loading