paraplex-mcp 0.2.1 → 0.3.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 CHANGED
@@ -30,9 +30,16 @@ time.
30
30
  | `list_templates`, `apply_template`, `new_project` | Start from a template or a blank file |
31
31
  | `set_time`, `frame`, `frames`, `export_png` | Look at the piece at any time, as its export draws it |
32
32
  | `scene_facts` | Where every layer lands in the frame, type sizes on the frame, the camera |
33
- | `export_video` | Export the video to a file on this computer (`./paraplex-exports`, or `PARAPLEX_OUT_DIR`) |
33
+ | `export` | Export to a file on this computer (`./paraplex-exports`, or `PARAPLEX_OUT_DIR`) with any of the Export section's options for that export only: image or video, format, scale, frame rate, part of the timeline, no background, one artboard or all, App Store / Keynote / PowerPoint / Google Slides presets. Large files stream in pieces, so there is no size ceiling |
34
+ | `render_variants` | A render per row of data (rows or CSV), each column filling the layer of its name |
35
+ | `export_video` | The video at the scene's own settings (kept from 0.2) |
36
+ | `import_file` | Send a file from this computer into the scene, as a drop takes it: a picture or video, sound, a 3D model, Lottie, Figma frames, an SVG, a font |
34
37
  | `import_hyperframes` | Bring in a HyperFrames project from a folder or .zip on this computer: one code layer, its sound on the timeline |
35
- | `save_project` | Save the scene as a project in your browser |
38
+ | `undo`, `redo` | Step back or forward, as ⌘Z does |
39
+ | `review_sheet`, `review_artboards` | Frames across the piece as one sheet, with what runs past the frame, small or low-contrast type, and motion hitches; or the piece on 9:16, 4:5, 1:1 and 16:9 |
40
+ | `list_projects`, `open_project`, `save_project` | Your projects in this browser and your account; open one, or save the scene as one |
41
+ | `list_design_systems`, `apply_design_system` | Paraplex's design systems and yours; apply one in a single undo step |
42
+ | `list_skills`, `load_skill`, `read_skill_file`, `write_skill` | The assistant's skills: read them, or write one into your library |
36
43
 
37
44
  Every edit lands in your tab as one undo step, so you can step back through
38
45
  anything the agent did.
@@ -42,8 +49,9 @@ anything the agent did.
42
49
  - The server listens on this computer only (port 47821; pass another as
43
50
  `npx paraplex-mcp 48000`).
44
51
  - Only a Paraplex page can connect, and only with the one-time code.
45
- - Nothing is sent anywhere else: the scene stays in your tab, and exports are
46
- written where you ran the server.
52
+ - Nothing is sent anywhere else: the scene stays in your tab, exports are
53
+ written where you ran the server, and a file you send in goes only to your
54
+ tab. An agent reaches only what you are signed in to in that tab.
47
55
  - AI used inside Paraplex is metered by your Paraplex plan as usual; the agent's
48
56
  own model is your Claude Code's.
49
57
 
@@ -1,6 +1,6 @@
1
- import { randomInt } from "node:crypto";
2
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join, relative, resolve } from "node:path";
1
+ import { randomInt, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeSync } from "node:fs";
3
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
4
4
 
5
5
  import { imageContent } from "./mcp.mjs";
6
6
  import { serveWebSocket } from "./ws-server.mjs";
@@ -18,6 +18,35 @@ import { serveWebSocket } from "./ws-server.mjs";
18
18
 
19
19
  export const PARAPLEX_ATTACH_PORT = 47821;
20
20
 
