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
package/src/api/client.js CHANGED
@@ -14,6 +14,15 @@ function gameBase(gameId) {
14
14
  return `/api/v1/games/${encodeURIComponent(gameId)}/playtest`;
15
15
  }
16
16
 
17
+ /** `/api/v1/games/:gameId/labs/lists/:list`, for the punch list and blue tape. */
18
+ function labsListBase(gameId, list) {
19
+ if (typeof gameId !== 'string' || gameId.length === 0) {
20
+ throw new TypeError('gameId is required');
21
+ }
22
+ if (list !== 'punch' && list !== 'tape') throw new TypeError('list must be punch or tape');
23
+ return `/api/v1/games/${encodeURIComponent(gameId)}/labs/lists/${list}`;
24
+ }
25
+
17
26
  /**
18
27
  * The Ravensight playtest API, as one object with one namespace per surface.
19
28
  *
@@ -195,9 +204,9 @@ export function createClient(options = {}) {
195
204
  * persona run's price; anything else leaves `run_length` out, which the
196
205
  * server reads as a standard run.
197
206
  */
198
- estimate: (gameId, { modules = [], personas = [], runLength = null, simulation, controllers, driver } = {}) =>
207
+ estimate: (gameId, { modules = [], personas = [], runLength = null, simulation, controllers, driver, validate, feature, acceptance } = {}) =>
199
208
  request('POST', `${gameBase(gameId)}/estimate`, {
200
- body: { modules, personas, ...(controllers ? { controllers, driver } : {}), ...(runLength === 'long' ? { run_length: 'long' } : {}), ...(simulation ? { simulation } : {}) }
209
+ body: { modules, personas, ...(controllers ? { controllers, driver } : {}), ...(runLength === 'long' ? { run_length: 'long' } : {}), ...(simulation ? { simulation } : {}), ...(validate ? { validate } : {}), ...(feature ? { feature } : {}), ...(acceptance ? { acceptance: true } : {}) }
201
210
  }).then(answer => answer.body),
202
211
 
203
212
  /**
@@ -238,6 +247,11 @@ export function createClient(options = {}) {
238
247
  body: { state, reason }
239
248
  }).then(answer => answer.body),
240
249
 
250
+ /** What became of the pull request a Labs job opened. Feedback, no money. */
251
+ labsOutcomePr: (gameId, jobId, { pr }) =>
252
+ request('PUT', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/labs-outcome/pr`, { body: { pr } })
253
+ .then(answer => answer.body),
254
+
241
255
  remove: (gameId, jobId) =>
242
256
  request('DELETE', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}`).then(() => undefined),
243
257
 
@@ -267,6 +281,17 @@ export function createClient(options = {}) {
267
281
  request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/fix/triage`, { body })
268
282
  .then(answer => answer.body),
269
283
 
284
+ /** A validate job's comparison, computed by the server from its run rows. */
285
+ validation: (gameId, jobId) =>
286
+ get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/validation`),
287
+
288
+ validationMarkdown: async (gameId, jobId) =>
289
+ (await request('GET', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/validation`, { query: { format: 'md' }, raw: true, idempotent: true })).body,
290
+
291
+ /** A feature job's report, computed by the server from its run rows. */
292
+ feature: (gameId, jobId) =>
293
+ get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/feature`),
294
+
270
295
  artifacts: (gameId, jobId) =>
271
296
  get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/artifacts`),
272
297
 
@@ -301,6 +326,16 @@ export function createClient(options = {}) {
301
326
  */
302
327
  get: (gameId, jobId, runId) => get(runPath(gameId, jobId, runId)),
303
328
 
329
+ /** More pairs on a validate job: a separate, confirmed purchase. */
330
+ addValidatePairs: (gameId, jobId, pairs) =>
331
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`, {
332
+ body: { validate_pairs: pairs }
333
+ }).then(answer => answer.body),
334
+
335
+ /** Record a trace replay. Free; the per-job cap answers 409 `replay_cap_reached`. */
336
+ recordReplay: (gameId, jobId, runId, report) =>
337
+ request('POST', runPath(gameId, jobId, runId, '/replays'), { body: report }).then(answer => answer.body),
338
+
304
339
  add: (gameId, jobId, personas) =>
