@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/dist/server.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ASPECTS, BACKGROUNDS, COLOR_MODES, FRAME_RATES, FREE_EXPORTS_PER_DAY, LANGUAGES, LAYOUTS, LOCALE_PATH_PREFIXES, MAX_INPUT_BYTES, MAX_LINES, MAX_PLAYBACK_SECONDS, DRAW_COLOR_KEYS, PENS, READING_WPS, RESOLUTIONS, SPEED, TIMINGS, } from "./constants.js";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { buildDrawingLink } from "./drawing.js";
|
|
6
|
+
import { readImage } from "./image.js";
|
|
7
|
+
import { paintToLink } from "./paint.js";
|
|
8
|
+
import { buildRecipeLink, LinkError } from "./link.js";
|
|
9
|
+
export const SERVER_VERSION = "0.1.0";
|
|
10
|
+
const MAX_DRAWING_PATHS = 2000;
|
|
11
|
+
const EXPORT_RULES = `Previewing is free and needs no account. Exporting the MP4 needs a free Todrawn account: ` +
|
|
12
|
+
`the Free plan allows ${FREE_EXPORTS_PER_DAY} exports per UTC day and ends each video with a "Made with Todrawn Scribe" card; ` +
|
|
13
|
+
`Premium has unlimited exports and makes the card optional.`;
|
|
14
|
+
const LIMITS_TEXT = [
|
|
15
|
+
"Todrawn Scribe limits:",
|
|
16
|
+
`- Input: up to ${MAX_INPUT_BYTES} bytes (1 MiB) and ${MAX_LINES} lines of text or code.`,
|
|
17
|
+
`- Playback: clips are capped at ${MAX_PLAYBACK_SECONDS} s; longer documents are drawn faster to fit. A readable clip is a few dozen lines.`,
|
|
18
|
+
`- Output: ${RESOLUTIONS.join(" or ")}, ${FRAME_RATES.join(" or ")} fps, aspects ${ASPECTS.join(", ")} (feed = 4:5).`,
|
|
19
|
+
`- Languages with syntax colours: ${LANGUAGES.filter((l) => l !== "plain").join(", ")}; "plain" draws prose and supports a Markdown subset (headings, **bold**, lists, ==highlight==).`,
|
|
20
|
+
`- ${EXPORT_RULES}`,
|
|
21
|
+
`- Drawings: SVG path data, drawn as pen lines in path order (fills are ignored); inks ${DRAW_COLOR_KEYS.join(", ")} or #rrggbb.`,
|
|
22
|
+
"- Photos: PNG or JPEG, painted locally into up to ~2800 strokes; the image itself is never uploaded.",
|
|
23
|
+
"- This server only builds links; the video is rendered and exported in the person's browser.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
const createVideoShape = {
|
|
26
|
+
text: z.string().min(1).describe("The code or text to draw, exactly as it should appear."),
|
|
27
|
+
language: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe(`Syntax colouring: ${LANGUAGES.join(", ")}. Common names (typescript, python, c++, shell, markdown) are accepted. Defaults to plain.`),
|
|
31
|
+
aspect: z
|
|
32
|
+
.enum(ASPECTS)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("landscape 16:9 (default), portrait 9:16 for Shorts/Reels/TikTok, square 1:1, feed 4:5."),
|
|
35
|
+
background: z.enum(BACKGROUNDS).optional().describe("dark board or light paper."),
|
|
36
|
+
pen: z.enum(PENS).optional(),
|
|
37
|
+
colorMode: z.enum(COLOR_MODES).optional().describe("syntax colours per token, or one solid ink colour."),
|
|
38
|
+
solidColor: z
|
|
39
|
+
.string()
|
|
40
|
+
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Ink colour as #rrggbb when colorMode is solid."),
|
|
43
|
+
timing: z.enum(TIMINGS).optional().describe("reading: paced by words per second (default); pen: paced by pen travel."),
|
|
44
|
+
readingWps: z
|
|
45
|
+
.number()
|
|
46
|
+
.min(READING_WPS.min)
|
|
47
|
+
.max(READING_WPS.max)
|
|
48
|
+
.multipleOf(READING_WPS.step)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Words per second when timing is reading."),
|
|
51
|
+
speedMultiplier: z.number().min(SPEED.min).max(SPEED.max).optional(),
|
|
52
|
+
markdown: z.boolean().optional().describe("Interpret Markdown in plain text (default true)."),
|
|
53
|
+
layout: z.enum(LAYOUTS).optional().describe("auto scrolls tall aspects and pages wide ones."),
|
|
54
|
+
resolution: z.enum(RESOLUTIONS).optional(),
|
|
55
|
+
fps: z.union([z.literal(24), z.literal(30)]).optional(),
|
|
56
|
+
locale: z
|
|
57
|
+
.enum(Object.keys(LOCALE_PATH_PREFIXES))
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Language of the Scribe interface the link opens. Defaults to en."),
|
|
60
|
+
};
|
|
61
|
+
const SETTING_KEYS = [
|
|
62
|
+
"background",
|
|
63
|
+
"pen",
|
|
64
|
+
"colorMode",
|
|
65
|
+
"solidColor",
|
|
66
|
+
"timing",
|
|
67
|
+
"readingWps",
|
|
68
|
+
"speedMultiplier",
|
|
69
|
+
"markdown",
|
|
70
|
+
"layout",
|
|
71
|
+
"resolution",
|
|
72
|
+
"fps",
|
|
73
|
+
];
|
|
74
|
+
const localeSchema = createVideoShape.locale;
|
|
75
|
+
const createDrawingShape = {
|
|
76
|
+
paths: z
|
|
77
|
+
.array(z.object({
|
|
78
|
+
d: z.string().min(1).describe("SVG path data. Curves (C, Q, A) are fine; fills are ignored, every path is drawn as a pen line."),
|
|
79
|
+
color: z
|
|
80
|
+
.string()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(`Ink: ${DRAW_COLOR_KEYS.join(", ")} (adapt to the background) or #rrggbb. Defaults to blue.`),
|
|
83
|
+
}))
|
|
84
|
+
.min(1)
|
|
85
|
+
.max(MAX_DRAWING_PATHS)
|
|
86
|
+
.describe("Paths in the order a person would draw them; the pen follows that order. y grows downward, as in SVG."),
|
|
87
|
+
viewBox: z
|
|
88
|
+
.string()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe('"minX minY width height" framing the drawing, as on an <svg>. Defaults to the paths\' bounding box with a margin.'),
|
|
91
|
+
aspect: z
|
|
92
|
+
.enum(ASPECTS)
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Artboard: landscape 16:9, portrait 9:16, square 1:1, feed 4:5. Defaults to the one closest to the drawing's shape."),
|
|
95
|
+
background: createVideoShape.background,
|
|
96
|
+
pen: createVideoShape.pen,
|
|
97
|
+
speedMultiplier: createVideoShape.speedMultiplier,
|
|
98
|
+
resolution: createVideoShape.resolution,
|
|
99
|
+
fps: createVideoShape.fps,
|
|
100
|
+
locale: localeSchema,
|
|
101
|
+
};
|
|
102
|
+
const paintImageShape = {
|
|
103
|
+
imagePath: z.string().min(1).describe("Path to a local PNG or JPEG file. Relative paths resolve against the server's working directory."),
|
|
104
|
+
aspect: z
|
|
105
|
+
.enum(ASPECTS)
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Artboard: landscape 16:9, portrait 9:16, square 1:1, feed 4:5. Defaults to the one closest to the image's shape."),
|
|
108
|
+
detail: z
|
|
109
|
+
.enum(["low", "medium", "high"])
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Stroke budget: low ~1200 strokes, medium ~2000 (default), high ~2800. More detail is more faithful and plays longer."),
|
|
112
|
+
pen: createVideoShape.pen.describe("Pen width shapes the painting: fineliner (default) is the finest, marker the boldest."),
|
|
113
|
+
background: createVideoShape.background,
|
|
114
|
+
speedMultiplier: createVideoShape.speedMultiplier,
|
|
115
|
+
locale: localeSchema,
|
|
116
|
+
};
|
|
117
|
+
export function createScribeServer(options = {}) {
|
|
118
|
+
const server = new McpServer({ name: "todrawn-scribe", version: SERVER_VERSION });
|
|
119
|
+
server.registerTool("scribe_create_video", {
|
|
120
|
+
title: "Create a Todrawn Scribe video",
|
|
121
|
+
description: "Turns code or text into a Todrawn Scribe project: a video of a pen writing it out by hand. " +
|
|
122
|
+
"Returns a link that opens the Scribe studio with the project loaded, where the person previews it and exports the MP4. " +
|
|
123
|
+
"Give the link to the person; do not try to download a video from it. " +
|
|
124
|
+
EXPORT_RULES,
|
|
125
|
+
inputSchema: createVideoShape,
|
|
126
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
127
|
+
}, async (args) => {
|
|
128
|
+
const settings = {};
|
|
129
|
+
for (const key of SETTING_KEYS) {
|
|
130
|
+
if (args[key] !== undefined)
|
|
131
|
+
settings[key] = args[key];
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const link = buildRecipeLink({
|
|
135
|
+
text: args.text,
|
|
136
|
+
language: args.language,
|
|
137
|
+
aspect: args.aspect,
|
|
138
|
+
settings,
|
|
139
|
+
locale: args.locale,
|
|
140
|
+
baseUrl: options.baseUrl,
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: [
|
|
147
|
+
`Open this link to preview and export the video in Todrawn Scribe:`,
|
|
148
|
+
link.url,
|
|
149
|
+
"",
|
|
150
|
+
`Language: ${link.language}, ${link.lines} line${link.lines === 1 ? "" : "s"}. The whole project is inside the link after "#", so nothing was uploaded.`,
|
|
151
|
+
`Clips are capped at ${MAX_PLAYBACK_SECONDS} s; longer text is drawn faster to fit.`,
|
|
152
|
+
EXPORT_RULES,
|
|
153
|
+
].join("\n"),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error instanceof LinkError) {
|
|
160
|
+
return { isError: true, content: [{ type: "text", text: error.message }] };
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
server.registerTool("scribe_create_drawing", {
|
|
166
|
+
title: "Create a Todrawn Scribe drawing video",
|
|
167
|
+
description: "Turns a line drawing, given as SVG paths, into a Todrawn Scribe Draw project: a video of a pen drawing it stroke by stroke. " +
|
|
168
|
+
"Describe the subject as outlines and details, not filled shapes. " +
|
|
169
|
+
"Returns a link that opens the Scribe studio with the drawing loaded, where the person previews it and exports the MP4. " +
|
|
170
|
+
EXPORT_RULES,
|
|
171
|
+
inputSchema: createDrawingShape,
|
|
172
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
173
|
+
}, async (args) => {
|
|
174
|
+
try {
|
|
175
|
+
const link = buildDrawingLink({
|
|
176
|
+
paths: args.paths,
|
|
177
|
+
viewBox: args.viewBox,
|
|
178
|
+
aspect: args.aspect,
|
|
179
|
+
settings: {
|
|
180
|
+
background: args.background,
|
|
181
|
+
pen: args.pen,
|
|
182
|
+
speedMultiplier: args.speedMultiplier,
|
|
183
|
+
resolution: args.resolution,
|
|
184
|
+
fps: args.fps,
|
|
185
|
+
},
|
|
186
|
+
locale: args.locale,
|
|
187
|
+
baseUrl: options.baseUrl,
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: [
|
|
194
|
+
"Open this link to preview and export the drawing in Todrawn Scribe:",
|
|
195
|
+
link.url,
|
|
196
|
+
"",
|
|
197
|
+
`Artboard: ${link.aspect}, ${link.strokes} stroke${link.strokes === 1 ? "" : "s"}, ${link.points} points. The whole drawing is inside the link after "#", so nothing was uploaded.`,
|
|
198
|
+
EXPORT_RULES,
|
|
199
|
+
].join("\n"),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof LinkError) {
|
|
206
|
+
return { isError: true, content: [{ type: "text", text: error.message }] };
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
server.registerTool("scribe_paint_image", {
|
|
212
|
+
title: "Paint a photo with Todrawn Scribe",
|
|
213
|
+
description: "Paints a local photo or picture (PNG or JPEG) as a Todrawn Scribe drawing: an underpaint first, then thousands of coloured pen strokes, " +
|
|
214
|
+
"replayed by the pen as a video. The image is processed on this machine and never uploaded; the returned link carries only the strokes. " +
|
|
215
|
+
`Long paintings are capped at ${MAX_PLAYBACK_SECONDS} s of playback. ` +
|
|
216
|
+
EXPORT_RULES,
|
|
217
|
+
inputSchema: paintImageShape,
|
|
218
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
219
|
+
}, async (args) => {
|
|
220
|
+
try {
|
|
221
|
+
const image = await readImage(resolve(args.imagePath));
|
|
222
|
+
const link = paintToLink(image, {
|
|
223
|
+
aspect: args.aspect,
|
|
224
|
+
detail: args.detail,
|
|
225
|
+
pen: args.pen,
|
|
226
|
+
background: args.background,
|
|
227
|
+
speedMultiplier: args.speedMultiplier,
|
|
228
|
+
locale: args.locale,
|
|
229
|
+
baseUrl: options.baseUrl,
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: "text",
|
|
235
|
+
text: [
|
|
236
|
+
"Open this link to watch the painting and export it in Todrawn Scribe:",
|
|
237
|
+
link.url,
|
|
238
|
+
"",
|
|
239
|
+
`Artboard: ${link.aspect}, ${link.strokes} strokes${link.trimmed ? ` (${link.trimmed} finest strokes left out to fit the link)` : ""}. ` +
|
|
240
|
+
"The photo was painted locally; only the strokes are in the link, after \"#\", so nothing was uploaded.",
|
|
241
|
+
EXPORT_RULES,
|
|
242
|
+
].join("\n"),
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error instanceof LinkError) {
|
|
249
|
+
return { isError: true, content: [{ type: "text", text: error.message }] };
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
server.registerTool("scribe_limits", {
|
|
255
|
+
title: "Todrawn Scribe limits",
|
|
256
|
+
description: "Lists what Todrawn Scribe accepts and what the Free and Premium plans allow, before building a video.",
|
|
257
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
258
|
+
}, async () => ({ content: [{ type: "text", text: LIMITS_TEXT }] }));
|
|
259
|
+
return server;
|
|
260
|
+
}
|
package/dist/svg-path.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/* SVG path data -> polylines. Agents describe drawings most reliably as SVG
|
|
2
|
+
paths, while a Scribe drawing is a list of pen polylines, so every curve is
|
|
3
|
+
flattened here. Supports the full path grammar: M L H V C S Q T A Z, both
|
|
4
|
+
absolute and relative, implicit command repetition and packed arc flags. */
|
|
5
|
+
export class PathError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const COMMAND_ARITY = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
|
|
8
|
+
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/;
|
|
9
|
+
function tokenize(d) {
|
|
10
|
+
const tokens = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
// arc flags may be packed ("a1 1 0 01-1 1"), so the tokenizer tracks the
|
|
13
|
+
// argument index inside the current arc to read 0/1 flags as single digits
|
|
14
|
+
let cmd = "";
|
|
15
|
+
let argIndex = 0;
|
|
16
|
+
while (i < d.length) {
|
|
17
|
+
const ch = d[i];
|
|
18
|
+
if (/[\s,]/.test(ch)) {
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (/[MLHVCSQTAZmlhvcsqtaz]/.test(ch)) {
|
|
23
|
+
tokens.push({ cmd: ch });
|
|
24
|
+
cmd = ch.toUpperCase();
|
|
25
|
+
argIndex = 0;
|
|
26
|
+
i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const arity = COMMAND_ARITY[cmd] ?? 0;
|
|
30
|
+
const slot = arity > 0 ? argIndex % arity : 0;
|
|
31
|
+
if (cmd === "A" && (slot === 3 || slot === 4) && (ch === "0" || ch === "1")) {
|
|
32
|
+
tokens.push({ num: ch === "1" ? 1 : 0 });
|
|
33
|
+
argIndex++;
|
|
34
|
+
i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const match = NUMBER.exec(d.slice(i));
|
|
38
|
+
if (!match)
|
|
39
|
+
throw new PathError(`Unexpected "${ch}" at position ${i} in path data.`);
|
|
40
|
+
tokens.push({ num: Number(match[0]) });
|
|
41
|
+
argIndex++;
|
|
42
|
+
i += match[0].length;
|
|
43
|
+
}
|
|
44
|
+
return tokens;
|
|
45
|
+
}
|
|
46
|
+
/** Number of straight segments for a curve of the given control-polygon length. */
|
|
47
|
+
function steps(length, tolerance) {
|
|
48
|
+
return Math.min(256, Math.max(4, Math.ceil(length / tolerance)));
|
|
49
|
+
}
|
|
50
|
+
function dist(a, b) {
|
|
51
|
+
return Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
52
|
+
}
|
|
53
|
+
/** Endpoint-parameterised SVG arc (spec F.6.5) flattened to points after `from`. */
|
|
54
|
+
function arcPoints(from, rxIn, ryIn, rotationDeg, largeArc, sweep, to, tolerance) {
|
|
55
|
+
let rx = Math.abs(rxIn);
|
|
56
|
+
let ry = Math.abs(ryIn);
|
|
57
|
+
if (rx === 0 || ry === 0 || (from[0] === to[0] && from[1] === to[1]))
|
|
58
|
+
return [to];
|
|
59
|
+
const phi = (rotationDeg * Math.PI) / 180;
|
|
60
|
+
const cos = Math.cos(phi);
|
|
61
|
+
const sin = Math.sin(phi);
|
|
62
|
+
const dx = (from[0] - to[0]) / 2;
|
|
63
|
+
const dy = (from[1] - to[1]) / 2;
|
|
64
|
+
const x1 = cos * dx + sin * dy;
|
|
65
|
+
const y1 = -sin * dx + cos * dy;
|
|
66
|
+
const lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
|
|
67
|
+
if (lambda > 1) {
|
|
68
|
+
rx *= Math.sqrt(lambda);
|
|
69
|
+
ry *= Math.sqrt(lambda);
|
|
70
|
+
}
|
|
71
|
+
const num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
|
|
72
|
+
const den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
|
|
73
|
+
const coef = (largeArc === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
|
|
74
|
+
const cx1 = (coef * rx * y1) / ry;
|
|
75
|
+
const cy1 = (-coef * ry * x1) / rx;
|
|
76
|
+
const cx = cos * cx1 - sin * cy1 + (from[0] + to[0]) / 2;
|
|
77
|
+
const cy = sin * cx1 + cos * cy1 + (from[1] + to[1]) / 2;
|
|
78
|
+
const angle = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
|
|
79
|
+
const theta1 = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry);
|
|
80
|
+
let delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry);
|
|
81
|
+
if (!sweep && delta > 0)
|
|
82
|
+
delta -= 2 * Math.PI;
|
|
83
|
+
if (sweep && delta < 0)
|
|
84
|
+
delta += 2 * Math.PI;
|
|
85
|
+
const n = steps(Math.abs(delta) * Math.max(rx, ry), tolerance);
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let k = 1; k <= n; k++) {
|
|
88
|
+
const t = theta1 + (delta * k) / n;
|
|
89
|
+
const ex = rx * Math.cos(t);
|
|
90
|
+
const ey = ry * Math.sin(t);
|
|
91
|
+
out.push([cos * ex - sin * ey + cx, sin * ex + cos * ey + cy]);
|
|
92
|
+
}
|
|
93
|
+
out[out.length - 1] = to;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Parses path data into polylines, one per subpath. `tolerance` is the target
|
|
98
|
+
* length of one flattened curve segment, in the path's own units.
|
|
99
|
+
*/
|
|
100
|
+
export function pathToPolylines(d, tolerance) {
|
|
101
|
+
const tokens = tokenize(d);
|
|
102
|
+
const lines = [];
|
|
103
|
+
let line = [];
|
|
104
|
+
let cur = [0, 0];
|
|
105
|
+
let start = [0, 0];
|
|
106
|
+
let lastCubic = null;
|
|
107
|
+
let lastQuad = null;
|
|
108
|
+
let i = 0;
|
|
109
|
+
let cmd = "";
|
|
110
|
+
const take = (n) => {
|
|
111
|
+
const out = [];
|
|
112
|
+
for (let k = 0; k < n; k++) {
|
|
113
|
+
const token = tokens[i++];
|
|
114
|
+
if (!token || !("num" in token))
|
|
115
|
+
throw new PathError(`Command "${cmd}" is missing arguments.`);
|
|
116
|
+
out.push(token.num);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
const flush = () => {
|
|
121
|
+
if (line.length > 1)
|
|
122
|
+
lines.push(line);
|
|
123
|
+
line = [];
|
|
124
|
+
};
|
|
125
|
+
const lineTo = (p) => {
|
|
126
|
+
if (line.length === 0)
|
|
127
|
+
line.push(cur);
|
|
128
|
+
line.push(p);
|
|
129
|
+
cur = p;
|
|
130
|
+
};
|
|
131
|
+
while (i < tokens.length) {
|
|
132
|
+
const token = tokens[i];
|
|
133
|
+
if ("cmd" in token) {
|
|
134
|
+
cmd = token.cmd;
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
else if (!cmd) {
|
|
138
|
+
throw new PathError("Path data must start with a command such as M.");
|
|
139
|
+
}
|
|
140
|
+
if (cmd.toUpperCase() === "Z") {
|
|
141
|
+
if (line.length > 0)
|
|
142
|
+
lineTo(start);
|
|
143
|
+
flush();
|
|
144
|
+
cur = start;
|
|
145
|
+
lastCubic = lastQuad = null;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const arity = COMMAND_ARITY[cmd.toUpperCase()];
|
|
149
|
+
if (arity === undefined)
|
|
150
|
+
throw new PathError(`Unknown command "${cmd}".`);
|
|
151
|
+
// a command letter followed by no numbers is an error, except Z handled above
|
|
152
|
+
if (i >= tokens.length || "cmd" in tokens[i])
|
|
153
|
+
throw new PathError(`Command "${cmd}" is missing arguments.`);
|
|
154
|
+
do {
|
|
155
|
+
// recomputed per repetition: extra pairs after M continue as L
|
|
156
|
+
const upper = cmd.toUpperCase();
|
|
157
|
+
const rel = cmd !== upper;
|
|
158
|
+
const at = (x, y) => (rel ? [cur[0] + x, cur[1] + y] : [x, y]);
|
|
159
|
+
const a = take(arity);
|
|
160
|
+
let nextCubic = null;
|
|
161
|
+
let nextQuad = null;
|
|
162
|
+
switch (upper) {
|
|
163
|
+
case "M": {
|
|
164
|
+
flush();
|
|
165
|
+
cur = at(a[0], a[1]);
|
|
166
|
+
start = cur;
|
|
167
|
+
// further pairs after M are implicit L
|
|
168
|
+
cmd = rel ? "l" : "L";
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "L":
|
|
172
|
+
lineTo(at(a[0], a[1]));
|
|
173
|
+
break;
|
|
174
|
+
case "H":
|
|
175
|
+
lineTo([rel ? cur[0] + a[0] : a[0], cur[1]]);
|
|
176
|
+
break;
|
|
177
|
+
case "V":
|
|
178
|
+
lineTo([cur[0], rel ? cur[1] + a[0] : a[0]]);
|
|
179
|
+
break;
|
|
180
|
+
case "C":
|
|
181
|
+
case "S": {
|
|
182
|
+
const p0 = cur;
|
|
183
|
+
const p1 = upper === "C"
|
|
184
|
+
? at(a[0], a[1])
|
|
185
|
+
: lastCubic
|
|
186
|
+
? [2 * p0[0] - lastCubic[0], 2 * p0[1] - lastCubic[1]]
|
|
187
|
+
: p0;
|
|
188
|
+
const p2 = upper === "C" ? at(a[2], a[3]) : at(a[0], a[1]);
|
|
189
|
+
const p3 = upper === "C" ? at(a[4], a[5]) : at(a[2], a[3]);
|
|
190
|
+
const n = steps(dist(p0, p1) + dist(p1, p2) + dist(p2, p3), tolerance);
|
|
191
|
+
for (let k = 1; k <= n; k++) {
|
|
192
|
+
const t = k / n;
|
|
193
|
+
const u = 1 - t;
|
|
194
|
+
lineTo([
|
|
195
|
+
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
|
196
|
+
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
cur = p3;
|
|
200
|
+
nextCubic = p2;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "Q":
|
|
204
|
+
case "T": {
|
|
205
|
+
const p0 = cur;
|
|
206
|
+
const p1 = upper === "Q"
|
|
207
|
+
? at(a[0], a[1])
|
|
208
|
+
: lastQuad
|
|
209
|
+
? [2 * p0[0] - lastQuad[0], 2 * p0[1] - lastQuad[1]]
|
|
210
|
+
: p0;
|
|
211
|
+
const p2 = upper === "Q" ? at(a[2], a[3]) : at(a[0], a[1]);
|
|
212
|
+
const n = steps(dist(p0, p1) + dist(p1, p2), tolerance);
|
|
213
|
+
for (let k = 1; k <= n; k++) {
|
|
214
|
+
const t = k / n;
|
|
215
|
+
const u = 1 - t;
|
|
216
|
+
lineTo([
|
|
217
|
+
u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0],
|
|
218
|
+
u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1],
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
cur = p2;
|
|
222
|
+
nextQuad = p1;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "A": {
|
|
226
|
+
const to = at(a[5], a[6]);
|
|
227
|
+
for (const p of arcPoints(cur, a[0], a[1], a[2], a[3] !== 0, a[4] !== 0, to, tolerance))
|
|
228
|
+
lineTo(p);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
lastCubic = nextCubic;
|
|
233
|
+
lastQuad = nextQuad;
|
|
234
|
+
} while (i < tokens.length && !("cmd" in tokens[i]));
|
|
235
|
+
}
|
|
236
|
+
flush();
|
|
237
|
+
return lines;
|
|
238
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@todrawn/scribe-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Todrawn Scribe: turn code or text into a handwriting video project your agent can hand you as a link.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"todrawn-scribe-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.build.json",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "node --test \"src/**/*.test.ts\"",
|
|
21
|
+
"prepublishOnly": "npm run build && npm test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
25
|
+
"jpeg-js": "^0.4.4",
|
|
26
|
+
"pngjs": "^7.0.0",
|
|
27
|
+
"zod": "^4.4.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20",
|
|
31
|
+
"@types/pngjs": "^6.0.5",
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://todrawn.com/scribe",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/Brbn-jpg/whiteboard.git",
|
|
38
|
+
"directory": "mcp/scribe-mcp"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp",
|
|
42
|
+
"model-context-protocol",
|
|
43
|
+
"video",
|
|
44
|
+
"code-to-video",
|
|
45
|
+
"handwriting",
|
|
46
|
+
"todrawn",
|
|
47
|
+
"scribe"
|
|
48
|
+
]
|
|
49
|
+
}
|