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.
- package/README.md +297 -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 +94 -4
- package/src/api/errors.js +17 -1
- package/src/api/http.js +56 -20
- 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 +209 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/build.js +81 -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 +578 -68
- package/src/commands/hosted.js +39 -0
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- 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/hosted/archivePreflight.js +240 -0
- package/src/hosted/buildUpload.js +118 -0
- package/src/hosted/recordedRun.js +191 -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/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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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
|
-
|
|
153
|
-
|
|
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 };
|