@hyperframes/engine 0.4.9 → 0.4.11-alpha.1

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
@@ -0,0 +1,109 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { extractPngMetadataFromBuffer, extractVideoMetadata } from "./ffprobe.js";
5
+
6
+ function crc32(buf: Buffer): number {
7
+ let crc = 0xffffffff;
8
+ for (let i = 0; i < buf.length; i++) {
9
+ crc ^= buf[i] ?? 0;
10
+ for (let bit = 0; bit < 8; bit++) {
11
+ const mask = -(crc & 1);
12
+ crc = (crc >>> 1) ^ (0xedb88320 & mask);
13
+ }
14
+ }
15
+ return (crc ^ 0xffffffff) >>> 0;
16
+ }
17
+
18
+ function pngChunk(type: string, data: number[]): Buffer {
19
+ const chunkData = Buffer.from(data);
20
+ const header = Buffer.alloc(8);
21
+ header.writeUInt32BE(chunkData.length, 0);
22
+ header.write(type, 4, 4, "ascii");
23
+ const crc = Buffer.alloc(4);
24
+ crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), chunkData])), 0);
25
+ return Buffer.concat([header, chunkData, crc]);
26
+ }
27
+
28
+ function buildPngWithChunks(chunks: Buffer[]): Buffer {
29
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), ...chunks]);
30
+ }
31
+
32
+ function buildMinimalPng(options?: {
33
+ cIcpAfterIdat?: boolean;
34
+ invalidCrc?: boolean;
35
+ longCicp?: boolean;
36
+ }) {
37
+ const ihdr = pngChunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 16, 2, 0, 0, 0]);
38
+ const cicpData = options?.longCicp ? [9, 16, 0, 1, 255] : [9, 16, 0, 1];
39
+ let cicp = pngChunk("cICP", cicpData);
40
+ if (options?.invalidCrc) {
41
+ cicp = Buffer.from(cicp);
42
+ cicp[cicp.length - 1] ^= 0xff;
43
+ }
44
+ const idat = pngChunk(
45
+ "IDAT",
46
+ [0x78, 0x9c, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01],
47
+ );
48
+ const iend = pngChunk("IEND", []);
49
+ return options?.cIcpAfterIdat
50
+ ? buildPngWithChunks([ihdr, idat, cicp, iend])
51
+ : buildPngWithChunks([ihdr, cicp, idat, iend]);
52
+ }
53
+
54
+ describe("extractVideoMetadata", () => {
55
+ it("reads HDR PNG cICP metadata when ffprobe color fields are absent", async () => {
56
+ const fixturePath = resolve(
57
+ __dirname,
58
+ "../../../producer/tests/hdr-image-only/src/hdr-photo.png",
59
+ );
60
+
61
+ const metadata = await extractVideoMetadata(fixturePath);
62
+
63
+ expect(metadata.colorSpace).toEqual({
64
+ colorPrimaries: "bt2020",
65
+ colorTransfer: "smpte2084",
66
+ colorSpace: "gbr",
67
+ });
68
+ });
69
+ });
70
+
71
+ describe("extractPngMetadataFromBuffer", () => {
72
+ it("accepts a valid cICP chunk before IDAT", () => {
73
+ const metadata = extractPngMetadataFromBuffer(buildMinimalPng());
74
+ expect(metadata?.colorSpace).toEqual({
75
+ colorPrimaries: "bt2020",
76
+ colorTransfer: "smpte2084",
77
+ colorSpace: "gbr",
78
+ });
79
+ });
80
+
81
+ it("rejects cICP chunks after IDAT", () => {
82
+ const metadata = extractPngMetadataFromBuffer(buildMinimalPng({ cIcpAfterIdat: true }));
83
+ expect(metadata).toEqual({
84
+ width: 1,
85
+ height: 1,
86
+ colorSpace: null,
87
+ });
88
+ });
89
+
90
+ it("rejects cICP chunks with invalid CRC", () => {
91
+ expect(extractPngMetadataFromBuffer(buildMinimalPng({ invalidCrc: true }))).toBeNull();
92
+ });
93
+
94
+ it("rejects cICP chunks whose payload is not exactly four bytes", () => {
95
+ const metadata = extractPngMetadataFromBuffer(buildMinimalPng({ longCicp: true }));
96
+ expect(metadata).toEqual({
97
+ width: 1,
98
+ height: 1,
99
+ colorSpace: null,
100
+ });
101
+ });
102
+
103
+ it("continues to parse the checked-in HDR PNG fixture", () => {
104
+ const fixture = readFileSync(
105
+ resolve(__dirname, "../../../producer/tests/hdr-image-only/src/hdr-photo.png"),
106
+ );
107
+ expect(extractPngMetadataFromBuffer(fixture)?.colorSpace?.colorTransfer).toBe("smpte2084");
108
+ });
109
+ });
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "child_process";
2
+ import { readFileSync } from "fs";
3
+ import { extname } from "path";
2
4
 
