@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/README.md +148 -146
- package/package.json +7 -4
- package/src/assets.d.ts +130 -130
- package/src/debug.ts +357 -357
- package/src/editor.ts +107 -0
- package/src/index.ts +54 -50
- package/src/load.ts +231 -231
- package/src/preload.ts +253 -253
- package/src/runtime.ts +107 -107
- package/src/web-image.ts +153 -117
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|