@hyperframes/engine 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/config.d.ts +6 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts.map +1 -1
  10. package/dist/services/browserManager.js +6 -3
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/chunkEncoder.d.ts +14 -7
  13. package/dist/services/chunkEncoder.d.ts.map +1 -1
  14. package/dist/services/chunkEncoder.js +25 -9
  15. package/dist/services/chunkEncoder.js.map +1 -1
  16. package/dist/services/chunkEncoder.types.d.ts +4 -0
  17. package/dist/services/chunkEncoder.types.d.ts.map +1 -1
  18. package/dist/services/hdrCapture.d.ts +62 -0
  19. package/dist/services/hdrCapture.d.ts.map +1 -0
  20. package/dist/services/hdrCapture.js +259 -0
  21. package/dist/services/hdrCapture.js.map +1 -0
  22. package/dist/services/screenshotService.d.ts +34 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +97 -19
  25. package/dist/services/screenshotService.js.map +1 -1
  26. package/dist/services/streamingEncoder.d.ts +18 -3
  27. package/dist/services/streamingEncoder.d.ts.map +1 -1
  28. package/dist/services/streamingEncoder.js +104 -50
  29. package/dist/services/streamingEncoder.js.map +1 -1
  30. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  31. package/dist/services/videoFrameExtractor.js +109 -18
  32. package/dist/services/videoFrameExtractor.js.map +1 -1
  33. package/dist/services/videoFrameInjector.d.ts +54 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +159 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/utils/alphaBlit.d.ts +105 -0
  38. package/dist/utils/alphaBlit.d.ts.map +1 -0
  39. package/dist/utils/alphaBlit.js +550 -0
  40. package/dist/utils/alphaBlit.js.map +1 -0
  41. package/dist/utils/ffprobe.d.ts +10 -0
  42. package/dist/utils/ffprobe.d.ts.map +1 -1
  43. package/dist/utils/ffprobe.js +7 -0
  44. package/dist/utils/ffprobe.js.map +1 -1
  45. package/dist/utils/hdr.d.ts +82 -0
  46. package/dist/utils/hdr.d.ts.map +1 -0
  47. package/dist/utils/hdr.js +87 -0
  48. package/dist/utils/hdr.js.map +1 -0
  49. package/dist/utils/layerCompositor.d.ts +36 -0
  50. package/dist/utils/layerCompositor.d.ts.map +1 -0
  51. package/dist/utils/layerCompositor.js +49 -0
  52. package/dist/utils/layerCompositor.js.map +1 -0
  53. package/package.json +3 -2
  54. package/src/config.ts +16 -0
  55. package/src/index.ts +42 -0
  56. package/src/services/browserManager.ts +6 -3
  57. package/src/services/chunkEncoder.test.ts +88 -0
  58. package/src/services/chunkEncoder.ts +35 -9
  59. package/src/services/chunkEncoder.types.ts +3 -0
  60. package/src/services/hdrCapture.test.ts +159 -0
  61. package/src/services/hdrCapture.ts +354 -0
  62. package/src/services/screenshotService.ts +102 -17
  63. package/src/services/streamingEncoder.test.ts +228 -0
  64. package/src/services/streamingEncoder.ts +153 -63
  65. package/src/services/videoFrameExtractor.ts +135 -31
  66. package/src/services/videoFrameInjector.ts +200 -0
  67. package/src/utils/alphaBlit.test.ts +993 -0
  68. package/src/utils/alphaBlit.ts +643 -0
  69. package/src/utils/ffprobe.ts +22 -0
  70. package/src/utils/hdr.test.ts +191 -0
  71. package/src/utils/hdr.ts +137 -0
  72. package/src/utils/layerCompositor.test.ts +141 -0
  73. package/src/utils/layerCompositor.ts +58 -0
  74. package/tsconfig.json +2 -1
