ravensight-playtest 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,337 @@
1
+ import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { decodePng, parseOgg, parseWav, pngHasAlphaChannel, readPngHeader } from './formats.js';
5
+ import { guessLayout, looksLikePixelArt, measureAlpha, measurePixelPitch, ALPHA_THRESHOLD } from './measure.js';
6
+
7
+ /**
8
+ * The inventory: what a studio's declared asset folder holds, measured, on this
9
+ * machine. It reads that folder and nothing else, follows no link out of it, and
10
+ * nothing here uploads anything. The feature and create loops show the result to
11
+ * the model as a manifest and put only counts and warning kinds in the report
12
+ * that goes to the server; the names stay here.
13
+ */
14
+
15
+ export const DEFAULT_ASSETS_DIR = 'assets';
16
+ export const MAX_ASSET_FILES = 2000;
17
+
18
+ /** Files larger than this are named and sized but not read. */
19
+ export const MAX_MEASURED_BYTES = 256 * 1024 * 1024;
20
+
21
+ export const IMAGE_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.bmp', '.tga', '.exr', '.hdr']);
22
+ export const AUDIO_EXTENSIONS = Object.freeze(['.wav', '.ogg', '.mp3']);
23
+ /** A studio's own 3D models. Listed by type and size: nothing here reads inside one. */
24
+ export const MODEL_EXTENSIONS = Object.freeze(['.glb', '.gltf', '.obj', '.fbx', '.dae', '.blend']);
25
+ /** Art or audio by extension: binary, or in SVG's case drawn. Never model input. */
26
+ export const MEDIA_EXTENSIONS = Object.freeze([...IMAGE_EXTENSIONS, ...AUDIO_EXTENSIONS, ...MODEL_EXTENSIONS]);
27
+
28
+ /** Sidecars Godot writes beside a file, which are not assets of their own. */
29
+ const IGNORED_SUFFIXES = Object.freeze(['.import', '.uid', '.tmp']);
30
+
31
+ const FAINT_BOX_RATIO = 1.1;
32
+ const SEAM_LIMIT = 0.2;
33
+
34
+ /**
35
+ * Each kind of warning, in plain words. `summary` is the only text about a warning
36
+ * that ever leaves this machine, which is why it names no file and no number.
37
+ */
38
+ export const WARNING_KINDS = Object.freeze({
39
+ upscaled: 'The art is upscaled: one art pixel covers several file pixels, so its real grid is smaller than the file and may not be the grid it was meant to have.',
40
+ grid_mismatch: 'The real pixel grid differs from the grid you said to expect.',
41
+ uneven_pitch: 'The measured pixel size does not divide the image evenly, so a nearest neighbour resize will not come out clean.',
42
+ faint_alpha: 'Nearly invisible pixels reach far past what you can see, so threshold alpha before trimming, or a trim keeps most of the canvas.',
43
+ tile_frame: 'A tile has its own frame drawn around its edge, which draws a grid across anything laid from it.',
44
+ tile_seam: 'Opposite edges of a tile do not match, so a seam shows when it repeats.',
45
+ codec: 'An audio file is in a form Godot does not import as sound.',
46
+ damaged: 'A file is damaged or cut short.',
47
+ empty_image: 'An image is fully transparent, so it draws nothing.',
48
+ not_measured: 'A file was listed by type and size only, because this inventory does not read inside that format or the file is very large.',
49
+ linear_filter: 'The project draws textures with a smooth filter by default, so pixel art blurs unless the node drawing it sets the nearest filter.'
50
+ });
51
+
52
+ function warning(kind, text) {
53
+ return { kind, text };
54
+ }
55
+
56
+ function plural(count, noun) {
57
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
58
+ }
59
+
60
+ /**
61
+ * Where the declared folder is, or why it cannot be used. It has to be inside the
62
+ * project, by real path, so a link cannot lead the inventory out.
63
+ *
64
+ * @param {{projectDir: string, folder?: string}} input
65
+ * @returns {Promise<{ok: true, absolute: string, relative: string, exists: boolean}|{ok: false, reason: string}>}
66
+ */
67
+ export async function resolveAssetsDir({ projectDir, folder = DEFAULT_ASSETS_DIR }) {
68
+ const root = await realpath(projectDir);
69
+ const absolute = path.resolve(root, folder);
70
+ const inside = target => target === root || target.startsWith(`${root}${path.sep}`);
71
+ if (!inside(absolute)) return { ok: false, reason: `The assets folder has to be inside the project, and ${folder} is not.` };
72
+ let real;
73
+ try {
74
+ real = await realpath(absolute);
75
+ } catch {
76
+ return { ok: true, absolute, relative: path.relative(root, absolute).split(path.sep).join('/'), exists: false };
77
+ }
78
+ if (!inside(real)) return { ok: false, reason: `The assets folder has to be inside the project, and ${folder} links out of it.` };
79
+ const info = await lstat(real);
80
+ if (!info.isDirectory()) return { ok: false, reason: `${folder} is not a folder.` };
81
+ return { ok: true, absolute: real, relative: path.relative(root, real).split(path.sep).join('/'), exists: true };
82
+ }
83
+
84
+ /** The files in a folder. Links are skipped, not followed, and so are hidden names. */
85
+ async function walk(directory, out, limit) {
86
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
87
+ entries.sort((a, b) => (a.name < b.name ? -1 : 1));
88
+ for (const entry of entries) {
89
+ if (out.files.length >= limit) { out.truncated = true; return; }
90
+ if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
91
+ const absolute = path.join(directory, entry.name);
92
+ if (entry.isDirectory()) await walk(absolute, out, limit);
93
+ else if (entry.isFile() && !IGNORED_SUFFIXES.some(suffix => entry.name.endsWith(suffix))) out.files.push(absolute);
94
+ }
95
+ }
96
+
97
+ /** Measure one PNG, with the warnings its pixels earn. */
98
+ function measurePng(data, relativePath, grid) {
99
+ const header = readPngHeader(data);
100
+ if (!header) return { image: null, warnings: [warning('damaged', 'The name says PNG, but this is not a PNG file.')] };
101
+ const image = { width: header.width, height: header.height, has_alpha_channel: pngHasAlphaChannel(header), bit_depth: header.bitDepth };
102
+ const warnings = [];
103
+ if (header.problem) {
104
+ warnings.push(warning('damaged', `This PNG is damaged: ${header.problem}.`));
105
+ return { image, warnings };
106
+ }
107
+ const decoded = decodePng(header);
108
+ if (decoded === null) {
109
+ warnings.push(warning('not_measured', header.interlaced
110
+ ? 'This PNG is interlaced, so only its size was read, not its pixels.'
111
+ : 'This PNG is too large to decode, so only its size was read, not its pixels.'));
112
+ return { image, warnings };
113
+ }
114
+ if (decoded.problem) {
115
+ warnings.push(warning('damaged', `This PNG is damaged: ${decoded.problem}.`));
116
+ return { image, warnings };
117
+ }
118
+ const alpha = measureAlpha(decoded);
119
+ const pitch = measurePixelPitch(decoded);
120
+ const layout = guessLayout(decoded, alpha, relativePath);
121
+ Object.assign(image, { alpha, pixel_pitch: pitch, layout, pixel_art: looksLikePixelArt(decoded, alpha, pitch, layout) });
122
+
123
+ if (pitch.value > 1) {
124
+ warnings.push(warning('upscaled', `One art pixel is ${pitch.exact ? '' : 'about '}${pitch.value} file pixels, so this ${image.width} by ${image.height} file is ${pitch.exact ? '' : 'about '}${pitch.art_width} by ${pitch.art_height} art pixels. Trust that number, not the grid the art was meant to have.`));
125
+ if (!pitch.exact || image.width % pitch.value !== 0 || image.height % pitch.value !== 0) {
126
+ warnings.push(warning('uneven_pitch', `The cells do not fit a whole-number grid. Resizing with the nearest filter will drop or double rows, so resize by the most common colour under each target pixel, or redraw at the size you need.`));
127
+ }
128
+ }
129
+ if (grid && pitch.art_width !== grid && pitch.art_height !== grid) {
130
+ warnings.push(warning('grid_mismatch', `You expected a ${grid} grid, and this measures ${pitch.art_width} by ${pitch.art_height} art pixels.`));
131
+ }
132
+ const { box_any: any, box_threshold: seen } = alpha;
133
+ if (!any) warnings.push(warning('empty_image', 'This image is fully transparent, so it draws nothing.'));
134
+ if (any && seen && any.width * any.height >= seen.width * seen.height * FAINT_BOX_RATIO) {
135
+ warnings.push(warning('faint_alpha', `Nearly invisible pixels reach far past what you can see. A trim that keeps every pixel that is not fully transparent keeps ${any.width} by ${any.height}; a trim at half alpha (${ALPHA_THRESHOLD} of 255) keeps ${seen.width} by ${seen.height}. Threshold alpha before trimming.`));
136
+ }
137
+ if (layout.framed) {
138
+ warnings.push(warning('tile_frame', `${layout.kind === 'sheet' ? 'Each tile here has' : 'This tile has'} its own frame drawn around its edge. Side by side, the frames draw a grid across the floor or wall. Use tiles with no per-tile frame, or one seamless texture.`));
139
+ }
140
+ if (layout.seam !== null && layout.seam > SEAM_LIMIT) {
141
+ warnings.push(warning('tile_seam', `Opposite edges differ by ${Math.round(layout.seam * 100)} percent, so a seam shows when this tile repeats.`));
142
+ }
143
+ return { image, warnings };
144
+ }
145
+
146
+ /** Measure one WAV or Ogg. */
147
+ function measureAudio(data, extension) {
148
+ const parsed = extension === '.wav' ? parseWav(data) : parseOgg(data);
149
+ if (!parsed) return { audio: null, warnings: [warning('damaged', `The name says ${extension.slice(1).toUpperCase()}, but the file is not one.`)] };
150
+ const audio = {
151
+ format: extension.slice(1),
152
+ codec: parsed.codec,
153
+ channels: parsed.channels,
154
+ sample_rate: parsed.sampleRate,
155
+ ...(parsed.bitsPerSample ? { bits_per_sample: parsed.bitsPerSample } : {}),
156
+ duration_seconds: parsed.durationSeconds === null ? null : Math.round(parsed.durationSeconds * 1000) / 1000
157
+ };
158
+ const warnings = [];
159
+ if (parsed.problem) warnings.push(warning('damaged', `This audio file is damaged: ${parsed.problem}.`));
160
+ if (extension === '.ogg' && parsed.codec === 'opus') warnings.push(warning('codec', 'This Ogg holds Opus, and Godot imports only Vorbis from an Ogg file. Convert it to Ogg Vorbis or WAV.'));
161
+ if (extension === '.wav' && !['pcm', 'float'].includes(parsed.codec)) warnings.push(warning('codec', `This WAV is ${parsed.codec}, and Godot imports uncompressed WAV. Convert it to plain PCM.`));
162
+ else if (extension === '.wav' && parsed.extensible) warnings.push(warning('codec', 'This WAV uses the extended WAV header, which Godot refuses to import. Save it again as plain PCM.'));
163
+ return { audio, warnings };
164
+ }
165
+
166
+ /**
167
+ * The project's default canvas texture filter, from project.godot. Godot's own
168
+ * default is linear when the setting is absent.
169
+ *
170
+ * @param {string} text
171
+ * @returns {'nearest'|'other'}
172
+ */
173
+ export function defaultFilter(text) {
174
+ let section = '';
175
+ for (const raw of String(text || '').split(/\r?\n/)) {
176
+ const line = raw.trim();
177
+ if (line.startsWith('[')) section = line;
178
+ else if (section === '[rendering]') {
179
+ const match = /^textures\/canvas_textures\/default_texture_filter\s*=\s*(\d+)/.exec(line);
180
+ if (match) return match[1] === '0' ? 'nearest' : 'other';
181
+ }
182
+ }
183
+ return 'other';
184
+ }
185
+
186
+ /**
187
+ * The inventory of a project's declared folder.
188
+ *
189
+ * @param {{projectDir: string, folder?: string, grid?: number|null}} input
190
+ * @returns {Promise<{ok: true, folder: string, exists: boolean, files: Array<Object>, counts: Object, warnings: Array<Object>, truncated: boolean, default_filter: string}|{ok: false, reason: string}>}
191
+ */
192
+ export async function buildInventory({ projectDir, folder = DEFAULT_ASSETS_DIR, grid = null }) {
193
+ const resolved = await resolveAssetsDir({ projectDir, folder });
194
+ if (!resolved.ok) return resolved;
195
+ const root = await realpath(projectDir);
196
+ const found = { files: [], truncated: false };
197
+ if (resolved.exists) await walk(resolved.absolute, found, MAX_ASSET_FILES);
198
+
199
+ const files = [];
200
+ for (const absolute of found.files) {
201
+ const relative = path.relative(root, absolute).split(path.sep).join('/');
202
+ const extension = path.extname(absolute).toLowerCase();
203
+ const info = await lstat(absolute);
204
+ const row = {
205
+ path: relative,
206
+ res_path: `res://${relative}`,
207
+ kind: IMAGE_EXTENSIONS.includes(extension) ? 'image' : (AUDIO_EXTENSIONS.includes(extension) ? 'audio' : (MODEL_EXTENSIONS.includes(extension) ? 'model' : 'other')),
208
+ format: extension.slice(1) || 'none',
209
+ bytes: info.size,
210
+ warnings: []
211
+ };
212
+ const measurable = extension === '.png' || extension === '.wav' || extension === '.ogg';
213
+ if (measurable && info.size <= MAX_MEASURED_BYTES) {
214
+ const data = await readFile(absolute);
215
+ if (extension === '.png') {
216
+ const { image, warnings } = measurePng(data, relative, grid);
217
+ if (image) row.image = image;
218
+ row.warnings.push(...warnings);
219
+ } else {
220
+ const { audio, warnings } = measureAudio(data, extension);
221
+ if (audio) row.audio = audio;
222
+ row.warnings.push(...warnings);
223
+ }
224
+ } else if (row.kind !== 'other') {
225
+ row.warnings.push(warning('not_measured', measurable ? 'This file is too large to read, so it is listed by type and size only.' : `This inventory reads inside PNG, WAV and Ogg, so this ${row.format} file is listed by type and size only.`));
226
+ }
227
+ files.push(row);
228
+ }
229
+
230
+ const settings = await readFile(path.join(root, 'project.godot'), 'utf8').catch(() => '');
231
+ const filter = defaultFilter(settings);
232
+ const warnings = [];
233
+ if (filter !== 'nearest' && files.some(file => file.image && file.image.pixel_art)) {
234
+ warnings.push(warning('linear_filter', 'The project draws textures with a smooth filter by default, so pixel art blurs unless the node drawing it sets texture_filter to nearest. Set it on each node, or change the project default yourself.'));
235
+ }
236
+ return {
237
+ ok: true,
238
+ folder: resolved.relative,
239
+ exists: resolved.exists,
240
+ files,
241
+ counts: countOf(files),
242
+ warnings,
243
+ truncated: found.truncated,
244
+ default_filter: filter
245
+ };
246
+ }
247
+
248
+ function countOf(files) {
249
+ const models = files.filter(file => file.kind === 'model').length;
250
+ return {
251
+ files: files.length,
252
+ images: files.filter(file => file.kind === 'image').length,
253
+ audio: files.filter(file => file.kind === 'audio').length,
254
+ ...(models > 0 ? { models } : {}),
255
+ other: files.filter(file => file.kind === 'other').length
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Every warning by kind, with how many it covers, and no name of any file: the
261
+ * only form a warning takes off this machine.
262
+ *
263
+ * @param {Object} inventory
264
+ * @returns {Array<{kind: string, count: number, summary: string}>}
265
+ */
266
+ export function warningKinds(inventory) {
267
+ const counts = new Map();
268
+ const add = kind => counts.set(kind, (counts.get(kind) || 0) + 1);
269
+ for (const file of inventory.files) for (const item of file.warnings) add(item.kind);
270
+ for (const item of inventory.warnings) add(item.kind);
271
+ return [...counts].map(([kind, count]) => ({ kind, count, summary: WARNING_KINDS[kind] }));
272
+ }
273
+
274
+ /**
275
+ * The manifest the model reads: one JSON line per file, so `read_file` can page
276
+ * it, with no file content in it at all.
277
+ *
278
+ * @param {Object} inventory
279
+ * @param {string} [projectInRepository] where the project sits, when it is not the repository root
280
+ * @returns {string}
281
+ */
282
+ export function manifestText(inventory, projectInRepository = '') {
283
+ const head = {
284
+ about: 'The art and audio this project has. Use these by res_path. Nothing else exists.',
285
+ ...(projectInRepository ? { project_folder_in_repository: projectInRepository } : {}),
286
+ folder: inventory.folder,
287
+ counts: inventory.counts,
288
+ project_default_texture_filter: inventory.default_filter === 'nearest' ? 'nearest' : 'smooth',
289
+ project_warnings: inventory.warnings.map(item => item.text),
290
+ ...(inventory.truncated ? { truncated: `only the first ${MAX_ASSET_FILES} files are listed` } : {})
291
+ };
292
+ const rows = inventory.files.map(file => {
293
+ const { warnings, ...rest } = file;
294
+ return JSON.stringify({ ...rest, ...(warnings.length ? { warnings: warnings.map(item => item.text) } : {}) });
295
+ });
296
+ return `${[JSON.stringify(head), ...rows].join('\n')}\n`;
297
+ }
298
+
299
+ /** Bytes, kept short for a terminal. */
300
+ export function formatBytes(bytes) {
301
+ if (bytes < 1024) return `${bytes} B`;
302
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 102.4) / 10} KB`;
303
+ return `${Math.round(bytes / 104857.6) / 10} MB`;
304
+ }
305
+
306
+ /** What one row of the inventory says, in a phrase. */
307
+ export function describeFile(file) {
308
+ const parts = [];
309
+ if (file.image) {
310
+ const { image } = file;
311
+ parts.push(`${image.width}x${image.height}`);
312
+ if (image.alpha) {
313
+ parts.push(image.alpha.opaque_share === 1 ? 'no transparency' : 'transparency');
314
+ parts.push(image.pixel_pitch.value > 1 ? `pixel pitch ${image.pixel_pitch.exact ? '' : 'about '}${image.pixel_pitch.value}, ${image.pixel_pitch.art_width}x${image.pixel_pitch.art_height} art pixels` : 'pixel pitch 1');
315
+ const { layout } = image;
316
+ parts.push(layout.kind === 'sheet' || layout.kind === 'strip' ? `${layout.kind} ${layout.columns}x${layout.rows} of ${layout.cell_width}x${layout.cell_height}` : layout.kind);
317
+ }
318
+ }
319
+ if (file.audio) {
320
+ const { audio } = file;
321
+ parts.push(`${audio.codec}, ${audio.channels === 1 ? 'mono' : (audio.channels === 2 ? 'stereo' : `${audio.channels} channels`)}, ${audio.sample_rate} Hz`);
322
+ if (audio.duration_seconds !== null) parts.push(`${audio.duration_seconds} s`);
323
+ }
324
+ return parts.join(', ');
325
+ }
326
+
327
+ /** A sentence for the top of a listing. */
328
+ export function countsLine(counts) {
329
+ return `${plural(counts.files, 'file')}: ${plural(counts.images, 'image')}, ${counts.audio} audio, ${counts.models ? `${plural(counts.models, 'model')}, ` : ''}${counts.other} other`;
330
+ }
331
+
332
+ /** Whether a path names art or audio, by extension. */
333
+ export function isMediaPath(file) {
334
+ return MEDIA_EXTENSIONS.includes(path.extname(String(file)).toLowerCase());
335
+ }
336
+
337
+ export default { buildInventory, manifestText, warningKinds, resolveAssetsDir, DEFAULT_ASSETS_DIR };