@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.
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/blurhash/index.d.ts +35 -0
- package/dist/blurhash/index.js +40 -0
- package/dist/browser/exif-orientation.d.ts +12 -0
- package/dist/browser/index.d.ts +7 -0
- package/dist/browser/index.js +160 -0
- package/dist/browser/orientation-semantics.d.ts +37 -0
- package/dist/browser/orientation.d.ts +25 -0
- package/dist/browser/prepare-upload.d.ts +100 -0
- package/dist/budget/index.d.ts +73 -0
- package/dist/budget/index.js +157 -0
- package/dist/calibrate/index.d.ts +129 -0
- package/dist/calibrate/index.js +206 -0
- package/dist/composite/index.d.ts +50 -0
- package/dist/composite/index.js +56 -0
- package/dist/exif/index.d.ts +41 -0
- package/dist/exif/index.js +100 -0
- package/dist/heif/index.d.ts +139 -0
- package/dist/heif/index.js +275 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +68 -0
- package/dist/ogp/index.d.ts +72 -0
- package/dist/ogp/index.js +117 -0
- package/dist/presets-PTxNoMdy.js +630 -0
- package/dist/product-photo/index.d.ts +50 -0
- package/dist/product-photo/index.js +189 -0
- package/dist/product-photo/shadow.d.ts +48 -0
- package/dist/run-BIjGA2Fu.js +22 -0
- package/dist/square/index.d.ts +98 -0
- package/dist/square/index.js +89 -0
- package/dist/variants/engine.d.ts +38 -0
- package/dist/variants/feature-detect.d.ts +8 -0
- package/dist/variants/hash-cache.d.ts +63 -0
- package/dist/variants/heic.d.ts +15 -0
- package/dist/variants/index.d.ts +5 -0
- package/dist/variants/index.js +19 -0
- package/dist/variants/presets.d.ts +18 -0
- package/dist/variants/repair.d.ts +28 -0
- package/dist/variants/run.d.ts +36 -0
- package/dist/variants/tags.d.ts +13 -0
- package/dist/variants/types.d.ts +208 -0
- 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>;
|