3
5
  /** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
4
6
  function runFfprobe(args: string[]): Promise<string> {
@@ -96,6 +98,107 @@ interface FFProbeOutput {
96
98
  format: FFProbeFormat;
97
99
  }
98
100
 
101
+ interface StillImageMetadata {
102
+ width: number;
103
+ height: number;
104
+ colorSpace: VideoColorSpace | null;
105
+ }
106
+
107
+ function crc32(buf: Buffer): number {
108
+ let crc = 0xffffffff;
109
+ for (let i = 0; i < buf.length; i++) {
110
+ crc ^= buf[i] ?? 0;
111
+ for (let bit = 0; bit < 8; bit++) {
112
+ const mask = -(crc & 1);
113
+ crc = (crc >>> 1) ^ (0xedb88320 & mask);
114
+ }
115
+ }
116
+ return (crc ^ 0xffffffff) >>> 0;
117
+ }
118
+
119
+ export function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata | null {
120
+ if (
121
+ buf.length < 8 ||
122
+ buf[0] !== 137 ||
123
+ buf[1] !== 80 ||
124
+ buf[2] !== 78 ||
125
+ buf[3] !== 71 ||
126
+ buf[4] !== 13 ||
127
+ buf[5] !== 10 ||
128
+ buf[6] !== 26 ||
129
+ buf[7] !== 10
130
+ ) {
131
+ return null;
132
+ }
133
+
134
+ let width = 0;
135
+ let height = 0;
136
+ let seenIdat = false;
137
+ let pos = 8;
138
+ while (pos + 12 <= buf.length) {
139
+ const chunkLen = buf.readUInt32BE(pos);
140
+ const chunkType = buf.toString("ascii", pos + 4, pos + 8);
141
+ if (pos + 12 + chunkLen > buf.length) return null;
142
+ const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
143
+ const chunkCrc = buf.readUInt32BE(pos + 8 + chunkLen);
144
+ const chunkBytes = Buffer.concat([Buffer.from(chunkType, "ascii"), chunkData]);
145
+ if (crc32(chunkBytes) !== chunkCrc) return null;
146
+
147
+ if (chunkType === "IHDR" && chunkLen >= 8) {
148
+ width = buf.readUInt32BE(pos + 8);
149
+ height = buf.readUInt32BE(pos + 12);
150
+ }
151
+
152
+ if (chunkType === "IDAT") {
153
+ seenIdat = true;
154
+ }
155
+
156
+ if (chunkType === "cICP" && chunkLen === 4 && !seenIdat) {
157
+ const primariesCode = chunkData[0] ?? 0;
158
+ const transferCode = chunkData[1] ?? 0;
159
+ const matrixCode = chunkData[2] ?? 0;
160
+
161
+ return {
162
+ width,
163
+ height,
164
+ colorSpace: {
165
+ colorPrimaries:
166
+ primariesCode === 9
167
+ ? "bt2020"
168
+ : primariesCode === 1
169
+ ? "bt709"
170
+ : `unknown-${primariesCode}`,
171
+ colorTransfer:
172
+ transferCode === 16
173
+ ? "smpte2084"
174
+ : transferCode === 18
175
+ ? "arib-std-b67"
176
+ : transferCode === 1
177
+ ? "bt709"
178
+ : `unknown-${transferCode}`,
179
+ colorSpace:
180
+ matrixCode === 9 ? "bt2020nc" : matrixCode === 0 ? "gbr" : `unknown-${matrixCode}`,
181
+ },
182
+ };
183
+ }
184
+
185
+ if (chunkType === "IEND") break;
186
+ pos += 12 + chunkLen;
187
+ }
188
+
189
+ return width > 0 && height > 0 ? { width, height, colorSpace: null } : null;
190
+ }
191
+
192
+ function extractStillImageMetadata(filePath: string): StillImageMetadata | null {
193
+ if (extname(filePath).toLowerCase() !== ".png") return null;
194
+
195
+ try {
196
+ return extractPngMetadataFromBuffer(readFileSync(filePath));
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
99
202
  function parseFrameRate(frameRateStr: string | undefined): number {
100
203
  if (!frameRateStr) return 0;
101
204
  const parts = frameRateStr.split("/");
@@ -112,18 +215,40 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
112
215
  if (cached) return cached;
113
216
 
114
217
  const probePromise = (async (): Promise<VideoMetadata> => {
115
- const stdout = await runFfprobe([
116
- "-v",
117
- "quiet",
118
- "-print_format",
119
- "json",
120
- "-show_format",
121
- "-show_streams",
122
- filePath,
123
- ]);
124
- const output = parseProbeJson(stdout);
125
- const videoStream = output.streams.find((s) => s.codec_type === "video");
126
- if (!videoStream) throw new Error("[FFmpeg] No video stream found");
218
+ const stillImageMeta = extractStillImageMetadata(filePath);
219
+
220
+ let output: FFProbeOutput | null = null;
221
+ try {
222
+ const stdout = await runFfprobe([
223
+ "-v",
224
+ "quiet",
225
+ "-print_format",
226
+ "json",
227
+ "-show_format",
228
+ "-show_streams",
229
+ filePath,
230
+ ]);
231
+ output = parseProbeJson(stdout);
232
+ } catch (error) {
233
+ if (!stillImageMeta) throw error;
234
+ }
235
+
236
+ const videoStream = output?.streams.find((s) => s.codec_type === "video");
237
+ if (!videoStream) {
238
+ if (stillImageMeta) {
239
+ return {
240
+ durationSeconds: 0,
241
+ width: stillImageMeta.width,
242
+ height: stillImageMeta.height,
243
+ fps: 0,
244
+ videoCodec: "png",
245
+ hasAudio: false,
246
+ isVFR: false,
247
+ colorSpace: stillImageMeta.colorSpace,
248
+ };
249
+ }
250
+ throw new Error("[FFmpeg] No video stream found");
251
+ }
127
252
 
128
253
  const rFps = parseFrameRate(videoStream.r_frame_rate);
129
254
  const avgFps = parseFrameRate(videoStream.avg_frame_rate);
@@ -134,19 +259,21 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
134
259
  const colorTransfer = videoStream.color_transfer || "";
135
260
  const colorPrimaries = videoStream.color_primaries || "";
136
261
  const colorSpaceVal = videoStream.color_space || "";
137
- const hasColorInfo = !!(colorTransfer || colorPrimaries || colorSpaceVal);
262
+ const ffprobeColorSpace =
263
+ colorTransfer || colorPrimaries || colorSpaceVal
264
+ ? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
265
+ : null;
266
+ const colorSpace = ffprobeColorSpace ?? stillImageMeta?.colorSpace ?? null;
138
267
 
139
268
  return {
140
- durationSeconds: output.format.duration ? parseFloat(output.format.duration) : 0,
141
- width: videoStream.width || 0,
142
- height: videoStream.height || 0,
269
+ durationSeconds: output?.format.duration ? parseFloat(output.format.duration) : 0,
270
+ width: videoStream.width || stillImageMeta?.width || 0,
271
+ height: videoStream.height || stillImageMeta?.height || 0,
143
272
  fps,
144
273
  videoCodec: videoStream.codec_name || "unknown",
145
- hasAudio: output.streams.some((s) => s.codec_type === "audio"),
274
+ hasAudio: output?.streams.some((s) => s.codec_type === "audio") ?? false,
146
275
  isVFR,
147
- colorSpace: hasColorInfo
148
- ? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
149
- : null,
276
+ colorSpace,
150
277
  };
151
278
  })();
152
279