ohzi-core 13.2.2 → 14.0.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.
- package/build/index.mjs +1 -1
- package/build/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/camera_controller/CameraController.ts +6 -4
- package/src/dev_bridge/CameraBridge.ts +363 -0
- package/src/dev_bridge/CaptureService.ts +256 -0
- package/src/dev_bridge/CommandDispatcher.ts +114 -0
- package/src/dev_bridge/ConsoleBuffer.ts +286 -0
- package/src/dev_bridge/DevBridge.ts +195 -0
- package/src/dev_bridge/InputSynthesizer.ts +287 -0
- package/src/dev_bridge/PerformanceProbe.ts +118 -0
- package/src/dev_bridge/RenderModeRegistry.ts +66 -0
- package/src/dev_bridge/SceneEditor.ts +288 -0
- package/src/dev_bridge/SceneInspector.ts +388 -0
- package/src/dev_bridge/ViewNavigator.ts +101 -0
- package/src/dev_bridge/protocol.ts +31 -0
- package/src/index.ts +15 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ohzi-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.0",
|
|
4
4
|
"description": "OHZI Interactive Core Library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"source": "src/index.ts",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"build": "rollup -c",
|
|
22
22
|
"create-tag": "node tasks/app/create_tag.mjs",
|
|
23
23
|
"fix-syntax": "npx eslint src --fix || echo 'Done.'",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test-watch": "vitest",
|
|
24
26
|
"generate-types": "npx -p typescript tsc",
|
|
25
27
|
"upload": "yarn build && npm publish && yarn create-tag",
|
|
26
28
|
"create-view": "node ./tasks/create_view/create_view.mjs",
|
|
@@ -46,12 +48,12 @@
|
|
|
46
48
|
"registry": "https://registry.npmjs.org/"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
|
-
"three": "0.
|
|
51
|
+
"three": "0.183.0"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
53
55
|
"@types/node": "^25.0.3",
|
|
54
|
-
"@types/three": "0.
|
|
56
|
+
"@types/three": "0.183.0",
|
|
55
57
|
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
|
56
58
|
"@typescript-eslint/parser": "^8.18.2",
|
|
57
59
|
"eslint": "^8.57.0",
|
|
@@ -62,6 +64,7 @@
|
|
|
62
64
|
"rollup-plugin-terser": "^7.0.2",
|
|
63
65
|
"tslib": "^2.8.1",
|
|
64
66
|
"typescript": "^5.9.3",
|
|
65
|
-
"typescript-eslint": "^8.18.2"
|
|
67
|
+
"typescript-eslint": "^8.18.2",
|
|
68
|
+
"vitest": "^4.1.11"
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -185,13 +185,15 @@ export class CameraController
|
|
|
185
185
|
return this.normalized_zoom < 0.2;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
// Arguments are nullish-checked, not falsy-checked: a falsy check discards a
|
|
189
|
+
// value of 0, which made it impossible to return any angle to zero.
|
|
190
|
+
set_rotation(tilt?: number, orientation?: number, azimuth?: number)
|
|
189
191
|
{
|
|
190
192
|
this.old_orientation = this.current_orientation;
|
|
191
193
|
|
|
192
|
-
this.current_tilt = tilt
|
|
193
|
-
this.current_orientation = orientation
|
|
194
|
-
this.current_azimuth = azimuth
|
|
194
|
+
this.current_tilt = tilt ?? this.current_tilt;
|
|
195
|
+
this.current_orientation = orientation ?? this.current_orientation;
|
|
196
|
+
this.current_azimuth = azimuth ?? this.current_azimuth;
|
|
195
197
|
|
|
196
198
|
this.set_quaternion(this.build_rotation(this.current_tilt, this.current_orientation)); //, this.current_azimuth
|
|
197
199
|
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { InspectableObject } from './SceneInspector';
|
|
2
|
+
|
|
3
|
+
const PRECISION = 10000;
|
|
4
|
+
|
|
5
|
+
export interface OrbitalController
|
|
6
|
+
{
|
|
7
|
+
current_tilt: number;
|
|
8
|
+
current_orientation: number;
|
|
9
|
+
current_azimuth: number;
|
|
10
|
+
normalized_zoom: number;
|
|
11
|
+
min_zoom: number;
|
|
12
|
+
max_zoom: number;
|
|
13
|
+
reference_position: { x: number; y: number; z: number };
|
|
14
|
+
get_current_tilt(): number;
|
|
15
|
+
get_current_orientation(): number;
|
|
16
|
+
get_current_azimuth(): number;
|
|
17
|
+
set_normalized_zoom(zoom: number): void;
|
|
18
|
+
set_rotation(tilt?: number, orientation?: number, azimuth?: number): void;
|
|
19
|
+
focus_on_bounding_box(box: unknown, scale?: number): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BridgeCamera
|
|
23
|
+
{
|
|
24
|
+
position: { x: number; y: number; z: number };
|
|
25
|
+
fov?: number;
|
|
26
|
+
near?: number;
|
|
27
|
+
far?: number;
|
|
28
|
+
clear_color?: { getHexString?: () => string };
|
|
29
|
+
clear_alpha?: number;
|
|
30
|
+
updateProjectionMatrix?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ControllerState
|
|
34
|
+
{
|
|
35
|
+
tilt: number;
|
|
36
|
+
orientation: number;
|
|
37
|
+
azimuth: number;
|
|
38
|
+
zoom: number;
|
|
39
|
+
min_zoom: number;
|
|
40
|
+
max_zoom: number;
|
|
41
|
+
target: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CameraState
|
|
45
|
+
{
|
|
46
|
+
position: number[];
|
|
47
|
+
fov: number | null;
|
|
48
|
+
near: number | null;
|
|
49
|
+
far: number | null;
|
|
50
|
+
clear_color: string | null;
|
|
51
|
+
clear_alpha: number | null;
|
|
52
|
+
controller: ControllerState | null;
|
|
53
|
+
changed?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Changes arrive off the dev-bridge wire and are therefore untrusted.
|
|
57
|
+
export interface CameraChanges
|
|
58
|
+
{
|
|
59
|
+
tilt?: unknown;
|
|
60
|
+
orientation?: unknown;
|
|
61
|
+
azimuth?: unknown;
|
|
62
|
+
zoom?: unknown;
|
|
63
|
+
fov?: unknown;
|
|
64
|
+
target?: unknown;
|
|
65
|
+
position?: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FrameRequest
|
|
69
|
+
{
|
|
70
|
+
uuid?: unknown;
|
|
71
|
+
name?: unknown;
|
|
72
|
+
scale?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Speaks the CameraController's own idiom. The controller is orbital, driven by
|
|
76
|
+
// tilt, orientation, azimuth and a normalised zoom in degrees, and it rewrites
|
|
77
|
+
// the camera transform every update. Assigning camera.position directly while a
|
|
78
|
+
// controller is active would simply be overwritten on the next frame.
|
|
79
|
+
class CameraBridge
|
|
80
|
+
{
|
|
81
|
+
get(camera: BridgeCamera, controller: OrbitalController | null): CameraState
|
|
82
|
+
{
|
|
83
|
+
return this.describe(this.require_camera(camera), controller);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
set(camera: BridgeCamera, controller: OrbitalController | null, changes: CameraChanges): CameraState
|
|
87
|
+
{
|
|
88
|
+
const target_camera = this.require_camera(camera);
|
|
89
|
+
const changed: string[] = [];
|
|
90
|
+
|
|
91
|
+
if (controller !== null)
|
|
92
|
+
{
|
|
93
|
+
this.apply_orbit(controller, changes, changed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.apply_position(target_camera, controller, changes, changed);
|
|
97
|
+
this.apply_fov(target_camera, changes, changed);
|
|
98
|
+
|
|
99
|
+
const state = this.describe(target_camera, controller);
|
|
100
|
+
state.changed = changed;
|
|
101
|
+
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
frame(
|
|
106
|
+
camera: BridgeCamera,
|
|
107
|
+
controller: OrbitalController | null,
|
|
108
|
+
root: InspectableObject,
|
|
109
|
+
request: FrameRequest,
|
|
110
|
+
make_bounds: (object: InspectableObject) => unknown
|
|
111
|
+
): CameraState
|
|
112
|
+
{
|
|
113
|
+
const target_camera = this.require_camera(camera);
|
|
114
|
+
|
|
115
|
+
if (controller === null)
|
|
116
|
+
{
|
|
117
|
+
throw this.error('no_controller', 'The active scene has no camera_controller, so framing is unavailable.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const uuid = typeof request.uuid === 'string' ? request.uuid : null;
|
|
121
|
+
const name = typeof request.name === 'string' ? request.name : null;
|
|
122
|
+
|
|
123
|
+
if (uuid === null && name === null)
|
|
124
|
+
{
|
|
125
|
+
throw this.error('not_found', 'Pass a uuid or a name to identify the object to frame.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const found = this.find(root, uuid, name);
|
|
129
|
+
|
|
130
|
+
if (found === null)
|
|
131
|
+
{
|
|
132
|
+
const label = uuid === null ? `name '${name === null ? '' : name}'` : `uuid '${uuid}'`;
|
|
133
|
+
|
|
134
|
+
throw this.error('not_found', `No object matching ${label} in the current scene.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const scale = typeof request.scale === 'number' && Number.isFinite(request.scale) ? request.scale : 1;
|
|
138
|
+
|
|
139
|
+
controller.focus_on_bounding_box(make_bounds(found), scale);
|
|
140
|
+
|
|
141
|
+
const state = this.describe(target_camera, controller);
|
|
142
|
+
state.changed = ['framed'];
|
|
143
|
+
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Angles go through set_rotation, which is nullish-checked, so an omitted
|
|
148
|
+
// value keeps the current angle and an explicit 0 is applied. It also records
|
|
149
|
+
// old_orientation, which assigning the fields directly would skip.
|
|
150
|
+
private apply_orbit(controller: OrbitalController, changes: CameraChanges, changed: string[]): void
|
|
151
|
+
{
|
|
152
|
+
const tilt = this.number(changes.tilt);
|
|
153
|
+
const orientation = this.number(changes.orientation);
|
|
154
|
+
const azimuth = this.number(changes.azimuth);
|
|
155
|
+
|
|
156
|
+
if (tilt !== null || orientation !== null || azimuth !== null)
|
|
157
|
+
{
|
|
158
|
+
if (tilt !== null)
|
|
159
|
+
{
|
|
160
|
+
changed.push('tilt');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (orientation !== null)
|
|
164
|
+
{
|
|
165
|
+
changed.push('orientation');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (azimuth !== null)
|
|
169
|
+
{
|
|
170
|
+
changed.push('azimuth');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
controller.set_rotation(
|
|
174
|
+
tilt === null ? undefined : tilt,
|
|
175
|
+
orientation === null ? undefined : orientation,
|
|
176
|
+
azimuth === null ? undefined : azimuth
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const zoom = this.number(changes.zoom);
|
|
181
|
+
|
|
182
|
+
if (zoom !== null)
|
|
183
|
+
{
|
|
184
|
+
controller.set_normalized_zoom(zoom);
|
|
185
|
+
changed.push('zoom');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const target = this.triple(changes.target);
|
|
189
|
+
|
|
190
|
+
if (target !== null)
|
|
191
|
+
{
|
|
192
|
+
controller.reference_position.x = target[0];
|
|
193
|
+
controller.reference_position.y = target[1];
|
|
194
|
+
controller.reference_position.z = target[2];
|
|
195
|
+
changed.push('target');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private apply_position(
|
|
200
|
+
camera: BridgeCamera,
|
|
201
|
+
controller: OrbitalController | null,
|
|
202
|
+
changes: CameraChanges,
|
|
203
|
+
changed: string[]
|
|
204
|
+
): void
|
|
205
|
+
{
|
|
206
|
+
const position = this.triple(changes.position);
|
|
207
|
+
|
|
208
|
+
if (position === null)
|
|
209
|
+
{
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (controller !== null)
|
|
214
|
+
{
|
|
215
|
+
throw this.error(
|
|
216
|
+
'controller_owns_camera',
|
|
217
|
+
'A CameraController is driving this camera and would overwrite a raw position on the next frame. Use tilt, orientation, azimuth, zoom and target instead.'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
camera.position.x = position[0];
|
|
222
|
+
camera.position.y = position[1];
|
|
223
|
+
camera.position.z = position[2];
|
|
224
|
+
changed.push('position');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private apply_fov(camera: BridgeCamera, changes: CameraChanges, changed: string[]): void
|
|
228
|
+
{
|
|
229
|
+
const fov = this.number(changes.fov);
|
|
230
|
+
|
|
231
|
+
if (fov === null)
|
|
232
|
+
{
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
camera.fov = fov;
|
|
237
|
+
changed.push('fov');
|
|
238
|
+
|
|
239
|
+
if (typeof camera.updateProjectionMatrix === 'function')
|
|
240
|
+
{
|
|
241
|
+
camera.updateProjectionMatrix();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private describe(camera: BridgeCamera, controller: OrbitalController | null): CameraState
|
|
246
|
+
{
|
|
247
|
+
return {
|
|
248
|
+
position: [this.round(camera.position.x), this.round(camera.position.y), this.round(camera.position.z)],
|
|
249
|
+
fov: typeof camera.fov === 'number' ? camera.fov : null,
|
|
250
|
+
near: typeof camera.near === 'number' ? camera.near : null,
|
|
251
|
+
far: typeof camera.far === 'number' ? camera.far : null,
|
|
252
|
+
clear_color: this.hex(camera.clear_color),
|
|
253
|
+
clear_alpha: typeof camera.clear_alpha === 'number' ? camera.clear_alpha : null,
|
|
254
|
+
controller: controller === null ? null : {
|
|
255
|
+
tilt: controller.get_current_tilt(),
|
|
256
|
+
orientation: controller.get_current_orientation(),
|
|
257
|
+
azimuth: controller.get_current_azimuth(),
|
|
258
|
+
zoom: controller.normalized_zoom,
|
|
259
|
+
min_zoom: controller.min_zoom,
|
|
260
|
+
max_zoom: controller.max_zoom,
|
|
261
|
+
target: [
|
|
262
|
+
this.round(controller.reference_position.x),
|
|
263
|
+
this.round(controller.reference_position.y),
|
|
264
|
+
this.round(controller.reference_position.z)
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private find(node: InspectableObject, uuid: string | null, name: string | null): InspectableObject | null
|
|
271
|
+
{
|
|
272
|
+
const matches = uuid !== null ? node.uuid === uuid : node.name === name;
|
|
273
|
+
|
|
274
|
+
if (matches)
|
|
275
|
+
{
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
280
|
+
|
|
281
|
+
for (const child of children)
|
|
282
|
+
{
|
|
283
|
+
const found = this.find(child, uuid, name);
|
|
284
|
+
|
|
285
|
+
if (found !== null)
|
|
286
|
+
{
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private require_camera(camera: BridgeCamera): BridgeCamera
|
|
295
|
+
{
|
|
296
|
+
if (typeof camera !== 'object' || camera === null || typeof camera.position !== 'object')
|
|
297
|
+
{
|
|
298
|
+
throw this.error('no_camera', 'No camera is active. CameraManager.current is not set yet.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return camera;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private hex(color: { getHexString?: () => string } | undefined): string | null
|
|
305
|
+
{
|
|
306
|
+
if (typeof color !== 'object' || color === null || typeof color.getHexString !== 'function')
|
|
307
|
+
{
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return `#${color.getHexString()}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private number(value: unknown): number | null
|
|
315
|
+
{
|
|
316
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private triple(value: unknown): number[] | null
|
|
320
|
+
{
|
|
321
|
+
if (!Array.isArray(value))
|
|
322
|
+
{
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const supplied = value as unknown[];
|
|
327
|
+
const out: number[] = [];
|
|
328
|
+
|
|
329
|
+
for (let i = 0; i < 3; i++)
|
|
330
|
+
{
|
|
331
|
+
const entry = this.number(supplied[i]);
|
|
332
|
+
|
|
333
|
+
if (entry === null)
|
|
334
|
+
{
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
out.push(entry);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private round(value: unknown): number
|
|
345
|
+
{
|
|
346
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
347
|
+
{
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Math.round(value * PRECISION) / PRECISION;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private error(code: string, message: string): Error
|
|
355
|
+
{
|
|
356
|
+
const error: Error & { code?: string } = new Error(message);
|
|
357
|
+
error.code = code;
|
|
358
|
+
|
|
359
|
+
return error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export { CameraBridge };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const MAX_DIMENSION = 8192;
|
|
2
|
+
const BASE64_CHUNK_SIZE = 8192;
|
|
3
|
+
|
|
4
|
+
// A retina canvas reports physical pixels, so a fast capture is 4x the pixels
|
|
5
|
+
// the developer is actually looking at. Every one of those bytes is base64'd
|
|
6
|
+
// into the model's context, so cap the long edge unless asked otherwise.
|
|
7
|
+
const DEFAULT_MAX_FAST_SIZE = 1280;
|
|
8
|
+
|
|
9
|
+
export interface CaptureCanvas
|
|
10
|
+
{
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
toBlob(callback: (blob: Blob | null) => void, type?: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ScratchContext
|
|
17
|
+
{
|
|
18
|
+
drawImage(source: unknown, x: number, y: number, width: number, height: number): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScratchCanvas
|
|
22
|
+
{
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
getContext(type: '2d'): ScratchContext | null;
|
|
26
|
+
toBlob(callback: (blob: Blob | null) => void, type?: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CaptureGraphics
|
|
30
|
+
{
|
|
31
|
+
canvas: CaptureCanvas;
|
|
32
|
+
take_screenshot(callback: (blob: Blob | null) => void, width?: number, height?: number): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Values arrive straight off the dev-bridge wire, so they are untrusted and
|
|
36
|
+
// every field is validated before use.
|
|
37
|
+
export interface CaptureOptions
|
|
38
|
+
{
|
|
39
|
+
mode?: unknown;
|
|
40
|
+
width?: unknown;
|
|
41
|
+
height?: unknown;
|
|
42
|
+
max_size?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CaptureResult
|
|
46
|
+
{
|
|
47
|
+
mode: 'fast' | 'hires';
|
|
48
|
+
mime_type: string;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
bytes: number;
|
|
52
|
+
data: string;
|
|
53
|
+
scaled_from?: { width: number; height: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Holds no OHZI singletons: the consumer injects the graphics surface and a
|
|
57
|
+
// camera predicate, so this stays testable without a WebGPU context.
|
|
58
|
+
class CaptureService
|
|
59
|
+
{
|
|
60
|
+
private graphics: CaptureGraphics;
|
|
61
|
+
private has_camera: () => boolean;
|
|
62
|
+
private create_canvas: () => ScratchCanvas;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
graphics: CaptureGraphics,
|
|
66
|
+
has_camera: () => boolean,
|
|
67
|
+
create_canvas: () => ScratchCanvas = () => document.createElement('canvas') as unknown as ScratchCanvas
|
|
68
|
+
)
|
|
69
|
+
{
|
|
70
|
+
this.graphics = graphics;
|
|
71
|
+
this.has_camera = has_camera;
|
|
72
|
+
this.create_canvas = create_canvas;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async capture(options: CaptureOptions): Promise<CaptureResult>
|
|
76
|
+
{
|
|
77
|
+
if (options.mode === 'hires')
|
|
78
|
+
{
|
|
79
|
+
return this.capture_hires(options);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return this.capture_fast(options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Reads the live canvas. Valid because the renderer is created with
|
|
86
|
+
// preserveDrawingBuffer: true, so the backbuffer still holds the last frame.
|
|
87
|
+
private async capture_fast(options: CaptureOptions): Promise<CaptureResult>
|
|
88
|
+
{
|
|
89
|
+
const canvas = this.graphics.canvas;
|
|
90
|
+
const target = this.scaled_size(canvas.width, canvas.height, options.max_size);
|
|
91
|
+
|
|
92
|
+
if (target !== null)
|
|
93
|
+
{
|
|
94
|
+
const scratch = this.draw_scaled(canvas, target.width, target.height);
|
|
95
|
+
|
|
96
|
+
if (scratch !== null)
|
|
97
|
+
{
|
|
98
|
+
const scaled_blob = await this.request_blob((callback) => scratch.toBlob(callback, 'image/png'));
|
|
99
|
+
const scaled = await this.build_result('fast', target.width, target.height, scaled_blob);
|
|
100
|
+
|
|
101
|
+
scaled.scaled_from = { width: canvas.width, height: canvas.height };
|
|
102
|
+
|
|
103
|
+
return scaled;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const blob = await this.request_blob((callback) => canvas.toBlob(callback, 'image/png'));
|
|
108
|
+
|
|
109
|
+
return this.build_result('fast', canvas.width, canvas.height, blob);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Returns null when no downscale should happen: an explicit opt-out, or a
|
|
113
|
+
// canvas already within the cap. Never upscales.
|
|
114
|
+
private scaled_size(width: number, height: number, max_size: unknown): { width: number; height: number } | null
|
|
115
|
+
{
|
|
116
|
+
const cap = typeof max_size === 'number' ? max_size : DEFAULT_MAX_FAST_SIZE;
|
|
117
|
+
|
|
118
|
+
if (!Number.isFinite(cap) || cap <= 0)
|
|
119
|
+
{
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const long_edge = Math.max(width, height);
|
|
124
|
+
|
|
125
|
+
if (long_edge <= cap)
|
|
126
|
+
{
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ratio = cap / long_edge;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
width: Math.max(1, Math.round(width * ratio)),
|
|
134
|
+
height: Math.max(1, Math.round(height * ratio))
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Returns null if no 2D context is available, in which case the caller falls
|
|
139
|
+
// back to a native capture. A large image beats no image.
|
|
140
|
+
private draw_scaled(source: unknown, width: number, height: number): ScratchCanvas | null
|
|
141
|
+
{
|
|
142
|
+
const scratch = this.create_canvas();
|
|
143
|
+
|
|
144
|
+
scratch.width = width;
|
|
145
|
+
scratch.height = height;
|
|
146
|
+
|
|
147
|
+
const context = scratch.getContext('2d');
|
|
148
|
+
|
|
149
|
+
if (context === null)
|
|
150
|
+
{
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
context.drawImage(source, 0, 0, width, height);
|
|
155
|
+
|
|
156
|
+
return scratch;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async capture_hires(options: CaptureOptions): Promise<CaptureResult>
|
|
160
|
+
{
|
|
161
|
+
// Graphics.take_screenshot dereferences CameraManager.current directly and
|
|
162
|
+
// would throw a TypeError, so refuse with a code the caller can act on.
|
|
163
|
+
if (!this.has_camera())
|
|
164
|
+
{
|
|
165
|
+
throw this.error('no_camera', 'A hires capture needs an active camera; CameraManager.current is not set yet.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const canvas = this.graphics.canvas;
|
|
169
|
+
const width = this.dimension(options.width, canvas.width);
|
|
170
|
+
const height = this.dimension(options.height, canvas.height);
|
|
171
|
+
|
|
172
|
+
const blob = await this.request_blob((callback) => this.graphics.take_screenshot(callback, width, height));
|
|
173
|
+
|
|
174
|
+
return this.build_result('hires', width, height, blob);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private dimension(value: unknown, fallback: number): number
|
|
178
|
+
{
|
|
179
|
+
if (typeof value === 'number')
|
|
180
|
+
{
|
|
181
|
+
return this.clamp(value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.clamp(fallback);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// take_screenshot renders ceil(w/1024) * ceil(h/1024) tiles, so an unbounded
|
|
188
|
+
// size would stall the frame for a very long time.
|
|
189
|
+
private clamp(value: number): number
|
|
190
|
+
{
|
|
191
|
+
if (!Number.isFinite(value) || value < 1)
|
|
192
|
+
{
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Math.min(Math.floor(value), MAX_DIMENSION);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private request_blob(request: (callback: (blob: Blob | null) => void) => void): Promise<Blob>
|
|
200
|
+
{
|
|
201
|
+
return new Promise((resolve, reject) =>
|
|
202
|
+
{
|
|
203
|
+
request((blob) =>
|
|
204
|
+
{
|
|
205
|
+
if (blob === null)
|
|
206
|
+
{
|
|
207
|
+
reject(this.error('capture_failed', 'The canvas produced no image data.'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
resolve(blob);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async build_result(mode: 'fast' | 'hires', width: number, height: number, blob: Blob): Promise<CaptureResult>
|
|
217
|
+
{
|
|
218
|
+
const buffer = await blob.arrayBuffer();
|
|
219
|
+
const bytes = new Uint8Array(buffer);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
mode,
|
|
223
|
+
// Graphics asks toBlob for the malformed type 'image/png;base64;', which
|
|
224
|
+
// browsers fall back to PNG for. Report what the bytes actually are.
|
|
225
|
+
mime_type: 'image/png',
|
|
226
|
+
width,
|
|
227
|
+
height,
|
|
228
|
+
bytes: bytes.length,
|
|
229
|
+
data: this.to_base64(bytes)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private to_base64(bytes: Uint8Array): string
|
|
234
|
+
{
|
|
235
|
+
let binary = '';
|
|
236
|
+
|
|
237
|
+
// Chunked: spreading a full-resolution capture into String.fromCharCode
|
|
238
|
+
// overflows the call stack.
|
|
239
|
+
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE)
|
|
240
|
+
{
|
|
241
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return btoa(binary);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private error(code: string, message: string): Error
|
|
248
|
+
{
|
|
249
|
+
const error: Error & { code?: string } = new Error(message);
|
|
250
|
+
error.code = code;
|
|
251
|
+
|
|
252
|
+
return error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export { CaptureService };
|