screentinker/server/lib/image-ops-worker.js
ScreenTinker 04e0e8fdf4 Measure and thumbnail an image from a single decode
Ingest asked for metadata() then writeThumbnail(), which decoded the file twice.
That pairing was free under sharp, whose .metadata() only parses the header, but
every decode here is a full one — ~1s for a 12MP photo — so the naive translation
doubled the most expensive thing on the ingest path.

image-ops.measureAndThumbnail() returns both from one decode. Full ingest of a
12MP photo: 2 decodes/~1.9s -> 1150ms, still with zero event-loop stalls.

The subtlety is the failure contract. In the two-call version width and height
were assigned BEFORE the thumbnail was attempted, so a failed thumbnail still left
usable dimensions on the row — the player needs them to letterbox. Merging naively
would have turned any thumbnail failure into total metadata loss. So a WRITE
failure is reported ({thumbnailWritten:false, thumbnailError}) with the dimensions
intact, and the caller sets thumbnail_path only when the write succeeded, keeping
the phantom-path discipline. A DECODE failure still throws — there is nothing to
report about an unreadable image.

backfill-rotation-dims.js deliberately keeps the separate calls: it probes every
image row but regenerates a thumbnail only for the few whose dimensions changed,
so pairing them there would decode files it has no reason to thumbnail.

Tests count decodes rather than timing them — an exact property, and a wall-clock
comparison would be flaky under load. The count filters for reads of the file under
test: Node's ESM loader also goes through fs.promises.readFile, so a raw call count
picks up jimp's and the WASM codecs' lazy loading and reads 30 instead of 1.

1649/1649 pass. Ingest re-verified across all 7 formats with sharp moved aside.
2026-08-13 11:34:23 -05:00

31 lines
1.2 KiB
JavaScript

'use strict';
/*
* Worker-thread host for image-ops-core. One job per message, one reply per job, keyed by id.
*
* Deliberately thin: every decision (queueing, lifecycle, fallback) lives in ../lib/image-ops so
* there is one place to reason about them. This end only does the work and reports what happened.
*
* Errors come back as a message rather than a thrown exception, so one undecodable upload does
* not tear down the worker and take the queued jobs of unrelated callers with it.
*/
const { parentPort } = require('worker_threads');
const core = require('./image-ops-core');
const OPS = {
metadata: (job) => core.metadata(job.src),
writeThumbnail: (job) => core.writeThumbnail(job.src, job.dest, job.width, job.quality),
measureAndThumbnail: (job) => core.measureAndThumbnail(job.src, job.dest, job.width, job.quality),
};
parentPort.on('message', async (job) => {
try {
const op = OPS[job.op];
if (!op) throw new Error(`unknown image op: ${job.op}`);
parentPort.postMessage({ id: job.id, ok: true, result: await op(job) });
} catch (err) {
parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
}
});