/** * V3.9 — thumbnail generation helper. * * Browsers expose `OffscreenCanvas` (Workers + main thread) and a Blob / * File API capable of decoding common image formats; we lean on those to * synthesize a 256x256 preview without pulling in `sharp` or `node-canvas`. * * Server-side runtimes (Bun/Node) typically already have an upstream * thumbnail (e.g. computed by an image-processing pipeline) — they should * pass `{ thumbnail: { bytes, mime } }` to `shade.upload()` directly * instead of `{ generateThumbnail: true }`. `generateThumbnail` returns * `null` when no in-process generator is available, which the SDK treats * as "skip the thumbnail" rather than crashing. * * The format-hardening invariants (`THUMBNAIL_MAX_BYTES`, * `THUMBNAIL_MIME_ALLOWLIST`) are enforced *here* so we never produce * something the receiver would reject. */ import { THUMBNAIL_MAX_BYTES, isAllowedThumbnailMime, type ThumbnailMime, } from '@shade/streams'; export interface GeneratedThumbnail { bytes: Uint8Array; mime: ThumbnailMime; } export interface ThumbnailGenerationOptions { /** Output longest-edge in pixels (default 256). */ maxEdge?: number; /** Preferred output MIME (default `image/webp`, falls back to `image/jpeg`). */ preferMime?: ThumbnailMime; /** JPEG/WebP quality in [0, 1]; default 0.78. */ quality?: number; } interface BlobLike { size: number; readonly type: string; arrayBuffer(): Promise; } interface OffscreenCanvasCtor { new (width: number, height: number): OffscreenCanvasLike; } interface OffscreenCanvasLike { getContext(kind: '2d'): { drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void; } | null; convertToBlob(opts?: { type?: string; quality?: number }): Promise; } interface CreateImageBitmapFn { (source: BlobLike): Promise<{ width: number; height: number; close(): void }>; } function getOffscreenCanvasCtor(): OffscreenCanvasCtor | null { const g = globalThis as unknown as { OffscreenCanvas?: OffscreenCanvasCtor }; return g.OffscreenCanvas ?? null; } function getCreateImageBitmap(): CreateImageBitmapFn | null { // `globalThis.createImageBitmap` (when DOM lib is loaded) has a wider // signature than our minimal `CreateImageBitmapFn`. Cast through // `unknown` so consumer tsconfigs that include "DOM" don't reject the // narrower local type as "insufficiently overlapping". const g = globalThis as unknown as { createImageBitmap?: CreateImageBitmapFn }; return g.createImageBitmap ?? null; } function isImageBlob(input: unknown): input is BlobLike { if (typeof input !== 'object' || input === null) return false; const b = input as BlobLike; return ( typeof b.size === 'number' && typeof b.type === 'string' && typeof b.arrayBuffer === 'function' && b.type.startsWith('image/') ); } /** * Generate a thumbnail from an image input. Returns `null` when: * - The input is not an image Blob/File. * - The runtime lacks `OffscreenCanvas` + `createImageBitmap` (Node, Deno * without polyfills). Server callers should pass `thumbnail` directly. * - The encoded thumbnail exceeds `THUMBNAIL_MAX_BYTES` even after * quality back-off (a pathological input — caller should fall back to * "no thumbnail"). */ export async function generateThumbnail( input: unknown, opts: ThumbnailGenerationOptions = {}, ): Promise { if (!isImageBlob(input)) return null; const Ctor = getOffscreenCanvasCtor(); const createBitmap = getCreateImageBitmap(); if (Ctor === null || createBitmap === null) return null; const maxEdge = opts.maxEdge ?? 256; const preferMime = opts.preferMime ?? 'image/webp'; const quality = clamp01(opts.quality ?? 0.78); const bitmap = await createBitmap(input); try { const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = new Ctor(w, h); const ctx = canvas.getContext('2d'); if (ctx === null) return null; ctx.drawImage(bitmap, 0, 0, w, h); const order: ThumbnailMime[] = preferMime === 'image/webp' ? ['image/webp', 'image/jpeg'] : preferMime === 'image/jpeg' ? ['image/jpeg', 'image/webp'] : ['image/png', 'image/webp', 'image/jpeg']; for (const mime of order) { let q = quality; // Try up to 3 quality back-offs before giving up on this format. for (let attempt = 0; attempt < 3; attempt++) { const blob = await canvas.convertToBlob({ type: mime, quality: q }); if (blob.size <= THUMBNAIL_MAX_BYTES) { if (!isAllowedThumbnailMime(mime)) continue; const buf = new Uint8Array(await blob.arrayBuffer()); return { bytes: buf, mime }; } q = Math.max(0.2, q * 0.7); } } return null; } finally { bitmap.close(); } } function clamp01(n: number): number { if (!Number.isFinite(n)) return 0.78; if (n < 0) return 0; if (n > 1) return 1; return n; }