@buildinternet/uploads 0.30.0 → 0.32.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 +17 -2
- package/assets/Excalifont-Regular.ttf +0 -0
- package/assets/OFL.txt +90 -0
- package/dist/annotate/index.d.ts +13 -0
- package/dist/annotate/index.js +2 -0
- package/dist/annotate/render.d.ts +18 -0
- package/dist/annotate/render.js +155 -0
- package/dist/annotate/shapes.d.ts +23 -0
- package/dist/annotate/shapes.js +147 -0
- package/dist/annotate/spec.d.ts +102 -0
- package/dist/annotate/spec.js +237 -0
- package/dist/annotate/text.d.ts +15 -0
- package/dist/annotate/text.js +39 -0
- package/dist/cli-args.d.ts +15 -0
- package/dist/cli-args.js +21 -0
- package/dist/cli-catalog.d.ts +2 -0
- package/dist/cli-catalog.js +16 -0
- package/dist/cli.js +6 -0
- package/dist/commands/admin-enrollment.js +10 -2
- package/dist/commands/annotate.d.ts +3 -0
- package/dist/commands/annotate.js +124 -0
- package/dist/commands/completion.js +21 -5
- package/dist/commands/install.d.ts +1 -1
- package/dist/commands/install.js +31 -6
- package/dist/commands/screenshot.d.ts +14 -1
- package/dist/commands/screenshot.js +102 -8
- package/dist/commands/update.js +4 -3
- package/dist/io.d.ts +2 -0
- package/dist/io.js +8 -0
- package/dist/screenshot-local.d.ts +32 -1
- package/dist/screenshot-local.js +34 -1
- package/dist/screenshot.d.ts +20 -1
- package/dist/screenshot.js +10 -2
- package/package.json +5 -1
package/dist/commands/install.js
CHANGED
|
@@ -5,10 +5,11 @@ import { writeCommandHelp } from "../cli-style.js";
|
|
|
5
5
|
import { HOOK_COMMAND, installHookManifests } from "../hooks-install.js";
|
|
6
6
|
export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
|
|
7
7
|
const SKILL_SOURCE = "buildinternet/uploads";
|
|
8
|
-
const SKILL_NAMES = ["uploads-cli", "github-screenshots"];
|
|
8
|
+
const SKILL_NAMES = ["uploads-cli", "github-screenshots", "annotate-screenshots"];
|
|
9
9
|
const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP + hooks)
|
|
10
10
|
|
|
11
|
-
Installs the github-screenshots
|
|
11
|
+
Installs the github-screenshots, uploads-cli, and annotate-screenshots agent
|
|
12
|
+
skills, registers the
|
|
12
13
|
hosted MCP server with Claude Code, and installs the PR screenshot reminder
|
|
13
14
|
hook for Grok / Cursor when those tools are present. The remote MCP endpoint
|
|
14
15
|
infers your workspace from the bearer token, so only the token is needed.
|
|
@@ -16,12 +17,17 @@ infers your workspace from the bearer token, so only the token is needed.
|
|
|
16
17
|
Claude Code and Codex ship the same reminder via their plugins (same command:
|
|
17
18
|
\`${HOOK_COMMAND}\`) — install those plugins instead of relying on this step.
|
|
18
19
|
|
|
20
|
+
Safe to re-run. An MCP server already registered under this name is reported
|
|
21
|
+
as \`already configured\` and left as-is — including the token it was created
|
|
22
|
+
with. To point it at a new token: \`claude mcp remove <name>\` first.
|
|
23
|
+
|
|
19
24
|
Usage:
|
|
20
25
|
uploads install [skill|mcp|hooks|all] (default: all)
|
|
21
26
|
|
|
22
27
|
What it does:
|
|
23
28
|
skill Agent skills (via npx skills) — github-screenshots: visuals into
|
|
24
|
-
PRs/issues; uploads-cli: full CLI reference
|
|
29
|
+
PRs/issues; uploads-cli: full CLI reference; annotate-screenshots:
|
|
30
|
+
hand-drawn callouts and redaction on screenshots
|
|
25
31
|
mcp Hosted MCP server in Claude Code — put, list, attach, galleries
|
|
26
32
|
hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
|
|
27
33
|
|
|
@@ -71,6 +77,16 @@ function skillCommand(skill) {
|
|
|
71
77
|
// -g global, -y non-interactive, -a '*' every agent (skips the multi-select TUI)
|
|
72
78
|
return ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", skill, "-g", "-y", "-a", "*"];
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* `claude mcp add` refuses to overwrite an existing entry and exits non-zero
|
|
82
|
+
* ("MCP server uploads already exists in local config"). That is the steady
|
|
83
|
+
* state for anyone re-running `uploads install`, not a failure — recognize it
|
|
84
|
+
* so the run stays green and the footer tells them how to re-add if they want
|
|
85
|
+
* a fresh token in the header.
|
|
86
|
+
*/
|
|
87
|
+
function alreadyConfigured(result) {
|
|
88
|
+
return !result.ok && /already exists/i.test(result.error ?? "");
|
|
89
|
+
}
|
|
74
90
|
function mcpCommand(name, url, bearer) {
|
|
75
91
|
return [
|
|
76
92
|
"claude",
|
|
@@ -99,7 +115,7 @@ function peekToken(globals) {
|
|
|
99
115
|
return undefined;
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
|
-
function printHumanSteps(results, redact, verbose) {
|
|
118
|
+
function printHumanSteps(results, redact, verbose, mcpName) {
|
|
103
119
|
for (const [step, r] of Object.entries(results)) {
|
|
104
120
|
const cmd = redact(r.command.join(" "));
|
|
105
121
|
if (r.skipped === "dry-run") {
|
|
@@ -108,6 +124,10 @@ function printHumanSteps(results, redact, verbose) {
|
|
|
108
124
|
else if (r.skipped === "sign-in") {
|
|
109
125
|
process.stdout.write(`${step}: skipped — ${redact(r.error ?? "needs sign-in")}\n`);
|
|
110
126
|
}
|
|
127
|
+
else if (r.skipped === "already-configured") {
|
|
128
|
+
process.stdout.write(`${step}: already configured — "${mcpName}" is registered in Claude Code (nothing to do)\n` +
|
|
129
|
+
` To re-register (e.g. with a new token): claude mcp remove ${mcpName} && uploads install mcp\n`);
|
|
130
|
+
}
|
|
111
131
|
else if (r.ok) {
|
|
112
132
|
process.stdout.write(`${step}: ok\n`);
|
|
113
133
|
if (verbose && r.output) {
|
|
@@ -187,7 +207,12 @@ export async function runInstall(args, opts, help = false) {
|
|
|
187
207
|
const command = mcpCommand(name, url, token || "<token>");
|
|
188
208
|
if (human)
|
|
189
209
|
process.stdout.write("Installing MCP server…\n");
|
|
190
|
-
|
|
210
|
+
const step = dryRun
|
|
211
|
+
? { command, ok: true, skipped: "dry-run" }
|
|
212
|
+
: runStep(run, command);
|
|
213
|
+
results.mcp = alreadyConfigured(step)
|
|
214
|
+
? { command, ok: true, skipped: "already-configured", output: step.error }
|
|
215
|
+
: step;
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
218
|
if (target === "hooks" || target === "all") {
|
|
@@ -221,7 +246,7 @@ export async function runInstall(args, opts, help = false) {
|
|
|
221
246
|
process.stdout.write(JSON.stringify({ ok: !failed, steps }, null, 2) + "\n");
|
|
222
247
|
return failed ? 1 : 0;
|
|
223
248
|
}
|
|
224
|
-
printHumanSteps(results, redact, verbose);
|
|
249
|
+
printHumanSteps(results, redact, verbose, name);
|
|
225
250
|
// Path-level detail for hooks (printHumanSteps only shows the synthetic step).
|
|
226
251
|
if ((target === "hooks" || target === "all") &&
|
|
227
252
|
(dryRun || (human && (verbose || hookWrites.some((w) => w.action !== "skipped"))))) {
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { type CliContext } from "../commands.js";
|
|
2
2
|
import { type CommandRunner } from "../github-gh.js";
|
|
3
3
|
import { captureScreenshot } from "../screenshot.js";
|
|
4
|
+
/**
|
|
5
|
+
* The slice of `../annotate/index.js` this command needs. Typed against the
|
|
6
|
+
* real module (so signatures stay honest) but loaded only via dynamic
|
|
7
|
+
* `import()` — never statically — to keep sharp/roughjs out of any bundle
|
|
8
|
+
* that pulls in commands/screenshot.ts. Injectable for tests as
|
|
9
|
+
* `loadAnnotateModule`, mirroring the `captureLocalImpl`-style seams
|
|
10
|
+
* elsewhere in this file's tests.
|
|
11
|
+
*/
|
|
12
|
+
export type AnnotateModule = Pick<typeof import("../annotate/index.js"), "validateSpec" | "hasSelectors" | "specSelectors" | "resolveSelectors" | "renderAnnotations" | "clampReport" | "AnnotateSpecError">;
|
|
4
13
|
export declare function runScreenshot(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner,
|
|
5
14
|
/** Injectable for tests — avoids launching a real browser or hitting the network. */
|
|
6
|
-
captureImpl?: typeof captureScreenshot
|
|
15
|
+
captureImpl?: typeof captureScreenshot,
|
|
16
|
+
/** Injectable for tests — avoids depending on a real stdin stream. */
|
|
17
|
+
readStdinImpl?: () => Promise<string>,
|
|
18
|
+
/** Injectable for tests — avoids depending on sharp/roughjs. */
|
|
19
|
+
loadAnnotateModule?: () => Promise<AnnotateModule>): Promise<number>;
|
|
@@ -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
|
|
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,
|
|
416
|
+
writeFileSync(outFile, finalPng);
|
|
323
417
|
if (logHuman)
|
|
324
418
|
process.stderr.write(`>> wrote ${outFile}\n`);
|
|
325
419
|
if (!noSidecar)
|
|
326
|
-
writeSidecarMeta(outFile,
|
|
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:
|
|
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,
|
|
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/commands/update.js
CHANGED
|
@@ -9,9 +9,10 @@ import { runInstall, runStep } from "./install.js";
|
|
|
9
9
|
const UPDATE_HELP = `uploads update — update the CLI and refresh agent integrations
|
|
10
10
|
|
|
11
11
|
Upgrades the globally installed npm package, then re-runs \`uploads install\` so
|
|
12
|
-
the agent skills
|
|
13
|
-
|
|
14
|
-
already
|
|
12
|
+
the agent skills match the new version. Skills drift on their own, so this
|
|
13
|
+
refreshes them even when the CLI is already current. An MCP server already
|
|
14
|
+
registered is left as-is (\`already configured\`) — \`claude mcp add\` never
|
|
15
|
+
overwrites an existing entry.
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
17
18
|
uploads update [options]
|
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<
|
|
99
|
+
export declare function captureLocal(opts: LocalCaptureOptions): Promise<{
|
|
100
|
+
png: Uint8Array;
|
|
101
|
+
measures?: Record<string, MeasuredBox>;
|
|
102
|
+
}>;
|
|
103
|
+
export {};
|
package/dist/screenshot-local.js
CHANGED
|
@@ -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();
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -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<
|
|
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.
|
package/dist/screenshot.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.32.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": {
|