/** * @factory/storage — thumbnail/preview planning (PURE) + a real, dependency-free * PNG path (encoder + header parser + JPEG SOF scanner + metadata extraction). * * Two distinct jobs: * * 1. PLANNING — `planThumbnails(args)` decides WHICH artifacts the pipeline * must produce (keys in the `thumbs/` bucket layout, target widths). * Pure; the plan is the contract the TASK-0102 worker executes: * * - images → 3 sizes (160 / 480 / 1280 px wide) in the SAME format, * keys `thumbs/{ws}/{fileId}/{size}/{safeName}.{ext}` (sizes skipped * when the source is smaller — never upscale) * - pdf → first-page PNG (1280w) * - video → first-frame PNG (1280w) * - audio/other → nothing (no thumbnail) * * 2. THE PNG PATH — a fully working, dependency-free PNG encoder * (IHDR/IDAT via node:zlib deflateSync/IEND + CRC32) with nearest-neighbor * downscaling, plus a PNG header parser and a JPEG SOF dimension scanner. * `computeMetadata(mime, buf, filenameHint)` is the metadata-extraction * pipeline step as a pure function over bytes: * mimeType (magic bytes) + dimensions + sha256 + isText. * * Real pixel work for non-PNG inputs (webp/avif/heic decode, pdf page-1 * render, video first frame) is the worker's job — it needs image codecs * (sharp / pdfjs / ffmpeg) this package deliberately does not ship. */ import { deflateSync } from "node:zlib"; import { createHash } from "node:crypto"; import { isImageMime, isVideoMime, detectMimeType } from "./mime.js"; import { buildThumbKey, THUMBNAIL_SIZES, THUMBNAIL_SIZE_WIDTH, type ThumbnailSize, } from "./keys.js"; // ── planning ──────────────────────────────────────────────────────────────── /** Output format per source image mime ("same format" — images only). */ const IMAGE_OUTPUT_EXT: Record = { "image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp", "image/gif": "gif", "image/bmp": "bmp", "image/svg+xml": "png", // rasterized "image/avif": "png", // rasterized "image/heic": "png", // rasterized "image/tiff": "png", // rasterized }; /** * Decide which thumbnail artifacts to generate for a file (TASK-0102 §5): * * - images → 3 sizes (160/480/1280 px wide, same format, max 1280) at * `thumbs/{ws}/{fileId}/{size}/{safeName}.{ext}`; a size is * skipped when the source width is ≤ the target (no upscale). * - pdf → one first-page PNG (1280w) at `thumbs/{ws}/{fileId}/1280/…`. * - video → one first-frame PNG (1280w). * - audio/other → [] (no thumbnail — `File.thumbnailKey` stays null). * * PURE — same args ⇒ same plan, no clock, no I/O. */ export function planThumbnails(args: { mime: string; workspaceId: string; fileId: string; /** Original file name (sanitized inside {@link buildThumbKey}). */ name: string; /** Source image width in px (skips sizes that would upscale). */ sourceWidth?: number; }): Array<{ size: ThumbnailSize; /** S3 key in the `thumbs` bucket. */ key: string; /** Target width in px. */ targetWidth: number; /** Output MIME. */ mime: string; /** What the worker must do to produce the artifact. */ source: "image" | "pdf-page-1" | "video-first-frame"; }> { const { mime, workspaceId, fileId, sourceWidth } = args; // Artifact name = source STEM + artifact extension: the original // extension is REPLACED by the output format (`c.jpg` → `c.jpeg`, // `doc.pdf` → `doc.png`) — never appended after it. const dot = args.name.lastIndexOf("."); const stem = dot > 0 ? args.name.slice(0, dot) : args.name; if (isImageMime(mime)) { const ext = IMAGE_OUTPUT_EXT[mime] ?? "png"; const plan: ReturnType = []; for (const size of THUMBNAIL_SIZES) { const width = THUMBNAIL_SIZE_WIDTH[size]; if (sourceWidth !== undefined && sourceWidth > 0 && sourceWidth <= width) { continue; // never upscale } plan.push({ size, key: buildThumbKey({ workspaceId, fileId, size, name: stem, ext }), targetWidth: width, mime, source: "image", }); } return plan; } if (mime === "application/pdf") { return [ { size: "1280", key: buildThumbKey({ workspaceId, fileId, size: "1280", name: stem, ext: "png" }), targetWidth: THUMBNAIL_SIZE_WIDTH["1280"], mime: "image/png", source: "pdf-page-1", }, ]; } if (isVideoMime(mime)) { return [ { size: "1280", key: buildThumbKey({ workspaceId, fileId, size: "1280", name: stem, ext: "png" }), targetWidth: THUMBNAIL_SIZE_WIDTH["1280"], mime: "image/png", source: "video-first-frame", }, ]; } return []; // audio / documents / other: no thumbnail } // ── CRC32 (PURE — needed for every PNG chunk) ────────────────────────────── const CRC_TABLE: readonly number[] = (() => { const table = new Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } table[n] = c >>> 0; } return table; })(); /** Standard (IEEE 802.3) CRC-32, as used by PNG. */ export function crc32(bytes: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) { c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8); } return (c ^ 0xffffffff) >>> 0; } // ── PNG chunk plumbing ────────────────────────────────────────────────────── function chunk(type: string, data: Uint8Array): Uint8Array { const out = new Uint8Array(12 + data.length); const view = new DataView(out.buffer); view.setUint32(0, data.length); const typeBytes = new TextEncoder().encode(type); out.set(typeBytes, 4); out.set(data, 8); // CRC covers type + data. view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); return out; } function concat(bytesList: readonly Uint8Array[]): Uint8Array { let total = 0; for (const b of bytesList) total += b.length; const out = new Uint8Array(total); let off = 0; for (const b of bytesList) { out.set(b, off); off += b.length; } return out; } /** IHDR payload: width, height, bitDepth, colorType, compression, filter, interlace. */ function ihdrData(width: number, height: number, bitDepth = 8, colorType = 6): Uint8Array { const data = new Uint8Array(13); const view = new DataView(data.buffer); view.setUint32(0, width); view.setUint32(4, height); view.setUint8(8, bitDepth); view.setUint8(9, colorType); view.setUint8(10, 0); // compression: deflate view.setUint8(11, 0); // filter: none view.setUint8(12, 0); // interlace: none return data; } const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** * Encode an RGBA pixel buffer as PNG bytes. * * - Rows are prefixed with filter byte 0 (None) and deflated (node:zlib). * - CRC32 on every chunk — output is a spec-valid, decodable PNG. * - `colorType` 6 = truecolor+alpha (the default); use 2 (RGB) when the * buffer has no alpha channel. */ export function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array { const bytesPerPx = 4; const rowLen = width * bytesPerPx; const raw = new Uint8Array((rowLen + 1) * height); for (let y = 0; y < height; y++) { raw[y * (rowLen + 1)] = 0; // filter: none raw.set(rgba.subarray(y * rowLen, (y + 1) * rowLen), y * (rowLen + 1) + 1); } const idat = deflateSync(Buffer.from(raw), { level: 6 }) as Buffer; return concat([ PNG_SIGNATURE, chunk("IHDR", ihdrData(width, height)), chunk("IDAT", new Uint8Array(idat.buffer, idat.byteOffset, idat.length)), chunk("IEND", new Uint8Array(0)), ]); } // ── nearest-neighbor downscale ────────────────────────────────────────────── /** * Nearest-neighbor downscale of an RGBA buffer. * * Each output pixel picks the source pixel at * `floor((x + 0.5) * srcW / dstW)` — the deterministic, library-free * resampler. (A worker may later upgrade to area averaging; the PLAN is what * is tested here, not the aesthetics.) */ export function nearestDownscale( src: Uint8Array, srcW: number, srcH: number, dstW: number, dstH: number, ): Uint8Array { if (src.length < srcW * srcH * 4) { throw new Error(`source buffer too small for ${srcW}x${srcH}`); } const dst = new Uint8Array(dstW * dstH * 4); for (let y = 0; y < dstH; y++) { const sy = Math.min(srcH - 1, Math.floor(((y + 0.5) * srcH) / dstH)); for (let x = 0; x < dstW; x++) { const sx = Math.min(srcW - 1, Math.floor(((x + 0.5) * srcW) / dstW)); const si = (sy * srcW + sx) * 4; const di = (y * dstW + x) * 4; dst[di] = src[si]!; dst[di + 1] = src[si + 1]!; dst[di + 2] = src[si + 2]!; dst[di + 3] = src[si + 3]!; } } return dst; } /** * Downscale an RGBA buffer to `targetW` (height scaled proportionally, * min 1×1) and encode as PNG. THE dependency-free thumbnail path. */ export function generatePngThumbnail( rgba: Uint8Array, srcW: number, srcH: number, targetW: number, ): Uint8Array { if (srcW < 1 || srcH < 1 || targetW < 1) { throw new Error(`invalid dimensions: ${srcW}x${srcH} → ${targetW}`); } const scale = Math.min(1, targetW / srcW); const dstW = Math.max(1, Math.round(srcW * scale)); const dstH = Math.max(1, Math.round(srcH * scale)); if (dstW === srcW && dstH === srcH) return encodePng(rgba, srcW, srcH); const scaled = nearestDownscale(rgba, srcW, srcH, dstW, dstH); return encodePng(scaled, dstW, dstH); } // ── PNG header parser (inverse of the encoder — round-trip test) ─────────── export interface PngHeader { width: number; height: number; bitDepth: number; colorType: number; } /** * Parse width/height/bitDepth/colorType from a PNG file (signature + IHDR). * Throws on anything that is not a PNG. */ export function parsePngHeader(buf: Uint8Array): PngHeader { // Layout: signature(8) + [length(4) + type(4) + data(13) + crc(4)] → 33+ bytes if (buf.length < 33) throw new Error("not a PNG: too short"); for (let i = 0; i < 8; i++) { if (buf[i] !== PNG_SIGNATURE[i]) throw new Error("not a PNG: bad signature"); } // First chunk type sits at 8 + 4 (after its length field). const type = String.fromCharCode(buf[12]!, buf[13]!, buf[14]!, buf[15]!); if (type !== "IHDR") throw new Error("not a PNG: first chunk is not IHDR"); const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); return { width: view.getUint32(16), height: view.getUint32(20), bitDepth: view.getUint8(24), colorType: view.getUint8(25), }; } // ── JPEG SOF scanner (dimensions without an image library) ───────────────── /** * Scan a JPEG byte stream for the first SOF0/SOF2 marker (0xC0/0xC2, * baseline / progressive) and read width/height from its payload. * Returns null when no SOF frame is present. */ export function parseJpegDimensions(buf: Uint8Array): { width: number; height: number } | null { if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); let i = 2; while (i + 4 <= buf.length) { if (buf[i] !== 0xff) { i++; continue; } const marker = buf[i + 1]!; // Standalone markers (no payload). if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { i += 2; continue; } // 0xFF fill bytes. if (marker === 0xff) { i++; continue; } const len = view.getUint16(i + 2); if ((marker === 0xc0 || marker === 0xc2) && i + 2 + len <= buf.length && len >= 7) { return { height: view.getUint16(i + 5), width: view.getUint16(i + 7), }; } i += 2 + len; } return null; } // ── metadata extraction (the pipeline step, as a pure function over bytes) ─ export interface ExtractedMetadata { /** Detected MIME (magic bytes first — see mime.ts). */ mimeType: string; /** True for image MIMEs. */ isImage?: boolean; /** Width in px (PNG/JPEG only, when detected). */ width?: number; /** Height in px (PNG/JPEG only, when detected). */ height?: number; /** SHA-256 hex of the content. */ sha256: string; /** True when the DETECTED MIME is text/* (incl. text inferred from the * filename extension — bytes always win over the claim). */ isText?: boolean; } /** * The metadata-extraction step as a pure function over bytes: detect MIME, * pull PNG/JPEG dimensions, hash the content, flag text. * * `mime` is the CLAIMED type (kept for the call signature's intent); the * bytes always win — a lying client is the normal case (AGENTS.md §20). * * Page count / duration are NOT extracted here — they need pdf/media codecs * (worker, TASK-0102). */ export function computeMetadata( mime: string, buf: Uint8Array, filenameHint?: string, ): ExtractedMetadata { void mime; // documented intent: detection from bytes overrides the claim const detected = detectMimeType(buf, filenameHint); const out: ExtractedMetadata = { mimeType: detected.mime, sha256: createHash("sha256").update(buf).digest("hex"), }; if (isImageMime(detected.mime)) out.isImage = true; if (detected.mime.startsWith("text/")) out.isText = true; if (detected.mime === "image/png") { try { const h = parsePngHeader(buf); out.width = h.width; out.height = h.height; } catch { // truncated/corrupt PNG — dimensions stay undefined } } else if (detected.mime === "image/jpeg") { const dims = parseJpegDimensions(buf); if (dims) { out.width = dims.width; out.height = dims.height; } } return out; }