rayzee 9.0.0 → 9.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.
Files changed (94) hide show
  1. package/README.md +139 -8
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-CQLCLpcC.js → NeuralSuperRes-CbNvQjCw.js} +3 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10344 -5334
  6. package/dist/rayzee.umd.js +265 -265
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +38 -56
  10. package/src/EngineEvents.js +3 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/PathTracerApp.js +290 -19
  16. package/src/Processor/ArchiveCache.js +120 -0
  17. package/src/Processor/ArchiveReader.js +179 -32
  18. package/src/Processor/AssetLoader.js +514 -76
  19. package/src/Processor/AtmosphereModel.js +465 -0
  20. package/src/Processor/BVHBuilder.js +4 -1
  21. package/src/Processor/BVHLeafFold.js +100 -0
  22. package/src/Processor/BVHRefitter.js +98 -214
  23. package/src/Processor/ChunkedRecords.js +189 -4
  24. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  25. package/src/Processor/GeometryExtractor.js +238 -40
  26. package/src/Processor/InstanceTable.js +55 -37
  27. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  28. package/src/Processor/PBRT/PBRTParser.js +132 -22
  29. package/src/Processor/PBRT/PBRTSceneBuilder.js +192 -49
  30. package/src/Processor/PBRT/buffers.js +27 -0
  31. package/src/Processor/PBRT/index.js +55 -15
  32. package/src/Processor/PackedRayBuffer.js +10 -13
  33. package/src/Processor/ParallelBVHBuilder.js +36 -15
  34. package/src/Processor/PhysicalSky.js +308 -0
  35. package/src/Processor/QueueManager.js +1 -0
  36. package/src/Processor/SceneProcessor.js +1132 -181
  37. package/src/Processor/SplitBLAS.js +249 -0
  38. package/src/Processor/SunPosition.js +41 -0
  39. package/src/Processor/TextureCreator.js +21 -0
  40. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  41. package/src/Processor/Workers/BVHWorker.js +17 -13
  42. package/src/Processor/ZipReader.js +207 -0
  43. package/src/RenderSettings.js +38 -0
  44. package/src/SceneState/SceneState.js +367 -0
  45. package/src/SceneState/portable.js +52 -0
  46. package/src/Stages/NormalDepth.js +7 -6
  47. package/src/Stages/PathTracer.js +108 -4
  48. package/src/Stages/PathTracerStage.js +251 -104
  49. package/src/Storage/BLASCache.js +190 -0
  50. package/src/Storage/CDFCache.js +77 -0
  51. package/src/Storage/DownloadCache.js +266 -0
  52. package/src/Storage/GeometrySpill.js +217 -0
  53. package/src/Storage/SceneGraphCodec.js +503 -0
  54. package/src/Storage/SpillStore.js +112 -0
  55. package/src/Storage/StorageManager.js +934 -0
  56. package/src/Storage/StorageOps.js +137 -0
  57. package/src/Storage/StorageWorker.js +37 -0
  58. package/src/Storage/events.js +29 -0
  59. package/src/Storage/identity.js +77 -0
  60. package/src/Storage/index.js +9 -0
  61. package/src/Storage/inlineTransport.js +24 -0
  62. package/src/Storage/locks.js +99 -0
  63. package/src/Storage/openStorage.js +87 -0
  64. package/src/Storage/sceneCachePolicy.js +12 -0
  65. package/src/Storage/shared.js +9 -0
  66. package/src/Storage/transport.js +62 -0
  67. package/src/TSL/Atmosphere.js +684 -0
  68. package/src/TSL/BVHTraversal.js +288 -239
  69. package/src/TSL/Common.js +11 -2
  70. package/src/TSL/Displacement.js +6 -6
  71. package/src/TSL/EmissiveSampling.js +5 -5
  72. package/src/TSL/EnvironmentCDF.js +176 -0
  73. package/src/TSL/GenerateKernel.js +3 -3
  74. package/src/TSL/HitFacet.js +4 -4
  75. package/src/TSL/LightsDirect.js +8 -8
  76. package/src/TSL/LightsSampling.js +55 -37
  77. package/src/TSL/Random.js +1 -0
  78. package/src/TSL/ShadeKernel.js +75 -19
  79. package/src/TSL/Sun.js +40 -0
  80. package/src/TSL/TextureSampling.js +6 -7
  81. package/src/index.js +18 -0
  82. package/src/managers/CameraManager.js +169 -1
  83. package/src/managers/EnvironmentManager.js +263 -33
  84. package/src/managers/GoboManager.js +19 -8
  85. package/src/managers/IESManager.js +3 -3
  86. package/src/managers/LightManager.js +98 -5
  87. package/src/managers/MaterialDataManager.js +22 -2
  88. package/src/managers/TransformManager.js +26 -0
  89. package/src/managers/UniformManager.js +5 -3
  90. package/src/managers/VideoRenderManager.js +3 -1
  91. package/src/managers/timeline/CameraTrack.js +22 -0
  92. package/src/managers/timeline/TimelineManager.js +14 -0
  93. package/src/version.js +3 -0
  94. package/src/Processor/ProceduralSky.js +0 -291
