@storyshelf/image 0.0.0-stage → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pngjs-downscale.mjs","names":[],"sources":["../src/pngjs-downscale.ts"],"sourcesContent":["/** Box-sampling PNG downscale on pngjs. */\nimport { PNG } from \"pngjs\";\n\n/** Downscale a PNG so its long edge is at most `maxEdge` (box sampling). */\nexport function downscaleWithPngjs(buffer: Uint8Array, maxEdge: number): Uint8Array {\n const src = PNG.sync.read(Buffer.from(buffer));\n const scale = Math.min(1, maxEdge / Math.max(src.width, src.height));\n if (scale >= 1) {\n return new Uint8Array(PNG.sync.write(src));\n }\n const dst = new PNG({\n width: Math.max(1, Math.round(src.width * scale)),\n height: Math.max(1, Math.round(src.height * scale)),\n });\n for (let row = 0; row < dst.height; row += 1) {\n for (let col = 0; col < dst.width; col += 1) {\n writePixel(dst, col, row, averageBox(src, boxOf(src, col, row, 1 / scale)));\n }\n }\n return new Uint8Array(PNG.sync.write(dst));\n}\n\ninterface Box {\n x0: number;\n y0: number;\n x1: number;\n y1: number;\n}\n\nfunction boxOf(src: PNG, col: number, row: number, factor: number): Box {\n const x0 = Math.floor(col * factor);\n const y0 = Math.floor(row * factor);\n return {\n x0,\n y0,\n x1: Math.min(src.width, Math.max(x0 + 1, Math.floor((col + 1) * factor))),\n y1: Math.min(src.height, Math.max(y0 + 1, Math.floor((row + 1) * factor))),\n };\n}\n\nfunction averageBox(src: PNG, box: Box): number[] {\n const sum = [0, 0, 0, 0];\n let count = 0;\n for (let sy = box.y0; sy < box.y1; sy += 1) {\n for (let sx = box.x0; sx < box.x1; sx += 1) {\n const at = (sy * src.width + sx) * 4;\n for (let channel = 0; channel < 4; channel += 1) {\n sum[channel] = (sum[channel] ?? 0) + (src.data[at + channel] ?? 0);\n }\n count += 1;\n }\n }\n return sum.map((total) => Math.round(total / Math.max(1, count)));\n}\n\nfunction writePixel(dst: PNG, col: number, row: number, rgba: number[]): void {\n const at = (row * dst.width + col) * 4;\n for (let channel = 0; channel < 4; channel += 1) {\n dst.data[at + channel] = rgba[channel] ?? 0;\n }\n}\n"],"mappings":";;;;;;;AAIA,SAAgB,mBAAmB,QAAoB,SAA6B;CAClF,MAAM,MAAM,IAAI,KAAK,KAAK,OAAO,KAAK,MAAM,CAAC;CAC7C,MAAM,QAAQ,KAAK,IAAI,GAAG,UAAU,KAAK,IAAI,IAAI,OAAO,IAAI,MAAM,CAAC;CACnE,IAAI,SAAS,GACX,OAAO,IAAI,WAAW,IAAI,KAAK,MAAM,GAAG,CAAC;CAE3C,MAAM,MAAM,IAAI,IAAI;EAClB,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,QAAQ,KAAK,CAAC;EAChD,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,SAAS,KAAK,CAAC;CACpD,CAAC;CACD,KAAK,IAAI,MAAM,GAAG,MAAM,IAAI,QAAQ,OAAO,GACzC,KAAK,IAAI,MAAM,GAAG,MAAM,IAAI,OAAO,OAAO,GACxC,WAAW,KAAK,KAAK,KAAK,WAAW,KAAK,MAAM,KAAK,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC;CAG9E,OAAO,IAAI,WAAW,IAAI,KAAK,MAAM,GAAG,CAAC;AAC3C;AASA,SAAS,MAAM,KAAU,KAAa,KAAa,QAAqB;CACtE,MAAM,KAAK,KAAK,MAAM,MAAM,MAAM;CAClC,MAAM,KAAK,KAAK,MAAM,MAAM,MAAM;CAClC,OAAO;EACL;EACA;EACA,IAAI,KAAK,IAAI,IAAI,OAAO,KAAK,IAAI,KAAK,GAAG,KAAK,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC;EACxE,IAAI,KAAK,IAAI,IAAI,QAAQ,KAAK,IAAI,KAAK,GAAG,KAAK,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC;CAC3E;AACF;AAEA,SAAS,WAAW,KAAU,KAAoB;CAChD,MAAM,MAAM;EAAC;EAAG;EAAG;EAAG;CAAC;CACvB,IAAI,QAAQ;CACZ,KAAK,IAAI,KAAK,IAAI,IAAI,KAAK,IAAI,IAAI,MAAM,GACvC,KAAK,IAAI,KAAK,IAAI,IAAI,KAAK,IAAI,IAAI,MAAM,GAAG;EAC1C,MAAM,MAAM,KAAK,IAAI,QAAQ,MAAM;EACnC,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW,GAC5C,IAAI,YAAY,IAAI,YAAY,MAAM,IAAI,KAAK,KAAK,YAAY;EAElE,SAAS;CACX;CAEF,OAAO,IAAI,KAAK,UAAU,KAAK,MAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,CAAC,CAAC;AAClE;AAEA,SAAS,WAAW,KAAU,KAAa,KAAa,MAAsB;CAC5E,MAAM,MAAM,MAAM,IAAI,QAAQ,OAAO;CACrC,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW,GAC5C,IAAI,KAAK,KAAK,WAAW,KAAK,YAAY;AAE9C"}
package/dist/pngjs.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { downscaleWithPngjs } from "./pngjs-downscale.mjs";
5
+ import { PNG } from "pngjs";
6
+ //#region src/pngjs.ts
7
+ /** The default codec: pure JavaScript (pngjs), runs on every runtime. */
8
+ const pngjsCodec = {
9
+ name: "pngjs",
10
+ decode(png) {
11
+ const { width, height, data } = PNG.sync.read(png);
12
+ return {
13
+ width,
14
+ height,
15
+ data
16
+ };
17
+ },
18
+ encode({ width, height, data }) {
19
+ const view = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
20
+ return PNG.sync.write({
21
+ width,
22
+ height,
23
+ data: view
24
+ });
25
+ },
26
+ downscale: downscaleWithPngjs
27
+ };
28
+ //#endregion
29
+ export { pngjsCodec };
30
+
31
+ //# sourceMappingURL=pngjs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pngjs.mjs","names":[],"sources":["../src/pngjs.ts"],"sourcesContent":["/** The default codec: pure JavaScript (pngjs), runs on every runtime. */\nimport { PNG } from \"pngjs\";\nimport type { ImageCodec, RgbaImage } from \"./codec.ts\";\nimport { downscaleWithPngjs } from \"./pngjs-downscale.ts\";\n\nexport const pngjsCodec: ImageCodec = {\n name: \"pngjs\",\n decode(png: Buffer): RgbaImage {\n const { width, height, data } = PNG.sync.read(png);\n return { width, height, data };\n },\n encode({ width, height, data }: RgbaImage): Buffer {\n // The packer only reads `width`, `height` and `data`, so hand it the pixels as they are\n // instead of copying a full frame into a fresh PNG.\n const view = Buffer.from(data.buffer, data.byteOffset, data.byteLength);\n return PNG.sync.write({ width, height, data: view } as PNG);\n },\n downscale: downscaleWithPngjs,\n};\n"],"mappings":";;;;;;;AAKA,MAAa,aAAyB;CACpC,MAAM;CACN,OAAO,KAAwB;EAC7B,MAAM,EAAE,OAAO,QAAQ,SAAS,IAAI,KAAK,KAAK,GAAG;EACjD,OAAO;GAAE;GAAO;GAAQ;EAAK;CAC/B;CACA,OAAO,EAAE,OAAO,QAAQ,QAA2B;EAGjD,MAAM,OAAO,OAAO,KAAK,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU;EACtE,OAAO,IAAI,KAAK,MAAM;GAAE;GAAO;GAAQ,MAAM;EAAK,CAAQ;CAC5D;CACA,WAAW;AACb"}
package/dist/pool.mjs ADDED
@@ -0,0 +1,239 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { loadCodec } from "./codecs.mjs";
5
+ import { diffImages, identicalResult } from "./engine.mjs";
6
+ import { fromTransferred, toTransferable } from "./transfer.mjs";
7
+ //#region src/pool.ts
8
+ /** Workers dying before `ready` in a row, after one has started fine, before the pool gives up. */
9
+ const MAX_START_FAILURES = 3;
10
+ const livePools = /* @__PURE__ */ new Set();
11
+ let exitHookInstalled = false;
12
+ /**
13
+ * Runs diffs on worker threads, transferring PNG bytes instead of copying them.
14
+ *
15
+ * Workers start on demand, take work only once they have reported `ready` (so a worker file that
16
+ * fails to load never swallows a transferred buffer), and are `unref`'d while idle so they never
17
+ * keep the process alive. If workers cannot be used at all, every diff runs in-thread.
18
+ */
19
+ var DiffPool = class {
20
+ size;
21
+ workerUrl;
22
+ engine;
23
+ slots = [];
24
+ queue = [];
25
+ nextId = 0;
26
+ broken = false;
27
+ /** Whether any worker has reported ready, which proves the worker file loads. */
28
+ everReady = false;
29
+ startFailures = 0;
30
+ onFallback;
31
+ workerClass;
32
+ constructor(options) {
33
+ this.size = Math.max(0, Math.floor(options.size));
34
+ this.workerUrl = options.workerUrl;
35
+ this.engine = options.engine ?? "pngjs";
36
+ this.onFallback = options.onFallback ?? warnFallback;
37
+ }
38
+ /**
39
+ * Diff two PNGs. The inputs may be detached (transferred) when a worker runs the diff, so
40
+ * callers must not reuse them afterwards.
41
+ */
42
+ async run(baseline, current, options) {
43
+ const identical = identicalResult(baseline, current);
44
+ if (identical) return identical;
45
+ if (this.size === 0 || this.broken || !await this.loadWorkerClass()) return await this.diffInThread(baseline, current, options);
46
+ return await new Promise((resolve, reject) => {
47
+ this.nextId += 1;
48
+ const { nextId: id } = this;
49
+ this.queue.push({
50
+ id,
51
+ baseline,
52
+ current,
53
+ options,
54
+ engine: this.engine,
55
+ resolve,
56
+ reject
57
+ });
58
+ this.pump();
59
+ });
60
+ }
61
+ async diffInThread(baseline, current, options) {
62
+ return await diffImages(baseline, current, options, await loadCodec(this.engine));
63
+ }
64
+ /** Stop all workers and reject unfinished work. The pool starts fresh workers if used again. */
65
+ async shutdown() {
66
+ livePools.delete(this);
67
+ const slots = this.slots.splice(0);
68
+ const queued = this.queue;
69
+ this.queue = [];
70
+ const stopped = /* @__PURE__ */ new Error("diff pool shut down");
71
+ for (const task of [...queued, ...slots.flatMap((slot) => slot.task ? [slot.task] : [])]) task.reject(stopped);
72
+ await Promise.all(slots.map(async (slot) => await slot.worker.terminate()));
73
+ }
74
+ /** Worker threads currently alive (for tests and diagnostics). */
75
+ get workerCount() {
76
+ return this.slots.length;
77
+ }
78
+ async loadWorkerClass() {
79
+ if (this.workerClass === void 0) try {
80
+ this.workerClass = (await import("node:worker_threads")).Worker;
81
+ } catch {
82
+ this.workerClass = null;
83
+ }
84
+ return this.workerClass !== null;
85
+ }
86
+ pump() {
87
+ if (this.broken) return;
88
+ this.dispatchToIdle();
89
+ this.spawnForQueue();
90
+ }
91
+ dispatchToIdle() {
92
+ for (let idle = this.idleSlot(); idle; idle = this.idleSlot()) {
93
+ const next = this.queue.shift();
94
+ if (!next) break;
95
+ dispatch(idle, next);
96
+ }
97
+ }
98
+ /** Start workers for queued work beyond the ones already starting, up to the pool size. */
99
+ spawnForQueue() {
100
+ const starting = this.slots.filter((slot) => !slot.ready).length;
101
+ const wanted = Math.min(this.queue.length - starting, this.size - this.slots.length);
102
+ for (let index = 0; index < wanted && this.workerClass; index += 1) this.spawn(this.workerClass);
103
+ }
104
+ idleSlot() {
105
+ return this.slots.find((slot) => slot.ready && !slot.task);
106
+ }
107
+ spawn(WorkerClass) {
108
+ try {
109
+ const slot = {
110
+ worker: new WorkerClass(this.workerUrl),
111
+ ready: false,
112
+ task: null
113
+ };
114
+ this.slots.push(slot);
115
+ installExitHook(this);
116
+ this.watch(slot);
117
+ } catch (error) {
118
+ this.onSpawnError(error);
119
+ }
120
+ }
121
+ watch(slot) {
122
+ slot.worker.on("message", (message) => {
123
+ this.onMessage(slot, message);
124
+ });
125
+ slot.worker.on("error", (error) => {
126
+ this.onLost(slot, error);
127
+ });
128
+ slot.worker.on("exit", (code) => {
129
+ this.onLost(slot, /* @__PURE__ */ new Error(`diff worker exited with code ${code}`));
130
+ });
131
+ }
132
+ /** `new Worker` threw (a thread limit, say): a start failure, and fatal with no worker left. */
133
+ onSpawnError(error) {
134
+ const reason = `diff worker could not start: ${String(error)}`;
135
+ this.onStartFailure(reason);
136
+ if (this.slots.length === 0) this.fallBackToThread(reason);
137
+ }
138
+ onMessage(slot, message) {
139
+ if (message.type === "ready") this.markReady(slot);
140
+ else if (slot.task?.id === message.id) {
141
+ const { task } = slot;
142
+ slot.task = null;
143
+ slot.worker.unref();
144
+ settle(task, message);
145
+ }
146
+ if (this.broken && !slot.task) this.retire(slot);
147
+ this.pump();
148
+ }
149
+ markReady(slot) {
150
+ slot.ready = true;
151
+ this.everReady = true;
152
+ this.startFailures = 0;
153
+ slot.worker.unref();
154
+ }
155
+ onLost(slot, error) {
156
+ const index = this.slots.indexOf(slot);
157
+ if (index === -1) return;
158
+ this.slots.splice(index, 1);
159
+ slot.task?.reject(error);
160
+ if (!slot.ready) this.onStartFailure(`diff worker exited before it was ready: ${error.message}`);
161
+ this.pump();
162
+ }
163
+ /**
164
+ * A worker died before reporting ready. Before any worker ever has, the worker file cannot run, so
165
+ * go in-thread; after one has, it is a crash (memory pressure, a thread limit) and the pool only
166
+ * gives up if replacements keep failing.
167
+ */
168
+ onStartFailure(reason) {
169
+ this.startFailures += 1;
170
+ if (!this.everReady || this.startFailures >= MAX_START_FAILURES) this.fallBackToThread(reason);
171
+ }
172
+ retire(slot) {
173
+ const index = this.slots.indexOf(slot);
174
+ if (index !== -1) this.slots.splice(index, 1);
175
+ slot.worker.terminate().catch(ignore);
176
+ }
177
+ /**
178
+ * Stay in-thread for good. Idle and starting workers stop now; a worker busy with a diff keeps it
179
+ * (its buffers were transferred to it) and stops once it answers, so no running diff is dropped.
180
+ */
181
+ fallBackToThread(reason) {
182
+ if (this.broken) return;
183
+ this.broken = true;
184
+ this.onFallback(reason);
185
+ for (const slot of this.slots.filter((candidate) => !candidate.task)) this.retire(slot);
186
+ const queued = this.queue;
187
+ this.queue = [];
188
+ for (const task of queued) this.diffInThread(task.baseline, task.current, task.options).then(task.resolve, (error) => {
189
+ task.reject(error instanceof Error ? error : new Error(String(error)));
190
+ });
191
+ }
192
+ };
193
+ /** Terminate workers when the process exits; they are unref'd when idle, this covers the rest. */
194
+ function installExitHook(pool) {
195
+ livePools.add(pool);
196
+ if (exitHookInstalled) return;
197
+ exitHookInstalled = true;
198
+ process.once("exit", () => {
199
+ for (const live of livePools) live.shutdown().catch(ignore);
200
+ });
201
+ }
202
+ function ignore() {}
203
+ function warnFallback(reason) {
204
+ process.emitWarning(`${reason}; running image diffs on the main thread from now on`, { code: "STORYSHELF_DIFF_POOL_FALLBACK" });
205
+ }
206
+ /** Hand a task to an idle worker, transferring both PNGs (copying any that are not exclusively owned). */
207
+ function dispatch(slot, task) {
208
+ const baseline = toTransferable(task.baseline);
209
+ let current = toTransferable(task.current);
210
+ if (current.buffer === baseline.buffer) current = Uint8Array.from(current);
211
+ slot.task = task;
212
+ slot.worker.ref();
213
+ const request = {
214
+ id: task.id,
215
+ baseline,
216
+ current,
217
+ options: task.options,
218
+ engine: task.engine
219
+ };
220
+ slot.worker.postMessage(request, [baseline.buffer, current.buffer]);
221
+ }
222
+ /** Resolve or reject a task from the worker's answer. */
223
+ function settle(task, message) {
224
+ if (message.type === "result") {
225
+ const { diffImage } = message.result;
226
+ task.resolve({
227
+ ...message.result,
228
+ diffImage: diffImage ? fromTransferred(diffImage) : null
229
+ });
230
+ return;
231
+ }
232
+ const error = new Error(message.message);
233
+ error.name = message.name;
234
+ task.reject(error);
235
+ }
236
+ //#endregion
237
+ export { DiffPool };
238
+
239
+ //# sourceMappingURL=pool.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.mjs","names":[],"sources":["../src/pool.ts"],"sourcesContent":["/** A small `worker_threads` pool that runs `diffImages` off the main thread. */\nimport type { Worker } from \"node:worker_threads\";\nimport type { ImageEngine } from \"./codec.ts\";\nimport { loadCodec } from \"./codecs.ts\";\nimport { diffImages, identicalResult } from \"./engine.ts\";\nimport type { DiffOptions, DiffResult } from \"./options.ts\";\nimport type { DiffRequest, DiffResponse } from \"./protocol.ts\";\nimport { fromTransferred, toTransferable } from \"./transfer.ts\";\n\n/** Options for {@link DiffPool}. */\nexport interface DiffPoolOptions {\n /** Maximum worker threads; 0 runs every diff in the calling thread. */\n size: number;\n /** Worker module to spawn. */\n workerUrl: URL | string;\n /** Image engine the workers (and the in-thread fallback) use; pngjs when omitted. */\n engine?: ImageEngine;\n /**\n * Called once if the pool gives up on threads and runs every diff in-thread from then on. Defaults\n * to a process warning, since every later diff then blocks the event loop.\n */\n onFallback?: (reason: string) => void;\n}\n\n/** Workers dying before `ready` in a row, after one has started fine, before the pool gives up. */\nconst MAX_START_FAILURES = 3;\n\ninterface Pending {\n id: number;\n baseline: Buffer;\n current: Buffer;\n options: DiffOptions;\n engine: ImageEngine;\n resolve: (result: DiffResult) => void;\n reject: (error: Error) => void;\n}\n\ninterface Slot {\n worker: Worker;\n ready: boolean;\n task: Pending | null;\n}\n\ntype WorkerCtor = typeof Worker;\n\nconst livePools = new Set<DiffPool>();\nlet exitHookInstalled = false;\n\n/**\n * Runs diffs on worker threads, transferring PNG bytes instead of copying them.\n *\n * Workers start on demand, take work only once they have reported `ready` (so a worker file that\n * fails to load never swallows a transferred buffer), and are `unref`'d while idle so they never\n * keep the process alive. If workers cannot be used at all, every diff runs in-thread.\n */\nexport class DiffPool {\n private readonly size: number;\n private readonly workerUrl: URL | string;\n private readonly engine: ImageEngine;\n private readonly slots: Slot[] = [];\n private queue: Pending[] = [];\n private nextId = 0;\n private broken = false;\n /** Whether any worker has reported ready, which proves the worker file loads. */\n private everReady = false;\n private startFailures = 0;\n private readonly onFallback: (reason: string) => void;\n private workerClass: WorkerCtor | null | undefined;\n\n constructor(options: DiffPoolOptions) {\n this.size = Math.max(0, Math.floor(options.size));\n this.workerUrl = options.workerUrl;\n this.engine = options.engine ?? \"pngjs\";\n this.onFallback = options.onFallback ?? warnFallback;\n }\n\n /**\n * Diff two PNGs. The inputs may be detached (transferred) when a worker runs the diff, so\n * callers must not reuse them afterwards.\n */\n async run(baseline: Buffer, current: Buffer, options: DiffOptions): Promise<DiffResult> {\n const identical = identicalResult(baseline, current);\n if (identical) {\n return identical;\n }\n if (this.size === 0 || this.broken || !(await this.loadWorkerClass())) {\n return await this.diffInThread(baseline, current, options);\n }\n return await new Promise<DiffResult>((resolve, reject) => {\n this.nextId += 1;\n const { nextId: id } = this;\n this.queue.push({ id, baseline, current, options, engine: this.engine, resolve, reject });\n this.pump();\n });\n }\n\n private async diffInThread(\n baseline: Buffer,\n current: Buffer,\n options: DiffOptions,\n ): Promise<DiffResult> {\n return await diffImages(baseline, current, options, await loadCodec(this.engine));\n }\n\n /** Stop all workers and reject unfinished work. The pool starts fresh workers if used again. */\n async shutdown(): Promise<void> {\n livePools.delete(this);\n const slots = this.slots.splice(0);\n const queued = this.queue;\n this.queue = [];\n const stopped = new Error(\"diff pool shut down\");\n for (const task of [...queued, ...slots.flatMap((slot) => (slot.task ? [slot.task] : []))]) {\n task.reject(stopped);\n }\n await Promise.all(slots.map(async (slot) => await slot.worker.terminate()));\n }\n\n /** Worker threads currently alive (for tests and diagnostics). */\n get workerCount(): number {\n return this.slots.length;\n }\n\n private async loadWorkerClass(): Promise<boolean> {\n if (this.workerClass === undefined) {\n try {\n this.workerClass = (await import(\"node:worker_threads\")).Worker;\n } catch {\n this.workerClass = null;\n }\n }\n return this.workerClass !== null;\n }\n\n private pump(): void {\n if (this.broken) {\n return;\n }\n this.dispatchToIdle();\n this.spawnForQueue();\n }\n\n private dispatchToIdle(): void {\n for (let idle = this.idleSlot(); idle; idle = this.idleSlot()) {\n const next = this.queue.shift();\n if (!next) {\n break;\n }\n dispatch(idle, next);\n }\n }\n\n /** Start workers for queued work beyond the ones already starting, up to the pool size. */\n private spawnForQueue(): void {\n const starting = this.slots.filter((slot) => !slot.ready).length;\n const wanted = Math.min(this.queue.length - starting, this.size - this.slots.length);\n for (let index = 0; index < wanted && this.workerClass; index += 1) {\n this.spawn(this.workerClass);\n }\n }\n\n private idleSlot(): Slot | undefined {\n return this.slots.find((slot) => slot.ready && !slot.task);\n }\n\n private spawn(WorkerClass: WorkerCtor): void {\n try {\n const worker = new WorkerClass(this.workerUrl);\n const slot: Slot = { worker, ready: false, task: null };\n this.slots.push(slot);\n installExitHook(this);\n this.watch(slot);\n } catch (error) {\n this.onSpawnError(error);\n }\n }\n\n private watch(slot: Slot): void {\n slot.worker.on(\"message\", (message: DiffResponse) => {\n this.onMessage(slot, message);\n });\n slot.worker.on(\"error\", (error: Error) => {\n this.onLost(slot, error);\n });\n slot.worker.on(\"exit\", (code) => {\n this.onLost(slot, new Error(`diff worker exited with code ${code}`));\n });\n }\n\n /** `new Worker` threw (a thread limit, say): a start failure, and fatal with no worker left. */\n private onSpawnError(error: unknown): void {\n const reason = `diff worker could not start: ${String(error)}`;\n this.onStartFailure(reason);\n if (this.slots.length === 0) {\n this.fallBackToThread(reason);\n }\n }\n\n private onMessage(slot: Slot, message: DiffResponse): void {\n if (message.type === \"ready\") {\n this.markReady(slot);\n } else if (slot.task?.id === message.id) {\n const { task } = slot;\n slot.task = null;\n slot.worker.unref();\n settle(task, message);\n }\n if (this.broken && !slot.task) {\n this.retire(slot);\n }\n this.pump();\n }\n\n private markReady(slot: Slot): void {\n slot.ready = true;\n this.everReady = true;\n this.startFailures = 0;\n slot.worker.unref();\n }\n\n private onLost(slot: Slot, error: Error): void {\n const index = this.slots.indexOf(slot);\n if (index === -1) {\n return;\n }\n this.slots.splice(index, 1);\n slot.task?.reject(error);\n if (!slot.ready) {\n this.onStartFailure(`diff worker exited before it was ready: ${error.message}`);\n }\n this.pump();\n }\n\n /**\n * A worker died before reporting ready. Before any worker ever has, the worker file cannot run, so\n * go in-thread; after one has, it is a crash (memory pressure, a thread limit) and the pool only\n * gives up if replacements keep failing.\n */\n private onStartFailure(reason: string): void {\n this.startFailures += 1;\n if (!this.everReady || this.startFailures >= MAX_START_FAILURES) {\n this.fallBackToThread(reason);\n }\n }\n\n private retire(slot: Slot): void {\n const index = this.slots.indexOf(slot);\n if (index !== -1) {\n this.slots.splice(index, 1);\n }\n slot.worker.terminate().catch(ignore);\n }\n\n /**\n * Stay in-thread for good. Idle and starting workers stop now; a worker busy with a diff keeps it\n * (its buffers were transferred to it) and stops once it answers, so no running diff is dropped.\n */\n private fallBackToThread(reason: string): void {\n if (this.broken) {\n return;\n }\n this.broken = true;\n this.onFallback(reason);\n for (const slot of this.slots.filter((candidate) => !candidate.task)) {\n this.retire(slot);\n }\n const queued = this.queue;\n this.queue = [];\n for (const task of queued) {\n this.diffInThread(task.baseline, task.current, task.options).then(\n task.resolve,\n (error: unknown) => {\n task.reject(error instanceof Error ? error : new Error(String(error)));\n },\n );\n }\n }\n}\n\n/** Terminate workers when the process exits; they are unref'd when idle, this covers the rest. */\nfunction installExitHook(pool: DiffPool): void {\n livePools.add(pool);\n if (exitHookInstalled) {\n return;\n }\n exitHookInstalled = true;\n process.once(\"exit\", () => {\n for (const live of livePools) {\n live.shutdown().catch(ignore);\n }\n });\n}\n\nfunction ignore(): void {}\n\nfunction warnFallback(reason: string): void {\n process.emitWarning(`${reason}; running image diffs on the main thread from now on`, {\n code: \"STORYSHELF_DIFF_POOL_FALLBACK\",\n });\n}\n\n/** Hand a task to an idle worker, transferring both PNGs (copying any that are not exclusively owned). */\nfunction dispatch(slot: Slot, task: Pending): void {\n const baseline = toTransferable(task.baseline);\n let current = toTransferable(task.current);\n if (current.buffer === baseline.buffer) {\n current = Uint8Array.from(current);\n }\n slot.task = task;\n slot.worker.ref();\n const request: DiffRequest = {\n id: task.id,\n baseline,\n current,\n options: task.options,\n engine: task.engine,\n };\n slot.worker.postMessage(request, [baseline.buffer as ArrayBuffer, current.buffer as ArrayBuffer]);\n}\n\n/** Resolve or reject a task from the worker's answer. */\nfunction settle(task: Pending, message: Exclude<DiffResponse, { type: \"ready\" }>): void {\n if (message.type === \"result\") {\n const { diffImage } = message.result;\n task.resolve({ ...message.result, diffImage: diffImage ? fromTransferred(diffImage) : null });\n return;\n }\n const error = new Error(message.message);\n error.name = message.name;\n task.reject(error);\n}\n"],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { ImageEngine } from "./codec.mjs";
4
+ //#region src/settings.d.ts
5
+ /** What `createShelfApp` passes down: `images.engine` and `compare.threads`. */
6
+ interface ImageSettings {
7
+ /** Image library: `"pngjs"` (default) or `"sharp"`; falls back to `STORYSHELF_IMAGE_ENGINE`. */
8
+ engine?: string;
9
+ /** Worker threads for diffs; `0` runs on the main thread. */
10
+ threads?: number;
11
+ }
12
+ /** The engine in effect: the configured one, else the environment, else the default. */
13
+ declare function currentImageEngine(): ImageEngine;
14
+ /**
15
+ * Load the engine's dependencies now, so a missing `sharp` fails at boot with a clear message
16
+ * instead of on the first diff. The default engine is a hard dependency with nothing to verify,
17
+ * so it returns at once and never delays startup (or the first capture).
18
+ */
19
+ declare function verifyImageEngine(): Promise<ImageEngine>;
20
+ //#endregion
21
+ export { ImageSettings, currentImageEngine, verifyImageEngine };
22
+ //# sourceMappingURL=settings.d.mts.map
@@ -0,0 +1,48 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { loadCodec } from "./codecs.mjs";
5
+ import { resolveImageEngine } from "./codec.mjs";
6
+ //#region src/settings.ts
7
+ /** Process-wide image settings, set once at app boot and read by the diff pool and thumbnails. */
8
+ let configuredEngine;
9
+ let configuredThreads;
10
+ /**
11
+ * Record the engine and thread count (the caller then rebuilds anything that depends on them).
12
+ * The option wins: `STORYSHELF_IMAGE_ENGINE` is read only when no engine is given, so a bad
13
+ * variable cannot break an explicit option. Throws on an unknown name so a typo never silently
14
+ * picks a different engine.
15
+ */
16
+ function setImageSettings(settings) {
17
+ configuredEngine = resolveImageEngine(settings.engine ?? process.env["STORYSHELF_IMAGE_ENGINE"]);
18
+ configuredThreads = settings.threads;
19
+ }
20
+ /** What the last {@link setImageSettings} resolved, without consulting the environment. */
21
+ function appliedImageSettings() {
22
+ return {
23
+ engine: configuredEngine,
24
+ threads: configuredThreads
25
+ };
26
+ }
27
+ /** The engine in effect: the configured one, else the environment, else the default. */
28
+ function currentImageEngine() {
29
+ return configuredEngine ?? resolveImageEngine(process.env["STORYSHELF_IMAGE_ENGINE"]);
30
+ }
31
+ /** The configured thread count, or `undefined` to defer to the environment/default. */
32
+ function configuredThreadCount() {
33
+ return configuredThreads;
34
+ }
35
+ /**
36
+ * Load the engine's dependencies now, so a missing `sharp` fails at boot with a clear message
37
+ * instead of on the first diff. The default engine is a hard dependency with nothing to verify,
38
+ * so it returns at once and never delays startup (or the first capture).
39
+ */
40
+ async function verifyImageEngine() {
41
+ const engine = currentImageEngine();
42
+ if (engine !== "pngjs") await loadCodec(engine);
43
+ return engine;
44
+ }
45
+ //#endregion
46
+ export { appliedImageSettings, configuredThreadCount, currentImageEngine, setImageSettings, verifyImageEngine };
47
+
48
+ //# sourceMappingURL=settings.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.mjs","names":[],"sources":["../src/settings.ts"],"sourcesContent":["/** Process-wide image settings, set once at app boot and read by the diff pool and thumbnails. */\nimport {\n DEFAULT_IMAGE_ENGINE,\n IMAGE_ENGINE_ENV,\n type ImageEngine,\n resolveImageEngine,\n} from \"./codec.ts\";\nimport { loadCodec } from \"./codecs.ts\";\n\n/** What `createShelfApp` passes down: `images.engine` and `compare.threads`. */\nexport interface ImageSettings {\n /** Image library: `\"pngjs\"` (default) or `\"sharp\"`; falls back to `STORYSHELF_IMAGE_ENGINE`. */\n engine?: string;\n /** Worker threads for diffs; `0` runs on the main thread. */\n threads?: number;\n}\n\nlet configuredEngine: ImageEngine | undefined;\nlet configuredThreads: number | undefined;\n\n/**\n * Record the engine and thread count (the caller then rebuilds anything that depends on them).\n * The option wins: `STORYSHELF_IMAGE_ENGINE` is read only when no engine is given, so a bad\n * variable cannot break an explicit option. Throws on an unknown name so a typo never silently\n * picks a different engine.\n */\nexport function setImageSettings(settings: ImageSettings): void {\n configuredEngine = resolveImageEngine(settings.engine ?? process.env[IMAGE_ENGINE_ENV]);\n configuredThreads = settings.threads;\n}\n\n/** What the last {@link setImageSettings} resolved, without consulting the environment. */\nexport function appliedImageSettings(): {\n engine: ImageEngine | undefined;\n threads: number | undefined;\n} {\n return { engine: configuredEngine, threads: configuredThreads };\n}\n\n/** The engine in effect: the configured one, else the environment, else the default. */\nexport function currentImageEngine(): ImageEngine {\n return configuredEngine ?? resolveImageEngine(process.env[IMAGE_ENGINE_ENV]);\n}\n\n/** The configured thread count, or `undefined` to defer to the environment/default. */\nexport function configuredThreadCount(): number | undefined {\n return configuredThreads;\n}\n\n/**\n * Load the engine's dependencies now, so a missing `sharp` fails at boot with a clear message\n * instead of on the first diff. The default engine is a hard dependency with nothing to verify,\n * so it returns at once and never delays startup (or the first capture).\n */\nexport async function verifyImageEngine(): Promise<ImageEngine> {\n const engine = currentImageEngine();\n if (engine !== DEFAULT_IMAGE_ENGINE) {\n await loadCodec(engine);\n }\n return engine;\n}\n"],"mappings":";;;;;;;AAiBA,IAAI;AACJ,IAAI;;;;;;;AAQJ,SAAgB,iBAAiB,UAA+B;CAC9D,mBAAmB,mBAAmB,SAAS,UAAU,QAAQ,IAAA,0BAAqB;CACtF,oBAAoB,SAAS;AAC/B;;AAGA,SAAgB,uBAGd;CACA,OAAO;EAAE,QAAQ;EAAkB,SAAS;CAAkB;AAChE;;AAGA,SAAgB,qBAAkC;CAChD,OAAO,oBAAoB,mBAAmB,QAAQ,IAAA,0BAAqB;AAC7E;;AAGA,SAAgB,wBAA4C;CAC1D,OAAO;AACT;;;;;;AAOA,eAAsB,oBAA0C;CAC9D,MAAM,SAAS,mBAAmB;CAClC,IAAI,WAAA,SACF,MAAM,UAAU,MAAM;CAExB,OAAO;AACT"}
package/dist/sharp.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { isMainThread } from "node:worker_threads";
5
+ //#region src/sharp.ts
6
+ /** The optional native codec: libvips via `sharp`. Several times faster than pngjs on large PNGs. */
7
+ /**
8
+ * Load `sharp` and build the codec. Throws a readable error when the optional peer is missing
9
+ * or has no binary for this platform, so a misconfigured engine fails at boot, not mid-build.
10
+ */
11
+ async function createSharpCodec() {
12
+ const sharp = await importSharp();
13
+ if (!isMainThread) sharp.concurrency(1);
14
+ return {
15
+ name: "sharp",
16
+ decode: async (png) => await decodeWithSharp(sharp, png),
17
+ encode: async (image) => await encodeWithSharp(sharp, image),
18
+ downscale: async (png, maxEdge) => await downscaleWithSharp(sharp, png, maxEdge)
19
+ };
20
+ }
21
+ async function importSharp() {
22
+ try {
23
+ return (await import("sharp")).default;
24
+ } catch (error) {
25
+ throw new Error("images.engine \"sharp\" needs the optional `sharp` package: install it next to StoryShelf (e.g. `nub add sharp`).", { cause: error });
26
+ }
27
+ }
28
+ async function decodeWithSharp(sharp, png) {
29
+ const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
30
+ return {
31
+ width: info.width,
32
+ height: info.height,
33
+ data
34
+ };
35
+ }
36
+ /**
37
+ * Encode RGBA as a PNG. The result is copied: libvips hands back a Buffer over external native
38
+ * memory, which a worker cannot transfer to the pool (`DataCloneError`).
39
+ */
40
+ async function encodeWithSharp(sharp, { width, height, data }) {
41
+ const encoded = await sharp(data, { raw: {
42
+ width,
43
+ height,
44
+ channels: 4
45
+ } }).png().toBuffer();
46
+ return Buffer.from(encoded);
47
+ }
48
+ async function downscaleWithSharp(sharp, png, maxEdge) {
49
+ const out = await sharp(png).resize({
50
+ width: maxEdge,
51
+ height: maxEdge,
52
+ fit: "inside",
53
+ withoutEnlargement: true
54
+ }).png().toBuffer();
55
+ return new Uint8Array(out);
56
+ }
57
+ //#endregion
58
+ export { createSharpCodec };
59
+
60
+ //# sourceMappingURL=sharp.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sharp.mjs","names":[],"sources":["../src/sharp.ts"],"sourcesContent":["/** The optional native codec: libvips via `sharp`. Several times faster than pngjs on large PNGs. */\nimport { isMainThread } from \"node:worker_threads\";\nimport type { ImageCodec, RgbaImage } from \"./codec.ts\";\n\ntype SharpModule = typeof import(\"sharp\");\n\n/**\n * Load `sharp` and build the codec. Throws a readable error when the optional peer is missing\n * or has no binary for this platform, so a misconfigured engine fails at boot, not mid-build.\n */\nexport async function createSharpCodec(): Promise<ImageCodec> {\n const sharp = await importSharp();\n if (!isMainThread) {\n // Each pool worker would otherwise run libvips on a CPU-count thread pool of its own; the pool\n // already parallelises across workers, so one libvips thread each avoids oversubscription.\n sharp.concurrency(1);\n }\n return {\n name: \"sharp\",\n decode: async (png) => await decodeWithSharp(sharp, png),\n encode: async (image) => await encodeWithSharp(sharp, image),\n downscale: async (png, maxEdge) => await downscaleWithSharp(sharp, png, maxEdge),\n };\n}\n\nasync function importSharp(): Promise<SharpModule[\"default\"]> {\n try {\n return (await import(\"sharp\")).default;\n } catch (error) {\n throw new Error(\n 'images.engine \"sharp\" needs the optional `sharp` package: install it next to StoryShelf (e.g. `nub add sharp`).',\n { cause: error },\n );\n }\n}\n\nasync function decodeWithSharp(sharp: SharpModule[\"default\"], png: Buffer): Promise<RgbaImage> {\n const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });\n return { width: info.width, height: info.height, data };\n}\n\n/**\n * Encode RGBA as a PNG. The result is copied: libvips hands back a Buffer over external native\n * memory, which a worker cannot transfer to the pool (`DataCloneError`).\n */\nasync function encodeWithSharp(\n sharp: SharpModule[\"default\"],\n { width, height, data }: RgbaImage,\n): Promise<Buffer> {\n const encoded = await sharp(data, { raw: { width, height, channels: 4 } })\n .png()\n .toBuffer();\n return Buffer.from(encoded);\n}\n\nasync function downscaleWithSharp(\n sharp: SharpModule[\"default\"],\n png: Uint8Array,\n maxEdge: number,\n): Promise<Uint8Array> {\n const out = await sharp(png)\n .resize({ width: maxEdge, height: maxEdge, fit: \"inside\", withoutEnlargement: true })\n .png()\n .toBuffer();\n return new Uint8Array(out);\n}\n"],"mappings":";;;;;;;;;;AAUA,eAAsB,mBAAwC;CAC5D,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,cAGH,MAAM,YAAY,CAAC;CAErB,OAAO;EACL,MAAM;EACN,QAAQ,OAAO,QAAQ,MAAM,gBAAgB,OAAO,GAAG;EACvD,QAAQ,OAAO,UAAU,MAAM,gBAAgB,OAAO,KAAK;EAC3D,WAAW,OAAO,KAAK,YAAY,MAAM,mBAAmB,OAAO,KAAK,OAAO;CACjF;AACF;AAEA,eAAe,cAA+C;CAC5D,IAAI;EACF,QAAQ,MAAM,OAAO,SAAA,CAAU;CACjC,SAAS,OAAO;EACd,MAAM,IAAI,MACR,qHACA,EAAE,OAAO,MAAM,CACjB;CACF;AACF;AAEA,eAAe,gBAAgB,OAA+B,KAAiC;CAC7F,MAAM,EAAE,MAAM,SAAS,MAAM,MAAM,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,mBAAmB,KAAK,CAAC;CAChG,OAAO;EAAE,OAAO,KAAK;EAAO,QAAQ,KAAK;EAAQ;CAAK;AACxD;;;;;AAMA,eAAe,gBACb,OACA,EAAE,OAAO,QAAQ,QACA;CACjB,MAAM,UAAU,MAAM,MAAM,MAAM,EAAE,KAAK;EAAE;EAAO;EAAQ,UAAU;CAAE,EAAE,CAAC,CAAC,CACvE,IAAI,CAAC,CACL,SAAS;CACZ,OAAO,OAAO,KAAK,OAAO;AAC5B;AAEA,eAAe,mBACb,OACA,KACA,SACqB;CACrB,MAAM,MAAM,MAAM,MAAM,GAAG,CAAC,CACzB,OAAO;EAAE,OAAO;EAAS,QAAQ;EAAS,KAAK;EAAU,oBAAoB;CAAK,CAAC,CAAC,CACpF,IAAI,CAAC,CACL,SAAS;CACZ,OAAO,IAAI,WAAW,GAAG;AAC3B"}
@@ -0,0 +1,24 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/transfer.ts
5
+ /** Zero-copy hand-off of Buffers across the worker boundary. */
6
+ /**
7
+ * A Uint8Array that may be transferred (detached from the caller) without corrupting anything.
8
+ *
9
+ * A Buffer that spans its whole ArrayBuffer is moved as-is. Pooled slices (small `Buffer`s share
10
+ * one 8 KiB pool) and views over shared memory are copied, since transferring them would detach
11
+ * unrelated data.
12
+ */
13
+ function toTransferable(buffer) {
14
+ const { buffer: backing, byteOffset, byteLength } = buffer;
15
+ return backing instanceof ArrayBuffer && byteOffset === 0 && byteLength === backing.byteLength ? buffer : Uint8Array.from(buffer);
16
+ }
17
+ /** Wrap bytes received from a worker as a Buffer without copying. */
18
+ function fromTransferred(bytes) {
19
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
20
+ }
21
+ //#endregion
22
+ export { fromTransferred, toTransferable };
23
+
24
+ //# sourceMappingURL=transfer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transfer.mjs","names":[],"sources":["../src/transfer.ts"],"sourcesContent":["/** Zero-copy hand-off of Buffers across the worker boundary. */\n\n/**\n * A Uint8Array that may be transferred (detached from the caller) without corrupting anything.\n *\n * A Buffer that spans its whole ArrayBuffer is moved as-is. Pooled slices (small `Buffer`s share\n * one 8 KiB pool) and views over shared memory are copied, since transferring them would detach\n * unrelated data.\n */\nexport function toTransferable(buffer: Uint8Array): Uint8Array {\n const { buffer: backing, byteOffset, byteLength } = buffer;\n const owns =\n backing instanceof ArrayBuffer && byteOffset === 0 && byteLength === backing.byteLength;\n return owns ? buffer : Uint8Array.from(buffer);\n}\n\n/** Wrap bytes received from a worker as a Buffer without copying. */\nexport function fromTransferred(bytes: Uint8Array): Buffer {\n return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n}\n"],"mappings":";;;;;;;;;;;;AASA,SAAgB,eAAe,QAAgC;CAC7D,MAAM,EAAE,QAAQ,SAAS,YAAY,eAAe;CAGpD,OADE,mBAAmB,eAAe,eAAe,KAAK,eAAe,QAAQ,aACjE,SAAS,WAAW,KAAK,MAAM;AAC/C;;AAGA,SAAgB,gBAAgB,OAA2B;CACzD,OAAO,OAAO,KAAK,MAAM,QAAQ,MAAM,YAAY,MAAM,UAAU;AACrE"}
@@ -0,0 +1,2 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
@@ -0,0 +1,47 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { loadCodec } from "./codecs.mjs";
5
+ import { diffImages } from "./engine.mjs";
6
+ import { fromTransferred, toTransferable } from "./transfer.mjs";
7
+ import { parentPort } from "node:worker_threads";
8
+ //#region src/worker.ts
9
+ /** Worker-thread entry for the diff pool: runs the same `diffImages` as the in-thread path. */
10
+ const port = parentPort;
11
+ async function handle(request) {
12
+ if (!port) return;
13
+ try {
14
+ const result = await diffImages(fromTransferred(request.baseline), fromTransferred(request.current), request.options, await loadCodec(request.engine));
15
+ const diffImage = result.diffImage ? toTransferable(result.diffImage) : null;
16
+ const response = {
17
+ type: "result",
18
+ id: request.id,
19
+ result: {
20
+ ...result,
21
+ diffImage
22
+ }
23
+ };
24
+ port.postMessage(response, diffImage ? [diffImage.buffer] : []);
25
+ } catch (error) {
26
+ const cause = error instanceof Error ? error : new Error(String(error));
27
+ const response = {
28
+ type: "error",
29
+ id: request.id,
30
+ name: cause.name,
31
+ message: cause.message
32
+ };
33
+ port.postMessage(response);
34
+ }
35
+ }
36
+ port?.on("message", (request) => {
37
+ handle(request).catch((error) => {
38
+ setImmediate(() => {
39
+ throw error;
40
+ });
41
+ });
42
+ });
43
+ port?.postMessage({ type: "ready" });
44
+ //#endregion
45
+ export {};
46
+
47
+ //# sourceMappingURL=worker.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/** Worker-thread entry for the diff pool: runs the same `diffImages` as the in-thread path. */\nimport { parentPort } from \"node:worker_threads\";\nimport { loadCodec } from \"./codecs.ts\";\nimport { diffImages } from \"./engine.ts\";\nimport type { DiffRequest, DiffResponse } from \"./protocol.ts\";\nimport { fromTransferred, toTransferable } from \"./transfer.ts\";\n\nconst port = parentPort;\n\nasync function handle(request: DiffRequest): Promise<void> {\n if (!port) {\n return;\n }\n try {\n const result = await diffImages(\n fromTransferred(request.baseline),\n fromTransferred(request.current),\n request.options,\n await loadCodec(request.engine),\n );\n const diffImage = result.diffImage ? toTransferable(result.diffImage) : null;\n const response: DiffResponse = {\n type: \"result\",\n id: request.id,\n result: { ...result, diffImage },\n };\n port.postMessage(response, diffImage ? [diffImage.buffer as ArrayBuffer] : []);\n } catch (error) {\n const cause = error instanceof Error ? error : new Error(String(error));\n const response: DiffResponse = {\n type: \"error\",\n id: request.id,\n name: cause.name,\n message: cause.message,\n };\n port.postMessage(response);\n }\n}\n\nport?.on(\"message\", (request: DiffRequest) => {\n handle(request).catch((error: unknown) => {\n // Not even the error reply could be posted. Throw outside the promise so the thread dies with\n // an 'error' event, which rejects the pool's task, instead of leaving it waiting forever.\n setImmediate(() => {\n throw error;\n });\n });\n});\nport?.postMessage({ type: \"ready\" } satisfies DiffResponse);\n"],"mappings":";;;;;;;;;AAOA,MAAM,OAAO;AAEb,eAAe,OAAO,SAAqC;CACzD,IAAI,CAAC,MACH;CAEF,IAAI;EACF,MAAM,SAAS,MAAM,WACnB,gBAAgB,QAAQ,QAAQ,GAChC,gBAAgB,QAAQ,OAAO,GAC/B,QAAQ,SACR,MAAM,UAAU,QAAQ,MAAM,CAChC;EACA,MAAM,YAAY,OAAO,YAAY,eAAe,OAAO,SAAS,IAAI;EACxE,MAAM,WAAyB;GAC7B,MAAM;GACN,IAAI,QAAQ;GACZ,QAAQ;IAAE,GAAG;IAAQ;GAAU;EACjC;EACA,KAAK,YAAY,UAAU,YAAY,CAAC,UAAU,MAAqB,IAAI,CAAC,CAAC;CAC/E,SAAS,OAAO;EACd,MAAM,QAAQ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;EACtE,MAAM,WAAyB;GAC7B,MAAM;GACN,IAAI,QAAQ;GACZ,MAAM,MAAM;GACZ,SAAS,MAAM;EACjB;EACA,KAAK,YAAY,QAAQ;CAC3B;AACF;AAEA,MAAM,GAAG,YAAY,YAAyB;CAC5C,OAAO,OAAO,CAAC,CAAC,OAAO,UAAmB;EAGxC,mBAAmB;GACjB,MAAM;EACR,CAAC;CACH,CAAC;AACH,CAAC;AACD,MAAM,YAAY,EAAE,MAAM,QAAQ,CAAwB"}
package/package.json CHANGED
@@ -1,6 +1,94 @@
1
1
  {
2
2
  "name": "@storyshelf/image",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.2",
4
+ "description": "StoryShelf image engine: pixel diffing, overlay rendering and thumbnails on a worker-thread pool, with pngjs (default) or sharp codecs.",
5
+ "keywords": [
6
+ "diff",
7
+ "image",
8
+ "pixelmatch",
9
+ "sharp",
10
+ "storybook",
11
+ "visual-testing"
12
+ ],
13
+ "homepage": "https://github.com/GuptaSiddhant/StoryShelf#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/GuptaSiddhant/StoryShelf/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": {
19
+ "name": "Siddhant Gupta",
20
+ "url": "https://guptasiddhant.com"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/GuptaSiddhant/StoryShelf.git",
25
+ "directory": "packages/image"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "type": "module",
31
+ "sideEffects": false,
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "source": "./src/index.ts",
36
+ "default": "./dist/index.mjs"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "publishConfig": {
41
+ "exports": {
42
+ ".": "./dist/index.mjs",
43
+ "./package.json": "./package.json"
44
+ },
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown",
49
+ "dev": "tsdown -w",
50
+ "fmt": "oxfmt ./src",
51
+ "lint": "oxlint --type-aware --type-check ./src",
52
+ "prepublishOnly": "nub run build",
53
+ "test": "vitest run",
54
+ "test:coverage": "vitest run --coverage",
55
+ "bench": "node --conditions=source bench/run.ts",
56
+ "test:flake": "vitest run --repeats=10 --maxWorkers=1 --no-file-parallelism src/pool.test.ts"
57
+ },
58
+ "dependencies": {
59
+ "pixelmatch": "^7.1.0",
60
+ "pngjs": "^7.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^26.0.0",
64
+ "@types/pngjs": "^6.0.5",
65
+ "@vitest/coverage-v8": "^4.0.0",
66
+ "oxfmt": "^0.65.0",
67
+ "oxlint": "^1.80.0",
68
+ "oxlint-tsgolint": "^7.0.2001",
69
+ "sharp": "^0.35.5",
70
+ "tsdown": "^0.22.14",
71
+ "typescript": "^7.0.2",
72
+ "vitest": "^5.0.0"
73
+ },
74
+ "peerDependencies": {
75
+ "sharp": ">=0.34.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "sharp": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "engines": {
83
+ "node": ">=24"
84
+ },
85
+ "jsr": {
86
+ "runtimeCompat": {
87
+ "node": true,
88
+ "deno": true,
89
+ "bun": true,
90
+ "workerd": false,
91
+ "browser": false
92
+ }
93
+ }
94
+ }