@ringozz/godot 4.7.2-614 → 4.7.2-616

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/src/runtime.ts CHANGED
@@ -1,107 +1,107 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- /** Shared runtime: platform-specific native-module selection lives in
6
- * `boot.ts` (desktop) / `boot.browser.ts` (web, via the `exports` `./boot`
7
- * entry's `browser` condition). `_mod` is the native module — the desktop
8
- * addon's `module.exports` or the wasm module's `.default`.
9
- *
10
- * The boot leaf is imported by **package self-reference** (`@ringozz/godot/boot`),
11
- * not by relative path: a bare specifier resolves through this package's own
12
- * `exports` map (whose `./boot` entry's `browser` condition selects
13
- * `boot.browser.ts` on web) exactly like the entrypoint's
14
- * `import { preloadGodot } from '@ringozz/godot/boot'`, so both sides land on the
15
- * same absolute path and share ONE `boot.browser.ts` module instance. (A relative
16
- * `./boot.ts` import would let a consumer's bundler realpath it into
17
- * `node_modules/.bun/...` while the entrypoint keeps the `node_modules/@ringozz/godot`
18
- * path — two instances, and the preload handoff below would read `null`.)
19
- */
20
- import { getNativeModule } from '@ringozz/godot/boot';
21
- const { default: _mod } = getNativeModule() as any;
22
-
23
- /** Godot instance */
24
- import type { GodotInstance } from '../gen/classes/GodotInstance.ts';
25
- export const getGodot = _mod.getGodot as () => GodotInstance;
26
-
27
- /** Base constructor for all Godot object wrappers. */
28
- export interface GodotVar {
29
- free(): void;
30
- }
31
- export const GodotVar = _mod.GodotVar as (new (...args: unknown[]) => GodotVar) & {
32
- assign<T extends GodotVar>(target: T, props: Record<string, unknown>): T;
33
- };
34
-
35
- /** Unified entry point:
36
- * - _C(this, methodId, ...args) → instance method
37
- * - _C(classId, methodId, ...args) → static method
38
- * - _C(null, methodId, ...args) → singleton lookup (_getInstance) or internal
39
- * - _C(0n, methodId, ...args) → utility function (BigInt tags the call;
40
- * arg types resolve by name at runtime)
41
- */
42
- export const _C = _mod._C as (...args: unknown[]) => unknown;
43
-
44
- /** Resolve a string name to its integer handle. */
45
- export const _S = _mod._S as (name: string) => number;
46
-
47
- /** Register Godot object wrapper. */
48
- export const _R = _mod._R as (typeId: number, ctor: Function) => string;
49
-
50
- /**
51
- * Value-type dispatch on tuple receivers: `_V(typeId, methodId, receiver, ...args)`.
52
- * `methodId == SX_valueCtor` constructs (`_V(typeId, ctorSentinel, ctorIndex, ...args)`),
53
- * negative ids are operators, non-negative ids are builtin methods.
54
- */
55
- export const _V = _mod._V as (...args: unknown[]) => unknown;
56
-
57
- /**
58
- * Build a typed value-type Variant for a Variant-typed slot by resolving the
59
- * property's type from the node; returns a GodotVar carrier the consuming `_C`
60
- * unwraps. Used by react-hooks' useTween (tweenProperty final_val).
61
- */
62
- export const toValueType = _mod.toValueType as (node: GodotVar, propName: string, value: unknown) => unknown;
63
-
64
- /** Stage bytes into the web MEMFS (Godot's `copyToFS`); undefined on desktop. */
65
- export const stageFile = (_mod as any).stageFile as ((path: string, bytes: Uint8Array) => void) | undefined;
66
-
67
- /** Get a Signal value from an Object by signal name, or connect a callback when 3rd arg given. */
68
- export const _G = _mod._G as (target: GodotVar, signalNameId: number, callback?: unknown) => unknown;
69
-
70
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame) */
71
- export const requestAnimationFrame = _mod.requestAnimationFrame as typeof globalThis.requestAnimationFrame ?? globalThis.requestAnimationFrame;
72
-
73
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/cancelAnimationFrame) */
74
- export const cancelAnimationFrame = _mod.cancelAnimationFrame as typeof globalThis.cancelAnimationFrame ?? globalThis.cancelAnimationFrame;
75
-
76
- // One-shot callbacks of in-flight `_P` awaits. The connection's JSCallable holds
77
- // only a weak reference to the callback, so without this the function could be
78
- // GC'd before the signal fires. A module root is the only retention Bun reliably
79
- // traces: neither `_P`'s closure cells nor a Set attached to the signal's object
80
- // wrapper survive a full GC while the await is pending (the await machinery
81
- // discards the promise `_P` returns, and Bun may collect the emitter's wrapper).
82
- // Removed as soon as the signal fires or the connect fails.
83
- const pendingCallbacks = new Set<(...args: any[]) => void>();
84
-
85
- /** Make a Signal PromiseLike: connects a one-shot callback that resolves with the args tuple. */
86
- export function _P(signal: any, onfulfilled?: any, onrejected?: any) {
87
- return new Promise<any>((resolve, reject) => {
88
- if (signal.isNull()) {
89
- reject(new Error('Cannot await a null Signal'));
90
- return;
91
- }
92
- const cb = (...args: any[]) => {
93
- pendingCallbacks.delete(cb);
94
- signal.disconnect(cb);
95
- resolve(args);
96
- };
97
- pendingCallbacks.add(cb);
98
- const res = signal.connect(cb);
99
- if (res !== 0) {
100
- pendingCallbacks.delete(cb);
101
- reject(new Error('Failed to connect signal: error code ' + res));
102
- }
103
- }).then(onfulfilled, onrejected);
104
- }
105
-
106
- /** Cleanup callbacks run once by `runGodot` just before the engine is freed; may be async. */
107
- export const cleanupHooks: Set<() => void | Promise<void>> = new Set();
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ /** Shared runtime: platform-specific native-module selection lives in
6
+ * `boot.ts` (desktop) / `boot.browser.ts` (web, via the `exports` `./boot`
7
+ * entry's `browser` condition). `_mod` is the native module — the desktop
8
+ * addon's `module.exports` or the wasm module's `.default`.
9
+ *
10
+ * The boot leaf is imported by **package self-reference** (`@ringozz/godot/boot`),
11
+ * not by relative path: a bare specifier resolves through this package's own
12
+ * `exports` map (whose `./boot` entry's `browser` condition selects
13
+ * `boot.browser.ts` on web) exactly like the entrypoint's
14
+ * `import { preloadGodot } from '@ringozz/godot/boot'`, so both sides land on the
15
+ * same absolute path and share ONE `boot.browser.ts` module instance. (A relative
16
+ * `./boot.ts` import would let a consumer's bundler realpath it into
17
+ * `node_modules/.bun/...` while the entrypoint keeps the `node_modules/@ringozz/godot`
18
+ * path — two instances, and the preload handoff below would read `null`.)
19
+ */
20
+ import { getNativeModule } from '@ringozz/godot/boot';
21
+ const { default: _mod } = getNativeModule() as any;
22
+
23
+ /** Godot instance */
24
+ import type { GodotInstance } from '../gen/classes/GodotInstance.ts';
25
+ export const getGodot = _mod.getGodot as () => GodotInstance;
26
+
27
+ /** Base constructor for all Godot object wrappers. */
28
+ export interface GodotVar {
29
+ free(): void;
30
+ }
31
+ export const GodotVar = _mod.GodotVar as (new (...args: unknown[]) => GodotVar) & {
32
+ assign<T extends GodotVar>(target: T, props: Record<string, unknown>): T;
33
+ };
34
+
35
+ /** Unified entry point:
36
+ * - _C(this, methodId, ...args) → instance method
37
+ * - _C(classId, methodId, ...args) → static method
38
+ * - _C(null, methodId, ...args) → singleton lookup (_getInstance) or internal
39
+ * - _C(0n, methodId, ...args) → utility function (BigInt tags the call;
40
+ * arg types resolve by name at runtime)
41
+ */
42
+ export const _C = _mod._C as (...args: unknown[]) => unknown;
43
+
44
+ /** Resolve a string name to its integer handle. */
45
+ export const _S = _mod._S as (name: string) => number;
46
+
47
+ /** Register Godot object wrapper. */
48
+ export const _R = _mod._R as (typeId: number, ctor: Function) => string;
49
+
50
+ /**
51
+ * Value-type dispatch on tuple receivers: `_V(typeId, methodId, receiver, ...args)`.
52
+ * `methodId == SX_valueCtor` constructs (`_V(typeId, ctorSentinel, ctorIndex, ...args)`),
53
+ * negative ids are operators, non-negative ids are builtin methods.
54
+ */
55
+ export const _V = _mod._V as (...args: unknown[]) => unknown;
56
+
57
+ /**
58
+ * Build a typed value-type Variant for a Variant-typed slot by resolving the
59
+ * property's type from the node; returns a GodotVar carrier the consuming `_C`
60
+ * unwraps. Used by react-hooks' useTween (tweenProperty final_val).
61
+ */
62
+ export const toValueType = _mod.toValueType as (node: GodotVar, propName: string, value: unknown) => unknown;
63
+
64
+ /** Stage bytes into the web MEMFS (Godot's `copyToFS`); undefined on desktop. */
65
+ export const stageFile = (_mod as any).stageFile as ((path: string, bytes: Uint8Array) => void) | undefined;
66
+
67
+ /** Get a Signal value from an Object by signal name, or connect a callback when 3rd arg given. */
68
+ export const _G = _mod._G as (target: GodotVar, signalNameId: number, callback?: unknown) => unknown;
69
+
70
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame) */
71
+ export const requestAnimationFrame = _mod.requestAnimationFrame as typeof globalThis.requestAnimationFrame ?? globalThis.requestAnimationFrame;
72
+
73
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/cancelAnimationFrame) */
74
+ export const cancelAnimationFrame = _mod.cancelAnimationFrame as typeof globalThis.cancelAnimationFrame ?? globalThis.cancelAnimationFrame;
75
+
76
+ // One-shot callbacks of in-flight `_P` awaits. The connection's JSCallable holds
77
+ // only a weak reference to the callback, so without this the function could be
78
+ // GC'd before the signal fires. A module root is the only retention Bun reliably
79
+ // traces: neither `_P`'s closure cells nor a Set attached to the signal's object
80
+ // wrapper survive a full GC while the await is pending (the await machinery
81
+ // discards the promise `_P` returns, and Bun may collect the emitter's wrapper).
82
+ // Removed as soon as the signal fires or the connect fails.
83
+ const pendingCallbacks = new Set<(...args: any[]) => void>();
84
+
85
+ /** Make a Signal PromiseLike: connects a one-shot callback that resolves with the args tuple. */
86
+ export function _P(signal: any, onfulfilled?: any, onrejected?: any) {
87
+ return new Promise<any>((resolve, reject) => {
88
+ if (signal.isNull()) {
89
+ reject(new Error('Cannot await a null Signal'));
90
+ return;
91
+ }
92
+ const cb = (...args: any[]) => {
93
+ pendingCallbacks.delete(cb);
94
+ signal.disconnect(cb);
95
+ resolve(args);
96
+ };
97
+ pendingCallbacks.add(cb);
98
+ const res = signal.connect(cb);
99
+ if (res !== 0) {
100
+ pendingCallbacks.delete(cb);
101
+ reject(new Error('Failed to connect signal: error code ' + res));
102
+ }
103
+ }).then(onfulfilled, onrejected);
104
+ }
105
+
106
+ /** Cleanup callbacks run once by `runGodot` just before the engine is freed; may be async. */
107
+ export const cleanupHooks: Set<() => void | Promise<void>> = new Set();
package/src/web-image.ts CHANGED
@@ -1,117 +1,153 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- // Web-only: decode the embedded PNG/WebP blobs of a `.ctex` CompressedTexture2D
6
- // container with the browser's `createImageBitmap`, and rebuild the container as
7
- // a raw RGBA8 `DATA_FORMAT_IMAGE` ctex — so no image codec runs in wasm on web.
8
- // Anything that isn't a PNG/WebP-embedded ctex is returned unchanged.
9
-
10
- const CTEX_MAGIC = 'GST2';
11
- const DATA_FORMAT_IMAGE = 0;
12
- const DATA_FORMAT_PNG = 1;
13
- const DATA_FORMAT_WEBP = 2;
14
- const FORMAT_RGBA8 = 5;
15
- const FORMAT_BIT_STREAM = 1 << 22;
16
-
17
- // Outer header: magic(4) version(4) w(4) h(4) df(4) mipmap_limit(4) reserved(12).
18
- const OUTER_HEADER_SIZE = 36;
19
- // Sub-header: data_format(4) w(2) h(2) mipmaps(4) format(4).
20
- const SUB_HEADER_OFFSET = OUTER_HEADER_SIZE;
21
- const SUB_HEADER_SIZE = 16;
22
- const BLOB_TABLE_OFFSET = SUB_HEADER_OFFSET + SUB_HEADER_SIZE;
23
-
24
- interface DecodedBitmap {
25
- width: number;
26
- height: number;
27
- data: Uint8Array;
28
- }
29
-
30
- async function decodeWebBitmap(blob: Uint8Array<ArrayBuffer>, type: string): Promise<DecodedBitmap> {
31
- const bmp = await createImageBitmap(new Blob([blob], { type }), {
32
- colorSpaceConversion: 'none',
33
- premultiplyAlpha: 'none',
34
- });
35
- try {
36
- const canvas = new OffscreenCanvas(bmp.width, bmp.height);
37
- const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
38
- | OffscreenCanvasRenderingContext2D
39
- | null;
40
- if (!ctx) {
41
- throw new Error('web-image: no 2d context for OffscreenCanvas');
42
- }
43
- ctx.drawImage(bmp, 0, 0);
44
- const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
45
- return { width: bmp.width, height: bmp.height, data: new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength) };
46
- } finally {
47
- bmp.close();
48
- }
49
- }
50
-
51
- /**
52
- * Decodes the embedded PNG/WebP blobs of a `.ctex` file with the browser's
53
- * `createImageBitmap` and rebuilds it as a raw RGBA8 (`DATA_FORMAT_IMAGE`)
54
- * container. Non-ctex bytes, streamable textures, and `DATA_FORMAT_IMAGE` /
55
- * `DATA_FORMAT_BASIS_UNIVERSAL` containers are returned unchanged. Throws if an
56
- * embedded blob can't be decoded or its dimensions don't match the expected
57
- * mipmap layout, so a would-be undecodable texture fails loudly at materialize
58
- * time. A no-op outside browsers (no `createImageBitmap`).
59
- */
60
- export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
61
- if (
62
- typeof createImageBitmap !== 'function' ||
63
- bytes.length < SUB_HEADER_OFFSET + SUB_HEADER_SIZE ||
64
- String.fromCharCode(...bytes.subarray(0, 4)) !== CTEX_MAGIC
65
- ) {
66
- return bytes;
67
- }
68
- const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
69
- if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
70
- return bytes; // streamable layout differs; leave it to the engine
71
- }
72
- const dataFormat = dv.getUint32(SUB_HEADER_OFFSET, true);
73
- if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
74
- return bytes; // raw and BASIS_UNIVERSAL containers pass through
75
- }
76
- const w = dv.getUint16(SUB_HEADER_OFFSET + 4, true);
77
- const h = dv.getUint16(SUB_HEADER_OFFSET + 6, true);
78
- const mipmaps = dv.getUint32(SUB_HEADER_OFFSET + 8, true);
79
- const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
80
-
81
- let ofs = BLOB_TABLE_OFFSET;
82
- const levels: Uint8Array[] = [];
83
- for (let i = 0; i <= mipmaps; i++) {
84
- if (ofs + 4 > bytes.length) {
85
- throw new Error(`web-image: truncated ctex at mipmap ${i}`);
86
- }
87
- const size = dv.getUint32(ofs, true);
88
- ofs += 4;
89
- if (ofs + size > bytes.length) {
90
- throw new Error(`web-image: truncated ctex blob at mipmap ${i}`);
91
- }
92
- const bmp = await decodeWebBitmap(bytes.slice(ofs, ofs + size), type);
93
- ofs += size;
94
- const ew = Math.max(w >> i, 1);
95
- const eh = Math.max(h >> i, 1);
96
- if (bmp.width !== ew || bmp.height !== eh) {
97
- throw new Error(`web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${ew}x${eh}`);
98
- }
99
- levels.push(bmp.data);
100
- }
101
-
102
- const dataSize = levels.reduce((n, level) => n + level.length, 0);
103
- const out = new Uint8Array(BLOB_TABLE_OFFSET + dataSize);
104
- out.set(bytes.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim
105
- const odv = new DataView(out.buffer);
106
- odv.setUint32(SUB_HEADER_OFFSET, DATA_FORMAT_IMAGE, true);
107
- odv.setUint16(SUB_HEADER_OFFSET + 4, w, true);
108
- odv.setUint16(SUB_HEADER_OFFSET + 6, h, true);
109
- odv.setUint32(SUB_HEADER_OFFSET + 8, mipmaps, true);
110
- odv.setUint32(SUB_HEADER_OFFSET + 12, FORMAT_RGBA8, true);
111
- let p = BLOB_TABLE_OFFSET;
112
- for (const level of levels) {
113
- out.set(level, p);
114
- p += level.length;
115
- }
116
- return out;
117
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // Web-only: decode the embedded PNG/WebP blobs of a `.ctex` CompressedTexture2D
6
+ // container with the browser's `createImageBitmap`, and rebuild the container as
7
+ // a raw RGBA8 `DATA_FORMAT_IMAGE` ctex — so no image codec runs in wasm on web.
8
+ // Anything that isn't a PNG/WebP-embedded ctex is returned unchanged.
9
+ //
10
+ // The mipmap blobs are codec-decoded **concurrently** (Promise.all), then
11
+ // rasterized serially into one reused OffscreenCanvas, so wall time is dominated
12
+ // by the full-res level's decode instead of the serial sum of every level.
13
+
14
+ const CTEX_MAGIC = 'GST2';
15
+ const DATA_FORMAT_IMAGE = 0;
16
+ const DATA_FORMAT_PNG = 1;
17
+ const DATA_FORMAT_WEBP = 2;
18
+ const FORMAT_RGBA8 = 5;
19
+ const FORMAT_BIT_STREAM = 1 << 22;
20
+
21
+ // Outer header: magic(4) version(4) w(4) h(4) df(4) mipmap_limit(4) reserved(12).
22
+ const OUTER_HEADER_SIZE = 36;
23
+ // Sub-header: data_format(4) w(2) h(2) mipmaps(4) format(4).
24
+ const SUB_HEADER_OFFSET = OUTER_HEADER_SIZE;
25
+ const SUB_HEADER_SIZE = 16;
26
+ const BLOB_TABLE_OFFSET = SUB_HEADER_OFFSET + SUB_HEADER_SIZE;
27
+
28
+ // One encoded mipmap's byte range inside the container, plus the RGBA
29
+ // dimensions its decoded pixels must have (`w>>i` clamped to 1).
30
+ interface MipEntry {
31
+ offset: number;
32
+ size: number;
33
+ width: number;
34
+ height: number;
35
+ }
36
+
37
+ function decodeBitmap(blob: Uint8Array<ArrayBuffer>, type: string): Promise<ImageBitmap> {
38
+ return createImageBitmap(new Blob([blob], { type }), {
39
+ colorSpaceConversion: 'none',
40
+ premultiplyAlpha: 'none',
41
+ });
42
+ }
43
+
44
+ // Rasterizes an ImageBitmap into a raw RGBA8 buffer, drawing into the caller's
45
+ // shared canvas (resized per level) so one canvas serves every mipmap.
46
+ function rasterize(canvas: OffscreenCanvas, bmp: ImageBitmap): Uint8Array {
47
+ const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
48
+ | OffscreenCanvasRenderingContext2D
49
+ | null;
50
+ if (!ctx) {
51
+ throw new Error('web-image: no 2d context for OffscreenCanvas');
52
+ }
53
+ canvas.width = bmp.width;
54
+ canvas.height = bmp.height;
55
+ ctx.drawImage(bmp, 0, 0);
56
+ const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
57
+ return new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
58
+ }
59
+
60
+ /**
61
+ * Decodes the embedded PNG/WebP blobs of a `.ctex` file with the browser's
62
+ * `createImageBitmap` and rebuilds it as a raw RGBA8 (`DATA_FORMAT_IMAGE`)
63
+ * container. Non-ctex bytes, streamable textures, and `DATA_FORMAT_IMAGE` /
64
+ * `DATA_FORMAT_BASIS_UNIVERSAL` containers are returned unchanged. Throws if an
65
+ * embedded blob can't be decoded or its dimensions don't match the expected
66
+ * mipmap layout, so a would-be undecodable texture fails loudly at materialize
67
+ * time. A no-op outside browsers (no `createImageBitmap`). Mip levels are
68
+ * decoded concurrently; the returned container's pixel data is ordered largest
69
+ * to smallest, matching the input blob table.
70
+ */
71
+ export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
72
+ if (
73
+ typeof createImageBitmap !== 'function' ||
74
+ bytes.length < SUB_HEADER_OFFSET + SUB_HEADER_SIZE ||
75
+ String.fromCharCode(...bytes.subarray(0, 4)) !== CTEX_MAGIC
76
+ ) {
77
+ return bytes;
78
+ }
79
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80
+ if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
81
+ return bytes; // streamable layout differs; leave it to the engine
82
+ }
83
+ const dataFormat = dv.getUint32(SUB_HEADER_OFFSET, true);
84
+ if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
85
+ return bytes; // raw and BASIS_UNIVERSAL containers pass through
86
+ }
87
+ const w = dv.getUint16(SUB_HEADER_OFFSET + 4, true);
88
+ const h = dv.getUint16(SUB_HEADER_OFFSET + 6, true);
89
+ const mipmaps = dv.getUint32(SUB_HEADER_OFFSET + 8, true);
90
+ const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
91
+
92
+ // Phase 1: bounds-check the blob table so every level's byte range is known
93
+ // before any decode starts (a malformed container fails fast, no decode).
94
+ const mips: MipEntry[] = [];
95
+ let ofs = BLOB_TABLE_OFFSET;
96
+ for (let i = 0; i <= mipmaps; i++) {
97
+ if (ofs + 4 > bytes.length) {
98
+ throw new Error(`web-image: truncated ctex at mipmap ${i}`);
99
+ }
100
+ const size = dv.getUint32(ofs, true);
101
+ ofs += 4;
102
+ if (ofs + size > bytes.length) {
103
+ throw new Error(`web-image: truncated ctex blob at mipmap ${i}`);
104
+ }
105
+ mips.push({ offset: ofs, size, width: Math.max(w >> i, 1), height: Math.max(h >> i, 1) });
106
+ ofs += size;
107
+ }
108
+
109
+ // Phase 2: codec-decode all levels concurrently — the dominant cost now
110
+ // overlaps instead of summing. Views (not copies) go into the Blob, which
111
+ // materializes them once.
112
+ // `decodeBitmap` feeds the Blob ctor, which needs an ArrayBuffer-backed view.
113
+ const bitmaps = await Promise.all(
114
+ mips.map((m) => decodeBitmap(bytes.subarray(m.offset, m.offset + m.size) as Uint8Array<ArrayBuffer>, type)),
115
+ );
116
+
117
+ // Phase 3: validate dimensions and rasterize serially into one shared
118
+ // canvas; close() on an already-closed bitmap is a no-op, so a throw
119
+ // mid-loop still releases every bitmap via the finally.
120
+ const levels: Uint8Array[] = new Array(mips.length);
121
+ const canvas = new OffscreenCanvas(w, h);
122
+ try {
123
+ for (let i = 0; i < mips.length; i++) {
124
+ const bmp = bitmaps[i];
125
+ if (bmp.width !== mips[i].width || bmp.height !== mips[i].height) {
126
+ throw new Error(
127
+ `web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${mips[i].width}x${mips[i].height}`,
128
+ );
129
+ }
130
+ levels[i] = rasterize(canvas, bmp);
131
+ }
132
+ } finally {
133
+ for (const bmp of bitmaps) {
134
+ bmp.close();
135
+ }
136
+ }
137
+
138
+ const dataSize = levels.reduce((n, level) => n + level.length, 0);
139
+ const out = new Uint8Array(BLOB_TABLE_OFFSET + dataSize);
140
+ out.set(bytes.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim
141
+ const odv = new DataView(out.buffer);
142
+ odv.setUint32(SUB_HEADER_OFFSET, DATA_FORMAT_IMAGE, true);
143
+ odv.setUint16(SUB_HEADER_OFFSET + 4, w, true);
144
+ odv.setUint16(SUB_HEADER_OFFSET + 6, h, true);
145
+ odv.setUint32(SUB_HEADER_OFFSET + 8, mipmaps, true);
146
+ odv.setUint32(SUB_HEADER_OFFSET + 12, FORMAT_RGBA8, true);
147
+ let p = BLOB_TABLE_OFFSET;
148
+ for (const level of levels) {
149
+ out.set(level, p);
150
+ p += level.length;
151
+ }
152
+ return out;
153
+ }