@pinepaper.studio/mcp-server 1.6.12 → 1.6.14
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.de.md +1 -1
- package/README.es.md +1 -1
- package/README.fr.md +1 -1
- package/README.hi.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +341 -4
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/agent/export-manager.d.ts.map +1 -1
- package/dist/agent/export-manager.js +9 -7
- package/dist/agent/export-manager.js.map +1 -1
- package/dist/assets/repositories/iconify.d.ts +0 -5
- package/dist/assets/repositories/iconify.d.ts.map +1 -1
- package/dist/assets/repositories/iconify.js +17 -5
- package/dist/assets/repositories/iconify.js.map +1 -1
- package/dist/browser/puppeteer-controller.d.ts +16 -0
- package/dist/browser/puppeteer-controller.d.ts.map +1 -1
- package/dist/browser/puppeteer-controller.js +32 -2
- package/dist/browser/puppeteer-controller.js.map +1 -1
- package/dist/build-info.json +5 -0
- package/dist/design/design-systems.d.ts +27 -2
- package/dist/design/design-systems.d.ts.map +1 -1
- package/dist/design/design-systems.js +20 -0
- package/dist/design/design-systems.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/ontology/upstream-vocabulary.d.ts +23 -0
- package/dist/ontology/upstream-vocabulary.d.ts.map +1 -0
- package/dist/ontology/upstream-vocabulary.js +1172 -0
- package/dist/ontology/upstream-vocabulary.js.map +1 -0
- package/dist/ontology/vocabulary.d.ts.map +1 -1
- package/dist/ontology/vocabulary.js +19 -0
- package/dist/ontology/vocabulary.js.map +1 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +299 -75
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/engine-methods.d.ts.map +1 -1
- package/dist/tools/engine-methods.js +28 -2
- package/dist/tools/engine-methods.js.map +1 -1
- package/dist/tools/engine-surface.d.ts +1 -1
- package/dist/tools/engine-surface.d.ts.map +1 -1
- package/dist/tools/engine-surface.js +18 -12
- package/dist/tools/engine-surface.js.map +1 -1
- package/dist/tools/handlers/camera.d.ts.map +1 -1
- package/dist/tools/handlers/camera.js +17 -2
- package/dist/tools/handlers/camera.js.map +1 -1
- package/dist/tools/handlers/font.d.ts.map +1 -1
- package/dist/tools/handlers/font.js +48 -3
- package/dist/tools/handlers/font.js.map +1 -1
- package/dist/tools/handlers/maps.d.ts.map +1 -1
- package/dist/tools/handlers/maps.js +16 -0
- package/dist/tools/handlers/maps.js.map +1 -1
- package/dist/tools/handlers.d.ts +24 -3
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/tools/handlers.js +645 -27
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/minimal-descriptions.d.ts.map +1 -1
- package/dist/tools/minimal-descriptions.js +2 -0
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/shape-params.d.ts +13 -2
- package/dist/tools/shape-params.d.ts.map +1 -1
- package/dist/tools/shape-params.js +80 -2
- package/dist/tools/shape-params.js.map +1 -1
- package/dist/tools/stick-vocabulary.d.ts +1 -1
- package/dist/tools/stick-vocabulary.js +1 -1
- package/dist/tools/toolkits.d.ts.map +1 -1
- package/dist/tools/toolkits.js +12 -1
- package/dist/tools/toolkits.js.map +1 -1
- package/dist/types/code-generator.d.ts +27 -1
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +2871 -316
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +1595 -271
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +269 -31
- package/dist/types/schemas.js.map +1 -1
- package/dist/utils/a11y.d.ts +67 -0
- package/dist/utils/a11y.d.ts.map +1 -0
- package/dist/utils/a11y.js +117 -0
- package/dist/utils/a11y.js.map +1 -0
- package/dist/utils/ad-package.d.ts +44 -0
- package/dist/utils/ad-package.d.ts.map +1 -0
- package/dist/utils/ad-package.js +121 -0
- package/dist/utils/ad-package.js.map +1 -0
- package/dist/utils/scc.d.ts +41 -0
- package/dist/utils/scc.d.ts.map +1 -0
- package/dist/utils/scc.js +185 -0
- package/dist/utils/scc.js.map +1 -0
- package/dist/utils/zip.d.ts +7 -0
- package/dist/utils/zip.d.ts.map +1 -0
- package/dist/utils/zip.js +82 -0
- package/dist/utils/zip.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +31 -1
- package/dist/version.js.map +1 -1
- package/manifest.json +9 -1
- package/package.json +6 -4
package/dist/tools/handlers.js
CHANGED
|
@@ -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,8 +87,14 @@ 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']);
|
|
87
|
-
|
|
90
|
+
export const ALWAYS_SAVE_FORMATS = new Set(['mp4', 'webm', 'gif', 'apng', 'pdf', 'wav', 'srt', 'vtt', 'scc', 'html5-ad', 'playable']);
|
|
91
|
+
// 500_000 was chosen against the bridge's limits, not the CALLER's. A pilot
|
|
92
|
+
// session hit a 263K-character end_job result — comfortably under this, so it
|
|
93
|
+
// was returned inline, and over the tool-result limit of the client reading
|
|
94
|
+
// it, so the agent could not read its own verification step. A value that
|
|
95
|
+
// lets a result through to be rejected downstream is the wrong value; saving
|
|
96
|
+
// to a file and naming the path costs one line of text and always works.
|
|
97
|
+
const SAVE_THRESHOLD_BYTES = 180_000; // ~180KB base64 ≈ 135KB decoded
|
|
88
98
|
/**
|
|
89
99
|
* Governor budget for the export run itself.
|
|
90
100
|
*
|
|
@@ -131,10 +141,82 @@ const IMAGE_MIME = {
|
|
|
131
141
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
132
142
|
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.avif': 'image/avif',
|
|
133
143
|
};
|
|
144
|
+
/**
|
|
145
|
+
* THE PAGE CANNOT FETCH A THIRD-PARTY IMAGE. THIS PROCESS CAN.
|
|
146
|
+
*
|
|
147
|
+
* pinepaper.studio serves `connect-src 'self' https://cloud.pinepaper.studio`,
|
|
148
|
+
* so an in-page fetch() of ANY other host fails outright. `img-src` still
|
|
149
|
+
* allows https:, which is why the old path could LOAD a remote image — and
|
|
150
|
+
* tainted the canvas doing it, killing every later export. The taint fix
|
|
151
|
+
* replaced that load with a fetch, which is safe and, under this CSP, can
|
|
152
|
+
* never succeed: import by URL stopped being broken-and-dangerous and became
|
|
153
|
+
* simply impossible.
|
|
154
|
+
*
|
|
155
|
+
* Node has no CSP. Fetching here and handing the page a data: URL is the only
|
|
156
|
+
* shape that both works and cannot taint, so a URL is resolved BEFORE it
|
|
157
|
+
* reaches the browser. Verified end to end by the session that found it: a
|
|
158
|
+
* 157KB data: URL imports and exports cleanly.
|
|
159
|
+
*/
|
|
160
|
+
const MAX_REMOTE_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
161
|
+
export async function fetchImageAsDataUrl(url) {
|
|
162
|
+
let res;
|
|
163
|
+
try {
|
|
164
|
+
res = await fetch(url, { redirect: 'follow' });
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
const why = e instanceof Error ? e.message : 'network request failed';
|
|
168
|
+
return { error: `could not reach ${url} — ${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.` };
|
|
169
|
+
}
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
return { error: `the server refused ${url} — HTTP ${res.status} ${res.statusText || ''}`.trim() };
|
|
172
|
+
}
|
|
173
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
174
|
+
if (!type.startsWith('image/')) {
|
|
175
|
+
return { error: `${url} served "${type || 'no content-type'}", not an image. A URL that returns an HTML page around the image will do this — link the file itself.` };
|
|
176
|
+
}
|
|
177
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
178
|
+
if (buf.length > MAX_REMOTE_IMAGE_BYTES) {
|
|
179
|
+
return { error: `${url} is ${(buf.length / 1024 / 1024).toFixed(1)} MB, above the ${MAX_REMOTE_IMAGE_BYTES / 1024 / 1024} MB import cap.` };
|
|
180
|
+
}
|
|
181
|
+
return { src: `data:${type};base64,${buf.toString('base64')}` };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Resolve an SVG to a string with every remote <image> already inlined.
|
|
185
|
+
*
|
|
186
|
+
* The emitted code does this in the page as a backstop, and under
|
|
187
|
+
* pinepaper.studio's CSP that backstop can only ever DROP images — the page
|
|
188
|
+
* may not fetch a third-party host. Doing it here means the page receives a
|
|
189
|
+
* document with no remote references at all: nothing to fetch, nothing to
|
|
190
|
+
* taint, and the pictures actually arrive.
|
|
191
|
+
*
|
|
192
|
+
* An href that cannot be fetched is still removed rather than left in, on the
|
|
193
|
+
* same reasoning as the in-page pass: a missing picture is visible and
|
|
194
|
+
* recoverable, a tainted canvas is neither.
|
|
195
|
+
*/
|
|
196
|
+
async function inlineSvgImages(svg) {
|
|
197
|
+
const warnings = [];
|
|
198
|
+
const hrefs = [...new Set([...svg.matchAll(/(?:xlink:)?href\s*=\s*("|')(https?:\/\/[^"']+)\1/gi)].map((m) => m[2]))];
|
|
199
|
+
let out = svg;
|
|
200
|
+
for (const href of hrefs) {
|
|
201
|
+
const got = await fetchImageAsDataUrl(href);
|
|
202
|
+
if ('src' in got) {
|
|
203
|
+
out = out.split(href).join(got.src);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const esc = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
207
|
+
out = out
|
|
208
|
+
.replace(new RegExp(`<image\\b[^>]*${esc}[^>]*\\/?>`, 'gi'), '')
|
|
209
|
+
.replace(new RegExp(`<image\\b[^>]*${esc}[\\s\\S]*?<\\/image>`, 'gi'), '');
|
|
210
|
+
warnings.push({ url: href, action: 'removed', reason: 'unfetchable', message: got.error });
|
|
211
|
+
}
|
|
212
|
+
return { svg: out, warnings };
|
|
213
|
+
}
|
|
134
214
|
export async function resolveImageSource(input) {
|
|
135
215
|
const raw = input.trim();
|
|
136
|
-
if (/^
|
|
216
|
+
if (/^data:/i.test(raw))
|
|
137
217
|
return { src: raw };
|
|
218
|
+
if (/^https?:/i.test(raw))
|
|
219
|
+
return fetchImageAsDataUrl(raw);
|
|
138
220
|
const path = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
|
|
139
221
|
const ext = extname(path).toLowerCase();
|
|
140
222
|
if (!IMAGE_EXTENSIONS.has(ext)) {
|
|
@@ -159,10 +241,150 @@ export async function resolveImageSource(input) {
|
|
|
159
241
|
}
|
|
160
242
|
return { src: `data:${IMAGE_MIME[ext] ?? 'application/octet-stream'};base64,${bytes.toString('base64')}` };
|
|
161
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
|
+
}
|
|
162
316
|
export function getFileExtension(format) {
|
|
163
|
-
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' };
|
|
164
318
|
return extMap[format] || format;
|
|
165
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
|
+
}
|
|
166
388
|
async function saveExportToFile(data, format, platform) {
|
|
167
389
|
const exportDir = getExportDir();
|
|
168
390
|
await mkdir(exportDir, { recursive: true });
|
|
@@ -416,7 +638,44 @@ function screenshotResult(screenshot) {
|
|
|
416
638
|
],
|
|
417
639
|
};
|
|
418
640
|
}
|
|
419
|
-
|
|
641
|
+
/**
|
|
642
|
+
* Swap a `code` field carrying the emitted script for a description of it.
|
|
643
|
+
*
|
|
644
|
+
* Only a long string is touched: a short `code` is usually a status or an
|
|
645
|
+
* error code, not a program, and stripping those would take information away.
|
|
646
|
+
*/
|
|
647
|
+
function redactGeneratedCode(details) {
|
|
648
|
+
if (process.env.PINEPAPER_ECHO_CODE === '1')
|
|
649
|
+
return details;
|
|
650
|
+
if (!details || typeof details !== 'object' || Array.isArray(details))
|
|
651
|
+
return details;
|
|
652
|
+
const d = details;
|
|
653
|
+
const script = d.code;
|
|
654
|
+
if (typeof script !== 'string' || script.length < 400)
|
|
655
|
+
return details;
|
|
656
|
+
const { code: _dropped, ...rest } = d;
|
|
657
|
+
return {
|
|
658
|
+
...rest,
|
|
659
|
+
generatedCode: {
|
|
660
|
+
bytes: script.length,
|
|
661
|
+
lines: script.split('\n').length,
|
|
662
|
+
firstLine: script.split('\n').find((l) => l.trim()) ?? '',
|
|
663
|
+
hint: 'omitted — set PINEPAPER_ECHO_CODE=1 to return the generated script with the error',
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
export function errorResult(code, message, rawDetails, context) {
|
|
668
|
+
// THE GENERATED SCRIPT IS NOT THE ERROR.
|
|
669
|
+
//
|
|
670
|
+
// Most error paths pass the emitted code in `details` as evidence, and for a
|
|
671
|
+
// long script that is ~12KB of JavaScript in front of a one-line failure —
|
|
672
|
+
// measured from a pilot session. An agent reading the result pays for all of
|
|
673
|
+
// it and learns nothing the message does not already say, and on a big
|
|
674
|
+
// export it can crowd out the result entirely.
|
|
675
|
+
//
|
|
676
|
+
// Replaced with its shape and a way to get it back. PINEPAPER_ECHO_CODE=1
|
|
677
|
+
// returns the script in full, the same switch that governs it on success.
|
|
678
|
+
const details = redactGeneratedCode(rawDetails);
|
|
420
679
|
// Build error object
|
|
421
680
|
const errorObj = {
|
|
422
681
|
success: false,
|
|
@@ -470,7 +729,13 @@ function handleValidationError(error, i18n) {
|
|
|
470
729
|
*
|
|
471
730
|
* Default is 'on_request' for optimal performance per best practices.
|
|
472
731
|
*/
|
|
473
|
-
export async function executeOrGenerate(code, description, options, toolName
|
|
732
|
+
export async function executeOrGenerate(code, description, options, toolName,
|
|
733
|
+
/**
|
|
734
|
+
* Values too large to live inside the generated code. Written to
|
|
735
|
+
* window.__ppStage before the run and read back by key — see
|
|
736
|
+
* ExecuteCodeOptions.stage for why inlining them breaks the governor.
|
|
737
|
+
*/
|
|
738
|
+
stage) {
|
|
474
739
|
const { executeInBrowser, browserController, screenshotMode, executionMode } = options;
|
|
475
740
|
const effectiveExecutionMode = executionMode ?? getExecutionMode();
|
|
476
741
|
const effectiveScreenshotMode = screenshotMode ?? getScreenshotMode();
|
|
@@ -604,7 +869,7 @@ ${code}
|
|
|
604
869
|
if (shouldTakeScreenshot) {
|
|
605
870
|
tracker.startTimer(`${timerId}_screenshot`);
|
|
606
871
|
}
|
|
607
|
-
const result = await controller.executeCode(code, shouldTakeScreenshot);
|
|
872
|
+
const result = await controller.executeCode(code, shouldTakeScreenshot, stage ? { stage } : {});
|
|
608
873
|
const browserDuration = tracker.endTimer(`${timerId}_browser_execution`);
|
|
609
874
|
tracker.recordMetric({
|
|
610
875
|
toolName,
|
|
@@ -672,19 +937,59 @@ ${code}
|
|
|
672
937
|
* not a failure, because plenty of emitters return a bare value (an id, a
|
|
673
938
|
* count, a list) and reading absence as refusal would turn every one of those
|
|
674
939
|
* into an error. `ok: false` is included because the export-store emitters use
|
|
675
|
-
* 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.
|
|
676
943
|
*/
|
|
677
944
|
export function innerFailure(value) {
|
|
678
945
|
if (!value || typeof value !== 'object')
|
|
679
946
|
return null;
|
|
680
947
|
const r = value;
|
|
681
|
-
|
|
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));
|
|
682
971
|
if (!failed)
|
|
683
972
|
return null;
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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';
|
|
688
993
|
}
|
|
689
994
|
// =============================================================================
|
|
690
995
|
// I18N HELPER FUNCTIONS
|
|
@@ -762,10 +1067,57 @@ function measureResponseBytes(result) {
|
|
|
762
1067
|
}
|
|
763
1068
|
return bytes;
|
|
764
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
|
+
}
|
|
765
1114
|
export async function handleToolCall(toolName, args, options = {}) {
|
|
766
1115
|
const tracker = getPerformanceTracker();
|
|
767
1116
|
const baseTimerId = `${toolName}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
768
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 }];
|
|
769
1121
|
// Record response payload size for token estimation
|
|
770
1122
|
const responseBytes = measureResponseBytes(result);
|
|
771
1123
|
tracker.recordMetric({
|
|
@@ -807,7 +1159,11 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
|
|
|
807
1159
|
});
|
|
808
1160
|
// Track code generation phase
|
|
809
1161
|
tracker.startTimer(`${baseTimerId}_code_generation`);
|
|
810
|
-
|
|
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);
|
|
811
1167
|
const codeGenDuration = tracker.endTimer(`${baseTimerId}_code_generation`);
|
|
812
1168
|
tracker.recordMetric({
|
|
813
1169
|
toolName,
|
|
@@ -963,6 +1319,11 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
|
|
|
963
1319
|
}
|
|
964
1320
|
case 'pinepaper_query_capabilities': {
|
|
965
1321
|
const input = QueryCapabilitiesInputSchema.parse(args);
|
|
1322
|
+
// `query` is what callers reach for on an action named 'find'; `key`
|
|
1323
|
+
// is what the emitter reads. Accepted as an alias rather than left as
|
|
1324
|
+
// a validation dead end.
|
|
1325
|
+
if (!input.key && input.query)
|
|
1326
|
+
input.key = input.query;
|
|
966
1327
|
const code = codeGenerator.generateQueryCapabilities(input);
|
|
967
1328
|
return executeOrGenerate(code, `Capabilities: ${input.action || 'list'}`, options, 'pinepaper_query_capabilities');
|
|
968
1329
|
}
|
|
@@ -1144,11 +1505,35 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
|
|
|
1144
1505
|
}
|
|
1145
1506
|
case 'pinepaper_media': {
|
|
1146
1507
|
const input = MediaInputSchema.parse(args);
|
|
1147
|
-
|
|
1148
|
-
|
|
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);
|
|
1149
1529
|
}
|
|
1150
1530
|
case 'pinepaper_text_style': {
|
|
1531
|
+
// 'list' is an alias for 'list_styles'. pinepaper_text_effect spells
|
|
1532
|
+
// the same action 'list', so an agent moving between the two guesses
|
|
1533
|
+
// wrong — and a rejected enum value is a dead end rather than a hint.
|
|
1151
1534
|
const input = TextStyleInputSchema.parse(args);
|
|
1535
|
+
if (input.action === 'list')
|
|
1536
|
+
input.action = 'list_styles';
|
|
1152
1537
|
const code = codeGenerator.generateTextStyle(input);
|
|
1153
1538
|
return executeOrGenerate(code, `Text style: ${input.action}`, options, 'pinepaper_text_style');
|
|
1154
1539
|
}
|
|
@@ -1203,6 +1588,142 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
|
|
|
1203
1588
|
const code = codeGenerator.generateAudioBeats(input);
|
|
1204
1589
|
return executeOrGenerate(code, `Audio: ${input.action}`, options, 'pinepaper_audio_beats');
|
|
1205
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
|
+
}
|
|
1206
1727
|
case 'pinepaper_template_params': {
|
|
1207
1728
|
const input = TemplateParamsInputSchema.parse(args);
|
|
1208
1729
|
const code = codeGenerator.generateTemplateParams(input);
|
|
@@ -1468,8 +1989,39 @@ You can now start creating new items on a clean canvas.`,
|
|
|
1468
1989
|
// -----------------------------------------------------------------------
|
|
1469
1990
|
case 'pinepaper_import_svg': {
|
|
1470
1991
|
const input = ImportSVGInputSchema.parse(args);
|
|
1471
|
-
|
|
1472
|
-
|
|
1992
|
+
// Resolve the document HERE, so the page never has to fetch anything.
|
|
1993
|
+
//
|
|
1994
|
+
// Under pinepaper.studio's CSP (connect-src 'self' + the cloud origin)
|
|
1995
|
+
// the page cannot fetch a third-party host at all, so the in-page
|
|
1996
|
+
// inlining could only ever DROP remote images. Fetching the SVG and
|
|
1997
|
+
// its images in this process means the browser receives a document
|
|
1998
|
+
// with no remote references: nothing to fetch, nothing to taint, and
|
|
1999
|
+
// the pictures arrive. The in-page pass stays as a backstop for an
|
|
2000
|
+
// svgString handed straight to the generator elsewhere.
|
|
2001
|
+
let svgText = input.svgString;
|
|
2002
|
+
let svgWarnings = [];
|
|
2003
|
+
if (input.url) {
|
|
2004
|
+
try {
|
|
2005
|
+
const res = await fetch(input.url, { redirect: 'follow' });
|
|
2006
|
+
if (!res.ok) {
|
|
2007
|
+
return errorResult(ErrorCodes.EXECUTION_ERROR, `the server refused ${input.url} — HTTP ${res.status} ${res.statusText || ''}`.trim());
|
|
2008
|
+
}
|
|
2009
|
+
svgText = await res.text();
|
|
2010
|
+
}
|
|
2011
|
+
catch (e) {
|
|
2012
|
+
const why = e instanceof Error ? e.message : 'network request failed';
|
|
2013
|
+
return errorResult(ErrorCodes.EXECUTION_ERROR, `could not reach ${input.url} — ${why}. This fetch runs in the MCP server rather than the page, so it is not a CSP problem.`);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
if (svgText && /(?:xlink:)?href\s*=\s*["']https?:/i.test(svgText)) {
|
|
2017
|
+
const inlined = await inlineSvgImages(svgText);
|
|
2018
|
+
svgText = inlined.svg;
|
|
2019
|
+
svgWarnings = inlined.warnings;
|
|
2020
|
+
}
|
|
2021
|
+
const code = codeGenerator.generateImportSVG(svgText, svgText ? undefined : input.url, input.position, input.scale, input.source);
|
|
2022
|
+
const description = svgWarnings.length
|
|
2023
|
+
? `Imports SVG onto the canvas (${svgWarnings.length} unreachable image(s) removed)`
|
|
2024
|
+
: 'Imports SVG onto the canvas';
|
|
1473
2025
|
return executeOrGenerate(code, description, options, 'pinepaper_import_svg');
|
|
1474
2026
|
}
|
|
1475
2027
|
case 'pinepaper_import_mermaid': {
|
|
@@ -1987,9 +2539,34 @@ You can now start creating new items on a clean canvas.`,
|
|
|
1987
2539
|
metadata = result.metadata;
|
|
1988
2540
|
}
|
|
1989
2541
|
else if (input.url) {
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
1992
|
-
|
|
2542
|
+
// IMPLEMENTED NOW, rather than redirected.
|
|
2543
|
+
//
|
|
2544
|
+
// This used to refuse with "URL imports not yet implemented" while
|
|
2545
|
+
// the agent guide documented the url parameter — the redirect
|
|
2546
|
+
// named a working alternative, which made it a good refusal, but
|
|
2547
|
+
// it was still a documented parameter that did nothing. The reason
|
|
2548
|
+
// it could not be done here was that fetching belonged to the
|
|
2549
|
+
// page; it does not any more, because the page cannot fetch a
|
|
2550
|
+
// third-party host under the studio's CSP and this process can.
|
|
2551
|
+
let res;
|
|
2552
|
+
try {
|
|
2553
|
+
res = await fetch(input.url, { redirect: 'follow' });
|
|
2554
|
+
}
|
|
2555
|
+
catch (e) {
|
|
2556
|
+
const why = e instanceof Error ? e.message : 'network request failed';
|
|
2557
|
+
return errorResult(ErrorCodes.EXECUTION_ERROR, `could not reach ${input.url} — ${why}. This fetch runs in the MCP server rather than the page, so it is not a CSP problem.`);
|
|
2558
|
+
}
|
|
2559
|
+
if (!res.ok) {
|
|
2560
|
+
return errorResult(ErrorCodes.EXECUTION_ERROR, `the server refused ${input.url} — HTTP ${res.status} ${res.statusText || ''}`.trim());
|
|
2561
|
+
}
|
|
2562
|
+
const body = await res.text();
|
|
2563
|
+
if (!/<svg[\s>]/i.test(body)) {
|
|
2564
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim();
|
|
2565
|
+
return errorResult(ErrorCodes.INVALID_PARAMS, `${input.url} served "${type || 'no content-type'}" and its body is not SVG. `
|
|
2566
|
+
+ 'For a raster image use pinepaper_import_image, which accepts a url and inlines it.');
|
|
2567
|
+
}
|
|
2568
|
+
svg = body;
|
|
2569
|
+
metadata = { source: 'url', url: input.url };
|
|
1993
2570
|
}
|
|
1994
2571
|
// Import the SVG onto canvas using existing import_svg tool
|
|
1995
2572
|
const code = codeGenerator.generateImportSVG(svg, undefined, input.position, input.scale);
|
|
@@ -2131,7 +2708,12 @@ You can now start creating new items on a clean canvas.`,
|
|
|
2131
2708
|
const previewScreenshot = await endJobController.takeScreenshot();
|
|
2132
2709
|
const content = [{
|
|
2133
2710
|
type: 'text',
|
|
2134
|
-
|
|
2711
|
+
// The script is not part of the answer here either — same reason
|
|
2712
|
+
// errors no longer carry it. PINEPAPER_ECHO_CODE=1 brings it back.
|
|
2713
|
+
text: (process.env.PINEPAPER_ECHO_CODE === '1'
|
|
2714
|
+
? `Executed PinePaper code:\n\n\`\`\`javascript\n${code}\n\`\`\`\n\n`
|
|
2715
|
+
: '')
|
|
2716
|
+
+ `Full screenshot saved to: ${filePath} (${(fileSize / 1024).toFixed(1)} KB)\n\nResult: ${JSON.stringify(cleanResult, null, 2)}`,
|
|
2135
2717
|
}];
|
|
2136
2718
|
if (previewScreenshot) {
|
|
2137
2719
|
content.push({
|
|
@@ -2388,7 +2970,29 @@ You can now start creating new items on a clean canvas.`,
|
|
|
2388
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 });
|
|
2389
2971
|
}
|
|
2390
2972
|
}
|
|
2391
|
-
|
|
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) ||
|
|
2392
2996
|
data.length > SAVE_THRESHOLD_BYTES);
|
|
2393
2997
|
if (shouldSaveToFile) {
|
|
2394
2998
|
try {
|
|
@@ -2638,8 +3242,22 @@ You can now start creating new items on a clean canvas.`,
|
|
|
2638
3242
|
if ('error' in resolved) {
|
|
2639
3243
|
return errorResult(ErrorCodes.INVALID_PARAMS, resolved.error, { url: parsed.url }, { toolName: 'pinepaper_import_image' });
|
|
2640
3244
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
3245
|
+
// THE BYTES GO BESIDE THE CODE, NOT INSIDE IT.
|
|
3246
|
+
//
|
|
3247
|
+
// Inlining the data: URL turned a 3000x800 photo into ~630KB of
|
|
3248
|
+
// generated JavaScript. app.runGenerated rewrites what it runs, and its
|
|
3249
|
+
// loop-guard transform bails at that size — "Loop-guard transform
|
|
3250
|
+
// bailed (unusual syntax)" — after which the run reported
|
|
3251
|
+
// itemsCreated: 0 and lost the return value, so the import answered
|
|
3252
|
+
// success with no itemId. Smaller photos were fine, which is exactly
|
|
3253
|
+
// what made it look like a registration bug rather than a size one.
|
|
3254
|
+
const staged = resolved.src.startsWith('data:') && resolved.src.length > 64_000;
|
|
3255
|
+
const stageKey = staged ? `img_${Date.now().toString(36)}` : undefined;
|
|
3256
|
+
const code = codeGenerator.generateImportImage({
|
|
3257
|
+
...parsed,
|
|
3258
|
+
url: staged ? `__ppStage:${stageKey}` : resolved.src,
|
|
3259
|
+
});
|
|
3260
|
+
return executeOrGenerate(code, 'Imports image', options, 'pinepaper_import_image', staged ? { [stageKey]: resolved.src } : undefined);
|
|
2643
3261
|
}
|
|
2644
3262
|
// -----------------------------------------------------------------------
|
|
2645
3263
|
// ONTOLOGY TOOLS — extracted to src/tools/handlers/ontology.ts
|