@godot-scene-web/html 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.
@@ -0,0 +1,1223 @@
1
+ import { GodotNode, GodotResource, GodotResourceRefValue, GodotVariant } from "@godot-scene-web/core";
2
+ import { InstanceBuffer, ParticleRenderConfig, sampleParticleGradient } from "@godot-scene-web/effects/particles";
3
+ import { uploadWebglTexture } from "@godot-scene-web/canvas-effects/webgl";
4
+ import { GodotBlendMode } from "@godot-scene-web/effects/shaders";
5
+ import { GodotSceneTreeDiagnostic } from "@godot-scene-web/layout";
6
+ import { GodotAnchorMap } from "@godot-scene-web/layout/anchors";
7
+ import { GodotResourceLoadStatus, GodotResourceStatus } from "@godot-scene-web/scene-graph";
8
+
9
+ //#region src/content-scale.d.ts
10
+ interface GodotContentScaleSize {
11
+ width: number;
12
+ height: number;
13
+ }
14
+ type GodotContentScaleAspect = "ignore" | "keep" | "keep_width" | "keep_height" | "expand";
15
+ type GodotContentScaleTechnique = "container" | "transform";
16
+ interface GodotContentScale {
17
+ /** Godot `content_scale_aspect`. Only `"keep"` is implemented for now. */
18
+ aspect: GodotContentScaleAspect;
19
+ /**
20
+ * How the uniform scale is realized in the DOM:
21
+ * - `"container"` (default): CSS-only, every length in the stage subtree is
22
+ * expressed in container-query units. No JavaScript; identical in Vue and the
23
+ * static HTML document.
24
+ * - `"transform"`: the stage keeps its px layout and is scaled with
25
+ * `transform: scale(var(--godot-scale))`; the factor is set by JS on resize.
26
+ */
27
+ technique?: GodotContentScaleTechnique;
28
+ /** Base/reference resolution (Godot `content_scale_size`). Defaults to the model viewport. */
29
+ baseSize?: GodotContentScaleSize;
30
+ /** Letterbox/pillarbox bar color painted by the frame around the centered stage. */
31
+ background?: string;
32
+ }
33
+ interface ResolvedContentScale {
34
+ aspect: GodotContentScaleAspect;
35
+ technique: GodotContentScaleTechnique;
36
+ base: GodotContentScaleSize;
37
+ background?: string;
38
+ }
39
+ declare function resolveContentScale(contentScale: GodotContentScale | undefined, viewport: GodotContentScaleSize): ResolvedContentScale | null;
40
+ declare function rewritePxLengths(text: string, base: GodotContentScaleSize): string;
41
+ declare function scaleStyleRecord(style: Record<string, string>, base: GodotContentScaleSize): void;
42
+ declare function contentScaleStageStyle(resolved: ResolvedContentScale): Record<string, string>;
43
+ declare function observeContentScale(frame: HTMLElement, base: GodotContentScaleSize): () => void;
44
+ declare function contentScaleScript(base: GodotContentScaleSize): string;
45
+ //#endregion
46
+ //#region src/text-scale.d.ts
47
+ declare const TEXT_SCALE_VAR = "--godot-text-scale";
48
+ interface GodotTextScale {
49
+ exemptAutoFit?: boolean;
50
+ }
51
+ type GodotTextScaleOption = boolean | GodotTextScale;
52
+ interface ResolvedTextScale {
53
+ exemptAutoFit: boolean;
54
+ }
55
+ declare function resolveTextScale(option: GodotTextScaleOption | undefined): ResolvedTextScale | null;
56
+ declare function textScaleLength(px: number, enabled: boolean): string;
57
+ declare function setGodotTextScale(element: HTMLElement, value: number): void;
58
+ //#endregion
59
+ //#region src/types.d.ts
60
+ /**
61
+ * A `<filter>` referenced by a self-layer's `filter: url(#id)` (an external
62
+ * texture's color-matrix tint, or a consumer-supplied shader-fallback effect).
63
+ * `markup` is the filter's inner SVG (e.g. a `feColorMatrix`, or a
64
+ * luminance→band→flood chain). Emitted once per render into a hidden
65
+ * `<svg><defs>` by every renderer.
66
+ */
67
+ interface GodotHtmlTintFilter {
68
+ id: string;
69
+ markup: string;
70
+ }
71
+ interface GodotHtmlModel {
72
+ viewport: {
73
+ width: number;
74
+ height: number;
75
+ };
76
+ nodes: GodotHtmlNode[];
77
+ fontFaces: GodotHtmlFontFace[];
78
+ tintFilters: GodotHtmlTintFilter[];
79
+ diagnostics: GodotSceneTreeDiagnostic[];
80
+ resourceStatuses: GodotResourceStatus[];
81
+ css: string;
82
+ contentScale: ResolvedContentScale | null;
83
+ }
84
+ type GodotHtmlPositioning = "root" | "absolute" | "container-managed";
85
+ type GodotHtmlContainerLayout = "box" | "flow" | "grid" | "center" | "margin" | "panel" | "scroll" | "aspect-ratio";
86
+ interface GodotHtmlNode {
87
+ kind?: "hidden-placeholder";
88
+ path: string;
89
+ name: string;
90
+ type: string;
91
+ parentPath: string | null;
92
+ children: string[];
93
+ positioning: GodotHtmlPositioning;
94
+ containerLayout: GodotHtmlContainerLayout | null;
95
+ attributes: Record<string, string>;
96
+ className: string;
97
+ style: Record<string, string>;
98
+ selfAttributes: Record<string, string>;
99
+ selfStyle: Record<string, string>;
100
+ text: string | null;
101
+ html: string | null;
102
+ }
103
+ interface GodotResolvedResource {
104
+ status?: GodotResourceLoadStatus;
105
+ type?: string;
106
+ path?: string;
107
+ url?: string;
108
+ document?: GodotResource;
109
+ atlas?: GodotResolvedResource;
110
+ shader?: GodotResolvedResource;
111
+ region?: {
112
+ x: number;
113
+ y: number;
114
+ width: number;
115
+ height: number;
116
+ };
117
+ margin?: {
118
+ x: number;
119
+ y: number;
120
+ width: number;
121
+ height: number;
122
+ };
123
+ atlasCrop?: {
124
+ region: {
125
+ x: number;
126
+ y: number;
127
+ width: number;
128
+ height: number;
129
+ };
130
+ margin: {
131
+ x: number;
132
+ y: number;
133
+ width: number;
134
+ height: number;
135
+ };
136
+ };
137
+ size?: {
138
+ width: number;
139
+ height: number;
140
+ };
141
+ fontFamily?: string;
142
+ fontUrl?: string;
143
+ fontStyle?: "normal" | "italic";
144
+ fontWeight?: number | string;
145
+ glyphSpacing?: number;
146
+ fontMsdf?: boolean;
147
+ message?: string;
148
+ }
149
+ interface GodotHtmlFontFace {
150
+ fontFamily: string;
151
+ url: string;
152
+ style: "normal" | "italic";
153
+ weight: string;
154
+ }
155
+ interface GodotTextAutoFitNominalMetrics {
156
+ contentWidthPx: number;
157
+ contentHeightPx: number;
158
+ lines?: {
159
+ widthPx?: number;
160
+ heightPx?: number;
161
+ }[];
162
+ metricSource?: string;
163
+ }
164
+ interface GodotTextAutoFitDirective {
165
+ minFontSizePx: number;
166
+ maxFontSizePx: number;
167
+ nominalFontSizePx?: number;
168
+ /**
169
+ * Paragraph geometry the host engine (Godot) measured at `nominalFontSizePx`.
170
+ * When present, auto-fit scales these by `candidate / nominalFontSizePx` to pick
171
+ * a size that matches the engine, instead of measuring the live DOM (which drifts
172
+ * before web fonts resolve). Absent => fall back to DOM measurement.
173
+ */
174
+ nominalMetrics?: GodotTextAutoFitNominalMetrics;
175
+ fitWidth: boolean;
176
+ fitHeight: boolean;
177
+ wrapMode?: string;
178
+ textOverrunBehavior?: string;
179
+ }
180
+ interface GodotShaderLoadingFallback {
181
+ background: string;
182
+ clipPath?: string;
183
+ borderRadius?: string;
184
+ }
185
+ type GodotBbcodeTagDescriptor = {
186
+ kind: "color";
187
+ value: string;
188
+ } | {
189
+ kind: "style";
190
+ css: Record<string, string>;
191
+ } | {
192
+ kind: "effect";
193
+ perChar?: boolean;
194
+ perWord?: boolean;
195
+ className?: string;
196
+ };
197
+ interface GodotHtmlRenderOptions {
198
+ classPrefix?: string;
199
+ textAutoFitByPath?: Record<string, GodotTextAutoFitDirective>;
200
+ resolveResource?: (ref: GodotResourceRefValue, node: GodotNode) => unknown;
201
+ resolveResourcePath?: (path: string, node: GodotNode) => unknown;
202
+ resolveTheme?: (node: GodotNode, name: string) => GodotVariant | undefined;
203
+ bbcodeTags?: Record<string, GodotBbcodeTagDescriptor>;
204
+ contentScale?: GodotContentScale;
205
+ textScale?: GodotTextScaleOption;
206
+ tintFilterIdPrefix?: string;
207
+ anchorsByPath?: GodotAnchorMap;
208
+ enableWebglShaders?: boolean;
209
+ shaderLoadingFallbacksByPath?: Record<string, GodotShaderLoadingFallback>;
210
+ webglShaderNodesByPath?: Record<string, boolean>;
211
+ webglShaderIds?: string[];
212
+ hiddenRawShaderFallbacksByPath?: Record<string, boolean>;
213
+ hiddenRawShaderFallbackShaderIds?: string[];
214
+ hsvAdjustShaderIds?: string[];
215
+ shaderFallbackFiltersByPath?: Record<string, string>;
216
+ enableParticles?: boolean;
217
+ particleNodesByPath?: Record<string, boolean>;
218
+ particleIds?: string[];
219
+ godotRenderer?: "forward_plus" | "mobile" | "gl_compatibility";
220
+ }
221
+ /**
222
+ * What the effect runtimes know about the frame they just presented/handled, handed to
223
+ * `GodotHtmlRenderOptions.onBindingRendered` alongside the node and its canvas.
224
+ *
225
+ * ADDITIVE, and it stays that way because of how TypeScript reads a callback: a
226
+ * consumer that declares `(node, canvas) => …` still satisfies the option, so the
227
+ * two-argument callers that existed before this parameter are untouched.
228
+ *
229
+ * WHY THESE THREE. A consumer that COMPOSITES the canvas itself — rather than
230
+ * leaving it in the DOM where the runtime placed it — has to reproduce two things
231
+ * the runtime otherwise expresses through CSS, and refuse a third:
232
+ *
233
+ * - `blend` is the shader's Godot `render_mode`. The runtime writes it onto the
234
+ * node as a `mix-blend-mode` (see `blendToMixBlendMode`), which only means
235
+ * anything while the canvas is painted by the page's compositor; a consumer
236
+ * drawing the same pixels into its own surface needs the mode itself.
237
+ * - `usesScreenTexture` says the frame was shaded against a capture of what the
238
+ * page had already painted BEHIND the node. That capture is a DOM composite
239
+ * (see `enableScreenTextureCapture`), so it describes the page's stacking — not
240
+ * a consumer's own surface — and a consumer whose scene is somewhere else
241
+ * should treat the frame as content it cannot reproduce faithfully.
242
+ * - `usesScreenUv` is the weaker twin: the shader read SCREEN_UV, so its output
243
+ * depends on where the node sits in the viewport, and moving the pixels
244
+ * elsewhere moves what they mean.
245
+ *
246
+ * The PARTICLE runtime reports a constant (`"mix"`, neither screen flag): a
247
+ * particle system's own additive mode is resolved INSIDE its canvas, whose
248
+ * finished pixels are premultiplied and composite source-over like any other.
249
+ *
250
+ * …AND A FOURTH, `staticKey`, which is about identity rather than about how to
251
+ * composite one frame: see its own note below.
252
+ */
253
+ interface GodotEffectRenderInfo {
254
+ /** The shader sampled SCREEN_TEXTURE. Always false for particles. */
255
+ usesScreenTexture: boolean;
256
+ /** The shader read SCREEN_UV. Always false for particles. */
257
+ usesScreenUv: boolean;
258
+ /** The shader's `render_mode` blend; always `"mix"` for particles. */
259
+ blend: GodotBlendMode;
260
+ /**
261
+ * The NAME OF THE FRAME this canvas now holds — the runtime's own static-frame
262
+ * key — or null when the frame is not content-addressed at all (live/animating
263
+ * mode, a screen-space shader, textures still decoding, a particle state the
264
+ * live loop has stepped, a blank that ends a burst).
265
+ *
266
+ * WHAT IT PROMISES: the frame is a PURE FUNCTION of this string. It is the same
267
+ * key the static-frame cache stores the bitmap under and the same one the image
268
+ * swap dedupes by, and it is what licenses those two to hand ONE binding's
269
+ * pixels to another. So two canvases reporting one key hold the same picture,
270
+ * and a host that uploads these canvases into its own renderer can upload ONE
271
+ * texture and point every twin at it — which on a hand of identical card glows
272
+ * is one upload instead of seven, of a surface that can be megabytes.
273
+ *
274
+ * A null key means "assume nothing": that frame may be unique to this binding,
275
+ * and a host must key it privately (its node id) as it always did.
276
+ */
277
+ staticKey: string | null;
278
+ }
279
+ //#endregion
280
+ //#region src/diagnostics.d.ts
281
+ type UnsupportedRenderKind = "shader" | "particle";
282
+ interface UnsupportedRenderInfo {
283
+ kind: UnsupportedRenderKind;
284
+ /** Shader identity (uid/path) or particle node path — enough to locate the offending node. */
285
+ id: string;
286
+ /** Short reason, e.g. "unsupported shader construct", "shader failed to compile", "shader source
287
+ * unresolved", "malformed particle spec". */
288
+ reason: string;
289
+ /** The underlying error, when the failure threw. */
290
+ error?: unknown;
291
+ }
292
+ type UnsupportedRenderReporter = (info: UnsupportedRenderInfo) => void;
293
+ declare function reportUnsupportedRender(info: UnsupportedRenderInfo, onUnsupported?: UnsupportedRenderReporter): void;
294
+ //#endregion
295
+ //#region src/effects-loop-pacing.d.ts
296
+ /** How a capped effect loop arms its next tick (see `GodotHtmlRenderOptions.effectsLoopPacing`). */
297
+ type EffectsLoopPacing = "timer" | "raf";
298
+ /** Slop (seconds) around a cap boundary, `"timer"` pacing only (see the module doc). */
299
+ declare const PARK_SLOP_S = 0.004;
300
+ /** The wakeup scheduler of one capped effect loop (see `createEffectsLoopPacer`). */
301
+ interface EffectsLoopPacer {
302
+ /** Whether a boundary `remaining` seconds away counts as reached now (absorbs the park slop). */
303
+ isDue(remaining: number): boolean;
304
+ /** Whether a wakeup (rAF or park timer) is already in flight. */
305
+ isArmed(): boolean;
306
+ /** Arm the next tick `remaining` seconds from now. No-op while already armed. */
307
+ arm(remaining: number): void;
308
+ /** Drop a pending park (NOT a pending rAF) — for a cap change that invalidates its deadline. */
309
+ cancelPark(): void;
310
+ /** Drop every pending wakeup. */
311
+ cancel(): void;
312
+ }
313
+ /**
314
+ * Create the wakeup scheduler for one capped effect loop. `tick` is the loop body; it runs
315
+ * inside a rAF callback in both pacing modes.
316
+ */
317
+ declare function createEffectsLoopPacer(tick: () => void, pacing: EffectsLoopPacing | undefined): EffectsLoopPacer;
318
+ //#endregion
319
+ //#region src/surface-image-swap.d.ts
320
+ /** The DOM attribute stamped on the `<img>` that stands in for a frozen surface's canvas. */
321
+ declare const STATIC_SURFACE_IMAGE_ATTR = "data-godot-shader-image";
322
+ /** Default `content-key` gate: how many consecutive unchanged observations of a surface's content key
323
+ * are required before it is swapped. Small on purpose — the revert is the real safety net, and the
324
+ * clock ticks many times a second — so 3 costs a fraction of a second of latency and excludes a
325
+ * surface that is merely between two states. */
326
+ declare const STABLE_OBSERVATIONS_BEFORE_SWAP = 3;
327
+ /** Default `quiet-window` gate: a surface's own draws must hold still this long before it freezes. */
328
+ declare const DEFAULT_QUIET_WINDOW_MS = 1000;
329
+ /** Default watchdog cadence, for the gates that need one (see the module doc). */
330
+ declare const DEFAULT_SURFACE_WATCHDOG_MS = 3000;
331
+ /** Default encode pacing: surfaces per batch (see the module doc). */
332
+ declare const DEFAULT_ENCODE_SLICE = 4;
333
+ /** Default encode pacing: gap between batches, ms. */
334
+ declare const DEFAULT_ENCODE_INTERVAL_MS = 120;
335
+ /** Default cap on one unbroken run of encode deferrals, for EITHER reason (see the module doc). Long
336
+ * enough that an ordinary burst of host activity is ridden out whole, short enough that a host whose
337
+ * predicate is stuck ON degrades to "the fleet freezes slowly" rather than "the fleet never
338
+ * freezes". */
339
+ declare const DEFAULT_ENCODE_BUSY_MAX_DEFER_MS = 3000;
340
+ /** Default `encode.perTask`: `0` reads as "the whole slice", i.e. a window's entire budget drains
341
+ * back-to-back in one timer task — the behavior every existing consumer already has. A host that has
342
+ * MEASURED its readbacks in the tens or hundreds of ms should pin `1`; see `pumpEncodes`. */
343
+ declare const DEFAULT_ENCODE_PER_TASK = 0;
344
+ /** Default gap between two encode TASKS inside one pacing window (only reachable when
345
+ * `perTask < slice`). 16 ms ≈ one 60 Hz display frame, chosen so the host's own rAF gets to run
346
+ * BETWEEN two readbacks — which is what makes a frame-derived `busy` predicate fresh again instead
347
+ * of stale for the whole drain. */
348
+ declare const DEFAULT_ENCODE_TASK_GAP_MS = 16;
349
+ /** Default `encode.slowEncodeMs`: `0` = the adaptive backoff is OFF, so nothing changes for a host
350
+ * that has not asked for it. */
351
+ declare const DEFAULT_ENCODE_SLOW_MS = 0;
352
+ /** Default hold after a readback measured at or over `encode.slowEncodeMs`. */
353
+ declare const DEFAULT_ENCODE_SLOW_BACKOFF_MS = 1000;
354
+ /** Default `encode.maxDim`: `0` = the source canvas is read back at its full backing-store size,
355
+ * exactly as it always has been. */
356
+ declare const DEFAULT_ENCODE_MAX_DIM = 0;
357
+ /** Default `encode.parkedStillBytes`: `0` = parked stills are OFF and a revert revokes immediately,
358
+ * which is what every consumer has today. The trade is retained pixel memory for skipped readbacks
359
+ * (see the module doc's "PARKED STILLS"), so the budget is the host's to choose — 24 MB is the
360
+ * measured shape of one fleet's worth of quiet-window stills, not a number this module may assume. */
361
+ declare const DEFAULT_PARKED_STILL_BYTES = 0;
362
+ /** Default `encode.stillCacheBytes`: `0` = KEYED retention is off, so an entry whose last holder
363
+ * lets go is revoked exactly as it always has been. Same reasoning as `parkedStillBytes` — the
364
+ * budget is memory this module cannot know a device has — with one difference that makes it worth
365
+ * more per byte: a retained still is claimable by KEY, so its bytes serve every surface that ever
366
+ * reaches that frame rather than the one surface that parked it. */
367
+ declare const DEFAULT_STILL_CACHE_BYTES = 0;
368
+ /** What a capture hook answers INSTEAD of a canvas to report the failure that otherwise reads as a
369
+ * success: the capture completed and holds NO VISIBLE PIXELS for a frame the producer knows it drew
370
+ * (see the module doc's BLANK CAPTURES). A plain `null` stays what it always was — "I could not
371
+ * produce these pixels at all" — and the two are counted apart. */
372
+ declare const STATIC_CAPTURE_BLANK = "blank";
373
+ /** What `StaticImageSwapBinding.captureCanvas` resolves to: the frame as an ordinary 2D canvas,
374
+ * `STATIC_CAPTURE_BLANK`, or null. */
375
+ type StaticSurfaceCapture = HTMLCanvasElement | typeof STATIC_CAPTURE_BLANK | null;
376
+ /** Whatever the injected `setTimeout` seam returns; only ever handed back to the injected
377
+ * `clearTimeout`. Deliberately opaque so a host can inject any scheduler. */
378
+ type StaticSurfaceTimerHandle = unknown;
379
+ /** The default gate: the host names each painted frame with a content key, and the surface swaps
380
+ * once that key has been observed unchanged `observations` times (default
381
+ * `STABLE_OBSERVATIONS_BEFORE_SWAP`). */
382
+ interface StaticSurfaceContentKeyGate {
383
+ kind: "content-key";
384
+ observations?: number;
385
+ }
386
+ /** The keyless gate: a surface becomes eligible `quietMs` (default `DEFAULT_QUIET_WINDOW_MS`) after
387
+ * its LAST DRAW, and thaws the instant it draws again. See the module doc on why the clock is the
388
+ * per-draw signal and never `reconcile()`, and on why this gate needs the watchdog. */
389
+ interface StaticSurfaceQuietWindowGate {
390
+ kind: "quiet-window";
391
+ quietMs?: number;
392
+ /** The window for a surface whose LAST reported key was non-null (see the module doc's
393
+ * KEYED-OR-QUIET). Default: `quietMs`, i.e. this option is inert unless a host asks for it — a
394
+ * host that reports no keys, or that wants keyed surfaces held to the same window as keyless
395
+ * ones, is unaffected by its existence.
396
+ *
397
+ * `0` means "eligible the instant it paints", which is the setting for a host whose key is a
398
+ * complete description of the frame: there is nothing to wait FOR, because a second paint under
399
+ * the same key would not change a pixel and a paint under a different key reverts anyway. The
400
+ * cost of pinning it wrongly is stated at the module doc: a stale `<img>` no proxy can catch. */
401
+ keyedQuietMs?: number;
402
+ }
403
+ type StaticSurfaceGate = StaticSurfaceContentKeyGate | StaticSurfaceQuietWindowGate;
404
+ /** Batching for the (main-thread) encodes — see the module doc. */
405
+ interface StaticSurfaceEncodePacing {
406
+ /** Encodes kicked per batch. Default `DEFAULT_ENCODE_SLICE`. */
407
+ slice?: number;
408
+ /** Gap between batches, ms. Default `DEFAULT_ENCODE_INTERVAL_MS`. */
409
+ intervalMs?: number;
410
+ /** `"smallest-first"` (default) orders a batch by backing-store area; `"dom"` keeps the order the
411
+ * surfaces became eligible in. */
412
+ order?: "smallest-first" | "dom";
413
+ /** `true` defers even the head of a burst through the timer seam instead of encoding it inline on
414
+ * the caller's stack (see the module doc's "ENCODE PACING" section). Default `false` — the head
415
+ * stays inline, which is today's behavior and every existing consumer's default. */
416
+ deferHead?: boolean;
417
+ /** HOST BUSY SIGNAL: "is this a bad instant to spend ~30 ms on a GPU readback?". Consulted ONCE per
418
+ * drain pass (never per queued surface), and a pass that it defers re-arms at `intervalMs` rather
419
+ * than encoding — the head included, `deferHead` or not. The host owns this because only the host
420
+ * can see its own frame loop; absent, nothing about the pacing changes. A predicate that THROWS
421
+ * fails OPEN (the pass encodes): a host bug must not be able to stop the mechanism. */
422
+ busy?: () => boolean;
423
+ /** Cap on one unbroken run of deferrals, ms — ONE bound shared by `busy` and by the adaptive
424
+ * backoff below, so neither of them (nor the two together) can stop the fleet. Default
425
+ * `DEFAULT_ENCODE_BUSY_MAX_DEFER_MS`; `0` means NEVER DEFER, i.e. the whole deferral apparatus is
426
+ * switched off — `busy` is not consulted and `slowEncodeMs` is not consulted (the A/B lever for a
427
+ * host that wants both wired but disabled). Once the bound elapses one pass goes out against a
428
+ * still-busy host and the bound restarts, so deferring can only ever slow the fleet down — see the
429
+ * module doc. */
430
+ busyMaxDeferMs?: number;
431
+ /** Readbacks allowed in ONE task. Default: `slice` (today: the whole slice drains back-to-back).
432
+ * `1` is the setting for a host whose surfaces are GPU-resident and big: `toBlob` on such a canvas
433
+ * is a SYNCHRONOUS GPU→CPU readback, so N of them in one task is one unsplittable park of
434
+ * N × readback — measured downstream at 1,163 ms for four ~4821×2156 surfaces on a phone whose GPU
435
+ * was already saturated, against 6-13 ms each for the SAME surfaces once the load passed. `slice`
436
+ * still bounds THROUGHPUT per `intervalMs`; this bounds the BLOCK. Setting it below `slice` costs
437
+ * `taskGapMs` per extra task and buys back the ability to be interrupted. */
438
+ perTask?: number;
439
+ /** Gap between two encode tasks inside one window, ms. Default `DEFAULT_ENCODE_TASK_GAP_MS`.
440
+ * Unreachable (and therefore inert) while `perTask >= slice`. */
441
+ taskGapMs?: number;
442
+ /** ADAPTIVE BACKOFF, the module's own evidence about readback cost — a readback whose SYNCHRONOUS
443
+ * part took at least this long holds the next one for `slowBackoffMs`. On a CAPTURE-HOOK source
444
+ * (`StaticImageSwapBinding.captureCanvas`) there is no synchronous part to measure, so what is
445
+ * compared against this threshold is the capture's WALL time — the GPU readback's own latency,
446
+ * which is precisely the condition this lever exists to back off from. Either way a surface over
447
+ * the threshold books `staticImageSlowEncodes`. Default
448
+ * `DEFAULT_ENCODE_SLOW_MS` (0 = off). This exists because a host's `busy` predicate is usually
449
+ * derived from its frame loop, and a long readback SUPPRESSES the frames that signal is made of:
450
+ * the jam manufactures a stale "idle" reading exactly when the system is most overloaded (the
451
+ * module doc's stale-signal trap). The measured cost of the previous readback cannot be faked that
452
+ * way. Bounded by `busyMaxDeferMs` like every other deferral. */
453
+ slowEncodeMs?: number;
454
+ /** How long a slow readback holds the next one, ms. Default `DEFAULT_ENCODE_SLOW_BACKOFF_MS`. */
455
+ slowBackoffMs?: number;
456
+ /** READBACK CLAMP: longest edge (backing-store px) the encode may read. Over it, the surface is
457
+ * blitted into a scratch canvas at the clamped, ASPECT-PRESERVED size and that is what `toBlob`
458
+ * reads — a GPU-side downscale, so a 4821×2156 surface reads back 5.5× fewer pixels. Default
459
+ * `DEFAULT_ENCODE_MAX_DIM` (0 = no clamp).
460
+ * FIDELITY: the stand-in is presented at the canvas's CSS box with `object-fit: fill`, so a clamp
461
+ * is a resampling, not a re-layout — but it IS visible on a surface whose backing store was denser
462
+ * than its CSS box. Soft output (a vignette, fog, a glow) survives it; sharp output does not. Ship
463
+ * it behind a host A/B, never as a silent default.
464
+ * KEY SHARING: `entry.key` names the FRAME, not the encode size, and two surfaces on one key may
465
+ * already have different backing sizes — the first to reach the gate encodes and the other
466
+ * stretches it. The clamp does not change that contract, only the pixel size of the shared
467
+ * frame. */
468
+ maxDim?: number;
469
+ /** PARKED STILLS budget, bytes (see the module doc). A revert parks its encoded frame instead of
470
+ * revoking it, so a re-freeze of the same, unpainted surface costs no readback at all; this is the
471
+ * ceiling on the retained blob bytes that buys. Default `DEFAULT_PARKED_STILL_BYTES` (0 = off,
472
+ * today's immediate revoke). The pool is MODULE-wide (key dedup already is), evicts
473
+ * least-recently-parked first, and applies the budget of whichever policy last parked into it — a
474
+ * document running several swappers should give them the same number. */
475
+ parkedStillBytes?: number;
476
+ /** RETAINED STILLS budget, bytes (see the module doc's "RETAINED STILLS"). An entry under a real
477
+ * content key whose LAST holder lets go — by revert or by dispose — is kept, held by nobody, so
478
+ * the next surface to reach that key attaches for zero readback (`claimStaticStill`) and so
479
+ * `bakeStill` has somewhere to publish. Default `DEFAULT_STILL_CACHE_BYTES` (0 = off, i.e. the
480
+ * revoke every consumer has today).
481
+ * SHARES ONE POOL AND ONE EVICTION WALK with `parkedStillBytes`: the budget the pool is trimmed
482
+ * to is the SUM of the two, and either kind may evict the other, least-recently-pooled first.
483
+ * What stays separate is admission — this number alone decides whether a KEYED entry may be
484
+ * retained — so the two mechanisms can still be switched on and off independently. */
485
+ stillCacheBytes?: number;
486
+ /** Exempt the FIRST encode of each key from the deferral apparatus (`busy`, `slowEncodeMs`), not
487
+ * from the pacing (see the module doc's "PRIMING UNSEEN KEYS"). Default `false`.
488
+ * The judgement it encodes is about SURFACE SIZE: deferral was tuned against ~4821×2156 readbacks
489
+ * measured at ~290 ms on a loaded phone, and a fleet of ~320 px canvases collapsing to ~2 distinct
490
+ * keys is three orders off that — there, holding the one encode a key will ever need buys no park
491
+ * back and leaves the whole fleet in the composite for another window. A host with big surfaces
492
+ * must leave this off. */
493
+ primeUnseenKeys?: boolean;
494
+ }
495
+ /**
496
+ * The host's policy for the surface image swap. `true` (or an absent option) is exactly
497
+ * `{ gate: { kind: "content-key" }, onInvalidate: "block" }` — the behavior that shipped first;
498
+ * `false` disables the mechanism entirely and the runtime takes the path it took before it existed.
499
+ */
500
+ interface StaticSurfacePolicy {
501
+ /** How a surface earns its swap. Default `{ kind: "content-key" }`. */
502
+ gate?: StaticSurfaceGate;
503
+ /** What a post-swap invalidation (a content-key change, a failed encode/decode) does to the
504
+ * surface. Default `"block"` — never offer it again. `"retry"` reverts, resets the gate, and lets
505
+ * it re-earn (failed encodes are rescheduled on the encode cadence, forever). */
506
+ onInvalidate?: "block" | "retry";
507
+ /** Encode batching. */
508
+ encode?: StaticSurfaceEncodePacing;
509
+ /** HOST VETO, consulted inside the "should I swap this?" decision right beside the dormancy check.
510
+ * Return false to keep a surface on its canvas — for a host running its own occlusion /
511
+ * virtualizer pass over the same DOM, this is how it says "I already claimed this element",
512
+ * which is the only way two mechanisms can avoid both owning one element's `display`. */
513
+ canFreezeSurface?: (node: HTMLElement, canvas: HTMLCanvasElement) => boolean;
514
+ /** Standing verification cadence over the swapped set, ms (see the module doc). 0 disables it.
515
+ * Default: `DEFAULT_SURFACE_WATCHDOG_MS` for the `quiet-window` gate (which REQUIRES it), 0 for
516
+ * `content-key` (whose invariant makes it unnecessary, and which therefore keeps costing zero
517
+ * idle wakeups). */
518
+ watchdogMs?: number;
519
+ /** RESERVED FOR THE HOST RUNTIME — the code that owns the bindings, not the end consumer that
520
+ * configures it. Called once after a swap is undone, for ANY cause, with the revert already
521
+ * complete (entry released, `<img>` gone, canvas visibility restored), so a re-entrant
522
+ * `revertStaticImage` from inside it finds nothing to revert and is a no-op.
523
+ *
524
+ * WHY IT EXISTS. A revert normally uncovers a canvas that still holds the right frame — that is
525
+ * the mechanism's safety net, and it needs no notification. `claimStaticStill` breaks that
526
+ * assumption on purpose: a surface mounted straight from a cached still has NEVER painted, so
527
+ * under its `<img>` is a canvas with no pixels (and, in the runtime this was written for, no
528
+ * context and no backing store either). An AUTONOMOUS revert — the watchdog, a host
529
+ * `invalidateStaticSurfaces`, a re-size — would therefore uncover a blank surface with nothing
530
+ * scheduled to fix it. This is how that runtime hears about it in time to build the surface and
531
+ * draw, in the same task, before anything composites.
532
+ * A runtime that wraps a consumer's policy MUST run the consumer's handler too, if one was given;
533
+ * this module calls exactly the one function it is handed.
534
+ * A handler that throws is a host bug and is swallowed here: it must not be able to leave a
535
+ * half-reverted surface behind. */
536
+ onRevert?: (binding: StaticImageSwapBinding) => void;
537
+ /** Injectable monotonic clock, ms. Default `performance.now()` (falling back to `Date.now()`). */
538
+ now?: () => number;
539
+ /** Injectable timer seam. Both must be supplied together; absent ⇒ the globals, and where there is
540
+ * no timer host at all (SSR) every deferred path is simply inert. */
541
+ setTimeout?: (fn: () => void, ms: number) => StaticSurfaceTimerHandle;
542
+ clearTimeout?: (handle: StaticSurfaceTimerHandle) => void;
543
+ }
544
+ /** The public option shape: `true`/`false` keep their original meaning, an object supplies policy. */
545
+ type StaticSurfaceOption = boolean | StaticSurfacePolicy;
546
+ /** Why a live swap was undone. `staticImageRevertsByCause` splits the aggregate by these. */
547
+ type StaticImageRevertCause = /** The content key moved (the `content-key` gate's churn case). */"key-change" /** The surface stopped producing frozen output at all (the host reported a null key). */ | "not-frozen" /** The surface drew again (the `quiet-window` gate's thaw). */ | "draw" /** A host `invalidateStaticSurfaces` call. */ | "host-invalidate" /** The watchdog found a surface that was no longer legitimately frozen. */ | "watchdog"
548
+ /** A runtime-wide deliberate change: a re-size, a pixel-ratio pin, a mode flip, the kill switch,
549
+ * or a geometry move of the canvas itself. */
550
+ | "resize" /** A dormancy wake whose next repaint cannot be attributed (see `noteStaticSurfaceWake`). */ | "dormancy-wake" /** The `<img>` would not decode. */ | "decode-failure";
551
+ /** The counters this module bumps on the host's live stats object (`WebglShaderRuntimeStats`
552
+ * extends this). Build one with `createStaticImageSwapCounters()` so a later field addition does
553
+ * not break every construction site. */
554
+ interface StaticImageSwapCounters {
555
+ /** COUNTER. Surfaces whose `<img>` went live (canvas hidden). Monotonic; a surface that swaps,
556
+ * reverts and swaps again counts twice. */
557
+ staticImageSwaps: number;
558
+ /** COUNTER. Live swaps undone, for ANY reason — the aggregate of `staticImageRevertsByCause`,
559
+ * kept under its original name so existing dashboards keep working. Disposal does NOT count
560
+ * (the surface is gone, not reverted). */
561
+ staticImageReverts: number;
562
+ /** COUNTER, per cause (see `StaticImageRevertCause`). Sums to `staticImageReverts`. */
563
+ staticImageRevertsByCause: Record<StaticImageRevertCause, number>;
564
+ /** COUNTER. Frames encoded — ONE per distinct content key, however many surfaces share it.
565
+ * Counted when the blob is in hand. */
566
+ staticImageEncodes: number;
567
+ /** COUNTER. Encodes/decodes that failed or are unsupported (`toBlob` missing, a null blob, an
568
+ * `<img>` that would not decode). Every one of them leaves the surface on its canvas. */
569
+ staticImageFailures: number;
570
+ /** COUNTER. Drain passes the host's `encode.busy` predicate sent away — one per PASS, whatever the
571
+ * queue length. Zero unless a host supplies the predicate. NOTE, for anyone comparing across
572
+ * versions: a host that pins `encode.perTask` below its `slice` makes MORE passes for an identical
573
+ * workload, so this number rises with it. The diagnosis pair below is a ratio and is unaffected. */
574
+ staticImageBusyDeferrals: number;
575
+ /** COUNTER. Passes that encoded against a still-deferring signal because `busyMaxDeferMs` had
576
+ * elapsed. The diagnosis pair: `staticImageBusyForcedEncodes` ≈ `staticImageBusyDeferrals` means
577
+ * the host's predicate is stuck ON (every deferral ran the bound out), while forced ≪ deferrals is
578
+ * the signal working as intended — bursts ridden out, quiet windows drained normally. */
579
+ staticImageBusyForcedEncodes: number;
580
+ /** COUNTER, ms. Summed SYNCHRONOUS cost of every readback (`toBlob`'s own call, any `maxDim` clamp
581
+ * blit included) — the main-thread park this module is charged for, as a number rather than a
582
+ * trace. */
583
+ staticImageEncodeMs: number;
584
+ /** HIGH-WATER, ms. The single worst readback. The regression probe for "N readbacks in one task":
585
+ * under `encode.perTask: 1` this IS the longest task the mechanism can produce. */
586
+ staticImageEncodeMaxMs: number;
587
+ /** COUNTER. Readbacks measured at or over `encode.slowEncodeMs`, i.e. the ones that armed the
588
+ * adaptive backoff. Zero unless a host asks for it. */
589
+ staticImageSlowEncodes: number;
590
+ /** COUNTER. Passes the ADAPTIVE BACKOFF sent away. Sibling of `staticImageBusyDeferrals`: the two
591
+ * split the deferral total by who asked for it (the host's predicate, or this module's own
592
+ * measurement of the previous readback). */
593
+ staticImageBackoffDeferrals: number;
594
+ /** COUNTER. Encodes that read a downscaled scratch instead of the source canvas (`encode.maxDim`). */
595
+ staticImageClampedEncodes: number;
596
+ /** COUNTER. Frames produced through a binding's `captureCanvas` hook — one per ENCODE (so one per
597
+ * distinct content key, like `staticImageEncodes`), not one per surface. Zero on a runtime whose
598
+ * surfaces are all directly readable canvases; on a WebGPU runtime it should track
599
+ * `staticImageEncodes` exactly. */
600
+ staticImageCaptures: number;
601
+ /** COUNTER. Capture hooks that answered null, threw, or handed back a degenerate canvas. Each one
602
+ * ALSO lands in `staticImageFailures` (it goes through the same `fail()`), so the aggregate keeps
603
+ * its meaning; this is the split that says the failure was the READBACK rather than the codec. */
604
+ staticImageCaptureFailures: number;
605
+ /** COUNTER. Captures REFUSED because they held no visible pixels for a frame the producer knew it
606
+ * had drawn (`STATIC_CAPTURE_BLANK`, see the module doc's BLANK CAPTURES). A SUBSET of
607
+ * `staticImageCaptureFailures` — a capture that produced nothing is a capture failure — split out
608
+ * because it is the one failure that would otherwise have looked like a success: without it, a
609
+ * device that cannot produce still pixels reads as `staticImagesLive N/N` over N invisible
610
+ * surfaces.
611
+ *
612
+ * NON-ZERO MEANS ONE OF TWO THINGS, and they are told apart by whether the affected surfaces ever
613
+ * freeze again: a capture path that produces nothing on this device/launch mode (every capture
614
+ * blank, nothing swaps, and the surfaces stay on their canvases — the correct outcome), or a
615
+ * producer claiming coverage for a frame that really is invisible, which costs that surface its
616
+ * freeze and nothing else. Each blank is TERMINAL for its surface under either `onInvalidate`.
617
+ *
618
+ * ZERO IS NOT A CLEAN BILL OF HEALTH FOR A 2D-BACKED FLEET. Only a CAPTURE HOOK can book this;
619
+ * a surface read directly through `toBlob` has no equivalent check, on purpose and on evidence
620
+ * (the module doc's WHY THE DIRECT PATH IS NOT GUARDED). */
621
+ staticImageBlankCaptures: number;
622
+ /** COUNTER, ms. Summed WALL time of every capture hook. Deliberately NOT part of
623
+ * `staticImageEncodeMs`: that number means synchronous main-thread park, and a GPU `mapAsync`
624
+ * readback does not park the main thread (see the module doc's CAPTURE-HOOK section). */
625
+ staticImageCaptureMs: number;
626
+ /** HIGH-WATER, ms. The single slowest capture — the probe for a GPU that has stopped handing
627
+ * pixels over promptly, and the number `encode.slowEncodeMs` is compared against on this path. */
628
+ staticImageCaptureMaxMs: number;
629
+ /** COUNTER. Freezes served from a PARKED still — a re-attach that cost no readback at all because
630
+ * the canvas had not been painted or re-allocated since the entry was encoded (see the module
631
+ * doc). Zero unless a host sets `encode.parkedStillBytes`. */
632
+ staticImageReuseHits: number;
633
+ /** COUNTER. `claimStaticStill` calls that found an attachable entry for the key — one with a URL
634
+ * in hand, live or retained. THE measurement of the second-appearance shortcut: a hit is a
635
+ * surface that skipped the gate, the paint and the encode entirely. */
636
+ staticStillCacheHits: number;
637
+ /** COUNTER. `claimStaticStill` calls that found nothing attachable, so the caller must render the
638
+ * surface itself — no entry, a failed one, or an encode still IN FLIGHT (which has no URL yet;
639
+ * the caller cannot wait, so it renders, and the ordinary gate will swap it when it settles). A
640
+ * steady stream of these against a stable key population means the stills are not surviving —
641
+ * check `staticStillRetainedEntries` and `encode.stillCacheBytes` before blaming the keys. */
642
+ staticStillCacheMisses: number;
643
+ /** COUNTER. Claimed stills whose `<img>` actually went live (decoded and mounted). Sits below
644
+ * `staticStillCacheHits` by exactly the claims that were undone before their decode finished — a
645
+ * revert, a dispose, or a decode failure — which is the only way to tell "the key was there" from
646
+ * "the pixels reached the screen". */
647
+ staticStillMounts: number;
648
+ /** COUNTER. `bakeStill` calls that really enqueued an encode (a bake for a known key, a solo key,
649
+ * or an unreadable canvas is a no-op and books nothing). One per key, by construction. */
650
+ staticStillBakes: number;
651
+ /** GAUGE — entries sitting in the module-wide still pool RIGHT NOW, parked and retained together
652
+ * (see the module doc's "RETAINED STILLS"). Read through `staticStillPoolStats()`; like
653
+ * `staticImageUrlsLive` it is document-wide rather than per runtime, and a host refreshes it on
654
+ * each `stats()` read. */
655
+ staticStillRetainedEntries: number;
656
+ /** GAUGE — bytes those entries pin (each blob's own `size`, as recorded at publish). The number to
657
+ * compare against `parkedStillBytes + stillCacheBytes`: at the budget, the pool is evicting. */
658
+ staticStillRetainedBytes: number;
659
+ /** GAUGE — how many OBJECT URLS are alive right now, MODULE-wide (across every runtime in the
660
+ * document), refreshed on each `stats()` read. This is the leak probe: it must come back to 0
661
+ * after the last runtime is disposed. NOT a swap count — one URL can back many surfaces, and under
662
+ * `encode.parkedStillBytes` a URL held by NO surface still counts, because its bytes are still
663
+ * pinned. Dispose revokes those too, so the probe is unaffected. */
664
+ staticImageUrlsLive: number;
665
+ /** GAUGE — how many SURFACES are swapped right now (`<img>` up, canvas hidden) against THIS
666
+ * counters object, i.e. per runtime. Rises on swap, falls on revert AND on dispose, so it comes
667
+ * back to 0 at teardown. This is the "is the mechanism actually engaged?" measurement (`72/72`);
668
+ * `staticImageUrlsLive` cannot answer that, because one URL can back many surfaces. */
669
+ staticImagesLive: number;
670
+ }
671
+ /** A zeroed counters object (see `StaticImageSwapCounters`). */
672
+ declare function createStaticImageSwapCounters(): StaticImageSwapCounters;
673
+ /** Per-surface swap state. Present only while the mechanism is enabled — `null` is the kill switch,
674
+ * and the host's render path then takes exactly the code it took before this module existed. */
675
+ interface StaticImageState {
676
+ /** The content key of the frame the canvas currently holds (null until the first cacheable render,
677
+ * and typically null throughout for a keyless `quiet-window` surface). */
678
+ key: string | null;
679
+ /** Consecutive unchanged observations of `key` (the `content-key` gate). */
680
+ stable: number;
681
+ /** The stand-in element, once one exists (created at swap time, dropped on revert). */
682
+ img: HTMLImageElement | null;
683
+ /** The refcounted per-key object-URL entry this surface holds, or null. Non-null covers BOTH "the
684
+ * encode/decode is in flight" and "the `<img>` is live" — see `shown`. */
685
+ entry: StaticImageEntry | null;
686
+ /** The entry this surface's LAST freeze left parked (`encode.parkedStillBytes`), held by nobody and
687
+ * claimable only by this surface. Mutually exclusive with `entry`: a surface holds its still or
688
+ * parks it, never both. */
689
+ parked: StaticImageEntry | null;
690
+ /** The reuse fingerprint `parked` was stamped with — the paint count and backing-store size the
691
+ * entry was ENCODED at. `reclaimParkedStill` re-attaches only while the canvas still reports all
692
+ * three unchanged, which is the whole correctness argument for handing back old pixels. */
693
+ parkedDrawSeq: number;
694
+ parkedW: number;
695
+ parkedH: number;
696
+ /** The `<img>` is mounted and standing in for the canvas. */
697
+ shown: boolean;
698
+ /** The entry this surface currently holds was taken by `claimStaticStill` rather than earned
699
+ * through the gate — i.e. this canvas has never painted the frame the `<img>` is showing, and may
700
+ * never have painted at all. Cleared when the entry is let go. Read only to split
701
+ * `staticStillMounts` out of the ordinary swap count; the mechanics below treat a claimed surface
702
+ * exactly like any other, which is deliberate — its stand-in reverts, re-syncs and is refcounted
703
+ * by the same code. */
704
+ claimed: boolean;
705
+ /** Disqualified for the life of the binding (only reachable under `onInvalidate: "block"`). */
706
+ blocked: boolean;
707
+ /** The swapper this surface belongs to: its policy, its encode queue, its timers. Every free
708
+ * function in this module reads the policy from here, so one document can run several runtimes
709
+ * under different policies. */
710
+ swapper: SwapperContext;
711
+ /** The counters object this surface's TIMER-driven paths (sweep, watchdog, retry, dispose) bump.
712
+ * Seeded when the swapper attaches the binding and refreshed by every call that carries one, so
713
+ * a deferred revert lands on the same object the synchronous ones did. */
714
+ counters: StaticImageSwapCounters | null;
715
+ /** `now()` of the last reported paint into this canvas — the `quiet-window` gate's clock. */
716
+ lastDrawAt: number;
717
+ /** Monotonic count of reported paints; the watchdog compares it against `drawSeqAtFreeze`. */
718
+ drawSeq: number;
719
+ drawSeqAtFreeze: number;
720
+ /** Backing-store size at freeze time: a `width`/`height` write REALLOCATES (and clears) a canvas,
721
+ * which the watchdog reads as an unexplained repaint. */
722
+ frozenW: number;
723
+ frozenH: number;
724
+ /** The canvas's inline style at freeze time. The stand-in copies the box ONCE, so a later
725
+ * placement write would leave it at the old box — the watchdog re-syncs on a mismatch. */
726
+ boxCss: string;
727
+ /** Earliest `now()` at which a failed encode/decode may be retried (`onInvalidate: "retry"`). */
728
+ retryAfter: number;
729
+ /** THIS module hid the canvas (so it, and only it, may un-hide it). */
730
+ hidCanvas: boolean;
731
+ /** The canvas's `display` as the HOST left it, captured the moment this module first hid it and
732
+ * restored verbatim when it un-hides. */
733
+ hostDisplay: string;
734
+ }
735
+ /** The subset of a host runtime's node binding this module touches. Structural on purpose: it keeps
736
+ * the swap independently testable and keeps the host runtimes free of a back-import. */
737
+ interface StaticImageSwapBinding {
738
+ /** The host's node element — passed to `canFreezeSurface`, never otherwise read or written. */
739
+ node: HTMLElement;
740
+ canvas: HTMLCanvasElement;
741
+ /** Set whenever the canvas may not match `state.key` yet (a pending re-render, a realloc that
742
+ * cleared it). The gate refuses to encode from a dirty binding. */
743
+ dirty: boolean;
744
+ /** Parked by the host: observes nothing, and hides BOTH surfaces. */
745
+ dormant: boolean;
746
+ /** ASYNC ENCODE SOURCE, for a surface whose own canvas cannot be read back — a WebGPU one, whose
747
+ * every canvas-read path is blank headless and pathological on Android (see the module doc's
748
+ * CAPTURE-HOOK SOURCES). Returns a fresh 2D canvas holding the frame the surface is CURRENTLY
749
+ * showing, at its backing-store size, or null when it cannot be produced; this module encodes
750
+ * that canvas and then releases it. `STATIC_CAPTURE_BLANK` is the third answer: the capture
751
+ * completed and holds NOTHING VISIBLE, for a frame the producer knows it drew (see the module
752
+ * doc's BLANK CAPTURES). ABSENT ⇒ `canvas` is read directly, which is what every 2D-backed
753
+ * surface does and is exactly the path that shipped first. */
754
+ captureCanvas?: () => Promise<StaticSurfaceCapture>;
755
+ staticImage: StaticImageState | null;
756
+ }
757
+ /** One encoded frame, shared by every surface on that content key. */
758
+ interface StaticImageEntry {
759
+ key: string;
760
+ /** Surfaces holding this entry (swapped or waiting for the encode). At 0 the URL is revoked. */
761
+ refs: number;
762
+ url: string | null;
763
+ /** The encode failed / is unsupported and no one may retry this key. Only ever set under
764
+ * `onInvalidate: "block"` — a `retry` policy drops the entry instead, so the key stays open. */
765
+ failed: boolean;
766
+ /** Encoded size in bytes, captured from the blob at publish (0 until then). The still pool budgets
767
+ * on this: what a pooled entry costs is its retained pixels, not its pixel count. */
768
+ bytes: number;
769
+ /** Surfaces waiting for the in-flight encode. */
770
+ waiters: Set<StaticImageSwapBinding>;
771
+ /** BORN HELD BY NOBODY: this entry was created by `bakeStill` for a key with no waiting surface,
772
+ * so `refs: 0` is its normal state and not the "everyone let go" that the encode tail drops an
773
+ * entry for. The three places that read `refs <= 0` as abandonment — the queue drain, the capture
774
+ * tail and `publish` — consult this to tell the two apart. A published bake goes straight into
775
+ * the retained pool, where the flag stops mattering: from then on it behaves like any other
776
+ * unheld entry. */
777
+ bake: boolean;
778
+ }
779
+ interface ResolvedPolicy {
780
+ quietWindow: boolean;
781
+ observations: number;
782
+ quietMs: number;
783
+ /** The quiet window for a surface whose last reported key was non-null (`keyedQuietMs`, defaulted
784
+ * to `quietMs` here so every read site is one lookup and the inert case costs nothing). */
785
+ keyedQuietMs: number;
786
+ retry: boolean;
787
+ slice: number;
788
+ intervalMs: number;
789
+ smallestFirst: boolean;
790
+ deferHead: boolean;
791
+ busy: (() => boolean) | null;
792
+ busyMaxDeferMs: number;
793
+ perTask: number;
794
+ taskGapMs: number;
795
+ slowEncodeMs: number;
796
+ slowBackoffMs: number;
797
+ maxDim: number;
798
+ parkedStillBytes: number;
799
+ stillCacheBytes: number;
800
+ /** What the shared pool is trimmed to: the two budgets added, because the two kinds of unheld
801
+ * still are the same bytes (see the module doc). Precomputed so the eviction walk reads one
802
+ * number. */
803
+ poolBytes: number;
804
+ primeUnseenKeys: boolean;
805
+ watchdogMs: number;
806
+ canFreeze: ((node: HTMLElement, canvas: HTMLCanvasElement) => boolean) | null;
807
+ onRevert: ((binding: StaticImageSwapBinding) => void) | null;
808
+ now: () => number;
809
+ setT: ((fn: () => void, ms: number) => StaticSurfaceTimerHandle) | null;
810
+ clearT: (handle: StaticSurfaceTimerHandle) => void;
811
+ }
812
+ interface EncodeJob {
813
+ entry: StaticImageEntry;
814
+ canvas: HTMLCanvasElement;
815
+ /** The binding's async encode source, or null for the ordinary "read the canvas" path. Captured
816
+ * at ENQUEUE so the job stays self-contained: the queue outlives the call that made the surface
817
+ * eligible, and the pacing must not have to reach back into a binding to drain. */
818
+ capture: (() => Promise<StaticSurfaceCapture>) | null;
819
+ area: number;
820
+ counters: StaticImageSwapCounters;
821
+ /** `bakeStill`'s completion hook, or undefined for an ordinary surface-driven encode (whose
822
+ * completion IS its `<img>` going up). Called exactly once, wherever the job ends. */
823
+ settle?: (published: boolean) => void;
824
+ }
825
+ /** One policy + its bookkeeping. Shared by every surface the swapper attached. */
826
+ interface SwapperContext {
827
+ policy: ResolvedPolicy;
828
+ counters: StaticImageSwapCounters | null;
829
+ bindings: Set<StaticImageSwapBinding>;
830
+ queue: EncodeJob[];
831
+ /** Start of the current encode WINDOW, and how many encodes it has already kicked (see
832
+ * `pumpEncodes`). */
833
+ sliceAt: number;
834
+ sliceCount: number;
835
+ /** Readbacks the CURRENT task has already spent (`encode.perTask`). Reset only where a task
836
+ * boundary is actually observable — inside a timer callback — because the module is handed the
837
+ * thread many times per task: one `runSweep` calls `maybeSwap` for every eligible surface, and
838
+ * each of those reaches `pumpEncodes` on the SAME stack. A per-call counter would therefore bound
839
+ * nothing at all on the path that produced the measured 1,163 ms park. */
840
+ taskKicks: number;
841
+ /** `now()` the CURRENT unbroken run of deferrals started, or `NOT_DEFERRING`. The bound is measured
842
+ * from here and restarts on every forced pass, so it caps a RUN rather than the queue's total wait
843
+ * — and it is ONE run whichever reason is deferring (see `encodeDeferred`). */
844
+ busyDeferSince: number;
845
+ /** `now()` until which the adaptive slow-encode backoff holds encodes (0 = not backing off). */
846
+ backoffUntil: number;
847
+ /** The scratch canvas `encode.maxDim` downscales into, allocated on the first clamped encode and
848
+ * released at dispose. ONE per swapper: `toBlob` snapshots its source synchronously in Blink (the
849
+ * guarantee gsw's own perf harness already depends on — `scenarios/static-surfaces.ts`), so reuse
850
+ * cannot race a blob still being compressed. */
851
+ scratch: HTMLCanvasElement | null;
852
+ drainTimer: StaticSurfaceTimerHandle | null;
853
+ sweepTimer: StaticSurfaceTimerHandle | null;
854
+ /** Absolute `now()` the armed sweep fires at (so an arm never postpones an earlier one). */
855
+ sweepAt: number;
856
+ lastWatchdogAt: number;
857
+ disposed: boolean;
858
+ }
859
+ /** The host-facing handle: everything that needs to enumerate the swapper's surfaces. Per-surface
860
+ * signalling stays in the free functions below, which read the policy off the surface's state. */
861
+ //#endregion
862
+ //#region src/runtime-options.d.ts
863
+ /** Browser effect hosting controls, separate from HTML model projection. */
864
+ interface GodotHtmlRuntimeOptions extends Pick<GodotHtmlRenderOptions, "enableWebglShaders" | "enableParticles"> {
865
+ externalRuntimes?: boolean;
866
+ resolveShaderSource?: (path?: string, uid?: string) => Promise<string | undefined> | string | undefined;
867
+ particleMaxInstances?: number;
868
+ particleFps?: number;
869
+ particleRectCache?: boolean;
870
+ particleObserverSizing?: boolean;
871
+ particleDormant?: boolean;
872
+ particleTravelExtents?: boolean;
873
+ staticParticles?: boolean;
874
+ staticParticleOneShotExpiry?: boolean;
875
+ staticParticlePixelRatio?: number;
876
+ parkStaticParticleBlend?: boolean;
877
+ staticParticleImages?: StaticSurfaceOption;
878
+ staticParticleFreezeAtMount?: boolean;
879
+ shaderFps?: number;
880
+ effectsLoopPacing?: EffectsLoopPacing;
881
+ effectsRenderer?: "auto" | "webgl" | "webgpu";
882
+ effectsProfiling?: boolean;
883
+ renderScale?: number;
884
+ staticShaders?: boolean;
885
+ staticShaderTime?: number;
886
+ staticShaderPixelRatio?: number;
887
+ staticShaderImages?: StaticSurfaceOption;
888
+ maxTextureDimension?: number;
889
+ enableScreenTextureCapture?: boolean;
890
+ maxScreenCaptureDim?: number;
891
+ onUnsupported?: UnsupportedRenderReporter;
892
+ onBindingRendered?: (node: HTMLElement, canvas: HTMLCanvasElement, info: GodotEffectRenderInfo) => void;
893
+ }
894
+ /** Options accepted by a host that both emits a model and mounts its effects. */
895
+ type GodotHtmlMountOptions = GodotHtmlRenderOptions & GodotHtmlRuntimeOptions;
896
+ //#endregion
897
+ //#region src/webgl/shared-gl.d.ts
898
+ interface GpuInfo {
899
+ /** The unmasked GL renderer string, or "" when masked/unknown/unavailable. */
900
+ renderer: string;
901
+ /** True only when `renderer` POSITIVELY matches a software rasterizer — never on "" (unknown). */
902
+ software: boolean;
903
+ /** True when WebGL is unavailable at all (no context). */
904
+ unavailable: boolean;
905
+ }
906
+ declare function describeGpu(): GpuInfo;
907
+ declare function effectivePixelRatio(renderScale?: number): number;
908
+ declare const MAX_PINNED_BACKING_DIM = 2048;
909
+ /** Backing-store size (px) for a `cssW`×`cssH` surface at `ratio`, plus the ratio ACTUALLY applied —
910
+ * which differs from `ratio` only when `maxDim` bit, and is what a caller that draws geometry in
911
+ * CSS px × ratio (the particle runtime) must scale by so its sprites still land inside the canvas.
912
+ *
913
+ * With `maxDim` the result is scaled down ASPECT-PRESERVINGLY (the `downscaleForUpload` idiom) so
914
+ * its longest edge fits. Per-axis clamping is not an option: the shader runtime's `uvFit` derives
915
+ * contain/cover from the canvas aspect, so a squashed backing store would re-fit the texture
916
+ * wrongly. Without `maxDim` (or under it) this is exactly the `Math.max(1, Math.round(css * ratio))`
917
+ * both runtimes have always done, and `ratio` comes back untouched. */
918
+ declare function backingStoreSize(cssW: number, cssH: number, ratio: number, maxDim?: number): {
919
+ w: number;
920
+ h: number;
921
+ ratio: number;
922
+ };
923
+ /** The self-layer attribute that carries a surface's PER-BINDING backing-density multiplier — the
924
+ * factor by which the device pixels this surface really covers exceed its own CSS box.
925
+ *
926
+ * Both fx runtimes size a canvas from `clientWidth × (devicePixelRatio × renderScale)`, and
927
+ * `clientWidth` is blind to ancestor CSS transforms: a node under a `transform: scale(1.41)`
928
+ * ancestor lays out at its untransformed width, so its backing store is sized for 1/1.41 of the
929
+ * device pixels it is magnified onto and the surface is visibly soft. Nothing the runtime can read
930
+ * off its own element tells it that — the transform belongs to an ancestor the runtime does not
931
+ * own, and finding it would be a `getBoundingClientRect()` walk per surface per frame, i.e. exactly
932
+ * the forced layout both runtimes are built around avoiding.
933
+ *
934
+ * So the HOST states it. It already composed that transform to write it; this attribute is that
935
+ * number, handed down. It is a MULTIPLIER on the density term, not a replacement for it: the
936
+ * device ratio, `renderScale` and any frozen-mode pin all still apply, and this rides on top.
937
+ *
938
+ * IT IS AN AXIS SCALE, NOT A BOUNDING-BOX RATIO, and a host that confuses the two will over-allocate
939
+ * every rotated surface it owns. `getBoundingClientRect()` returns the axis-aligned bounding box of a
940
+ * transformed element, so a square rotated θ measures `|cos θ| + |sin θ|` wider than it is — √2 at
941
+ * 45° — while covering exactly as many device pixels as before. Rotation is rigid. The number this
942
+ * attribute wants is the transform's column norm (or the mean of the two under a non-uniform scale).
943
+ *
944
+ * Named `data-godot-shader-*` and read by the PARTICLE runtime as well, deliberately — one name
945
+ * for one question ("how magnified is this surface?") that both fx families ask identically, so a
946
+ * host stamps it the same way whichever runtime picks the node up. */
947
+ declare const SURFACE_PIXEL_RATIO_ATTR = "data-godot-shader-pixel-ratio";
948
+ /** Ceiling on the attribute above. The multiplier is the one density input that arrives as a STRING
949
+ * from outside the runtime — `renderScale` is clamped to ≤ 1 and the static pin is bounded by
950
+ * `MAX_PINNED_BACKING_DIM` — so a host mid-animation, or a host with a bug, could otherwise turn
951
+ * one attribute write into a quadratic backing-store allocation on a surface the runtime has no
952
+ * other reason to distrust. 4 is 16× the area, past any magnification a UI plausibly applies to a
953
+ * live effect, and a surface asking for more is far likelier to be wrong than under-resolved. */
954
+ declare const MAX_SURFACE_PIXEL_RATIO = 4;
955
+ /** Parse `SURFACE_PIXEL_RATIO_ATTR` into a density MULTIPLIER.
956
+ *
957
+ * Absent, empty, unparseable, non-finite or non-positive ⇒ exactly `1`, i.e. the density term is
958
+ * the product it has always been and the surface is sized byte-for-byte as it was before this
959
+ * attribute existed. That is the whole off-switch: a host that never writes the attribute cannot
960
+ * tell this feature is here.
961
+ *
962
+ * Values BELOW 1 are honoured (a surface minified by an ancestor really does cover fewer device
963
+ * pixels than its box). Above `MAX_SURFACE_PIXEL_RATIO` they are capped, not rejected — a too-large
964
+ * value is still evidence the surface is magnified, so clamping keeps most of the fix while
965
+ * refusing the allocation. */
966
+ declare function parseSurfacePixelRatio(attr: string | null | undefined): number;
967
+ //#endregion
968
+ //#region src/webgpu/device.d.ts
969
+ type WebgpuFallbackReason = "no-navigator-gpu" | "no-adapter" | "fallback-adapter" | "acquire-timeout" | "device-lost" | "context-refused" | "pipeline-error";
970
+ //#endregion
971
+ //#region src/particles/runtime.d.ts
972
+ /** OPT-IN per-frame cost attribution for ONE particle runtime (`effectsProfiling`, see `../types`),
973
+ * read from `stats().profile`. NULL when the option is off — see `ParticleRuntimeStats.profile`.
974
+ *
975
+ * WHY IT EXISTS. A live tick's wall clock alone says nothing about what to DO: "9 ms/frame" is the
976
+ * same number whether the CPU integrator is chewing through 2000 particles × 4 sub-steps, the
977
+ * instance buffer is being rebuilt per frame, or the GL→2D blit is fill-bound at
978
+ * devicePixelRatio 3. Those have opposite fixes (`particleFps`/`amount`/`staticParticles` vs
979
+ * `renderScale`), so the tick is split into the four buckets below and each carries its own WORK
980
+ * counter — a millisecond total is only interpretable next to the work that produced it.
981
+ *
982
+ * Counters are monotonic and never reset (the `ParticleRuntimeStats` contract): a benchmark
983
+ * snapshots the object, runs its window, and diffs. Times are `performance.now()` deltas in ms,
984
+ * summed — wall clock on the main thread, so an interrupted frame charges its interruption to
985
+ * whichever bucket was open. */
986
+ interface ParticleProfile {
987
+ /** LIVE ticks that simulated + drew at least one binding. The frozen (`staticParticles`) path
988
+ * books NONE: it draws once and parks the loop, so a frozen runtime reports `ticks: 0` and every
989
+ * other field 0 — which is the right report for a mode whose whole point is that it has no
990
+ * per-frame cost. A deferred tick (the FPS cap re-arming without work) books none either. */
991
+ ticks: number;
992
+ /** Bindings simulated + drawn, summed across those ticks. `bindings / ticks` is the live system
993
+ * count the frame really paid for — suspended, parked and unmounted bindings are skipped by the
994
+ * loop and never counted. */
995
+ bindings: number;
996
+ /** Fixed sub-steps `simulateParticles` executed. THE sim work unit, and deliberately not the tick
997
+ * count: the sim runs at `fixed_fps` (30 by default) regardless of display rate, so a 60Hz device
998
+ * runs ~0.5 steps per tick per binding and a stalled frame runs several. `simMs / simSteps` is
999
+ * therefore the only stable cost-per-unit, and a `simSteps` far from `bindings` is the proof that
1000
+ * the sim rate is decoupled from the display rate. */
1001
+ simSteps: number;
1002
+ /** Instances pushed into the GL instance buffer ≈ live particles actually drawn (a dead or
1003
+ * fully-transparent particle is skipped by the build loop). The denominator for both `buildMs`
1004
+ * and `glMs`, and the number to compare against `particleMaxInstances`. */
1005
+ instances: number;
1006
+ /** CPU simulation: `simulateParticles` (integrate + emit + curve sampling). The bucket the
1007
+ * mid-range-phone suspicion points at. */
1008
+ simMs: number;
1009
+ /** Instance-buffer BUILD: the per-particle push loop in `drawBinding` that turns simulation state
1010
+ * into the interleaved float array. Separate from `simMs` because it is a different fix — it
1011
+ * scales with LIVE particles, not with sub-steps. */
1012
+ buildMs: number;
1013
+ /** GL SUBMIT: `drawParticles` — uniform writes, the buffer upload and the instanced draw call.
1014
+ * SUBMIT ONLY: the GPU executes asynchronously, so this is main-thread issue cost, never GPU
1015
+ * time. A GPU-bound frame shows up as back-pressure in `blitMs` (the readback-shaped
1016
+ * `drawImage`), not here. */
1017
+ glMs: number;
1018
+ /** GL→2D BLIT: the binding canvas's `clearRect` plus the `drawImage` that copies the shared GL
1019
+ * canvas onto it. Pure fill cost, so it scales with BACKING-STORE AREA (`renderScale`,
1020
+ * `devicePixelRatio`, the sprite `pad`) and not with particle count — which is exactly the
1021
+ * distinction a "particles are slow" report cannot make without this split. */
1022
+ blitMs: number;
1023
+ }
1024
+ /** Live, monotonically-increasing counters for ONE particle runtime (see `ParticleRuntime.stats`).
1025
+ * Same contract as the shader runtime's `WebglShaderRuntimeStats`: plain `++` writes, never reset,
1026
+ * the SAME object returned on every `stats()` call (snapshot to diff). */
1027
+ interface ParticleRuntimeStats extends StaticImageSwapCounters {
1028
+ /** Actual instanced GL draws of a particle-system frame (`drawBinding` reaching `drawParticles`). */
1029
+ draws: number;
1030
+ /** Frozen-mode static-frame cache hits: the warm AND the instanced draw were both skipped and a
1031
+ * cached canvas blitted instead (see `./static-frame-cache`). The shader runtime's `cacheHits`
1032
+ * sibling. A fleet of N identical frozen systems should settle at 1 `draws` + (N-1) `cacheHits`. */
1033
+ cacheHits: number;
1034
+ /** `syncCanvasSize` calls that sized a binding at the PINNED static ratio
1035
+ * (`staticParticlePixelRatio`) instead of `devicePixelRatio × renderScale` — the shader runtime's
1036
+ * `pinnedCanvasSyncs` sibling, and the same purpose: a device probe reads it to confirm the pin
1037
+ * is really in force (0 = option unset, or frozen mode never entered). */
1038
+ pinnedCanvasSyncs: number;
1039
+ /** Self-layer box reads: `selfLayer.clientWidth`/`clientHeight`, i.e. the layout this runtime
1040
+ * forces. Booked by the create-time measure pass and by any `syncCanvasSize` that had neither a
1041
+ * ResizeObserver `contentRect` nor a cached box. With the cache on (`particleRectCache`, the
1042
+ * default) it settles at exactly one per binding CREATED and zero for everything after — fleet
1043
+ * re-sizes, texture-load re-pads and observer deliveries are all reflow-free — so a device probe
1044
+ * can confirm that live. It counts READS, not flushes: the create-time reads are batched into one
1045
+ * contiguous run, so N new bindings book N reads and cost ONE forced layout flush.
1046
+ *
1047
+ * With `particleObserverSizing` also on (the default) even that per-create read is gone and this
1048
+ * settles at **0**: a new binding's first box arrives from the shared ResizeObserver, off the main
1049
+ * path. Any read left standing is therefore a real signal — a wake whose observation never landed,
1050
+ * or the mount backstop firing on an engine that does not deliver 0x0 initial observations. */
1051
+ boxReads: number;
1052
+ /** COUNTER. Bindings PARKED (canvas hidden) because their subtree went suspended — a create that
1053
+ * was born parked included. Monotonic: a binding that parks, wakes and parks again counts twice.
1054
+ * 0 means the park never engaged (nothing suspended, or `particleDormant: false`). */
1055
+ dormantParks: number;
1056
+ /** COUNTER. Parked bindings woken again (the resume half, so a probe can tell "parked and stayed
1057
+ * parked" from "flapped"). */
1058
+ dormantWakes: number;
1059
+ /** COUNTER. Bindings the expiry sweep DISPOSED for real after ~`DORMANT_DISPOSE_SECONDS` parked
1060
+ * (canvas removed from the DOM, GL buffer released) rather than holding them forever. */
1061
+ dormantDisposes: number;
1062
+ /** GAUGE — bindings parked RIGHT NOW, sampled on each `stats()` read (the `staticImagesLive`
1063
+ * contract exactly, and re-derived from the binding set rather than trusted incrementally). This
1064
+ * is the "is the park actually engaged?" measurement a device probe reads — `28/33` — which the
1065
+ * monotonic counters above cannot answer. */
1066
+ dormantLive: number;
1067
+ /** Per-frame cost attribution (see `ParticleProfile`), or NULL when `effectsProfiling` is off —
1068
+ * which is the default, and the no-op handle always. NULL rather than a zeroed object ON PURPOSE:
1069
+ * a bench that read `simMs: 0` out of an un-instrumented runtime would report "the simulation is
1070
+ * free" when the truth is "nobody measured". The same object every `stats()` call, mutated in
1071
+ * place by the live tick. */
1072
+ profile: ParticleProfile | null;
1073
+ /** GAUGE — which renderer this runtime's bindings are drawing through RIGHT NOW, re-derived on
1074
+ * each `stats()` read (a runtime can change backend mid-life, in one direction: a WebGPU device
1075
+ * that is lost is rebuilt on WebGL).
1076
+ *
1077
+ * `"pending"` is a real state, not a transient to be waited out politely: with
1078
+ * `effectsRenderer: "auto"`/`"webgpu"` on a browser that HAS `navigator.gpu`, the device arrives
1079
+ * from a promise, and until it does the bindings exist, are sized and are mounted but have no
1080
+ * surface and draw nothing. `"none"` is the no-op handle (no WebGL2, or particles disabled). */
1081
+ renderer: "pending" | "webgpu" | "webgl" | "none";
1082
+ /** COUNTER. Times this runtime adopted WebGL after being asked for `"auto"`/`"webgpu"` — the
1083
+ * SYNCHRONOUS "this browser has no navigator.gpu" decline included, which is the common case and
1084
+ * the reason a plain WebGL page reports 1 here rather than 0. Stays 0 for `effectsRenderer:
1085
+ * "webgl"` (nothing was ever asked for) and for a runtime that adopted WebGPU and kept it. */
1086
+ webgpuFallbacks: number;
1087
+ /** The FIRST reason this runtime declined WebGPU (later ones cannot un-explain it), or null while
1088
+ * it never has. THE diagnostic for a silent fallback: `renderer: "webgl"` under
1089
+ * `effectsRenderer: "webgpu"` says something went wrong, and only this says what. */
1090
+ webgpuFallbackReason: WebgpuFallbackReason | null;
1091
+ /** COUNTER — `queue.submit` calls this runtime's WebGPU backend has made, sampled on read. ONE per
1092
+ * tick that drew anything, whatever the binding count: that batching is the measured win (S7), so
1093
+ * a probe that finds it climbing with N systems has found the win being given back. 0 on WebGL,
1094
+ * where the question is meaningless. */
1095
+ webgpuSubmits: number;
1096
+ /** GAUGE — `device.lost` resolutions seen by the page-wide device (see `../webgpu/device`). A lost
1097
+ * device stops producing frames, so a non-zero value here next to `renderer: "webgl"` is the
1098
+ * device-loss rebuild having happened. */
1099
+ webgpuDeviceLosses: number;
1100
+ /** GAUGE — `uncapturederror` events on the page-wide device. Non-zero means a frame was silently
1101
+ * WRONG: WebGPU reports most command-level mistakes this way and nothing else says so. */
1102
+ webgpuErrors: number;
1103
+ /** GAUGE — BAKE DONORS this runtime is holding right now (see `donateStill`): bindings whose node
1104
+ * is gone but whose surface is kept alive, out of the DOM, only long enough to encode the frame
1105
+ * their successors will claim. Sampled on each `stats()` read. A number pinned at the bound means
1106
+ * bakes are not draining — check `staticStillDonorBakes` against `staticStillDonorsDropped`. */
1107
+ staticStillDonors: number;
1108
+ /** COUNTER. Donor bakes that PUBLISHED, i.e. banked a frame nothing had encoded yet. Each one is a
1109
+ * key the next binding to reach it can claim for free. */
1110
+ staticStillDonorBakes: number;
1111
+ /** COUNTER. Donors released WITHOUT publishing — evicted by the donor bound, or dropped at runtime
1112
+ * teardown. A donor is speculative work by construction, so these are not failures; a run where
1113
+ * they dominate `staticStillDonorBakes` means the bound is too small for the scene's churn, or
1114
+ * that retention (`encode.stillCacheBytes`) is off and every bake is refused. */
1115
+ staticStillDonorsDropped: number;
1116
+ }
1117
+ /** A persistent particle runtime for a mounted scene root (see `createParticleRuntime`). */
1118
+ interface ParticleRuntime {
1119
+ /** Diff the current `[data-godot-particle-runtime]` nodes against the live bindings:
1120
+ * unchanged specs keep their running simulation, changed/new specs re-init that node only,
1121
+ * gone nodes are disposed. */
1122
+ reconcile(): void;
1123
+ /** Live retune the backing-store resolution (devicePixelRatio × clamped `scale`) without a
1124
+ * dispose+recreate — running simulations keep going, only the canvas density changes. While a pin
1125
+ * is in force (see `setStaticParticlePixelRatio`) AND the runtime is frozen this re-sizes nothing:
1126
+ * the frozen canvases are deliberately held still. The new scale applies to live bindings as soon
1127
+ * as frozen mode is left. */
1128
+ setRenderScale(scale: number): void;
1129
+ /** Live set/clear the pinned FROZEN backing-store ratio (see `staticParticlePixelRatio`). Pass
1130
+ * `undefined` (or a non-positive value) to un-pin. Only frozen bindings are affected. */
1131
+ setStaticParticlePixelRatio(ratio: number | undefined): void;
1132
+ /** Live retune the particle FPS cap (0 = uncapped). */
1133
+ setFps(fps: number): void;
1134
+ /** Live toggle frozen (single-shot) mode, mirroring `WebglShaderRuntime.setStaticShaders`: warm each system
1135
+ * to a representative mid-flight state, draw ONCE, then park the loop (true); or resume live simulation (false).
1136
+ * The low-cost fallback that shows a frozen spray of particles instead of a per-frame CPU sim + GL draw. */
1137
+ setStaticParticles(value: boolean): void;
1138
+ /** Live toggle of the frozen-surface image swap (see the `staticParticleImages` option and
1139
+ * `../surface-image-swap`), mirroring `WebglShaderRuntime.setStaticShaderImages`. Turning it OFF
1140
+ * reverts every live swap immediately and revokes its object URLs — the kill switch, safe to
1141
+ * throw at any time. Turning it ON arms the runtime's CONFIGURED policy (a boolean here never
1142
+ * replaces it) and every binding re-earns its window; a runtime left at the DEFAULT (`false`,
1143
+ * since this option is opt-in) has no configured policy to arm, so ON gives it the same
1144
+ * quiet-window policy `staticParticleImages: true` means — otherwise this switch could never
1145
+ * turn the mechanism on for the consumers it exists for. */
1146
+ setStaticParticleImages(value: boolean): void;
1147
+ /** HOST-DRIVEN revert of the surface image swap, WITHOUT blocking, mirroring the shader runtime's:
1148
+ * with no argument every swapped surface is handed back to its canvas; with a node list, only the
1149
+ * bindings at (or under) those elements. Each affected surface restarts its gate and re-earns the
1150
+ * swap on its own. This is how a host that knows something the runtime cannot see — it is about
1151
+ * to re-parent a subtree, it just re-themed, its own occlusion pass changed its mind —
1152
+ * un-shadows a stale stand-in immediately instead of waiting for a watchdog window. */
1153
+ invalidateStaticSurfaces(nodes?: Iterable<HTMLElement>): void;
1154
+ /** The runtime's live counters (see `ParticleRuntimeStats`). Returns the SAME live object every
1155
+ * call — read-only by convention; snapshot (spread) it to diff. All-zero on the no-op handle.
1156
+ * `.profile` carries the opt-in per-frame cost attribution (`effectsProfiling`) and is NULL
1157
+ * whenever it was not measured, the no-op handle included. */
1158
+ stats(): ParticleRuntimeStats;
1159
+ /**
1160
+ * TEST / DIAGNOSTIC HOOK: this node's binding re-rendered into an offscreen texture and read back
1161
+ * as tightly-packed RGBA (PREMULTIPLIED, top-down, at the canvas's BACKING-STORE size), or null
1162
+ * when there is nothing to read — no binding at (or under) `node`, no surface yet, a zero-sized
1163
+ * canvas, or a runtime rendering on WebGL.
1164
+ *
1165
+ * WEBGL RETURNS NULL ON PURPOSE, and it is not a gap: that canvas holds readable 2D pixels, so a
1166
+ * caller who wants them uses `getImageData` on it. This exists because a WebGPU canvas has no such
1167
+ * path — `drawImage`/`toDataURL` from one are blank under headless Chrome and pathological on
1168
+ * Android (docs/perf-harness.md S7) — so the frame has to be produced a SECOND time, into a
1169
+ * texture that `copyTextureToBuffer` can reach (`../webgpu/readback`). It renders the binding's
1170
+ * CURRENT state, which for a frozen/static binding is exactly the frame on screen.
1171
+ *
1172
+ * This is the WebGL↔WebGPU image-parity harness's capture path, and the seam the readback-based
1173
+ * surface image swap encodes from — `attachSurfaceSwap` gives a WebGPU binding a `captureCanvas`
1174
+ * hook built out of exactly this production (`../surface-image-swap`).
1175
+ */
1176
+ captureNodePixels?(node: HTMLElement): Promise<Uint8Array | null>;
1177
+ /** Tear down every binding and stop the loop. */
1178
+ dispose(): void;
1179
+ }
1180
+ /**
1181
+ * Create a persistent particle runtime over a mounted scene root. The caller keeps this handle
1182
+ * and calls `reconcile()` on each re-render. Bindings
1183
+ * are keyed by their outer node element; a binding whose `data-godot-particle-specs` is
1184
+ * unchanged keeps its running simulation (so ambient emitters and in-flight one-shot bursts are
1185
+ * NOT reset by an unrelated re-render), while a changed spec re-inits only that node — the seam
1186
+ * a host uses to re-trigger a one-shot burst (bump a value in the spec). A no-op handle when
1187
+ * WebGL2/particles are unavailable.
1188
+ */
1189
+ declare function createParticleRuntime(root: HTMLElement, options: GodotHtmlRuntimeOptions): ParticleRuntime;
1190
+ //#endregion
1191
+ //#region src/particles/spec.d.ts
1192
+ /** HTML attribute transport: portable parameters plus DOM placement and resource URLs. */
1193
+ interface ParticleSpecConfig extends ParticleRenderConfig {
1194
+ originX: number;
1195
+ originY: number;
1196
+ boxOffsetX: number;
1197
+ boxOffsetY: number;
1198
+ textureUrl: string | null;
1199
+ maskUrl?: string | null;
1200
+ }
1201
+ declare function normalizeParticleSpecConfig(raw: Partial<ParticleSpecConfig> | null | undefined): ParticleSpecConfig;
1202
+ /** Decode the DOM attribute without putting its transport contract in the simulator. */
1203
+ declare function parseParticleSpecConfig(json: string | null | undefined): ParticleSpecConfig | null;
1204
+ //#endregion
1205
+ //#region src/particles/render-backend.d.ts
1206
+ /** The structural subset of a texture entry the runtime and the draw carry around: enough to size a
1207
+ * sprite (`width`/`height`), to know whether the real pixels have arrived (`loaded`, the frozen
1208
+ * frame's cache gate) and to be told when they do (`listeners`, see `onTextureLoaded`). Deliberately
1209
+ * NOT `TextureEntry`: the GPU resource itself is the backend's business, and a non-WebGL entry
1210
+ * carries no `WebGLTexture`. */
1211
+ interface ParticleTextureHandle {
1212
+ width: number;
1213
+ height: number;
1214
+ loaded: boolean;
1215
+ listeners: Set<() => void>;
1216
+ }
1217
+ //#endregion
1218
+ //#region src/particles/extents.d.ts
1219
+ declare function spriteExtentPad(cfg: ParticleSpecConfig, texture: ParticleTextureHandle | null): number;
1220
+ declare function emissionExtentPad(cfg: ParticleSpecConfig): number;
1221
+ //#endregion
1222
+ export { UnsupportedRenderReporter as $, DEFAULT_QUIET_WINDOW_MS as A, rewritePxLengths as At, StaticSurfaceEncodePacing as B, DEFAULT_ENCODE_MAX_DIM as C, GodotContentScaleSize as Ct, DEFAULT_ENCODE_SLOW_MS as D, contentScaleStageStyle as Dt, DEFAULT_ENCODE_SLOW_BACKOFF_MS as E, contentScaleScript as Et, STATIC_SURFACE_IMAGE_ATTR as F, StaticSurfaceTimerHandle as G, StaticSurfaceOption as H, StaticImageRevertCause as I, EffectsLoopPacing as J, createStaticImageSwapCounters as K, StaticImageSwapCounters as L, DEFAULT_SURFACE_WATCHDOG_MS as M, STABLE_OBSERVATIONS_BEFORE_SWAP as N, DEFAULT_ENCODE_TASK_GAP_MS as O, observeContentScale as Ot, STATIC_CAPTURE_BLANK as P, UnsupportedRenderKind as Q, StaticSurfaceCapture as R, DEFAULT_ENCODE_INTERVAL_MS as S, GodotContentScaleAspect as St, DEFAULT_ENCODE_SLICE as T, ResolvedContentScale as Tt, StaticSurfacePolicy as U, StaticSurfaceGate as V, StaticSurfaceQuietWindowGate as W, createEffectsLoopPacer as X, PARK_SLOP_S as Y, UnsupportedRenderInfo as Z, effectivePixelRatio as _, TEXT_SCALE_VAR as _t, parseParticleSpecConfig as a, GodotHtmlModel as at, GodotHtmlRuntimeOptions as b, textScaleLength as bt, ParticleRuntimeStats as c, GodotHtmlRenderOptions as ct, GpuInfo as d, GodotShaderLoadingFallback as dt, reportUnsupportedRender as et, MAX_PINNED_BACKING_DIM as f, GodotTextAutoFitDirective as ft, describeGpu as g, ResolvedTextScale as gt, backingStoreSize as h, GodotTextScaleOption as ht, normalizeParticleSpecConfig as i, GodotHtmlFontFace as it, DEFAULT_STILL_CACHE_BYTES as j, scaleStyleRecord as jt, DEFAULT_PARKED_STILL_BYTES as k, resolveContentScale as kt, createParticleRuntime as l, GodotHtmlTintFilter as lt, SURFACE_PIXEL_RATIO_ATTR as m, GodotTextScale as mt, spriteExtentPad as n, GodotEffectRenderInfo as nt, ParticleProfile as o, GodotHtmlNode as ot, MAX_SURFACE_PIXEL_RATIO as p, GodotTextAutoFitNominalMetrics as pt, EffectsLoopPacer as q, ParticleSpecConfig as r, GodotHtmlContainerLayout as rt, ParticleRuntime as s, GodotHtmlPositioning as st, emissionExtentPad as t, GodotBbcodeTagDescriptor as tt, WebgpuFallbackReason as u, GodotResolvedResource as ut, parseSurfacePixelRatio as v, resolveTextScale as vt, DEFAULT_ENCODE_PER_TASK as w, GodotContentScaleTechnique as wt, DEFAULT_ENCODE_BUSY_MAX_DEFER_MS as x, GodotContentScale as xt, GodotHtmlMountOptions as y, setGodotTextScale as yt, StaticSurfaceContentKeyGate as z };
1223
+ //# sourceMappingURL=extents-R8RIim2S.d.ts.map