@hyperframes/engine 0.4.10 → 0.4.11

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 (36) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/frameCapture.d.ts.map +1 -1
  6. package/dist/services/frameCapture.js +22 -3
  7. package/dist/services/frameCapture.js.map +1 -1
  8. package/dist/services/videoFrameExtractor.d.ts +7 -0
  9. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  10. package/dist/services/videoFrameExtractor.js +31 -0
  11. package/dist/services/videoFrameExtractor.js.map +1 -1
  12. package/dist/services/videoFrameInjector.d.ts +17 -3
  13. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  14. package/dist/services/videoFrameInjector.js +11 -4
  15. package/dist/services/videoFrameInjector.js.map +1 -1
  16. package/dist/types.d.ts +12 -0
  17. package/dist/types.d.ts.map +1 -1
  18. package/dist/utils/alphaBlit.d.ts +34 -0
  19. package/dist/utils/alphaBlit.d.ts.map +1 -1
  20. package/dist/utils/alphaBlit.js +192 -0
  21. package/dist/utils/alphaBlit.js.map +1 -1
  22. package/dist/utils/ffprobe.d.ts +7 -0
  23. package/dist/utils/ffprobe.d.ts.map +1 -1
  24. package/dist/utils/ffprobe.js +128 -20
  25. package/dist/utils/ffprobe.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/index.ts +7 -0
  28. package/src/services/frameCapture.ts +22 -3
  29. package/src/services/videoFrameExtractor.test.ts +50 -1
  30. package/src/services/videoFrameExtractor.ts +42 -0
  31. package/src/services/videoFrameInjector.ts +23 -4
  32. package/src/types.ts +12 -0
  33. package/src/utils/alphaBlit.test.ts +154 -0
  34. package/src/utils/alphaBlit.ts +227 -0
  35. package/src/utils/ffprobe.test.ts +109 -0
  36. package/src/utils/ffprobe.ts +147 -20
