narraleaf-react 0.44.0 → 0.45.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.
@@ -5,4 +5,5 @@ import { useRouter } from "../../player/lib/PageRouter/router";
5
5
  import { usePathname, useParams, useQueryParams } from "../../player/lib/PageRouter/routerHooks";
6
6
  export * from "../../player/type";
7
7
  export * from "../../player/libElements";
8
+ export type { ImageCacheManager, ImageCacheStats } from "../../player/lib/ImageCacheManager";
8
9
  export { GameProviders, Player, useGame, useRouter, usePathname, useParams, useQueryParams, };
@@ -45,9 +45,18 @@ export interface ISoundUserConfig {
45
45
  */
46
46
  rate: number;
47
47
  /**
48
- * Set to `true` to force HTML5 Audio.
49
- * This should be used for large audio files
50
- * so that you don't have to wait for the full file to be downloaded and decoded before playing.
48
+ * Set to `true` to force this clip to be streamed through an HTML5 `<audio>` element instead of
49
+ * decoded into memory. Use it for large audio files: playback starts as soon as the first bytes
50
+ * arrive rather than after the whole file has been downloaded and decoded, and no decoded PCM
51
+ * buffer is held for as long as it plays.
52
+ *
53
+ * Leaving it `false` does not forbid streaming — it leaves the choice to the engine, which
54
+ * streams whole-file loops (background music, by construction) and decodes everything else. See
55
+ * {@link import("../gameTypes").GameConfig.audioStreaming} for that rule and how to turn it
56
+ * off.
57
+ *
58
+ * A streamed clip has no loop *region*: with `loop` it repeats the whole file, so
59
+ * {@link ISoundUserConfig.endTime} and {@link ISoundUserConfig.loopStart} are ignored for one.
51
60
  * @default false
52
61
  */
53
62
  streaming: boolean;
@@ -217,6 +217,49 @@ export type GameConfig = {
217
217
  * @default 10
218
218
  */
219
219
  maxPreloadActions: number;
220
+ /**
221
+ * How many bytes of fetched image data the preload cache may hold at once.
222
+ *
223
+ * Every image the cache fetches stays in memory as a blob until the cache lets it go, and a
224
+ * scene's registered set plus a hop of look-ahead is most of a chapter's artwork. Past this
225
+ * budget the least recently used images that nothing on stage is showing are released; a
226
+ * released image is fetched again the next time a scene wants it.
227
+ *
228
+ * The current scene's opening background and every image a mounted `<img>` is showing are
229
+ * never released whatever the budget says, so a budget too small for the frame on screen
230
+ * degrades to fetching on demand, never to a broken frame. `Infinity` removes the limit.
231
+ *
232
+ * The default is meant to hold a whole scene's registered set on a large project without
233
+ * releasing anything it is about to want again - a chapter's worth of artwork measured at
234
+ * around 200 MB of files - while capping a session that visits fifty scenes at that rather
235
+ * than at the size of the library. Raise it for a game whose scenes are heavier than that.
236
+ *
237
+ * @default 256 * 1024 * 1024
238
+ */
239
+ imageCacheBudgetBytes: number;
240
+ /**
241
+ * How many bytes of decoded bitmaps the preload cache may keep decoded, at width × height × 4
242
+ * bytes each.
243
+ *
244
+ * A decoded bitmap is what lets an image paint the frame it is revealed on without an
245
+ * asynchronous decode first, and it is the expensive half of an image: a 1080p background
246
+ * decodes to about 8 MB whatever its file size, a 2000-pixel sprite to about 10 MB. The cache
247
+ * keeps the current scene's registered images decoded up to this budget, least recently used
248
+ * first to go past it; an image it let go of is decoded again on demand, which costs tens of
249
+ * milliseconds for a 1080p JPEG and is paid before the transition that reveals it starts.
250
+ *
251
+ * The current scene's opening background and every image on stage are exempt. `Infinity`
252
+ * removes the limit; `0` keeps nothing decoded beyond those.
253
+ *
254
+ * The default is fifteen 1080p backgrounds or a dozen large sprites - several times what a
255
+ * scene has on screen at once, which is a background and two or three characters - so the
256
+ * frame being revealed and the ones about to follow it stay warm while a chapter's worth of
257
+ * alternative poses does not. This is the pool that grew without limit before there was a
258
+ * budget, because a bitmap's size has nothing to do with how well its file compressed.
259
+ *
260
+ * @default 128 * 1024 * 1024
261
+ */
262
+ decodedImageBudgetBytes: number;
220
263
  /**
221
264
  * The audio bus tree, declared by the host at boot.
222
265
  *
@@ -257,6 +300,30 @@ export type GameConfig = {
257
300
  * ```
258
301
  */
259
302
  audioBuses: AudioBusDeclaration[];
303
+ /**
304
+ * Which clips are streamed through an `<audio>` element instead of being decoded into memory.
305
+ *
306
+ * Decoded audio is float32 PCM, so its size has nothing to do with the size of the file it came
307
+ * from: a five-minute 44.1 kHz stereo track occupies about 106 MB decoded however small the mp3
308
+ * was. Background music is the worst case of that — long, and resident for as long as the scene
309
+ * lasts — while a short effect wants to be decoded so that it can start on the frame it is
310
+ * asked to and overlap with itself.
311
+ *
312
+ * - `"loops"` — a clip is streamed when {@link import("./elements/sound").ISoundUserConfig.streaming} says so, **or** when
313
+ * it loops the whole file (`loop` with no `endTime`). A whole-file loop is background music
314
+ * by construction, and repeating a whole file is the one thing an `<audio>` element does
315
+ * exactly. A loop that marks an out point is asking for a sample-accurate loop *region*,
316
+ * which only a decoded buffer has, so it keeps decoding.
317
+ * - `"declared"` — only clips whose `streaming` is set are streamed; everything else is decoded.
318
+ * Choose this when a game would rather spend the memory than let its music loop through an
319
+ * element: `<audio>` repeats the file rather than the samples, and on some browsers and
320
+ * formats the loop point is audible.
321
+ *
322
+ * A `data:` or `blob:` source is already in memory and is always decoded, whichever this says.
323
+ *
324
+ * @default "loops"
325
+ */
326
+ audioStreaming: "loops" | "declared";
260
327
  /**
261
328
  * Src of the cursor image, if null, the game will show the default cursor
262
329
  * @default null
@@ -25,6 +25,7 @@ import { Video, VideoStateRaw } from "../nlcore/elements/video";
25
25
  import { Vfx, VfxStateRaw } from "../nlcore/elements/vfx";
26
26
  import { Timelines } from "./Tasks";
27
27
  import { Notification, NotificationManager } from "./lib/notification";
28
+ import type { ImageCacheManager } from "./lib/ImageCacheManager";
28
29
  import { ActionHistoryManager } from "../../game/nlcore/action/actionHistory";
29
30
  import { GameHistoryManager } from "../../game/nlcore/action/gameHistory";
30
31
  import type { Character } from "../nlcore/elements/character";
@@ -233,6 +234,15 @@ export declare class GameState {
233
234
  removeElement(element: PlayerStateElement): this;
234
235
  preloadScene(arg: Scene | Story): this;
235
236
  getPreloadingScene(): Scene | null;
237
+ /**
238
+ * The image cache the mounted player is using, or `null` while no player is mounted.
239
+ *
240
+ * What it holds against {@link GameConfig.imageCacheBudgetBytes} and
241
+ * {@link GameConfig.decodedImageBudgetBytes} is readable at any time, which is what a profiler
242
+ * or a host checking a long session's memory wants:
243
+ * `game.getLiveGame().getGameState()?.getImageCache()?.getStats()`.
244
+ */
245
+ getImageCache(): ImageCacheManager | null;
236
246
  addElement(element: PlayerStateElement): this;
237
247
  addScene(scene: Scene): this;
238
248
  flush(): this;
@@ -1,5 +1,5 @@
1
1
  import { Sound as SoundElement, SoundBusId } from "../../nlcore/elements/sound";
2
- import { SoundToken } from "@narraleaf/sound";
2
+ import { CacheStats, SoundToken } from "@narraleaf/sound";
3
3
  import { FadeOptions, SoundPlayOptions } from "../../nlcore/elements/type";
4
4
  import { Awaitable } from "../../../util/data";
5
5
  import { GameState } from "../gameState";
@@ -57,11 +57,28 @@ export declare class AudioManager {
57
57
  * not have.
58
58
  */
59
59
  private static readonly BusRampSettle;
60
+ /**
61
+ * How long the backend keeps a decoded clip that nothing holds any more, in milliseconds.
62
+ *
63
+ * Zero would be the tidiest answer, but a clip nothing references is exactly the shape of a
64
+ * short effect fired repeatedly - an interface click, a footstep - and re-fetching and
65
+ * re-decoding one per keystroke is worse than holding it. A few seconds is long enough to
66
+ * cover a burst and short enough that nothing accumulates: what is still resident after this
67
+ * is only what is playing or what {@link AudioManager.preload} is holding for the scene.
68
+ */
69
+ private static readonly CacheGrace;
60
70
  private state;
61
71
  private channels;
62
72
  private busTree;
63
73
  private busSubscription;
64
74
  private unknownBuses;
75
+ /**
76
+ * Sources this manager holds a cache reference on - the current scene's, plus anything a host
77
+ * preloaded by hand. One reference per distinct source, given back by
78
+ * {@link AudioManager.retainOnly} when the scene that wanted it is left. The value resolves to
79
+ * whether the reference was really taken, which a release has to wait for.
80
+ */
81
+ private retained;
65
82
  private globalVolume;
66
83
  private sound;
67
84
  private ready;
@@ -192,43 +209,40 @@ export declare class AudioManager {
192
209
  */
193
210
  private static clipRegionOf;
194
211
  /**
195
- * The region as the sound backend's play options.
212
+ * Everything the backend needs to start a clip, apart from the volume the caller is fading to.
196
213
  *
197
- * A looping clip deliberately hands over **no** `endTime`. The backend turns `endTime` into a
198
- * timer that stops the token after one pass, whether or not the clip loops, so passing it here
199
- * is what kept the loop region from ever repeating. The region reaches a looping clip through
200
- * {@link AudioManager.applyLoopRegion} instead; for a one-shot `endTime` *is* the out point and
201
- * the backend's timer is exactly the right mechanism.
214
+ * The region goes over as it stands: an out point is an out point for a one-shot and the point
215
+ * a looping clip repeats *from* when `loop` is set, and `loopStart` moves that repeat's in
216
+ * point without moving where the first pass begins. A streamed clip has no region to speak of
217
+ * (see {@link AudioManager.loadModeOf}) and the backend ignores both keys for one.
202
218
  */
203
- private static playRegionOf;
219
+ private playOptionsOf;
204
220
  /**
205
- * Write a looping clip's region onto the Web Audio node the backend is playing it through.
206
- *
207
- * **This is a shim against `@narraleaf/sound@0.1.0`'s internals, and the only place in this
208
- * repo that reaches into them.** Two things in that version make the region unusable from the
209
- * outside:
210
- *
211
- * - `SoundToken`'s constructor arms `setTimeout(stop, duration * 1000)` whenever a duration was
212
- * given, without consulting `loop` — so a looping clip with an out point hard-stops after its
213
- * first pass through the region.
214
- * - `Sound.createToken` pins `loopStart` to the playback start offset, so "play the intro from
215
- * 0s, then repeat 12s→90s forever" cannot be expressed at all.
216
- *
217
- * The fix belongs upstream and is small: honour `loop` before arming the duration timer, and
218
- * accept an independent `loopStart` in `PlayOptions`. Until that ships, this manager withholds
219
- * `endTime` from a looping clip's play options (which is what disarms the timer) and sets the
220
- * loop region here.
221
- *
222
- * `SoundToken.sourceController` is TypeScript-`private` while `AudioSourceController.getSource`
223
- * is public, so the node is reachable at runtime but not through the types — hence the cast and
224
- * the shape check. If a later backend changes that shape this returns silently and the clip
225
- * degrades to the behaviour it has today: the region plays once and the clip stops.
226
- *
227
- * Seeking survives this. `SoundToken.seek` rebuilds the buffer source and copies `loop`,
228
- * `loopStart` and `loopEnd` off the old node onto the new one, so a jump inside a looping track
229
- * keeps the region.
221
+ * Whether a clip is decoded into memory or streamed through an `<audio>` element.
222
+ *
223
+ * Decoding is what a short clip wants: it can start on the frame it is asked to, several copies
224
+ * can overlap, and its loop region is sample-accurate. What it costs is memory, and the cost has
225
+ * nothing to do with the size of the file - decoded audio is float32 PCM, so a five-minute
226
+ * 44.1 kHz stereo track is about 106 MB however small the mp3 was. Background music is the worst
227
+ * case of exactly that: long, and playing for as long as the scene lasts.
228
+ *
229
+ * So a clip is streamed when it is background music by construction:
230
+ *
231
+ * - the author set `streaming` on it, which has always meant "force this one to stream";
232
+ * - or it loops the **whole file**. A whole-file loop is a track meant to play under a scene
233
+ * rather than to punctuate it, and repeating a whole file is the one thing an element does
234
+ * exactly. A loop with an out point is asking for a region instead, which only a decoded
235
+ * buffer has, so it keeps decoding.
236
+ *
237
+ * `data:` and `blob:` sources are already in memory and are always decoded; there is nothing to
238
+ * stream and an element would only add latency.
239
+ *
240
+ * {@link import("../../nlcore/gameTypes").GameConfig.audioStreaming} set to `"declared"` drops the
241
+ * second rule, for a game that would rather spend the memory than let its music loop through an
242
+ * element - `<audio>` repeats the file rather than the samples, so a loop point can be heard on
243
+ * some browsers and formats.
230
244
  */
231
- private static applyLoopRegion;
245
+ private loadModeOf;
232
246
  private static clampToRegion;
233
247
  setRate(sound: SoundElement, rate: number): Awaitable<void>;
234
248
  getPosition(sound: SoundElement): number;
@@ -242,6 +256,16 @@ export declare class AudioManager {
242
256
  * Fetch and decode a sound into the audio cache without playing it, so the first `play()` of
243
257
  * this source starts on the same frame it is asked to instead of after a fetch and a decode.
244
258
  *
259
+ * **Holds the clip decoded until it is released.** A decoded clip is dropped as soon as nothing
260
+ * plays it, which is what keeps a session's audio from growing without bound; a preload is the
261
+ * one thing that says "keep this one anyway". The reference is given back by
262
+ * {@link AudioManager.retainOnly} - what the player preloads is the scene's set, and a scene
263
+ * change replaces it - or by {@link AudioManager.reset}.
264
+ *
265
+ * A clip that streams rather than decodes ({@link AudioManager.loadModeOf}) has nothing to warm:
266
+ * an element fetches as it plays, so there is no decoded buffer to have ready and this resolves
267
+ * having done nothing.
268
+ *
245
269
  * Deliberately **not** something to gate a loading screen on: the audio context only becomes
246
270
  * ready once the browser's autoplay policy is satisfied by a user gesture, so this can sit
247
271
  * pending indefinitely on a page nobody has interacted with yet. Start it and let it land —
@@ -250,6 +274,33 @@ export declare class AudioManager {
250
274
  * as it did before.
251
275
  */
252
276
  preload(sound: SoundElement): Promise<void>;
277
+ /**
278
+ * Preload `sounds` and give back every cache reference this manager holds that is not among
279
+ * them - the audio counterpart of the image cache being filtered down to the scene that is
280
+ * about to paint.
281
+ *
282
+ * Called with a scene's own sounds when that scene opens. Clips shared with the previous scene
283
+ * keep their reference rather than being released and re-decoded, so the common case of a
284
+ * carried-over effect costs nothing.
285
+ *
286
+ * Releasing a reference does not stop anything: a clip that is still playing is held by its
287
+ * token until it ends, and only then does the buffer go.
288
+ */
289
+ retainOnly(sounds: SoundElement[]): void;
290
+ /**
291
+ * What the audio cache is holding: decoded clips, clips in flight, and the bytes of PCM the
292
+ * decoded ones occupy. Zeroes before the audio context has unlocked.
293
+ *
294
+ * Here so that a game can see its own audio residency - the number that used to grow for the
295
+ * length of a session and now tracks what is playing plus what the current scene declared.
296
+ */
297
+ getCacheStats(): CacheStats;
298
+ /**
299
+ * Give back the cache reference held on one source, if there is one, once it has actually been
300
+ * taken - a release that overtook its own load would find nothing to release and leave the
301
+ * reference behind for good.
302
+ */
303
+ private releaseSource;
253
304
  /**
254
305
  * Start a new game: stop everything and put the mixer back on the wire.
255
306
  *
@@ -1,13 +1,62 @@
1
- import { Game } from "../../../game/nlcore/game";
2
- import { GameState } from "../gameState";
1
+ import type { Game } from "../../../game/nlcore/game";
2
+ import type { GameState } from "../gameState";
3
3
  export type PreloadedToken = {
4
4
  abort: () => void;
5
5
  onFinished: (callback: () => void) => PreloadedToken;
6
6
  onErrored: (callback: (reason: any) => void) => PreloadedToken;
7
7
  };
8
+ /**
9
+ * What the image cache is holding, against what it is allowed to hold. All sizes are bytes.
10
+ *
11
+ * Read through {@link GameState.getImageCache}:
12
+ * `game.getLiveGame().getGameState()?.getImageCache()?.getStats()`.
13
+ */
14
+ export type ImageCacheStats = {
15
+ /** Sources the cache holds a url for. */
16
+ entries: number;
17
+ /** Bytes of fetched image data kept alive by the object urls the cache minted. */
18
+ blobBytes: number;
19
+ /** Sources whose decoded bitmap the cache is holding on to. */
20
+ decodedEntries: number;
21
+ /** Estimated size of those bitmaps, at width × height × 4 bytes each. */
22
+ decodedBytes: number;
23
+ /** Entries that cannot be evicted right now: shown by a mounted `<img>`, or pinned by the scene. */
24
+ pinned: number;
25
+ /** The budgets in force: {@link GameConfig.imageCacheBudgetBytes} and {@link GameConfig.decodedImageBudgetBytes}. */
26
+ budget: {
27
+ blobBytes: number;
28
+ decodedBytes: number;
29
+ };
30
+ };
31
+ /**
32
+ * The player's image cache: fetched bytes as object urls, and decoded bitmaps for the images about
33
+ * to be revealed, both under a memory budget.
34
+ *
35
+ * The cache used to be unbounded, and what it held was decided by the preload pass after the one
36
+ * that filled it: a scene's registered images - every pose of every character it shows, every
37
+ * background it cuts to - stayed fetched and decoded until the *next* scene's look-ahead pool had
38
+ * finished, and stayed for ever if that pass was superseded before it got there. A long session on
39
+ * a large library grew without limit and ended in a renderer out of memory. Now:
40
+ *
41
+ * - Both pools have a budget, read live from the game config. Past it, the least recently used
42
+ * entries that nothing pins go first: a bitmap is dropped and decoded again on demand, a fetched
43
+ * image is released and fetched again when a scene wants it. Recency is the {@link entries} map's
44
+ * order, refreshed by every {@link get}.
45
+ * - Two things pin an entry against both budgets. A scene pins its opening background through
46
+ * {@link pin}; and every mounted `<img>` holds the url it is showing through {@link hold}, so the
47
+ * frame on screen - both halves of a transition in flight, a caller parked behind a scene call -
48
+ * is never taken out from under the DOM.
49
+ * - What a scene keeps is decided when its pass starts, through {@link retain}: everything outside
50
+ * the plan is stale, dropped at once if nothing shows it and the moment its last `<img>` unmounts
51
+ * otherwise. That is what releases a left scene at scene exit rather than a pass later.
52
+ */
8
53
  export declare class ImageCacheManager {
9
54
  private readonly game;
10
- static getImage(src: string, abortSignal?: AbortSignal, options?: RequestInit): Promise<string>;
55
+ /** Fetch `src` and hand back a url for its bytes, and how many there are. Mocked by tests. */
56
+ static getImage(src: string, abortSignal?: AbortSignal, options?: RequestInit): Promise<{
57
+ url: string;
58
+ bytes: number;
59
+ }>;
11
60
  /**
12
61
  * Decode an image source off-screen so the browser's decoded-image cache is warm before
13
62
  * the source is ever attached to a visible `<img>`. Without this, "preloaded" only means
@@ -20,29 +69,49 @@ export declare class ImageCacheManager {
20
69
  * `Image` or no `decode()`.
21
70
  */
22
71
  private static decodeImage;
23
- private src;
24
- private preloadTasks;
25
72
  /**
26
- * Decoded images held on purpose. A decoded bitmap only stays in the browser's cache while
27
- * something still references it, so dropping the element right after `decode()` lets the
28
- * bitmap be evicted and the reveal decodes all over again. Retention is opt-in per preload
29
- * (`retainDecoded`) because a full-resolution bitmap costs width × height × 4 bytes — worth
30
- * it for the scene that is about to paint, far too expensive for a whole reachable graph.
73
+ * What a retained bitmap costs. The browser keeps a decoded image as 32-bit pixels at its
74
+ * natural size whatever the file's format or size was, so this is the number that matters.
31
75
  */
32
- private decoded;
76
+ private static estimateDecodedBytes;
77
+ /** A budget as configured; anything that is not a number reads as no limit. */
78
+ private static readBudget;
79
+ /**
80
+ * Every cached source, least recently used first. A touch deletes and re-inserts its entry,
81
+ * so eviction walks the map from the front.
82
+ */
83
+ private entries;
84
+ /** The same entries by the url an `<img>` shows, which is the only name an `<img>` knows. */
85
+ private byUrl;
86
+ private preloadTasks;
87
+ /** Urls some mounted `<img>` is showing, with how many are showing each. */
88
+ private holds;
89
+ /** Sources the scene pinned - its opening background - replaced as a whole by {@link pin}. */
90
+ private pinned;
91
+ /** Every source that has been through the cache, kept for {@link wasCached}. */
92
+ private seen;
93
+ private blobBytes;
94
+ private decodedBytes;
33
95
  constructor(game: Game);
96
+ has(name: string): boolean;
34
97
  /**
35
- * Release the object URL an entry holds.
98
+ * Whether this source has ever been through the cache, whether or not it still is.
36
99
  *
37
- * Every path that drops an entry goes through this. An object URL pins its blob for the
38
- * lifetime of the document, so a cache that forgets an entry without revoking leaks the whole
39
- * image - which on a scene change is most of a scene's artwork.
100
+ * For diagnostics that want to tell "the preloader never heard of this image" - which means an
101
+ * image action nothing could predict, and is worth telling the author about - from "the cache
102
+ * had it and a budget released it again", which is the cache working as designed and is not.
103
+ */
104
+ wasCached(name: string): boolean;
105
+ /**
106
+ * Cache `src` under `name` without fetching it. The bytes behind a url given this way are not
107
+ * the cache's to count, so it weighs nothing against the fetched budget.
40
108
  */
41
- private release;
42
- private releaseAll;
43
- has(name: string): boolean;
44
109
  add(name: string, src: string): this;
45
110
  remove(name: string): this;
111
+ /**
112
+ * The url to show for `name`, or `undefined` when it is not cached. This is the cache's notion
113
+ * of a use: the entry becomes the most recently used one.
114
+ */
46
115
  get(name: string): string | undefined;
47
116
  /**
48
117
  * Whether this source has been decoded and its decoded bitmap is still held, i.e. attaching
@@ -56,9 +125,16 @@ export declare class ImageCacheManager {
56
125
  * Fetch `url`, cache it as an object URL and decode it, resolving the returned token's
57
126
  * `onFinished` only once the decode has settled.
58
127
  *
59
- * @param options.retainDecoded keep the decoded bitmap alive until this source leaves the
60
- * cache. Use it for the assets that are about to be revealed; leave it off for speculative
61
- * look-ahead preloading, whose bitmaps would otherwise pile up in memory.
128
+ * Asking again is safe and converges. A url that is cached and, if asked, decoded settles at
129
+ * once. One that is cached but whose bitmap the budget let go of - or that was fetched as
130
+ * look-ahead, which never retains - is decoded again and held when `retainDecoded` asks for it,
131
+ * so a first frame the budget evicted still gates on a decode rather than on nothing. One that
132
+ * another pass is still fetching follows that fetch instead of getting a token that never fires.
133
+ *
134
+ * @param options.retainDecoded keep the decoded bitmap alive, within
135
+ * {@link GameConfig.decodedImageBudgetBytes}, until this source leaves the cache. Use it for the
136
+ * assets that are about to be revealed; leave it off for speculative look-ahead preloading,
137
+ * whose bitmaps would otherwise pile up in memory.
62
138
  */
63
139
  preload(gameState: GameState, url: string, options?: {
64
140
  retainDecoded?: boolean;
@@ -66,5 +142,66 @@ export declare class ImageCacheManager {
66
142
  abortAll(): void;
67
143
  abort(src: string): void;
68
144
  preloadedSrc(): string[];
69
- filter(names: string[]): this;
145
+ /**
146
+ * Keep `names` and let everything else go.
147
+ *
148
+ * Called when a scene's preload pass starts, with everything the pass is about to want. An entry
149
+ * outside the set is dropped at once unless something pins it; a pinned one is marked stale and
150
+ * dropped the moment it is not - which for the scene that was just left is the moment its last
151
+ * `<img>` unmounts. An entry that is back in the set is not stale any more.
152
+ */
153
+ retain(names: Iterable<string>): this;
154
+ /**
155
+ * Exempt `names` from both budgets, replacing whatever was pinned before.
156
+ *
157
+ * For the current scene's opening background: fetched and decoded before the scene is allowed
158
+ * to paint, and the one image a budget must never take back. Whatever a mounted `<img>` shows is
159
+ * pinned separately, through {@link hold}, so this only needs to name what is about to be shown.
160
+ */
161
+ pin(names: Iterable<string>): this;
162
+ /**
163
+ * Report that a mounted `<img>` is showing `url`; the entry behind it cannot be evicted until
164
+ * every such `<img>` has called {@link release}. By url rather than by source name, because the
165
+ * url is all an `<img>` knows - and a url the cache never minted is remembered and ignored.
166
+ */
167
+ hold(url: string): void;
168
+ /** Undo one {@link hold}. The last release drops a stale entry, and lets the budgets run again. */
169
+ release(url: string): void;
170
+ getStats(): ImageCacheStats;
171
+ private budget;
172
+ private isPinned;
173
+ private touch;
174
+ private insert;
175
+ /**
176
+ * Drop an entry entirely, handing back its object url.
177
+ *
178
+ * Every path that drops an entry goes through this. An object URL pins its blob for the
179
+ * lifetime of the document, so a cache that forgets an entry without revoking leaks the whole
180
+ * image - which on a scene change is most of a scene's artwork.
181
+ */
182
+ private drop;
183
+ /** Let the bitmap go and keep the bytes; the browser decodes them again when they are next shown. */
184
+ private dropDecoded;
185
+ private retainDecoded;
186
+ /**
187
+ * Bring both pools back under budget, least recently used first, skipping what is pinned.
188
+ *
189
+ * Fetched bytes first: dropping an entry takes its bitmap with it, and may settle the decoded
190
+ * pool on its own. An entry that is over a budget all by itself goes too, unless it is pinned -
191
+ * the budget is a limit, not a preference, and what is on stage is protected by the pins.
192
+ */
193
+ private enforce;
194
+ private fetchAndDecode;
195
+ /** A cached entry's bitmap, wanted again after the budget let it go or a look-ahead skipped it. */
196
+ private decodeAgain;
197
+ private runTask;
198
+ /**
199
+ * A token for a url another caller is already fetching. It finishes when that fetch has landed
200
+ * *and* this caller's own request is met: the running task may be a look-ahead that retains no
201
+ * bitmap, so asking again once it is done settles the difference - unless it failed, in which
202
+ * case asking again would only fetch a broken url once per follower.
203
+ */
204
+ private followTask;
205
+ /** A token for a request that is already met; it finishes on the next microtask, like the others. */
206
+ private static settledToken;
70
207
  }
@@ -0,0 +1,19 @@
1
+ import { RefObject } from "react";
2
+ /**
3
+ * Tell the image cache which url this `<img>` is showing, for as long as it shows it.
4
+ *
5
+ * The cache evicts by budget and drops a left scene's images as soon as nothing needs them, and
6
+ * "nothing needs them" has exactly one witness: whether some mounted `<img>` still has the url as
7
+ * its `src`. So the leaf reports it. The attribute is watched rather than a prop, because a
8
+ * non-layered image's `src` is written imperatively, frame by frame, by its transition and never
9
+ * passes through React - and because both halves of a transition in flight are `<img>`s, which is
10
+ * what keeps the outgoing picture from being taken back mid-fade.
11
+ *
12
+ * Layout effects rather than passive ones: the first hold has to land in the same task as the
13
+ * commit that wrote the `src`, before any fetch that finishes in between gets to run the budget.
14
+ * The attaching one runs after every commit rather than only the first, because the element may
15
+ * not exist yet on the pass this hook is first called on - a dialogue avatar renders nothing until
16
+ * there is a speaker with one - and because React may replace the node underneath the ref. It does
17
+ * nothing at all once it is already watching the right element.
18
+ */
19
+ export declare function useHoldImageSrc(ref: RefObject<HTMLImageElement | null>): void;