@buildinternet/uploads 0.30.0 → 0.31.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.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
3
+ import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
5
  import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
@@ -12,7 +12,7 @@ import { safeCaptureFacts } from "../capture-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
14
14
  import { writeSidecarMeta } from "../sidecar.js";
15
- import { writeJson, writeStdout } from "../io.js";
15
+ import { readStdin, writeJson, writeStdout } from "../io.js";
16
16
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
17
17
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
18
18
 
@@ -61,6 +61,12 @@ Options:
61
61
  on --via remote — neutralizes animations via injected CSS)
62
62
  --eval <js> Run JS in the page after settle, before capture (--via local only)
63
63
  --init-script <file> Inject a JS file before navigation (--via local only)
64
+ --annotate <file|-> Bake hand-drawn boxes, arrows, labels, and redactions from a JSON
65
+ annotation spec onto the capture before upload (file path or - for
66
+ stdin; see the annotate-screenshots skill for the spec format). Specs
67
+ that target a CSS selector instead of pixel coordinates need a live
68
+ page to resolve, so they require --via local (or auto resolving to
69
+ local) — a selector spec on the remote backend is rejected up front.
64
70
  --out <file> Also write the PNG to a local file. Also writes a sidecar manifest,
65
71
  <file>.uploads.json, recording this capture's derived metadata
66
72
  (path/url/env/viewport, plus --state if given) with a content hash; a
@@ -110,6 +116,7 @@ Examples:
110
116
  uploads screenshot https://uploads.sh --pr 128 --comment
111
117
  uploads screenshot ./card.html --no-upload --out ./card.png
112
118
  uploads screenshot https://app.example/settings --branch
119
+ uploads screenshot http://localhost:3000 --via local --annotate ./callouts.json
113
120
  `;
114
121
  function colorSchemeFromFlags(flags) {
115
122
  const dark = flagBool(flags, "--dark");
@@ -132,12 +139,17 @@ function viaFromFlags(flags, fallback) {
132
139
  }
133
140
  export async function runScreenshot(ctx, args, help = false, run = execRunner,
134
141
  /** Injectable for tests — avoids launching a real browser or hitting the network. */
135
- captureImpl = captureScreenshot) {
142
+ captureImpl = captureScreenshot,
143
+ /** Injectable for tests — avoids depending on a real stdin stream. */
144
+ readStdinImpl = readStdin,
145
+ /** Injectable for tests — avoids depending on sharp/roughjs. */
146
+ loadAnnotateModule = () => import("../annotate/index.js")) {
136
147
  if (help) {
137
148
  writeCommandHelp(SCREENSHOT_HELP);
138
149
  return 0;
139
150
  }
140
- const parsed = parseCommandArgs(args);
151
+ const { args: preArgs, dash: annotateFromDash } = extractDashValue(args, "--annotate");
152
+ const parsed = parseCommandArgs(preArgs);
141
153
  if (parsed.help) {
142
154
  writeCommandHelp(SCREENSHOT_HELP);
143
155
  return 0;
@@ -182,6 +194,61 @@ captureImpl = captureScreenshot) {
182
194
  throw new UsageError(`could not read --init-script ${initScriptPath}: ${err instanceof Error ? err.message : String(err)}`);
183
195
  }
184
196
  }
197
+ // Parse + validate the annotation spec (if any) before capturing anything —
198
+ // fail fast rather than burning a browser launch / render-endpoint budget
199
+ // hit on a spec that was never going to work.
200
+ const annotateArg = annotateFromDash ? "-" : flagString(parsed.flags, "--annotate");
201
+ let annotateModule;
202
+ let annotateSpec;
203
+ let annotateSelectors = [];
204
+ if (annotateArg !== undefined) {
205
+ annotateModule = await loadAnnotateModule();
206
+ let specText;
207
+ if (annotateArg === "-") {
208
+ specText = await readStdinImpl();
209
+ }
210
+ else {
211
+ try {
212
+ specText = readFileSync(annotateArg, "utf8");
213
+ }
214
+ catch (err) {
215
+ throw new UsageError(`could not read --annotate ${annotateArg}: ${err instanceof Error ? err.message : String(err)}`);
216
+ }
217
+ }
218
+ let specJson;
219
+ try {
220
+ specJson = JSON.parse(specText);
221
+ }
222
+ catch (err) {
223
+ throw new UsageError(`--annotate spec is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
224
+ }
225
+ try {
226
+ annotateSpec = annotateModule.validateSpec(specJson);
227
+ }
228
+ catch (err) {
229
+ if (err instanceof annotateModule.AnnotateSpecError) {
230
+ const lines = err.errors.map((e) => e.index === null ? e.message : `annotations[${e.index}]: ${e.message}`);
231
+ throw new UsageError(`--annotate spec is invalid: ${lines.join("; ")}`);
232
+ }
233
+ throw err;
234
+ }
235
+ annotateSelectors = annotateModule.specSelectors(annotateSpec);
236
+ // The remote render endpoint has no eval escape hatch to measure a live
237
+ // selector — fail fast on an explicit --via remote rather than let the
238
+ // capture succeed and only then discover the annotation step can't
239
+ // resolve. (auto resolving to remote is caught below, after capture,
240
+ // once the actual backend is known.)
241
+ if (annotateSelectors.length > 0 && via === "remote") {
242
+ throw new UsageError("selector annotations need --via local in v1");
243
+ }
244
+ // An element capture (--selector) crops the PNG to the element, but the
245
+ // annotation boxes are measured in viewport coordinates (and playwright
246
+ // may scroll the element into view first) — the two coordinate systems
247
+ // don't line up, so annotations would land in the wrong place.
248
+ if (annotateSelectors.length > 0 && selector) {
249
+ throw new UsageError("--annotate with selector targets cannot combine with --selector element capture; use pixel coordinates or capture the full viewport");
250
+ }
251
+ }
185
252
  const outFile = flagString(parsed.flags, "--out");
