@volter/blender-engine 0.1.127 → 0.1.128
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/browser/blender-emscripten-engine.mts +32 -13
- package/browser/blender-engine.mts +2 -2
- package/browser/blender-wali-engine.mts +1 -1
- package/browser/protocol.ts +2 -0
- package/browser/runtime.ts +1 -1
- package/browser/session.py +65 -17
- package/browser/three/blender-display-NOTICE.txt +45 -0
- package/browser/three/blender-display-shaders.json +1 -0
- package/browser/three/blender-display-shaders.lut +0 -0
- package/browser/three/blender-display-transform.ts +139 -0
- package/browser/three/blender-graph-material.ts +34 -7
- package/browser/three/blender-node-graph.ts +15 -1
- package/browser/three/blender-object-info-materials.ts +75 -0
- package/browser/three/blender-physical-material.ts +3 -2
- package/browser/three/blender-runtime-armature.ts +1 -1
- package/browser/three/blender-runtime-lighting.ts +3 -1
- package/browser/three/blender-runtime-view.ts +16 -1
- package/browser/three/blender-single-scattering-sky.ts +128 -0
- package/browser/three/blender-sky.ts +19 -15
- package/browser/three/presenter.ts +8 -55
- package/browser/three/world-field-sampler.ts +2 -0
- package/browser/volter_three.py +4 -5
- package/browser/worker.ts +45 -12
- package/package.json +1 -1
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Blender's
|
|
2
|
+
* Blender's scattering skies, transcribed from the pinned source.
|
|
3
3
|
*
|
|
4
4
|
* `intern/sky/source/sky_multiple_scattering.cpp` precomputes a 512x256 XYZ
|
|
5
5
|
* texture and `gpu_shader_material_tex_sky.glsl`'s `node_tex_sky_nishita` reads
|
|
6
6
|
* it back per direction; both halves are here because both are what a script
|
|
7
|
-
* sees through `ShaderNodeTexSky`.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* native REFUSES -- `enum "NISHITA" not found in ('SINGLE_SCATTERING',
|
|
12
|
-
* 'MULTIPLE_SCATTERING', 'PREETHAM', 'HOSEK_WILKIE')` -- so the sky it renders
|
|
13
|
-
* is the untouched default, which is MULTIPLE_SCATTERING.
|
|
7
|
+
* sees through `ShaderNodeTexSky`. The single-scattering atmosphere is in
|
|
8
|
+
* `blender-single-scattering-sky.ts`; model selection, caching and the shared
|
|
9
|
+
* GPU lookup convention live here. Each model keeps its own native elevation
|
|
10
|
+
* and azimuth conventions.
|
|
14
11
|
*
|
|
15
12
|
* The constants and the arithmetic are the source's. The XYZ-to-scene-linear
|
|
16
13
|
* matrix is not in the source -- it comes from the colour configuration -- and
|
|
@@ -18,6 +15,7 @@
|
|
|
18
15
|
* `Linear CIE-XYZ D65`.
|
|
19
16
|
*/
|
|
20
17
|
import * as THREE from 'three';
|
|
18
|
+
import {precomputeSingleScatteringSky} from './blender-single-scattering-sky';
|
|
21
19
|
|
|
22
20
|
const M_PI_2_F = 1.5707963267948966;
|
|
23
21
|
const M_2PI_F = 6.2831853071795864;
|
|
@@ -104,6 +102,8 @@ const aerosolDensity = (h: number): number =>
|
|
|
104
102
|
(Math.exp(-h / AEROSOL_HEIGHT_SCALE) + AEROSOL_BACKGROUND_DENSITY / AEROSOL_BASE_DENSITY);
|
|
105
103
|
|
|
106
104
|
export interface SkyParameters {
|
|
105
|
+
/** Omitted in older frames, whose only supported model was multiple scattering. */
|
|
106
|
+
model?: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING';
|
|
107
107
|
sunElevation: number;
|
|
108
108
|
sunRotation: number;
|
|
109
109
|
altitude: number;
|
|
@@ -363,16 +363,17 @@ function precomputeTexture(p: SkyParameters, sunElevation: number): Float32Array
|
|
|
363
363
|
* post the Float32Array back. See `contributions/sky-precompute-worker.ts`.
|
|
364
364
|
*/
|
|
365
365
|
export function precomputeSkyTexture(p: SkyParameters): Float32Array {
|
|
366
|
+
if (p.model === 'SINGLE_SCATTERING') return precomputeSingleScatteringSky(p);
|
|
366
367
|
const [elevation] = simplify(p.sunElevation, p.sunRotation);
|
|
367
368
|
return precomputeTexture(p, elevation);
|
|
368
369
|
}
|
|
369
370
|
|
|
370
|
-
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
371
|
+
/** Texture identity: model, native elevation, altitude and three densities.
|
|
372
|
+
* Rotation only changes the lookup. Multiple scattering folds elevation;
|
|
373
|
+
* single scattering keeps it as supplied to Blender's precompute. */
|
|
373
374
|
export function skyTextureKey(p: SkyParameters): string {
|
|
374
|
-
const
|
|
375
|
-
return JSON.stringify([elevation, p.altitude, p.airDensity, p.aerosolDensity, p.ozoneDensity]);
|
|
375
|
+
const elevation = p.model === 'SINGLE_SCATTERING' ? p.sunElevation : simplify(p.sunElevation, p.sunRotation)[0];
|
|
376
|
+
return JSON.stringify([p.model ?? 'MULTIPLE_SCATTERING', elevation, p.altitude, p.airDensity, p.aerosolDensity, p.ozoneDensity]);
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
/** Precomputed sky textures, however they were derived. `skyField` is
|
|
@@ -410,11 +411,14 @@ export function primeSkyTexture(p: SkyParameters, pixels: Float32Array): void {
|
|
|
410
411
|
* who cares about the main thread primes the cache first.
|
|
411
412
|
*/
|
|
412
413
|
export function skyField(p: SkyParameters): (direction: THREE.Vector3) => THREE.Vector3 {
|
|
413
|
-
|
|
414
|
+
// Single scattering only wraps azimuth; it does not fold sun elevation.
|
|
415
|
+
const rotation = p.model === 'SINGLE_SCATTERING'
|
|
416
|
+
? M_2PI_F - ((p.sunRotation % M_2PI_F + M_2PI_F) % M_2PI_F)
|
|
417
|
+
: simplify(p.sunElevation, p.sunRotation)[1];
|
|
414
418
|
const key = skyTextureKey(p);
|
|
415
419
|
let pixels = skyTextures.get(key);
|
|
416
420
|
if (pixels === undefined) {
|
|
417
|
-
pixels =
|
|
421
|
+
pixels = precomputeSkyTexture(p);
|
|
418
422
|
primeSkyTexture(p, pixels);
|
|
419
423
|
}
|
|
420
424
|
const colour = new THREE.Vector3();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from './blender-display-transform';
|
|
1
2
|
/**
|
|
2
3
|
* THE PRESENTER FOR A HOST WITHOUT A STAGE (WS-AC, cut 1b).
|
|
3
4
|
*
|
|
@@ -15,16 +16,12 @@
|
|
|
15
16
|
* What it does NOT do, on purpose: overlays (modeling chrome, hidden for a
|
|
16
17
|
* render anyway), selection outlines, the editor's supersampled `captureImage`
|
|
17
18
|
* for the Standard transform. Every transform here goes through the linear
|
|
18
|
-
* capture and the same
|
|
19
|
+
* capture and the same OCIO GPU display processors the editor uses.
|
|
19
20
|
*/
|
|
20
21
|
import * as THREE from 'three';
|
|
21
|
-
import type { CaptureRequest
|
|
22
|
+
import type { CaptureRequest } from '../protocol';
|
|
22
23
|
import type { PresentAnswer } from '../runtime';
|
|
23
|
-
import { AGX_LOOK_TABLES, agxEncodeFrame } from './blender-agx';
|
|
24
|
-
import { displayTableUrl } from './blender-display-lut';
|
|
25
|
-
import { filmicEncodeFrame } from './blender-filmic';
|
|
26
24
|
import { BlenderRuntimeView } from './blender-runtime-view';
|
|
27
|
-
import { standardEncodeFrame } from './blender-standard';
|
|
28
25
|
|
|
29
26
|
export interface PresenterOptions {
|
|
30
27
|
/** The canvas to render into; an OffscreenCanvas of 1x1 when absent. */
|
|
@@ -45,35 +42,6 @@ export interface Presenter {
|
|
|
45
42
|
|
|
46
43
|
const MAXIMUM_EDGE = 2048;
|
|
47
44
|
|
|
48
|
-
const displayTables = new Map<string, Promise<Uint16Array>>();
|
|
49
|
-
function displayTable(file: string): Promise<Uint16Array> {
|
|
50
|
-
let pending = displayTables.get(file);
|
|
51
|
-
if (!pending) {
|
|
52
|
-
pending = fetch(displayTableUrl(file)).then(async (response) => {
|
|
53
|
-
if (!response.ok)
|
|
54
|
-
throw new Error(`Blender display table ${file}: ${response.status} ${response.statusText}`);
|
|
55
|
-
return new Uint16Array(await response.arrayBuffer());
|
|
56
|
-
});
|
|
57
|
-
displayTables.set(file, pending);
|
|
58
|
-
}
|
|
59
|
-
return pending;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function displayTableFor(render: RenderRequest): string | null {
|
|
63
|
-
const look = render.look ?? 'None';
|
|
64
|
-
if (render.toneMapping === 'none') return null;
|
|
65
|
-
if (render.toneMapping === 'filmic') return 'filmic-srgb.lut';
|
|
66
|
-
if (render.toneMapping !== 'agx')
|
|
67
|
-
throw new Error(
|
|
68
|
-
`Blender's Khronos PBR Neutral view transform has no scene-linear implementation in the ` +
|
|
69
|
-
`browser (implemented: Standard, AgX, Filmic)`,
|
|
70
|
-
);
|
|
71
|
-
if (look === 'None') return 'agx-base-srgb.lut';
|
|
72
|
-
const file = AGX_LOOK_TABLES[look as keyof typeof AGX_LOOK_TABLES];
|
|
73
|
-
if (file === undefined) throw new Error(`Blender display transform has no table for look ${look}`);
|
|
74
|
-
return file;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
45
|
async function pngBase64(bytes: Uint8Array | Uint8ClampedArray, width: number, height: number): Promise<string> {
|
|
78
46
|
// The linear capture is bottom-up (GL); the image is top-down.
|
|
79
47
|
const stride = width * 4;
|
|
@@ -178,18 +146,11 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
|
|
|
178
146
|
render: { width, height, fov: render.fov, orthographic: render.orthographic },
|
|
179
147
|
});
|
|
180
148
|
|
|
181
|
-
const
|
|
182
|
-
const table = tableFile ? await displayTable(tableFile) : null;
|
|
183
|
-
const mappings: Record<string, THREE.ToneMapping> = {
|
|
184
|
-
none: THREE.NoToneMapping,
|
|
185
|
-
agx: THREE.AgXToneMapping,
|
|
186
|
-
neutral: THREE.NeutralToneMapping,
|
|
187
|
-
filmic: THREE.NoToneMapping,
|
|
188
|
-
};
|
|
149
|
+
const displayTransform = await createBlenderDisplayTransform(blenderDisplaySettingsForRender(render));
|
|
189
150
|
const previousMapping = renderer.toneMapping;
|
|
190
151
|
const previousExposure = renderer.toneMappingExposure;
|
|
191
|
-
renderer.toneMapping =
|
|
192
|
-
renderer.toneMappingExposure =
|
|
152
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
153
|
+
renderer.toneMappingExposure = 1;
|
|
193
154
|
const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
|
|
194
155
|
let effect: ReturnType<BlenderRuntimeView['createWorldVolumePass']>;
|
|
195
156
|
try {
|
|
@@ -217,16 +178,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
|
|
|
217
178
|
renderer.readRenderTargetPixels(resolved, 0, 0, width, height, pixels);
|
|
218
179
|
renderer.setRenderTarget(null);
|
|
219
180
|
}
|
|
220
|
-
const
|
|
221
|
-
const bytes =
|
|
222
|
-
render.toneMapping === 'none'
|
|
223
|
-
? standardEncodeFrame(pixels, count, render.exposure)
|
|
224
|
-
: render.toneMapping === 'filmic'
|
|
225
|
-
? filmicEncodeFrame(table!, pixels, count, render.exposure)
|
|
226
|
-
: agxEncodeFrame(table!, pixels, count, {
|
|
227
|
-
exposure: render.exposure,
|
|
228
|
-
composedLook: (render.look ?? 'None') !== 'None',
|
|
229
|
-
});
|
|
181
|
+
const bytes = displayTransform.encodeFrame(renderer, pixels, width, height);
|
|
230
182
|
const photograph: Record<string, unknown> = {
|
|
231
183
|
base64: await pngBase64(bytes, width, height),
|
|
232
184
|
mimeType: 'image/png',
|
|
@@ -245,6 +197,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
|
|
|
245
197
|
} finally {
|
|
246
198
|
effect?.dispose();
|
|
247
199
|
sceneTarget.dispose();
|
|
200
|
+
displayTransform.dispose();
|
|
248
201
|
renderer.toneMapping = previousMapping;
|
|
249
202
|
renderer.toneMappingExposure = previousExposure;
|
|
250
203
|
await view.setRendered(false);
|
|
@@ -100,6 +100,7 @@ export function worldField(
|
|
|
100
100
|
// Built ONCE per node: the model precomputes a 512x256 texture, and
|
|
101
101
|
// rebuilding it per direction would be a 65,536-pixel job per texel.
|
|
102
102
|
const field = skyField({
|
|
103
|
+
...(expression.sky_model === undefined ? {} : {model: expression.sky_model}),
|
|
103
104
|
sunElevation: expression.sun_elevation,
|
|
104
105
|
sunRotation: expression.sun_rotation,
|
|
105
106
|
altitude: expression.altitude,
|
|
@@ -253,6 +254,7 @@ export function collectSkyParameters(expression: WorldExpression): SkyParameters
|
|
|
253
254
|
switch (node.kind) {
|
|
254
255
|
case 'sky':
|
|
255
256
|
found.push({
|
|
257
|
+
...(node.sky_model === undefined ? {} : {model: node.sky_model}),
|
|
256
258
|
sunElevation: node.sun_elevation,
|
|
257
259
|
sunRotation: node.sun_rotation,
|
|
258
260
|
altitude: node.altitude,
|
package/browser/volter_three.py
CHANGED
|
@@ -318,13 +318,12 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
318
318
|
"fov": _vertical_extent(camera.data, int(width), int(height)),
|
|
319
319
|
"toneMapping": transform,
|
|
320
320
|
"exposure": float(2.0 ** view.exposure),
|
|
321
|
+
"gamma": float(view.gamma),
|
|
321
322
|
"orthographic": camera.data.type == "ORTHO",
|
|
322
323
|
"transparent": bool(scene.render.film_transparent),
|
|
323
324
|
}
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
# view itself -- the same table as no look at all.
|
|
327
|
-
if look not in ("None", "AgX - Base Contrast"):
|
|
325
|
+
# Preserve the full look name for the native OCIO display processor.
|
|
326
|
+
if look != "None":
|
|
328
327
|
render["look"] = look
|
|
329
328
|
# The capture's scene-referred frame rides along when asked for: a render
|
|
330
329
|
# result is what a render leaves, and it holds scene-linear pixels.
|
|
@@ -412,7 +411,7 @@ def _fill_result(engine, result, answer, png, width, height):
|
|
|
412
411
|
view transform over them wherever it shows or saves one (`write_still`, the
|
|
413
412
|
Image Editor), as it does over Cycles'. The photograph's PNG is the display
|
|
414
413
|
image -- the transform already ran -- so loading it put every render
|
|
415
|
-
through AgX or Filmic twice. Measured 2026-09-28 by
|
|
414
|
+
through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
|
|
416
415
|
render view: a background 53 against the viewport's 59, a shadow's blue 2
|
|
417
416
|
against 7. The same capture's linear frame goes in instead: half floats,
|
|
418
417
|
RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
|
package/browser/worker.ts
CHANGED
|
@@ -351,7 +351,13 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
351
351
|
// be `open_mainfile` on the document — which lives on the host's disk and is
|
|
352
352
|
// not in the engine's filesystem until it is staged. Every other call stages
|
|
353
353
|
// on its way in (`execute`); start had nothing to open before it did.
|
|
354
|
-
if (document)
|
|
354
|
+
if (document) {
|
|
355
|
+
// The document itself not reaching the engine is a refusal, not a footnote: the open below
|
|
356
|
+
// would fail on a missing file (or open a stale copy) without saying why.
|
|
357
|
+
const failed = await stageProjectFiles(started.files, project);
|
|
358
|
+
const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
|
|
359
|
+
if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
|
|
360
|
+
}
|
|
355
361
|
await loadCheckpoint?.("project-imported");
|
|
356
362
|
const banner = (await started.request({
|
|
357
363
|
op: 'start',
|
|
@@ -459,10 +465,11 @@ function say(line: string): void {
|
|
|
459
465
|
log('error', line);
|
|
460
466
|
}
|
|
461
467
|
|
|
462
|
-
async function projectIndex(project: string): Promise<ProjectFile[] |
|
|
463
|
-
const unreadable = (reason: string):
|
|
464
|
-
|
|
465
|
-
|
|
468
|
+
async function projectIndex(project: string): Promise<ProjectFile[] | string> {
|
|
469
|
+
const unreadable = (reason: string): string => {
|
|
470
|
+
const line = `The project's files are not readable from Python: ${reason}`;
|
|
471
|
+
say(line);
|
|
472
|
+
return line;
|
|
466
473
|
};
|
|
467
474
|
let answer: Response;
|
|
468
475
|
try {
|
|
@@ -515,9 +522,20 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
|
515
522
|
return info ? `${info.size}:${info.mtimeMs}` : '';
|
|
516
523
|
}
|
|
517
524
|
|
|
518
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Copy the project's changed files into the engine, and answer what could NOT be copied.
|
|
527
|
+
*
|
|
528
|
+
* THE ANSWER IS THE POINT (2026-10-06 audit: files that failed to copy were "reported to the
|
|
529
|
+
* console only"). `say` writes the page console once per line, which nobody running a script
|
|
530
|
+
* reads; a script that then opens or loads the missing file fails, or worse succeeds on a
|
|
531
|
+
* stale copy, with no word of why. So every call returns its failures, and the callers put
|
|
532
|
+
* them where the caller of THAT is looking: `execute` appends them to the script's answer, and
|
|
533
|
+
* `start` refuses when the document itself is among them.
|
|
534
|
+
*/
|
|
535
|
+
async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
|
|
519
536
|
const files = await projectIndex(project);
|
|
520
|
-
if (
|
|
537
|
+
if (typeof files === 'string') return [files];
|
|
538
|
+
const failed: string[] = [];
|
|
521
539
|
const present = new Set(files.map((file) => `${project}/${file.path}`));
|
|
522
540
|
for (const path of [...staged.keys()]) {
|
|
523
541
|
if (present.has(path)) continue;
|
|
@@ -539,6 +557,7 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
539
557
|
);
|
|
540
558
|
if (!answer.ok) {
|
|
541
559
|
say(`${file.path}: HTTP ${answer.status}`);
|
|
560
|
+
failed.push(`${file.path} (the session answered HTTP ${answer.status})`);
|
|
542
561
|
continue;
|
|
543
562
|
}
|
|
544
563
|
// The index can precede an append or rewrite. Stream the response's own size,
|
|
@@ -555,20 +574,34 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
555
574
|
}
|
|
556
575
|
staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
|
|
557
576
|
}
|
|
577
|
+
return failed;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The failures `stageProjectFiles` answered, as one paragraph a script's caller can read. */
|
|
581
|
+
function stagingNote(failed: readonly string[]): string {
|
|
582
|
+
return failed.length === 0 ? '' :
|
|
583
|
+
`\n\nThese project files could not be copied into Blender, so this code did not see their current contents: ${failed.join('; ')}`;
|
|
558
584
|
}
|
|
559
585
|
|
|
560
586
|
/** Everything under `root` this SESSION owns -- what the transport mirrors out.
|
|
561
587
|
* A host file staged in and NEVER WRITTEN is the host's and is not output;
|
|
562
588
|
* one Python has written over since it was staged is this session's output,
|
|
563
589
|
* the same as a path it created (see {@link staged}). */
|
|
564
|
-
async function listSessionFiles(files: BlenderFiles,
|
|
590
|
+
async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<FileEntry[]> {
|
|
591
|
+
// A host root reaches the engine in the engine's spelling, as the project itself does.
|
|
592
|
+
const root = projectMount(hostRoot);
|
|
565
593
|
const out: FileEntry[] = [];
|
|
594
|
+
// A root the engine has no directory at holds no output yet, and that is an honest empty
|
|
595
|
+
// listing. A directory that exists and cannot be read is NOT: until 2026-10-06 every readdir
|
|
596
|
+
// failure was answered as "no files", which is how a Windows root the engine never had (the
|
|
597
|
+
// host's `C:\…` instead of `/C:/…`) reported success while nothing reached disk.
|
|
598
|
+
if ((await files.stat(root)) === null) return out;
|
|
566
599
|
const walk = async (dir: string): Promise<void> => {
|
|
567
600
|
let names: string[];
|
|
568
601
|
try {
|
|
569
602
|
names = await files.readdir(dir);
|
|
570
|
-
} catch {
|
|
571
|
-
|
|
603
|
+
} catch (error) {
|
|
604
|
+
throw new Error(`Blender's files under ${dir} could not be listed, so its outputs there cannot be written back: ${String(error)}`);
|
|
572
605
|
}
|
|
573
606
|
for (const name of names) {
|
|
574
607
|
if (name === '.' || name === '..') continue;
|
|
@@ -641,13 +674,13 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
641
674
|
return ask({ op: 'history-step', token: request.token, direction: request.direction });
|
|
642
675
|
case 'execute':
|
|
643
676
|
// Code about to run may open a file the host wrote since the last call.
|
|
644
|
-
await stageProjectFiles(files, projectRoot);
|
|
645
677
|
{
|
|
678
|
+
const note = stagingNote(await stageProjectFiles(files, projectRoot));
|
|
646
679
|
const answer = await ask({ op: 'execute', code: request.code, history: request.history ?? true,
|
|
647
680
|
label: request.label ?? 'Blender Python' }) as {
|
|
648
681
|
error?: string; result: string;
|
|
649
682
|
};
|
|
650
|
-
return answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}
|
|
683
|
+
return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) + note;
|
|
651
684
|
}
|
|
652
685
|
case 'present':
|
|
653
686
|
// Straight through to `session.py`'s own `present` op — the worker adds
|