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.
- package/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
65
|
-
|
|
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,
|
|
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
|
-
|
|
163
|
-
|
|
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 };
|