/** * apps/worker — thumbnail generation (TASK-0102 §5). * * Consumes the PURE plan from `@factory/storage` `planThumbnails` (keys, * target widths, output mime, `source` kind) and does the REAL pixel work the * storage package deliberately doesn't ship (its header reads only PNG/JPEG): * * - image → sharp resize (same output format; webp/avif/heic/tiff * rasterized to PNG — the plan's `mime` for those is the * rasterized format, so we follow the plan, never the * source container) * - pdf-page-1 → pdfjs-dist (legacy build) renders page 1 to a * node-canvas canvas; sharp normalizes to 1280w PNG * - video-first-frame → ffmpeg `-frames:v 1` PNG; sharp normalizes to 1280w * * Every artifact is written to the `thumbs` bucket by the caller * (finalize/thumbnails step) at its planned key — key-idempotent (re-running * a READY file's job is a no-op upstream, so these keys are never overwritten * with different bytes). `File.thumbnailKey` = the 480 variant (primary UI * thumb); `File.thumbnailUrl` is regenerated on read (never stored). * * Licensing (AGENTS.md rule 17): `sharp` (Apache-2.0 + bundled libvips — * image decode/resize), `pdfjs-dist` (Apache-2.0 — PDF page model + render), * `canvas` (MIT — the canvas2d context pdfjs renders into), `ffmpeg` * (LGPL, system binary in the worker image — video frame grab). */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { unlink } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { planThumbnails } from "@factory/storage"; import { getSharp } from "../../lib/sharp.js"; import { getLogger } from "../../logger.js"; import { FatalError } from "./errors.js"; const log = getLogger("files.thumbs"); const pExecFile = promisify(execFile); /** One produced artifact (key in the thumbs bucket + its bytes). */ export interface ThumbArtifact { key: string; body: Buffer; mime: string; } export interface GenerateThumbsArgs { /** Path to the on-disk copy of the object. */ sourcePath: string; /** The re-sniffed (authoritative) mime. */ mime: string; workspaceId: string; fileId: string; /** Original name (stem is derived inside planThumbnails). */ name: string; /** Source image width (skips upscale — from the metadata step). */ sourceWidth?: number; } /** * Produce every planned thumbnail. Returns the artifacts (NOT yet uploaded) * — the caller writes them (keeps this module I/O-light for tests). * * Throws FatalError on a corrupt/undecodable source (the object's kind was * identified by magic bytes, so a decode failure here is a permanent, * non-retryable condition — the row goes FAILED). */ export async function generateThumbs( args: GenerateThumbsArgs ): Promise<{ artifacts: ThumbArtifact[]; primaryKey: string | null }> { const plan = planThumbnails({ mime: args.mime, workspaceId: args.workspaceId, fileId: args.fileId, name: args.name, ...(args.sourceWidth !== undefined ? { sourceWidth: args.sourceWidth } : {}) }); if (plan.length === 0) return { artifacts: [], primaryKey: null }; const artifacts: ThumbArtifact[] = []; for (const item of plan) { let body: Buffer; if (item.source === "image") { body = await renderImage(args.sourcePath, item.targetWidth, item.mime); } else if (item.source === "pdf-page-1") { body = await renderPdfPage1(args.sourcePath, item.targetWidth); } else { body = await renderVideoFrame(args.sourcePath, item.targetWidth); } artifacts.push({ key: item.key, body, mime: item.mime }); } const primaryKey = artifacts.find((a) => a.key.includes("/480/"))?.key ?? artifacts[0]?.key ?? null; log.info( { fileId: args.fileId, count: artifacts.length, primaryKey, bytes: artifacts.reduce((n, a) => n + a.body.length, 0) }, "thumbnails generated" ); return { artifacts, primaryKey }; } // ── image (sharp) ─────────────────────────────────────────────────────────── /** Resize an image to `targetWidth` in `format` (e.g. "jpeg", "webp", "png"). */ async function renderImage(sourcePath: string, targetWidth: number, format: string): Promise { const sharp = await getSharp(); const formatName = outputFormatName(format); const pipe = sharp(sourcePath) .rotate() // honor EXIF orientation .resize({ width: targetWidth, withoutEnlargement: true }); try { return await pipe.toFormat(formatName).toBuffer(); } catch (err) { throw new FatalError( `image thumbnail failed: ${(err as Error).message}`, "corrupt_object" ); } } /** The plan's mime → sharp format name (mime's subtype minus parameters). */ function outputFormatName(mime: string): string { const sub = mime.split("/")[1] ?? "png"; const name = sub.split(";")[0]!.trim().toLowerCase(); // sharp understands "jpeg" (not "jpg"). return name === "jpg" ? "jpeg" : name; } // ── PDF first page (pdfjs + node-canvas → sharp) ──────────────────────────── /** Render PDF page 1 to a PNG of `targetWidth` width. */ async function renderPdfPage1(sourcePath: string, targetWidth: number): Promise { const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs"); const { createCanvas } = await import("canvas"); const sharp = await getSharp(); const { readFile } = await import("node:fs/promises"); const data = new Uint8Array(await readFile(sourcePath)); const doc = await getDocument({ data, useSystemFonts: true, disableFontFace: true, }).promise; try { const page = await doc.getPage(1); const baseW = page.getViewport({ scale: 1 }).width || 1; const vp = page.getViewport({ scale: targetWidth / baseW }); const canvas = createCanvas(Math.max(1, Math.floor(vp.width)), Math.max(1, Math.floor(vp.height))); await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp } as never).promise; // The canvas is already ~targetWidth; sharp normalizes exactly (and // de-alpha-composites to a clean PNG). const png = await sharp(canvas.toBuffer("image/png")).png().toBuffer(); return png; } catch (err) { throw new FatalError( `pdf thumbnail failed: ${(err as Error).message}`, "corrupt_object" ); } } // ── video first frame (ffmpeg → sharp) ────────────────────────────────────── /** Grab the first video frame as a PNG of `targetWidth` width. */ async function renderVideoFrame(sourcePath: string, targetWidth: number): Promise { const sharp = await getSharp(); const tmpPng = `/tmp/thumb-frame-${randomUUID()}.png`; try { await pExecFile( "ffmpeg", [ "-v", "error", "-ss", "0", "-i", sourcePath, "-frames:v", "1", "-q:v", "2", "-y", tmpPng ], { timeout: 30_000, maxBuffer: 1024 * 1024 } ); const sharpInstance = sharp(tmpPng) .rotate() .resize({ width: targetWidth, withoutEnlargement: true }); return await sharpInstance.png().toBuffer(); } catch (err) { // ffmpeg exits non-zero with stderr when the stream has no video frame // (audio-only file claimed as video, or corrupt container). const msg = err instanceof Error ? err.message : String(err); throw new FatalError(`video thumbnail failed: ${msg}`, "corrupt_object"); } finally { await unlink(tmpPng).catch(() => {}); } }