@empty-sekai/sekai-custom-profile-sdk 0.3.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/LICENSE +235 -0
- package/LICENSE-EXCEPTION +32 -0
- package/NOTICE +11 -0
- package/README.en.md +456 -0
- package/README.md +458 -0
- package/dist/allium_renderer_wasm.js +14 -0
- package/dist/allium_renderer_wasm.wasm +0 -0
- package/dist/authoring.d.ts +14 -0
- package/dist/authoring.js +34 -0
- package/dist/cache/glyphPersistentCache.d.ts +145 -0
- package/dist/cache/glyphPersistentCache.js +346 -0
- package/dist/cache/indexedDbGlyphRecordStore.d.ts +26 -0
- package/dist/cache/indexedDbGlyphRecordStore.js +214 -0
- package/dist/cache/sessionImageResourceCache.d.ts +39 -0
- package/dist/cache/sessionImageResourceCache.js +156 -0
- package/dist/emscripten.d.ts +11 -0
- package/dist/emscripten.js +1 -0
- package/dist/fontProvider.d.ts +36 -0
- package/dist/fontProvider.js +84 -0
- package/dist/fontSdfAtlas.d.ts +92 -0
- package/dist/fontSdfAtlas.js +296 -0
- package/dist/gpu/browserSemanticResources.d.ts +64 -0
- package/dist/gpu/browserSemanticResources.js +232 -0
- package/dist/gpu/generalTextRenderPlacement.d.ts +8 -0
- package/dist/gpu/generalTextRenderPlacement.js +131 -0
- package/dist/gpu/previewTransformTextureLayout.d.ts +1 -0
- package/dist/gpu/previewTransformTextureLayout.js +12 -0
- package/dist/gpu/semanticCommandGeometry.d.ts +21 -0
- package/dist/gpu/semanticCommandGeometry.js +226 -0
- package/dist/gpu/semanticCommandPlanner.d.ts +131 -0
- package/dist/gpu/semanticCommandPlanner.js +203 -0
- package/dist/gpu/semanticTextGlyphBridge.d.ts +3 -0
- package/dist/gpu/semanticTextGlyphBridge.js +22 -0
- package/dist/gpu/semanticWebglSceneRenderer.d.ts +70 -0
- package/dist/gpu/semanticWebglSceneRenderer.js +211 -0
- package/dist/gpu/slotRanges.d.ts +5 -0
- package/dist/gpu/slotRanges.js +14 -0
- package/dist/gpu/webglSdfAtlasTexture.d.ts +17 -0
- package/dist/gpu/webglSdfAtlasTexture.js +72 -0
- package/dist/gpu/webglSdfGlyphPipeline.d.ts +26 -0
- package/dist/gpu/webglSdfGlyphPipeline.js +267 -0
- package/dist/gpu/webglSemanticCommandExecutor.d.ts +79 -0
- package/dist/gpu/webglSemanticCommandExecutor.js +686 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +8 -0
- package/dist/interaction/numericTextRegions.d.ts +46 -0
- package/dist/interaction/numericTextRegions.js +44 -0
- package/dist/localizationProvider.d.ts +33 -0
- package/dist/localizationProvider.js +74 -0
- package/dist/originPrebuiltSdfAtlasPackage.d.ts +39 -0
- package/dist/originPrebuiltSdfAtlasPackage.js +316 -0
- package/dist/prebuiltSdfAtlas.d.ts +36 -0
- package/dist/prebuiltSdfAtlas.js +261 -0
- package/dist/protocol.d.ts +456 -0
- package/dist/protocol.js +1 -0
- package/dist/renderer.d.ts +218 -0
- package/dist/renderer.js +641 -0
- package/dist/resourceProvider.d.ts +24 -0
- package/dist/resourceProvider.js +41 -0
- package/dist/telemetry/rendererTelemetry.d.ts +158 -0
- package/dist/telemetry/rendererTelemetry.js +232 -0
- package/dist/third-party/freetype/FTL.txt +169 -0
- package/dist/types/atlas.d.ts +90 -0
- package/dist/types/atlas.js +1 -0
- package/dist/types/authoring.d.ts +81 -0
- package/dist/types/authoring.js +1 -0
- package/dist/types/core.d.ts +128 -0
- package/dist/types/core.js +1 -0
- package/dist/types/freeType.d.ts +54 -0
- package/dist/types/freeType.js +13 -0
- package/dist/types/glyph.d.ts +33 -0
- package/dist/types/glyph.js +1 -0
- package/dist/types/layout.d.ts +25 -0
- package/dist/types/layout.js +1 -0
- package/dist/types/text.d.ts +28 -0
- package/dist/types/text.js +1 -0
- package/dist/worker/glyphWorkScheduler.d.ts +29 -0
- package/dist/worker/glyphWorkScheduler.js +107 -0
- package/dist/worker-client.d.ts +114 -0
- package/dist/worker-client.js +413 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +708 -0
- package/package.json +46 -0
- package/src/atlas.rs +652 -0
- package/src/authoring.ts +42 -0
- package/src/authoring_runtime.rs +303 -0
- package/src/cache/glyphPersistentCache.ts +463 -0
- package/src/cache/indexedDbGlyphRecordStore.ts +238 -0
- package/src/cache/sessionImageResourceCache.ts +186 -0
- package/src/edt.rs +97 -0
- package/src/emscripten.ts +17 -0
- package/src/fontProvider.ts +127 -0
- package/src/fontSdfAtlas.ts +431 -0
- package/src/geometry.rs +149 -0
- package/src/glyph_plan.rs +226 -0
- package/src/gpu/browserSemanticResources.ts +294 -0
- package/src/gpu/generalTextRenderPlacement.ts +175 -0
- package/src/gpu/previewTransformTextureLayout.ts +12 -0
- package/src/gpu/semanticCommandGeometry.ts +260 -0
- package/src/gpu/semanticCommandPlanner.ts +280 -0
- package/src/gpu/semanticTextGlyphBridge.ts +35 -0
- package/src/gpu/semanticWebglSceneRenderer.ts +261 -0
- package/src/gpu/slotRanges.ts +15 -0
- package/src/gpu/webglSdfAtlasTexture.ts +69 -0
- package/src/gpu/webglSdfGlyphPipeline.ts +299 -0
- package/src/gpu/webglSemanticCommandExecutor.ts +739 -0
- package/src/index.ts +84 -0
- package/src/interaction/numericTextRegions.ts +80 -0
- package/src/layout.rs +1882 -0
- package/src/lib.rs +1443 -0
- package/src/localizationProvider.ts +113 -0
- package/src/main.rs +55 -0
- package/src/masterdata_runtime.rs +496 -0
- package/src/originPrebuiltSdfAtlasPackage.ts +403 -0
- package/src/prebuiltSdfAtlas.ts +306 -0
- package/src/protocol.ts +150 -0
- package/src/renderer.ts +792 -0
- package/src/resourceProvider.ts +69 -0
- package/src/scene.rs +665 -0
- package/src/telemetry/rendererTelemetry.ts +358 -0
- package/src/types/atlas.ts +80 -0
- package/src/types/authoring.ts +85 -0
- package/src/types/core.ts +110 -0
- package/src/types/freeType.ts +63 -0
- package/src/types/glyph.ts +33 -0
- package/src/types/layout.ts +28 -0
- package/src/types/text.ts +28 -0
- package/src/worker/glyphWorkScheduler.ts +130 -0
- package/src/worker-client.ts +484 -0
- package/src/worker.ts +895 -0
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BrowserSemanticResourceManager,
|
|
3
|
+
type BrowserImageSource,
|
|
4
|
+
type BrowserSemanticResourceSet,
|
|
5
|
+
} from "./gpu/browserSemanticResources.js";
|
|
6
|
+
import {
|
|
7
|
+
profileResourceDescriptors,
|
|
8
|
+
type ResourceProvider,
|
|
9
|
+
} from "./resourceProvider.js";
|
|
10
|
+
import {
|
|
11
|
+
LocalizationProviderManager,
|
|
12
|
+
type LocalizationProvider,
|
|
13
|
+
type LocalizationRequest,
|
|
14
|
+
} from "./localizationProvider.js";
|
|
15
|
+
import {
|
|
16
|
+
FontProviderManager,
|
|
17
|
+
type FontProvider,
|
|
18
|
+
type FontRequest,
|
|
19
|
+
} from "./fontProvider.js";
|
|
20
|
+
import {
|
|
21
|
+
semanticCommandPlanFromCoreSnapshot,
|
|
22
|
+
} from "./gpu/semanticCommandPlanner.js";
|
|
23
|
+
import { SemanticWebglSceneRenderer } from "./gpu/semanticWebglSceneRenderer.js";
|
|
24
|
+
import type { NumericTextRegion } from "./interaction/numericTextRegions.js";
|
|
25
|
+
import {
|
|
26
|
+
buildSdfAtlas,
|
|
27
|
+
disposeWorkerAtlasSessions,
|
|
28
|
+
type FontSource,
|
|
29
|
+
type GlyphRequest,
|
|
30
|
+
type PersistentCacheSelection,
|
|
31
|
+
type SdfAtlas,
|
|
32
|
+
type SdfBackend,
|
|
33
|
+
} from "./fontSdfAtlas.js";
|
|
34
|
+
import {
|
|
35
|
+
buildPrebuiltSdfAtlas,
|
|
36
|
+
type PrebuiltSdfAtlasProvider,
|
|
37
|
+
} from "./prebuiltSdfAtlas.js";
|
|
38
|
+
import {
|
|
39
|
+
RendererMasterData,
|
|
40
|
+
RendererScene,
|
|
41
|
+
RendererWorkerClient,
|
|
42
|
+
type RendererWorkerClientOptions,
|
|
43
|
+
} from "./worker-client.js";
|
|
44
|
+
import type {
|
|
45
|
+
CoreSceneDelta,
|
|
46
|
+
CoreSceneDump,
|
|
47
|
+
StableId,
|
|
48
|
+
} from "./types/core.js";
|
|
49
|
+
import {
|
|
50
|
+
RendererRuntimeTelemetry,
|
|
51
|
+
type RendererAtlasSummary,
|
|
52
|
+
type RendererTelemetryOptions,
|
|
53
|
+
type RendererRuntimeSnapshot,
|
|
54
|
+
} from "./telemetry/rendererTelemetry.js";
|
|
55
|
+
|
|
56
|
+
const CARD_WIDTH = 1830;
|
|
57
|
+
const CARD_HEIGHT = 812;
|
|
58
|
+
|
|
59
|
+
export type BrowserRendererOptions = RendererWorkerClientOptions & {
|
|
60
|
+
canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
61
|
+
region: string;
|
|
62
|
+
resourceProvider: ResourceProvider;
|
|
63
|
+
resourceConcurrency?: number;
|
|
64
|
+
localizationProvider?: LocalizationProvider;
|
|
65
|
+
localizationConcurrency?: number;
|
|
66
|
+
fontProvider?: FontProvider;
|
|
67
|
+
fontConcurrency?: number;
|
|
68
|
+
prebuiltSdfAtlasProvider?: PrebuiltSdfAtlasProvider;
|
|
69
|
+
telemetry?: RendererTelemetryOptions;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type MasterDataTableRequest = {
|
|
73
|
+
table: string;
|
|
74
|
+
region: string;
|
|
75
|
+
revision: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type MasterDataTableLoader = (
|
|
79
|
+
request: MasterDataTableRequest,
|
|
80
|
+
context: { signal: AbortSignal },
|
|
81
|
+
) => Promise<unknown>;
|
|
82
|
+
|
|
83
|
+
export type ProfileSceneCreateOptions = {
|
|
84
|
+
masterData: RendererMasterData;
|
|
85
|
+
documentKey: string;
|
|
86
|
+
card: unknown;
|
|
87
|
+
profile?: unknown;
|
|
88
|
+
locale?: string;
|
|
89
|
+
frameMode?: "final" | "animate";
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
sdf?: {
|
|
92
|
+
backend?: SdfBackend;
|
|
93
|
+
supersample?: number;
|
|
94
|
+
persistence?: PersistentCacheSelection;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* WebGL2-only browser renderer. Rust/WASM scene, text layout, and glyph work
|
|
100
|
+
* stay in one worker; the main thread owns resource decoding and GPU state.
|
|
101
|
+
*/
|
|
102
|
+
export class BrowserRenderer {
|
|
103
|
+
private readonly resources: BrowserSemanticResourceManager;
|
|
104
|
+
private readonly localizations: LocalizationProviderManager | null;
|
|
105
|
+
private readonly providedFonts: FontProviderManager | null;
|
|
106
|
+
private readonly fonts = new Map<string, FontSource>();
|
|
107
|
+
private readonly fontRegistrations = new Map<string, Promise<void>>();
|
|
108
|
+
private readonly scenes = new Set<BrowserScene>();
|
|
109
|
+
private readonly canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
110
|
+
private readonly telemetryOptions: RendererTelemetryOptions;
|
|
111
|
+
private contextLost = false;
|
|
112
|
+
private restorePromise: Promise<void> | null = null;
|
|
113
|
+
private readonly lifetime = new AbortController();
|
|
114
|
+
private destroyed = false;
|
|
115
|
+
|
|
116
|
+
private constructor(
|
|
117
|
+
private gl: WebGL2RenderingContext,
|
|
118
|
+
private readonly worker: RendererWorkerClient,
|
|
119
|
+
private readonly region: string,
|
|
120
|
+
canvas: HTMLCanvasElement | OffscreenCanvas,
|
|
121
|
+
resourceProvider: ResourceProvider,
|
|
122
|
+
resourceConcurrency?: number,
|
|
123
|
+
localizationProvider?: LocalizationProvider,
|
|
124
|
+
localizationConcurrency?: number,
|
|
125
|
+
fontProvider?: FontProvider,
|
|
126
|
+
fontConcurrency?: number,
|
|
127
|
+
private readonly prebuiltSdfAtlasProvider?: PrebuiltSdfAtlasProvider,
|
|
128
|
+
telemetryOptions: RendererTelemetryOptions = {},
|
|
129
|
+
) {
|
|
130
|
+
this.canvas = canvas;
|
|
131
|
+
this.telemetryOptions = telemetryOptions;
|
|
132
|
+
this.resources = new BrowserSemanticResourceManager({
|
|
133
|
+
provider: resourceProvider,
|
|
134
|
+
concurrency: resourceConcurrency,
|
|
135
|
+
});
|
|
136
|
+
this.localizations = localizationProvider
|
|
137
|
+
? new LocalizationProviderManager({
|
|
138
|
+
provider: localizationProvider,
|
|
139
|
+
concurrency: localizationConcurrency,
|
|
140
|
+
})
|
|
141
|
+
: null;
|
|
142
|
+
this.providedFonts = fontProvider
|
|
143
|
+
? new FontProviderManager({
|
|
144
|
+
provider: fontProvider,
|
|
145
|
+
concurrency: fontConcurrency,
|
|
146
|
+
})
|
|
147
|
+
: null;
|
|
148
|
+
if (isHtmlCanvas(canvas)) {
|
|
149
|
+
canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
|
150
|
+
canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static async create(options: BrowserRendererOptions): Promise<BrowserRenderer> {
|
|
155
|
+
const gl = options.canvas.getContext("webgl2", {
|
|
156
|
+
alpha: true,
|
|
157
|
+
antialias: true,
|
|
158
|
+
depth: false,
|
|
159
|
+
premultipliedAlpha: true,
|
|
160
|
+
preserveDrawingBuffer: false,
|
|
161
|
+
}) as WebGL2RenderingContext | null;
|
|
162
|
+
if (!gl) throw new BrowserRendererError("WEBGL2_UNAVAILABLE", "WebGL2 is required by renderer-wasm 0.2");
|
|
163
|
+
options.canvas.width = CARD_WIDTH;
|
|
164
|
+
options.canvas.height = CARD_HEIGHT;
|
|
165
|
+
const worker = await RendererWorkerClient.create(options);
|
|
166
|
+
return new BrowserRenderer(
|
|
167
|
+
gl,
|
|
168
|
+
worker,
|
|
169
|
+
options.region,
|
|
170
|
+
options.canvas,
|
|
171
|
+
options.resourceProvider,
|
|
172
|
+
options.resourceConcurrency,
|
|
173
|
+
options.localizationProvider,
|
|
174
|
+
options.localizationConcurrency,
|
|
175
|
+
options.fontProvider,
|
|
176
|
+
options.fontConcurrency,
|
|
177
|
+
options.prebuiltSdfAtlasProvider,
|
|
178
|
+
options.telemetry,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async createProfileScene(options: ProfileSceneCreateOptions): Promise<BrowserScene> {
|
|
183
|
+
this.assertAlive();
|
|
184
|
+
let resources: BrowserSemanticResourceSet | null = null;
|
|
185
|
+
let core: RendererScene | null = null;
|
|
186
|
+
let atlas: SdfAtlas | null = null;
|
|
187
|
+
const abort = combinedAbortSignal(this.lifetime.signal, options.signal);
|
|
188
|
+
try {
|
|
189
|
+
const locale = options.locale ?? this.region;
|
|
190
|
+
let localizedText: Record<string, string> | undefined;
|
|
191
|
+
if (this.localizations) {
|
|
192
|
+
const demandPreparation = await options.masterData.prepareProfile({
|
|
193
|
+
documentKey: options.documentKey,
|
|
194
|
+
card: options.card,
|
|
195
|
+
profile: options.profile,
|
|
196
|
+
locale,
|
|
197
|
+
demandOnly: true,
|
|
198
|
+
});
|
|
199
|
+
localizedText = await this.localizations.resolve(
|
|
200
|
+
preparedLocalizationDemands(demandPreparation),
|
|
201
|
+
abort.signal,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (this.providedFonts) {
|
|
205
|
+
const fontPreparation = await options.masterData.prepareProfile({
|
|
206
|
+
documentKey: options.documentKey,
|
|
207
|
+
card: options.card,
|
|
208
|
+
profile: options.profile,
|
|
209
|
+
locale,
|
|
210
|
+
localizedText,
|
|
211
|
+
fontDemandOnly: true,
|
|
212
|
+
});
|
|
213
|
+
const missing = preparedFontDemands(fontPreparation, this.region)
|
|
214
|
+
.filter((request) => !this.fonts.has(request.family));
|
|
215
|
+
const provided = await this.providedFonts.resolve(missing, abort.signal);
|
|
216
|
+
await Promise.all([...provided].map(([family, bytes]) => this.registerFont({ family, bytes })));
|
|
217
|
+
}
|
|
218
|
+
const preparation = await options.masterData.prepareProfile({
|
|
219
|
+
documentKey: options.documentKey,
|
|
220
|
+
card: options.card,
|
|
221
|
+
profile: options.profile,
|
|
222
|
+
locale,
|
|
223
|
+
localizedText,
|
|
224
|
+
});
|
|
225
|
+
const glyphRequests = preparedGlyphRequests(preparation);
|
|
226
|
+
if (glyphRequests.length === 0) {
|
|
227
|
+
atlas = null;
|
|
228
|
+
} else {
|
|
229
|
+
atlas = this.prebuiltSdfAtlasProvider
|
|
230
|
+
? await buildPrebuiltSdfAtlas(this.prebuiltSdfAtlasProvider, glyphRequests, abort.signal)
|
|
231
|
+
: null;
|
|
232
|
+
atlas ??= await buildSdfAtlas(
|
|
233
|
+
requiredFontSources(glyphRequests, this.fonts),
|
|
234
|
+
glyphRequests,
|
|
235
|
+
{ worker: this.worker, persistence: options.sdf?.persistence ?? "origin" },
|
|
236
|
+
options.sdf?.supersample,
|
|
237
|
+
options.sdf?.backend,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const requestedResources = profileResourceDescriptors(preparation);
|
|
241
|
+
let acquired: BrowserSemanticResourceSet;
|
|
242
|
+
acquired = await this.resources.acquire(requestedResources, abort.signal);
|
|
243
|
+
resources = acquired;
|
|
244
|
+
const compiled = await options.masterData.createProfileScene({
|
|
245
|
+
documentKey: options.documentKey,
|
|
246
|
+
card: options.card,
|
|
247
|
+
profile: options.profile,
|
|
248
|
+
locale,
|
|
249
|
+
localizedText,
|
|
250
|
+
frameMode: options.frameMode ?? "animate",
|
|
251
|
+
resourceMetrics: requestedResources.map((resource) => {
|
|
252
|
+
const image = acquired.sources.get(resource.id);
|
|
253
|
+
return {
|
|
254
|
+
namespace: resource.namespace,
|
|
255
|
+
key: resource.key,
|
|
256
|
+
width: image?.width ?? 0,
|
|
257
|
+
height: image?.height ?? 0,
|
|
258
|
+
available: acquired.availability.get(resource.id) ?? false,
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
}, preparedLayoutRequest(preparation, atlas));
|
|
262
|
+
core = compiled.scene;
|
|
263
|
+
const plan = semanticCommandPlanFromCoreSnapshot(
|
|
264
|
+
core.initial.snapshot as unknown as Parameters<typeof semanticCommandPlanFromCoreSnapshot>[0],
|
|
265
|
+
);
|
|
266
|
+
assertPreparedResources(plan.resourceRequests(), acquired.sources);
|
|
267
|
+
const renderer = new SemanticWebglSceneRenderer(this.gl);
|
|
268
|
+
const bootstrap = await renderer.setScene({
|
|
269
|
+
plan,
|
|
270
|
+
atlas,
|
|
271
|
+
layout: compiled.layout,
|
|
272
|
+
imageSources: acquired.sources,
|
|
273
|
+
});
|
|
274
|
+
const scene = new BrowserScene(core, renderer, acquired, atlas, {
|
|
275
|
+
dynamicProgramCount: compiled.layout.dynamicPrograms.length,
|
|
276
|
+
canvas: this.canvas,
|
|
277
|
+
telemetry: this.telemetryOptions,
|
|
278
|
+
bootstrap,
|
|
279
|
+
onDestroy: (destroyed) => this.scenes.delete(destroyed),
|
|
280
|
+
});
|
|
281
|
+
this.scenes.add(scene);
|
|
282
|
+
return scene;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
atlas?.release();
|
|
285
|
+
resources?.release();
|
|
286
|
+
await core?.destroy().catch(() => undefined);
|
|
287
|
+
throw error;
|
|
288
|
+
} finally {
|
|
289
|
+
abort.dispose();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async createMasterData(revision: string): Promise<RendererMasterData> {
|
|
294
|
+
this.assertAlive();
|
|
295
|
+
return this.worker.createMasterData(this.region, revision);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async loadMasterData(
|
|
299
|
+
revision: string,
|
|
300
|
+
loadTable: MasterDataTableLoader,
|
|
301
|
+
options: { signal?: AbortSignal; concurrency?: number } = {},
|
|
302
|
+
): Promise<RendererMasterData> {
|
|
303
|
+
if (typeof loadTable !== "function") throw new BrowserRendererError("MASTERDATA_LOADER_REQUIRED", "A master-data table loader is required");
|
|
304
|
+
const session = await this.createMasterData(revision);
|
|
305
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
306
|
+
const concurrency = options.concurrency ?? 4;
|
|
307
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) {
|
|
308
|
+
await session.destroy().catch(() => undefined);
|
|
309
|
+
throw new BrowserRendererError("INVALID_MASTERDATA_CONCURRENCY", "Master-data concurrency must be a positive integer");
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
let cursor = 0;
|
|
313
|
+
const workers = Array.from({ length: Math.min(concurrency, session.requiredTables.length) }, async () => {
|
|
314
|
+
while (cursor < session.requiredTables.length) {
|
|
315
|
+
if (signal.aborted) throw abortReason(signal);
|
|
316
|
+
const table = session.requiredTables[cursor++];
|
|
317
|
+
const value = await loadTable({ table, region: this.region, revision }, { signal });
|
|
318
|
+
await session.putTable(table, value);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
await Promise.all(workers);
|
|
322
|
+
await session.seal();
|
|
323
|
+
return session;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
await session.destroy().catch(() => undefined);
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async registerFont(font: { family: string; bytes: ArrayBuffer }): Promise<void> {
|
|
331
|
+
this.assertAlive();
|
|
332
|
+
if (typeof font.family !== "string" || font.family.length === 0) {
|
|
333
|
+
throw new BrowserRendererError("INVALID_FONT_FAMILY", "Font family must be a non-empty string");
|
|
334
|
+
}
|
|
335
|
+
if (!(font.bytes instanceof ArrayBuffer) || font.bytes.byteLength === 0) {
|
|
336
|
+
throw new BrowserRendererError("INVALID_FONT_BYTES", `Font bytes are empty: ${font.family}`);
|
|
337
|
+
}
|
|
338
|
+
const sourceHash = await sha256Hex(font.bytes);
|
|
339
|
+
const pending = this.fontRegistrations.get(font.family);
|
|
340
|
+
if (pending) await pending;
|
|
341
|
+
const registered = this.fonts.get(font.family);
|
|
342
|
+
if (registered) {
|
|
343
|
+
if (registered.sourceHash === sourceHash) return;
|
|
344
|
+
throw new BrowserRendererError("FONT_IDENTITY_CONFLICT", `Font family is already registered with different bytes: ${font.family}`);
|
|
345
|
+
}
|
|
346
|
+
const source = { region: this.region, family: font.family, sourceHash };
|
|
347
|
+
const registration = this.worker.registerFont({ ...source, bytes: font.bytes })
|
|
348
|
+
.then(() => { this.fonts.set(font.family, source); });
|
|
349
|
+
this.fontRegistrations.set(font.family, registration);
|
|
350
|
+
try {
|
|
351
|
+
await registration;
|
|
352
|
+
} finally {
|
|
353
|
+
this.fontRegistrations.delete(font.family);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async stats() {
|
|
358
|
+
if (this.destroyed) throw new BrowserRendererError("RENDERER_DESTROYED", "Browser renderer is destroyed");
|
|
359
|
+
return {
|
|
360
|
+
worker: await this.worker.stats(),
|
|
361
|
+
resources: this.resources.stats(),
|
|
362
|
+
fonts: {
|
|
363
|
+
registered: this.fonts.size,
|
|
364
|
+
provider: this.providedFonts?.stats() ?? null,
|
|
365
|
+
},
|
|
366
|
+
localizations: this.localizations?.stats() ?? null,
|
|
367
|
+
scenes: {
|
|
368
|
+
active: this.scenes.size,
|
|
369
|
+
contextLost: [...this.scenes].filter((scene) => scene.stats().state === "context-lost").length,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async restoreContext(): Promise<void> {
|
|
375
|
+
if (this.destroyed) throw new BrowserRendererError("RENDERER_DESTROYED", "Browser renderer is destroyed");
|
|
376
|
+
if (this.restorePromise) return this.restorePromise;
|
|
377
|
+
this.restorePromise = (async () => {
|
|
378
|
+
const started = monotonicNow();
|
|
379
|
+
const gl = this.canvas.getContext("webgl2", {
|
|
380
|
+
alpha: true,
|
|
381
|
+
antialias: true,
|
|
382
|
+
depth: false,
|
|
383
|
+
premultipliedAlpha: true,
|
|
384
|
+
preserveDrawingBuffer: false,
|
|
385
|
+
}) as WebGL2RenderingContext | null;
|
|
386
|
+
if (!gl) {
|
|
387
|
+
const restoreMs = monotonicNow() - started;
|
|
388
|
+
for (const scene of this.scenes) scene.notifyContextRestoreFailed(restoreMs);
|
|
389
|
+
throw new BrowserRendererError("WEBGL2_RESTORE_FAILED", "WebGL2 context restoration failed");
|
|
390
|
+
}
|
|
391
|
+
this.gl = gl;
|
|
392
|
+
await Promise.all([...this.scenes].map((scene) => scene.restoreContext(gl)));
|
|
393
|
+
this.contextLost = false;
|
|
394
|
+
})();
|
|
395
|
+
try {
|
|
396
|
+
await this.restorePromise;
|
|
397
|
+
} finally {
|
|
398
|
+
this.restorePromise = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
destroy(): void {
|
|
403
|
+
if (this.destroyed) return;
|
|
404
|
+
this.destroyed = true;
|
|
405
|
+
this.lifetime.abort(new BrowserRendererError("RENDERER_DESTROYED", "Browser renderer is destroyed"));
|
|
406
|
+
if (isHtmlCanvas(this.canvas)) {
|
|
407
|
+
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
|
|
408
|
+
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
|
409
|
+
}
|
|
410
|
+
disposeWorkerAtlasSessions(this.worker);
|
|
411
|
+
this.worker.terminate();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private assertAlive(): void {
|
|
415
|
+
if (this.destroyed) throw new BrowserRendererError("RENDERER_DESTROYED", "Browser renderer is destroyed");
|
|
416
|
+
if (this.contextLost) throw new BrowserRendererError("WEBGL_CONTEXT_LOST", "WebGL context is lost");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private readonly handleContextLost = (event: Event): void => {
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
this.contextLost = true;
|
|
422
|
+
for (const scene of this.scenes) scene.notifyContextLost();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
private readonly handleContextRestored = (): void => {
|
|
426
|
+
void this.restoreContext().catch(() => {
|
|
427
|
+
// Scene recovery counters retain the failure. Call restoreContext() explicitly
|
|
428
|
+
// when the host needs an actionable rejection.
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function atlasLayoutInput(atlas: SdfAtlas) {
|
|
434
|
+
return {
|
|
435
|
+
baseSize: atlas.baseSize,
|
|
436
|
+
spread: atlas.spread,
|
|
437
|
+
glyphs: Array.from(atlas.glyphs.values()),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function preparedGlyphRequests(preparation: Record<string, unknown>): GlyphRequest[] {
|
|
442
|
+
const demand = preparation.glyph_demand;
|
|
443
|
+
if (!demand || typeof demand !== "object" || Array.isArray(demand)) {
|
|
444
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile preparation did not return glyph demand");
|
|
445
|
+
}
|
|
446
|
+
const requests = (demand as Record<string, unknown>).requests;
|
|
447
|
+
if (!Array.isArray(requests)) {
|
|
448
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile glyph demand is invalid");
|
|
449
|
+
}
|
|
450
|
+
return requests.map((entry) => {
|
|
451
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
452
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile glyph request is invalid");
|
|
453
|
+
}
|
|
454
|
+
const request = entry as Record<string, unknown>;
|
|
455
|
+
if (
|
|
456
|
+
typeof request.region !== "string"
|
|
457
|
+
|| typeof request.family !== "string"
|
|
458
|
+
|| typeof request.font_source_hash !== "string"
|
|
459
|
+
|| typeof request.char !== "string"
|
|
460
|
+
) {
|
|
461
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile glyph identity is invalid");
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
region: request.region,
|
|
465
|
+
family: request.family,
|
|
466
|
+
fontSourceHash: request.font_source_hash,
|
|
467
|
+
char: request.char,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function preparedFontDemands(
|
|
473
|
+
preparation: Record<string, unknown>,
|
|
474
|
+
region: string,
|
|
475
|
+
): FontRequest[] {
|
|
476
|
+
const values = preparation.fontDemands;
|
|
477
|
+
if (!Array.isArray(values)) {
|
|
478
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile preparation did not return font demands");
|
|
479
|
+
}
|
|
480
|
+
return values.map((family, index) => {
|
|
481
|
+
if (typeof family !== "string" || family.length === 0) {
|
|
482
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", `Profile font demand is invalid at index ${index}`);
|
|
483
|
+
}
|
|
484
|
+
return { region, family };
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function preparedLayoutRequest(
|
|
489
|
+
preparation: Record<string, unknown>,
|
|
490
|
+
atlas: SdfAtlas | null,
|
|
491
|
+
): Record<string, unknown> {
|
|
492
|
+
const request = preparation.layout_request;
|
|
493
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
494
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile preparation did not return an authored layout request");
|
|
495
|
+
}
|
|
496
|
+
if (!atlas) {
|
|
497
|
+
const layers = (request as { layers?: unknown }).layers;
|
|
498
|
+
if (!Array.isArray(layers) || layers.length !== 0) {
|
|
499
|
+
throw new BrowserRendererError("INVALID_PROFILE_PREPARATION", "Profile preparation returned text layers without glyph demand");
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
...(request as Record<string, unknown>),
|
|
503
|
+
atlas: { baseSize: 1, spread: 0, glyphs: [] },
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
...(request as Record<string, unknown>),
|
|
508
|
+
atlas: atlasLayoutInput(atlas),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function requiredFontSources(
|
|
513
|
+
requests: ReadonlyArray<{ family: string; fontSourceHash: string }>,
|
|
514
|
+
fonts: ReadonlyMap<string, FontSource>,
|
|
515
|
+
): FontSource[] {
|
|
516
|
+
const output = new Map<string, FontSource>();
|
|
517
|
+
for (const request of requests) {
|
|
518
|
+
const source = fonts.get(request.family);
|
|
519
|
+
if (!source || source.sourceHash !== request.fontSourceHash) {
|
|
520
|
+
throw new BrowserRendererError("FONT_NOT_REGISTERED", `Required font is not registered: ${request.family}`);
|
|
521
|
+
}
|
|
522
|
+
output.set(`${source.region}\0${source.sourceHash}\0${source.family}`, source);
|
|
523
|
+
}
|
|
524
|
+
return [...output.values()];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function assertPreparedResources(
|
|
528
|
+
requested: Array<{ namespace: string; key: string }>,
|
|
529
|
+
sources: Map<string, BrowserImageSource>,
|
|
530
|
+
): void {
|
|
531
|
+
for (const resource of requested) {
|
|
532
|
+
if (!sources.has(`${resource.namespace}\0${resource.key}`)) {
|
|
533
|
+
throw new BrowserRendererError("RESOURCE_PREPARATION_DRIFT", `Scene requested an unprepared resource: ${resource.namespace}/${resource.key}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function preparedLocalizationDemands(preparation: Record<string, unknown>): LocalizationRequest[] {
|
|
539
|
+
const raw = preparation.localizationDemands;
|
|
540
|
+
if (!Array.isArray(raw)) return [];
|
|
541
|
+
return raw.map((value, index) => {
|
|
542
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
543
|
+
throw new BrowserRendererError("INVALID_LOCALIZATION_DEMAND", `Invalid localization demand at index ${index}`);
|
|
544
|
+
}
|
|
545
|
+
const { region, locale, key } = value as Record<string, unknown>;
|
|
546
|
+
if (typeof region !== "string" || typeof locale !== "string" || typeof key !== "string") {
|
|
547
|
+
throw new BrowserRendererError("INVALID_LOCALIZATION_DEMAND", `Invalid localization demand at index ${index}`);
|
|
548
|
+
}
|
|
549
|
+
return { region, locale, key };
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function combinedAbortSignal(lifetime: AbortSignal, request?: AbortSignal): { signal: AbortSignal; dispose(): void } {
|
|
554
|
+
if (!request || lifetime.aborted || request.aborted) {
|
|
555
|
+
return { signal: request?.aborted ? request : lifetime, dispose() {} };
|
|
556
|
+
}
|
|
557
|
+
const controller = new AbortController();
|
|
558
|
+
const abortLifetime = () => controller.abort(lifetime.reason);
|
|
559
|
+
const abortRequest = () => controller.abort(request.reason);
|
|
560
|
+
lifetime.addEventListener("abort", abortLifetime, { once: true });
|
|
561
|
+
request.addEventListener("abort", abortRequest, { once: true });
|
|
562
|
+
return {
|
|
563
|
+
signal: controller.signal,
|
|
564
|
+
dispose() {
|
|
565
|
+
lifetime.removeEventListener("abort", abortLifetime);
|
|
566
|
+
request.removeEventListener("abort", abortRequest);
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
572
|
+
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export class BrowserScene {
|
|
576
|
+
private destroyed = false;
|
|
577
|
+
private readonly runtime: RendererRuntimeTelemetry;
|
|
578
|
+
private readonly onDestroy: ((scene: BrowserScene) => void) | undefined;
|
|
579
|
+
|
|
580
|
+
constructor(
|
|
581
|
+
private readonly core: RendererScene,
|
|
582
|
+
private readonly renderer: SemanticWebglSceneRenderer,
|
|
583
|
+
private readonly resources: BrowserSemanticResourceSet,
|
|
584
|
+
private readonly atlas: SdfAtlas | null,
|
|
585
|
+
options: {
|
|
586
|
+
dynamicProgramCount: number;
|
|
587
|
+
canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
588
|
+
telemetry?: RendererTelemetryOptions;
|
|
589
|
+
bootstrap: RendererRuntimeSnapshot["bootstrap"];
|
|
590
|
+
onDestroy?: (scene: BrowserScene) => void;
|
|
591
|
+
},
|
|
592
|
+
) {
|
|
593
|
+
this.dynamicProgramCount = options.dynamicProgramCount;
|
|
594
|
+
this.animated = this.dynamicProgramCount > 0;
|
|
595
|
+
this.canvas = options.canvas;
|
|
596
|
+
this.runtime = new RendererRuntimeTelemetry(options.telemetry ?? {}, atlasSummary(atlas), options.bootstrap);
|
|
597
|
+
this.onDestroy = options.onDestroy;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Whether this scene owns renderer-driven programs that require timeline advancement. */
|
|
601
|
+
readonly animated: boolean;
|
|
602
|
+
|
|
603
|
+
/** Number of renderer-compiled dynamic programs retained by this scene. */
|
|
604
|
+
readonly dynamicProgramCount: number;
|
|
605
|
+
|
|
606
|
+
private readonly canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
607
|
+
|
|
608
|
+
draw() {
|
|
609
|
+
this.assertAlive();
|
|
610
|
+
this.assertGpuReady();
|
|
611
|
+
const started = monotonicNow();
|
|
612
|
+
const metrics = this.renderer.draw();
|
|
613
|
+
this.runtime.recordDraw(metrics, monotonicNow() - started);
|
|
614
|
+
return metrics;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Draws the current frame and encodes an exact 1830×812 PNG snapshot. */
|
|
618
|
+
async exportPng(): Promise<Blob> {
|
|
619
|
+
this.assertAlive();
|
|
620
|
+
this.draw();
|
|
621
|
+
if (typeof document !== "undefined") {
|
|
622
|
+
const snapshot = document.createElement("canvas");
|
|
623
|
+
snapshot.width = CARD_WIDTH;
|
|
624
|
+
snapshot.height = CARD_HEIGHT;
|
|
625
|
+
const context = snapshot.getContext("2d");
|
|
626
|
+
if (!context) throw new BrowserRendererError("PNG_EXPORT_UNAVAILABLE", "Canvas 2D is required for PNG export");
|
|
627
|
+
context.drawImage(this.canvas, 0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
628
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
629
|
+
snapshot.toBlob((blob: Blob | null) => blob
|
|
630
|
+
? resolve(blob)
|
|
631
|
+
: reject(new BrowserRendererError("PNG_EXPORT_FAILED", "Canvas PNG encoding returned no data")), "image/png");
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
if (typeof OffscreenCanvas === "undefined") {
|
|
635
|
+
throw new BrowserRendererError("PNG_EXPORT_UNAVAILABLE", "OffscreenCanvas is required outside a document");
|
|
636
|
+
}
|
|
637
|
+
const snapshot = new OffscreenCanvas(CARD_WIDTH, CARD_HEIGHT);
|
|
638
|
+
const context = snapshot.getContext("2d");
|
|
639
|
+
if (!context) throw new BrowserRendererError("PNG_EXPORT_UNAVAILABLE", "Canvas 2D is required for PNG export");
|
|
640
|
+
context.drawImage(this.canvas, 0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
641
|
+
return snapshot.convertToBlob({ type: "image/png" });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async advance(tick: number) { this.assertGpuReady(); return this.apply(await this.core.advance(tick)); }
|
|
645
|
+
async setLayerVisible(layerId: StableId, visible: boolean) { this.assertGpuReady(); return this.apply(await this.core.setLayerVisible(layerId, visible)); }
|
|
646
|
+
async setLayerMasks(expectedRevision: number, overrides: Array<{ layerId: StableId; visible: boolean | null }>) {
|
|
647
|
+
this.assertGpuReady();
|
|
648
|
+
return this.apply(await this.core.setLayerMasks(expectedRevision, overrides));
|
|
649
|
+
}
|
|
650
|
+
async setTab(controlId: StableId, value: string) { this.assertGpuReady(); return this.apply(await this.core.setTab(controlId, value)); }
|
|
651
|
+
async setScrollOffset(controlId: StableId, offset: number) { this.assertGpuReady(); return this.apply(await this.core.setScrollOffset(controlId, offset)); }
|
|
652
|
+
async scrollBy(controlId: StableId, delta: number) { this.assertGpuReady(); return this.apply(await this.core.scrollBy(controlId, delta)); }
|
|
653
|
+
|
|
654
|
+
setLayerPreviewTransform(layerId: StableId, matrix: [number, number, number, number, number, number] | null) {
|
|
655
|
+
this.assertGpuReady();
|
|
656
|
+
const gpu = this.renderer.setLayerPreviewTransform(layerId, matrix);
|
|
657
|
+
const draw = this.draw();
|
|
658
|
+
return { gpu, draw };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async dump(): Promise<CoreSceneDump & { numeric_text_regions: NumericTextRegion[] }> {
|
|
662
|
+
this.assertAlive();
|
|
663
|
+
const dump = await this.core.dump();
|
|
664
|
+
const textGeometry = this.renderer.authoredTextHitGeometry();
|
|
665
|
+
const interactionRegions = dump.interaction_regions.map((region) => {
|
|
666
|
+
const geometry = region.role === "primary" ? textGeometry.get(region.layer_id) : undefined;
|
|
667
|
+
return geometry ? {
|
|
668
|
+
...region,
|
|
669
|
+
hit_geometry: geometry.quad,
|
|
670
|
+
} : region;
|
|
671
|
+
});
|
|
672
|
+
return {
|
|
673
|
+
...dump,
|
|
674
|
+
interaction_regions: interactionRegions,
|
|
675
|
+
numeric_text_regions: this.renderer.interactionRegions(),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async destroy(): Promise<void> {
|
|
680
|
+
if (this.destroyed) return;
|
|
681
|
+
this.destroyed = true;
|
|
682
|
+
this.renderer.destroy();
|
|
683
|
+
this.resources.release();
|
|
684
|
+
await this.atlas?.release();
|
|
685
|
+
await this.core.destroy();
|
|
686
|
+
this.runtime.markDestroyed();
|
|
687
|
+
this.onDestroy?.(this);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
notifyContextLost(): void {
|
|
691
|
+
if (this.destroyed) return;
|
|
692
|
+
this.renderer.notifyContextLost();
|
|
693
|
+
this.runtime.markContextLost();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
notifyContextRestoreFailed(restoreMs: number): void {
|
|
697
|
+
if (this.destroyed) return;
|
|
698
|
+
this.runtime.markRestoreFailed(restoreMs);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async restoreContext(gl: WebGL2RenderingContext): Promise<void> {
|
|
702
|
+
this.assertAlive();
|
|
703
|
+
const started = monotonicNow();
|
|
704
|
+
try {
|
|
705
|
+
const uploads = await this.renderer.restoreContext(gl);
|
|
706
|
+
this.runtime.markContextRestored(monotonicNow() - started, uploads);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
this.runtime.markRestoreFailed(monotonicNow() - started);
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
stats(): RendererRuntimeSnapshot {
|
|
714
|
+
return this.runtime.snapshot();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private apply(delta: CoreSceneDelta) {
|
|
718
|
+
this.assertAlive();
|
|
719
|
+
const gpu = this.renderer.applyCoreDelta(delta);
|
|
720
|
+
this.runtime.recordPatch(gpu);
|
|
721
|
+
return { delta, gpu };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private assertAlive(): void {
|
|
725
|
+
if (this.destroyed) throw new BrowserRendererError("SCENE_DESTROYED", "Browser scene is destroyed");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private assertGpuReady(): void {
|
|
729
|
+
this.assertAlive();
|
|
730
|
+
if (this.runtime.state() === "context-lost") {
|
|
731
|
+
throw new BrowserRendererError("WEBGL_CONTEXT_LOST", "WebGL context is lost");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export class BrowserRendererError extends Error {
|
|
737
|
+
constructor(readonly code: string, message: string) {
|
|
738
|
+
super(message);
|
|
739
|
+
this.name = "BrowserRendererError";
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function atlasSummary(atlas: SdfAtlas | null): RendererAtlasSummary {
|
|
744
|
+
if (!atlas) {
|
|
745
|
+
return {
|
|
746
|
+
backend: "edt",
|
|
747
|
+
pages: 0,
|
|
748
|
+
glyphs: 0,
|
|
749
|
+
missingGlyphs: 0,
|
|
750
|
+
generation: { glyphs: 0, pixels: 0, glyphMs: 0, faceLoadMs: 0 },
|
|
751
|
+
cache: null,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const cache = atlas.perf.cache;
|
|
755
|
+
return {
|
|
756
|
+
backend: atlas.sdfBackend,
|
|
757
|
+
pages: atlas.depth,
|
|
758
|
+
glyphs: atlas.glyphs.size,
|
|
759
|
+
missingGlyphs: atlas.missing.length,
|
|
760
|
+
generation: {
|
|
761
|
+
glyphs: atlas.perf.totalGlyphCount,
|
|
762
|
+
pixels: atlas.perf.totalPixelCount,
|
|
763
|
+
glyphMs: atlas.perf.totalGlyphMs,
|
|
764
|
+
faceLoadMs: atlas.perf.totalFaceLoadMs,
|
|
765
|
+
},
|
|
766
|
+
cache: cache ? {
|
|
767
|
+
hits: cache.hits,
|
|
768
|
+
misses: cache.misses,
|
|
769
|
+
generations: cache.generations,
|
|
770
|
+
bytes: cache.bytes,
|
|
771
|
+
sessionHits: cache.sessionHits,
|
|
772
|
+
persistentHits: cache.persistentHits,
|
|
773
|
+
persistentMisses: cache.persistentMisses,
|
|
774
|
+
persistentWritesQueued: cache.persistentWritesQueued,
|
|
775
|
+
pinnedPages: cache.pinnedPages,
|
|
776
|
+
pageEvictions: cache.pageEvictions,
|
|
777
|
+
} : null,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function monotonicNow(): number {
|
|
782
|
+
return typeof performance === "undefined" ? Date.now() : performance.now();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
|
786
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
787
|
+
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function isHtmlCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): canvas is HTMLCanvasElement {
|
|
791
|
+
return typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement;
|
|
792
|
+
}
|