@ttt-productions/media-processing-core 0.8.14 → 0.8.16

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.
Files changed (39) hide show
  1. package/dist/audio/audio-processor.d.ts.map +1 -1
  2. package/dist/audio/audio-processor.js +12 -2
  3. package/dist/audio/audio-processor.js.map +1 -1
  4. package/dist/audio/probe.d.ts +2 -1
  5. package/dist/audio/probe.d.ts.map +1 -1
  6. package/dist/audio/probe.js +8 -10
  7. package/dist/audio/probe.js.map +1 -1
  8. package/dist/duration/media-duration.d.ts +26 -0
  9. package/dist/duration/media-duration.d.ts.map +1 -0
  10. package/dist/duration/media-duration.js +141 -0
  11. package/dist/duration/media-duration.js.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/inspection/inspect-media.d.ts +5 -1
  17. package/dist/inspection/inspect-media.d.ts.map +1 -1
  18. package/dist/inspection/inspect-media.js +17 -12
  19. package/dist/inspection/inspect-media.js.map +1 -1
  20. package/dist/server/download-token.d.ts +14 -0
  21. package/dist/server/download-token.d.ts.map +1 -0
  22. package/dist/server/download-token.js +19 -0
  23. package/dist/server/download-token.js.map +1 -0
  24. package/dist/server/index.d.ts +1 -0
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +1 -0
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/video/ffmpeg.d.ts +10 -2
  29. package/dist/video/ffmpeg.d.ts.map +1 -1
  30. package/dist/video/ffmpeg.js +28 -1
  31. package/dist/video/ffmpeg.js.map +1 -1
  32. package/dist/video/probe.d.ts +2 -1
  33. package/dist/video/probe.d.ts.map +1 -1
  34. package/dist/video/probe.js +8 -17
  35. package/dist/video/probe.js.map +1 -1
  36. package/dist/video/video-processor.d.ts.map +1 -1
  37. package/dist/video/video-processor.js +9 -2
  38. package/dist/video/video-processor.js.map +1 -1
  39. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"audio-processor.d.ts","sourceRoot":"","sources":["../../src/audio/audio-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAM9G,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AA4BvD,wBAAsB,YAAY,CAChC,IAAI,EAAE,mBAAmB,EACzB,GAAG,EAAE;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,EACD,IAAI,CAAC,EAAE,mBAAmB,GACzB,OAAO,CAAC,qBAAqB,CAAC,CAkIhC"}
