@ttt-productions/media-processing-core 0.8.16 → 0.8.18
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/audio/audio-processor.d.ts +2 -2
- package/dist/audio/audio-processor.d.ts.map +1 -1
- package/dist/audio/audio-processor.js +63 -50
- package/dist/audio/audio-processor.js.map +1 -1
- package/dist/audio/probe.d.ts +1 -1
- package/dist/audio/probe.d.ts.map +1 -1
- package/dist/audio/probe.js +6 -5
- package/dist/audio/probe.js.map +1 -1
- package/dist/duration/media-duration.d.ts +2 -13
- package/dist/duration/media-duration.d.ts.map +1 -1
- package/dist/duration/media-duration.js +24 -49
- package/dist/duration/media-duration.js.map +1 -1
- package/dist/image/formats.d.ts +2 -13
- package/dist/image/formats.d.ts.map +1 -1
- package/dist/image/formats.js +25 -35
- package/dist/image/formats.js.map +1 -1
- package/dist/image/image-processor.d.ts +2 -2
- package/dist/image/image-processor.d.ts.map +1 -1
- package/dist/image/image-processor.js +48 -57
- package/dist/image/image-processor.js.map +1 -1
- package/dist/image/resize.d.ts +2 -16
- package/dist/image/resize.d.ts.map +1 -1
- package/dist/image/resize.js +13 -28
- package/dist/image/resize.js.map +1 -1
- package/dist/index.d.ts +19 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -16
- package/dist/index.js.map +1 -1
- package/dist/inspection/inspect-media.d.ts +13 -20
- package/dist/inspection/inspect-media.d.ts.map +1 -1
- package/dist/inspection/inspect-media.js +197 -201
- package/dist/inspection/inspect-media.js.map +1 -1
- package/dist/io/fs.d.ts +1 -1
- package/dist/io/fs.d.ts.map +1 -1
- package/dist/io/fs.js +4 -5
- package/dist/io/fs.js.map +1 -1
- package/dist/io/local-input.d.ts +1 -21
- package/dist/io/local-input.d.ts.map +1 -1
- package/dist/io/local-input.js +15 -35
- package/dist/io/local-input.js.map +1 -1
- package/dist/io/types.d.ts +0 -3
- package/dist/io/types.d.ts.map +1 -1
- package/dist/io/types.js.map +1 -1
- package/dist/moderation/merge.d.ts +1 -1
- package/dist/moderation/merge.d.ts.map +1 -1
- package/dist/moderation/merge.js +1 -2
- package/dist/moderation/merge.js.map +1 -1
- package/dist/moderation/types.d.ts +3 -5
- package/dist/moderation/types.d.ts.map +1 -1
- package/dist/moderation/types.js.map +1 -1
- package/dist/process-media.d.ts +2 -3
- package/dist/process-media.d.ts.map +1 -1
- package/dist/process-media.js +9 -9
- package/dist/process-media.js.map +1 -1
- package/dist/processing/errors.d.ts.map +1 -1
- package/dist/processing/errors.js +1 -1
- package/dist/processing/errors.js.map +1 -1
- package/dist/processing/result.d.ts +2 -2
- package/dist/processing/result.js +1 -1
- package/dist/processing/result.js.map +1 -1
- package/dist/run-pipeline.d.ts +5 -22
- package/dist/run-pipeline.d.ts.map +1 -1
- package/dist/run-pipeline.js +72 -81
- package/dist/run-pipeline.js.map +1 -1
- package/dist/server/download-token.d.ts +0 -6
- package/dist/server/download-token.d.ts.map +1 -1
- package/dist/server/download-token.js +3 -13
- package/dist/server/download-token.js.map +1 -1
- package/dist/server/firebase-media-io.d.ts +3 -13
- package/dist/server/firebase-media-io.d.ts.map +1 -1
- package/dist/server/firebase-media-io.js +1 -6
- package/dist/server/firebase-media-io.js.map +1 -1
- package/dist/server/gcs-errors.d.ts +0 -8
- package/dist/server/gcs-errors.d.ts.map +1 -1
- package/dist/server/gcs-errors.js +2 -10
- package/dist/server/gcs-errors.js.map +1 -1
- package/dist/server/index.d.ts +7 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/pending-media.d.ts +3 -34
- package/dist/server/pending-media.d.ts.map +1 -1
- package/dist/server/pending-media.js +13 -30
- package/dist/server/pending-media.js.map +1 -1
- package/dist/server/r2-retry.d.ts +4 -52
- package/dist/server/r2-retry.d.ts.map +1 -1
- package/dist/server/r2-retry.js +49 -134
- package/dist/server/r2-retry.js.map +1 -1
- package/dist/server/staged-object.d.ts +2 -46
- package/dist/server/staged-object.d.ts.map +1 -1
- package/dist/server/staged-object.js +4 -48
- package/dist/server/staged-object.js.map +1 -1
- package/dist/server/storage-ops.d.ts +5 -67
- package/dist/server/storage-ops.d.ts.map +1 -1
- package/dist/server/storage-ops.js +110 -185
- package/dist/server/storage-ops.js.map +1 -1
- package/dist/types.d.ts +2 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/log.d.ts.map +1 -1
- package/dist/utils/log.js +3 -3
- package/dist/utils/log.js.map +1 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +1 -4
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/safe-path.d.ts.map +1 -1
- package/dist/utils/safe-path.js +8 -9
- package/dist/utils/safe-path.js.map +1 -1
- package/dist/validation/validate-duration.d.ts.map +1 -1
- package/dist/validation/validate-duration.js.map +1 -1
- package/dist/validation/validate-mime.d.ts.map +1 -1
- package/dist/validation/validate-mime.js.map +1 -1
- package/dist/validation/validate-size.d.ts.map +1 -1
- package/dist/validation/validate-size.js.map +1 -1
- package/dist/video/ffmpeg.d.ts +1 -23
- package/dist/video/ffmpeg.d.ts.map +1 -1
- package/dist/video/ffmpeg.js +57 -64
- package/dist/video/ffmpeg.js.map +1 -1
- package/dist/video/probe.d.ts +1 -1
- package/dist/video/probe.d.ts.map +1 -1
- package/dist/video/probe.js +10 -8
- package/dist/video/probe.js.map +1 -1
- package/dist/video/video-processor.d.ts +2 -2
- package/dist/video/video-processor.d.ts.map +1 -1
- package/dist/video/video-processor.js +125 -96
- package/dist/video/video-processor.js.map +1 -1
- package/dist/workspace/temp.js +4 -4
- package/dist/workspace/temp.js.map +1 -1
- package/package.json +2 -2
- package/dist/audio/index.d.ts +0 -3
- package/dist/audio/index.d.ts.map +0 -1
- package/dist/audio/index.js +0 -3
- package/dist/audio/index.js.map +0 -1
- package/dist/image/index.d.ts +0 -4
- package/dist/image/index.d.ts.map +0 -1
- package/dist/image/index.js +0 -4
- package/dist/image/index.js.map +0 -1
- package/dist/video/index.d.ts +0 -4
- package/dist/video/index.d.ts.map +0 -1
- package/dist/video/index.js +0 -4
- package/dist/video/index.js.map +0 -1
|
@@ -1,41 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
15
|
-
// - family selection by a bounded 64 KiB SIGNATURE read (header only —
|
|
16
|
-
// semantic elements are NEVER searched for in arbitrary payload: payload
|
|
17
|
-
// bytes can mimic container elements, so a flat scan misclassifies WebM
|
|
18
|
-
// audio as video);
|
|
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 { DEFAULT_PROBE_TIMEOUT_MS, runCmd } from "../video/ffmpeg.js";
|
|
32
|
-
import { measureDurationSec } from "../duration/media-duration.js";
|
|
33
|
-
/** Bump on any classification-rule change (rides every decision event). */
|
|
34
|
-
export const INSPECTOR_VERSION = "mpc-inspect-2";
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { MediaInspectionResultSchema, } from '@ttt-productions/media-schemas';
|
|
4
|
+
import { DEFAULT_PROBE_TIMEOUT_MS, runCmd } from '../video/ffmpeg.js';
|
|
5
|
+
import { measureDurationSec } from '../duration/media-duration.js';
|
|
6
|
+
export const INSPECTOR_VERSION = 'mpc-inspect-2';
|
|
35
7
|
const SIGNATURE_READ_BYTES = 64 * 1024;
|
|
36
|
-
const PROBE_SIZE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const PROBE_SIZE_BYTES = 4 * 1024 * 1024;
|
|
37
9
|
const ANALYZE_DURATION_US = 10 * 1000 * 1000;
|
|
38
|
-
// ---- ffprobe JSON boundary (strict; anything else is malformed) ----
|
|
39
10
|
const ProbeStreamSchema = z
|
|
40
11
|
.object({
|
|
41
12
|
codec_type: z.string().max(32).optional(),
|
|
@@ -48,42 +19,53 @@ const ProbeStreamSchema = z
|
|
|
48
19
|
.loose();
|
|
49
20
|
const ProbeJsonSchema = z
|
|
50
21
|
.object({
|
|
51
|
-
format: z
|
|
22
|
+
format: z
|
|
23
|
+
.object({ format_name: z.string().max(200).optional() })
|
|
24
|
+
.loose()
|
|
25
|
+
.optional(),
|
|
52
26
|
streams: z.array(ProbeStreamSchema).max(256).optional(),
|
|
53
27
|
})
|
|
54
28
|
.loose();
|
|
55
|
-
// ---- codec normalization (bounded — raw strings never escape) ----
|
|
56
29
|
const CODEC_MAP = {
|
|
57
|
-
h264:
|
|
58
|
-
hevc:
|
|
59
|
-
vp8:
|
|
60
|
-
vp9:
|
|
61
|
-
av1:
|
|
62
|
-
theora:
|
|
63
|
-
mpeg4:
|
|
64
|
-
mjpeg:
|
|
65
|
-
aac:
|
|
66
|
-
alac:
|
|
67
|
-
opus:
|
|
68
|
-
vorbis:
|
|
69
|
-
mp3:
|
|
70
|
-
flac:
|
|
30
|
+
h264: 'h264',
|
|
31
|
+
hevc: 'hevc',
|
|
32
|
+
vp8: 'vp8',
|
|
33
|
+
vp9: 'vp9',
|
|
34
|
+
av1: 'av1',
|
|
35
|
+
theora: 'theora',
|
|
36
|
+
mpeg4: 'mpeg4',
|
|
37
|
+
mjpeg: 'mjpeg',
|
|
38
|
+
aac: 'aac',
|
|
39
|
+
alac: 'alac',
|
|
40
|
+
opus: 'opus',
|
|
41
|
+
vorbis: 'vorbis',
|
|
42
|
+
mp3: 'mp3',
|
|
43
|
+
flac: 'flac',
|
|
71
44
|
};
|
|
72
45
|
function normalizeCodec(name) {
|
|
73
46
|
if (!name)
|
|
74
|
-
return
|
|
47
|
+
return 'other';
|
|
75
48
|
const n = name.toLowerCase();
|
|
76
49
|
if (CODEC_MAP[n])
|
|
77
50
|
return CODEC_MAP[n];
|
|
78
|
-
if (n.startsWith(
|
|
79
|
-
return
|
|
80
|
-
if (n.startsWith(
|
|
81
|
-
return
|
|
82
|
-
return
|
|
51
|
+
if (n.startsWith('pcm_'))
|
|
52
|
+
return 'pcm';
|
|
53
|
+
if (n.startsWith('amr'))
|
|
54
|
+
return 'amr';
|
|
55
|
+
return 'other';
|
|
83
56
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
57
|
+
const ISOBMFF_IMAGE_BRANDS = new Set([
|
|
58
|
+
'avif',
|
|
59
|
+
'avis',
|
|
60
|
+
'heic',
|
|
61
|
+
'heix',
|
|
62
|
+
'heim',
|
|
63
|
+
'heis',
|
|
64
|
+
'hevc',
|
|
65
|
+
'hevx',
|
|
66
|
+
'mif1',
|
|
67
|
+
'msf1',
|
|
68
|
+
]);
|
|
87
69
|
function ascii(b, off, s) {
|
|
88
70
|
if (off + s.length > b.length)
|
|
89
71
|
return false;
|
|
@@ -94,58 +76,59 @@ function ascii(b, off, s) {
|
|
|
94
76
|
}
|
|
95
77
|
export function detectSignatureFamily(b) {
|
|
96
78
|
if (b.length < 12)
|
|
97
|
-
return { family:
|
|
98
|
-
// images (single-format signatures)
|
|
79
|
+
return { family: 'unknown' };
|
|
99
80
|
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
100
|
-
return { family:
|
|
101
|
-
if (b[0] === 0x89 && ascii(b, 1,
|
|
102
|
-
return { family:
|
|
103
|
-
if (ascii(b, 0,
|
|
104
|
-
return { family:
|
|
105
|
-
if (ascii(b, 0,
|
|
106
|
-
return { family:
|
|
81
|
+
return { family: 'image', formatId: 'jpeg', container: 'jpeg' };
|
|
82
|
+
if (b[0] === 0x89 && ascii(b, 1, 'PNG'))
|
|
83
|
+
return { family: 'image', formatId: 'png', container: 'png' };
|
|
84
|
+
if (ascii(b, 0, 'GIF8'))
|
|
85
|
+
return { family: 'image', formatId: 'gif', container: 'gif' };
|
|
86
|
+
if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP'))
|
|
87
|
+
return { family: 'image', formatId: 'webp', container: 'webp' };
|
|
107
88
|
if (b[0] === 0x42 && b[1] === 0x4d)
|
|
108
|
-
return { family:
|
|
109
|
-
if ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) ||
|
|
110
|
-
|
|
89
|
+
return { family: 'image', formatId: 'bmp', container: 'bmp' };
|
|
90
|
+
if ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) ||
|
|
91
|
+
(b[0] === 0x4d && b[1] === 0x4d && b[3] === 0x2a)) {
|
|
92
|
+
return { family: 'image', formatId: 'tiff', container: 'tiff' };
|
|
111
93
|
}
|
|
112
|
-
// SVG: XML text — scan a short prefix for '<svg' (bounded; case-insensitive).
|
|
113
94
|
{
|
|
114
95
|
const prefix = Buffer.from(b.slice(0, Math.min(b.length, 4096)))
|
|
115
|
-
.toString(
|
|
96
|
+
.toString('latin1')
|
|
116
97
|
.toLowerCase();
|
|
117
|
-
if (prefix.includes(
|
|
118
|
-
|
|
98
|
+
if (prefix.includes('<svg') &&
|
|
99
|
+
(prefix.trimStart().startsWith('<?xml') ||
|
|
100
|
+
prefix.trimStart().startsWith('<svg') ||
|
|
101
|
+
prefix.includes('<!doctype svg'))) {
|
|
102
|
+
return { family: 'svg' };
|
|
119
103
|
}
|
|
120
104
|
}
|
|
121
|
-
// containers
|
|
122
105
|
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
|
|
123
|
-
return { family:
|
|
124
|
-
if (ascii(b, 4,
|
|
106
|
+
return { family: 'ebml' };
|
|
107
|
+
if (ascii(b, 4, 'ftyp')) {
|
|
125
108
|
const brand = String.fromCharCode(b[8], b[9], b[10], b[11]).trim().toLowerCase();
|
|
126
|
-
return { family:
|
|
109
|
+
return { family: 'isobmff', brand };
|
|
127
110
|
}
|
|
128
|
-
if (ascii(b, 0,
|
|
129
|
-
return { family:
|
|
130
|
-
if (ascii(b, 0,
|
|
131
|
-
return { family:
|
|
132
|
-
if (ascii(b, 0,
|
|
133
|
-
return { family:
|
|
134
|
-
if (ascii(b, 0,
|
|
135
|
-
return { family:
|
|
111
|
+
if (ascii(b, 0, 'OggS'))
|
|
112
|
+
return { family: 'ogg' };
|
|
113
|
+
if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'AVI '))
|
|
114
|
+
return { family: 'avi' };
|
|
115
|
+
if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WAVE'))
|
|
116
|
+
return { family: 'wav' };
|
|
117
|
+
if (ascii(b, 0, 'ID3'))
|
|
118
|
+
return { family: 'mp3' };
|
|
136
119
|
if (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)
|
|
137
|
-
return { family:
|
|
138
|
-
if (ascii(b, 0,
|
|
139
|
-
return { family:
|
|
140
|
-
return { family:
|
|
120
|
+
return { family: 'mp3' };
|
|
121
|
+
if (ascii(b, 0, 'fLaC'))
|
|
122
|
+
return { family: 'flac' };
|
|
123
|
+
return { family: 'unknown' };
|
|
141
124
|
}
|
|
142
125
|
let cachedFfprobeVersion;
|
|
143
126
|
async function getFfprobeVersion() {
|
|
144
127
|
if (cachedFfprobeVersion !== undefined)
|
|
145
128
|
return cachedFfprobeVersion ?? undefined;
|
|
146
129
|
try {
|
|
147
|
-
const r = await runCmd(
|
|
148
|
-
const first = (r.stdout || r.stderr).split(/\r?\n/)[0]?.trim() ??
|
|
130
|
+
const r = await runCmd('ffprobe', ['-version'], { timeoutMs: 5_000 });
|
|
131
|
+
const first = (r.stdout || r.stderr).split(/\r?\n/)[0]?.trim() ?? '';
|
|
149
132
|
cachedFfprobeVersion = first ? first.slice(0, 120) : null;
|
|
150
133
|
}
|
|
151
134
|
catch {
|
|
@@ -156,40 +139,42 @@ async function getFfprobeVersion() {
|
|
|
156
139
|
async function defaultProbe(localPath, opts) {
|
|
157
140
|
let r;
|
|
158
141
|
try {
|
|
159
|
-
r = await runCmd(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
142
|
+
r = await runCmd('ffprobe', [
|
|
143
|
+
'-v',
|
|
144
|
+
'error',
|
|
145
|
+
'-print_format',
|
|
146
|
+
'json',
|
|
147
|
+
'-show_format',
|
|
148
|
+
'-show_streams',
|
|
149
|
+
'-probesize',
|
|
167
150
|
String(PROBE_SIZE_BYTES),
|
|
168
|
-
|
|
151
|
+
'-analyzeduration',
|
|
169
152
|
String(ANALYZE_DURATION_US),
|
|
170
153
|
localPath,
|
|
171
154
|
], { timeoutMs: opts.timeoutMs, signal: opts.signal });
|
|
172
155
|
}
|
|
173
156
|
catch {
|
|
174
|
-
return { ok: false, reason:
|
|
157
|
+
return { ok: false, reason: 'failed' };
|
|
175
158
|
}
|
|
176
159
|
if (r.timedOut)
|
|
177
|
-
return { ok: false, reason:
|
|
160
|
+
return { ok: false, reason: 'timeout' };
|
|
178
161
|
if (r.truncated)
|
|
179
|
-
return { ok: false, reason:
|
|
162
|
+
return { ok: false, reason: 'truncated' };
|
|
180
163
|
if (r.code !== 0)
|
|
181
|
-
return { ok: false, reason:
|
|
164
|
+
return { ok: false, reason: 'failed' };
|
|
182
165
|
try {
|
|
183
|
-
return { ok: true, json: JSON.parse(r.stdout ||
|
|
166
|
+
return { ok: true, json: JSON.parse(r.stdout || '{}') };
|
|
184
167
|
}
|
|
185
168
|
catch {
|
|
186
|
-
return { ok: false, reason:
|
|
169
|
+
return { ok: false, reason: 'bad_json' };
|
|
187
170
|
}
|
|
188
171
|
}
|
|
189
172
|
async function defaultDecodeImage(localPath) {
|
|
190
173
|
try {
|
|
191
|
-
const sharp = (await import(
|
|
192
|
-
const meta = await sharp(localPath, {
|
|
174
|
+
const sharp = (await import('sharp')).default;
|
|
175
|
+
const meta = await sharp(localPath, {
|
|
176
|
+
limitInputPixels: 268402689,
|
|
177
|
+
}).metadata();
|
|
193
178
|
if (!meta.width || !meta.height)
|
|
194
179
|
return null;
|
|
195
180
|
return { frames: Math.max(1, meta.pages ?? 1) };
|
|
@@ -198,12 +183,17 @@ async function defaultDecodeImage(localPath) {
|
|
|
198
183
|
return null;
|
|
199
184
|
}
|
|
200
185
|
}
|
|
201
|
-
// ---- result builders ----
|
|
202
186
|
function baseResult(partial, ffprobeVersion) {
|
|
203
187
|
const result = {
|
|
204
188
|
inspectorVersion: INSPECTOR_VERSION,
|
|
205
189
|
...(ffprobeVersion ? { ffprobeVersion } : {}),
|
|
206
|
-
streams: partial.streams ?? {
|
|
190
|
+
streams: partial.streams ?? {
|
|
191
|
+
audio: 0,
|
|
192
|
+
timedVideo: 0,
|
|
193
|
+
attachedPictures: 0,
|
|
194
|
+
imageFrames: 0,
|
|
195
|
+
auxiliary: 0,
|
|
196
|
+
},
|
|
207
197
|
codecs: partial.codecs ?? { audio: [], video: [] },
|
|
208
198
|
status: partial.status,
|
|
209
199
|
container: partial.container,
|
|
@@ -217,9 +207,9 @@ function baseResult(partial, ffprobeVersion) {
|
|
|
217
207
|
}
|
|
218
208
|
function indeterminate(container, reasonCode, ffprobeVersion, formatId) {
|
|
219
209
|
return baseResult({
|
|
220
|
-
status:
|
|
210
|
+
status: 'indeterminate',
|
|
221
211
|
container,
|
|
222
|
-
safetyPlan:
|
|
212
|
+
safetyPlan: 'strict-video-fallback',
|
|
223
213
|
reasonCode,
|
|
224
214
|
...(formatId ? { formatId } : {}),
|
|
225
215
|
}, ffprobeVersion);
|
|
@@ -229,10 +219,9 @@ export async function inspectMedia(args) {
|
|
|
229
219
|
const probe = args.deps?.probe ?? defaultProbe;
|
|
230
220
|
const decodeImage = args.deps?.decodeImage ?? defaultDecodeImage;
|
|
231
221
|
const ffprobeVersion = args.deps?.ffprobeVersion ?? (await getFfprobeVersion());
|
|
232
|
-
// 1) bounded signature read (header only)
|
|
233
222
|
let head;
|
|
234
223
|
try {
|
|
235
|
-
const fh = await open(args.localPath,
|
|
224
|
+
const fh = await open(args.localPath, 'r');
|
|
236
225
|
try {
|
|
237
226
|
const buf = Buffer.alloc(SIGNATURE_READ_BYTES);
|
|
238
227
|
const { bytesRead } = await fh.read(buf, 0, SIGNATURE_READ_BYTES, 0);
|
|
@@ -243,89 +232,97 @@ export async function inspectMedia(args) {
|
|
|
243
232
|
}
|
|
244
233
|
}
|
|
245
234
|
catch {
|
|
246
|
-
return indeterminate(
|
|
235
|
+
return indeterminate('unknown', 'read_failed', ffprobeVersion);
|
|
247
236
|
}
|
|
248
237
|
const sig = detectSignatureFamily(head);
|
|
249
|
-
// 2) family routing
|
|
250
238
|
switch (sig.family) {
|
|
251
|
-
case
|
|
252
|
-
return indeterminate(
|
|
253
|
-
case
|
|
254
|
-
// Scriptable XML "image" — structurally recognized, deliberately NOT a
|
|
255
|
-
// processable format: it can carry script and no sanitize/rasterize step
|
|
256
|
-
// exists. Never classified by decoder capability alone.
|
|
239
|
+
case 'unknown':
|
|
240
|
+
return indeterminate('unknown', 'unrecognized_signature', ffprobeVersion);
|
|
241
|
+
case 'svg':
|
|
257
242
|
return baseResult({
|
|
258
|
-
status:
|
|
259
|
-
container:
|
|
260
|
-
formatId:
|
|
261
|
-
safetyPlan:
|
|
262
|
-
reasonCode:
|
|
243
|
+
status: 'unsupported',
|
|
244
|
+
container: 'svg',
|
|
245
|
+
formatId: 'svg',
|
|
246
|
+
safetyPlan: 'strict-video-fallback',
|
|
247
|
+
reasonCode: 'unsupported_format:svg',
|
|
263
248
|
}, ffprobeVersion);
|
|
264
|
-
case
|
|
265
|
-
// Single-format image signature: decoder proof required — a signature is
|
|
266
|
-
// a candidate, not a classification.
|
|
249
|
+
case 'image': {
|
|
267
250
|
const decoded = await decodeImage(args.localPath);
|
|
268
251
|
if (!decoded)
|
|
269
|
-
return baseResult({
|
|
252
|
+
return baseResult({
|
|
253
|
+
status: 'malformed',
|
|
254
|
+
container: sig.container,
|
|
255
|
+
formatId: sig.formatId,
|
|
256
|
+
safetyPlan: 'strict-video-fallback',
|
|
257
|
+
reasonCode: 'image_decode_failed',
|
|
258
|
+
}, ffprobeVersion);
|
|
270
259
|
const frames = decoded.frames;
|
|
271
260
|
return baseResult({
|
|
272
|
-
status:
|
|
273
|
-
canonicalKind:
|
|
261
|
+
status: 'definitive',
|
|
262
|
+
canonicalKind: 'image',
|
|
274
263
|
container: sig.container,
|
|
275
264
|
formatId: sig.formatId,
|
|
276
|
-
streams: {
|
|
277
|
-
|
|
278
|
-
|
|
265
|
+
streams: {
|
|
266
|
+
audio: 0,
|
|
267
|
+
timedVideo: 0,
|
|
268
|
+
attachedPictures: 0,
|
|
269
|
+
imageFrames: frames,
|
|
270
|
+
auxiliary: 0,
|
|
271
|
+
},
|
|
272
|
+
safetyPlan: frames > 1 ? 'animated-image-frames' : 'still-image',
|
|
273
|
+
reasonCode: 'ok',
|
|
279
274
|
}, ffprobeVersion);
|
|
280
275
|
}
|
|
281
|
-
case
|
|
276
|
+
case 'isobmff':
|
|
282
277
|
if (ISOBMFF_IMAGE_BRANDS.has(sig.brand)) {
|
|
283
|
-
// IMAGE-FAMILY ISO-BMFF resolves BEFORE the timed-video rule: a valid
|
|
284
|
-
// AVIF probes as a one-frame AV1 `video` stream. Proof = image brand +
|
|
285
|
-
// successful bounded decode + no ordinary timed A/V presentation.
|
|
286
278
|
const probed = await probe(args.localPath, { timeoutMs, signal: args.signal });
|
|
287
279
|
const parsed = probed.ok ? ProbeJsonSchema.safeParse(probed.json) : null;
|
|
288
|
-
const streams = parsed?.success ? parsed.data.streams ?? [] : null;
|
|
289
|
-
const hasAudio = streams ? streams.some((s) => s.codec_type ===
|
|
290
|
-
const formatId = sig.brand.startsWith(
|
|
280
|
+
const streams = parsed?.success ? (parsed.data.streams ?? []) : null;
|
|
281
|
+
const hasAudio = streams ? streams.some((s) => s.codec_type === 'audio') : true;
|
|
282
|
+
const formatId = sig.brand.startsWith('avi') ? 'avif' : 'heic';
|
|
291
283
|
const decoded = await decodeImage(args.localPath);
|
|
292
284
|
if (!decoded || hasAudio) {
|
|
293
|
-
if (formatId ===
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
285
|
+
if (formatId === 'heic' && !decoded && !hasAudio) {
|
|
286
|
+
return baseResult({
|
|
287
|
+
status: 'unsupported',
|
|
288
|
+
container: 'heif',
|
|
289
|
+
formatId,
|
|
290
|
+
safetyPlan: 'strict-video-fallback',
|
|
291
|
+
reasonCode: 'unsupported_format:heic',
|
|
292
|
+
}, ffprobeVersion);
|
|
297
293
|
}
|
|
298
|
-
|
|
299
|
-
// real timed presentation wearing an image brand): fail toward the
|
|
300
|
-
// stricter visual path.
|
|
301
|
-
return indeterminate("heif", decoded ? "image_brand_with_audio" : "image_decode_failed", ffprobeVersion, formatId);
|
|
294
|
+
return indeterminate('heif', decoded ? 'image_brand_with_audio' : 'image_decode_failed', ffprobeVersion, formatId);
|
|
302
295
|
}
|
|
303
296
|
const frames = decoded.frames;
|
|
304
297
|
return baseResult({
|
|
305
|
-
status:
|
|
306
|
-
canonicalKind:
|
|
307
|
-
container:
|
|
298
|
+
status: 'definitive',
|
|
299
|
+
canonicalKind: 'image',
|
|
300
|
+
container: 'heif',
|
|
308
301
|
formatId,
|
|
309
|
-
streams: {
|
|
310
|
-
|
|
311
|
-
|
|
302
|
+
streams: {
|
|
303
|
+
audio: 0,
|
|
304
|
+
timedVideo: 0,
|
|
305
|
+
attachedPictures: 0,
|
|
306
|
+
imageFrames: frames,
|
|
307
|
+
auxiliary: 0,
|
|
308
|
+
},
|
|
309
|
+
safetyPlan: frames > 1 ? 'animated-image-frames' : 'still-image',
|
|
310
|
+
reasonCode: 'ok',
|
|
312
311
|
}, ffprobeVersion);
|
|
313
312
|
}
|
|
314
|
-
return classifyAvContainer(args,
|
|
315
|
-
case
|
|
316
|
-
return classifyAvContainer(args,
|
|
317
|
-
case
|
|
318
|
-
return classifyAvContainer(args,
|
|
319
|
-
case
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return classifyAvContainer(args,
|
|
323
|
-
case
|
|
324
|
-
return classifyAvContainer(args,
|
|
325
|
-
case
|
|
326
|
-
return classifyAvContainer(args,
|
|
327
|
-
case "flac":
|
|
328
|
-
return classifyAvContainer(args, "flac", "flac", probe, timeoutMs, ffprobeVersion);
|
|
313
|
+
return classifyAvContainer(args, 'isobmff', 'isobmff', probe, timeoutMs, ffprobeVersion);
|
|
314
|
+
case 'ebml':
|
|
315
|
+
return classifyAvContainer(args, 'webm', 'webm', probe, timeoutMs, ffprobeVersion);
|
|
316
|
+
case 'ogg':
|
|
317
|
+
return classifyAvContainer(args, 'ogg', 'ogg', probe, timeoutMs, ffprobeVersion);
|
|
318
|
+
case 'avi':
|
|
319
|
+
return classifyAvContainer(args, 'avi', undefined, probe, timeoutMs, ffprobeVersion);
|
|
320
|
+
case 'wav':
|
|
321
|
+
return classifyAvContainer(args, 'wav', 'wav', probe, timeoutMs, ffprobeVersion);
|
|
322
|
+
case 'mp3':
|
|
323
|
+
return classifyAvContainer(args, 'mp3', 'mp3', probe, timeoutMs, ffprobeVersion);
|
|
324
|
+
case 'flac':
|
|
325
|
+
return classifyAvContainer(args, 'flac', 'flac', probe, timeoutMs, ffprobeVersion);
|
|
329
326
|
}
|
|
330
327
|
}
|
|
331
328
|
async function classifyAvContainer(args, container, formatId, probe, timeoutMs, ffprobeVersion) {
|
|
@@ -335,25 +332,27 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
|
|
|
335
332
|
}
|
|
336
333
|
const parsed = ProbeJsonSchema.safeParse(probed.json);
|
|
337
334
|
if (!parsed.success)
|
|
338
|
-
return indeterminate(container,
|
|
335
|
+
return indeterminate(container, 'probe_schema_invalid', ffprobeVersion, formatId);
|
|
339
336
|
const rawStreams = parsed.data.streams ?? [];
|
|
340
337
|
if (rawStreams.length === 0)
|
|
341
|
-
return indeterminate(container,
|
|
342
|
-
const counts = {
|
|
338
|
+
return indeterminate(container, 'no_streams', ffprobeVersion, formatId);
|
|
339
|
+
const counts = {
|
|
340
|
+
audio: 0,
|
|
341
|
+
timedVideo: 0,
|
|
342
|
+
attachedPictures: 0,
|
|
343
|
+
imageFrames: 0,
|
|
344
|
+
auxiliary: 0,
|
|
345
|
+
};
|
|
343
346
|
const audioCodecs = new Set();
|
|
344
347
|
const videoCodecs = new Set();
|
|
345
348
|
let sawUnknownType = false;
|
|
346
349
|
for (const s of rawStreams) {
|
|
347
|
-
const type = s.codec_type ??
|
|
348
|
-
if (type ===
|
|
350
|
+
const type = s.codec_type ?? '';
|
|
351
|
+
if (type === 'audio') {
|
|
349
352
|
counts.audio++;
|
|
350
353
|
audioCodecs.add(normalizeCodec(s.codec_name));
|
|
351
354
|
}
|
|
352
|
-
else if (type ===
|
|
353
|
-
// An attached picture (cover art) is NOT a timed presentation — but the
|
|
354
|
-
// disposition flag is untrusted container data, so it is COUNTED and the
|
|
355
|
-
// safety plan forces every such picture through the image hash path
|
|
356
|
-
// before the audio guard may return clean.
|
|
355
|
+
else if (type === 'video') {
|
|
357
356
|
if (s.disposition?.attached_pic === 1) {
|
|
358
357
|
counts.attachedPictures++;
|
|
359
358
|
}
|
|
@@ -362,7 +361,7 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
|
|
|
362
361
|
videoCodecs.add(normalizeCodec(s.codec_name));
|
|
363
362
|
}
|
|
364
363
|
}
|
|
365
|
-
else if (type ===
|
|
364
|
+
else if (type === 'subtitle' || type === 'data' || type === 'attachment') {
|
|
366
365
|
counts.auxiliary++;
|
|
367
366
|
}
|
|
368
367
|
else {
|
|
@@ -374,44 +373,41 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
|
|
|
374
373
|
const measure = args.deps?.measureDuration ?? measureDurationSec;
|
|
375
374
|
const durationSec = await measure(args.localPath, { signal: args.signal });
|
|
376
375
|
const duration = durationSec !== undefined ? { durationSec } : {};
|
|
377
|
-
// classification table (fail-closed)
|
|
378
376
|
if (counts.timedVideo > 0) {
|
|
379
377
|
return baseResult({
|
|
380
|
-
status:
|
|
381
|
-
canonicalKind:
|
|
378
|
+
status: 'definitive',
|
|
379
|
+
canonicalKind: 'video',
|
|
382
380
|
container,
|
|
383
381
|
...(formatId ? { formatId } : {}),
|
|
384
382
|
streams: counts,
|
|
385
383
|
codecs,
|
|
386
384
|
...duration,
|
|
387
|
-
safetyPlan:
|
|
388
|
-
reasonCode:
|
|
385
|
+
safetyPlan: 'video-frames',
|
|
386
|
+
reasonCode: 'ok',
|
|
389
387
|
}, ffprobeVersion);
|
|
390
388
|
}
|
|
391
389
|
if (counts.audio > 0 && !sawUnknownType) {
|
|
392
390
|
return baseResult({
|
|
393
|
-
status:
|
|
394
|
-
canonicalKind:
|
|
391
|
+
status: 'definitive',
|
|
392
|
+
canonicalKind: 'audio',
|
|
395
393
|
container,
|
|
396
394
|
...(formatId ? { formatId } : {}),
|
|
397
395
|
streams: counts,
|
|
398
396
|
codecs,
|
|
399
397
|
...duration,
|
|
400
|
-
safetyPlan: counts.attachedPictures > 0 ?
|
|
401
|
-
reasonCode:
|
|
398
|
+
safetyPlan: counts.attachedPictures > 0 ? 'audio-plus-artwork' : 'audio-only',
|
|
399
|
+
reasonCode: 'ok',
|
|
402
400
|
}, ffprobeVersion);
|
|
403
401
|
}
|
|
404
|
-
// auxiliary-only, unknown stream types alongside audio, or nothing usable:
|
|
405
|
-
// never audio — the strict visual fallback owns it.
|
|
406
402
|
return baseResult({
|
|
407
|
-
status:
|
|
403
|
+
status: 'indeterminate',
|
|
408
404
|
container,
|
|
409
405
|
...(formatId ? { formatId } : {}),
|
|
410
406
|
streams: counts,
|
|
411
407
|
codecs,
|
|
412
408
|
...duration,
|
|
413
|
-
safetyPlan:
|
|
414
|
-
reasonCode: sawUnknownType ?
|
|
409
|
+
safetyPlan: 'strict-video-fallback',
|
|
410
|
+
reasonCode: sawUnknownType ? 'unknown_stream_type' : 'auxiliary_only',
|
|
415
411
|
}, ffprobeVersion);
|
|
416
412
|
}
|
|
417
413
|
//# sourceMappingURL=inspect-media.js.map
|