1
20import { readFile } from "node:fs/promises";
21import { execFile } from "node:child_process";
22import { promisify } from "node:util";
23import { getLogger } from "../../../logger.js";
24import { getSharp } from "../../../lib/sharp.js";
25import type { FilePipelineContext } from "../types.js";
26
27const log = getLogger("files.metadata");
28const pExecFile = promisify(execFile);
29
30
31const MAX_INPUT_PIXELS = 16 * 1024 * 1024;
32const PDF_PAGE_CAP = 5_000;
33const FFPROBE_TIMEOUT_MS = 30_000;
34const PARSER_TIMEOUT_MS = 10_000;
35
36
37async function withTimeout<T>(
38 fn: () => Promise<T>,
39 ms: number,
40 fallback: T
41): Promise<T> {
42 return await Promise.race([
43 fn(),
44 new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms).unref())
45 ]);
46}
47
48interface SharpMeta {
49 width?: number;
50 height?: number;
51 orientation?: number;
52}
53
54
55async function imageMeta(ctx: FilePipelineContext): Promise<void> {
56 const sharp = await getSharp();
57
58
59 const res: SharpMeta | null = await withTimeout(
60 () =>
61 sharp(ctx.temp.path, { limitInputPixels: MAX_INPUT_PIXELS })
62 .rotate()
63 .metadata(),
64 PARSER_TIMEOUT_MS,
65 null
66 );
67 if (res && res.width && res.height) {
68 ctx.extracted.dimensions = {
69 width: res.width,
70 height: res.height,
71 orientation: res.orientation ?? 1
72 };
73 } else {
74 ctx.warnings.push("image: could not decode dimensions (sharp)");
75 }
76}
77
78
79async function mediaMeta(ctx: FilePipelineContext): Promise<void> {
80
81
82
83
84
85 const out = await withTimeout(
86 async () => (await pExecFile(
87 "ffprobe",
88 ["-v", "error", "-show_streams", "-show_format", "-of", "json", ctx.temp.path],
89 { timeout: FFPROBE_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 }
90 )).stdout,
91 FFPROBE_TIMEOUT_MS,
92 ""
93 );
94 if (!out) {
95 ctx.warnings.push("media: ffprobe timed out or unavailable");
96 return;
97 }
98 try {
99 const parsed = JSON.parse(out) as {
100 format?: { duration?: string };
101 streams?: Array<{ codec_name?: string; codec_type?: string }>;
102 };
103 const dur = parseFloat(parsed.format?.duration ?? "NaN");
104 const videoStream = (parsed.streams ?? []).find((s) => s.codec_type === "video");
105 const audioStream = (parsed.streams ?? []).find((s) => s.codec_type === "audio");
106 const codec = videoStream?.codec_name ?? audioStream?.codec_name ?? "unknown";
107 if (Number.isFinite(dur)) {
108 ctx.extracted.media = { duration: dur, codec };
109 } else {
110 ctx.warnings.push("media: ffprobe returned no duration");
111 }
112 } catch (err) {
113 ctx.warnings.push(`media: ffprobe output unparseable (${(err as Error).message})`);
114 }
115}
116
117
118async function pdfMeta(ctx: FilePipelineContext): Promise<void> {
119 type PdfDoc = import("pdfjs-dist").PDFDocumentProxy;
120 const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
121 const data = new Uint8Array(await readFile(ctx.temp.path));
122 const loadingTask = getDocument({
123 data,
124 useSystemFonts: true,
125 disableFontFace: true,
126 });
127 const doc: PdfDoc | null = await withTimeout(
128 () => loadingTask.promise,
129 PARSER_TIMEOUT_MS * 3,
130 null
131 );
132 if (!doc) {
133 ctx.warnings.push("pdf: could not count pages (pdfjs timeout/undecodable)");
134 return;
135 }
136 ctx.extracted.pdf = { pages: doc.numPages };
137 if (doc.numPages > PDF_PAGE_CAP) {
138 ctx.warnings.push(`pdf: page count ${doc.numPages} exceeds cap ${PDF_PAGE_CAP} — no rasterize`);
139 }
140}
141
142
143export async function metadataStep(ctx: FilePipelineContext, sniffedMime: string): Promise<void> {
144 ctx.extracted.sniffedMime = sniffedMime;
145 try {
146 if (sniffedMime.startsWith("image/")) await imageMeta(ctx);
147 else if (sniffedMime.startsWith("video/")) await mediaMeta(ctx);
148 else if (sniffedMime === "application/pdf") await pdfMeta(ctx);
149 else {
150
151 if (sniffedMime.startsWith("audio/")) await mediaMeta(ctx);
152 }
153 } catch (err) {
154
155
156 ctx.warnings.push(`metadata extraction failed: ${(err as Error).message}`);
157 log.warn({ fileId: ctx.job.fileId, err: (err as Error).message }, "metadata extraction failed (non-fatal)");
158 }
159}
160