1
27import { execFile } from "node:child_process";
28import { promisify } from "node:util";
29import { unlink } from "node:fs/promises";
30import { randomUUID } from "node:crypto";
31import { planThumbnails } from "@factory/storage";
32import { getSharp } from "../../lib/sharp.js";
33import { getLogger } from "../../logger.js";
34import { FatalError } from "./errors.js";
35
36const log = getLogger("files.thumbs");
37const pExecFile = promisify(execFile);
38
39
40export interface ThumbArtifact {
41 key: string;
42 body: Buffer;
43 mime: string;
44}
45
46export interface GenerateThumbsArgs {
47
48 sourcePath: string;
49
50 mime: string;
51 workspaceId: string;
52 fileId: string;
53
54 name: string;
55
56 sourceWidth?: number;
57}
58
59
67export async function generateThumbs(
68 args: GenerateThumbsArgs
69): Promise<{ artifacts: ThumbArtifact[]; primaryKey: string | null }> {
70 const plan = planThumbnails({
71 mime: args.mime,
72 workspaceId: args.workspaceId,
73 fileId: args.fileId,
74 name: args.name,
75 ...(args.sourceWidth !== undefined ? { sourceWidth: args.sourceWidth } : {})
76 });
77 if (plan.length === 0) return { artifacts: [], primaryKey: null };
78
79 const artifacts: ThumbArtifact[] = [];
80 for (const item of plan) {
81 let body: Buffer;
82 if (item.source === "image") {
83 body = await renderImage(args.sourcePath, item.targetWidth, item.mime);
84 } else if (item.source === "pdf-page-1") {
85 body = await renderPdfPage1(args.sourcePath, item.targetWidth);
86 } else {
87 body = await renderVideoFrame(args.sourcePath, item.targetWidth);
88 }
89 artifacts.push({ key: item.key, body, mime: item.mime });
90 }
91
92 const primaryKey =
93 artifacts.find((a) => a.key.includes("/480/"))?.key ??
94 artifacts[0]?.key ??
95 null;
96 log.info(
97 {
98 fileId: args.fileId,
99 count: artifacts.length,
100 primaryKey,
101 bytes: artifacts.reduce((n, a) => n + a.body.length, 0)
102 },
103 "thumbnails generated"
104 );
105 return { artifacts, primaryKey };
106}
107
108
109
110
111async function renderImage(sourcePath: string, targetWidth: number, format: string): Promise<Buffer> {
112 const sharp = await getSharp();
113 const formatName = outputFormatName(format);
114 const pipe = sharp(sourcePath)
115 .rotate()
116 .resize({ width: targetWidth, withoutEnlargement: true });
117 try {
118 return await pipe.toFormat(formatName).toBuffer();
119 } catch (err) {
120 throw new FatalError(
121 `image thumbnail failed: ${(err as Error).message}`,
122 "corrupt_object"
123 );
124 }
125}
126
127
128function outputFormatName(mime: string): string {
129 const sub = mime.split("/")[1] ?? "png";
130 const name = sub.split(";")[0]!.trim().toLowerCase();
131
132 return name === "jpg" ? "jpeg" : name;
133}
134
135
136
137
138async function renderPdfPage1(sourcePath: string, targetWidth: number): Promise<Buffer> {
139 const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
140 const { createCanvas } = await import("canvas");
141 const sharp = await getSharp();
142 const { readFile } = await import("node:fs/promises");
143
144 const data = new Uint8Array(await readFile(sourcePath));
145 const doc = await getDocument({
146 data,
147 useSystemFonts: true,
148 disableFontFace: true,
149 }).promise;
150 try {
151 const page = await doc.getPage(1);
152 const baseW = page.getViewport({ scale: 1 }).width || 1;
153 const vp = page.getViewport({ scale: targetWidth / baseW });
154 const canvas = createCanvas(Math.max(1, Math.floor(vp.width)), Math.max(1, Math.floor(vp.height)));
155 await page.render({
156 canvasContext: canvas.getContext("2d"),
157 viewport: vp
158 } as never).promise;
159
160
161 const png = await sharp(canvas.toBuffer("image/png")).png().toBuffer();
162 return png;
163 } catch (err) {
164 throw new FatalError(
165 `pdf thumbnail failed: ${(err as Error).message}`,
166 "corrupt_object"
167 );
168 }
169}
170
171
172
173
174async function renderVideoFrame(sourcePath: string, targetWidth: number): Promise<Buffer> {
175 const sharp = await getSharp();
176 const tmpPng = `/tmp/thumb-frame-${randomUUID()}.png`;
177 try {
178 await pExecFile(
179 "ffmpeg",
180 [
181 "-v", "error",
182 "-ss", "0",
183 "-i", sourcePath,
184 "-frames:v", "1",
185 "-q:v", "2",
186 "-y",
187 tmpPng
188 ],
189 { timeout: 30_000, maxBuffer: 1024 * 1024 }
190 );
191 const sharpInstance = sharp(tmpPng)
192 .rotate()
193 .resize({ width: targetWidth, withoutEnlargement: true });
194 return await sharpInstance.png().toBuffer();
195 } catch (err) {
196
197
198 const msg = err instanceof Error ? err.message : String(err);
199 throw new FatalError(`video thumbnail failed: ${msg}`, "corrupt_object");
200 } finally {
201 await unlink(tmpPng).catch(() => {});
202 }
203}
204