@buildinternet/uploads 0.2.0 → 0.4.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 +30 -9
- package/dist/cli.js +13 -1
- package/dist/client.d.ts +13 -0
- package/dist/client.js +11 -1
- package/dist/commands.d.ts +16 -0
- package/dist/commands.js +200 -19
- package/dist/config-file.d.ts +5 -1
- package/dist/config-file.js +27 -2
- package/dist/destinations.d.ts +26 -0
- package/dist/destinations.js +47 -0
- package/dist/errors.d.ts +1 -1
- package/dist/frame.d.ts +36 -0
- package/dist/frame.js +245 -0
- package/dist/github.d.ts +11 -0
- package/dist/github.js +32 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/mcp/tools.js +172 -19
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.js +177 -0
- package/dist/provenance.d.ts +12 -0
- package/dist/provenance.js +29 -0
- package/package.json +5 -1
package/dist/mcp/tools.js
CHANGED
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { basename } from "node:path";
|
|
10
10
|
import { createUploadsClient } from "../client.js";
|
|
11
|
-
import { buildDoctorReport, makeGhTarget, syncAttachmentsComment } from "../commands.js";
|
|
11
|
+
import { buildDoctorReport, makeGhTarget, prepareImageForUpload, syncAttachmentsComment, } from "../commands.js";
|
|
12
|
+
import { resolveFrameId } from "../frame.js";
|
|
12
13
|
import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
13
14
|
import { buildMarkdown } from "../embed.js";
|
|
15
|
+
import { resolvePutPrefix } from "../destinations.js";
|
|
14
16
|
import { ghAttachmentKey, ghKeyPrefix } from "../github.js";
|
|
17
|
+
import { rewriteKeyExtension } from "../optimize.js";
|
|
18
|
+
import { buildCliProvenance } from "../provenance.js";
|
|
15
19
|
import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
16
20
|
import { optPosInt, optString, usage } from "./args.js";
|
|
17
21
|
function optBool(args, name) {
|
|
@@ -31,6 +35,55 @@ function optStringArray(args, name) {
|
|
|
31
35
|
}
|
|
32
36
|
return v;
|
|
33
37
|
}
|
|
38
|
+
function mcpOptimizeOptions(args, defaults) {
|
|
39
|
+
const quality = optPosInt(args, "optimizeQuality");
|
|
40
|
+
if (quality !== undefined && quality > 100)
|
|
41
|
+
usage("optimizeQuality must be 1–100");
|
|
42
|
+
return {
|
|
43
|
+
enabled: !(optBool(args, "noOptimize") || defaults.noOptimize === true),
|
|
44
|
+
maxEdge: optPosInt(args, "optimizeMaxEdge"),
|
|
45
|
+
quality,
|
|
46
|
+
keepExif: optBool(args, "keepExif") || defaults.keepExif === true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function mcpFrameOptions(args) {
|
|
50
|
+
const raw = optString(args, "frame");
|
|
51
|
+
let frameId;
|
|
52
|
+
try {
|
|
53
|
+
frameId = resolveFrameId(raw);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
usage(err instanceof Error ? err.message : String(err));
|
|
57
|
+
}
|
|
58
|
+
const fitRaw = optString(args, "frameFit");
|
|
59
|
+
let frameFit;
|
|
60
|
+
if (fitRaw) {
|
|
61
|
+
if (fitRaw !== "cover" && fitRaw !== "contain") {
|
|
62
|
+
usage("frameFit must be cover or contain");
|
|
63
|
+
}
|
|
64
|
+
frameFit = fitRaw;
|
|
65
|
+
}
|
|
66
|
+
if (frameFit && !frameId)
|
|
67
|
+
usage("frameFit requires frame");
|
|
68
|
+
const frameUrl = optString(args, "frameUrl");
|
|
69
|
+
if (frameUrl && !frameId)
|
|
70
|
+
usage("frameUrl requires frame");
|
|
71
|
+
return { frameId, frameUrl, frameFit };
|
|
72
|
+
}
|
|
73
|
+
const frameProps = {
|
|
74
|
+
frame: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Optional frame before optimize: phone | browser | iphone-16-pro.",
|
|
77
|
+
},
|
|
78
|
+
frameUrl: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Address bar text for frame=browser.",
|
|
81
|
+
},
|
|
82
|
+
frameFit: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "cover (default) or contain.",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
34
87
|
/** Reads pr/issue (+ repo) into a GhTarget; undefined when neither is present. */
|
|
35
88
|
function ghTargetFromArgs(args, run) {
|
|
36
89
|
return makeGhTarget(optPosInt(args, "pr"), optPosInt(args, "issue"), optString(args, "repo"), run);
|
|
@@ -105,6 +158,10 @@ export function createUploadsMcpTools(opts) {
|
|
|
105
158
|
type: "string",
|
|
106
159
|
description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Cannot be combined with pr/issue.",
|
|
107
160
|
},
|
|
161
|
+
destination: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "Typed destination root: screenshots | gh | f. Sets the key prefix; first-class alternative to prefix. With pr/issue must be gh or omitted.",
|
|
164
|
+
},
|
|
108
165
|
prefix: {
|
|
109
166
|
type: "string",
|
|
110
167
|
description: "Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX). Cannot be combined with pr/issue.",
|
|
@@ -124,7 +181,27 @@ export function createUploadsMcpTools(opts) {
|
|
|
124
181
|
type: "number",
|
|
125
182
|
description: "Emit <img width=…> markdown instead of a plain image embed.",
|
|
126
183
|
},
|
|
127
|
-
contentType: {
|
|
184
|
+
contentType: {
|
|
185
|
+
type: "string",
|
|
186
|
+
description: "Override the Content-Type (ignored when optimize rewrites the body).",
|
|
187
|
+
},
|
|
188
|
+
noOptimize: {
|
|
189
|
+
type: "boolean",
|
|
190
|
+
description: "Skip client-side image optimization (default: optimize still images to WebP).",
|
|
191
|
+
},
|
|
192
|
+
optimizeMaxEdge: {
|
|
193
|
+
type: "number",
|
|
194
|
+
description: "Max long edge in pixels when optimizing (default: 2400).",
|
|
195
|
+
},
|
|
196
|
+
optimizeQuality: {
|
|
197
|
+
type: "number",
|
|
198
|
+
description: "WebP quality 1–100 when optimizing (default: 85).",
|
|
199
|
+
},
|
|
200
|
+
keepExif: {
|
|
201
|
+
type: "boolean",
|
|
202
|
+
description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
|
|
203
|
+
},
|
|
204
|
+
...frameProps,
|
|
128
205
|
noGit: { type: "boolean", description: "Don't derive the repo segment from git." },
|
|
129
206
|
comment: {
|
|
130
207
|
type: "boolean",
|
|
@@ -146,44 +223,81 @@ export function createUploadsMcpTools(opts) {
|
|
|
146
223
|
}
|
|
147
224
|
const target = ghTargetFromArgs(args, run);
|
|
148
225
|
const wantComment = optBool(args, "comment");
|
|
149
|
-
const
|
|
226
|
+
const keyArg = optString(args, "key");
|
|
227
|
+
const destArg = optString(args, "destination");
|
|
150
228
|
const prefixArg = optString(args, "prefix");
|
|
151
229
|
const refArg = optString(args, "ref");
|
|
152
230
|
if (wantComment && !target)
|
|
153
231
|
usage("comment requires pr or issue");
|
|
154
232
|
if (target) {
|
|
155
|
-
if (
|
|
233
|
+
if (keyArg)
|
|
156
234
|
usage("key cannot be combined with pr/issue");
|
|
157
235
|
if (refArg)
|
|
158
236
|
usage("ref cannot be combined with pr/issue");
|
|
159
237
|
if (prefixArg)
|
|
160
238
|
usage("prefix cannot be combined with pr/issue");
|
|
161
239
|
}
|
|
240
|
+
let resolvedPrefix;
|
|
241
|
+
try {
|
|
242
|
+
resolvedPrefix = resolvePutPrefix({
|
|
243
|
+
destination: destArg,
|
|
244
|
+
prefix: prefixArg,
|
|
245
|
+
key: keyArg,
|
|
246
|
+
ghAttachment: Boolean(target),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
usage(err instanceof Error ? err.message : String(err));
|
|
251
|
+
}
|
|
162
252
|
const { client } = clientFor(args);
|
|
163
253
|
const bytes = file !== undefined
|
|
164
254
|
? new Uint8Array(readFileSync(file))
|
|
165
255
|
: new Uint8Array(Buffer.from(contentBase64, "base64"));
|
|
166
|
-
const
|
|
256
|
+
const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
|
|
167
257
|
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
258
|
+
const frameOpts = mcpFrameOptions(args);
|
|
259
|
+
const optimizeOpts = mcpOptimizeOptions(args, defaults);
|
|
260
|
+
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
261
|
+
...frameOpts,
|
|
262
|
+
optimize: optimizeOpts,
|
|
263
|
+
});
|
|
264
|
+
const filename = prepared.filename;
|
|
265
|
+
let key = target ? ghAttachmentKey(target, filename) : keyArg;
|
|
266
|
+
if (key && prepared.optimized)
|
|
267
|
+
key = rewriteKeyExtension(key, filename);
|
|
168
268
|
const noGit = optBool(args, "noGit") || defaults.noGit === true;
|
|
169
|
-
const result = await client.put(bytes, {
|
|
269
|
+
const result = await client.put(prepared.bytes, {
|
|
170
270
|
filename,
|
|
171
|
-
key
|
|
172
|
-
prefix:
|
|
271
|
+
key,
|
|
272
|
+
prefix: resolvedPrefix ?? defaults.prefix,
|
|
173
273
|
repo: optString(args, "repo") ?? defaults.repo,
|
|
174
274
|
ref: refArg ?? defaults.ref,
|
|
175
|
-
contentType: optString(args, "contentType"),
|
|
275
|
+
contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
|
|
176
276
|
deriveRepoFromGit: !noGit,
|
|
277
|
+
provenance: buildCliProvenance({
|
|
278
|
+
sourceName,
|
|
279
|
+
client: "uploads-mcp",
|
|
280
|
+
optimized: prepared.optimized,
|
|
281
|
+
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
282
|
+
keepExif: optimizeOpts.keepExif === true,
|
|
283
|
+
}),
|
|
177
284
|
});
|
|
178
285
|
const markdown = buildMarkdown(result.url, {
|
|
179
|
-
alt: optString(args, "alt") ??
|
|
286
|
+
alt: optString(args, "alt") ?? sourceName,
|
|
180
287
|
width: optPosInt(args, "width") ?? defaults.width,
|
|
181
288
|
});
|
|
289
|
+
const optimize = {
|
|
290
|
+
optimized: prepared.optimized,
|
|
291
|
+
skippedReason: prepared.skippedReason,
|
|
292
|
+
originalBytes: prepared.originalBytes,
|
|
293
|
+
outputBytes: prepared.outputBytes,
|
|
294
|
+
filename: prepared.filename,
|
|
295
|
+
};
|
|
182
296
|
if (wantComment && target) {
|
|
183
297
|
const { comment, commentError } = await syncComment(client, target);
|
|
184
|
-
return { ...result, markdown, comment, commentError };
|
|
298
|
+
return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
|
|
185
299
|
}
|
|
186
|
-
return { ...result, markdown };
|
|
300
|
+
return { ...result, markdown, optimize, frame: prepared.frame };
|
|
187
301
|
},
|
|
188
302
|
},
|
|
189
303
|
{
|
|
@@ -204,8 +318,25 @@ export function createUploadsMcpTools(opts) {
|
|
|
204
318
|
},
|
|
205
319
|
contentType: {
|
|
206
320
|
type: "string",
|
|
207
|
-
description: "Override the Content-Type (applied to every file).",
|
|
321
|
+
description: "Override the Content-Type (applied to every file; ignored when optimize rewrites).",
|
|
322
|
+
},
|
|
323
|
+
noOptimize: {
|
|
324
|
+
type: "boolean",
|
|
325
|
+
description: "Skip client-side image optimization (default: optimize still images to WebP).",
|
|
326
|
+
},
|
|
327
|
+
optimizeMaxEdge: {
|
|
328
|
+
type: "number",
|
|
329
|
+
description: "Max long edge in pixels when optimizing (default: 2400).",
|
|
330
|
+
},
|
|
331
|
+
optimizeQuality: {
|
|
332
|
+
type: "number",
|
|
333
|
+
description: "WebP quality 1–100 when optimizing (default: 85).",
|
|
208
334
|
},
|
|
335
|
+
keepExif: {
|
|
336
|
+
type: "boolean",
|
|
337
|
+
description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
|
|
338
|
+
},
|
|
339
|
+
...frameProps,
|
|
209
340
|
workspace: workspaceProp,
|
|
210
341
|
},
|
|
211
342
|
required: ["files"],
|
|
@@ -220,15 +351,37 @@ export function createUploadsMcpTools(opts) {
|
|
|
220
351
|
resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
|
|
221
352
|
const { client } = clientFor(args);
|
|
222
353
|
const contentType = optString(args, "contentType");
|
|
354
|
+
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
355
|
+
const frameOpts = mcpFrameOptions(args);
|
|
356
|
+
const optimizeOpts = mcpOptimizeOptions(args, defaults);
|
|
223
357
|
const uploads = [];
|
|
224
358
|
for (const file of files) {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
359
|
+
const sourceName = basename(file);
|
|
360
|
+
const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, { ...frameOpts, optimize: optimizeOpts });
|
|
361
|
+
const result = await client.put(prepared.bytes, {
|
|
362
|
+
filename: prepared.filename,
|
|
363
|
+
key: ghAttachmentKey(target, prepared.filename),
|
|
364
|
+
contentType: prepared.optimized ? prepared.contentType : contentType,
|
|
365
|
+
provenance: buildCliProvenance({
|
|
366
|
+
sourceName,
|
|
367
|
+
client: "uploads-mcp",
|
|
368
|
+
optimized: prepared.optimized,
|
|
369
|
+
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
370
|
+
keepExif: optimizeOpts.keepExif === true,
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
uploads.push({
|
|
374
|
+
...result,
|
|
375
|
+
markdown: buildMarkdown(result.url, { alt: sourceName }),
|
|
376
|
+
frame: prepared.frame,
|
|
377
|
+
optimize: {
|
|
378
|
+
optimized: prepared.optimized,
|
|
379
|
+
skippedReason: prepared.skippedReason,
|
|
380
|
+
originalBytes: prepared.originalBytes,
|
|
381
|
+
outputBytes: prepared.outputBytes,
|
|
382
|
+
filename: prepared.filename,
|
|
383
|
+
},
|
|
230
384
|
});
|
|
231
|
-
uploads.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
|
|
232
385
|
}
|
|
233
386
|
if (optBool(args, "noComment"))
|
|
234
387
|
return { target, uploads };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
2
|
+
export declare const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
3
|
+
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
4
|
+
export declare const DEFAULT_OPTIMIZE_QUALITY = 85;
|
|
5
|
+
export type OptimizeOutputFormat = "webp" | "jpeg";
|
|
6
|
+
export interface OptimizeImageOptions {
|
|
7
|
+
/** When false, returns the input unchanged. Default true. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
format?: OptimizeOutputFormat;
|
|
10
|
+
maxEdge?: number;
|
|
11
|
+
quality?: number;
|
|
12
|
+
/**
|
|
13
|
+
* When true, preserve EXIF/XMP/ICC (and related) from the input via sharp
|
|
14
|
+
* `withMetadata()`. Default false — strip for privacy and smaller embeds.
|
|
15
|
+
* Orientation is still applied so pixels match what the user saw.
|
|
16
|
+
*/
|
|
17
|
+
keepExif?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface OptimizeImageResult {
|
|
20
|
+
bytes: Uint8Array;
|
|
21
|
+
filename: string;
|
|
22
|
+
/** Suggested Content-Type for the body (API still sniffs magic bytes). */
|
|
23
|
+
contentType: string;
|
|
24
|
+
optimized: boolean;
|
|
25
|
+
/** Why bytes were left as-is when optimized is false. */
|
|
26
|
+
skippedReason?: string;
|
|
27
|
+
originalBytes: number;
|
|
28
|
+
outputBytes: number;
|
|
29
|
+
}
|
|
30
|
+
/** Replace a trailing image-looking extension, or append when missing. */
|
|
31
|
+
export declare function withImageExtension(name: string, ext: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Optimize still images for public embeds. Safe to call on any payload:
|
|
34
|
+
* non-images and unsupported types pass through.
|
|
35
|
+
*/
|
|
36
|
+
export declare function optimizeImageForUpload(bytes: Uint8Array, filename: string, opts?: OptimizeImageOptions): Promise<OptimizeImageResult>;
|
|
37
|
+
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
38
|
+
export declare function rewriteKeyExtension(key: string, filename: string): string;
|
package/dist/optimize.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side still-image optimization for put/attach.
|
|
3
|
+
*
|
|
4
|
+
* Default path for GitHub embeds: re-encode PNG/JPEG (and similar) to WebP,
|
|
5
|
+
* cap the long edge, keep the smaller of original vs optimized. Animated GIF,
|
|
6
|
+
* SVG, video, and non-images are left unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
10
|
+
export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
11
|
+
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
12
|
+
export const DEFAULT_OPTIMIZE_QUALITY = 85;
|
|
13
|
+
const IMAGE_EXT = new Set([
|
|
14
|
+
"png",
|
|
15
|
+
"jpg",
|
|
16
|
+
"jpeg",
|
|
17
|
+
"webp",
|
|
18
|
+
"gif",
|
|
19
|
+
"tif",
|
|
20
|
+
"tiff",
|
|
21
|
+
"avif",
|
|
22
|
+
"heic",
|
|
23
|
+
"heif",
|
|
24
|
+
]);
|
|
25
|
+
function extensionOf(name) {
|
|
26
|
+
const base = name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name;
|
|
27
|
+
const dot = base.lastIndexOf(".");
|
|
28
|
+
return dot >= 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
29
|
+
}
|
|
30
|
+
/** Replace a trailing image-looking extension, or append when missing. */
|
|
31
|
+
export function withImageExtension(name, ext) {
|
|
32
|
+
const clean = ext.replace(/^\./, "").toLowerCase();
|
|
33
|
+
const slash = name.lastIndexOf("/");
|
|
34
|
+
const dir = slash >= 0 ? name.slice(0, slash + 1) : "";
|
|
35
|
+
const base = slash >= 0 ? name.slice(slash + 1) : name;
|
|
36
|
+
const dot = base.lastIndexOf(".");
|
|
37
|
+
if (dot >= 0 && IMAGE_EXT.has(base.slice(dot + 1).toLowerCase())) {
|
|
38
|
+
return `${dir}${base.slice(0, dot)}.${clean}`;
|
|
39
|
+
}
|
|
40
|
+
return `${dir}${base}.${clean}`;
|
|
41
|
+
}
|
|
42
|
+
function contentTypeForFormat(format) {
|
|
43
|
+
return format === "jpeg" ? "image/jpeg" : "image/webp";
|
|
44
|
+
}
|
|
45
|
+
function looksLikeSvg(bytes, filename) {
|
|
46
|
+
if (extensionOf(filename) === "svg")
|
|
47
|
+
return true;
|
|
48
|
+
const head = new TextDecoder("utf-8", { fatal: false })
|
|
49
|
+
.decode(bytes.subarray(0, Math.min(bytes.length, 256)))
|
|
50
|
+
.trimStart()
|
|
51
|
+
.toLowerCase();
|
|
52
|
+
return head.startsWith("<?xml") || head.startsWith("<svg");
|
|
53
|
+
}
|
|
54
|
+
function passthrough(bytes, filename, contentType, skippedReason) {
|
|
55
|
+
return {
|
|
56
|
+
bytes,
|
|
57
|
+
filename,
|
|
58
|
+
contentType,
|
|
59
|
+
optimized: false,
|
|
60
|
+
skippedReason,
|
|
61
|
+
originalBytes: bytes.byteLength,
|
|
62
|
+
outputBytes: bytes.byteLength,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Optimize still images for public embeds. Safe to call on any payload:
|
|
67
|
+
* non-images and unsupported types pass through.
|
|
68
|
+
*/
|
|
69
|
+
export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
70
|
+
const originalBytes = bytes.byteLength;
|
|
71
|
+
if (opts.enabled === false) {
|
|
72
|
+
return passthrough(bytes, filename, guessContentType(filename), "disabled");
|
|
73
|
+
}
|
|
74
|
+
if (originalBytes === 0) {
|
|
75
|
+
return passthrough(bytes, filename, guessContentType(filename), "empty");
|
|
76
|
+
}
|
|
77
|
+
if (looksLikeSvg(bytes, filename)) {
|
|
78
|
+
return passthrough(bytes, filename, "image/svg+xml", "svg");
|
|
79
|
+
}
|
|
80
|
+
const format = opts.format ?? "webp";
|
|
81
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_OPTIMIZE_MAX_EDGE;
|
|
82
|
+
const quality = opts.quality ?? DEFAULT_OPTIMIZE_QUALITY;
|
|
83
|
+
if (!Number.isFinite(maxEdge) || maxEdge < 1) {
|
|
84
|
+
throw new Error(`optimize maxEdge must be a positive number (got ${maxEdge})`);
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
|
|
87
|
+
throw new Error(`optimize quality must be 1–100 (got ${quality})`);
|
|
88
|
+
}
|
|
89
|
+
let image = sharp(bytes, { animated: true, failOn: "none" });
|
|
90
|
+
let meta;
|
|
91
|
+
try {
|
|
92
|
+
meta = await image.metadata();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_image");
|
|
96
|
+
}
|
|
97
|
+
if (!meta.format) {
|
|
98
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_image");
|
|
99
|
+
}
|
|
100
|
+
// Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
|
|
101
|
+
if ((meta.pages ?? 1) > 1) {
|
|
102
|
+
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessContentType(filename), "animated");
|
|
103
|
+
}
|
|
104
|
+
if (meta.format === "gif") {
|
|
105
|
+
// Single-frame GIF can convert; multi-page already returned above.
|
|
106
|
+
}
|
|
107
|
+
const width = meta.width ?? 0;
|
|
108
|
+
const height = meta.height ?? 0;
|
|
109
|
+
if (width > 0 && height > 0) {
|
|
110
|
+
const longEdge = Math.max(width, height);
|
|
111
|
+
if (longEdge > maxEdge) {
|
|
112
|
+
image = image.resize({
|
|
113
|
+
width: width >= height ? maxEdge : undefined,
|
|
114
|
+
height: height > width ? maxEdge : undefined,
|
|
115
|
+
fit: "inside",
|
|
116
|
+
withoutEnlargement: true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Apply EXIF orientation so stored pixels match what users saw.
|
|
121
|
+
image = image.rotate();
|
|
122
|
+
// Default: strip metadata (privacy + size). Opt in to keep EXIF/etc. for
|
|
123
|
+
// cases where image metadata is part of the discussion (e.g. photo forensics).
|
|
124
|
+
if (opts.keepExif) {
|
|
125
|
+
image = image.withMetadata();
|
|
126
|
+
}
|
|
127
|
+
let encoded;
|
|
128
|
+
try {
|
|
129
|
+
if (format === "jpeg") {
|
|
130
|
+
encoded = await image.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
encoded = await image.webp({ quality, effort: 4 }).toBuffer();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return passthrough(bytes, filename, guessContentType(filename), "encode_failed");
|
|
138
|
+
}
|
|
139
|
+
if (encoded.byteLength >= originalBytes) {
|
|
140
|
+
return passthrough(bytes, filename, guessContentType(filename), "not_smaller");
|
|
141
|
+
}
|
|
142
|
+
const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
|
|
143
|
+
return {
|
|
144
|
+
bytes: new Uint8Array(encoded),
|
|
145
|
+
filename: outFilename,
|
|
146
|
+
contentType: contentTypeForFormat(format),
|
|
147
|
+
optimized: true,
|
|
148
|
+
originalBytes,
|
|
149
|
+
outputBytes: encoded.byteLength,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
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
|
+
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
172
|
+
export function rewriteKeyExtension(key, filename) {
|
|
173
|
+
const ext = extensionOf(filename);
|
|
174
|
+
if (!ext || !IMAGE_EXT.has(ext))
|
|
175
|
+
return key;
|
|
176
|
+
return withImageExtension(key, ext);
|
|
177
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side provenance headers for put (X-Uploads-Meta-*).
|
|
3
|
+
* API allowlists keys; secrets never go here.
|
|
4
|
+
*/
|
|
5
|
+
import type { ProvenanceInput } from "./client.js";
|
|
6
|
+
export declare function buildCliProvenance(opts: {
|
|
7
|
+
sourceName: string;
|
|
8
|
+
optimized?: boolean;
|
|
9
|
+
frameId?: string;
|
|
10
|
+
keepExif?: boolean;
|
|
11
|
+
client?: string;
|
|
12
|
+
}): ProvenanceInput;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
let cachedVersion;
|
|
3
|
+
function packageVersion() {
|
|
4
|
+
if (cachedVersion)
|
|
5
|
+
return cachedVersion;
|
|
6
|
+
try {
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require("../package.json");
|
|
9
|
+
cachedVersion = pkg.version ?? "0.0.0";
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
cachedVersion = "0.0.0";
|
|
13
|
+
}
|
|
14
|
+
return cachedVersion;
|
|
15
|
+
}
|
|
16
|
+
export function buildCliProvenance(opts) {
|
|
17
|
+
const provenance = {
|
|
18
|
+
client: opts.client ?? "uploads-cli",
|
|
19
|
+
"client-version": packageVersion(),
|
|
20
|
+
"source-name": opts.sourceName.slice(0, 128),
|
|
21
|
+
};
|
|
22
|
+
if (opts.optimized)
|
|
23
|
+
provenance.optimized = "1";
|
|
24
|
+
if (opts.frameId)
|
|
25
|
+
provenance.frame = opts.frameId.slice(0, 64);
|
|
26
|
+
if (opts.keepExif)
|
|
27
|
+
provenance["keep-exif"] = "1";
|
|
28
|
+
return provenance;
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
@@ -54,6 +55,9 @@
|
|
|
54
55
|
"access": "public",
|
|
55
56
|
"provenance": true
|
|
56
57
|
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"sharp": "^0.35.3"
|
|
60
|
+
},
|
|
57
61
|
"scripts": {
|
|
58
62
|
"test": "vitest run",
|
|
59
63
|
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|