@takazudo/zudo-image-tweaker 0.1.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/dist/blurhash/index.d.ts +35 -0
  5. package/dist/blurhash/index.js +40 -0
  6. package/dist/browser/exif-orientation.d.ts +12 -0
  7. package/dist/browser/index.d.ts +7 -0
  8. package/dist/browser/index.js +160 -0
  9. package/dist/browser/orientation-semantics.d.ts +37 -0
  10. package/dist/browser/orientation.d.ts +25 -0
  11. package/dist/browser/prepare-upload.d.ts +100 -0
  12. package/dist/budget/index.d.ts +73 -0
  13. package/dist/budget/index.js +157 -0
  14. package/dist/calibrate/index.d.ts +129 -0
  15. package/dist/calibrate/index.js +206 -0
  16. package/dist/composite/index.d.ts +50 -0
  17. package/dist/composite/index.js +56 -0
  18. package/dist/exif/index.d.ts +41 -0
  19. package/dist/exif/index.js +100 -0
  20. package/dist/heif/index.d.ts +139 -0
  21. package/dist/heif/index.js +275 -0
  22. package/dist/index.d.ts +14 -0
  23. package/dist/index.js +68 -0
  24. package/dist/ogp/index.d.ts +72 -0
  25. package/dist/ogp/index.js +117 -0
  26. package/dist/presets-PTxNoMdy.js +630 -0
  27. package/dist/product-photo/index.d.ts +50 -0
  28. package/dist/product-photo/index.js +189 -0
  29. package/dist/product-photo/shadow.d.ts +48 -0
  30. package/dist/run-BIjGA2Fu.js +22 -0
  31. package/dist/square/index.d.ts +98 -0
  32. package/dist/square/index.js +89 -0
  33. package/dist/variants/engine.d.ts +38 -0
  34. package/dist/variants/feature-detect.d.ts +8 -0
  35. package/dist/variants/hash-cache.d.ts +63 -0
  36. package/dist/variants/heic.d.ts +15 -0
  37. package/dist/variants/index.d.ts +5 -0
  38. package/dist/variants/index.js +19 -0
  39. package/dist/variants/presets.d.ts +18 -0
  40. package/dist/variants/repair.d.ts +28 -0
  41. package/dist/variants/run.d.ts +36 -0
  42. package/dist/variants/tags.d.ts +13 -0
  43. package/dist/variants/types.d.ts +208 -0
  44. package/package.json +126 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # Changelog
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Takeshi Takatsudo
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 ADDED
@@ -0,0 +1,57 @@
1
+ # @takazudo/zudo-image-tweaker
2
+
3
+ A sharp-based image-tweaking toolkit, exposed as focused subpath modules so
4
+ consumers only pull in the code paths (and native/optional dependencies)
5
+ they actually use.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @takazudo/zudo-image-tweaker
11
+ # npm install @takazudo/zudo-image-tweaker
12
+ # yarn add @takazudo/zudo-image-tweaker
13
+ ```
14
+
15
+ ## Modules
16
+
17
+ | Subpath | Description | Docs |
18
+ |---|---|---|
19
+ | `@takazudo/zudo-image-tweaker/variants` | Multi-width WebP variant engine (content-hash cache, repair, tag dispatch). | [variants](https://zudo-image-tweaker.takazudomodular.com/docs/reference/variants/) |
20
+ | `@takazudo/zudo-image-tweaker/heif` | HEIC/HEIF → JPEG conversion with ICC profile preservation. | [heif](https://zudo-image-tweaker.takazudomodular.com/docs/reference/heif/) |
21
+ | `@takazudo/zudo-image-tweaker/ogp` | Social-card (Open Graph / Twitter card) image compositor. | [ogp](https://zudo-image-tweaker.takazudomodular.com/docs/reference/ogp/) |
22
+ | `@takazudo/zudo-image-tweaker/budget` | Byte-budget encode ladder. | [budget](https://zudo-image-tweaker.takazudomodular.com/docs/reference/budget/) |
23
+ | `@takazudo/zudo-image-tweaker/square` | Five-mode square-crop toolbox. | [square](https://zudo-image-tweaker.takazudomodular.com/docs/reference/square/) |
24
+ | `@takazudo/zudo-image-tweaker/product-photo` | ML background removal + procedural shadows for product photography. | [product-photo](https://zudo-image-tweaker.takazudomodular.com/docs/reference/product-photo/) |
25
+ | `@takazudo/zudo-image-tweaker/calibrate` | Background color normalization. | [calibrate](https://zudo-image-tweaker.takazudomodular.com/docs/reference/calibrate/) |
26
+ | `@takazudo/zudo-image-tweaker/composite` | Overlay batch compositing. | [composite](https://zudo-image-tweaker.takazudomodular.com/docs/reference/composite/) |
27
+ | `@takazudo/zudo-image-tweaker/blurhash` | BlurHash placeholder generation. | [blurhash](https://zudo-image-tweaker.takazudomodular.com/docs/reference/blurhash/) |
28
+ | `@takazudo/zudo-image-tweaker/exif` | EXIF metadata extraction. | [exif](https://zudo-image-tweaker.takazudomodular.com/docs/reference/exif/) |
29
+ | `@takazudo/zudo-image-tweaker/browser` | Client-side upload preparation pipeline. | [browser](https://zudo-image-tweaker.takazudomodular.com/docs/reference/browser/) |
30
+
31
+ Each link above goes to that module's reference page on the
32
+ [docs site](https://zudo-image-tweaker.takazudomodular.com/docs/reference/).
33
+
34
+ ## Optional peer dependencies
35
+
36
+ `@imgly/background-removal-node`, `exifr`, and `heic2any` are optional peer
37
+ dependencies, dynamically imported only by the modules that need them
38
+ (`product-photo` uses `@imgly/background-removal-node`; `browser` uses both
39
+ `exifr` and `heic2any`). Install them only if you use those modules. `/exif`
40
+ has no optional peer dependency — it parses the raw EXIF buffer directly.
41
+ `/heif`'s HEIC decoder (`heic-decode`) is a regular dependency and is always
42
+ installed.
43
+
44
+ ## Security notes
45
+
46
+ ### `/heif`
47
+
48
+ `convertHeifToJpeg`'s Node/WASM fallback (`heic-decode` → bundled
49
+ `libheif-js` 1.19.8) predates the libheif 1.22.0 heap-overflow and
50
+ infinite-loop-DoS fixes. Only decode HEIC/HEIF files from sources you
51
+ trust. The `maxInputBytes` option (default 256 MiB) rejects oversized
52
+ inputs before they reach the decoder, but that's defense-in-depth, not a
53
+ substitute for trusting the source.
54
+
55
+ ## License
56
+
57
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,35 @@
1
+ import { type SharpInput } from 'sharp';
2
+ export interface EncodeImageToBlurhashOptions {
3
+ /** Horizontal component count passed to the blurhash algorithm. */
4
+ componentsX?: number;
5
+ /** Vertical component count passed to the blurhash algorithm. */
6
+ componentsY?: number;
7
+ /** Size (both dimensions) the source is downsampled to before encoding. */
8
+ rawSize?: number;
9
+ }
10
+ /**
11
+ * Encode an image into a blurhash string. Downsamples the source with sharp,
12
+ * then feeds the raw RGBA pixels to the blurhash algorithm.
13
+ */
14
+ export declare function encodeImageToBlurhash(input: SharpInput, options?: EncodeImageToBlurhashOptions): Promise<string>;
15
+ export interface BlurhashToDataUriOptions {
16
+ /** Output width and height, in pixels, of the decoded PNG. */
17
+ size?: number;
18
+ }
19
+ /**
20
+ * Decode a blurhash string into a tiny PNG data URI, suitable for use as a
21
+ * placeholder while the full image loads.
22
+ */
23
+ export declare function blurhashToDataUri(hash: string, options?: BlurhashToDataUriOptions): Promise<string>;
24
+ export interface BatchBlurhashToDataUriOptions {
25
+ /** Number of hashes decoded concurrently per batch. */
26
+ chunkSize?: number;
27
+ /** Output width and height, in pixels, of each decoded PNG. */
28
+ size?: number;
29
+ }
30
+ /**
31
+ * Decode many blurhash strings into PNG data URIs, processing them in
32
+ * chunks so a large batch doesn't spawn unbounded concurrent sharp work.
33
+ * Output order matches the input order.
34
+ */
35
+ export declare function batchBlurhashToDataUri(hashes: string[], options?: BatchBlurhashToDataUriOptions): Promise<string[]>;
@@ -0,0 +1,40 @@
1
+ import { encode as p, decode as g } from "blurhash";
2
+ import u from "sharp";
3
+ const a = 1, c = 9;
4
+ function h(t, n) {
5
+ if (!Number.isInteger(t) || t < a || t > c)
6
+ throw new Error(
7
+ `encodeImageToBlurhash: ${n} must be an integer between ${a} and ${c}, got ${t}`
8
+ );
9
+ }
10
+ async function b(t, n = {}) {
11
+ const { componentsX: e = 4, componentsY: r = 4, rawSize: o = 32 } = n;
12
+ h(e, "componentsX"), h(r, "componentsY");
13
+ const { data: i, info: s } = await u(t).rotate().raw().ensureAlpha().resize(o, o, { fit: "inside" }).toBuffer({ resolveWithObject: !0 });
14
+ return p(new Uint8ClampedArray(i), s.width, s.height, e, r);
15
+ }
16
+ async function d(t, n = {}) {
17
+ const { size: e = 16 } = n;
18
+ if (!Number.isInteger(e) || e <= 0)
19
+ throw new Error(`blurhashToDataUri: size must be a positive integer, got ${e}`);
20
+ const r = g(t, e, e);
21
+ return `data:image/png;base64,${(await u(Buffer.from(r), {
22
+ raw: { width: e, height: e, channels: 4 }
23
+ }).png({ compressionLevel: 9 }).toBuffer()).toString("base64")}`;
24
+ }
25
+ async function B(t, n = {}) {
26
+ const { chunkSize: e = 20, size: r } = n;
27
+ if (!Number.isInteger(e) || e < 1)
28
+ throw new Error(`batchBlurhashToDataUri: chunkSize must be a positive integer, got ${e}`);
29
+ const o = [];
30
+ for (let i = 0; i < t.length; i += e) {
31
+ const s = t.slice(i, i + e), f = await Promise.all(s.map((m) => d(m, { size: r })));
32
+ o.push(...f);
33
+ }
34
+ return o;
35
+ }
36
+ export {
37
+ B as batchBlurhashToDataUri,
38
+ d as blurhashToDataUri,
39
+ b as encodeImageToBlurhash
40
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Decide whether a raw EXIF `Orientation` tag value (0x0112, values 1-8)
3
+ * requires baking pixels upright before upload.
4
+ *
5
+ * Orientation `1` (or a missing/malformed tag) means the pixels are already
6
+ * upright — no canvas round-trip needed. Any other valid value (2-8) means
7
+ * the decoder must rotate/flip on display, so we bake it in via canvas
8
+ * before the bytes leave the browser (see `bakeOrientation` in
9
+ * `prepare-upload.ts`, which delegates the actual rotate/flip math to
10
+ * `createImageBitmap(..., { imageOrientation: 'from-image' })`).
11
+ */
12
+ export declare function needsOrientationBake(exifOrientation: number | undefined): boolean;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Client-safe subpath: browser upload-preparation pipeline. Must never
3
+ * import `sharp` or any `node:` builtin — see `browser-chunk-guard.test.ts`.
4
+ */
5
+ export { prepareImageForUpload, HEIC_DECODE_FAILED_MESSAGE, ORIENTATION_BAKE_FAILED_MESSAGE_PREFIX, MISSING_EXIFR_MESSAGE, MISSING_HEIC2ANY_MESSAGE, DEFAULT_MAX_CANVAS_AREA, type PrepareImageForUploadOptions, type PreparedImage, } from './prepare-upload.js';
6
+ export { deriveGeometry, deriveOrientation, roundAspectRatio, type Dimensions, type DerivedGeometry, type Orientation, } from './orientation.js';
7
+ export { needsOrientationBake } from './exif-orientation.js';
@@ -0,0 +1,160 @@
1
+ function E(e, t) {
2
+ return e === t ? "square" : e > t ? "landscape" : "portrait";
3
+ }
4
+ function y(e, t) {
5
+ return !Number.isFinite(e) || !Number.isFinite(t) || t === 0 ? 0 : Math.round(e / t * 1e4) / 1e4;
6
+ }
7
+ function T(e, t) {
8
+ return {
9
+ dimensions: { w: e, h: t },
10
+ aspectRatio: y(e, t),
11
+ orientation: E(e, t)
12
+ };
13
+ }
14
+ function b(e) {
15
+ return e !== void 0 && e >= 2 && e <= 8;
16
+ }
17
+ const p = /^image\/(heic|heif)$/i, I = /\.(heic|heif)$/i, A = 4096 * 4096, M = "Install heic2any to decode HEIC in the browser", D = "Install exifr to enable EXIF reading (capture date + orientation baking)";
18
+ class f extends Error {
19
+ constructor(t) {
20
+ super(t), this.name = "MissingPeerError";
21
+ }
22
+ }
23
+ function C(e) {
24
+ return p.test(e.type) || I.test(e.name);
25
+ }
26
+ function N(e) {
27
+ const t = e.lastIndexOf(".");
28
+ return t > 0 ? e.slice(0, t) : e;
29
+ }
30
+ const O = {
31
+ "image/jpeg": "jpg",
32
+ "image/png": "png",
33
+ "image/webp": "webp",
34
+ "image/heic": "heic",
35
+ "image/heif": "heif"
36
+ };
37
+ function _(e) {
38
+ return O[e.toLowerCase()] ?? "bin";
39
+ }
40
+ function x(e, t) {
41
+ if (e instanceof File) return e;
42
+ const r = e.type || "application/octet-stream", o = `${N(t)}.${_(r)}`;
43
+ return new File([e], o, { type: r });
44
+ }
45
+ async function F(e, t) {
46
+ let r;
47
+ try {
48
+ ({ default: r } = await import("heic2any"));
49
+ } catch {
50
+ throw new f(M);
51
+ }
52
+ const o = await r({ blob: e, toType: "image/jpeg", quality: t });
53
+ return Array.isArray(o) ? o[0] : o;
54
+ }
55
+ async function w(e, t) {
56
+ let r;
57
+ try {
58
+ r = await import("exifr");
59
+ } catch {
60
+ throw new f(D);
61
+ }
62
+ try {
63
+ const o = t ? ["DateTimeOriginal", "CreateDate", "ModifyDate", "Orientation"] : ["DateTimeOriginal", "CreateDate", "ModifyDate"], s = await r.parse(e, { pick: o, translateValues: !1 }), n = s?.DateTimeOriginal ?? s?.CreateDate ?? s?.ModifyDate ?? null;
64
+ let a = null;
65
+ if (n instanceof Date && !Number.isNaN(n.getTime()))
66
+ a = n;
67
+ else if (typeof n == "string") {
68
+ const c = new Date(n);
69
+ Number.isNaN(c.getTime()) || (a = c);
70
+ }
71
+ const l = t && typeof s?.Orientation == "number" ? s.Orientation : void 0;
72
+ return { takenAt: a, exifOrientation: l };
73
+ } catch {
74
+ return { takenAt: null, exifOrientation: void 0 };
75
+ }
76
+ }
77
+ const S = "Could not decode HEIC image — heic2any failed and the browser cannot decode HEIC natively. Try saving as JPEG and uploading again.", v = "Could not bake image orientation: ";
78
+ async function k(e) {
79
+ const t = URL.createObjectURL(e);
80
+ try {
81
+ const r = await new Promise((o, s) => {
82
+ const n = new Image();
83
+ n.onload = () => o(n), n.onerror = () => s(new Error("Failed to decode image for dimensions")), n.src = t;
84
+ });
85
+ return { width: r.naturalWidth, height: r.naturalHeight };
86
+ } finally {
87
+ URL.revokeObjectURL(t);
88
+ }
89
+ }
90
+ async function H(e, t, r, o) {
91
+ const s = await createImageBitmap(e, { imageOrientation: "from-image" });
92
+ try {
93
+ let n = s.width, a = s.height;
94
+ const l = n * a;
95
+ if (l > o) {
96
+ const i = Math.sqrt(o / l);
97
+ n = Math.max(1, Math.floor(n * i)), a = Math.max(1, Math.floor(a * i));
98
+ }
99
+ const c = document.createElement("canvas");
100
+ c.width = n, c.height = a;
101
+ const m = c.getContext("2d");
102
+ if (!m) throw new Error("2D canvas context is unavailable");
103
+ m.drawImage(s, 0, 0, n, a);
104
+ const u = t === "image/png" || t === "image/webp" ? t : "image/jpeg", d = await new Promise((i) => c.toBlob(i, u, r));
105
+ if (!d) throw new Error("Canvas toBlob produced no data");
106
+ const h = c.width, g = c.height;
107
+ return c.width = 0, c.height = 0, { blob: d, width: h, height: g };
108
+ } finally {
109
+ s.close();
110
+ }
111
+ }
112
+ async function R(e, t = {}) {
113
+ const r = t.heicQuality ?? 0.9, o = t.bakeQuality ?? 0.92, s = t.maxCanvasArea ?? A, n = C(e);
114
+ let a = e, l = !1;
115
+ if (n)
116
+ try {
117
+ a = await F(e, r), l = !0;
118
+ } catch (i) {
119
+ if (i instanceof f) throw i;
120
+ }
121
+ const { takenAt: c, exifOrientation: m } = l ? await w(e, !1) : await w(a, !0), u = b(m);
122
+ let d, h, g;
123
+ try {
124
+ if (u) {
125
+ const i = await H(a, e.type, o, s);
126
+ d = i.blob, h = i.width, g = i.height;
127
+ } else {
128
+ const i = await k(a);
129
+ d = a, h = i.width, g = i.height;
130
+ }
131
+ } catch (i) {
132
+ if (n && !l)
133
+ throw new Error(S);
134
+ if (u)
135
+ throw new Error(`${v}${i.message}`);
136
+ d = a, h = 0, g = 0;
137
+ }
138
+ return {
139
+ // Preserve the source name base and give the output an extension that
140
+ // matches its ACTUAL MIME. Unchanged originals pass through untouched.
141
+ file: x(d, e.name),
142
+ width: h,
143
+ height: g,
144
+ takenAt: c,
145
+ orientation: E(h, g),
146
+ transcodedFromHeic: l
147
+ };
148
+ }
149
+ export {
150
+ A as DEFAULT_MAX_CANVAS_AREA,
151
+ S as HEIC_DECODE_FAILED_MESSAGE,
152
+ D as MISSING_EXIFR_MESSAGE,
153
+ M as MISSING_HEIC2ANY_MESSAGE,
154
+ v as ORIENTATION_BAKE_FAILED_MESSAGE_PREFIX,
155
+ T as deriveGeometry,
156
+ E as deriveOrientation,
157
+ b as needsOrientationBake,
158
+ R as prepareImageForUpload,
159
+ y as roundAspectRatio
160
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reference implementation of the 8 EXIF `Orientation` tag transforms (values
3
+ * 1-8), expressed as pure grid permutations.
4
+ *
5
+ * This does NOT mirror any code path inside `bakeOrientation`
6
+ * (`prepare-upload.ts`): that function deliberately delegates the actual
7
+ * rotate/flip math to `createImageBitmap(..., { imageOrientation: 'from-image'
8
+ * })` rather than hand-rolling the 8-case matrix itself (see its doc comment).
9
+ * There is therefore no in-repo "pure orientation function" for production
10
+ * code to share with this one.
11
+ *
12
+ * Its purpose is different: it's an independently-reasoned oracle for what
13
+ * each orientation value is SUPPOSED to do to a grid of pixels, used to
14
+ * compute expected outputs for both `orientation-semantics.test.ts` (below)
15
+ * and the real-browser CDP smoke test in
16
+ * `scripts/browser-orientation-smoke.mjs` (see issue #52) — so the smoke test
17
+ * can assert Chrome's actual `imageOrientation: 'from-image'` behaviour
18
+ * against a formally-derived expectation instead of a hand-wavy "looks right".
19
+ *
20
+ * Transform definitions (standard EXIF/TIFF orientation semantics, matching
21
+ * the well-known PIL `ImageOps.exif_transpose` table):
22
+ * 1 = normal (no-op)
23
+ * 2 = mirror horizontal
24
+ * 3 = rotate 180
25
+ * 4 = mirror vertical
26
+ * 5 = transpose (mirror across the top-left/bottom-right diagonal)
27
+ * 6 = rotate 90 CW
28
+ * 7 = transverse (mirror across the top-right/bottom-left diagonal)
29
+ * 8 = rotate 90 CCW
30
+ */
31
+ /** A grid of pixels/values, indexed `grid[row][col]`. */
32
+ export type Grid<T> = readonly T[][];
33
+ /**
34
+ * Apply the display-transform implied by a raw EXIF `Orientation` value
35
+ * (1-8) to a grid, returning the upright arrangement.
36
+ */
37
+ export declare function applyExifOrientationToGrid<T>(grid: Grid<T>, orientation: number): T[][];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Derive width/height, aspect ratio, and orientation classification from a
3
+ * decoded image's dimensions.
4
+ *
5
+ * Pure utility — kept framework-agnostic so it can be unit-tested without
6
+ * touching the DOM or the Canvas API.
7
+ */
8
+ export type Orientation = 'landscape' | 'portrait' | 'square';
9
+ export interface Dimensions {
10
+ w: number;
11
+ h: number;
12
+ }
13
+ export interface DerivedGeometry {
14
+ dimensions: Dimensions;
15
+ aspectRatio: number;
16
+ orientation: Orientation;
17
+ }
18
+ export declare function deriveOrientation(w: number, h: number): Orientation;
19
+ /**
20
+ * Round to 4 decimal places. Returns `0` when `h` is 0 (or either input is
21
+ * non-finite) to avoid surfacing `Infinity`/`NaN` downstream; callers should
22
+ * treat a 0 aspect ratio as "unknown".
23
+ */
24
+ export declare function roundAspectRatio(w: number, h: number): number;
25
+ export declare function deriveGeometry(w: number, h: number): DerivedGeometry;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Client-side pre-upload pipeline:
3
+ *
4
+ * 1. If the file is HEIC/HEIF, transcode to JPEG using `heic2any`. On failure
5
+ * we keep the raw HEIC bytes so a later step (or the server) can still
6
+ * deal with it.
7
+ * 2. Read EXIF with `exifr` — `DateTimeOriginal`/`CreateDate`/`ModifyDate`
8
+ * for `takenAt`, plus the raw `Orientation` tag.
9
+ * 3. If the orientation tag says the image isn't already upright, bake the
10
+ * rotation into the pixels via canvas so the bytes leaving the browser
11
+ * need no further EXIF-orientation handling downstream.
12
+ * 4. Decode the final bytes once to measure width/height.
13
+ *
14
+ * ## Optional peer dependencies
15
+ *
16
+ * `exifr` and `heic2any` are optional peer dependencies and are always
17
+ * dynamic-imported so this subpath never forces them into a consumer's
18
+ * bundle unless the pipeline actually runs:
19
+ *
20
+ * - `exifr` enables EXIF reading — both the capture date (`takenAt`) and the
21
+ * orientation-bake guarantee. If it isn't installed the pipeline throws
22
+ * {@link MISSING_EXIFR_MESSAGE} rather than silently dropping metadata and
23
+ * shipping images the decoder still has to rotate on display.
24
+ * - `heic2any` enables HEIC/HEIF decoding in the browser. If it isn't
25
+ * installed and a HEIC/HEIF file is submitted, the pipeline throws
26
+ * {@link MISSING_HEIC2ANY_MESSAGE} rather than misreporting the absence as a
27
+ * generic decode failure.
28
+ */
29
+ import { type Orientation } from './orientation.js';
30
+ /**
31
+ * Conservative default cap on the pixel area of the bake canvas.
32
+ *
33
+ * Safari/iOS historically refuse to allocate canvases beyond ~16.7M pixels
34
+ * (≈4096×4096 on older devices) and, rather than throwing, hand back a
35
+ * silently blank canvas — so an uncapped full-resolution bake produces empty
36
+ * output on exactly the devices that need orientation baking most. We downscale
37
+ * to fit this budget instead. Override via `maxCanvasArea` when the target
38
+ * environment is known to allow more.
39
+ *
40
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
41
+ */
42
+ export declare const DEFAULT_MAX_CANVAS_AREA: number;
43
+ /**
44
+ * Thrown when a HEIC/HEIF file is submitted but the optional `heic2any` peer
45
+ * dependency is not installed. Exported so callers/tests can assert against the
46
+ * wording without duplicating it.
47
+ */
48
+ export declare const MISSING_HEIC2ANY_MESSAGE = "Install heic2any to decode HEIC in the browser";
49
+ /**
50
+ * Thrown when the pipeline needs to read EXIF (always) but the optional `exifr`
51
+ * peer dependency is not installed. `exifr` is required for every image — the
52
+ * capture date is read even for plain JPEGs that need no orientation bake — so
53
+ * the message names EXIF reading, not just baking. Exported so callers/tests
54
+ * can assert against the wording without duplicating it.
55
+ */
56
+ export declare const MISSING_EXIFR_MESSAGE = "Install exifr to enable EXIF reading (capture date + orientation baking)";
57
+ export interface PrepareImageForUploadOptions {
58
+ /** JPEG quality (0-1) used when transcoding HEIC/HEIF to JPEG. Default 0.9. */
59
+ heicQuality?: number;
60
+ /**
61
+ * Quality (0-1) used when re-encoding after an orientation bake. Applies to
62
+ * every lossy output type this pipeline can emit — JPEG and WebP alike.
63
+ * Default 0.92.
64
+ */
65
+ bakeQuality?: number;
66
+ /**
67
+ * Maximum pixel area (width × height) of the bake canvas. Inputs larger than
68
+ * this are downscaled to fit before baking, so oversized images produce a
69
+ * correctly-downscaled result instead of Safari's silently-blank output.
70
+ * Default {@link DEFAULT_MAX_CANVAS_AREA}.
71
+ */
72
+ maxCanvasArea?: number;
73
+ }
74
+ export interface PreparedImage {
75
+ /** The upload-ready bytes: JPEG when HEIC was transcoded or orientation was baked, otherwise the original file. */
76
+ file: File | Blob;
77
+ width: number;
78
+ height: number;
79
+ /** EXIF capture date, if present and parseable. */
80
+ takenAt: Date | null;
81
+ orientation: Orientation;
82
+ transcodedFromHeic: boolean;
83
+ }
84
+ /**
85
+ * User-facing error message emitted when a HEIC file failed `heic2any`
86
+ * transcoding AND the browser also cannot decode the raw HEIC bytes for
87
+ * dimensions (neither `createImageBitmap` nor `<img>` support HEIC
88
+ * natively in most browsers). Exported so callers/tests can assert against
89
+ * it without duplicating the wording.
90
+ */
91
+ export declare const HEIC_DECODE_FAILED_MESSAGE = "Could not decode HEIC image \u2014 heic2any failed and the browser cannot decode HEIC natively. Try saving as JPEG and uploading again.";
92
+ /**
93
+ * Prefix for the error thrown when EXIF said the image needs an orientation
94
+ * bake but the bake itself failed (createImageBitmap/canvas/toBlob). We
95
+ * throw rather than silently falling back to un-rotated bytes + a 0x0
96
+ * placeholder — that would quietly violate the "stored originals are
97
+ * upright" guarantee this pipeline exists to provide.
98
+ */
99
+ export declare const ORIENTATION_BAKE_FAILED_MESSAGE_PREFIX = "Could not bake image orientation: ";
100
+ export declare function prepareImageForUpload(file: File, opts?: PrepareImageForUploadOptions): Promise<PreparedImage>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Encode an image so its output stays under a byte-count ceiling, by
3
+ * progressively lowering quality (and, when quality alone isn't enough,
4
+ * width) until the result fits.
5
+ *
6
+ * Two presets cover the two shapes this problem takes in practice:
7
+ *
8
+ * - **Exact-width preset** (`exactWidth` set): the output width is fixed —
9
+ * smaller sources are upscaled to it, larger ones downscaled — and only
10
+ * compression quality steps down across a small ladder of rungs matched
11
+ * to the output format (for PNG, palette quantization engages below the
12
+ * near-lossless top rung). Useful when a downstream consumer requires an
13
+ * exact pixel width regardless of size.
14
+ * - **Step-down preset** (`exactWidth` omitted): the source's aspect ratio
15
+ * is kept, output format auto-detects JPEG vs PNG from the alpha
16
+ * channel, and once the quality ladder at the current width is
17
+ * exhausted the width itself steps down (down to `minWidth`) before the
18
+ * ladder is retried. Useful when only a total byte ceiling matters, not
19
+ * an exact width.
20
+ *
21
+ * Both presets share the same budget-loop, GIF-skip, and input-loading
22
+ * machinery; `qualityLadder`, `minWidth`, `widthStepFactor`, and
23
+ * `paletteQuantization` let a caller override either preset's defaults.
24
+ */
25
+ export interface Step {
26
+ width: number;
27
+ quality: number;
28
+ bytes: number;
29
+ }
30
+ export type BudgetResult = {
31
+ ok: true;
32
+ buffer: Buffer;
33
+ format: 'jpeg' | 'png';
34
+ width: number;
35
+ quality: number;
36
+ bytes: number;
37
+ steps: Step[];
38
+ } | {
39
+ ok: false;
40
+ reason: 'unreachable-budget' | 'animated-gif-skipped';
41
+ steps: Step[];
42
+ };
43
+ export interface EncodeUnderByteBudgetOptions {
44
+ /** Byte-count ceiling the encoded output must not exceed. */
45
+ maxBytes: number;
46
+ /**
47
+ * Explicit output format, or `'auto'` to detect from the source's alpha
48
+ * channel (PNG when present, JPEG otherwise). Defaults to `'png'` when
49
+ * `exactWidth` is set and this is omitted, `'auto'` behavior otherwise.
50
+ */
51
+ format?: 'jpeg' | 'png' | 'auto';
52
+ /** Fixed output width; enables the exact-width preset. Upscales smaller sources. */
53
+ exactWidth?: number;
54
+ /** Overrides the default quality rungs tried at each width. */
55
+ qualityLadder?: number[];
56
+ /** Step-down preset only: width floor the step-down loop won't go below. Defaults to 400. */
57
+ minWidth?: number;
58
+ /** Step-down preset only: multiplier applied to width on each step-down. Defaults to 0.85. */
59
+ widthStepFactor?: number;
60
+ /** Whether the lower quality rungs may engage palette quantization for PNG output. Defaults to true. */
61
+ paletteQuantization?: boolean;
62
+ /** Remote-URL inputs only: milliseconds before the fetch is aborted. Defaults to 30000. */
63
+ fetchTimeoutMs?: number;
64
+ /** Remote-URL inputs only: byte cap on the downloaded input. Defaults to 100MB. */
65
+ maxInputBytes?: number;
66
+ }
67
+ /**
68
+ * Encode `input` into a buffer no larger than `options.maxBytes`, stepping
69
+ * down quality (and, in the step-down preset, width) until it fits.
70
+ * Never throws for an unreachable budget or an animated GIF — both are
71
+ * reported through the returned result's `ok: false` shape.
72
+ */
73
+ export declare function encodeUnderByteBudget(input: Buffer | string, options: EncodeUnderByteBudgetOptions): Promise<BudgetResult>;