@ttt-productions/media-processing-core 0.8.4 → 0.8.6

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=local-input.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-input.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/local-input.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Readable } from "node:stream";
3
+ import { readFile, stat, writeFile, mkdtemp, rm } from "node:fs/promises";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { streamToTempFile, hashFileSha256, readFileHeader, ByteLimitExceededError, FILE_HEADER_BYTES, } from "../io/local-input.js";
8
+ const chunked = (buf, chunkSize) => {
9
+ const chunks = [];
10
+ for (let i = 0; i < buf.length; i += chunkSize)
11
+ chunks.push(buf.subarray(i, i + chunkSize));
12
+ return Readable.from(chunks);
13
+ };
14
+ describe("streamToTempFile", () => {
15
+ it("writes the exact bytes and reports bytesWritten; cleanup removes the file", async () => {
16
+ const data = randomBytes(256 * 1024 + 17);
17
+ const input = await streamToTempFile(chunked(data, 64 * 1024), { maxBytes: 1024 * 1024 });
18
+ try {
19
+ expect(input.bytesWritten).toBe(data.length);
20
+ expect(Buffer.compare(await readFile(input.localPath), data)).toBe(0);
21
+ }
22
+ finally {
23
+ await input.cleanup();
24
+ }
25
+ await expect(stat(input.localPath)).rejects.toThrow();
26
+ });
27
+ it("HARD CAP: throws ByteLimitExceededError and leaves no temp file behind", async () => {
28
+ const data = randomBytes(100_000);
29
+ let caught;
30
+ try {
31
+ await streamToTempFile(chunked(data, 10_000), { maxBytes: 50_000 });
32
+ }
33
+ catch (err) {
34
+ caught = err;
35
+ }
36
+ expect(caught).toBeInstanceOf(ByteLimitExceededError);
37
+ expect(caught.maxBytes).toBe(50_000);
38
+ });
39
+ it("a source stream error cleans up and propagates", async () => {
40
+ const bad = new Readable({
41
+ read() {
42
+ this.emit("error", new Error("source died"));
43
+ },
44
+ });
45
+ await expect(streamToTempFile(bad, { maxBytes: 1000 })).rejects.toThrow("source died");
46
+ });
47
+ it("rejects a non-positive maxBytes", async () => {
48
+ await expect(streamToTempFile(Readable.from([]), { maxBytes: 0 })).rejects.toThrow(/positive finite maxBytes/);
49
+ });
50
+ it("cleanup is idempotent", async () => {
51
+ const input = await streamToTempFile(Readable.from([Buffer.from("x")]), { maxBytes: 10 });
52
+ await input.cleanup();
53
+ await expect(input.cleanup()).resolves.toBeUndefined();
54
+ });
55
+ });
56
+ describe("hashFileSha256", () => {
57
+ it("matches crypto's whole-buffer digest", async () => {
58
+ const dir = await mkdtemp(path.join(os.tmpdir(), "local-input-test-"));
59
+ const p = path.join(dir, "f");
60
+ const data = randomBytes(300_000);
61
+ try {
62
+ await writeFile(p, data);
63
+ const expected = createHash("sha256").update(data).digest("hex");
64
+ await expect(hashFileSha256(p)).resolves.toBe(expected);
65
+ }
66
+ finally {
67
+ await rm(dir, { recursive: true, force: true });
68
+ }
69
+ });
70
+ });
71
+ describe("readFileHeader", () => {
72
+ it("reads at most the requested window and the whole file when smaller", async () => {
73
+ const dir = await mkdtemp(path.join(os.tmpdir(), "local-input-test-"));
74
+ const p = path.join(dir, "f");
75
+ const data = randomBytes(FILE_HEADER_BYTES + 500);
76
+ try {
77
+ await writeFile(p, data);
78
+ const header = await readFileHeader(p);
79
+ expect(header.byteLength).toBe(FILE_HEADER_BYTES);
80
+ expect(Buffer.compare(Buffer.from(header), data.subarray(0, FILE_HEADER_BYTES))).toBe(0);
81
+ const small = await readFileHeader(p, 16);
82
+ expect(small.byteLength).toBe(16);
83
+ await writeFile(p, Buffer.from("tiny"));
84
+ const whole = await readFileHeader(p);
85
+ expect(Buffer.from(whole).toString()).toBe("tiny");
86
+ }
87
+ finally {
88
+ await rm(dir, { recursive: true, force: true });
89
+ }
90
+ });
91
+ });
92
+ //# sourceMappingURL=local-input.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-input.test.js","sourceRoot":"","sources":["../../src/__tests__/local-input.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect } from \"vitest\";\nimport { Readable } from \"node:stream\";\nimport { readFile, stat, writeFile, mkdtemp, rm } from \"node:fs/promises\";\nimport { createHash, randomBytes } from \"node:crypto\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport {\n streamToTempFile,\n hashFileSha256,\n readFileHeader,\n ByteLimitExceededError,\n FILE_HEADER_BYTES,\n} from \"../io/local-input.js\";\n\nconst chunked = (buf: Buffer, chunkSize: number): Readable => {\n const chunks: Buffer[] = [];\n for (let i = 0; i < buf.length; i += chunkSize) chunks.push(buf.subarray(i, i + chunkSize));\n return Readable.from(chunks);\n};\n\ndescribe(\"streamToTempFile\", () => {\n it(\"writes the exact bytes and reports bytesWritten; cleanup removes the file\", async () => {\n const data = randomBytes(256 * 1024 + 17);\n const input = await streamToTempFile(chunked(data, 64 * 1024), { maxBytes: 1024 * 1024 });\n try {\n expect(input.bytesWritten).toBe(data.length);\n expect(Buffer.compare(await readFile(input.localPath), data)).toBe(0);\n } finally {\n await input.cleanup();\n }\n await expect(stat(input.localPath)).rejects.toThrow();\n });\n\n it(\"HARD CAP: throws ByteLimitExceededError and leaves no temp file behind\", async () => {\n const data = randomBytes(100_000);\n let caught: unknown;\n try {\n await streamToTempFile(chunked(data, 10_000), { maxBytes: 50_000 });\n } catch (err) {\n caught = err;\n }\n expect(caught).toBeInstanceOf(ByteLimitExceededError);\n expect((caught as ByteLimitExceededError).maxBytes).toBe(50_000);\n });\n\n it(\"a source stream error cleans up and propagates\", async () => {\n const bad = new Readable({\n read() {\n this.emit(\"error\", new Error(\"source died\"));\n },\n });\n await expect(streamToTempFile(bad, { maxBytes: 1000 })).rejects.toThrow(\"source died\");\n });\n\n it(\"rejects a non-positive maxBytes\", async () => {\n await expect(streamToTempFile(Readable.from([]), { maxBytes: 0 })).rejects.toThrow(\n /positive finite maxBytes/,\n );\n });\n\n it(\"cleanup is idempotent\", async () => {\n const input = await streamToTempFile(Readable.from([Buffer.from(\"x\")]), { maxBytes: 10 });\n await input.cleanup();\n await expect(input.cleanup()).resolves.toBeUndefined();\n });\n});\n\ndescribe(\"hashFileSha256\", () => {\n it(\"matches crypto's whole-buffer digest\", async () => {\n const dir = await mkdtemp(path.join(os.tmpdir(), \"local-input-test-\"));\n const p = path.join(dir, \"f\");\n const data = randomBytes(300_000);\n try {\n await writeFile(p, data);\n const expected = createHash(\"sha256\").update(data).digest(\"hex\");\n await expect(hashFileSha256(p)).resolves.toBe(expected);\n } finally {\n await rm(dir, { recursive: true, force: true });\n }\n });\n});\n\ndescribe(\"readFileHeader\", () => {\n it(\"reads at most the requested window and the whole file when smaller\", async () => {\n const dir = await mkdtemp(path.join(os.tmpdir(), \"local-input-test-\"));\n const p = path.join(dir, \"f\");\n const data = randomBytes(FILE_HEADER_BYTES + 500);\n try {\n await writeFile(p, data);\n const header = await readFileHeader(p);\n expect(header.byteLength).toBe(FILE_HEADER_BYTES);\n expect(Buffer.compare(Buffer.from(header), data.subarray(0, FILE_HEADER_BYTES))).toBe(0);\n\n const small = await readFileHeader(p, 16);\n expect(small.byteLength).toBe(16);\n\n await writeFile(p, Buffer.from(\"tiny\"));\n const whole = await readFileHeader(p);\n expect(Buffer.from(whole).toString()).toBe(\"tiny\");\n } finally {\n await rm(dir, { recursive: true, force: true });\n }\n });\n});\n"]}
package/dist/index.d.ts CHANGED
@@ -8,7 +8,9 @@ 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";
13
+ export * from "./io/local-input.js";
12
14
  export * from "./moderation/types.js";
13
15
  export * from "./moderation/merge.js";
14
16
  //# sourceMappingURL=index.d.ts.map
@@ -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;AAC3B,cAAc,qBAAqB,CAAC;AAEpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -8,7 +8,9 @@ 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";
13
+ export * from "./io/local-input.js";
12
14
  export * from "./moderation/types.js";
13
15
  export * from "./moderation/merge.js";
14
16
  //# sourceMappingURL=index.js.map
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;AAC3B,cAAc,qBAAqB,CAAC;AAEpC,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\";\nexport * from \"./io/local-input.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