8c6e7930db
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
// Pure JPEG DPI metadata patcher.
|
|
// Re-encoding a JPEG through Skia drops the original EXIF/JFIF headers, so the
|
|
// DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only — pixel
|
|
// dimensions never change — but print pipelines read it, so this rewrites the
|
|
// JFIF density (and EXIF XResolution/YResolution when present) to the requested
|
|
// value. All writes are same-length, so no segment offsets move.
|
|
//
|
|
// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is
|
|
// inserted right after SOI.
|
|
|
|
function u16(bytes: Uint8Array, p: number): number {
|
|
return (bytes[p] << 8) | bytes[p + 1];
|
|
}
|
|
function setU16(bytes: Uint8Array, p: number, v: number): void {
|
|
bytes[p] = (v >> 8) & 0xff;
|
|
bytes[p + 1] = v & 0xff;
|
|
}
|
|
|
|
// Patches `bytes` in place for JFIF; returns the (possibly rebuilt) array.
|
|
export function patchJpegDpi(input: Uint8Array, dpi: number): Uint8Array {
|
|
if (input.length < 2 || input[0] !== 0xff || input[1] !== 0xd8) return input;
|
|
const bytes = input.slice(); // work on a copy; insertion path rebuilds anyway
|
|
const dpiLo = dpi & 0xff;
|
|
const dpiHi = (dpi >> 8) & 0xff;
|
|
|
|
let sawJfif = false;
|
|
let sawExif = false;
|
|
|
|
let i = 2;
|
|
while (i + 1 < bytes.length && bytes[i] === 0xff) {
|
|
while (bytes[i] === 0xff) i++;
|
|
const marker = bytes[i];
|
|
i++;
|
|
if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS — headers only
|
|
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
if (i + 2 > bytes.length) break;
|
|
const len = u16(bytes, i);
|
|
if (len < 2 || i + len > bytes.length) break;
|
|
const dataStart = i + 2;
|
|
const payloadLen = len - 2;
|
|
|
|
// APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd][1 thW][1 thH]...
|
|
if (marker === 0xe0 && payloadLen >= 14 && bytes[dataStart] === 0x4a &&
|
|
bytes[dataStart + 1] === 0x46 && bytes[dataStart + 2] === 0x49 &&
|
|
bytes[dataStart + 3] === 0x46 && bytes[dataStart + 4] === 0x00) {
|
|
sawJfif = true;
|
|
bytes[dataStart + 7] = 1; // units = dots per inch
|
|
bytes[dataStart + 8] = dpiHi;
|
|
bytes[dataStart + 9] = dpiLo;
|
|
bytes[dataStart + 10] = dpiHi;
|
|
bytes[dataStart + 11] = dpiLo;
|
|
}
|
|
|
|
// APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF
|
|
if (marker === 0xe1 && payloadLen >= 14 &&
|
|
bytes[dataStart] === 0x45 && bytes[dataStart + 1] === 0x78 &&
|
|
bytes[dataStart + 2] === 0x69 && bytes[dataStart + 3] === 0x66 &&
|
|
bytes[dataStart + 4] === 0x00 && bytes[dataStart + 5] === 0x00) {
|
|
sawExif = true;
|
|
const tiff = dataStart + 6;
|
|
const le = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
|
|
const be = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d;
|
|
if ((le || be) && tiff + 8 <= bytes.length) {
|
|
const read32 = (p: number): number =>
|
|
le
|
|
? bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24)
|
|
: ((bytes[p] << 24) | (bytes[p + 1] << 16) | (bytes[p + 2] << 8) | bytes[p + 3]) >>> 0;
|
|
const write32 = (p: number, v: number): void => {
|
|
if (le) {
|
|
bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff;
|
|
bytes[p + 2] = (v >> 16) & 0xff; bytes[p + 3] = (v >> 24) & 0xff;
|
|
} else {
|
|
bytes[p] = (v >> 24) & 0xff; bytes[p + 1] = (v >> 16) & 0xff;
|
|
bytes[p + 2] = (v >> 8) & 0xff; bytes[p + 3] = v & 0xff;
|
|
}
|
|
};
|
|
// TIFF integer fields (tag/type/count/values) follow the file byte order
|
|
const u16r = (p: number): number =>
|
|
le ? bytes[p] | (bytes[p + 1] << 8) : (bytes[p] << 8) | bytes[p + 1];
|
|
const u16w = (p: number, v: number): void => {
|
|
if (le) { bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; }
|
|
else { bytes[p] = (v >> 8) & 0xff; bytes[p + 1] = v & 0xff; }
|
|
};
|
|
const ifdOff = read32(tiff + 4);
|
|
if (ifdOff + 2 <= bytes.length - tiff) {
|
|
const ifd = tiff + ifdOff;
|
|
const count = u16r(ifd);
|
|
for (let e = 0; e < count; e++) {
|
|
const entry = ifd + 2 + e * 12;
|
|
if (entry + 12 > bytes.length) break;
|
|
const tag = u16r(entry);
|
|
const type = u16r(entry + 2);
|
|
const cnt = read32(entry + 4);
|
|
if ((tag === 0x011a || tag === 0x011b) && type === 5 && cnt === 1) {
|
|
// RATIONAL: entry+8 holds offset to (num, den), relative to TIFF start
|
|
const rat = tiff + read32(entry + 8);
|
|
if (rat + 8 <= bytes.length) write32(rat, dpi); // keep denominator
|
|
} else if (tag === 0x0128 && type === 3 && cnt === 1) {
|
|
// ResolutionUnit (2 = inch): normalize to inch so the new density reads correctly
|
|
u16w(entry + 8, 2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
i += len;
|
|
}
|
|
|
|
if (sawJfif || sawExif) return bytes;
|
|
|
|
// Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch) after SOI.
|
|
const app0 = new Uint8Array([
|
|
0xff, 0xe0, 0x00, 0x10,
|
|
0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
|
0x01, 0x01, // version 1.01
|
|
0x01, // units: inch
|
|
dpiHi, dpiLo, dpiHi, dpiLo,
|
|
0x00, 0x00, // no thumbnail
|
|
]);
|
|
const out = new Uint8Array(bytes.length + app0.length);
|
|
out[0] = 0xff; out[1] = 0xd8;
|
|
out.set(app0, 2);
|
|
out.set(bytes.subarray(2), 2 + app0.length);
|
|
return out;
|
|
}
|