@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.
- 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
|
@@ -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
|
+
});
|
package/src/utils/ffprobe.ts
CHANGED
|
@@ -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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
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
|
|
274
|
+
hasAudio: output?.streams.some((s) => s.codec_type === "audio") ?? false,
|
|
146
275
|
isVFR,
|
|
147
|
-
colorSpace
|
|
148
|
-
? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
|
|
149
|
-
: null,
|
|
276
|
+
colorSpace,
|
|
150
277
|
};
|
|
151
278
|
})();
|
|
152
279
|
|