21
+ /** A piece of a file each way (roadmap v22 W5): 768 KB, about 1 MB as base64. */
22
+ export const PARAPLEX_CHUNK_BYTES = 768 * 1024;
23
+
24
+ /** A path in `dir` for `fileName` that is not taken: "name.mp4", then "name-2.mp4". */
25
+ export function paraplexFreePath(dir, fileName) {
26
+ const safe = basename(String(fileName || "paraplex-export")).replace(/[\\/:*?"<>|]+/g, "-");
27
+ const extension = extname(safe);
28
+ const stem = safe.slice(0, safe.length - extension.length);
29
+ let candidate = join(dir, safe);
30
+
31
+ for (let index = 2; existsSync(candidate); index += 1) {
32
+ candidate = join(dir, `${stem}-${index}${extension}`);
33
+ }
34
+
35
+ return candidate;
36
+ }
37
+
38
+ /** What a file's extension says it is, for the tab's import. */
39
+ export function paraplexMimeType(path) {
40
+ const types = {
41
+ ".aac": "audio/aac", ".avif": "image/avif", ".csv": "text/csv", ".flac": "audio/flac", ".gif": "image/gif", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json",
42
+ ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".json": "application/json", ".lottie": "application/zip", ".m4a": "audio/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg",
43
+ ".mp4": "video/mp4", ".ogg": "audio/ogg", ".otf": "font/otf", ".png": "image/png", ".ply": "application/octet-stream", ".splat": "application/octet-stream", ".spz": "application/octet-stream",
44
+ ".svg": "image/svg+xml", ".ttf": "font/ttf", ".wav": "audio/wav", ".webm": "video/webm", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
45
+ };
46
+
47
+ return types[extname(path).toLowerCase()] ?? "application/octet-stream";
48
+ }
49
+
21
50
  /** What a project folder may send the tab: past this, its media will not fit a layer anyway. */
22
51
  const PROJECT_MAX_BYTES = 96 * 1024 * 1024;
23
52
  const PROJECT_MAX_FILES = 4000;
@@ -131,13 +160,40 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
131
160
  }
132
161
 
133
162
  waiters = [];
163
+ } else if (data.type === "file" && pending.has(data.id)) {
164
+ // A file the call sends back, in pieces: written as they come.
165
+ const entry = pending.get(data.id);
166
+ const dir = entry.into ?? join(process.cwd(), "paraplex-exports");
167
+
168
+ mkdirSync(dir, { recursive: true });
169
+
170
+ const path = paraplexFreePath(dir, data.fileName);
171
+
172
+ entry.files.set(data.file, { bytes: 0, fd: openSync(path, "w"), path });
173
+ } else if (data.type === "chunk" && pending.has(data.id)) {
174
+ const file = pending.get(data.id).files.get(data.file);
175
+
176
+ if (file && typeof data.data === "string") {
177
+ file.bytes += writeSync(file.fd, Buffer.from(data.data, "base64"));
178
+ }
134
179
  } else if (data.type === "result" && pending.has(data.id)) {
135
- const { reject, resolve } = pending.get(data.id);
180
+ const { files, reject, resolve } = pending.get(data.id);
136
181
 
137
182
  pending.delete(data.id);
138
183
 
184
+ for (const file of files.values()) {
185
+ closeSync(file.fd);
186
+ }
187
+
139
188
  if (data.ok) {
140
- resolve(data.value);
189
+ // A streamed file's number in the answer becomes where it was written.
190
+ const value = data.value;
191
+
192
+ if (value && Array.isArray(value.files) && files.size > 0) {
193
+ resolve({ ...value, files: value.files.map((entry) => (files.has(entry.file) ? { ...entry, bytes: files.get(entry.file).bytes, path: files.get(entry.file).path } : entry)) });
194
+ } else {
195
+ resolve(value);
196
+ }
141
197
  } else {
142
198
  reject(new Error(data.error ?? "The tab could not do that."));
143
199
  }
@@ -147,7 +203,11 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
147
203
  if (tab === peer) {
148
204
  tab = null;
149
205
 
150
- for (const { reject } of pending.values()) {
206
+ for (const { files, reject } of pending.values()) {
207
+ for (const file of files.values()) {
208
+ closeSync(file.fd);
209
+ }
210
+
151
211
  reject(new Error("The tab disconnected."));
152
212
  }
153
213
 
@@ -163,7 +223,7 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
163
223
 
164
224
  log(`Waiting for a Paraplex tab: in Paraplex, Menu → Connect an agent, port ${listening}, code ${code}.`);
165
225
 
166
- const call = async (method, params = {}) => {
226
+ const waitForTab = async () => {
167
227
  if (!tab) {
168
228
  await new Promise((resolve, reject) => {
169
229
  const timer = setTimeout(() => reject(new Error(`No Paraplex tab is attached. In Paraplex: Menu → Connect an agent, port ${listening}, code ${code}.`)), 180_000);
@@ -174,6 +234,43 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
174
234
  });
175
235
  });
176
236
  }
237
+ };
238
+
239
+ /**
240
+ * Sends a local file to the tab in pieces, ahead of the call that uses
241
+ * it; resolves with its upload id.
242
+ */
243
+ const upload = async (path) => {
244
+ await waitForTab();
245
+
246
+ const uploadId = randomUUID();
247
+ const mimeType = paraplexMimeType(path);
248
+ const fd = openSync(path, "r");
249
+ const buffer = Buffer.alloc(PARAPLEX_CHUNK_BYTES);
250
+
251
+ try {
252
+ for (;;) {
253
+ const read = readSync(fd, buffer, 0, buffer.length, null);
254
+
255
+ if (read <= 0) {
256
+ break;
257
+ }
258
+
259
+ tab.send(JSON.stringify({ data: buffer.subarray(0, read).toString("base64"), mimeType, type: "upload", upload: uploadId }));
260
+ }
261
+ } finally {
262
+ closeSync(fd);
263
+ }
264
+
265
+ // An empty file still arrives, as nothing.
266
+ tab.send(JSON.stringify({ mimeType, type: "upload", upload: uploadId }));
267
+
268
+ return uploadId;
269
+ };
270
+
271
+ /** A call to the tab; `into` is where files it streams back are written. */
272
+ const call = async (method, params = {}, { into } = {}) => {
273
+ await waitForTab();
177
274
 
178
275
  const id = next++;
179
276
 
@@ -185,6 +282,8 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
185
282
  }, 600_000);
186
283
 
187
284
  pending.set(id, {
285
+ files: new Map(),
286
+ into,
188
287
  reject: (error) => {
189
288
  clearTimeout(timer);
190
289
  reject(error);
@@ -201,6 +300,7 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
201
300
  return {
202
301
  attached: () => tab !== null,
203
302
  call,
303
+ upload,
204
304
  close: () => new Promise((resolve) => {
205
305
  tab?.close();
206
306
  server.close(() => resolve());
@@ -210,6 +310,28 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
210
310
  };
211
311
  }
212
312
 
313
+ /**
314
+ * An export through the tab, its files streamed into `path` (a folder, or
315
+ * a file name when one file comes back) or the export folder; answers with
316
+ * each file, where it went and its size.
317
+ */
318
+ export async function exportToDisk(tab, options, { outDir, path }) {
319
+ const asFile = typeof path === "string" && extname(path) !== "" && !(existsSync(path) && statSync(path).isDirectory());
320
+ const into = path ? (asFile ? dirname(resolve(path)) : resolve(path)) : outDir;
321
+ const result = await tab.call("exportFiles", options, { into });
322
+ const files = Array.isArray(result?.files) ? result.files : [];
323
+
324
+ if (asFile && files.length === 1 && files[0].path && resolve(path) !== files[0].path) {
325
+ renameSync(files[0].path, resolve(path));
326
+ files[0].path = resolve(path);
327
+ }
328
+
329
+ return {
330
+ files: files.map(({ bytes, fileName, notice, path: written }) => ({ bytes, fileName, ...(notice ? { notice } : {}), path: written })),
331
+ written: files.map((file) => file.path).join(", "),
332
+ };
333
+ }
334
+
213
335
  /** The MCP tools over an attached tab: the headless server's, as far as named calls reach. */
214
336
  export function attachedTabTools(tab, { outDir }) {
215
337
  return [
@@ -315,23 +437,53 @@ export function attachedTabTools(tab, { outDir }) {
315
437
  },
316
438
  },
317
439
  {
318
- description: "Export the scene as a video, written to a file in the export folder (or at `path`); answers with where it went.",
440
+ description:
441
+ "Export with any of the Export section's options, the files written on this computer as the tab renders them (no size limit): an image (png, jpg) at `seconds`, or a video (mp4, webm, gif, png-sequence) over `inSeconds`–`outSeconds`; `scale` 0.5–4, `fps`, `background: false` for transparency (PNG, WebM, PNG sequence); `artboard` an artboard's id or label, or \"all\" for one file each; `deliver` a destination's preset (app-store with `device` iphone/ipad/mac, keynote, powerpoint, google-slides). Only what you pass changes, and only for this export: the person's settings stay. `path` is a folder, or a file name for a single file (default ./paraplex-exports). A Free account gets the reason a Pro export is refused.",
442
+ inputSchema: {
443
+ properties: {
444
+ artboard: { type: "string" },
445
+ background: { type: "boolean" },
446
+ deliver: { enum: ["app-store", "keynote", "powerpoint", "google-slides"], type: "string" },
447
+ device: { enum: ["iphone", "ipad", "mac"], type: "string" },
448
+ format: { enum: ["png", "jpg", "mp4", "webm", "gif", "png-sequence"], type: "string" },
449
+ fps: { type: "number" },
450
+ inSeconds: { type: "number" },
451
+ kind: { enum: ["image", "video"], type: "string" },
452
+ outSeconds: { type: "number" },
453
+ path: { type: "string" },
454
+ scale: { type: "number" },
455
+ seconds: { type: "number" },
456
+ },
457
+ type: "object",
458
+ },
459
+ name: "export",
460
+ run: async ({ path, ...options }) => exportToDisk(tab, options, { outDir, path }),
461
+ },
462
+ {
463
+ description:
464
+ "Render a variant per row, as Variants does: `rows` (objects) or `csv` (a header row, then a row per variant). Each column fills the layer named like it — its text, a counter's end value, a fill color, or a picture by its file name in the scene's media library — or the layer `bindings` maps it to (column → layer id or name). `nameColumn` names each file. Takes every option `export` takes (an image by default; kind video for a clip per row). Without rows, the sheet already set in Variants. Answers with the files and any column that bound to nothing.",
465
+ inputSchema: {
466
+ properties: {
467
+ bindings: { additionalProperties: { type: "string" }, type: "object" },
468
+ csv: { type: "string" },
469
+ format: { enum: ["png", "jpg", "mp4", "webm", "gif", "png-sequence"], type: "string" },
470
+ kind: { enum: ["image", "video"], type: "string" },
471
+ nameColumn: { type: "string" },
472
+ options: { description: "Any other export option: scale, fps, seconds, inSeconds, outSeconds, background, artboard.", type: "object" },
473
+ path: { type: "string" },
474
+ rows: { items: { type: "object" }, type: "array" },
475
+ },
476
+ type: "object",
477
+ },
478
+ name: "render_variants",
479
+ run: async ({ bindings, csv, format, kind, nameColumn, options, path, rows }) =>
480
+ exportToDisk(tab, { ...(options ?? {}), ...(format ? { format } : {}), ...(kind ? { kind } : {}), variants: { ...(bindings ? { bindings } : {}), ...(csv !== undefined ? { csv } : {}), ...(nameColumn ? { nameColumn } : {}), ...(rows ? { rows } : {}) } }, { outDir, path: path ?? join(outDir, "variants") }),
481
+ },
482
+ {
483
+ description: "Export the scene as a video with its own settings, written to a file in the export folder (or at `path`); answers with where it went. The `export` tool takes every option.",
319
484
  inputSchema: { properties: { path: { type: "string" } }, type: "object" },
320
485
  name: "export_video",
321
- run: async ({ path }) => {
322
- const result = await tab.call("exportVideo");
323
-
324
- if (!result) {
325
- return "No editor.";
326
- }
327
-
328
- const target = path ?? join(outDir, result.fileName);
329
-
330
- mkdirSync(dirname(target), { recursive: true });
331
- writeFileSync(target, Buffer.from(String(result.dataUrl).replace(/^data:[^,]+,/, ""), "base64"));
332
-
333
- return `Written: ${target}`;
334
- },
486
+ run: async ({ path }) => exportToDisk(tab, { kind: "video" }, { outDir, path }),
335
487
  },
336
488
  {
337
489
  description:
@@ -349,6 +501,114 @@ export function attachedTabTools(tab, { outDir }) {
349
501
  return { message: result.message, missing: result.missing ?? [], scene: await tab.call("scene") };
350
502
  },
351
503
  },
504
+ {
505
+ description:
506
+ "Bring a file from this computer into the open scene, as dropping it on the canvas does: a picture or video becomes a layer (or fills the layer `target`, by id — a card, a phone screen, a component's slot), sound a clip at the playhead, a GLB/glTF/splat a model, a .lottie or Lottie .json an animation, an SVG a vector, a TTF/OTF/WOFF a font of the scene's. Answers with what happened and the layers it added.",
507
+ inputSchema: { properties: { path: { type: "string" }, target: { type: "string" } }, required: ["path"], type: "object" },
508
+ name: "import_file",
509
+ run: async ({ path, target }) => {
510
+ const full = resolve(path);
511
+
512
+ if (!existsSync(full) || !statSync(full).isFile()) {
513
+ throw new Error(`No file at ${full}.`);
514
+ }
515
+
516
+ const upload = await tab.upload(full);
517
+
518
+ return tab.call("importFile", { name: basename(full), upload, ...(target ? { target } : {}) });
519
+ },
520
+ },
521
+ {
522
+ description: "Undo the last `steps` edits in the tab (default 1), as ⌘Z does — a run_tool call is one step. Answers with how many it took and whether more remain.",
523
+ inputSchema: { properties: { steps: { type: "number" } }, type: "object" },
524
+ name: "undo",
525
+ run: ({ steps }) => tab.call("undo", { steps: steps ?? 1 }),
526
+ },
527
+ {
528
+ description: "Redo `steps` undone edits (default 1), as ⇧⌘Z does.",
529
+ inputSchema: { properties: { steps: { type: "number" } }, type: "object" },
530
+ name: "redo",
531
+ run: ({ steps }) => tab.call("redo", { steps: steps ?? 1 }),
532
+ },
533
+ {
534
+ description:
535
+ "A review sheet of the open scene, the way to judge a piece before calling it done: frames across it (default eight; or `times`) as one picture, and the flags — layers past the frame (more than 0.05 of the frame), text under 14 px on the frame, text under 3:1 contrast against what is behind it, motion hitches (a speed that jumps or a path that turns a corner mid-move). Fix what is flagged unless it is a deliberate choice you can name.",
536
+ inputSchema: { properties: { times: { items: { type: "number" }, type: "array" }, width: { type: "number" } }, type: "object" },
537
+ name: "review_sheet",
538
+ run: async ({ times, width }) => {
539
+ const review = await tab.call("review", { ...(times ? { times } : {}), ...(width ? { width } : {}) });
540
+
541
+ if (!review) {
542
+ return "No editor.";
543
+ }
544
+
545
+ const { sheet, ...facts } = review;
546
+
547
+ return [...imageContent(sheet, "The frames, left to right, top to bottom."), { text: JSON.stringify(facts, null, 2), type: "text" }];
548
+ },
549
+ },
550
+ {
551
+ description:
552
+ "The piece on every review artboard (9:16, 4:5, 1:1, 16:9) as Responsive lays it out, at `times`: what each artboard did, what its frame cuts, the smallest type and type at the edge, with all the frames as one picture.",
553
+ inputSchema: { properties: { times: { items: { type: "number" }, type: "array" } }, type: "object" },
554
+ name: "review_artboards",
555
+ run: async ({ times }) => {
556
+ const scene = await tab.call("scene");
557
+ const moments = times?.length ? times : [0.25, 0.5, 0.9].map((share) => Number(((scene?.durationSeconds ?? 8) * share).toFixed(2)));
558
+ const review = await tab.call("reviewArtboards", { times: moments });
559
+
560
+ if (!review) {
561
+ return "No editor.";
562
+ }
563
+
564
+ return [...imageContent(review.sheet, "Each artboard at each time."), { text: JSON.stringify(review.reviews, null, 2), type: "text" }];
565
+ },
566
+ },
567
+ {
568
+ description: "The person's projects, newest first: those in this browser and, when the tab is signed in, those kept in their account (in the cloud), each with its id, name, when it changed, and which one is open.",
569
+ name: "list_projects",
570
+ run: () => tab.call("projects"),
571
+ },
572
+ {
573
+ description: "Open one of the person's projects by id (list_projects), as the editor opens it: the scene being left is saved first; a project kept only in the account is fetched. Answers with its name and its scene.",
574
+ inputSchema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" },
575
+ name: "open_project",
576
+ run: ({ id }) => tab.call("openProject", { id }),
577
+ },
578
+ {
579
+ description: "Design systems the person can apply: Paraplex's and their own (made, imported or installed), each with what it holds (colors, text styles, materials, looks, fonts), and the one the scene follows now.",
580
+ name: "list_design_systems",
581
+ run: () => tab.call("designSystems"),
582
+ },
583
+ {
584
+ description: "Apply a design system by id or name, as Use in project does: its colors, text styles, materials, looks and fonts merged into the scene, which then follows it. One undo step. Then build with its tokens (run_tool get_brand reads them).",
585
+ inputSchema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" },
586
+ name: "apply_design_system",
587
+ run: ({ id }) => tab.call("applyDesignSystem", { id }),
588
+ },
589
+ {
590
+ description: "The person's skills and Paraplex's (Agent Skills: instructions and reference files), each with its description and whether it is on. Read one with load_skill when a task matches it.",
591
+ name: "list_skills",
592
+ run: () => tab.call("skills"),
593
+ },
594
+ {
595
+ description: "A skill's instructions (its SKILL.md body) and the files inside it, by name. Read it before doing the work it describes.",
596
+ inputSchema: { properties: { name: { type: "string" } }, required: ["name"], type: "object" },
597
+ name: "load_skill",
598
+ run: async ({ name }) => (await tab.call("loadSkill", { name })).content,
599
+ },
600
+ {
601
+ description: "One file inside a skill — a reference, a template, a checklist — by the path load_skill listed.",
602
+ inputSchema: { properties: { name: { type: "string" }, path: { type: "string" } }, required: ["name", "path"], type: "object" },
603
+ name: "read_skill_file",
604
+ run: async ({ name, path }) => (await tab.call("readSkillFile", { name, path })).content,
605
+ },
606
+ {
607
+ description: "Write a skill's SKILL.md into the person's library: the whole file, frontmatter (name, description) then instructions. Rewrites the named skill's SKILL.md and keeps its reference files, or creates it. Paraplex's own skills are not rewritten.",
608
+ inputSchema: { properties: { markdown: { type: "string" }, name: { type: "string" } }, required: ["name", "markdown"], type: "object" },
609
+ name: "write_skill",
610
+ run: ({ markdown, name }) => tab.call("writeSkill", { markdown, name }),
611
+ },
352
612
  {
353
613
  description: "Save the open scene as a project of that browser's, by name.",
354
614
  inputSchema: { properties: { name: { type: "string" } }, required: ["name"], type: "object" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paraplex-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "mcpName": "ai.paraplex/paraplex-mcp",
5
5
  "description": "Connect Claude Code (or any MCP client) to the Paraplex tab you have open: build and edit 3D motion design scenes with the assistant's tools, look at frames, export.",
6
6
  "keywords": [
@@ -56,15 +56,35 @@ what you changed after each step; never clear their scene without asking.
56
56
 
57
57
  After every section, look: `frames` (one picture per time), `frame` at a
58
58
  moment, `scene_facts` (where each layer lands on the frame, type sizes, the
59
- camera). Check frame fit (nothing the piece is about half out of frame at a
60
- hold), type (a caption ≥ 14 px on the frame, contrast ≥ 3:1), motion (one
61
- ease per move, holds long enough to read) — fix what you see, then look
62
- again. Work section by section for anything longer than a few seconds: plan
63
- the sections, block a storyboard with `build_scene`, show the person the
59
+ camera). `review_sheet` does the checking for you: the frames as one sheet
60
+ and the flags — anything past the frame, type under 14 px, contrast under
61
+ 3:1, motion hitches; `review_artboards` shows the piece on 9:16, 4:5, 1:1
62
+ and 16:9. Check frame fit (nothing the piece is about half out of frame at
63
+ a hold), type, motion (one ease per move, holds long enough to read) — fix
64
+ what you see, then look again. `undo` takes back a step that went wrong.
65
+ Work section by section for anything longer than a few seconds: plan the
66
+ sections, block a storyboard with `build_scene`, show the person the
64
67
  frames, and build once they agree.
65
68
 
69
+ ## Their library
70
+
71
+ - Files from this computer: `import_file` with a path (a picture, video,
72
+ sound, model, Lottie, Figma frames, SVG or font), optionally onto a
73
+ layer by id. Read the layers it says it added.
74
+ - Their projects: `list_projects`, `open_project` (ask before leaving a
75
+ scene they are working on), `save_project`.
76
+ - A brand: `list_design_systems`, `apply_design_system` by name.
77
+ - The assistant's skills: `list_skills`, `load_skill` before a kind of
78
+ piece a skill covers; `write_skill` saves one to their library.
79
+
66
80
  ## Deliver
67
81
 
68
- `export_png` returns a still; `export_video` writes the file (in
69
- `./paraplex-exports`, where the server was started) and says where.
70
- `save_project` saves the scene in their browser by name.
82
+ `export` writes the file (in `./paraplex-exports`, where the server was
83
+ started) and says where; pass only the options the person asked for —
84
+ `kind`, `format` (png, jpg, mp4, webm, gif, png-sequence), `scale`, `fps`,
85
+ `inSeconds`/`outSeconds`, `background: false` for transparency, `artboard`
86
+ (a name, or `all`), `deliver` (app-store, keynote, powerpoint,
87
+ google-slides). Their own Export settings stay as they were; a Pro-only
88
+ export on Free comes back with the reason, as the Export button gives it.
89
+ `render_variants` makes a file per row of data. `export_png` returns a
90
+ still to look at.