@volter/blender-engine 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
Binary file
@@ -0,0 +1,140 @@
1
+ /**
2
+ * ONE PRESENTER, ATTACHED TO ONE BLENDER (WS-AC, cut 3).
3
+ *
4
+ * `vgai_three.py` ships inside our Blender and turns every
5
+ * `bpy.ops.render.render` into an ASK on a directory it owns; this is the
6
+ * other half, for a host that holds that directory as a filesystem in its own
7
+ * realm. It is the whole of what a host must do: hand it the filesystem, the
8
+ * root the program was told to ask at, and a presenter
9
+ * (`./presenter`'s `createPresenter()`, or the editor's own document wrapped
10
+ * around the same translation).
11
+ *
12
+ * ONE WRITER PER DIRECTORY, which is the channel's single structural rule:
13
+ * the guest writes and unlinks `ask/<n>.*`, this file writes and unlinks
14
+ * `reply/<n>.*`, and neither ever touches the other's. Under a snapshot-backed
15
+ * skew (WALI) a crossing edit is not a race but a terminated program -- the
16
+ * host refuses a patch whose base moved under it.
17
+ *
18
+ * THE TRIGGER IS THE GUEST'S OWN `.done` FILE, never a timer: the filesystem
19
+ * announces the write, this serves the ask, and the guest's removal of its
20
+ * `ask/<n>.json` is the acknowledgement that lets the reply be retired.
21
+ */
22
+ import { columnsToTypedArrays, describeFrame } from '../session-frame.mts';
23
+
24
+ /** The host filesystem seam, in exactly the shape this file uses. */
25
+ export interface PresenterFileSystem {
26
+ existsSync(path: string): boolean;
27
+ readFile(path: string): Promise<Uint8Array>;
28
+ writeFileSync(path: string, data: string | Uint8Array): void;
29
+ readdirSync(path: string): string[];
30
+ unlinkSync(path: string): void;
31
+ on(event: 'change' | 'delete', listener: (path: string) => void): unknown;
32
+ off(event: 'change' | 'delete', listener: (path: string) => void): unknown;
33
+ }
34
+
35
+ /** What `createPresenter()` returns, in the part this file uses. */
36
+ export interface AttachablePresenter {
37
+ present(frame: unknown, description: unknown, capture?: unknown): Promise<unknown>;
38
+ }
39
+
40
+ export function attachPresenter(
41
+ filesystem: PresenterFileSystem,
42
+ root: string,
43
+ presenter: AttachablePresenter,
44
+ ): () => void {
45
+ const askDirectory = `${root}/ask`;
46
+ const decoder = new TextDecoder();
47
+ /**
48
+ * Asks answered and not yet acknowledged -- this is also the "already
49
+ * served" test, and it may not be replaced by a highest-id watermark or a
50
+ * set that outlives an ask.
51
+ *
52
+ * ONE PRESENTER SERVES MANY PROCESSES, and every process starts its ask
53
+ * sequence at 0 again: the numbers are a conversation's, not a page's.
54
+ * MEASURED 2026-09-20 in the substrate tab with a page-lifetime `served`
55
+ * set: the first `blender -b … -a` photographed twelve frames at ~0.30 s,
56
+ * and every later one had its `ask/0` ("hello") skipped as already served,
57
+ * printed the no-presenter line and path traced with Cycles at ~1.40 s a
58
+ * frame -- a fallback that looked like a slow success. The ack is what
59
+ * closes an ask: the guest removes `ask/<n>.*` once it has read the reply,
60
+ * and this set is emptied by that.
61
+ */
62
+ const replied = new Set<string>();
63
+ let attached = true;
64
+ let pumping: Promise<void> = Promise.resolve();
65
+
66
+ async function answer(id: string): Promise<unknown> {
67
+ const payload = JSON.parse(decoder.decode(await filesystem.readFile(`${askDirectory}/${id}.json`))) as {
68
+ op?: string;
69
+ frame?: unknown;
70
+ capture?: unknown;
71
+ arena?: string;
72
+ };
73
+ if (payload.op === 'hello') return { ok: true };
74
+ const arena = await filesystem.readFile(payload.arena ?? `${root}/arena.bin`);
75
+ const answered = (await presenter.present(
76
+ columnsToTypedArrays(arena, payload.frame),
77
+ await describeFrame(arena, payload.frame),
78
+ payload.capture,
79
+ )) as { capture?: unknown; held?: unknown };
80
+ // THE CAPTURE IS THE ANSWER'S BODY and `held` rides beside it, exactly as
81
+ // the editor's worker composes it: the guest reads a photograph's own
82
+ // fields off this object.
83
+ const body =
84
+ typeof answered?.capture === 'object' && answered.capture !== null
85
+ ? (answered.capture as Record<string, unknown>)
86
+ : {};
87
+ return { ...body, ...(answered && 'held' in answered ? { held: answered.held } : {}) };
88
+ }
89
+
90
+ async function pump(): Promise<void> {
91
+ if (!attached) return;
92
+ let names: string[];
93
+ try {
94
+ names = filesystem.readdirSync(askDirectory);
95
+ } catch {
96
+ return;
97
+ }
98
+ const raised = new Set(names.filter((name) => name.endsWith('.done')).map((name) => name.slice(0, -'.done'.length)));
99
+ // THE GUEST'S ACK: its own ask is gone, so it has taken the reply and this
100
+ // host may retire the two files IT wrote. `.done` goes last, as it does on
101
+ // the guest's side, because `.done` is the token the other side watches.
102
+ for (const id of [...replied]) {
103
+ if (raised.has(id)) continue;
104
+ filesystem.unlinkSync(`${root}/reply/${id}.json`);
105
+ filesystem.unlinkSync(`${root}/reply/${id}.done`);
106
+ replied.delete(id);
107
+ }
108
+ for (const id of [...raised].sort((left, right) => Number(left) - Number(right))) {
109
+ if (replied.has(id) || !attached) continue;
110
+ let body: unknown;
111
+ try {
112
+ body = await answer(id);
113
+ } catch (error) {
114
+ body = { error: error instanceof Error ? error.message : String(error) };
115
+ }
116
+ if (!attached) return;
117
+ filesystem.writeFileSync(`${root}/reply/${id}.json`, JSON.stringify(body ?? null));
118
+ filesystem.writeFileSync(`${root}/reply/${id}.done`, '1');
119
+ replied.add(id);
120
+ }
121
+ }
122
+
123
+ /** Serialized: one ask is answered at a time, and this host's own writes
124
+ * into `reply/` announce themselves here too. */
125
+ const wake = (): void => {
126
+ pumping = pumping.then(pump, pump);
127
+ };
128
+ const changed = (path: string): void => {
129
+ if (path.startsWith(`${askDirectory}/`)) wake();
130
+ };
131
+ filesystem.on('change', changed);
132
+ filesystem.on('delete', changed);
133
+ wake();
134
+
135
+ return () => {
136
+ attached = false;
137
+ filesystem.off('change', changed);
138
+ filesystem.off('delete', changed);
139
+ };
140
+ }
@@ -0,0 +1,235 @@
1
+ import { encodeDisplayFrame, sampleDisplayLut } from './blender-display-lut';
2
+
3
+ /**
4
+ * Blender's AgX view transform, transcribed from the pinned OCIO config.
5
+ *
6
+ * WHY THIS EXISTS AND `THREE.AgXToneMapping` DOES NOT DO. three's AgX is
7
+ * Filament's approximation: it allocates log2 to `AgxMaxEv = 4.026069` where
8
+ * Blender's config allocates to `+12.5260688117`, and it substitutes a
9
+ * 6th-order polynomial for the config's curve. MEASURED on flat grey, it is
10
+ * 23 to 48 levels of 255 away from Blender -- 42 of them at middle grey, where
11
+ * Blender answers 0.4456 and three answers 0.6060. Every render this lane
12
+ * produced carried that.
13
+ *
14
+ * THE CHAIN IS THE CONFIG'S, step for step (`AgX Base Rec.1886` on display
15
+ * `sRGB`):
16
+ *
17
+ * scene linear Rec.709 -> CIE XYZ D65 -> Linear FilmLight E-Gamut
18
+ * -> lg2 allocation over [-12.47393, +12.5260688117]
19
+ * -> AgX_Base_sRGB.cube, 57^3, TETRAHEDRAL
20
+ * -> Rec.1886 -> sRGB (the Rec.709 matrices cancel: `srgb(x^2.4)`)
21
+ *
22
+ * VERIFIED THREE WAYS, all agreeing to the digit: this code, Blender's own
23
+ * `image.save_render()` on a known float buffer, and the `PyOpenColorIO` 2.5.0
24
+ * that ships inside the oracle.
25
+ *
26
+ * scene linear here save_render OCIO
27
+ * 0.01337859 0.008866 0.00886492 --
28
+ * 0.15904408 0.158059 0.15805990 --
29
+ * 1.01823460 0.559880 0.55986845 --
30
+ * 0.18 0.461326 -- 0.46132022 (sRGB encoded)
31
+ *
32
+ * A render of a flat emission plane is NOT the instrument -- Cycles' film does
33
+ * not hand the view transform exactly the colour the emission node was set to,
34
+ * and chasing that cost a wrong "unexplained residual" once.
35
+ *
36
+ * THE LUT IS DATA, not a curve: `agx-base-srgb.lut` is the config's own
37
+ * `AgX_Base_sRGB.cube` as little-endian uint16 over [0,1], red varying fastest,
38
+ * 185,193 RGB triples. One part in 65,535 is 0.004 of a 255-level step, far
39
+ * under anything measurable here, and it halves what a project carries.
40
+ * Source sha256 e707a36f3e90ee79bc342332febf91334c02ce3974cac700ece00ca9d4507491.
41
+ *
42
+ * NOT WIRED INTO THE VIEW YET, and the reason is named rather than left to be
43
+ * discovered: `renderer.toneMapping` applies per fragment inside every
44
+ * material's shader, and that seam takes an enum, not a 3D texture. Reaching
45
+ * it means the photograph rendering to a float target and this chain running
46
+ * as a full-screen pass. Until then the capture still uses three's curve and
47
+ * the AgX LOOKS are still refused by name in `bpy/_render_three.py` -- which
48
+ * is the honest state, not a degrade. The LOOKS also need a SECOND pinned LUT
49
+ * (`luminance_compensation_bt2020.cube`, 37^3): their process space is `AgX
50
+ * Log`, which is not this chain's E-Gamut path.
51
+ */
52
+
53
+ /** The LUT's edge length; `AgX_Base_sRGB.cube` says `LUT_3D_SIZE 57`. */
54
+ export const AGX_LUT_SIZE = 57;
55
+
56
+ /** `AllocationTransform {allocation: lg2, vars: [-12.47393, 12.5260688117]}`.
57
+ * The ends are `log2(2^-10 * 0.18)` and `log2(2^15 * 0.18)`: ten stops under
58
+ * middle grey to fifteen over, which is why middle grey lands on exactly
59
+ * 0.4 of the domain. */
60
+ const LOG2_MIN = -12.47393;
61
+ const LOG2_MAX = 12.5260688117;
62
+
63
+ /** Linear Rec.709 -> CIE XYZ D65, the inverse of the config's own
64
+ * `Linear Rec.709` matrix. */
65
+ const RGB_TO_XYZ = [
66
+ [0.4123908, 0.3575843, 0.1804808],
67
+ [0.212639, 0.7151687, 0.0721923],
68
+ [0.0193308, 0.1191948, 0.9505322],
69
+ ] as const;
70
+
71
+ /** CIE XYZ D65 -> Linear FilmLight E-Gamut, verbatim from the config. */
72
+ const XYZ_TO_EGAMUT = [
73
+ [1.5250528, -0.3159135, -0.1226583],
74
+ [-0.5091526, 1.3333274, 0.1382844],
75
+ [0.0957153, 0.0508974, 0.7879558],
76
+ ] as const;
77
+
78
+ /** In place, into `out`. A frame is 1.3M pixels, so nothing on this path
79
+ * allocates -- see `blender-display-lut.ts`'s header. */
80
+ function transform(
81
+ m: readonly (readonly number[])[],
82
+ v: ArrayLike<number>,
83
+ out: Float64Array,
84
+ ): void {
85
+ const a = v[0]!;
86
+ const b = v[1]!;
87
+ const c = v[2]!;
88
+ out[0] = m[0]![0]! * a + m[0]![1]! * b + m[0]![2]! * c;
89
+ out[1] = m[1]![0]! * a + m[1]![1]! * b + m[1]![2]! * c;
90
+ out[2] = m[2]![0]! * a + m[2]![1]! * b + m[2]![2]! * c;
91
+ }
92
+
93
+ /** Scratch for the two matrix stages and the display answer. */
94
+ const stageA = new Float64Array(3);
95
+ const stageB = new Float64Array(3);
96
+ const display: [number, number, number] = [0, 0, 0];
97
+
98
+ /** The sRGB display colourspace's own encode -- piecewise, not a pure 2.2. */
99
+ function srgbEncode(value: number): number {
100
+ const v = Math.min(1, Math.max(0, value));
101
+ return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
102
+ }
103
+
104
+ /**
105
+ * The 57^3 LUT, sampled TETRAHEDRALLY -- which is what the config asks for by
106
+ * name (`interpolation: tetrahedral`).
107
+ *
108
+ * On the neutral diagonal every barycentric weight collapses to the same `t`,
109
+ * so a grey input is a straight lerp between the two diagonal corners; that is
110
+ * the case the verification above exercises, and rendering at inputs that land
111
+ * exactly ON a grid point exercises the table itself with no interpolation at
112
+ * all.
113
+ */
114
+ /** The returned triple is SCRATCH -- the next call overwrites it. */
115
+ export function sampleAgxLut(
116
+ lut: Uint16Array,
117
+ r: number,
118
+ g: number,
119
+ b: number,
120
+ ): [number, number, number] {
121
+ return sampleDisplayLut(lut, AGX_LUT_SIZE, r, g, b);
122
+ }
123
+
124
+ /** One scene-linear Rec.709 colour through Blender's AgX, to sRGB display.
125
+ * THE RETURNED TRIPLE IS SCRATCH -- the next call overwrites it. */
126
+ export function agxDisplay(lut: Uint16Array, rgb: ArrayLike<number>): [number, number, number] {
127
+ transform(RGB_TO_XYZ, rgb, stageB);
128
+ transform(XYZ_TO_EGAMUT, stageB, stageA);
129
+ const span = LOG2_MAX - LOG2_MIN;
130
+ const formed = sampleAgxLut(
131
+ lut,
132
+ (Math.log2(Math.max(stageA[0]!, 1e-10)) - LOG2_MIN) / span,
133
+ (Math.log2(Math.max(stageA[1]!, 1e-10)) - LOG2_MIN) / span,
134
+ (Math.log2(Math.max(stageA[2]!, 1e-10)) - LOG2_MIN) / span,
135
+ );
136
+ // The view ends at Rec.1886 and the display is sRGB; both are Rec.709
137
+ // primaries, so the matrices cancel and only the two curves remain.
138
+ display[0] = srgbEncode(Math.max(0, formed[0]) ** 2.4);
139
+ display[1] = srgbEncode(Math.max(0, formed[1]) ** 2.4);
140
+ display[2] = srgbEncode(Math.max(0, formed[2]) ** 2.4);
141
+ return display;
142
+ }
143
+
144
+ /**
145
+ * THE LOOKS, as the config's own composed transform.
146
+ *
147
+ * A look is not a curve that can be applied after the fact. OCIO converts to
148
+ * the look's process space (`AgX Log` -- luminance-compensated Rec.2020 through
149
+ * a SECOND 37^3 LUT, an inset matrix and the lg2 allocation), grades there,
150
+ * converts back, and only then runs the display view. Two of those steps invert
151
+ * a 3D LUT, which is not something to do per pixel in a browser.
152
+ *
153
+ * SO THE COMPOSED look+view IS SAMPLED, by OCIO itself, on the display LUT's
154
+ * own lg2 grid -- `2^(x*(MAX-MIN)+MIN)` at every 57^3 corner, through the
155
+ * `sRGB`/`AgX` display view with the look overridden. The table that comes out
156
+ * is a drop-in for the base one: allocate, sample, done -- its values ARE the
157
+ * sRGB display answer, so no `Rec.1886` step follows it.
158
+ *
159
+ * MEASURED against OCIO's direct answer for ten colours off the grid: WORST 3
160
+ * LEVELS of 255 (`AgX - Medium High Contrast`) and 1 (`AgX - Punchy`). That
161
+ * residual is resampling a curve that already contains an interpolated LUT,
162
+ * and it lands on an artifact that is a DECLARED difference -- a three.js
163
+ * raster is never compared to a Cycles path trace for equality.
164
+ *
165
+ * TWO OTHER REPRESENTATIONS WERE TRIED AND MEASURED WORSE. Baking the look
166
+ * alone as a scene-to-scene map and re-log-encoding it scored 19 levels,
167
+ * because a saturation grade produces NEGATIVE linear values and a log
168
+ * encoding collapses every one of them onto its floor -- which is exactly why
169
+ * the config's own `AgX Log` carries a luminance-compensation LUT to remove
170
+ * the negatives before the grade. Carrying the log span alongside the table
171
+ * did not help, because the loss is the floor, not the clamp.
172
+ *
173
+ * A LOOK WITH NO TABLE HERE IS REFUSED BY NAME in `bpy/_render_three.py`.
174
+ * There is no generic path: `AgX - Base Contrast` is the identity and already
175
+ * accepted, these two are baked, and the rest say so.
176
+ */
177
+ export const AGX_LOOK_TABLES = {
178
+ 'AgX - Medium High Contrast': 'agx-look-medium-high-contrast.lut',
179
+ 'AgX - Punchy': 'agx-look-punchy.lut',
180
+ } as const;
181
+
182
+ /** One scene-linear Rec.709 colour through a composed look+view table.
183
+ * The returned triple is SCRATCH, as in `agxDisplay`. */
184
+ export function agxDisplayWithLook(
185
+ lookLut: Uint16Array,
186
+ rgb: ArrayLike<number>,
187
+ ): [number, number, number] {
188
+ // The composed table is sampled in the SCENE's own channels: OCIO was handed
189
+ // `2^(x*(MAX-MIN)+MIN)` per axis, so the grid axis IS the allocated scene
190
+ // value and no matrix runs before the lookup.
191
+ const span = LOG2_MAX - LOG2_MIN;
192
+ return sampleAgxLut(
193
+ lookLut,
194
+ (Math.log2(Math.max(rgb[0]!, 1e-10)) - LOG2_MIN) / span,
195
+ (Math.log2(Math.max(rgb[1]!, 1e-10)) - LOG2_MIN) / span,
196
+ (Math.log2(Math.max(rgb[2]!, 1e-10)) - LOG2_MIN) / span,
197
+ );
198
+ }
199
+
200
+ /** One IEEE half (uint16 bits) as a JS number. `readRenderTargetPixels` on a
201
+ * `HalfFloatType` target hands back exactly these. */
202
+ export { decodeHalf } from './blender-display-lut';
203
+
204
+ /**
205
+ * A captured HDR frame through Blender's AgX, as the RGBA bytes a PNG wants.
206
+ *
207
+ * THE FRAME IS ALREADY THERE. `Object3DDocumentSession.captureImage` renders
208
+ * into a `HalfFloatType` target to keep lit surfaces off the byte clip, and
209
+ * only then resolves through the viewport's output pass into the bytes it
210
+ * reads back. That half-float target is scene-referred linear -- which is the
211
+ * one thing an AgX LOOK, an EXR write and this transform all need and an
212
+ * 8-bit canvas cannot give. `readRenderTargetPixels` accepts it: three's
213
+ * `textureTypeReadable` passes `HalfFloatType` whenever
214
+ * `EXT_color_buffer_half_float` or `EXT_color_buffer_float` is present, and
215
+ * WebGL2 has both.
216
+ *
217
+ * ALPHA IS CARRIED, NOT TRANSFORMED. Blender treats alpha as data, not colour,
218
+ * everywhere else in this lane (`Image.save`'s encode does the same), so it is
219
+ * scaled to a byte and nothing more.
220
+ */
221
+ export function agxEncodeFrame(
222
+ lut: Uint16Array,
223
+ halfPixels: Uint16Array,
224
+ pixelCount: number,
225
+ options: { readonly exposure?: number; readonly composedLook?: boolean } = {},
226
+ ): Uint8ClampedArray {
227
+ // BLENDER'S EXPOSURE IS A LINEAR MULTIPLIER APPLIED BEFORE THE VIEW
228
+ // TRANSFORM (`view_settings.exposure` in stops, which the session already turns
229
+ // into `2 ** stops`), which is the same place `toneMappingExposure` sat.
230
+ const exposure = options.exposure ?? 1;
231
+ // A composed look table IS the display answer; the base table still has the
232
+ // matrices and the `Rec.1886 -> sRGB` tail after it.
233
+ const through = options.composedLook === true ? agxDisplayWithLook : agxDisplay;
234
+ return encodeDisplayFrame(halfPixels, pixelCount, exposure, (rgb) => through(lut, rgb));
235
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Base64 → bytes, as a PLAIN LOOP.
3
+ *
4
+ * `Uint8Array.from(atob(text), (c) => c.charCodeAt(0))` is the obvious form and
5
+ * it is a trap: the mapping callback is invoked ONCE PER CHARACTER, so decoding
6
+ * scales with call overhead rather than with a memory copy. Measured against
7
+ * the loop below, byte-identical output throughout: 1MB 63ms vs 4ms (16.9x),
8
+ * 3MB 224ms vs 8ms (27.7x), 8MB 545ms vs 19ms (28.9x). Textures and EXR frames
9
+ * are routinely multi-megabyte and one of these decodes runs on the page's main
10
+ * thread, so the callback form is never the right one here.
11
+ */
12
+ export function bytesFromBase64(text: string): Uint8Array<ArrayBuffer> {
13
+ const binary = atob(text);
14
+ const out = new Uint8Array(binary.length);
15
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
16
+ return out;
17
+ }
18
+
19
+ /**
20
+ * Bytes → base64, the way back.
21
+ *
22
+ * `String.fromCharCode(...bytes)` in one call overflows the argument stack on
23
+ * anything megabyte-sized, so the string is built in 32K spreads. Extracted
24
+ * from the Blender worker's `encode_image`, which had exactly this
25
+ * loop; a frame's JSON channel needs the same conversion for a raster that has
26
+ * no transferable buffer to travel in.
27
+ */
28
+ export function base64FromBytes(bytes: Uint8Array): string {
29
+ let binary = '';
30
+ for (let i = 0; i < bytes.length; i += 0x8000)
31
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
32
+ return btoa(binary);
33
+ }
34
+
35
+ /** {@link bytesFromBase64} for callers that want an 8-bit RGBA raster's own
36
+ * element type (`ImageData.data`'s), rather than a plain byte array. */
37
+ export function clampedBytesFromBase64(text: string): Uint8ClampedArray<ArrayBuffer> {
38
+ const binary = atob(text);
39
+ const out = new Uint8ClampedArray(binary.length);
40
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
41
+ return out;
42
+ }