@pinepaper.studio/mcp-server 1.6.13 → 1.6.15

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.
Files changed (94) hide show
  1. package/README.de.md +6 -1
  2. package/README.es.md +6 -1
  3. package/README.fr.md +6 -1
  4. package/README.hi.md +6 -1
  5. package/README.ja.md +6 -1
  6. package/README.ko.md +6 -1
  7. package/README.md +245 -6
  8. package/README.pt-BR.md +6 -1
  9. package/README.zh-CN.md +6 -1
  10. package/assets/brand/pinepaper-lockup-horizontal-white.svg +1 -0
  11. package/assets/brand/pinepaper-lockup-horizontal.svg +1 -0
  12. package/assets/edit-whenever.svg +1 -1
  13. package/dist/browser/puppeteer-controller.js +2 -2
  14. package/dist/browser/puppeteer-controller.js.map +1 -1
  15. package/dist/build-info.json +5 -0
  16. package/dist/design/design-systems.js +1 -1
  17. package/dist/design/design-systems.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/ontology/upstream-vocabulary.d.ts +23 -0
  22. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -0
  23. package/dist/ontology/upstream-vocabulary.js +1172 -0
  24. package/dist/ontology/upstream-vocabulary.js.map +1 -0
  25. package/dist/ontology/vocabulary.d.ts.map +1 -1
  26. package/dist/ontology/vocabulary.js +19 -0
  27. package/dist/ontology/vocabulary.js.map +1 -1
  28. package/dist/tools/definitions.d.ts.map +1 -1
  29. package/dist/tools/definitions.js +264 -70
  30. package/dist/tools/definitions.js.map +1 -1
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +24 -2
  33. package/dist/tools/engine-methods.js.map +1 -1
  34. package/dist/tools/engine-surface.d.ts +1 -1
  35. package/dist/tools/engine-surface.d.ts.map +1 -1
  36. package/dist/tools/engine-surface.js +37 -10
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/camera.d.ts.map +1 -1
  39. package/dist/tools/handlers/camera.js +17 -2
  40. package/dist/tools/handlers/camera.js.map +1 -1
  41. package/dist/tools/handlers/font.d.ts.map +1 -1
  42. package/dist/tools/handlers/font.js +48 -3
  43. package/dist/tools/handlers/font.js.map +1 -1
  44. package/dist/tools/handlers/maps.d.ts.map +1 -1
  45. package/dist/tools/handlers/maps.js +16 -0
  46. package/dist/tools/handlers/maps.js.map +1 -1
  47. package/dist/tools/handlers.d.ts +11 -1
  48. package/dist/tools/handlers.d.ts.map +1 -1
  49. package/dist/tools/handlers.js +426 -14
  50. package/dist/tools/handlers.js.map +1 -1
  51. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  52. package/dist/tools/minimal-descriptions.js +2 -0
  53. package/dist/tools/minimal-descriptions.js.map +1 -1
  54. package/dist/tools/shape-params.d.ts +13 -2
  55. package/dist/tools/shape-params.d.ts.map +1 -1
  56. package/dist/tools/shape-params.js +98 -3
  57. package/dist/tools/shape-params.js.map +1 -1
  58. package/dist/tools/stick-vocabulary.d.ts +1 -1
  59. package/dist/tools/stick-vocabulary.js +1 -1
  60. package/dist/tools/toolkits.d.ts.map +1 -1
  61. package/dist/tools/toolkits.js +2 -0
  62. package/dist/tools/toolkits.js.map +1 -1
  63. package/dist/types/code-generator.d.ts +27 -1
  64. package/dist/types/code-generator.d.ts.map +1 -1
  65. package/dist/types/code-generator.js +2506 -273
  66. package/dist/types/code-generator.js.map +1 -1
  67. package/dist/types/schemas.d.ts +1483 -239
  68. package/dist/types/schemas.d.ts.map +1 -1
  69. package/dist/types/schemas.js +259 -26
  70. package/dist/types/schemas.js.map +1 -1
  71. package/dist/utils/a11y.d.ts +67 -0
  72. package/dist/utils/a11y.d.ts.map +1 -0
  73. package/dist/utils/a11y.js +117 -0
  74. package/dist/utils/a11y.js.map +1 -0
  75. package/dist/utils/ad-package.d.ts +44 -0
  76. package/dist/utils/ad-package.d.ts.map +1 -0
  77. package/dist/utils/ad-package.js +121 -0
  78. package/dist/utils/ad-package.js.map +1 -0
  79. package/dist/utils/scc.d.ts +41 -0
  80. package/dist/utils/scc.d.ts.map +1 -0
  81. package/dist/utils/scc.js +185 -0
  82. package/dist/utils/scc.js.map +1 -0
  83. package/dist/utils/zip.d.ts +7 -0
  84. package/dist/utils/zip.d.ts.map +1 -0
  85. package/dist/utils/zip.js +82 -0
  86. package/dist/utils/zip.js.map +1 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.d.ts.map +1 -1
  89. package/dist/version.js +31 -1
  90. package/dist/version.js.map +1 -1
  91. package/icon.png +0 -0
  92. package/icon.svg +1 -0
  93. package/manifest.json +11 -2
  94. package/package.json +8 -4
@@ -6,7 +6,7 @@
6
6
  * Supports browser execution via Puppeteer.
7
7
  */
8
8
  import { codeGenerator } from '../types/code-generator.js';