@@ -0,0 +1,228 @@
1
+ /**
2
+ * buildStreamingArgs unit tests.
3
+ *
4
+ * These tests focus on the FFmpeg CLI shape rather than spawning the encoder
5
+ * — they're the cheap regression net for the HDR static-metadata bug
6
+ * (side_data=[none] in the encoded MP4) reproduced by
7
+ * packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
8
+ * refactors of the x265-params string can silently strip
9
+ * master-display / max-cll and ship as SDR BT.2020 again.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import {
15
+ buildStreamingArgs,
16
+ createFrameReorderBuffer,
17
+ type StreamingEncoderOptions,
18
+ } from "./streamingEncoder.js";
19
+ import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
20
+
21
+ const baseHdrPq: StreamingEncoderOptions = {
22
+ fps: 30,
23
+ width: 1920,
24
+ height: 1080,
25
+ codec: "h265",
26
+ preset: "medium",
27
+ quality: 23,
28
+ pixelFormat: "yuv420p10le",
29
+ useGpu: false,
30
+ rawInputFormat: "rgb48le",
31
+ hdr: { transfer: "pq" },
32
+ };
33
+
34
+ const baseHdrHlg: StreamingEncoderOptions = {
35
+ ...baseHdrPq,
36
+ hdr: { transfer: "hlg" },
37
+ };
38
+
39
+ const baseSdr: StreamingEncoderOptions = {
40
+ fps: 30,
41
+ width: 1920,
42
+ height: 1080,
43
+ codec: "h264",
44
+ preset: "medium",
45
+ quality: 23,
46
+ useGpu: false,
47
+ };
48
+
49
+ function getX265ParamsValue(args: string[]): string | undefined {
50
+ const idx = args.indexOf("-x265-params");
51
+ return idx === -1 ? undefined : args[idx + 1];
52
+ }
53
+
54
+ describe("buildStreamingArgs", () => {
55
+ describe("HDR PQ (libx265)", () => {
56
+ it("emits master-display and max-cll in -x265-params", () => {
57
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
58
+ const x265 = getX265ParamsValue(args);
59
+ expect(x265).toBeDefined();
60
+ expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
61
+ expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
62
+ expect(x265).toContain("colorprim=bt2020");
63
+ expect(x265).toContain("transfer=smpte2084");
64
+ expect(x265).toContain("colormatrix=bt2020nc");
65
+ });
66
+
67
+ it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
68
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
69
+ expect(args).toContain("-colorspace:v");
70
+ expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
71
+ expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
72
+ expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
73
+ expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
74
+ });
75
+
76
+ it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
77
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
78
+ const cvIdx = args.indexOf("-c:v");
79
+ expect(cvIdx).toBeGreaterThan(-1);
80
+ expect(args[cvIdx + 1]).toBe("libx265");
81
+ expect(args).toContain("-tag:v");
82
+ expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
83
+ });
84
+
85
+ it("keeps the aq-mode prefix even with master-display present", () => {
86
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
87
+ const x265 = getX265ParamsValue(args);
88
+ expect(x265?.startsWith("aq-mode=3")).toBe(true);
89
+ });
90
+
91
+ it("uses the simpler aq-mode prefix on ultrafast preset", () => {
92
+ const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
93
+ const x265 = getX265ParamsValue(args);
94
+ expect(x265?.startsWith("aq-mode=3:")).toBe(true);
95
+ expect(x265).not.toContain("aq-strength");
96
+ expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
97
+ });
98
+ });
99
+
100
+ describe("HDR HLG (libx265)", () => {
101
+ it("emits master-display, max-cll, and the HLG transfer", () => {
102
+ const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
103
+ const x265 = getX265ParamsValue(args);
104
+ expect(x265).toContain("transfer=arib-std-b67");
105
+ expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
106
+ expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
107
+ });
108
+
109
+ it("tags the output stream with arib-std-b67", () => {
110
+ const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
111
+ expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
112
+ });
113
+ });
114
+
115
+ describe("HDR raw input tagging", () => {
116
+ it("tags the rawvideo input with the matching color metadata", () => {
117
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
118
+ const inputColorTrcIdx = args.indexOf("-color_trc");
119
+ expect(inputColorTrcIdx).toBeGreaterThan(-1);
120
+ expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
121
+ const inputPrimariesIdx = args.indexOf("-color_primaries");
122
+ expect(inputPrimariesIdx).toBeGreaterThan(-1);
123
+ expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
124
+ // Pix_fmt of the raw input must match the buffer we hand FFmpeg.
125
+ expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
126
+ });
127
+
128
+ it("does not strip the input color tags when bitrate is set instead of CRF", () => {
129
+ const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
130
+ const x265 = getX265ParamsValue(args);
131
+ expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
132
+ expect(args).toContain("-b:v");
133
+ expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
134
+ });
135
+ });
136
+
137
+ describe("SDR fallback", () => {
138
+ it("does NOT emit HDR mastering metadata for SDR encodes", () => {
139
+ const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
140
+ const x264 = args[args.indexOf("-x264-params") + 1];
141
+ expect(x264).toContain("colorprim=bt709");
142
+ expect(x264).toContain("transfer=bt709");
143
+ expect(x264).toContain("colormatrix=bt709");
144
+ expect(x264).not.toContain("master-display");
145
+ expect(x264).not.toContain("max-cll");
146
+ });
147
+
148
+ it("tags SDR output with bt709 and tv range", () => {
149
+ const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
150
+ expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
151
+ expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
152
+ expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
153
+ expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
154
+ });
155
+ });
156
+
157
+ describe("output path", () => {
158
+ it("places the output path last after -y", () => {
159
+ const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
160
+ expect(args[args.length - 2]).toBe("-y");
161
+ expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
162
+ });
163
+ });
164
+ });
165
+
166
+ describe("createFrameReorderBuffer", () => {
167
+ it("fast-paths waitForFrame(cursor) without queueing", async () => {
168
+ const buf = createFrameReorderBuffer(0, 3);
169
+ await buf.waitForFrame(0);
170
+ });
171
+
172
+ it("gates out-of-order writers into cursor order", async () => {
173
+ const buf = createFrameReorderBuffer(0, 4);
174
+ const writeOrder: number[] = [];
175
+
176
+ const writer = async (frame: number) => {
177
+ await buf.waitForFrame(frame);
178
+ writeOrder.push(frame);
179
+ buf.advanceTo(frame + 1);
180
+ };
181
+
182
+ const p3 = writer(3);
183
+ const p1 = writer(1);
184
+ const p2 = writer(2);
185
+ const p0 = writer(0);
186
+
187
+ await Promise.all([p0, p1, p2, p3]);
188
+ expect(writeOrder).toEqual([0, 1, 2, 3]);
189
+ });
190
+
191
+ it("supports multiple waiters registered for the same frame", async () => {
192
+ const buf = createFrameReorderBuffer(0, 2);
193
+ const resolved: string[] = [];
194
+
195
+ const a = buf.waitForFrame(1).then(() => resolved.push("a"));
196
+ const b = buf.waitForFrame(1).then(() => resolved.push("b"));
197
+
198
+ buf.advanceTo(0);
199
+ await Promise.resolve();
200
+ expect(resolved).toEqual([]);
201
+
202
+ buf.advanceTo(1);
203
+ await Promise.all([a, b]);
204
+ expect(resolved.sort()).toEqual(["a", "b"]);
205
+ });
206
+
207
+ it("waitForAllDone resolves when cursor reaches endFrame", async () => {
208
+ const buf = createFrameReorderBuffer(0, 3);
209
+ let done = false;
210
+ const allDone = buf.waitForAllDone().then(() => {
211
+ done = true;
212
+ });
213
+
214
+ buf.advanceTo(1);
215
+ await Promise.resolve();
216
+ expect(done).toBe(false);
217
+
218
+ buf.advanceTo(3);
219
+ await allDone;
220
+ expect(done).toBe(true);
221
+ });
222
+
223
+ it("waitForAllDone fast-paths when cursor already past endFrame", async () => {
224
+ const buf = createFrameReorderBuffer(0, 3);
225
+ buf.advanceTo(5);
226
+ await buf.waitForAllDone();
227
+ });
228
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Streaming Encoder Service
3
3
  *
4
- * Pipes frame screenshot buffers directly to FFmpeg's stdin instead of writing
5
- * them to disk and reading them back in a separate encode stage. Follows the
6
- * Remotion pattern of image2pipe FFmpeg.
4
+ * Pipes frame screenshot buffers directly to FFmpeg's stdin via `-f image2pipe`
5
+ * instead of writing them to disk and reading them back in a separate encode
6
+ * stage. Inspired by Remotion's approach to browser-based video rendering.
7
7
  *
8
8
  * Two building blocks:
9
9
  * 1. Frame reorder buffer – ensures out-of-order parallel workers feed
@@ -17,6 +17,7 @@ import { existsSync, mkdirSync, statSync } from "fs";
17
17
  import { dirname } from "path";
18
18
 
19
19
  import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
20
+ import { getHdrEncoderColorParams } from "../utils/hdr.js";
20
21
  import { type EncoderOptions } from "./chunkEncoder.types.js";
21
22
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
22
23
 
@@ -24,8 +25,16 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
24
25
  export type { EncoderOptions } from "./chunkEncoder.types.js";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
- // 1. Frame reorder buffer (based on Remotion's ensure-frames-in-order.ts)
28
+ // 1. Frame reorder buffer ordered async barrier
28
29
  // ---------------------------------------------------------------------------
30
+ //
31
+ // Parallel workers produce frames out of order; FFmpeg's stdin expects them in
32
+ // strict sequential order. Each worker calls `waitForFrame(n)` to block until
33
+ // its turn, writes, then calls `advanceTo(n + 1)` to release the next waiter.
34
+ //
35
+ // `pending` holds an array per frame index (not a single resolver) so that
36
+ // `waitForAllDone` can coexist with the writer still waiting on the final
37
+ // frame without one clobbering the other.
29
38
 
30
39
  export interface FrameReorderBuffer {
31
40
  waitForFrame: (frame: number) => Promise<void>;
@@ -34,34 +43,49 @@ export interface FrameReorderBuffer {
34
43
  }
35
44
 
36
45
  export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer {
37
- let nextFrame = startFrame;
38
- let waiters: Array<{ frame: number; resolve: () => void }> = [];
39
-
40
- const resolveWaiters = () => {
41
- for (const waiter of waiters.slice()) {
42
- if (waiter.frame === nextFrame) {
43
- waiter.resolve();
44
- waiters = waiters.filter((w) => w !== waiter);
45
- }
46
+ let cursor = startFrame;
47
+ const pending = new Map<number, Array<() => void>>();
48
+
49
+ const enqueueAt = (frame: number, resolve: () => void): void => {
50
+ const list = pending.get(frame);
51
+ if (list === undefined) {
52
+ pending.set(frame, [resolve]);
53
+ } else {
54
+ list.push(resolve);
46
55
  }
47
56
  };
48
57
 
49
- return {
50
- waitForFrame: (frame: number) =>
51
- new Promise<void>((resolve) => {
52
- waiters.push({ frame, resolve });
53
- resolveWaiters();
54
- }),
55
- advanceTo: (frame: number) => {
56
- nextFrame = frame;
57
- resolveWaiters();
58
- },
59
- waitForAllDone: () =>
60
- new Promise<void>((resolve) => {
61
- waiters.push({ frame: endFrame, resolve });
62
- resolveWaiters();
63
- }),
58
+ const flushAt = (frame: number): void => {
59
+ const list = pending.get(frame);
60
+ if (list === undefined) return;
61
+ pending.delete(frame);
62
+ for (const resolve of list) resolve();
64
63
  };
64
+
65
+ const waitForFrame = (frame: number): Promise<void> =>
66
+ new Promise<void>((resolve) => {
67
+ if (frame === cursor) {
68
+ resolve();
69
+ return;
70
+ }
71
+ enqueueAt(frame, resolve);
72
+ });
73
+
74
+ const advanceTo = (frame: number): void => {
75
+ cursor = frame;
76
+ flushAt(frame);
77
+ };
78
+
79
+ const waitForAllDone = (): Promise<void> =>
80
+ new Promise<void>((resolve) => {
81
+ if (cursor >= endFrame) {
82
+ resolve();
83
+ return;
84
+ }
85
+ enqueueAt(endFrame, resolve);
86
+ });
87
+
88
+ return { waitForFrame, advanceTo, waitForAllDone };
65
89
  }
66
90
 
67
91
  // ---------------------------------------------------------------------------
@@ -79,6 +103,9 @@ export interface StreamingEncoderOptions {
79
103
  pixelFormat?: string;
80
104
  useGpu?: boolean;
81
105
  imageFormat?: "jpeg" | "png";
106
+ hdr?: { transfer: import("../utils/hdr.js").HdrTransfer };
107
+ /** When set, use rawvideo input instead of image2pipe. For HDR PQ-encoded frames. */
108
+ rawInputFormat?: "rgb48le";
82
109
  }
