@buildinternet/uploads 0.51.0 → 0.52.1
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/dist/commands.js +21 -10
- package/dist/comment-render.generated.d.ts +12 -0
- package/dist/comment-render.generated.js +123 -20
- package/dist/embed.d.ts +14 -1
- package/dist/embed.js +14 -22
- package/dist/mcp/tools.js +1 -1
- package/dist/optimize.js +22 -26
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
|
|
|
4
4
|
import { createUploadsClient, } from "./client.js";
|
|
5
5
|
import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
|
|
6
6
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
7
|
-
import { buildUploadMarkdown } from "./embed.js";
|
|
7
|
+
import { buildUploadMarkdown, fileKindFromName } from "./embed.js";
|
|
8
8
|
import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
|
|
9
9
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
10
10
|
import { UploadsError } from "./errors.js";
|
|
@@ -110,6 +110,10 @@ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
|
|
|
110
110
|
high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
|
|
111
111
|
when they are already smaller, animated, or not an image. Use --no-optimize to
|
|
112
112
|
upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
113
|
+
Non-media files (PDF, zip, gzip, logs, JSON, CSV, markdown) upload as-is and
|
|
114
|
+
show up in the managed comment as links. HTML and SVG are rejected. Uploads are
|
|
115
|
+
public, so scrub secrets and tokens out of logs, JSON, and other text before
|
|
116
|
+
uploading.
|
|
113
117
|
|
|
114
118
|
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
115
119
|
(default off). See: uploads put --help frames
|
|
@@ -421,13 +425,23 @@ export async function prepareImageForUpload(bytes, filename, opts) {
|
|
|
421
425
|
return { ...optimized, frame: frameMeta };
|
|
422
426
|
}
|
|
423
427
|
/**
|
|
424
|
-
* Merge an image's own EXIF-derived facts under any explicit metadata
|
|
428
|
+
* Merge an image's own EXIF-derived facts under any explicit metadata, when
|
|
429
|
+
* the caller asked for them and the filename could plausibly be an image —
|
|
430
|
+
* only an `image/*` extension, or none at all (an extension-less
|
|
431
|
+
* screenshot), is worth an `imageFactsFromBytes` probe
|
|
432
|
+
* (`sharp(bytes).metadata()` under the hood); a known non-image extension (a
|
|
433
|
+
* 25 MB zip, a `.log`, a PDF…) skips it rather than paying a sharp call that
|
|
434
|
+
* can only come back empty.
|
|
435
|
+
*
|
|
425
436
|
* Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
|
|
426
437
|
* budget drops the derived pairs rather than failing the upload. Returns the
|
|
427
438
|
* input untouched (including `undefined`) when there is nothing to add, so a
|
|
428
439
|
* metadata-free upload stays metadata-free.
|
|
429
440
|
*/
|
|
430
|
-
async function
|
|
441
|
+
async function maybeDeriveImageFacts(enabled, sourceName, bytes, metadata) {
|
|
442
|
+
const kind = fileKindFromName(sourceName);
|
|
443
|
+
if (!enabled || (kind !== "image" && kind !== "unknown"))
|
|
444
|
+
return metadata;
|
|
431
445
|
const facts = await imageFactsFromBytes(bytes);
|
|
432
446
|
if (Object.keys(facts).length === 0)
|
|
433
447
|
return metadata;
|
|
@@ -444,9 +458,7 @@ async function mergeImageFacts(bytes, metadata) {
|
|
|
444
458
|
*/
|
|
445
459
|
export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
446
460
|
// Read EXIF from the original bytes before the optimizer strips it.
|
|
447
|
-
const metadata = opts.deriveImageFacts
|
|
448
|
-
? await mergeImageFacts(bytes, opts.metadata)
|
|
449
|
-
: opts.metadata;
|
|
461
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, opts.metadata);
|
|
450
462
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
451
463
|
frameId: opts.frame.frameId,
|
|
452
464
|
frameUrl: opts.frame.frameUrl,
|
|
@@ -643,7 +655,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
643
655
|
target: { kind: target.kind, num: target.num },
|
|
644
656
|
});
|
|
645
657
|
const prefixes = ghListPrefixes(ghKeyPrefix(target), ghPrefix, (id) => ghPrivateKeyPrefix(id, target));
|
|
646
|
-
const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
|
|
658
|
+
const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, size, metadata }) => {
|
|
647
659
|
// The list endpoint returns every metadata key; the comment
|
|
648
660
|
// renders only these two. Narrowing here keeps both render paths
|
|
649
661
|
// byte-identical.
|
|
@@ -661,6 +673,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
661
673
|
url,
|
|
662
674
|
embedUrl,
|
|
663
675
|
pageUrl,
|
|
676
|
+
...(size != null ? { size } : {}),
|
|
664
677
|
...(path || state
|
|
665
678
|
? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
|
|
666
679
|
: {}),
|
|
@@ -955,9 +968,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
955
968
|
const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
|
|
956
969
|
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
957
970
|
// per-file tail (it builds keys differently), so it opts in here too.
|
|
958
|
-
const metadata = opts.deriveImageFacts
|
|
959
|
-
? await mergeImageFacts(bytes, baseMetadata)
|
|
960
|
-
: baseMetadata;
|
|
971
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, baseMetadata);
|
|
961
972
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
962
973
|
...opts.frame,
|
|
963
974
|
optimize: opts.optimize,
|
|
@@ -47,6 +47,14 @@ export declare function parseGhPrivateKey(key: string): {
|
|
|
47
47
|
kind: GhTargetKind;
|
|
48
48
|
num: number;
|
|
49
49
|
} | undefined;
|
|
50
|
+
export declare function inferContentType(filename: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
53
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
54
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
55
|
+
* callers treat as "might be an image, probe it".
|
|
56
|
+
*/
|
|
57
|
+
export declare function fileKindFromName(filename: string): "image" | "video" | "file" | "unknown";
|
|
50
58
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
51
59
|
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
52
60
|
/**
|
|
@@ -112,6 +120,10 @@ export interface AttachmentItem {
|
|
|
112
120
|
width?: number;
|
|
113
121
|
height?: number;
|
|
114
122
|
};
|
|
123
|
+
/** Object size in bytes, when known — drives the non-media file table's Size column. */
|
|
124
|
+
size?: number;
|
|
125
|
+
/** Stored content type, when known — preferred over name-based inference for file classification and the table's Type column. */
|
|
126
|
+
contentType?: string;
|
|
115
127
|
}
|
|
116
128
|
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
117
129
|
export interface GalleryCommentItem {
|
|
@@ -69,27 +69,55 @@ export function parseGhPrivateKey(key) {
|
|
|
69
69
|
return { prefixId, kind: kind, num: Number(num) };
|
|
70
70
|
}
|
|
71
71
|
/** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
|
|
72
|
-
|
|
72
|
+
const CONTENT_TYPE_BY_EXTENSION = {
|
|
73
|
+
png: "image/png",
|
|
74
|
+
jpg: "image/jpeg",
|
|
75
|
+
jpeg: "image/jpeg",
|
|
76
|
+
gif: "image/gif",
|
|
77
|
+
webp: "image/webp",
|
|
78
|
+
avif: "image/avif",
|
|
79
|
+
svg: "image/svg+xml",
|
|
80
|
+
mp4: "video/mp4",
|
|
81
|
+
webm: "video/webm",
|
|
82
|
+
mov: "video/quicktime",
|
|
83
|
+
pdf: "application/pdf",
|
|
84
|
+
zip: "application/zip",
|
|
85
|
+
gz: "application/gzip",
|
|
86
|
+
tgz: "application/gzip",
|
|
87
|
+
txt: "text/plain",
|
|
88
|
+
text: "text/plain",
|
|
89
|
+
log: "text/plain",
|
|
90
|
+
jsonl: "text/plain",
|
|
91
|
+
ndjson: "text/plain",
|
|
92
|
+
yaml: "text/plain",
|
|
93
|
+
yml: "text/plain",
|
|
94
|
+
md: "text/markdown",
|
|
95
|
+
markdown: "text/markdown",
|
|
96
|
+
csv: "text/csv",
|
|
97
|
+
json: "application/json",
|
|
98
|
+
xml: "application/xml",
|
|
99
|
+
};
|
|
100
|
+
export function inferContentType(filename) {
|
|
73
101
|
const ext = filename.includes(".")
|
|
74
102
|
? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
|
|
75
103
|
: "";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
return CONTENT_TYPE_BY_EXTENSION[ext] ?? "application/octet-stream";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
108
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
109
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
110
|
+
* callers treat as "might be an image, probe it".
|
|
111
|
+
*/
|
|
112
|
+
export function fileKindFromName(filename) {
|
|
113
|
+
const type = inferContentType(filename);
|
|
114
|
+
if (type === "application/octet-stream")
|
|
115
|
+
return "unknown";
|
|
116
|
+
if (type.startsWith("image/"))
|
|
117
|
+
return "image";
|
|
118
|
+
if (type.startsWith("video/"))
|
|
119
|
+
return "video";
|
|
120
|
+
return "file";
|
|
93
121
|
}
|
|
94
122
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
95
123
|
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
@@ -257,6 +285,47 @@ function formatMetaCaption(meta, options, mode) {
|
|
|
257
285
|
})
|
|
258
286
|
.join(" · ");
|
|
259
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Decimal-SI byte size for the non-media file table's Size column: whole
|
|
290
|
+
* bytes below 1000, one decimal place at KB/MB/GB and above. `"—"` when the
|
|
291
|
+
* size is unknown.
|
|
292
|
+
*/
|
|
293
|
+
function formatBytes(bytes) {
|
|
294
|
+
if (bytes == null || !Number.isFinite(bytes) || bytes < 0)
|
|
295
|
+
return "—";
|
|
296
|
+
if (bytes < 1000)
|
|
297
|
+
return `${Math.round(bytes)} B`;
|
|
298
|
+
const units = [
|
|
299
|
+
[1e9, "GB"],
|
|
300
|
+
[1e6, "MB"],
|
|
301
|
+
[1e3, "KB"],
|
|
302
|
+
];
|
|
303
|
+
for (const [threshold, label] of units) {
|
|
304
|
+
if (bytes >= threshold)
|
|
305
|
+
return `${(bytes / threshold).toFixed(1)} ${label}`;
|
|
306
|
+
}
|
|
307
|
+
return `${Math.round(bytes)} B`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Type label for the non-media file table: uppercase filename extension
|
|
311
|
+
* first, then the content type's subtype, then a bare "FILE" fallback.
|
|
312
|
+
*/
|
|
313
|
+
function fileTypeLabel(name, contentType) {
|
|
314
|
+
const dot = name.lastIndexOf(".");
|
|
315
|
+
if (dot !== -1 && dot < name.length - 1)
|
|
316
|
+
return name.slice(dot + 1).toUpperCase();
|
|
317
|
+
if (contentType) {
|
|
318
|
+
const slash = contentType.indexOf("/");
|
|
319
|
+
if (slash !== -1 && slash < contentType.length - 1) {
|
|
320
|
+
return contentType.slice(slash + 1).toUpperCase();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return "FILE";
|
|
324
|
+
}
|
|
325
|
+
/** Escape `|` so a filename can't break out of a markdown table cell. */
|
|
326
|
+
function escapeTableCell(s) {
|
|
327
|
+
return s.replace(/\|/g, "\\|");
|
|
328
|
+
}
|
|
260
329
|
/** Resolved pixel width for an image site, or `null` meaning "omit the width
|
|
261
330
|
* attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
|
|
262
331
|
* `"full"` always omits; a number always wins. */
|
|
@@ -457,6 +526,10 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
457
526
|
const consumedByPair = new Set();
|
|
458
527
|
let inlinedImages = 0;
|
|
459
528
|
const overflowImages = [];
|
|
529
|
+
// Non-media attachments (PDFs, archives, text/data files) never inline and
|
|
530
|
+
// never overflow into the <details> link list — they render as one table
|
|
531
|
+
// after the image/video section instead (issue #946).
|
|
532
|
+
const fileItems = [];
|
|
460
533
|
for (let idx = 0; idx < sorted.length; idx++) {
|
|
461
534
|
if (consumedByPair.has(idx))
|
|
462
535
|
continue;
|
|
@@ -482,8 +555,21 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
482
555
|
const stable = item.url;
|
|
483
556
|
const src = item.embedUrl ?? item.url;
|
|
484
557
|
const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
|
|
485
|
-
|
|
486
|
-
|
|
558
|
+
// "application/octet-stream" is the server's generic fallback for an
|
|
559
|
+
// object stored without an explicit content type — not a real signal —
|
|
560
|
+
// so it defers to the filename the same as an absent `contentType`.
|
|
561
|
+
const effectiveType = item.contentType && item.contentType !== "application/octet-stream"
|
|
562
|
+
? item.contentType
|
|
563
|
+
: inferContentType(name);
|
|
564
|
+
const isImage = Boolean(src) && effectiveType.startsWith("image/");
|
|
565
|
+
const isPosterVideo = Boolean(item.posterUrl) && effectiveType.startsWith("video/");
|
|
566
|
+
if (!effectiveType.startsWith("image/") && !effectiveType.startsWith("video/")) {
|
|
567
|
+
// Neither an image nor a video by content type — a non-media
|
|
568
|
+
// attachment goes into the file table, never the bullet list or
|
|
569
|
+
// overflow details.
|
|
570
|
+
fileItems.push(item);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
487
573
|
const inlines = isImage || isPosterVideo;
|
|
488
574
|
if (inlines && inlinedImages >= options.maxInlineImages) {
|
|
489
575
|
// Cap hit — defer to the collapsed overflow list below rather than
|
|
@@ -576,6 +662,23 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
576
662
|
lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
|
|
577
663
|
}
|
|
578
664
|
}
|
|
665
|
+
if (fileItems.length > 0) {
|
|
666
|
+
lines.push("| File | Type | Size |", "| --- | --- | --- |");
|
|
667
|
+
for (const item of fileItems) {
|
|
668
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
669
|
+
const escapedName = escapeTableCell(name);
|
|
670
|
+
let fileCell = item.url ? `[${escapedName}](${item.url})` : escapedName;
|
|
671
|
+
if (item.pageUrl)
|
|
672
|
+
fileCell += ` · [page](${item.pageUrl})`;
|
|
673
|
+
const cap = formatMetaCaption(item.meta, options, "markdown");
|
|
674
|
+
if (cap)
|
|
675
|
+
fileCell += ` · ${cap}`;
|
|
676
|
+
const typeLabel = fileTypeLabel(name, item.contentType);
|
|
677
|
+
const sizeLabel = formatBytes(item.size);
|
|
678
|
+
lines.push(`| ${fileCell} | ${typeLabel} | ${sizeLabel} |`);
|
|
679
|
+
}
|
|
680
|
+
lines.push("");
|
|
681
|
+
}
|
|
579
682
|
if (overflowImages.length > 0) {
|
|
580
683
|
const n = overflowImages.length;
|
|
581
684
|
lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
|
package/dist/embed.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
3
16
|
export declare function buildMarkdown(url: string, opts: {
|
|
4
17
|
alt: string;
|
|
5
18
|
width?: number;
|
package/dist/embed.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
case "svg":
|
|
17
|
-
return "image/svg+xml";
|
|
18
|
-
case "mp4":
|
|
19
|
-
return "video/mp4";
|
|
20
|
-
default:
|
|
21
|
-
return "application/octet-stream";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
24
16
|
export function buildMarkdown(url, opts) {
|
|
25
17
|
if (opts.width) {
|
|
26
18
|
const alt = opts.alt.replace(/"/g, """);
|
package/dist/mcp/tools.js
CHANGED
|
@@ -292,7 +292,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
292
292
|
title: "Upload file",
|
|
293
293
|
annotations: mcpDestroyPublic,
|
|
294
294
|
securitySchemes: mcpOAuthWrite,
|
|
295
|
-
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
|
|
295
|
+
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public. Accepts images (PNG, JPEG, GIF, WebP, AVIF), video (MP4, WebM, MOV), PDF, zip, gzip, and text (plain, markdown, CSV, JSON). SVG and XML are accepted only on storage lanes verified to serve them sandboxed (see the workspace's storage settings); HTML is rejected.",
|
|
296
296
|
inputSchema: {
|
|
297
297
|
type: "object",
|
|
298
298
|
properties: {
|
package/dist/optimize.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* SVG, video, and non-images are left unchanged.
|
|
7
7
|
*/
|
|
8
8
|
import sharp from "sharp";
|
|
9
|
+
import { fileKindFromName, inferContentType } from "./embed.js";
|
|
9
10
|
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
10
11
|
export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
11
12
|
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
@@ -68,11 +69,25 @@ function passthrough(bytes, filename, contentType, skippedReason) {
|
|
|
68
69
|
*/
|
|
69
70
|
export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
70
71
|
const originalBytes = bytes.byteLength;
|
|
72
|
+
// The filename's own claim, used by every passthrough exit below.
|
|
73
|
+
const guessed = inferContentType(filename);
|
|
71
74
|
if (opts.enabled === false) {
|
|
72
|
-
return passthrough(bytes, filename,
|
|
75
|
+
return passthrough(bytes, filename, guessed, "disabled");
|
|
73
76
|
}
|
|
74
77
|
if (originalBytes === 0) {
|
|
75
|
-
return passthrough(bytes, filename,
|
|
78
|
+
return passthrough(bytes, filename, guessed, "empty");
|
|
79
|
+
}
|
|
80
|
+
// A known non-image extension (log, pdf, zip, video…) never needs sharp —
|
|
81
|
+
// and must be checked before `looksLikeSvg` below, since that check sniffs
|
|
82
|
+
// the leading bytes for an XML/SVG prologue with no regard for the actual
|
|
83
|
+
// extension: a `.log` or `.json` file that happens to start with `<?xml`
|
|
84
|
+
// (a JUnit report, say) would otherwise misreport as `skippedReason: "svg"`
|
|
85
|
+
// / `image/svg+xml`. An unknown extension is still "might be an image": it
|
|
86
|
+
// falls through to the SVG sniff and the sharp probe below, so an
|
|
87
|
+
// extension-less screenshot is optimized as before.
|
|
88
|
+
const kind = fileKindFromName(filename);
|
|
89
|
+
if (kind === "file" || kind === "video") {
|
|
90
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
76
91
|
}
|
|
77
92
|
if (looksLikeSvg(bytes, filename)) {
|
|
78
93
|
return passthrough(bytes, filename, "image/svg+xml", "svg");
|
|
@@ -92,14 +107,14 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
92
107
|
meta = await image.metadata();
|
|
93
108
|
}
|
|
94
109
|
catch {
|
|
95
|
-
return passthrough(bytes, filename,
|
|
110
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
96
111
|
}
|
|
97
112
|
if (!meta.format) {
|
|
98
|
-
return passthrough(bytes, filename,
|
|
113
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
99
114
|
}
|
|
100
115
|
// Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
|
|
101
116
|
if ((meta.pages ?? 1) > 1) {
|
|
102
|
-
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" :
|
|
117
|
+
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessed, "animated");
|
|
103
118
|
}
|
|
104
119
|
if (meta.format === "gif") {
|
|
105
120
|
// Single-frame GIF can convert; multi-page already returned above.
|
|
@@ -134,10 +149,10 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
catch {
|
|
137
|
-
return passthrough(bytes, filename,
|
|
152
|
+
return passthrough(bytes, filename, guessed, "encode_failed");
|
|
138
153
|
}
|
|
139
154
|
if (encoded.byteLength >= originalBytes) {
|
|
140
|
-
return passthrough(bytes, filename,
|
|
155
|
+
return passthrough(bytes, filename, guessed, "not_smaller");
|
|
141
156
|
}
|
|
142
157
|
const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
|
|
143
158
|
return {
|
|
@@ -149,25 +164,6 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
149
164
|
outputBytes: encoded.byteLength,
|
|
150
165
|
};
|
|
151
166
|
}
|
|
152
|
-
function guessContentType(filename) {
|
|
153
|
-
switch (extensionOf(filename)) {
|
|
154
|
-
case "png":
|
|
155
|
-
return "image/png";
|
|
156
|
-
case "jpg":
|
|
157
|
-
case "jpeg":
|
|
158
|
-
return "image/jpeg";
|
|
159
|
-
case "gif":
|
|
160
|
-
return "image/gif";
|
|
161
|
-
case "webp":
|
|
162
|
-
return "image/webp";
|
|
163
|
-
case "avif":
|
|
164
|
-
return "image/avif";
|
|
165
|
-
case "svg":
|
|
166
|
-
return "image/svg+xml";
|
|
167
|
-
default:
|
|
168
|
-
return "application/octet-stream";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
167
|
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
172
168
|
export function rewriteKeyExtension(key, filename) {
|
|
173
169
|
const ext = extensionOf(filename);
|