@empty-sekai/sekai-custom-profile-sdk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +235 -0
  2. package/LICENSE-EXCEPTION +32 -0
  3. package/NOTICE +11 -0
  4. package/README.en.md +456 -0
  5. package/README.md +458 -0
  6. package/dist/allium_renderer_wasm.js +14 -0
  7. package/dist/allium_renderer_wasm.wasm +0 -0
  8. package/dist/authoring.d.ts +14 -0
  9. package/dist/authoring.js +34 -0
  10. package/dist/cache/glyphPersistentCache.d.ts +145 -0
  11. package/dist/cache/glyphPersistentCache.js +346 -0
  12. package/dist/cache/indexedDbGlyphRecordStore.d.ts +26 -0
  13. package/dist/cache/indexedDbGlyphRecordStore.js +214 -0
  14. package/dist/cache/sessionImageResourceCache.d.ts +39 -0
  15. package/dist/cache/sessionImageResourceCache.js +156 -0
  16. package/dist/emscripten.d.ts +11 -0
  17. package/dist/emscripten.js +1 -0
  18. package/dist/fontProvider.d.ts +36 -0
  19. package/dist/fontProvider.js +84 -0
  20. package/dist/fontSdfAtlas.d.ts +92 -0
  21. package/dist/fontSdfAtlas.js +296 -0
  22. package/dist/gpu/browserSemanticResources.d.ts +64 -0
  23. package/dist/gpu/browserSemanticResources.js +232 -0
  24. package/dist/gpu/generalTextRenderPlacement.d.ts +8 -0
  25. package/dist/gpu/generalTextRenderPlacement.js +131 -0
  26. package/dist/gpu/previewTransformTextureLayout.d.ts +1 -0
  27. package/dist/gpu/previewTransformTextureLayout.js +12 -0
  28. package/dist/gpu/semanticCommandGeometry.d.ts +21 -0
  29. package/dist/gpu/semanticCommandGeometry.js +226 -0
  30. package/dist/gpu/semanticCommandPlanner.d.ts +131 -0
  31. package/dist/gpu/semanticCommandPlanner.js +203 -0
  32. package/dist/gpu/semanticTextGlyphBridge.d.ts +3 -0
  33. package/dist/gpu/semanticTextGlyphBridge.js +22 -0
  34. package/dist/gpu/semanticWebglSceneRenderer.d.ts +70 -0
  35. package/dist/gpu/semanticWebglSceneRenderer.js +211 -0
  36. package/dist/gpu/slotRanges.d.ts +5 -0
  37. package/dist/gpu/slotRanges.js +14 -0
  38. package/dist/gpu/webglSdfAtlasTexture.d.ts +17 -0
  39. package/dist/gpu/webglSdfAtlasTexture.js +72 -0
  40. package/dist/gpu/webglSdfGlyphPipeline.d.ts +26 -0
  41. package/dist/gpu/webglSdfGlyphPipeline.js +267 -0
  42. package/dist/gpu/webglSemanticCommandExecutor.d.ts +79 -0
  43. package/dist/gpu/webglSemanticCommandExecutor.js +686 -0
  44. package/dist/index.d.ts +18 -0
  45. package/dist/index.js +8 -0
  46. package/dist/interaction/numericTextRegions.d.ts +46 -0
  47. package/dist/interaction/numericTextRegions.js +44 -0
  48. package/dist/localizationProvider.d.ts +33 -0
  49. package/dist/localizationProvider.js +74 -0
  50. package/dist/originPrebuiltSdfAtlasPackage.d.ts +39 -0
  51. package/dist/originPrebuiltSdfAtlasPackage.js +316 -0
  52. package/dist/prebuiltSdfAtlas.d.ts +36 -0
  53. package/dist/prebuiltSdfAtlas.js +261 -0
  54. package/dist/protocol.d.ts +456 -0
  55. package/dist/protocol.js +1 -0
  56. package/dist/renderer.d.ts +218 -0
  57. package/dist/renderer.js +641 -0
  58. package/dist/resourceProvider.d.ts +24 -0
  59. package/dist/resourceProvider.js +41 -0
  60. package/dist/telemetry/rendererTelemetry.d.ts +158 -0
  61. package/dist/telemetry/rendererTelemetry.js +232 -0
  62. package/dist/third-party/freetype/FTL.txt +169 -0
  63. package/dist/types/atlas.d.ts +90 -0
  64. package/dist/types/atlas.js +1 -0
  65. package/dist/types/authoring.d.ts +81 -0
  66. package/dist/types/authoring.js +1 -0
  67. package/dist/types/core.d.ts +128 -0
  68. package/dist/types/core.js +1 -0
  69. package/dist/types/freeType.d.ts +54 -0
  70. package/dist/types/freeType.js +13 -0
  71. package/dist/types/glyph.d.ts +33 -0
  72. package/dist/types/glyph.js +1 -0
  73. package/dist/types/layout.d.ts +25 -0
  74. package/dist/types/layout.js +1 -0
  75. package/dist/types/text.d.ts +28 -0
  76. package/dist/types/text.js +1 -0
  77. package/dist/worker/glyphWorkScheduler.d.ts +29 -0
  78. package/dist/worker/glyphWorkScheduler.js +107 -0
  79. package/dist/worker-client.d.ts +114 -0
  80. package/dist/worker-client.js +413 -0
  81. package/dist/worker.d.ts +1 -0
  82. package/dist/worker.js +708 -0
  83. package/package.json +46 -0
  84. package/src/atlas.rs +652 -0
  85. package/src/authoring.ts +42 -0
  86. package/src/authoring_runtime.rs +303 -0
  87. package/src/cache/glyphPersistentCache.ts +463 -0
  88. package/src/cache/indexedDbGlyphRecordStore.ts +238 -0
  89. package/src/cache/sessionImageResourceCache.ts +186 -0
  90. package/src/edt.rs +97 -0
  91. package/src/emscripten.ts +17 -0
  92. package/src/fontProvider.ts +127 -0
  93. package/src/fontSdfAtlas.ts +431 -0
  94. package/src/geometry.rs +149 -0
  95. package/src/glyph_plan.rs +226 -0
  96. package/src/gpu/browserSemanticResources.ts +294 -0
  97. package/src/gpu/generalTextRenderPlacement.ts +175 -0
  98. package/src/gpu/previewTransformTextureLayout.ts +12 -0
  99. package/src/gpu/semanticCommandGeometry.ts +260 -0
  100. package/src/gpu/semanticCommandPlanner.ts +280 -0
  101. package/src/gpu/semanticTextGlyphBridge.ts +35 -0
  102. package/src/gpu/semanticWebglSceneRenderer.ts +261 -0
  103. package/src/gpu/slotRanges.ts +15 -0
  104. package/src/gpu/webglSdfAtlasTexture.ts +69 -0
  105. package/src/gpu/webglSdfGlyphPipeline.ts +299 -0
  106. package/src/gpu/webglSemanticCommandExecutor.ts +739 -0
  107. package/src/index.ts +84 -0
  108. package/src/interaction/numericTextRegions.ts +80 -0
  109. package/src/layout.rs +1882 -0
  110. package/src/lib.rs +1443 -0
  111. package/src/localizationProvider.ts +113 -0
  112. package/src/main.rs +55 -0
  113. package/src/masterdata_runtime.rs +496 -0
  114. package/src/originPrebuiltSdfAtlasPackage.ts +403 -0
  115. package/src/prebuiltSdfAtlas.ts +306 -0
  116. package/src/protocol.ts +150 -0
  117. package/src/renderer.ts +792 -0
  118. package/src/resourceProvider.ts +69 -0
  119. package/src/scene.rs +665 -0
  120. package/src/telemetry/rendererTelemetry.ts +358 -0
  121. package/src/types/atlas.ts +80 -0
  122. package/src/types/authoring.ts +85 -0
  123. package/src/types/core.ts +110 -0
  124. package/src/types/freeType.ts +63 -0
  125. package/src/types/glyph.ts +33 -0
  126. package/src/types/layout.ts +28 -0
  127. package/src/types/text.ts +28 -0
  128. package/src/worker/glyphWorkScheduler.ts +130 -0
  129. package/src/worker-client.ts +484 -0
  130. package/src/worker.ts +895 -0
