@aethervtt/shard-texture 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/array.d.ts +21 -0
- package/dist/array.js +111 -0
- package/dist/basis.d.ts +16 -0
- package/dist/basis.js +122 -0
- package/dist/decode/hdr.d.ts +3 -0
- package/dist/decode/hdr.js +68 -0
- package/dist/decode/index.d.ts +11 -0
- package/dist/decode/index.js +40 -0
- package/dist/decode/jpeg.d.ts +3 -0
- package/dist/decode/jpeg.js +21 -0
- package/dist/decode/png.d.ts +10 -0
- package/dist/decode/png.js +193 -0
- package/dist/decode/webp.d.ts +2 -0
- package/dist/decode/webp.js +39 -0
- package/dist/image.d.ts +7 -0
- package/dist/image.js +0 -0
- package/dist/importer.d.ts +34 -0
- package/dist/importer.js +168 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +62 -0
- package/dist/ktx2.d.ts +32 -0
- package/dist/ktx2.js +124 -0
- package/dist/mips.d.ts +22 -0
- package/dist/mips.js +125 -0
- package/dist/pack.d.ts +40 -0
- package/dist/pack.js +147 -0
- package/dist/texture.d.ts +97 -0
- package/dist/texture.js +218 -0
- package/package.json +39 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/basis_encoder.js +2 -0
- package/vendor/basis/basis_encoder.wasm +0 -0
- package/vendor/basis/basis_transcoder.js +2 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
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
package/dist/array.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Image } from './image.js';
|
|
2
|
+
import { type TextureUsage } from './mips.js';
|
|
3
|
+
/** What a `*.texarray.json` file holds. */
|
|
4
|
+
export interface TextureArrayFile {
|
|
5
|
+
/** Image files (relative to the file), one per layer, in layer order. */
|
|
6
|
+
layers: string[];
|
|
7
|
+
/** Side of every layer in pixels (default: the first image's width, as a power of two). */
|
|
8
|
+
size?: number;
|
|
9
|
+
/** color (sRGB albedo), data (linear masks such as ORM), or normal. Default: from the file name. */
|
|
10
|
+
usage?: TextureUsage;
|
|
11
|
+
/** Normal maps: opengl (+Y up, the default) or directx (green flipped on import). */
|
|
12
|
+
normalMap?: 'opengl' | 'directx';
|
|
13
|
+
}
|
|
14
|
+
/** Bilinear resample to `width` × `height` (RGBA). */
|
|
15
|
+
export declare function resizeImage(image: Image, width: number, height: number): Image;
|
|
16
|
+
/**
|
|
17
|
+
* `*.texarray.json`: a 2D texture array from a list of images, all resized to one size, each with
|
|
18
|
+
* its own mip chain, stored as one uncompressed KTX2 (spec 0043). Materials bind it as
|
|
19
|
+
* `texture_2d_array`; terrain biomes index its layers. Editing any listed image re-imports it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const TextureArrayImporter: import("@aethervtt/shard-assets").ImporterDef;
|
package/dist/array.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { defineImporter } from "@aethervtt/shard-assets";
|
|
2
|
+
import { defineSchema, ShardError } from "@aethervtt/shard-core";
|
|
3
|
+
import { decodeImage } from "./decode/index.js";
|
|
4
|
+
import { flipGreen, usageFromName } from "./importer.js";
|
|
5
|
+
import { writeKtx2 } from "./ktx2.js";
|
|
6
|
+
import { buildMips } from "./mips.js";
|
|
7
|
+
function resizeImage(image, width, height) {
|
|
8
|
+
if (image.width === width && image.height === height) return image;
|
|
9
|
+
const src = image.data;
|
|
10
|
+
const out = image.kind === "u8" ? new Uint8Array(width * height * 4) : new Float32Array(width * height * 4);
|
|
11
|
+
for (let y = 0; y < height; y++) {
|
|
12
|
+
const fy = Math.min(image.height - 1, Math.max(0, (y + 0.5) * image.height / height - 0.5));
|
|
13
|
+
const y0 = Math.floor(fy);
|
|
14
|
+
const y1 = Math.min(image.height - 1, y0 + 1);
|
|
15
|
+
const ty = fy - y0;
|
|
16
|
+
for (let x = 0; x < width; x++) {
|
|
17
|
+
const fx = Math.min(image.width - 1, Math.max(0, (x + 0.5) * image.width / width - 0.5));
|
|
18
|
+
const x0 = Math.floor(fx);
|
|
19
|
+
const x1 = Math.min(image.width - 1, x0 + 1);
|
|
20
|
+
const tx = fx - x0;
|
|
21
|
+
for (let c = 0; c < 4; c++) {
|
|
22
|
+
const a = src[(y0 * image.width + x0) * 4 + c];
|
|
23
|
+
const b = src[(y0 * image.width + x1) * 4 + c];
|
|
24
|
+
const d = src[(y1 * image.width + x0) * 4 + c];
|
|
25
|
+
const e = src[(y1 * image.width + x1) * 4 + c];
|
|
26
|
+
const v = (a * (1 - tx) + b * tx) * (1 - ty) + (d * (1 - tx) + e * tx) * ty;
|
|
27
|
+
out[(y * width + x) * 4 + c] = image.kind === "u8" ? Math.round(v) : v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { width, height, kind: image.kind, data: out };
|
|
32
|
+
}
|
|
33
|
+
const NoSettings = defineSchema(
|
|
34
|
+
"texture/TextureArraySettings",
|
|
35
|
+
{},
|
|
36
|
+
{ description: "None: the file lists the layers." }
|
|
37
|
+
);
|
|
38
|
+
function fail(path, message, hint) {
|
|
39
|
+
throw new ShardError("texture/invalid-array", `${path}: ${message}`, { path, hint });
|
|
40
|
+
}
|
|
41
|
+
const TextureArrayImporter = defineImporter({
|
|
42
|
+
name: "texture-array",
|
|
43
|
+
version: 1,
|
|
44
|
+
extensions: [".texarray.json"],
|
|
45
|
+
settings: NoSettings,
|
|
46
|
+
async import(source, ctx) {
|
|
47
|
+
let file;
|
|
48
|
+
try {
|
|
49
|
+
file = JSON.parse(source.text());
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new ShardError("assets/import-failed", `${source.path} isn't valid JSON`, {
|
|
52
|
+
path: source.path,
|
|
53
|
+
cause
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const hint = 'A texture array file is { "layers": ["grass.png", "rock.png"], "size": 512, "usage": "color" }.';
|
|
57
|
+
if (!Array.isArray(file.layers) || file.layers.length === 0)
|
|
58
|
+
fail(source.path, '"layers" must list at least one image', hint);
|
|
59
|
+
if (file.layers.length > 256) fail(source.path, "at most 256 layers", hint);
|
|
60
|
+
const usage = file.usage ?? usageFromName(source.path.replace(".texarray.json", ""));
|
|
61
|
+
if (usage === "hdr") fail(source.path, "HDR arrays come later", "Use color, data, or normal.");
|
|
62
|
+
const images = [];
|
|
63
|
+
for (const layer of file.layers) {
|
|
64
|
+
let image = await decodeImage(await ctx.read(layer));
|
|
65
|
+
if (usage === "normal" && file.normalMap === "directx") image = flipGreen(image);
|
|
66
|
+
images.push(image);
|
|
67
|
+
}
|
|
68
|
+
const first = images[0];
|
|
69
|
+
let size = file.size ?? 2 ** Math.round(Math.log2(Math.max(1, first.width)));
|
|
70
|
+
size = Math.max(1, Math.min(4096, Math.floor(size)));
|
|
71
|
+
const chains = images.map((image, i) => {
|
|
72
|
+
if (image.width !== size || image.height !== size) {
|
|
73
|
+
ctx.warn(
|
|
74
|
+
`Layer ${i} (${file.layers[i]}) is ${image.width}\xD7${image.height}; resized to ${size}\xD7${size}.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return buildMips(resizeImage(image, size, size), {
|
|
78
|
+
usage,
|
|
79
|
+
mipmaps: true,
|
|
80
|
+
maxSize: 16384,
|
|
81
|
+
flipY: false,
|
|
82
|
+
premultiplyAlpha: false
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
const levels = chains[0].levels.map((_, level) => {
|
|
86
|
+
const parts = chains.map((c) => c.levels[level]);
|
|
87
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
|
|
88
|
+
let o = 0;
|
|
89
|
+
for (const p of parts) {
|
|
90
|
+
out.set(p, o);
|
|
91
|
+
o += p.byteLength;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
});
|
|
95
|
+
const bytes = writeKtx2({ width: size, height: size, levels }, usage, 1, false, images.length);
|
|
96
|
+
return {
|
|
97
|
+
assets: [
|
|
98
|
+
{
|
|
99
|
+
label: "",
|
|
100
|
+
type: "Texture",
|
|
101
|
+
bytes,
|
|
102
|
+
info: { width: size, height: size, layers: images.length, usage, mips: levels.length }
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
export {
|
|
109
|
+
TextureArrayImporter,
|
|
110
|
+
resizeImage
|
|
111
|
+
};
|
package/dist/basis.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TextureUsage } from './mips.js';
|
|
2
|
+
/**
|
|
3
|
+
* Encodes RGBA8 into a Basis Universal KTX2 with a full mip chain. Dimensions must be multiples of
|
|
4
|
+
* 4 (the importer pads first). `mode`: 'uastc' (high quality) or 'etc1s' (small).
|
|
5
|
+
*/
|
|
6
|
+
export declare function encodeBasis(rgba: Uint8Array, width: number, height: number, usage: TextureUsage, mode: 'uastc' | 'etc1s', mipmaps: boolean): Promise<Uint8Array>;
|
|
7
|
+
/** GPU formats a Basis texture can become, by device feature, best first. */
|
|
8
|
+
export type TranscodeTarget = 'bc7' | 'bc5' | 'astc' | 'etc2' | 'eac-rg11' | 'rgba8';
|
|
9
|
+
export interface TranscodedTexture {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
format: TranscodeTarget;
|
|
13
|
+
levels: Uint8Array[];
|
|
14
|
+
}
|
|
15
|
+
/** Transcodes every level of a Basis KTX2 to `target`. */
|
|
16
|
+
export declare function transcodeBasis(bytes: Uint8Array, target: TranscodeTarget): Promise<TranscodedTexture>;
|
package/dist/basis.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const modules = /* @__PURE__ */ new Map();
|
|
3
|
+
async function readVendor(file) {
|
|
4
|
+
const url = new URL(`../vendor/basis/${file}`, import.meta.url);
|
|
5
|
+
if (url.protocol === "file:") {
|
|
6
|
+
const { readFile } = await import("node:fs/promises");
|
|
7
|
+
const data = await readFile(url);
|
|
8
|
+
return file.endsWith(".js") ? { text: data.toString("utf8") } : { bytes: new Uint8Array(data) };
|
|
9
|
+
}
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (!res.ok) throw new ShardError("texture/transcoder-unavailable", `Couldn't fetch ${url}`);
|
|
12
|
+
return file.endsWith(".js") ? { text: await res.text() } : { bytes: new Uint8Array(await res.arrayBuffer()) };
|
|
13
|
+
}
|
|
14
|
+
function load(name) {
|
|
15
|
+
let pending = modules.get(name);
|
|
16
|
+
if (!pending) {
|
|
17
|
+
pending = (async () => {
|
|
18
|
+
try {
|
|
19
|
+
const [{ text }, { bytes }] = await Promise.all([
|
|
20
|
+
readVendor(`${name}.js`),
|
|
21
|
+
readVendor(`${name}.wasm`)
|
|
22
|
+
]);
|
|
23
|
+
let require2;
|
|
24
|
+
let dirname = "";
|
|
25
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
26
|
+
const { createRequire } = await import("node:module");
|
|
27
|
+
const { fileURLToPath } = await import("node:url");
|
|
28
|
+
require2 = createRequire(import.meta.url);
|
|
29
|
+
dirname = fileURLToPath(new URL("../vendor/basis/", import.meta.url));
|
|
30
|
+
}
|
|
31
|
+
const factory = new Function(
|
|
32
|
+
"module",
|
|
33
|
+
"exports",
|
|
34
|
+
"require",
|
|
35
|
+
"__dirname",
|
|
36
|
+
"__filename",
|
|
37
|
+
`${text}
|
|
38
|
+
return BASIS`
|
|
39
|
+
)(void 0, void 0, require2, dirname, `${dirname}${name}.js`);
|
|
40
|
+
const quiet = () => {
|
|
41
|
+
};
|
|
42
|
+
const mod = await factory({ wasmBinary: bytes, print: quiet, printErr: quiet });
|
|
43
|
+
mod.initializeBasis();
|
|
44
|
+
return mod;
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
throw new ShardError(
|
|
47
|
+
"texture/transcoder-unavailable",
|
|
48
|
+
`Couldn't load ${name}: ${cause.message}`,
|
|
49
|
+
{
|
|
50
|
+
cause,
|
|
51
|
+
hint: "Basis Universal ships in @aethervtt/shard-texture/vendor/basis; check the files are present."
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
modules.set(name, pending);
|
|
57
|
+
}
|
|
58
|
+
return pending;
|
|
59
|
+
}
|
|
60
|
+
async function encodeBasis(rgba, width, height, usage, mode, mipmaps) {
|
|
61
|
+
const basis = await load("basis_encoder");
|
|
62
|
+
const enc = new basis.BasisEncoder();
|
|
63
|
+
try {
|
|
64
|
+
enc.setCreateKTX2File(true);
|
|
65
|
+
const format = mode === "uastc" ? basis.basis_tex_format.cUASTC_LDR_4x4 : basis.basis_tex_format.cETC1S;
|
|
66
|
+
enc.setFormatModeAndQualityEffort(format.value, -1, -1, true);
|
|
67
|
+
enc.setKTX2UASTCSupercompression(mode === "uastc");
|
|
68
|
+
const color = usage === "color";
|
|
69
|
+
enc.setPerceptual(color);
|
|
70
|
+
enc.setKTX2AndBasisSRGBTransferFunc(color);
|
|
71
|
+
enc.setMipGen(mipmaps);
|
|
72
|
+
enc.setMipSRGB(color);
|
|
73
|
+
enc.setMipRenormalize(usage === "normal");
|
|
74
|
+
enc.setCheckForAlpha(true);
|
|
75
|
+
if (!enc.setSliceSourceImage(0, rgba, width, height, 0)) {
|
|
76
|
+
throw new ShardError("texture/decode-failed", "Basis encoder rejected the image");
|
|
77
|
+
}
|
|
78
|
+
const out = new Uint8Array(width * height * 4 * 2 + 1024 * 1024);
|
|
79
|
+
const size = enc.encode(out);
|
|
80
|
+
if (!size) throw new ShardError("texture/decode-failed", "Basis encoding failed");
|
|
81
|
+
return out.slice(0, size);
|
|
82
|
+
} finally {
|
|
83
|
+
enc.delete();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const TARGET_FORMATS = {
|
|
87
|
+
bc7: "cTFBC7_RGBA",
|
|
88
|
+
bc5: "cTFBC5_RG",
|
|
89
|
+
astc: "cTFASTC_4x4_RGBA",
|
|
90
|
+
etc2: "cTFETC2_RGBA",
|
|
91
|
+
"eac-rg11": "cTFETC2_EAC_RG11",
|
|
92
|
+
rgba8: "cTFRGBA32"
|
|
93
|
+
};
|
|
94
|
+
async function transcodeBasis(bytes, target) {
|
|
95
|
+
const basis = await load("basis_transcoder");
|
|
96
|
+
const file = new basis.KTX2File(bytes);
|
|
97
|
+
try {
|
|
98
|
+
if (!file.isValid() || !file.startTranscoding()) {
|
|
99
|
+
throw new ShardError("texture/decode-failed", "Invalid Basis KTX2 payload");
|
|
100
|
+
}
|
|
101
|
+
const format = basis.transcoder_texture_format[TARGET_FORMATS[target]].value;
|
|
102
|
+
const levels = [];
|
|
103
|
+
for (let level = 0; level < file.getLevels(); level++) {
|
|
104
|
+
const dst = new Uint8Array(file.getImageTranscodedSizeInBytes(level, 0, 0, format));
|
|
105
|
+
if (!file.transcodeImage(dst, level, 0, 0, format, 0, -1, -1)) {
|
|
106
|
+
throw new ShardError(
|
|
107
|
+
"texture/decode-failed",
|
|
108
|
+
`Couldn't transcode level ${level} to ${target}`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
levels.push(dst);
|
|
112
|
+
}
|
|
113
|
+
return { width: file.getWidth(), height: file.getHeight(), format: target, levels };
|
|
114
|
+
} finally {
|
|
115
|
+
file.close();
|
|
116
|
+
file.delete();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
encodeBasis,
|
|
121
|
+
transcodeBasis
|
|
122
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
function decodeHdr(bytes) {
|
|
3
|
+
const text = new TextDecoder("latin1");
|
|
4
|
+
let pos = 0;
|
|
5
|
+
const line = () => {
|
|
6
|
+
const start = pos;
|
|
7
|
+
while (pos < bytes.length && bytes[pos] !== 10) pos++;
|
|
8
|
+
return text.decode(bytes.subarray(start, pos++));
|
|
9
|
+
};
|
|
10
|
+
const magic = line();
|
|
11
|
+
if (!magic.startsWith("#?"))
|
|
12
|
+
throw new ShardError("texture/decode-failed", "Not a Radiance .hdr file");
|
|
13
|
+
for (let l = line(); l !== ""; l = line()) {
|
|
14
|
+
if (l.startsWith("FORMAT=") && l !== "FORMAT=32-bit_rle_rgbe") {
|
|
15
|
+
throw new ShardError(
|
|
16
|
+
"texture/unsupported-format",
|
|
17
|
+
`.hdr ${l} isn't supported (need 32-bit_rle_rgbe)`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (pos >= bytes.length) throw new ShardError("texture/decode-failed", ".hdr header never ends");
|
|
21
|
+
}
|
|
22
|
+
const size = /^-Y (\d+) \+X (\d+)$/.exec(line());
|
|
23
|
+
if (!size)
|
|
24
|
+
throw new ShardError(
|
|
25
|
+
"texture/unsupported-format",
|
|
26
|
+
'Only "-Y h +X w" .hdr orientation is supported'
|
|
27
|
+
);
|
|
28
|
+
const height = Number(size[1]);
|
|
29
|
+
const width = Number(size[2]);
|
|
30
|
+
const rgbe = new Uint8Array(width * height * 4);
|
|
31
|
+
const scan = new Uint8Array(width * 4);
|
|
32
|
+
for (let y = 0; y < height; y++) {
|
|
33
|
+
const rle = width >= 8 && width < 32768 && bytes[pos] === 2 && bytes[pos + 1] === 2 && !(bytes[pos + 2] & 128);
|
|
34
|
+
if (!rle) {
|
|
35
|
+
rgbe.set(bytes.subarray(pos, pos + width * 4), y * width * 4);
|
|
36
|
+
pos += width * 4;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
pos += 4;
|
|
40
|
+
for (let c = 0; c < 4; c++) {
|
|
41
|
+
let x = 0;
|
|
42
|
+
while (x < width) {
|
|
43
|
+
let count = bytes[pos++];
|
|
44
|
+
if (count > 128) {
|
|
45
|
+
count -= 128;
|
|
46
|
+
const v = bytes[pos++];
|
|
47
|
+
for (let k = 0; k < count; k++) scan[x++ * 4 + c] = v;
|
|
48
|
+
} else {
|
|
49
|
+
for (let k = 0; k < count; k++) scan[x++ * 4 + c] = bytes[pos++];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
rgbe.set(scan, y * width * 4);
|
|
54
|
+
}
|
|
55
|
+
const out = new Float32Array(width * height * 4);
|
|
56
|
+
for (let i = 0; i < width * height; i++) {
|
|
57
|
+
const e = rgbe[i * 4 + 3];
|
|
58
|
+
const f = e === 0 ? 0 : 2 ** (e - 136);
|
|
59
|
+
out[i * 4] = rgbe[i * 4] * f;
|
|
60
|
+
out[i * 4 + 1] = rgbe[i * 4 + 1] * f;
|
|
61
|
+
out[i * 4 + 2] = rgbe[i * 4 + 2] * f;
|
|
62
|
+
out[i * 4 + 3] = 1;
|
|
63
|
+
}
|
|
64
|
+
return { width, height, kind: "f32", data: out };
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
decodeHdr
|
|
68
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Image } from '../image.js';
|
|
2
|
+
import { decodeHdr } from './hdr.js';
|
|
3
|
+
import { decodeJpeg } from './jpeg.js';
|
|
4
|
+
import { decodePngImage } from './png.js';
|
|
5
|
+
import { decodeWebp } from './webp.js';
|
|
6
|
+
export type ImageFormat = 'png' | 'jpeg' | 'webp' | 'hdr' | 'ktx2';
|
|
7
|
+
/** The file format, from its first bytes. */
|
|
8
|
+
export declare function sniffImage(bytes: Uint8Array): ImageFormat | undefined;
|
|
9
|
+
/** Decodes PNG, JPEG, WebP, or Radiance HDR with the engine's own decoders (never the browser's). */
|
|
10
|
+
export declare function decodeImage(bytes: Uint8Array): Promise<Image>;
|
|
11
|
+
export { decodeHdr, decodeJpeg, decodePngImage, decodeWebp };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { decodeHdr } from "./hdr.js";
|
|
3
|
+
import { decodeJpeg } from "./jpeg.js";
|
|
4
|
+
import { decodePngImage } from "./png.js";
|
|
5
|
+
import { decodeWebp } from "./webp.js";
|
|
6
|
+
function sniffImage(bytes) {
|
|
7
|
+
const b = bytes;
|
|
8
|
+
if (b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "png";
|
|
9
|
+
if (b[0] === 255 && b[1] === 216) return "jpeg";
|
|
10
|
+
if (b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69) {
|
|
11
|
+
return "webp";
|
|
12
|
+
}
|
|
13
|
+
if (b[0] === 35 && b[1] === 63) return "hdr";
|
|
14
|
+
if (b[0] === 171 && b[1] === 75 && b[2] === 84 && b[3] === 88) return "ktx2";
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
async function decodeImage(bytes) {
|
|
18
|
+
switch (sniffImage(bytes)) {
|
|
19
|
+
case "png":
|
|
20
|
+
return decodePngImage(bytes);
|
|
21
|
+
case "jpeg":
|
|
22
|
+
return decodeJpeg(bytes);
|
|
23
|
+
case "webp":
|
|
24
|
+
return decodeWebp(bytes);
|
|
25
|
+
case "hdr":
|
|
26
|
+
return decodeHdr(bytes);
|
|
27
|
+
default:
|
|
28
|
+
throw new ShardError("texture/unsupported-format", "Unrecognized image format", {
|
|
29
|
+
hint: "Supported: PNG, JPEG, WebP, Radiance .hdr, and KTX2."
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
decodeHdr,
|
|
35
|
+
decodeImage,
|
|
36
|
+
decodeJpeg,
|
|
37
|
+
decodePngImage,
|
|
38
|
+
decodeWebp,
|
|
39
|
+
sniffImage
|
|
40
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import jpeg from "jpeg-js";
|
|
3
|
+
function decodeJpeg(bytes) {
|
|
4
|
+
try {
|
|
5
|
+
const out = jpeg.decode(bytes, {
|
|
6
|
+
useTArray: true,
|
|
7
|
+
formatAsRGBA: true,
|
|
8
|
+
maxMemoryUsageInMB: 1024
|
|
9
|
+
});
|
|
10
|
+
return { width: out.width, height: out.height, kind: "u8", data: out.data };
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
throw new ShardError(
|
|
13
|
+
"texture/decode-failed",
|
|
14
|
+
`JPEG decode failed: ${cause.message}`,
|
|
15
|
+
{ cause }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
decodeJpeg
|
|
21
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Image } from '../image.js';
|
|
2
|
+
export interface PngInfo {
|
|
3
|
+
/** The file declares sRGB (sRGB chunk or an ICC profile), linear (gAMA 1.0), or says nothing. */
|
|
4
|
+
colorSpace: 'srgb' | 'linear' | 'unspecified';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Decodes any standard PNG: gray, RGB, palette (with tRNS), gray+alpha, RGBA; bit depths 1–16;
|
|
8
|
+
* interlaced or not. Output is RGBA8 (16-bit samples round to 8 bits).
|
|
9
|
+
*/
|
|
10
|
+
export declare function decodePngImage(png: Uint8Array): Promise<Image & PngInfo>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
async function inflate(input) {
|
|
3
|
+
const stream = new Blob([input]).stream().pipeThrough(new DecompressionStream("deflate"));
|
|
4
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
5
|
+
}
|
|
6
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
7
|
+
const ADAM7 = [
|
|
8
|
+
[0, 0, 8, 8],
|
|
9
|
+
[4, 0, 8, 8],
|
|
10
|
+
[0, 4, 4, 8],
|
|
11
|
+
[2, 0, 4, 4],
|
|
12
|
+
[0, 2, 2, 4],
|
|
13
|
+
[1, 0, 2, 2],
|
|
14
|
+
[0, 1, 1, 2]
|
|
15
|
+
];
|
|
16
|
+
function paeth(a, b, c) {
|
|
17
|
+
const p = a + b - c;
|
|
18
|
+
const pa = Math.abs(p - a);
|
|
19
|
+
const pb = Math.abs(p - b);
|
|
20
|
+
const pc = Math.abs(p - c);
|
|
21
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
22
|
+
}
|
|
23
|
+
function unfilter(raw, offset, rowBytes, rows, bpp) {
|
|
24
|
+
const out = new Uint8Array(rowBytes * rows);
|
|
25
|
+
for (let y = 0; y < rows; y++) {
|
|
26
|
+
const filter = raw[offset + y * (rowBytes + 1)];
|
|
27
|
+
const src = offset + y * (rowBytes + 1) + 1;
|
|
28
|
+
const row = y * rowBytes;
|
|
29
|
+
const prev = row - rowBytes;
|
|
30
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
31
|
+
const v = raw[src + x];
|
|
32
|
+
const a = x >= bpp ? out[row + x - bpp] : 0;
|
|
33
|
+
const b = y > 0 ? out[prev + x] : 0;
|
|
34
|
+
const c = x >= bpp && y > 0 ? out[prev + x - bpp] : 0;
|
|
35
|
+
let r;
|
|
36
|
+
switch (filter) {
|
|
37
|
+
case 0:
|
|
38
|
+
r = v;
|
|
39
|
+
break;
|
|
40
|
+
case 1:
|
|
41
|
+
r = v + a;
|
|
42
|
+
break;
|
|
43
|
+
case 2:
|
|
44
|
+
r = v + b;
|
|
45
|
+
break;
|
|
46
|
+
case 3:
|
|
47
|
+
r = v + (a + b >> 1);
|
|
48
|
+
break;
|
|
49
|
+
case 4:
|
|
50
|
+
r = v + paeth(a, b, c);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
throw new ShardError("texture/decode-failed", `PNG row filter ${filter} is invalid`);
|
|
54
|
+
}
|
|
55
|
+
out[row + x] = r & 255;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
async function decodePngImage(png) {
|
|
61
|
+
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
62
|
+
if (png.length < 8 || view.getUint32(0) !== 2303741511) {
|
|
63
|
+
throw new ShardError("texture/decode-failed", "Not a PNG file");
|
|
64
|
+
}
|
|
65
|
+
let offset = 8;
|
|
66
|
+
let width = 0;
|
|
67
|
+
let height = 0;
|
|
68
|
+
let depth = 8;
|
|
69
|
+
let colorType = 6;
|
|
70
|
+
let interlace = 0;
|
|
71
|
+
let palette;
|
|
72
|
+
let trns;
|
|
73
|
+
let colorSpace = "unspecified";
|
|
74
|
+
const idat = [];
|
|
75
|
+
while (offset + 8 <= png.length) {
|
|
76
|
+
const length = view.getUint32(offset);
|
|
77
|
+
const type = String.fromCharCode(
|
|
78
|
+
png[offset + 4],
|
|
79
|
+
png[offset + 5],
|
|
80
|
+
png[offset + 6],
|
|
81
|
+
png[offset + 7]
|
|
82
|
+
);
|
|
83
|
+
const data = png.subarray(offset + 8, offset + 8 + length);
|
|
84
|
+
if (type === "IHDR") {
|
|
85
|
+
const d = new DataView(data.buffer, data.byteOffset);
|
|
86
|
+
width = d.getUint32(0);
|
|
87
|
+
height = d.getUint32(4);
|
|
88
|
+
depth = data[8];
|
|
89
|
+
colorType = data[9];
|
|
90
|
+
interlace = data[12];
|
|
91
|
+
} else if (type === "PLTE") palette = data;
|
|
92
|
+
else if (type === "tRNS") trns = data;
|
|
93
|
+
else if (type === "IDAT") idat.push(data);
|
|
94
|
+
else if (type === "sRGB" || type === "iCCP") colorSpace = "srgb";
|
|
95
|
+
else if (type === "gAMA" && colorSpace === "unspecified") {
|
|
96
|
+
const gamma = new DataView(data.buffer, data.byteOffset).getUint32(0);
|
|
97
|
+
colorSpace = gamma === 1e5 ? "linear" : "srgb";
|
|
98
|
+
} else if (type === "IEND") break;
|
|
99
|
+
offset += 12 + length;
|
|
100
|
+
}
|
|
101
|
+
const channels = CHANNELS[colorType];
|
|
102
|
+
if (!width || !height || !channels) {
|
|
103
|
+
throw new ShardError("texture/decode-failed", "PNG header is missing or invalid");
|
|
104
|
+
}
|
|
105
|
+
const joined = new Uint8Array(idat.reduce((n, p) => n + p.length, 0));
|
|
106
|
+
let o = 0;
|
|
107
|
+
for (const p of idat) {
|
|
108
|
+
joined.set(p, o);
|
|
109
|
+
o += p.length;
|
|
110
|
+
}
|
|
111
|
+
const raw = await inflate(joined);
|
|
112
|
+
const bitsPerPixel = channels * depth;
|
|
113
|
+
const bpp = Math.max(1, bitsPerPixel >> 3);
|
|
114
|
+
const out = new Uint8Array(width * height * 4);
|
|
115
|
+
const maxValue = (1 << depth) - 1;
|
|
116
|
+
const sample = (row, rowStart, x, c) => {
|
|
117
|
+
if (depth === 8) return row[rowStart + x * channels + c];
|
|
118
|
+
if (depth === 16) {
|
|
119
|
+
const i = rowStart + (x * channels + c) * 2;
|
|
120
|
+
return row[i] * 256 + row[i + 1];
|
|
121
|
+
}
|
|
122
|
+
const bit = (x * channels + c) * depth;
|
|
123
|
+
const byte = row[rowStart + (bit >> 3)];
|
|
124
|
+
return byte >> 8 - depth - (bit & 7) & maxValue;
|
|
125
|
+
};
|
|
126
|
+
const to8 = (v) => depth === 8 ? v : depth === 16 ? Math.round(v / 257) : Math.round(v * 255 / maxValue);
|
|
127
|
+
const put = (row, rowStart, x, px, py) => {
|
|
128
|
+
const i = (py * width + px) * 4;
|
|
129
|
+
if (colorType === 3) {
|
|
130
|
+
const index = sample(row, rowStart, x, 0);
|
|
131
|
+
out[i] = palette?.[index * 3] ?? 0;
|
|
132
|
+
out[i + 1] = palette?.[index * 3 + 1] ?? 0;
|
|
133
|
+
out[i + 2] = palette?.[index * 3 + 2] ?? 0;
|
|
134
|
+
out[i + 3] = trns && index < trns.length ? trns[index] : 255;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (colorType === 0 || colorType === 4) {
|
|
138
|
+
const g2 = sample(row, rowStart, x, 0);
|
|
139
|
+
out[i] = out[i + 1] = out[i + 2] = to8(g2);
|
|
140
|
+
if (colorType === 4) out[i + 3] = to8(sample(row, rowStart, x, 1));
|
|
141
|
+
else {
|
|
142
|
+
const key = trns ? new DataView(trns.buffer, trns.byteOffset).getUint16(0) : -1;
|
|
143
|
+
out[i + 3] = g2 === key ? 0 : 255;
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const r = sample(row, rowStart, x, 0);
|
|
148
|
+
const g = sample(row, rowStart, x, 1);
|
|
149
|
+
const b = sample(row, rowStart, x, 2);
|
|
150
|
+
out[i] = to8(r);
|
|
151
|
+
out[i + 1] = to8(g);
|
|
152
|
+
out[i + 2] = to8(b);
|
|
153
|
+
if (colorType === 6) out[i + 3] = to8(sample(row, rowStart, x, 3));
|
|
154
|
+
else {
|
|
155
|
+
const t = trns ? new DataView(trns.buffer, trns.byteOffset) : void 0;
|
|
156
|
+
out[i + 3] = t && r === t.getUint16(0) && g === t.getUint16(2) && b === t.getUint16(4) ? 0 : 255;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
if (interlace === 0) {
|
|
160
|
+
const rowBytes = Math.ceil(width * bitsPerPixel / 8);
|
|
161
|
+
const pixels = unfilter(raw, 0, rowBytes, height, bpp);
|
|
162
|
+
if (depth === 8 && colorType === 6)
|
|
163
|
+
return { width, height, kind: "u8", data: pixels, colorSpace };
|
|
164
|
+
if (depth === 8 && colorType === 2 && !trns) {
|
|
165
|
+
for (let i = 0, o2 = 0; i < pixels.length; i += 3, o2 += 4) {
|
|
166
|
+
out[o2] = pixels[i];
|
|
167
|
+
out[o2 + 1] = pixels[i + 1];
|
|
168
|
+
out[o2 + 2] = pixels[i + 2];
|
|
169
|
+
out[o2 + 3] = 255;
|
|
170
|
+
}
|
|
171
|
+
return { width, height, kind: "u8", data: out, colorSpace };
|
|
172
|
+
}
|
|
173
|
+
for (let y = 0; y < height; y++)
|
|
174
|
+
for (let x = 0; x < width; x++) put(pixels, y * rowBytes, x, x, y);
|
|
175
|
+
} else {
|
|
176
|
+
let at = 0;
|
|
177
|
+
for (const [sx, sy, dx, dy] of ADAM7) {
|
|
178
|
+
const w = Math.ceil((width - sx) / dx);
|
|
179
|
+
const h = Math.ceil((height - sy) / dy);
|
|
180
|
+
if (w <= 0 || h <= 0) continue;
|
|
181
|
+
const rowBytes = Math.ceil(w * bitsPerPixel / 8);
|
|
182
|
+
const pixels = unfilter(raw, at, rowBytes, h, bpp);
|
|
183
|
+
at += (rowBytes + 1) * h;
|
|
184
|
+
for (let y = 0; y < h; y++) {
|
|
185
|
+
for (let x = 0; x < w; x++) put(pixels, y * rowBytes, x, sx + x * dx, sy + y * dy);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { width, height, kind: "u8", data: out, colorSpace };
|
|
190
|
+
}
|
|
191
|
+
export {
|
|
192
|
+
decodePngImage
|
|
193
|
+
};
|