@ttt-productions/media-processing-core 0.8.3 → 0.8.5

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 CHANGED
@@ -8,6 +8,7 @@ export * from "./audio/index.js";
8
8
  export * from "./audio/audio-processor.js";
9
9
  export * from "./io/types.js";
10
10
  export * from "./run-pipeline.js";
11
+ export * from "./inspection/inspect-media.js";
11
12
  export * from "./io/fs.js";
12
13
  export * from "./moderation/types.js";
13
14
  export * from "./moderation/merge.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,YAAY,CAAC;AAE3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,+BAA+B,CAAC;AAE9C,cAAc,YAAY,CAAC;AAE3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./audio/index.js"; // Not ./audio.js
8
8
  export * from "./audio/audio-processor.js";
9
9
  export * from "./io/types.js";
10
10
  export * from "./run-pipeline.js";
11
+ export * from "./inspection/inspect-media.js";
11
12
  export * from "./io/fs.js";
12
13
  export * from "./moderation/types.js";
13
14
  export * from "./moderation/merge.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,YAAY,CAAC;AAE3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC","sourcesContent":["export * from \"./image/index.js\"; // Not ./image.js\nexport * from \"./image/image-processor.js\";\n\nexport * from \"./video/index.js\"; // Not ./video.js\nexport * from \"./video/video-processor.js\";\n\nexport * from \"./process-media.js\";\nexport * from \"./workspace/temp.js\";\n\nexport * from \"./audio/index.js\"; // Not ./audio.js\nexport * from \"./audio/audio-processor.js\";\n\nexport * from \"./io/types.js\";\nexport * from \"./run-pipeline.js\";\n\nexport * from \"./io/fs.js\";\n\nexport * from \"./moderation/types.js\";\nexport * from \"./moderation/merge.js\";"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,kBAAkB,CAAC,CAAW,iBAAiB;AAC7D,cAAc,4BAA4B,CAAC;AAE3C,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,+BAA+B,CAAC;AAE9C,cAAc,YAAY,CAAC;AAE3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC","sourcesContent":["export * from \"./image/index.js\"; // Not ./image.js\nexport * from \"./image/image-processor.js\";\n\nexport * from \"./video/index.js\"; // Not ./video.js\nexport * from \"./video/video-processor.js\";\n\nexport * from \"./process-media.js\";\nexport * from \"./workspace/temp.js\";\n\nexport * from \"./audio/index.js\"; // Not ./audio.js\nexport * from \"./audio/audio-processor.js\";\n\nexport * from \"./io/types.js\";\nexport * from \"./run-pipeline.js\";\n\nexport * from \"./inspection/inspect-media.js\";\n\nexport * from \"./io/fs.js\";\n\nexport * from \"./moderation/types.js\";\nexport * from \"./moderation/merge.js\";"]}
