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:
@@ -1,8 +1,14 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import sharp from "sharp";
|
||||
import * as jpeg from "jpeg-js";
|
||||
import exifReader from "exif-reader";
|
||||
import type { Client } from "./client.js";
|
||||
import { decryptBlob, fromBase64 } from "./crypto/index.js";
|
||||
@@ -62,6 +68,87 @@ const fetchMLDataForFiles = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
|
||||
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
||||
// header) or undefined if no APP1 marker is found.
|
||||
const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => {
|
||||
if (buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
|
||||
let offset = 2;
|
||||
while (offset < buf.length - 1) {
|
||||
if (buf[offset] !== 0xff) return undefined;
|
||||
const marker = buf[offset + 1]!;
|
||||
if (marker === 0xda) break; // start of scan, no more markers
|
||||
if (offset + 3 >= buf.length) break;
|
||||
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
|
||||
if (marker === 0xe1) {
|
||||
// APP1 — check for "Exif\0\0" header
|
||||
if (
|
||||
buf[offset + 4] === 0x45 &&
|
||||
buf[offset + 5] === 0x78 &&
|
||||
buf[offset + 6] === 0x69 &&
|
||||
buf[offset + 7] === 0x66
|
||||
) {
|
||||
return Buffer.from(
|
||||
buf.buffer,
|
||||
buf.byteOffset + offset + 4,
|
||||
len - 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
offset += 2 + len;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractImageMetadata = (
|
||||
fileBytes: Uint8Array,
|
||||
): Record<string, unknown> | undefined => {
|
||||
try {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// Try to get dimensions from JPEG decode
|
||||
try {
|
||||
const decoded = jpeg.decode(fileBytes, {
|
||||
useTArray: true,
|
||||
formatAsRGBA: false,
|
||||
});
|
||||
result.format = "jpeg";
|
||||
result.width = decoded.width;
|
||||
result.height = decoded.height;
|
||||
} catch {
|
||||
// Not a JPEG or corrupt; still try EXIF extraction
|
||||
}
|
||||
|
||||
const exifBuf = extractExifFromJpeg(fileBytes);
|
||||
if (exifBuf) {
|
||||
try {
|
||||
result.exif = exifReader(exifBuf);
|
||||
} catch {
|
||||
result.exifRaw = exifBuf.toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
||||
const xmpStart = Buffer.from(fileBytes).indexOf("<?xpacket begin");
|
||||
if (xmpStart !== -1) {
|
||||
const xmpEnd = Buffer.from(fileBytes).indexOf(
|
||||
"<?xpacket end",
|
||||
xmpStart,
|
||||
);
|
||||
if (xmpEnd !== -1) {
|
||||
const end = Buffer.from(fileBytes).indexOf("?>", xmpEnd);
|
||||
result.xmp = Buffer.from(fileBytes)
|
||||
.subarray(xmpStart, end !== -1 ? end + 2 : xmpEnd + 50)
|
||||
.toString("utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const extractExif = async (
|
||||
client: Client,
|
||||
file: EnteFile,
|
||||
@@ -70,42 +157,8 @@ const extractExif = async (
|
||||
try {
|
||||
const origPath = join(tmpDir, "original");
|
||||
await client.downloadFile(file, origPath);
|
||||
const meta = await sharp(origPath).metadata();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
format: meta.format,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
space: meta.space,
|
||||
channels: meta.channels,
|
||||
depth: meta.depth,
|
||||
density: meta.density,
|
||||
chromaSubSampling: meta.chromaSubSampling,
|
||||
isProgressive: meta.isProgressive,
|
||||
hasProfile: meta.hasProfile,
|
||||
hasAlpha: meta.hasAlpha,
|
||||
orientation: meta.orientation,
|
||||
};
|
||||
|
||||
if (meta.exif) {
|
||||
try {
|
||||
result.exif = exifReader(meta.exif);
|
||||
} catch {
|
||||
// Malformed EXIF; store the raw bytes as base64 instead
|
||||
result.exifRaw = meta.exif.toString("base64");
|
||||
}
|
||||
}
|
||||
if (meta.iptc) result.iptcRaw = meta.iptc.toString("base64");
|
||||
if (meta.xmp) {
|
||||
try {
|
||||
result.xmp = Buffer.from(meta.xmp).toString("utf-8");
|
||||
} catch {
|
||||
result.xmpRaw = meta.xmp.toString("base64");
|
||||
}
|
||||
}
|
||||
if (meta.icc) result.iccRaw = meta.icc.toString("base64");
|
||||
|
||||
return result;
|
||||
const fileBytes = new Uint8Array(readFileSync(origPath));
|
||||
return extractImageMetadata(fileBytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
@@ -182,7 +235,6 @@ export const runMetadataBackup = async (
|
||||
);
|
||||
log(`Got ML data for ${mlDataMap.size} file(s)`);
|
||||
|
||||
// Write per-file JSON (with optional ML data and EXIF)
|
||||
const writtenFileIDs = new Set<number>();
|
||||
for (const { file, colDirName } of allFiles) {
|
||||
const colDir = join(outDir, "collections", colDirName);
|
||||
|
||||
@@ -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)...`,
|
||||
|
||||
Reference in New Issue
Block a user