1
29
30import { deflateSync } from "node:zlib";
31import { createHash } from "node:crypto";
32
33import { isImageMime, isVideoMime, detectMimeType } from "./mime.js";
34import {
35 buildThumbKey,
36 THUMBNAIL_SIZES,
37 THUMBNAIL_SIZE_WIDTH,
38 type ThumbnailSize,
39} from "./keys.js";
40
41
42
43
44const IMAGE_OUTPUT_EXT: Record<string, string> = {
45 "image/png": "png",
46 "image/jpeg": "jpeg",
47 "image/webp": "webp",
48 "image/gif": "gif",
49 "image/bmp": "bmp",
50 "image/svg+xml": "png",
51 "image/avif": "png",
52 "image/heic": "png",
53 "image/tiff": "png",
54};
55
56
68export function planThumbnails(args: {
69 mime: string;
70 workspaceId: string;
71 fileId: string;
72
73 name: string;
74
75 sourceWidth?: number;
76}): Array<{
77 size: ThumbnailSize;
78
79 key: string;
80
81 targetWidth: number;
82
83 mime: string;
84
85 source: "image" | "pdf-page-1" | "video-first-frame";
86}> {
87 const { mime, workspaceId, fileId, sourceWidth } = args;
88
89
90
91
92 const dot = args.name.lastIndexOf(".");
93 const stem = dot > 0 ? args.name.slice(0, dot) : args.name;
94
95 if (isImageMime(mime)) {
96 const ext = IMAGE_OUTPUT_EXT[mime] ?? "png";
97 const plan: ReturnType<typeof planThumbnails> = [];
98 for (const size of THUMBNAIL_SIZES) {
99 const width = THUMBNAIL_SIZE_WIDTH[size];
100 if (sourceWidth !== undefined && sourceWidth > 0 && sourceWidth <= width) {
101 continue;
102 }
103 plan.push({
104 size,
105 key: buildThumbKey({ workspaceId, fileId, size, name: stem, ext }),
106 targetWidth: width,
107 mime,
108 source: "image",
109 });
110 }
111 return plan;
112 }
113
114 if (mime === "application/pdf") {
115 return [
116 {
117 size: "1280",
118 key: buildThumbKey({ workspaceId, fileId, size: "1280", name: stem, ext: "png" }),
119 targetWidth: THUMBNAIL_SIZE_WIDTH["1280"],
120 mime: "image/png",
121 source: "pdf-page-1",
122 },
123 ];
124 }
125
126 if (isVideoMime(mime)) {
127 return [
128 {
129 size: "1280",
130 key: buildThumbKey({ workspaceId, fileId, size: "1280", name: stem, ext: "png" }),
131 targetWidth: THUMBNAIL_SIZE_WIDTH["1280"],
132 mime: "image/png",
133 source: "video-first-frame",
134 },
135 ];
136 }
137
138 return [];
139}
140
141
142
143const CRC_TABLE: readonly number[] = (() => {
144 const table = new Array<number>(256);
145 for (let n = 0; n < 256; n++) {
146 let c = n;
147 for (let k = 0; k < 8; k++) {
148 c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
149 }
150 table[n] = c >>> 0;
151 }
152 return table;
153})();
154
155
156export function crc32(bytes: Uint8Array): number {
157 let c = 0xffffffff;
158 for (let i = 0; i < bytes.length; i++) {
159 c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
160 }
161 return (c ^ 0xffffffff) >>> 0;
162}
163
164
165
166function chunk(type: string, data: Uint8Array): Uint8Array {
167 const out = new Uint8Array(12 + data.length);
168 const view = new DataView(out.buffer);
169 view.setUint32(0, data.length);
170 const typeBytes = new TextEncoder().encode(type);
171 out.set(typeBytes, 4);
172 out.set(data, 8);
173
174 view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
175 return out;
176}
177
178function concat(bytesList: readonly Uint8Array[]): Uint8Array {
179 let total = 0;
180 for (const b of bytesList) total += b.length;
181 const out = new Uint8Array(total);
182 let off = 0;
183 for (const b of bytesList) {
184 out.set(b, off);
185 off += b.length;
186 }
187 return out;
188}
189
190
191function ihdrData(width: number, height: number, bitDepth = 8, colorType = 6): Uint8Array {
192 const data = new Uint8Array(13);
193 const view = new DataView(data.buffer);
194 view.setUint32(0, width);
195 view.setUint32(4, height);
196 view.setUint8(8, bitDepth);
197 view.setUint8(9, colorType);
198 view.setUint8(10, 0);
199 view.setUint8(11, 0);
200 view.setUint8(12, 0);
201 return data;
202}
203
204const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
205
206
214export function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array {
215 const bytesPerPx = 4;
216 const rowLen = width * bytesPerPx;
217 const raw = new Uint8Array((rowLen + 1) * height);
218 for (let y = 0; y < height; y++) {
219 raw[y * (rowLen + 1)] = 0;
220 raw.set(rgba.subarray(y * rowLen, (y + 1) * rowLen), y * (rowLen + 1) + 1);
221 }
222 const idat = deflateSync(Buffer.from(raw), { level: 6 }) as Buffer;
223 return concat([
224 PNG_SIGNATURE,
225 chunk("IHDR", ihdrData(width, height)),
226 chunk("IDAT", new Uint8Array(idat.buffer, idat.byteOffset, idat.length)),
227 chunk("IEND", new Uint8Array(0)),
228 ]);
229}
230
231
232
233
241export function nearestDownscale(
242 src: Uint8Array,
243 srcW: number,
244 srcH: number,
245 dstW: number,
246 dstH: number,
247): Uint8Array {
248 if (src.length < srcW * srcH * 4) {
249 throw new Error(`source buffer too small for ${srcW}x${srcH}`);
250 }
251 const dst = new Uint8Array(dstW * dstH * 4);
252 for (let y = 0; y < dstH; y++) {
253 const sy = Math.min(srcH - 1, Math.floor(((y + 0.5) * srcH) / dstH));
254 for (let x = 0; x < dstW; x++) {
255 const sx = Math.min(srcW - 1, Math.floor(((x + 0.5) * srcW) / dstW));
256 const si = (sy * srcW + sx) * 4;
257 const di = (y * dstW + x) * 4;
258 dst[di] = src[si]!;
259 dst[di + 1] = src[si + 1]!;
260 dst[di + 2] = src[si + 2]!;
261 dst[di + 3] = src[si + 3]!;
262 }
263 }
264 return dst;
265}
266
267
271export function generatePngThumbnail(
272 rgba: Uint8Array,
273 srcW: number,
274 srcH: number,
275 targetW: number,
276): Uint8Array {
277 if (srcW < 1 || srcH < 1 || targetW < 1) {
278 throw new Error(`invalid dimensions: ${srcW}x${srcH} → ${targetW}`);
279 }
280 const scale = Math.min(1, targetW / srcW);
281 const dstW = Math.max(1, Math.round(srcW * scale));
282 const dstH = Math.max(1, Math.round(srcH * scale));
283 if (dstW === srcW && dstH === srcH) return encodePng(rgba, srcW, srcH);
284 const scaled = nearestDownscale(rgba, srcW, srcH, dstW, dstH);
285 return encodePng(scaled, dstW, dstH);
286}
287
288
289
290export interface PngHeader {
291 width: number;
292 height: number;
293 bitDepth: number;
294 colorType: number;
295}
296
297
301export function parsePngHeader(buf: Uint8Array): PngHeader {
302
303 if (buf.length < 33) throw new Error("not a PNG: too short");
304 for (let i = 0; i < 8; i++) {
305 if (buf[i] !== PNG_SIGNATURE[i]) throw new Error("not a PNG: bad signature");
306 }
307
308 const type = String.fromCharCode(buf[12]!, buf[13]!, buf[14]!, buf[15]!);
309 if (type !== "IHDR") throw new Error("not a PNG: first chunk is not IHDR");
310 const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
311 return {
312 width: view.getUint32(16),
313 height: view.getUint32(20),
314 bitDepth: view.getUint8(24),
315 colorType: view.getUint8(25),
316 };
317}
318
319
320
321
326export function parseJpegDimensions(buf: Uint8Array): { width: number; height: number } | null {
327 if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
328 const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
329 let i = 2;
330 while (i + 4 <= buf.length) {
331 if (buf[i] !== 0xff) {
332 i++;
333 continue;
334 }
335 const marker = buf[i + 1]!;
336
337 if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
338 i += 2;
339 continue;
340 }
341
342 if (marker === 0xff) {
343 i++;
344 continue;
345 }
346 const len = view.getUint16(i + 2);
347 if ((marker === 0xc0 || marker === 0xc2) && i + 2 + len <= buf.length && len >= 7) {
348 return {
349 height: view.getUint16(i + 5),
350 width: view.getUint16(i + 7),
351 };
352 }
353 i += 2 + len;
354 }
355 return null;
356}
357
358
359
360export interface ExtractedMetadata {
361
362 mimeType: string;
363
364 isImage?: boolean;
365
366 width?: number;
367
368 height?: number;
369
370 sha256: string;
371
373 isText?: boolean;
374}
375
376
386export function computeMetadata(
387 mime: string,
388 buf: Uint8Array,
389 filenameHint?: string,
390): ExtractedMetadata {
391 void mime;
392 const detected = detectMimeType(buf, filenameHint);
393 const out: ExtractedMetadata = {
394 mimeType: detected.mime,
395 sha256: createHash("sha256").update(buf).digest("hex"),
396 };
397 if (isImageMime(detected.mime)) out.isImage = true;
398 if (detected.mime.startsWith("text/")) out.isText = true;
399
400 if (detected.mime === "image/png") {
401 try {
402 const h = parsePngHeader(buf);
403 out.width = h.width;
404 out.height = h.height;
405 } catch {
406
407 }
408 } else if (detected.mime === "image/jpeg") {
409 const dims = parseJpegDimensions(buf);
410 if (dims) {
411 out.width = dims.width;
412 out.height = dims.height;
413 }
414 }
415 return out;
416}
417