186
253
  const noUpload = flagBool(parsed.flags, "--no-upload");
187
254
  if (noUpload && !outFile)
@@ -313,21 +380,48 @@ captureImpl = captureScreenshot) {
313
380
  reducedMotion,
314
381
  evalJs,
315
382
  initScript,
383
+ measureSelectors: annotateSelectors.length > 0 ? annotateSelectors : undefined,
316
384
  apiUrl: ctx.config.apiUrl,
317
385
  token: ctx.config.token,
318
386
  });
319
387
  if (logHuman)
320
388
  process.stderr.write(`>> captured via ${captured.backend} backend\n`);
389
+ // Resolve selectors + render annotations before the frame/optimize/upload
390
+ // pipeline runs — everything downstream (the --out write, the sidecar
391
+ // hash, and the upload itself) should see the annotated bytes.
392
+ let finalPng = captured.png;
393
+ if (annotateModule && annotateSpec) {
394
+ let resolvedSpec = annotateSpec;
395
+ if (annotateSelectors.length > 0) {
396
+ // Covers auto-routing landing on remote: the explicit --via remote
397
+ // case already failed fast above, before capture.
398
+ if (captured.backend !== "local") {
399
+ throw new UsageError("selector annotations need --via local in v1");
400
+ }
401
+ try {
402
+ resolvedSpec = annotateModule.resolveSelectors(annotateSpec, captured.measures ?? {});
403
+ }
404
+ catch (err) {
405
+ if (err instanceof annotateModule.AnnotateSpecError) {
406
+ throw new UsageError(`--annotate: ${err.errors.map((e) => e.message).join("; ")}`);
407
+ }
408
+ throw err;
409
+ }
410
+ }
411
+ finalPng = await annotateModule.renderAnnotations(captured.png, resolvedSpec);
412
+ if (logHuman)
413
+ process.stderr.write(">> annotated\n");
414
+ }
321
415
  if (outFile) {
322
- writeFileSync(outFile, captured.png);
416
+ writeFileSync(outFile, finalPng);
323
417
  if (logHuman)
324
418
  process.stderr.write(`>> wrote ${outFile}\n`);
325
419
  if (!noSidecar)
326
- writeSidecarMeta(outFile, captured.png, withFacts);
420
+ writeSidecarMeta(outFile, finalPng, withFacts);
327
421
  }