@@ -0,0 +1,463 @@
1
+ export const GLYPH_CACHE_SCHEMA = "allium.glyph-raster-cache.v1";
2
+
3
+ export type PersistentCacheMode = "memory-only" | "origin";
4
+
5
+ export type GlyphRasterKeyInput = {
6
+ region: string;
7
+ fontSha256: string;
8
+ faceIndex: number;
9
+ variationAxes: ReadonlyArray<readonly [string, number]>;
10
+ glyphId: number;
11
+ pointSize26d6: number;
12
+ dpiX: number;
13
+ dpiY: number;
14
+ loadFlags: string;
15
+ renderMode: string;
16
+ spread26d6: number;
17
+ sdfAlgorithm: string;
18
+ supersample: number;
19
+ threshold: number;
20
+ downsampleVersion: string;
21
+ fontEngineFingerprint: string;
22
+ rasterContractId: string;
23
+ };
24
+
25
+ export type GlyphRasterIdentity = {
26
+ opaqueKey: string;
27
+ schemaNamespace: string;
28
+ fontEngineFingerprint: string;
29
+ rasterContractId: string;
30
+ };
31
+
32
+ export type PersistentGlyphMetrics = {
33
+ advance: number;
34
+ xOffset: number;
35
+ yOffset: number;
36
+ planeBearingX: number;
37
+ planeBearingY: number;
38
+ planeWidth: number;
39
+ planeHeight: number;
40
+ drawable: boolean;
41
+ width: number;
42
+ height: number;
43
+ pixels: Uint8Array;
44
+ };
45
+
46
+ export type PersistentGlyphRecord = {
47
+ opaqueKey: string;
48
+ schemaNamespace: string;
49
+ fontEngineFingerprint: string;
50
+ rasterContractId: string;
51
+ identity: GlyphRasterIdentity;
52
+ format: "r8";
53
+ advance: number;
54
+ xOffset: number;
55
+ yOffset: number;
56
+ planeBearingX: number;
57
+ planeBearingY: number;
58
+ planeWidth: number;
59
+ planeHeight: number;
60
+ drawable: boolean;
61
+ width: number;
62
+ height: number;
63
+ pixels: ArrayBuffer;
64
+ payloadLength: number;
65
+ payloadDigest: string;
66
+ byteSize: number;
67
+ lastAccessDay: number;
68
+ };
69
+
70
+ export type PersistentStoreStats = { entries: number; bytes: number };
71
+ export type PersistentSweepResult = { scanned: number; deleted: number };
72
+ export type PersistentWriteToken = { epoch: number; startedAtMs: number };
73
+
74
+ export interface GlyphRecordStore {
75
+ getMany(keys: readonly string[]): Promise<PersistentGlyphRecord[]>;
76
+ putMany(records: readonly PersistentGlyphRecord[], token?: PersistentWriteToken): Promise<boolean>;
77
+ deleteMany(keys: readonly string[]): Promise<void>;
78
+ stats(): Promise<PersistentStoreStats>;
79
+ trimLruTo(maxBytes: number, protectedKeys?: ReadonlySet<string>): Promise<number>;
80
+ sweepBefore(lastAccessDayExclusive: number, maxRecords: number): Promise<PersistentSweepResult>;
81
+ clear(clearedAtMs?: number): Promise<void>;
82
+ }
83
+
84
+ export type GlyphPersistentCacheStats = {
85
+ lookups: number;
86
+ hits: number;
87
+ misses: number;
88
+ inserts: number;
89
+ evictions: number;
90
+ corruptions: number;
91
+ skippedWrites: number;
92
+ cancelledWrites: number;
93
+ readErrors: number;
94
+ writeErrors: number;
95
+ degraded: boolean;
96
+ };
97
+
98
+ export type GlyphPersistentCacheOptions = {
99
+ mode?: PersistentCacheMode;
100
+ store?: GlyphRecordStore;
101
+ softBytes?: number;
102
+ hardBytes?: number;
103
+ ttlDays?: number;
104
+ now?: () => number;
105
+ };
106
+
107
+ const DAY_MS = 86_400_000;
108
+ const DEFAULT_SOFT_BYTES = 64 * 1024 * 1024;
109
+ const DEFAULT_HARD_BYTES = 96 * 1024 * 1024;
110
+
111
+ export async function createGlyphRasterIdentity(input: GlyphRasterKeyInput): Promise<GlyphRasterIdentity> {
112
+ validateKeyInput(input);
113
+ const canonical = [
114
+ ["schema", GLYPH_CACHE_SCHEMA],
115
+ ["region", input.region],
116
+ ["font_sha256", input.fontSha256.toLowerCase()],
117
+ ["face_index", input.faceIndex],
118
+ ["variation_axes", [...input.variationAxes].sort(([a], [b]) => a.localeCompare(b))],
119
+ ["glyph_id", input.glyphId],
120
+ ["point_size_26d6", input.pointSize26d6],
121
+ ["dpi_x", input.dpiX],
122
+ ["dpi_y", input.dpiY],
123
+ ["load_flags", input.loadFlags],
124
+ ["render_mode", input.renderMode],
125
+ ["spread_26d6", input.spread26d6],
126
+ ["sdf_algorithm", input.sdfAlgorithm],
127
+ ["supersample", input.supersample],
128
+ ["threshold", input.threshold],
129
+ ["downsample_version", input.downsampleVersion],
130
+ ["font_engine", input.fontEngineFingerprint],
131
+ ["raster_contract", input.rasterContractId],
132
+ ];
133
+ const opaqueKey = await sha256Hex(new TextEncoder().encode(JSON.stringify(canonical)));
134
+ return {
135
+ opaqueKey,
136
+ schemaNamespace: GLYPH_CACHE_SCHEMA,
137
+ fontEngineFingerprint: input.fontEngineFingerprint,
138
+ rasterContractId: input.rasterContractId,
139
+ };
140
+ }
141
+
142
+ export async function createPersistentGlyphRecord(
143
+ identity: GlyphRasterIdentity,
144
+ glyph: PersistentGlyphMetrics,
145
+ nowMs = Date.now(),
146
+ ): Promise<PersistentGlyphRecord> {
147
+ const pixels = glyph.pixels.slice();
148
+ const payloadDigest = await sha256Hex(pixels);
149
+ const byteSize = estimateRecordBytes(pixels.byteLength);
150
+ return {
151
+ opaqueKey: identity.opaqueKey,
152
+ schemaNamespace: identity.schemaNamespace,
153
+ fontEngineFingerprint: identity.fontEngineFingerprint,
154
+ rasterContractId: identity.rasterContractId,
155
+ identity: { ...identity },
156
+ format: "r8",
157
+ advance: glyph.advance,
158
+ xOffset: glyph.xOffset,
159
+ yOffset: glyph.yOffset,
160
+ planeBearingX: glyph.planeBearingX,
161
+ planeBearingY: glyph.planeBearingY,
162
+ planeWidth: glyph.planeWidth,
163
+ planeHeight: glyph.planeHeight,
164
+ drawable: glyph.drawable,
165
+ width: glyph.width,
166
+ height: glyph.height,
167
+ pixels: pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength) as ArrayBuffer,
168
+ payloadLength: pixels.byteLength,
169
+ payloadDigest,
170
+ byteSize,
171
+ lastAccessDay: dayBucket(nowMs),
172
+ };
173
+ }
174
+
175
+ export function persistentRecordPublicShape(record: PersistentGlyphRecord): Omit<PersistentGlyphRecord, "pixels"> {
176
+ const { pixels: _, ...publicShape } = record;
177
+ return publicShape;
178
+ }
179
+
180
+ export class GlyphPersistentCache {
181
+ private readonly mode: PersistentCacheMode;
182
+ private readonly store?: GlyphRecordStore;
183
+ private readonly softBytes: number;
184
+ private readonly hardBytes: number;
185
+ private readonly ttlDays: number;
186
+ private readonly now: () => number;
187
+ private readonly counters: GlyphPersistentCacheStats = {
188
+ lookups: 0,
189
+ hits: 0,
190
+ misses: 0,
191
+ inserts: 0,
192
+ evictions: 0,
193
+ corruptions: 0,
194
+ skippedWrites: 0,
195
+ cancelledWrites: 0,
196
+ readErrors: 0,
197
+ writeErrors: 0,
198
+ degraded: false,
199
+ };
200
+ private writeEpoch = 0;
201
+
202
+ constructor(options: GlyphPersistentCacheOptions = {}) {
203
+ this.mode = options.mode ?? "memory-only";
204
+ this.store = options.store;
205
+ this.softBytes = options.softBytes ?? DEFAULT_SOFT_BYTES;
206
+ this.hardBytes = options.hardBytes ?? DEFAULT_HARD_BYTES;
207
+ this.ttlDays = options.ttlDays ?? 30;
208
+ this.now = options.now ?? Date.now;
209
+ if (this.softBytes <= 0 || this.hardBytes < this.softBytes) {
210
+ throw new Error("persistent glyph cache requires 0 < softBytes <= hardBytes");
211
+ }
212
+ }
213
+
214
+ async getMany(identities: readonly GlyphRasterIdentity[]): Promise<Map<string, PersistentGlyphRecord>> {
215
+ this.counters.lookups += identities.length;
216
+ if (!this.available()) {
217
+ this.counters.misses += identities.length;
218
+ return new Map();
219
+ }
220
+ try {
221
+ const expected = new Map(identities.map((identity) => [identity.opaqueKey, identity]));
222
+ const records = await this.store!.getMany([...expected.keys()]);
223
+ const valid = new Map<string, PersistentGlyphRecord>();
224
+ const corruptKeys: string[] = [];
225
+ for (const record of records) {
226
+ const identity = expected.get(record.opaqueKey);
227
+ if (!identity || !(await validateRecord(record, identity))) {
228
+ corruptKeys.push(record.opaqueKey);
229
+ this.counters.corruptions += 1;
230
+ continue;
231
+ }
232
+ record.lastAccessDay = dayBucket(this.now());
233
+ valid.set(record.opaqueKey, record);
234
+ }
235
+ if (corruptKeys.length > 0) await this.store!.deleteMany(corruptKeys);
236
+ if (valid.size > 0) await this.store!.putMany([...valid.values()]);
237
+ this.counters.hits += valid.size;
238
+ this.counters.misses += identities.length - valid.size;
239
+ return valid;
240
+ } catch {
241
+ this.counters.readErrors += 1;
242
+ this.counters.degraded = true;
243
+ this.counters.misses += identities.length;
244
+ return new Map();
245
+ }
246
+ }
247
+
248
+ beginWrite(): PersistentWriteToken {
249
+ return { epoch: this.writeEpoch, startedAtMs: this.now() };
250
+ }
251
+
252
+ async putMany(
253
+ records: readonly PersistentGlyphRecord[],
254
+ writeToken: PersistentWriteToken = this.beginWrite(),
255
+ ): Promise<void> {
256
+ if (writeToken.epoch !== this.writeEpoch) {
257
+ this.counters.cancelledWrites += records.length;
258
+ return;
259
+ }
260
+ if (!this.available() || records.length === 0) return;
261
+ try {
262
+ const accepted: PersistentGlyphRecord[] = [];
263
+ let pendingBytes = 0;
264
+ for (const record of records) {
265
+ if (record.byteSize > this.hardBytes) {
266
+ this.counters.skippedWrites += 1;
267
+ continue;
268
+ }
269
+ const before = await this.store!.stats();
270
+ const pendingWithRecord = pendingBytes + record.byteSize;
271
+ if (before.bytes + pendingWithRecord > this.softBytes) {
272
+ const target = Math.max(0, this.softBytes - pendingWithRecord);
273
+ this.counters.evictions += await this.store!.trimLruTo(target, new Set([record.opaqueKey]));
274
+ }
275
+ const afterTrim = await this.store!.stats();
276
+ if (afterTrim.bytes + pendingWithRecord > this.hardBytes) {
277
+ this.counters.skippedWrites += 1;
278
+ continue;
279
+ }
280
+ accepted.push(record);
281
+ pendingBytes = pendingWithRecord;
282
+ }
283
+ if (accepted.length > 0) {
284
+ const acceptedByStore = await this.store!.putMany(accepted, writeToken);
285
+ if (acceptedByStore) {
286
+ this.counters.inserts += accepted.length;
287
+ } else {
288
+ this.counters.cancelledWrites += accepted.length;
289
+ }
290
+ }
291
+ } catch {
292
+ this.counters.writeErrors += 1;
293
+ this.counters.degraded = true;
294
+ }
295
+ }
296
+
297
+ async sweep(options: { maxRecords?: number } = {}): Promise<PersistentSweepResult> {
298
+ if (!this.available()) return { scanned: 0, deleted: 0 };
299
+ try {
300
+ const cutoff = dayBucket(this.now()) - this.ttlDays;
301
+ return await this.store!.sweepBefore(cutoff, options.maxRecords ?? 128);
302
+ } catch {
303
+ this.counters.writeErrors += 1;
304
+ this.counters.degraded = true;
305
+ return { scanned: 0, deleted: 0 };
306
+ }
307
+ }
308
+
309
+ async trimPersistentCache(targetBytes = this.softBytes): Promise<number> {
310
+ if (!this.available()) return 0;
311
+ try {
312
+ const evicted = await this.store!.trimLruTo(Math.max(0, targetBytes));
313
+ this.counters.evictions += evicted;
314
+ return evicted;
315
+ } catch {
316
+ this.counters.writeErrors += 1;
317
+ this.counters.degraded = true;
318
+ return 0;
319
+ }
320
+ }
321
+
322
+ async clearPersistentCache(): Promise<void> {
323
+ this.writeEpoch += 1;
324
+ if (!this.store) return;
325
+ try {
326
+ await this.store.clear(this.now());
327
+ } catch {
328
+ this.counters.writeErrors += 1;
329
+ this.counters.degraded = true;
330
+ }
331
+ }
332
+
333
+ async getPersistentCacheStats(): Promise<PersistentStoreStats & GlyphPersistentCacheStats> {
334
+ const stored = this.available() ? await this.store!.stats().catch(() => ({ entries: 0, bytes: 0 })) : { entries: 0, bytes: 0 };
335
+ return { ...stored, ...this.stats() };
336
+ }
337
+
338
+ stats(): GlyphPersistentCacheStats {
339
+ return { ...this.counters };
340
+ }
341
+
342
+ private available(): boolean {
343
+ return this.mode === "origin" && this.store != null && !this.counters.degraded;
344
+ }
345
+ }
346
+
347
+ export class MemoryGlyphRecordStore implements GlyphRecordStore {
348
+ private readonly records = new Map<string, PersistentGlyphRecord>();
349
+ private readonly failWrites: boolean;
350
+
351
+ constructor(options: { failWrites?: boolean } = {}) {
352
+ this.failWrites = options.failWrites ?? false;
353
+ }
354
+
355
+ async getMany(keys: readonly string[]): Promise<PersistentGlyphRecord[]> {
356
+ return keys.flatMap((key) => {
357
+ const record = this.records.get(key);
358
+ return record ? [cloneRecord(record)] : [];
359
+ });
360
+ }
361
+
362
+ private lastClearMs = Number.NEGATIVE_INFINITY;
363
+
364
+ async putMany(records: readonly PersistentGlyphRecord[], token?: PersistentWriteToken): Promise<boolean> {
365
+ if (this.failWrites) throw new Error("simulated quota/private-mode failure");
366
+ if (token && token.startedAtMs <= this.lastClearMs) return false;
367
+ for (const record of records) this.records.set(record.opaqueKey, cloneRecord(record));
368
+ return true;
369
+ }
370
+
371
+ async deleteMany(keys: readonly string[]): Promise<void> {
372
+ if (this.failWrites) throw new Error("simulated write failure");
373
+ for (const key of keys) this.records.delete(key);
374
+ }
375
+
376
+ async stats(): Promise<PersistentStoreStats> {
377
+ return {
378
+ entries: this.records.size,
379
+ bytes: [...this.records.values()].reduce((sum, record) => sum + record.byteSize, 0),
380
+ };
381
+ }
382
+
383
+ async trimLruTo(maxBytes: number, protectedKeys: ReadonlySet<string> = new Set()): Promise<number> {
384
+ if (this.failWrites) throw new Error("simulated write failure");
385
+ let bytes = (await this.stats()).bytes;
386
+ let evicted = 0;
387
+ const candidates = [...this.records.values()]
388
+ .filter((record) => !protectedKeys.has(record.opaqueKey))
389
+ .sort((a, b) => a.lastAccessDay - b.lastAccessDay || a.opaqueKey.localeCompare(b.opaqueKey));
390
+ for (const record of candidates) {
391
+ if (bytes <= maxBytes) break;
392
+ this.records.delete(record.opaqueKey);
393
+ bytes -= record.byteSize;
394
+ evicted += 1;
395
+ }
396
+ return evicted;
397
+ }
398
+
399
+ async sweepBefore(lastAccessDayExclusive: number, maxRecords: number): Promise<PersistentSweepResult> {
400
+ if (this.failWrites) throw new Error("simulated write failure");
401
+ let scanned = 0;
402
+ let deleted = 0;
403
+ for (const record of [...this.records.values()].sort((a, b) => a.lastAccessDay - b.lastAccessDay)) {
404
+ if (scanned >= maxRecords) break;
405
+ scanned += 1;
406
+ if (record.lastAccessDay < lastAccessDayExclusive) {
407
+ this.records.delete(record.opaqueKey);
408
+ deleted += 1;
409
+ }
410
+ }
411
+ return { scanned, deleted };
412
+ }
413
+
414
+ async clear(clearedAtMs = Date.now()): Promise<void> {
415
+ if (this.failWrites) throw new Error("simulated write failure");
416
+ this.lastClearMs = Math.max(this.lastClearMs, clearedAtMs);
417
+ this.records.clear();
418
+ }
419
+ }
420
+
421
+ function validateKeyInput(input: GlyphRasterKeyInput): void {
422
+ if (!/^[0-9a-f]{64}$/i.test(input.fontSha256)) throw new Error("fontSha256 must be a full SHA-256 hex digest");
423
+ if (!input.region || !input.fontEngineFingerprint || !input.rasterContractId) throw new Error("glyph raster key identity fields must be non-empty");
424
+ for (const value of [input.faceIndex, input.glyphId, input.pointSize26d6, input.dpiX, input.dpiY, input.spread26d6, input.supersample, input.threshold]) {
425
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("glyph raster numeric fields must be non-negative safe integers");
426
+ }
427
+ }
428
+
429
+ async function validateRecord(record: PersistentGlyphRecord, identity: GlyphRasterIdentity): Promise<boolean> {
430
+ if (
431
+ record.opaqueKey !== identity.opaqueKey ||
432
+ record.schemaNamespace !== identity.schemaNamespace ||
433
+ record.fontEngineFingerprint !== identity.fontEngineFingerprint ||
434
+ record.rasterContractId !== identity.rasterContractId ||
435
+ record.format !== "r8" ||
436
+ record.payloadLength !== record.pixels.byteLength ||
437
+ record.byteSize !== estimateRecordBytes(record.payloadLength)
438
+ ) return false;
439
+ return (await sha256Hex(new Uint8Array(record.pixels))) === record.payloadDigest;
440
+ }
441
+
442
+ function estimateRecordBytes(payloadBytes: number): number {
443
+ return payloadBytes + 256;
444
+ }
445
+
446
+ function dayBucket(nowMs: number): number {
447
+ return Math.floor(nowMs / DAY_MS);
448
+ }
449
+
450
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
451
+ const subtle = globalThis.crypto?.subtle;
452
+ if (!subtle) throw new Error("Web Crypto SHA-256 is required for persistent glyph identity");
453
+ const digest = await subtle.digest("SHA-256", bytes as BufferSource);
454
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
455
+ }
456
+
457
+ function cloneRecord(record: PersistentGlyphRecord): PersistentGlyphRecord {
458
+ return {
459
+ ...record,
460
+ identity: { ...record.identity },
461
+ pixels: record.pixels.slice(0),
462
+ };
463
+ }
@@ -0,0 +1,238 @@
1
+ import {
2
+ GLYPH_CACHE_SCHEMA,
3
+ GlyphPersistentCache,
4
+ type GlyphRecordStore,
5
+ type PersistentGlyphRecord,
6
+ type PersistentStoreStats,
7
+ type PersistentSweepResult,
8
+ type PersistentWriteToken,
9
+ } from "./glyphPersistentCache.js";
10
+
11
+ const DATABASE_NAME = "sekai-custom-profile-sdk-glyph-cache-v1";
12
+ const DATABASE_VERSION = 1;
13
+ const GLYPH_STORE = "glyphs";
14
+ const META_STORE = "meta";
15
+ const TOTALS_KEY = "totals";
16
+
17
+ type TotalsRecord = { key: typeof TOTALS_KEY; entries: number; bytes: number; lastClearMs: number };
18
+
19
+ export class IndexedDbGlyphRecordStore implements GlyphRecordStore {
20
+ private databasePromise: Promise<IDBDatabase> | null = null;
21
+
22
+ constructor(private readonly databaseName = DATABASE_NAME) {}
23
+
24
+ async getMany(keys: readonly string[]): Promise<PersistentGlyphRecord[]> {
25
+ if (keys.length === 0) return [];
26
+ const database = await this.database();
27
+ const transaction = database.transaction(GLYPH_STORE, "readonly");
28
+ const store = transaction.objectStore(GLYPH_STORE);
29
+ const records = await Promise.all(keys.map((key) => request(store.get(key))));
30
+ await transactionDone(transaction);
31
+ return records.filter((record): record is PersistentGlyphRecord => record != null);
32
+ }
33
+
34
+ async putMany(records: readonly PersistentGlyphRecord[], token?: PersistentWriteToken): Promise<boolean> {
35
+ if (records.length === 0) return true;
36
+ const database = await this.database();
37
+ const transaction = database.transaction([GLYPH_STORE, META_STORE], "readwrite", { durability: "relaxed" });
38
+ const glyphs = transaction.objectStore(GLYPH_STORE);
39
+ const meta = transaction.objectStore(META_STORE);
40
+ const totals = await readTotals(meta);
41
+ if (token && token.startedAtMs <= totals.lastClearMs) {
42
+ transaction.abort();
43
+ try { await transactionDone(transaction); } catch { /* intentional abort */ }
44
+ return false;
45
+ }
46
+ for (const record of records) {
47
+ const existing = await request<PersistentGlyphRecord | undefined>(glyphs.get(record.opaqueKey));
48
+ if (existing) {
49
+ totals.bytes -= existing.byteSize;
50
+ } else {
51
+ totals.entries += 1;
52
+ }
53
+ totals.bytes += record.byteSize;
54
+ glyphs.put(record);
55
+ }
56
+ meta.put(totals);
57
+ await transactionDone(transaction);
58
+ return true;
59
+ }
60
+
61
+ async deleteMany(keys: readonly string[]): Promise<void> {
62
+ if (keys.length === 0) return;
63
+ const database = await this.database();
64
+ const transaction = database.transaction([GLYPH_STORE, META_STORE], "readwrite", { durability: "relaxed" });
65
+ const glyphs = transaction.objectStore(GLYPH_STORE);
66
+ const meta = transaction.objectStore(META_STORE);
67
+ const totals = await readTotals(meta);
68
+ for (const key of keys) {
69
+ const existing = await request<PersistentGlyphRecord | undefined>(glyphs.get(key));
70
+ if (!existing) continue;
71
+ glyphs.delete(key);
72
+ totals.entries -= 1;
73
+ totals.bytes -= existing.byteSize;
74
+ }
75
+ meta.put(normalizeTotals(totals));
76
+ await transactionDone(transaction);
77
+ }
78
+
79
+ async stats(): Promise<PersistentStoreStats> {
80
+ const database = await this.database();
81
+ const transaction = database.transaction(META_STORE, "readonly");
82
+ const totals = await readTotals(transaction.objectStore(META_STORE));
83
+ await transactionDone(transaction);
84
+ return { entries: totals.entries, bytes: totals.bytes };
85
+ }
86
+
87
+ async trimLruTo(maxBytes: number, protectedKeys: ReadonlySet<string> = new Set()): Promise<number> {
88
+ const database = await this.database();
89
+ const transaction = database.transaction([GLYPH_STORE, META_STORE], "readwrite", { durability: "relaxed" });
90
+ const glyphs = transaction.objectStore(GLYPH_STORE);
91
+ const meta = transaction.objectStore(META_STORE);
92
+ const totals = await readTotals(meta);
93
+ let evicted = 0;
94
+ if (totals.bytes > maxBytes) {
95
+ const index = glyphs.index("lastAccessDay");
96
+ await iterateCursor(index.openCursor(), (cursor) => {
97
+ if (totals.bytes <= maxBytes) return false;
98
+ const record = cursor.value as PersistentGlyphRecord;
99
+ if (!protectedKeys.has(record.opaqueKey)) {
100
+ cursor.delete();
101
+ totals.entries -= 1;
102
+ totals.bytes -= record.byteSize;
103
+ evicted += 1;
104
+ }
105
+ return true;
106
+ });
107
+ }
108
+ meta.put(normalizeTotals(totals));
109
+ await transactionDone(transaction);
110
+ return evicted;
111
+ }
112
+
113
+ async sweepBefore(lastAccessDayExclusive: number, maxRecords: number): Promise<PersistentSweepResult> {
114
+ const database = await this.database();
115
+ const transaction = database.transaction([GLYPH_STORE, META_STORE], "readwrite", { durability: "relaxed" });
116
+ const glyphs = transaction.objectStore(GLYPH_STORE);
117
+ const meta = transaction.objectStore(META_STORE);
118
+ const totals = await readTotals(meta);
119
+ let scanned = 0;
120
+ let deleted = 0;
121
+ await iterateCursor(glyphs.index("lastAccessDay").openCursor(), (cursor) => {
122
+ if (scanned >= maxRecords) return false;
123
+ scanned += 1;
124
+ const record = cursor.value as PersistentGlyphRecord;
125
+ if (record.lastAccessDay >= lastAccessDayExclusive) return false;
126
+ cursor.delete();
127
+ totals.entries -= 1;
128
+ totals.bytes -= record.byteSize;
129
+ deleted += 1;
130
+ return true;
131
+ });
132
+ meta.put(normalizeTotals(totals));
133
+ await transactionDone(transaction);
134
+ return { scanned, deleted };
135
+ }
136
+
137
+ async clear(clearedAtMs = Date.now()): Promise<void> {
138
+ const database = await this.database();
139
+ const transaction = database.transaction([GLYPH_STORE, META_STORE], "readwrite");
140
+ transaction.objectStore(GLYPH_STORE).clear();
141
+ const meta = transaction.objectStore(META_STORE);
142
+ const totals = await readTotals(meta);
143
+ meta.put({ key: TOTALS_KEY, entries: 0, bytes: 0, lastClearMs: Math.max(totals.lastClearMs, clearedAtMs) } satisfies TotalsRecord);
144
+ await transactionDone(transaction);
145
+ }
146
+
147
+ close(): void {
148
+ void this.databasePromise?.then((database) => database.close());
149
+ this.databasePromise = null;
150
+ }
151
+
152
+ private database(): Promise<IDBDatabase> {
153
+ if (typeof indexedDB === "undefined") return Promise.reject(new Error("IndexedDB is unavailable"));
154
+ this.databasePromise ??= new Promise((resolve, reject) => {
155
+ const open = indexedDB.open(this.databaseName, DATABASE_VERSION);
156
+ open.onupgradeneeded = () => {
157
+ const database = open.result;
158
+ const glyphs = database.createObjectStore(GLYPH_STORE, { keyPath: "opaqueKey" });
159
+ glyphs.createIndex("lastAccessDay", "lastAccessDay");
160
+ database.createObjectStore(META_STORE, { keyPath: "key" });
161
+ };
162
+ open.onerror = () => reject(open.error ?? new Error("open IndexedDB glyph cache failed"));
163
+ open.onblocked = () => reject(new Error("IndexedDB glyph cache upgrade was blocked"));
164
+ open.onsuccess = () => {
165
+ open.result.onversionchange = () => open.result.close();
166
+ resolve(open.result);
167
+ };
168
+ });
169
+ return this.databasePromise;
170
+ }
171
+ }
172
+
173
+ export function createOriginGlyphPersistentCache(options: {
174
+ databaseName?: string;
175
+ softBytes?: number;
176
+ hardBytes?: number;
177
+ ttlDays?: number;
178
+ } = {}): GlyphPersistentCache {
179
+ return new GlyphPersistentCache({
180
+ mode: "origin",
181
+ store: new IndexedDbGlyphRecordStore(options.databaseName),
182
+ softBytes: options.softBytes,
183
+ hardBytes: options.hardBytes,
184
+ ttlDays: options.ttlDays,
185
+ });
186
+ }
187
+
188
+ export const GLYPH_PERSISTENT_CACHE_INFO = Object.freeze({
189
+ databaseName: DATABASE_NAME,
190
+ databaseVersion: DATABASE_VERSION,
191
+ schemaNamespace: GLYPH_CACHE_SCHEMA,
192
+ });
193
+
194
+ function readTotals(store: IDBObjectStore): Promise<TotalsRecord> {
195
+ return request<TotalsRecord | undefined>(store.get(TOTALS_KEY)).then(
196
+ (totals) => totals
197
+ ? { ...totals, lastClearMs: Number.isFinite(totals.lastClearMs) ? totals.lastClearMs : Number.NEGATIVE_INFINITY }
198
+ : { key: TOTALS_KEY, entries: 0, bytes: 0, lastClearMs: Number.NEGATIVE_INFINITY },
199
+ );
200
+ }
201
+
202
+ function normalizeTotals(totals: TotalsRecord): TotalsRecord {
203
+ totals.entries = Math.max(0, totals.entries);
204
+ totals.bytes = Math.max(0, totals.bytes);
205
+ return totals;
206
+ }
207
+
208
+ function request<T>(value: IDBRequest<T>): Promise<T> {
209
+ return new Promise((resolve, reject) => {
210
+ value.onsuccess = () => resolve(value.result);
211
+ value.onerror = () => reject(value.error ?? new Error("IndexedDB request failed"));
212
+ });
213
+ }
214
+
215
+ function transactionDone(transaction: IDBTransaction): Promise<void> {
216
+ return new Promise((resolve, reject) => {
217
+ transaction.oncomplete = () => resolve();
218
+ transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
219
+ transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
220
+ });
221
+ }
222
+
223
+ function iterateCursor(
224
+ cursorRequest: IDBRequest<IDBCursorWithValue | null>,
225
+ visit: (cursor: IDBCursorWithValue) => boolean,
226
+ ): Promise<void> {
227
+ return new Promise((resolve, reject) => {
228
+ cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("IndexedDB cursor failed"));
229
+ cursorRequest.onsuccess = () => {
230
+ const cursor = cursorRequest.result;
231
+ if (!cursor || !visit(cursor)) {
232
+ resolve();
233
+ return;
234
+ }
235
+ cursor.continue();
236
+ };
237
+ });
238
+ }