83
110
 
84
111
  export interface StreamingEncoderResult {
@@ -98,8 +125,11 @@ export interface StreamingEncoder {
98
125
  * Build FFmpeg args for streaming (image2pipe) input.
99
126
  * Reuses the same codec/quality/GPU logic as chunkEncoder's buildEncoderArgs
100
127
  * but with `-f image2pipe` instead of `-i <pattern>`.
128
+ *
129
+ * Exported so unit tests can assert on the constructed CLI without spawning
130
+ * FFmpeg — see streamingEncoder.test.ts.
101
131
  */
102
- function buildStreamingArgs(
132
+ export function buildStreamingArgs(
103
133
  options: StreamingEncoderOptions,
104
134
  outputPath: string,
105
135
  gpuEncoder: GpuEncoder = null,
@@ -116,19 +146,41 @@ function buildStreamingArgs(
116
146
  } = options;
117
147
 
118
148
  // Input args: pipe from stdin
119
- const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
120
- const args: string[] = [
121
- "-f",
122
- "image2pipe",
123
- "-vcodec",
124
- inputCodec,
125
- "-framerate",
126
- String(fps),
127
- "-i",
128
- "-",
129
- "-r",
130
- String(fps),
131
- ];
149
+ const args: string[] = [];
150
+ if (options.rawInputFormat) {
151
+ // Raw pixel input (HLG/PQ-encoded rgb48le from FFmpeg extraction).
152
+ // Tag the input with the correct color space so FFmpeg uses the right
153
+ // YUV matrix when converting rgb48le → yuv420p10le for encoding.
154
+ // Without these tags FFmpeg assumes bt709 and applies the wrong matrix.
155
+ const hdrTransfer = options.hdr?.transfer;
156
+ const inputColorTrc =
157
+ hdrTransfer === "pq" ? "smpte2084" : hdrTransfer === "hlg" ? "arib-std-b67" : undefined;
158
+ args.push(
159
+ "-f",
160
+ "rawvideo",
161
+ "-pix_fmt",
162
+ options.rawInputFormat,
163
+ "-s",
164
+ `${options.width}x${options.height}`,
165
+ "-framerate",
166
+ String(fps),
167
+ );
168
+ if (inputColorTrc) {
169
+ args.push(
170
+ "-color_primaries",
171
+ "bt2020",
172
+ "-color_trc",
173
+ inputColorTrc,
174
+ "-colorspace",
175
+ "bt2020nc",
176
+ );
177
+ }
178
+ args.push("-i", "-");
179
+ } else {
180
+ const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
181
+ args.push("-f", "image2pipe", "-vcodec", inputCodec, "-framerate", String(fps), "-i", "-");
182
+ }
183
+ args.push("-r", String(fps));
132
184
 
133
185
  const shouldUseGpu = useGpu && gpuEncoder !== null;
134
186
 
@@ -169,16 +221,25 @@ function buildStreamingArgs(
169
221
  if (bitrate) args.push("-b:v", bitrate);
170
222
  else args.push("-crf", String(quality));
171
223
 
172
- // Encoder-specific params: anti-banding + bt709 color space.
173
- // aq-mode=3 redistributes bits to dark flat areas (gradients).
174
- // colorprim/transfer/colormatrix embed bt709 in the H.264/H.265 VUI.
224
+ // Encoder-specific params: anti-banding + color space tagging.
225
+ // For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
226
+ // mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages
227
+ // without them, players (Apple, YouTube, HDR TVs) treat the file
228
+ // as SDR BT.2020 and tone-map incorrectly.
175
229
  const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
176
- const colorParams = "colorprim=bt709:transfer=bt709:colormatrix=bt709";
230
+ const colorParams =
231
+ options.rawInputFormat && options.hdr
232
+ ? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
233
+ : "colorprim=bt709:transfer=bt709:colormatrix=bt709";
177
234
  if (preset === "ultrafast") {
178
235
  args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
179
236
  } else {
180
237
  args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
181
238
  }
239
+ // Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
240
+ if (codec === "h265") {
241
+ args.push("-tag:v", "hvc1");
242
+ }
182
243
  }
183
244
  } else if (codec === "vp9") {
184
245
  args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
@@ -194,27 +255,47 @@ function buildStreamingArgs(
194
255
  return [...args, "-y", outputPath];
195
256
  }
196
257
 
197
- // BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709.
198
- // Tags the output so players interpret colors correctly across devices.
258
+ // Color space metadata.
259
+ // When rawInputFormat is set, data comes from the WebGPU HDR pipeline
260
+ // (PQ-encoded) — tag with bt2020/PQ truthfully.
261
+ // Otherwise, Chrome captures sRGB — tag as bt709.
199
262
  if (codec === "h264" || codec === "h265") {
200
- args.push(
201
- "-colorspace:v",
202
- "bt709",
203
- "-color_primaries:v",
204
- "bt709",
205
- "-color_trc:v",
206
- "bt709",
207
- "-color_range",
208
- "tv",
209
- );
263
+ if (options.rawInputFormat && options.hdr) {
264
+ args.push(
265
+ "-colorspace:v",
266
+ "bt2020nc",
267
+ "-color_primaries:v",
268
+ "bt2020",
269
+ "-color_trc:v",
270
+ options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67",
271
+ "-color_range",
272
+ "tv",
273
+ );
274
+ } else {
275
+ args.push(
276
+ "-colorspace:v",
277
+ "bt709",
278
+ "-color_primaries:v",
279
+ "bt709",
280
+ "-color_trc:v",
281
+ "bt709",
282
+ "-color_range",
283
+ "tv",
284
+ );
285
+ }
210
286
 
211
- // Convert full-range RGB input (Chrome screenshots) to limited/TV range for H.264.
212
- if (gpuEncoder === "vaapi") {
287
+ // Video filter for range/color conversion.
288
+ // Raw HDR input (from WebGPU pipeline) is already PQ-encoded — no conversion needed.
289
+ // Chrome screenshots need full→TV range conversion.
290
+ if (options.rawInputFormat) {
291
+ // No filter needed — PQ data goes straight to encoder
292
+ } else if (gpuEncoder === "vaapi") {
213
293
  const vfIdx = args.indexOf("-vf");
214
294
  if (vfIdx !== -1) {
215
295
  args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
216
296
  }
217
297
  } else if (!shouldUseGpu) {
298
+ // Range conversion: Chrome screenshots are full-range RGB.
218
299
  args.push("-vf", "scale=in_range=pc:out_range=tv");
219
300
  }
220
301
 
@@ -304,7 +385,15 @@ export async function spawnStreamingEncoder(
304
385
  if (exitStatus !== "running" || !ffmpeg.stdin || ffmpeg.stdin.destroyed) {
305
386
  return false;
306
387
  }
307
- return ffmpeg.stdin.write(buffer);
388
+ // Copy the buffer before writing — Node streams hold a reference to the
389
+ // provided buffer and drain it asynchronously. The HDR path's compositor
390
+ // reuses pre-allocated transOutput/normalCanvas buffers across frames,
391
+ // so without this copy the pipe would read partially-overwritten data
392
+ // and flicker. The SDR path doesn't invoke writeFrame at all (it pipes
393
+ // PNG files via encodeFramesFromDir), so the memcpy here is HDR-only
394
+ // and justified by correctness.
395
+ const copy = Buffer.from(buffer);
396
+ return ffmpeg.stdin.write(copy);
308
397
  },
309
398
 
310
399
  close: async (): Promise<StreamingEncoderResult> => {
@@ -312,9 +401,10 @@ export async function spawnStreamingEncoder(
312
401
  if (signal) signal.removeEventListener("abort", onAbort);
313
402
 
314
403
  // Close stdin to signal end of input
315
- if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
404
+ const stdin = ffmpeg.stdin;
405
+ if (stdin && !stdin.destroyed) {
316
406
  await new Promise<void>((resolve) => {
317
- ffmpeg.stdin!.end(() => resolve());
407
+ stdin.end(() => resolve());
318
408
  });
319
409
  }
320
410