ravensight-playtest 0.5.1 → 0.6.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 (101) hide show
  1. package/README.md +297 -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 +94 -4
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +56 -20
  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 +209 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/build.js +81 -0
  18. package/src/commands/check.js +4 -1
  19. package/src/commands/create.js +734 -0
  20. package/src/commands/fakeCore.js +16 -1
  21. package/src/commands/feature.js +845 -0
  22. package/src/commands/fix.js +578 -68
  23. package/src/commands/hosted.js +39 -0
  24. package/src/commands/init.js +19 -3
  25. package/src/commands/profile.js +25 -6
  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/template.js +70 -0
  30. package/src/commands/validate.js +414 -0
  31. package/src/commands/validateHosted.js +292 -0
  32. package/src/create/scaffold.js +192 -0
  33. package/src/hosted/archivePreflight.js +240 -0
  34. package/src/hosted/buildUpload.js +118 -0
  35. package/src/hosted/recordedRun.js +191 -0
  36. package/src/run/acceptance.js +212 -0
  37. package/src/run/args.js +18 -1
  38. package/src/run/checks.js +705 -0
  39. package/src/run/dimension.js +95 -0
  40. package/src/run/drivers/godot-observation.js +59 -11
  41. package/src/run/drivers/godot-project.js +201 -31
  42. package/src/run/drivers/godot.js +532 -45
  43. package/src/run/drivers/web.js +18 -0
  44. package/src/run/evidence.js +170 -0
  45. package/src/run/featureLoop.js +288 -0
  46. package/src/run/fixLoop.js +82 -12
  47. package/src/run/git.js +110 -20
  48. package/src/run/json.js +65 -9
  49. package/src/run/labsOutcome.js +136 -0
  50. package/src/run/liveWork.js +103 -0
  51. package/src/run/model.js +105 -5
  52. package/src/run/openPr.js +107 -0
  53. package/src/run/paths.js +4 -1
  54. package/src/run/pendingRegister.js +81 -0
  55. package/src/run/personaLoop.js +111 -4
  56. package/src/run/profile.js +68 -7
  57. package/src/run/replay.js +236 -0
  58. package/src/run/repoTools.js +227 -27
  59. package/src/run/report.js +7 -1
  60. package/src/run/resume.js +19 -1
  61. package/src/run/secretScan.js +35 -0
  62. package/src/run/settle.js +41 -0
  63. package/src/run/tools.js +33 -4
  64. package/src/run/trace.js +124 -0
  65. package/src/simulation/godotPack.js +112 -0
  66. package/src/ui/index.js +61 -9
  67. package/src/upload/allowlist.js +6 -2
  68. package/src/upload/index.js +5 -4
  69. package/templates/godot-2d/README.md +36 -0
  70. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  71. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  72. package/templates/godot-2d/export_presets.cfg +27 -0
  73. package/templates/godot-2d/gitignore +10 -0
  74. package/templates/godot-2d/project.godot +58 -0
  75. package/templates/godot-2d/scenes/main.tscn +67 -0
  76. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  77. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  78. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  79. package/templates/godot-2d/scripts/main.gd +32 -0
  80. package/templates/godot-2d/scripts/player.gd +10 -0
  81. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  82. package/templates/godot-2d/template.json +14 -0
  83. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  84. package/templates/godot-2d/tools/selftest.gd +70 -0
  85. package/templates/godot-3d/README.md +49 -0
  86. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  87. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  88. package/templates/godot-3d/export_presets.cfg +27 -0
  89. package/templates/godot-3d/gitignore +10 -0
  90. package/templates/godot-3d/project.godot +56 -0
  91. package/templates/godot-3d/scenes/main.tscn +154 -0
  92. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  93. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  94. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  95. package/templates/godot-3d/scripts/beacon.gd +20 -0
  96. package/templates/godot-3d/scripts/main.gd +49 -0
  97. package/templates/godot-3d/scripts/player.gd +19 -0
  98. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  99. package/templates/godot-3d/template.json +16 -0
  100. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  101. package/templates/godot-3d/tools/selftest.gd +77 -0
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,19 +42,43 @@ 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
- * Read a response body without ever throwing. A 502 from a load balancer is
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
- async function readBody(response) {
58
+ async function readBody(response, onResponseRaw) {
53
59
  let text = '';
54
- try {
55
- text = await response.text();
56
- } catch {
57
- return { json: null, text: '' };
60
+ if (onResponseRaw) {
61
+ let bytes = Buffer.alloc(0), bodyComplete = false;
62
+ try { bytes = Buffer.from(await response.arrayBuffer()); bodyComplete = true; }
63
+ catch { /* Preserve a read-failure observation, never invent complete bytes. */ }
64
+ // Opt-in capture runs on every HTTP status BEFORE JSON parsing or an
65
+ // ApiError. Capture failures propagate and cannot trigger a request retry.
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.
74
+ if (!bodyComplete) return { json: null, text: '' };
75
+ text = new TextDecoder().decode(bytes);
76
+ } else {
77
+ try {
78
+ text = await response.text();
79
+ } catch (cause) {
80
+ return { json: null, text: '', cut: cause };
81
+ }
58
82
  }
59
83
  if (!text) return { json: null, text: '' };
60
84
  try {
@@ -87,7 +111,8 @@ function errorFrom(response, parsed, { method, path }) {
87
111
  * Retries cover a network failure, a timeout, 408, 425, 429 and 5xx, plus the
88
112
  * two error codes whose status alone gives the wrong answer (see
89
113
  * `codeRetryLimit`): `charge_in_progress` is retried on its own `Retry-After`
90
- * 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
91
116
  * is a 429. A mutation is only ever retried when the caller says
92
117
  * `idempotent: true`, which in practice means a GET, a heartbeat, or a POST
93
118
  * carrying an `Idempotency-Key` the server uses to replay rather than repeat.
@@ -102,6 +127,7 @@ function errorFrom(response, parsed, { method, path }) {
102
127
  * @param {string} [options.bodyText]
103
128
  * @param {boolean} [options.idempotent]
104
129
  * @param {boolean} [options.raw] - resolve the body as text, not JSON
130
+ * @param {Function} [options.onResponseRaw] - await exact response-byte capture before parsing, including non-2xx
105
131
  * @param {number} [options.retries]
106
132
  * @param {number} [options.timeoutMs]
107
133
  * @param {typeof fetch} [options.fetchImpl]
@@ -119,6 +145,7 @@ export async function requestWithRetry(options) {
119
145
  bodyText,
120
146
  idempotent = false,
121
147
  raw = false,
148
+ onResponseRaw,
122
149
  retries = DEFAULT_RETRIES,
123
150
  timeoutMs = DEFAULT_TIMEOUT_MS,
124
151
  fetchImpl = globalThis.fetch,
@@ -140,7 +167,16 @@ export async function requestWithRetry(options) {
140
167
  for (let attempt = 1; ; attempt += 1) {
141
168
  const controller = new AbortController();
142
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
+ });
143
178
  let response = null;
179
+ let parsed = null;
144
180
  try {
145
181
  response = await fetchImpl(url, {
146
182
  method,
@@ -148,21 +184,21 @@ export async function requestWithRetry(options) {
148
184
  body: bodyText === undefined ? undefined : bodyText,
149
185
  signal: controller.signal
150
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
+ }
151
194
  } catch (cause) {
152
- lastError = new ApiError({
153
- status: 0,
154
- code: 'network_error',
155
- message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
156
- method,
157
- path,
158
- cause
159
- });
195
+ if (cause instanceof CaptureFailure) throw cause.error;
196
+ lastError = unreachable(cause);
160
197
  } finally {
161
198
  clearTimeout(timer);
162
199
  }
163
200
 
164
201
  if (response) {
165
- const parsed = await readBody(response);
166
202
  if (response.ok || response.status === 304) {
167
203
  return {
168
204
  status: response.status,
@@ -173,7 +209,7 @@ export async function requestWithRetry(options) {
173
209
  lastError = errorFrom(response, parsed, { method, path });
174
210
  }
175
211
 
176
- if (!retryable) throw lastError;
212
+ if (!retryable && !nothingMoved(lastError)) throw lastError;
177
213
 
178
214
  const limit = codeRetryLimit(lastError);
179
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 };