zerobucket 0.4.0 → 0.6.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/README.md +87 -20
- package/dist/bin.js +2506 -0
- package/dist/{chunk-W2C37XYV.js → chunk-SCPC3QDR.js} +1 -1
- package/dist/{client-WZ4VRCPi.d.cts → client-C7xs1dlr.d.cts} +49 -1
- package/dist/{client-WZ4VRCPi.d.ts → client-C7xs1dlr.d.ts} +49 -1
- package/dist/http.d.cts +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.cjs +456 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.js +450 -13
- package/dist/index.js.map +1 -1
- package/package.json +16 -4
- /package/dist/{chunk-W2C37XYV.js.map → chunk-SCPC3QDR.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -54,10 +54,14 @@ __export(src_exports, {
|
|
|
54
54
|
});
|
|
55
55
|
module.exports = __toCommonJS(src_exports);
|
|
56
56
|
|
|
57
|
+
// node_modules/tsup/assets/cjs_shims.js
|
|
58
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
59
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
60
|
+
|
|
57
61
|
// src/client.ts
|
|
58
62
|
var import_node_crypto2 = require("crypto");
|
|
59
63
|
var import_promises = require("fs/promises");
|
|
60
|
-
var
|
|
64
|
+
var import_node_path2 = require("path");
|
|
61
65
|
|
|
62
66
|
// src/errors.ts
|
|
63
67
|
var ZeroBucketError = class extends Error {
|
|
@@ -187,6 +191,17 @@ async function* streamFromObjectStorage(os, id, key, start, end, chunkSize) {
|
|
|
187
191
|
offset = last + 1;
|
|
188
192
|
}
|
|
189
193
|
}
|
|
194
|
+
function prettySize(bytes) {
|
|
195
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
196
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
197
|
+
let v = bytes / 1024;
|
|
198
|
+
let i = 0;
|
|
199
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
200
|
+
v /= 1024;
|
|
201
|
+
i += 1;
|
|
202
|
+
}
|
|
203
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
204
|
+
}
|
|
190
205
|
|
|
191
206
|
// src/adapters/postgres.ts
|
|
192
207
|
var SCHEMA = `
|
|
@@ -821,6 +836,95 @@ var PostgresBackend = class {
|
|
|
821
836
|
throw wrap("Failed to check image existence", exc);
|
|
822
837
|
}
|
|
823
838
|
}
|
|
839
|
+
// ---- maintenance (CLI) -----------------------------------------------
|
|
840
|
+
async getInfo() {
|
|
841
|
+
try {
|
|
842
|
+
return await this.run("info", void 0, async (q) => {
|
|
843
|
+
const imgTable = this.dedup ? "zerobucket_image_refs" : "zerobucket_images";
|
|
844
|
+
const present = await q.query(
|
|
845
|
+
"SELECT to_regclass($1) AS a, to_regclass('zerobucket_blobs') AS b",
|
|
846
|
+
[imgTable]
|
|
847
|
+
);
|
|
848
|
+
if (!present.rows[0].a || this.dedup && !present.rows[0].b)
|
|
849
|
+
return null;
|
|
850
|
+
const j = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
851
|
+
const size = this.dedup ? "b.size_bytes" : "size_bytes";
|
|
852
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
853
|
+
const mime = this.dedup ? "b.mime_type" : "mime_type";
|
|
854
|
+
const tot = (await q.query(
|
|
855
|
+
`SELECT count(*)::int AS n, coalesce(sum(${size}), 0)::text AS bytes, min(${created})::text AS oldest, max(${created})::text AS newest ${j}`
|
|
856
|
+
)).rows[0];
|
|
857
|
+
const disk = (await q.query(
|
|
858
|
+
this.dedup ? "SELECT pg_size_pretty(pg_total_relation_size('zerobucket_blobs') + pg_total_relation_size('zerobucket_image_refs')) AS s" : "SELECT pg_size_pretty(pg_total_relation_size('zerobucket_images')) AS s"
|
|
859
|
+
)).rows[0].s;
|
|
860
|
+
const fmt = (await q.query(
|
|
861
|
+
`SELECT ${mime} AS m, count(*)::int AS n, coalesce(sum(${size}), 0)::text AS bytes ${j} GROUP BY ${mime} ORDER BY count(*) DESC, ${mime}`
|
|
862
|
+
)).rows;
|
|
863
|
+
const info = {
|
|
864
|
+
mode: this.dedup ? "dedup" : "classic",
|
|
865
|
+
count: tot.n,
|
|
866
|
+
totalBytes: Number(tot.bytes),
|
|
867
|
+
// Postgres prints the offset as "+00"; Python's CLI (and ISO-8601) print "+00:00". Normalise so output is identical.
|
|
868
|
+
oldest: tot.oldest && tot.oldest.replace(/([+-]\d\d)$/, "$1:00"),
|
|
869
|
+
newest: tot.newest && tot.newest.replace(/([+-]\d\d)$/, "$1:00"),
|
|
870
|
+
onDisk: disk,
|
|
871
|
+
byFormat: fmt.map((r) => ({
|
|
872
|
+
mimeType: r.m,
|
|
873
|
+
count: r.n,
|
|
874
|
+
bytes: Number(r.bytes)
|
|
875
|
+
}))
|
|
876
|
+
};
|
|
877
|
+
if (this.dedup) {
|
|
878
|
+
const b = (await q.query(
|
|
879
|
+
"SELECT count(*)::int AS n, coalesce(sum(size_bytes), 0)::text AS bytes FROM zerobucket_blobs"
|
|
880
|
+
)).rows[0];
|
|
881
|
+
info.dedup = { blobs: b.n, storedBytes: Number(b.bytes) };
|
|
882
|
+
} else {
|
|
883
|
+
const t = (await q.query(
|
|
884
|
+
"SELECT count(*)::int AS n, coalesce(sum(size_bytes), 0)::text AS bytes FROM zerobucket_images WHERE storage_backend = 'object_storage'"
|
|
885
|
+
)).rows[0];
|
|
886
|
+
info.tiered = { count: t.n, bytes: Number(t.bytes) };
|
|
887
|
+
}
|
|
888
|
+
return info;
|
|
889
|
+
});
|
|
890
|
+
} catch (exc) {
|
|
891
|
+
throw wrap("Failed to read storage info", exc);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
async listImages(o = {}) {
|
|
895
|
+
const where = [];
|
|
896
|
+
const params = [];
|
|
897
|
+
const add = (sql2, v) => (params.push(v), where.push(sql2.replace("$?", `$${params.length}`)));
|
|
898
|
+
if (o.minSize !== void 0)
|
|
899
|
+
add(`${this.dedup ? "b.size_bytes" : "size_bytes"} >= $?`, o.minSize);
|
|
900
|
+
if (o.olderThanDays !== void 0)
|
|
901
|
+
add(
|
|
902
|
+
`${this.dedup ? "r.created_at" : "created_at"} <= now() - make_interval(days => $?::int)`,
|
|
903
|
+
o.olderThanDays
|
|
904
|
+
);
|
|
905
|
+
if (o.onlyUntiered && !this.dedup)
|
|
906
|
+
where.push("storage_backend = 'postgres'");
|
|
907
|
+
const from = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
908
|
+
const idCol = this.dedup ? "r.id" : "id";
|
|
909
|
+
const tierCol = this.dedup ? "false AS tiered" : "(storage_backend = 'object_storage') AS tiered";
|
|
910
|
+
const order = o.sample !== void 0 ? "ORDER BY random()" : `ORDER BY ${this.dedup ? "r.created_at" : "created_at"}, ${idCol}`;
|
|
911
|
+
const lim = o.sample ?? o.limit;
|
|
912
|
+
let sql = `SELECT ${idCol} AS id, ${tierCol} ${from}${where.length ? " WHERE " + where.join(" AND ") : ""} ${order}`;
|
|
913
|
+
if (lim !== void 0) {
|
|
914
|
+
params.push(lim);
|
|
915
|
+
sql += ` LIMIT $${params.length}`;
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const rows = await this.run(
|
|
919
|
+
"list_images",
|
|
920
|
+
void 0,
|
|
921
|
+
async (q) => (await q.query(sql, params)).rows
|
|
922
|
+
);
|
|
923
|
+
return rows.map((r) => ({ id: String(r.id), tiered: Boolean(r.tiered) }));
|
|
924
|
+
} catch (exc) {
|
|
925
|
+
throw wrap("Failed to list images", exc);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
824
928
|
// ---- dedup deletes / migration ---------------------------------------
|
|
825
929
|
async deleteDedup(id, connection) {
|
|
826
930
|
try {
|
|
@@ -1102,6 +1206,7 @@ function isQueryable(c) {
|
|
|
1102
1206
|
|
|
1103
1207
|
// src/adapters/sqlite.ts
|
|
1104
1208
|
var import_node_crypto = require("crypto");
|
|
1209
|
+
var import_node_fs = require("fs");
|
|
1105
1210
|
var SCHEMA2 = `
|
|
1106
1211
|
CREATE TABLE IF NOT EXISTS zerobucket_images (
|
|
1107
1212
|
id TEXT PRIMARY KEY,
|
|
@@ -1688,6 +1793,96 @@ var SQLiteBackend = class {
|
|
|
1688
1793
|
throw wrap("Failed to check image existence", exc);
|
|
1689
1794
|
}
|
|
1690
1795
|
}
|
|
1796
|
+
// ---- maintenance (CLI) -----------------------------------------------
|
|
1797
|
+
async getInfo() {
|
|
1798
|
+
try {
|
|
1799
|
+
return await this.execute("info", void 0, "read", (db) => {
|
|
1800
|
+
const has = (t) => db.prepare(
|
|
1801
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
|
|
1802
|
+
).get(t) !== void 0;
|
|
1803
|
+
if (!has(this.dedup ? "zerobucket_image_refs" : "zerobucket_images") || this.dedup && !has("zerobucket_blobs"))
|
|
1804
|
+
return null;
|
|
1805
|
+
const j = this.dedup ? D_FROM2 : "FROM zerobucket_images";
|
|
1806
|
+
const size = this.dedup ? "b.size_bytes" : "size_bytes";
|
|
1807
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
1808
|
+
const mime = this.dedup ? "b.mime_type" : "mime_type";
|
|
1809
|
+
const tot = db.prepare(
|
|
1810
|
+
`SELECT count(*) AS n, coalesce(sum(${size}), 0) AS bytes, min(${created}) AS oldest, max(${created}) AS newest ${j}`
|
|
1811
|
+
).get();
|
|
1812
|
+
const fmt = db.prepare(
|
|
1813
|
+
`SELECT ${mime} AS m, count(*) AS n, coalesce(sum(${size}), 0) AS bytes ${j} GROUP BY ${mime} ORDER BY count(*) DESC, ${mime}`
|
|
1814
|
+
).all();
|
|
1815
|
+
let onDisk = null;
|
|
1816
|
+
if (this.opts.path !== ":memory:") {
|
|
1817
|
+
let total = (0, import_node_fs.statSync)(this.opts.path).size;
|
|
1818
|
+
for (const suffix of ["-wal", "-shm"])
|
|
1819
|
+
if ((0, import_node_fs.existsSync)(this.opts.path + suffix))
|
|
1820
|
+
total += (0, import_node_fs.statSync)(this.opts.path + suffix).size;
|
|
1821
|
+
onDisk = prettySize(total);
|
|
1822
|
+
}
|
|
1823
|
+
const info = {
|
|
1824
|
+
mode: this.dedup ? "dedup" : "classic",
|
|
1825
|
+
count: tot.n,
|
|
1826
|
+
totalBytes: Number(tot.bytes),
|
|
1827
|
+
oldest: tot.oldest,
|
|
1828
|
+
newest: tot.newest,
|
|
1829
|
+
onDisk,
|
|
1830
|
+
byFormat: fmt.map((r) => ({
|
|
1831
|
+
mimeType: r.m,
|
|
1832
|
+
count: r.n,
|
|
1833
|
+
bytes: Number(r.bytes)
|
|
1834
|
+
}))
|
|
1835
|
+
};
|
|
1836
|
+
if (this.dedup) {
|
|
1837
|
+
const b = db.prepare(
|
|
1838
|
+
"SELECT count(*) AS n, coalesce(sum(size_bytes), 0) AS bytes FROM zerobucket_blobs"
|
|
1839
|
+
).get();
|
|
1840
|
+
info.dedup = { blobs: b.n, storedBytes: Number(b.bytes) };
|
|
1841
|
+
} else {
|
|
1842
|
+
const t = db.prepare(
|
|
1843
|
+
"SELECT count(*) AS n, coalesce(sum(size_bytes), 0) AS bytes FROM zerobucket_images WHERE storage_backend = 'object_storage'"
|
|
1844
|
+
).get();
|
|
1845
|
+
info.tiered = { count: t.n, bytes: Number(t.bytes) };
|
|
1846
|
+
}
|
|
1847
|
+
return info;
|
|
1848
|
+
});
|
|
1849
|
+
} catch (exc) {
|
|
1850
|
+
throw wrap("Failed to read storage info", exc);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
async listImages(o = {}) {
|
|
1854
|
+
const where = [];
|
|
1855
|
+
const params = [];
|
|
1856
|
+
if (o.minSize !== void 0)
|
|
1857
|
+
where.push(`${this.dedup ? "b.size_bytes" : "size_bytes"} >= ?`), params.push(o.minSize);
|
|
1858
|
+
if (o.olderThanDays !== void 0) {
|
|
1859
|
+
const cutoff = new Date(Date.now() - o.olderThanDays * 864e5).toISOString().replace("Z", "000+00:00");
|
|
1860
|
+
where.push(`${this.dedup ? "r.created_at" : "created_at"} <= ?`);
|
|
1861
|
+
params.push(cutoff);
|
|
1862
|
+
}
|
|
1863
|
+
if (o.onlyUntiered && !this.dedup) where.push("storage_backend = 'sqlite'");
|
|
1864
|
+
const from = this.dedup ? D_FROM2 : "FROM zerobucket_images";
|
|
1865
|
+
const idCol = this.dedup ? "r.id" : "id";
|
|
1866
|
+
const tierCol = this.dedup ? "0 AS tiered" : "(storage_backend = 'object_storage') AS tiered";
|
|
1867
|
+
const order = o.sample !== void 0 ? "ORDER BY random()" : `ORDER BY ${this.dedup ? "r.created_at" : "created_at"}, ${idCol}`;
|
|
1868
|
+
const lim = o.sample ?? o.limit;
|
|
1869
|
+
let sql = `SELECT ${idCol} AS id, ${tierCol} ${from}${where.length ? " WHERE " + where.join(" AND ") : ""} ${order}`;
|
|
1870
|
+
if (lim !== void 0) sql += " LIMIT ?", params.push(lim);
|
|
1871
|
+
try {
|
|
1872
|
+
const rows = await this.execute(
|
|
1873
|
+
"list_images",
|
|
1874
|
+
void 0,
|
|
1875
|
+
"read",
|
|
1876
|
+
(db) => db.prepare(sql).all(...params)
|
|
1877
|
+
);
|
|
1878
|
+
return rows.map((r) => ({
|
|
1879
|
+
id: String(r.id),
|
|
1880
|
+
tiered: Boolean(r.tiered)
|
|
1881
|
+
}));
|
|
1882
|
+
} catch (exc) {
|
|
1883
|
+
throw wrap("Failed to list images", exc);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1691
1886
|
/**
|
|
1692
1887
|
* Remove tiered objects AFTER their rows are gone (a failed object delete leaves a harmless orphan; the
|
|
1693
1888
|
* reverse order could leave a row pointing at nothing). Skipped inside a caller-owned transaction, since the
|
|
@@ -1789,11 +1984,191 @@ var SQLiteBackend = class {
|
|
|
1789
1984
|
}
|
|
1790
1985
|
};
|
|
1791
1986
|
|
|
1987
|
+
// src/heic.ts
|
|
1988
|
+
var import_node_module = require("module");
|
|
1989
|
+
var import_node_path = require("path");
|
|
1990
|
+
var import_node_worker_threads = require("worker_threads");
|
|
1991
|
+
var HEIC_BRANDS = /* @__PURE__ */ new Set([
|
|
1992
|
+
"heic",
|
|
1993
|
+
"heix",
|
|
1994
|
+
"hevc",
|
|
1995
|
+
"heim",
|
|
1996
|
+
"heis",
|
|
1997
|
+
"mif1",
|
|
1998
|
+
"msf1"
|
|
1999
|
+
]);
|
|
2000
|
+
function looksLikeHeic(data) {
|
|
2001
|
+
if (data.length < 12) return false;
|
|
2002
|
+
if (String.fromCharCode(data[4], data[5], data[6], data[7]) !== "ftyp")
|
|
2003
|
+
return false;
|
|
2004
|
+
return HEIC_BRANDS.has(
|
|
2005
|
+
String.fromCharCode(data[8], data[9], data[10], data[11])
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
var HEIC_INSTALL_HINT = "npm install libheif-js";
|
|
2009
|
+
var WORKER_SOURCE = `
|
|
2010
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
2011
|
+
const libheif = require(workerData.libPath);
|
|
2012
|
+
// libheif-js reports decode problems by dumping a raw error object to the console. Capture that text instead:
|
|
2013
|
+
// it becomes part of the error we return (the useful diagnosis), and nothing is printed to the host's stderr.
|
|
2014
|
+
let diag = "";
|
|
2015
|
+
for (const level of ["log", "info", "warn", "error"]) {
|
|
2016
|
+
console[level] = (...args) => {
|
|
2017
|
+
diag = args.map((a) => (a && a.message) || (typeof a === "string" ? a : "")).filter(Boolean).join(" ").trim();
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
const describe = (e) => {
|
|
2021
|
+
const base = String((e && e.message) || e);
|
|
2022
|
+
return diag && !base.includes(diag) ? base + ": " + diag : base;
|
|
2023
|
+
};
|
|
2024
|
+
function open(bytes) {
|
|
2025
|
+
const images = new libheif.HeifDecoder().decode(bytes);
|
|
2026
|
+
if (!images || images.length === 0) throw new Error("no images found in HEIF container");
|
|
2027
|
+
return images[0];
|
|
2028
|
+
}
|
|
2029
|
+
parentPort.on("message", async ({ id, op, data }) => {
|
|
2030
|
+
diag = "";
|
|
2031
|
+
try {
|
|
2032
|
+
const img = open(new Uint8Array(data));
|
|
2033
|
+
const width = img.get_width();
|
|
2034
|
+
const height = img.get_height();
|
|
2035
|
+
if (op === "info") return parentPort.postMessage({ id, ok: true, width, height });
|
|
2036
|
+
const out = await new Promise((resolve, reject) =>
|
|
2037
|
+
img.display({ data: new Uint8ClampedArray(width * height * 4), width, height }, (d) =>
|
|
2038
|
+
d ? resolve(d) : reject(new Error("HEIF processing error")),
|
|
2039
|
+
),
|
|
2040
|
+
);
|
|
2041
|
+
if (op === "validate") return parentPort.postMessage({ id, ok: true, width, height });
|
|
2042
|
+
const rgba = out.data.buffer;
|
|
2043
|
+
parentPort.postMessage({ id, ok: true, width, height, rgba }, [rgba]);
|
|
2044
|
+
} catch (e) {
|
|
2045
|
+
parentPort.postMessage({ id, ok: false, error: describe(e) });
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
`;
|
|
2049
|
+
var HeicWorkerError = class extends StorageError {
|
|
2050
|
+
};
|
|
2051
|
+
var worker;
|
|
2052
|
+
var nextId = 1;
|
|
2053
|
+
var pending = /* @__PURE__ */ new Map();
|
|
2054
|
+
var stats = { info: 0, validate: 0, decode: 0, workersStarted: 0 };
|
|
2055
|
+
var resolveLib = () => {
|
|
2056
|
+
const attempts = [
|
|
2057
|
+
() => (0, import_node_module.createRequire)(importMetaUrl).resolve("libheif-js/wasm-bundle"),
|
|
2058
|
+
// next to / above this package
|
|
2059
|
+
() => (0, import_node_module.createRequire)((0, import_node_path.join)(process.cwd(), "noop.js")).resolve(
|
|
2060
|
+
"libheif-js/wasm-bundle"
|
|
2061
|
+
)
|
|
2062
|
+
// the app's own node_modules
|
|
2063
|
+
];
|
|
2064
|
+
let last;
|
|
2065
|
+
for (const attempt of attempts) {
|
|
2066
|
+
try {
|
|
2067
|
+
return attempt();
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
last = e;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
throw last;
|
|
2073
|
+
};
|
|
2074
|
+
function heicAvailable() {
|
|
2075
|
+
try {
|
|
2076
|
+
resolveLib();
|
|
2077
|
+
return true;
|
|
2078
|
+
} catch {
|
|
2079
|
+
return false;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
function releaseIfIdle(w) {
|
|
2083
|
+
for (const p of pending.values()) if (p.owner === w) return;
|
|
2084
|
+
w.unref();
|
|
2085
|
+
}
|
|
2086
|
+
function startWorker() {
|
|
2087
|
+
let libPath;
|
|
2088
|
+
try {
|
|
2089
|
+
libPath = resolveLib();
|
|
2090
|
+
} catch (cause) {
|
|
2091
|
+
throw new StorageError(
|
|
2092
|
+
`HEIC support requires the optional dependency 'libheif-js'. Install it with: ${HEIC_INSTALL_HINT}`,
|
|
2093
|
+
{ cause }
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
const w = new import_node_worker_threads.Worker(WORKER_SOURCE, { eval: true, workerData: { libPath } });
|
|
2097
|
+
stats.workersStarted += 1;
|
|
2098
|
+
w.on(
|
|
2099
|
+
"message",
|
|
2100
|
+
(m) => {
|
|
2101
|
+
const p = pending.get(m.id);
|
|
2102
|
+
if (!p) return;
|
|
2103
|
+
pending.delete(m.id);
|
|
2104
|
+
releaseIfIdle(w);
|
|
2105
|
+
if (m.ok) p.resolve(m);
|
|
2106
|
+
else p.reject(new Error(m.error));
|
|
2107
|
+
}
|
|
2108
|
+
);
|
|
2109
|
+
const fail = (err) => {
|
|
2110
|
+
if (worker === w) worker = void 0;
|
|
2111
|
+
for (const [id, p] of pending) {
|
|
2112
|
+
if (p.owner !== w) continue;
|
|
2113
|
+
pending.delete(id);
|
|
2114
|
+
p.reject(err);
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
w.on(
|
|
2118
|
+
"error",
|
|
2119
|
+
(e) => fail(
|
|
2120
|
+
new HeicWorkerError(
|
|
2121
|
+
`HEIC decoder worker crashed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2122
|
+
{ cause: e }
|
|
2123
|
+
)
|
|
2124
|
+
)
|
|
2125
|
+
);
|
|
2126
|
+
w.on(
|
|
2127
|
+
"exit",
|
|
2128
|
+
(code) => fail(
|
|
2129
|
+
new HeicWorkerError(
|
|
2130
|
+
`HEIC decoder worker exited unexpectedly (code ${code})`
|
|
2131
|
+
)
|
|
2132
|
+
)
|
|
2133
|
+
);
|
|
2134
|
+
w.unref();
|
|
2135
|
+
return w;
|
|
2136
|
+
}
|
|
2137
|
+
function call(op, data) {
|
|
2138
|
+
stats[op] += 1;
|
|
2139
|
+
const w = worker ??= startWorker();
|
|
2140
|
+
const id = nextId++;
|
|
2141
|
+
const copy = new Uint8Array(data).buffer;
|
|
2142
|
+
return new Promise((resolve, reject) => {
|
|
2143
|
+
pending.set(id, { owner: w, resolve, reject });
|
|
2144
|
+
w.ref();
|
|
2145
|
+
w.postMessage({ id, op, data: copy }, [copy]);
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
async function heicInfo(data) {
|
|
2149
|
+
const r = await call("info", data);
|
|
2150
|
+
return { width: r.width, height: r.height };
|
|
2151
|
+
}
|
|
2152
|
+
async function heicValidate(data) {
|
|
2153
|
+
const r = await call("validate", data);
|
|
2154
|
+
return { width: r.width, height: r.height };
|
|
2155
|
+
}
|
|
2156
|
+
async function decodeHeic(data) {
|
|
2157
|
+
const r = await call("decode", data);
|
|
2158
|
+
return {
|
|
2159
|
+
width: r.width,
|
|
2160
|
+
height: r.height,
|
|
2161
|
+
rgba: Buffer.from(r.rgba)
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
1792
2165
|
// src/validation.ts
|
|
1793
2166
|
var FORMAT_TO_MIME = {
|
|
1794
2167
|
jpeg: "image/jpeg",
|
|
1795
2168
|
png: "image/png",
|
|
1796
|
-
webp: "image/webp"
|
|
2169
|
+
webp: "image/webp",
|
|
2170
|
+
heic: "image/heic"
|
|
2171
|
+
// optional: needs `npm install libheif-js` (see heic.ts)
|
|
1797
2172
|
};
|
|
1798
2173
|
var SUPPORTED_FORMATS = Object.keys(FORMAT_TO_MIME);
|
|
1799
2174
|
var DEFAULT_MAX_PIXELS = 89e6;
|
|
@@ -1821,6 +2196,8 @@ async function validateImage(data, opts) {
|
|
|
1821
2196
|
const sizeBytes = data.length;
|
|
1822
2197
|
if (sizeBytes > maxBytes) throw new ImageTooLargeError(sizeBytes, maxBytes);
|
|
1823
2198
|
if (sizeBytes === 0) throw new CorruptedImageError("Image data is empty");
|
|
2199
|
+
if (looksLikeHeic(data))
|
|
2200
|
+
return validateHeic(data, { maxBytes, maxPixels, allowedFormats });
|
|
1824
2201
|
const sharp = await loadSharp();
|
|
1825
2202
|
const make = () => sharp(data, { limitInputPixels: maxPixels, failOn: "error" });
|
|
1826
2203
|
let format;
|
|
@@ -1856,6 +2233,45 @@ async function validateImage(data, opts) {
|
|
|
1856
2233
|
}
|
|
1857
2234
|
return { mimeType: FORMAT_TO_MIME[format], width, height, sizeBytes };
|
|
1858
2235
|
}
|
|
2236
|
+
async function validateHeic(data, {
|
|
2237
|
+
maxBytes,
|
|
2238
|
+
maxPixels,
|
|
2239
|
+
allowedFormats
|
|
2240
|
+
}) {
|
|
2241
|
+
const sizeBytes = data.length;
|
|
2242
|
+
if (!heicAvailable()) {
|
|
2243
|
+
throw new ImageValidationError(
|
|
2244
|
+
`This looks like a HEIC/HEIF image, which requires an optional dependency. Install it with: ${HEIC_INSTALL_HINT}`
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
if (!allowedFormats.includes("heic"))
|
|
2248
|
+
throw new UnsupportedFormatError("heic", allowedFormats);
|
|
2249
|
+
let width;
|
|
2250
|
+
let height;
|
|
2251
|
+
try {
|
|
2252
|
+
({ width, height } = await heicInfo(data));
|
|
2253
|
+
} catch (exc) {
|
|
2254
|
+
if (exc instanceof StorageError) throw exc;
|
|
2255
|
+
throw new CorruptedImageError(
|
|
2256
|
+
`Could not decode image: ${errMessage(exc)}`,
|
|
2257
|
+
{ cause: exc }
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
if (!width || !height)
|
|
2261
|
+
throw new CorruptedImageError("Image has no readable dimensions");
|
|
2262
|
+
if (width * height > maxPixels)
|
|
2263
|
+
throw new ImageTooLargeError(sizeBytes, maxBytes);
|
|
2264
|
+
try {
|
|
2265
|
+
await heicValidate(data);
|
|
2266
|
+
} catch (exc) {
|
|
2267
|
+
if (exc instanceof StorageError) throw exc;
|
|
2268
|
+
throw new CorruptedImageError(
|
|
2269
|
+
`Image data is truncated or corrupted: ${errMessage(exc)}`,
|
|
2270
|
+
{ cause: exc }
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
return { mimeType: FORMAT_TO_MIME.heic, width, height, sizeBytes };
|
|
2274
|
+
}
|
|
1859
2275
|
function isPixelLimit(exc) {
|
|
1860
2276
|
return /pixel limit/i.test(errMessage(exc));
|
|
1861
2277
|
}
|
|
@@ -1864,28 +2280,53 @@ function errMessage(exc) {
|
|
|
1864
2280
|
}
|
|
1865
2281
|
|
|
1866
2282
|
// src/optimize.ts
|
|
2283
|
+
var ENCODABLE_FORMATS = ["jpeg", "png", "webp"];
|
|
1867
2284
|
var DEFAULT_JPEG_QUALITY = 90;
|
|
1868
2285
|
var DEFAULT_WEBP_QUALITY = 88;
|
|
1869
|
-
var FORMAT_ALIASES = { jpg: "jpeg" };
|
|
1870
2286
|
async function optimizeImage(data, opts) {
|
|
1871
2287
|
const { maxWidth, quality, maxBytes, maxPixels = DEFAULT_MAX_PIXELS } = opts;
|
|
1872
2288
|
const sharp = await loadSharp();
|
|
1873
2289
|
const originalSizeBytes = data.length;
|
|
1874
|
-
const
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
const target = FORMAT_ALIASES[requested] ?? requested;
|
|
1878
|
-
if (!SUPPORTED_FORMATS.includes(target)) {
|
|
2290
|
+
const requestedRaw = opts.format?.toLowerCase();
|
|
2291
|
+
const requested = requestedRaw === "jpg" ? "jpeg" : requestedRaw;
|
|
2292
|
+
if (requested === "heic" || requested === "heif") {
|
|
1879
2293
|
throw new ImageValidationError(
|
|
1880
|
-
|
|
1881
|
-
[...SUPPORTED_FORMATS].sort()
|
|
1882
|
-
)}`
|
|
2294
|
+
"Encoding to HEIC is not available in the Node package (there is no HEVC encoder for Node). Optimize to jpeg, webp or png instead, or store the original HEIC without `optimize`."
|
|
1883
2295
|
);
|
|
1884
2296
|
}
|
|
1885
|
-
let
|
|
2297
|
+
let input = data;
|
|
2298
|
+
let inputOptions = {
|
|
1886
2299
|
limitInputPixels: maxPixels,
|
|
1887
2300
|
failOn: "error"
|
|
1888
|
-
}
|
|
2301
|
+
};
|
|
2302
|
+
let sourceFormat;
|
|
2303
|
+
if (looksLikeHeic(data)) {
|
|
2304
|
+
if (!heicAvailable()) {
|
|
2305
|
+
throw new ImageValidationError(
|
|
2306
|
+
`This looks like a HEIC/HEIF image, which requires an optional dependency. Install it with: ${HEIC_INSTALL_HINT}`
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
if (!requested) {
|
|
2310
|
+
throw new ImageValidationError(
|
|
2311
|
+
"Optimizing a HEIC image needs an explicit output format, because re-encoding to HEIC is not available in the Node package. Pass optimize: { format: 'jpeg' | 'webp' | 'png' } (for example { format: 'webp', maxWidth: 1600 })."
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
const decoded = await decodeHeic(data);
|
|
2315
|
+
input = decoded.rgba;
|
|
2316
|
+
inputOptions = {
|
|
2317
|
+
raw: { width: decoded.width, height: decoded.height, channels: 4 }
|
|
2318
|
+
};
|
|
2319
|
+
sourceFormat = "heic";
|
|
2320
|
+
} else {
|
|
2321
|
+
sourceFormat = (await sharp(data, inputOptions).metadata()).format;
|
|
2322
|
+
}
|
|
2323
|
+
const target = requested ?? sourceFormat ?? "jpeg";
|
|
2324
|
+
if (!ENCODABLE_FORMATS.includes(target)) {
|
|
2325
|
+
throw new ImageValidationError(
|
|
2326
|
+
`Cannot re-encode to unsupported format ${JSON.stringify(target)}. Supported: ${JSON.stringify([...ENCODABLE_FORMATS].sort())}`
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
let pipeline = sharp(input, inputOptions).rotate();
|
|
1889
2330
|
if (maxWidth !== void 0) {
|
|
1890
2331
|
pipeline = pipeline.resize({
|
|
1891
2332
|
width: maxWidth,
|
|
@@ -2328,7 +2769,7 @@ async function readInput(image, filename, maxBytes) {
|
|
|
2328
2769
|
if (size > maxBytes) throw new ImageTooLargeError(size, maxBytes);
|
|
2329
2770
|
return {
|
|
2330
2771
|
data: await (0, import_promises.readFile)(image),
|
|
2331
|
-
filename: filename ?? (0,
|
|
2772
|
+
filename: filename ?? (0, import_node_path2.basename)(image)
|
|
2332
2773
|
};
|
|
2333
2774
|
}
|
|
2334
2775
|
if (Buffer.isBuffer(image)) return { data: image, filename };
|
|
@@ -2363,7 +2804,7 @@ async function readInput(image, filename, maxBytes) {
|
|
|
2363
2804
|
const p = image.path;
|
|
2364
2805
|
return {
|
|
2365
2806
|
data: Buffer.concat(chunks, total),
|
|
2366
|
-
filename: filename ?? (typeof p === "string" ? (0,
|
|
2807
|
+
filename: filename ?? (typeof p === "string" ? (0, import_node_path2.basename)(p) : void 0)
|
|
2367
2808
|
};
|
|
2368
2809
|
}
|
|
2369
2810
|
throw new TypeError(
|