@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siddhant Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # @storyshelf/image
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ StoryShelf's image engine: pixel diffing (pixelmatch), diff overlay rendering and thumbnails, run on a
4
+ worker-thread pool.
5
+
6
+ ## Engines
7
+
8
+ The comparison is always pixelmatch; the **engine** is the PNG codec around it.
9
+
10
+ | Engine | Install | Notes |
11
+ | ----------------- | --------------- | -------------------------------------------------- |
12
+ | `pngjs` (default) | built in | Pure JavaScript, runs anywhere. |
13
+ | `sharp` | `nub add sharp` | Native (libvips); several times faster on big PNGs |
14
+
15
+ ## Usage
16
+
17
+ Most users never import this package; set the engine on `createShelfApp`:
18
+
19
+ ```ts
20
+ createShelfApp({ ..., testing: { runner, images: { engine: "sharp" } } });
21
+ ```
22
+
23
+ or set `STORYSHELF_IMAGE_ENGINE=sharp`. The option wins over the variable.
24
+
25
+ ## API
26
+
27
+ ```ts
28
+ import {
29
+ diffImages, // (baseline, current, options, codec?) -> Promise<DiffResult>
30
+ diffImagesAsync, // same, on the shared worker-thread pool
31
+ downscalePng, // (png, maxEdge) -> Promise<Uint8Array>, on the configured engine
32
+ configureImages, // ({ engine?, threads? }) apply testing.images.engine and testing.compare.threads
33
+ verifyImageEngine, // load the configured engine now; rejects when it cannot load
34
+ shutdownDiffPool, // stop the pool's workers
35
+ DEFAULT_DIFF_OPTIONS,
36
+ type DiffOptions,
37
+ type DiffResult,
38
+ type ImageEngine,
39
+ } from "@storyshelf/image";
40
+ ```
41
+
42
+ `configureImages` is process-wide. The option beats `STORYSHELF_IMAGE_ENGINE`, and an unknown engine name throws.
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,30 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { DiffOptions, DiffResult } from "./options.mjs";
4
+ import { ImageSettings } from "./settings.mjs";
5
+ //#region src/async.d.ts
6
+ /** Environment variable overriding the pool size; `0` runs diffs in-thread. */
7
+ declare const DIFF_WORKERS_ENV = "STORYSHELF_DIFF_WORKERS";
8
+ /**
9
+ * Apply `images.engine` and `compare.threads`; both beat their environment variables
10
+ * (`STORYSHELF_IMAGE_ENGINE`, `STORYSHELF_DIFF_WORKERS`). A running pool whose engine or size no
11
+ * longer matches is shut down and rebuilt on next use.
12
+ */
13
+ declare function configureImages(settings: ImageSettings): void;
14
+ /**
15
+ * Diff two PNGs without blocking the event loop; same result as `diffImages`.
16
+ *
17
+ * Runs on a shared `worker_threads` pool (`max(1, availableParallelism() - 1)` threads, or
18
+ * `STORYSHELF_DIFF_WORKERS`; `0` means in-thread) and falls back to the in-thread path when
19
+ * threads are unavailable. The buffers may be transferred to a worker, which detaches them in
20
+ * the caller: do not read them afterwards.
21
+ *
22
+ * The pool is process-scoped, not owned by the capture consumer, so the re-diff route benefits
23
+ * even on an instance that never renders.
24
+ */
25
+ declare function diffImagesAsync(baseline: Buffer, current: Buffer, options: DiffOptions): Promise<DiffResult>;
26
+ /** Stop the shared pool's workers. A later `diffImagesAsync` starts a fresh pool. */
27
+ declare function shutdownDiffPool(): Promise<void>;
28
+ //#endregion
29
+ export { DIFF_WORKERS_ENV, configureImages, diffImagesAsync, shutdownDiffPool };
30
+ //# sourceMappingURL=async.d.mts.map
package/dist/async.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { DiffPool } from "./pool.mjs";
5
+ import { appliedImageSettings, configuredThreadCount, currentImageEngine, setImageSettings } from "./settings.mjs";
6
+ import { availableParallelism } from "node:os";
7
+ //#region src/async.ts
8
+ /** `diffImagesAsync`: `diffImages` on a lazily created, process-wide worker pool, plus `configureImages`. */
9
+ /** Environment variable overriding the pool size; `0` runs diffs in-thread. */
10
+ const DIFF_WORKERS_ENV = "STORYSHELF_DIFF_WORKERS";
11
+ let pool;
12
+ /**
13
+ * Apply `images.engine` and `compare.threads`; both beat their environment variables
14
+ * (`STORYSHELF_IMAGE_ENGINE`, `STORYSHELF_DIFF_WORKERS`). A running pool whose engine or size no
15
+ * longer matches is shut down and rebuilt on next use.
16
+ */
17
+ function configureImages(settings) {
18
+ const before = appliedImageSettings();
19
+ setImageSettings(settings);
20
+ const after = appliedImageSettings();
21
+ if (before.engine === after.engine && before.threads === after.threads) return;
22
+ const stale = pool;
23
+ pool = void 0;
24
+ stale?.shutdown().catch(() => {});
25
+ }
26
+ /**
27
+ * Diff two PNGs without blocking the event loop; same result as `diffImages`.
28
+ *
29
+ * Runs on a shared `worker_threads` pool (`max(1, availableParallelism() - 1)` threads, or
30
+ * `STORYSHELF_DIFF_WORKERS`; `0` means in-thread) and falls back to the in-thread path when
31
+ * threads are unavailable. The buffers may be transferred to a worker, which detaches them in
32
+ * the caller: do not read them afterwards.
33
+ *
34
+ * The pool is process-scoped, not owned by the capture consumer, so the re-diff route benefits
35
+ * even on an instance that never renders.
36
+ */
37
+ async function diffImagesAsync(baseline, current, options) {
38
+ pool ??= new DiffPool({
39
+ size: configuredThreadCount() ?? resolveDiffWorkers(process.env),
40
+ workerUrl: defaultWorkerUrl(),
41
+ engine: currentImageEngine()
42
+ });
43
+ return await pool.run(baseline, current, options);
44
+ }
45
+ /** Stop the shared pool's workers. A later `diffImagesAsync` starts a fresh pool. */
46
+ async function shutdownDiffPool() {
47
+ const current = pool;
48
+ pool = void 0;
49
+ await current?.shutdown();
50
+ }
51
+ /** Pool size from the environment: a non-negative integer, else one fewer than the CPU count (min 1). */
52
+ function resolveDiffWorkers(env, parallelism = availableParallelism()) {
53
+ const raw = env[DIFF_WORKERS_ENV]?.trim();
54
+ if (raw && /^\d+$/u.test(raw)) return Number(raw);
55
+ return Math.max(1, parallelism - 1);
56
+ }
57
+ /** `worker.mjs` beside the built output; `worker.ts` when running from source (tests, `nub run serve`). */
58
+ function defaultWorkerUrl() {
59
+ return import.meta.url.endsWith(".ts") ? new URL("worker.ts", import.meta.url) : new URL("worker.mjs", import.meta.url);
60
+ }
61
+ //#endregion
62
+ export { DIFF_WORKERS_ENV, configureImages, diffImagesAsync, resolveDiffWorkers, shutdownDiffPool };
63
+
64
+ //# sourceMappingURL=async.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async.mjs","names":[],"sources":["../src/async.ts"],"sourcesContent":["/** `diffImagesAsync`: `diffImages` on a lazily created, process-wide worker pool, plus `configureImages`. */\nimport { availableParallelism } from \"node:os\";\nimport type { DiffOptions, DiffResult } from \"./options.ts\";\nimport { DiffPool } from \"./pool.ts\";\nimport {\n appliedImageSettings,\n configuredThreadCount,\n currentImageEngine,\n type ImageSettings,\n setImageSettings,\n} from \"./settings.ts\";\n\n/** Environment variable overriding the pool size; `0` runs diffs in-thread. */\nexport const DIFF_WORKERS_ENV = \"STORYSHELF_DIFF_WORKERS\";\n\nlet pool: DiffPool | undefined;\n\n/**\n * Apply `images.engine` and `compare.threads`; both beat their environment variables\n * (`STORYSHELF_IMAGE_ENGINE`, `STORYSHELF_DIFF_WORKERS`). A running pool whose engine or size no\n * longer matches is shut down and rebuilt on next use.\n */\nexport function configureImages(settings: ImageSettings): void {\n const before = appliedImageSettings();\n setImageSettings(settings);\n const after = appliedImageSettings();\n if (before.engine === after.engine && before.threads === after.threads) {\n return;\n }\n const stale = pool;\n pool = undefined;\n stale?.shutdown().catch(() => {});\n}\n\n/**\n * Diff two PNGs without blocking the event loop; same result as `diffImages`.\n *\n * Runs on a shared `worker_threads` pool (`max(1, availableParallelism() - 1)` threads, or\n * `STORYSHELF_DIFF_WORKERS`; `0` means in-thread) and falls back to the in-thread path when\n * threads are unavailable. The buffers may be transferred to a worker, which detaches them in\n * the caller: do not read them afterwards.\n *\n * The pool is process-scoped, not owned by the capture consumer, so the re-diff route benefits\n * even on an instance that never renders.\n */\nexport async function diffImagesAsync(\n baseline: Buffer,\n current: Buffer,\n options: DiffOptions,\n): Promise<DiffResult> {\n pool ??= new DiffPool({\n size: configuredThreadCount() ?? resolveDiffWorkers(process.env),\n workerUrl: defaultWorkerUrl(),\n engine: currentImageEngine(),\n });\n return await pool.run(baseline, current, options);\n}\n\n/** Stop the shared pool's workers. A later `diffImagesAsync` starts a fresh pool. */\nexport async function shutdownDiffPool(): Promise<void> {\n const current = pool;\n pool = undefined;\n await current?.shutdown();\n}\n\n/** Pool size from the environment: a non-negative integer, else one fewer than the CPU count (min 1). */\nexport function resolveDiffWorkers(\n env: Record<string, string | undefined>,\n parallelism: number = availableParallelism(),\n): number {\n const raw = env[DIFF_WORKERS_ENV]?.trim();\n if (raw && /^\\d+$/u.test(raw)) {\n return Number(raw);\n }\n return Math.max(1, parallelism - 1);\n}\n\n/** `worker.mjs` beside the built output; `worker.ts` when running from source (tests, `nub run serve`). */\nfunction defaultWorkerUrl(): URL {\n return import.meta.url.endsWith(\".ts\")\n ? new URL(\"worker.ts\", import.meta.url)\n : new URL(\"worker.mjs\", import.meta.url);\n}\n"],"mappings":";;;;;;;;;AAaA,MAAa,mBAAmB;AAEhC,IAAI;;;;;;AAOJ,SAAgB,gBAAgB,UAA+B;CAC7D,MAAM,SAAS,qBAAqB;CACpC,iBAAiB,QAAQ;CACzB,MAAM,QAAQ,qBAAqB;CACnC,IAAI,OAAO,WAAW,MAAM,UAAU,OAAO,YAAY,MAAM,SAC7D;CAEF,MAAM,QAAQ;CACd,OAAO,KAAA;CACP,OAAO,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;AAClC;;;;;;;;;;;;AAaA,eAAsB,gBACpB,UACA,SACA,SACqB;CACrB,SAAS,IAAI,SAAS;EACpB,MAAM,sBAAsB,KAAK,mBAAmB,QAAQ,GAAG;EAC/D,WAAW,iBAAiB;EAC5B,QAAQ,mBAAmB;CAC7B,CAAC;CACD,OAAO,MAAM,KAAK,IAAI,UAAU,SAAS,OAAO;AAClD;;AAGA,eAAsB,mBAAkC;CACtD,MAAM,UAAU;CAChB,OAAO,KAAA;CACP,MAAM,SAAS,SAAS;AAC1B;;AAGA,SAAgB,mBACd,KACA,cAAsB,qBAAqB,GACnC;CACR,MAAM,MAAM,IAAI,iBAAiB,EAAE,KAAK;CACxC,IAAI,OAAO,SAAS,KAAK,GAAG,GAC1B,OAAO,OAAO,GAAG;CAEnB,OAAO,KAAK,IAAI,GAAG,cAAc,CAAC;AACpC;;AAGA,SAAS,mBAAwB;CAC/B,OAAO,YAAY,IAAI,SAAS,KAAK,IACjC,IAAI,IAAI,aAAa,YAAY,GAAG,IACpC,IAAI,IAAI,cAAc,YAAY,GAAG;AAC3C"}
@@ -0,0 +1,38 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ //#region src/codec.d.ts
4
+ /** Image codecs: how PNGs become raw RGBA pixels and back. The comparison itself is always pixelmatch. */
5
+ /** Engine names accepted by `images.engine` and `STORYSHELF_IMAGE_ENGINE`. */
6
+ declare const IMAGE_ENGINES: readonly ["pngjs", "sharp"];
7
+ /** A codec that can decode, encode and downscale PNGs. */
8
+ type ImageEngine = (typeof IMAGE_ENGINES)[number];
9
+ /** Environment variable that selects the engine when `images.engine` is not set. */
10
+ declare const IMAGE_ENGINE_ENV = "STORYSHELF_IMAGE_ENGINE";
11
+ /** The engine used when nothing is configured: pure JavaScript, runs anywhere. */
12
+ declare const DEFAULT_IMAGE_ENGINE: ImageEngine;
13
+ /** Raw 8-bit RGBA pixels, row-major, `width * height * 4` bytes. */
14
+ interface RgbaImage {
15
+ width: number;
16
+ height: number;
17
+ data: Uint8Array;
18
+ }
19
+ /** Everything the engine needs from an image library. */
20
+ interface ImageCodec {
21
+ readonly name: ImageEngine;
22
+ /** Decode a PNG to RGBA. */
23
+ decode(png: Buffer): Promise<RgbaImage> | RgbaImage;
24
+ /** Encode RGBA pixels as a PNG. */
25
+ encode(image: RgbaImage): Promise<Buffer> | Buffer;
26
+ /** Downscale a PNG so its long edge is at most `maxEdge`; output stays PNG. */
27
+ downscale(png: Uint8Array, maxEdge: number): Promise<Uint8Array> | Uint8Array;
28
+ }
29
+ /** Narrow an arbitrary string to a known engine name. */
30
+ declare function isImageEngine(value: unknown): value is ImageEngine;
31
+ /**
32
+ * The engine named by a raw setting (an option or the `STORYSHELF_IMAGE_ENGINE` value); blank or
33
+ * missing means the default. An unknown name throws so a typo cannot silently fall back.
34
+ */
35
+ declare function resolveImageEngine(raw: string | undefined): ImageEngine;
36
+ //#endregion
37
+ export { DEFAULT_IMAGE_ENGINE, IMAGE_ENGINES, IMAGE_ENGINE_ENV, ImageCodec, ImageEngine, RgbaImage, isImageEngine, resolveImageEngine };
38
+ //# sourceMappingURL=codec.d.mts.map
package/dist/codec.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/codec.ts
5
+ /** Image codecs: how PNGs become raw RGBA pixels and back. The comparison itself is always pixelmatch. */
6
+ /** Engine names accepted by `images.engine` and `STORYSHELF_IMAGE_ENGINE`. */
7
+ const IMAGE_ENGINES = ["pngjs", "sharp"];
8
+ /** Environment variable that selects the engine when `images.engine` is not set. */
9
+ const IMAGE_ENGINE_ENV = "STORYSHELF_IMAGE_ENGINE";
10
+ /** The engine used when nothing is configured: pure JavaScript, runs anywhere. */
11
+ const DEFAULT_IMAGE_ENGINE = "pngjs";
12
+ /** Narrow an arbitrary string to a known engine name. */
13
+ function isImageEngine(value) {
14
+ return IMAGE_ENGINES.some((engine) => engine === value);
15
+ }
16
+ /**
17
+ * The engine named by a raw setting (an option or the `STORYSHELF_IMAGE_ENGINE` value); blank or
18
+ * missing means the default. An unknown name throws so a typo cannot silently fall back.
19
+ */
20
+ function resolveImageEngine(raw) {
21
+ const value = raw?.trim();
22
+ if (value === void 0 || value === "") return DEFAULT_IMAGE_ENGINE;
23
+ if (!isImageEngine(value)) throw new Error(`Unknown image engine "${value}". Use one of: ${IMAGE_ENGINES.join(", ")}.`);
24
+ return value;
25
+ }
26
+ //#endregion
27
+ export { DEFAULT_IMAGE_ENGINE, IMAGE_ENGINES, IMAGE_ENGINE_ENV, isImageEngine, resolveImageEngine };
28
+
29
+ //# sourceMappingURL=codec.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.mjs","names":[],"sources":["../src/codec.ts"],"sourcesContent":["/** Image codecs: how PNGs become raw RGBA pixels and back. The comparison itself is always pixelmatch. */\n\n/** Engine names accepted by `images.engine` and `STORYSHELF_IMAGE_ENGINE`. */\nexport const IMAGE_ENGINES = [\"pngjs\", \"sharp\"] as const;\n\n/** A codec that can decode, encode and downscale PNGs. */\nexport type ImageEngine = (typeof IMAGE_ENGINES)[number];\n\n/** Environment variable that selects the engine when `images.engine` is not set. */\nexport const IMAGE_ENGINE_ENV = \"STORYSHELF_IMAGE_ENGINE\";\n\n/** The engine used when nothing is configured: pure JavaScript, runs anywhere. */\nexport const DEFAULT_IMAGE_ENGINE: ImageEngine = \"pngjs\";\n\n/** Raw 8-bit RGBA pixels, row-major, `width * height * 4` bytes. */\nexport interface RgbaImage {\n width: number;\n height: number;\n data: Uint8Array;\n}\n\n/** Everything the engine needs from an image library. */\nexport interface ImageCodec {\n readonly name: ImageEngine;\n /** Decode a PNG to RGBA. */\n decode(png: Buffer): Promise<RgbaImage> | RgbaImage;\n /** Encode RGBA pixels as a PNG. */\n encode(image: RgbaImage): Promise<Buffer> | Buffer;\n /** Downscale a PNG so its long edge is at most `maxEdge`; output stays PNG. */\n downscale(png: Uint8Array, maxEdge: number): Promise<Uint8Array> | Uint8Array;\n}\n\n/** Narrow an arbitrary string to a known engine name. */\nexport function isImageEngine(value: unknown): value is ImageEngine {\n return IMAGE_ENGINES.some((engine) => engine === value);\n}\n\n/**\n * The engine named by a raw setting (an option or the `STORYSHELF_IMAGE_ENGINE` value); blank or\n * missing means the default. An unknown name throws so a typo cannot silently fall back.\n */\nexport function resolveImageEngine(raw: string | undefined): ImageEngine {\n const value = raw?.trim();\n if (value === undefined || value === \"\") {\n return DEFAULT_IMAGE_ENGINE;\n }\n if (!isImageEngine(value)) {\n throw new Error(`Unknown image engine \"${value}\". Use one of: ${IMAGE_ENGINES.join(\", \")}.`);\n }\n return value;\n}\n"],"mappings":";;;;;;AAGA,MAAa,gBAAgB,CAAC,SAAS,OAAO;;AAM9C,MAAa,mBAAmB;;AAGhC,MAAa,uBAAoC;;AAqBjD,SAAgB,cAAc,OAAsC;CAClE,OAAO,cAAc,MAAM,WAAW,WAAW,KAAK;AACxD;;;;;AAMA,SAAgB,mBAAmB,KAAsC;CACvE,MAAM,QAAQ,KAAK,KAAK;CACxB,IAAI,UAAU,KAAA,KAAa,UAAU,IACnC,OAAO;CAET,IAAI,CAAC,cAAc,KAAK,GACtB,MAAM,IAAI,MAAM,yBAAyB,MAAM,iBAAiB,cAAc,KAAK,IAAI,EAAE,EAAE;CAE7F,OAAO;AACT"}
@@ -0,0 +1,28 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/codecs.ts
5
+ const loaded = /* @__PURE__ */ new Map();
6
+ /** The codec for an engine, loaded once per thread. Rejects when its dependency is unavailable. */
7
+ async function loadCodec(engine) {
8
+ const existing = loaded.get(engine);
9
+ if (existing) return await existing;
10
+ const pending = engine === "sharp" ? importSharpCodec() : importPngjsCodec();
11
+ loaded.set(engine, pending);
12
+ try {
13
+ return await pending;
14
+ } catch (error) {
15
+ loaded.delete(engine);
16
+ throw error;
17
+ }
18
+ }
19
+ async function importPngjsCodec() {
20
+ return (await import("./pngjs.mjs")).pngjsCodec;
21
+ }
22
+ async function importSharpCodec() {
23
+ return await (await import("./sharp.mjs")).createSharpCodec();
24
+ }
25
+ //#endregion
26
+ export { loadCodec };
27
+
28
+ //# sourceMappingURL=codecs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codecs.mjs","names":[],"sources":["../src/codecs.ts"],"sourcesContent":["/** Codec registry: engines load on demand so an unused engine's dependencies are never imported. */\nimport type { ImageCodec, ImageEngine } from \"./codec.ts\";\n\nconst loaded = new Map<ImageEngine, Promise<ImageCodec>>();\n\n/** The codec for an engine, loaded once per thread. Rejects when its dependency is unavailable. */\nexport async function loadCodec(engine: ImageEngine): Promise<ImageCodec> {\n const existing = loaded.get(engine);\n if (existing) {\n return await existing;\n }\n const pending = engine === \"sharp\" ? importSharpCodec() : importPngjsCodec();\n loaded.set(engine, pending);\n try {\n return await pending;\n } catch (error) {\n loaded.delete(engine);\n throw error;\n }\n}\n\nasync function importPngjsCodec(): Promise<ImageCodec> {\n return (await import(\"./pngjs.ts\")).pngjsCodec;\n}\n\nasync function importSharpCodec(): Promise<ImageCodec> {\n return await (await import(\"./sharp.ts\")).createSharpCodec();\n}\n"],"mappings":";;;;AAGA,MAAM,yBAAS,IAAI,IAAsC;;AAGzD,eAAsB,UAAU,QAA0C;CACxE,MAAM,WAAW,OAAO,IAAI,MAAM;CAClC,IAAI,UACF,OAAO,MAAM;CAEf,MAAM,UAAU,WAAW,UAAU,iBAAiB,IAAI,iBAAiB;CAC3E,OAAO,IAAI,QAAQ,OAAO;CAC1B,IAAI;EACF,OAAO,MAAM;CACf,SAAS,OAAO;EACd,OAAO,OAAO,MAAM;EACpB,MAAM;CACR;AACF;AAEA,eAAe,mBAAwC;CACrD,QAAQ,MAAM,OAAO,eAAA,CAAe;AACtC;AAEA,eAAe,mBAAwC;CACrD,OAAO,OAAO,MAAM,OAAO,eAAA,CAAe,iBAAiB;AAC7D"}
@@ -0,0 +1,8 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ //#region src/downscale.d.ts
4
+ /** Downscale a PNG so its long edge is at most `maxEdge`, using the configured image engine. */
5
+ declare function downscalePng(buffer: Uint8Array, maxEdge: number): Promise<Uint8Array>;
6
+ //#endregion
7
+ export { downscalePng };
8
+ //# sourceMappingURL=downscale.d.mts.map
@@ -0,0 +1,15 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { loadCodec } from "./codecs.mjs";
5
+ import { currentImageEngine } from "./settings.mjs";
6
+ //#region src/downscale.ts
7
+ /** PNG thumbnails on the configured engine (output stays PNG). */
8
+ /** Downscale a PNG so its long edge is at most `maxEdge`, using the configured image engine. */
9
+ async function downscalePng(buffer, maxEdge) {
10
+ return await (await loadCodec(currentImageEngine())).downscale(buffer, maxEdge);
11
+ }
12
+ //#endregion
13
+ export { downscalePng };
14
+
15
+ //# sourceMappingURL=downscale.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"downscale.mjs","names":[],"sources":["../src/downscale.ts"],"sourcesContent":["/** PNG thumbnails on the configured engine (output stays PNG). */\nimport { loadCodec } from \"./codecs.ts\";\nimport { currentImageEngine } from \"./settings.ts\";\n\n/** Downscale a PNG so its long edge is at most `maxEdge`, using the configured image engine. */\nexport async function downscalePng(buffer: Uint8Array, maxEdge: number): Promise<Uint8Array> {\n const codec = await loadCodec(currentImageEngine());\n return await codec.downscale(buffer, maxEdge);\n}\n"],"mappings":";;;;;;;;AAKA,eAAsB,aAAa,QAAoB,SAAsC;CAE3F,OAAO,OAAM,MADO,UAAU,mBAAmB,CAAC,EAAA,CAC/B,UAAU,QAAQ,OAAO;AAC9C"}
@@ -0,0 +1,18 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { DiffOptions, DiffResult } from "./options.mjs";
4
+ import { ImageCodec } from "./codec.mjs";
5
+ //#region src/engine.d.ts
6
+ /**
7
+ * Compare baseline and current screenshots, returning pass/fail and a diff image.
8
+ *
9
+ * @param baseline - Baseline PNG buffer.
10
+ * @param current - Current capture PNG buffer.
11
+ * @param options - Pixel threshold and ratio limits.
12
+ * @param codec - Decodes and encodes the PNGs; pngjs unless an engine says otherwise.
13
+ * @returns The diff result with pixel counts and an optional overlay image.
14
+ */
15
+ declare function diffImages(baseline: Buffer, current: Buffer, options: DiffOptions, codec?: ImageCodec): Promise<DiffResult>;
16
+ //#endregion
17
+ export { diffImages };
18
+ //# sourceMappingURL=engine.d.mts.map
@@ -0,0 +1,144 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { pngjsCodec } from "./pngjs.mjs";
5
+ import pixelmatch from "pixelmatch";
6
+ //#region src/engine.ts
7
+ /**
8
+ * Compare baseline and current screenshots, returning pass/fail and a diff image.
9
+ *
10
+ * @param baseline - Baseline PNG buffer.
11
+ * @param current - Current capture PNG buffer.
12
+ * @param options - Pixel threshold and ratio limits.
13
+ * @param codec - Decodes and encodes the PNGs; pngjs unless an engine says otherwise.
14
+ * @returns The diff result with pixel counts and an optional overlay image.
15
+ */
16
+ async function diffImages(baseline, current, options, codec = pngjsCodec) {
17
+ const identical = identicalResult(baseline, current);
18
+ if (identical) return identical;
19
+ const [baselineImage, currentImage] = await Promise.all([codec.decode(baseline), codec.decode(current)]);
20
+ return await compareImages(baselineImage, currentImage, options, codec);
21
+ }
22
+ /** Diff two decoded images; a size mismatch fails, or (with `failOnSizeChange: false`) diffs the overlap. */
23
+ async function compareImages(baseline, current, options, codec) {
24
+ const baselineDimensions = {
25
+ width: baseline.width,
26
+ height: baseline.height
27
+ };
28
+ const currentDimensions = {
29
+ width: current.width,
30
+ height: current.height
31
+ };
32
+ const sizeChanged = baselineDimensions.width !== currentDimensions.width || baselineDimensions.height !== currentDimensions.height;
33
+ if (sizeChanged && options.failOnSizeChange) return {
34
+ passed: false,
35
+ diffPixels: 0,
36
+ diffRatio: 1,
37
+ diffImage: null,
38
+ baselineDimensions,
39
+ currentDimensions,
40
+ sizeChanged
41
+ };
42
+ const regions = sizeChanged ? overlapRegions(baseline, current) : whole(baseline, current);
43
+ const overlap = await compareRegions(regions, options, codec);
44
+ const diffRatio = overlap.diffPixels / (regions.width * regions.height);
45
+ return {
46
+ passed: diffRatio <= options.maxDiffRatio,
47
+ diffPixels: overlap.diffPixels,
48
+ diffRatio,
49
+ diffImage: overlap.diffImage,
50
+ baselineDimensions,
51
+ currentDimensions,
52
+ sizeChanged
53
+ };
54
+ }
55
+ function whole(baseline, current) {
56
+ return {
57
+ width: baseline.width,
58
+ height: baseline.height,
59
+ baseline: baseline.data,
60
+ current: current.data
61
+ };
62
+ }
63
+ /** Pixelmatch needs equal-size inputs, so both images are cropped to their shared top-left area. */
64
+ function overlapRegions(baseline, current) {
65
+ const width = Math.min(baseline.width, current.width);
66
+ const height = Math.min(baseline.height, current.height);
67
+ return {
68
+ width,
69
+ height,
70
+ baseline: cropRgba(baseline.data, baseline.width, width, height),
71
+ current: cropRgba(current.data, current.width, width, height)
72
+ };
73
+ }
74
+ const PNG_SIGNATURE = Buffer.from([
75
+ 137,
76
+ 80,
77
+ 78,
78
+ 71,
79
+ 13,
80
+ 10,
81
+ 26,
82
+ 10
83
+ ]);
84
+ const IHDR_END = 24;
85
+ /**
86
+ * Fast path for byte-equal inputs: no decode, no pixelmatch, no overlay.
87
+ *
88
+ * Equal bytes decode to equal pixels, so the diff is empty by construction. Dimensions come from
89
+ * the IHDR chunk (big-endian width/height at bytes 16-23). Inputs that are not recognisably PNG
90
+ * return null so the regular path reports the decode error as before. A direct byte comparison
91
+ * is used instead of hashing both buffers: it is exact and cheaper than two digests.
92
+ */
93
+ function identicalResult(baseline, current) {
94
+ if (baseline.length < IHDR_END || !baseline.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || !baseline.equals(current)) return null;
95
+ const dimensions = {
96
+ width: baseline.readUInt32BE(16),
97
+ height: baseline.readUInt32BE(20)
98
+ };
99
+ return {
100
+ passed: true,
101
+ diffPixels: 0,
102
+ diffRatio: 0,
103
+ diffImage: null,
104
+ baselineDimensions: dimensions,
105
+ currentDimensions: { ...dimensions },
106
+ sizeChanged: false
107
+ };
108
+ }
109
+ function cropRgba(source, sourceWidth, width, height) {
110
+ const cropped = new Uint8Array(width * height * 4);
111
+ const rowBytes = width * 4;
112
+ const sourceRowBytes = sourceWidth * 4;
113
+ for (let y = 0; y < height; y += 1) cropped.set(source.subarray(y * sourceRowBytes, y * sourceRowBytes + rowBytes), y * rowBytes);
114
+ return cropped;
115
+ }
116
+ /**
117
+ * Count differing pixels first without an output buffer; only a failing result pays for the
118
+ * overlay (a second pixelmatch pass plus PNG encode), since passing overlays are discarded.
119
+ */
120
+ async function compareRegions({ width, height, baseline, current }, options, codec) {
121
+ const matchOptions = {
122
+ threshold: options.pixelThreshold,
123
+ includeAA: options.includeAntialiasing
124
+ };
125
+ const diffPixels = pixelmatch(baseline, current, void 0, width, height, matchOptions);
126
+ if (diffPixels / (width * height) <= options.maxDiffRatio) return {
127
+ diffPixels,
128
+ diffImage: null
129
+ };
130
+ const overlay = {
131
+ width,
132
+ height,
133
+ data: new Uint8Array(width * height * 4)
134
+ };
135
+ pixelmatch(baseline, current, overlay.data, width, height, matchOptions);
136
+ return {
137
+ diffPixels,
138
+ diffImage: await codec.encode(overlay)
139
+ };
140
+ }
141
+ //#endregion
142
+ export { diffImages, identicalResult };
143
+
144
+ //# sourceMappingURL=engine.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.mjs","names":[],"sources":["../src/engine.ts"],"sourcesContent":["import pixelmatch from \"pixelmatch\";\nimport type { ImageCodec, RgbaImage } from \"./codec.ts\";\nimport type { DiffOptions, DiffResult } from \"./options.ts\";\nimport { pngjsCodec } from \"./pngjs.ts\";\n\n/**\n * Compare baseline and current screenshots, returning pass/fail and a diff image.\n *\n * @param baseline - Baseline PNG buffer.\n * @param current - Current capture PNG buffer.\n * @param options - Pixel threshold and ratio limits.\n * @param codec - Decodes and encodes the PNGs; pngjs unless an engine says otherwise.\n * @returns The diff result with pixel counts and an optional overlay image.\n */\nexport async function diffImages(\n baseline: Buffer,\n current: Buffer,\n options: DiffOptions,\n codec: ImageCodec = pngjsCodec,\n): Promise<DiffResult> {\n const identical = identicalResult(baseline, current);\n if (identical) {\n return identical;\n }\n const [baselineImage, currentImage] = await Promise.all([\n codec.decode(baseline),\n codec.decode(current),\n ]);\n return await compareImages(baselineImage, currentImage, options, codec);\n}\n\n/** Diff two decoded images; a size mismatch fails, or (with `failOnSizeChange: false`) diffs the overlap. */\nasync function compareImages(\n baseline: RgbaImage,\n current: RgbaImage,\n options: DiffOptions,\n codec: ImageCodec,\n): Promise<DiffResult> {\n const baselineDimensions = { width: baseline.width, height: baseline.height };\n const currentDimensions = { width: current.width, height: current.height };\n const sizeChanged =\n baselineDimensions.width !== currentDimensions.width ||\n baselineDimensions.height !== currentDimensions.height;\n if (sizeChanged && options.failOnSizeChange) {\n return {\n passed: false,\n diffPixels: 0,\n diffRatio: 1,\n diffImage: null,\n baselineDimensions,\n currentDimensions,\n sizeChanged,\n };\n }\n // The larger image is cropped to the top-left overlap, which is normalised by its own area.\n const regions = sizeChanged ? overlapRegions(baseline, current) : whole(baseline, current);\n const overlap = await compareRegions(regions, options, codec);\n const diffRatio = overlap.diffPixels / (regions.width * regions.height);\n return {\n passed: diffRatio <= options.maxDiffRatio,\n diffPixels: overlap.diffPixels,\n diffRatio,\n diffImage: overlap.diffImage,\n baselineDimensions,\n currentDimensions,\n sizeChanged,\n };\n}\n\nfunction whole(baseline: RgbaImage, current: RgbaImage): Regions {\n return {\n width: baseline.width,\n height: baseline.height,\n baseline: baseline.data,\n current: current.data,\n };\n}\n\n/** Pixelmatch needs equal-size inputs, so both images are cropped to their shared top-left area. */\nfunction overlapRegions(baseline: RgbaImage, current: RgbaImage): Regions {\n const width = Math.min(baseline.width, current.width);\n const height = Math.min(baseline.height, current.height);\n return {\n width,\n height,\n baseline: cropRgba(baseline.data, baseline.width, width, height),\n current: cropRgba(current.data, current.width, width, height),\n };\n}\n\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst IHDR_END = 24;\n\n/**\n * Fast path for byte-equal inputs: no decode, no pixelmatch, no overlay.\n *\n * Equal bytes decode to equal pixels, so the diff is empty by construction. Dimensions come from\n * the IHDR chunk (big-endian width/height at bytes 16-23). Inputs that are not recognisably PNG\n * return null so the regular path reports the decode error as before. A direct byte comparison\n * is used instead of hashing both buffers: it is exact and cheaper than two digests.\n */\nexport function identicalResult(baseline: Buffer, current: Buffer): DiffResult | null {\n if (\n baseline.length < IHDR_END ||\n !baseline.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) ||\n !baseline.equals(current)\n ) {\n return null;\n }\n const dimensions = { width: baseline.readUInt32BE(16), height: baseline.readUInt32BE(20) };\n return {\n passed: true,\n diffPixels: 0,\n diffRatio: 0,\n diffImage: null,\n baselineDimensions: dimensions,\n currentDimensions: { ...dimensions },\n sizeChanged: false,\n };\n}\n\nfunction cropRgba(\n source: Uint8Array,\n sourceWidth: number,\n width: number,\n height: number,\n): Uint8Array {\n const cropped = new Uint8Array(width * height * 4);\n const rowBytes = width * 4;\n const sourceRowBytes = sourceWidth * 4;\n for (let y = 0; y < height; y += 1) {\n cropped.set(source.subarray(y * sourceRowBytes, y * sourceRowBytes + rowBytes), y * rowBytes);\n }\n return cropped;\n}\n\n/** Two same-sized RGBA buffers to compare. */\ninterface Regions {\n width: number;\n height: number;\n baseline: Uint8Array;\n current: Uint8Array;\n}\n\n/**\n * Count differing pixels first without an output buffer; only a failing result pays for the\n * overlay (a second pixelmatch pass plus PNG encode), since passing overlays are discarded.\n */\nasync function compareRegions(\n { width, height, baseline, current }: Regions,\n options: DiffOptions,\n codec: ImageCodec,\n): Promise<{ diffPixels: number; diffImage: Buffer | null }> {\n const matchOptions = {\n threshold: options.pixelThreshold,\n includeAA: options.includeAntialiasing,\n };\n const diffPixels = pixelmatch(baseline, current, undefined, width, height, matchOptions);\n if (diffPixels / (width * height) <= options.maxDiffRatio) {\n return { diffPixels, diffImage: null };\n }\n const overlay: RgbaImage = { width, height, data: new Uint8Array(width * height * 4) };\n pixelmatch(baseline, current, overlay.data, width, height, matchOptions);\n return { diffPixels, diffImage: await codec.encode(overlay) };\n}\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { DEFAULT_DIFF_OPTIONS, DiffOptions, DiffResult } from "./options.mjs";
4
+ import { DEFAULT_IMAGE_ENGINE, IMAGE_ENGINES, IMAGE_ENGINE_ENV, ImageEngine, isImageEngine, resolveImageEngine } from "./codec.mjs";
5
+ import { ImageSettings, currentImageEngine, verifyImageEngine } from "./settings.mjs";
6
+ import { DIFF_WORKERS_ENV, configureImages, diffImagesAsync, shutdownDiffPool } from "./async.mjs";
7
+ import { downscalePng } from "./downscale.mjs";
8
+ import { diffImages } from "./engine.mjs";
9
+ export { DEFAULT_DIFF_OPTIONS, DEFAULT_IMAGE_ENGINE, DIFF_WORKERS_ENV, type DiffOptions, type DiffResult, IMAGE_ENGINES, IMAGE_ENGINE_ENV, type ImageEngine, type ImageSettings, configureImages, currentImageEngine, diffImages, diffImagesAsync, downscalePng, isImageEngine, resolveImageEngine, shutdownDiffPool, verifyImageEngine };
package/dist/index.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { diffImages } from "./engine.mjs";
5
+ import { DEFAULT_IMAGE_ENGINE, IMAGE_ENGINES, IMAGE_ENGINE_ENV, isImageEngine, resolveImageEngine } from "./codec.mjs";
6
+ import { currentImageEngine, verifyImageEngine } from "./settings.mjs";
7
+ import { DIFF_WORKERS_ENV, configureImages, diffImagesAsync, shutdownDiffPool } from "./async.mjs";
8
+ import { downscalePng } from "./downscale.mjs";
9
+ import { DEFAULT_DIFF_OPTIONS } from "./options.mjs";
10
+ export { DEFAULT_DIFF_OPTIONS, DEFAULT_IMAGE_ENGINE, DIFF_WORKERS_ENV, IMAGE_ENGINES, IMAGE_ENGINE_ENV, configureImages, currentImageEngine, diffImages, diffImagesAsync, downscalePng, isImageEngine, resolveImageEngine, shutdownDiffPool, verifyImageEngine };
@@ -0,0 +1,42 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ //#region src/options.d.ts
4
+ /** Options that control how two images are compared. */
5
+ interface DiffOptions {
6
+ /** Per-pixel difference threshold (0-1) above which a pixel counts as different. */
7
+ pixelThreshold: number;
8
+ /** Maximum allowed ratio of differing pixels for a diff to pass. */
9
+ maxDiffRatio: number;
10
+ /** Whether to include antialiasing artifacts when counting differing pixels. */
11
+ includeAntialiasing: boolean;
12
+ /** Whether a size mismatch between images should fail the diff. When false, only the overlapping region is diffed. */
13
+ failOnSizeChange: boolean;
14
+ }
15
+ /** The outcome of comparing a baseline and a current screenshot. */
16
+ interface DiffResult {
17
+ /** Whether the current image is within the accepted tolerance. */
18
+ passed: boolean;
19
+ /** Number of differing pixels. */
20
+ diffPixels: number;
21
+ /** Ratio of differing pixels to total pixels. */
22
+ diffRatio: number;
23
+ /** Generated diff overlay image, or null when unavailable. */
24
+ diffImage: Buffer | null;
25
+ /** Dimensions of the baseline image. */
26
+ baselineDimensions: {
27
+ width: number;
28
+ height: number;
29
+ };
30
+ /** Dimensions of the current image. */
31
+ currentDimensions: {
32
+ width: number;
33
+ height: number;
34
+ };
35
+ /** Whether the two images have different dimensions. */
36
+ sizeChanged: boolean;
37
+ }
38
+ /** Default diff settings used when no project overrides are present. */
39
+ declare const DEFAULT_DIFF_OPTIONS: DiffOptions;
40
+ //#endregion
41
+ export { DEFAULT_DIFF_OPTIONS, DiffOptions, DiffResult };
42
+ //# sourceMappingURL=options.d.mts.map
@@ -0,0 +1,15 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/options.ts
5
+ /** Default diff settings used when no project overrides are present. */
6
+ const DEFAULT_DIFF_OPTIONS = {
7
+ pixelThreshold: .1,
8
+ maxDiffRatio: .01,
9
+ includeAntialiasing: false,
10
+ failOnSizeChange: true
11
+ };
12
+ //#endregion
13
+ export { DEFAULT_DIFF_OPTIONS };
14
+
15
+ //# sourceMappingURL=options.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.mjs","names":[],"sources":["../src/options.ts"],"sourcesContent":["/** Options that control how two images are compared. */\nexport interface DiffOptions {\n /** Per-pixel difference threshold (0-1) above which a pixel counts as different. */\n pixelThreshold: number;\n /** Maximum allowed ratio of differing pixels for a diff to pass. */\n maxDiffRatio: number;\n /** Whether to include antialiasing artifacts when counting differing pixels. */\n includeAntialiasing: boolean;\n /** Whether a size mismatch between images should fail the diff. When false, only the overlapping region is diffed. */\n failOnSizeChange: boolean;\n}\n\n/** The outcome of comparing a baseline and a current screenshot. */\nexport interface DiffResult {\n /** Whether the current image is within the accepted tolerance. */\n passed: boolean;\n /** Number of differing pixels. */\n diffPixels: number;\n /** Ratio of differing pixels to total pixels. */\n diffRatio: number;\n /** Generated diff overlay image, or null when unavailable. */\n diffImage: Buffer | null;\n /** Dimensions of the baseline image. */\n baselineDimensions: { width: number; height: number };\n /** Dimensions of the current image. */\n currentDimensions: { width: number; height: number };\n /** Whether the two images have different dimensions. */\n sizeChanged: boolean;\n}\n\n/** Default diff settings used when no project overrides are present. */\nexport const DEFAULT_DIFF_OPTIONS: DiffOptions = {\n pixelThreshold: 0.1,\n maxDiffRatio: 0.01,\n includeAntialiasing: false,\n failOnSizeChange: true,\n};\n"],"mappings":";;;;;AA+BA,MAAa,uBAAoC;CAC/C,gBAAgB;CAChB,cAAc;CACd,qBAAqB;CACrB,kBAAkB;AACpB"}
@@ -0,0 +1,51 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { PNG } from "pngjs";
5
+ //#region src/pngjs-downscale.ts
6
+ /** Box-sampling PNG downscale on pngjs. */
7
+ /** Downscale a PNG so its long edge is at most `maxEdge` (box sampling). */
8
+ function downscaleWithPngjs(buffer, maxEdge) {
9
+ const src = PNG.sync.read(Buffer.from(buffer));
10
+ const scale = Math.min(1, maxEdge / Math.max(src.width, src.height));
11
+ if (scale >= 1) return new Uint8Array(PNG.sync.write(src));
12
+ const dst = new PNG({
13
+ width: Math.max(1, Math.round(src.width * scale)),
14
+ height: Math.max(1, Math.round(src.height * scale))
15
+ });
16
+ for (let row = 0; row < dst.height; row += 1) for (let col = 0; col < dst.width; col += 1) writePixel(dst, col, row, averageBox(src, boxOf(src, col, row, 1 / scale)));
17
+ return new Uint8Array(PNG.sync.write(dst));
18
+ }
19
+ function boxOf(src, col, row, factor) {
20
+ const x0 = Math.floor(col * factor);
21
+ const y0 = Math.floor(row * factor);
22
+ return {
23
+ x0,
24
+ y0,
25
+ x1: Math.min(src.width, Math.max(x0 + 1, Math.floor((col + 1) * factor))),
26
+ y1: Math.min(src.height, Math.max(y0 + 1, Math.floor((row + 1) * factor)))
27
+ };
28
+ }
29
+ function averageBox(src, box) {
30
+ const sum = [
31
+ 0,
32
+ 0,
33
+ 0,
34
+ 0
35
+ ];
36
+ let count = 0;
37
+ for (let sy = box.y0; sy < box.y1; sy += 1) for (let sx = box.x0; sx < box.x1; sx += 1) {
38
+ const at = (sy * src.width + sx) * 4;
39
+ for (let channel = 0; channel < 4; channel += 1) sum[channel] = (sum[channel] ?? 0) + (src.data[at + channel] ?? 0);
40
+ count += 1;
41
+ }
42
+ return sum.map((total) => Math.round(total / Math.max(1, count)));
43
+ }
44
+ function writePixel(dst, col, row, rgba) {
45
+ const at = (row * dst.width + col) * 4;
46
+ for (let channel = 0; channel < 4; channel += 1) dst.data[at + channel] = rgba[channel] ?? 0;
47
+ }
48
+ //#endregion
49
+ export { downscaleWithPngjs };
50
+
51
+ //# sourceMappingURL=pngjs-downscale.mjs.map