@@ -87,6 +87,19 @@ export async function createCaptureSession(
87
87
  const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
88
88
 
89
89
  const page = await browser.newPage();
90
+ // Polyfill esbuild's keepNames helper inside the page. Tools like tsx/Bun
91
+ // transform this engine's source on the fly and wrap every named function
92
+ // with `__name(fn, "name")`. When `page.evaluate()` serializes a callback
93
+ // and ships it to the browser, those `__name(...)` calls would crash with
94
+ // `__name is not defined` because the helper only exists in Node. Defining
95
+ // a no-op shim once per page makes the engine work uniformly whether it is
96
+ // imported from compiled dist (no helper) or from source via tsx.
97
+ await page.evaluateOnNewDocument(() => {
98
+ const w = window as unknown as { __name?: <T>(fn: T, _name: string) => T };
99
+ if (typeof w.__name !== "function") {
100
+ w.__name = <T>(fn: T, _name: string): T => fn;
101
+ }
102
+ });
90
103
  const browserVersion = await browser.version();
91
104
  const expectedMajor = config?.expectedChromiumMajor;
92
105
  if (Number.isFinite(expectedMajor)) {
@@ -240,10 +253,14 @@ export async function initializeSession(session: CaptureSession): Promise<void>
240
253
  }
241
254
 
242
255
  // Wait for all video elements to have loaded metadata (dimensions + duration)
243
- // Without this, frame 0 captures videos at their 300x150 default size
256
+ // Without this, frame 0 captures videos at their 300x150 default size.
257
+ // skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
258
+ // sources) whose frames come from ffmpeg out-of-band — Chromium may not be
259
+ // able to decode them at all (e.g. HEVC on Linux headless-shell).
260
+ const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
244
261
  const videosReady = await pollPageExpression(
245
262
  page,
246
- `document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
263
+ `(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
247
264
  pageReadyTimeout,
248
265
  );
249
266
  if (!videosReady) {
@@ -318,11 +335,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
318
335
 
319
336
  // Wait for all video elements to have loaded metadata (dimensions + duration).
320
337
  // Without this, frame 0 captures videos at their 300x150 default size.
338
+ // See screenshot-mode comment above for why skipReadinessVideoIds exists.
339
+ const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
321
340
  const videoDeadline =
322
341
  Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
323
342
  while (Date.now() < videoDeadline) {
324
343
  const videosReady = await page.evaluate(
325
- `document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
344
+ `(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
326
345
  );
327
346
  if (videosReady) break;
328
347
  await new Promise((r) => setTimeout(r, 100));
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { parseVideoElements } from "./videoFrameExtractor.js";
2
+ import { parseVideoElements, parseImageElements } from "./videoFrameExtractor.js";
3
3
 
4
4
  describe("parseVideoElements", () => {
5
5
  it("parses videos without an id or data-start attribute", () => {
@@ -32,3 +32,52 @@ describe("parseVideoElements", () => {
32
32
  });
33
33
  });
34
34
  });
35
+
36
+ describe("parseImageElements", () => {
37
+ it("parses images with data-start and data-duration", () => {
38
+ const images = parseImageElements(
39
+ '<img id="photo" src="hdr-photo.png" data-start="0" data-duration="3" />',
40
+ );
41
+
42
+ expect(images).toHaveLength(1);
43
+ expect(images[0]).toEqual({
44
+ id: "photo",
45
+ src: "hdr-photo.png",
46
+ start: 0,
47
+ end: 3,
48
+ });
49
+ });
50
+
51
+ it("generates stable IDs for images without one", () => {
52
+ const images = parseImageElements(
53
+ '<img src="a.png" data-start="0" data-end="2" /><img src="b.png" data-start="1" data-end="4" />',
54
+ );
55
+
56
+ expect(images).toHaveLength(2);
57
+ expect(images[0]!.id).toBe("hf-img-0");
58
+ expect(images[1]!.id).toBe("hf-img-1");
59
+ });
60
+
61
+ it("defaults start to 0 and end to Infinity when attributes missing", () => {
62
+ const images = parseImageElements('<img src="photo.png" />');
63
+
64
+ expect(images).toHaveLength(1);
65
+ expect(images[0]).toMatchObject({
66
+ src: "photo.png",
67
+ start: 0,
68
+ end: Infinity,
69
+ });
70
+ });
71
+
72
+ it("ignores img elements without src", () => {
73
+ const images = parseImageElements('<img data-start="0" data-end="3" />');
74
+ expect(images).toHaveLength(0);
75
+ });
76
+
77
+ it("uses data-end over data-duration when both present", () => {
78
+ const images = parseImageElements(
79
+ '<img src="a.png" data-start="1" data-end="5" data-duration="10" />',
80
+ );
81
+ expect(images[0]!.end).toBe(5);
82
+ });
83
+ });
@@ -97,6 +97,48 @@ export function parseVideoElements(html: string): VideoElement[] {
97
97
  return videos;
98
98
  }
99
99
 
100
+ export interface ImageElement {
101
+ id: string;
102
+ src: string;
103
+ start: number;
104
+ end: number;
105
+ }
106
+
107
+ export function parseImageElements(html: string): ImageElement[] {
108
+ const images: ImageElement[] = [];
109
+ const { document } = parseHTML(html);
110
+
111
+ const imgEls = document.querySelectorAll("img[src]");
112
+ let autoIdCounter = 0;
113
+ for (const el of imgEls) {
114
+ const src = el.getAttribute("src");
115
+ if (!src) continue;
116
+
117
+ const id = el.getAttribute("id") || `hf-img-${autoIdCounter++}`;
118
+ if (!el.getAttribute("id")) {
119
+ el.setAttribute("id", id);
120
+ }
121
+
122
+ const startAttr = el.getAttribute("data-start");
123
+ const endAttr = el.getAttribute("data-end");
124
+ const durationAttr = el.getAttribute("data-duration");
125
+
126
+ const start = startAttr ? parseFloat(startAttr) : 0;
127
+ let end = 0;
128
+ if (endAttr) {
129
+ end = parseFloat(endAttr);
130
+ } else if (durationAttr) {
131
+ end = start + parseFloat(durationAttr);
132
+ } else {
133
+ end = Infinity;
134
+ }
135
+
136
+ images.push({ id, src, start, end });
137
+ }
138
+
139
+ return images;
140
+ }
141
+
100
142
  export async function extractVideoFramesRange(
101
143
  videoPath: string,
102
144
  videoId: string,
@@ -244,20 +244,34 @@ export interface ElementStackingInfo {
244
244
  isHdr: boolean;
245
245
  transform: string; // CSS transform matrix string, e.g. "matrix(1,0,0,1,0,0)" or "none"
246
246
  borderRadius: [number, number, number, number]; // [tl, tr, br, bl] in CSS px from nearest clipping ancestor
247
+ /**
248
+ * CSS `object-fit` value for replaced elements (`<img>`, `<video>`).
249
+ * One of: `fill` (default), `cover`, `contain`, `none`, `scale-down`.
250
+ * The HDR compositor uses this to resample image/video buffers into the
251
+ * element's layout box the same way the browser would.
252
+ */
253
+ objectFit: string;
254
+ /**
255
+ * CSS `object-position` value (e.g. `"50% 50%"`, `"center top"`).
256
+ * Falls back to the CSS default `"50% 50%"` (center) when unset.
257
+ */
258
+ objectPosition: string;
247
259
  }
248
260
 
249
261
  /**
250
262
  * Query Chrome for ALL timed elements' stacking context.
251
- * Returns z-index, bounds, opacity, and whether each element is a native HDR video.
263
+ * Returns z-index, bounds, opacity, and whether each element is a native HDR source.
252
264
  *
253
265
  * Queries every element with `data-start` (not just videos) so the layer compositor
254
- * can determine z-ordering between DOM content and HDR video elements.
266
+ * can determine z-ordering between DOM content and HDR video/image elements.
267
+ *
268
+ * @param nativeHdrIds Combined set of HDR-tagged element IDs (videos AND images).
255
269
  */
256
270
  export async function queryElementStacking(
257
271
  page: Page,
258
- nativeHdrVideoIds: Set<string>,
272
+ nativeHdrIds: Set<string>,
259
273
  ): Promise<ElementStackingInfo[]> {
260
- const hdrIds = Array.from(nativeHdrVideoIds);
274
+ const hdrIds = Array.from(nativeHdrIds);
261
275
  return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
262
276
  const hdrSet = new Set(hdrIdList);
263
277
  const elements = document.querySelectorAll("[data-start]");
@@ -454,6 +468,11 @@ export async function queryElementStacking(
454
468
  // elements, the element-level transform is sufficient for reference.
455
469
  transform: isHdrEl ? getViewportMatrix(el) : style.transform || "none",
456
470
  borderRadius: isHdrEl ? getEffectiveBorderRadius(el) : [0, 0, 0, 0],
471
+ // `getComputedStyle` returns "" when the property doesn't apply (e.g.
472
+ // for non-replaced elements); normalize to the CSS defaults so callers
473
+ // can rely on a populated value.
474
+ objectFit: style.objectFit || "fill",
475
+ objectPosition: style.objectPosition || "50% 50%",
457
476
  });
458
477
  }
459
478
  return results;
package/src/types.ts CHANGED
@@ -94,6 +94,18 @@ export interface CaptureOptions {
94
94
  format?: "jpeg" | "png";
95
95
  quality?: number;
96
96
  deviceScaleFactor?: number;
97
+ /**
98
+ * Video element IDs to exclude from the in-page readiness check that waits
99
+ * for `video.readyState >= 1` before capture starts.
100
+ *
101
+ * Use for videos whose frames are supplied out-of-band (e.g. native HDR
102
+ * frame extraction via ffmpeg). The DOM `<video>` element is then only
103
+ * needed for layout (`getBoundingClientRect` / `offsetWidth`), which works
104
+ * at `readyState=0`. Without this, codecs that headless Chromium can't
105
+ * decode (HEVC on Linux `headless-shell`) cause a fatal timeout even
106
+ * though we never asked the browser to play the video.
107
+ */
108
+ skipReadinessVideoIds?: readonly string[];
97
109
  }
98
110
 
99
111
  export interface CaptureResult {
@@ -8,6 +8,8 @@ import {
8
8
  blitRgb48leAffine,
9
9
  parseTransformMatrix,
10
10
  roundedRectAlpha,
11
+ resampleRgb48leObjectFit,
12
+ normalizeObjectFit,
11
13
  } from "./alphaBlit.js";
12
14
 
13
15
  // ── PNG construction helpers ─────────────────────────────────────────────────
@@ -991,3 +993,155 @@ describe("blitRgb48leAffine with borderRadius", () => {
991
993
  expect(Buffer.compare(canvas1, canvas2)).toBe(0);
992
994
  });
993
995
  });
996
+
997
+ // ── normalizeObjectFit ──────────────────────────────────────────────────────
998
+
999
+ describe("normalizeObjectFit", () => {
1000
+ it("returns supported values verbatim", () => {
1001
+ expect(normalizeObjectFit("fill")).toBe("fill");
1002
+ expect(normalizeObjectFit("cover")).toBe("cover");
1003
+ expect(normalizeObjectFit("contain")).toBe("contain");
1004
+ expect(normalizeObjectFit("none")).toBe("none");
1005
+ expect(normalizeObjectFit("scale-down")).toBe("scale-down");
1006
+ });
1007
+
1008
+ it("trims whitespace and lowercases input", () => {
1009
+ expect(normalizeObjectFit(" COVER ")).toBe("cover");
1010
+ });
1011
+
1012
+ it("falls back to fill for unsupported values", () => {
1013
+ expect(normalizeObjectFit(undefined)).toBe("fill");
1014
+ expect(normalizeObjectFit("")).toBe("fill");
1015
+ expect(normalizeObjectFit("inherit")).toBe("fill");
1016
+ expect(normalizeObjectFit("garbage")).toBe("fill");
1017
+ });
1018
+ });
1019
+
1020
+ // ── resampleRgb48leObjectFit ────────────────────────────────────────────────
1021
+
1022
+ function readRgb16(buf: Buffer, width: number, x: number, y: number): [number, number, number] {
1023
+ const off = (y * width + x) * 6;
1024
+ return [buf.readUInt16LE(off), buf.readUInt16LE(off + 2), buf.readUInt16LE(off + 4)];
1025
+ }
1026
+
1027
+ describe("resampleRgb48leObjectFit", () => {
1028
+ it("returns the same buffer unchanged for identity fill resample", () => {
1029
+ const src = makeHdrFrame(4, 4, 40000, 30000, 20000);
1030
+ const out = resampleRgb48leObjectFit(src, 4, 4, 4, 4, "fill");
1031
+
1032
+ // Fast path returns the same Buffer reference, not a copy
1033
+ expect(out).toBe(src);
1034
+ });
1035
+
1036
+ it("returns the source untouched on degenerate dimensions", () => {
1037
+ const src = makeHdrFrame(4, 4, 1, 2, 3);
1038
+ expect(resampleRgb48leObjectFit(src, 0, 4, 8, 8, "cover")).toBe(src);
1039
+ expect(resampleRgb48leObjectFit(src, 4, 4, 0, 8, "cover")).toBe(src);
1040
+ });
1041
+
1042
+ it("fills a larger box with stretched content (fit=fill)", () => {
1043
+ const src = makeHdrFrame(2, 2, 50000, 40000, 30000);
1044
+ const out = resampleRgb48leObjectFit(src, 2, 2, 8, 4, "fill");
1045
+
1046
+ expect(out.length).toBe(8 * 4 * 6);
1047
+ // Every output pixel should be the source color (uniform input → uniform output)
1048
+ for (let y = 0; y < 4; y++) {
1049
+ for (let x = 0; x < 8; x++) {
1050
+ const [r, g, b] = readRgb16(out, 8, x, y);
1051
+ expect(r).toBe(50000);
1052
+ expect(g).toBe(40000);
1053
+ expect(b).toBe(30000);
1054
+ }
1055
+ }
1056
+ });
1057
+
1058
+ it("covers the destination box (cover) — fills entire box, no black bars", () => {
1059
+ // 4×2 source into a 6×6 dst: cover scales by 6/2 = 3 → rendered 12×6, cropped horizontally
1060
+ const src = makeHdrFrame(4, 2, 65000, 0, 0);
1061
+ const out = resampleRgb48leObjectFit(src, 4, 2, 6, 6, "cover");
1062
+
1063
+ // No pillarbox/letterbox black anywhere
1064
+ for (let y = 0; y < 6; y++) {
1065
+ for (let x = 0; x < 6; x++) {
1066
+ const [r] = readRgb16(out, 6, x, y);
1067
+ expect(r).toBe(65000);
1068
+ }
1069
+ }
1070
+ });
1071
+
1072
+ it("contains the source (contain) and letterboxes with opaque black", () => {
1073
+ // 4×2 source into a 6×6 dst: contain scales by 6/4 = 1.5 → rendered 6×3, vertically centered
1074
+ const src = makeHdrFrame(4, 2, 65000, 65000, 65000);
1075
+ const out = resampleRgb48leObjectFit(src, 4, 2, 6, 6, "contain");
1076
+
1077
+ // Top and bottom rows should be black (letterbox)
1078
+ for (const y of [0, 5]) {
1079
+ for (let x = 0; x < 6; x++) {
1080
+ expect(readRgb16(out, 6, x, y)).toEqual([0, 0, 0]);
1081
+ }
1082
+ }
1083
+ // Middle band (rows 2–3) should be the source color
1084
+ for (const y of [2, 3]) {
1085
+ for (let x = 0; x < 6; x++) {
1086
+ const [r, g, b] = readRgb16(out, 6, x, y);
1087
+ expect(r).toBe(65000);
1088
+ expect(g).toBe(65000);
1089
+ expect(b).toBe(65000);
1090
+ }
1091
+ }
1092
+ });
1093
+
1094
+ it("none preserves source size and centers it on a black background", () => {
1095
+ // 2×2 source into a 6×6 dst with default object-position 50%/50%
1096
+ const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1097
+ const out = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none");
1098
+
1099
+ // Center 2×2 region (rows 2–3, cols 2–3) holds the source
1100
+ for (let y = 2; y < 4; y++) {
1101
+ for (let x = 2; x < 4; x++) {
1102
+ const [r, g, b] = readRgb16(out, 6, x, y);
1103
+ expect(r).toBe(40000);
1104
+ expect(g).toBe(30000);
1105
+ expect(b).toBe(20000);
1106
+ }
1107
+ }
1108
+ // Corners should be black
1109
+ expect(readRgb16(out, 6, 0, 0)).toEqual([0, 0, 0]);
1110
+ expect(readRgb16(out, 6, 5, 5)).toEqual([0, 0, 0]);
1111
+ });
1112
+
1113
+ it("respects object-position for none-fit alignment", () => {
1114
+ // 2×2 source into a 6×6 dst, anchored top-left
1115
+ const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1116
+ const out = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none", "0% 0%");
1117
+
1118
+ // Top-left 2×2 block holds the source
1119
+ for (let y = 0; y < 2; y++) {
1120
+ for (let x = 0; x < 2; x++) {
1121
+ const [r] = readRgb16(out, 6, x, y);
1122
+ expect(r).toBe(40000);
1123
+ }
1124
+ }
1125
+ // Bottom-right corner stays black
1126
+ expect(readRgb16(out, 6, 5, 5)).toEqual([0, 0, 0]);
1127
+ // Just below the source band should be black
1128
+ expect(readRgb16(out, 6, 0, 2)).toEqual([0, 0, 0]);
1129
+ expect(readRgb16(out, 6, 2, 0)).toEqual([0, 0, 0]);
1130
+ });
1131
+
1132
+ it("scale-down behaves like none when source fits in dst", () => {
1133
+ const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1134
+ const noneOut = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none");
1135
+ const sdOut = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "scale-down");
1136
+
1137
+ expect(Buffer.compare(noneOut, sdOut)).toBe(0);
1138
+ });
1139
+
1140
+ it("scale-down behaves like contain when source overflows dst", () => {
1141
+ const src = makeHdrFrame(8, 4, 40000, 30000, 20000);
1142
+ const containOut = resampleRgb48leObjectFit(src, 8, 4, 6, 6, "contain");
1143
+ const sdOut = resampleRgb48leObjectFit(src, 8, 4, 6, 6, "scale-down");
1144
+
1145
+ expect(Buffer.compare(containOut, sdOut)).toBe(0);
1146
+ });
1147
+ });
@@ -622,6 +622,233 @@ export function blitRgb48leAffine(
622
622
  }
623
623
  }
624
624
 
625
+ /**
626
+ * CSS `object-fit` values supported by the HDR image/video resampler.
627
+ *
628
+ * Matches the CSS spec subset that browsers actually render for replaced
629
+ * elements (`<img>`, `<video>`). `scale-down` is normalized to whichever of
630
+ * `none` or `contain` produces the smaller rendered size, mirroring the spec.
631
+ */
632
+ export type ObjectFit = "fill" | "cover" | "contain" | "none" | "scale-down";
633
+
634
+ /**
635
+ * Parse a single axis of a CSS `object-position` string into a fraction in
636
+ * `[0, 1]` (proportion of the slack space along that axis).
637
+ *
638
+ * Defaults to 0.5 (centered) for unrecognized inputs to match CSS, which
639
+ * resolves invalid `object-position` values to the initial value (`50% 50%`).
640
+ */
641
+ function parseObjectPositionAxis(value: string, axis: "x" | "y"): number {
642
+ const lower = value.trim().toLowerCase();
643
+ if (lower === "left" || lower === "top") return 0;
644
+ if (lower === "right" || lower === "bottom") return 1;
645
+ if (lower === "center" || lower === "") return 0.5;
646
+ if (lower.endsWith("%")) {
647
+ const pct = parseFloat(lower) / 100;
648
+ return Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0.5;
649
+ }
650
+ // Pixel values (e.g. "10px") aren't fractional; without the slack-space
651
+ // numerator we can't honor them precisely. Fall back to center — this is
652
+ // strictly worse than the browser but matches what we'd render today.
653
+ if (axis === "x" || axis === "y") return 0.5;
654
+ return 0.5;
655
+ }
656
+
657
+ /**
658
+ * Parse a CSS `object-position` string like `"50% 50%"`, `"center top"`, or
659
+ * `"25% 75%"` into normalized `[0, 1]` fractions for X and Y.
660
+ *
661
+ * The fractions express how the slack space (the portion of the layout box
662
+ * not covered by the rendered content) should be distributed between the
663
+ * leading and trailing edges. `0` aligns to the left/top, `1` to the
664
+ * right/bottom, `0.5` (the default) centers the content.
665
+ */
666
+ function parseObjectPosition(css: string | undefined): { x: number; y: number } {
667
+ if (!css || !css.trim()) return { x: 0.5, y: 0.5 };
668
+ const tokens = css.trim().split(/\s+/);
669
+ if (tokens.length === 1) {
670
+ const single = tokens[0] ?? "";
671
+ const v = parseObjectPositionAxis(single, "x");
672
+ return { x: v, y: 0.5 };
673
+ }
674
+ return {
675
+ x: parseObjectPositionAxis(tokens[0] ?? "", "x"),
676
+ y: parseObjectPositionAxis(tokens[1] ?? "", "y"),
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Compute the rendered rectangle for an `object-fit` value.
682
+ *
683
+ * Returns the destination box (`dx`, `dy`, `dw`, `dh`) where the source image
684
+ * lands inside the layout box. For `cover` the rectangle extends past the
685
+ * layout box on the crop axis; the resampler clamps that overflow to the
686
+ * destination buffer bounds.
687
+ */
688
+ function computeObjectFitRect(
689
+ srcW: number,
690
+ srcH: number,
691
+ dstW: number,
692
+ dstH: number,
693
+ fit: ObjectFit,
694
+ pos: { x: number; y: number },
695
+ ): { dx: number; dy: number; dw: number; dh: number } {
696
+ let renderedW = dstW;
697
+ let renderedH = dstH;
698
+ if (fit === "fill") {
699
+ return { dx: 0, dy: 0, dw: dstW, dh: dstH };
700
+ }
701
+ if (fit === "none") {
702
+ renderedW = srcW;
703
+ renderedH = srcH;
704
+ } else if (fit === "scale-down") {
705
+ // Pick the smaller of `none` and `contain` rendered sizes.
706
+ const scale = Math.min(dstW / srcW, dstH / srcH, 1);
707
+ renderedW = srcW * scale;
708
+ renderedH = srcH * scale;
709
+ } else if (fit === "cover") {
710
+ const scale = Math.max(dstW / srcW, dstH / srcH);
711
+ renderedW = srcW * scale;
712
+ renderedH = srcH * scale;
713
+ } else {
714
+ // contain
715
+ const scale = Math.min(dstW / srcW, dstH / srcH);
716
+ renderedW = srcW * scale;
717
+ renderedH = srcH * scale;
718
+ }
719
+ const dx = (dstW - renderedW) * pos.x;
720
+ const dy = (dstH - renderedH) * pos.y;
721
+ return { dx, dy, dw: renderedW, dh: renderedH };
722
+ }
723
+
724
+ /**
725
+ * Resample an `rgb48le` image buffer into a destination box of `dstW × dstH`,
726
+ * honoring CSS `object-fit` and `object-position` semantics.
727
+ *
728
+ * Used at HDR-image setup so the per-frame blit can treat the buffer as if it
729
+ * were sized to the element's layout box, mirroring how browsers render
730
+ * `<img object-fit:…>` for SDR content. Pixels that fall outside the rendered
731
+ * rectangle (the letterboxed/pillarboxed area for `contain` and `none`) are
732
+ * filled with opaque black, matching the default background for replaced
733
+ * elements without a transparent canvas.
734
+ *
735
+ * Sampling is bilinear, which is what `blitRgb48leAffine` already uses for
736
+ * its on-canvas affine scale, so a one-time resample here matches the visual
737
+ * quality the rest of the pipeline produces.
738
+ *
739
+ * Returns the source buffer unchanged when `dstW === srcW && dstH === srcH`
740
+ * and `fit === "fill"`, so callers can call this unconditionally without
741
+ * paying for an unnecessary copy.
742
+ */
743
+ export function resampleRgb48leObjectFit(
744
+ source: Buffer,
745
+ srcW: number,
746
+ srcH: number,
747
+ dstW: number,
748
+ dstH: number,
749
+ fit: ObjectFit = "fill",
750
+ objectPosition?: string,
751
+ ): Buffer {
752
+ if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) {
753
+ return source;
754
+ }
755
+ if (fit === "fill" && srcW === dstW && srcH === dstH) {
756
+ return source;
757
+ }
758
+
759
+ const pos = parseObjectPosition(objectPosition);
760
+ const rect = computeObjectFitRect(srcW, srcH, dstW, dstH, fit, pos);
761
+ const dst = Buffer.alloc(dstW * dstH * 6); // pre-zeroed → opaque black background
762
+
763
+ const stride = dstW * 6;
764
+ // For each destination pixel that lies inside the rendered rect, sample
765
+ // the source bilinearly. Pixels outside the rect are left as the
766
+ // pre-zeroed black background (letterbox/pillarbox area).
767
+ const xMin = Math.max(0, Math.floor(rect.dx));
768
+ const yMin = Math.max(0, Math.floor(rect.dy));
769
+ const xMax = Math.min(dstW, Math.ceil(rect.dx + rect.dw));
770
+ const yMax = Math.min(dstH, Math.ceil(rect.dy + rect.dh));
771
+
772
+ if (rect.dw <= 0 || rect.dh <= 0) {
773
+ return dst;
774
+ }
775
+
776
+ const invScaleX = srcW / rect.dw;
777
+ const invScaleY = srcH / rect.dh;
778
+
779
+ for (let dy = yMin; dy < yMax; dy++) {
780
+ const rowOff = dy * stride;
781
+ const sy = (dy + 0.5 - rect.dy) * invScaleY - 0.5;
782
+ const syc = Math.max(0, Math.min(srcH - 1, sy));
783
+ const y0 = Math.floor(syc);
784
+ const y1 = Math.min(y0 + 1, srcH - 1);
785
+ const fy = syc - y0;
786
+ const ify = 1 - fy;
787
+
788
+ for (let dx = xMin; dx < xMax; dx++) {
789
+ const sx = (dx + 0.5 - rect.dx) * invScaleX - 0.5;
790
+ const sxc = Math.max(0, Math.min(srcW - 1, sx));
791
+ const x0 = Math.floor(sxc);
792
+ const x1 = Math.min(x0 + 1, srcW - 1);
793
+ const fx = sxc - x0;
794
+ const ifx = 1 - fx;
795
+
796
+ const off00 = (y0 * srcW + x0) * 6;
797
+ const off10 = (y0 * srcW + x1) * 6;
798
+ const off01 = (y1 * srcW + x0) * 6;
799
+ const off11 = (y1 * srcW + x1) * 6;
800
+
801
+ const w00 = ifx * ify;
802
+ const w10 = fx * ify;
803
+ const w01 = ifx * fy;
804
+ const w11 = fx * fy;
805
+
806
+ const r =
807
+ source.readUInt16LE(off00) * w00 +
808
+ source.readUInt16LE(off10) * w10 +
809
+ source.readUInt16LE(off01) * w01 +
810
+ source.readUInt16LE(off11) * w11;
811
+ const g =
812
+ source.readUInt16LE(off00 + 2) * w00 +
813
+ source.readUInt16LE(off10 + 2) * w10 +
814
+ source.readUInt16LE(off01 + 2) * w01 +
815
+ source.readUInt16LE(off11 + 2) * w11;
816
+ const b =
817
+ source.readUInt16LE(off00 + 4) * w00 +
818
+ source.readUInt16LE(off10 + 4) * w10 +
819
+ source.readUInt16LE(off01 + 4) * w01 +
820
+ source.readUInt16LE(off11 + 4) * w11;
821
+
822
+ const dstOff = rowOff + dx * 6;
823
+ dst.writeUInt16LE(Math.round(r), dstOff);
824
+ dst.writeUInt16LE(Math.round(g), dstOff + 2);
825
+ dst.writeUInt16LE(Math.round(b), dstOff + 4);
826
+ }
827
+ }
828
+
829
+ return dst;
830
+ }
831
+
832
+ /**
833
+ * Coerce a CSS `object-fit` value to the supported subset. Anything else
834
+ * (including `inherit`, `initial`, the empty string, or vendor-prefixed
835
+ * values) collapses to `"fill"` — the CSS default for replaced elements.
836
+ */
837
+ export function normalizeObjectFit(value: string | undefined): ObjectFit {
838
+ switch ((value ?? "").trim().toLowerCase()) {
839
+ case "cover":
840
+ return "cover";
841
+ case "contain":
842
+ return "contain";
843
+ case "none":
844
+ return "none";
845
+ case "scale-down":
846
+ return "scale-down";
847
+ default:
848
+ return "fill";
849
+ }
850
+ }
851
+
625
852
  /**
626
853
  * Parse a CSS `matrix(a,b,c,d,e,f)` string into a 6-element array.
627
854
  * Returns null for "none", empty, or unsupported formats (matrix3d).