@@ -0,0 +1,934 @@
1
+ import { ISSUE_CODES } from '../EngineIssues.js';
2
+ import { acquireLock, heldLockNames } from './locks.js';
3
+ import { entryIdFor } from './identity.js';
4
+ import { Emitter } from './events.js';
5
+ import { InlineTransport } from './inlineTransport.js';
6
+
7
+ export const STORAGE_KIND = Object.freeze( { CACHE: 'cache', USER: 'user', SCRATCH: 'scratch' } );
8
+
9
+ /** Areas the engine itself writes; hosts define their own with `defineArea`. */
10
+ export const ENGINE_AREAS = Object.freeze( { DOWNLOADS: 'downloads', ARCHIVES: 'archives', SCENES: 'scenes', CDF: 'cdf', SPILL: 'spill' } );
11
+
12
+ const META = 'meta.json';
13
+ const META_VERSION = 1;
14
+ const TOUCH_INTERVAL_MS = 3_600_000;
15
+ const BUDGET_FRACTION = 0.3;
16
+ const MAX_DEFAULT_BUDGET = 100 * 1024 ** 3;
17
+ const FREE_MARGIN = 64 * 1024 ** 2;
18
+ const STREAM_BATCH = 8 * 1024 ** 2;
19
+ const STREAM_WINDOW = 4;
20
+
21
+ const encoder = new TextEncoder();
22
+
23
+ function transferable( data, { copy, transfer } ) {
24
+
25
+ if ( typeof data === 'string' ) data = encoder.encode( data );
26
+
27
+ if ( data instanceof ArrayBuffer ) {
28
+
29
+ if ( ! copy || transfer ) return { args: { buffer: data }, list: [ data ] };
30
+ const clone = data.slice( 0 );
31
+ return { args: { buffer: clone }, list: [ clone ] };
32
+
33
+ }
34
+
35
+ if ( ! ArrayBuffer.isView( data ) ) throw new TypeError( 'storage: write expects an ArrayBuffer, a typed array, a Blob or a string' );
36
+
37
+ const { buffer, byteOffset, byteLength } = data;
38
+ const shared = typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer;
39
+
40
+ if ( ! copy ) return { args: { buffer, byteOffset, byteLength }, list: [] };
41
+
42
+ // A shared buffer handed over as is lives until the storage worker next collects garbage,
43
+ // which a worker allocating almost nothing may not do for the rest of a load: 2.5 GB of spilled
44
+ // 64 MB chunks stayed in memory after they were on disk. A copy is transferred and goes with it.
45
+ if ( shared ) {
46
+
47
+ const clone = new Uint8Array( byteLength );
48
+ clone.set( new Uint8Array( buffer, byteOffset, byteLength ) );
49
+ return { args: { buffer: clone.buffer }, list: [ clone.buffer ] };
50
+
51
+ }
52
+
53
+ if ( transfer && byteOffset === 0 && byteLength === buffer.byteLength ) return { args: { buffer }, list: [ buffer ] };
54
+
55
+ const clone = buffer.slice( byteOffset, byteOffset + byteLength );
56
+ return { args: { buffer: clone }, list: [ clone ] };
57
+
58
+ }
59
+
60
+ async function readJSONFile( dir, name ) {
61
+
62
+ try {
63
+
64
+ const file = await ( await dir.getFileHandle( name ) ).getFile();
65
+ return JSON.parse( await file.text() );
66
+
67
+ } catch {
68
+
69
+ return null;
70
+
71
+ }
72
+
73
+ }
74
+
75
+ async function* childDirs( dir ) {
76
+
77
+ for await ( const [ name, handle ] of dir.entries() ) {
78
+
79
+ if ( handle.kind === 'directory' ) yield [ name, handle ];
80
+
81
+ }
82
+
83
+ }
84
+
85
+ export class StorageEntry {
86
+
87
+ constructor( area, id, meta, dir, release ) {
88
+
89
+ this.area = area;
90
+ this.id = id;
91
+ this.key = meta.key;
92
+ this.meta = meta;
93
+ this._dir = dir;
94
+ this._release = release;
95
+
96
+ }
97
+
98
+ get bytes() {
99
+
100
+ return this.meta.bytes;
101
+
102
+ }
103
+
104
+ get extra() {
105
+
106
+ return this.meta.extra ?? {};
107
+
108
+ }
109
+
110
+ /** @returns {Promise<?File>} */
111
+ async file( name ) {
112
+
113
+ try {
114
+
115
+ return await ( await this._dir.getFileHandle( name ) ).getFile();
116
+
117
+ } catch ( error ) {
118
+
119
+ this.area.manager._issue( ISSUE_CODES.STORAGE_READ_FAILED, `could not read ${this.area.name}/${this.id}/${name}`, { error: error.message } );
120
+ return null;
121
+
122
+ }
123
+
124
+ }
125
+
126
+ async json( name ) {
127
+
128
+ const file = await this.file( name );
129
+ return file ? JSON.parse( await file.text() ) : null;
130
+
131
+ }
132
+
133
+ release() {
134
+
135
+ this._release?.();
136
+ this._release = null;
137
+
138
+ }
139
+
140
+ }
141
+
142
+ export class EntryWriter {
143
+
144
+ constructor( area, id, key, info, release, { sizes = {}, append = false } = {} ) {
145
+
146
+ this.area = area;
147
+ this.id = id;
148
+ this.key = key;
149
+ this._info = info;
150
+ this._release = release;
151
+ this._writers = new Map();
152
+ this._sizes = { ...sizes };
153
+ this._append = append;
154
+ this._done = false;
155
+
156
+ }
157
+
158
+ get _transport() {
159
+
160
+ return this.area.manager._transport;
161
+
162
+ }
163
+
164
+ get _path() {
165
+
166
+ return [ this.area.name, this.id ];
167
+
168
+ }
169
+
170
+ async _writer( name ) {
171
+
172
+ let writer = this._writers.get( name );
173
+ if ( ! writer ) {
174
+
175
+ const keep = this._append && name in this._sizes;
176
+ const resumeAt = keep && ( this._info.growable ?? [] ).includes( name ) ? this._sizes[ name ] : undefined;
177
+ writer = this._transport.call( 'openWriter', { path: this._path, name, truncate: ! keep, length: resumeAt } );
178
+ this._writers.set( name, writer );
179
+
180
+ }
181
+
182
+ return ( await writer ).id;
183
+
184
+ }
185
+
186
+ /**
187
+ * Appends to (or, with `at`, writes into) a file of this entry. `transfer: true` hands the
188
+ * caller's buffer to the storage worker instead of copying it.
189
+ */
190
+ async write( name, data, { at, transfer = false } = {} ) {
191
+
192
+ this._assertOpen();
193
+ const isBlob = typeof Blob !== 'undefined' && data instanceof Blob;
194
+ // Copied before any await: callers (muxers, stream readers) reuse their buffers.
195
+ const prepared = isBlob ? null : transferable( data, { copy: true, transfer } );
196
+ const { args, list } = prepared ?? transferable( await data.arrayBuffer(), { copy: true, transfer: true } );
197
+
198
+ const id = await this._writer( name );
199
+ return this._transport.call( 'write', { id, at, ...args }, list );
200
+
201
+ }
202
+
203
+ async writeFile( name, data ) {
204
+
205
+ this._assertOpen();
206
+ if ( typeof Blob !== 'undefined' && data instanceof Blob ) data = await data.arrayBuffer();
207
+ const { args, list } = transferable( data, { copy: true, transfer: true } );
208
+ const { size } = await this._transport.call( 'writeFile', { path: this._path, name, ...args }, list );
209
+ this._sizes[ name ] = size;
210
+ return size;
211
+
212
+ }
213
+
214
+ writeJSON( name, value ) {
215
+
216
+ return this.writeFile( name, JSON.stringify( value ) );
217
+
218
+ }
219
+
220
+ /**
221
+ * Pipes a ReadableStream of bytes into a file, batching small chunks so the worker sees
222
+ * few, large writes.
223
+ * @returns {Promise<number>} bytes written
224
+ */
225
+ async writeStream( name, stream, { onProgress = null, signal = null, batchBytes = STREAM_BATCH } = {} ) {
226
+
227
+ const reader = stream.getReader();
228
+ const inFlight = [];
229
+ let staging = new Uint8Array( batchBytes );
230
+ let filled = 0;
231
+ let total = 0;
232
+
233
+ const flush = async () => {
234
+
235
+ if ( filled === 0 ) return;
236
+ const out = filled === batchBytes ? staging : staging.slice( 0, filled );
237
+ staging = new Uint8Array( batchBytes );
238
+ filled = 0;
239
+ inFlight.push( this.write( name, out, { transfer: true } ) );
240
+ if ( inFlight.length >= STREAM_WINDOW ) await inFlight.shift();
241
+
242
+ };
243
+
244
+ try {
245
+
246
+ for ( ;; ) {
247
+
248
+ if ( signal?.aborted ) throw signal.reason ?? new DOMException( 'aborted', 'AbortError' );
249
+ const { done, value } = await reader.read();
250
+ if ( done ) break;
251
+
252
+ let offset = 0;
253
+ while ( offset < value.length ) {
254
+
255
+ const n = Math.min( value.length - offset, batchBytes - filled );
256
+ staging.set( value.subarray( offset, offset + n ), filled );
257
+ filled += n;
258
+ offset += n;
259
+ total += n;
260
+ if ( filled === batchBytes ) await flush();
261
+
262
+ }
263
+
264
+ onProgress?.( total );
265
+
266
+ }
267
+
268
+ await flush();
269
+ await Promise.all( inFlight );
270
+ return total;
271
+
272
+ } catch ( error ) {
273
+
274
+ reader.cancel().catch( () => {} );
275
+ await Promise.allSettled( inFlight );
276
+ throw error;
277
+
278
+ }
279
+
280
+ }
281
+
282
+ async commit( extra = {} ) {
283
+
284
+ this._assertOpen();
285
+ this._done = true;
286
+
287
+ try {
288
+
289
+ for ( const [ name, writer ] of this._writers ) {
290
+
291
+ const { size } = await this._transport.call( 'closeWriter', { id: ( await writer ).id } );
292
+ this._sizes[ name ] = size;
293
+
294
+ }
295
+
296
+ this._writers.clear();
297
+
298
+ const now = Date.now();
299
+ const meta = {
300
+ v: META_VERSION,
301
+ key: this.key,
302
+ label: this._info.label ?? '',
303
+ kind: this.area.kind,
304
+ files: { ...this._sizes },
305
+ bytes: Object.values( this._sizes ).reduce( ( n, b ) => n + b, 0 ),
306
+ createdAt: this._info.createdAt ?? now,
307
+ lastUsed: now,
308
+ pinned: this._info.pinned === true,
309
+ extra: { ...this._info.extra, ...extra },
310
+ };
311
+ const growable = this._info.growable ?? [];
312
+ if ( growable.length ) meta.growable = growable;
313
+
314
+ const { args, list } = transferable( JSON.stringify( meta ), { copy: false } );
315
+ await this._transport.call( 'writeFile', { path: this._path, name: META, ...args }, list );
316
+ this.area.manager._changed( this.area.name );
317
+ return meta;
318
+
319
+ } catch ( error ) {
320
+
321
+ await this._discard();
322
+ this.area.manager._writeFailed( this.area.name, error );
323
+ throw error;
324
+
325
+ } finally {
326
+
327
+ this._release?.();
328
+ this._release = null;
329
+
330
+ }
331
+
332
+ }
333
+
334
+ async abort() {
335
+
336
+ if ( this._done ) return;
337
+ this._done = true;
338
+ try {
339
+
340
+ if ( this._append ) await this._closeAll( true );
341
+ else await this._discard();
342
+
343
+ } finally {
344
+
345
+ this._release?.();
346
+ this._release = null;
347
+
348
+ }
349
+
350
+ }
351
+
352
+ async _closeAll( abort ) {
353
+
354
+ for ( const writer of this._writers.values() ) {
355
+
356
+ try {
357
+
358
+ await this._transport.call( 'closeWriter', { id: ( await writer ).id, abort } );
359
+
360
+ } catch { /* already closed */ }
361
+
362
+ }
363
+
364
+ this._writers.clear();
365
+
366
+ }
367
+
368
+ async _discard() {
369
+
370
+ await this._closeAll( true );
371
+ await this.area._removeDir( this.id );
372
+
373
+ }
374
+
375
+ _assertOpen() {
376
+
377
+ if ( this._done ) throw new Error( `storage: entry ${this.area.name}/${this.id} is already committed or aborted` );
378
+
379
+ }
380
+
381
+ }
382
+
383
+ export class StorageArea {
384
+
385
+ constructor( manager, name, kind ) {
386
+
387
+ this.manager = manager;
388
+ this.name = name;
389
+ this.kind = kind;
390
+
391
+ }
392
+
393
+ _lockName( id ) {
394
+
395
+ return `${this.manager.namespace}:store:${this.name}:${id}`;
396
+
397
+ }
398
+
399
+ async _dir( create = false ) {
400
+
401
+ try {
402
+
403
+ return await this.manager._root.getDirectoryHandle( this.name, { create } );
404
+
405
+ } catch ( error ) {
406
+
407
+ if ( error.name === 'NotFoundError' ) return null;
408
+ throw error;
409
+
410
+ }
411
+
412
+ }
413
+
414
+ async _entryDir( id, create = false ) {
415
+
416
+ const dir = await this._dir( create );
417
+ if ( ! dir ) return null;
418
+ try {
419
+
420
+ return await dir.getDirectoryHandle( id, { create } );
421
+
422
+ } catch ( error ) {
423
+
424
+ if ( error.name === 'NotFoundError' ) return null;
425
+ throw error;
426
+
427
+ }
428
+
429
+ }
430
+
431
+ async _removeDir( id ) {
432
+
433
+ const dir = await this._dir( false );
434
+ if ( ! dir ) return;
435
+ try {
436
+
437
+ await dir.removeEntry( id, { recursive: true } );
438
+
439
+ } catch ( error ) {
440
+
441
+ if ( error.name !== 'NotFoundError' ) throw error;
442
+
443
+ }
444
+
445
+ }
446
+
447
+ async _validMeta( dir, key = null ) {
448
+
449
+ const meta = await readJSONFile( dir, META );
450
+ if ( ! meta || meta.v !== META_VERSION || typeof meta.key !== 'string' ) return null;
451
+ if ( key !== null && meta.key !== key ) return null;
452
+
453
+ const growable = meta.growable ?? [];
454
+ for ( const [ name, size ] of Object.entries( meta.files ?? {} ) ) {
455
+
456
+ try {
457
+
458
+ const file = await ( await dir.getFileHandle( name ) ).getFile();
459
+ if ( file.size !== size && ! ( growable.includes( name ) && file.size > size ) ) return null;
460
+
461
+ } catch {
462
+
463
+ return null;
464
+
465
+ }
466
+
467
+ }
468
+
469
+ return meta;
470
+
471
+ }
472
+
473
+ /**
474
+ * Holds a shared lock until `entry.release()`, so no tab can replace or evict the entry
475
+ * while its files are being read. An entry being written is a miss unless `wait`.
476
+ * @returns {Promise<?StorageEntry>}
477
+ */
478
+ async open( key, { wait = false } = {} ) {
479
+
480
+ const id = await entryIdFor( key );
481
+ const release = await acquireLock( this._lockName( id ), { mode: 'shared', ifAvailable: ! wait } );
482
+ if ( ! release ) return null;
483
+
484
+ try {
485
+
486
+ const dir = await this._entryDir( id );
487
+ const meta = dir ? await this._validMeta( dir, key ) : null;
488
+ if ( ! meta ) {
489
+
490
+ release();
491
+ return null;
492
+
493
+ }
494
+
495
+ if ( Date.now() - meta.lastUsed > TOUCH_INTERVAL_MS ) this._touch( id, meta );
496
+ return new StorageEntry( this, id, meta, dir, release );
497
+
498
+ } catch ( error ) {
499
+
500
+ release();
501
+ this.manager._issue( ISSUE_CODES.STORAGE_READ_FAILED, `could not open ${this.name} entry`, { error: error.message } );
502
+ return null;
503
+
504
+ }
505
+
506
+ }
507
+
508
+ async has( key ) {
509
+
510
+ const entry = await this.open( key );
511
+ entry?.release();
512
+ return !! entry;
513
+
514
+ }
515
+
516
+ _touch( id, meta ) {
517
+
518
+ const next = { ...meta, lastUsed: Date.now() };
519
+ const { args, list } = transferable( JSON.stringify( next ), { copy: false } );
520
+ this.manager._transport.call( 'writeFile', { path: [ this.name, id ], name: META, ...args }, list ).catch( () => {} );
521
+ meta.lastUsed = next.lastUsed;
522
+
523
+ }
524
+
525
+ /**
526
+ * Starts writing an entry, replacing any existing one under the same key once this commits.
527
+ * Resolves null when `expectedBytes` cannot be made to fit.
528
+ * @returns {Promise<?EntryWriter>}
529
+ */
530
+ async create( key, { label = '', pinned = false, expectedBytes = 0, extra = {}, growable = [] } = {} ) {
531
+
532
+ const id = await entryIdFor( key );
533
+ const release = await acquireLock( this._lockName( id ), { mode: 'exclusive' } );
534
+
535
+ try {
536
+
537
+ if ( expectedBytes > 0 && ! await this.manager.ensureSpace( expectedBytes, this.kind ) ) {
538
+
539
+ release();
540
+ this.manager._issue( ISSUE_CODES.STORAGE_QUOTA_EXCEEDED, `no room for ${( expectedBytes / 1048576 ).toFixed( 0 )} MB in ${this.name}`, { area: this.name, expectedBytes } );
541
+ return null;
542
+
543
+ }
544
+
545
+ await this._removeDir( id );
546
+ await this._entryDir( id, true );
547
+ return new EntryWriter( this, id, key, { label, pinned, extra, growable }, release );
548
+
549
+ } catch ( error ) {
550
+
551
+ release();
552
+ this.manager._writeFailed( this.name, error );
553
+ return null;
554
+
555
+ }
556
+
557
+ }
558
+
559
+ /** Merges into a committed entry's metadata. Resolves false when there is no such entry. */
560
+ async patchMeta( key, patch ) {
561
+
562
+ const id = await entryIdFor( key );
563
+ const release = await acquireLock( this._lockName( id ), { mode: 'shared', ifAvailable: true } );
564
+ if ( ! release ) return false;
565
+
566
+ try {
567
+
568
+ const dir = await this._entryDir( id );
569
+ const meta = dir ? await this._validMeta( dir, key ) : null;
570
+ if ( ! meta ) return false;
571
+
572
+ const { args, list } = transferable( JSON.stringify( { ...meta, ...patch, v: META_VERSION, key } ), { copy: false } );
573
+ await this.manager._transport.call( 'writeFile', { path: [ this.name, id ], name: META, ...args }, list );
574
+ return true;
575
+
576
+ } finally {
577
+
578
+ release();
579
+
580
+ }
581
+
582
+ }
583
+
584
+ /**
585
+ * Reopens a committed entry for writing: `writeFile` replaces a file, `write` appends to it, and
586
+ * files left alone are kept. Resolves null when there is no such entry. Aborting keeps the entry
587
+ * as it was committed, minus any bytes already appended.
588
+ * @returns {Promise<?EntryWriter>}
589
+ */
590
+ async edit( key ) {
591
+
592
+ const id = await entryIdFor( key );
593
+ const release = await acquireLock( this._lockName( id ), { mode: 'exclusive' } );
594
+
595
+ try {
596
+
597
+ const dir = await this._entryDir( id );
598
+ const meta = dir ? await this._validMeta( dir, key ) : null;
599
+ if ( ! meta ) {
600
+
601
+ release();
602
+ return null;
603
+
604
+ }
605
+
606
+ const info = { label: meta.label, pinned: meta.pinned, extra: meta.extra, createdAt: meta.createdAt, growable: meta.growable };
607
+ return new EntryWriter( this, id, key, info, release, { sizes: meta.files, append: true } );
608
+
609
+ } catch ( error ) {
610
+
611
+ release();
612
+ this.manager._writeFailed( this.name, error );
613
+ return null;
614
+
615
+ }
616
+
617
+ }
618
+
619
+ /** @returns {Promise<boolean>} false while another tab holds the entry */
620
+ async remove( key ) {
621
+
622
+ return this.removeById( await entryIdFor( key ) );
623
+
624
+ }
625
+
626
+ async removeById( id ) {
627
+
628
+ const release = await acquireLock( this._lockName( id ), { mode: 'exclusive', ifAvailable: true } );
629
+ if ( ! release ) return false;
630
+
631
+ try {
632
+
633
+ await this._removeDir( id );
634
+ this.manager._changed( this.name );
635
+ return true;
636
+
637
+ } finally {
638
+
639
+ release();
640
+
641
+ }
642
+
643
+ }
644
+
645
+ /** @returns {Promise<Array<Object>>} committed entries' metadata, each with its `id` */
646
+ async list() {
647
+
648
+ const dir = await this._dir( false );
649
+ if ( ! dir ) return [];
650
+
651
+ const out = [];
652
+ for await ( const [ id, entryDir ] of childDirs( dir ) ) {
653
+
654
+ const meta = await readJSONFile( entryDir, META );
655
+ if ( meta?.v === META_VERSION ) out.push( { ...meta, id } );
656
+
657
+ }
658
+
659
+ return out;
660
+
661
+ }
662
+
663
+ async clear() {
664
+
665
+ let removed = 0;
666
+ for ( const { id } of await this.list() ) if ( await this.removeById( id ) ) removed ++;
667
+ return removed;
668
+
669
+ }
670
+
671
+ /** Deletes entries that never committed and are not being written. */
672
+ async sweep() {
673
+
674
+ const dir = await this._dir( false );
675
+ if ( ! dir ) return 0;
676
+
677
+ const held = await heldLockNames();
678
+ let removed = 0;
679
+ for await ( const [ id, entryDir ] of childDirs( dir ) ) {
680
+
681
+ if ( held.has( this._lockName( id ) ) ) continue;
682
+ if ( await this._validMeta( entryDir ) ) continue;
683
+ if ( await this.removeById( id ) ) removed ++;
684
+
685
+ }
686
+
687
+ return removed;
688
+
689
+ }
690
+
691
+ }
692
+
693
+ export class StorageManager extends Emitter {
694
+
695
+ constructor( { root, transport, namespace } ) {
696
+
697
+ super();
698
+ this.namespace = namespace;
699
+ this._root = root;
700
+ this._transport = transport;
701
+ this._areas = new Map();
702
+ this._budget = null;
703
+
704
+ for ( const name of Object.values( ENGINE_AREAS ) ) this.defineArea( name, { kind: name === ENGINE_AREAS.SPILL ? STORAGE_KIND.SCRATCH : STORAGE_KIND.CACHE } );
705
+
706
+ }
707
+
708
+ defineArea( name, { kind = STORAGE_KIND.CACHE } = {} ) {
709
+
710
+ const existing = this._areas.get( name );
711
+ if ( existing ) {
712
+
713
+ if ( existing.kind !== kind ) throw new Error( `storage: area "${name}" is already defined as ${existing.kind}` );
714
+ return existing;
715
+
716
+ }
717
+
718
+ const area = new StorageArea( this, name, kind );
719
+ this._areas.set( name, area );
720
+ return area;
721
+
722
+ }
723
+
724
+ area( name ) {
725
+
726
+ const area = this._areas.get( name );
727
+ if ( ! area ) throw new Error( `storage: unknown area "${name}"` );
728
+ return area;
729
+
730
+ }
731
+
732
+ get areaNames() {
733
+
734
+ return [ ...this._areas.keys() ];
735
+
736
+ }
737
+
738
+ async estimate() {
739
+
740
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : null;
741
+ if ( ! storage?.estimate ) return { quota: Infinity, usage: 0 };
742
+ const { quota = Infinity, usage = 0 } = await storage.estimate();
743
+ return { quota, usage };
744
+
745
+ }
746
+
747
+ async persisted() {
748
+
749
+ return ( await globalThis.navigator?.storage?.persisted?.() ) === true;
750
+
751
+ }
752
+
753
+ /** Asks the browser to exempt this site's data from eviction. Firefox shows a prompt. */
754
+ async persist() {
755
+
756
+ const granted = ( await globalThis.navigator?.storage?.persist?.() ) === true;
757
+ this._changed( null );
758
+ return granted;
759
+
760
+ }
761
+
762
+ async budget() {
763
+
764
+ if ( this._budget !== null ) return this._budget;
765
+ const { quota } = await this.estimate();
766
+ return Math.min( quota * BUDGET_FRACTION, MAX_DEFAULT_BUDGET );
767
+
768
+ }
769
+
770
+ /** Caps what cache areas may hold together; null restores the default. */
771
+ setBudget( bytes ) {
772
+
773
+ this._budget = bytes === null ? null : Math.max( 0, bytes );
774
+ this._changed( null );
775
+
776
+ }
777
+
778
+ async _cacheEntries() {
779
+
780
+ const out = [];
781
+ for ( const area of this._areas.values() ) {
782
+
783
+ if ( area.kind !== STORAGE_KIND.CACHE ) continue;
784
+ for ( const meta of await area.list() ) out.push( { area, meta } );
785
+
786
+ }
787
+
788
+ return out;
789
+
790
+ }
791
+
792
+ async usage() {
793
+
794
+ const { quota, usage } = await this.estimate();
795
+ const areas = {};
796
+ let cacheBytes = 0;
797
+ for ( const area of this._areas.values() ) {
798
+
799
+ const entries = await area.list();
800
+ const bytes = entries.reduce( ( n, m ) => n + ( m.bytes ?? 0 ), 0 );
801
+ areas[ area.name ] = { kind: area.kind, bytes, entries: entries.length };
802
+ if ( area.kind === STORAGE_KIND.CACHE ) cacheBytes += bytes;
803
+
804
+ }
805
+
806
+ return { quota, usage, persisted: await this.persisted(), budget: await this.budget(), cacheBytes, areas };
807
+
808
+ }
809
+
810
+ /**
811
+ * Evicts least-recently-used cache entries (never pinned, never in use) until `freeBytes`
812
+ * are released, or, without it, until caches fit the budget.
813
+ * @returns {Promise<number>} bytes released
814
+ */
815
+ async collect( { freeBytes = null } = {} ) {
816
+
817
+ const entries = await this._cacheEntries();
818
+ const budget = await this.budget();
819
+ let cacheBytes = entries.reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 );
820
+ const target = freeBytes ?? Math.max( 0, cacheBytes - budget );
821
+ if ( target <= 0 ) return 0;
822
+
823
+ const held = await heldLockNames();
824
+ const candidates = entries
825
+ .filter( ( { area, meta } ) => ! meta.pinned && ! held.has( area._lockName( meta.id ) ) )
826
+ .sort( ( a, b ) => a.meta.lastUsed - b.meta.lastUsed );
827
+
828
+ let freed = 0;
829
+ for ( const { area, meta } of candidates ) {
830
+
831
+ if ( freed >= target ) break;
832
+ if ( await area.removeById( meta.id ) ) {
833
+
834
+ freed += meta.bytes ?? 0;
835
+ cacheBytes -= meta.bytes ?? 0;
836
+
837
+ }
838
+
839
+ }
840
+
841
+ return freed;
842
+
843
+ }
844
+
845
+ /**
846
+ * Makes room for a write of `bytes`, evicting least-recently-used caches as needed. The budget
847
+ * caps what caches pile up to, not one write: an entry larger than it is still allowed when the
848
+ * disk has room, and the rest are evicted to make way.
849
+ */
850
+ async ensureSpace( bytes, kind = STORAGE_KIND.CACHE ) {
851
+
852
+ const shortfall = async () => {
853
+
854
+ const { quota, usage } = await this.estimate();
855
+ const free = bytes + FREE_MARGIN - ( quota - usage );
856
+ if ( kind !== STORAGE_KIND.CACHE ) return { free, budget: 0 };
857
+
858
+ const cacheBytes = ( await this._cacheEntries() ).reduce( ( n, e ) => n + ( e.meta.bytes ?? 0 ), 0 );
859
+ return { free, budget: cacheBytes + bytes - await this.budget() };
860
+
861
+ };
862
+
863
+ const before = await shortfall();
864
+ if ( before.free <= 0 && before.budget <= 0 ) return true;
865
+
866
+ await this.collect( { freeBytes: Math.max( before.free, before.budget ) } );
867
+ return ( await shortfall() ).free <= 0;
868
+
869
+ }
870
+
871
+ /**
872
+ * Removes half-written entries left by a crash, and scratch no open page holds. Runs in the
873
+ * background after opening.
874
+ */
875
+ async sweep() {
876
+
877
+ let removed = 0;
878
+ for ( const area of this._areas.values() ) {
879
+
880
+ removed += await area.sweep();
881
+ if ( area.kind === STORAGE_KIND.SCRATCH ) removed += await area.clear();
882
+
883
+ }
884
+
885
+ return removed;
886
+
887
+ }
888
+
889
+ _changed( area ) {
890
+
891
+ this.dispatchEvent( { type: 'change', area } );
892
+
893
+ }
894
+
895
+ _issue( code, message, detail = {} ) {
896
+
897
+ this.dispatchEvent( { type: 'issue', code, message, detail } );
898
+
899
+ }
900
+
901
+ _writeFailed( area, error ) {
902
+
903
+ const quota = error?.name === 'QuotaExceededError';
904
+ this._issue(
905
+ quota ? ISSUE_CODES.STORAGE_QUOTA_EXCEEDED : ISSUE_CODES.STORAGE_WRITE_FAILED,
906
+ `${area}: ${error?.message ?? error}`,
907
+ { area, error: error?.name }
908
+ );
909
+
910
+ }
911
+
912
+ dispose() {
913
+
914
+ this._transport?.dispose();
915
+ this._transport = null;
916
+ this._areas.clear();
917
+
918
+ }
919
+
920
+ }
921
+
922
+ /**
923
+ * Storage over a directory this thread writes itself (a worker's own, or a test's fake root).
924
+ * @returns {StorageManager}
925
+ */
926
+ export function openInlineStorage( { namespace, root } ) {
927
+
928
+ const transport = new InlineTransport( root );
929
+ transport.inline = true;
930
+ const storage = new StorageManager( { root, transport, namespace } );
931
+ storage.sweep().catch( () => {} );
932
+ return storage;
933
+
934
+ }