@hyperframes/engine 0.4.6 → 0.4.8

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 (84) 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 +11 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -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 +115 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +252 -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 +59 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +290 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/types.d.ts +32 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/utils/alphaBlit.d.ts +105 -0
  40. package/dist/utils/alphaBlit.d.ts.map +1 -0
  41. package/dist/utils/alphaBlit.js +550 -0
  42. package/dist/utils/alphaBlit.js.map +1 -0
  43. package/dist/utils/ffprobe.d.ts +10 -0
  44. package/dist/utils/ffprobe.d.ts.map +1 -1
  45. package/dist/utils/ffprobe.js +7 -0
  46. package/dist/utils/ffprobe.js.map +1 -1
  47. package/dist/utils/hdr.d.ts +82 -0
  48. package/dist/utils/hdr.d.ts.map +1 -0
  49. package/dist/utils/hdr.js +87 -0
  50. package/dist/utils/hdr.js.map +1 -0
  51. package/dist/utils/layerCompositor.d.ts +36 -0
  52. package/dist/utils/layerCompositor.d.ts.map +1 -0
  53. package/dist/utils/layerCompositor.js +49 -0
  54. package/dist/utils/layerCompositor.js.map +1 -0
  55. package/dist/utils/shaderTransitions.d.ts +166 -0
  56. package/dist/utils/shaderTransitions.d.ts.map +1 -0
  57. package/dist/utils/shaderTransitions.js +894 -0
  58. package/dist/utils/shaderTransitions.js.map +1 -0
  59. package/package.json +3 -2
  60. package/src/config.ts +16 -0
  61. package/src/index.ts +61 -0
  62. package/src/services/browserManager.ts +6 -3
  63. package/src/services/chunkEncoder.test.ts +88 -0
  64. package/src/services/chunkEncoder.ts +35 -9
  65. package/src/services/chunkEncoder.types.ts +3 -0
  66. package/src/services/hdrCapture.test.ts +159 -0
  67. package/src/services/hdrCapture.ts +354 -0
  68. package/src/services/screenshotService.ts +271 -17
  69. package/src/services/streamingEncoder.test.ts +228 -0
  70. package/src/services/streamingEncoder.ts +153 -63
  71. package/src/services/videoFrameExtractor.ts +135 -31
  72. package/src/services/videoFrameInjector.ts +342 -0
  73. package/src/types.ts +34 -0
  74. package/src/utils/alphaBlit.test.ts +993 -0
  75. package/src/utils/alphaBlit.ts +643 -0
  76. package/src/utils/ffprobe.ts +22 -0
  77. package/src/utils/hdr.test.ts +191 -0
  78. package/src/utils/hdr.ts +137 -0
  79. package/src/utils/layerCompositor.test.ts +141 -0
  80. package/src/utils/layerCompositor.ts +58 -0
  81. package/src/utils/shaderTransitions.test.ts +674 -0
  82. package/src/utils/shaderTransitions.ts +1130 -0
  83. package/src/utils/uint16-alignment-audit.test.ts +125 -0
  84. package/tsconfig.json +2 -1
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isHdrColorSpace,
4
+ detectTransfer,
5
+ getHdrEncoderColorParams,
6
+ analyzeCompositionHdr,
7
+ DEFAULT_HDR10_MASTERING,
8
+ } from "./hdr.js";
9
+ import type { VideoColorSpace } from "./ffprobe.js";
10
+
11
+ describe("isHdrColorSpace", () => {
12
+ it("returns false for null", () => {
13
+ expect(isHdrColorSpace(null)).toBe(false);
14
+ });
15
+
16
+ it("returns false for bt709 SDR", () => {
17
+ expect(
18
+ isHdrColorSpace({ colorTransfer: "bt709", colorPrimaries: "bt709", colorSpace: "bt709" }),
19
+ ).toBe(false);
20
+ });
21
+
22
+ it("detects bt2020 primaries", () => {
23
+ expect(
24
+ isHdrColorSpace({ colorTransfer: "bt709", colorPrimaries: "bt2020", colorSpace: "bt709" }),
25
+ ).toBe(true);
26
+ });
27
+
28
+ it("detects smpte2084 (PQ)", () => {
29
+ expect(
30
+ isHdrColorSpace({
31
+ colorTransfer: "smpte2084",
32
+ colorPrimaries: "bt2020",
33
+ colorSpace: "bt2020nc",
34
+ }),
35
+ ).toBe(true);
36
+ });
37
+
38
+ it("detects arib-std-b67 (HLG)", () => {
39
+ expect(
40
+ isHdrColorSpace({
41
+ colorTransfer: "arib-std-b67",
42
+ colorPrimaries: "bt2020",
43
+ colorSpace: "bt2020nc",
44
+ }),
45
+ ).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("detectTransfer", () => {
50
+ it("returns hlg for null", () => {
51
+ expect(detectTransfer(null)).toBe("hlg");
52
+ });
53
+
54
+ it("returns pq for smpte2084", () => {
55
+ expect(
56
+ detectTransfer({
57
+ colorTransfer: "smpte2084",
58
+ colorPrimaries: "bt2020",
59
+ colorSpace: "bt2020nc",
60
+ }),
61
+ ).toBe("pq");
62
+ });
63
+
64
+ it("returns hlg for arib-std-b67", () => {
65
+ expect(
66
+ detectTransfer({
67
+ colorTransfer: "arib-std-b67",
68
+ colorPrimaries: "bt2020",
69
+ colorSpace: "bt2020nc",
70
+ }),
71
+ ).toBe("hlg");
72
+ });
73
+
74
+ it("returns hlg for bt709 (fallback)", () => {
75
+ expect(
76
+ detectTransfer({ colorTransfer: "bt709", colorPrimaries: "bt709", colorSpace: "bt709" }),
77
+ ).toBe("hlg");
78
+ });
79
+ });
80
+
81
+ describe("getHdrEncoderColorParams", () => {
82
+ it("returns PQ params with mastering metadata", () => {
83
+ const params = getHdrEncoderColorParams("pq");
84
+ expect(params.colorTrc).toBe("smpte2084");
85
+ expect(params.colorPrimaries).toBe("bt2020");
86
+ expect(params.colorspace).toBe("bt2020nc");
87
+ expect(params.pixelFormat).toBe("yuv420p10le");
88
+ expect(params.x265ColorParams).toContain("colorprim=bt2020");
89
+ expect(params.x265ColorParams).toContain("transfer=smpte2084");
90
+ expect(params.x265ColorParams).toContain("colormatrix=bt2020nc");
91
+ expect(params.mastering).toEqual(DEFAULT_HDR10_MASTERING);
92
+ });
93
+
94
+ it("returns HLG params with mastering metadata", () => {
95
+ const params = getHdrEncoderColorParams("hlg");
96
+ expect(params.colorTrc).toBe("arib-std-b67");
97
+ expect(params.colorPrimaries).toBe("bt2020");
98
+ expect(params.pixelFormat).toBe("yuv420p10le");
99
+ expect(params.x265ColorParams).toContain("transfer=arib-std-b67");
100
+ expect(params.mastering).toEqual(DEFAULT_HDR10_MASTERING);
101
+ });
102
+
103
+ // Regression guard for the side_data=[none] bug. See
104
+ // packages/producer/scripts/hdr-smoke.ts and the bug-1 entry in
105
+ // hdr-deferred-followups.md. Without master-display + max-cll in the
106
+ // x265-params, downstream players (Apple QuickTime, YouTube, HDR TVs) treat
107
+ // the file as SDR BT.2020 and tone-map incorrectly.
108
+ it("emits master-display and max-cll for PQ", () => {
109
+ const params = getHdrEncoderColorParams("pq");
110
+ expect(params.x265ColorParams).toContain(
111
+ `master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`,
112
+ );
113
+ expect(params.x265ColorParams).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
114
+ });
115
+
116
+ it("emits master-display and max-cll for HLG", () => {
117
+ const params = getHdrEncoderColorParams("hlg");
118
+ expect(params.x265ColorParams).toContain(
119
+ `master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`,
120
+ );
121
+ expect(params.x265ColorParams).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
122
+ });
123
+
124
+ it("respects an explicit mastering override", () => {
125
+ const custom = {
126
+ masterDisplay: "G(1,2)B(3,4)R(5,6)WP(7,8)L(9,10)",
127
+ maxCll: "500,200",
128
+ };
129
+ const params = getHdrEncoderColorParams("pq", custom);
130
+ expect(params.mastering).toBe(custom);
131
+ expect(params.x265ColorParams).toContain("master-display=G(1,2)B(3,4)R(5,6)WP(7,8)L(9,10)");
132
+ expect(params.x265ColorParams).toContain("max-cll=500,200");
133
+ });
134
+
135
+ // The DEFAULT_HDR10_MASTERING values are tagged as "P3-D65 inside BT.2020,
136
+ // 0.0001-1000 nits, MaxCLL 1000 / MaxFALL 400". If anyone tweaks these
137
+ // numbers without updating the docstring or the deferred-followups doc,
138
+ // this test will fail and force a deliberate review.
139
+ it("DEFAULT_HDR10_MASTERING matches the documented HDR10 reference", () => {
140
+ expect(DEFAULT_HDR10_MASTERING.masterDisplay).toBe(
141
+ "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
142
+ );
143
+ expect(DEFAULT_HDR10_MASTERING.maxCll).toBe("1000,400");
144
+ });
145
+ });
146
+
147
+ describe("analyzeCompositionHdr", () => {
148
+ const sdr: VideoColorSpace = {
149
+ colorTransfer: "bt709",
150
+ colorPrimaries: "bt709",
151
+ colorSpace: "bt709",
152
+ };
153
+ const hlg: VideoColorSpace = {
154
+ colorTransfer: "arib-std-b67",
155
+ colorPrimaries: "bt2020",
156
+ colorSpace: "bt2020nc",
157
+ };
158
+ const pq: VideoColorSpace = {
159
+ colorTransfer: "smpte2084",
160
+ colorPrimaries: "bt2020",
161
+ colorSpace: "bt2020nc",
162
+ };
163
+
164
+ it("returns no HDR for all SDR", () => {
165
+ expect(analyzeCompositionHdr([sdr, sdr, null])).toEqual({
166
+ hasHdr: false,
167
+ dominantTransfer: null,
168
+ });
169
+ });
170
+
171
+ it("detects HLG", () => {
172
+ expect(analyzeCompositionHdr([sdr, hlg])).toEqual({
173
+ hasHdr: true,
174
+ dominantTransfer: "hlg",
175
+ });
176
+ });
177
+
178
+ it("detects PQ", () => {
179
+ expect(analyzeCompositionHdr([sdr, pq])).toEqual({
180
+ hasHdr: true,
181
+ dominantTransfer: "pq",
182
+ });
183
+ });
184
+
185
+ it("PQ takes priority over HLG in mixed HDR", () => {
186
+ expect(analyzeCompositionHdr([hlg, pq])).toEqual({
187
+ hasHdr: true,
188
+ dominantTransfer: "pq",
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * HDR Color Space Utilities
3
+ *
4
+ * Centralized HDR detection, transfer type handling, and FFmpeg color
5
+ * parameter generation for the HDR rendering pipeline.
6
+ */
7
+
8
+ import type { VideoColorSpace } from "./ffprobe.js";
9
+
10
+ export type HdrTransfer = "hlg" | "pq";
11
+
12
+ /**
13
+ * Check if a video's color space indicates HDR content.
14
+ * Re-exported from videoFrameExtractor for backward compatibility.
15
+ */
16
+ export function isHdrColorSpace(cs: VideoColorSpace | null): boolean {
17
+ if (!cs) return false;
18
+ return (
19
+ cs.colorPrimaries.includes("bt2020") ||
20
+ cs.colorSpace.includes("bt2020") ||
21
+ cs.colorTransfer === "smpte2084" ||
22
+ cs.colorTransfer === "arib-std-b67"
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Determine the HDR transfer function from a video's color space metadata.
28
+ *
29
+ * IMPORTANT: Callers must gate on `isHdrColorSpace(cs)` first. This function
30
+ * assumes the input has already been classified as HDR and defaults ambiguous
31
+ * inputs to "hlg" — calling it with an SDR color space silently returns "hlg",
32
+ * which is wrong for SDR.
33
+ *
34
+ * Returns "pq" for SMPTE 2084, "hlg" for ARIB STD-B67, defaults to "hlg".
35
+ */
36
+ export function detectTransfer(cs: VideoColorSpace | null): HdrTransfer {
37
+ if (cs?.colorTransfer === "smpte2084") return "pq";
38
+ return "hlg";
39
+ }
40
+
41
+ /**
42
+ * HDR static metadata for the encoded stream.
43
+ *
44
+ * `masterDisplay` is the SMPTE ST 2086 mastering-display color volume string
45
+ * accepted by x265 (`G(Gx,Gy)B(Bx,By)R(Rx,Ry)WP(WPx,WPy)L(Lmax,Lmin)`).
46
+ * Chromaticity values are scaled by 50000 (0.00002 cd/m² per unit) and
47
+ * luminance values by 10000 (0.0001 cd/m² per unit).
48
+ *
49
+ * `maxCll` is the CTA-861.3 Content Light Level pair `MaxCLL,MaxFALL` in
50
+ * cd/m². Without these SEI messages, downstream players (Apple QuickTime,
51
+ * YouTube, HDR TVs) treat the stream as SDR BT.2020 and tone-map incorrectly
52
+ * — see packages/producer/scripts/hdr-smoke.ts for the regression assertion.
53
+ */
54
+ export interface HdrMasteringMetadata {
55
+ masterDisplay: string;
56
+ maxCll: string;
57
+ }
58
+
59
+ /**
60
+ * Default HDR10 mastering metadata: P3-D65 primaries inside a BT.2020
61
+ * container, mastered for 0.0001–1000 cd/m² with MaxCLL=1000, MaxFALL=400.
62
+ *
63
+ * These are conservative defaults that match how most HDR10 grading suites
64
+ * (Premiere, DaVinci Resolve) tag content when per-frame measured values
65
+ * aren't available. A future PR can plumb measured MaxCLL through `--hdr-opt`.
66
+ */
67
+ export const DEFAULT_HDR10_MASTERING: HdrMasteringMetadata = {
68
+ masterDisplay: "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
69
+ maxCll: "1000,400",
70
+ };
71
+
72
+ export interface HdrEncoderColorParams {
73
+ colorPrimaries: string;
74
+ colorTrc: string;
75
+ colorspace: string;
76
+ pixelFormat: string;
77
+ /**
78
+ * Full x265-params string including color tagging and HDR static metadata.
79
+ * Pass directly to `-x265-params` (concatenate with other options via `:`).
80
+ */
81
+ x265ColorParams: string;
82
+ /** The mastering metadata that was baked into `x265ColorParams`. */
83
+ mastering: HdrMasteringMetadata;
84
+ }
85
+
86
+ /**
87
+ * Get FFmpeg encoder color parameters for a given HDR transfer function.
88
+ *
89
+ * The returned `x265ColorParams` includes both color tagging
90
+ * (`colorprim`/`transfer`/`colormatrix`) and HDR static metadata
91
+ * (`master-display`/`max-cll`). Without the static metadata the encoded
92
+ * stream is rejected as SDR by most HDR-aware players and CDNs.
93
+ */
94
+ export function getHdrEncoderColorParams(
95
+ transfer: HdrTransfer,
96
+ mastering: HdrMasteringMetadata = DEFAULT_HDR10_MASTERING,
97
+ ): HdrEncoderColorParams {
98
+ const colorTrc = transfer === "pq" ? "smpte2084" : "arib-std-b67";
99
+ const tagging = `colorprim=bt2020:transfer=${colorTrc}:colormatrix=bt2020nc`;
100
+ const metadata = `master-display=${mastering.masterDisplay}:max-cll=${mastering.maxCll}`;
101
+ return {
102
+ colorPrimaries: "bt2020",
103
+ colorTrc,
104
+ colorspace: "bt2020nc",
105
+ pixelFormat: "yuv420p10le",
106
+ x265ColorParams: `${tagging}:${metadata}`,
107
+ mastering,
108
+ };
109
+ }
110
+
111
+ export interface CompositionHdrInfo {
112
+ hasHdr: boolean;
113
+ dominantTransfer: HdrTransfer | null;
114
+ }
115
+
116
+ /**
117
+ * Analyze a set of video color spaces to determine if the composition
118
+ * contains HDR content and what the dominant transfer function is.
119
+ */
120
+ export function analyzeCompositionHdr(
121
+ colorSpaces: Array<VideoColorSpace | null>,
122
+ ): CompositionHdrInfo {
123
+ let hasPq = false;
124
+ let hasHdr = false;
125
+
126
+ for (const cs of colorSpaces) {
127
+ if (!isHdrColorSpace(cs)) continue;
128
+ hasHdr = true;
129
+ if (cs?.colorTransfer === "smpte2084") hasPq = true;
130
+ }
131
+
132
+ if (!hasHdr) return { hasHdr: false, dominantTransfer: null };
133
+
134
+ // PQ takes priority — it's the more common HDR10 format
135
+ const dominantTransfer: HdrTransfer = hasPq ? "pq" : "hlg";
136
+ return { hasHdr: true, dominantTransfer };
137
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { groupIntoLayers } from "./layerCompositor.js";
3
+ import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
4
+
5
+ function makeEl(id: string, zIndex: number, isHdr: boolean): ElementStackingInfo {
6
+ return {
7
+ id,
8
+ zIndex,
9
+ x: 0,
10
+ y: 0,
11
+ width: 1920,
12
+ height: 1080,
13
+ layoutWidth: 1920,
14
+ layoutHeight: 1080,
15
+ opacity: 1,
16
+ visible: true,
17
+ isHdr,
18
+ transform: "none",
19
+ borderRadius: [0, 0, 0, 0],
20
+ };
21
+ }
22
+
23
+ describe("groupIntoLayers", () => {
24
+ it("single DOM element → 1 DOM layer", () => {
25
+ const layers = groupIntoLayers([makeEl("text", 0, false)]);
26
+ expect(layers).toHaveLength(1);
27
+ expect(layers[0]!.type).toBe("dom");
28
+ });
29
+
30
+ it("single HDR element → 1 HDR layer", () => {
31
+ const layers = groupIntoLayers([makeEl("v-hdr", 0, true)]);
32
+ expect(layers).toHaveLength(1);
33
+ expect(layers[0]!.type).toBe("hdr");
34
+ });
35
+
36
+ it("merges adjacent DOM elements into one layer", () => {
37
+ const elements = [makeEl("bg", 0, false), makeEl("text", 1, false), makeEl("logo", 2, false)];
38
+ const layers = groupIntoLayers(elements);
39
+ expect(layers).toHaveLength(1);
40
+ expect(layers[0]!.type).toBe("dom");
41
+ if (layers[0]!.type === "dom") {
42
+ expect(layers[0]!.elementIds).toEqual(["bg", "text", "logo"]);
43
+ }
44
+ });
45
+
46
+ it("splits on HDR/DOM boundary: DOM → HDR → DOM = 3 layers", () => {
47
+ const elements = [makeEl("bg", 0, false), makeEl("v-hdr", 1, true), makeEl("title", 2, false)];
48
+ const layers = groupIntoLayers(elements);
49
+ expect(layers).toHaveLength(3);
50
+ expect(layers[0]!.type).toBe("dom");
51
+ expect(layers[1]!.type).toBe("hdr");
52
+ expect(layers[2]!.type).toBe("dom");
53
+ });
54
+
55
+ it("merges adjacent DOM around multiple HDR: DOM → HDR → HDR → DOM = 4 layers", () => {
56
+ const elements = [
57
+ makeEl("bg", 0, false),
58
+ makeEl("v-hdr1", 1, true),
59
+ makeEl("v-hdr2", 2, true),
60
+ makeEl("title", 3, false),
61
+ ];
62
+ const layers = groupIntoLayers(elements);
63
+ expect(layers).toHaveLength(4);
64
+ expect(layers[0]!.type).toBe("dom");
65
+ expect(layers[1]!.type).toBe("hdr");
66
+ expect(layers[2]!.type).toBe("hdr");
67
+ expect(layers[3]!.type).toBe("dom");
68
+ });
69
+
70
+ it("complex case: DOM DOM HDR DOM HDR DOM = 5 layers (2 DOM merges)", () => {
71
+ const elements = [
72
+ makeEl("bg", 0, false),
73
+ makeEl("caption", 1, false),
74
+ makeEl("v-hdr1", 2, true),
75
+ makeEl("text", 3, false),
76
+ makeEl("v-hdr2", 4, true),
77
+ makeEl("logo", 5, false),
78
+ ];
79
+ const layers = groupIntoLayers(elements);
80
+ expect(layers).toHaveLength(5);
81
+ expect(layers.map((l) => l.type)).toEqual(["dom", "hdr", "dom", "hdr", "dom"]);
82
+ if (layers[0]!.type === "dom") {
83
+ expect(layers[0]!.elementIds).toEqual(["bg", "caption"]);
84
+ }
85
+ });
86
+
87
+ it("sorts by zIndex before grouping", () => {
88
+ const elements = [makeEl("title", 5, false), makeEl("v-hdr", 2, true), makeEl("bg", 0, false)];
89
+ const layers = groupIntoLayers(elements);
90
+ expect(layers).toHaveLength(3);
91
+ expect(layers[0]!.type).toBe("dom"); // bg (z=0)
92
+ expect(layers[1]!.type).toBe("hdr"); // v-hdr (z=2)
93
+ expect(layers[2]!.type).toBe("dom"); // title (z=5)
94
+ });
95
+
96
+ it("includes invisible elements in correct z-position", () => {
97
+ const elements = [
98
+ makeEl("bg", 0, false),
99
+ { ...makeEl("hidden-sdr", 1, false), visible: false },
100
+ { ...makeEl("hidden-hdr", 2, true), visible: false },
101
+ makeEl("title", 3, false),
102
+ ];
103
+ const layers = groupIntoLayers(elements);
104
+ // All elements included — invisible SDR videos need their injected
105
+ // <img> replacements hidden from other layers' screenshots
106
+ expect(layers).toHaveLength(3);
107
+ expect(layers[0]!.type).toBe("dom"); // bg + hidden-sdr (merged)
108
+ expect(layers[1]!.type).toBe("hdr"); // hidden-hdr
109
+ expect(layers[2]!.type).toBe("dom"); // title
110
+ if (layers[0]!.type === "dom") {
111
+ expect(layers[0]!.elementIds).toEqual(["bg", "hidden-sdr"]);
112
+ }
113
+ });
114
+
115
+ it("returns an empty array for empty input", () => {
116
+ expect(groupIntoLayers([])).toEqual([]);
117
+ });
118
+
119
+ it("handles negative z-index (valid CSS back layers)", () => {
120
+ const elements = [makeEl("fg", 1, false), makeEl("bg", -5, false)];
121
+ const layers = groupIntoLayers(elements);
122
+ expect(layers).toHaveLength(1);
123
+ expect(layers[0]!.type).toBe("dom");
124
+ if (layers[0]!.type === "dom") {
125
+ expect(layers[0]!.elementIds).toEqual(["bg", "fg"]);
126
+ }
127
+ });
128
+
129
+ it("preserves input order for equal z-index (stable tie-break)", () => {
130
+ const elements = [
131
+ makeEl("first", 0, false),
132
+ makeEl("second", 0, false),
133
+ makeEl("third", 0, false),
134
+ ];
135
+ const layers = groupIntoLayers(elements);
136
+ expect(layers).toHaveLength(1);
137
+ if (layers[0]!.type === "dom") {
138
+ expect(layers[0]!.elementIds).toEqual(["first", "second", "third"]);
139
+ }
140
+ });
141
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Layer Compositor — z-order analysis for multi-layer HDR compositing.
3
+ *
4
+ * Groups timed elements into z-ordered layers (DOM or HDR) for the
5
+ * per-frame compositing loop. Adjacent DOM elements merge into a single
6
+ * layer to minimize Chrome screenshots.
7
+ */
8
+
9
+ import type { ElementStackingInfo } from "../services/videoFrameInjector.js";
10
+
11
+ export type { ElementStackingInfo };
12
+
13
+ export type CompositeLayer =
14
+ | { type: "dom"; elementIds: string[] }
15
+ | { type: "hdr"; element: ElementStackingInfo };
16
+
17
+ /**
18
+ * Group z-sorted elements into composite layers. Adjacent DOM elements merge
19
+ * into a single layer; each HDR video/image is its own layer.
20
+ *
21
+ * Elements are sorted by \`zIndex\` ascending (back to front). Ties fall
22
+ * through to V8's stable sort, which preserves \`querySelectorAll\` DOM order —
23
+ * this is the same order Chrome uses for equal-z elements in a stacking
24
+ * context, so the blit order matches what the user sees in-browser.
25
+ *
26
+ * The DOM merge doesn't lose information: DOM layers are rendered via a
27
+ * full-page screenshot with non-layer elements hidden, so within-layer
28
+ * z-order is handled by Chrome itself.
29
+ *
30
+ * Invisible elements ARE included (video elements are hidden by the frame
31
+ * injector, but their injected \`<img>\` replacements are visible — they must
32
+ * stay in the correct z-ordered layer so sibling layers' DOM screenshots
33
+ * hide them).
34
+ */
35
+ export function groupIntoLayers(elements: ElementStackingInfo[]): CompositeLayer[] {
36
+ // Include ALL elements regardless of visibility. Video elements are hidden by
37
+ // the frame injector (HEVC can't decode in headless Chrome) but their injected
38
+ // <img> replacements ARE visible. We need them in the correct z-ordered layer
39
+ // so they get hidden from other layers' DOM screenshots.
40
+ const sorted = [...elements].sort((a, b) => a.zIndex - b.zIndex);
41
+
42
+ const layers: CompositeLayer[] = [];
43
+
44
+ for (const el of sorted) {
45
+ if (el.isHdr) {
46
+ layers.push({ type: "hdr", element: el });
47
+ } else {
48
+ const last = layers[layers.length - 1];
49
+ if (last && last.type === "dom") {
50
+ last.elementIds.push(el.id);
51
+ } else {
52
+ layers.push({ type: "dom", elementIds: [el.id] });
53
+ }
54
+ }
55
+ }
56
+
57
+ return layers;
58
+ }