media-analysis-mcp 2.0.0
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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/bailian.js +320 -0
- package/dist/bailian.js.map +1 -0
- package/dist/bytes.js +20 -0
- package/dist/bytes.js.map +1 -0
- package/dist/cli.js +56 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-lookup.js +271 -0
- package/dist/config-lookup.js.map +1 -0
- package/dist/config.js +212 -0
- package/dist/config.js.map +1 -0
- package/dist/doctor.js +206 -0
- package/dist/doctor.js.map +1 -0
- package/dist/errors.js +387 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/media.js +585 -0
- package/dist/media.js.map +1 -0
- package/dist/mpeg-audio.js +226 -0
- package/dist/mpeg-audio.js.map +1 -0
- package/dist/provider-error.js +73 -0
- package/dist/provider-error.js.map +1 -0
- package/dist/sanitize.js +103 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/server.js +290 -0
- package/dist/server.js.map +1 -0
- package/dist/sse.js +327 -0
- package/dist/sse.js.map +1 -0
- package/dist/upload-cache.js +146 -0
- package/dist/upload-cache.js.map +1 -0
- package/dist/upload.js +368 -0
- package/dist/upload.js.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +73 -0
- package/scripts/prepare.mjs +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { applyCliArgs } from "./cli.js";
|
|
4
|
+
import { formatConfigSourceLog, inspectConfig } from "./config-lookup.js";
|
|
5
|
+
import { formatDoctorText, runDoctor } from "./doctor.js";
|
|
6
|
+
import { startupErrorText } from "./errors.js";
|
|
7
|
+
import { abortActiveAnalysis, createServer } from "./server.js";
|
|
8
|
+
import { formatPackageBanner, PACKAGE_VERSION } from "./version.js";
|
|
9
|
+
async function runServer() {
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
const server = createServer();
|
|
12
|
+
const transport = new StdioServerTransport();
|
|
13
|
+
const shutdown = () => {
|
|
14
|
+
if (shuttingDown) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
shuttingDown = true;
|
|
18
|
+
abortActiveAnalysis(server);
|
|
19
|
+
void server.close();
|
|
20
|
+
};
|
|
21
|
+
process.on("SIGINT", shutdown);
|
|
22
|
+
process.on("SIGTERM", shutdown);
|
|
23
|
+
process.stdin.on("end", shutdown);
|
|
24
|
+
process.stdin.on("close", shutdown);
|
|
25
|
+
process.stderr.write(`${formatPackageBanner()}\n`);
|
|
26
|
+
process.stderr.write(`${formatConfigSourceLog(inspectConfig())}\n`);
|
|
27
|
+
await server.connect(transport);
|
|
28
|
+
}
|
|
29
|
+
const args = applyCliArgs(process.argv.slice(2));
|
|
30
|
+
if (args.version) {
|
|
31
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`);
|
|
32
|
+
}
|
|
33
|
+
else if (args.doctor) {
|
|
34
|
+
try {
|
|
35
|
+
const report = await runDoctor();
|
|
36
|
+
if (args.json) {
|
|
37
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.stdout.write(formatDoctorText(report));
|
|
41
|
+
}
|
|
42
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
process.stderr.write(`${startupErrorText(err)}\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
try {
|
|
51
|
+
await runServer();
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
process.stderr.write(`${startupErrorText(err)}\n`);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute, relative } from "node:path";
|
|
4
|
+
import { readBounded } from "./bytes.js";
|
|
5
|
+
import { MediaError } from "./errors.js";
|
|
6
|
+
import { probeMpegAudio } from "./mpeg-audio.js";
|
|
7
|
+
export const MAX_LOCAL_MEDIA_DURATION_SECONDS = 3600;
|
|
8
|
+
export const MAX_MP4_PROBE_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_MP4_PROBE_BOXES = 4096;
|
|
10
|
+
const HEADER_BYTES = 12;
|
|
11
|
+
const PRIVATE_IPV4 = /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[0-1])\.)/;
|
|
12
|
+
const REMOTE_AUDIO_EXTENSION = /\.mp3$/i;
|
|
13
|
+
const CONTAINER_BY_EXTENSION = {
|
|
14
|
+
".mp4": "mp4",
|
|
15
|
+
".mov": "mov",
|
|
16
|
+
".mp3": "mp3",
|
|
17
|
+
};
|
|
18
|
+
const CONTAINER_UPLOAD = {
|
|
19
|
+
mp4: { uploadName: "video.mp4", contentType: "video/mp4", objectExtension: "mp4" },
|
|
20
|
+
mov: { uploadName: "video.mov", contentType: "video/quicktime", objectExtension: "mov" },
|
|
21
|
+
mp3: { uploadName: "audio.mp3", contentType: "audio/mpeg", objectExtension: "mp3" },
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Codecs the provider is documented to decode inside an ISO-BMFF file. Anything
|
|
25
|
+
* else is refused before upload: an undecodable audio track would otherwise turn
|
|
26
|
+
* into a misleading "no sound heard" answer.
|
|
27
|
+
*/
|
|
28
|
+
const SUPPORTED_VIDEO_CODECS = new Set(["avc1", "avc3", "hvc1", "hev1"]);
|
|
29
|
+
const SUPPORTED_AUDIO_CODECS = new Set(["mp4a"]);
|
|
30
|
+
export async function closeResolvedMedia(media) {
|
|
31
|
+
if (media.kind === "local") {
|
|
32
|
+
await media.handle.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function isContainedInRoot(root, candidate) {
|
|
36
|
+
const rel = relative(root, candidate);
|
|
37
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
38
|
+
}
|
|
39
|
+
function durationSecondsFromProbe(probed) {
|
|
40
|
+
if (probed === undefined || probed.timescale === 0n) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const seconds = probed.duration / probed.timescale;
|
|
44
|
+
if (seconds > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return Number(seconds);
|
|
48
|
+
}
|
|
49
|
+
const CONTENT_FINGERPRINT_BYTES = 64 * 1024;
|
|
50
|
+
async function readFingerprintPart(handle, position, length) {
|
|
51
|
+
const buffer = Buffer.alloc(length);
|
|
52
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
53
|
+
return new Uint8Array(buffer.subarray(0, bytesRead));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Content marker over a bounded head and tail. It is deliberately not a full-file hash:
|
|
57
|
+
* the probe budget stays small on large media, while any change to the head or tail — the
|
|
58
|
+
* regions a re-encode or re-export always touches — changes the key.
|
|
59
|
+
*/
|
|
60
|
+
async function contentFingerprint(handle, sizeBytes) {
|
|
61
|
+
const partLength = Math.min(CONTENT_FINGERPRINT_BYTES, sizeBytes);
|
|
62
|
+
const parts = [Buffer.from(`${String(sizeBytes)}|`, "utf8")];
|
|
63
|
+
parts.push(await readFingerprintPart(handle, 0, partLength));
|
|
64
|
+
if (sizeBytes > partLength) {
|
|
65
|
+
parts.push(await readFingerprintPart(handle, sizeBytes - partLength, partLength));
|
|
66
|
+
}
|
|
67
|
+
const hash = createHash("sha256");
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
hash.update(new Uint8Array(part));
|
|
70
|
+
}
|
|
71
|
+
return hash.digest("hex").slice(0, 32);
|
|
72
|
+
}
|
|
73
|
+
function uploadIdentityKey(realPath, sizeBytes, mtimeMs) {
|
|
74
|
+
const pathKey = process.platform === "win32" ? realPath.toLowerCase() : realPath;
|
|
75
|
+
return `${pathKey}|${String(sizeBytes)}|${String(mtimeMs)}`;
|
|
76
|
+
}
|
|
77
|
+
function sameIdentity(left, right) {
|
|
78
|
+
return (left.dev === right.dev &&
|
|
79
|
+
left.ino === right.ino &&
|
|
80
|
+
left.size === right.size &&
|
|
81
|
+
left.isFile() === right.isFile());
|
|
82
|
+
}
|
|
83
|
+
function isMp4Ftyp(header) {
|
|
84
|
+
return header.length >= 8 && header.toString("ascii", 4, 8) === "ftyp";
|
|
85
|
+
}
|
|
86
|
+
function readAt(reader, position, length, state) {
|
|
87
|
+
return readBounded(reader, position, length, state, MAX_MP4_PROBE_BYTES);
|
|
88
|
+
}
|
|
89
|
+
async function readBoxHeader(reader, position, limit, state) {
|
|
90
|
+
if (position + 8 > limit) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const head = await readAt(reader, position, 8, state);
|
|
94
|
+
if (head === undefined) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const size32 = head.readUInt32BE(0);
|
|
98
|
+
const type = head.toString("ascii", 4, 8);
|
|
99
|
+
let headerSize = 8;
|
|
100
|
+
let boxSize;
|
|
101
|
+
if (size32 === 1) {
|
|
102
|
+
const large = await readAt(reader, position + 8, 8, state);
|
|
103
|
+
if (large === undefined) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
boxSize = large.readBigUInt64BE(0);
|
|
107
|
+
headerSize = 16;
|
|
108
|
+
}
|
|
109
|
+
else if (size32 === 0) {
|
|
110
|
+
boxSize = BigInt(limit - position);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
boxSize = BigInt(size32);
|
|
114
|
+
}
|
|
115
|
+
if (type === "uuid") {
|
|
116
|
+
const uuid = await readAt(reader, position + headerSize, 16, state);
|
|
117
|
+
if (uuid === undefined) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
headerSize += 16;
|
|
121
|
+
}
|
|
122
|
+
if (boxSize < BigInt(headerSize)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const end = BigInt(position) + boxSize;
|
|
126
|
+
if (end > BigInt(limit) || end > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const contentEnd = Number(end);
|
|
130
|
+
if (contentEnd <= position) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return { type, contentStart: position + headerSize, contentEnd };
|
|
134
|
+
}
|
|
135
|
+
async function parseMvhd(reader, box, state) {
|
|
136
|
+
const versionBuf = await readAt(reader, box.contentStart, 1, state);
|
|
137
|
+
const version = versionBuf?.[0];
|
|
138
|
+
if (version === 1) {
|
|
139
|
+
const body = await readAt(reader, box.contentStart, 32, state);
|
|
140
|
+
if (body === undefined) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const timescale = BigInt(body.readUInt32BE(20));
|
|
144
|
+
const duration = body.readBigUInt64BE(24);
|
|
145
|
+
if (timescale === 0n) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return { duration, timescale };
|
|
149
|
+
}
|
|
150
|
+
if (version !== 0) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const body = await readAt(reader, box.contentStart, 20, state);
|
|
154
|
+
if (body === undefined) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const timescale = BigInt(body.readUInt32BE(12));
|
|
158
|
+
const duration = BigInt(body.readUInt32BE(16));
|
|
159
|
+
if (timescale === 0n) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
return { duration, timescale };
|
|
163
|
+
}
|
|
164
|
+
async function walkBoxes(reader, start, limit, state, wanted) {
|
|
165
|
+
let offset = start;
|
|
166
|
+
while (offset + 8 <= limit) {
|
|
167
|
+
state.boxes += 1;
|
|
168
|
+
if (state.boxes > MAX_MP4_PROBE_BOXES) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const box = await readBoxHeader(reader, offset, limit, state);
|
|
172
|
+
if (box === undefined) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
if (wanted === "moov" && box.type === "moov") {
|
|
176
|
+
return walkBoxes(reader, box.contentStart, box.contentEnd, state, "mvhd");
|
|
177
|
+
}
|
|
178
|
+
if (wanted === "mvhd" && box.type === "mvhd") {
|
|
179
|
+
return parseMvhd(reader, box, state);
|
|
180
|
+
}
|
|
181
|
+
offset = box.contentEnd;
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
async function firstBoxOfType(reader, parent, state, wanted) {
|
|
186
|
+
let offset = parent.contentStart;
|
|
187
|
+
while (offset + 8 <= parent.contentEnd) {
|
|
188
|
+
state.boxes += 1;
|
|
189
|
+
if (state.boxes > MAX_MP4_PROBE_BOXES) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const box = await readBoxHeader(reader, offset, parent.contentEnd, state);
|
|
193
|
+
if (box === undefined) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
if (box.type === wanted) {
|
|
197
|
+
return box;
|
|
198
|
+
}
|
|
199
|
+
offset = box.contentEnd;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
async function childBoxes(reader, parent, state, wanted) {
|
|
204
|
+
const found = [];
|
|
205
|
+
let offset = parent.contentStart;
|
|
206
|
+
while (offset + 8 <= parent.contentEnd) {
|
|
207
|
+
state.boxes += 1;
|
|
208
|
+
if (state.boxes > MAX_MP4_PROBE_BOXES) {
|
|
209
|
+
return { boxes: found, complete: false };
|
|
210
|
+
}
|
|
211
|
+
const box = await readBoxHeader(reader, offset, parent.contentEnd, state);
|
|
212
|
+
if (box === undefined) {
|
|
213
|
+
return { boxes: found, complete: false };
|
|
214
|
+
}
|
|
215
|
+
if (box.type === wanted) {
|
|
216
|
+
found.push(box);
|
|
217
|
+
}
|
|
218
|
+
offset = box.contentEnd;
|
|
219
|
+
}
|
|
220
|
+
return { boxes: found, complete: offset === parent.contentEnd };
|
|
221
|
+
}
|
|
222
|
+
/** hdlr: version/flags, pre_defined, then the 4-byte handler type ("vide"/"soun"). */
|
|
223
|
+
async function readHandlerType(reader, hdlr, state) {
|
|
224
|
+
const body = await readAt(reader, hdlr.contentStart + 8, 4, state);
|
|
225
|
+
return body?.toString("ascii");
|
|
226
|
+
}
|
|
227
|
+
/** stsd: version/flags, entry_count, then sample entries starting with size + format. */
|
|
228
|
+
async function readSampleFormats(reader, stsd, state) {
|
|
229
|
+
const head = await readAt(reader, stsd.contentStart, 8, state);
|
|
230
|
+
if (head === undefined) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const count = head.readUInt32BE(4);
|
|
234
|
+
if (count === 0 || count > 16) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const formats = [];
|
|
238
|
+
let offset = stsd.contentStart + 8;
|
|
239
|
+
for (let index = 0; index < count; index += 1) {
|
|
240
|
+
const entry = await readAt(reader, offset, 8, state);
|
|
241
|
+
if (entry === undefined) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
const size = entry.readUInt32BE(0);
|
|
245
|
+
formats.push(entry.toString("ascii", 4, 8));
|
|
246
|
+
if (size < 8 || offset + size > stsd.contentEnd) {
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
offset += size;
|
|
250
|
+
}
|
|
251
|
+
return formats;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Bounded codec probe: moov → trak → mdia → hdlr/minf → stbl → stsd, fourcc only.
|
|
255
|
+
* Returns empty lists when the structure is unreadable, so callers can decide.
|
|
256
|
+
*/
|
|
257
|
+
async function probeTrackCodecsDetailed(reader, fileSize) {
|
|
258
|
+
const codecs = { video: [], audio: [] };
|
|
259
|
+
let audioTrackCount = 0;
|
|
260
|
+
if (!Number.isSafeInteger(fileSize) || fileSize < 8) {
|
|
261
|
+
return { ...codecs, complete: false, audioTrackCount };
|
|
262
|
+
}
|
|
263
|
+
const state = { bytesRead: 0, boxes: 0 };
|
|
264
|
+
const moov = await (async () => {
|
|
265
|
+
let offset = 0;
|
|
266
|
+
while (offset + 8 <= fileSize) {
|
|
267
|
+
state.boxes += 1;
|
|
268
|
+
if (state.boxes > MAX_MP4_PROBE_BOXES) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
const box = await readBoxHeader(reader, offset, fileSize, state);
|
|
272
|
+
if (box === undefined) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (box.type === "moov") {
|
|
276
|
+
return box;
|
|
277
|
+
}
|
|
278
|
+
offset = box.contentEnd;
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
})();
|
|
282
|
+
if (moov === undefined) {
|
|
283
|
+
return { ...codecs, complete: false, audioTrackCount };
|
|
284
|
+
}
|
|
285
|
+
const tracks = await childBoxes(reader, moov, state, "trak");
|
|
286
|
+
let complete = tracks.complete && tracks.boxes.length > 0;
|
|
287
|
+
for (const trak of tracks.boxes) {
|
|
288
|
+
const mdia = await firstBoxOfType(reader, trak, state, "mdia");
|
|
289
|
+
if (mdia === undefined) {
|
|
290
|
+
complete = false;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const handler = await (async () => {
|
|
294
|
+
const hdlr = await firstBoxOfType(reader, mdia, state, "hdlr");
|
|
295
|
+
return hdlr === undefined ? undefined : readHandlerType(reader, hdlr, state);
|
|
296
|
+
})();
|
|
297
|
+
const minf = await firstBoxOfType(reader, mdia, state, "minf");
|
|
298
|
+
if (minf === undefined) {
|
|
299
|
+
complete = false;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const stbl = await firstBoxOfType(reader, minf, state, "stbl");
|
|
303
|
+
if (stbl === undefined) {
|
|
304
|
+
complete = false;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const stsd = await firstBoxOfType(reader, stbl, state, "stsd");
|
|
308
|
+
if (stsd === undefined) {
|
|
309
|
+
complete = false;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const formats = await readSampleFormats(reader, stsd, state);
|
|
313
|
+
if (handler === undefined || formats.length === 0)
|
|
314
|
+
complete = false;
|
|
315
|
+
if (handler === "vide") {
|
|
316
|
+
codecs.video.push(...formats);
|
|
317
|
+
}
|
|
318
|
+
else if (handler === "soun") {
|
|
319
|
+
audioTrackCount += 1;
|
|
320
|
+
codecs.audio.push(...formats);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
codecs.video = [...new Set(codecs.video)];
|
|
324
|
+
codecs.audio = [...new Set(codecs.audio)];
|
|
325
|
+
return { ...codecs, complete, audioTrackCount };
|
|
326
|
+
}
|
|
327
|
+
export async function probeTrackCodecs(reader, fileSize) {
|
|
328
|
+
const { video, audio } = await probeTrackCodecsDetailed(reader, fileSize);
|
|
329
|
+
return { video, audio };
|
|
330
|
+
}
|
|
331
|
+
/** First codec outside the provider's documented set, or undefined when all are fine. */
|
|
332
|
+
export function unsupportedCodec(codecs) {
|
|
333
|
+
const badVideo = codecs.video.find((codec) => !SUPPORTED_VIDEO_CODECS.has(codec));
|
|
334
|
+
if (badVideo !== undefined) {
|
|
335
|
+
return badVideo;
|
|
336
|
+
}
|
|
337
|
+
return codecs.audio.find((codec) => !SUPPORTED_AUDIO_CODECS.has(codec));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Light ISO BMFF duration probe: box headers + seek only.
|
|
341
|
+
* Returns undefined when duration is unknown (malformed, missing mvhd, timescale 0).
|
|
342
|
+
*/
|
|
343
|
+
export async function probeMp4Duration(reader, fileSize) {
|
|
344
|
+
if (!Number.isSafeInteger(fileSize) || fileSize < 8) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return walkBoxes(reader, 0, fileSize, { bytesRead: 0, boxes: 0 }, "moov");
|
|
348
|
+
}
|
|
349
|
+
function asPositionedReader(handle) {
|
|
350
|
+
return {
|
|
351
|
+
read(buffer, offset, length, position) {
|
|
352
|
+
return handle.read(buffer, offset, length, position);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function blockedHostname(hostname) {
|
|
357
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
358
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
if (host === "0.0.0.0" || host === "::" || host === "::1" || host === "0:0:0:0:0:0:0:0") {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (PRIVATE_IPV4.test(host)) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
if (host.startsWith("::ffff:")) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
const hextets = host.split(":");
|
|
371
|
+
const firstRaw = hextets[0];
|
|
372
|
+
if (firstRaw !== undefined && firstRaw.length > 0 && !firstRaw.includes(".")) {
|
|
373
|
+
const first = Number.parseInt(firstRaw, 16);
|
|
374
|
+
if (Number.isInteger(first) && first >= 0xfc00 && first <= 0xfdff) {
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (Number.isInteger(first) && first >= 0xfe80 && first <= 0xfebf) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const compact = host.replace(/::/, ":");
|
|
382
|
+
if (compact.includes(":ffff:") && host.startsWith("0:")) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
function parseHttpsVideoUrl(raw) {
|
|
388
|
+
let parsed;
|
|
389
|
+
try {
|
|
390
|
+
parsed = new URL(raw);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
throw new MediaError({ code: "INVALID_MEDIA_INPUT", stage: "received" });
|
|
394
|
+
}
|
|
395
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
|
|
396
|
+
throw new MediaError({ code: "INVALID_MEDIA_INPUT", stage: "received" });
|
|
397
|
+
}
|
|
398
|
+
if (blockedHostname(parsed.hostname)) {
|
|
399
|
+
throw new MediaError({ code: "INVALID_MEDIA_INPUT", stage: "received" });
|
|
400
|
+
}
|
|
401
|
+
if (REMOTE_AUDIO_EXTENSION.test(parsed.pathname)) {
|
|
402
|
+
throw new MediaError({
|
|
403
|
+
code: "UNSUPPORTED_MEDIA",
|
|
404
|
+
stage: "received",
|
|
405
|
+
diagnostic: { input_kind: "remote_audio" },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return parsed.toString();
|
|
409
|
+
}
|
|
410
|
+
function isInsideAllowedRoots(candidate, roots) {
|
|
411
|
+
return roots.some((root) => isContainedInRoot(root, candidate));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Whether the runtime still requires the file to sit under an allowed root.
|
|
415
|
+
* MEDIA_ALLOW_ANY_LOCAL_FILE=on (the installer's own choice) drops the containment
|
|
416
|
+
* requirement; every other check below still runs.
|
|
417
|
+
*/
|
|
418
|
+
function requiresAllowedRoot(cfg) {
|
|
419
|
+
return !cfg.allowAnyLocalFile;
|
|
420
|
+
}
|
|
421
|
+
async function authorizeLocalMedia(raw, cfg) {
|
|
422
|
+
const container = CONTAINER_BY_EXTENSION[extname(raw).toLowerCase()];
|
|
423
|
+
if (!isAbsolute(raw) || container === undefined) {
|
|
424
|
+
throw new MediaError({ code: "INVALID_MEDIA_INPUT", stage: "received" });
|
|
425
|
+
}
|
|
426
|
+
const rootGated = requiresAllowedRoot(cfg);
|
|
427
|
+
if (rootGated && cfg.allowedRoots.length === 0) {
|
|
428
|
+
throw new MediaError({ code: "MEDIA_PATH_NOT_ALLOWED", stage: "authorized" });
|
|
429
|
+
}
|
|
430
|
+
let requestedReal;
|
|
431
|
+
try {
|
|
432
|
+
requestedReal = await realpath(raw);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
throw new MediaError({ code: "MEDIA_NOT_FOUND", stage: "authorized" });
|
|
436
|
+
}
|
|
437
|
+
if (rootGated && !isInsideAllowedRoots(requestedReal, cfg.allowedRoots)) {
|
|
438
|
+
throw new MediaError({ code: "MEDIA_PATH_NOT_ALLOWED", stage: "authorized" });
|
|
439
|
+
}
|
|
440
|
+
let snapshot;
|
|
441
|
+
try {
|
|
442
|
+
snapshot = await stat(requestedReal);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
throw new MediaError({ code: "MEDIA_NOT_FOUND", stage: "authorized" });
|
|
446
|
+
}
|
|
447
|
+
if (!snapshot.isFile() || snapshot.size <= 0) {
|
|
448
|
+
throw new MediaError({ code: "UNSUPPORTED_MEDIA", stage: "authorized" });
|
|
449
|
+
}
|
|
450
|
+
if (snapshot.size > cfg.maxLocalMediaBytes) {
|
|
451
|
+
throw new MediaError({
|
|
452
|
+
code: "MEDIA_FILE_TOO_LARGE",
|
|
453
|
+
stage: "authorized",
|
|
454
|
+
diagnostic: { size_bytes: snapshot.size },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
let handle;
|
|
458
|
+
try {
|
|
459
|
+
handle = await open(requestedReal, "r");
|
|
460
|
+
const opened = await handle.stat();
|
|
461
|
+
if (!opened.isFile() || !sameIdentity(snapshot, opened)) {
|
|
462
|
+
throw new MediaError({ code: "MEDIA_NOT_FOUND", stage: "authorized" });
|
|
463
|
+
}
|
|
464
|
+
const recheckPath = await realpath(raw);
|
|
465
|
+
const recheckStat = await stat(recheckPath);
|
|
466
|
+
if (recheckPath !== requestedReal || !sameIdentity(snapshot, recheckStat)) {
|
|
467
|
+
throw new MediaError({ code: "MEDIA_NOT_FOUND", stage: "authorized" });
|
|
468
|
+
}
|
|
469
|
+
if (rootGated && !isInsideAllowedRoots(recheckPath, cfg.allowedRoots)) {
|
|
470
|
+
throw new MediaError({ code: "MEDIA_PATH_NOT_ALLOWED", stage: "authorized" });
|
|
471
|
+
}
|
|
472
|
+
const reader = asPositionedReader(handle);
|
|
473
|
+
const upload = CONTAINER_UPLOAD[container];
|
|
474
|
+
const shared = {
|
|
475
|
+
kind: "local",
|
|
476
|
+
handle,
|
|
477
|
+
sizeBytes: opened.size,
|
|
478
|
+
identityKey: uploadIdentityKey(requestedReal, opened.size, opened.mtimeMs),
|
|
479
|
+
container,
|
|
480
|
+
uploadName: upload.uploadName,
|
|
481
|
+
contentType: upload.contentType,
|
|
482
|
+
objectExtension: upload.objectExtension,
|
|
483
|
+
};
|
|
484
|
+
let durationSeconds;
|
|
485
|
+
let mediaKind;
|
|
486
|
+
let videoCodecs = [];
|
|
487
|
+
let audioCodecs = [];
|
|
488
|
+
let audioTrackCount;
|
|
489
|
+
let trackProbeComplete;
|
|
490
|
+
if (container === "mp3") {
|
|
491
|
+
const probe = await probeMpegAudio(reader, opened.size);
|
|
492
|
+
if (probe.status === "unsupported") {
|
|
493
|
+
throw new MediaError({
|
|
494
|
+
code: "UNSUPPORTED_MEDIA_CODEC",
|
|
495
|
+
stage: "authorized",
|
|
496
|
+
diagnostic: { codec: probe.codec, input_kind: container },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (probe.status === "container") {
|
|
500
|
+
throw new MediaError({
|
|
501
|
+
code: "UNSUPPORTED_MEDIA",
|
|
502
|
+
stage: "authorized",
|
|
503
|
+
diagnostic: { input_kind: "ftyp_container" },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
if (probe.status === "invalid") {
|
|
507
|
+
throw new MediaError({ code: "UNSUPPORTED_MEDIA", stage: "authorized" });
|
|
508
|
+
}
|
|
509
|
+
mediaKind = "audio";
|
|
510
|
+
durationSeconds = probe.facts.durationSeconds;
|
|
511
|
+
if (durationSeconds !== undefined && durationSeconds > MAX_LOCAL_MEDIA_DURATION_SECONDS) {
|
|
512
|
+
throw new MediaError({ code: "MEDIA_TOO_LONG", stage: "authorized" });
|
|
513
|
+
}
|
|
514
|
+
audioCodecs = [probe.facts.codec];
|
|
515
|
+
trackProbeComplete = true;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
519
|
+
const read = await handle.read(header, 0, HEADER_BYTES, 0);
|
|
520
|
+
if (read.bytesRead < 8 || !isMp4Ftyp(header.subarray(0, read.bytesRead))) {
|
|
521
|
+
throw new MediaError({ code: "UNSUPPORTED_MEDIA", stage: "authorized" });
|
|
522
|
+
}
|
|
523
|
+
const probed = await probeMp4Duration(reader, opened.size);
|
|
524
|
+
if (probed !== undefined &&
|
|
525
|
+
probed.duration > BigInt(MAX_LOCAL_MEDIA_DURATION_SECONDS) * probed.timescale) {
|
|
526
|
+
throw new MediaError({ code: "MEDIA_TOO_LONG", stage: "authorized" });
|
|
527
|
+
}
|
|
528
|
+
durationSeconds = durationSecondsFromProbe(probed);
|
|
529
|
+
const codecs = await probeTrackCodecsDetailed(reader, opened.size);
|
|
530
|
+
const badCodec = unsupportedCodec(codecs);
|
|
531
|
+
if (badCodec !== undefined) {
|
|
532
|
+
throw new MediaError({
|
|
533
|
+
code: "UNSUPPORTED_MEDIA_CODEC",
|
|
534
|
+
stage: "authorized",
|
|
535
|
+
diagnostic: { codec: badCodec, input_kind: container },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
mediaKind = "video";
|
|
539
|
+
videoCodecs = codecs.video;
|
|
540
|
+
audioCodecs = codecs.audio;
|
|
541
|
+
audioTrackCount = codecs.audioTrackCount;
|
|
542
|
+
trackProbeComplete = codecs.complete;
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
...shared,
|
|
546
|
+
contentFingerprint: await contentFingerprint(handle, opened.size),
|
|
547
|
+
mediaKind,
|
|
548
|
+
durationSeconds,
|
|
549
|
+
videoCodecs,
|
|
550
|
+
audioCodecs,
|
|
551
|
+
audioTrackCount,
|
|
552
|
+
trackProbeComplete,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
if (handle !== undefined) {
|
|
557
|
+
await handle.close().catch(() => undefined);
|
|
558
|
+
}
|
|
559
|
+
if (err instanceof MediaError) {
|
|
560
|
+
throw err;
|
|
561
|
+
}
|
|
562
|
+
throw new MediaError({ code: "MEDIA_NOT_FOUND", stage: "authorized" });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Classify and authorize a media input. HTTPS video URLs are not fetched.
|
|
567
|
+
* Local MP4/MOV/MP3 files require MEDIA_ALLOWED_ROOTS unless
|
|
568
|
+
* MEDIA_ALLOW_ANY_LOCAL_FILE=on, and are returned as a FileHandle with
|
|
569
|
+
* container-specific upload metadata.
|
|
570
|
+
* The caller owns the handle and must close it.
|
|
571
|
+
*/
|
|
572
|
+
export async function resolveMedia(raw, cfg) {
|
|
573
|
+
const trimmed = raw.trim();
|
|
574
|
+
if (trimmed.length === 0) {
|
|
575
|
+
throw new MediaError({ code: "INVALID_MEDIA_INPUT", stage: "received" });
|
|
576
|
+
}
|
|
577
|
+
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) {
|
|
578
|
+
return authorizeLocalMedia(trimmed, cfg);
|
|
579
|
+
}
|
|
580
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
581
|
+
return { kind: "https", url: parseHttpsVideoUrl(trimmed), mediaKind: "video" };
|
|
582
|
+
}
|
|
583
|
+
return authorizeLocalMedia(trimmed, cfg);
|
|
584
|
+
}
|
|
585
|
+
//# sourceMappingURL=media.js.map
|