9
- import { CreateItemInputSchema, ModifyItemInputSchema, DeleteItemInputSchema, AddRelationInputSchema, RemoveRelationInputSchema, QueryRelationsInputSchema, AnimateItemInputSchema, ImportMotionCaptureInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, GetItemsInputSchema, PlayTimelineInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, ImportSVGInputSchema, ImportMermaidInputSchema, AddFilterInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, GeometryInputSchema, EquationPathInputSchema, EventInputSchema, ConstructionSequenceInputSchema, ValidateSceneInputSchema, CaptureFramesInputSchema, InstantiateOntologyInputSchema, LintSceneInputSchema, MediaInputSchema, TextStyleInputSchema, TextEffectInputSchema, DesignMediumInputSchema, ShatterImageInputSchema, ImportLayeredCharacterInputSchema, CharacterInputSchema, GameInputSchema, World3DInputSchema, BrandKitInputSchema, ComponentInputSchema, ArtboardInputSchema, CommentInputSchema, ProvenanceInputSchema, SceneDiffInputSchema, AudioBeatsInputSchema, TemplateParamsInputSchema, ComposeInputSchema, CropImageInputSchema, PathOpInputSchema, MotionInputSchema, SoundInputSchema, InterchangeInputSchema, StickInputSchema, DesignSystemInputSchema, StoryInputSchema, ChromaKeyInputSchema, RiggingInputSchema, GroupInputSchema, CameraDirectorInputSchema, DetectObjectsInputSchema, ExtractObjectInputSchema, ArrangeInputSchema, CreateSceneInputSchema, GetPerformanceMetricsInputSchema, DiagnosticReportInputSchema, SearchAssetsInputSchema, ImportAssetInputSchema, P5DrawInputSchema,
9
+ import { CreateItemInputSchema, ModifyItemInputSchema, DeleteItemInputSchema, AddRelationInputSchema, RemoveRelationInputSchema, QueryRelationsInputSchema, AnimateItemInputSchema, ImportMotionCaptureInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, GetItemsInputSchema, PlayTimelineInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, ImportSVGInputSchema, ImportMermaidInputSchema, AddFilterInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, GeometryInputSchema, EquationPathInputSchema, EventInputSchema, ConstructionSequenceInputSchema, ValidateSceneInputSchema, CaptureFramesInputSchema, InstantiateOntologyInputSchema, LintSceneInputSchema, MediaInputSchema, TextStyleInputSchema, TextEffectInputSchema, DesignMediumInputSchema, ShatterImageInputSchema, ImportLayeredCharacterInputSchema, CharacterInputSchema, GameInputSchema, World3DInputSchema, BrandKitInputSchema, ComponentInputSchema, ArtboardInputSchema, CommentInputSchema, ProvenanceInputSchema, SceneDiffInputSchema, AudioBeatsInputSchema, TemplateParamsInputSchema, RenderBatchInputSchema, AccessibilityCheckInputSchema, ComposeInputSchema, CropImageInputSchema, PathOpInputSchema, MotionInputSchema, SoundInputSchema, InterchangeInputSchema, StickInputSchema, DesignSystemInputSchema, StoryInputSchema, ChromaKeyInputSchema, RiggingInputSchema, GroupInputSchema, CameraDirectorInputSchema, DetectObjectsInputSchema, ExtractObjectInputSchema, ArrangeInputSchema, CreateSceneInputSchema, GetPerformanceMetricsInputSchema, DiagnosticReportInputSchema, SearchAssetsInputSchema, ImportAssetInputSchema, P5DrawInputSchema,
10
10
  // Diagram schemas
11
11
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
12
12
  // Agent flow mode schemas
@@ -28,7 +28,7 @@ ImportImageInputSchema,
28
28
  ManageScenesInputSchema, ScenePlaybackInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema,
29
29
  // New consolidated tool schemas
30
30
  SelectionInputSchema, TransformInputSchema, HistoryInputSchema, ImageFilterInputSchema, LassoInputSchema, CutoutStyleInputSchema, PrecompInputSchema, BackgroundInputSchema, QueryInputSchema, QueryCapabilitiesInputSchema, DeformInputSchema, SpriteSheetInputSchema, InteractionInputSchema, ExportWidgetInputSchema, ExportWidgetHtmlInputSchema, CreateChartInputSchema, MagicInputSchema, PhysicsInputSchema, MeasurementInputSchema, ErrorCodes, } from '../types/schemas.js';
31
- import { ZodError } from 'zod';
31
+ import { ZodError, z } from 'zod';
32
32
  import { writeFile, mkdir, appendFile, unlink, readFile, stat } from 'node:fs/promises';
33
33
  import { tmpdir } from 'node:os';
34
34
  import { join, extname } from 'node:path';
@@ -47,6 +47,10 @@ import { ontologyHandlers } from './handlers/ontology.js';
47
47
  import * as designSystems from '../design/design-systems.js';
48
48
  import { exportHandlers } from './handlers/export.js';
49
49
  import { planCharacter, generateCharacterCode } from './handlers/character.js';
50
+ import { buildZip } from '../utils/zip.js';
51
+ import { buildScc } from '../utils/scc.js';
52
+ import { checkContrast, checkFlashes, parseCssColor } from '../utils/a11y.js';
53
+ import { buildHtml5Ad, buildPlayable, externalRequests, pinSceneBackground } from '../utils/ad-package.js';
50
54
  /**
51
55
  * Registry of per-domain handler maps. Tools listed here short-circuit the
52
56
  * main switch at dispatch time (see handleToolCallInner). New domains are
@@ -83,7 +87,7 @@ function getExportDir() {
83
87
  // wav joins them: a minute of 48kHz 16-bit is ~5.8 MB of base64 and the ten
84
88
  // minutes the schema allows is ~77 MB. Deliverable across the bridge, useless
85
89
  // pasted into a response.
86
- export const ALWAYS_SAVE_FORMATS = new Set(['mp4', 'webm', 'gif', 'pdf', 'wav']);
90
+ export const ALWAYS_SAVE_FORMATS = new Set(['mp4', 'webm', 'gif', 'apng', 'pdf', 'wav', 'srt', 'vtt', 'scc', 'html5-ad', 'playable']);
87
91
  // 500_000 was chosen against the bridge's limits, not the CALLER's. A pilot
88
92
  // session hit a 263K-character end_job result — comfortably under this, so it
89
93
  // was returned inline, and over the tool-result limit of the client reading
@@ -237,10 +241,150 @@ export async function resolveImageSource(input) {
237
241
  }
238
242
  return { src: `data:${IMAGE_MIME[ext] ?? 'application/octet-stream'};base64,${bytes.toString('base64')}` };
239
243
  }
244
+ /**
245
+ * Audio / video for pinepaper_media upload, fetched or read BY THE SERVER.
246
+ *
247
+ * The page cannot do it on production: its CSP refuses fetch() of a third-party
248
+ * host, so upload_audio of a working https URL (Wikimedia's Example.ogg, 200
249
+ * from anywhere else) failed "Failed to fetch (upload.wikimedia.org)" — every
250
+ * stock music and SFX URL was unusable. Same answer as remote images: the
251
+ * server fetches, and the page receives bytes (decoded to a File in the page,
252
+ * never fetch()ed there — see generateMedia).
253
+ *
254
+ * A User-Agent is sent because some media hosts (Wikimedia among them) refuse
255
+ * anonymous clients. The content-type check accepts audio/*, video/*,
256
+ * application/ogg, and a generic binary type when the extension says media;
257
+ * an HTML page around the file is refused by name.
258
+ */
259
+ const MEDIA_MIME = {
260
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.m4a': 'audio/mp4',
261
+ '.aac': 'audio/aac', '.flac': 'audio/flac', '.opus': 'audio/ogg',
262
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.ogv': 'video/ogg',
263
+ };
264
+ const MAX_MEDIA_BYTES = 48 * 1024 * 1024;
265
+ export async function resolveMediaSource(input) {
266
+ const raw = input.trim();
267
+ if (/^data:/i.test(raw))
268
+ return { src: raw };
269
+ const tooBig = (what, n) => ({
270
+ error: `${what} is ${(n / 1024 / 1024).toFixed(1)} MB, above the ${MAX_MEDIA_BYTES / 1024 / 1024} MB upload cap for this tool. Trim or re-encode it, or upload it in the studio itself.`,
271
+ });
272
+ if (/^https?:/i.test(raw)) {
273
+ let res;
274
+ try {
275
+ res = await fetch(raw, { redirect: 'follow', headers: { 'User-Agent': 'PinePaper-MCP (+https://pinepaper.studio)' } });
276
+ }
277
+ catch (e) {
278
+ const why = e instanceof Error ? e.message : 'network request failed';
279
+ return { error: `could not reach ${raw} — ${why}. This fetch runs in the MCP server, not the page, so it is not a CSP problem: check the host resolves and any proxy is reachable.` };
280
+ }
281
+ if (!res.ok)
282
+ return { error: `the server refused ${raw} — HTTP ${res.status} ${res.statusText || ''}`.trim() };
283
+ let type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
284
+ const extType = MEDIA_MIME[extname(new URL(raw).pathname).toLowerCase()];
285
+ if (!type || type === 'application/octet-stream' || type === 'binary/octet-stream')
286
+ type = extType ?? type;
287
+ if (type === 'application/ogg')
288
+ type = 'audio/ogg';
289
+ if (!/^(audio|video)\//.test(type)) {
290
+ return { error: `${raw} served "${type || 'no content-type'}", not audio or video. A URL that returns an HTML page around the file will do this — link the file itself.` };
291
+ }
292
+ const buf = Buffer.from(await res.arrayBuffer());
293
+ if (buf.length > MAX_MEDIA_BYTES)
294
+ return tooBig(raw, buf.length);
295
+ return { src: `data:${type};base64,${buf.toString('base64')}` };
296
+ }
297
+ const path = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
298
+ const type = MEDIA_MIME[extname(path).toLowerCase()];
299
+ if (!type) {
300
+ return { error: `"${input}" is neither an http(s) URL, a data: URL, nor a path to an audio/video file. Recognised extensions: ${Object.keys(MEDIA_MIME).join(', ')}.` };
301
+ }
302
+ try {
303
+ const info = await stat(path);
304
+ if (!info.isFile())
305
+ return { error: `"${path}" is not a file.` };
306
+ if (info.size > MAX_MEDIA_BYTES)
307
+ return tooBig(`"${path}"`, info.size);
308
+ const bytes = await readFile(path);
309
+ return { src: `data:${type};base64,${bytes.toString('base64')}` };
310
+ }
311
+ catch (e) {
312
+ const why = e instanceof Error ? e.message : 'unknown error';
313
+ return { error: `could not read "${path}": ${why}. Paths are resolved from the server's working directory, so pass an absolute path if in doubt.` };
314
+ }
315
+ }
240
316
  export function getFileExtension(format) {
241
- const extMap = { mp4: 'mp4', webm: 'webm', gif: 'gif', pdf: 'pdf', png: 'png', svg: 'svg', wav: 'wav' };
317
+ const extMap = { mp4: 'mp4', webm: 'webm', gif: 'gif', apng: 'png', 'html5-ad': 'zip', playable: 'html', pdf: 'pdf', png: 'png', svg: 'svg', wav: 'wav', jpg: 'jpg', webp: 'webp', srt: 'srt', vtt: 'vtt', scc: 'scc' };
242
318
  return extMap[format] || format;
243
319
  }
320
+ /** The text of a tool result, joined. */
321
+ function toolText(r) {
322
+ return (r.content ?? []).map((c) => (c.type === 'text' ? c.text : '')).join('\n');
323
+ }
324
+ /**
325
+ * Wrap the widget page for its network, write it, and say what a trafficker
326
+ * checks: the upload's size against the budget, and anything the page fetches
327
+ * from outside (networks block or must allow-list external requests).
328
+ */
329
+ async function saveAdExport(r, input, platform) {
330
+ const exportDir = getExportDir();
331
+ await mkdir(exportDir, { recursive: true });
332
+ const stamp = `pinepaper_${platform}_${Date.now()}`;
333
+ const opts = { width: r.adSize.width, height: r.adSize.height, clickUrl: input.ad?.clickUrl, cta: r.cta, background: r.background };
334
+ const warnings = [...(r.fidelity?.warnings ?? [])];
335
+ // The renderer paints SCENE.background over the page CSS, so the scene's own
336
+ // fill must be pinned too; a page without the hook is named, not assumed.
337
+ if (r.background && !pinSceneBackground(r.adPage, r.background).applied) {
338
+ warnings.push({ code: 'background_not_pinned', message: `the page's own scene background could not be replaced with ${r.background}; the ad may show the widget's stored colour instead. Check the backup image against the served ad.` });
339
+ }
340
+ const files = {};
341
+ let uploadBytes;
342
+ if (r.format === 'html5-ad') {
343
+ const html = buildHtml5Ad(r.adPage, opts);
344
+ const zip = buildZip([{ name: 'index.html', data: html }]);
345
+ files.zip = join(exportDir, `${stamp}.zip`);
346
+ await writeFile(files.zip, zip);
347
+ uploadBytes = zip.length;
348
+ // The backup image is uploaded BESIDE the zip, not inside it.
349
+ if (r.backupImage && r.backupImage.startsWith('data:')) {
350
+ files.backupImage = join(exportDir, `${stamp}_backup.png`);
351
+ await writeFile(files.backupImage, Buffer.from(r.backupImage.split(',')[1], 'base64'));
352
+ }
353
+ else {
354
+ warnings.push({ code: 'no_backup_image', message: 'no backup image was made (the studio could not capture a frame); display networks require one — export format "png" at the same size.' });
355
+ }
356
+ }
357
+ else {
358
+ const html = buildPlayable(r.adPage, opts);
359
+ files.html = join(exportDir, `${stamp}.html`);
360
+ await writeFile(files.html, html, 'utf-8');
361
+ uploadBytes = Buffer.byteLength(html, 'utf-8');
362
+ }
363
+ // 150 KB is Google Ads' display HTML5 figure; some uploads allow 600 KB, and
364
+ // playable networks set their own — so it is a default, not a rule.
365
+ const budget = input.ad?.maxBytes ?? (r.format === 'html5-ad' ? 150_000 : undefined);
366
+ if (budget !== undefined && uploadBytes > budget) {
367
+ warnings.push({ code: 'over_size_budget', message: `the ${r.format === 'html5-ad' ? 'zip' : 'HTML'} is ${uploadBytes} bytes, over the ${budget}-byte budget. Simplify the scene (fewer items, no embedded images), or set ad.maxBytes to your network's figure if it allows more.` });
368
+ }
369
+ const external = externalRequests(r.adPage);
370
+ if (external.length) {
371
+ warnings.push({ code: 'external_requests', message: `the page fetches ${external.length} external URL(s): ${external.slice(0, 5).join(', ')}${external.length > 5 ? ', …' : ''}. Display and playable networks often block external requests or require them allow-listed; a scene using only system fonts avoids web-font requests.` });
372
+ }
373
+ const { adPage: _page, backupImage: _img, ...rest } = r;
374
+ void _page;
375
+ void _img;
376
+ const cleanResult = { ...rest, files, uploadBytes,
377
+ ...(files.backupImage ? { backupNote: 'the backup image is a separate file beside the zip, not inside it: display ad servers take it as its own upload.' } : {}), ...(budget !== undefined ? { sizeBudget: budget } : {}), externalRequests: external,
378
+ fidelity: { ...(r.fidelity ?? {}), warnings } };
379
+ if (warnings.length && cleanResult.fidelity.note)
380
+ delete cleanResult.fidelity.note;
381
+ return {
382
+ content: [{
383
+ type: 'text',
384
+ text: `Ad saved:\n\n${Object.entries(files).map(([k, v]) => `${k}: ${v}`).join('\n')}\nFormat: ${r.format}\nSize: ${r.adSize.width}x${r.adSize.height}, upload ${(uploadBytes / 1024).toFixed(1)} KB\n\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
385
+ }],
386
+ };
387
+ }
244
388
  async function saveExportToFile(data, format, platform) {
245
389
  const exportDir = getExportDir();
246
390
  await mkdir(exportDir, { recursive: true });
@@ -793,19 +937,59 @@ ${code}
793
937
  * not a failure, because plenty of emitters return a bare value (an id, a
794
938
  * count, a list) and reading absence as refusal would turn every one of those
795
939
  * into an error. `ok: false` is included because the export-store emitters use
796
- * it, and it carries a `reason` rather than an `error`.
940
+ * it, and it carries a `reason` rather than an `error`. A non-empty `error`
941
+ * string with no `success: true` beside it is a failure too — it is what the
942
+ * emitters' catch blocks return.
797
943
  */
798
944
  export function innerFailure(value) {
799
945
  if (!value || typeof value !== 'object')
800
946
  return null;
801
947
  const r = value;
802
- const failed = r.success === false || r.ok === false;
948
+ // `ok` IS A DOMAIN VERDICT, NOT A TRANSPORT ONE — when `success` disagrees.
949
+ //
950
+ // pinepaper_validate answers {success: true, ok: false, diagnostics: [...]}:
951
+ // the call worked and the SCENE has problems, which is the entire point of
952
+ // the tool. Treating ok:false as a failure turned that into "the studio
953
+ // reported failure without naming a reason" and dropped the diagnostics —
954
+ // this guard, built to stop silent failures, silencing the one tool whose
955
+ // job is to report them. An explicit success:true wins; ok is only consulted
956
+ // when nothing else says.
957
+ // A PAYLOAD CARRYING AN `error` IS A FAILURE, even with no success flag.
958
+ //
959
+ // 89 emitted returns in code-generator.ts are `{ error: '…' }` with no
960
+ // success:false beside them — the shape a try/catch naturally produces. This
961
+ // guard only looked at success and ok, so those reached the caller wrapped
962
+ // in a successful tool result with the error nested inside it. Reported for
963
+ // image_filter on a vector item, where applyImageFilter throws and the catch
964
+ // returns exactly that shape.
965
+ //
966
+ // An explicit success:true still wins, so a tool that legitimately reports a
967
+ // domain error alongside a successful call is unaffected.
968
+ const hasError = typeof r.error === 'string' && r.error.length > 0;
969
+ const failed = r.success === false
970
+ || (r.success !== true && (r.ok === false || hasError));
803
971
  if (!failed)
804
972
  return null;
805
- const why = typeof r.error === 'string' ? r.error
806
- : typeof r.reason === 'string' ? r.reason
807
- : 'the studio reported failure without naming a reason';
808
- return why;
973
+ if (typeof r.error === 'string')
974
+ return r.error;
975
+ if (typeof r.reason === 'string')
976
+ return r.reason;
977
+ // A refusal that carries diagnostics is not reasonless — say what they were
978
+ // rather than reporting that nothing was said.
979
+ const detail = Array.isArray(r.diagnostics) ? r.diagnostics
980
+ : Array.isArray(r.errors) ? r.errors
981
+ : null;
982
+ if (detail && detail.length > 0) {
983
+ const first = detail.slice(0, 3).map((d) => {
984
+ if (typeof d === 'string')
985
+ return d;
986
+ const o = d;
987
+ return String(o.message ?? o.reason ?? o.code ?? JSON.stringify(o));
988
+ });
989
+ return `${detail.length} problem${detail.length > 1 ? 's' : ''}: ${first.join('; ')}`
990
+ + (detail.length > first.length ? ` (+${detail.length - first.length} more)` : '');
991
+ }
992
+ return 'the studio reported failure without naming a reason';
809
993
  }
810
994
  // =============================================================================
811
995
  // I18N HELPER FUNCTIONS
@@ -883,10 +1067,57 @@ function measureResponseBytes(result) {
883
1067
  }
884
1068
  return bytes;
885
1069
  }
1070
+ /**
1071
+ * TOP-LEVEL ARGUMENTS A TOOL DROPPED, SAID OUT LOUD.
1072
+ *
1073
+ * z.object() strips unknown keys, so agent_export {loop: true, seamless: true}
1074
+ * answered success and exported exactly what it would have without them — the
1075
+ * caller believed they had a loop setting. create_item / modify_item report
1076
+ * unread PROPERTIES; this is the same idea one level up, for tools listed
1077
+ * here. Keyed to the zod schema the handler parses with, so a key the handler
1078
+ * accepts is never reported, and extended one tool at a time rather than
1079
+ * guessed for all 150.
1080
+ */
1081
+ const IGNORED_ARGUMENT_SCHEMAS = {
1082
+ pinepaper_agent_export: AgentExportInputSchema,
1083
+ // A shape property passed beside `properties` instead of inside it is the
1084
+ // common slip here (round 7 retest, 1.63).
1085
+ pinepaper_create_item: CreateItemInputSchema,
1086
+ pinepaper_modify_item: ModifyItemInputSchema,
1087
+ };
1088
+ const IGNORED_ARGUMENT_HINTS = {
1089
+ pinepaper_create_item: Object.fromEntries(['width', 'height', 'radius', 'color', 'fillColor', 'strokeColor', 'strokeWidth', 'content', 'fontSize', 'fontFamily', 'opacity', 'rotation', 'x', 'y']
1090
+ .map((k) => [k, 'Shape properties go INSIDE properties: {…}; only itemType, position, anchor, properties, animation*, keyframes and data are top-level.'])),
1091
+ pinepaper_modify_item: Object.fromEntries(['width', 'height', 'radius', 'color', 'fillColor', 'strokeColor', 'strokeWidth', 'content', 'fontSize', 'fontFamily', 'opacity', 'rotation', 'x', 'y', 'scale']
1092
+ .map((k) => [k, 'Changes go INSIDE properties: {…}; only itemId, properties and data are top-level.'])),
1093
+ pinepaper_agent_export: {
1094
+ loop: 'There is no loop switch on export: a loop is seamless when the animation is keyed to the SAME state at t = 0 and at t = duration (the export stops one frame before duration, so no frame is doubled at the wrap). GIF exports already loop forever.',
1095
+ seamless: 'A seamless loop is keyed, not a switch: key the animation to the SAME state at t = 0 and at t = duration and export exactly that duration (the export stops one frame before duration, so nothing is doubled at the wrap). A GIF\'s play count is the loop parameter.',
1096
+ },
1097
+ };
1098
+ export function ignoredArgumentsNote(toolName, args) {
1099
+ let schema = IGNORED_ARGUMENT_SCHEMAS[toolName];
1100
+ if (!schema || !args || typeof args !== 'object')
1101
+ return null;
1102
+ while (schema instanceof z.ZodEffects)
1103
+ schema = schema._def.schema;
1104
+ if (!(schema instanceof z.ZodObject))
1105
+ return null;
1106
+ const known = new Set(Object.keys(schema.shape));
1107
+ const ignored = Object.keys(args).filter((k) => !known.has(k));
1108
+ if (ignored.length === 0)
1109
+ return null;
1110
+ const hints = ignored.map((k) => IGNORED_ARGUMENT_HINTS[toolName]?.[k]).filter(Boolean);
1111
+ return `IGNORED ARGUMENTS: ${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not a parameter of ${toolName}, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect.`
1112
+ + (hints.length ? ` ${[...new Set(hints)].join(' ')}` : '');
1113
+ }
886
1114
  export async function handleToolCall(toolName, args, options = {}) {
887
1115
  const tracker = getPerformanceTracker();
888
1116
  const baseTimerId = `${toolName}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
889
1117
  const result = await handleToolCallInner(toolName, args, options, tracker, baseTimerId);
1118
+ const ignoredNote = ignoredArgumentsNote(toolName, args);
1119
+ if (ignoredNote)
1120
+ result.content = [...(result.content ?? []), { type: 'text', text: ignoredNote }];
890
1121
  // Record response payload size for token estimation
891
1122
  const responseBytes = measureResponseBytes(result);
892
1123
  tracker.recordMetric({
@@ -928,7 +1159,11 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
928
1159
  });
929
1160
  // Track code generation phase
930
1161
  tracker.startTimer(`${baseTimerId}_code_generation`);
931
- const code = codeGenerator.generateCreateItem(input);
1162
+ // Whether the CALLER gave a position is only knowable from the raw args:
1163
+ // the schema defaults position to (400, 300), so after the parse above
1164
+ // every call looks like it asked for one — and a pathData path was
1165
+ // moved to the default (1.81, retest: 26241cf's fix was bypassed here).
1166
+ const code = codeGenerator.generateCreateItem(args.position === undefined ? { ...input, position: undefined } : input);
932
1167
  const codeGenDuration = tracker.endTimer(`${baseTimerId}_code_generation`);
933
1168
  tracker.recordMetric({
934
1169
  toolName,
@@ -1270,8 +1505,27 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1270
1505
  }
1271
1506
  case 'pinepaper_media': {
1272
1507
  const input = MediaInputSchema.parse(args);
1273
- const code = codeGenerator.generateMedia(input);
1274
- return executeOrGenerate(code, `Media: ${input.action}`, options, 'pinepaper_media');
1508
+ // A LARGE data: URL GOES BESIDE THE CODE — the 1.9 fix, for uploads.
1509
+ // A ~288 KB clip returned an id; a ~2 MB one answered success with no
1510
+ // result, because the bytes were inlined into the generated code and
1511
+ // the governor's transform bails at that size, taking the return value
1512
+ // with it. Same threshold and route as import_image.
1513
+ const isUpload = input.action === 'upload_audio' || input.action === 'upload_video';
1514
+ let url = input.url;
1515
+ // Remote URLs and local paths are fetched/read HERE and arrive in the
1516
+ // page as bytes — see resolveMediaSource.
1517
+ if (isUpload && typeof url === 'string') {
1518
+ const resolved = await resolveMediaSource(url);
1519
+ if ('error' in resolved) {
1520
+ return errorResult(ErrorCodes.INVALID_PARAMS, resolved.error, { url }, { toolName: 'pinepaper_media' });
1521
+ }
1522
+ url = resolved.src;
1523
+ }
1524
+ const staged = isUpload && typeof url === 'string' && url.startsWith('data:') && url.length > 64_000;
1525
+ const stageKey = staged ? `media_${Date.now().toString(36)}` : undefined;
1526
+ const forCode = isUpload ? { ...input, url: staged ? `__ppStage:${stageKey}` : url } : input;
1527
+ const code = codeGenerator.generateMedia(forCode);
1528
+ return executeOrGenerate(code, `Media: ${input.action}`, options, 'pinepaper_media', staged ? { [stageKey]: url } : undefined);
1275
1529
  }
1276
1530
  case 'pinepaper_text_style': {
1277
1531
  // 'list' is an alias for 'list_styles'. pinepaper_text_effect spells
@@ -1334,6 +1588,142 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1334
1588
  const code = codeGenerator.generateAudioBeats(input);
1335
1589
  return executeOrGenerate(code, `Audio: ${input.action}`, options, 'pinepaper_audio_beats');
1336
1590
  }
1591
+ case 'pinepaper_accessibility_check': {
1592
+ const input = AccessibilityCheckInputSchema.parse(args);
1593
+ const code = codeGenerator.generateAccessibilityCheck(input);
1594
+ const mode = options.executionMode ?? getExecutionMode();
1595
+ if (mode === 'code' || !options.executeInBrowser) {
1596
+ return executeOrGenerate(code, 'Accessibility measurements (verdicts need the server: run with a live studio)', options, 'pinepaper_accessibility_check');
1597
+ }
1598
+ const controller = options.browserController || getBrowserController();
1599
+ if (!controller.connected)
1600
+ await controller.connect();
1601
+ const run = await controller.executeCode(code, false);
1602
+ const m = run.result;
1603
+ if (!run.success || !m)
1604
+ return errorResult(ErrorCodes.EXECUTION_ERROR, run.error || 'the studio returned no measurements');
1605
+ const report = {};
1606
+ const page = parseCssColor(m.page);
1607
+ if (m.texts) {
1608
+ const c = checkContrast(m.texts, page);
1609
+ report.contrast = { pass: c.failing.length === 0, checked: c.checked, failing: c.failing, skipped: c.skipped,
1610
+ method: 'WCAG 1.4.3 AA (4.5:1, 3:1 for large text) between each text fill and the solid fill of the topmost item under its centre, or the page background' + (c.pageAssumed ? ' (none set, so white was assumed)' : '') + '. Approximate: text over a photo or gradient is not judged.' };
1611
+ }
1612
+ if (m.flashUnavailable)
1613
+ report.flash = { checked: false, reason: m.flashUnavailable };
1614
+ else if (m.series) {
1615
+ const f = checkFlashes(m.series, m.fps);
1616
+ report.flash = { pass: f.failing.length === 0, maxFlashesPerSecond: f.maxFlashesPerSecond, failing: f.failing.map((x) => ({ ...x, region: ['top-left', 'top', 'top-right', 'left', 'centre', 'right', 'bottom-left', 'bottom', 'bottom-right'][x.region] })),
1617
+ frames: m.series.length, fps: m.fps,
1618
+ method: 'WCAG 2.3.1 general flash: opposing luminance changes of >= 10% with the darker state below 0.8, over a 3 x 3 grid; more than 3 flashes in any second fails. A heuristic screen, not a certified PSE test (red flashes are not checked).' };
1619
+ }
1620
+ const pass = Object.values(report).every((r) => r.pass !== false);
1621
+ return dataResult({ success: true, pass, ...report });
1622
+ }
1623
+ case 'pinepaper_render_batch': {
1624
+ const input = RenderBatchInputSchema.parse(args);
1625
+ const exportCheck = AgentExportInputSchema.safeParse(input.export);
1626
+ if (!exportCheck.success) {
1627
+ return errorResult(ErrorCodes.VALIDATION_ERROR, `export options are not valid agent_export options: ${exportCheck.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')}`);
1628
+ }
1629
+ const itemIds = [...new Set(input.rows.flatMap((r) => Object.keys(r.changes ?? {})))];
1630
+ if (input.estimateOnly) {
1631
+ // Item ids are checked against the live scene when there is one, so
1632
+ // a typo shows up in the plan rather than as a failed row.
1633
+ let missingItems;
1634
+ if ((options.executionMode ?? getExecutionMode()) !== 'code' && options.executeInBrowser && itemIds.length) {
1635
+ try {
1636
+ const controller = options.browserController || getBrowserController();
1637
+ if (!controller.connected)
1638
+ await controller.connect();
1639
+ const r = await controller.executeCode(`(function() { const ids = ${JSON.stringify(itemIds)}; return ids.filter(function(id) { const e = app.itemRegistry && app.itemRegistry.get(id); return !(e && e.item); }); })();`, false);
1640
+ if (r.success && Array.isArray(r.result))
1641
+ missingItems = r.result;
1642
+ }
1643
+ catch { /* unchecked: said below */ }
1644
+ }
1645
+ return dataResult({
1646
+ rows: input.rows.length, itemsChanged: itemIds, export: input.export,
1647
+ ...(missingItems ? { missingItems, ok: missingItems.length === 0 } : { itemsChecked: false }),
1648
+ note: 'nothing rendered. Each row runs modify_item per item, template_params when given, then agent_export.'
1649
+ + (missingItems && missingItems.length ? ` These items are not on the canvas, so rows naming them would fail: ${missingItems.join(', ')}.` : '')
1650
+ + (missingItems ? '' : ' Item ids were not checked (no live studio).'),
1651
+ });
1652
+ }
1653
+ if ((options.executionMode ?? getExecutionMode()) === 'code' || !options.executeInBrowser) {
1654
+ return errorResult(ErrorCodes.VALIDATION_ERROR, 'render_batch renders each row, so it needs a live studio (browser execution). In code-only mode, call modify_item and agent_export per row yourself.');
1655
+ }
1656
+ const rowResults = [];
1657
+ for (let i = 0; i < input.rows.length; i++) {
1658
+ const row = input.rows[i];
1659
+ const out = { index: i, ...(row.id ? { id: row.id } : {}) };
1660
+ const fail = (stage, r) => {
1661
+ out.success = false;
1662
+ out.stage = stage;
1663
+ out.error = toolText(r).slice(0, 600);
1664
+ };
1665
+ let failed = false;
1666
+ for (const [itemId, properties] of Object.entries(row.changes ?? {})) {
1667
+ const r = await handleToolCall('pinepaper_modify_item', { itemId, properties }, options);
1668
+ if (r.isError || /"success":\s*false/.test(toolText(r))) {
1669
+ fail(`modify ${itemId}`, r);
1670
+ failed = true;
1671
+ break;
1672
+ }
1673
+ const ignored = /"ignoredProperties":\s*(\[[^\]]*\])/.exec(toolText(r));
1674
+ if (ignored) {
1675
+ const bag = (out.ignored ?? {});
1676
+ bag[itemId] = JSON.parse(ignored[1]);
1677
+ out.ignored = bag;
1678
+ }
1679
+ }
1680
+ if (!failed && row.template) {
1681
+ const r = await handleToolCall('pinepaper_template_params', { action: 'apply', templateId: row.template.templateId, params: row.template.params }, options);
1682
+ if (r.isError || /"success":\s*false/.test(toolText(r))) {
1683
+ fail('template', r);
1684
+ failed = true;
1685
+ }
1686
+ }
1687
+ if (!failed) {
1688
+ // saveToFile: a small still would otherwise come back inline, and a
1689
+ // batch row must name a file.
1690
+ const r = await handleToolCall('pinepaper_agent_export', input.export, { ...options, saveToFile: true });
1691
+ const text = toolText(r);
1692
+ if (r.isError) {
1693
+ fail('export', r);
1694
+ }
1695
+ else {
1696
+ const files = [...text.matchAll(/^(?:File|zip|html|backupImage): (\S+)$/gm)].map((m) => m[1]);
1697
+ if (files.length) {
1698
+ out.success = true;
1699
+ out.files = files;
1700
+ }
1701
+ else {
1702
+ out.success = false;
1703
+ out.stage = 'export';
1704
+ out.error = 'the export reported success but wrote no file: ' + text.slice(0, 300);
1705
+ }
1706
+ const json = text.indexOf('Result: ') >= 0 ? text.slice(text.indexOf('Result: ') + 8) : null;
1707
+ try {
1708
+ const parsed = json ? JSON.parse(json) : null;
1709
+ const warnings = parsed?.fidelity?.warnings;
1710
+ if (Array.isArray(warnings) && warnings.length)
1711
+ out.warnings = warnings.map((w) => ({ code: w.code, message: w.message }));
1712
+ }
1713
+ catch { /* the file list stands on its own */ }
1714
+ }
1715
+ }
1716
+ rowResults.push(out);
1717
+ }
1718
+ const ok = rowResults.filter((r) => r.success).length;
1719
+ return dataResult({
1720
+ success: ok === rowResults.length,
1721
+ rendered: ok,
1722
+ failed: rowResults.length - ok,
1723
+ rows: rowResults,
1724
+ ...(rowResults.some((r) => r.warnings) ? { note: 'rows with warnings rendered, but read them — text_overflow means copy past its box.' } : {}),
1725
+ });
1726
+ }
1337
1727
  case 'pinepaper_template_params': {
1338
1728
  const input = TemplateParamsInputSchema.parse(args);
1339
1729
  const code = codeGenerator.generateTemplateParams(input);
@@ -2580,7 +2970,29 @@ You can now start creating new items on a clean canvas.`,
2580
2970
  return errorResult(ErrorCodes.EXECUTION_ERROR, streamError instanceof Error ? streamError.message : 'Paging the export out of the store failed', { code, exportId: exportResult.exportId, size: exportResult.size }, { toolName: 'pinepaper_agent_export', canvasState: canvasState || undefined });
2581
2971
  }
2582
2972
  }
2583
- const shouldSaveToFile = data && typeof data === 'string' && (ALWAYS_SAVE_FORMATS.has(format) ||
2973
+ // AN AD (8.21 / 8.24): the studio sent its widget page; the network
2974
+ // wrapper, the zip and the size check are done here.
2975
+ // SCC (CEA-608, 8.37): the studio sent the cues; encoded here.
2976
+ if (format === 'scc' && Array.isArray(exportResult?.sccCues)) {
2977
+ const scc = buildScc(exportResult.sccCues);
2978
+ const { filePath, fileSize } = await saveExportToFile(scc.text, 'scc', input.platform || 'auto');
2979
+ const warnings = [];
2980
+ if (scc.truncated)
2981
+ warnings.push({ code: 'caption_truncated', message: `${scc.truncated} caption(s) did not fit two rows of 32 characters and were cut. Split long captions into more cues.` });
2982
+ if (scc.replacedChars.length)
2983
+ warnings.push({ code: 'caption_chars_dropped', message: `characters outside the CEA-608 set were left out: ${scc.replacedChars.join(' ')}. 608 carries Latin letters, digits, common punctuation and a few accented letters.` });
2984
+ if (scc.delayed.length)
2985
+ warnings.push({ code: 'caption_delayed', message: `${scc.delayed.length} caption(s) went on late — 608 carries two bytes a frame, so a caption needs time to load before it shows: ${scc.delayed.slice(0, 5).map((d) => `cue ${d.cue + 1} by ${d.frames} frame(s)`).join(', ')}. Leave more time between captions.` });
2986
+ const { sccCues: _c, ...rest } = exportResult;
2987
+ void _c;
2988
+ const cleanResult = { ...rest, filePath, fileSize, frameRate: '29.97 drop-frame', fidelity: { warnings } };
2989
+ return { content: [{ type: 'text', text: `Export saved to file:\n\nFile: ${filePath}\nFormat: scc (CEA-608, CC1, pop-on)\nSize: ${fileSize} bytes\n\nResult: ${JSON.stringify(cleanResult, null, 2)}` }] };
2990
+ }
2991
+ if ((format === 'html5-ad' || format === 'playable') && typeof exportResult?.adPage === 'string') {
2992
+ return saveAdExport(exportResult, input, input.platform || 'auto');
2993
+ }
2994
+ const shouldSaveToFile = data && typeof data === 'string' && (options.saveToFile ||
2995
+ ALWAYS_SAVE_FORMATS.has(format) ||
2584
2996
  data.length > SAVE_THRESHOLD_BYTES);
2585
2997
  if (shouldSaveToFile) {
2586
2998
  try {