Replace sharp with jpeg-js + exif-reader; add bun compile binary

sharp was the only native dependency preventing a single-file binary.
Replaced with:
  - jpeg-js (pure JS) for JPEG decode/resize/encode in thumbnail gen
  - exif-reader (pure JS) for EXIF tag parsing
  - Raw JPEG APP1 marker extraction for EXIF segment discovery
  - Raw XMP packet extraction from file bytes

make build-bin produces a ~59MB self-contained Mach-O binary via
bun build --compile (bun installed via nix-shell). Zero runtime
dependencies. Tested: login, whoami, collections, files all work
from the compiled binary.

bin/quak.ts: init() called once at program start before commander
parses, so libsodium is ready for all commands including those that
restore sessions from disk.

118 tests pass.
This commit is contained in:
2026-06-10 10:44:26 -07:00
parent 5e6069f574
commit 25d3c612cf
9 changed files with 209 additions and 279 deletions

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import sharp from "sharp";
import { readFileSync } from "node:fs";
import * as jpeg from "jpeg-js";
import type { Client } from "./client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js";
import { downloadFile } from "./download/index.js";
@@ -74,16 +75,71 @@ export const listMissingThumbnails = async (
return missing;
};
const generateThumbnail = async (originalPath: string): Promise<Uint8Array> => {
const result = await sharp(originalPath)
.rotate()
.resize(THUMB_MAX_DIMENSION, THUMB_MAX_DIMENSION, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: THUMB_JPEG_QUALITY })
.toBuffer();
return new Uint8Array(result);
// Bilinear resize of RGBA pixel buffer
const resizeRGBA = (
src: Uint8Array,
srcW: number,
srcH: number,
dstW: number,
dstH: number,
): Uint8Array => {
const dst = new Uint8Array(dstW * dstH * 4);
const xRatio = srcW / dstW;
const yRatio = srcH / dstH;
for (let y = 0; y < dstH; y++) {
const srcY = y * yRatio;
const y0 = Math.floor(srcY);
const y1 = Math.min(y0 + 1, srcH - 1);
const fy = srcY - y0;
for (let x = 0; x < dstW; x++) {
const srcX = x * xRatio;
const x0 = Math.floor(srcX);
const x1 = Math.min(x0 + 1, srcW - 1);
const fx = srcX - x0;
const i00 = (y0 * srcW + x0) * 4;
const i10 = (y0 * srcW + x1) * 4;
const i01 = (y1 * srcW + x0) * 4;
const i11 = (y1 * srcW + x1) * 4;
const di = (y * dstW + x) * 4;
for (let c = 0; c < 4; c++) {
dst[di + c] = Math.round(
src[i00 + c]! * (1 - fx) * (1 - fy) +
src[i10 + c]! * fx * (1 - fy) +
src[i01 + c]! * (1 - fx) * fy +
src[i11 + c]! * fx * fy,
);
}
}
}
return dst;
};
const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
const decoded = jpeg.decode(fileBytes, {
useTArray: true,
formatAsRGBA: true,
});
const { width: srcW, height: srcH } = decoded;
const scale = Math.min(
THUMB_MAX_DIMENSION / srcW,
THUMB_MAX_DIMENSION / srcH,
1,
);
const dstW = Math.round(srcW * scale);
const dstH = Math.round(srcH * scale);
let pixels: Uint8Array;
if (scale < 1) {
pixels = resizeRGBA(decoded.data, srcW, srcH, dstW, dstH);
} else {
pixels = decoded.data;
}
const encoded = jpeg.encode(
{ data: pixels, width: dstW, height: dstH },
THUMB_JPEG_QUALITY,
);
return new Uint8Array(encoded.data);
};
export const fixMissingThumbnails = async (
@@ -139,7 +195,8 @@ export const fixMissingThumbnails = async (
log(
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
);
const thumbJpeg = await generateThumbnail(origPath);
const fileBytes = readFileSync(origPath);
const thumbJpeg = generateThumbnail(new Uint8Array(fileBytes));
log(
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,