1
+ {"version":3,"file":"audio-processor.d.ts","sourceRoot":"","sources":["../../src/audio/audio-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAO9G,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AA4BvD,wBAAsB,YAAY,CAChC,IAAI,EAAE,mBAAmB,EACzB,GAAG,EAAE;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,EACD,IAAI,CAAC,EAAE,mBAAmB,GACzB,OAAO,CAAC,qBAAqB,CAAC,CA4IhC"}
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { safeOutputPathFor } from "../utils/safe-path.js";
4
4
  import { ensureFfmpegAvailable, runFfmpeg } from "../video/ffmpeg.js";
5
5
  import { probeAudio } from "./probe.js";
6
+ import { unreadableLengthError } from "../duration/media-duration.js";
6
7
  import { TIMED_MEDIA_MAIN_OUTPUT_KEY } from "@ttt-productions/media-schemas";
7
8
  function matchMime(accepted, actual) {
8
9
  const a = accepted.trim().toLowerCase();
@@ -64,9 +65,15 @@ export async function processAudio(spec, ctx, opts) {
64
65
  }
65
66
  const outDir = path.dirname(ctx.outputBasePath);
66
67
  await mkdir(outDir, { recursive: true });
67
- const probe = await probeAudio(ctx.inputPath);
68
+ const probe = await probeAudio(ctx.inputPath, { signal: opts?.signal });
69
+ if (opts?.signal?.aborted) {
70
+ return { ok: false, mediaType: "audio", error: { code: "processing_canceled", message: "Processing canceled." } };
71
+ }
68
72
  const maxDur = spec.maxDurationSec ?? spec.audio?.maxDurationSec;
69
- if (maxDur && probe.durationSec && probe.durationSec > maxDur) {
73
+ if (maxDur && probe.durationSec === undefined) {
74
+ return { ok: false, mediaType: "audio", error: unreadableLengthError("audio", maxDur) };
75
+ }
76
+ if (maxDur && probe.durationSec !== undefined && probe.durationSec > maxDur) {
70
77
  return {
71
78
  ok: false,
72
79
  mediaType: "audio",
@@ -133,6 +140,9 @@ export async function processAudio(spec, ctx, opts) {
133
140
  };
134
141
  }
135
142
  catch (e) {
143
+ if (opts?.signal?.aborted) {
144
+ return { ok: false, mediaType: "audio", error: { code: "processing_canceled", message: "Processing canceled." } };
145
+ }
136
146
  return {
137
147
  ok: false,
138
148
  mediaType: "audio",
@@ -1 +1 @@
1
- {"version":3,"file":"audio-processor.js","sourceRoot":"","sources":["../../src/audio/audio-processor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MediaOutput, MediaProcessingResult, MediaProcessingSpec } from \"@ttt-productions/media-schemas\";\nimport { mkdir, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { safeOutputPathFor } from \"../utils/safe-path.js\";\nimport { ensureFfmpegAvailable, runFfmpeg } from \"../video/ffmpeg.js\";\nimport { probeAudio } from \"./probe.js\";\nimport type { ProcessMediaOptions } from \"../types.js\";\nimport { TIMED_MEDIA_MAIN_OUTPUT_KEY } from \"@ttt-productions/media-schemas\";\n\nfunction matchMime(accepted: string, actual: string): boolean {\n const a = accepted.trim().toLowerCase();\n const m = actual.trim().toLowerCase();\n if (!a) return true;\n if (a === \"*/*\") return true;\n if (a.endsWith(\"/*\")) return m.startsWith(a.slice(0, -1));\n return a === m;\n}\n\nfunction acceptAllowsAudio(spec: MediaProcessingSpec): boolean {\n const kinds = spec.accept?.kinds?.filter(Boolean) ?? [];\n return kinds.length === 0 ? true : kinds.includes(\"audio\");\n}\n\nfunction acceptAllowsMime(spec: MediaProcessingSpec, actualMime?: string): boolean {\n const mimes = spec.accept?.mimes?.filter(Boolean) ?? [];\n if (mimes.length === 0) return true; // empty => accept anything\n if (!actualMime) return false;\n return mimes.some((a) => matchMime(a, actualMime));\n}\n\nfunction outputPathFor(base: string, key: string, ext: string): string {\n return safeOutputPathFor(base, key, ext);\n}\n\nexport async function processAudio(\n spec: MediaProcessingSpec,\n ctx: {\n inputPath: string;\n outputBasePath: string;\n inputMime?: string;\n },\n opts?: ProcessMediaOptions\n): Promise<MediaProcessingResult> {\n const tempFiles: string[] = [];\n\n try {\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"audio\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n const ff = await ensureFfmpegAvailable();\n if (!ff.ok) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: \"ffmpeg is not available.\",\n details: { error: String((ff as any).error ?? \"unknown\") },\n },\n };\n }\n if (!acceptAllowsAudio(spec)) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: { code: \"invalid_mime\", message: \"Spec does not allow audio uploads.\", details: { accept: spec.accept } },\n };\n }\n\n if (!acceptAllowsMime(spec, ctx.inputMime)) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: { code: \"invalid_mime\", message: \"Audio mime type is not allowed.\", details: { inputMime: ctx.inputMime, accept: spec.accept } },\n };\n }\n\n const outDir = path.dirname(ctx.outputBasePath);\n await mkdir(outDir, { recursive: true });\n\n const probe = await probeAudio(ctx.inputPath);\n\n const maxDur = spec.maxDurationSec ?? spec.audio?.maxDurationSec;\n if (maxDur && probe.durationSec && probe.durationSec > maxDur) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"too_long\",\n message: \"Audio is too long.\",\n details: { durationSec: probe.durationSec, maxDurationSec: maxDur },\n },\n };\n }\n\n // Normalize to AAC in m4a (small + widely supported)\n const outPath = outputPathFor(ctx.outputBasePath, TIMED_MEDIA_MAIN_OUTPUT_KEY, \"m4a\");\n\n tempFiles.push(outPath);\n\n const args = [\n \"-y\",\n \"-i\",\n ctx.inputPath,\n \"-vn\",\n \"-c:a\",\n \"aac\",\n \"-b:a\",\n \"128k\",\n outPath,\n ];\n\n const r = await runFfmpeg(args, {\n signal: opts?.signal,\n durationMs: probe.durationSec ? probe.durationSec * 1000 : undefined,\n onProgress: (p) => {\n opts?.onProgress?.({ phase: \"process\", percent: p.percent, detail: { step: \"audio_transcode\", outTimeMs: p.outTimeMs } });\n },\n phase: \"transcode\",\n });\n if (r.code !== 0) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: \"ffmpeg audio transcode failed.\",\n details: { stderr: r.stderr, stdout: r.stdout },\n },\n };\n }\n\n const s = await stat(outPath);\n\n const outputs: MediaOutput[] = [\n {\n key: TIMED_MEDIA_MAIN_OUTPUT_KEY,\n url: `file://${outPath}`,\n path: outPath,\n mime: \"audio/mp4\",\n sizeBytes: s.size,\n durationSec: probe.durationSec,\n },\n ];\n\n tempFiles.length = 0;\n\n return {\n ok: true,\n mediaType: \"audio\",\n outputs,\n meta: {\n mime: ctx.inputMime,\n durationSec: probe.durationSec,\n },\n };\n } catch (e: any) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: e?.message ? String(e.message) : \"Audio processing failed\",\n details: { name: e?.name },\n },\n };\n } finally {\n if (tempFiles.length) {\n const { rm } = await import(\"node:fs/promises\");\n await Promise.all(tempFiles.map((f) => rm(f, { force: true }).catch(() => {})));\n }\n }\n}\n"]}
1
+ {"version":3,"file":"audio-processor.js","sourceRoot":"","sources":["../../src/audio/audio-processor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MediaOutput, MediaProcessingResult, MediaProcessingSpec } from \"@ttt-productions/media-schemas\";\nimport { mkdir, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { safeOutputPathFor } from \"../utils/safe-path.js\";\nimport { ensureFfmpegAvailable, runFfmpeg } from \"../video/ffmpeg.js\";\nimport { probeAudio } from \"./probe.js\";\nimport { unreadableLengthError } from \"../duration/media-duration.js\";\nimport type { ProcessMediaOptions } from \"../types.js\";\nimport { TIMED_MEDIA_MAIN_OUTPUT_KEY } from \"@ttt-productions/media-schemas\";\n\nfunction matchMime(accepted: string, actual: string): boolean {\n const a = accepted.trim().toLowerCase();\n const m = actual.trim().toLowerCase();\n if (!a) return true;\n if (a === \"*/*\") return true;\n if (a.endsWith(\"/*\")) return m.startsWith(a.slice(0, -1));\n return a === m;\n}\n\nfunction acceptAllowsAudio(spec: MediaProcessingSpec): boolean {\n const kinds = spec.accept?.kinds?.filter(Boolean) ?? [];\n return kinds.length === 0 ? true : kinds.includes(\"audio\");\n}\n\nfunction acceptAllowsMime(spec: MediaProcessingSpec, actualMime?: string): boolean {\n const mimes = spec.accept?.mimes?.filter(Boolean) ?? [];\n if (mimes.length === 0) return true; // empty => accept anything\n if (!actualMime) return false;\n return mimes.some((a) => matchMime(a, actualMime));\n}\n\nfunction outputPathFor(base: string, key: string, ext: string): string {\n return safeOutputPathFor(base, key, ext);\n}\n\nexport async function processAudio(\n spec: MediaProcessingSpec,\n ctx: {\n inputPath: string;\n outputBasePath: string;\n inputMime?: string;\n },\n opts?: ProcessMediaOptions\n): Promise<MediaProcessingResult> {\n const tempFiles: string[] = [];\n\n try {\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"audio\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n const ff = await ensureFfmpegAvailable();\n if (!ff.ok) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: \"ffmpeg is not available.\",\n details: { error: String((ff as any).error ?? \"unknown\") },\n },\n };\n }\n if (!acceptAllowsAudio(spec)) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: { code: \"invalid_mime\", message: \"Spec does not allow audio uploads.\", details: { accept: spec.accept } },\n };\n }\n\n if (!acceptAllowsMime(spec, ctx.inputMime)) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: { code: \"invalid_mime\", message: \"Audio mime type is not allowed.\", details: { inputMime: ctx.inputMime, accept: spec.accept } },\n };\n }\n\n const outDir = path.dirname(ctx.outputBasePath);\n await mkdir(outDir, { recursive: true });\n\n const probe = await probeAudio(ctx.inputPath, { signal: opts?.signal });\n\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"audio\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n\n const maxDur = spec.maxDurationSec ?? spec.audio?.maxDurationSec;\n if (maxDur && probe.durationSec === undefined) {\n return { ok: false, mediaType: \"audio\", error: unreadableLengthError(\"audio\", maxDur) };\n }\n if (maxDur && probe.durationSec !== undefined && probe.durationSec > maxDur) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"too_long\",\n message: \"Audio is too long.\",\n details: { durationSec: probe.durationSec, maxDurationSec: maxDur },\n },\n };\n }\n\n // Normalize to AAC in m4a (small + widely supported)\n const outPath = outputPathFor(ctx.outputBasePath, TIMED_MEDIA_MAIN_OUTPUT_KEY, \"m4a\");\n\n tempFiles.push(outPath);\n\n const args = [\n \"-y\",\n \"-i\",\n ctx.inputPath,\n \"-vn\",\n \"-c:a\",\n \"aac\",\n \"-b:a\",\n \"128k\",\n outPath,\n ];\n\n const r = await runFfmpeg(args, {\n signal: opts?.signal,\n durationMs: probe.durationSec ? probe.durationSec * 1000 : undefined,\n onProgress: (p) => {\n opts?.onProgress?.({ phase: \"process\", percent: p.percent, detail: { step: \"audio_transcode\", outTimeMs: p.outTimeMs } });\n },\n phase: \"transcode\",\n });\n if (r.code !== 0) {\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: \"ffmpeg audio transcode failed.\",\n details: { stderr: r.stderr, stdout: r.stdout },\n },\n };\n }\n\n const s = await stat(outPath);\n\n const outputs: MediaOutput[] = [\n {\n key: TIMED_MEDIA_MAIN_OUTPUT_KEY,\n url: `file://${outPath}`,\n path: outPath,\n mime: \"audio/mp4\",\n sizeBytes: s.size,\n durationSec: probe.durationSec,\n },\n ];\n\n tempFiles.length = 0;\n\n return {\n ok: true,\n mediaType: \"audio\",\n outputs,\n meta: {\n mime: ctx.inputMime,\n durationSec: probe.durationSec,\n },\n };\n } catch (e: any) {\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"audio\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n return {\n ok: false,\n mediaType: \"audio\",\n error: {\n code: \"processing_failed\",\n message: e?.message ? String(e.message) : \"Audio processing failed\",\n details: { name: e?.name },\n },\n };\n } finally {\n if (tempFiles.length) {\n const { rm } = await import(\"node:fs/promises\");\n await Promise.all(tempFiles.map((f) => rm(f, { force: true }).catch(() => {})));\n }\n }\n}\n"]}
@@ -1,5 +1,6 @@
1
+ import { type MeasureDurationOptions } from "../duration/media-duration.js";
1
2
  export interface AudioProbe {
2
3
  durationSec?: number;
3
4
  }
4
- export declare function probeAudio(inputPath: string): Promise<AudioProbe>;
5
+ export declare function probeAudio(inputPath: string, opts?: MeasureDurationOptions): Promise<AudioProbe>;
5
6
  //# sourceMappingURL=probe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../../src/audio/probe.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAevE"}
1
+ {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../../src/audio/probe.ts"],"names":[],"mappings":"AACA,OAAO,EAAsB,KAAK,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAEhG,MAAM,WAAW,UAAU;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,UAAU,CAAC,CAUtG"}
@@ -1,14 +1,12 @@
1
- import { runCmd } from "../video/ffmpeg.js";
2
- export async function probeAudio(inputPath) {
3
- const args = ["-v", "error", "-print_format", "json", "-show_format", inputPath];
4
- const r = await runCmd("ffprobe", args);
5
- if (r.code !== 0) {
1
+ import { DEFAULT_PROBE_TIMEOUT_MS, runCmd } from "../video/ffmpeg.js";
2
+ import { measureDurationSec } from "../duration/media-duration.js";
3
+ export async function probeAudio(inputPath, opts) {
4
+ // ffprobe opening the file is the readability check: a file it cannot open
5
+ // throws here rather than reaching the length rule as merely unmeasurable.
6
+ const r = await runCmd("ffprobe", ["-v", "error", inputPath], { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS, signal: opts?.signal });
7
+ if (r.timedOut || r.code !== 0) {
6
8
  throw new Error(`ffprobe failed (${r.code}): ${r.stderr || r.stdout}`);
7
9
  }
8
- const json = JSON.parse(r.stdout || "{}");
9
- const format = json.format ?? {};
10
- const durationStr = format.duration;
11
- const durationSec = durationStr ? Number(durationStr) : undefined;
12
- return { durationSec: Number.isFinite(durationSec) ? durationSec : undefined };
10
+ return { durationSec: await measureDurationSec(inputPath, opts) };
13
11
  }
14
12
  //# sourceMappingURL=probe.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"probe.js","sourceRoot":"","sources":["../../src/audio/probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAM5C,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,SAAiB;IAChD,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,cAAc,EAAE,SAAS,CAAC,CAAC;IAEjF,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAExC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;IACzE,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAQ,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;IACtC,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC;IACpC,MAAM,WAAW,GAAG,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAElE,OAAO,EAAE,WAAW,EAAE,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;AACjF,CAAC","sourcesContent":["import { runCmd } from \"../video/ffmpeg.js\";\n\nexport interface AudioProbe {\n durationSec?: number;\n}\n\nexport async function probeAudio(inputPath: string): Promise<AudioProbe> {\n const args = [\"-v\", \"error\", \"-print_format\", \"json\", \"-show_format\", inputPath];\n\n const r = await runCmd(\"ffprobe\", args);\n\n if (r.code !== 0) {\n throw new Error(`ffprobe failed (${r.code}): ${r.stderr || r.stdout}`);\n }\n\n const json = JSON.parse(r.stdout || \"{}\");\n const format: any = json.format ?? {};\n const durationStr = format.duration;\n const durationSec = durationStr ? Number(durationStr) : undefined;\n\n return { durationSec: Number.isFinite(durationSec) ? durationSec : undefined };\n}\n"]}
1
+ {"version":3,"file":"probe.js","sourceRoot":"","sources":["../../src/audio/probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAA+B,MAAM,+BAA+B,CAAC;AAMhG,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,SAAiB,EAAE,IAA6B;IAC/E,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,EAAE,SAAS,EAAE,wBAAwB,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IAE7H,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;IACzE,CAAC;IAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC;AACpE,CAAC","sourcesContent":["import { DEFAULT_PROBE_TIMEOUT_MS, runCmd } from \"../video/ffmpeg.js\";\nimport { measureDurationSec, type MeasureDurationOptions } from \"../duration/media-duration.js\";\n\nexport interface AudioProbe {\n durationSec?: number;\n}\n\nexport async function probeAudio(inputPath: string, opts?: MeasureDurationOptions): Promise<AudioProbe> {\n // ffprobe opening the file is the readability check: a file it cannot open\n // throws here rather than reaching the length rule as merely unmeasurable.\n const r = await runCmd(\"ffprobe\", [\"-v\", \"error\", inputPath], { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS, signal: opts?.signal });\n\n if (r.timedOut || r.code !== 0) {\n throw new Error(`ffprobe failed (${r.code}): ${r.stderr || r.stdout}`);\n }\n\n return { durationSec: await measureDurationSec(inputPath, opts) };\n}\n"]}
@@ -0,0 +1,26 @@
1
+ import type { MediaProcessingError } from "@ttt-productions/media-schemas";
2
+ /** Wall-clock ceiling for the measuring pass; it bounds an adversarial input,
3
+ * not a real upload (a 920 MB, 30-minute WebM measures in about a second). */
4
+ export declare const DEFAULT_DURATION_MEASURE_TIMEOUT_MS = 120000;
5
+ export interface PacketLengthFold {
6
+ /** One line of ffprobe's `-of compact` packet and stream listing. */
7
+ add(line: string): void;
8
+ /** Seconds from the earliest packet start (never before zero) to the latest
9
+ * packet end across the timed (audio, and non-cover-art video) streams, or
10
+ * `undefined`. */
11
+ lengthSec(): number | undefined;
12
+ }
13
+ export declare function createPacketLengthFold(): PacketLengthFold;
14
+ export interface MeasureDurationOptions {
15
+ timeoutMs?: number;
16
+ signal?: AbortSignal;
17
+ }
18
+ /** The file's length in seconds, or `undefined` when it cannot be measured. */
19
+ export declare function measureDurationSec(localPath: string, opts?: MeasureDurationOptions): Promise<number | undefined>;
20
+ export type MeasureDurationFn = (localPath: string, opts?: MeasureDurationOptions) => Promise<number | undefined>;
21
+ /** The refusal for a file with no length under a spec that caps length: a
22
+ * length that cannot be measured cannot be shown to be within the cap. The
23
+ * code is `unsupported_format`, never `too_long` — the file is not known to
24
+ * be too long, and its copy must not say so. */
25
+ export declare function unreadableLengthError(kind: "video" | "audio", maxDurationSec: number): MediaProcessingError;
26
+ //# sourceMappingURL=media-duration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-duration.d.ts","sourceRoot":"","sources":["../../src/duration/media-duration.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAG3E;+EAC+E;AAC/E,eAAO,MAAM,mCAAmC,SAAU,CAAC;AAwB3D,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;uBAEmB;IACnB,SAAS,IAAI,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,wBAAgB,sBAAsB,IAAI,gBAAgB,CAgDzD;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,+EAA+E;AAC/E,wBAAsB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA0BtH;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,sBAAsB,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAElH;;;iDAGiD;AACjD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,EAAE,cAAc,EAAE,MAAM,GAAG,oBAAoB,CAM3G"}
@@ -0,0 +1,141 @@
1
+ // The ONE rule for a timed media file's length (ENG-002). The inspection's
2
+ // `durationSec` and both processors' too-long checks read it here, so they can
3
+ // never disagree about the same file.
4
+ //
5
+ // 1. The length is always MEASURED from the packets themselves: ffprobe
6
+ // lists every packet's timestamp and duration (demux only, no decode),
7
+ // and the length is the span from the earliest packet start (never before
8
+ // zero) to the latest packet end across the audio and video streams. A
9
+ // container header's declared length (`format.duration`) is never used:
10
+ // a header can claim a short length for a long file, and Chromium's
11
+ // MediaRecorder writes WebM with none. ffmpeg's `-progress` output time
12
+ // is not used either: it trails the true end by the B-frame reorder
13
+ // delay. The listing is read one line at a time and folded into a few
14
+ // numbers per stream, so a file of any packet count is measured in
15
+ // bounded memory.
16
+ // 2. A measure that fails, times out, is aborted, is cut short, or finds no
17
+ // timed packet is NO length. Every caller treats that as unknown and
18
+ // fails closed.
19
+ import { runCmd } from "../video/ffmpeg.js";
20
+ /** Wall-clock ceiling for the measuring pass; it bounds an adversarial input,
21
+ * not a real upload (a 920 MB, 30-minute WebM measures in about a second). */
22
+ export const DEFAULT_DURATION_MEASURE_TIMEOUT_MS = 120_000;
23
+ /** More streams than this is an adversarial file, and is no length. */
24
+ const MAX_TRACKED_STREAMS = 256;
25
+ const SECONDS = /^-?\d+(\.\d+)?$/;
26
+ const STREAM_INDEX = /^\d+$/;
27
+ function seconds(raw) {
28
+ if (raw === undefined || !SECONDS.test(raw))
29
+ return undefined;
30
+ const n = Number(raw);
31
+ return Number.isFinite(n) ? n : undefined;
32
+ }
33
+ function fields(line) {
34
+ const [section = "", ...pairs] = line.split("|");
35
+ const values = new Map();
36
+ for (const pair of pairs) {
37
+ const eq = pair.indexOf("=");
38
+ if (eq > 0)
39
+ values.set(pair.slice(0, eq), pair.slice(eq + 1).trim());
40
+ }
41
+ return { section, values };
42
+ }
43
+ export function createPacketLengthFold() {
44
+ const spans = new Map();
45
+ const timed = new Set();
46
+ let overflow = false;
47
+ return {
48
+ add(line) {
49
+ const { section, values } = fields(line);
50
+ if (section === "packet") {
51
+ const rawIndex = values.get("stream_index");
52
+ if (rawIndex === undefined || !STREAM_INDEX.test(rawIndex))
53
+ return;
54
+ const index = Number(rawIndex);
55
+ const start = seconds(values.get("pts_time")) ?? seconds(values.get("dts_time"));
56
+ if (start === undefined)
57
+ return;
58
+ const end = start + Math.max(0, seconds(values.get("duration_time")) ?? 0);
59
+ const span = spans.get(index);
60
+ if (span) {
61
+ if (start < span.start)
62
+ span.start = start;
63
+ if (end > span.end)
64
+ span.end = end;
65
+ }
66
+ else if (spans.size >= MAX_TRACKED_STREAMS) {
67
+ overflow = true;
68
+ }
69
+ else {
70
+ spans.set(index, { start, end });
71
+ }
72
+ }
73
+ else if (section === "stream") {
74
+ const rawIndex = values.get("index");
75
+ if (rawIndex === undefined || !STREAM_INDEX.test(rawIndex))
76
+ return;
77
+ const type = values.get("codec_type");
78
+ const coverArt = values.get("disposition:attached_pic") === "1";
79
+ if (type === "audio" || (type === "video" && !coverArt))
80
+ timed.add(Number(rawIndex));
81
+ }
82
+ },
83
+ lengthSec() {
84
+ if (overflow)
85
+ return undefined;
86
+ let start = Infinity;
87
+ let end = -Infinity;
88
+ for (const index of timed) {
89
+ const span = spans.get(index);
90
+ if (!span)
91
+ continue;
92
+ if (span.start < start)
93
+ start = span.start;
94
+ if (span.end > end)
95
+ end = span.end;
96
+ }
97
+ // Packets before zero are encoder pre-roll (AAC priming, Opus pre-skip)
98
+ // that a player discards, so they add no playable length.
99
+ const sec = Math.round((end - Math.max(0, start)) * 1_000_000) / 1_000_000;
100
+ return Number.isFinite(sec) && sec > 0 ? sec : undefined;
101
+ },
102
+ };
103
+ }
104
+ /** The file's length in seconds, or `undefined` when it cannot be measured. */
105
+ export async function measureDurationSec(localPath, opts) {
106
+ const fold = createPacketLengthFold();
107
+ let r;
108
+ try {
109
+ r = await runCmd("ffprobe", [
110
+ "-v",
111
+ "error",
112
+ "-show_entries",
113
+ "stream=index,codec_type:stream_disposition=attached_pic:packet=stream_index,pts_time,dts_time,duration_time",
114
+ "-of",
115
+ "compact",
116
+ localPath,
117
+ ], {
118
+ timeoutMs: opts?.timeoutMs ?? DEFAULT_DURATION_MEASURE_TIMEOUT_MS,
119
+ signal: opts?.signal,
120
+ onStdoutLine: (line) => fold.add(line),
121
+ });
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ if (r.timedOut || r.truncated || r.code !== 0)
127
+ return undefined;
128
+ return fold.lengthSec();
129
+ }
130
+ /** The refusal for a file with no length under a spec that caps length: a
131
+ * length that cannot be measured cannot be shown to be within the cap. The
132
+ * code is `unsupported_format`, never `too_long` — the file is not known to
133
+ * be too long, and its copy must not say so. */
134
+ export function unreadableLengthError(kind, maxDurationSec) {
135
+ return {
136
+ code: "unsupported_format",
137
+ message: kind === "video" ? "The video's length could not be read." : "The audio's length could not be read.",
138
+ details: { maxDurationSec },
139
+ };
140
+ }
141
+ //# sourceMappingURL=media-duration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-duration.js","sourceRoot":"","sources":["../../src/duration/media-duration.ts"],"names":[],"mappings":"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","sourcesContent":["// The ONE rule for a timed media file's length (ENG-002). The inspection's\n// `durationSec` and both processors' too-long checks read it here, so they can\n// never disagree about the same file.\n//\n// 1. The length is always MEASURED from the packets themselves: ffprobe\n// lists every packet's timestamp and duration (demux only, no decode),\n// and the length is the span from the earliest packet start (never before\n// zero) to the latest packet end across the audio and video streams. A\n// container header's declared length (`format.duration`) is never used:\n// a header can claim a short length for a long file, and Chromium's\n// MediaRecorder writes WebM with none. ffmpeg's `-progress` output time\n// is not used either: it trails the true end by the B-frame reorder\n// delay. The listing is read one line at a time and folded into a few\n// numbers per stream, so a file of any packet count is measured in\n// bounded memory.\n// 2. A measure that fails, times out, is aborted, is cut short, or finds no\n// timed packet is NO length. Every caller treats that as unknown and\n// fails closed.\n\nimport type { MediaProcessingError } from \"@ttt-productions/media-schemas\";\nimport { runCmd } from \"../video/ffmpeg.js\";\n\n/** Wall-clock ceiling for the measuring pass; it bounds an adversarial input,\n * not a real upload (a 920 MB, 30-minute WebM measures in about a second). */\nexport const DEFAULT_DURATION_MEASURE_TIMEOUT_MS = 120_000;\n\n/** More streams than this is an adversarial file, and is no length. */\nconst MAX_TRACKED_STREAMS = 256;\n\nconst SECONDS = /^-?\\d+(\\.\\d+)?$/;\nconst STREAM_INDEX = /^\\d+$/;\n\nfunction seconds(raw: string | undefined): number | undefined {\n if (raw === undefined || !SECONDS.test(raw)) return undefined;\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n}\n\nfunction fields(line: string): { section: string; values: Map<string, string> } {\n const [section = \"\", ...pairs] = line.split(\"|\");\n const values = new Map<string, string>();\n for (const pair of pairs) {\n const eq = pair.indexOf(\"=\");\n if (eq > 0) values.set(pair.slice(0, eq), pair.slice(eq + 1).trim());\n }\n return { section, values };\n}\n\nexport interface PacketLengthFold {\n /** One line of ffprobe's `-of compact` packet and stream listing. */\n add(line: string): void;\n /** Seconds from the earliest packet start (never before zero) to the latest\n * packet end across the timed (audio, and non-cover-art video) streams, or\n * `undefined`. */\n lengthSec(): number | undefined;\n}\n\nexport function createPacketLengthFold(): PacketLengthFold {\n const spans = new Map<number, { start: number; end: number }>();\n const timed = new Set<number>();\n let overflow = false;\n\n return {\n add(line) {\n const { section, values } = fields(line);\n if (section === \"packet\") {\n const rawIndex = values.get(\"stream_index\");\n if (rawIndex === undefined || !STREAM_INDEX.test(rawIndex)) return;\n const index = Number(rawIndex);\n const start = seconds(values.get(\"pts_time\")) ?? seconds(values.get(\"dts_time\"));\n if (start === undefined) return;\n const end = start + Math.max(0, seconds(values.get(\"duration_time\")) ?? 0);\n const span = spans.get(index);\n if (span) {\n if (start < span.start) span.start = start;\n if (end > span.end) span.end = end;\n } else if (spans.size >= MAX_TRACKED_STREAMS) {\n overflow = true;\n } else {\n spans.set(index, { start, end });\n }\n } else if (section === \"stream\") {\n const rawIndex = values.get(\"index\");\n if (rawIndex === undefined || !STREAM_INDEX.test(rawIndex)) return;\n const type = values.get(\"codec_type\");\n const coverArt = values.get(\"disposition:attached_pic\") === \"1\";\n if (type === \"audio\" || (type === \"video\" && !coverArt)) timed.add(Number(rawIndex));\n }\n },\n lengthSec() {\n if (overflow) return undefined;\n let start = Infinity;\n let end = -Infinity;\n for (const index of timed) {\n const span = spans.get(index);\n if (!span) continue;\n if (span.start < start) start = span.start;\n if (span.end > end) end = span.end;\n }\n // Packets before zero are encoder pre-roll (AAC priming, Opus pre-skip)\n // that a player discards, so they add no playable length.\n const sec = Math.round((end - Math.max(0, start)) * 1_000_000) / 1_000_000;\n return Number.isFinite(sec) && sec > 0 ? sec : undefined;\n },\n };\n}\n\nexport interface MeasureDurationOptions {\n timeoutMs?: number;\n signal?: AbortSignal;\n}\n\n/** The file's length in seconds, or `undefined` when it cannot be measured. */\nexport async function measureDurationSec(localPath: string, opts?: MeasureDurationOptions): Promise<number | undefined> {\n const fold = createPacketLengthFold();\n let r;\n try {\n r = await runCmd(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-show_entries\",\n \"stream=index,codec_type:stream_disposition=attached_pic:packet=stream_index,pts_time,dts_time,duration_time\",\n \"-of\",\n \"compact\",\n localPath,\n ],\n {\n timeoutMs: opts?.timeoutMs ?? DEFAULT_DURATION_MEASURE_TIMEOUT_MS,\n signal: opts?.signal,\n onStdoutLine: (line) => fold.add(line),\n },\n );\n } catch {\n return undefined;\n }\n if (r.timedOut || r.truncated || r.code !== 0) return undefined;\n return fold.lengthSec();\n}\n\nexport type MeasureDurationFn = (localPath: string, opts?: MeasureDurationOptions) => Promise<number | undefined>;\n\n/** The refusal for a file with no length under a spec that caps length: a\n * length that cannot be measured cannot be shown to be within the cap. The\n * code is `unsupported_format`, never `too_long` — the file is not known to\n * be too long, and its copy must not say so. */\nexport function unreadableLengthError(kind: \"video\" | \"audio\", maxDurationSec: number): MediaProcessingError {\n return {\n code: \"unsupported_format\",\n message: kind === \"video\" ? \"The video's length could not be read.\" : \"The audio's length could not be read.\",\n details: { maxDurationSec },\n };\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./audio/audio-processor.js";
9
9
  export * from "./io/types.js";
10
10
  export * from "./run-pipeline.js";
11
11
  export * from "./inspection/inspect-media.js";
12
+ export * from "./duration/media-duration.js";
12
13
  export * from "./io/fs.js";
13
14
  export * from "./io/local-input.js";
14
15
  export * from "./moderation/types.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,+BAA+B,CAAC;AAE9C,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AAEpC,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;AAC9C,cAAc,8BAA8B,CAAC;AAE7C,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AAEpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./audio/audio-processor.js";
9
9
  export * from "./io/types.js";
10
10
  export * from "./run-pipeline.js";
11
11
  export * from "./inspection/inspect-media.js";
12
+ export * from "./duration/media-duration.js";
12
13
  export * from "./io/fs.js";
13
14
  export * from "./io/local-input.js";
14
15
  export * from "./moderation/types.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,+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\";"]}
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;AAC9C,cAAc,8BAA8B,CAAC;AAE7C,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\";\nexport * from \"./duration/media-duration.js\";\n\nexport * from \"./io/fs.js\";\nexport * from \"./io/local-input.js\";\n\nexport * from \"./moderation/types.js\";\nexport * from \"./moderation/merge.js\";"]}
@@ -1,6 +1,7 @@
1
1
  import { type MediaInspectionResult, type MediaFormatId } from "@ttt-productions/media-schemas";
2
+ import { type MeasureDurationFn } from "../duration/media-duration.js";
2
3
  /** Bump on any classification-rule change (rides every decision event). */
3
- export declare const INSPECTOR_VERSION = "mpc-inspect-1";
4
+ export declare const INSPECTOR_VERSION = "mpc-inspect-2";
4
5
  type SignatureFamily = {
5
6
  family: "image";
6
7
  formatId: MediaFormatId;
@@ -43,6 +44,9 @@ export interface InspectMediaDeps {
43
44
  decodeImage?: (localPath: string) => Promise<{
44
45
  frames: number;
45
46
  } | null>;
47
+ /** Measure the file's length; default reads its packet timestamps
48
+ * (`measureDurationSec`). */
49
+ measureDuration?: MeasureDurationFn;
46
50
  /** Injected ffprobe version (default: cached `ffprobe -version` first line). */
47
51
  ffprobeVersion?: string;
48
52
  }
@@ -1 +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"}
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;AAExC,OAAO,EAAsB,KAAK,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAE3F,2EAA2E;AAC3E,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAwDjD,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;kCAC8B;IAC9B,eAAe,CAAC,EAAE,iBAAiB,CAAC;IACpC,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AA0GD,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"}
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // Canonical media content inspection (canonical-upload-content-classification)
2
+ // Canonical media content inspection
3
3
  // =============================================================================
4
4
  //
5
5
  // The ONE server-side classification authority. Consumes a LOCAL file (the
@@ -11,11 +11,11 @@
11
11
  // filename, extension, container brand, and payload-byte heuristics are never
12
12
  // consulted.
13
13
  //
14
- // Design (reviewed 2026-07-31; see the consuming app's
15
- // CODE_CHANGE_canonical_upload_content_classification.md):
14
+ // Design:
16
15
  // - 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);
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
19
  // - shared A/V containers (EBML/ISO-BMFF/Ogg/AVI) classified by a hardened
20
20
  // `ffprobe` stream table (libavformat's structural parsers), with timeout,
21
21
  // kill-on-abort, output cap, and strict schema validation;
@@ -28,11 +28,11 @@
28
28
  import { open } from "node:fs/promises";
29
29
  import { z } from "zod";
30
30
  import { MediaInspectionResultSchema, } from "@ttt-productions/media-schemas";
31
- import { runCmd } from "../video/ffmpeg.js";
31
+ import { DEFAULT_PROBE_TIMEOUT_MS, runCmd } from "../video/ffmpeg.js";
32
+ import { measureDurationSec } from "../duration/media-duration.js";
32
33
  /** Bump on any classification-rule change (rides every decision event). */
33
- export const INSPECTOR_VERSION = "mpc-inspect-1";
34
+ export const INSPECTOR_VERSION = "mpc-inspect-2";
34
35
  const SIGNATURE_READ_BYTES = 64 * 1024;
35
- const DEFAULT_PROBE_TIMEOUT_MS = 20_000;
36
36
  const PROBE_SIZE_BYTES = 4 * 1024 * 1024; // -probesize: metadata, not playback
37
37
  const ANALYZE_DURATION_US = 10 * 1000 * 1000;
38
38
  // ---- ffprobe JSON boundary (strict; anything else is malformed) ----
@@ -44,8 +44,6 @@ const ProbeStreamSchema = z
44
44
  .object({ attached_pic: z.number().int().min(0).max(1).optional() })
45
45
  .loose()
46
46
  .optional(),
47
- nb_frames: z.string().max(20).optional(),
48
- duration: z.string().max(32).optional(),
49
47
  })
50
48
  .loose();
51
49
  const ProbeJsonSchema = z
@@ -213,6 +211,7 @@ function baseResult(partial, ffprobeVersion) {
213
211
  reasonCode: partial.reasonCode,
214
212
  ...(partial.canonicalKind ? { canonicalKind: partial.canonicalKind } : {}),
215
213
  ...(partial.formatId ? { formatId: partial.formatId } : {}),
214
+ ...(partial.durationSec !== undefined ? { durationSec: partial.durationSec } : {}),
216
215
  };
217
216
  return MediaInspectionResultSchema.parse(result);
218
217
  }
@@ -253,8 +252,8 @@ export async function inspectMedia(args) {
253
252
  return indeterminate("unknown", "unrecognized_signature", ffprobeVersion);
254
253
  case "svg":
255
254
  // 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.
255
+ // processable format: it can carry script and no sanitize/rasterize step
256
+ // exists. Never classified by decoder capability alone.
258
257
  return baseResult({
259
258
  status: "unsupported",
260
259
  container: "svg",
@@ -372,6 +371,9 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
372
371
  }
373
372
  }
374
373
  const codecs = { audio: [...audioCodecs], video: [...videoCodecs] };
374
+ const measure = args.deps?.measureDuration ?? measureDurationSec;
375
+ const durationSec = await measure(args.localPath, { signal: args.signal });
376
+ const duration = durationSec !== undefined ? { durationSec } : {};
375
377
  // classification table (fail-closed)
376
378
  if (counts.timedVideo > 0) {
377
379
  return baseResult({
@@ -381,6 +383,7 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
381
383
  ...(formatId ? { formatId } : {}),
382
384
  streams: counts,
383
385
  codecs,
386
+ ...duration,
384
387
  safetyPlan: "video-frames",
385
388
  reasonCode: "ok",
386
389
  }, ffprobeVersion);
@@ -393,6 +396,7 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
393
396
  ...(formatId ? { formatId } : {}),
394
397
  streams: counts,
395
398
  codecs,
399
+ ...duration,
396
400
  safetyPlan: counts.attachedPictures > 0 ? "audio-plus-artwork" : "audio-only",
397
401
  reasonCode: "ok",
398
402
  }, ffprobeVersion);
@@ -405,6 +409,7 @@ async function classifyAvContainer(args, container, formatId, probe, timeoutMs,
405
409
  ...(formatId ? { formatId } : {}),
406
410
  streams: counts,
407
411
  codecs,
412
+ ...duration,
408
413
  safetyPlan: "strict-video-fallback",
409
414
  reasonCode: sawUnknownType ? "unknown_stream_type" : "auxiliary_only",
410
415
  }, ffprobeVersion);