328
422
  if (noUpload) {
329
423
  if (ctx.json) {
330
- await writeJson({ file: outFile, backend: captured.backend, size: captured.png.byteLength });
424
+ await writeJson({ file: outFile, backend: captured.backend, size: finalPng.byteLength });
331
425
  }
332
426
  else {
333
427
  await writeStdout(`FILE: ${outFile}\n`);
@@ -340,7 +434,7 @@ captureImpl = captureScreenshot) {
340
434
  ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
341
435
  : undefined;
342
436
  const alt = altFlag ?? basename(captured.filename);
343
- const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
437
+ const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, finalPng, captured.filename, {
344
438
  frame: frameOpts,
345
439
  optimize: optimizeOpts,
346
440
  ghTarget,
package/dist/io.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  /** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
2
2
  export declare function writeStdout(text: string): Promise<void>;
3
3
  export declare function writeJson(value: unknown): Promise<void>;
4
+ /** Reads stdin to end as UTF-8 (the `--flag -` convention). */
5
+ export declare function readStdin(): Promise<string>;
package/dist/io.js CHANGED
@@ -7,3 +7,11 @@ export async function writeStdout(text) {
7
7
  export async function writeJson(value) {
8
8
  await writeStdout(JSON.stringify(value, null, 2) + "\n");
9
9
  }
10
+ /** Reads stdin to end as UTF-8 (the `--flag -` convention). */
11
+ export async function readStdin() {
12
+ const chunks = [];
13
+ for await (const chunk of process.stdin) {
14
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
15
+ }
16
+ return Buffer.concat(chunks).toString("utf8");
17
+ }
@@ -59,6 +59,13 @@ export interface LocalCaptureOptions {
59
59
  evalJs?: string;
60
60
  /** JS injected via addInitScript before navigation. */
61
61
  initScript?: string;
62
+ /**
63
+ * CSS selectors to measure (getBoundingClientRect, scaled to device pixels)
64
+ * after settle, before capture — for resolving annotation-spec selectors.
65
+ * Every selector must match exactly one element; a miss throws naming the
66
+ * selector (no silent skips).
67
+ */
68
+ measureSelectors?: string[];
62
69
  timeoutMs?: number;
63
70
  detectRoots?: DetectRoots;
64
71
  /**
@@ -68,5 +75,29 @@ export interface LocalCaptureOptions {
68
75
  */
69
76
  detectResult?: DetectResult;
70
77
  }
78
+ /** A measured element box in device (raster) pixels — CSS pixels × deviceScaleFactor. */
79
+ export interface MeasuredBox {
80
+ x: number;
81
+ y: number;
82
+ w: number;
83
+ h: number;
84
+ }
85
+ /** Minimal page shape this needs — matches playwright-core's `Page.evaluate`. */
86
+ interface EvaluatablePage {
87
+ evaluate<T>(fn: (selectors: string[]) => T, arg: string[]): Promise<T>;
88
+ }
89
+ /**
90
+ * Measures each selector's getBoundingClientRect on the page in a single
91
+ * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
92
+ * the resulting boxes line up with the captured PNG. Throws `UploadsError`
93
+ * naming any selector that matches zero elements or more than one — an
94
+ * ambiguous selector would silently measure the first match and place the
95
+ * annotation confidently in the wrong spot.
96
+ */
97
+ export declare function measureSelectorBoxes(page: EvaluatablePage, selectors: readonly string[], scale: number): Promise<Record<string, MeasuredBox>>;
71
98
  /** Capture a PNG screenshot using a local (already-installed) browser. */
72
- export declare function captureLocal(opts: LocalCaptureOptions): Promise<Uint8Array>;
99
+ export declare function captureLocal(opts: LocalCaptureOptions): Promise<{
100
+ png: Uint8Array;
101
+ measures?: Record<string, MeasuredBox>;
102
+ }>;
103
+ export {};
@@ -203,6 +203,36 @@ export function detectLocalBrowser(roots = {}) {
203
203
  const winner = [...candidates].toSorted((a, b) => rank(a) - rank(b))[0];
204
204
  return { envOverride, candidates, winner };
205
205
  }
206
+ /**
207
+ * Measures each selector's getBoundingClientRect on the page in a single
208
+ * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
209
+ * the resulting boxes line up with the captured PNG. Throws `UploadsError`
210
+ * naming any selector that matches zero elements or more than one — an
211
+ * ambiguous selector would silently measure the first match and place the
212
+ * annotation confidently in the wrong spot.
213
+ */
214
+ export async function measureSelectorBoxes(page, selectors, scale) {
215
+ if (selectors.length === 0)
216
+ return {};
217
+ const boxes = await page.evaluate((sels) => sels.map((selector) => {
218
+ const matches = document.querySelectorAll(selector);
219
+ if (matches.length !== 1)
220
+ return { count: matches.length };
221
+ const r = matches[0].getBoundingClientRect();
222
+ return { x: r.x, y: r.y, w: r.width, h: r.height };
223
+ }), [...selectors]);
224
+ const measures = {};
225
+ selectors.forEach((sel, i) => {
226
+ const box = boxes[i];
227
+ if ("count" in box) {
228
+ throw new UploadsError(box.count === 0
229
+ ? `--annotate selector matched no element: ${sel}`
230
+ : `--annotate selector is ambiguous (${box.count} matches): ${sel}`, "USAGE");
231
+ }
232
+ measures[sel] = { x: box.x * scale, y: box.y * scale, w: box.w * scale, h: box.h * scale };
233
+ });
234
+ return measures;
235
+ }
206
236
  async function loadPlaywrightCore() {
207
237
  try {
208
238
  // Dynamic import only — never hoist this to a static `import` statement.
@@ -316,11 +346,14 @@ export async function captureLocal(opts) {
316
346
  }
317
347
  if (opts.evalJs)
318
348
  await page.evaluate(opts.evalJs);
349
+ const measures = opts.measureSelectors && opts.measureSelectors.length > 0
350
+ ? await measureSelectorBoxes(page, opts.measureSelectors, opts.viewport.deviceScaleFactor)
351
+ : undefined;
319
352
  const png = opts.selector
320
353
  ? await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 })
321
354
  : await page.screenshot({ fullPage: opts.fullPage === true });
322
355
  // Buffer extends Uint8Array — return it as-is rather than copying.
323
- return png;
356
+ return { png, measures };
324
357
  }
325
358
  finally {
326
359
  await browser.close();
@@ -48,6 +48,13 @@ export type ScreenshotTarget = {
48
48
  export declare function isPrivateOrLocalHost(hostname: string): boolean;
49
49
  /** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
50
50
  export declare function classifyTarget(target: string): ScreenshotTarget;
51
+ /** A measured element box in device (raster) pixels — CSS pixels × deviceScaleFactor. */
52
+ export interface MeasuredBox {
53
+ x: number;
54
+ y: number;
55
+ w: number;
56
+ h: number;
57
+ }
51
58
  export interface CaptureScreenshotOptions {
52
59
  target: string;
53
60
  via: ScreenshotBackend;
@@ -71,6 +78,12 @@ export interface CaptureScreenshotOptions {
71
78
  evalJs?: string;
72
79
  /** Inject this JS as an init script before navigation (local backend only). */
73
80
  initScript?: string;
81
+ /**
82
+ * CSS selectors to measure (getBoundingClientRect, scaled to device pixels)
83
+ * before capture, for resolving annotation-spec selectors. Local backend
84
+ * only — throws if the resolved backend is remote.
85
+ */
86
+ measureSelectors?: string[];
74
87
  apiUrl: string;
75
88
  token: string;
76
89
  /** Injectable for tests; forwarded to detectLocalBrowser. */
@@ -89,10 +102,14 @@ export interface CaptureScreenshotOptions {
89
102
  reducedMotion?: boolean;
90
103
  evalJs?: string;
91
104
  initScript?: string;
105
+ measureSelectors?: string[];
92
106
  detectRoots?: DetectRoots;
93
107
  /** Pre-computed detection result from auto-routing, to avoid a second fs scan. */
94
108
  detectResult?: import("./screenshot-local.js").DetectResult;
95
- }) => Promise<Uint8Array>;
109
+ }) => Promise<{
110
+ png: Uint8Array;
111
+ measures?: Record<string, MeasuredBox>;
112
+ }>;
96
113
  /** Injectable for tests: replaces the remote capture implementation. */
97
114
  captureRemoteImpl?: typeof captureRemote;
98
115
  }
@@ -100,6 +117,8 @@ export interface CaptureScreenshotResult {
100
117
  png: Uint8Array;
101
118
  filename: string;
102
119
  backend: "local" | "remote";
120
+ /** Present when `measureSelectors` was given and the local backend ran. */
121
+ measures?: Record<string, MeasuredBox>;
103
122
  }
104
123
  /**
105
124
  * Resolve target + options into PNG bytes via the local or remote backend.
@@ -234,13 +234,20 @@ export async function captureScreenshot(opts) {
234
234
  if (backend === "remote" && (opts.evalJs !== undefined || opts.initScript !== undefined)) {
235
235
  throw new UploadsError("--eval and --init-script are local-only — use --via local", "USAGE");
236
236
  }
237
+ // Selector-based annotation measurement needs a live local page — the
238
+ // remote render endpoint has no eval escape hatch to run
239
+ // getBoundingClientRect. Covers both explicit --via remote and auto
240
+ // resolving to remote.
241
+ if (backend === "remote" && opts.measureSelectors && opts.measureSelectors.length > 0) {
242
+ throw new UploadsError("selector annotations need --via local in v1", "USAGE");
243
+ }
237
244
  if (backend === "local") {
238
245
  const captureLocalImpl = opts.captureLocalImpl ??
239
246
  (async (localOpts) => {
240
247
  const { captureLocal } = await import("./screenshot-local.js");
241
248
  return captureLocal(localOpts);
242
249
  });
243
- const png = await captureLocalImpl({
250
+ const localResult = await captureLocalImpl({
244
251
  url: target.kind === "html-file" ? pathToFileURL(target.path).href : target.url,
245
252
  browserPath: opts.browserPath,
246
253
  cdp: opts.cdp,
@@ -253,10 +260,11 @@ export async function captureScreenshot(opts) {
253
260
  reducedMotion: opts.reducedMotion,
254
261
  evalJs: opts.evalJs,
255
262
  initScript: opts.initScript,
263
+ measureSelectors: opts.measureSelectors,
256
264
  detectRoots: opts.detectRoots,
257
265
  detectResult: detected,
258
266
  });
259
- return { png, filename, backend };
267
+ return { png: localResult.png, filename, backend, measures: localResult.measures };
260
268
  }
261
269
  if (target.kind === "html-file") {
262
270
  const bytes = new TextEncoder().encode(target.html).byteLength;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "bin",
33
33
  "dist",
34
+ "assets",
34
35
  "README.md"
35
36
  ],
36
37
  "engines": {
@@ -57,6 +58,9 @@
57
58
  },
58
59
  "dependencies": {
59
60
  "exif-reader": "^2.0.3",
61
+ "opentype.js": "^2.0.0",
62
+ "perfect-freehand": "^1.2.3",
63
+ "roughjs": "^4.6.6",
60
64
  "sharp": "^0.35.3"
61
65
  },
62
66
  "optionalDependencies": {