305
340
  request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`, {
306
341
  body: { personas }
@@ -366,5 +401,87 @@ export function createClient(options = {}) {
366
401
  })).body
367
402
  };
368
403
 
369
- return { apiUrl, token, cliVersion, request, cli, packs, brief, jobs, runs };
404
+ /**
405
+ * Ravensight Labs. `get` answers which Labs capabilities are live for the
406
+ * game; an older server has no such route, which callers read as "off".
407
+ */
408
+ const labs = {
409
+ get: gameId => {
410
+ if (typeof gameId !== 'string' || gameId.length === 0) throw new TypeError('gameId is required');
411
+ return get(`/api/v1/games/${encodeURIComponent(gameId)}/labs`);
412
+ },
413
+
414
+ /**
415
+ * Build a feature specifications. Writing one is one AI unit; reading is
416
+ * free. Editing and approving are the dashboard's, on purpose: this token
417
+ * cannot do either.
418
+ */
419
+ /**
420
+ * The punch list and blue tape. `list` is `punch` or `tape`. Reads and
421
+ * plain writes are free; `draft` is one AI unit and saves nothing.
422
+ */
423
+ lists: {
424
+ list: (gameId, list, { status } = {}) =>
425
+ get(labsListBase(gameId, list), { query: status ? { status } : undefined }),
426
+ get: (gameId, list, itemId) =>
427
+ get(`${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`),
428
+ add: (gameId, list, body) =>
429
+ request('POST', labsListBase(gameId, list), { body }).then(answer => answer.body),
430
+ update: (gameId, list, itemId, body) =>
431
+ request('PATCH', `${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`, { body }).then(answer => answer.body),
432
+ promote: (gameId, itemId) =>
433
+ request('POST', `${labsListBase(gameId, 'tape')}/items/${encodeURIComponent(itemId)}/promote`, { body: {} }).then(answer => answer.body),
434
+ draft: gameId =>
435
+ request('POST', `${labsListBase(gameId, 'punch')}/draft`, { body: {} }).then(answer => answer.body)
436
+ },
437
+
438
+ featureSpecs: {
439
+ create: (gameId, brief, extra = {}) =>
440
+ request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/feature-specs`, { body: { brief, ...extra } }).then(answer => answer.body),
441
+ get: (gameId, specId) =>
442
+ get(`/api/v1/games/${encodeURIComponent(gameId)}/labs/feature-specs/${encodeURIComponent(specId)}`)
443
+ },
444
+
445
+ /**
446
+ * Create a game (Labs, Experimental). The quote comes before any game exists,
447
+ * so it is org-scoped. Approving and rejecting a design are the dashboard's, on
448
+ * purpose: this token cannot do either.
449
+ */
450
+ prototypes: {
451
+ estimate: ({ milestones, size }) =>
452
+ get(`/api/v1/labs/prototype/estimate?milestones=${encodeURIComponent(milestones)}&size=${encodeURIComponent(size)}`),
453
+ createGame: ({ gameId, name }) =>
454
+ request('POST', '/api/v1/games', { body: { gameId, name, engine: 'godot', labs_prototype: true } }).then(answer => answer.body),
455
+ get: (gameId, jobId) =>
456
+ get(`/api/v1/games/${encodeURIComponent(gameId)}/labs/prototypes/${encodeURIComponent(jobId)}`),
457
+ submitDesign: (gameId, jobId, design) =>
458
+ request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/prototypes/${encodeURIComponent(jobId)}/design`, { body: { design } }).then(answer => answer.body),
459
+ stop: (gameId, jobId) =>
460
+ request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/prototypes/${encodeURIComponent(jobId)}/stop`, { body: {} }).then(answer => answer.body),
461
+ playable: (gameId, jobId, body) =>
462
+ request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/prototypes/${encodeURIComponent(jobId)}/playable`, { body }).then(answer => answer.body)
463
+ }
464
+ };
465
+
466
+ // Hosted comparison. A spending call carries the caller's Idempotency-Key, so a
467
+ // retry cannot start a second one.
468
+ const hosted = {
469
+ capabilities: gameId => get(`${gameBase(gameId)}/hosted/capabilities`),
470
+ builds: gameId => get(`${gameBase(gameId)}/hosted/builds`),
471
+ createBuild: (gameId, body, idempotencyKey) =>
472
+ request('POST', `${gameBase(gameId)}/hosted/builds`, { body, headers: { 'idempotency-key': idempotencyKey } }).then(answer => answer.body),
473
+ completeBuild: (gameId, buildId) =>
474
+ request('POST', `${gameBase(gameId)}/hosted/builds/${encodeURIComponent(buildId)}/complete`, { body: {} }).then(answer => answer.body),
475
+ deleteBuild: (gameId, buildId) =>
476
+ request('DELETE', `${gameBase(gameId)}/hosted/builds/${encodeURIComponent(buildId)}`).then(answer => answer.body),
477
+ quotePair: (gameId, body) =>
478
+ request('POST', `${gameBase(gameId)}/hosted/validate-pairs/quotes`, { body }).then(answer => answer.body),
479
+ createPair: (gameId, body, idempotencyKey) =>
480
+ request('POST', `${gameBase(gameId)}/hosted/validate-pairs`, { body, headers: { 'idempotency-key': idempotencyKey } }).then(answer => answer.body),
481
+ readPair: (gameId, pairId) => get(`${gameBase(gameId)}/hosted/validate-pairs/${encodeURIComponent(pairId)}`),
482
+ cancelPair: (gameId, pairId) =>
483
+ request('POST', `${gameBase(gameId)}/hosted/validate-pairs/${encodeURIComponent(pairId)}/cancel`, { body: {} }).then(answer => answer.body)
484
+ };
485
+
486
+ return { apiUrl, token, cliVersion, request, cli, packs, brief, jobs, runs, labs, hosted };
370
487
  }
package/src/api/errors.js CHANGED
@@ -65,9 +65,25 @@ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
65
65
  * answer (a replay, or a charge) within a few seconds. Only retried when it
66
66
  * actually carries a `Retry-After`: without one there is no interval the
67
67
  * server has blessed, and guessing at a money path is not this layer's call.
68
+ *
69
+ * `settle_contended` is a 409 from the same family, and one that says outright
70
+ * that the job changed underneath the settle and nothing moved. So it is the one
71
+ * code safe to repeat on a POST that carries no idempotency key: see
72
+ * `nothingMoved`. The sweep settles the job if every try is contended.
68
73
  */
69
74
  const NEVER_RETRY_CODES = new Set(['too_many_active_jobs']);
70
- const CODE_RETRY_LIMITS = new Map([['charge_in_progress', 5]]);
75
+ const CODE_RETRY_LIMITS = new Map([['charge_in_progress', 5], ['settle_contended', 4]]);
76
+ const NOTHING_MOVED_CODES = new Set(['settle_contended']);
77
+
78
+ /**
79
+ * Whether this refusal promises that repeating the request cannot move money
80
+ * twice, whatever the method.
81
+ * @param {unknown} error
82
+ * @returns {boolean}
83
+ */
84
+ export function nothingMoved(error) {
85
+ return error instanceof ApiError && NOTHING_MOVED_CODES.has(error.code);
86
+ }
71
87
 
72
88
  /**
73
89
  * How many retries this error's own code is allowed, independent of the
package/src/api/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApiError, codeRetryLimit, isRetryable, parseRetryAfter } from './errors.js';
1
+ import { ApiError, codeRetryLimit, isRetryable, nothingMoved, parseRetryAfter } from './errors.js';
2
2
 
3
3
  export const DEFAULT_RETRIES = 4;
4
4
  export const DEFAULT_TIMEOUT_MS = 30000;
@@ -42,12 +42,18 @@ export function buildUrl(apiUrl, path, query) {
42
42
  return url.toString();
43
43
  }
44
44
 
45
+ /** Carries a capture hook's own error past the request loop's network catch. */
46
+ class CaptureFailure {
47
+ constructor(error) { this.error = error; }
48
+ }
49
+
45
50
  /**
46
51
  * Read a response body without throwing for body or JSON decoding errors. A 502 from a load balancer is
47
52
  * HTML, a 204 is empty, and a JSON parse failure on the error path would
48
- * replace a useful status code with a useless SyntaxError.
53
+ * replace a useful status code with a useless SyntaxError. `cut` says the connection
54
+ * went before the body finished, which is not the same as an empty body.
49
55
  * @param {Response} response
50
- * @returns {Promise<{json: unknown, text: string}>}
56
+ * @returns {Promise<{json: unknown, text: string, cut?: Error}>}
51
57
  */
52
58
  async function readBody(response, onResponseRaw) {
53
59
  let text = '';
@@ -57,12 +63,22 @@ async function readBody(response, onResponseRaw) {
57
63
  catch { /* Preserve a read-failure observation, never invent complete bytes. */ }
58
64
  // Opt-in capture runs on every HTTP status BEFORE JSON parsing or an
59
65
  // ApiError. Capture failures propagate and cannot trigger a request retry.
60
- await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
66
+ try {
67
+ await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
68
+ } catch (error) {
69
+ throw new CaptureFailure(error);
70
+ }
71
+ // A capture caller owns an incomplete body: the observation says so, and it
72
+ // must decide from that record (an admission never POSTs again on an unknown
73
+ // outcome), so this is not turned into a retryable network error.
61
74
  if (!bodyComplete) return { json: null, text: '' };
62
75
  text = new TextDecoder().decode(bytes);
63
76
  } else {
64
- try { text = await response.text(); }
65
- catch { return { json: null, text: '' }; }
77
+ try {
78
+ text = await response.text();
79
+ } catch (cause) {
80
+ return { json: null, text: '', cut: cause };
81
+ }
66
82
  }
67
83
  if (!text) return { json: null, text: '' };
68
84
  try {
@@ -95,7 +111,8 @@ function errorFrom(response, parsed, { method, path }) {
95
111
  * Retries cover a network failure, a timeout, 408, 425, 429 and 5xx, plus the
96
112
  * two error codes whose status alone gives the wrong answer (see
97
113
  * `codeRetryLimit`): `charge_in_progress` is retried on its own `Retry-After`
98
- * although it is a 409, and `too_many_active_jobs` is not retried although it
114
+ * although it is a 409, `settle_contended` likewise and on any method because it
115
+ * promises nothing moved, and `too_many_active_jobs` is not retried although it
99
116
  * is a 429. A mutation is only ever retried when the caller says
100
117
  * `idempotent: true`, which in practice means a GET, a heartbeat, or a POST
101
118
  * carrying an `Idempotency-Key` the server uses to replay rather than repeat.
@@ -150,7 +167,16 @@ export async function requestWithRetry(options) {
150
167
  for (let attempt = 1; ; attempt += 1) {
151
168
  const controller = new AbortController();
152
169
  const timer = setTimeout(() => controller.abort(), timeoutMs);
170
+ const unreachable = cause => new ApiError({
171
+ status: 0,
172
+ code: 'network_error',
173
+ message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
174
+ method,
175
+ path,
176
+ cause
177
+ });
153
178
  let response = null;
179
+ let parsed = null;
154
180
  try {
155
181
  response = await fetchImpl(url, {
156
182
  method,
@@ -158,21 +184,21 @@ export async function requestWithRetry(options) {
158
184
  body: bodyText === undefined ? undefined : bodyText,
159
185
  signal: controller.signal
160
186
  });
187
+ // The time limit covers the body too, and a success whose body never finished
188
+ // is no answer: reading it as an empty 200 lost the job id of a job just charged.
189
+ parsed = await readBody(response, onResponseRaw);
190
+ if (parsed.cut) {
191
+ lastError = unreachable(parsed.cut);
192
+ response = null;
193
+ }
161
194
  } catch (cause) {
162
- lastError = new ApiError({
163
- status: 0,
164
- code: 'network_error',
165
- message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
166
- method,
167
- path,
168
- cause
169
- });
195
+ if (cause instanceof CaptureFailure) throw cause.error;
196
+ lastError = unreachable(cause);
170
197
  } finally {
171
198
  clearTimeout(timer);
172
199
  }
173
200
 
174
201
  if (response) {
175
- const parsed = await readBody(response, onResponseRaw);
176
202
  if (response.ok || response.status === 304) {
177
203
  return {
178
204
  status: response.status,
@@ -183,7 +209,7 @@ export async function requestWithRetry(options) {
183
209
  lastError = errorFrom(response, parsed, { method, path });
184
210
  }
185
211
 
186
- if (!retryable) throw lastError;
212
+ if (!retryable && !nothingMoved(lastError)) throw lastError;
187
213
 
188
214
  const limit = codeRetryLimit(lastError);
189
215
  let canRetry;
@@ -0,0 +1,267 @@
1
+ import { inflateSync } from 'node:zlib';
2
+
3
+ /**
4
+ * Plain parsers for the three formats the inventory measures closely: PNG, WAV
5
+ * and Ogg. No dependency, because this runs inside a CLI that ships with few, and
6
+ * none of these needs more than a header and, for PNG, one inflate.
7
+ *
8
+ * Each parser answers what it read and `problem` when the file is damaged, and
9
+ * never throws on hostile bytes: a studio's folder can hold anything.
10
+ */
11
+
12
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
+
14
+ /** Above this the pixels are not decoded: the header still answers the size. */
15
+ export const MAX_DECODED_PIXELS = 64 * 1024 * 1024;
16
+
17
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
18
+ const VALID_DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
19
+
20
+ /**
21
+ * The PNG's chunks up to IEND, without decoding the pixels.
22
+ *
23
+ * @param {Buffer} data
24
+ * @returns {{width: number, height: number, bitDepth: number, colorType: number, interlaced: boolean, palette: Buffer|null, transparency: Buffer|null, idat: Buffer[], problem: string|null}|null} null when it is not a PNG at all
25
+ */
26
+ export function readPngHeader(data) {
27
+ if (data.length < 33 || !data.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
28
+ const out = { width: 0, height: 0, bitDepth: 0, colorType: 0, interlaced: false, palette: null, transparency: null, idat: [], problem: null };
29
+ let at = 8;
30
+ let sawHeader = false;
31
+ let sawEnd = false;
32
+ while (at + 8 <= data.length) {
33
+ const length = data.readUInt32BE(at);
34
+ const type = data.toString('latin1', at + 4, at + 8);
35
+ const start = at + 8;
36
+ if (start + length > data.length) {
37
+ out.problem = `the ${type} chunk runs past the end of the file`;
38
+ break;
39
+ }
40
+ const body = data.subarray(start, start + length);
41
+ if (type === 'IHDR') {
42
+ if (length < 13) return { ...out, problem: 'the image header is too short' };
43
+ out.width = body.readUInt32BE(0);
44
+ out.height = body.readUInt32BE(4);
45
+ out.bitDepth = body[8];
46
+ out.colorType = body[9];
47
+ out.interlaced = body[12] === 1;
48
+ sawHeader = true;
49
+ } else if (type === 'PLTE') out.palette = body;
50
+ else if (type === 'tRNS') out.transparency = body;
51
+ else if (type === 'IDAT') out.idat.push(body);
52
+ else if (type === 'IEND') { sawEnd = true; break; }
53
+ at = start + length + 4;
54
+ }
55
+ if (!sawHeader) return { ...out, problem: out.problem || 'there is no image header' };
56
+ if (!(VALID_DEPTHS[out.colorType] || []).includes(out.bitDepth)) return { ...out, problem: 'the colour type and bit depth are not valid together' };
57
+ if (!sawEnd && !out.problem) out.problem = 'the file ends before the image does';
58
+ return out;
59
+ }
60
+
61
+ /** The Paeth predictor, from the PNG specification. */
62
+ function paeth(a, b, c) {
63
+ const p = a + b - c;
64
+ const pa = Math.abs(p - a);
65
+ const pb = Math.abs(p - b);
66
+ const pc = Math.abs(p - c);
67
+ if (pa <= pb && pa <= pc) return a;
68
+ return pb <= pc ? b : c;
69
+ }
70
+
71
+ /**
72
+ * Decode a non-interlaced PNG to 8-bit RGBA.
73
+ *
74
+ * @param {ReturnType<typeof readPngHeader>} header
75
+ * @returns {{width: number, height: number, rgba: Uint8Array}|{problem: string}|null} null for what is
76
+ * not decoded on purpose: interlaced, or over MAX_DECODED_PIXELS
77
+ */
78
+ export function decodePng(header) {
79
+ const { width, height, bitDepth, colorType } = header;
80
+ if (header.interlaced || width * height > MAX_DECODED_PIXELS || width * height === 0) return null;
81
+ const channels = CHANNELS[colorType];
82
+ const stride = Math.ceil((width * channels * bitDepth) / 8);
83
+ const bytesPerPixel = Math.max(1, (channels * bitDepth) >> 3);
84
+ const expected = (stride + 1) * height;
85
+ let raw;
86
+ try {
87
+ raw = inflateSync(Buffer.concat(header.idat), { maxOutputLength: expected + 1024 });
88
+ } catch {
89
+ return { problem: 'the pixel data does not decompress' };
90
+ }
91
+ if (raw.length < expected) return { problem: 'the pixel data is shorter than the image' };
92
+
93
+ const line = new Uint8Array(stride);
94
+ const previous = new Uint8Array(stride);
95
+ const rgba = new Uint8Array(width * height * 4);
96
+ const palette = header.palette;
97
+ const alphas = header.transparency;
98
+ const keyed = colorType !== 3 && alphas && alphas.length >= (colorType === 0 ? 2 : 6) ? alphas : null;
99
+ for (let y = 0; y < height; y += 1) {
100
+ const offset = y * (stride + 1);
101
+ const filter = raw[offset];
102
+ if (filter > 4) return { problem: 'a row of pixels has an unknown filter' };
103
+ for (let x = 0; x < stride; x += 1) {
104
+ const value = raw[offset + 1 + x];
105
+ const a = x >= bytesPerPixel ? line[x - bytesPerPixel] : 0;
106
+ const b = previous[x];
107
+ const c = x >= bytesPerPixel ? previous[x - bytesPerPixel] : 0;
108
+ let out = value;
109
+ if (filter === 1) out = value + a;
110
+ else if (filter === 2) out = value + b;
111
+ else if (filter === 3) out = value + ((a + b) >> 1);
112
+ else if (filter === 4) out = value + paeth(a, b, c);
113
+ line[x] = out & 0xff;
114
+ }
115
+ for (let x = 0; x < width; x += 1) {
116
+ const to = (y * width + x) * 4;
117
+ if (colorType === 3 || (colorType === 0 && bitDepth < 8)) {
118
+ const bitAt = x * bitDepth;
119
+ const sample = (line[bitAt >> 3] >> (8 - bitDepth - (bitAt & 7))) & ((1 << bitDepth) - 1);
120
+ if (colorType === 3) {
121
+ rgba[to] = palette ? palette[sample * 3] : 0;
122
+ rgba[to + 1] = palette ? palette[sample * 3 + 1] : 0;
123
+ rgba[to + 2] = palette ? palette[sample * 3 + 2] : 0;
124
+ rgba[to + 3] = alphas && sample < alphas.length ? alphas[sample] : 255;
125
+ } else {
126
+ const grey = Math.round((sample * 255) / ((1 << bitDepth) - 1));
127
+ rgba[to] = grey; rgba[to + 1] = grey; rgba[to + 2] = grey;
128
+ rgba[to + 3] = keyed && keyed.readUInt16BE(0) === sample ? 0 : 255;
129
+ }
130
+ continue;
131
+ }
132
+ // At 16 bits the high byte of each sample is what a screen shows.
133
+ const step = bitDepth === 16 ? 2 : 1;
134
+ const from = x * channels * step;
135
+ if (colorType === 0 || colorType === 4) {
136
+ const grey = line[from];
137
+ rgba[to] = grey; rgba[to + 1] = grey; rgba[to + 2] = grey;
138
+ rgba[to + 3] = colorType === 4 ? line[from + step]
139
+ : (keyed && (bitDepth === 16 ? (line[from] << 8) | line[from + 1] : line[from]) === keyed.readUInt16BE(0) ? 0 : 255);
140
+ } else {
141
+ rgba[to] = line[from];
142
+ rgba[to + 1] = line[from + step];
143
+ rgba[to + 2] = line[from + step * 2];
144
+ if (colorType === 6) rgba[to + 3] = line[from + step * 3];
145
+ else {
146
+ const sample = i => (bitDepth === 16 ? (line[from + i * 2] << 8) | line[from + i * 2 + 1] : line[from + i]);
147
+ rgba[to + 3] = keyed && sample(0) === keyed.readUInt16BE(0) && sample(1) === keyed.readUInt16BE(2) && sample(2) === keyed.readUInt16BE(4) ? 0 : 255;
148
+ }
149
+ }
150
+ }
151
+ previous.set(line);
152
+ }
153
+ return { width, height, rgba };
154
+ }
155
+
156
+ /** Whether a PNG can carry transparency at all, from its header. */
157
+ export function pngHasAlphaChannel(header) {
158
+ return header.colorType === 4 || header.colorType === 6 || Boolean(header.transparency);
159
+ }
160
+
161
+ const WAV_FORMATS = { 1: 'pcm', 3: 'float', 6: 'a-law', 7: 'mu-law' };
162
+
163
+ /**
164
+ * A WAV's format and length, from its `fmt ` and `data` chunks.
165
+ *
166
+ * @param {Buffer} data
167
+ * @returns {{codec: string, extensible: boolean, channels: number, sampleRate: number, bitsPerSample: number, durationSeconds: number|null, problem: string|null}|null} null when it is not a WAV
168
+ */
169
+ export function parseWav(data) {
170
+ if (data.length < 12 || data.toString('latin1', 0, 4) !== 'RIFF' || data.toString('latin1', 8, 12) !== 'WAVE') return null;
171
+ let at = 12;
172
+ let format = null;
173
+ let dataBytes = null;
174
+ let problem = null;
175
+ while (at + 8 <= data.length) {
176
+ const id = data.toString('latin1', at, at + 4);
177
+ const length = data.readUInt32LE(at + 4);
178
+ const start = at + 8;
179
+ if (id === 'fmt ' && start + 16 <= data.length) {
180
+ const tag = data.readUInt16LE(start);
181
+ // An extensible header names the real format in the first bytes of its subformat.
182
+ const extended = tag === 0xfffe && start + 26 <= data.length ? data.readUInt16LE(start + 24) : null;
183
+ format = {
184
+ tag: extended ?? tag,
185
+ extensible: tag === 0xfffe,
186
+ channels: data.readUInt16LE(start + 2),
187
+ sampleRate: data.readUInt32LE(start + 4),
188
+ byteRate: data.readUInt32LE(start + 8),
189
+ bitsPerSample: data.readUInt16LE(start + 14)
190
+ };
191
+ } else if (id === 'data') {
192
+ dataBytes = length;
193
+ if (start + length > data.length) {
194
+ dataBytes = data.length - start;
195
+ problem = 'the sound data runs past the end of the file';
196
+ }
197
+ break;
198
+ }
199
+ at = start + length + (length & 1);
200
+ }
201
+ if (!format) return { codec: 'unknown', extensible: false, channels: 0, sampleRate: 0, bitsPerSample: 0, durationSeconds: null, problem: 'there is no format chunk' };
202
+ if (dataBytes === null) problem = problem || 'there is no sound data';
203
+ return {
204
+ codec: WAV_FORMATS[format.tag] || `format ${format.tag}`,
205
+ extensible: format.extensible,
206
+ channels: format.channels,
207
+ sampleRate: format.sampleRate,
208
+ bitsPerSample: format.bitsPerSample,
209
+ durationSeconds: dataBytes !== null && format.byteRate > 0 ? dataBytes / format.byteRate : null,
210
+ problem
211
+ };
212
+ }
213
+
214
+ /** One Ogg page: its header fields and where its body sits. */
215
+ function oggPageAt(data, at) {
216
+ if (at + 27 > data.length || data.toString('latin1', at, at + 4) !== 'OggS') return null;
217
+ const segments = data[at + 26];
218
+ if (at + 27 + segments > data.length) return null;
219
+ let size = 0;
220
+ for (let i = 0; i < segments; i += 1) size += data[at + 27 + i];
221
+ return { granule: data.readBigInt64LE(at + 6), serial: data.readUInt32LE(at + 14), bodyStart: at + 27 + segments, next: at + 27 + segments + size };
222
+ }
223
+
224
+ /**
225
+ * An Ogg's codec and length: the first page names the codec, and the last page's
226
+ * granule position is how far the stream got. Vorbis counts samples; Opus counts
227
+ * at 48 kHz, less its pre-skip.
228
+ *
229
+ * @param {Buffer} data
230
+ * @returns {{codec: string, channels: number, sampleRate: number, bitsPerSample: null, durationSeconds: number|null, problem: string|null}|null} null when it is not an Ogg
231
+ */
232
+ export function parseOgg(data) {
233
+ const first = oggPageAt(data, 0);
234
+ if (!first) return null;
235
+ const body = data.subarray(first.bodyStart, first.next);
236
+ const out = { codec: 'unknown', channels: 0, sampleRate: 0, bitsPerSample: null, durationSeconds: null, problem: null };
237
+ let ticksPerSecond = 0;
238
+ let skip = 0;
239
+ if (body.length >= 30 && body[0] === 1 && body.toString('latin1', 1, 7) === 'vorbis') {
240
+ out.codec = 'vorbis';
241
+ out.channels = body[11];
242
+ out.sampleRate = body.readUInt32LE(12);
243
+ ticksPerSecond = out.sampleRate;
244
+ } else if (body.length >= 19 && body.toString('latin1', 0, 8) === 'OpusHead') {
245
+ out.codec = 'opus';
246
+ out.channels = body[9];
247
+ out.sampleRate = body.readUInt32LE(12);
248
+ ticksPerSecond = 48000;
249
+ skip = body.readUInt16LE(10);
250
+ } else {
251
+ out.problem = 'the first page does not name a codec this reads';
252
+ return out;
253
+ }
254
+ let at = 0;
255
+ let lastGranule = -1n;
256
+ for (;;) {
257
+ const page = oggPageAt(data, at);
258
+ if (!page || page.next > data.length) break;
259
+ if (page.serial === first.serial && page.granule >= 0n) lastGranule = page.granule;
260
+ at = page.next;
261
+ }
262
+ if (at < data.length) out.problem = 'the file ends part way through a page';
263
+ if (lastGranule >= 0n && ticksPerSecond > 0) out.durationSeconds = Math.max(0, Number(lastGranule) - skip) / ticksPerSecond;
264
+ return out;
265
+ }
266
+
267
+ export default { readPngHeader, decodePng, pngHasAlphaChannel, parseWav, parseOgg, MAX_DECODED_PIXELS };