@replayablejs/assets 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3932 @@
1
+ import { z } from "zod";
2
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
4
+ import { assetBundleNames, assetCategories, groupedAssetCategories, simpleAssetCategories } from "@replayablejs/runtime/assets";
5
+ import { basename as basename$1, dirname as dirname$1, extname as extname$1, join as join$1 } from "node:path/posix";
6
+ import { packAsync } from "free-tex-packer-core";
7
+ import sharp from "sharp";
8
+ import subsetFont from "subset-font";
9
+ import { spawn } from "node:child_process";
10
+ import ffmpeg from "@ffmpeg-installer/ffmpeg";
11
+ import { AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, TextureAtlas } from "@esotericsoftware/spine-core";
12
+ import { glob } from "tinyglobby";
13
+ import { parse, printParseErrorCode } from "jsonc-parser";
14
+ //#region src/config/schemas/base.ts
15
+ /** Trimmed configuration string that must contain at least one character. */
16
+ const requiredStringSchema = z.string().trim().min(1);
17
+ /**
18
+ * Strict source-selection fields shared by every asset rule.
19
+ *
20
+ * Category schemas extend this base when they accept processor options. A
21
+ * category without options can use it directly, causing an authored `options`
22
+ * field to be rejected rather than accepted as unknown data.
23
+ */
24
+ const assetRuleSchema = z.strictObject({
25
+ /**
26
+ * Category-relative source-file glob or grouped-asset directory.
27
+ *
28
+ * File categories such as sprites and sounds match paths below their category
29
+ * directory. Grouped categories match the directory that represents the
30
+ * complete asset, so a Spine export in `spines/raptor` uses `raptor`, not
31
+ * `raptor/**`. Omitted matches default to `**` and select every asset.
32
+ */
33
+ match: requiredStringSchema.default("**"),
34
+ /** Optional glob patterns removed from the matched sources. */
35
+ exclude: z.array(requiredStringSchema).optional()
36
+ });
37
+ //#endregion
38
+ //#region src/config/schemas/image.ts
39
+ /** Image options that allow lossy format selection and encoder quality. */
40
+ const lossyImageOptionsSchema = z.strictObject({
41
+ /** Width multiplier applied before encoding. */
42
+ scale: z.number().positive().default(1),
43
+ /** Selects the default lossy encoding policy. */
44
+ lossless: z.literal(false).default(false),
45
+ /** Shared lossy encoder quality; incompatible with lossless output. */
46
+ quality: z.number().int().min(1).max(100).optional()
47
+ });
48
+ /** Image options that preserve exact pixel values and reject lossy quality. */
49
+ const losslessImageOptionsSchema = z.strictObject({
50
+ /** Width multiplier applied before encoding. */
51
+ scale: z.number().positive().default(1),
52
+ /** Restricts selection to encodings that preserve exact pixel values. */
53
+ lossless: z.literal(true)
54
+ });
55
+ /** Options shared by standalone images and Spine texture pages. */
56
+ const imageOptionsSchema = z.union([lossyImageOptionsSchema, losslessImageOptionsSchema]);
57
+ /** Rule for standalone sprites and 3D textures. */
58
+ const imageRuleSchema = assetRuleSchema.extend({ options: imageOptionsSchema.prefault({}) });
59
+ //#endregion
60
+ //#region src/config/schemas/atlas.ts
61
+ /** Texture-packing controls added to the shared image encoding policy. */
62
+ const atlasPackingOptionFields = {
63
+ /** Removes transparent frame borders while preserving original bounds. */
64
+ allowTrim: z.boolean().default(true),
65
+ /** Allows frame rotation when it produces a tighter sheet. */
66
+ allowRotation: z.boolean().default(true),
67
+ /** Empty pixels placed between packed frames. */
68
+ padding: z.number().int().min(0).default(2),
69
+ /** Repeated edge pixels added around each packed frame. */
70
+ extrude: z.number().int().min(0).default(0),
71
+ /** Restricts generated sheet dimensions to powers of two. */
72
+ powerOfTwo: z.boolean().default(false)
73
+ };
74
+ /** Image encoding and texture-packing options for generated atlas sheets. */
75
+ const atlasOptionsSchema = z.union([lossyImageOptionsSchema.extend(atlasPackingOptionFields), losslessImageOptionsSchema.extend(atlasPackingOptionFields)]);
76
+ /** Rule for directories of images packed into Pixi atlas sheets. */
77
+ const atlasRuleSchema = assetRuleSchema.extend({ options: atlasOptionsSchema.prefault({}) });
78
+ //#endregion
79
+ //#region src/config/schemas/font.ts
80
+ /** Required runtime family name plus optional characters added to the subset. */
81
+ const fontOptionsSchema = z.strictObject({
82
+ /** Runtime CSS font-family name. */
83
+ family: requiredStringSchema,
84
+ /** Literal characters added to printable ASCII and resolved locale text. */
85
+ extraCharacters: z.string().min(1).optional()
86
+ });
87
+ /**
88
+ * Configuration for one matched font source.
89
+ *
90
+ * Every matched source is subset and converted to WOFF2. Output format is not
91
+ * configurable because playable builds ship one compact web-font file.
92
+ */
93
+ const fontRuleSchema = assetRuleSchema.extend({ options: fontOptionsSchema });
94
+ //#endregion
95
+ //#region src/config/schemas/locale.ts
96
+ /** Rule selecting the project's single multilingual translation dictionary. */
97
+ const localeRuleSchema = assetRuleSchema;
98
+ //#endregion
99
+ //#region src/config/schemas/shader.ts
100
+ /**
101
+ * Rule for directories containing paired vertex and fragment shader sources.
102
+ *
103
+ * Shader sources are copied unchanged and do not accept processor options. The
104
+ * resolver only requires both `vert.glsl` and `frag.glsl`; Replayable does not
105
+ * validate GLSL syntax, versions, entry points, or stage compatibility.
106
+ */
107
+ const shaderRuleSchema = assetRuleSchema;
108
+ //#endregion
109
+ //#region src/config/schemas/sound.ts
110
+ /** Encoding settings applied to every generated MP3 and M4A candidate. */
111
+ const soundOptionsSchema = z.strictObject({
112
+ /** Target audio bitrate in kilobits per second. */
113
+ bitrate: z.number().int().min(8).max(512).default(96),
114
+ /** Output channel layout, or the source file's original channel count. */
115
+ channels: z.enum([
116
+ "mono",
117
+ "stereo",
118
+ "source"
119
+ ]).default("mono"),
120
+ /** Output sample rate in hertz. */
121
+ sampleRate: z.number().int().min(8e3).max(192e3).default(32e3)
122
+ });
123
+ /**
124
+ * Rule for producing the smallest MP3 or M4A runtime file.
125
+ * Defaults favor compact playable delivery: 96 kbps, mono, and 32 kHz.
126
+ */
127
+ const soundRuleSchema = assetRuleSchema.extend({ options: soundOptionsSchema.prefault({}) });
128
+ //#endregion
129
+ //#region src/config/schemas/spine.ts
130
+ /** Image processing options applied independently to every Spine texture page. */
131
+ const spineOptionsSchema = imageOptionsSchema;
132
+ /**
133
+ * Rule for directories containing a Spine skeleton, atlas, and texture pages.
134
+ *
135
+ * Image options apply independently to every texture page. Skeleton and atlas
136
+ * files retain their source formats. The configured scale is also emitted as
137
+ * runtime metadata so the application can align skeleton coordinates with
138
+ * resized textures.
139
+ */
140
+ const spineRuleSchema = assetRuleSchema.extend({ options: spineOptionsSchema.prefault({}) });
141
+ //#endregion
142
+ //#region src/config/schema.ts
143
+ /** Complete category rule collection with omitted categories normalized to empty arrays. */
144
+ const assetsSchema = z.strictObject({
145
+ atlases: z.array(atlasRuleSchema).default([]),
146
+ fonts: z.array(fontRuleSchema).default([]),
147
+ locales: z.array(localeRuleSchema).default([]),
148
+ shaders: z.array(shaderRuleSchema).default([]),
149
+ sounds: z.array(soundRuleSchema).default([]),
150
+ spines: z.array(spineRuleSchema).default([]),
151
+ sprites: z.array(imageRuleSchema).default([]),
152
+ textures: z.array(imageRuleSchema).default([])
153
+ });
154
+ /** Fixed build language and the language used for missing translations or assets. */
155
+ const localizationSchema = z.strictObject({
156
+ fallback: requiredStringSchema,
157
+ language: requiredStringSchema
158
+ });
159
+ /** Selects assets moved from the always-present primary bundle into secondary. */
160
+ const secondaryBundleSchema = z.strictObject({
161
+ exclude: z.array(requiredStringSchema).optional(),
162
+ include: z.array(requiredStringSchema).min(1)
163
+ });
164
+ /** Optional secondary selection; every unmatched asset remains in primary. */
165
+ const bundlesSchema = z.strictObject({ secondary: secondaryBundleSchema.optional() }).prefault({});
166
+ /** Generated TypeScript module destinations relative to the project root. */
167
+ const emitSchema = z.strictObject({
168
+ assets: requiredStringSchema.default("src/assets/assets.gen.ts"),
169
+ registries: requiredStringSchema.optional()
170
+ });
171
+ /**
172
+ * Complete runtime schema for `replayable.assets.ts`.
173
+ *
174
+ * `AssetConfigInput` describes author-written configuration where defaulted
175
+ * values may be absent. Parsing produces `AssetConfig`, where defaults are
176
+ * present and strings such as language codes have been trimmed.
177
+ *
178
+ * @remarks Most consumers should call `defineConfig()` instead of invoking
179
+ * this schema directly. The schema is exported for integrations that need Zod
180
+ * parsing, safe parsing, or introspection.
181
+ */
182
+ const assetConfigSchema = z.strictObject({
183
+ sourceDir: requiredStringSchema,
184
+ outDir: requiredStringSchema,
185
+ localization: localizationSchema,
186
+ bundles: bundlesSchema,
187
+ assets: assetsSchema,
188
+ /** Source-relative glob patterns that veto otherwise selected logical assets. */
189
+ exclude: z.array(requiredStringSchema).default([]),
190
+ emit: emitSchema
191
+ });
192
+ //#endregion
193
+ //#region src/emitters/utils/assets-module.ts
194
+ /**
195
+ * Registers one generated file and returns its unique local import identifier.
196
+ *
197
+ * Asset renderers serialize object property lines but do not write import
198
+ * statements. They share one `AssetsModuleContext` and register every file
199
+ * referenced by those lines. If three imports already exist, registering a
200
+ * background image with the prefix `image` performs two related operations:
201
+ *
202
+ * 1. Append this description to `context.imports`:
203
+ *
204
+ * { name: 'image_3', path: '../../assets/out/background.webp' }
205
+ *
206
+ * 2. Return `'image_3'` so the renderer can reference it:
207
+ *
208
+ * "background": { src: image_3, scale: 1 },
209
+ *
210
+ * `emitAssetsModule` serializes all asset entries first, which registers every
211
+ * required import. It then writes the accumulated imports above the generated
212
+ * `assets` object:
213
+ *
214
+ * import image_3 from "../../assets/out/background.webp";
215
+ *
216
+ * Numbering belongs to the complete generated module rather than an individual
217
+ * category. An atlas import followed by an image import can therefore become
218
+ * `atlas_0` and `image_1`. The emitter processes bundles, categories and assets
219
+ * deterministically, so these identifiers remain stable across builds.
220
+ *
221
+ * Every file uses the same ordinary static-import shape. This context does not
222
+ * attach loading instructions based on extensions or asset categories; the
223
+ * playable's bundler owns those decisions. Every call registers a distinct
224
+ * import. This function does not inspect files, emit source code or deduplicate
225
+ * paths; each renderer decides which generated files its runtime entry
226
+ * references.
227
+ *
228
+ * @returns The unique local identifier reserved for the generated import.
229
+ */
230
+ function registerImport(context, prefix, absolutePath) {
231
+ const name = `${prefix}_${context.imports.length}`;
232
+ context.imports.push({
233
+ name,
234
+ path: resolveModulePath(context.fromDirectory, absolutePath)
235
+ });
236
+ return name;
237
+ }
238
+ /** Resolves one generated file relative to the module that references it. */
239
+ function resolveModulePath(fromDirectory, toFile) {
240
+ const path = relative(fromDirectory, toFile).split(sep).join("/");
241
+ return path.startsWith(".") ? path : `./${path}`;
242
+ }
243
+ //#endregion
244
+ //#region src/emitters/utils/object-literal.ts
245
+ /** JSON quoting escapes text; a computed key also avoids JavaScript's prototype setter. */
246
+ function renderPropertyKey(key) {
247
+ const quoted = JSON.stringify(key);
248
+ return key === "__proto__" ? `[${quoted}]` : quoted;
249
+ }
250
+ /**
251
+ * Renders the registry's string/object-only shape as a typed JavaScript literal.
252
+ * JSON is not interchangeable with an object literal: `"__proto__": value`
253
+ * changes prototype semantics when parsed as JavaScript, including nested keys.
254
+ * Strings still use JSON.stringify; only object framing and keys are emitted here.
255
+ */
256
+ function renderRegistryObject(registry, depth = 0) {
257
+ const entries = Object.entries(registry);
258
+ if (entries.length === 0) return "{}";
259
+ const indent = " ".repeat(depth + 1);
260
+ return `{\n${entries.map(([key, value]) => {
261
+ const source = typeof value === "string" ? JSON.stringify(value) : renderRegistryObject(value, depth + 1);
262
+ return `${indent}${renderPropertyKey(key)}: ${source}`;
263
+ }).join(",\n")}\n${" ".repeat(depth)}}`;
264
+ }
265
+ //#endregion
266
+ //#region src/emitters/categories/atlas.ts
267
+ /**
268
+ * Serializes one processed atlas sheet as one TypeScript property line inside
269
+ * the generated `assets` object.
270
+ *
271
+ * Processing has already paired each sheet's JSON frame data with its selected
272
+ * texture image. This renderer therefore receives one complete logical sheet,
273
+ * not independent files that still need to be grouped.
274
+ *
275
+ * For example, a texture packer that splits `ui/menu` into two sheets produces
276
+ * two processed records and invokes this function once for each record:
277
+ *
278
+ * ```ts
279
+ * [
280
+ * {
281
+ * bundle: 'primary',
282
+ * category: 'atlases',
283
+ * id: 'ui/menu-0',
284
+ * files: {
285
+ * json: {
286
+ * format: 'json',
287
+ * path: '/project/assets/generated/atlases/ui/menu-0.json',
288
+ * },
289
+ * image: {
290
+ * format: 'webp',
291
+ * path: '/project/assets/generated/atlases/ui/menu-0.webp',
292
+ * },
293
+ * },
294
+ * },
295
+ * {
296
+ * bundle: 'primary',
297
+ * category: 'atlases',
298
+ * id: 'ui/menu-1',
299
+ * files: {
300
+ * json: {
301
+ * format: 'json',
302
+ * path: '/project/assets/generated/atlases/ui/menu-1.json',
303
+ * },
304
+ * image: {
305
+ * format: 'avif',
306
+ * path: '/project/assets/generated/atlases/ui/menu-1.avif',
307
+ * },
308
+ * },
309
+ * },
310
+ * ]
311
+ * ```
312
+ *
313
+ * With an initially empty module context, the two calls register imports in
314
+ * semantic sheet order:
315
+ *
316
+ * ```ts
317
+ * import atlas_0 from '../../assets/generated/atlases/ui/menu-0.json';
318
+ * import image_1 from '../../assets/generated/atlases/ui/menu-0.webp';
319
+ * import atlas_2 from '../../assets/generated/atlases/ui/menu-1.json';
320
+ * import image_3 from '../../assets/generated/atlases/ui/menu-1.avif';
321
+ * ```
322
+ *
323
+ * The first call returns the first line and the second call returns the second:
324
+ *
325
+ * ```ts
326
+ * ' "ui/menu-0": { json: atlas_0, image: image_1 },'
327
+ * ' "ui/menu-1": { json: atlas_2, image: image_3 },'
328
+ * ```
329
+ *
330
+ * A source atlas that fits on one sheet reaches this renderer once with the
331
+ * unsuffixed base ID `ui/menu`. Packing, sheet naming, image selection, and
332
+ * runtime-ID uniqueness are handled before emission.
333
+ *
334
+ * Both files use ordinary static imports with their generated extensions. The
335
+ * playable's bundler decides how JSON and image modules are loaded.
336
+ *
337
+ * @param asset - One complete processed atlas sheet containing JSON and texture files.
338
+ * @param context - Module-wide state used to register both generated files.
339
+ * @returns One indented TypeScript source line for the atlas sheet.
340
+ */
341
+ function renderAtlasEntry(asset, context) {
342
+ const atlasDataImport = registerImport(context, "atlas", asset.files.json.path);
343
+ const textureImport = registerImport(context, "image", asset.files.image.path);
344
+ return ` ${renderPropertyKey(asset.id)}: { json: ${atlasDataImport}, image: ${textureImport} },`;
345
+ }
346
+ /**
347
+ * Builds the registry value for every processed atlas sheet in the build.
348
+ *
349
+ * Unlike `renderAtlasEntry`, this function does not produce TypeScript source
350
+ * or register file imports. It turns the frame names retained during atlas
351
+ * processing into a plain nested object. `emitRegistries` later serializes that
352
+ * object into the generated `atlases.ts` module.
353
+ *
354
+ * For example, this input contains two sheets from the build:
355
+ *
356
+ * ```ts
357
+ * [
358
+ * {
359
+ * bundle: 'primary',
360
+ * category: 'atlases',
361
+ * id: 'ui/menu-0',
362
+ * files: {
363
+ * json: {
364
+ * format: 'json',
365
+ * path: '/project/assets/generated/atlases/ui/menu-0.json',
366
+ * },
367
+ * image: {
368
+ * format: 'webp',
369
+ * path: '/project/assets/generated/atlases/ui/menu-0.webp',
370
+ * },
371
+ * },
372
+ * frameNames: ['menu-0/buttons/play', 'menu-0/icons/close'],
373
+ * },
374
+ * {
375
+ * bundle: 'primary',
376
+ * category: 'atlases',
377
+ * id: 'ui/menu-1',
378
+ * files: {
379
+ * json: {
380
+ * format: 'json',
381
+ * path: '/project/assets/generated/atlases/ui/menu-1.json',
382
+ * },
383
+ * image: {
384
+ * format: 'avif',
385
+ * path: '/project/assets/generated/atlases/ui/menu-1.avif',
386
+ * },
387
+ * },
388
+ * frameNames: ['menu-1/panel'],
389
+ * },
390
+ * ]
391
+ * ```
392
+ *
393
+ * If `menu-0` retains the frames `menu-0/buttons/play` and
394
+ * `menu-0/icons/close`, while `menu-1` retains `menu-1/panel`, this function
395
+ * returns:
396
+ *
397
+ * ```ts
398
+ * {
399
+ * 'ui/menu-0': {
400
+ * 'buttons/play': 'menu-0/buttons/play',
401
+ * 'icons/close': 'menu-0/icons/close',
402
+ * },
403
+ * 'ui/menu-1': {
404
+ * panel: 'menu-1/panel',
405
+ * },
406
+ * }
407
+ * ```
408
+ *
409
+ * Each outer key is the runtime ID of one generated sheet. Each inner key is
410
+ * the convenient application-facing frame key, while its value remains the
411
+ * exact frame name stored in the generated atlas JSON and expected by the
412
+ * runtime texture-atlas loader.
413
+ *
414
+ * A generated sheet prefix is removed only from the registry key. For example,
415
+ * `menu-0/buttons/play` becomes the key `buttons/play`, but its value remains
416
+ * `menu-0/buttons/play`. A frame without that exact prefix is preserved on both
417
+ * sides. This prevents a generated sheet name from leaking into application
418
+ * code without changing the actual lookup value.
419
+ *
420
+ * Assets arrive in deterministic ID order from the shared emitter grouping
421
+ * step. The processor retained frame names while the packer's JSON was already
422
+ * in memory; this renderer therefore performs no filesystem reads or parsing.
423
+ * Frame names are sorted here before serialization. Bundle membership does not
424
+ * appear in the registry.
425
+ *
426
+ * @param assets - Complete processed atlas sheets across all bundles.
427
+ * @returns A sheet-ID-to-frame-lookup object ready for registry serialization.
428
+ */
429
+ function renderAtlasRegistry(assets) {
430
+ const entries = assets.map((asset) => {
431
+ const frameEntries = [...asset.frameNames].sort((left, right) => left.localeCompare(right)).map((frameName) => [stripSheetPrefix(asset.id, frameName), frameName]);
432
+ return [asset.id, Object.fromEntries(frameEntries)];
433
+ });
434
+ return Object.fromEntries(entries);
435
+ }
436
+ /**
437
+ * Removes one generated sheet-name prefix from an application-facing frame key.
438
+ *
439
+ * For atlas ID `ui/menu-0`, `menu-0/buttons/play` becomes `buttons/play`.
440
+ * `shared/logo` remains `shared/logo` because it does not begin with the exact
441
+ * generated sheet name `menu-0/`.
442
+ */
443
+ function stripSheetPrefix(atlasId, frameName) {
444
+ const sheetPrefix = `${atlasId.split("/").at(-1) ?? atlasId}/`;
445
+ return frameName.startsWith(sheetPrefix) ? frameName.slice(sheetPrefix.length) : frameName;
446
+ }
447
+ //#endregion
448
+ //#region src/emitters/utils/registry.ts
449
+ /** Creates a deterministic `{ value: value }` lookup table. */
450
+ function createIdentityRegistry(values) {
451
+ const uniqueValues = [...new Set(values)].sort((left, right) => left.localeCompare(right));
452
+ return Object.fromEntries(uniqueValues.map((value) => [value, value]));
453
+ }
454
+ //#endregion
455
+ //#region src/emitters/categories/font.ts
456
+ /**
457
+ * Serializes one processed font as one TypeScript property line inside the
458
+ * generated `assets` object.
459
+ *
460
+ * Processing has already subset the source font and emitted its one runtime
461
+ * WOFF2 file. This renderer receives that generated file together with the
462
+ * required CSS family configured for the asset:
463
+ *
464
+ * ```ts
465
+ * {
466
+ * bundle: 'primary',
467
+ * category: 'fonts',
468
+ * id: 'heading',
469
+ * file: {
470
+ * format: 'woff2',
471
+ * path: '/project/assets/generated/fonts/heading.woff2',
472
+ * },
473
+ * runtime: {
474
+ * family: 'Brand Display',
475
+ * },
476
+ * }
477
+ * ```
478
+ *
479
+ * With an initially empty module context, rendering this record registers:
480
+ *
481
+ * ```ts
482
+ * import font_0 from '../../assets/generated/fonts/heading.woff2';
483
+ * ```
484
+ *
485
+ * and returns exactly one property line:
486
+ *
487
+ * ```ts
488
+ * ' "heading": { src: font_0, family: "Brand Display" },'
489
+ * ```
490
+ *
491
+ * The runtime ID `heading` is the key used to retrieve this asset from the
492
+ * generated object. `Brand Display` is the required family name used by CSS;
493
+ * the two values serve different purposes and do not need to match.
494
+ *
495
+ * Charset construction, `extraCharacters`, and font subsetting are build-only
496
+ * concerns completed before emission. They are intentionally absent from the
497
+ * runtime entry. Application CSS owns descriptors such as style, weight,
498
+ * stretch, and display; this renderer does not infer them from filenames or
499
+ * font metadata.
500
+ *
501
+ * @param asset - One complete processed font containing its WOFF2 file and family.
502
+ * @param context - Module-wide state used to register the generated font file.
503
+ * @returns One indented TypeScript source line for the font.
504
+ */
505
+ function renderFontEntry(asset, context) {
506
+ const fontFileImport = registerImport(context, "font", asset.file.path);
507
+ return ` ${renderPropertyKey(asset.id)}: { src: ${fontFileImport}, family: ${JSON.stringify(asset.runtime.family)} },`;
508
+ }
509
+ /**
510
+ * Builds the font-family registry value for all processed fonts in the build.
511
+ *
512
+ * This function does not render TypeScript source or register WOFF2 imports.
513
+ * It extracts the CSS family from each processed font and creates a plain
514
+ * identity lookup. `emitRegistries` later serializes that value into the
515
+ * generated `fonts.ts` module.
516
+ *
517
+ * For example, this input contains two font assets from the build:
518
+ *
519
+ * ```ts
520
+ * [
521
+ * {
522
+ * bundle: 'primary',
523
+ * category: 'fonts',
524
+ * id: 'body',
525
+ * file: {
526
+ * format: 'woff2',
527
+ * path: '/project/assets/generated/fonts/body.woff2',
528
+ * },
529
+ * runtime: {
530
+ * family: 'Replayable Body',
531
+ * },
532
+ * },
533
+ * {
534
+ * bundle: 'primary',
535
+ * category: 'fonts',
536
+ * id: 'heading',
537
+ * file: {
538
+ * format: 'woff2',
539
+ * path: '/project/assets/generated/fonts/heading.woff2',
540
+ * },
541
+ * runtime: {
542
+ * family: 'Brand Display',
543
+ * },
544
+ * },
545
+ * ]
546
+ * ```
547
+ *
548
+ * The function returns:
549
+ *
550
+ * ```ts
551
+ * {
552
+ * 'Brand Display': 'Brand Display',
553
+ * 'Replayable Body': 'Replayable Body',
554
+ * }
555
+ * ```
556
+ *
557
+ * Registry keys intentionally use configured CSS families rather than asset
558
+ * IDs. Application code uses these values in CSS declarations such as
559
+ * `font-family: fonts['Brand Display']`; the build-only IDs `body` and
560
+ * `heading` address entries in the generated `assets` object instead.
561
+ *
562
+ * `createIdentityRegistry` deduplicates repeated families and sorts them
563
+ * alphabetically, so two font assets configured with the same family produce
564
+ * one registry entry and generated output remains deterministic. Font paths,
565
+ * formats, charset information, and CSS descriptors do not belong in this
566
+ * lookup.
567
+ *
568
+ * `emitRegistries` calls this function once with fonts from the complete build;
569
+ * bundle membership is intentionally absent from the returned registry.
570
+ *
571
+ * @param assets - Complete processed fonts across all bundles.
572
+ * @returns A deterministic CSS-family identity lookup ready for serialization.
573
+ */
574
+ function renderFontRegistry(assets) {
575
+ return createIdentityRegistry(assets.map((asset) => asset.runtime.family));
576
+ }
577
+ //#endregion
578
+ //#region src/emitters/categories/image.ts
579
+ /**
580
+ * Serializes one processed sprite or texture as one TypeScript property line
581
+ * inside the generated `assets` object.
582
+ *
583
+ * Processing has already generated every applicable AVIF, WebP, PNG, or JPEG
584
+ * candidate, compared their file sizes, retained the smallest, and deleted the
585
+ * rejected candidates. This renderer receives exactly one selected image:
586
+ *
587
+ * ```ts
588
+ * {
589
+ * bundle: 'primary',
590
+ * category: 'sprites',
591
+ * id: 'logo',
592
+ * file: {
593
+ * format: 'webp',
594
+ * path: '/project/assets/generated/sprites/logo.webp',
595
+ * },
596
+ * runtime: {
597
+ * scale: 0.5,
598
+ * },
599
+ * }
600
+ * ```
601
+ *
602
+ * With an initially empty module context, rendering this record registers:
603
+ *
604
+ * ```ts
605
+ * import image_0 from '../../assets/generated/sprites/logo.webp';
606
+ * ```
607
+ *
608
+ * and returns exactly one property line:
609
+ *
610
+ * ```ts
611
+ * ' "logo": { src: image_0, scale: 0.5 },'
612
+ * ```
613
+ *
614
+ * The selected encoding does not affect the runtime shape: `src` is always one
615
+ * imported value rather than a format map. Sprites and textures use the same
616
+ * entry shape, so their surrounding category block is the only distinction in
617
+ * generated source; `asset.category` is not repeated inside the entry.
618
+ *
619
+ * Localized sprite selection belongs to resolution. A selected source such as
620
+ * `logo.hy.png` therefore reaches this renderer with the stable runtime ID
621
+ * `logo` and no language suffix. `scale` records the resize factor that
622
+ * processing already applied to the generated image.
623
+ *
624
+ * @param asset - One complete processed sprite or texture with its selected image.
625
+ * @param context - Module-wide state used to register the generated image file.
626
+ * @returns One indented TypeScript source line for the image.
627
+ */
628
+ function renderImageEntry(asset, context) {
629
+ const imageFileImport = registerImport(context, "image", asset.file.path);
630
+ return ` ${renderPropertyKey(asset.id)}: { src: ${imageFileImport}, scale: ${asset.runtime.scale} },`;
631
+ }
632
+ /**
633
+ * Builds the runtime-ID registry for one image category across the build.
634
+ *
635
+ * This shared renderer is used for both `sprites` and `textures`, but one call
636
+ * receives only one category. `emitRegistries` passes the complete category
637
+ * bucket across all bundles.
638
+ *
639
+ * For example, one call for the `sprites` category can receive:
640
+ *
641
+ * ```ts
642
+ * [
643
+ * {
644
+ * bundle: 'primary',
645
+ * category: 'sprites',
646
+ * id: 'logo',
647
+ * file: {
648
+ * format: 'webp',
649
+ * path: '/project/assets/generated/sprites/logo.webp',
650
+ * },
651
+ * runtime: {
652
+ * scale: 0.5,
653
+ * },
654
+ * },
655
+ * {
656
+ * bundle: 'primary',
657
+ * category: 'sprites',
658
+ * id: 'ui/play-button',
659
+ * file: {
660
+ * format: 'avif',
661
+ * path: '/project/assets/generated/sprites/ui/play-button.avif',
662
+ * },
663
+ * runtime: {
664
+ * scale: 1,
665
+ * },
666
+ * },
667
+ * ]
668
+ * ```
669
+ *
670
+ * and returns this plain registry value:
671
+ *
672
+ * ```ts
673
+ * {
674
+ * logo: 'logo',
675
+ * 'ui/play-button': 'ui/play-button',
676
+ * }
677
+ * ```
678
+ *
679
+ * Registry keys and values are the same stable runtime IDs used in the
680
+ * generated `assets` object. File paths, selected encodings, and image scales
681
+ * are deliberately excluded because the registry identifies assets; the
682
+ * generated assets module contains their loadable runtime data.
683
+ *
684
+ * Localized sprite resolution has already removed language suffixes. If the
685
+ * Armenian build selected `logo.hy.png`, its processed ID and registry entry
686
+ * are still `logo`. Textures follow the same identity shape, although they are
687
+ * emitted into a separate `textures` registry.
688
+ *
689
+ * `createIdentityRegistry` sorts IDs alphabetically, keeping generated output
690
+ * deterministic. Asset identity has already been validated before emission,
691
+ * so repeated IDs produce the same identity value. Bundle membership does not
692
+ * appear in the registry.
693
+ *
694
+ * @param assets - Processed sprites or textures from one complete category.
695
+ * @returns A deterministic runtime-ID identity lookup ready for serialization.
696
+ */
697
+ function renderImageRegistry(assets) {
698
+ return createIdentityRegistry(assets.map((asset) => asset.id));
699
+ }
700
+ //#endregion
701
+ //#region src/emitters/categories/locale.ts
702
+ /**
703
+ * Serializes one processed locale dictionary as one TypeScript property line
704
+ * inside the generated `assets` object.
705
+ *
706
+ * One input item represents one compiled JSON dictionary. Locale resolution is
707
+ * already complete: each phrase contains either the configured build language
708
+ * or its fallback, never every authored language. For example, a source file
709
+ * named `translations.jsonc` can arrive as:
710
+ *
711
+ * {
712
+ * id: 'translations',
713
+ * file: { format: 'json', path: '/out/locales/translations.json' },
714
+ * }
715
+ *
716
+ * Its generated file contains the final value selected for every phrase:
717
+ *
718
+ * {
719
+ * "play": "Խաղալ",
720
+ * "install": "Install"
721
+ * }
722
+ *
723
+ * The renderer does not inspect or transform that JSON. It registers one
724
+ * ordinary static import and returns its property line:
725
+ *
726
+ * import locale_0 from '../../assets/out/locales/translations.json';
727
+ *
728
+ * ' "translations": locale_0,'
729
+ *
730
+ * Replayable's bundler decides whether that import becomes a parsed inline
731
+ * dictionary or an emitted resource URL. No query suffix, URL constructor, or
732
+ * bundler-specific loading instruction appears in the generated module.
733
+ *
734
+ * Language selection and JSON compilation belong to processing. Runtime-key
735
+ * uniqueness and collection ordering are handled centrally. This renderer owns
736
+ * one JSON import and one source line.
737
+ *
738
+ * @returns One indented TypeScript source line for the locale dictionary.
739
+ */
740
+ function renderLocaleEntry(asset, context) {
741
+ const dictionaryImport = registerImport(context, "locale", asset.file.path);
742
+ return ` ${renderPropertyKey(asset.id)}: ${dictionaryImport},`;
743
+ }
744
+ /**
745
+ * Builds the phrase-ID registry from the generated translation dictionary.
746
+ *
747
+ * This function does not render TypeScript source or register JSON imports. It
748
+ * collects the top-level phrase IDs retained on each processed locale and
749
+ * returns a plain identity lookup. `emitRegistries` later serializes that
750
+ * lookup into the generated `locales.ts` module.
751
+ *
752
+ * For example, this input represents the processed dictionary:
753
+ *
754
+ * ```ts
755
+ * [
756
+ * {
757
+ * bundle: 'primary',
758
+ * category: 'locales',
759
+ * id: 'translations',
760
+ * file: {
761
+ * format: 'json',
762
+ * path: '/project/assets/generated/locales/translations.json',
763
+ * },
764
+ * phraseIds: ['play', 'install'],
765
+ * },
766
+ * ]
767
+ * ```
768
+ *
769
+ * The function combines those phrase IDs and returns:
770
+ *
771
+ * ```ts
772
+ * {
773
+ * install: 'install',
774
+ * play: 'play',
775
+ * }
776
+ * ```
777
+ *
778
+ * Translation values are irrelevant to registry generation: both
779
+ * `{ play: 'Խաղալ' }` and `{ play: 'Play' }` contribute the same phrase ID,
780
+ * `play`. Language selection, fallback resolution, and extraction of these
781
+ * top-level IDs were completed before emission.
782
+ *
783
+ * `createIdentityRegistry` sorts the final keys alphabetically for
784
+ * deterministic output. Bundle membership does not appear in the registry.
785
+ *
786
+ * @param assets - The processed primary translation dictionary, when present.
787
+ * @returns The deterministic phrase-ID identity lookup.
788
+ */
789
+ function renderLocaleRegistry(assets) {
790
+ return createIdentityRegistry(assets.flatMap((asset) => asset.phraseIds));
791
+ }
792
+ //#endregion
793
+ //#region src/emitters/categories/shader.ts
794
+ /**
795
+ * Serializes one processed shader pair as one TypeScript property line inside
796
+ * the generated `assets` object.
797
+ *
798
+ * Resolution has already required the authored `vert.glsl` and `frag.glsl`
799
+ * pair, and processing has copied both files unchanged. This renderer receives
800
+ * one complete logical shader:
801
+ *
802
+ * ```ts
803
+ * {
804
+ * bundle: 'primary',
805
+ * category: 'shaders',
806
+ * id: 'glow',
807
+ * files: {
808
+ * vert: {
809
+ * format: 'glsl',
810
+ * path: '/project/assets/generated/shaders/glow/vert.glsl',
811
+ * },
812
+ * frag: {
813
+ * format: 'glsl',
814
+ * path: '/project/assets/generated/shaders/glow/frag.glsl',
815
+ * },
816
+ * },
817
+ * }
818
+ * ```
819
+ *
820
+ * With an initially empty module context, the vertex stage is registered first
821
+ * and the fragment stage second:
822
+ *
823
+ * ```ts
824
+ * import shader_0 from '../../assets/generated/shaders/glow/vert.glsl';
825
+ * import shader_1 from '../../assets/generated/shaders/glow/frag.glsl';
826
+ * ```
827
+ *
828
+ * The function then returns one property containing the same semantic stage
829
+ * order:
830
+ *
831
+ * ```ts
832
+ * ' "glow": { vert: shader_0, frag: shader_1 },'
833
+ * ```
834
+ *
835
+ * `vert` and `frag` are explicit runtime fields rather than an order-dependent
836
+ * source array. The renderer adds no `?raw`, `?url`, import attribute, or GLSL
837
+ * interpretation. No build stage validates shader syntax or imposes a GLSL
838
+ * version; the playable's bundler and runtime own how the imports are loaded
839
+ * and compiled.
840
+ *
841
+ * @param asset - One complete processed shader containing both generated stages.
842
+ * @param context - Module-wide state used to register the vertex and fragment files.
843
+ * @returns One indented TypeScript source line for the shader pair.
844
+ */
845
+ function renderShaderEntry(asset, context) {
846
+ const vertexShaderImport = registerImport(context, "shader", asset.files.vert.path);
847
+ const fragmentShaderImport = registerImport(context, "shader", asset.files.frag.path);
848
+ return ` ${renderPropertyKey(asset.id)}: { vert: ${vertexShaderImport}, frag: ${fragmentShaderImport} },`;
849
+ }
850
+ /**
851
+ * Builds the logical shader-ID registry for all processed shaders in the build.
852
+ *
853
+ * A processed shader is one runtime asset containing both its vertex and
854
+ * fragment stages. This function therefore registers the shader's logical ID,
855
+ * not separate stage paths. It returns plain registry data; `emitRegistries`
856
+ * later serializes that value into the generated `shaders.ts` module.
857
+ *
858
+ * For example, this input contains two complete shader programs:
859
+ *
860
+ * ```ts
861
+ * [
862
+ * {
863
+ * bundle: 'primary',
864
+ * category: 'shaders',
865
+ * id: 'dissolve',
866
+ * files: {
867
+ * vert: {
868
+ * format: 'glsl',
869
+ * path: '/project/assets/generated/shaders/dissolve/vert.glsl',
870
+ * },
871
+ * frag: {
872
+ * format: 'glsl',
873
+ * path: '/project/assets/generated/shaders/dissolve/frag.glsl',
874
+ * },
875
+ * },
876
+ * },
877
+ * {
878
+ * bundle: 'primary',
879
+ * category: 'shaders',
880
+ * id: 'grayscale',
881
+ * files: {
882
+ * vert: {
883
+ * format: 'glsl',
884
+ * path: '/project/assets/generated/shaders/grayscale/vert.glsl',
885
+ * },
886
+ * frag: {
887
+ * format: 'glsl',
888
+ * path: '/project/assets/generated/shaders/grayscale/frag.glsl',
889
+ * },
890
+ * },
891
+ * },
892
+ * ]
893
+ * ```
894
+ *
895
+ * The function returns:
896
+ *
897
+ * ```ts
898
+ * {
899
+ * dissolve: 'dissolve',
900
+ * grayscale: 'grayscale',
901
+ * }
902
+ * ```
903
+ *
904
+ * No `dissolve/vert` or `dissolve/frag` registry values are generated. Runtime
905
+ * code selects the complete shader through `shaders.dissolve`; the
906
+ * generated `assets` entry then provides its explicit `vert` and `frag`
907
+ * imports. Stage paths, GLSL source, and formats belong to that assets module,
908
+ * not to this identity registry.
909
+ *
910
+ * `createIdentityRegistry` sorts shader IDs alphabetically for deterministic
911
+ * generated output. Shader identity has already been validated before
912
+ * emission, and no generated files need to be read to build this lookup.
913
+ * Bundle membership does not appear in the registry.
914
+ *
915
+ * @param assets - Complete processed shader programs across all bundles.
916
+ * @returns A deterministic shader-ID identity lookup ready for serialization.
917
+ */
918
+ function renderShaderRegistry(assets) {
919
+ return createIdentityRegistry(assets.map((asset) => asset.id));
920
+ }
921
+ //#endregion
922
+ //#region src/emitters/categories/sound.ts
923
+ /**
924
+ * Serializes one processed sound as one TypeScript property line inside the
925
+ * generated `assets` object.
926
+ *
927
+ * Processing has already transcoded the source as MP3 and M4A, measured both
928
+ * generated files, retained the smaller candidate, and deleted the rejected
929
+ * candidate. This renderer therefore receives only the selected runtime file:
930
+ *
931
+ * ```ts
932
+ * {
933
+ * bundle: 'primary',
934
+ * category: 'sounds',
935
+ * id: 'click',
936
+ * file: {
937
+ * format: 'm4a',
938
+ * path: '/project/assets/generated/sounds/click.m4a',
939
+ * },
940
+ * }
941
+ * ```
942
+ *
943
+ * With an initially empty module context, rendering this record registers:
944
+ *
945
+ * ```ts
946
+ * import sound_0 from '../../assets/generated/sounds/click.m4a';
947
+ * ```
948
+ *
949
+ * and returns exactly one direct runtime reference:
950
+ *
951
+ * ```ts
952
+ * ' "click": sound_0,'
953
+ * ```
954
+ *
955
+ * The winning encoding does not change the runtime shape. There is no format
956
+ * map, fallback field, or source array because a playable ships only the one
957
+ * selected file. Bitrate, channels, and sample rate are build-only transcoding
958
+ * controls and are intentionally absent from the runtime entry. The playable's
959
+ * bundler decides how the ordinary static audio import becomes a runtime URL or
960
+ * equivalent value.
961
+ *
962
+ * @param asset - One complete processed sound containing its selected audio file.
963
+ * @param context - Module-wide state used to register the generated sound file.
964
+ * @returns One indented TypeScript source line for the sound.
965
+ */
966
+ function renderSoundEntry(asset, context) {
967
+ const soundFileImport = registerImport(context, "sound", asset.file.path);
968
+ return ` ${renderPropertyKey(asset.id)}: ${soundFileImport},`;
969
+ }
970
+ /**
971
+ * Builds the runtime-ID registry for all processed sounds in the build.
972
+ *
973
+ * This function does not render TypeScript source or register audio imports. It
974
+ * extracts each sound's stable runtime ID and returns a plain identity lookup.
975
+ * `emitRegistries` later serializes that value into the generated `sounds.ts`
976
+ * module.
977
+ *
978
+ * For example, this input contains two sounds whose automatic encoding
979
+ * selection produced different winning formats:
980
+ *
981
+ * ```ts
982
+ * [
983
+ * {
984
+ * bundle: 'primary',
985
+ * category: 'sounds',
986
+ * id: 'click',
987
+ * file: {
988
+ * format: 'm4a',
989
+ * path: '/project/assets/generated/sounds/click.m4a',
990
+ * },
991
+ * },
992
+ * {
993
+ * bundle: 'primary',
994
+ * category: 'sounds',
995
+ * id: 'music/theme',
996
+ * file: {
997
+ * format: 'mp3',
998
+ * path: '/project/assets/generated/sounds/music/theme.mp3',
999
+ * },
1000
+ * },
1001
+ * ]
1002
+ * ```
1003
+ *
1004
+ * The function returns:
1005
+ *
1006
+ * ```ts
1007
+ * {
1008
+ * click: 'click',
1009
+ * 'music/theme': 'music/theme',
1010
+ * }
1011
+ * ```
1012
+ *
1013
+ * Registry identity is independent of the selected MP3 or M4A encoding. Code
1014
+ * selects the sound through a stable value such as
1015
+ * `sounds['music/theme']`; the generated `assets` module owns the
1016
+ * corresponding imported file. Paths, formats, and transcoding options are
1017
+ * therefore intentionally absent from this registry.
1018
+ *
1019
+ * `createIdentityRegistry` sorts sound IDs alphabetically for deterministic
1020
+ * generated output. Sound identity has already been validated before emission,
1021
+ * and no generated audio files need to be read here. Bundle membership does
1022
+ * not appear in the registry.
1023
+ *
1024
+ * @param assets - Complete processed sounds across all bundles.
1025
+ * @returns A deterministic sound-ID identity lookup ready for serialization.
1026
+ */
1027
+ function renderSoundRegistry(assets) {
1028
+ return createIdentityRegistry(assets.map((asset) => asset.id));
1029
+ }
1030
+ //#endregion
1031
+ //#region src/emitters/categories/spine.ts
1032
+ /**
1033
+ * Serializes one processed Spine asset as one TypeScript property line inside
1034
+ * the generated `assets` object.
1035
+ *
1036
+ * Resolution and processing have already assembled every physical file into
1037
+ * one complete logical Spine asset. A two-page JSON Spine named `hero` arrives
1038
+ * as one processed record:
1039
+ *
1040
+ * ```ts
1041
+ * {
1042
+ * bundle: 'primary',
1043
+ * category: 'spines',
1044
+ * id: 'hero',
1045
+ * files: {
1046
+ * skeleton: {
1047
+ * format: 'json',
1048
+ * path: '/project/assets/generated/spines/hero/skeleton.json',
1049
+ * },
1050
+ * atlas: {
1051
+ * format: 'atlas',
1052
+ * path: '/project/assets/generated/spines/hero/atlas.atlas',
1053
+ * },
1054
+ * images: [
1055
+ * {
1056
+ * format: 'webp',
1057
+ * path: '/project/assets/generated/spines/hero/images/page0.webp',
1058
+ * },
1059
+ * {
1060
+ * format: 'avif',
1061
+ * path: '/project/assets/generated/spines/hero/images/page1.avif',
1062
+ * },
1063
+ * ],
1064
+ * },
1065
+ * metadata: {
1066
+ * animationNames: ['idle', 'run'],
1067
+ * skinNames: ['default', 'armored'],
1068
+ * },
1069
+ * runtime: { scale: 0.5 },
1070
+ * }
1071
+ * ```
1072
+ *
1073
+ * With an initially empty module context, rendering registers the skeleton,
1074
+ * atlas description, and texture pages in semantic order:
1075
+ *
1076
+ * ```ts
1077
+ * import spine_0 from '../../assets/generated/spines/hero/skeleton.json';
1078
+ * import spine_1 from '../../assets/generated/spines/hero/atlas.atlas';
1079
+ * import image_2 from '../../assets/generated/spines/hero/images/page0.webp';
1080
+ * import image_3 from '../../assets/generated/spines/hero/images/page1.avif';
1081
+ * ```
1082
+ *
1083
+ * The function then returns exactly one property line for the complete logical
1084
+ * asset:
1085
+ *
1086
+ * ```ts
1087
+ * ' "hero": { format: "json", skel: spine_0, atlas: spine_1, images: [image_2, image_3], scale: 0.5 },'
1088
+ * ```
1089
+ *
1090
+ * Each texture page has independently retained its smallest image encoding, so
1091
+ * page 0 can be WebP while page 1 is AVIF. Processing stores the selected files
1092
+ * in the order declared by the Spine atlas. Array mapping preserves that order
1093
+ * during import registration and in the generated runtime `images` array.
1094
+ *
1095
+ * The runtime field `skel` references either a JSON skeleton, as above, or a
1096
+ * binary `.skel` file. Every physical file retains its generated extension and
1097
+ * uses an ordinary static import without attributes or query suffixes. The
1098
+ * playable's bundler decides how each extension is loaded.
1099
+ *
1100
+ * `scale` comes from the shared image options used to resize texture pages. It
1101
+ * remains in the runtime entry so a rendering integration can restore each
1102
+ * texture page's authored logical dimensions. Quality and lossless controls
1103
+ * are build-only and are not emitted.
1104
+ *
1105
+ * Source completeness belongs to resolution. Processing keeps the required
1106
+ * files together, so this renderer only registers imports and serializes one
1107
+ * complete runtime entry.
1108
+ *
1109
+ * @param asset - One complete processed Spine asset with ordered texture pages.
1110
+ * @param context - Module-wide state used to register every generated Spine file.
1111
+ * @returns One indented TypeScript source line for the Spine asset.
1112
+ */
1113
+ function renderSpineEntry(asset, context) {
1114
+ const skeletonFileImport = registerImport(context, "spine", asset.files.skeleton.path);
1115
+ const atlasFileImport = registerImport(context, "spine", asset.files.atlas.path);
1116
+ const texturePageImports = asset.files.images.map((image) => registerImport(context, "image", image.path));
1117
+ return ` ${renderPropertyKey(asset.id)}: { format: ${JSON.stringify(asset.files.skeleton.format)}, skel: ${skeletonFileImport}, atlas: ${atlasFileImport}, images: [${texturePageImports.join(", ")}], scale: ${asset.runtime.scale} },`;
1118
+ }
1119
+ /**
1120
+ * Builds skeleton, animation, and skin registry values for all Spine assets.
1121
+ *
1122
+ * Unlike `renderSpineEntry`, this function does not register file imports or
1123
+ * produce TypeScript source. It converts animation and skin names retained
1124
+ * during processing into a plain nested object. `emitRegistries` later
1125
+ * serializes that value into the generated `spines.ts` module.
1126
+ *
1127
+ * For example, this processed Spine asset can reach the function inside a
1128
+ * one-item array:
1129
+ *
1130
+ * ```ts
1131
+ * [
1132
+ * {
1133
+ * bundle: 'primary',
1134
+ * category: 'spines',
1135
+ * id: 'hero',
1136
+ * files: {
1137
+ * skeleton: {
1138
+ * format: 'json',
1139
+ * path: '/project/assets/generated/spines/hero/skeleton.json',
1140
+ * },
1141
+ * atlas: {
1142
+ * format: 'atlas',
1143
+ * path: '/project/assets/generated/spines/hero/atlas.atlas',
1144
+ * },
1145
+ * images: [
1146
+ * {
1147
+ * format: 'webp',
1148
+ * path: '/project/assets/generated/spines/hero/images/page0.webp',
1149
+ * },
1150
+ * {
1151
+ * format: 'avif',
1152
+ * path: '/project/assets/generated/spines/hero/images/page1.avif',
1153
+ * },
1154
+ * ],
1155
+ * },
1156
+ * metadata: {
1157
+ * animationNames: ['idle', 'run'],
1158
+ * skinNames: ['default', 'armored'],
1159
+ * },
1160
+ * runtime: {
1161
+ * scale: 0.5,
1162
+ * },
1163
+ * },
1164
+ * ]
1165
+ * ```
1166
+ *
1167
+ * If the generated skeleton declares the animations `idle` and `run` and the
1168
+ * skins `default` and `armored`, the function returns:
1169
+ *
1170
+ * ```ts
1171
+ * {
1172
+ * hero: {
1173
+ * skeleton: 'hero',
1174
+ * animations: {
1175
+ * idle: 'idle',
1176
+ * run: 'run',
1177
+ * },
1178
+ * skins: {
1179
+ * armored: 'armored',
1180
+ * default: 'default',
1181
+ * },
1182
+ * },
1183
+ * }
1184
+ * ```
1185
+ *
1186
+ * The outer `hero` key groups metadata for one logical Spine asset.
1187
+ * `skeleton` repeats that stable asset ID as a directly consumable registry
1188
+ * value, while `animations` and `skins` provide typed identity lookups for the
1189
+ * names authored inside the skeleton.
1190
+ *
1191
+ * Metadata extraction already supported either JSON or binary SKEL through the
1192
+ * official Spine runtime during processing. Texture page files, selected image
1193
+ * encodings, and runtime scale are not registry metadata; they remain available
1194
+ * through the generated `assets` entry.
1195
+ *
1196
+ * Assets arrive in deterministic ID order from the shared emitter grouping
1197
+ * step. Each animation and skin collection is converted through
1198
+ * `createIdentityRegistry`, which keeps its keys unique and alphabetically
1199
+ * ordered. Bundle membership does not appear in the registry.
1200
+ *
1201
+ * @param assets - Complete processed Spine assets across all bundles.
1202
+ * @returns The deterministic Spine metadata registry.
1203
+ */
1204
+ function renderSpineRegistry(assets) {
1205
+ const entries = assets.map((asset) => [asset.id, {
1206
+ skeleton: asset.id,
1207
+ animations: createIdentityRegistry(asset.metadata.animationNames),
1208
+ skins: createIdentityRegistry(asset.metadata.skinNames)
1209
+ }]);
1210
+ return Object.fromEntries(entries);
1211
+ }
1212
+ //#endregion
1213
+ //#region src/emitters/utils/generated-module.ts
1214
+ const GENERATED_MODULE_HEADER = [
1215
+ "// AUTO-GENERATED FILE - DO NOT EDIT",
1216
+ "// Generated by @replayablejs/assets",
1217
+ ""
1218
+ ];
1219
+ /**
1220
+ * Wraps generated TypeScript body lines in the shared module framing.
1221
+ *
1222
+ * Every generated module receives the same warning header, one blank line
1223
+ * before its body, and exactly one final newline. Callers provide only their
1224
+ * module-specific lines and do not need to know how generated files are
1225
+ * framed.
1226
+ *
1227
+ * @example
1228
+ *
1229
+ * ```ts
1230
+ * renderGeneratedModule(['export const sounds = {} as const;']);
1231
+ * ```
1232
+ *
1233
+ * returns:
1234
+ *
1235
+ * ```ts
1236
+ * // AUTO-GENERATED FILE - DO NOT EDIT
1237
+ * // Generated by @replayablejs/assets
1238
+ *
1239
+ * export const sounds = {} as const;
1240
+ * ```
1241
+ *
1242
+ * @param bodyLines - Complete source lines belonging after the shared header.
1243
+ * @returns A complete generated TypeScript module with one final newline.
1244
+ */
1245
+ function renderGeneratedModule(bodyLines) {
1246
+ return [
1247
+ ...GENERATED_MODULE_HEADER,
1248
+ ...bodyLines,
1249
+ ""
1250
+ ].join("\n");
1251
+ }
1252
+ //#endregion
1253
+ //#region src/emitters/emit-assets-module.ts
1254
+ /**
1255
+ * Generates the TypeScript module consumed by a playable at runtime.
1256
+ *
1257
+ * Processed assets already represent complete runtime values. This emitter
1258
+ * renders those values into an in-memory module and then performs one
1259
+ * filesystem write. Category renderers never write files themselves.
1260
+ *
1261
+ * Every example in this file follows the same build: bundle `primary` contains
1262
+ * the sound `click` and the two-page Spine asset `hero`; bundle `secondary`
1263
+ * contains the sprite `logo`.
1264
+ *
1265
+ * @example
1266
+ *
1267
+ * ```ts
1268
+ * const assetGroups = groupAssets([
1269
+ * {
1270
+ * bundle: 'primary',
1271
+ * category: 'sounds',
1272
+ * id: 'click',
1273
+ * file: {
1274
+ * format: 'm4a',
1275
+ * path: '/project/assets/generated/sounds/click.m4a',
1276
+ * },
1277
+ * },
1278
+ * {
1279
+ * bundle: 'primary',
1280
+ * category: 'spines',
1281
+ * id: 'hero',
1282
+ * files: {
1283
+ * skeleton: {
1284
+ * format: 'json',
1285
+ * path: '/project/assets/generated/spines/hero/skeleton.json',
1286
+ * },
1287
+ * atlas: {
1288
+ * format: 'atlas',
1289
+ * path: '/project/assets/generated/spines/hero/atlas.atlas',
1290
+ * },
1291
+ * images: [
1292
+ * {
1293
+ * format: 'webp',
1294
+ * path: '/project/assets/generated/spines/hero/images/page0.webp',
1295
+ * },
1296
+ * {
1297
+ * format: 'avif',
1298
+ * path: '/project/assets/generated/spines/hero/images/page1.avif',
1299
+ * },
1300
+ * ],
1301
+ * },
1302
+ * runtime: { scale: 0.5 },
1303
+ * },
1304
+ * {
1305
+ * bundle: 'secondary',
1306
+ * category: 'sprites',
1307
+ * id: 'logo',
1308
+ * file: {
1309
+ * format: 'webp',
1310
+ * path: '/project/assets/generated/sprites/logo.webp',
1311
+ * },
1312
+ * runtime: { scale: 1 },
1313
+ * },
1314
+ * ]);
1315
+ *
1316
+ * await emitAssetsModule('/project/src/assets/assets.gen.ts', assetGroups);
1317
+ * ```
1318
+ *
1319
+ * The call writes `/project/src/assets/assets.gen.ts` with the complete source
1320
+ * shown in the `renderAssetsModule` example below.
1321
+ */
1322
+ async function emitAssetsModule(file, assetGroups) {
1323
+ await writeAssetsModule(file, renderAssetsModule(assetGroups, dirname(file)));
1324
+ }
1325
+ /**
1326
+ * Renders every processed asset into the complete generated TypeScript module.
1327
+ *
1328
+ * `assetGroups` contains the canonical bundle, category, and asset hierarchy
1329
+ * prepared for all emitters. `fromDirectory` is the directory that will contain
1330
+ * the generated module; import registration uses it to turn absolute generated-
1331
+ * file paths into relative module specifiers.
1332
+ *
1333
+ * The function traverses every prepared bundle and renders its nested category
1334
+ * and asset entries. Rendering an entry registers its physical files in a
1335
+ * local `AssetsModuleContext`, so bundle lines must be rendered before import
1336
+ * statements can be assembled. The context never escapes this function.
1337
+ *
1338
+ * Given the three canonical processed records above and
1339
+ * `fromDirectory = '/project/src/assets'`, the function returns this complete
1340
+ * string. Bundle order, category order, asset order, and module-wide import
1341
+ * numbering are all visible in the result:
1342
+ *
1343
+ * ```ts
1344
+ * // AUTO-GENERATED FILE - DO NOT EDIT
1345
+ * // Generated by @replayablejs/assets
1346
+ *
1347
+ * import type { Assets } from '@replayablejs/assets';
1348
+ * import sound_0 from '../../assets/generated/sounds/click.m4a';
1349
+ * import spine_1 from '../../assets/generated/spines/hero/skeleton.json';
1350
+ * import spine_2 from '../../assets/generated/spines/hero/atlas.atlas';
1351
+ * import image_3 from '../../assets/generated/spines/hero/images/page0.webp';
1352
+ * import image_4 from '../../assets/generated/spines/hero/images/page1.avif';
1353
+ * import image_5 from '../../assets/generated/sprites/logo.webp';
1354
+ *
1355
+ * export const assets = {
1356
+ * "primary": {
1357
+ * sounds: {
1358
+ * "click": sound_0,
1359
+ * },
1360
+ * spines: {
1361
+ * "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },
1362
+ * },
1363
+ * },
1364
+ * "secondary": {
1365
+ * sprites: {
1366
+ * "logo": { src: image_5, scale: 1 },
1367
+ * },
1368
+ * },
1369
+ * } satisfies Assets;
1370
+ * ```
1371
+ *
1372
+ * This function only returns source text. It does not create directories or
1373
+ * write the generated module.
1374
+ *
1375
+ * @param assetGroups - Processed assets organized once for all emitters.
1376
+ * @param fromDirectory - Absolute directory containing the generated module.
1377
+ * @returns The complete TypeScript module, including imports and final newline.
1378
+ */
1379
+ function renderAssetsModule(assetGroups, fromDirectory) {
1380
+ const imports = [];
1381
+ const context = {
1382
+ fromDirectory,
1383
+ imports
1384
+ };
1385
+ const bundleLines = assetGroups.bundles.flatMap((bundle) => renderBundle(bundle, context));
1386
+ return renderGeneratedModule([
1387
+ "import type { Assets } from '@replayablejs/assets';",
1388
+ ...imports.map((generatedImport) => `import ${generatedImport.name} from ${JSON.stringify(generatedImport.path)};`),
1389
+ "",
1390
+ "export const assets = {",
1391
+ ...bundleLines,
1392
+ "} satisfies Assets;"
1393
+ ]);
1394
+ }
1395
+ /**
1396
+ * Renders one bundle object containing each of its non-empty categories.
1397
+ *
1398
+ * The caller supplies one bundle whose assets are already separated by category
1399
+ * and sorted by ID. This function visits categories in the canonical
1400
+ * `assetCategories` order and delegates each non-empty block to
1401
+ * `renderCategory`. Entry rendering may register imports in `context`, but this
1402
+ * function returns only the lines belonging inside the top-level `assets`
1403
+ * object.
1404
+ *
1405
+ * In the canonical example, calling this function for `primary` with the
1406
+ * `click` and `hero` records returns the complete bundle block below. Because
1407
+ * the shared context is initially empty, rendering the block also registers
1408
+ * `sound_0`, `spine_1`, `spine_2`, `image_3`, and `image_4` in that order:
1409
+ *
1410
+ * ```ts
1411
+ * [
1412
+ * ' "primary": {',
1413
+ * ' sounds: {',
1414
+ * ' "click": sound_0,',
1415
+ * ' },',
1416
+ * ' spines: {',
1417
+ * ' "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },',
1418
+ * ' },',
1419
+ * ' },',
1420
+ * ]
1421
+ * ```
1422
+ *
1423
+ * @param bundle - Organized runtime bundle to render.
1424
+ * @param context - Module-wide state that receives imports from rendered entries.
1425
+ * @returns Source lines for one complete bundle object property.
1426
+ */
1427
+ function renderBundle(bundle, context) {
1428
+ const categoryLines = assetCategories.flatMap((category) => renderCategory(category, bundle.categories[category], context));
1429
+ return [
1430
+ ` ${JSON.stringify(bundle.name)}: {`,
1431
+ ...categoryLines,
1432
+ " },"
1433
+ ];
1434
+ }
1435
+ /**
1436
+ * Renders one category block from the matching assets in a bundle.
1437
+ *
1438
+ * `categoryAssets` contains only the requested category and is already sorted
1439
+ * by runtime ID. This function asks `renderAssetEntry` for one property line per
1440
+ * record and wraps those lines in the category object.
1441
+ *
1442
+ * In the canonical example, calling the function with `category = 'sounds'`
1443
+ * and the prepared `[click]` group registers its M4A file as `sound_0` and
1444
+ * returns:
1445
+ *
1446
+ * ```ts
1447
+ * [
1448
+ * ' sounds: {',
1449
+ * ' "click": sound_0,',
1450
+ * ' },',
1451
+ * ]
1452
+ * ```
1453
+ *
1454
+ * A bundle without assets in the requested category returns `[]`, so no empty
1455
+ * category object is written to the generated module.
1456
+ *
1457
+ * @param category - Category to select and use as the generated object key.
1458
+ * @param categoryAssets - Prepared assets belonging to this exact category.
1459
+ * @param context - Module-wide state that receives imports from rendered entries.
1460
+ * @returns A complete category block, or an empty array when the category is absent.
1461
+ */
1462
+ function renderCategory(category, categoryAssets, context) {
1463
+ if (categoryAssets.length === 0) return [];
1464
+ const entryLines = categoryAssets.map((asset) => renderAssetEntry(asset, context));
1465
+ return [
1466
+ ` ${category}: {`,
1467
+ ...entryLines,
1468
+ " },"
1469
+ ];
1470
+ }
1471
+ /**
1472
+ * Renders one processed asset as one property inside its category object.
1473
+ *
1474
+ * `ProcessedAsset` is a discriminated union. Switching on `asset.category`
1475
+ * narrows the record to the exact type required by its specialized renderer.
1476
+ * Those renderers register all physical files owned by the asset in `context`
1477
+ * and return one already-indented TypeScript property line.
1478
+ *
1479
+ * In the canonical example, the first call receives the complete `click`
1480
+ * record, including its bundle, runtime ID, and selected audio file:
1481
+ *
1482
+ * ```ts
1483
+ * {
1484
+ * bundle: 'primary',
1485
+ * category: 'sounds',
1486
+ * id: 'click',
1487
+ * file: {
1488
+ * format: 'm4a',
1489
+ * path: '/project/assets/generated/sounds/click.m4a',
1490
+ * },
1491
+ * }
1492
+ * ```
1493
+ *
1494
+ * Rendering it registers the selected M4A file as `sound_0` and returns:
1495
+ *
1496
+ * ```ts
1497
+ * ' "click": sound_0,'
1498
+ * ```
1499
+ *
1500
+ * The next call receives the complete two-page `hero` record from the same
1501
+ * `primary` bundle:
1502
+ *
1503
+ * ```ts
1504
+ * {
1505
+ * bundle: 'primary',
1506
+ * category: 'spines',
1507
+ * id: 'hero',
1508
+ * files: {
1509
+ * skeleton: {
1510
+ * format: 'json',
1511
+ * path: '/project/assets/generated/spines/hero/skeleton.json',
1512
+ * },
1513
+ * atlas: {
1514
+ * format: 'atlas',
1515
+ * path: '/project/assets/generated/spines/hero/atlas.atlas',
1516
+ * },
1517
+ * images: [
1518
+ * {
1519
+ * format: 'webp',
1520
+ * path: '/project/assets/generated/spines/hero/images/page0.webp',
1521
+ * },
1522
+ * {
1523
+ * format: 'avif',
1524
+ * path: '/project/assets/generated/spines/hero/images/page1.avif',
1525
+ * },
1526
+ * ],
1527
+ * },
1528
+ * runtime: { scale: 0.5 },
1529
+ * }
1530
+ * ```
1531
+ *
1532
+ * Because `click` already registered `sound_0`, rendering `hero` registers
1533
+ * `spine_1`, `spine_2`, `image_3`, and `image_4`, then returns exactly one
1534
+ * property line:
1535
+ *
1536
+ * ```ts
1537
+ * ' "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },'
1538
+ * ```
1539
+ *
1540
+ * The renderer therefore returns one line per logical asset, not one line per
1541
+ * generated file.
1542
+ *
1543
+ * @param asset - One complete logical runtime asset from processing.
1544
+ * @param context - Module-wide state used to register the asset's files.
1545
+ * @returns One indented TypeScript property line for the asset.
1546
+ */
1547
+ function renderAssetEntry(asset, context) {
1548
+ switch (asset.category) {
1549
+ case "atlases": return renderAtlasEntry(asset, context);
1550
+ case "fonts": return renderFontEntry(asset, context);
1551
+ case "locales": return renderLocaleEntry(asset, context);
1552
+ case "shaders": return renderShaderEntry(asset, context);
1553
+ case "sounds": return renderSoundEntry(asset, context);
1554
+ case "spines": return renderSpineEntry(asset, context);
1555
+ case "sprites":
1556
+ case "textures": return renderImageEntry(asset, context);
1557
+ default: return asset;
1558
+ }
1559
+ }
1560
+ /**
1561
+ * Writes one fully rendered assets module to its final destination.
1562
+ *
1563
+ * In the canonical example, `file` is
1564
+ * `/project/src/assets/assets.gen.ts` and `source` is the complete module string
1565
+ * returned by `renderAssetsModule`. The function creates
1566
+ * `/project/src/assets` when necessary and writes that string unchanged to
1567
+ * `assets.gen.ts`. It performs no rendering, formatting, or additional import
1568
+ * discovery and resolves without a return value after the write completes.
1569
+ *
1570
+ * @param file - Absolute destination of the generated TypeScript module.
1571
+ * @param source - Complete module source returned by `renderAssetsModule`.
1572
+ * @returns A promise that resolves after the module has been written.
1573
+ */
1574
+ async function writeAssetsModule(file, source) {
1575
+ await mkdir(dirname(file), { recursive: true });
1576
+ await writeFile(file, source);
1577
+ }
1578
+ //#endregion
1579
+ //#region src/emitters/emit-registries.ts
1580
+ /**
1581
+ * Emits one bundle-independent registry module per supported category and one index.
1582
+ *
1583
+ * Rendering first produces every complete module in memory. Writing then owns
1584
+ * directory creation and filesystem output. Keeping those phases separate
1585
+ * prevents partial rendering logic from being mixed with filesystem work.
1586
+ *
1587
+ * Every build with registry emission enabled creates the complete registry set:
1588
+ *
1589
+ * ```text
1590
+ * registries/
1591
+ * atlases.ts
1592
+ * fonts.ts
1593
+ * locales.ts
1594
+ * shaders.ts
1595
+ * sounds.ts
1596
+ * spines.ts
1597
+ * sprites.ts
1598
+ * textures.ts
1599
+ * index.ts
1600
+ * ```
1601
+ *
1602
+ * Empty categories still receive an empty module, keeping generated imports
1603
+ * stable. The canonical `assetCategories` order determines file creation and
1604
+ * index exports.
1605
+ *
1606
+ * @param directory - Absolute directory that will contain generated registries.
1607
+ * @param assetGroups - Processed assets organized once for all emitters.
1608
+ */
1609
+ async function emitRegistries(directory, assetGroups) {
1610
+ await writeRegistryModules(directory, renderRegistryModules(assetGroups));
1611
+ }
1612
+ /**
1613
+ * Renders every category registry and the re-exporting index in memory.
1614
+ *
1615
+ * Every supported category reads its complete bundle-independent bucket and
1616
+ * produces one module, including categories with no assets. The index is added
1617
+ * last so the returned array mirrors the intended write order.
1618
+ *
1619
+ * For a build containing only the sound `click`, the returned array still
1620
+ * contains one module for every supported category plus `index.ts`. Its sound
1621
+ * item is:
1622
+ *
1623
+ * ```ts
1624
+ * {
1625
+ * filename: 'sounds.ts',
1626
+ * source: `// AUTO-GENERATED FILE - DO NOT EDIT
1627
+ * // Generated by @replayablejs/assets
1628
+ *
1629
+ * export const sounds = {
1630
+ * "click": "click"
1631
+ * } as const;
1632
+ * `,
1633
+ * }
1634
+ * ```
1635
+ *
1636
+ * @param assetGroups - Processed assets organized once for all emitters.
1637
+ * @returns Complete registry modules ready to be written without further rendering.
1638
+ */
1639
+ function renderRegistryModules(assetGroups) {
1640
+ const categoryRegistryModules = assetCategories.map((category) => renderRegistryModule(category, assetGroups.categories));
1641
+ const registryIndexModule = {
1642
+ filename: "index.ts",
1643
+ source: renderRegistryIndex()
1644
+ };
1645
+ return [...categoryRegistryModules, registryIndexModule];
1646
+ }
1647
+ /**
1648
+ * Writes already-rendered registry modules into one generated directory.
1649
+ *
1650
+ * This function performs no grouping, category dispatch, or serialization. It
1651
+ * creates the destination directory and writes modules in the order supplied
1652
+ * by `renderRegistryModules`.
1653
+ *
1654
+ * @param directory - Absolute directory that will contain generated registries.
1655
+ * @param modules - Complete filenames and sources produced during rendering.
1656
+ */
1657
+ async function writeRegistryModules(directory, modules) {
1658
+ await mkdir(directory, { recursive: true });
1659
+ for (const module of modules) await writeFile(resolve(directory, module.filename), module.source);
1660
+ }
1661
+ /**
1662
+ * Renders one complete bundle-independent registry module for a category.
1663
+ *
1664
+ * Category buckets are already combined and ordered by the pipeline. This
1665
+ * function delegates the category-specific data shape to
1666
+ * `renderCategoryRegistry`, then owns the generated TypeScript module around
1667
+ * that value.
1668
+ *
1669
+ * For category `sounds` and rendered registry data
1670
+ * `{ click: 'click' }`, the complete return value is:
1671
+ *
1672
+ * ```ts
1673
+ * {
1674
+ * filename: 'sounds.ts',
1675
+ * source: `// AUTO-GENERATED FILE - DO NOT EDIT
1676
+ * // Generated by @replayablejs/assets
1677
+ *
1678
+ * export const sounds = {
1679
+ * "click": "click"
1680
+ * } as const;
1681
+ * `,
1682
+ * }
1683
+ * ```
1684
+ *
1685
+ * @param category - Category used for bucket selection and the exported name.
1686
+ * @param assets - All processed assets grouped by category across the build.
1687
+ * @returns One complete generated category registry module.
1688
+ */
1689
+ function renderRegistryModule(category, assets) {
1690
+ const source = renderGeneratedModule([`export const ${category} = ${renderRegistryObject(renderCategoryRegistry(category, assets))} as const;`]);
1691
+ return {
1692
+ filename: `${category}.ts`,
1693
+ source
1694
+ };
1695
+ }
1696
+ /**
1697
+ * Selects the concrete registry renderer for one typed category bucket.
1698
+ *
1699
+ * `AssetsByCategory` preserves the relationship between every category name and
1700
+ * its processed asset type. The switch therefore passes atlas assets to the
1701
+ * atlas renderer, font assets to the font renderer, and so on without filtering
1702
+ * or type assertions. Sprites and textures intentionally share the image
1703
+ * registry representation.
1704
+ *
1705
+ * @param category - Category whose registry should be rendered.
1706
+ * @param assets - All typed category buckets across the complete build.
1707
+ * @returns The rendered registry for the populated category bucket.
1708
+ */
1709
+ function renderCategoryRegistry(category, assets) {
1710
+ switch (category) {
1711
+ case "atlases": return renderAtlasRegistry(assets.atlases);
1712
+ case "fonts": return renderFontRegistry(assets.fonts);
1713
+ case "locales": return renderLocaleRegistry(assets.locales);
1714
+ case "shaders": return renderShaderRegistry(assets.shaders);
1715
+ case "sounds": return renderSoundRegistry(assets.sounds);
1716
+ case "spines": return renderSpineRegistry(assets.spines);
1717
+ case "sprites": return renderImageRegistry(assets.sprites);
1718
+ case "textures": return renderImageRegistry(assets.textures);
1719
+ default: return category;
1720
+ }
1721
+ }
1722
+ /**
1723
+ * Serializes the index that re-exports every generated category registry.
1724
+ *
1725
+ * Given the canonical asset categories, the returned module contains the
1726
+ * complete registry set in canonical order:
1727
+ *
1728
+ * ```ts
1729
+ * // AUTO-GENERATED FILE - DO NOT EDIT
1730
+ * // Generated by @replayablejs/assets
1731
+ *
1732
+ * export { sprites } from './sprites.js';
1733
+ * export { textures } from './textures.js';
1734
+ * export { sounds } from './sounds.js';
1735
+ * export { fonts } from './fonts.js';
1736
+ * export { locales } from './locales.js';
1737
+ * export { atlases } from './atlases.js';
1738
+ * export { spines } from './spines.js';
1739
+ * export { shaders } from './shaders.js';
1740
+ * ```
1741
+ *
1742
+ * @returns The complete generated index source, including its final newline.
1743
+ */
1744
+ function renderRegistryIndex() {
1745
+ return renderGeneratedModule([...assetCategories.map((category) => `export { ${category} } from './${category}.js';`)]);
1746
+ }
1747
+ //#endregion
1748
+ //#region src/emitters/emit-build-outputs.ts
1749
+ /**
1750
+ * Coordinates every source-code output produced from processed assets.
1751
+ *
1752
+ * The runtime assets module is mandatory and is always emitted first. Developer
1753
+ * registries are optional: when registry configuration is absent from the
1754
+ * normalized build context, there is no registry work to perform.
1755
+ *
1756
+ * For example, one build may write:
1757
+ *
1758
+ * ```text
1759
+ * src/assets/assets.gen.ts
1760
+ * src/assets/registries/index.ts
1761
+ * src/assets/registries/atlases.ts
1762
+ * src/assets/registries/fonts.ts
1763
+ * src/assets/registries/locales.ts
1764
+ * src/assets/registries/shaders.ts
1765
+ * src/assets/registries/sounds.ts
1766
+ * src/assets/registries/spines.ts
1767
+ * src/assets/registries/sprites.ts
1768
+ * src/assets/registries/textures.ts
1769
+ * ```
1770
+ *
1771
+ * `BuildContext` already contains absolute, validated output paths, while
1772
+ * `AssetGroups` contains prepared bundle and bundle-independent category views.
1773
+ * This function therefore performs no processing, grouping, or sorting. Rendering and
1774
+ * filesystem writes remain owned by the specialized emitters. Keeping the
1775
+ * calls sequential also makes the output order and any partial failure
1776
+ * straightforward to follow.
1777
+ *
1778
+ * @param context - Validated absolute paths controlling generated outputs.
1779
+ * @param assetGroups - Processed assets organized for source-code emission.
1780
+ */
1781
+ async function emitBuildOutputs(context, assetGroups) {
1782
+ await emitAssetsModule(context.assetsFile, assetGroups);
1783
+ if (context.registriesDirectory !== void 0) await emitRegistries(context.registriesDirectory, assetGroups);
1784
+ }
1785
+ //#endregion
1786
+ //#region src/pipeline/asset-groups.ts
1787
+ /**
1788
+ * Organizes the flat processing result before it enters the emitter layer.
1789
+ *
1790
+ * Processors intentionally return independent asset records. The assets module
1791
+ * needs bundles containing categories, while registries need categories across
1792
+ * the complete build. This transition constructs both views once so emitters
1793
+ * can render without regrouping or sorting.
1794
+ *
1795
+ * For these processed records:
1796
+ *
1797
+ * ```ts
1798
+ * [
1799
+ * { bundle: 'secondary', category: 'sprites', id: 'logo', ... },
1800
+ * { bundle: 'primary', category: 'sounds', id: 'click', ... },
1801
+ * { bundle: 'primary', category: 'sprites', id: 'background', ... },
1802
+ * ]
1803
+ * ```
1804
+ *
1805
+ * the returned bundle hierarchy is equivalent to:
1806
+ *
1807
+ * ```text
1808
+ * primary
1809
+ * sounds -> [click]
1810
+ * sprites -> [background]
1811
+ * secondary
1812
+ * sprites -> [logo]
1813
+ * ```
1814
+ *
1815
+ * The parallel category view contains `sounds -> [click]` and
1816
+ * `sprites -> [background, logo]`, without bundle boundaries.
1817
+ *
1818
+ * Primary is always created, even when it contains no assets. Secondary is
1819
+ * included only when at least one processed asset belongs to it. Only references
1820
+ * are reorganized; processed asset records are not copied or changed.
1821
+ *
1822
+ * @param processedAssets - Flat logical runtime assets produced by processing.
1823
+ * @returns Assets grouped and ordered for deterministic source generation.
1824
+ */
1825
+ function groupAssets(processedAssets) {
1826
+ const allCategories = createEmptyCategories();
1827
+ const mutableBundles = /* @__PURE__ */ new Map([["primary", createEmptyCategories()]]);
1828
+ for (const asset of processedAssets) {
1829
+ addAsset(allCategories, asset);
1830
+ let categories = mutableBundles.get(asset.bundle);
1831
+ if (categories === void 0) {
1832
+ categories = createEmptyCategories();
1833
+ mutableBundles.set(asset.bundle, categories);
1834
+ }
1835
+ addAsset(categories, asset);
1836
+ }
1837
+ const bundles = [];
1838
+ for (const name of assetBundleNames) {
1839
+ const categories = mutableBundles.get(name);
1840
+ if (categories === void 0) continue;
1841
+ sortCategoryAssets(categories);
1842
+ bundles.push({
1843
+ name,
1844
+ categories
1845
+ });
1846
+ }
1847
+ sortCategoryAssets(allCategories);
1848
+ return {
1849
+ bundles,
1850
+ categories: allCategories
1851
+ };
1852
+ }
1853
+ /** Sorts every category bucket by runtime ID for deterministic source output. */
1854
+ function sortCategoryAssets(categories) {
1855
+ for (const category of assetCategories) categories[category].sort((left, right) => left.id.localeCompare(right.id));
1856
+ }
1857
+ /**
1858
+ * Creates the complete set of typed category buckets for one new bundle.
1859
+ * Keeping this object explicit makes TypeScript require a bucket whenever a
1860
+ * new asset category is added.
1861
+ */
1862
+ function createEmptyCategories() {
1863
+ return {
1864
+ atlases: [],
1865
+ fonts: [],
1866
+ locales: [],
1867
+ shaders: [],
1868
+ sounds: [],
1869
+ spines: [],
1870
+ sprites: [],
1871
+ textures: []
1872
+ };
1873
+ }
1874
+ /**
1875
+ * Adds one discriminated processed asset to its matching typed bucket.
1876
+ *
1877
+ * The exhaustive switch narrows each union member before writing it. A dynamic
1878
+ * `categories[asset.category].push(asset)` loses that relationship in
1879
+ * TypeScript and requires an unsafe assertion.
1880
+ */
1881
+ function addAsset(categories, asset) {
1882
+ switch (asset.category) {
1883
+ case "atlases":
1884
+ categories.atlases.push(asset);
1885
+ break;
1886
+ case "fonts":
1887
+ categories.fonts.push(asset);
1888
+ break;
1889
+ case "locales":
1890
+ categories.locales.push(asset);
1891
+ break;
1892
+ case "shaders":
1893
+ categories.shaders.push(asset);
1894
+ break;
1895
+ case "sounds":
1896
+ categories.sounds.push(asset);
1897
+ break;
1898
+ case "spines":
1899
+ categories.spines.push(asset);
1900
+ break;
1901
+ case "sprites":
1902
+ categories.sprites.push(asset);
1903
+ break;
1904
+ case "textures":
1905
+ categories.textures.push(asset);
1906
+ break;
1907
+ default: return asset;
1908
+ }
1909
+ }
1910
+ //#endregion
1911
+ //#region src/pipeline/build-result.ts
1912
+ /** Summarizes the logical assets and physical files produced by a successful build. */
1913
+ function createBuildResult(context, assetGroups) {
1914
+ let emittedAssets = 0;
1915
+ let emittedFiles = 0;
1916
+ for (const bundle of assetGroups.bundles) for (const category of assetCategories) for (const asset of bundle.categories[category]) {
1917
+ emittedAssets += 1;
1918
+ emittedFiles += countGeneratedFiles(asset);
1919
+ }
1920
+ return {
1921
+ bundles: assetGroups.bundles.map((bundle) => bundle.name),
1922
+ emittedAssets,
1923
+ emittedFiles,
1924
+ outputDirectory: context.outputRoot
1925
+ };
1926
+ }
1927
+ /** Counts the physical files owned by one complete runtime asset. */
1928
+ function countGeneratedFiles(asset) {
1929
+ switch (asset.category) {
1930
+ case "atlases":
1931
+ case "shaders": return 2;
1932
+ case "spines": return asset.files.images.length + 2;
1933
+ case "fonts":
1934
+ case "locales":
1935
+ case "sounds":
1936
+ case "sprites":
1937
+ case "textures": return 1;
1938
+ default: return asset;
1939
+ }
1940
+ }
1941
+ //#endregion
1942
+ //#region src/pipeline/context.ts
1943
+ /**
1944
+ * Resolves configured project-relative paths into validated absolute paths.
1945
+ *
1946
+ * All generated paths must stay inside the project, and authored sources must
1947
+ * remain separate from every generated tree. Registries may live inside
1948
+ * `outDir` when the complete output tree is owned by Replayable. These checks
1949
+ * protect authored files when the completed build replaces the previous output.
1950
+ * Filesystem symlink checks run separately before staging and publication.
1951
+ */
1952
+ function createBuildContext(config, workingDirectory) {
1953
+ const workspaceRoot = resolve(workingDirectory);
1954
+ const sourceRoot = resolve(workspaceRoot, config.sourceDir);
1955
+ const outputRoot = resolve(workspaceRoot, config.outDir);
1956
+ const assetsFile = resolve(workspaceRoot, config.emit.assets);
1957
+ const registriesDirectory = config.emit.registries === void 0 ? void 0 : resolve(workspaceRoot, config.emit.registries);
1958
+ assertSafeProjectPath(workspaceRoot, sourceRoot, "sourceDir");
1959
+ assertSafeProjectPath(workspaceRoot, outputRoot, "outDir");
1960
+ assertSafeProjectPath(workspaceRoot, assetsFile, "emit.assets");
1961
+ if (registriesDirectory !== void 0) assertSafeProjectPath(workspaceRoot, registriesDirectory, "emit.registries");
1962
+ if (pathsOverlap(sourceRoot, outputRoot)) throw new Error("outDir and sourceDir must be separate, non-nested directories.");
1963
+ if (pathsOverlap(assetsFile, sourceRoot)) throw new Error("emit.assets must be separate from sourceDir.");
1964
+ if (pathsOverlap(assetsFile, outputRoot) && !isNestedInside(assetsFile, outputRoot)) throw new Error("emit.assets must be separate from or nested inside outDir.");
1965
+ if (registriesDirectory !== void 0 && pathsOverlap(registriesDirectory, sourceRoot)) throw new Error("emit.registries must be separate from sourceDir.");
1966
+ if (registriesDirectory !== void 0) {
1967
+ const registriesNestedInsideOutput = isNestedInside(registriesDirectory, outputRoot);
1968
+ if (pathsOverlap(registriesDirectory, outputRoot) && !registriesNestedInsideOutput) throw new Error("emit.registries must be separate from or nested inside outDir.");
1969
+ if (pathsOverlap(assetsFile, registriesDirectory)) throw new Error("emit.assets must be separate from emit.registries.");
1970
+ }
1971
+ return {
1972
+ assetsFile,
1973
+ outputRoot,
1974
+ registriesDirectory,
1975
+ sourceRoot
1976
+ };
1977
+ }
1978
+ /** Returns whether `target` is strictly nested beneath `directory`. */
1979
+ function isNestedInside(target, directory) {
1980
+ return target.startsWith(`${directory}${sep}`);
1981
+ }
1982
+ function pathsOverlap(left, right) {
1983
+ return left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`);
1984
+ }
1985
+ function assertSafeProjectPath(workingDirectory, target, field) {
1986
+ const pathFromWorkingDirectory = relative(workingDirectory, target);
1987
+ if (pathFromWorkingDirectory === "" || isAbsolute(pathFromWorkingDirectory) || pathFromWorkingDirectory === ".." || pathFromWorkingDirectory.startsWith(`..${sep}`)) throw new Error(`${field} must resolve to a path inside the project directory.`);
1988
+ }
1989
+ //#endregion
1990
+ //#region src/pipeline/validate-build-paths.ts
1991
+ /**
1992
+ * Rejects symlinks below the project root before reading or replacing build trees.
1993
+ * Lexical containment alone cannot protect `linked-directory/generated`: an
1994
+ * existing parent may point outside the project or back into authored sources.
1995
+ * The project root itself may be a symlink; paths beneath it may not be.
1996
+ * This checks the current filesystem, not concurrent hostile filesystem changes.
1997
+ */
1998
+ async function validateBuildPaths(context, projectRoot) {
1999
+ const paths = [
2000
+ context.sourceRoot,
2001
+ context.outputRoot,
2002
+ context.assetsFile
2003
+ ];
2004
+ if (context.registriesDirectory !== void 0) paths.push(context.registriesDirectory);
2005
+ for (const path of paths) {
2006
+ let parent = resolve(projectRoot);
2007
+ for (const segment of relative(parent, path).split(sep)) {
2008
+ parent = resolve(parent, segment);
2009
+ const entry = await lstat(parent).catch((error) => {
2010
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
2011
+ throw error;
2012
+ });
2013
+ if (entry === void 0) break;
2014
+ if (entry.isSymbolicLink()) throw new Error(`Asset build paths must not contain symbolic links: ${parent}`);
2015
+ }
2016
+ }
2017
+ }
2018
+ //#endregion
2019
+ //#region src/pipeline/create-build-staging.ts
2020
+ /**
2021
+ * Keeps the last successful outputs untouched while a replacement is built.
2022
+ * The temporary `prepared` tree mirrors project-relative destinations, so
2023
+ * generated relative imports remain correct after publication. Sources still
2024
+ * point at the authored tree; only output paths are redirected.
2025
+ *
2026
+ * Output roots may be separate or contain generated modules. Only non-nested
2027
+ * roots are moved, preventing a module from being installed twice. Publication
2028
+ * backs up old roots and restores them on failure. Failed restoration retains
2029
+ * the recovery directory and reports its path instead of deleting the backup.
2030
+ * This is failure recovery, not a crash-safe transaction or a concurrent-build lock.
2031
+ */
2032
+ async function createBuildStaging(context, workingDirectory) {
2033
+ const projectRoot = resolve(workingDirectory);
2034
+ await validateBuildPaths(context, projectRoot);
2035
+ const directory = await mkdtemp(join(projectRoot, ".replayable-assets-"));
2036
+ const preparedRoot = join(directory, "prepared");
2037
+ let preserveBackup = false;
2038
+ const stagedContext = {
2039
+ sourceRoot: context.sourceRoot,
2040
+ outputRoot: stagePath(context.outputRoot),
2041
+ assetsFile: stagePath(context.assetsFile),
2042
+ registriesDirectory: context.registriesDirectory === void 0 ? void 0 : stagePath(context.registriesDirectory)
2043
+ };
2044
+ const destinations = [context.outputRoot, context.assetsFile];
2045
+ if (context.registriesDirectory !== void 0) destinations.push(context.registriesDirectory);
2046
+ const replacements = destinations.filter((path) => !destinations.some((parent) => path.startsWith(`${parent}${sep}`))).map((destination, index) => ({
2047
+ destination,
2048
+ prepared: stagePath(destination),
2049
+ backup: join(directory, "previous", String(index)),
2050
+ backedUp: false,
2051
+ installed: false
2052
+ }));
2053
+ return {
2054
+ context: stagedContext,
2055
+ commit,
2056
+ dispose
2057
+ };
2058
+ /** Preserves the relative distance between every generated module and asset. */
2059
+ function stagePath(path) {
2060
+ return join(preparedRoot, relative(projectRoot, path));
2061
+ }
2062
+ /** Publishes completed outputs, undoing earlier replacements if a later one fails. */
2063
+ async function commit() {
2064
+ await validateBuildPaths(context, projectRoot);
2065
+ await mkdir(stagedContext.outputRoot, { recursive: true });
2066
+ await mkdir(join(directory, "previous"));
2067
+ try {
2068
+ for (const replacement of replacements) {
2069
+ await mkdir(dirname(replacement.destination), { recursive: true });
2070
+ replacement.backedUp = await backupOutput(replacement);
2071
+ await rename(replacement.prepared, replacement.destination);
2072
+ replacement.installed = true;
2073
+ }
2074
+ } catch (error) {
2075
+ const restoreErrors = await restoreOutputs(replacements);
2076
+ if (restoreErrors.length > 0) {
2077
+ preserveBackup = true;
2078
+ throw new AggregateError([error, ...restoreErrors], `Asset publication failed. Recovery files remain at ${directory}.`, { cause: error });
2079
+ }
2080
+ throw error;
2081
+ }
2082
+ }
2083
+ /** Removes staged files and obsolete backups, never a required recovery copy. */
2084
+ async function dispose() {
2085
+ if (!preserveBackup) await rm(directory, {
2086
+ recursive: true,
2087
+ force: true
2088
+ });
2089
+ }
2090
+ }
2091
+ /** Only a missing destination means there is no previous output to preserve. */
2092
+ async function backupOutput(replacement) {
2093
+ try {
2094
+ await rename(replacement.destination, replacement.backup);
2095
+ return true;
2096
+ } catch (error) {
2097
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
2098
+ throw error;
2099
+ }
2100
+ }
2101
+ /** Attempts every rollback in reverse publication order, retaining all failures. */
2102
+ async function restoreOutputs(replacements) {
2103
+ const errors = [];
2104
+ for (const replacement of [...replacements].reverse()) try {
2105
+ if (replacement.installed) await rm(replacement.destination, {
2106
+ recursive: true,
2107
+ force: true
2108
+ });
2109
+ if (replacement.backedUp) await rename(replacement.backup, replacement.destination);
2110
+ } catch (error) {
2111
+ errors.push(error);
2112
+ }
2113
+ return errors;
2114
+ }
2115
+ //#endregion
2116
+ //#region src/pipeline/validate-output-claims.ts
2117
+ /**
2118
+ * Reserves physical output identities before concurrent processors start.
2119
+ * Bundles are runtime groups, not separate filesystem trees. For example,
2120
+ * primary `textures/logo.png` and secondary `textures/logo.webp` would both
2121
+ * write candidates at `textures/logo.*`, so they must not share that basename.
2122
+ * Runtime-ID validation still runs after processing for expanded atlas sheets.
2123
+ */
2124
+ function validateOutputClaims(assets) {
2125
+ const owners = /* @__PURE__ */ new Map();
2126
+ for (const asset of assets) {
2127
+ const path = "outputBasePath" in asset ? asset.outputBasePath : asset.outputDirectory;
2128
+ const owner = owners.get(path);
2129
+ if (owner !== void 0) throw new Error(`Asset output collision: ${owner} and ${asset.relativePath} both write ${path}.`);
2130
+ owners.set(path, asset.relativePath);
2131
+ }
2132
+ }
2133
+ //#endregion
2134
+ //#region src/pipeline/source-path.ts
2135
+ /** Removes only the final extension from a source-relative POSIX path. */
2136
+ function removeExtension(path) {
2137
+ const extension = extname$1(path);
2138
+ return extension === "" ? path : path.slice(0, -extension.length);
2139
+ }
2140
+ //#endregion
2141
+ //#region src/pipeline/asset-identity.ts
2142
+ /**
2143
+ * Rejects logical assets that would use the same generated runtime property.
2144
+ *
2145
+ * An ID is scoped by both bundle and category. `primary.sprites.logo` and
2146
+ * `secondary.sprites.logo` are independent, as are `primary.sprites.logo` and
2147
+ * `primary.textures.logo`. Generated file formats and physical file counts do
2148
+ * not participate because every processed record now represents one complete
2149
+ * runtime asset.
2150
+ */
2151
+ function assertUniqueRuntimeAssetIds(assets) {
2152
+ const seen = /* @__PURE__ */ new Set();
2153
+ for (const asset of assets) {
2154
+ const key = JSON.stringify([
2155
+ asset.bundle,
2156
+ asset.category,
2157
+ asset.id
2158
+ ]);
2159
+ if (seen.has(key)) throw new Error(`Duplicate runtime asset ID: ${asset.bundle}:${asset.category}:${asset.id}`);
2160
+ seen.add(key);
2161
+ }
2162
+ }
2163
+ /** Creates the final runtime ID used for one generated atlas sheet. */
2164
+ function createAtlasSheetId(atlasId, sheetName) {
2165
+ return join$1(dirname$1(atlasId), sheetName);
2166
+ }
2167
+ /** Derives the runtime ID shared by source and generated forms of a simple asset. */
2168
+ function createSimpleAssetId(asset) {
2169
+ const categoryPrefix = `${asset.category}/`;
2170
+ return removeExtension(asset.relativePath.startsWith(categoryPrefix) ? asset.relativePath.slice(categoryPrefix.length) : asset.relativePath);
2171
+ }
2172
+ //#endregion
2173
+ //#region src/pipeline/settle-asset-tasks.ts
2174
+ /**
2175
+ * Waits for every started task before reporting a processing failure.
2176
+ * Promise.all rejects early: other encoders could still write after rollback
2177
+ * starts. Each concurrent writing layer must settle its own children first.
2178
+ * Results retain input order, and a single failure retains its original error.
2179
+ */
2180
+ async function settleAssetTasks(tasks) {
2181
+ const results = await Promise.allSettled(tasks);
2182
+ const values = [];
2183
+ const errors = [];
2184
+ for (const result of results) if (result.status === "fulfilled") values.push(result.value);
2185
+ else errors.push(result.reason);
2186
+ if (errors.length === 1) throw errors[0];
2187
+ if (errors.length > 1) throw new AggregateError(errors, "Multiple asset tasks failed.");
2188
+ return values;
2189
+ }
2190
+ //#endregion
2191
+ //#region src/adapters/pixi-atlas-parser.ts
2192
+ const pixiAtlasLayoutSchema = z.object({ frames: z.union([z.record(z.string(), z.unknown()), z.array(z.object({ filename: z.string() }))]) });
2193
+ /**
2194
+ * Parses one third-party Pixi atlas layout while it is still in memory.
2195
+ *
2196
+ * Pixi atlas exporters support either an object keyed by frame name or an
2197
+ * array whose entries carry a `filename`. Replayable extracts those names for
2198
+ * registry generation and serializes the original object—not Zod's parsed
2199
+ * projection—so unrelated runtime fields such as `meta` remain intact.
2200
+ *
2201
+ * @param buffer - UTF-8 JSON bytes returned by the texture packer.
2202
+ * @returns Frame metadata and the complete minified JSON written at runtime.
2203
+ * @throws When the packer returns malformed JSON or an unsupported frame shape.
2204
+ */
2205
+ function parsePixiAtlasLayout(buffer) {
2206
+ const layout = JSON.parse(buffer.toString("utf8"));
2207
+ const { frames } = pixiAtlasLayoutSchema.parse(layout);
2208
+ return {
2209
+ frameNames: Array.isArray(frames) ? frames.map((frame) => frame.filename) : Object.keys(frames),
2210
+ serializedJson: JSON.stringify(layout)
2211
+ };
2212
+ }
2213
+ //#endregion
2214
+ //#region src/adapters/texture-packer.ts
2215
+ /** Hard sheet boundary that limits generated texture dimensions and build memory. */
2216
+ const MAX_ATLAS_SIZE = 2048;
2217
+ const BASE_ATLAS_PACKER_OPTIONS = {
2218
+ width: MAX_ATLAS_SIZE,
2219
+ height: MAX_ATLAS_SIZE,
2220
+ fixedSize: false,
2221
+ packer: "OptimalPacker",
2222
+ exporter: "Pixi",
2223
+ filter: "none",
2224
+ scaleMethod: "BILINEAR",
2225
+ trimMode: "trim",
2226
+ alphaThreshold: 0,
2227
+ detectIdentical: true,
2228
+ prependFolderName: false,
2229
+ removeFileExtension: true
2230
+ };
2231
+ /**
2232
+ * Reads and packs one logical atlas into validated in-memory sheets.
2233
+ *
2234
+ * `free-tex-packer-core` returns a flat file list. A small atlas normally
2235
+ * yields `name.json` and `name.png`; a multi-sheet atlas yields
2236
+ * `name-0.{json,png}`, `name-1.{json,png}`, and so on. This adapter converts
2237
+ * that third-party representation into complete JSON/PNG sheet pairs.
2238
+ *
2239
+ * @param imagePaths - Absolute source-image paths belonging to one logical atlas.
2240
+ * @param atlasId - Stable logical ID used as the generated texture basename.
2241
+ * @param options - Validated packing, scaling, padding, and trimming settings.
2242
+ * @returns Complete in-memory sheets ordered by their generated names.
2243
+ */
2244
+ async function packAtlasSources(imagePaths, atlasId, options) {
2245
+ const images = await readAtlasSourceImages(imagePaths);
2246
+ const packerOptions = createTexturePackerOptions(atlasId, options);
2247
+ return collectPackedAtlasSheets(await packAsync(images, packerOptions), atlasId);
2248
+ }
2249
+ /** Reads source images into the in-memory shape required by the texture packer. */
2250
+ async function readAtlasSourceImages(paths) {
2251
+ return Promise.all(paths.map(async (path) => ({
2252
+ contents: await readFile(path),
2253
+ path: basename(path)
2254
+ })));
2255
+ }
2256
+ /** Combines fixed adapter behavior with options resolved for one atlas. */
2257
+ function createTexturePackerOptions(atlasId, options) {
2258
+ return {
2259
+ ...BASE_ATLAS_PACKER_OPTIONS,
2260
+ textureName: basename(atlasId),
2261
+ powerOfTwo: options.powerOfTwo,
2262
+ scale: options.scale,
2263
+ padding: options.padding,
2264
+ extrude: options.extrude,
2265
+ allowRotation: options.allowRotation,
2266
+ allowTrim: options.allowTrim
2267
+ };
2268
+ }
2269
+ /**
2270
+ * Converts untrusted texture-packer output into complete atlas sheets.
2271
+ *
2272
+ * Replayable requires exactly one JSON layout and one PNG intermediate texture
2273
+ * with the same basename for every sheet. Validation happens before files are
2274
+ * written, so malformed packer output cannot leave a partially generated atlas
2275
+ * on disk.
2276
+ *
2277
+ * A single-sheet flat result:
2278
+ *
2279
+ * ```text
2280
+ * ui.json, ui.png
2281
+ * ```
2282
+ *
2283
+ * becomes one `{ name: 'ui', json, png }` value. A split result:
2284
+ *
2285
+ * ```text
2286
+ * ui-1.png, ui-0.json, ui-0.png, ui-1.json
2287
+ * ```
2288
+ *
2289
+ * becomes two complete values ordered as `ui-0`, then `ui-1`, regardless of
2290
+ * the third-party file order.
2291
+ *
2292
+ * @param files - Flat files returned by `free-tex-packer-core`.
2293
+ * @param atlasId - Logical atlas ID used in validation diagnostics.
2294
+ * @returns Complete sheets sorted by generated sheet name.
2295
+ * @throws When output is empty, has an unsupported extension, repeats a file
2296
+ * role, or omits either member of a JSON/PNG pair.
2297
+ */
2298
+ function collectPackedAtlasSheets(files, atlasId) {
2299
+ const partialSheets = /* @__PURE__ */ new Map();
2300
+ for (const file of files) {
2301
+ const extension = extname(file.name).slice(1).toLowerCase();
2302
+ const sheetName = basename(file.name, extname(file.name));
2303
+ const sheet = partialSheets.get(sheetName) ?? {};
2304
+ switch (extension) {
2305
+ case "json":
2306
+ if (sheet.json !== void 0) throw new Error(`Atlas ${atlasId} sheet ${sheetName} has duplicate JSON layouts.`);
2307
+ sheet.json = file;
2308
+ break;
2309
+ case "png":
2310
+ if (sheet.png !== void 0) throw new Error(`Atlas ${atlasId} sheet ${sheetName} has duplicate PNG textures.`);
2311
+ sheet.png = file;
2312
+ break;
2313
+ default: throw new Error(`Atlas ${atlasId} produced unsupported file ${file.name}.`);
2314
+ }
2315
+ partialSheets.set(sheetName, sheet);
2316
+ }
2317
+ if (partialSheets.size === 0) throw new Error(`Atlas ${atlasId} produced no sheets.`);
2318
+ return [...partialSheets.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, sheet]) => {
2319
+ if (sheet.json === void 0 || sheet.png === void 0) throw new Error(`Atlas ${atlasId} sheet ${name} must contain one JSON and one PNG file.`);
2320
+ return {
2321
+ json: sheet.json,
2322
+ name,
2323
+ png: sheet.png
2324
+ };
2325
+ });
2326
+ }
2327
+ //#endregion
2328
+ //#region src/processors/output-selection.ts
2329
+ /**
2330
+ * Selects the smallest file among equivalent generated candidates.
2331
+ *
2332
+ * The function measures the files on disk, deletes every larger candidate, and
2333
+ * returns the measured winner.
2334
+ * For example, `[hero.avif 8 KB, hero.webp 12 KB]` selects only `hero.avif`.
2335
+ * Equal sizes preserve input order, so the earlier automatic candidate wins a
2336
+ * tie. Callers must pass candidates for the same logical asset or texture page.
2337
+ */
2338
+ async function selectSmallestOutput(outputs) {
2339
+ const firstOutput = outputs[0];
2340
+ if (firstOutput === void 0) throw new Error("Cannot select an output from an empty candidate list.");
2341
+ if (outputs.length === 1) return firstOutput;
2342
+ const smallestOutput = (await settleAssetTasks(outputs.map(async (output) => ({
2343
+ output,
2344
+ size: (await stat(output.path)).size
2345
+ })))).reduce((selected, candidate) => candidate.size < selected.size ? candidate : selected).output;
2346
+ await settleAssetTasks(outputs.filter((output) => output !== smallestOutput).map((output) => rm(output.path, { force: true })));
2347
+ return smallestOutput;
2348
+ }
2349
+ //#endregion
2350
+ //#region src/processors/image-encoder.ts
2351
+ const DEFAULT_IMAGE_QUALITY = {
2352
+ avif: 72,
2353
+ jpg: 82,
2354
+ webp: 80
2355
+ };
2356
+ /**
2357
+ * Selects the encodings worth comparing for an image.
2358
+ *
2359
+ * Lossless assets compare WebP with PNG. Lossy opaque assets can use JPEG,
2360
+ * while assets containing transparent pixels use PNG as their alpha-safe
2361
+ * conventional fallback. AVIF and WebP are considered in both lossy cases.
2362
+ */
2363
+ function selectImageCandidateFormats(options, image) {
2364
+ if (options.lossless) return ["webp", "png"];
2365
+ return image.isOpaque ? [
2366
+ "avif",
2367
+ "webp",
2368
+ "jpg"
2369
+ ] : [
2370
+ "avif",
2371
+ "webp",
2372
+ "png"
2373
+ ];
2374
+ }
2375
+ /**
2376
+ * Scales an image once and inspects the resulting pixels for transparency.
2377
+ *
2378
+ * The inspection happens after resizing because interpolation can change alpha
2379
+ * values near transparent edges. A lossless intermediate guarantees that each
2380
+ * candidate encoder receives exactly the same transformed pixels.
2381
+ */
2382
+ async function prepareImage(input, scale, sourcePath) {
2383
+ let image = sharp(input);
2384
+ if (scale !== 1) {
2385
+ const metadata = await image.metadata();
2386
+ if (metadata.width === void 0) throw new Error(`Cannot determine image width for scaling: ${sourcePath}`);
2387
+ image = image.resize({ width: Math.max(1, Math.round(metadata.width * scale)) });
2388
+ }
2389
+ const buffer = await image.png().toBuffer();
2390
+ const { isOpaque } = await sharp(buffer).stats();
2391
+ return {
2392
+ buffer,
2393
+ isOpaque
2394
+ };
2395
+ }
2396
+ /**
2397
+ * Writes every applicable image candidate and returns the smallest generated file.
2398
+ *
2399
+ * Standalone images, atlas sheets, and Spine pages all pass through this same
2400
+ * boundary, ensuring that they use identical preparation, encoding, comparison,
2401
+ * and cleanup behavior.
2402
+ */
2403
+ async function writeSmallestImage(request) {
2404
+ const prepared = await prepareImage(request.input, request.options.scale, request.sourcePath);
2405
+ return selectSmallestOutput(await settleAssetTasks(selectImageCandidateFormats(request.options, prepared).map(async (format) => {
2406
+ const path = `${request.outputBasePath}.${format}`;
2407
+ await writeImageVariant(prepared.buffer, path, format, request.options);
2408
+ return {
2409
+ format,
2410
+ path
2411
+ };
2412
+ })));
2413
+ }
2414
+ /** Writes one encoded image candidate from a prepared image buffer. */
2415
+ async function writeImageVariant(input, outputPath, format, options) {
2416
+ await mkdir(dirname(outputPath), { recursive: true });
2417
+ let image = sharp(input);
2418
+ const quality = options.lossless ? void 0 : options.quality;
2419
+ switch (format) {
2420
+ case "avif":
2421
+ image = image.avif({ quality: quality ?? DEFAULT_IMAGE_QUALITY.avif });
2422
+ break;
2423
+ case "jpg":
2424
+ image = image.jpeg({ quality: quality ?? DEFAULT_IMAGE_QUALITY.jpg });
2425
+ break;
2426
+ case "png":
2427
+ image = image.png();
2428
+ break;
2429
+ case "webp": image = options.lossless ? image.webp({ lossless: true }) : image.webp({ quality: quality ?? DEFAULT_IMAGE_QUALITY.webp });
2430
+ }
2431
+ await image.toFile(outputPath);
2432
+ }
2433
+ //#endregion
2434
+ //#region src/processors/categories/atlas.ts
2435
+ /**
2436
+ * Packs all source images in one logical atlas and writes its generated files.
2437
+ *
2438
+ * The texture-packer adapter returns validated PNG sheets with matching Pixi
2439
+ * layouts. Those PNGs are lossless intermediates rather than predetermined
2440
+ * runtime formats. Each one passes through the shared image encoder, which
2441
+ * keeps only the smallest applicable AVIF, WebP, JPEG, or PNG result.
2442
+ *
2443
+ * One packed sheet produces one processed runtime entry:
2444
+ *
2445
+ * ```text
2446
+ * ui -> [{ id: 'ui', files: { json, image } }]
2447
+ * ```
2448
+ *
2449
+ * When the source images exceed one sheet, the generated sheet names become
2450
+ * distinct runtime IDs while preserving their logical atlas directory:
2451
+ *
2452
+ * ```text
2453
+ * ui -> [
2454
+ * { id: 'ui-0', files: { json, image } },
2455
+ * { id: 'ui-1', files: { json, image } },
2456
+ * ]
2457
+ * ```
2458
+ */
2459
+ async function processAtlas(asset) {
2460
+ const { id, images } = asset.atlas;
2461
+ return writeAtlasSheets(asset, await packAtlasSources(images, id, asset.options));
2462
+ }
2463
+ /** Writes validated sheets sequentially to limit peak image-encoding memory. */
2464
+ async function writeAtlasSheets(asset, sheets) {
2465
+ const processedSheets = [];
2466
+ for (const sheet of sheets) processedSheets.push(await writeAtlasSheet(asset, sheet));
2467
+ return processedSheets;
2468
+ }
2469
+ /** Writes one validated sheet and returns its complete processed runtime value. */
2470
+ async function writeAtlasSheet(asset, sheet) {
2471
+ const jsonOutputPath = join(asset.outputDirectory, `${sheet.name}.json`);
2472
+ const imageOutputBasePath = join(asset.outputDirectory, sheet.name);
2473
+ const layout = parsePixiAtlasLayout(sheet.json.buffer);
2474
+ await writeAtlasLayout(layout.serializedJson, jsonOutputPath);
2475
+ const image = await writeSmallestImage({
2476
+ input: sheet.png.buffer,
2477
+ options: {
2478
+ ...asset.options,
2479
+ scale: 1
2480
+ },
2481
+ outputBasePath: imageOutputBasePath,
2482
+ sourcePath: `${asset.relativePath} (${sheet.name})`
2483
+ });
2484
+ return {
2485
+ bundle: asset.bundle,
2486
+ category: "atlases",
2487
+ files: {
2488
+ image,
2489
+ json: {
2490
+ format: "json",
2491
+ path: jsonOutputPath
2492
+ }
2493
+ },
2494
+ frameNames: layout.frameNames,
2495
+ id: createAtlasSheetId(asset.atlas.id, sheet.name)
2496
+ };
2497
+ }
2498
+ /**
2499
+ * Writes the already-validated and minified Pixi layout for one atlas sheet.
2500
+ */
2501
+ async function writeAtlasLayout(serializedJson, outputPath) {
2502
+ await mkdir(dirname(outputPath), { recursive: true });
2503
+ await writeFile(outputPath, serializedJson);
2504
+ }
2505
+ //#endregion
2506
+ //#region src/adapters/font-subsetter.ts
2507
+ /**
2508
+ * Loads one source font and returns WOFF2 bytes containing the required glyphs.
2509
+ *
2510
+ * `charset` is text rather than a list of glyph identifiers. The subsetting
2511
+ * engine reads its Unicode characters, keeps the glyphs needed to render them,
2512
+ * and may retain related glyphs required by the font's layout substitutions.
2513
+ * Duplicate characters do not produce duplicate glyphs.
2514
+ *
2515
+ * TTF, OTF, WOFF, and WOFF2 sources all cross this adapter boundary as raw
2516
+ * bytes and always return WOFF2 bytes. This function does not write a file;
2517
+ * the font processor owns the generated output path and filesystem write.
2518
+ * Source-read errors and invalid or unsupported font errors intentionally
2519
+ * propagate to that processor with their original diagnostics.
2520
+ *
2521
+ * @param sourcePath - Absolute path of the authored source font.
2522
+ * @param charset - Complete text whose characters must remain renderable.
2523
+ * @returns Encoded WOFF2 font bytes ready to be written by the processor.
2524
+ *
2525
+ * @example
2526
+ *
2527
+ * ```ts
2528
+ * const woff2 = await createWoff2Subset(
2529
+ * '/project/raw-assets/fonts/interface.ttf',
2530
+ * 'ABCDEFGHIJKLMNOPQRSTUVWXYZԲարև',
2531
+ * );
2532
+ * ```
2533
+ */
2534
+ async function createWoff2Subset(sourcePath, charset) {
2535
+ const sourceFont = await readFile(sourcePath);
2536
+ return subsetFont(sourceFont, charset, { targetFormat: "woff2" });
2537
+ }
2538
+ //#endregion
2539
+ //#region src/processors/categories/font.ts
2540
+ /**
2541
+ * Subsets and converts one source font into WOFF2.
2542
+ *
2543
+ * Resolution has already constructed the complete charset from printable
2544
+ * ASCII, fixed-language locale values, and font-specific extra characters.
2545
+ * TTF, OTF, WOFF, and WOFF2 sources all produce the same WOFF2 output shape.
2546
+ * The runtime family is authored configuration; Replayable does not infer or
2547
+ * rewrite it from the font's internal metadata.
2548
+ *
2549
+ * For example:
2550
+ *
2551
+ * ```text
2552
+ * Source:
2553
+ * fonts/ui.ttf
2554
+ * family: Replayable UI
2555
+ * charset: printable ASCII + selected locale characters
2556
+ *
2557
+ * Generated:
2558
+ * fonts/ui.woff2
2559
+ *
2560
+ * Processed entry:
2561
+ * {
2562
+ * id: 'ui',
2563
+ * file: { format: 'woff2', path: '.../fonts/ui.woff2' },
2564
+ * runtime: { family: 'Replayable UI' },
2565
+ * }
2566
+ * ```
2567
+ *
2568
+ * The processor returns a one-entry array because the shared dispatcher uses
2569
+ * one batch shape for singleton assets and atlases that may generate several
2570
+ * sheets.
2571
+ */
2572
+ async function processFont(asset) {
2573
+ const { family } = asset.options;
2574
+ const outputPath = `${asset.outputBasePath}.woff2`;
2575
+ await writeFontFile(outputPath, await createWoff2Subset(asset.absolutePath, asset.charset));
2576
+ return [{
2577
+ bundle: asset.bundle,
2578
+ category: "fonts",
2579
+ file: {
2580
+ format: "woff2",
2581
+ path: outputPath
2582
+ },
2583
+ id: createSimpleAssetId(asset),
2584
+ runtime: { family }
2585
+ }];
2586
+ }
2587
+ /** Writes the generated WOFF2 file, creating its output directory when needed. */
2588
+ async function writeFontFile(outputPath, contents) {
2589
+ await mkdir(dirname(outputPath), { recursive: true });
2590
+ await writeFile(outputPath, contents);
2591
+ }
2592
+ //#endregion
2593
+ //#region src/processors/categories/image.ts
2594
+ /**
2595
+ * Processes one standalone sprite or texture into one selected runtime file.
2596
+ *
2597
+ * Asset rules configure intent (`lossless`, `quality`, and `scale`), not an
2598
+ * output extension. The shared image encoder generates every applicable
2599
+ * candidate and keeps only the smallest file, so the emitted runtime value
2600
+ * always references one URL.
2601
+ *
2602
+ * For example, `sprites/ui/button.png` may be encoded as AVIF, WebP, and PNG.
2603
+ * If WebP is smallest, only `sprites/ui/button.webp` remains and this function
2604
+ * returns one processed sprite containing that file.
2605
+ *
2606
+ * The absolute path is used to read the source, while the relative path is
2607
+ * retained for human-readable processing errors. The resolved category is
2608
+ * preserved because this processor serves both `sprites` and `textures`.
2609
+ * Runtime `scale` records the image transformation applied by the encoder. The
2610
+ * returned one-item array follows the common processor contract: one resolved
2611
+ * image produces one processed asset.
2612
+ */
2613
+ async function processImage(asset) {
2614
+ const selectedFile = await writeSmallestImage({
2615
+ input: asset.absolutePath,
2616
+ options: asset.options,
2617
+ outputBasePath: asset.outputBasePath,
2618
+ sourcePath: asset.relativePath
2619
+ });
2620
+ return [{
2621
+ bundle: asset.bundle,
2622
+ category: asset.category,
2623
+ file: selectedFile,
2624
+ id: createSimpleAssetId(asset),
2625
+ runtime: { scale: asset.options.scale }
2626
+ }];
2627
+ }
2628
+ //#endregion
2629
+ //#region src/processors/categories/locale.ts
2630
+ /**
2631
+ * Writes one locale dictionary that was fixed to the build language during resolution.
2632
+ *
2633
+ * This processor does not parse JSONC or choose translations. Locale resolution
2634
+ * has already performed those operations and supplied only the selected value
2635
+ * for each phrase. For example, an Armenian build may reach this processor as:
2636
+ *
2637
+ * ```ts
2638
+ * {
2639
+ * play: 'Խաղալ',
2640
+ * install: 'Install', // Armenian was absent, so English was selected.
2641
+ * }
2642
+ * ```
2643
+ *
2644
+ * A source named `translations.jsonc` is emitted as runtime-ready
2645
+ * `translations.json`. Its top-level phrase IDs are retained beside the file
2646
+ * descriptor so registry emission does not need to read and parse that
2647
+ * generated JSON again:
2648
+ *
2649
+ * ```ts
2650
+ * {
2651
+ * id: 'translations',
2652
+ * file: { format: 'json', path: '.../locales/translations.json' },
2653
+ * phraseIds: ['play', 'install'],
2654
+ * }
2655
+ * ```
2656
+ *
2657
+ * The returned one-item array follows the common processor contract: one
2658
+ * resolved locale asset produces one processed file.
2659
+ */
2660
+ async function processLocale(asset) {
2661
+ const outputPath = `${asset.outputBasePath}.json`;
2662
+ const serializedLocale = JSON.stringify(asset.resolvedLocale);
2663
+ const phraseIds = Object.keys(asset.resolvedLocale);
2664
+ await mkdir(dirname(outputPath), { recursive: true });
2665
+ await writeFile(outputPath, serializedLocale);
2666
+ return [{
2667
+ bundle: asset.bundle,
2668
+ category: "locales",
2669
+ file: {
2670
+ format: "json",
2671
+ path: outputPath
2672
+ },
2673
+ id: createSimpleAssetId(asset),
2674
+ phraseIds
2675
+ }];
2676
+ }
2677
+ //#endregion
2678
+ //#region src/processors/categories/shader.ts
2679
+ /**
2680
+ * Copies a vertex/fragment pair without changing its authored GLSL.
2681
+ *
2682
+ * One resolved shader becomes one logical processed asset that owns its
2683
+ * generated vertex and fragment files. Keeping the pair together means every
2684
+ * later stage receives a complete runtime shader instead of reconstructing it
2685
+ * from unrelated records.
2686
+ *
2687
+ * For example, the resolved sources:
2688
+ *
2689
+ * ```text
2690
+ * assets/source/shaders/glow/vert.glsl
2691
+ * assets/source/shaders/glow/frag.glsl
2692
+ * ```
2693
+ *
2694
+ * are copied byte-for-byte to:
2695
+ *
2696
+ * ```text
2697
+ * assets/generated/shaders/glow/vert.glsl
2698
+ * assets/generated/shaders/glow/frag.glsl
2699
+ * ```
2700
+ *
2701
+ * and returned as one processed asset whose ID is `glow` and whose `files`
2702
+ * object contains both generated files. The emitter can therefore serialize a
2703
+ * complete shader program without pairing independent file records.
2704
+ */
2705
+ async function processShader(asset) {
2706
+ const vertexOutputPath = join(asset.outputDirectory, "vert.glsl");
2707
+ const fragmentOutputPath = join(asset.outputDirectory, "frag.glsl");
2708
+ await mkdir(asset.outputDirectory, { recursive: true });
2709
+ const vertexFile = await copyShaderStage(asset.shader.vert, vertexOutputPath);
2710
+ const fragmentFile = await copyShaderStage(asset.shader.frag, fragmentOutputPath);
2711
+ return [{
2712
+ bundle: asset.bundle,
2713
+ category: "shaders",
2714
+ files: {
2715
+ frag: fragmentFile,
2716
+ vert: vertexFile
2717
+ },
2718
+ id: asset.shader.id
2719
+ }];
2720
+ }
2721
+ /**
2722
+ * Copies one shader stage byte-for-byte and describes the generated file.
2723
+ *
2724
+ * The returned value contains the file's format and path, not its GLSL source.
2725
+ * Emitters later use this metadata to create the runtime import.
2726
+ */
2727
+ async function copyShaderStage(inputPath, outputPath) {
2728
+ await copyFile(inputPath, outputPath);
2729
+ return {
2730
+ format: "glsl",
2731
+ path: outputPath
2732
+ };
2733
+ }
2734
+ //#endregion
2735
+ //#region src/adapters/audio-transcoder.ts
2736
+ /**
2737
+ * Runs the bundled FFmpeg executable to produce one encoded audio candidate.
2738
+ *
2739
+ * The executable is spawned directly rather than through a shell, so paths are
2740
+ * passed as literal arguments and require no quoting. Standard output is not
2741
+ * used. FFmpeg's standard error is retained because it contains both progress
2742
+ * information and the useful diagnostic when decoding or encoding fails.
2743
+ *
2744
+ * The promise resolves only after FFmpeg exits successfully. It rejects when
2745
+ * the executable cannot start or exits with a non-zero status.
2746
+ *
2747
+ * @example
2748
+ *
2749
+ * ```ts
2750
+ * await transcodeAudio({
2751
+ * format: 'mp3',
2752
+ * inputPath: '/project/raw-assets/sounds/click.wav',
2753
+ * options: { bitrate: 96, channels: 'mono', sampleRate: 32000 },
2754
+ * outputPath: '/project/assets/generated/sounds/click.mp3',
2755
+ * });
2756
+ * ```
2757
+ */
2758
+ function transcodeAudio(request) {
2759
+ const ffmpegArguments = createFfmpegArguments(request);
2760
+ return new Promise((resolve, reject) => {
2761
+ const childProcess = spawn(ffmpeg.path, ffmpegArguments, { stdio: [
2762
+ "ignore",
2763
+ "ignore",
2764
+ "pipe"
2765
+ ] });
2766
+ let diagnostics = "";
2767
+ childProcess.stderr.on("data", (chunk) => {
2768
+ diagnostics += chunk.toString();
2769
+ });
2770
+ childProcess.on("error", reject);
2771
+ childProcess.on("close", (code) => {
2772
+ if (code === 0) {
2773
+ resolve();
2774
+ return;
2775
+ }
2776
+ reject(/* @__PURE__ */ new Error(`FFmpeg failed for ${request.inputPath}: ${diagnostics}`));
2777
+ });
2778
+ });
2779
+ }
2780
+ /**
2781
+ * Converts a transcode request into a deterministic FFmpeg argument list.
2782
+ *
2783
+ * For an MP3 request using 96 kbps, 32 kHz, and mono audio, the returned
2784
+ * arguments represent this command:
2785
+ *
2786
+ * ```text
2787
+ * ffmpeg -y -i click.wav -map 0:a:0 -c:a libmp3lame -b:a 96k -ar 32000 -ac 1 click.mp3
2788
+ * ```
2789
+ *
2790
+ * M4A uses FFmpeg's AAC encoder instead. When `channels` is `source`, the
2791
+ * `-ac` pair is omitted and FFmpeg preserves the input stream's channel count.
2792
+ * The returned array excludes the executable path because `spawn` receives it
2793
+ * separately.
2794
+ */
2795
+ function createFfmpegArguments(request) {
2796
+ const { format, inputPath, options, outputPath } = request;
2797
+ const codec = format === "m4a" ? "aac" : "libmp3lame";
2798
+ const channelArguments = createChannelArguments(options.channels);
2799
+ return [
2800
+ "-y",
2801
+ "-i",
2802
+ inputPath,
2803
+ "-map",
2804
+ "0:a:0",
2805
+ "-c:a",
2806
+ codec,
2807
+ "-b:a",
2808
+ `${options.bitrate}k`,
2809
+ "-ar",
2810
+ String(options.sampleRate),
2811
+ ...channelArguments,
2812
+ outputPath
2813
+ ];
2814
+ }
2815
+ /** Returns FFmpeg's optional output-channel arguments. */
2816
+ function createChannelArguments(channels) {
2817
+ if (channels === "source") return [];
2818
+ return ["-ac", channels === "mono" ? "1" : "2"];
2819
+ }
2820
+ //#endregion
2821
+ //#region src/processors/categories/sound.ts
2822
+ const SOUND_CANDIDATE_FORMATS = ["mp3", "m4a"];
2823
+ /**
2824
+ * Encodes one sound as MP3 and M4A, then keeps the smaller file.
2825
+ *
2826
+ * Both candidates pass through FFmpeg with the same bitrate, sample rate, and
2827
+ * channel configuration. Source files are never copied directly because that
2828
+ * would make their encoding settings incomparable with the other candidate.
2829
+ *
2830
+ * For example, `sounds/ui/click.wav` is encoded as temporary candidates
2831
+ * `click.mp3` and `click.m4a`. After their file sizes are measured, only the
2832
+ * smaller candidate remains on disk and becomes the file of the returned
2833
+ * runtime sound asset. The rejected candidate is deleted.
2834
+ *
2835
+ * Candidate order also provides deterministic tie-breaking: if both files
2836
+ * have exactly the same size, MP3 wins because it appears first. The returned
2837
+ * one-item array follows the common processor contract: one resolved sound
2838
+ * produces one processed asset.
2839
+ */
2840
+ async function processSound(asset) {
2841
+ const outputDirectory = dirname(asset.outputBasePath);
2842
+ await mkdir(outputDirectory, { recursive: true });
2843
+ const selectedFile = await selectSmallestOutput(await settleAssetTasks(SOUND_CANDIDATE_FORMATS.map((format) => encodeSoundCandidate(asset, format))));
2844
+ return [{
2845
+ bundle: asset.bundle,
2846
+ category: "sounds",
2847
+ file: selectedFile,
2848
+ id: createSimpleAssetId(asset)
2849
+ }];
2850
+ }
2851
+ /**
2852
+ * Encodes one candidate file and returns the metadata consumed by later stages.
2853
+ *
2854
+ * The generated-file descriptor identifies the candidate's format and path;
2855
+ * it does not contain audio data. After all candidates are encoded, their
2856
+ * descriptors allow smallest-file selection to measure and remove files.
2857
+ */
2858
+ async function encodeSoundCandidate(asset, format) {
2859
+ const outputPath = `${asset.outputBasePath}.${format}`;
2860
+ await transcodeAudio({
2861
+ format,
2862
+ inputPath: asset.absolutePath,
2863
+ options: asset.options,
2864
+ outputPath
2865
+ });
2866
+ return {
2867
+ format,
2868
+ path: outputPath
2869
+ };
2870
+ }
2871
+ //#endregion
2872
+ //#region src/adapters/spine-skeleton-parser.ts
2873
+ /**
2874
+ * Parses one authored Spine skeleton with the official runtime readers.
2875
+ *
2876
+ * Both JSON and binary SKEL readers require an `AtlasAttachmentLoader` while
2877
+ * resolving skeleton attachments. The companion `.atlas` file is therefore
2878
+ * parsed even though Replayable retains only animation and skin names. Texture
2879
+ * image pixels are not required for this metadata operation.
2880
+ *
2881
+ * Names remain in the order supplied by Spine. Registry generation owns
2882
+ * deduplication and alphabetical ordering because those are serialization
2883
+ * concerns rather than properties of the authored skeleton.
2884
+ *
2885
+ * The temporary `TextureAtlas` owns runtime objects and is always disposed
2886
+ * after skeleton parsing, whether parsing succeeds or throws.
2887
+ *
2888
+ * @param skeleton - Authored JSON or SKEL skeleton path and resolved format.
2889
+ * @param atlasPath - Absolute path of the companion authored `.atlas` file.
2890
+ * @returns Animation and skin names needed by the generated Spine registry.
2891
+ * @throws A source-specific error when the atlas or skeleton cannot be parsed.
2892
+ */
2893
+ async function readSpineSkeletonMetadata(skeleton, atlasPath) {
2894
+ const textureAtlas = await readTextureAtlas(atlasPath);
2895
+ try {
2896
+ const skeletonData = await readSkeletonData(skeleton, new AtlasAttachmentLoader(textureAtlas));
2897
+ return {
2898
+ animationNames: skeletonData.animations.map((animation) => animation.name),
2899
+ skinNames: skeletonData.skins.map((skin) => skin.name)
2900
+ };
2901
+ } finally {
2902
+ textureAtlas.dispose();
2903
+ }
2904
+ }
2905
+ /** Reads and parses the companion atlas used to resolve skeleton attachments. */
2906
+ async function readTextureAtlas(atlasPath) {
2907
+ const source = await readFile(atlasPath, "utf8");
2908
+ try {
2909
+ return new TextureAtlas(source);
2910
+ } catch (cause) {
2911
+ throw new Error(`Invalid Spine atlas: ${atlasPath}`, { cause });
2912
+ }
2913
+ }
2914
+ /** Selects the official JSON or binary reader for the resolved source format. */
2915
+ async function readSkeletonData(skeleton, attachmentLoader) {
2916
+ try {
2917
+ switch (skeleton.format) {
2918
+ case "json": {
2919
+ const source = await readFile(skeleton.path, "utf8");
2920
+ return new SkeletonJson(attachmentLoader).readSkeletonData(JSON.parse(source));
2921
+ }
2922
+ case "skel": {
2923
+ const source = await readFile(skeleton.path);
2924
+ return new SkeletonBinary(attachmentLoader).readSkeletonData(new Uint8Array(source));
2925
+ }
2926
+ default: return skeleton.format;
2927
+ }
2928
+ } catch (cause) {
2929
+ throw new Error(`Invalid Spine ${skeleton.format.toUpperCase()} skeleton: ${skeleton.path}`, { cause });
2930
+ }
2931
+ }
2932
+ //#endregion
2933
+ //#region src/processors/categories/spine.ts
2934
+ /**
2935
+ * Emits one self-contained Spine runtime asset.
2936
+ *
2937
+ * JSON skeletons are minified, binary skeletons and atlas descriptions are
2938
+ * copied byte-for-byte, and every texture page is optimized independently. A
2939
+ * two-page Spine therefore emits four files: skeleton, atlas, and one selected
2940
+ * image for each page.
2941
+ *
2942
+ * For example, these resolved source files:
2943
+ *
2944
+ * ```text
2945
+ * spines/hero/hero.json
2946
+ * spines/hero/hero.atlas -> declares body.png, then effects.png
2947
+ * spines/hero/body.png
2948
+ * spines/hero/effects.png
2949
+ * ```
2950
+ *
2951
+ * may produce:
2952
+ *
2953
+ * ```text
2954
+ * spines/hero/skeleton.json
2955
+ * spines/hero/atlas.atlas
2956
+ * spines/hero/images/page0.webp
2957
+ * spines/hero/images/page1.avif
2958
+ * ```
2959
+ *
2960
+ * The returned processed asset contains the skeleton file, atlas file, and
2961
+ * `[page0, page1]` texture array. It also retains animation and skin names while
2962
+ * the authored skeleton is available, allowing registry emission to remain a
2963
+ * synchronous serialization step. Page order remains identical to the atlas
2964
+ * declaration even when different encodings win for different pages. The
2965
+ * returned one-item array follows the common processor contract: one resolved
2966
+ * Spine asset produces one complete processed asset.
2967
+ */
2968
+ async function processSpine(asset) {
2969
+ const spine = asset.spine;
2970
+ const metadata = await readSpineSkeletonMetadata(spine.skeleton, spine.atlas);
2971
+ await mkdir(asset.outputDirectory, { recursive: true });
2972
+ const skeletonFile = await writeSpineSkeleton(spine.skeleton, asset.outputDirectory);
2973
+ const atlasFile = await copySpineAtlas(spine.atlas, asset.outputDirectory);
2974
+ const texturePages = await writeSpineTexturePages(spine.images, asset.outputDirectory, asset.options);
2975
+ return [{
2976
+ bundle: asset.bundle,
2977
+ category: "spines",
2978
+ files: {
2979
+ atlas: atlasFile,
2980
+ images: texturePages,
2981
+ skeleton: skeletonFile
2982
+ },
2983
+ id: spine.id,
2984
+ metadata,
2985
+ runtime: { scale: asset.options.scale }
2986
+ }];
2987
+ }
2988
+ /**
2989
+ * Writes the skeleton in the format selected during resolution.
2990
+ *
2991
+ * JSON must be parsed before it can be serialized without optional whitespace;
2992
+ * this verifies JSON syntax but does not validate the Spine data model. Binary
2993
+ * SKEL content cannot be interpreted here and is copied byte-for-byte.
2994
+ */
2995
+ async function writeSpineSkeleton(skeleton, outputDirectory) {
2996
+ const outputPath = join(outputDirectory, `skeleton.${skeleton.format}`);
2997
+ switch (skeleton.format) {
2998
+ case "json":
2999
+ await writeJsonSkeleton(skeleton.path, outputPath);
3000
+ break;
3001
+ case "skel": await copyFile(skeleton.path, outputPath);
3002
+ }
3003
+ return {
3004
+ format: skeleton.format,
3005
+ path: outputPath
3006
+ };
3007
+ }
3008
+ /**
3009
+ * Minifies a JSON skeleton and identifies its source when parsing fails.
3010
+ *
3011
+ * Parsing exists solely to remove insignificant JSON whitespace. The parsed
3012
+ * value remains unknown because this processor does not enforce a Spine schema.
3013
+ */
3014
+ async function writeJsonSkeleton(inputPath, outputPath) {
3015
+ const sourceText = await readFile(inputPath, "utf8");
3016
+ let skeletonData;
3017
+ try {
3018
+ skeletonData = JSON.parse(sourceText);
3019
+ } catch (cause) {
3020
+ throw new Error(`Invalid Spine JSON skeleton: ${inputPath}`, { cause });
3021
+ }
3022
+ await writeFile(outputPath, JSON.stringify(skeletonData));
3023
+ }
3024
+ /** Copies the authored atlas description and returns its generated-file metadata. */
3025
+ async function copySpineAtlas(inputPath, outputDirectory) {
3026
+ const outputPath = join(outputDirectory, "atlas.atlas");
3027
+ await copyFile(inputPath, outputPath);
3028
+ return {
3029
+ format: "atlas",
3030
+ path: outputPath
3031
+ };
3032
+ }
3033
+ /**
3034
+ * Optimizes texture pages sequentially while preserving their atlas order.
3035
+ *
3036
+ * Every page independently generates the supported image candidates and keeps
3037
+ * its smallest result. A page may hold a large decoded bitmap plus multiple
3038
+ * encoded candidates, so sequential processing bounds peak build memory. The
3039
+ * loop appends each result in source order, preserving the positional mapping
3040
+ * between the returned array and the pages declared by the Spine atlas.
3041
+ */
3042
+ async function writeSpineTexturePages(pageSourcePaths, outputDirectory, options) {
3043
+ const generatedPages = [];
3044
+ for (const [pageIndex, sourcePath] of pageSourcePaths.entries()) {
3045
+ const generatedPage = await writeSmallestImage({
3046
+ input: sourcePath,
3047
+ options,
3048
+ outputBasePath: join(outputDirectory, "images", `page${pageIndex}`),
3049
+ sourcePath
3050
+ });
3051
+ generatedPages.push(generatedPage);
3052
+ }
3053
+ return generatedPages;
3054
+ }
3055
+ //#endregion
3056
+ //#region src/processors/process-assets.ts
3057
+ /**
3058
+ * Processes every fully resolved asset without category-specific scheduling.
3059
+ *
3060
+ * Every returned record represents one final runtime entry. Processor results
3061
+ * remain arrays because one source atlas may produce several logical sheets;
3062
+ * singleton categories return a one-element array through the same dispatcher.
3063
+ * The completed collection is guaranteed to contain unique runtime IDs.
3064
+ */
3065
+ async function processAssets(assets) {
3066
+ const processedAssets = (await settleAssetTasks(assets.map(processAsset))).flat();
3067
+ assertUniqueRuntimeAssetIds(processedAssets);
3068
+ return processedAssets;
3069
+ }
3070
+ /** Delegates one resolved asset to the processor for its discriminated category. */
3071
+ function processAsset(asset) {
3072
+ const category = asset.category;
3073
+ switch (category) {
3074
+ case "atlases": return processAtlas(asset);
3075
+ case "fonts": return processFont(asset);
3076
+ case "locales": return processLocale(asset);
3077
+ case "shaders": return processShader(asset);
3078
+ case "sounds": return processSound(asset);
3079
+ case "spines": return processSpine(asset);
3080
+ case "sprites":
3081
+ case "textures": return processImage(asset);
3082
+ default: return unsupportedCategory(category);
3083
+ }
3084
+ }
3085
+ /** Enforces exhaustive category dispatch when the ResolvedAsset union changes. */
3086
+ function unsupportedCategory(category) {
3087
+ throw new Error(`Unsupported resolved asset category: ${String(category)}.`);
3088
+ }
3089
+ //#endregion
3090
+ //#region src/pipeline/discover-source-files.ts
3091
+ /**
3092
+ * Recursively discovers regular files beneath the configured source directory.
3093
+ *
3094
+ * Returned paths contain both an absolute filesystem location for reading and
3095
+ * a POSIX-style relative path for platform-independent matching and asset IDs.
3096
+ * Results are sorted so later resolution is deterministic on every filesystem.
3097
+ */
3098
+ async function discoverSourceFiles(sourceRoot) {
3099
+ await assertSourceDirectory(sourceRoot);
3100
+ return (await glob("**/*", {
3101
+ cwd: sourceRoot,
3102
+ onlyFiles: true,
3103
+ dot: true,
3104
+ followSymbolicLinks: false
3105
+ })).sort().map((relativePath) => ({
3106
+ absolutePath: resolve(sourceRoot, relativePath),
3107
+ relativePath
3108
+ }));
3109
+ }
3110
+ /** Ensures discovery starts from an existing directory rather than a file. */
3111
+ async function assertSourceDirectory(sourceRoot) {
3112
+ let sourceStats;
3113
+ try {
3114
+ sourceStats = await stat(sourceRoot);
3115
+ } catch (error) {
3116
+ if (hasCode(error, "ENOENT")) throw new Error(`Asset source directory does not exist: ${sourceRoot}`, { cause: error });
3117
+ throw error;
3118
+ }
3119
+ if (!sourceStats.isDirectory()) throw new Error(`Asset source path is not a directory: ${sourceRoot}`);
3120
+ }
3121
+ function hasCode(error, code) {
3122
+ return error instanceof Error && "code" in error && error.code === code;
3123
+ }
3124
+ //#endregion
3125
+ //#region src/resolvers/source-resolution.ts
3126
+ /**
3127
+ * Resolves the final configuration rule that claims one logical asset.
3128
+ *
3129
+ * Category ownership is checked first, so asset rules receive the physical
3130
+ * path relative to that directory. Final source-relative exclusions then veto
3131
+ * selection before the last matching rule supplies processor options. Most
3132
+ * assets use the same physical and logical path; localized sprites pass their
3133
+ * canonical path separately so every language variant shares one exclusion
3134
+ * identity.
3135
+ */
3136
+ function resolveAssetRule(context, category, sourcePath, logicalPath = sourcePath) {
3137
+ const categoryPath = getCategoryPath(sourcePath, category);
3138
+ if (categoryPath === void 0) return;
3139
+ if (matchesAnyGlob(logicalPath, context.config.exclude)) return;
3140
+ return context.config.assets[category].findLast((rule) => matchesRule(rule, categoryPath));
3141
+ }
3142
+ /**
3143
+ * Chooses the runtime bundle that will contain one resolved asset.
3144
+ *
3145
+ * Every asset belongs to `primary` unless it matches the optional
3146
+ * `bundles.secondary` selection. This fixed contract mirrors playable loading:
3147
+ * primary assets are available at startup, while secondary assets may be loaded
3148
+ * later.
3149
+ *
3150
+ * For example, given:
3151
+ *
3152
+ * ```ts
3153
+ * bundles: {
3154
+ * secondary: {
3155
+ * include: ['sprites/deferred/**', 'sounds/deferred/**'],
3156
+ * exclude: ['sprites/deferred/debug/**'],
3157
+ * },
3158
+ * }
3159
+ * ```
3160
+ *
3161
+ * `sprites/player.png` and `sprites/deferred/debug/grid.png` resolve to
3162
+ * `primary`; `sprites/deferred/logo.png` resolves to `secondary`.
3163
+ */
3164
+ function resolveBundle(config, relativePath) {
3165
+ const secondary = config.bundles.secondary;
3166
+ if (secondary !== void 0 && matchesSecondaryBundle(secondary, relativePath)) return "secondary";
3167
+ return "primary";
3168
+ }
3169
+ /**
3170
+ * Removes the category directory from a path already claimed by that category.
3171
+ *
3172
+ * For example, `sprites/ui/button.png` becomes `ui/button.png` for the
3173
+ * `sprites` category. `resolveAssetRule` establishes this ownership before a
3174
+ * resolver constructs runtime identity or output paths.
3175
+ */
3176
+ function stripCategoryDirectory(path, category) {
3177
+ return path.slice(category.length + 1);
3178
+ }
3179
+ /**
3180
+ * Returns whether a source file uses a format supported by one asset category.
3181
+ *
3182
+ * Configuration rules select logical assets, not file encodings. Keeping the
3183
+ * format contract here lets a general sound rule omit `match` while accepting
3184
+ * authored audio and ignoring unrelated files in the same directory.
3185
+ */
3186
+ function isSupportedSourceFile(category, path) {
3187
+ switch (category) {
3188
+ case "atlases":
3189
+ case "sprites":
3190
+ case "textures": return isImageFile(path);
3191
+ case "fonts": return /\.(?:otf|ttf|woff2?)$/i.test(path);
3192
+ case "locales": return /\.jsonc?$/i.test(path);
3193
+ case "shaders": return isShaderSourceFile(path);
3194
+ case "sounds": return /\.(?:m4a|mp3|ogg|wav)$/i.test(path);
3195
+ case "spines": return isImageFile(path) || isSpineAtlasFile(path) || isSpineSkeletonFile(path);
3196
+ default: return unsupportedSourceCategory(category);
3197
+ }
3198
+ }
3199
+ /** Returns a path relative to its category directory, or undefined when outside it. */
3200
+ function getCategoryPath(path, category) {
3201
+ const categoryDirectory = `${category}/`;
3202
+ return path.startsWith(categoryDirectory) ? path.slice(categoryDirectory.length) : void 0;
3203
+ }
3204
+ function isImageFile(path) {
3205
+ return /\.(?:avif|jpe?g|png|webp)$/i.test(path);
3206
+ }
3207
+ function isShaderSourceFile(path) {
3208
+ return /\.glsl$/i.test(path);
3209
+ }
3210
+ function isSpineAtlasFile(path) {
3211
+ return /\.atlas$/i.test(path);
3212
+ }
3213
+ function isSpineSkeletonFile(path) {
3214
+ return /\.(?:json|skel)$/i.test(path);
3215
+ }
3216
+ function matchesRule(rule, relativePath) {
3217
+ if (!matchesGlob(relativePath, rule.match)) return false;
3218
+ if (rule.exclude !== void 0 && matchesAnyGlob(relativePath, rule.exclude)) return false;
3219
+ return true;
3220
+ }
3221
+ /** Keeps source-format handling exhaustive when a new asset category is added. */
3222
+ function unsupportedSourceCategory(category) {
3223
+ throw new Error(`Unsupported source asset category: ${String(category)}.`);
3224
+ }
3225
+ /**
3226
+ * Determines whether the secondary selection claims a source-relative path.
3227
+ *
3228
+ * Patterns in `include` use OR semantics: matching any one of them selects the
3229
+ * path as a candidate. Patterns in `exclude` are vetoes: matching any one of
3230
+ * them rejects the candidate even when an `include` pattern matched.
3231
+ *
3232
+ * For example:
3233
+ *
3234
+ * ```ts
3235
+ * {
3236
+ * include: ['sprites/deferred/**', 'sounds/deferred/**'],
3237
+ * exclude: ['sprites/deferred/debug/**'],
3238
+ * }
3239
+ * ```
3240
+ *
3241
+ * accepts `sprites/deferred/logo.png` and `sounds/deferred/music.wav`, but
3242
+ * rejects both `sprites/player.png` (not included) and
3243
+ * `sprites/deferred/debug/grid.png`
3244
+ * (explicitly excluded).
3245
+ */
3246
+ function matchesSecondaryBundle(secondary, relativePath) {
3247
+ if (!matchesAnyGlob(relativePath, secondary.include)) return false;
3248
+ if (secondary.exclude !== void 0 && matchesAnyGlob(relativePath, secondary.exclude)) return false;
3249
+ return true;
3250
+ }
3251
+ /** Returns whether a path matches at least one glob in a pattern collection. */
3252
+ function matchesAnyGlob(relativePath, patterns) {
3253
+ return patterns.some((pattern) => matchesGlob(relativePath, pattern));
3254
+ }
3255
+ //#endregion
3256
+ //#region src/resolvers/categories/atlas.ts
3257
+ /**
3258
+ * Resolves each matched image directory into one logical atlas.
3259
+ *
3260
+ * For example, `atlases/ui/button.png` and `atlases/ui/icon.png` become one
3261
+ * `ui` atlas containing both absolute image paths. The processor later packs
3262
+ * that logical atlas into one or more generated sheets.
3263
+ */
3264
+ function resolveAtlases(context) {
3265
+ return groupAtlasImages(context).map((group) => createResolvedAtlas(context, group));
3266
+ }
3267
+ /** Creates the logical atlas value consumed by the texture-packing processor. */
3268
+ function createResolvedAtlas(context, group) {
3269
+ const category = "atlases";
3270
+ const id = stripCategoryDirectory(group.directory, category);
3271
+ return {
3272
+ atlas: {
3273
+ id,
3274
+ images: group.images.map((image) => image.absolutePath)
3275
+ },
3276
+ bundle: resolveBundle(context.config, group.directory),
3277
+ category,
3278
+ options: group.rule.options,
3279
+ outputDirectory: resolve(context.outputRoot, category, id),
3280
+ relativePath: group.directory
3281
+ };
3282
+ }
3283
+ /** Groups image directories selected by an atlas rule. */
3284
+ function groupAtlasImages(context) {
3285
+ const imageFiles = context.files.filter((file) => isSupportedSourceFile("atlases", file.relativePath));
3286
+ return [...Map.groupBy(imageFiles, (file) => dirname$1(file.relativePath))].map(([directory, images]) => createAtlasSourceGroup(context, directory, images)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
3287
+ }
3288
+ /** Creates one ordered source group when its directory matches an atlas rule. */
3289
+ function createAtlasSourceGroup(context, directory, images) {
3290
+ const rule = resolveAssetRule(context, "atlases", directory);
3291
+ if (rule === void 0) return;
3292
+ return {
3293
+ directory,
3294
+ images: [...images].sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
3295
+ rule
3296
+ };
3297
+ }
3298
+ //#endregion
3299
+ //#region src/resolvers/font-charset.ts
3300
+ const DEFAULT_FONT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
3301
+ /**
3302
+ * Collects the unique Unicode characters used by resolved locale values.
3303
+ *
3304
+ * Locale keys are metadata, so they do not contribute to a font subset. The
3305
+ * resolver has already selected the build language or its fallback; this
3306
+ * function therefore sees only text that can appear in the current build.
3307
+ *
3308
+ * Characters retain their first-seen order across files and phrases. For
3309
+ * example, `{ play: 'Խաղալ' }` contributes `Խաղալ`, while `play` contributes
3310
+ * no characters.
3311
+ */
3312
+ function collectLocaleCharacters(dictionaries) {
3313
+ const characters = /* @__PURE__ */ new Set();
3314
+ for (const dictionary of dictionaries) for (const translation of Object.values(dictionary)) for (const character of translation) characters.add(character);
3315
+ return [...characters].join("");
3316
+ }
3317
+ /**
3318
+ * Creates the complete subset charset for one generated font.
3319
+ *
3320
+ * Every font starts with printable ASCII so common UI text, numbers, and
3321
+ * punctuation remain available even when they are absent from locale files.
3322
+ * Characters used by the selected locale come next, followed by characters
3323
+ * explicitly requested for this font. Duplicate code points retain the
3324
+ * position of their first occurrence.
3325
+ */
3326
+ function createFontCharset(localeCharacters, extraCharacters) {
3327
+ const characters = /* @__PURE__ */ new Set(DEFAULT_FONT_CHARSET);
3328
+ for (const character of localeCharacters) characters.add(character);
3329
+ for (const character of extraCharacters ?? "") characters.add(character);
3330
+ return [...characters].join("");
3331
+ }
3332
+ //#endregion
3333
+ //#region src/resolvers/simple-sources.ts
3334
+ /**
3335
+ * Resolves physical files selected by one simple asset category.
3336
+ *
3337
+ * Every category receives the same discovered source inventory. Unsupported
3338
+ * formats are ignored first, so a rule without `match` can select every
3339
+ * supported source. A remaining file becomes a font, sound, sprite, or texture
3340
+ * only when one of that category's rules matches its path relative to that
3341
+ * category directory. When several rules match, the final rule supplies the
3342
+ * processing options.
3343
+ *
3344
+ * The returned output path has no extension because the category processor
3345
+ * decides the final format. For example, resolving `sounds/ui/click.wav` for
3346
+ * the `sounds` category beneath `/project/assets/generated` produces:
3347
+ *
3348
+ * ```ts
3349
+ * {
3350
+ * relativePath: 'sounds/ui/click.wav',
3351
+ * outputBasePath: '/project/assets/generated/sounds/ui/click',
3352
+ * bundle: 'primary',
3353
+ * options: { bitrate: 96, channels: 'mono', sampleRate: 32000 },
3354
+ * }
3355
+ * ```
3356
+ *
3357
+ * Removing the owned `sounds` directory prevents the generated path from
3358
+ * becoming `/project/assets/generated/sounds/sounds/ui/click`. Files outside
3359
+ * that directory cannot resolve as sounds.
3360
+ */
3361
+ function resolveSimpleSources(context, category) {
3362
+ const sources = [];
3363
+ for (const file of context.files) {
3364
+ if (!isSupportedSourceFile(category, file.relativePath)) continue;
3365
+ const rule = resolveAssetRule(context, category, file.relativePath);
3366
+ if (rule === void 0) continue;
3367
+ const categoryPath = stripCategoryDirectory(file.relativePath, category);
3368
+ sources.push({
3369
+ ...file,
3370
+ bundle: resolveBundle(context.config, file.relativePath),
3371
+ options: rule.options,
3372
+ outputBasePath: resolve(context.outputRoot, category, removeExtension(categoryPath))
3373
+ });
3374
+ }
3375
+ return sources;
3376
+ }
3377
+ //#endregion
3378
+ //#region src/resolvers/categories/font.ts
3379
+ /** Resolves matched fonts with the complete charset required by the locale build. */
3380
+ function resolveFonts(context, localeCharacters) {
3381
+ return resolveSimpleSources(context, "fonts").map((source) => ({
3382
+ ...source,
3383
+ category: "fonts",
3384
+ charset: createFontCharset(localeCharacters, source.options.extraCharacters)
3385
+ }));
3386
+ }
3387
+ //#endregion
3388
+ //#region src/resolvers/locale-dictionary.ts
3389
+ /**
3390
+ * Parses one locale source and selects the build language or its fallback.
3391
+ *
3392
+ * This function performs no filesystem work. Locale emission and automatic
3393
+ * font subsetting can therefore consume the same fixed-language dictionary
3394
+ * without parsing or resolving translations independently.
3395
+ */
3396
+ function resolveLocaleDictionary(source, relativePath, localization) {
3397
+ const { fallback, language } = localization;
3398
+ const errors = [];
3399
+ const parsed = parse(source, errors, { allowTrailingComma: true });
3400
+ if (errors.length > 0) throw localeParseError(relativePath, source, errors[0]);
3401
+ if (parsed === void 0) throw new Error(`Locale is empty: ${relativePath}`);
3402
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`Locale must be an object: ${relativePath}`);
3403
+ return Object.fromEntries(Object.entries(parsed).map(([key, translations]) => {
3404
+ if (typeof translations !== "object" || translations === null || Array.isArray(translations)) throw new Error(`Locale phrase ${JSON.stringify(key)} must contain language values.`);
3405
+ const selectedLanguage = language in translations ? language : fallback;
3406
+ if (!(selectedLanguage in translations)) throw new Error(`Locale phrase ${JSON.stringify(key)} is missing language ${JSON.stringify(language)} and fallback ${JSON.stringify(fallback)}.`);
3407
+ const selectedTranslation = translations[selectedLanguage];
3408
+ if (typeof selectedTranslation !== "string") throw new Error(`Locale phrase ${JSON.stringify(key)} language ${JSON.stringify(selectedLanguage)} must be a string.`);
3409
+ return [key, selectedTranslation];
3410
+ }));
3411
+ }
3412
+ function localeParseError(relativePath, source, error) {
3413
+ if (error === void 0) return /* @__PURE__ */ new Error(`Invalid locale: ${relativePath}`);
3414
+ const beforeError = source.slice(0, error.offset);
3415
+ const line = beforeError.split("\n").length;
3416
+ const lastNewline = beforeError.lastIndexOf("\n");
3417
+ const column = error.offset - lastNewline;
3418
+ return /* @__PURE__ */ new Error(`Invalid locale ${relativePath}:${line}:${column}: ${printParseErrorCode(error.error)}`);
3419
+ }
3420
+ //#endregion
3421
+ //#region src/resolvers/categories/locale.ts
3422
+ /** Resolves the one locale source selected by the project's locale rules. */
3423
+ async function resolveLocales(context) {
3424
+ const sources = resolveLocaleSources(context);
3425
+ const source = sources[0];
3426
+ if (source === void 0) return [];
3427
+ if (sources.length > 1) throw new Error(`Locale rules must match at most one translation file; received ${sources.length}.`);
3428
+ return [await resolveLocaleSource(source, context.config.localization)];
3429
+ }
3430
+ /** Selects locale files without mixing singular locale behavior into simple-source resolution. */
3431
+ function resolveLocaleSources(context) {
3432
+ const sources = [];
3433
+ for (const file of context.files) {
3434
+ if (!isSupportedSourceFile("locales", file.relativePath)) continue;
3435
+ if (resolveAssetRule(context, "locales", file.relativePath) === void 0) continue;
3436
+ const localePath = stripCategoryDirectory(file.relativePath, "locales");
3437
+ sources.push({
3438
+ ...file,
3439
+ bundle: resolveBundle(context.config, file.relativePath),
3440
+ outputBasePath: resolve(context.outputRoot, "locales", removeExtension(localePath))
3441
+ });
3442
+ }
3443
+ return sources;
3444
+ }
3445
+ /** Reads and resolves the selected locale into one fixed-language runtime asset. */
3446
+ async function resolveLocaleSource(source, localization) {
3447
+ const resolvedLocale = resolveLocaleDictionary(await readFile(source.absolutePath, "utf8"), source.relativePath, localization);
3448
+ return {
3449
+ ...source,
3450
+ category: "locales",
3451
+ resolvedLocale
3452
+ };
3453
+ }
3454
+ //#endregion
3455
+ //#region src/resolvers/categories/shader.ts
3456
+ /**
3457
+ * Resolves each matched directory containing `vert.glsl` and `frag.glsl` into
3458
+ * one shader asset. For example, `shaders/glow/{vert,frag}.glsl` becomes the
3459
+ * runtime shader ID `glow`.
3460
+ */
3461
+ function resolveShaders(context) {
3462
+ return groupShaderSources(context).map((group) => createResolvedShader(context, group));
3463
+ }
3464
+ /** Creates the build instruction for one complete shader source pair. */
3465
+ function createResolvedShader(context, group) {
3466
+ const category = "shaders";
3467
+ const id = stripCategoryDirectory(group.directory, category);
3468
+ return {
3469
+ bundle: resolveBundle(context.config, group.directory),
3470
+ category,
3471
+ outputDirectory: resolve(context.outputRoot, category, id),
3472
+ relativePath: group.directory,
3473
+ shader: {
3474
+ frag: group.frag.absolutePath,
3475
+ id,
3476
+ vert: group.vert.absolutePath
3477
+ }
3478
+ };
3479
+ }
3480
+ /** Finds complete shader directories selected by the configured rules. */
3481
+ function groupShaderSources(context) {
3482
+ const shaderFiles = context.files.filter((file) => isSupportedSourceFile("shaders", file.relativePath));
3483
+ return [...Map.groupBy(shaderFiles, (file) => dirname$1(file.relativePath))].map(([directory, directoryFiles]) => createShaderSourceGroup(context, directory, directoryFiles)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
3484
+ }
3485
+ /** Creates one complete source pair when its directory matches a shader rule. */
3486
+ function createShaderSourceGroup(context, directory, files) {
3487
+ if (resolveAssetRule(context, "shaders", directory) === void 0) return;
3488
+ const vert = files.find((file) => basename$1(file.relativePath) === "vert.glsl");
3489
+ const frag = files.find((file) => basename$1(file.relativePath) === "frag.glsl");
3490
+ if (vert === void 0 || frag === void 0) throw new Error(`Shader ${directory} must contain vert.glsl and frag.glsl.`);
3491
+ return {
3492
+ directory,
3493
+ frag,
3494
+ vert
3495
+ };
3496
+ }
3497
+ //#endregion
3498
+ //#region src/resolvers/categories/sound.ts
3499
+ /** Resolves matched sound sources and their normalized encoding options. */
3500
+ function resolveSounds(context) {
3501
+ return resolveSimpleSources(context, "sounds").map((source) => ({
3502
+ ...source,
3503
+ category: "sounds"
3504
+ }));
3505
+ }
3506
+ //#endregion
3507
+ //#region src/adapters/spine-atlas-parser.ts
3508
+ /**
3509
+ * Parses the texture pages declared by an authored Spine atlas.
3510
+ *
3511
+ * Replayable uses Spine's official runtime parser so page boundaries and names
3512
+ * follow the same rules as the eventual runtime loader. Returned names preserve
3513
+ * declaration order because the processor emits its texture array in that exact
3514
+ * order: the first atlas page becomes `images[0]`, the second becomes
3515
+ * `images[1]`, and so on.
3516
+ *
3517
+ * `TextureAtlas` owns runtime objects even though Replayable only needs their
3518
+ * page names. The `finally` block releases those objects after names have been
3519
+ * copied, including when reading the pages unexpectedly throws.
3520
+ *
3521
+ * @param source - Complete text of the authored `.atlas` file.
3522
+ * @param sourcePath - Source-relative path used only in parse diagnostics.
3523
+ * @returns Texture page names in their authored declaration order.
3524
+ * @throws A source-specific error when the official Spine parser rejects the atlas.
3525
+ *
3526
+ * @example
3527
+ *
3528
+ * ```ts
3529
+ * const pageNames = parseSpineAtlasPageNames(atlasSource, 'spines/hero/hero.atlas');
3530
+ *
3531
+ * // hero.atlas declares body.png first and effects.png second.
3532
+ * console.log(pageNames); // ['body.png', 'effects.png']
3533
+ * ```
3534
+ */
3535
+ function parseSpineAtlasPageNames(source, sourcePath) {
3536
+ const atlas = createTextureAtlas(source, sourcePath);
3537
+ try {
3538
+ return atlas.pages.map((page) => page.name);
3539
+ } finally {
3540
+ atlas.dispose();
3541
+ }
3542
+ }
3543
+ /** Creates the official runtime atlas and adds the source path to parse failures. */
3544
+ function createTextureAtlas(source, sourcePath) {
3545
+ try {
3546
+ return new TextureAtlas(source);
3547
+ } catch (cause) {
3548
+ throw new Error(`Invalid Spine atlas: ${sourcePath}`, { cause });
3549
+ }
3550
+ }
3551
+ //#endregion
3552
+ //#region src/resolvers/categories/spine.ts
3553
+ /**
3554
+ * Resolves each matched Spine directory into one complete build instruction.
3555
+ *
3556
+ * Source grouping validates the skeleton and atlas structure first. Atlas page
3557
+ * declarations are then read concurrently because each Spine export is
3558
+ * independent, while `Promise.all` preserves the stable group order.
3559
+ */
3560
+ async function resolveSpines(context) {
3561
+ const groups = groupSpineSources(context);
3562
+ return Promise.all(groups.map((group) => createResolvedSpine(context, group)));
3563
+ }
3564
+ /** Finds complete Spine directories selected by the configured rules. */
3565
+ function groupSpineSources(context) {
3566
+ const spineFiles = context.files.filter((file) => isSupportedSourceFile("spines", file.relativePath));
3567
+ return [...Map.groupBy(spineFiles, (file) => dirname$1(file.relativePath))].map(([directory, directoryFiles]) => createSpineSourceGroup(context, directory, directoryFiles)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
3568
+ }
3569
+ /** Creates one complete source group when its directory matches a Spine rule. */
3570
+ function createSpineSourceGroup(context, directory, files) {
3571
+ const rule = resolveAssetRule(context, "spines", directory);
3572
+ if (rule === void 0) return;
3573
+ const skeleton = selectOnlySkeleton(directory, files);
3574
+ return {
3575
+ atlas: selectOnlyAtlas(directory, files),
3576
+ directory,
3577
+ images: files.filter((file) => isImageFile(file.relativePath)),
3578
+ options: rule.options,
3579
+ skeleton
3580
+ };
3581
+ }
3582
+ /** Reads one Spine atlas and constructs its processor-ready build instruction. */
3583
+ async function createResolvedSpine(context, group) {
3584
+ const pageNames = parseSpineAtlasPageNames(await readFile(group.atlas.absolutePath, "utf8"), group.atlas.relativePath);
3585
+ if (pageNames.length === 0) throw new Error(`Spine atlas ${group.atlas.relativePath} contains no texture pages.`);
3586
+ const pageImages = resolveTexturePages(group.atlas, pageNames, group.images);
3587
+ const id = stripCategoryDirectory(group.directory, "spines");
3588
+ return {
3589
+ bundle: resolveBundle(context.config, group.directory),
3590
+ category: "spines",
3591
+ options: group.options,
3592
+ outputDirectory: resolve(context.outputRoot, "spines", id),
3593
+ spine: {
3594
+ atlas: group.atlas.absolutePath,
3595
+ id,
3596
+ images: pageImages.map((image) => image.absolutePath),
3597
+ skeleton: {
3598
+ format: group.skeleton.format,
3599
+ path: group.skeleton.absolutePath
3600
+ }
3601
+ },
3602
+ relativePath: group.directory
3603
+ };
3604
+ }
3605
+ /** Requires exactly one JSON or SKEL skeleton in a selected Spine directory. */
3606
+ function selectOnlySkeleton(directory, files) {
3607
+ const skeletons = files.filter((file) => isSpineSkeletonFile(file.relativePath));
3608
+ const skeleton = skeletons[0];
3609
+ if (skeleton === void 0 || skeletons.length > 1) throw new Error(`Spine ${directory} must contain exactly one matched JSON or SKEL skeleton; found ${skeletons.length}.`);
3610
+ return {
3611
+ ...skeleton,
3612
+ format: resolveSpineSkeletonFormat(skeleton.relativePath)
3613
+ };
3614
+ }
3615
+ /** Converts a supported skeleton extension into its runtime loading format. */
3616
+ function resolveSpineSkeletonFormat(relativePath) {
3617
+ switch (extname$1(relativePath).toLowerCase()) {
3618
+ case ".json": return "json";
3619
+ case ".skel": return "skel";
3620
+ default: throw new Error(`Unsupported Spine skeleton: ${relativePath}`);
3621
+ }
3622
+ }
3623
+ /** Requires exactly one atlas description in a selected Spine directory. */
3624
+ function selectOnlyAtlas(directory, files) {
3625
+ const atlases = files.filter((file) => isSpineAtlasFile(file.relativePath));
3626
+ const atlas = atlases[0];
3627
+ if (atlas === void 0 || atlases.length > 1) throw new Error(`Spine ${directory} must contain exactly one .atlas file; found ${atlases.length}.`);
3628
+ return atlas;
3629
+ }
3630
+ /** Resolves official atlas page declarations to sibling source images in order. */
3631
+ function resolveTexturePages(atlas, pageNames, images) {
3632
+ const imageFilesByName = new Map(images.map((image) => [basename$1(image.relativePath), image]));
3633
+ const resolvedImages = [];
3634
+ const resolvedNames = /* @__PURE__ */ new Set();
3635
+ for (const pageName of pageNames) {
3636
+ const imageName = basename$1(pageName);
3637
+ if (resolvedNames.has(imageName)) throw new Error(`Spine atlas ${atlas.relativePath} declares duplicate page ${pageName}.`);
3638
+ const image = imageFilesByName.get(imageName);
3639
+ if (image === void 0) throw new Error(`Spine atlas ${atlas.relativePath} references missing page ${pageName}.`);
3640
+ resolvedNames.add(imageName);
3641
+ resolvedImages.push(image);
3642
+ }
3643
+ return resolvedImages;
3644
+ }
3645
+ //#endregion
3646
+ //#region src/resolvers/categories/sprite.ts
3647
+ /**
3648
+ * Selects exactly one sprite source for the configured build language.
3649
+ *
3650
+ * Locale suffixes are removed from the logical asset path before variants are
3651
+ * grouped. For example, `logo.hy.png`, `logo.en.png`, and `logo.png` all map to
3652
+ * `logo.png`. Selection prefers the requested language, then the configured
3653
+ * fallback language, then the unlocalized file.
3654
+ */
3655
+ function resolveSprites(context) {
3656
+ return groupSpriteVariants(context).map((group) => {
3657
+ return createResolvedSprite(context, group, selectSpriteVariant(group, context.config.localization));
3658
+ });
3659
+ }
3660
+ /**
3661
+ * Groups matched physical images by the canonical path shared by their locale variants.
3662
+ *
3663
+ * For example, this source inventory:
3664
+ *
3665
+ * ```text
3666
+ * sprites/background.png
3667
+ * sprites/logo.hy.png
3668
+ * sprites/logo.en.png
3669
+ * sprites/logo.png
3670
+ * textures/normal.png
3671
+ * sprites/README.md
3672
+ * ```
3673
+ *
3674
+ * produces the equivalent of:
3675
+ *
3676
+ * ```ts
3677
+ * [
3678
+ * {
3679
+ * canonicalPath: 'sprites/background.png',
3680
+ * variants: ['sprites/background.png'],
3681
+ * },
3682
+ * {
3683
+ * canonicalPath: 'sprites/logo.png',
3684
+ * variants: [
3685
+ * 'sprites/logo.en.png',
3686
+ * 'sprites/logo.hy.png',
3687
+ * 'sprites/logo.png',
3688
+ * ],
3689
+ * },
3690
+ * ]
3691
+ * ```
3692
+ *
3693
+ * `textures/normal.png` is discarded when no sprite rule selects it, and the
3694
+ * non-image README is ignored before rule matching. Both groups and variants
3695
+ * use stable path ordering. This function only forms the groups;
3696
+ * `selectSpriteVariant` later chooses the language-specific source included in
3697
+ * the fixed-language build.
3698
+ */
3699
+ function groupSpriteVariants(context) {
3700
+ const variants = context.files.filter((file) => isSupportedSourceFile("sprites", file.relativePath)).map((file) => matchSpriteVariant(context, file)).filter((variant) => variant !== void 0);
3701
+ return [...Map.groupBy(variants, (variant) => variant.canonicalPath)].map(([canonicalPath, groupedVariants]) => ({
3702
+ canonicalPath,
3703
+ variants: [...groupedVariants].sort((left, right) => left.relativePath.localeCompare(right.relativePath))
3704
+ })).sort((left, right) => left.canonicalPath.localeCompare(right.canonicalPath));
3705
+ }
3706
+ /**
3707
+ * Attempts to classify one discovered image as a configured sprite variant.
3708
+ *
3709
+ * The shared source inventory contains images belonging to atlases, Spine
3710
+ * exports, textures, and sprites. An image becomes a sprite variant only when
3711
+ * at least one sprite rule matches its physical source path. If several rules
3712
+ * match, `resolveAssetRule` supplies options from the final matching rule unless
3713
+ * the logical sprite is excluded.
3714
+ *
3715
+ * Locale parsing then separates physical identity from logical identity. For
3716
+ * example, `sprites/logo.hy.png` retains that physical `relativePath`, but its
3717
+ * `canonicalPath` becomes `sprites/logo.png` and its locale becomes `hy`.
3718
+ * Returning `undefined` tells the grouping stage to discard an image that no
3719
+ * sprite rule selected.
3720
+ */
3721
+ function matchSpriteVariant(context, file) {
3722
+ const parsedPath = parseSpritePath(file.relativePath);
3723
+ const rule = resolveAssetRule(context, "sprites", file.relativePath, parsedPath.canonicalPath);
3724
+ if (rule === void 0) return;
3725
+ return {
3726
+ ...file,
3727
+ canonicalPath: parsedPath.canonicalPath,
3728
+ locale: parsedPath.locale,
3729
+ options: rule.options
3730
+ };
3731
+ }
3732
+ /**
3733
+ * Selects the one physical variant included in the fixed-language build.
3734
+ *
3735
+ * Selection follows the configured priority exactly:
3736
+ *
3737
+ * 1. A variant matching `localization.language`.
3738
+ * 2. A variant matching `localization.fallback`.
3739
+ * 3. An unlocalized variant without a locale suffix.
3740
+ *
3741
+ * Continuing the `groupSpriteVariants` example, its `sprites/logo.png` group
3742
+ * contains Armenian, English, and unlocalized variants. Given:
3743
+ *
3744
+ * ```ts
3745
+ * { language: 'hy', fallback: 'en' }
3746
+ * ```
3747
+ *
3748
+ * this function returns the equivalent of:
3749
+ *
3750
+ * ```ts
3751
+ * {
3752
+ * canonicalPath: 'sprites/logo.png',
3753
+ * relativePath: 'sprites/logo.hy.png',
3754
+ * locale: 'hy',
3755
+ * // absolutePath and processing options belong to this Armenian source.
3756
+ * }
3757
+ * ```
3758
+ *
3759
+ * If `logo.hy.png` is absent, the same group returns `logo.en.png`. If both
3760
+ * localized variants are absent, it returns `logo.png`. A group containing
3761
+ * only `logo.fr.png` fails because Replayable must not silently include an
3762
+ * unrelated language.
3763
+ *
3764
+ * The returned variant retains the processing options and physical path of
3765
+ * the rule that selected that specific localized source.
3766
+ */
3767
+ function selectSpriteVariant(group, localization) {
3768
+ const languageVariant = group.variants.find((variant) => variant.locale === localization.language);
3769
+ if (languageVariant !== void 0) return languageVariant;
3770
+ const fallbackVariant = group.variants.find((variant) => variant.locale === localization.fallback);
3771
+ if (fallbackVariant !== void 0) return fallbackVariant;
3772
+ const unlocalizedVariant = group.variants.find((variant) => variant.locale === void 0);
3773
+ if (unlocalizedVariant !== void 0) return unlocalizedVariant;
3774
+ throw new Error(`Localized sprite ${group.canonicalPath} has no ${localization.language}, ${localization.fallback}, or unlocalized variant.`);
3775
+ }
3776
+ /** Creates the single resolved sprite selected for the fixed-language build. */
3777
+ function createResolvedSprite(context, group, selectedVariant) {
3778
+ const category = "sprites";
3779
+ const categoryPath = stripCategoryDirectory(group.canonicalPath, category);
3780
+ return {
3781
+ absolutePath: selectedVariant.absolutePath,
3782
+ bundle: resolveBundle(context.config, selectedVariant.relativePath),
3783
+ category,
3784
+ options: selectedVariant.options,
3785
+ outputBasePath: resolve(context.outputRoot, category, removeExtension(categoryPath)),
3786
+ relativePath: group.canonicalPath
3787
+ };
3788
+ }
3789
+ /**
3790
+ * Removes an optional locale suffix while preserving the physical extension.
3791
+ *
3792
+ * `sprites/logo.hy.png` becomes `{ canonicalPath: 'sprites/logo.png', locale:
3793
+ * 'hy' }`, while `sprites/logo.png` keeps its path and has no locale. The
3794
+ * canonical path allows every localized source to join the same variant group.
3795
+ */
3796
+ function parseSpritePath(path) {
3797
+ const fileName = basename$1(path);
3798
+ const match = /^(.*)\.([a-z]{2}(?:-[A-Z]{2})?)\.([^.]+)$/.exec(fileName);
3799
+ if (match === null) return {
3800
+ canonicalPath: path,
3801
+ locale: void 0
3802
+ };
3803
+ const directory = dirname$1(path);
3804
+ const canonicalName = `${match[1]}.${match[3]}`;
3805
+ return {
3806
+ canonicalPath: directory === "." ? canonicalName : `${directory}/${canonicalName}`,
3807
+ locale: match[2]
3808
+ };
3809
+ }
3810
+ //#endregion
3811
+ //#region src/resolvers/categories/texture.ts
3812
+ /** Resolves matched non-localized texture sources. */
3813
+ function resolveTextures(context) {
3814
+ return resolveSimpleSources(context, "textures").map((source) => ({
3815
+ ...source,
3816
+ category: "textures"
3817
+ }));
3818
+ }
3819
+ //#endregion
3820
+ //#region src/resolvers/resolution-context.ts
3821
+ /** Narrows complete build state to the inputs available during source resolution. */
3822
+ function createResolutionContext(config, context, files) {
3823
+ return {
3824
+ config,
3825
+ files,
3826
+ outputRoot: context.outputRoot
3827
+ };
3828
+ }
3829
+ //#endregion
3830
+ //#region src/resolvers/resolve-assets.ts
3831
+ /**
3832
+ * Discovers source files and converts them into self-contained build instructions.
3833
+ *
3834
+ * Locales resolve first because their selected text determines the characters
3835
+ * retained by every generated font. All other categories are independent.
3836
+ */
3837
+ async function resolveAssets(config, context) {
3838
+ const resolution = createResolutionContext(config, context, await discoverSourceFiles(context.sourceRoot));
3839
+ const locales = await resolveLocales(resolution);
3840
+ const localeCharacters = collectLocaleCharacters(locales.map((locale) => locale.resolvedLocale));
3841
+ const atlases = resolveAtlases(resolution);
3842
+ const fonts = resolveFonts(resolution, localeCharacters);
3843
+ const shaders = resolveShaders(resolution);
3844
+ const sounds = resolveSounds(resolution);
3845
+ const spines = await resolveSpines(resolution);
3846
+ const sprites = resolveSprites(resolution);
3847
+ const textures = resolveTextures(resolution);
3848
+ return [
3849
+ ...atlases,
3850
+ ...fonts,
3851
+ ...locales,
3852
+ ...shaders,
3853
+ ...sounds,
3854
+ ...spines,
3855
+ ...sprites,
3856
+ ...textures
3857
+ ];
3858
+ }
3859
+ //#endregion
3860
+ //#region src/build-assets.ts
3861
+ /**
3862
+ * Builds every configured asset and generates the runtime assets module.
3863
+ *
3864
+ * @param input - Author-written or previously normalized asset configuration.
3865
+ * @param workingDirectory - Project root used to resolve every configured path.
3866
+ * Defaults to the current process directory.
3867
+ * @returns Logical asset and physical file counts, populated bundles, and the
3868
+ * absolute output directory.
3869
+ * @throws When configuration is invalid, configured paths are unsafe, or an
3870
+ * asset cannot be resolved, processed, or emitted.
3871
+ *
3872
+ * @example
3873
+ *
3874
+ * ```ts
3875
+ * const result = await buildAssets(config, process.cwd());
3876
+ *
3877
+ * console.log(result.emittedAssets);
3878
+ * console.log(result.emittedFiles);
3879
+ * console.log(result.outputDirectory);
3880
+ * ```
3881
+ */
3882
+ async function buildAssets(input, workingDirectory = process.cwd()) {
3883
+ const config = assetConfigSchema.parse(input);
3884
+ const context = createBuildContext(config, workingDirectory);
3885
+ const staging = await createBuildStaging(context, workingDirectory);
3886
+ try {
3887
+ const resolved = await resolveAssets(config, staging.context);
3888
+ validateOutputClaims(resolved);
3889
+ const assetGroups = groupAssets(await processAssets(resolved));
3890
+ await emitBuildOutputs(staging.context, assetGroups);
3891
+ await staging.commit();
3892
+ return createBuildResult(context, assetGroups);
3893
+ } finally {
3894
+ await staging.dispose();
3895
+ }
3896
+ }
3897
+ //#endregion
3898
+ //#region src/config/define-config.ts
3899
+ /**
3900
+ * Validates an authored asset configuration and applies schema defaults.
3901
+ *
3902
+ * Invalid fields or values throw when the configuration module loads. Valid
3903
+ * input is returned with trimmed strings, normalized category arrays, and all
3904
+ * processor defaults applied.
3905
+ *
3906
+ * @param config - Author-written configuration to validate and normalize.
3907
+ * @returns A validated configuration with schema defaults applied.
3908
+ * @throws When any configuration value fails schema validation.
3909
+ *
3910
+ * @example
3911
+ *
3912
+ * ```ts
3913
+ * import { defineConfig } from '@replayablejs/assets';
3914
+ *
3915
+ * export default defineConfig({
3916
+ * assets: {
3917
+ * sounds: [{ match: '**' }],
3918
+ * },
3919
+ * emit: { assets: 'src/assets/assets.gen.ts' },
3920
+ * localization: { language: 'en', fallback: 'en' },
3921
+ * outDir: 'assets/out',
3922
+ * sourceDir: 'assets/source',
3923
+ * });
3924
+ * ```
3925
+ */
3926
+ function defineConfig(config) {
3927
+ return assetConfigSchema.parse(config);
3928
+ }
3929
+ //#endregion
3930
+ export { assetBundleNames, assetCategories, assetConfigSchema, buildAssets, defineConfig, groupedAssetCategories, simpleAssetCategories };
3931
+
3932
+ //# sourceMappingURL=index.mjs.map