@buildinternet/uploads 0.29.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.
- 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/client.d.ts +24 -1
- 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.js +5 -3
- package/dist/commands/screenshot.d.ts +14 -1
- package/dist/commands/screenshot.js +102 -8
- package/dist/commands.d.ts +37 -21
- package/dist/commands.js +35 -15
- 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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotation spec: types, validation, and selector resolution.
|
|
3
|
+
*
|
|
4
|
+
* NOT exported outside this module directly — only `index.ts` re-exports
|
|
5
|
+
* these. Keep the public surface narrow so the renderer stays swappable
|
|
6
|
+
* (see the module header in `index.ts`).
|
|
7
|
+
*/
|
|
8
|
+
/** Thrown by `validateSpec` and `resolveSelectors`; carries every collected error. */
|
|
9
|
+
export class AnnotateSpecError extends Error {
|
|
10
|
+
errors;
|
|
11
|
+
constructor(errors) {
|
|
12
|
+
super(errors
|
|
13
|
+
.map((e) => (e.index === null ? e.message : `annotations[${e.index}]: ${e.message}`))
|
|
14
|
+
.join("; "));
|
|
15
|
+
this.name = "AnnotateSpecError";
|
|
16
|
+
this.errors = errors;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const GEOMETRIC_TYPES = new Set(["box", "arrow", "label", "redact"]);
|
|
20
|
+
/**
|
|
21
|
+
* Default placement offsets used when a spec omits an explicit position —
|
|
22
|
+
* kept together so "where things land by default" has one home. Units are
|
|
23
|
+
* image pixels; `labelAt` is additionally multiplied by the render scale.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_PLACEMENT = {
|
|
26
|
+
/** Selector-only arrow: tail offset from the target center (resolveSelectors). */
|
|
27
|
+
arrowFrom: [120, -120],
|
|
28
|
+
/** Label with a target but no `at`: bubble offset above-right (renderLabel). */
|
|
29
|
+
labelAt: [30, -90],
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Returns a rejection reason if a raw `svg` fragment is unsafe, else null.
|
|
33
|
+
* Shared by `validateSpec` and the renderer (defense in depth — the renderer
|
|
34
|
+
* re-checks in case a caller bypasses validation): `<script` is an obvious
|
|
35
|
+
* injection, and librsvg resolves href/xlink:href and CSS url() references,
|
|
36
|
+
* which has been an arbitrary-file-read vector (e.g. CVE-2023-38633).
|
|
37
|
+
*/
|
|
38
|
+
export function unsafeSvgFragmentReason(fragment) {
|
|
39
|
+
if (/<script/i.test(fragment))
|
|
40
|
+
return "svg fragment must not contain <script";
|
|
41
|
+
if (/\bhref\s*=|\burl\s*\(/i.test(fragment)) {
|
|
42
|
+
return "svg fragment must not reference external resources (href= or url())";
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function isFiniteNumber(v) {
|
|
47
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
48
|
+
}
|
|
49
|
+
function isPoint(v) {
|
|
50
|
+
return Array.isArray(v) && v.length === 2 && isFiniteNumber(v[0]) && isFiniteNumber(v[1]);
|
|
51
|
+
}
|
|
52
|
+
function hasOwn(obj, key) {
|
|
53
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
54
|
+
}
|
|
55
|
+
/** Validates one raw annotation, pushing any `SpecError`s onto `errors`. */
|
|
56
|
+
function validateAnnotation(raw, index, errors) {
|
|
57
|
+
const fail = (message) => errors.push({ index, message });
|
|
58
|
+
if (typeof raw !== "object" || raw === null) {
|
|
59
|
+
fail("must be an object");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const a = raw;
|
|
63
|
+
const type = a.type;
|
|
64
|
+
if (typeof type !== "string") {
|
|
65
|
+
fail("missing or non-string type");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const hasSelector = typeof a.selector === "string" && a.selector.length > 0;
|
|
69
|
+
const hasPixelGeometry = (() => {
|
|
70
|
+
switch (type) {
|
|
71
|
+
case "box":
|
|
72
|
+
case "redact":
|
|
73
|
+
return hasOwn(a, "x") || hasOwn(a, "y") || hasOwn(a, "w") || hasOwn(a, "h");
|
|
74
|
+
// `from` is deliberately allowed alongside a selector: the selector
|
|
75
|
+
// resolves the arrow's head (`to`), and an explicit `from` overrides
|
|
76
|
+
// the default tail placement (see resolveSelectors).
|
|
77
|
+
case "arrow":
|
|
78
|
+
return hasOwn(a, "to");
|
|
79
|
+
case "label":
|
|
80
|
+
return hasOwn(a, "target");
|
|
81
|
+
default:
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
if (GEOMETRIC_TYPES.has(type) && hasSelector && hasPixelGeometry) {
|
|
86
|
+
fail("ambiguous: both pixel geometry and a selector are present, pick one");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const requireBoxFields = () => {
|
|
90
|
+
if (hasSelector)
|
|
91
|
+
return;
|
|
92
|
+
if (!isFiniteNumber(a.x) ||
|
|
93
|
+
!isFiniteNumber(a.y) ||
|
|
94
|
+
!isFiniteNumber(a.w) ||
|
|
95
|
+
!isFiniteNumber(a.h)) {
|
|
96
|
+
fail(`${type} requires finite x, y, w, h (or a selector)`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
switch (type) {
|
|
100
|
+
case "box": {
|
|
101
|
+
requireBoxFields();
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "redact": {
|
|
105
|
+
requireBoxFields();
|
|
106
|
+
if (hasOwn(a, "style") && a.style !== "blur" && a.style !== "solid") {
|
|
107
|
+
fail('redact style must be "blur" or "solid"');
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "arrow": {
|
|
112
|
+
if (hasSelector)
|
|
113
|
+
break;
|
|
114
|
+
if (!isPoint(a.from) || !isPoint(a.to)) {
|
|
115
|
+
fail("arrow requires from/to points (or a selector)");
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "label": {
|
|
120
|
+
if (typeof a.text !== "string" || a.text.length === 0) {
|
|
121
|
+
fail("label requires non-empty text");
|
|
122
|
+
}
|
|
123
|
+
if (hasSelector)
|
|
124
|
+
break;
|
|
125
|
+
if (hasOwn(a, "target") && !isPoint(a.target)) {
|
|
126
|
+
fail("label target must be a point");
|
|
127
|
+
}
|
|
128
|
+
if (hasOwn(a, "at") && !isPoint(a.at)) {
|
|
129
|
+
fail("label at must be a point");
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "draw": {
|
|
134
|
+
if (!Array.isArray(a.points) || a.points.length < 2 || !a.points.every(isPoint)) {
|
|
135
|
+
fail("draw requires at least 2 points");
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "svg": {
|
|
140
|
+
if (typeof a.fragment !== "string" || a.fragment.length === 0) {
|
|
141
|
+
fail("svg requires a non-empty fragment");
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const unsafe = unsafeSvgFragmentReason(a.fragment);
|
|
145
|
+
if (unsafe)
|
|
146
|
+
fail(unsafe);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
default:
|
|
151
|
+
fail(`unknown annotation type "${String(type)}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Throws AnnotateSpecError (carries errors: SpecError[]) on invalid input. */
|
|
155
|
+
export function validateSpec(json) {
|
|
156
|
+
const errors = [];
|
|
157
|
+
if (typeof json !== "object" || json === null) {
|
|
158
|
+
throw new AnnotateSpecError([{ index: null, message: "spec must be an object" }]);
|
|
159
|
+
}
|
|
160
|
+
const raw = json;
|
|
161
|
+
if (raw.version !== 1) {
|
|
162
|
+
errors.push({ index: null, message: `version must be 1, got ${JSON.stringify(raw.version)}` });
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(raw.annotations) || raw.annotations.length === 0) {
|
|
165
|
+
errors.push({ index: null, message: "annotations must be a non-empty array" });
|
|
166
|
+
}
|
|
167
|
+
if (errors.length > 0)
|
|
168
|
+
throw new AnnotateSpecError(errors);
|
|
169
|
+
const annotations = raw.annotations;
|
|
170
|
+
annotations.forEach((a, i) => validateAnnotation(a, i, errors));
|
|
171
|
+
if (errors.length > 0)
|
|
172
|
+
throw new AnnotateSpecError(errors);
|
|
173
|
+
return { version: 1, annotations: annotations };
|
|
174
|
+
}
|
|
175
|
+
/** True if any annotation still carries an unresolved selector. */
|
|
176
|
+
export function hasSelectors(spec) {
|
|
177
|
+
return spec.annotations.some((a) => "selector" in a && typeof a.selector === "string");
|
|
178
|
+
}
|
|
179
|
+
/** All distinct selectors in the spec, in order. */
|
|
180
|
+
export function specSelectors(spec) {
|
|
181
|
+
const seen = [];
|
|
182
|
+
for (const a of spec.annotations) {
|
|
183
|
+
const sel = "selector" in a ? a.selector : undefined;
|
|
184
|
+
if (typeof sel === "string" && !seen.includes(sel))
|
|
185
|
+
seen.push(sel);
|
|
186
|
+
}
|
|
187
|
+
return seen;
|
|
188
|
+
}
|
|
189
|
+
function center(box) {
|
|
190
|
+
return [box.x + box.w / 2, box.y + box.h / 2];
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Replace selector targeting with pixel geometry using measured boxes keyed
|
|
194
|
+
* by selector. Throws AnnotateSpecError naming any selector missing from
|
|
195
|
+
* boxes.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveSelectors(spec, boxes) {
|
|
198
|
+
const errors = [];
|
|
199
|
+
const resolved = spec.annotations.map((a, index) => {
|
|
200
|
+
const sel = "selector" in a ? a.selector : undefined;
|
|
201
|
+
if (typeof sel !== "string")
|
|
202
|
+
return a;
|
|
203
|
+
const box = boxes[sel];
|
|
204
|
+
if (!box) {
|
|
205
|
+
errors.push({ index, message: `selector "${sel}" was not found among the measured boxes` });
|
|
206
|
+
return a;
|
|
207
|
+
}
|
|
208
|
+
switch (a.type) {
|
|
209
|
+
case "box":
|
|
210
|
+
case "redact": {
|
|
211
|
+
const { selector: _selector, ...rest } = a;
|
|
212
|
+
return { ...rest, x: box.x, y: box.y, w: box.w, h: box.h };
|
|
213
|
+
}
|
|
214
|
+
case "arrow": {
|
|
215
|
+
const { selector: _selector, ...rest } = a;
|
|
216
|
+
const to = center(box);
|
|
217
|
+
// A selector-only arrow points at the element from its upper right;
|
|
218
|
+
// the renderer clamps if that lands outside the image.
|
|
219
|
+
const from = rest.from ??
|
|
220
|
+
[
|
|
221
|
+
to[0] + DEFAULT_PLACEMENT.arrowFrom[0],
|
|
222
|
+
to[1] + DEFAULT_PLACEMENT.arrowFrom[1],
|
|
223
|
+
];
|
|
224
|
+
return { ...rest, from, to };
|
|
225
|
+
}
|
|
226
|
+
case "label": {
|
|
227
|
+
const { selector: _selector, ...rest } = a;
|
|
228
|
+
return { ...rest, target: center(box) };
|
|
229
|
+
}
|
|
230
|
+
default:
|
|
231
|
+
return a;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (errors.length > 0)
|
|
235
|
+
throw new AnnotateSpecError(errors);
|
|
236
|
+
return { version: 1, annotations: resolved };
|
|
237
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface TextMetrics {
|
|
2
|
+
/** SVG path `d` data, already positioned at the requested x/y baseline. */
|
|
3
|
+
d: string;
|
|
4
|
+
/** Total advance width in pixels at the requested font size. */
|
|
5
|
+
width: number;
|
|
6
|
+
/** Ascent + descent in pixels at the requested font size (line height). */
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
/** Renders `text` to an SVG path at the given baseline origin and font size (px). */
|
|
10
|
+
export declare function textToPath(text: string, x: number, y: number, fontSize: number): TextMetrics;
|
|
11
|
+
/** Measures `text` at the given font size without generating path data. */
|
|
12
|
+
export declare function measureText(text: string, fontSize: number): {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-as-paths via opentype.js + the bundled Excalifont.
|
|
3
|
+
*
|
|
4
|
+
* Not exported outside this module — only `index.ts` re-exports the public
|
|
5
|
+
* surface (see the module header there). Rendering label text as SVG paths
|
|
6
|
+
* (instead of an SVG `<text>` element) sidesteps librsvg's fontconfig-based
|
|
7
|
+
* font resolution entirely, so the renderer produces identical output no
|
|
8
|
+
* matter which fonts happen to be installed on the host.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import opentype from "opentype.js";
|
|
13
|
+
const FONT_URL = new URL("../../assets/Excalifont-Regular.ttf", import.meta.url);
|
|
14
|
+
let cachedFont;
|
|
15
|
+
function loadFont() {
|
|
16
|
+
if (!cachedFont) {
|
|
17
|
+
const buf = readFileSync(fileURLToPath(FONT_URL));
|
|
18
|
+
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
19
|
+
cachedFont = opentype.parse(arrayBuffer);
|
|
20
|
+
}
|
|
21
|
+
return cachedFont;
|
|
22
|
+
}
|
|
23
|
+
/** Renders `text` to an SVG path at the given baseline origin and font size (px). */
|
|
24
|
+
export function textToPath(text, x, y, fontSize) {
|
|
25
|
+
const font = loadFont();
|
|
26
|
+
const path = font.getPath(text, x, y, fontSize);
|
|
27
|
+
const width = font.getAdvanceWidth(text, fontSize);
|
|
28
|
+
const scale = fontSize / font.unitsPerEm;
|
|
29
|
+
const height = (font.ascender - font.descender) * scale;
|
|
30
|
+
return { d: path.toPathData(2), width, height };
|
|
31
|
+
}
|
|
32
|
+
/** Measures `text` at the given font size without generating path data. */
|
|
33
|
+
export function measureText(text, fontSize) {
|
|
34
|
+
const font = loadFont();
|
|
35
|
+
const width = font.getAdvanceWidth(text, fontSize);
|
|
36
|
+
const scale = fontSize / font.unitsPerEm;
|
|
37
|
+
const height = (font.ascender - font.descender) * scale;
|
|
38
|
+
return { width, height };
|
|
39
|
+
}
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -45,6 +45,21 @@ export declare function parseCommandArgs(args: string[]): CommandFlags;
|
|
|
45
45
|
* `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
|
|
46
46
|
* `flagValues` instead.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Rewrites short aliases (e.g. `-o` -> `--out`) before `parseCommandArgs`,
|
|
50
|
+
* which only understands `--long` flags — a bare short flag would otherwise
|
|
51
|
+
* land in positionals.
|
|
52
|
+
*/
|
|
53
|
+
export declare function expandFlagAliases(args: string[], aliases: Record<string, string>): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Pulls a `--flag -` pair (the stdin convention) out of `args` before the
|
|
56
|
+
* generic parse: `parseCommandArgs` treats a bare `-` as a flag boundary, so
|
|
57
|
+
* it can never be consumed as a flag's value.
|
|
58
|
+
*/
|
|
59
|
+
export declare function extractDashValue(args: string[], flag: string): {
|
|
60
|
+
args: string[];
|
|
61
|
+
dash: boolean;
|
|
62
|
+
};
|
|
48
63
|
export declare function flagString(flags: CommandFlags["flags"], name: string): string | undefined;
|
|
49
64
|
export declare function flagBool(flags: CommandFlags["flags"], name: string): boolean;
|
|
50
65
|
/**
|
package/dist/cli-args.js
CHANGED
|
@@ -139,6 +139,27 @@ export function parseCommandArgs(args) {
|
|
|
139
139
|
* `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
|
|
140
140
|
* `flagValues` instead.
|
|
141
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Rewrites short aliases (e.g. `-o` -> `--out`) before `parseCommandArgs`,
|
|
144
|
+
* which only understands `--long` flags — a bare short flag would otherwise
|
|
145
|
+
* land in positionals.
|
|
146
|
+
*/
|
|
147
|
+
export function expandFlagAliases(args, aliases) {
|
|
148
|
+
return args.map((a) => aliases[a] ?? a);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Pulls a `--flag -` pair (the stdin convention) out of `args` before the
|
|
152
|
+
* generic parse: `parseCommandArgs` treats a bare `-` as a flag boundary, so
|
|
153
|
+
* it can never be consumed as a flag's value.
|
|
154
|
+
*/
|
|
155
|
+
export function extractDashValue(args, flag) {
|
|
156
|
+
for (let i = 0; i < args.length; i++) {
|
|
157
|
+
if (args[i] === flag && args[i + 1] === "-") {
|
|
158
|
+
return { args: [...args.slice(0, i), ...args.slice(i + 2)], dash: true };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { args, dash: false };
|
|
162
|
+
}
|
|
142
163
|
export function flagString(flags, name) {
|
|
143
164
|
const value = flags.get(name);
|
|
144
165
|
if (typeof value === "string")
|
package/dist/cli-catalog.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare const PUT_LIKE_FLAGS: readonly string[];
|
|
|
29
29
|
* screenshot never parses.
|
|
30
30
|
*/
|
|
31
31
|
export declare const SCREENSHOT_FLAGS: readonly string[];
|
|
32
|
+
/** All flags `uploads annotate` actually reads (verified against commands/annotate.ts). */
|
|
33
|
+
export declare const ANNOTATE_FLAGS: readonly string[];
|
|
32
34
|
export declare const LIST_LIKE_FLAGS: readonly string[];
|
|
33
35
|
export declare const ROOT_COMMANDS: readonly CatalogCommand[];
|
|
34
36
|
export declare const COMPLETION_SHELLS: readonly ["bash", "zsh", "fish"];
|
package/dist/cli-catalog.js
CHANGED
|
@@ -59,6 +59,7 @@ export const SCREENSHOT_FLAGS = [
|
|
|
59
59
|
"--dark",
|
|
60
60
|
"--light",
|
|
61
61
|
"--wait",
|
|
62
|
+
"--annotate",
|
|
62
63
|
"--out",
|
|
63
64
|
"--no-sidecar",
|
|
64
65
|
"--no-upload",
|
|
@@ -92,6 +93,16 @@ export const SCREENSHOT_FLAGS = [
|
|
|
92
93
|
"--help",
|
|
93
94
|
"-h",
|
|
94
95
|
];
|
|
96
|
+
/** All flags `uploads annotate` actually reads (verified against commands/annotate.ts). */
|
|
97
|
+
export const ANNOTATE_FLAGS = [
|
|
98
|
+
"--spec",
|
|
99
|
+
"-o",
|
|
100
|
+
"--out",
|
|
101
|
+
"--seed",
|
|
102
|
+
"--format",
|
|
103
|
+
"--help",
|
|
104
|
+
"-h",
|
|
105
|
+
];
|
|
95
106
|
export const LIST_LIKE_FLAGS = [
|
|
96
107
|
"--prefix",
|
|
97
108
|
"--limit",
|
|
@@ -125,6 +136,11 @@ export const ROOT_COMMANDS = [
|
|
|
125
136
|
summary: "Capture a URL or .html file and host it (local browser or remote render)",
|
|
126
137
|
essential: true,
|
|
127
138
|
},
|
|
139
|
+
{
|
|
140
|
+
name: "annotate",
|
|
141
|
+
usage: "annotate <image>",
|
|
142
|
+
summary: "Bake hand-drawn boxes, arrows, labels, and redactions onto an image",
|
|
143
|
+
},
|
|
128
144
|
{
|
|
129
145
|
name: "gallery",
|
|
130
146
|
summary: "Create and organize public media galleries",
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { runLogout, runWhoami } from "./commands/session.js";
|
|
|
19
19
|
import { runTelemetry } from "./commands/telemetry.js";
|
|
20
20
|
import { runReport } from "./commands/report.js";
|
|
21
21
|
import { runScreenshot } from "./commands/screenshot.js";
|
|
22
|
+
import { runAnnotate } from "./commands/annotate.js";
|
|
22
23
|
import { packageVersion } from "./package-version.js";
|
|
23
24
|
import { checkForUpdate, maybeHintUpdate } from "./update-check.js";
|
|
24
25
|
import { maybeSyncSessionCliVersion } from "./session-cli-version.js";
|
|
@@ -281,6 +282,11 @@ export async function runCli(argv) {
|
|
|
281
282
|
case "completions":
|
|
282
283
|
code = await runCompletion(cmdArgs, showHelp);
|
|
283
284
|
break;
|
|
285
|
+
case "annotate":
|
|
286
|
+
// No CliContext needed — annotate is a pure local pixel transform,
|
|
287
|
+
// never touches auth/client/upload.
|
|
288
|
+
code = await runAnnotate(cmdArgs, showHelp);
|
|
289
|
+
break;
|
|
284
290
|
case "attach":
|
|
285
291
|
case "put":
|
|
286
292
|
case "staged":
|
package/dist/client.d.ts
CHANGED
|
@@ -82,6 +82,24 @@ export interface PutResult {
|
|
|
82
82
|
* non-`gh/` key, existing object, no `replace`) instead of overwriting.
|
|
83
83
|
*/
|
|
84
84
|
wouldRefuse?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The object's R2 provenance bag (`client`, `source-name`,
|
|
87
|
+
* `content-sha256`) — what the upload was made *by*, not the tags it was
|
|
88
|
+
* tagged *with*. Absent on a dry run, and on API deployments older than the
|
|
89
|
+
* split that gave this bag its own name.
|
|
90
|
+
*/
|
|
91
|
+
provenance?: Record<string, string>;
|
|
92
|
+
/**
|
|
93
|
+
* The queryable metadata (D1) this put stored, including server-derived
|
|
94
|
+
* pairs the client never sent (`gh.uploader`). Absent when the put carried
|
|
95
|
+
* no metadata — that case leaves any existing tags untouched, so the server
|
|
96
|
+
* reports nothing rather than implying an empty set. Means the same thing
|
|
97
|
+
* on `getMetadata`, `patchMetadata`, and `list({ metadata: true })`.
|
|
98
|
+
*
|
|
99
|
+
* An API older than the split returns the provenance bag here instead, so a
|
|
100
|
+
* client that must work against both reads `path`-style tags defensively —
|
|
101
|
+
* see `pathMetaHintFor` in commands.ts, which prefers what it sent.
|
|
102
|
+
*/
|
|
85
103
|
metadata?: Record<string, string>;
|
|
86
104
|
}
|
|
87
105
|
export interface ListItem {
|
|
@@ -106,7 +124,12 @@ export interface HeadResult {
|
|
|
106
124
|
size: number;
|
|
107
125
|
contentType: string;
|
|
108
126
|
uploaded?: string;
|
|
109
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The object's R2 provenance bag — see `PutResult.provenance`. A plain head
|
|
129
|
+
* returns no queryable metadata at all: that tier lives in a separate store
|
|
130
|
+
* and takes a separate read, so call `getMetadata(key)` for it.
|
|
131
|
+
*/
|
|
132
|
+
provenance?: Record<string, string>;
|
|
110
133
|
}
|
|
111
134
|
export interface DeleteResult {
|
|
112
135
|
key: string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join, dirname } from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { expandFlagAliases, extractDashValue, flagInt, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
|
|
5
|
+
import { writeCommandHelp } from "../cli-style.js";
|
|
6
|
+
import { readStdin, writeJson, writeStdout } from "../io.js";
|
|
7
|
+
const ANNOTATE_HELP = `uploads annotate <image> --spec <file|-> [options]
|
|
8
|
+
|
|
9
|
+
Bake hand-drawn boxes, arrows, labels, freeform strokes, and redactions onto
|
|
10
|
+
an existing image. image is a path to a local PNG/JPEG file. The spec is a
|
|
11
|
+
JSON document (see the annotate-screenshots skill for the full format);
|
|
12
|
+
pass a file path or "-" to read it from stdin.
|
|
13
|
+
|
|
14
|
+
Selector-bearing specs (annotations that point at a CSS selector instead of
|
|
15
|
+
pixel coordinates) are rejected here — selectors can only be resolved
|
|
16
|
+
against a live page, via "uploads screenshot --annotate".
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--spec <file|-> JSON annotation spec (required)
|
|
20
|
+
-o, --out <file> Output PNG path (default: <stem>.annotated.png next to the image)
|
|
21
|
+
--seed <n> Fix the rough.js seed for deterministic sketchy rendering (default: 7)
|
|
22
|
+
--format human|json Output format (default: human)
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 ok · 1 invalid spec · 2 usage.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
uploads annotate ./shot.png --spec ./callouts.json
|
|
28
|
+
cat ./callouts.json | uploads annotate ./shot.png --spec -
|
|
29
|
+
uploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.png
|
|
30
|
+
`;
|
|
31
|
+
function defaultOutPath(imagePath) {
|
|
32
|
+
const ext = extname(imagePath);
|
|
33
|
+
const stem = ext ? basename(imagePath, ext) : basename(imagePath);
|
|
34
|
+
return join(dirname(imagePath), `${stem}.annotated.png`);
|
|
35
|
+
}
|
|
36
|
+
export async function runAnnotate(args, help = false,
|
|
37
|
+
/** Injectable for tests — avoids depending on a real stdin stream. */
|
|
38
|
+
readStdinImpl = readStdin) {
|
|
39
|
+
if (help) {
|
|
40
|
+
writeCommandHelp(ANNOTATE_HELP);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const { args: preArgs, dash: specFromDash } = extractDashValue(expandFlagAliases(args, { "-o": "--out" }), "--spec");
|
|
44
|
+
const parsed = parseCommandArgs(preArgs);
|
|
45
|
+
if (parsed.help) {
|
|
46
|
+
writeCommandHelp(ANNOTATE_HELP);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const imagePath = parsed.positionals[0];
|
|
50
|
+
if (!imagePath) {
|
|
51
|
+
writeCommandHelp(ANNOTATE_HELP);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
if (parsed.positionals.length > 1) {
|
|
55
|
+
throw new UsageError("annotate takes exactly one image argument");
|
|
56
|
+
}
|
|
57
|
+
const specArg = specFromDash ? "-" : flagString(parsed.flags, "--spec");
|
|
58
|
+
if (!specArg) {
|
|
59
|
+
throw new UsageError("--spec is required (a file path or - for stdin)");
|
|
60
|
+
}
|
|
61
|
+
const outFlag = flagString(parsed.flags, "--out");
|
|
62
|
+
const seed = flagInt(parsed.flags, "--seed", "--seed");
|
|
63
|
+
const format = flagString(parsed.flags, "--format");
|
|
64
|
+
if (format && format !== "human" && format !== "json") {
|
|
65
|
+
throw new UsageError(`invalid --format: ${format} (use human or json)`);
|
|
66
|
+
}
|
|
67
|
+
const wantJson = format === "json";
|
|
68
|
+
let specText;
|
|
69
|
+
if (specArg === "-") {
|
|
70
|
+
specText = await readStdinImpl();
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
try {
|
|
74
|
+
specText = readFileSync(specArg, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new UsageError(`could not read --spec ${specArg}: ${err instanceof Error ? err.message : String(err)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const { validateSpec, hasSelectors, renderAnnotations, clampReport, AnnotateSpecError } = await import("../annotate/index.js");
|
|
81
|
+
let specJson;
|
|
82
|
+
try {
|
|
83
|
+
specJson = JSON.parse(specText);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
await writeStdout("");
|
|
87
|
+
process.stderr.write(`spec is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
let spec;
|
|
91
|
+
try {
|
|
92
|
+
spec = validateSpec(specJson);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err instanceof AnnotateSpecError) {
|
|
96
|
+
for (const e of err.errors) {
|
|
97
|
+
const prefix = e.index === null ? "spec" : `annotations[${e.index}]`;
|
|
98
|
+
process.stderr.write(`${prefix}: ${e.message}\n`);
|
|
99
|
+
}
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
if (hasSelectors(spec)) {
|
|
105
|
+
throw new UsageError('annotate works on pixels; selectors need "uploads screenshot --annotate" (live page required)');
|
|
106
|
+
}
|
|
107
|
+
const image = readFileSync(imagePath);
|
|
108
|
+
const meta = await sharp(image).metadata();
|
|
109
|
+
const width = meta.width ?? 0;
|
|
110
|
+
const height = meta.height ?? 0;
|
|
111
|
+
const warnings = clampReport(spec, width, height);
|
|
112
|
+
for (const w of warnings)
|
|
113
|
+
process.stderr.write(`${w}\n`);
|
|
114
|
+
const rendered = await renderAnnotations(image, spec, seed !== undefined ? { seed } : undefined);
|
|
115
|
+
const outPath = outFlag ?? defaultOutPath(imagePath);
|
|
116
|
+
writeFileSync(outPath, rendered);
|
|
117
|
+
if (wantJson) {
|
|
118
|
+
await writeJson({ out: outPath, width, height, warnings });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
await writeStdout(`${outPath}\n`);
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseCommandArgs, UsageError } from "../cli-args.js";
|
|
2
|
-
import { COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, SCREENSHOT_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
|
|
2
|
+
import { ANNOTATE_FLAGS, COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, SCREENSHOT_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
|
|
3
3
|
import { writeCommandHelp } from "../cli-style.js";
|
|
4
4
|
const HELP = `uploads completion <shell>
|
|
5
5
|
|
|
@@ -29,6 +29,7 @@ function bashScript() {
|
|
|
29
29
|
const putFlags = PUT_LIKE_FLAGS.join(" ");
|
|
30
30
|
const listFlags = LIST_LIKE_FLAGS.join(" ");
|
|
31
31
|
const screenshotFlags = SCREENSHOT_FLAGS.join(" ");
|
|
32
|
+
const annotateFlags = ANNOTATE_FLAGS.join(" ");
|
|
32
33
|
const subMaps = ROOT_COMMANDS.filter((c) => c.subcommands?.length).map((c) => {
|
|
33
34
|
const names = c.subcommands.map((s) => s.name).join(" ");
|
|
34
35
|
return ` ${c.name}) subs="${names}" ;;`;
|
|
@@ -54,6 +55,7 @@ _uploads() {
|
|
|
54
55
|
local -a put_flags=(${putFlags})
|
|
55
56
|
local -a list_flags=(${listFlags})
|
|
56
57
|
local -a screenshot_flags=(${screenshotFlags})
|
|
58
|
+
local -a annotate_flags=(${annotateFlags})
|
|
57
59
|
|
|
58
60
|
# Find the first non-global positional (the subcommand).
|
|
59
61
|
local cmd="" i=1
|
|
@@ -107,6 +109,9 @@ ${subMaps.join("\n")}
|
|
|
107
109
|
screenshot)
|
|
108
110
|
COMPREPLY=( $(compgen -W "\${screenshot_flags[*]}" -- "$cur") )
|
|
109
111
|
;;
|
|
112
|
+
annotate)
|
|
113
|
+
COMPREPLY=( $(compgen -W "\${annotate_flags[*]}" -- "$cur") )
|
|
114
|
+
;;
|
|
110
115
|
list|find)
|
|
111
116
|
COMPREPLY=( $(compgen -W "\${list_flags[*]}" -- "$cur") )
|
|
112
117
|
;;
|
|
@@ -119,7 +124,7 @@ ${subMaps.join("\n")}
|
|
|
119
124
|
|
|
120
125
|
# File paths for upload-style commands.
|
|
121
126
|
case "$cmd" in
|
|
122
|
-
put|attach|screenshot)
|
|
127
|
+
put|attach|screenshot|annotate)
|
|
123
128
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
124
129
|
;;
|
|
125
130
|
esac
|
|
@@ -199,7 +204,7 @@ ${globalArgs} \\
|
|
|
199
204
|
args)
|
|
200
205
|
case $line[1] in
|
|
201
206
|
${subCases}
|
|
202
|
-
put|attach|screenshot)
|
|
207
|
+
put|attach|screenshot|annotate)
|
|
203
208
|
_files
|
|
204
209
|
;;
|
|
205
210
|
esac
|
|
@@ -265,8 +270,19 @@ function fishScript() {
|
|
|
265
270
|
continue;
|
|
266
271
|
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from list find' -l ${flag.slice(2)}`);
|
|
267
272
|
}
|
|
268
|
-
|
|
269
|
-
|
|
273
|
+
for (const flag of ANNOTATE_FLAGS) {
|
|
274
|
+
if (flag === "-o")
|
|
275
|
+
continue; // folded into --out's paired completion below
|
|
276
|
+
if (flag === "--out") {
|
|
277
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from annotate' -s o -l out`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!flag.startsWith("--"))
|
|
281
|
+
continue;
|
|
282
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from annotate' -l ${flag.slice(2)}`);
|
|
283
|
+
}
|
|
284
|
+
// File completion for put/attach/screenshot/annotate
|
|
285
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach screenshot annotate' -F`);
|
|
270
286
|
return lines.join("\n") + "\n";
|
|
271
287
|
}
|
|
272
288
|
export function generateCompletionScript(shell) {
|
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.
|
|
@@ -21,7 +22,8 @@ Usage:
|
|
|
21
22
|
|
|
22
23
|
What it does:
|
|
23
24
|
skill Agent skills (via npx skills) — github-screenshots: visuals into
|
|
24
|
-
PRs/issues; uploads-cli: full CLI reference
|
|
25
|
+
PRs/issues; uploads-cli: full CLI reference; annotate-screenshots:
|
|
26
|
+
hand-drawn callouts and redaction on screenshots
|
|
25
27
|
mcp Hosted MCP server in Claude Code — put, list, attach, galleries
|
|
26
28
|
hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
|
|
27
29
|
|