@todrawn/scribe-mcp 0.0.0-stage → 0.1.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 +74 -2
- package/dist/constants.js +36 -0
- package/dist/drawing.js +124 -0
- package/dist/image.js +48 -0
- package/dist/index.js +17 -0
- package/dist/link.js +88 -0
- package/dist/paint.js +27 -0
- package/dist/painter.js +504 -0
- package/dist/server.js +260 -0
- package/dist/svg-path.js +238 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Todrawn Scribe MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an AI agent turn code, text, a line drawing or a photo into a [Todrawn Scribe](https://todrawn.com/scribe) project, a video of a pen writing, drawing or painting it by hand.
|
|
4
|
+
|
|
5
|
+
The agent gets back a link. Opening it loads the project into the Scribe studio, where you preview it and export the MP4. The whole project travels inside the link after `#`, so the server uploads nothing and needs no account or API key.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Claude Code:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add todrawn-scribe -- npx -y @todrawn/scribe-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
OpenAI Codex CLI:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
codex mcp add todrawn-scribe -- npx -y @todrawn/scribe-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
VS Code (GitHub Copilot):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
code --add-mcp '{"name":"todrawn-scribe","command":"npx","args":["-y","@todrawn/scribe-mcp"]}'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Claude Desktop (Settings > Developer > Edit Config), Cursor (`~/.cursor/mcp.json`), Gemini CLI (`~/.gemini/settings.json`) and other clients that read an `mcpServers` block:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"todrawn-scribe": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "@todrawn/scribe-mcp"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Any other MCP client runs the same stdio command: `npx -y @todrawn/scribe-mcp`. Needs Node.js 20 or newer.
|
|
41
|
+
|
|
42
|
+
Then ask, for example: "Make a vertical Scribe video of the function I just wrote." or "Draw a lighthouse with Scribe." or "Paint ~/Pictures/dinner.jpg with Scribe."
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `scribe_create_video` | Takes `text` and optionally `language`, `aspect`, look and pacing settings and `locale`. Returns the studio link. |
|
|
49
|
+
| `scribe_create_drawing` | Takes `paths` (SVG path data plus an optional ink colour each) and optionally `viewBox`, `aspect`, look settings and `locale`. Curves and arcs are flattened; the pen draws the paths in order. Returns the studio link. |
|
|
50
|
+
| `scribe_paint_image` | Takes `imagePath` (a local PNG or JPEG) and optionally `aspect`, `detail` (low, medium, high), `pen`, `background`, `speedMultiplier` and `locale`. Paints the photo into up to ~2800 coloured strokes (an underpaint, then detail) and returns the studio link. |
|
|
51
|
+
| `scribe_limits` | Lists input limits and what the Free and Premium plans allow. |
|
|
52
|
+
|
|
53
|
+
## Privacy
|
|
54
|
+
|
|
55
|
+
Photos are painted on your machine. The link holds only the pen strokes, after `#`, which browsers never send to a server, so neither the photo nor the painting is uploaded by this server.
|
|
56
|
+
|
|
57
|
+
## Plans
|
|
58
|
+
|
|
59
|
+
Previewing is free and needs no account. Exporting needs a free Todrawn account:
|
|
60
|
+
|
|
61
|
+
- **Free:** 5 exports per UTC day; every video ends with a "Made with Todrawn Scribe" card.
|
|
62
|
+
- **Premium:** unlimited exports; the card is optional.
|
|
63
|
+
|
|
64
|
+
Input is capped at 1 MiB and 5000 lines. Clips are capped at 180 s, and longer text is drawn faster to fit.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install
|
|
70
|
+
npm test # node --test, runs the TypeScript sources directly (Node 22.6+)
|
|
71
|
+
npm run build # emits dist/
|
|
72
|
+
TODRAWN_SCRIBE_URL=http://localhost:3000 node dist/index.js # links point at a local frontend
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The constants in `src/constants.ts` mirror the studio. `frontend/src/lib/scribe/recipe-link-mcp.test.ts` fails if they drift, and checks that every link this server builds opens in the studio.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* Product facts the server validates against. Each one mirrors a constant in
|
|
2
|
+
frontend/src/lib/scribe; frontend/src/lib/scribe/recipe-link-mcp.test.ts
|
|
3
|
+
fails when they drift. Kept dependency-free so that test can import it. */
|
|
4
|
+
/** Studio tokenizer languages (lib/scribe/tokenize.ts Language). */
|
|
5
|
+
export const LANGUAGES = ["js", "ts", "py", "java", "clike", "sql", "html", "css", "bash", "go", "rust", "plain"];
|
|
6
|
+
/** Output aspects (lib/scribe/presets.ts ASPECTS); "feed" is 4:5. */
|
|
7
|
+
export const ASPECTS = ["landscape", "portrait", "square", "feed"];
|
|
8
|
+
/** Site locales and their path prefixes (src/i18n/routing.ts LOCALE_PATH_PREFIXES). */
|
|
9
|
+
export const LOCALE_PATH_PREFIXES = { en: "", pl: "/pl", de: "/de", fr: "/fr", es: "/es", "pt-BR": "/pt-br" };
|
|
10
|
+
/** Input caps (lib/scribe/limits.ts). */
|
|
11
|
+
export const MAX_INPUT_BYTES = 1024 * 1024;
|
|
12
|
+
export const MAX_LINES = 5000;
|
|
13
|
+
export const MAX_PLAYBACK_SECONDS = 180;
|
|
14
|
+
export const FREE_EXPORTS_PER_DAY = 5;
|
|
15
|
+
export const RESOLUTIONS = ["720p", "1080p"];
|
|
16
|
+
export const FRAME_RATES = [24, 30];
|
|
17
|
+
/** Link format (lib/scribe/recipe-link.ts). */
|
|
18
|
+
export const RECIPE_LINK_VERSION = 1;
|
|
19
|
+
export const MAX_RECIPE_LINK_CHARS = 120_000;
|
|
20
|
+
/** Settings ranges a link may set (lib/scribe/settings.ts validateScribeSettings). */
|
|
21
|
+
export const PENS = ["ballpoint", "marker", "fineliner", "pencil"];
|
|
22
|
+
export const BACKGROUNDS = ["dark", "paper"];
|
|
23
|
+
export const COLOR_MODES = ["syntax", "solid"];
|
|
24
|
+
export const TIMINGS = ["reading", "pen"];
|
|
25
|
+
export const LAYOUTS = ["auto", "scroll", "pages"];
|
|
26
|
+
export const READING_WPS = { min: 1.5, max: 6, step: 0.5 };
|
|
27
|
+
export const SPEED = { min: 0.5, max: 2 };
|
|
28
|
+
/** Width/height of each artboard (lib/scribe/presets.ts paperForPreset). */
|
|
29
|
+
export const ASPECT_RATIOS = { landscape: 16 / 9, portrait: 9 / 16, square: 1, feed: 4 / 5 };
|
|
30
|
+
/** Named ink colours that adapt to the background (lib/scribe/annotations.ts ANNOTATION_COLOR_KEYS); #rrggbb also works. */
|
|
31
|
+
export const DRAW_COLOR_KEYS = ["blue", "red", "green", "violet", "amber"];
|
|
32
|
+
/** Draft caps (lib/scribe/recent-work.ts MAX_RECENT_TOTAL_POINTS / MAX_RECENT_STROKES). */
|
|
33
|
+
export const MAX_DRAW_POINTS = 500_000;
|
|
34
|
+
export const MAX_DRAW_STROKES = 50_000;
|
|
35
|
+
/** Drawing link coordinates are integers in 0..DRAW_COORD_MAX across the artboard (lib/scribe/recipe-link.ts). */
|
|
36
|
+
export const DRAW_COORD_MAX = 10_000;
|
package/dist/drawing.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { deflateRawSync } from "node:zlib";
|
|
2
|
+
import { ASPECT_RATIOS, ASPECTS, DRAW_COLOR_KEYS, DRAW_COORD_MAX, LOCALE_PATH_PREFIXES, MAX_DRAW_POINTS, MAX_DRAW_STROKES, MAX_RECIPE_LINK_CHARS, RECIPE_LINK_VERSION, } from "./constants.js";
|
|
3
|
+
import { DEFAULT_BASE_URL, LinkError } from "./link.js";
|
|
4
|
+
import { PathError, pathToPolylines } from "./svg-path.js";
|
|
5
|
+
/** Share of the artboard left empty around a drawing framed by its own bounding box. */
|
|
6
|
+
const AUTO_MARGIN = 0.08;
|
|
7
|
+
function parseViewBox(value) {
|
|
8
|
+
const parts = value.trim().split(/[\s,]+/).map(Number);
|
|
9
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)) || parts[2] <= 0 || parts[3] <= 0) {
|
|
10
|
+
throw new LinkError(`viewBox must be "minX minY width height" with a positive size, got "${value}".`);
|
|
11
|
+
}
|
|
12
|
+
const [x, y, w, h] = parts;
|
|
13
|
+
return { x, y, w, h };
|
|
14
|
+
}
|
|
15
|
+
function resolveColor(value) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return "blue";
|
|
18
|
+
const color = value.trim().toLowerCase();
|
|
19
|
+
if (DRAW_COLOR_KEYS.includes(color) || /^#[0-9a-f]{6}$/.test(color))
|
|
20
|
+
return color;
|
|
21
|
+
throw new LinkError(`Unknown colour "${value}". Use ${DRAW_COLOR_KEYS.join(", ")} or #rrggbb.`);
|
|
22
|
+
}
|
|
23
|
+
function boundingBox(lines) {
|
|
24
|
+
let minX = Infinity;
|
|
25
|
+
let minY = Infinity;
|
|
26
|
+
let maxX = -Infinity;
|
|
27
|
+
let maxY = -Infinity;
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
for (const [x, y] of line) {
|
|
30
|
+
minX = Math.min(minX, x);
|
|
31
|
+
minY = Math.min(minY, y);
|
|
32
|
+
maxX = Math.max(maxX, x);
|
|
33
|
+
maxY = Math.max(maxY, y);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { x: minX, y: minY, w: Math.max(maxX - minX, 1e-9), h: Math.max(maxY - minY, 1e-9) };
|
|
37
|
+
}
|
|
38
|
+
/** The aspect whose artboard ratio is closest to the content's, on a log scale. */
|
|
39
|
+
function closestAspect(ratio) {
|
|
40
|
+
let best = "square";
|
|
41
|
+
for (const aspect of ASPECTS) {
|
|
42
|
+
if (Math.abs(Math.log(ASPECT_RATIOS[aspect] / ratio)) < Math.abs(Math.log(ASPECT_RATIOS[best] / ratio)))
|
|
43
|
+
best = aspect;
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
/** Builds a /scribe#r= link for a drawing; throws LinkError with a message meant for the agent. */
|
|
48
|
+
export function buildDrawingLink(input) {
|
|
49
|
+
if (input.paths.length === 0)
|
|
50
|
+
throw new LinkError("Pass at least one path to draw.");
|
|
51
|
+
const frame = input.viewBox === undefined ? null : parseViewBox(input.viewBox);
|
|
52
|
+
const flatten = (tolerance) => input.paths.map((path, index) => {
|
|
53
|
+
try {
|
|
54
|
+
return pathToPolylines(path.d, tolerance);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof PathError)
|
|
58
|
+
throw new LinkError(`Path ${index + 1}: ${error.message}`);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// a coarse pass sizes the drawing, then the real pass flattens with a
|
|
63
|
+
// tolerance relative to that size, so a 24-unit icon and a 2000-unit
|
|
64
|
+
// illustration come out equally smooth
|
|
65
|
+
const coarse = flatten(Number.POSITIVE_INFINITY).flat();
|
|
66
|
+
if (coarse.length === 0)
|
|
67
|
+
throw new LinkError("The paths contain nothing to draw (every subpath is a single point).");
|
|
68
|
+
const coarseBox = frame ?? boundingBox(coarse);
|
|
69
|
+
const lines = flatten(Math.max(coarseBox.w, coarseBox.h) / 300);
|
|
70
|
+
const content = frame ?? boundingBox(lines.flat());
|
|
71
|
+
const aspect = input.aspect ?? closestAspect(content.w / content.h);
|
|
72
|
+
const ratio = ASPECT_RATIOS[aspect];
|
|
73
|
+
// artboard units: width `ratio`, height 1
|
|
74
|
+
const margin = frame ? 0 : AUTO_MARGIN;
|
|
75
|
+
const scale = Math.min((ratio * (1 - 2 * margin)) / content.w, (1 - 2 * margin) / content.h);
|
|
76
|
+
const cx = content.x + content.w / 2;
|
|
77
|
+
const cy = content.y + content.h / 2;
|
|
78
|
+
const toCoord = (value) => Math.min(DRAW_COORD_MAX, Math.max(0, Math.round(value * DRAW_COORD_MAX)));
|
|
79
|
+
const strokes = [];
|
|
80
|
+
let points = 0;
|
|
81
|
+
input.paths.forEach((path, index) => {
|
|
82
|
+
const color = resolveColor(path.color);
|
|
83
|
+
for (const line of lines[index]) {
|
|
84
|
+
const pts = [];
|
|
85
|
+
for (const [x, y] of line) {
|
|
86
|
+
const u = toCoord((ratio / 2 + (x - cx) * scale) / ratio);
|
|
87
|
+
const v = toCoord(0.5 + (y - cy) * scale);
|
|
88
|
+
if (pts.length >= 2 && pts[pts.length - 2] === u && pts[pts.length - 1] === v)
|
|
89
|
+
continue;
|
|
90
|
+
pts.push(u, v);
|
|
91
|
+
}
|
|
92
|
+
if (pts.length < 4)
|
|
93
|
+
continue;
|
|
94
|
+
strokes.push({ color, pts });
|
|
95
|
+
points += pts.length / 2;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (strokes.length === 0)
|
|
99
|
+
throw new LinkError("The drawing is too small to see once fitted to the artboard.");
|
|
100
|
+
if (strokes.length > MAX_DRAW_STROKES || points > MAX_DRAW_POINTS) {
|
|
101
|
+
throw new LinkError(`The drawing has ${strokes.length} strokes and ${points} points; Scribe accepts ${MAX_DRAW_STROKES} and ${MAX_DRAW_POINTS}.`);
|
|
102
|
+
}
|
|
103
|
+
const link = encodeDrawLink({ aspect, strokes, settings: input.settings, locale: input.locale, baseUrl: input.baseUrl });
|
|
104
|
+
if (link === null) {
|
|
105
|
+
throw new LinkError(`This drawing is too detailed for a link (limit ${MAX_RECIPE_LINK_CHARS} encoded characters). Use fewer or simpler paths.`);
|
|
106
|
+
}
|
|
107
|
+
return { url: link, aspect, strokes: strokes.length, points };
|
|
108
|
+
}
|
|
109
|
+
/** Encodes a mode "draw" recipe link, or null when it would exceed the link cap. */
|
|
110
|
+
export function encodeDrawLink(input) {
|
|
111
|
+
const settings = Object.fromEntries(Object.entries(input.settings ?? {}).filter(([, value]) => value !== undefined));
|
|
112
|
+
const payload = {
|
|
113
|
+
v: RECIPE_LINK_VERSION,
|
|
114
|
+
mode: "draw",
|
|
115
|
+
aspect: input.aspect,
|
|
116
|
+
strokes: input.strokes,
|
|
117
|
+
...(Object.keys(settings).length > 0 ? { settings } : {}),
|
|
118
|
+
};
|
|
119
|
+
const encoded = deflateRawSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 }).toString("base64url");
|
|
120
|
+
if (encoded.length > (input.maxChars ?? MAX_RECIPE_LINK_CHARS))
|
|
121
|
+
return null;
|
|
122
|
+
const base = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
123
|
+
return `${base}${LOCALE_PATH_PREFIXES[input.locale ?? "en"]}/scribe#r=${encoded}`;
|
|
124
|
+
}
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import jpeg from "jpeg-js";
|
|
3
|
+
import { PNG } from "pngjs";
|
|
4
|
+
import { LinkError } from "./link.js";
|
|
5
|
+
/** Larger photos are refused rather than decoded into gigabytes of memory. */
|
|
6
|
+
const MAX_MEGAPIXELS = 40;
|
|
7
|
+
/** Decodes a local PNG or JPEG into planar RGB, compositing transparency over white paper. */
|
|
8
|
+
export async function readImage(path) {
|
|
9
|
+
let buffer;
|
|
10
|
+
try {
|
|
11
|
+
buffer = await readFile(path);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new LinkError(`Could not read the image at "${path}". Pass an absolute path to a PNG or JPEG file.`);
|
|
15
|
+
}
|
|
16
|
+
let width, height, rgba;
|
|
17
|
+
try {
|
|
18
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
19
|
+
const png = PNG.sync.read(buffer);
|
|
20
|
+
({ width, height } = png);
|
|
21
|
+
rgba = png.data;
|
|
22
|
+
}
|
|
23
|
+
else if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
24
|
+
const img = jpeg.decode(buffer, { useTArray: true, formatAsRGBA: true, maxResolutionInMP: MAX_MEGAPIXELS, maxMemoryUsageInMB: 1024 });
|
|
25
|
+
({ width, height } = img);
|
|
26
|
+
rgba = img.data;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
throw new LinkError("Only PNG and JPEG images are supported. Convert the image (for example WebP or HEIC) to PNG or JPEG first.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof LinkError)
|
|
34
|
+
throw error;
|
|
35
|
+
throw new LinkError(`The image could not be decoded: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
if (width * height > MAX_MEGAPIXELS * 1e6)
|
|
38
|
+
throw new LinkError(`The image is larger than ${MAX_MEGAPIXELS} megapixels; resize it first.`);
|
|
39
|
+
const n = width * height;
|
|
40
|
+
const r = new Float32Array(n), g = new Float32Array(n), b = new Float32Array(n);
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
const a = rgba[i * 4 + 3] / 255;
|
|
43
|
+
r[i] = (rgba[i * 4] / 255) * a + (1 - a);
|
|
44
|
+
g[i] = (rgba[i * 4 + 1] / 255) * a + (1 - a);
|
|
45
|
+
b[i] = (rgba[i * 4 + 2] / 255) * a + (1 - a);
|
|
46
|
+
}
|
|
47
|
+
return { width, height, r, g, b };
|
|
48
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { DEFAULT_BASE_URL } from "./link.js";
|
|
4
|
+
import { createScribeServer } from "./server.js";
|
|
5
|
+
/** TODRAWN_SCRIBE_URL points links at another deployment, e.g. http://localhost:3000. */
|
|
6
|
+
function baseUrlFromEnv() {
|
|
7
|
+
const raw = process.env.TODRAWN_SCRIBE_URL?.trim();
|
|
8
|
+
if (!raw)
|
|
9
|
+
return DEFAULT_BASE_URL;
|
|
10
|
+
const url = new URL(raw);
|
|
11
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
12
|
+
throw new Error(`TODRAWN_SCRIBE_URL must be an http(s) URL, got ${raw}`);
|
|
13
|
+
}
|
|
14
|
+
return url.origin;
|
|
15
|
+
}
|
|
16
|
+
const server = createScribeServer({ baseUrl: baseUrlFromEnv() });
|
|
17
|
+
await server.connect(new StdioServerTransport());
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { deflateRawSync } from "node:zlib";
|
|
2
|
+
import { LANGUAGES, LOCALE_PATH_PREFIXES, MAX_INPUT_BYTES, MAX_LINES, MAX_RECIPE_LINK_CHARS, RECIPE_LINK_VERSION, } from "./constants.js";
|
|
3
|
+
export const DEFAULT_BASE_URL = "https://todrawn.com";
|
|
4
|
+
/** Names agents tend to use for a language, mapped onto the studio's tokenizers. */
|
|
5
|
+
const LANGUAGE_ALIASES = {
|
|
6
|
+
javascript: "js",
|
|
7
|
+
jsx: "js",
|
|
8
|
+
mjs: "js",
|
|
9
|
+
node: "js",
|
|
10
|
+
typescript: "ts",
|
|
11
|
+
tsx: "ts",
|
|
12
|
+
python: "py",
|
|
13
|
+
c: "clike",
|
|
14
|
+
"c++": "clike",
|
|
15
|
+
cpp: "clike",
|
|
16
|
+
"c#": "clike",
|
|
17
|
+
csharp: "clike",
|
|
18
|
+
cs: "clike",
|
|
19
|
+
kotlin: "clike",
|
|
20
|
+
kt: "clike",
|
|
21
|
+
swift: "clike",
|
|
22
|
+
php: "clike",
|
|
23
|
+
dart: "clike",
|
|
24
|
+
scala: "clike",
|
|
25
|
+
postgres: "sql",
|
|
26
|
+
postgresql: "sql",
|
|
27
|
+
mysql: "sql",
|
|
28
|
+
xml: "html",
|
|
29
|
+
svg: "html",
|
|
30
|
+
vue: "html",
|
|
31
|
+
scss: "css",
|
|
32
|
+
sass: "css",
|
|
33
|
+
less: "css",
|
|
34
|
+
sh: "bash",
|
|
35
|
+
shell: "bash",
|
|
36
|
+
zsh: "bash",
|
|
37
|
+
console: "bash",
|
|
38
|
+
golang: "go",
|
|
39
|
+
rs: "rust",
|
|
40
|
+
text: "plain",
|
|
41
|
+
txt: "plain",
|
|
42
|
+
markdown: "plain",
|
|
43
|
+
md: "plain",
|
|
44
|
+
prose: "plain",
|
|
45
|
+
};
|
|
46
|
+
export class LinkError extends Error {
|
|
47
|
+
}
|
|
48
|
+
export function resolveLanguage(value) {
|
|
49
|
+
if (value === undefined || value.trim() === "")
|
|
50
|
+
return "plain";
|
|
51
|
+
const key = value.trim().toLowerCase();
|
|
52
|
+
if (LANGUAGES.includes(key))
|
|
53
|
+
return key;
|
|
54
|
+
const alias = LANGUAGE_ALIASES[key];
|
|
55
|
+
if (alias)
|
|
56
|
+
return alias;
|
|
57
|
+
throw new LinkError(`Unknown language "${value}". Use one of: ${LANGUAGES.join(", ")} (or a common name such as typescript, python, c++, shell, markdown).`);
|
|
58
|
+
}
|
|
59
|
+
/** Builds a /scribe#r= link; throws LinkError with a message meant for the agent. */
|
|
60
|
+
export function buildRecipeLink(input) {
|
|
61
|
+
// the studio draws tabs and CRLF as written; normalise line endings only
|
|
62
|
+
const text = input.text.replace(/\r\n?/g, "\n");
|
|
63
|
+
if (text.trim() === "")
|
|
64
|
+
throw new LinkError("The text is empty. Pass the code or text to draw.");
|
|
65
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
66
|
+
if (bytes > MAX_INPUT_BYTES) {
|
|
67
|
+
throw new LinkError(`The text is ${bytes} bytes; Scribe accepts at most ${MAX_INPUT_BYTES} bytes (1 MiB).`);
|
|
68
|
+
}
|
|
69
|
+
const lines = text.split("\n").length;
|
|
70
|
+
if (lines > MAX_LINES)
|
|
71
|
+
throw new LinkError(`The text has ${lines} lines; Scribe draws at most ${MAX_LINES}.`);
|
|
72
|
+
const language = resolveLanguage(input.language);
|
|
73
|
+
const settings = Object.fromEntries(Object.entries(input.settings ?? {}).filter(([, value]) => value !== undefined));
|
|
74
|
+
const payload = {
|
|
75
|
+
v: RECIPE_LINK_VERSION,
|
|
76
|
+
text,
|
|
77
|
+
language,
|
|
78
|
+
...(input.aspect ? { aspect: input.aspect } : {}),
|
|
79
|
+
...(Object.keys(settings).length > 0 ? { settings } : {}),
|
|
80
|
+
};
|
|
81
|
+
const encoded = deflateRawSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 }).toString("base64url");
|
|
82
|
+
if (encoded.length > MAX_RECIPE_LINK_CHARS) {
|
|
83
|
+
throw new LinkError(`This text is too large for a link (${encoded.length} encoded characters, limit ${MAX_RECIPE_LINK_CHARS}). Shorten it: a readable clip is a few dozen lines.`);
|
|
84
|
+
}
|
|
85
|
+
const base = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
86
|
+
const prefix = LOCALE_PATH_PREFIXES[input.locale ?? "en"];
|
|
87
|
+
return { url: `${base}${prefix}/scribe#r=${encoded}`, language, lines, bytes };
|
|
88
|
+
}
|
package/dist/paint.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DRAW_COORD_MAX } from "./constants.js";
|
|
2
|
+
import { encodeDrawLink } from "./drawing.js";
|
|
3
|
+
import { LinkError } from "./link.js";
|
|
4
|
+
import { paintImage } from "./painter.js";
|
|
5
|
+
/** Paints an image and packs it into a /scribe#r= draw link, trimming the finest strokes if the link would be too long. */
|
|
6
|
+
export function paintToLink(image, input = {}) {
|
|
7
|
+
const painting = paintImage(image, { aspect: input.aspect, pen: input.pen, detail: input.detail });
|
|
8
|
+
const q = (v, max) => Math.min(DRAW_COORD_MAX, Math.max(0, Math.round((v / max) * DRAW_COORD_MAX)));
|
|
9
|
+
const all = painting.strokes.map((s) => {
|
|
10
|
+
const pts = s.pts.flatMap(([x, y]) => [q(x, painting.width), q(y, painting.height)]);
|
|
11
|
+
// a stroke that collapsed onto one coordinate still needs two points
|
|
12
|
+
if (pts.length === 4 && pts[0] === pts[2] && pts[1] === pts[3])
|
|
13
|
+
pts[2] = Math.min(DRAW_COORD_MAX, pts[2] + 1);
|
|
14
|
+
return { color: s.color, pts };
|
|
15
|
+
});
|
|
16
|
+
const settings = { background: input.background ?? "paper", pen: input.pen ?? "fineliner", speedMultiplier: input.speedMultiplier };
|
|
17
|
+
let keep = all.length;
|
|
18
|
+
for (;;) {
|
|
19
|
+
const url = encodeDrawLink({ aspect: painting.aspect, strokes: all.slice(0, keep), settings, locale: input.locale, baseUrl: input.baseUrl, maxChars: input.maxChars });
|
|
20
|
+
if (url)
|
|
21
|
+
return { url, aspect: painting.aspect, strokes: keep, trimmed: all.length - keep, meanAbsDiff: painting.meanAbsDiff };
|
|
22
|
+
keep = Math.floor(keep * 0.93);
|
|
23
|
+
if (keep <= painting.underpaintStrokes) {
|
|
24
|
+
throw new LinkError("This image is too detailed to fit in a link even at its coarsest. Try detail \"low\" or a smaller artboard.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|