@@ -0,0 +1,57 @@
1
+ import { type MediaInspectionResult, type MediaFormatId } from "@ttt-productions/media-schemas";
2
+ /** Bump on any classification-rule change (rides every decision event). */
3
+ export declare const INSPECTOR_VERSION = "mpc-inspect-1";
4
+ type SignatureFamily = {
5
+ family: "image";
6
+ formatId: MediaFormatId;
7
+ container: string;
8
+ } | {
9
+ family: "svg";
10
+ } | {
11
+ family: "ebml";
12
+ } | {
13
+ family: "isobmff";
14
+ brand: string;
15
+ } | {
16
+ family: "ogg";
17
+ } | {
18
+ family: "avi";
19
+ } | {
20
+ family: "wav";
21
+ } | {
22
+ family: "mp3";
23
+ } | {
24
+ family: "flac";
25
+ } | {
26
+ family: "unknown";
27
+ };
28
+ export declare function detectSignatureFamily(b: Uint8Array): SignatureFamily;
29
+ export interface InspectMediaDeps {
30
+ /** Probe the local file; default shells to the hardened `ffprobe`. */
31
+ probe?: (localPath: string, opts: {
32
+ timeoutMs: number;
33
+ signal?: AbortSignal;
34
+ }) => Promise<{
35
+ ok: true;
36
+ json: unknown;
37
+ } | {
38
+ ok: false;
39
+ reason: "timeout" | "truncated" | "failed" | "bad_json";
40
+ }>;
41
+ /** Decode-prove an image; default uses sharp metadata. Returns frame count
42
+ * (pages) on success, null on decode failure. */
43
+ decodeImage?: (localPath: string) => Promise<{
44
+ frames: number;
45
+ } | null>;
46
+ /** Injected ffprobe version (default: cached `ffprobe -version` first line). */
47
+ ffprobeVersion?: string;
48
+ }
49
+ export interface InspectMediaArgs {
50
+ localPath: string;
51
+ timeoutMs?: number;
52
+ signal?: AbortSignal;
53
+ deps?: InspectMediaDeps;
54
+ }
55
+ export declare function inspectMedia(args: InspectMediaArgs): Promise<MediaInspectionResult>;
56
+ export {};
57
+ //# sourceMappingURL=inspect-media.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect-media.d.ts","sourceRoot":"","sources":["../../src/inspection/inspect-media.ts"],"names":[],"mappings":"AA8BA,OAAO,EAEL,KAAK,qBAAqB,EAG1B,KAAK,aAAa,EACnB,MAAM,gCAAgC,CAAC;AAGxC,2EAA2E;AAC3E,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AA2DjD,KAAK,eAAe,GAChB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,aAAa,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAAC;AAY1B,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,UAAU,GAAG,eAAe,CAiCpE;AAID,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CACrF;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,GAC3B;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,CAAA;KAAE,CACzE,CAAC;IACF;sDACkD;IAClD,WAAW,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACxE,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAyGD,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA0HzF"}
@@ -0,0 +1,412 @@
1
+ // =============================================================================
2
+ // Canonical media content inspection (canonical-upload-content-classification)
3
+ // =============================================================================
4
+ //
5
+ // The ONE server-side classification authority. Consumes a LOCAL file (the
6
+ // pipeline's generation-pinned temp download), establishes what the bytes
7
+ // actually contain, and returns the bounded `MediaInspectionResult` contract
8
+ // from @ttt-productions/media-schemas. Everything downstream — origin/kind
9
+ // validation, processing-spec selection, the persisted asset kind, and the
10
+ // safety (PhotoDNA) routing — derives from this one result. Client MIME,
11
+ // filename, extension, container brand, and payload-byte heuristics are never
12
+ // consulted.
13
+ //
14
+ // Design (reviewed 2026-07-31; see the consuming app's
15
+ // CODE_CHANGE_canonical_upload_content_classification.md):
16
+ // - family selection by a bounded 64 KiB SIGNATURE read (header only —
17
+ // semantic elements are NEVER searched for in arbitrary payload; that
18
+ // flat-scan heuristic caused the WebM audio misclassification twice);
19
+ // - shared A/V containers (EBML/ISO-BMFF/Ogg/AVI) classified by a hardened
20
+ // `ffprobe` stream table (libavformat's structural parsers), with timeout,
21
+ // kill-on-abort, output cap, and strict schema validation;
22
+ // - image-family proof by DECODER (sharp) — a signature or `ftyp` brand is a
23
+ // candidate, not proof, and image-family ISO-BMFF (AVIF/HEIC) is resolved
24
+ // BEFORE the ordinary "any timed video stream wins" rule (a valid AVIF
25
+ // probes as a one-frame AV1 `video` stream);
26
+ // - FAIL-CLOSED: anything unproven is `indeterminate` with the
27
+ // `strict-video-fallback` safety plan — never audio, never publishable.
28
+ import { open } from "node:fs/promises";
29
+ import { z } from "zod";
30
+ import { MediaInspectionResultSchema, } from "@ttt-productions/media-schemas";
31
+ import { runCmd } from "../video/ffmpeg.js";
32
+ /** Bump on any classification-rule change (rides every decision event). */
33
+ export const INSPECTOR_VERSION = "mpc-inspect-1";
34
+ const SIGNATURE_READ_BYTES = 64 * 1024;
35
+ const DEFAULT_PROBE_TIMEOUT_MS = 20_000;
36
+ const PROBE_SIZE_BYTES = 4 * 1024 * 1024; // -probesize: metadata, not playback
37
+ const ANALYZE_DURATION_US = 10 * 1000 * 1000;
38
+ // ---- ffprobe JSON boundary (strict; anything else is malformed) ----
39
+ const ProbeStreamSchema = z
40
+ .object({
41
+ codec_type: z.string().max(32).optional(),
42
+ codec_name: z.string().max(64).optional(),
43
+ disposition: z
44
+ .object({ attached_pic: z.number().int().min(0).max(1).optional() })
45
+ .loose()
46
+ .optional(),
47
+ nb_frames: z.string().max(20).optional(),
48
+ duration: z.string().max(32).optional(),
49
+ })
50
+ .loose();
51
+ const ProbeJsonSchema = z
52
+ .object({
53
+ format: z.object({ format_name: z.string().max(200).optional() }).loose().optional(),
54
+ streams: z.array(ProbeStreamSchema).max(256).optional(),
55
+ })
56
+ .loose();
57
+ // ---- codec normalization (bounded — raw strings never escape) ----
58
+ const CODEC_MAP = {
59
+ h264: "h264",
60
+ hevc: "hevc",
61
+ vp8: "vp8",
62
+ vp9: "vp9",
63
+ av1: "av1",
64
+ theora: "theora",
65
+ mpeg4: "mpeg4",
66
+ mjpeg: "mjpeg",
67
+ aac: "aac",
68
+ alac: "alac",
69
+ opus: "opus",
70
+ vorbis: "vorbis",
71
+ mp3: "mp3",
72
+ flac: "flac",
73
+ };
74
+ function normalizeCodec(name) {
75
+ if (!name)
76
+ return "other";
77
+ const n = name.toLowerCase();
78
+ if (CODEC_MAP[n])
79
+ return CODEC_MAP[n];
80
+ if (n.startsWith("pcm_"))
81
+ return "pcm";
82
+ if (n.startsWith("amr"))
83
+ return "amr";
84
+ return "other";
85
+ }
86
+ /** ISO-BMFF brands that declare an IMAGE-item file (HEIF family). Resolved via
87
+ * decoder proof BEFORE the timed-video rule — see the AVIF note above. */
88
+ const ISOBMFF_IMAGE_BRANDS = new Set(["avif", "avis", "heic", "heix", "heim", "heis", "hevc", "hevx", "mif1", "msf1"]);
89
+ function ascii(b, off, s) {
90
+ if (off + s.length > b.length)
91
+ return false;
92
+ for (let i = 0; i < s.length; i++)
93
+ if (b[off + i] !== s.charCodeAt(i))
94
+ return false;
95
+ return true;
96
+ }
97
+ export function detectSignatureFamily(b) {
98
+ if (b.length < 12)
99
+ return { family: "unknown" };
100
+ // images (single-format signatures)
101
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
102
+ return { family: "image", formatId: "jpeg", container: "jpeg" };
103
+ if (b[0] === 0x89 && ascii(b, 1, "PNG"))
104
+ return { family: "image", formatId: "png", container: "png" };
105
+ if (ascii(b, 0, "GIF8"))
106
+ return { family: "image", formatId: "gif", container: "gif" };
107
+ if (ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP"))
108
+ return { family: "image", formatId: "webp", container: "webp" };
109
+ if (b[0] === 0x42 && b[1] === 0x4d)
110
+ return { family: "image", formatId: "bmp", container: "bmp" };
111
+ if ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) || (b[0] === 0x4d && b[1] === 0x4d && b[3] === 0x2a)) {
112
+ return { family: "image", formatId: "tiff", container: "tiff" };
113
+ }
114
+ // SVG: XML text — scan a short prefix for '<svg' (bounded; case-insensitive).
115
+ {
116
+ const prefix = Buffer.from(b.slice(0, Math.min(b.length, 4096)))
117
+ .toString("latin1")
118
+ .toLowerCase();
119
+ if (prefix.includes("<svg") && (prefix.trimStart().startsWith("<?xml") || prefix.trimStart().startsWith("<svg") || prefix.includes("<!doctype svg"))) {
120
+ return { family: "svg" };
121
+ }
122
+ }
123
+ // containers
124
+ if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
125
+ return { family: "ebml" };
126
+ if (ascii(b, 4, "ftyp")) {
127
+ const brand = String.fromCharCode(b[8], b[9], b[10], b[11]).trim().toLowerCase();
128
+ return { family: "isobmff", brand };
129
+ }
130
+ if (ascii(b, 0, "OggS"))
131
+ return { family: "ogg" };
132
+ if (ascii(b, 0, "RIFF") && ascii(b, 8, "AVI "))
133
+ return { family: "avi" };
134
+ if (ascii(b, 0, "RIFF") && ascii(b, 8, "WAVE"))
135
+ return { family: "wav" };
136
+ if (ascii(b, 0, "ID3"))
137
+ return { family: "mp3" };
138
+ if (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)
139
+ return { family: "mp3" };
140
+ if (ascii(b, 0, "fLaC"))
141
+ return { family: "flac" };
142
+ return { family: "unknown" };
143
+ }
144
+ let cachedFfprobeVersion;
145
+ async function getFfprobeVersion() {
146
+ if (cachedFfprobeVersion !== undefined)
147
+ return cachedFfprobeVersion ?? undefined;
148
+ try {
149
+ const r = await runCmd("ffprobe", ["-version"], { timeoutMs: 5_000 });
150
+ const first = (r.stdout || r.stderr).split(/\r?\n/)[0]?.trim() ?? "";
151
+ cachedFfprobeVersion = first ? first.slice(0, 120) : null;
152
+ }
153
+ catch {
154
+ cachedFfprobeVersion = null;
155
+ }
156
+ return cachedFfprobeVersion ?? undefined;
157
+ }
158
+ async function defaultProbe(localPath, opts) {
159
+ let r;
160
+ try {
161
+ r = await runCmd("ffprobe", [
162
+ "-v",
163
+ "error",
164
+ "-print_format",
165
+ "json",
166
+ "-show_format",
167
+ "-show_streams",
168
+ "-probesize",
169
+ String(PROBE_SIZE_BYTES),
170
+ "-analyzeduration",
171
+ String(ANALYZE_DURATION_US),
172
+ localPath,
173
+ ], { timeoutMs: opts.timeoutMs, signal: opts.signal });
174
+ }
175
+ catch {
176
+ return { ok: false, reason: "failed" }; // spawn failure (ffprobe missing) — fail closed
177
+ }
178
+ if (r.timedOut)
179
+ return { ok: false, reason: "timeout" };
180
+ if (r.truncated)
181
+ return { ok: false, reason: "truncated" };
182
+ if (r.code !== 0)
183
+ return { ok: false, reason: "failed" };
184
+ try {
185
+ return { ok: true, json: JSON.parse(r.stdout || "{}") };
186
+ }
187
+ catch {
188
+ return { ok: false, reason: "bad_json" };
189
+ }
190
+ }
191
+ async function defaultDecodeImage(localPath) {
192
+ try {
193
+ const sharp = (await import("sharp")).default;
194
+ const meta = await sharp(localPath, { limitInputPixels: 268402689 /* sharp default ~16k x 16k */ }).metadata();
195
+ if (!meta.width || !meta.height)
196
+ return null;
197
+ return { frames: Math.max(1, meta.pages ?? 1) };
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }
203
+ // ---- result builders ----
204
+ function baseResult(partial, ffprobeVersion) {
205
+ const result = {
206
+ inspectorVersion: INSPECTOR_VERSION,
207
+ ...(ffprobeVersion ? { ffprobeVersion } : {}),
208
+ streams: partial.streams ?? { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: 0, auxiliary: 0 },
209
+ codecs: partial.codecs ?? { audio: [], video: [] },
210
+ status: partial.status,
211
+ container: partial.container,
212
+ safetyPlan: partial.safetyPlan,
213
+ reasonCode: partial.reasonCode,
214
+ ...(partial.canonicalKind ? { canonicalKind: partial.canonicalKind } : {}),
215
+ ...(partial.formatId ? { formatId: partial.formatId } : {}),
216
+ };
217
+ return MediaInspectionResultSchema.parse(result);
218
+ }
219
+ function indeterminate(container, reasonCode, ffprobeVersion, formatId) {
220
+ return baseResult({
221
+ status: "indeterminate",
222
+ container,
223
+ safetyPlan: "strict-video-fallback",
224
+ reasonCode,
225
+ ...(formatId ? { formatId } : {}),
226
+ }, ffprobeVersion);
227
+ }
228
+ export async function inspectMedia(args) {
229
+ const timeoutMs = args.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
230
+ const probe = args.deps?.probe ?? defaultProbe;
231
+ const decodeImage = args.deps?.decodeImage ?? defaultDecodeImage;
232
+ const ffprobeVersion = args.deps?.ffprobeVersion ?? (await getFfprobeVersion());
233
+ // 1) bounded signature read (header only)
234
+ let head;
235
+ try {
236
+ const fh = await open(args.localPath, "r");
237
+ try {
238
+ const buf = Buffer.alloc(SIGNATURE_READ_BYTES);
239
+ const { bytesRead } = await fh.read(buf, 0, SIGNATURE_READ_BYTES, 0);
240
+ head = buf.subarray(0, bytesRead);
241
+ }
242
+ finally {
243
+ await fh.close();
244
+ }
245
+ }
246
+ catch {
247
+ return indeterminate("unknown", "read_failed", ffprobeVersion);
248
+ }
249
+ const sig = detectSignatureFamily(head);
250
+ // 2) family routing
251
+ switch (sig.family) {
252
+ case "unknown":
253
+ return indeterminate("unknown", "unrecognized_signature", ffprobeVersion);
254
+ case "svg":
255
+ // Scriptable XML "image" — structurally recognized, deliberately NOT a
256
+ // processable format until a sanitize/rasterize design exists (launch
257
+ // policy 2026-07-31). Never classified by decoder capability alone.
258
+ return baseResult({
259
+ status: "unsupported",
260
+ container: "svg",
261
+ formatId: "svg",
262
+ safetyPlan: "strict-video-fallback",
263
+ reasonCode: "unsupported_format:svg",
264
+ }, ffprobeVersion);
265
+ case "image": {
266
+ // Single-format image signature: decoder proof required — a signature is
267
+ // a candidate, not a classification.
268
+ const decoded = await decodeImage(args.localPath);
269
+ if (!decoded)
270
+ return baseResult({ status: "malformed", container: sig.container, formatId: sig.formatId, safetyPlan: "strict-video-fallback", reasonCode: "image_decode_failed" }, ffprobeVersion);
271
+ const frames = decoded.frames;
272
+ return baseResult({
273
+ status: "definitive",
274
+ canonicalKind: "image",
275
+ container: sig.container,
276
+ formatId: sig.formatId,
277
+ streams: { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: frames, auxiliary: 0 },
278
+ safetyPlan: frames > 1 ? "animated-image-frames" : "still-image",
279
+ reasonCode: "ok",
280
+ }, ffprobeVersion);
281
+ }
282
+ case "isobmff":
283
+ if (ISOBMFF_IMAGE_BRANDS.has(sig.brand)) {
284
+ // IMAGE-FAMILY ISO-BMFF resolves BEFORE the timed-video rule: a valid
285
+ // AVIF probes as a one-frame AV1 `video` stream. Proof = image brand +
286
+ // successful bounded decode + no ordinary timed A/V presentation.
287
+ const probed = await probe(args.localPath, { timeoutMs, signal: args.signal });
288
+ const parsed = probed.ok ? ProbeJsonSchema.safeParse(probed.json) : null;
289
+ const streams = parsed?.success ? parsed.data.streams ?? [] : null;
290
+ const hasAudio = streams ? streams.some((s) => s.codec_type === "audio") : true; // unknown ⇒ assume worst
291
+ const formatId = sig.brand.startsWith("avi") ? "avif" : "heic";
292
+ const decoded = await decodeImage(args.localPath);
293
+ if (!decoded || hasAudio) {
294
+ if (formatId === "heic" && !decoded && !hasAudio) {
295
+ // Recognized HEIC-family that the runtime cannot decode: explicit
296
+ // unsupported-image (launch policy) — NEVER relabeled video.
297
+ return baseResult({ status: "unsupported", container: "heif", formatId, safetyPlan: "strict-video-fallback", reasonCode: "unsupported_format:heic" }, ffprobeVersion);
298
+ }
299
+ // Undecodable/ambiguous image-brand file, or one carrying audio (a
300
+ // real timed presentation wearing an image brand): fail toward the
301
+ // stricter visual path.
302
+ return indeterminate("heif", decoded ? "image_brand_with_audio" : "image_decode_failed", ffprobeVersion, formatId);
303
+ }
304
+ const frames = decoded.frames;
305
+ return baseResult({
306
+ status: "definitive",
307
+ canonicalKind: "image",
308
+ container: "heif",
309
+ formatId,
310
+ streams: { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: frames, auxiliary: 0 },
311
+ safetyPlan: frames > 1 ? "animated-image-frames" : "still-image",
312
+ reasonCode: "ok",
313
+ }, ffprobeVersion);
314
+ }
315
+ return classifyAvContainer(args, "isobmff", "isobmff", probe, timeoutMs, ffprobeVersion);
316
+ case "ebml":
317
+ return classifyAvContainer(args, "webm", "webm", probe, timeoutMs, ffprobeVersion);
318
+ case "ogg":
319
+ return classifyAvContainer(args, "ogg", "ogg", probe, timeoutMs, ffprobeVersion);
320
+ case "avi":
321
+ // Real container ffprobe parses; no registry entry (no origin enables it
322
+ // today) — the stream truth still classifies it, policy rejects by format.
323
+ return classifyAvContainer(args, "avi", undefined, probe, timeoutMs, ffprobeVersion);
324
+ case "wav":
325
+ return classifyAvContainer(args, "wav", "wav", probe, timeoutMs, ffprobeVersion);
326
+ case "mp3":
327
+ return classifyAvContainer(args, "mp3", "mp3", probe, timeoutMs, ffprobeVersion);
328
+ case "flac":
329
+ return classifyAvContainer(args, "flac", "flac", probe, timeoutMs, ffprobeVersion);
330
+ }
331
+ }
332
+ async function classifyAvContainer(args, container, formatId, probe, timeoutMs, ffprobeVersion) {
333
+ const probed = await probe(args.localPath, { timeoutMs, signal: args.signal });
334
+ if (!probed.ok) {
335
+ return indeterminate(container, `probe_${probed.reason}`, ffprobeVersion, formatId);
336
+ }
337
+ const parsed = ProbeJsonSchema.safeParse(probed.json);
338
+ if (!parsed.success)
339
+ return indeterminate(container, "probe_schema_invalid", ffprobeVersion, formatId);
340
+ const rawStreams = parsed.data.streams ?? [];
341
+ if (rawStreams.length === 0)
342
+ return indeterminate(container, "no_streams", ffprobeVersion, formatId);
343
+ const counts = { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: 0, auxiliary: 0 };
344
+ const audioCodecs = new Set();
345
+ const videoCodecs = new Set();
346
+ let sawUnknownType = false;
347
+ for (const s of rawStreams) {
348
+ const type = s.codec_type ?? "";
349
+ if (type === "audio") {
350
+ counts.audio++;
351
+ audioCodecs.add(normalizeCodec(s.codec_name));
352
+ }
353
+ else if (type === "video") {
354
+ // An attached picture (cover art) is NOT a timed presentation — but the
355
+ // disposition flag is untrusted container data, so it is COUNTED and the
356
+ // safety plan forces every such picture through the image hash path
357
+ // before the audio guard may return clean.
358
+ if (s.disposition?.attached_pic === 1) {
359
+ counts.attachedPictures++;
360
+ }
361
+ else {
362
+ counts.timedVideo++;
363
+ videoCodecs.add(normalizeCodec(s.codec_name));
364
+ }
365
+ }
366
+ else if (type === "subtitle" || type === "data" || type === "attachment") {
367
+ counts.auxiliary++;
368
+ }
369
+ else {
370
+ counts.auxiliary++;
371
+ sawUnknownType = true;
372
+ }
373
+ }
374
+ const codecs = { audio: [...audioCodecs], video: [...videoCodecs] };
375
+ // classification table (fail-closed)
376
+ if (counts.timedVideo > 0) {
377
+ return baseResult({
378
+ status: "definitive",
379
+ canonicalKind: "video",
380
+ container,
381
+ ...(formatId ? { formatId } : {}),
382
+ streams: counts,
383
+ codecs,
384
+ safetyPlan: "video-frames",
385
+ reasonCode: "ok",
386
+ }, ffprobeVersion);
387
+ }
388
+ if (counts.audio > 0 && !sawUnknownType) {
389
+ return baseResult({
390
+ status: "definitive",
391
+ canonicalKind: "audio",
392
+ container,
393
+ ...(formatId ? { formatId } : {}),
394
+ streams: counts,
395
+ codecs,
396
+ safetyPlan: counts.attachedPictures > 0 ? "audio-plus-artwork" : "audio-only",
397
+ reasonCode: "ok",
398
+ }, ffprobeVersion);
399
+ }
400
+ // auxiliary-only, unknown stream types alongside audio, or nothing usable:
401
+ // never audio — the strict visual fallback owns it.
402
+ return baseResult({
403
+ status: "indeterminate",
404
+ container,
405
+ ...(formatId ? { formatId } : {}),
406
+ streams: counts,
407
+ codecs,
408
+ safetyPlan: "strict-video-fallback",
409
+ reasonCode: sawUnknownType ? "unknown_stream_type" : "auxiliary_only",
410
+ }, ffprobeVersion);
411
+ }
412
+ //# sourceMappingURL=inspect-media.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect-media.js","sourceRoot":"","sources":["../../src/inspection/inspect-media.ts"],"names":[],"mappings":"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","sourcesContent":["// =============================================================================\n// Canonical media content inspection (canonical-upload-content-classification)\n// =============================================================================\n//\n// The ONE server-side classification authority. Consumes a LOCAL file (the\n// pipeline's generation-pinned temp download), establishes what the bytes\n// actually contain, and returns the bounded `MediaInspectionResult` contract\n// from @ttt-productions/media-schemas. Everything downstream — origin/kind\n// validation, processing-spec selection, the persisted asset kind, and the\n// safety (PhotoDNA) routing — derives from this one result. Client MIME,\n// filename, extension, container brand, and payload-byte heuristics are never\n// consulted.\n//\n// Design (reviewed 2026-07-31; see the consuming app's\n// CODE_CHANGE_canonical_upload_content_classification.md):\n// - family selection by a bounded 64 KiB SIGNATURE read (header only —\n// semantic elements are NEVER searched for in arbitrary payload; that\n// flat-scan heuristic caused the WebM audio misclassification twice);\n// - shared A/V containers (EBML/ISO-BMFF/Ogg/AVI) classified by a hardened\n// `ffprobe` stream table (libavformat's structural parsers), with timeout,\n// kill-on-abort, output cap, and strict schema validation;\n// - image-family proof by DECODER (sharp) — a signature or `ftyp` brand is a\n// candidate, not proof, and image-family ISO-BMFF (AVIF/HEIC) is resolved\n// BEFORE the ordinary \"any timed video stream wins\" rule (a valid AVIF\n// probes as a one-frame AV1 `video` stream);\n// - FAIL-CLOSED: anything unproven is `indeterminate` with the\n// `strict-video-fallback` safety plan — never audio, never publishable.\n\nimport { open } from \"node:fs/promises\";\nimport { z } from \"zod\";\nimport {\n MediaInspectionResultSchema,\n type MediaInspectionResult,\n type MediaInspectionStreams,\n type NormalizedCodecId,\n type MediaFormatId,\n} from \"@ttt-productions/media-schemas\";\nimport { runCmd } from \"../video/ffmpeg.js\";\n\n/** Bump on any classification-rule change (rides every decision event). */\nexport const INSPECTOR_VERSION = \"mpc-inspect-1\";\n\nconst SIGNATURE_READ_BYTES = 64 * 1024;\nconst DEFAULT_PROBE_TIMEOUT_MS = 20_000;\nconst PROBE_SIZE_BYTES = 4 * 1024 * 1024; // -probesize: metadata, not playback\nconst ANALYZE_DURATION_US = 10 * 1000 * 1000;\n\n// ---- ffprobe JSON boundary (strict; anything else is malformed) ----\n\nconst ProbeStreamSchema = z\n .object({\n codec_type: z.string().max(32).optional(),\n codec_name: z.string().max(64).optional(),\n disposition: z\n .object({ attached_pic: z.number().int().min(0).max(1).optional() })\n .loose()\n .optional(),\n nb_frames: z.string().max(20).optional(),\n duration: z.string().max(32).optional(),\n })\n .loose();\n\nconst ProbeJsonSchema = z\n .object({\n format: z.object({ format_name: z.string().max(200).optional() }).loose().optional(),\n streams: z.array(ProbeStreamSchema).max(256).optional(),\n })\n .loose();\n\n// ---- codec normalization (bounded — raw strings never escape) ----\n\nconst CODEC_MAP: Record<string, NormalizedCodecId> = {\n h264: \"h264\",\n hevc: \"hevc\",\n vp8: \"vp8\",\n vp9: \"vp9\",\n av1: \"av1\",\n theora: \"theora\",\n mpeg4: \"mpeg4\",\n mjpeg: \"mjpeg\",\n aac: \"aac\",\n alac: \"alac\",\n opus: \"opus\",\n vorbis: \"vorbis\",\n mp3: \"mp3\",\n flac: \"flac\",\n};\n\nfunction normalizeCodec(name: string | undefined): NormalizedCodecId {\n if (!name) return \"other\";\n const n = name.toLowerCase();\n if (CODEC_MAP[n]) return CODEC_MAP[n];\n if (n.startsWith(\"pcm_\")) return \"pcm\";\n if (n.startsWith(\"amr\")) return \"amr\";\n return \"other\";\n}\n\n// ---- signature families ----\n\ntype SignatureFamily =\n | { family: \"image\"; formatId: MediaFormatId; container: string }\n | { family: \"svg\" }\n | { family: \"ebml\" }\n | { family: \"isobmff\"; brand: string }\n | { family: \"ogg\" }\n | { family: \"avi\" }\n | { family: \"wav\" }\n | { family: \"mp3\" }\n | { family: \"flac\" }\n | { family: \"unknown\" };\n\n/** ISO-BMFF brands that declare an IMAGE-item file (HEIF family). Resolved via\n * decoder proof BEFORE the timed-video rule — see the AVIF note above. */\nconst ISOBMFF_IMAGE_BRANDS = new Set([\"avif\", \"avis\", \"heic\", \"heix\", \"heim\", \"heis\", \"hevc\", \"hevx\", \"mif1\", \"msf1\"]);\n\nfunction ascii(b: Uint8Array, off: number, s: string): boolean {\n if (off + s.length > b.length) return false;\n for (let i = 0; i < s.length; i++) if (b[off + i] !== s.charCodeAt(i)) return false;\n return true;\n}\n\nexport function detectSignatureFamily(b: Uint8Array): SignatureFamily {\n if (b.length < 12) return { family: \"unknown\" };\n // images (single-format signatures)\n if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return { family: \"image\", formatId: \"jpeg\", container: \"jpeg\" };\n if (b[0] === 0x89 && ascii(b, 1, \"PNG\")) return { family: \"image\", formatId: \"png\", container: \"png\" };\n if (ascii(b, 0, \"GIF8\")) return { family: \"image\", formatId: \"gif\", container: \"gif\" };\n if (ascii(b, 0, \"RIFF\") && ascii(b, 8, \"WEBP\")) return { family: \"image\", formatId: \"webp\", container: \"webp\" };\n if (b[0] === 0x42 && b[1] === 0x4d) return { family: \"image\", formatId: \"bmp\", container: \"bmp\" };\n if ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) || (b[0] === 0x4d && b[1] === 0x4d && b[3] === 0x2a)) {\n return { family: \"image\", formatId: \"tiff\", container: \"tiff\" };\n }\n // SVG: XML text — scan a short prefix for '<svg' (bounded; case-insensitive).\n {\n const prefix = Buffer.from(b.slice(0, Math.min(b.length, 4096)))\n .toString(\"latin1\")\n .toLowerCase();\n if (prefix.includes(\"<svg\") && (prefix.trimStart().startsWith(\"<?xml\") || prefix.trimStart().startsWith(\"<svg\") || prefix.includes(\"<!doctype svg\"))) {\n return { family: \"svg\" };\n }\n }\n // containers\n if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return { family: \"ebml\" };\n if (ascii(b, 4, \"ftyp\")) {\n const brand = String.fromCharCode(b[8], b[9], b[10], b[11]).trim().toLowerCase();\n return { family: \"isobmff\", brand };\n }\n if (ascii(b, 0, \"OggS\")) return { family: \"ogg\" };\n if (ascii(b, 0, \"RIFF\") && ascii(b, 8, \"AVI \")) return { family: \"avi\" };\n if (ascii(b, 0, \"RIFF\") && ascii(b, 8, \"WAVE\")) return { family: \"wav\" };\n if (ascii(b, 0, \"ID3\")) return { family: \"mp3\" };\n if (b[0] === 0xff && (b[1] & 0xe0) === 0xe0) return { family: \"mp3\" };\n if (ascii(b, 0, \"fLaC\")) return { family: \"flac\" };\n return { family: \"unknown\" };\n}\n\n// ---- dependency seams (tests inject; production uses the real tools) ----\n\nexport interface InspectMediaDeps {\n /** Probe the local file; default shells to the hardened `ffprobe`. */\n probe?: (localPath: string, opts: { timeoutMs: number; signal?: AbortSignal }) => Promise<\n | { ok: true; json: unknown }\n | { ok: false; reason: \"timeout\" | \"truncated\" | \"failed\" | \"bad_json\" }\n >;\n /** Decode-prove an image; default uses sharp metadata. Returns frame count\n * (pages) on success, null on decode failure. */\n decodeImage?: (localPath: string) => Promise<{ frames: number } | null>;\n /** Injected ffprobe version (default: cached `ffprobe -version` first line). */\n ffprobeVersion?: string;\n}\n\nlet cachedFfprobeVersion: string | null | undefined;\n\nasync function getFfprobeVersion(): Promise<string | undefined> {\n if (cachedFfprobeVersion !== undefined) return cachedFfprobeVersion ?? undefined;\n try {\n const r = await runCmd(\"ffprobe\", [\"-version\"], { timeoutMs: 5_000 });\n const first = (r.stdout || r.stderr).split(/\\r?\\n/)[0]?.trim() ?? \"\";\n cachedFfprobeVersion = first ? first.slice(0, 120) : null;\n } catch {\n cachedFfprobeVersion = null;\n }\n return cachedFfprobeVersion ?? undefined;\n}\n\nasync function defaultProbe(\n localPath: string,\n opts: { timeoutMs: number; signal?: AbortSignal },\n): Promise<{ ok: true; json: unknown } | { ok: false; reason: \"timeout\" | \"truncated\" | \"failed\" | \"bad_json\" }> {\n let r;\n try {\n r = await runCmd(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-print_format\",\n \"json\",\n \"-show_format\",\n \"-show_streams\",\n \"-probesize\",\n String(PROBE_SIZE_BYTES),\n \"-analyzeduration\",\n String(ANALYZE_DURATION_US),\n localPath,\n ],\n { timeoutMs: opts.timeoutMs, signal: opts.signal },\n );\n } catch {\n return { ok: false, reason: \"failed\" }; // spawn failure (ffprobe missing) — fail closed\n }\n if (r.timedOut) return { ok: false, reason: \"timeout\" };\n if (r.truncated) return { ok: false, reason: \"truncated\" };\n if (r.code !== 0) return { ok: false, reason: \"failed\" };\n try {\n return { ok: true, json: JSON.parse(r.stdout || \"{}\") };\n } catch {\n return { ok: false, reason: \"bad_json\" };\n }\n}\n\nasync function defaultDecodeImage(localPath: string): Promise<{ frames: number } | null> {\n try {\n const sharp = (await import(\"sharp\")).default;\n const meta = await sharp(localPath, { limitInputPixels: 268402689 /* sharp default ~16k x 16k */ }).metadata();\n if (!meta.width || !meta.height) return null;\n return { frames: Math.max(1, meta.pages ?? 1) };\n } catch {\n return null;\n }\n}\n\n// ---- result builders ----\n\nfunction baseResult(\n partial: Omit<MediaInspectionResult, \"inspectorVersion\" | \"streams\" | \"codecs\"> &\n Partial<Pick<MediaInspectionResult, \"streams\" | \"codecs\">>,\n ffprobeVersion: string | undefined,\n): MediaInspectionResult {\n const result: MediaInspectionResult = {\n inspectorVersion: INSPECTOR_VERSION,\n ...(ffprobeVersion ? { ffprobeVersion } : {}),\n streams: partial.streams ?? { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: 0, auxiliary: 0 },\n codecs: partial.codecs ?? { audio: [], video: [] },\n status: partial.status,\n container: partial.container,\n safetyPlan: partial.safetyPlan,\n reasonCode: partial.reasonCode,\n ...(partial.canonicalKind ? { canonicalKind: partial.canonicalKind } : {}),\n ...(partial.formatId ? { formatId: partial.formatId } : {}),\n };\n return MediaInspectionResultSchema.parse(result);\n}\n\nfunction indeterminate(\n container: string,\n reasonCode: string,\n ffprobeVersion: string | undefined,\n formatId?: MediaFormatId,\n): MediaInspectionResult {\n return baseResult(\n {\n status: \"indeterminate\",\n container,\n safetyPlan: \"strict-video-fallback\",\n reasonCode,\n ...(formatId ? { formatId } : {}),\n },\n ffprobeVersion,\n );\n}\n\n// ---- the inspector ----\n\nexport interface InspectMediaArgs {\n localPath: string;\n timeoutMs?: number;\n signal?: AbortSignal;\n deps?: InspectMediaDeps;\n}\n\nexport async function inspectMedia(args: InspectMediaArgs): Promise<MediaInspectionResult> {\n const timeoutMs = args.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;\n const probe = args.deps?.probe ?? defaultProbe;\n const decodeImage = args.deps?.decodeImage ?? defaultDecodeImage;\n const ffprobeVersion = args.deps?.ffprobeVersion ?? (await getFfprobeVersion());\n\n // 1) bounded signature read (header only)\n let head: Uint8Array;\n try {\n const fh = await open(args.localPath, \"r\");\n try {\n const buf = Buffer.alloc(SIGNATURE_READ_BYTES);\n const { bytesRead } = await fh.read(buf, 0, SIGNATURE_READ_BYTES, 0);\n head = buf.subarray(0, bytesRead);\n } finally {\n await fh.close();\n }\n } catch {\n return indeterminate(\"unknown\", \"read_failed\", ffprobeVersion);\n }\n\n const sig = detectSignatureFamily(head);\n\n // 2) family routing\n switch (sig.family) {\n case \"unknown\":\n return indeterminate(\"unknown\", \"unrecognized_signature\", ffprobeVersion);\n\n case \"svg\":\n // Scriptable XML \"image\" — structurally recognized, deliberately NOT a\n // processable format until a sanitize/rasterize design exists (launch\n // policy 2026-07-31). Never classified by decoder capability alone.\n return baseResult(\n {\n status: \"unsupported\",\n container: \"svg\",\n formatId: \"svg\",\n safetyPlan: \"strict-video-fallback\",\n reasonCode: \"unsupported_format:svg\",\n },\n ffprobeVersion,\n );\n\n case \"image\": {\n // Single-format image signature: decoder proof required — a signature is\n // a candidate, not a classification.\n const decoded = await decodeImage(args.localPath);\n if (!decoded) return baseResult(\n { status: \"malformed\", container: sig.container, formatId: sig.formatId, safetyPlan: \"strict-video-fallback\", reasonCode: \"image_decode_failed\" },\n ffprobeVersion,\n );\n const frames = decoded.frames;\n return baseResult(\n {\n status: \"definitive\",\n canonicalKind: \"image\",\n container: sig.container,\n formatId: sig.formatId,\n streams: { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: frames, auxiliary: 0 },\n safetyPlan: frames > 1 ? \"animated-image-frames\" : \"still-image\",\n reasonCode: \"ok\",\n },\n ffprobeVersion,\n );\n }\n\n case \"isobmff\":\n if (ISOBMFF_IMAGE_BRANDS.has(sig.brand)) {\n // IMAGE-FAMILY ISO-BMFF resolves BEFORE the timed-video rule: a valid\n // AVIF probes as a one-frame AV1 `video` stream. Proof = image brand +\n // successful bounded decode + no ordinary timed A/V presentation.\n const probed = await probe(args.localPath, { timeoutMs, signal: args.signal });\n const parsed = probed.ok ? ProbeJsonSchema.safeParse(probed.json) : null;\n const streams = parsed?.success ? parsed.data.streams ?? [] : null;\n const hasAudio = streams ? streams.some((s) => s.codec_type === \"audio\") : true; // unknown ⇒ assume worst\n const formatId: MediaFormatId | undefined = sig.brand.startsWith(\"avi\") ? \"avif\" : \"heic\";\n const decoded = await decodeImage(args.localPath);\n if (!decoded || hasAudio) {\n if (formatId === \"heic\" && !decoded && !hasAudio) {\n // Recognized HEIC-family that the runtime cannot decode: explicit\n // unsupported-image (launch policy) — NEVER relabeled video.\n return baseResult(\n { status: \"unsupported\", container: \"heif\", formatId, safetyPlan: \"strict-video-fallback\", reasonCode: \"unsupported_format:heic\" },\n ffprobeVersion,\n );\n }\n // Undecodable/ambiguous image-brand file, or one carrying audio (a\n // real timed presentation wearing an image brand): fail toward the\n // stricter visual path.\n return indeterminate(\"heif\", decoded ? \"image_brand_with_audio\" : \"image_decode_failed\", ffprobeVersion, formatId);\n }\n const frames = decoded.frames;\n return baseResult(\n {\n status: \"definitive\",\n canonicalKind: \"image\",\n container: \"heif\",\n formatId,\n streams: { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: frames, auxiliary: 0 },\n safetyPlan: frames > 1 ? \"animated-image-frames\" : \"still-image\",\n reasonCode: \"ok\",\n },\n ffprobeVersion,\n );\n }\n return classifyAvContainer(args, \"isobmff\", \"isobmff\", probe, timeoutMs, ffprobeVersion);\n\n case \"ebml\":\n return classifyAvContainer(args, \"webm\", \"webm\", probe, timeoutMs, ffprobeVersion);\n case \"ogg\":\n return classifyAvContainer(args, \"ogg\", \"ogg\", probe, timeoutMs, ffprobeVersion);\n case \"avi\":\n // Real container ffprobe parses; no registry entry (no origin enables it\n // today) — the stream truth still classifies it, policy rejects by format.\n return classifyAvContainer(args, \"avi\", undefined, probe, timeoutMs, ffprobeVersion);\n case \"wav\":\n return classifyAvContainer(args, \"wav\", \"wav\", probe, timeoutMs, ffprobeVersion);\n case \"mp3\":\n return classifyAvContainer(args, \"mp3\", \"mp3\", probe, timeoutMs, ffprobeVersion);\n case \"flac\":\n return classifyAvContainer(args, \"flac\", \"flac\", probe, timeoutMs, ffprobeVersion);\n }\n}\n\nasync function classifyAvContainer(\n args: InspectMediaArgs,\n container: string,\n formatId: MediaFormatId | undefined,\n probe: NonNullable<InspectMediaDeps[\"probe\"]>,\n timeoutMs: number,\n ffprobeVersion: string | undefined,\n): Promise<MediaInspectionResult> {\n const probed = await probe(args.localPath, { timeoutMs, signal: args.signal });\n if (!probed.ok) {\n return indeterminate(container, `probe_${probed.reason}`, ffprobeVersion, formatId);\n }\n const parsed = ProbeJsonSchema.safeParse(probed.json);\n if (!parsed.success) return indeterminate(container, \"probe_schema_invalid\", ffprobeVersion, formatId);\n\n const rawStreams = parsed.data.streams ?? [];\n if (rawStreams.length === 0) return indeterminate(container, \"no_streams\", ffprobeVersion, formatId);\n\n const counts: MediaInspectionStreams = { audio: 0, timedVideo: 0, attachedPictures: 0, imageFrames: 0, auxiliary: 0 };\n const audioCodecs = new Set<NormalizedCodecId>();\n const videoCodecs = new Set<NormalizedCodecId>();\n let sawUnknownType = false;\n\n for (const s of rawStreams) {\n const type = s.codec_type ?? \"\";\n if (type === \"audio\") {\n counts.audio++;\n audioCodecs.add(normalizeCodec(s.codec_name));\n } else if (type === \"video\") {\n // An attached picture (cover art) is NOT a timed presentation — but the\n // disposition flag is untrusted container data, so it is COUNTED and the\n // safety plan forces every such picture through the image hash path\n // before the audio guard may return clean.\n if (s.disposition?.attached_pic === 1) {\n counts.attachedPictures++;\n } else {\n counts.timedVideo++;\n videoCodecs.add(normalizeCodec(s.codec_name));\n }\n } else if (type === \"subtitle\" || type === \"data\" || type === \"attachment\") {\n counts.auxiliary++;\n } else {\n counts.auxiliary++;\n sawUnknownType = true;\n }\n }\n\n const codecs = { audio: [...audioCodecs], video: [...videoCodecs] };\n\n // classification table (fail-closed)\n if (counts.timedVideo > 0) {\n return baseResult(\n {\n status: \"definitive\",\n canonicalKind: \"video\",\n container,\n ...(formatId ? { formatId } : {}),\n streams: counts,\n codecs,\n safetyPlan: \"video-frames\",\n reasonCode: \"ok\",\n },\n ffprobeVersion,\n );\n }\n if (counts.audio > 0 && !sawUnknownType) {\n return baseResult(\n {\n status: \"definitive\",\n canonicalKind: \"audio\",\n container,\n ...(formatId ? { formatId } : {}),\n streams: counts,\n codecs,\n safetyPlan: counts.attachedPictures > 0 ? \"audio-plus-artwork\" : \"audio-only\",\n reasonCode: \"ok\",\n },\n ffprobeVersion,\n );\n }\n // auxiliary-only, unknown stream types alongside audio, or nothing usable:\n // never audio — the strict visual fallback owns it.\n return baseResult(\n {\n status: \"indeterminate\",\n container,\n ...(formatId ? { formatId } : {}),\n streams: counts,\n codecs,\n safetyPlan: \"strict-video-fallback\",\n reasonCode: sawUnknownType ? \"unknown_stream_type\" : \"auxiliary_only\",\n },\n ffprobeVersion,\n );\n}\n"]}
@@ -1,4 +1,5 @@
1
- import type { MediaProcessingResult, MediaProcessingSpec } from "@ttt-productions/media-schemas";
1
+ import type { MediaProcessingResult, MediaProcessingSpec, MediaInspectionResult, MediaProcessingError } from "@ttt-productions/media-schemas";
2
+ import { type InspectMediaDeps } from "./inspection/inspect-media.js";
2
3
  import type { MediaIO } from "./io/types.js";
3
4
  import type { ModerationAdapter } from "./moderation/types.js";
4
5
  export interface RunPipelineArgs {
@@ -8,6 +9,33 @@ export interface RunPipelineArgs {
8
9
  moderation?: ModerationAdapter;
9
10
  signal?: AbortSignal;
10
11
  onProgress?: (p: import("./types.js").MediaPipelineProgress) => void;
12
+ /**
13
+ * CANONICAL INSPECTION SEAM (canonical-upload-content-classification).
14
+ * When present, the pipeline runs `inspectMedia` on the just-downloaded temp
15
+ * input — BEFORE moderation and processing — and calls this policy hook with
16
+ * the bounded result. The hook either:
17
+ * - returns `{ spec }` — the caller's policy adapter selected the
18
+ * processing spec FROM the inspection (the byte-derived kind, never the
19
+ * client MIME); the pipeline continues with that spec; or
20
+ * - returns `{ reject }` — a typed terminal (`kind_mismatch`,
21
+ * `unsupported_format`, `unsupported_codec`, …); the pipeline stops
22
+ * before any transcode/moderation work.
23
+ * The inspection result rides the returned `MediaProcessingResult.inspection`
24
+ * either way, so the finalizer hands the SAME object to the safety gate —
25
+ * one authority, no re-detection. Callers that omit the hook get the
26
+ * pre-existing behavior unchanged.
27
+ */
28
+ resolveAfterInspection?: (inspection: MediaInspectionResult) => Promise<{
29
+ spec: MediaProcessingSpec;
30
+ } | {
31
+ reject: MediaProcessingError;
32
+ }> | ({
33
+ spec: MediaProcessingSpec;
34
+ } | {
35
+ reject: MediaProcessingError;
36
+ });
37
+ /** Inspection tool overrides (tests). */
38
+ inspectionDeps?: InspectMediaDeps;
11
39
  }
12
40
  export declare function runMediaPipeline(args: RunPipelineArgs): Promise<MediaProcessingResult>;
13
41
  //# sourceMappingURL=run-pipeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"run-pipeline.d.ts","sourceRoot":"","sources":["../src/run-pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,qBAAqB,EACrB,mBAAmB,EAEpB,MAAM,gCAAgC,CAAC;AAIxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAK/D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,YAAY,EAAE,qBAAqB,KAAK,IAAI,CAAC;CACpE;AAiBD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA2M5F"}
1
+ {"version":3,"file":"run-pipeline.d.ts","sourceRoot":"","sources":["../src/run-pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,qBAAqB,EACrB,mBAAmB,EAEnB,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,gCAAgC,CAAC;AAIxC,OAAO,EAAgB,KAAK,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAK/D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,YAAY,EAAE,qBAAqB,KAAK,IAAI,CAAC;IACrE;;;;;;;;;;;;;;;OAeG;IACH,sBAAsB,CAAC,EAAE,CACvB,UAAU,EAAE,qBAAqB,KAC9B,OAAO,CAAC;QAAE,IAAI,EAAE,mBAAmB,CAAA;KAAE,GAAG;QAAE,MAAM,EAAE,oBAAoB,CAAA;KAAE,CAAC,GAAG,CAAC;QAAE,IAAI,EAAE,mBAAmB,CAAA;KAAE,GAAG;QAAE,MAAM,EAAE,oBAAoB,CAAA;KAAE,CAAC,CAAC;IACpJ,yCAAyC;IACzC,cAAc,CAAC,EAAE,gBAAgB,CAAC;CACjC;AAiBD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgP5F"}