lecodes-assets 2.5.1 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-assets",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "LeCodes asset pipeline: FBX → GLB (ufbx), clip merging by bone name, texture embedding, GLB doctor (bone cap, texture normalize, KTX2).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@ export default defineCommand({
17
17
  "lod-bones": bool("fold fingers / toes / twists onto their parents in the last level's skin"),
18
18
  impostor: str("bake the level-0 geometry into an impostor atlas (albedo + hull normal / AO) and add it as a LOD level, one camera-facing quad (<name>_LOD<k>, k = --impostor-level, never past the first missing level; the levels from k up are replaced). PNG / JPEG base colours only — run it with --ktx2, not after it; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
19
19
  "impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30)"),
20
+ "impostor-samples": int("the impostor bake's supersamples a texel's side, S: S×S samples (default 8: 5.3 s for a 16 m pine against 2.1 s at 4) - the coverage is covered / S², a porous crown's holes kept as a fraction the engine's mask cuts (alphaToCoverage takes it under MSAA)"),
20
21
  },
21
22
  examples: [
22
23
  "lecodes assets doctor hero.glb",
@@ -31,7 +32,7 @@ export default defineCommand({
31
32
  const { validateGlb, formatReport } = await import("../doctor/validateGlb")
32
33
  const useKtx2 = wantsKtx2(flags)
33
34
  const lod = (flags.lod ?? "").split(",").map(Number).filter((n) => n > 0 && n < 1)
34
- const impostor = impostorFlag(flags.impostor, flags["impostor-level"])
35
+ const impostor = impostorFlag(flags.impostor, flags["impostor-level"], flags["impostor-samples"])
35
36
  const doFix = wantsDoctorFix(flags) || flags["fix-metallic"] || lod.length > 0 || !!impostor
36
37
  const opts = { ...doctorOptions(flags), lod, lodBones: flags["lod-bones"] }
37
38
  const ktxEncoder = useKtx2 ? findKtxEncoder() : null
@@ -50,6 +50,7 @@ export default defineCommand({
50
50
  "max-tris": int("triangles a kind may have before the import simplifies it (default 50000, 0 = no cap): a photogrammetry scan otherwise arrives as a million-triangle prop AND mesh collider"),
51
51
  impostor: str("foliage kinds ≥ 4 m tall get their far LOD level as ONE camera-facing quad over an atlas of views (albedo + hull normal / AO), replacing the pack's billboard; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
52
52
  "impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30, the pack's LOD1 goes; never past the first missing level)"),
53
+ "impostor-samples": int("the impostor bake's supersamples a texel's side, S: S×S samples (default 8: 5.3 s for a 16 m pine against 2.1 s at 4) - the coverage is covered / S², a porous crown's holes kept as a fraction the engine's mask cuts (alphaToCoverage takes it under MSAA)"),
53
54
  "no-trim-cards": bool("skip the alpha-card trim the import otherwise runs (it can empty a kind whose decal atlas is mostly clear)"),
54
55
  filter: str("keep only instances whose kind name / scene group / node name matches", { value: "<re>", group: "Subset" }),
55
56
  "drop-at": str(`leave OUT the placements standing at these points ("x,y,z;x,y,z", the lecodes frame the scene file prints, within 10 cm): the one instance of a kind the game moves while its twins stay put — the range's five lane targets, which Unity names like the sixth on a shelf`, { value: "<x,y,z;…>" }),
@@ -89,7 +90,7 @@ export default defineCommand({
89
90
  project: flags.project, name: flags.name, force: flags.force, maxSize: flags["max-size"] ?? 1024, ktx2: !flags.png, log,
90
91
  foliage: regexFlag(flags.foliage), noFoliage: flags["no-foliage"], noSun: flags["no-sun"],
91
92
  noLightmap: regexFlag(flags["no-lightmap"]), noTrimCards: flags["no-trim-cards"], colliderFit: flags["collider-fit"], noColliders: flags["no-colliders"], noShadows: regexFlag(flags["no-shadows"]), maxTris: flags["max-tris"],
92
- impostor: impostorFlag(flags.impostor, flags["impostor-level"]),
93
+ impostor: impostorFlag(flags.impostor, flags["impostor-level"], flags["impostor-samples"]),
93
94
  terrainRes: flags["terrain-res"] ?? 1025,
94
95
  // the invocation, for the scene file's header (quote what a shell would need quoted)
95
96
  command: "lecodes assets scene " + argv.map((a) => (/[\s"'|<>^&();]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "),
@@ -120,9 +120,10 @@ export const resolveInputs = (args: string[], ext = /\.glb$/i, skipGenerated = t
120
120
  /** `--impostor <A>x<E>[@<px>]` (the grid of A azimuths × E elevation rows: "12x3", "8x2@256") or `oct<N>[@<px>]` (the
121
121
  * hemi-octahedron: "oct8", "oct8@128"), bare = 12x3, + `--impostor-level` → the bake's options (rows 0 = the
122
122
  * octahedron, cols = N), or undefined when absent. */
123
- export const impostorFlag = (v: string | undefined, level?: number): { cols: number, rows: number, px?: number, level?: number } | undefined => {
123
+ export const impostorFlag = (v: string | undefined, level?: number, samples?: number): { cols: number, rows: number, px?: number, level?: number, samples?: number } | undefined => {
124
124
  if (v === undefined) return undefined
125
125
  if (level !== undefined && (!Number.isInteger(level) || level < 1 || level > 3)) throw usageError(`--impostor-level wants 1, 2 or 3 — got "${level}"`)
126
+ if (samples !== undefined && (!Number.isInteger(samples) || samples < 1 || samples > 16)) throw usageError(`--impostor-samples wants 1..16 — got "${samples}"`)
126
127
  const s = v.trim() || "12x3"
127
128
  const bad = (why: string): Error => usageError(`--impostor wants <A>x<E>[@<px>] (azimuths ≥ 3, rows 1..8) or oct<N>[@<px>] (N 2..64), px 32..1024 a multiple of 4 — e.g. 12x3, 8x2@256, oct8@128 — got "${v}" (${why})`)
128
129
  const grid = /^(\d+)x(\d+)(?:@(\d+))?$/i.exec(s), oct = /^oct(\d+)(?:@(\d+))?$/i.exec(s)
@@ -133,7 +134,7 @@ export const impostorFlag = (v: string | undefined, level?: number): { cols: num
133
134
  if (grid && (rows < 1 || rows > 8)) throw bad("rows 1..8")
134
135
  if (oct && (cols < 2 || cols > 64)) throw bad("N 2..64")
135
136
  if (px !== undefined && (px < 32 || px > 1024 || px % 4 !== 0)) throw bad("px 32..1024, a multiple of 4")
136
- return { cols, rows, px, level }
137
+ return { cols, rows, px, level, samples }
137
138
  }
138
139
 
139
140
  /** `--x <regex>` as a case-insensitive RegExp, or undefined. */
@@ -17,6 +17,7 @@
17
17
  import type { Accessor, Document, Primitive } from '@gltf-transform/core'
18
18
  import { weldPrimitive } from '@gltf-transform/functions'
19
19
  import { decodeImage } from '../unity/image'
20
+ import { ByteLru } from '../textures/byteLru'
20
21
 
21
22
  export interface CardTrimOptions {
22
23
  /** Trim alpha-MASK quads to their opaque texels (see the header). */
@@ -82,6 +83,7 @@ class OpaqueTable {
82
83
  }
83
84
  this.sat = sat
84
85
  }
86
+ get bytes(): number { return this.sat.byteLength }
85
87
  /** Opaque texels in [x0, x1) × [y0, y1) (clamped). */
86
88
  count(x0: number, y0: number, x1: number, y1: number): number {
87
89
  x0 = Math.max(0, Math.min(this.w, x0)); x1 = Math.max(0, Math.min(this.w, x1))
@@ -166,10 +168,21 @@ function findQuads(indices: Uint32Array, uv: Float32Array): Quad[] {
166
168
  return quads
167
169
  }
168
170
 
171
+ /**
172
+ * THE OPAQUE TABLES OUTLIVE THE CALL, keyed by the image OBJECT and the cutoff: a level import runs this pass per
173
+ * kind, and its foliage kinds share a handful of atlases by the same bytes (unity/materialWriter.ts
174
+ * `SharedTextures`) — each kind decoded every MASK base colour again (a 2048² PNG: ~50 ms of inflate + 16 MB).
175
+ * Bounded by the tables' own size (a 2048² table is 16 MB; 512 MB ≈ 30 atlases). The id is per object and never
176
+ * reused, so a table whose image is gone just ages out.
177
+ */
178
+ const tableCache = new ByteLru<OpaqueTable | null>(512 * 1024 * 1024, (t) => t?.bytes ?? 0)
179
+ const imageIds = new WeakMap<Uint8Array, number>()
180
+ let nextImageId = 0
181
+ const imageId = (b: Uint8Array): number => { let id = imageIds.get(b); if (id === undefined) imageIds.set(b, id = ++nextImageId); return id }
182
+
169
183
  /** Apply the pass to every planned primitive. */
170
184
  export async function applyCardTrim(doc: Document, plan: CardTrimPlan): Promise<CardTrimResult> {
171
185
  const res: CardTrimResult = { primitives: 0, quads: 0, trimmed: 0, dropped: 0, areaBefore: 0, areaAfter: 0, skipped: [] }
172
- const tables = new Map<string, Promise<OpaqueTable | null>>()
173
186
  for (const p of plan.prims) {
174
187
  const mat = p.prim.getMaterial()!
175
188
  if (p.texture !== 'ok') {
@@ -178,18 +191,11 @@ export async function applyCardTrim(doc: Document, plan: CardTrimPlan): Promise<
178
191
  }
179
192
  const tex = mat.getBaseColorTexture()!
180
193
  const cutoff = Math.max(0, Math.min(1, mat.getAlphaCutoff())) * 255 / Math.max(1e-3, mat.getBaseColorFactor()[3])
181
- const key = `${tex.getName()}#${tex.getImage()?.byteLength ?? 0}#${cutoff.toFixed(2)}`
182
- let table = tables.get(key)
183
- if (!table) {
184
- table = (async () => {
185
- const bytes = tex.getImage()
186
- if (!bytes) return null
187
- const img = await decodeImage(bytes, tex.getURI() || tex.getMimeType() || 'texture.png')
188
- return img ? new OpaqueTable(img.data, img.width, img.height, cutoff) : null
189
- })()
190
- tables.set(key, table)
191
- }
192
- const opaque = await table
194
+ const bytes = tex.getImage()
195
+ const opaque = bytes ? await tableCache.get(`${imageId(bytes)}#${cutoff}`, async () => {
196
+ const img = await decodeImage(bytes, tex.getURI() || tex.getMimeType() || 'texture.png')
197
+ return img ? new OpaqueTable(img.data, img.width, img.height, cutoff) : null
198
+ }) : null
193
199
  if (!opaque) { res.skipped.push({ name: `${p.name}/${p.material}`, reason: 'base colour texture could not be decoded' }); continue }
194
200
  const r = trimPrimitive(p.prim, opaque, plan.margin)
195
201
  if (!r) continue
@@ -0,0 +1,132 @@
1
+ // COVERAGE-PRESERVING MIPS for an alpha-MASK texture (Castaño, "Computing alpha mipmaps"; Unity's "Mip Maps Preserve
2
+ // Coverage"). A leaf card's alpha is hard-edged: 13 % of a birch atlas' texels stand over the material's cutoff, and a
3
+ // plain box mip averages that edge into a grey that falls UNDER the cutoff — four levels down (a 1 m card at 20 m) the
4
+ // alpha test passes almost nothing and the crown goes bare. Here every level is box-filtered and then its alpha is
5
+ // scaled by ONE scalar, chosen so that the fraction of texels at or over the cutoff equals the fraction the full-size
6
+ // source has. A `--max-size` downscale is a level like any other: the target is measured once, on the source.
7
+ //
8
+ // colour area-weighted box, averaged in LINEAR light (the sRGB transfer undone and redone), weighted by alpha so a
9
+ // transparent texel's colour (often black) does not bleed into the leaf's; a footprint with no alpha at all
10
+ // keeps the plain average
11
+ // alpha the area-weighted box average, then × s (clamped to 1), s from a 4096-bin histogram: the alpha τ at which
12
+ // the level's own coverage reaches the target, s = cutoff / τ
13
+ //
14
+ // Pure functions over raw RGBA8 buffers: the doctor writes the levels as PNGs and hands them to the encoder as an
15
+ // explicit mip chain (transcodeKtx2.ts), the tests call them directly.
16
+
17
+ export type Level = { data: Uint8Array, width: number, height: number }
18
+
19
+ const SRGB_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => {
20
+ const c = i / 255
21
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
22
+ })
23
+ const linearToSrgb8 = (x: number): number => {
24
+ const c = x <= 0 ? 0 : x >= 1 ? 1 : x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(x, 1 / 2.4) - 0.055
25
+ return Math.round(c * 255)
26
+ }
27
+
28
+ /** The fraction of texels whose alpha (8-bit, as the GPU reads it) is at or over `cutoff`. */
29
+ export const alphaCoverage = (rgba: Uint8Array, cutoff: number): number => {
30
+ const n = rgba.length >> 2
31
+ if (!n) return 0
32
+ const min8 = Math.ceil(cutoff * 255 - 1e-6)
33
+ let k = 0
34
+ for (let i = 3; i < rgba.length; i += 4) if (rgba[i] >= min8) k++
35
+ return k / n
36
+ }
37
+
38
+ /** Area-weighted box resample of RGBA8 to dw × dh: colour in linear light (sRGB in and out when `srgb`) weighted by
39
+ * alpha, alpha the plain area average. Returns floats: rgb linear 0..1, alpha 0..1. */
40
+ const boxResample = (src: Uint8Array, sw: number, sh: number, dw: number, dh: number, srgb: boolean): Float32Array => {
41
+ const out = new Float32Array(dw * dh * 4)
42
+ const lin = srgb ? SRGB_TO_LINEAR : Float32Array.from({ length: 256 }, (_, i) => i / 255)
43
+ const fx = sw / dw, fy = sh / dh
44
+ for (let y = 0; y < dh; y++) {
45
+ const y0 = y * fy, y1 = y0 + fy
46
+ for (let x = 0; x < dw; x++) {
47
+ const x0 = x * fx, x1 = x0 + fx
48
+ let wa = 0, ra = 0, ga = 0, ba = 0, w = 0, r = 0, g = 0, b = 0
49
+ for (let sy = Math.floor(y0); sy < Math.min(Math.ceil(y1), sh); sy++) {
50
+ const wy = Math.min(sy + 1, y1) - Math.max(sy, y0)
51
+ if (wy <= 0) continue
52
+ for (let sx = Math.floor(x0); sx < Math.min(Math.ceil(x1), sw); sx++) {
53
+ const wx = Math.min(sx + 1, x1) - Math.max(sx, x0)
54
+ if (wx <= 0) continue
55
+ const k = wx * wy, o = (sy * sw + sx) * 4
56
+ const a = src[o + 3] / 255
57
+ const lr = lin[src[o]], lg = lin[src[o + 1]], lb = lin[src[o + 2]]
58
+ w += k; r += lr * k; g += lg * k; b += lb * k
59
+ wa += a * k; ra += lr * a * k; ga += lg * a * k; ba += lb * a * k
60
+ }
61
+ }
62
+ const o = (y * dw + x) * 4
63
+ if (wa > 1e-6) { out[o] = ra / wa; out[o + 1] = ga / wa; out[o + 2] = ba / wa }
64
+ else if (w > 0) { out[o] = r / w; out[o + 1] = g / w; out[o + 2] = b / w }
65
+ out[o + 3] = w > 0 ? wa / w : 0
66
+ }
67
+ }
68
+ return out
69
+ }
70
+
71
+ /** The scale s that brings `alpha`'s coverage at `cutoff` to `target` (the fraction of texels at or over it). */
72
+ const coverageScale = (lvl: Float32Array, cutoff: number, target: number): number => {
73
+ const n = lvl.length >> 2
74
+ const want = Math.round(target * n)
75
+ if (want <= 0 || cutoff <= 0) return 1
76
+ const BINS = 4096
77
+ const hist = new Uint32Array(BINS + 1)
78
+ for (let i = 3; i < lvl.length; i += 4) hist[Math.min(BINS, Math.floor(lvl[i] * BINS))]++
79
+ // walk down from the top: τ = the alpha at which `want` texels are at or over it
80
+ let k = 0, bin = BINS
81
+ for (; bin > 0; bin--) { k += hist[bin]; if (k >= want) break }
82
+ const tau = bin / BINS
83
+ if (tau <= 1e-4) return 1 // the level has (almost) no alpha left to scale
84
+ return cutoff / tau
85
+ }
86
+
87
+ /** Floats (linear rgb, alpha) → RGBA8 with the alpha scaled by `s` (and clamped). */
88
+ const toBytes = (lvl: Float32Array, s: number, srgb: boolean): Uint8Array => {
89
+ const out = new Uint8Array(lvl.length)
90
+ for (let o = 0; o < lvl.length; o += 4) {
91
+ for (let c = 0; c < 3; c++) out[o + c] = srgb ? linearToSrgb8(lvl[o + c]) : Math.round(Math.min(Math.max(lvl[o + c], 0), 1) * 255)
92
+ out[o + 3] = Math.round(Math.min(lvl[o + 3] * s, 1) * 255)
93
+ }
94
+ return out
95
+ }
96
+
97
+ export type MipChainOptions = {
98
+ /** The alpha test's cutoff (the lowest of the MASK materials sharing the texture). */
99
+ cutoff: number
100
+ /** Level 0's size (the `--max-size` cap, ×4 snapped by the caller); the source's when equal. */
101
+ width: number
102
+ height: number
103
+ /** The colour is sRGB-encoded (base colour): averaged in linear light. */
104
+ srgb: boolean
105
+ /** false = the plain box chain (no alpha scaling) — the tests' contrast. Default true. */
106
+ preserve?: boolean
107
+ }
108
+
109
+ /**
110
+ * The full mip chain, level 0 (`width` × `height`) down to 1 × 1, each level max(1, ⌊w / 2^i⌋) × max(1, ⌊h / 2^i⌋) (the
111
+ * encoder's own level sizes). Every level is box-filtered from the one above (level 0 from the source) and, with
112
+ * `preserve`, its alpha scaled to the SOURCE's coverage at the cutoff. A level 0 at the source's size is the source.
113
+ */
114
+ export const coverageMipChain = (src: Uint8Array, sw: number, sh: number, o: MipChainOptions): { levels: Level[], target: number } => {
115
+ const preserve = o.preserve !== false
116
+ const target = alphaCoverage(src, o.cutoff)
117
+ const levels: Level[] = []
118
+ let w = o.width, h = o.height
119
+ let cur: Level = { data: src, width: sw, height: sh }
120
+ for (let i = 0; ; i++) {
121
+ if (i === 0 && w === sw && h === sh) levels.push(cur)
122
+ else {
123
+ const f = boxResample(cur.data, cur.width, cur.height, w, h, o.srgb)
124
+ const s = preserve ? coverageScale(f, o.cutoff, target) : 1
125
+ cur = { data: toBytes(f, s, o.srgb), width: w, height: h }
126
+ levels.push(cur)
127
+ }
128
+ if (w === 1 && h === 1) break
129
+ w = Math.max(1, w >> 1); h = Math.max(1, h >> 1)
130
+ }
131
+ return { levels, target }
132
+ }
@@ -12,6 +12,7 @@ import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type
12
12
  import { planKtx2, applyKtx2, findKtxEncoder, KTX_ENCODER_MISSING, type Ktx2Plan, type KtxEncoder } from './transcodeKtx2'
13
13
  import { measureScale, planScale, applyScale, type ScaleInfo, type ScalePlan, type ScaleOptions } from './normalizeScale'
14
14
  import { analyzeShading, applyShading, type ShadingPlan, type ShadingOptions } from './shading'
15
+ import { imageInfo } from './imageFacts'
15
16
  import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
16
17
  import { planLod, applyLod, type LodPlan, type LodOptions, type LodResult } from './lod'
17
18
  import { planMerge, applyMerge, describeScale, type MergePlan, type MergeOptions, type MergeResult } from './merge'
@@ -39,6 +40,9 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
39
40
  // KTX2 mode: everything → ETC1S (max compression). --ktx2-hq upgrades NORMAL maps to UASTC
40
41
  // (much higher quality, same VRAM, larger file); colour/metalRough stay ETC1S either way.
41
42
  const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
43
+ // sharp's look at every image, all at once (its own threads; cached per image object, imageFacts.ts) — one after
44
+ // another was 0.1–0.25 s a map: 3 s of the 30 maps of Chernobyl's Department_Store
45
+ await Promise.all(root.listTextures().map((t) => { const img = t.getImage(); return img && !(t.getMimeType() || '').includes('ktx') ? imageInfo(img) : null }))
42
46
  for (const tex of root.listTextures()) {
43
47
  if (opts.ktx2) {
44
48
  const p = await planKtx2(tex, root, maxSize, codecPolicy); if (p) ktx2Plans.push(p)
@@ -255,7 +259,10 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
255
259
  // texture set went through one core however many were idle.
256
260
  await Promise.all(plan.ktx2Plans.map(async (t) => {
257
261
  await applyKtx2(t, enc, maxSize)
258
- opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
262
+ // counted OUTSIDE the optional call: `progress?.(++n)` skips its arguments without a callback, so the
263
+ // result said 0 encoded for every caller that passed none
264
+ ktx2Encoded++
265
+ opts.progress?.(ktx2Encoded, plan.ktx2Plans.length)
259
266
  }))
260
267
  // KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
261
268
  // gltf-transform only emits that form when the extension is attached to the document.
@@ -0,0 +1,44 @@
1
+ // What sharp says about an image, once per image OBJECT: its size, whether its alpha is used, and its sha1 (the
2
+ // key of transcodeKtx2.ts's encode cache).
3
+ //
4
+ // A level import hands every kind the same `Uint8Array` of a shared map (unity/materialWriter.ts
5
+ // `SharedTextures`), and `metadata()` + `stats()` of each was the whole of the `analyze` stage — 12 s of a
6
+ // 14-kind Chernobyl sample, 0.1–0.25 s a 2048–4096² map, paid again in every kind that used it (2026-11-04).
7
+ // Weak: an entry dies with its bytes.
8
+ import { createHash } from 'node:crypto'
9
+ import sharp from 'sharp'
10
+ import type { Rgba } from '../textures/resolveImage'
11
+
12
+ type ImageFacts = { width: number; height: number; hasAlpha: boolean; sha1?: string }
13
+ const facts = new WeakMap<Uint8Array, Promise<ImageFacts | null>>()
14
+ export function imageInfo(img: Uint8Array): Promise<ImageFacts | null> {
15
+ let f = facts.get(img)
16
+ if (!f) {
17
+ f = (async () => {
18
+ try {
19
+ const b = Buffer.from(img.buffer, img.byteOffset, img.byteLength)
20
+ const m = await sharp(b).metadata()
21
+ return { width: m.width || 0, height: m.height || 0, hasAlpha: !(await sharp(b).stats()).isOpaque }
22
+ } catch { return null }
23
+ })()
24
+ facts.set(img, f)
25
+ }
26
+ return f
27
+ }
28
+ /** The image's sha1, hashed once per image object. */
29
+ export const imageSha1 = async (img: Uint8Array): Promise<string> => {
30
+ const f = await imageInfo(img)
31
+ if (f) return (f.sha1 ??= createHash('sha1').update(img).digest('hex'))
32
+ return createHash('sha1').update(img).digest('hex')
33
+ }
34
+
35
+ /** The facts of a PNG the caller has just encoded from `rgba` itself — sharp is not asked. Sharp's `isOpaque` is
36
+ * "the minimum alpha is the maximum", which is exactly "no texel below 255" here. Not past sharp's own input
37
+ * limit (16383² pixels): sharp refuses such an image, and the passes must go on seeing that refusal. */
38
+ export function knownImageInfo(png: Uint8Array, rgba: Rgba): void {
39
+ if (facts.has(png) || rgba.width * rgba.height > 0x3fff * 0x3fff) return
40
+ const d = rgba.data
41
+ let hasAlpha = false
42
+ for (let i = 3; i < d.length; i += 4) if (d[i] !== 255) { hasAlpha = true; break }
43
+ facts.set(png, Promise.resolve({ width: rgba.width, height: rgba.height, hasAlpha }))
44
+ }