@bli-cockpit/cli 0.1.9 → 0.1.12
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 +25 -14
- package/dist/adapters/agent-image-evidence.js +97 -0
- package/dist/adapters/agent-image-records.js +147 -0
- package/dist/adapters/agent-image-validation.js +88 -0
- package/dist/adapters/raw-evidence.js +145 -16
- package/dist/autostart.js +47 -1
- package/dist/commands/local-args.js +383 -0
- package/dist/commands/local.js +15 -877
- package/dist/commands/public-root.js +3 -0
- package/dist/commands/session-sync.js +504 -0
- package/dist/evidence-upload-client.js +7 -0
- package/dist/local-state.js +1 -1
- package/dist/upload.js +153 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ Public BLI Cockpit command-line interface for approved operators and interns.
|
|
|
4
4
|
|
|
5
5
|
The npm package is public; the Cockpit backend and admin tooling are not. The
|
|
6
6
|
CLI pairs a local laptop with the private Cockpit dashboard, records safe
|
|
7
|
-
work context, uploads consented raw Codex/diff evidence
|
|
8
|
-
storage, and uploads
|
|
7
|
+
work context, uploads consented raw Codex/Claude/diff evidence and explicit
|
|
8
|
+
images attached into agent sessions to durable private storage, and uploads
|
|
9
|
+
metadata refs after dashboard-approved
|
|
9
10
|
device pairing.
|
|
10
11
|
|
|
11
12
|
## One-paste install
|
|
@@ -14,8 +15,8 @@ Run this from the repo where work will happen:
|
|
|
14
15
|
|
|
15
16
|
```bash
|
|
16
17
|
COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
|
|
17
|
-
npm
|
|
18
|
-
|
|
18
|
+
npm install -g @bli-cockpit/cli@latest
|
|
19
|
+
cockpit onboard \
|
|
19
20
|
--email <APPROVED_EMAIL> \
|
|
20
21
|
--device-name "$COCKPIT_DEVICE_NAME" \
|
|
21
22
|
--repo "$PWD"
|
|
@@ -26,13 +27,20 @@ If a work folder contains multiple repos, run the same command from the parent:
|
|
|
26
27
|
```bash
|
|
27
28
|
cd ~/Downloads/bluepearl-workspace
|
|
28
29
|
COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
|
|
29
|
-
npm
|
|
30
|
-
|
|
30
|
+
npm install -g @bli-cockpit/cli@latest
|
|
31
|
+
cockpit onboard \
|
|
31
32
|
--email <APPROVED_EMAIL> \
|
|
32
33
|
--device-name "$COCKPIT_DEVICE_NAME" \
|
|
33
34
|
--repo "$PWD"
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
The CLI defaults to the production dashboard. Normal intern/operator setup,
|
|
38
|
+
updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
|
|
39
|
+
staging, a custom dashboard, or deliberately forcing a different dashboard
|
|
40
|
+
pairing. Already-onboarded users update with
|
|
41
|
+
`npm install -g @bli-cockpit/cli@latest`, then run
|
|
42
|
+
`cockpit sync --repo "$PWD" --json`.
|
|
43
|
+
|
|
36
44
|
Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
|
|
37
45
|
repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
|
|
38
46
|
if the repo cap truncates discovery), creates one stable work context per
|
|
@@ -47,7 +55,7 @@ being duplicated across repos or dropped.
|
|
|
47
55
|
|
|
48
56
|
What happens:
|
|
49
57
|
|
|
50
|
-
1. npm
|
|
58
|
+
1. npm installs or updates the public `@bli-cockpit/cli` package.
|
|
51
59
|
2. `cockpit onboard` writes local user config.
|
|
52
60
|
3. Cockpit prints a dashboard pairing URL and code.
|
|
53
61
|
4. Admin approves the pending request from Ambient -> Collector approvals, or
|
|
@@ -62,8 +70,8 @@ What happens:
|
|
|
62
70
|
`"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
|
|
63
71
|
On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
|
|
64
72
|
`cockpit onboard` skips pairing only when the existing valid session belongs to
|
|
65
|
-
that same email and dashboard URL
|
|
66
|
-
approval.
|
|
73
|
+
that same email and dashboard URL (the production default unless overridden); a
|
|
74
|
+
different email or dashboard forces a new approval.
|
|
67
75
|
|
|
68
76
|
Before the command runs, an Admin must create or approve the email in the
|
|
69
77
|
private dashboard. Default launch path is email + temporary password, handed
|
|
@@ -74,12 +82,11 @@ separate invite acceptance step.
|
|
|
74
82
|
When ticket work starts later:
|
|
75
83
|
|
|
76
84
|
```bash
|
|
77
|
-
|
|
85
|
+
cockpit start \
|
|
78
86
|
--ticket <ticket-id> \
|
|
79
87
|
--repo "$PWD"
|
|
80
88
|
|
|
81
|
-
|
|
82
|
-
--dashboard-url <DASHBOARD_URL> \
|
|
89
|
+
cockpit sync \
|
|
83
90
|
--repo "$PWD" \
|
|
84
91
|
--json
|
|
85
92
|
```
|
|
@@ -91,7 +98,8 @@ Only pass `--ticket` when the work really belongs to a visible ticket.
|
|
|
91
98
|
|
|
92
99
|
Local files:
|
|
93
100
|
|
|
94
|
-
- `~/.config/bli-cockpit/config.json`: dashboard URL
|
|
101
|
+
- `~/.config/bli-cockpit/config.json`: dashboard URL (production default unless
|
|
102
|
+
overridden) and local install config.
|
|
95
103
|
- `~/.config/bli-cockpit/session.json`: normal paired device session.
|
|
96
104
|
- `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
|
|
97
105
|
- `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
|
|
@@ -109,11 +117,14 @@ Remote dashboard:
|
|
|
109
117
|
- durable private Storage objects accepted by the chunked
|
|
110
118
|
`/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
|
|
111
119
|
durable per-object upload ledger;
|
|
120
|
+
- attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
|
|
121
|
+
for screenshots/images explicitly attached into Codex or Claude sessions;
|
|
112
122
|
- Codex session attribution records (session id, file hash, attribution state
|
|
113
123
|
and reason labels, scores) accepted by `/api/ambient/codex-sessions`.
|
|
114
124
|
|
|
115
125
|
Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
|
|
116
|
-
or deployment tokens to this CLI. The collector must never read env files
|
|
126
|
+
or deployment tokens to this CLI. The collector must never read env files and
|
|
127
|
+
does not collect random desktop screenshots or screen recordings.
|
|
117
128
|
|
|
118
129
|
## Public package boundary
|
|
119
130
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { SECRET_FILE_SEGMENT_PATTERN } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AGENT_IMAGE_MAX_BYTES, validateAgentImageBytes, } from "./agent-image-validation.js";
|
|
6
|
+
import { imageCandidatesFromRecord, } from "./agent-image-records.js";
|
|
7
|
+
export async function collectAgentImageEvidenceFromJsonlFile(options) {
|
|
8
|
+
const stat = await fs.stat(options.filePath).catch(() => null);
|
|
9
|
+
if (!stat?.isFile())
|
|
10
|
+
return { images: [], skipped: [] };
|
|
11
|
+
if (stat.size > AGENT_IMAGE_MAX_BYTES) {
|
|
12
|
+
return {
|
|
13
|
+
images: [],
|
|
14
|
+
skipped: [{ label: "transcript", reason: "file_too_large" }],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const content = await fs.readFile(options.filePath, "utf8").catch(() => null);
|
|
18
|
+
if (content === null)
|
|
19
|
+
return { images: [], skipped: [] };
|
|
20
|
+
const images = [];
|
|
21
|
+
const skipped = [];
|
|
22
|
+
let lineIndex = 0;
|
|
23
|
+
for (const line of content.split("\n")) {
|
|
24
|
+
if (!line.trim())
|
|
25
|
+
continue;
|
|
26
|
+
lineIndex += 1;
|
|
27
|
+
let record;
|
|
28
|
+
try {
|
|
29
|
+
record = JSON.parse(line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
for (const candidate of imageCandidatesFromRecord(record, {
|
|
35
|
+
source: options.source,
|
|
36
|
+
sessionId: options.sessionId,
|
|
37
|
+
sidecarId: options.sidecarId,
|
|
38
|
+
fallbackOccurredAt: new Date(stat.mtimeMs).toISOString(),
|
|
39
|
+
lineIndex,
|
|
40
|
+
})) {
|
|
41
|
+
const result = await loadCandidate(candidate, path.dirname(options.filePath));
|
|
42
|
+
if ("reason" in result) {
|
|
43
|
+
skipped.push({ label: candidate.label, reason: result.reason });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
images.push(result);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { images, skipped };
|
|
51
|
+
}
|
|
52
|
+
async function loadCandidate(candidate, transcriptDir) {
|
|
53
|
+
let bytes;
|
|
54
|
+
if (candidate.dataValue) {
|
|
55
|
+
bytes = Buffer.from(candidate.dataValue, "base64");
|
|
56
|
+
}
|
|
57
|
+
else if (candidate.pathValue) {
|
|
58
|
+
const imagePath = path.isAbsolute(candidate.pathValue)
|
|
59
|
+
? candidate.pathValue
|
|
60
|
+
: path.resolve(transcriptDir, candidate.pathValue);
|
|
61
|
+
if (SECRET_FILE_SEGMENT_PATTERN.test(imagePath)) {
|
|
62
|
+
return { reason: "secret_like_file_name" };
|
|
63
|
+
}
|
|
64
|
+
bytes = await fs.readFile(imagePath).catch(() => Buffer.alloc(0));
|
|
65
|
+
if (bytes.byteLength === 0)
|
|
66
|
+
return { reason: "file_read_failed" };
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
return { reason: "missing_image_bytes" };
|
|
70
|
+
}
|
|
71
|
+
const validation = validateAgentImageBytes(bytes, candidate.declaredMediaType);
|
|
72
|
+
if ("reason" in validation)
|
|
73
|
+
return validation;
|
|
74
|
+
return {
|
|
75
|
+
bytes,
|
|
76
|
+
extension: validation.extension,
|
|
77
|
+
label: candidate.label,
|
|
78
|
+
metadata: {
|
|
79
|
+
agent_source: candidate.source,
|
|
80
|
+
source_session_id: candidate.sessionId,
|
|
81
|
+
...(candidate.sourceMessageId ? { source_message_id: candidate.sourceMessageId } : {}),
|
|
82
|
+
...(candidate.sidecarId ? { source_sidecar_id: candidate.sidecarId } : {}),
|
|
83
|
+
...(candidate.turnIndex !== undefined ? { turn_index: candidate.turnIndex } : {}),
|
|
84
|
+
occurred_at: candidate.occurredAt,
|
|
85
|
+
artifact_kind: candidate.artifactKind,
|
|
86
|
+
capture_origin: "agent_session_attachment",
|
|
87
|
+
width: validation.width,
|
|
88
|
+
height: validation.height,
|
|
89
|
+
media_type: validation.mediaType,
|
|
90
|
+
content_hash_sha256: sha256(bytes),
|
|
91
|
+
byte_size: bytes.byteLength,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function sha256(value) {
|
|
96
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
97
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export function imageCandidatesFromRecord(record, context) {
|
|
2
|
+
if (!isRecord(record))
|
|
3
|
+
return [];
|
|
4
|
+
const payload = recordOf(record["payload"]);
|
|
5
|
+
const message = recordOf(record["message"]) ?? recordOf(payload?.["message"]);
|
|
6
|
+
const sourceMessageId = stringFrom(record, ["id", "message_id", "messageId", "uuid"]) ??
|
|
7
|
+
stringFrom(payload, ["id", "message_id", "messageId", "uuid"]);
|
|
8
|
+
const turnIndex = numberFrom(record, ["turn_index", "turnIndex"]) ??
|
|
9
|
+
numberFrom(payload, ["turn_index", "turnIndex"]);
|
|
10
|
+
const occurredAt = isoFrom(stringFrom(record, ["timestamp", "created_at", "createdAt", "time"]) ??
|
|
11
|
+
stringFrom(payload, ["timestamp", "created_at", "createdAt", "time"])) ?? context.fallbackOccurredAt;
|
|
12
|
+
const candidates = [];
|
|
13
|
+
const base = {
|
|
14
|
+
source: context.source,
|
|
15
|
+
sessionId: context.sessionId,
|
|
16
|
+
sidecarId: context.sidecarId,
|
|
17
|
+
sourceMessageId,
|
|
18
|
+
turnIndex,
|
|
19
|
+
occurredAt,
|
|
20
|
+
lineIndex: context.lineIndex,
|
|
21
|
+
};
|
|
22
|
+
for (const value of explicitAttachmentObjects(record, payload, message)) {
|
|
23
|
+
const candidate = candidateFromObject(value, base, true);
|
|
24
|
+
if (candidate)
|
|
25
|
+
candidates.push(candidate);
|
|
26
|
+
}
|
|
27
|
+
for (const value of explicitImageBlocks(record, payload, message)) {
|
|
28
|
+
const candidate = candidateFromObject(value, base, false);
|
|
29
|
+
if (candidate)
|
|
30
|
+
candidates.push(candidate);
|
|
31
|
+
}
|
|
32
|
+
return candidates;
|
|
33
|
+
}
|
|
34
|
+
function explicitAttachmentObjects(record, payload, message) {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (isAttachmentRecord(record))
|
|
37
|
+
out.push(record);
|
|
38
|
+
if (payload && isAttachmentRecord(payload))
|
|
39
|
+
out.push(payload);
|
|
40
|
+
for (const holder of [record, payload, message]) {
|
|
41
|
+
if (!holder)
|
|
42
|
+
continue;
|
|
43
|
+
const single = holder["attachment"];
|
|
44
|
+
if (isRecord(single))
|
|
45
|
+
out.push(single);
|
|
46
|
+
const attachments = holder["attachments"];
|
|
47
|
+
if (Array.isArray(attachments))
|
|
48
|
+
out.push(...attachments);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function explicitImageBlocks(record, payload, message) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const holder of [record, payload, message]) {
|
|
55
|
+
const content = holder?.["content"];
|
|
56
|
+
if (Array.isArray(content))
|
|
57
|
+
out.push(...content.filter((value) => isRecord(value)));
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function candidateFromObject(value, base, attachmentEnvelope) {
|
|
62
|
+
if (!isRecord(value))
|
|
63
|
+
return null;
|
|
64
|
+
const nestedSource = recordOf(value["source"]);
|
|
65
|
+
const imageUrl = recordOf(value["image_url"]);
|
|
66
|
+
const typeValue = stringFrom(value, ["type", "kind", "artifact_kind"]);
|
|
67
|
+
const declaredMediaType = stringFrom(value, ["media_type", "mime_type", "mimeType"]) ??
|
|
68
|
+
stringFrom(nestedSource, ["media_type", "mime_type", "mimeType"]);
|
|
69
|
+
const parsedDataUrl = parseDataUrl(stringFrom(imageUrl, ["url"]));
|
|
70
|
+
const pathValue = stringFrom(value, ["path", "local_path", "file_path", "filePath"]) ??
|
|
71
|
+
stringFrom(nestedSource, ["path", "local_path", "file_path", "filePath"]);
|
|
72
|
+
const dataValue = parsedDataUrl?.data ??
|
|
73
|
+
stringFrom(value, ["data_base64", "base64", "content_base64", "data"]) ??
|
|
74
|
+
stringFrom(nestedSource, ["data_base64", "base64", "content_base64", "data"]);
|
|
75
|
+
const mediaType = parsedDataUrl?.mediaType ?? declaredMediaType;
|
|
76
|
+
if (!pathValue && !dataValue)
|
|
77
|
+
return null;
|
|
78
|
+
if (!attachmentEnvelope && !isImageBlockType(typeValue))
|
|
79
|
+
return null;
|
|
80
|
+
if (attachmentEnvelope && !isImageish(typeValue, mediaType))
|
|
81
|
+
return null;
|
|
82
|
+
const objectId = stringFrom(value, ["id", "attachment_id", "attachmentId", "uuid"]);
|
|
83
|
+
return {
|
|
84
|
+
source: base.source,
|
|
85
|
+
sessionId: base.sessionId,
|
|
86
|
+
sidecarId: base.sidecarId,
|
|
87
|
+
sourceMessageId: objectId ?? base.sourceMessageId,
|
|
88
|
+
turnIndex: base.turnIndex,
|
|
89
|
+
occurredAt: base.occurredAt,
|
|
90
|
+
artifactKind: isScreenshot(typeValue) ? "screenshot" : "image_attachment",
|
|
91
|
+
declaredMediaType: mediaType,
|
|
92
|
+
pathValue,
|
|
93
|
+
dataValue,
|
|
94
|
+
label: objectId ?? base.sourceMessageId ?? `line-${base.lineIndex}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseDataUrl(value) {
|
|
98
|
+
const match = value?.match(/^data:([^;,]+);base64,(.+)$/i);
|
|
99
|
+
return match ? { mediaType: match[1] ?? "", data: match[2] ?? "" } : null;
|
|
100
|
+
}
|
|
101
|
+
function isAttachmentRecord(value) {
|
|
102
|
+
return ["attachment", "image_attachment", "screenshot"].includes(String(value["type"] ?? "").toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
function isImageBlockType(value) {
|
|
105
|
+
return ["image", "input_image", "image_attachment", "screenshot"].includes(value?.toLowerCase() ?? "");
|
|
106
|
+
}
|
|
107
|
+
function isImageish(typeValue, mediaType) {
|
|
108
|
+
return isImageBlockType(typeValue) || Boolean(normalizeMediaType(mediaType));
|
|
109
|
+
}
|
|
110
|
+
function isScreenshot(typeValue) {
|
|
111
|
+
return typeValue?.toLowerCase().includes("screenshot") ?? false;
|
|
112
|
+
}
|
|
113
|
+
function normalizeMediaType(value) {
|
|
114
|
+
return ["image/png", "image/jpeg", "image/jpg"].includes(value?.trim().toLowerCase() ?? "");
|
|
115
|
+
}
|
|
116
|
+
function isoFrom(value) {
|
|
117
|
+
if (!value)
|
|
118
|
+
return null;
|
|
119
|
+
const date = new Date(value);
|
|
120
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
121
|
+
}
|
|
122
|
+
function stringFrom(record, keys) {
|
|
123
|
+
if (!record)
|
|
124
|
+
return undefined;
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
const value = record[key];
|
|
127
|
+
if (typeof value === "string" && value.trim())
|
|
128
|
+
return value.trim();
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
function numberFrom(record, keys) {
|
|
133
|
+
if (!record)
|
|
134
|
+
return undefined;
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const value = record[key];
|
|
137
|
+
if (Number.isInteger(value))
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function recordOf(value) {
|
|
143
|
+
return isRecord(value) ? value : undefined;
|
|
144
|
+
}
|
|
145
|
+
function isRecord(value) {
|
|
146
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
147
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const AGENT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
2
|
+
export const AGENT_IMAGE_MAX_DIMENSION_PX = 16_384;
|
|
3
|
+
export const AGENT_IMAGE_MAX_PIXELS = 50_000_000;
|
|
4
|
+
export function validateAgentImageBytes(bytes, declaredMediaType) {
|
|
5
|
+
if (bytes.byteLength > AGENT_IMAGE_MAX_BYTES)
|
|
6
|
+
return { reason: "file_too_large" };
|
|
7
|
+
if (declaredMediaType && !normalizeMediaType(declaredMediaType)) {
|
|
8
|
+
return { reason: "unsupported_media_type" };
|
|
9
|
+
}
|
|
10
|
+
const detected = detectImage(bytes);
|
|
11
|
+
if (!detected)
|
|
12
|
+
return { reason: "invalid_image_magic" };
|
|
13
|
+
if (detected.width <= 0 ||
|
|
14
|
+
detected.height <= 0 ||
|
|
15
|
+
detected.width > AGENT_IMAGE_MAX_DIMENSION_PX ||
|
|
16
|
+
detected.height > AGENT_IMAGE_MAX_DIMENSION_PX ||
|
|
17
|
+
detected.width * detected.height > AGENT_IMAGE_MAX_PIXELS) {
|
|
18
|
+
return { reason: "image_pixel_limit_exceeded" };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...detected,
|
|
22
|
+
extension: detected.mediaType === "image/png" ? "png" : "jpg",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function detectImage(bytes) {
|
|
26
|
+
const png = parsePngDimensions(bytes);
|
|
27
|
+
if (png)
|
|
28
|
+
return { mediaType: "image/png", ...png };
|
|
29
|
+
const jpeg = parseJpegDimensions(bytes);
|
|
30
|
+
if (jpeg)
|
|
31
|
+
return { mediaType: "image/jpeg", ...jpeg };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function parsePngDimensions(bytes) {
|
|
35
|
+
const signature = "89504e470d0a1a0a";
|
|
36
|
+
if (bytes.byteLength < 24 || bytes.subarray(0, 8).toString("hex") !== signature) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (bytes.subarray(12, 16).toString("ascii") !== "IHDR")
|
|
40
|
+
return null;
|
|
41
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
42
|
+
}
|
|
43
|
+
function parseJpegDimensions(bytes) {
|
|
44
|
+
if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
45
|
+
return null;
|
|
46
|
+
let offset = 2;
|
|
47
|
+
while (offset + 3 < bytes.byteLength) {
|
|
48
|
+
while (offset < bytes.byteLength && bytes[offset] === 0xff)
|
|
49
|
+
offset += 1;
|
|
50
|
+
if (offset + 2 > bytes.byteLength)
|
|
51
|
+
return null;
|
|
52
|
+
const marker = bytes[offset];
|
|
53
|
+
if (marker === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
offset += 1;
|
|
56
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
57
|
+
break;
|
|
58
|
+
if (marker !== undefined && marker >= 0xd0 && marker <= 0xd7)
|
|
59
|
+
continue;
|
|
60
|
+
if (offset + 2 > bytes.byteLength)
|
|
61
|
+
return null;
|
|
62
|
+
const length = bytes.readUInt16BE(offset);
|
|
63
|
+
if (length < 2 || offset + length > bytes.byteLength)
|
|
64
|
+
return null;
|
|
65
|
+
if (isJpegSofMarker(marker) && offset + 7 <= bytes.byteLength) {
|
|
66
|
+
return {
|
|
67
|
+
height: bytes.readUInt16BE(offset + 3),
|
|
68
|
+
width: bytes.readUInt16BE(offset + 5),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
offset += length;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function isJpegSofMarker(marker) {
|
|
76
|
+
return (marker !== undefined &&
|
|
77
|
+
marker >= 0xc0 &&
|
|
78
|
+
marker <= 0xcf &&
|
|
79
|
+
![0xc4, 0xc8, 0xcc].includes(marker));
|
|
80
|
+
}
|
|
81
|
+
function normalizeMediaType(value) {
|
|
82
|
+
const normalized = value?.trim().toLowerCase();
|
|
83
|
+
if (normalized === "image/png")
|
|
84
|
+
return "image/png";
|
|
85
|
+
if (normalized === "image/jpeg" || normalized === "image/jpg")
|
|
86
|
+
return "image/jpeg";
|
|
87
|
+
return null;
|
|
88
|
+
}
|