@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.
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +22 -3
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +7 -0
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +31 -0
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts +17 -3
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +11 -4
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/alphaBlit.d.ts +34 -0
- package/dist/utils/alphaBlit.d.ts.map +1 -1
- package/dist/utils/alphaBlit.js +192 -0
- package/dist/utils/alphaBlit.js.map +1 -1
- package/dist/utils/ffprobe.d.ts +7 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +128 -20
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/services/frameCapture.ts +22 -3
- package/src/services/videoFrameExtractor.test.ts +50 -1
- package/src/services/videoFrameExtractor.ts +42 -0
- package/src/services/videoFrameInjector.ts +23 -4
- package/src/types.ts +12 -0
- package/src/utils/alphaBlit.test.ts +154 -0
- package/src/utils/alphaBlit.ts +227 -0
- package/src/utils/ffprobe.test.ts +109 -0
- 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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
272
|
+
nativeHdrIds: Set<string>,
|
|
259
273
|
): Promise<ElementStackingInfo[]> {
|
|
260
|
-
const hdrIds = Array.from(
|
|
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
|
+
});
|
package/src/utils/alphaBlit.ts
CHANGED
|
@@ -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).
|