@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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* This code is designed to run in the browser context where window.PinePaper is available.
|
|
6
6
|
*/
|
|
7
7
|
import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
|
|
8
|
+
import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
|
|
8
9
|
import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
|
|
9
10
|
import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
10
11
|
// Diagram types
|
|
@@ -33,6 +34,38 @@ import { OntologyCompiler } from '../ontology/ontology-compiler.js';
|
|
|
33
34
|
function isGradient(color) {
|
|
34
35
|
return typeof color === 'object' && color !== null && 'type' in color && 'stops' in color;
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A colour OBJECT is a gradient spec, checked before it reaches the engine.
|
|
39
|
+
*
|
|
40
|
+
* {gradient: true, stops: ['#a', '#b']} — no type, bare-string stops, invalid
|
|
41
|
+
* hex — went to the engine as a plain colour and drew NOTHING, with success
|
|
42
|
+
* (round 9 ads, 1.82). The shape the tools document is {type: 'linear' |
|
|
43
|
+
* 'radial', stops: [{color, offset}], origin?, destination?}. Near-misses are
|
|
44
|
+
* normalised (a missing type is linear; bare stops get even offsets); what
|
|
45
|
+
* cannot be read is refused by name, with the shape.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeGradientSpec(v, key) {
|
|
48
|
+
const o = v;
|
|
49
|
+
const rawStops = Array.isArray(o.stops) ? o.stops : null;
|
|
50
|
+
const shape = `${key} as a gradient is {type: 'linear' | 'radial', stops: [{color: '#rrggbb', offset: 0..1}, …], origin?: [x, y], destination?: [x, y]}.`;
|
|
51
|
+
if (!rawStops || rawStops.length < 2)
|
|
52
|
+
return { error: `${key} is an object but not a gradient with at least 2 stops, so nothing would be drawn. ${shape}` };
|
|
53
|
+
const hexOk = (c) => !c.startsWith('#') || /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c);
|
|
54
|
+
const n = rawStops.length;
|
|
55
|
+
const stops = [];
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const st = rawStops[i];
|
|
58
|
+
const color = typeof st === 'string' ? st : (st && typeof st.color === 'string' ? st.color : null);
|
|
59
|
+
const offset = typeof st === 'string' ? i / (n - 1) : Number(st.offset ?? i / (n - 1));
|
|
60
|
+
if (!color || !hexOk(color.trim()))
|
|
61
|
+
return { error: `${key} stop ${i} has colour ${JSON.stringify(color)}, which is not a colour (a hex needs 3, 4, 6 or 8 digits). ${shape}` };
|
|
62
|
+
if (!(offset >= 0 && offset <= 1))
|
|
63
|
+
return { error: `${key} stop ${i} has offset ${JSON.stringify(st.offset)}; offsets run 0 to 1. ${shape}` };
|
|
64
|
+
stops.push({ color: color.trim(), offset });
|
|
65
|
+
}
|
|
66
|
+
const type = o.type === 'radial' ? 'radial' : 'linear';
|
|
67
|
+
return { gradient: { ...o, type, stops } };
|
|
68
|
+
}
|
|
36
69
|
/**
|
|
37
70
|
* Generate Paper.js gradient code for a fill color
|
|
38
71
|
*/
|
|
@@ -80,19 +113,79 @@ function generateBlendModeCode(itemVarName, blendMode) {
|
|
|
80
113
|
/**
|
|
81
114
|
* Template for creating items with support for gradients, shadows, and blend modes
|
|
82
115
|
*/
|
|
83
|
-
function generateCreateItemCode(itemType, position, properties) {
|
|
116
|
+
function generateCreateItemCode(itemType, position, properties, dataFlags, positionGiven = true) {
|
|
117
|
+
// Colour objects are gradient specs: normalised, or refused by name.
|
|
118
|
+
for (const key of ['color', 'fillColor', 'strokeColor']) {
|
|
119
|
+
const v = properties[key];
|
|
120
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
121
|
+
const g = normalizeGradientSpec(v, key);
|
|
122
|
+
if ('error' in g) {
|
|
123
|
+
return `
|
|
124
|
+
// Create ${itemType} — refused: an unreadable gradient would draw nothing
|
|
125
|
+
({ success: false, error: ${JSON.stringify(g.error)} });`.trim();
|
|
126
|
+
}
|
|
127
|
+
properties = { ...properties, [key]: g.gradient };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
84
130
|
// Extract special properties that need separate handling
|
|
85
131
|
const { color, fillColor, strokeColor, shadowColor, shadowBlur, shadowOffset, blendMode, opacity, contentType, contentFormat, countdownTarget, countdownEndText, ...baseProperties } = properties;
|
|
132
|
+
// A PATH KEEPS ITS OWN COORDINATES WHEN NO POSITION WAS ASKED FOR (round 9
|
|
133
|
+
// HH, 1.81). The schema defaults position to (400, 300), and the engine
|
|
134
|
+
// places pathData AT x / y (so origin-centred data can be positioned) — so a
|
|
135
|
+
// pathData path authored in canvas coordinates was moved to the default,
|
|
136
|
+
// off by (+99.5, -170). The segments form was untouched only by accident of
|
|
137
|
+
// the engine's branch. With no position from the caller, a coordinate-built
|
|
138
|
+
// item (path from pathData / segments, line, arc) gets no x / y at all.
|
|
139
|
+
const omitXY = !positionGiven && isCoordinateBuilt(itemType, properties);
|
|
140
|
+
// `fit` is applied through app.fitText after creation (below), not sent to
|
|
141
|
+
// create(), so a studio without it cannot half-read it.
|
|
142
|
+
const textFit = itemType === 'text' && baseProperties.fit && typeof baseProperties.fit === 'object' ? baseProperties.fit : null;
|
|
143
|
+
const { fit: _fitDropped, ...createProps } = baseProperties;
|
|
144
|
+
void _fitDropped;
|
|
145
|
+
let textDirection = itemType === 'text' ? takeDirection(createProps) : null;
|
|
146
|
+
// RIGHT-TO-LEFT TEXT WITH NO DIRECTION GIVEN. The engine's default is auto,
|
|
147
|
+
// but it records nothing, so nothing could confirm it. For content with any
|
|
148
|
+
// RTL script, auto is sent explicitly — recorded, so checkable — and the
|
|
149
|
+
// result says which way it resolved.
|
|
150
|
+
if (itemType === 'text' && !textDirection && typeof createProps.content === 'string' && RTL_SCRIPT.test(createProps.content)) {
|
|
151
|
+
createProps.direction = 'auto';
|
|
152
|
+
textDirection = { value: 'auto' };
|
|
153
|
+
}
|
|
154
|
+
if (textDirection && 'value' in textDirection && textDirection.value === 'auto' && typeof createProps.content === 'string') {
|
|
155
|
+
textDirection.resolved = baseDirection(createProps.content);
|
|
156
|
+
}
|
|
157
|
+
const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
|
|
158
|
+
const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
|
|
86
159
|
const params = {
|
|
87
|
-
x: position.x,
|
|
88
|
-
|
|
89
|
-
...withRadiusAxes(baseProperties),
|
|
160
|
+
...(omitXY ? {} : { x: position.x, y: position.y }),
|
|
161
|
+
...withRadiusAxes(createProps),
|
|
90
162
|
};
|
|
163
|
+
// "NO FILL" HAS THREE SPELLINGS, AND NONE OF THEM WORKED (round 7 AA, 1.50).
|
|
164
|
+
// fillColor:null was dropped by a truthiness check, so the engine's default
|
|
165
|
+
// blue applied; 'transparent' reached Paper, which read it as black. A
|
|
166
|
+
// stroke-only rectangle — whiteboard line art — could not be made at create.
|
|
167
|
+
// All three now mean no fill, applied after the item exists.
|
|
168
|
+
const noFill = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
|
|
169
|
+
const clearFill = (fillColor !== undefined && noFill(fillColor)) || (fillColor === undefined && color !== undefined && noFill(color));
|
|
170
|
+
// `color` ON A CLOSED PATH IS ITS FILL.
|
|
171
|
+
//
|
|
172
|
+
// The engine's path branch paints `color` as the STROKE whenever no fillColor
|
|
173
|
+
// is given — right for an open line, and the opposite of every shape, where
|
|
174
|
+
// color is the fill. So a closed blob or hand-drawn badge passed {color} came
|
|
175
|
+
// out as a hollow outline (round 6 U, 1.41). A closed path (closed:true, or
|
|
176
|
+
// pathData ending in Z) now takes color as its fill; an open one keeps the
|
|
177
|
+
// stroke it always had, so existing line drawings are unchanged.
|
|
178
|
+
const pathData = typeof baseProperties.pathData === 'string' ? baseProperties.pathData.trim() : '';
|
|
179
|
+
const closedPath = itemType === 'path'
|
|
180
|
+
&& (baseProperties.closed === true || /z$/i.test(pathData));
|
|
181
|
+
if (closedPath && color && !isGradient(color) && fillColor === undefined && strokeColor === undefined) {
|
|
182
|
+
params.fillColor = color;
|
|
183
|
+
}
|
|
91
184
|
// Handle simple solid colors in params
|
|
92
|
-
if (color && !isGradient(color)) {
|
|
185
|
+
if (color && !isGradient(color) && !noFill(color)) {
|
|
93
186
|
params.color = color;
|
|
94
187
|
}
|
|
95
|
-
if (fillColor && !isGradient(fillColor)) {
|
|
188
|
+
if (fillColor && !isGradient(fillColor) && !noFill(fillColor)) {
|
|
96
189
|
params.fillColor = fillColor;
|
|
97
190
|
}
|
|
98
191
|
if (strokeColor && !isGradient(strokeColor)) {
|
|
@@ -126,7 +219,17 @@ function generateCreateItemCode(itemType, position, properties) {
|
|
|
126
219
|
// ${itemType} — a render-time surface. Drawn per pixel by the cloud renderer;
|
|
127
220
|
// stood in for locally by a flat plate so the layout still reads.
|
|
128
221
|
const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
|
|
129
|
-
if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
|
|
222
|
+
if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
|
|
223
|
+
// The snippet's value is its last statement's, which was the assignment above:
|
|
224
|
+
// the caller got renderParams back and no itemId to address the item by.
|
|
225
|
+
// ONE outer paren pair: the governor's wrapTrailingReturn reads a top-level
|
|
226
|
+
// \`}\` as a statement end, so a bare ternary of object literals is not wrapped
|
|
227
|
+
// and its value is lost.
|
|
228
|
+
((item && item.data)
|
|
229
|
+
? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} },
|
|
230
|
+
localStandIn: true,
|
|
231
|
+
note: 'drawn here as a flat plate; the ${itemType} itself appears only in a cloud render. A local export shows the plate.' }
|
|
232
|
+
: { success: false, error: 'the stand-in plate for this ${itemType} was not created.' });`;
|
|
130
233
|
}
|
|
131
234
|
// AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
|
|
132
235
|
//
|
|
@@ -151,6 +254,17 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
|
|
|
151
254
|
if (params.origin !== undefined)
|
|
152
255
|
params.origin = normalized;
|
|
153
256
|
}
|
|
257
|
+
// AN UNREAD PROPERTY IS DROPPED WITHOUT A WORD — SO SAY THE WORD HERE.
|
|
258
|
+
//
|
|
259
|
+
// `properties` is free-form and create() returns an item whatever it is
|
|
260
|
+
// given, so {blur: 12, backdropFilter: 'blur(8px)'} produced a crisp shape and
|
|
261
|
+
// a successful call; the caller learned only by rendering. The allowlist is
|
|
262
|
+
// generated from the engine (what create() and normalizeParams() read), and
|
|
263
|
+
// the handful this emitter consumes itself are added. Reported back, not
|
|
264
|
+
// refused: the item is still what the caller asked for in every other way,
|
|
265
|
+
// and a refusal would cost a re-issue over one typo.
|
|
266
|
+
const ignored = Object.keys(properties).filter((k) => !CREATE_KNOWN_KEYS.has(k)
|
|
267
|
+
&& !(itemType === 'text' && (TEXT_STYLE_KEYS.includes(k) || k === 'fit' || DIRECTION_KEYS.includes(k) || TABULAR_KEYS.includes(k) || k === 'strokePosition')));
|
|
154
268
|
// Build the code
|
|
155
269
|
let code = `
|
|
156
270
|
// Create ${itemType} item
|
|
@@ -174,12 +288,51 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
|
|
|
174
288
|
if (blendMode) {
|
|
175
289
|
code += '\n' + generateBlendModeCode('item', blendMode);
|
|
176
290
|
}
|
|
291
|
+
if (clearFill)
|
|
292
|
+
code += `\nitem.fillColor = null;`;
|
|
293
|
+
// DASHES, CAPS AND JOINS FOR EVERY SHAPE (round 7 Z, 1.54). create() applies
|
|
294
|
+
// them only in its path / line / pattern branches; the shared tail sets a
|
|
295
|
+
// stroke's colour and width for every type but not these, so a dashed circle
|
|
296
|
+
// rendered solid and nothing said so (the key is on create()'s read list, so
|
|
297
|
+
// the unread-property report could not catch it). Re-applied here for every
|
|
298
|
+
// type; on a path it repeats the engine's own value.
|
|
299
|
+
const strokeStyle = [];
|
|
300
|
+
if (Array.isArray(properties.dashArray))
|
|
301
|
+
strokeStyle.push(`item.dashArray = ${JSON.stringify(properties.dashArray)};`);
|
|
302
|
+
if (typeof properties.strokeCap === 'string')
|
|
303
|
+
strokeStyle.push(`item.strokeCap = ${JSON.stringify(properties.strokeCap)};`);
|
|
304
|
+
if (typeof properties.strokeJoin === 'string')
|
|
305
|
+
strokeStyle.push(`item.strokeJoin = ${JSON.stringify(properties.strokeJoin)};`);
|
|
306
|
+
if (strokeStyle.length)
|
|
307
|
+
code += `\n${strokeStyle.join('\n')}`;
|
|
177
308
|
// Add opacity support
|
|
178
309
|
if (opacity !== undefined) {
|
|
179
310
|
code += `\nitem.opacity = ${opacity};`;
|
|
180
311
|
}
|
|
312
|
+
if (itemType === 'text')
|
|
313
|
+
code += emitTextStyle('item', properties);
|
|
314
|
+
// The vertical hold follows the anchor, as the engine's own create step
|
|
315
|
+
// derives it (1.90): without it the fit held the centre, and a top-left
|
|
316
|
+
// anchored headline's top edge drifted on every refit.
|
|
317
|
+
if (textDirection)
|
|
318
|
+
code += emitDirectionCheck('item', textDirection);
|
|
319
|
+
if (textTabular)
|
|
320
|
+
code += emitTabularCheck('item');
|
|
321
|
+
if (textStrokePos)
|
|
322
|
+
code += emitStrokePositionCheck('item', textStrokePos);
|
|
323
|
+
if (textFit)
|
|
324
|
+
code += `\nlet __textFit = null;${emitTextFit('item', textFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null))}`;
|
|
325
|
+
// After opacity, so the "on" level of the lifetime is the item's own.
|
|
326
|
+
const hasLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
|
|
327
|
+
if (hasLifetime) {
|
|
328
|
+
code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt, properties.ttl)}`;
|
|
329
|
+
}
|
|
181
330
|
// Ensure item is visible above backgrounds/generators
|
|
182
331
|
code += `\nif (item.bringToFront) item.bringToFront();`;
|
|
332
|
+
// selectable / isDraggable / isDecorative live on item.data, not in params.
|
|
333
|
+
if (dataFlags && Object.keys(dataFlags).length > 0) {
|
|
334
|
+
code += `\nif (item.data) Object.assign(item.data, ${JSON.stringify(dataFlags)});`;
|
|
335
|
+
}
|
|
183
336
|
// Add dynamic content support for text items
|
|
184
337
|
if (contentType && itemType === 'text') {
|
|
185
338
|
const opts = {};
|
|
@@ -191,19 +344,440 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
|
|
|
191
344
|
opts.countdownEndText = countdownEndText;
|
|
192
345
|
code += `\nif (app.setDynamicContent) app.setDynamicContent(item, '${contentType}', ${JSON.stringify(opts)});`;
|
|
193
346
|
}
|
|
347
|
+
const loadsFont = itemType === 'text' && typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
|
|
194
348
|
code += `
|
|
195
349
|
const itemId = item.data.registryId;
|
|
196
350
|
app.historyManager.saveState();
|
|
197
351
|
|
|
198
352
|
// Return item info
|
|
199
|
-
({ itemId, type: '${itemType}', position: { x: ${position.x}, y: ${position.y} } })
|
|
353
|
+
({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
|
|
354
|
+
? `, ignoredProperties: ${JSON.stringify(ignored)}, warning: ${JSON.stringify(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
|
|
355
|
+
+ 'Check the spelling against this item type\'s documented properties.')}`
|
|
356
|
+
: ''} });`;
|
|
357
|
+
// Loading a font is async, and it has to happen BEFORE create() measures the
|
|
358
|
+
// text — so only this case becomes an async IIFE; every other create stays a
|
|
359
|
+
// plain snippet ending in its (-led result.
|
|
360
|
+
if (loadsFont) {
|
|
361
|
+
const body = code.trim().replace(/\(\{ itemId([\s\S]*)\}\);$/, 'return { itemId$1 };');
|
|
362
|
+
return `(async function() {${emitEnsureFont(properties.fontFamily)}\n${body}\n})();`;
|
|
363
|
+
}
|
|
200
364
|
return code.trim();
|
|
201
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Every `properties` key modify_item can act on: what the engine's modify path
|
|
368
|
+
* and normalizeParams() read (generated), plus what generateModifyItemCode
|
|
369
|
+
* applies itself (dynamic content, audio level, lifetime, raster smoothing,
|
|
370
|
+
* the text properties modifyItem does not pass on).
|
|
371
|
+
*/
|
|
372
|
+
const MODIFY_KNOWN_KEYS = new Set([
|
|
373
|
+
...MODIFY_CHANGE_READS,
|
|
374
|
+
...NORMALIZE_PARAM_READS,
|
|
375
|
+
'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
|
|
376
|
+
'audioGain', 'volume', 'gain', 'bornAt', 'ttl', 'smoothing',
|
|
377
|
+
'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition',
|
|
378
|
+
]);
|
|
379
|
+
/** Spellings callers reach for, and the key the engine actually reads. */
|
|
380
|
+
const MODIFY_DID_YOU_MEAN = {
|
|
381
|
+
scaling: 'scale', size: 'width / height', rotate: 'rotation', angle: 'rotation', shear: 'skewX / skewY', skew: 'skewX / skewY',
|
|
382
|
+
alpha: 'opacity', text: 'content', stroke_width: 'strokeWidth', lineWidth: 'strokeWidth',
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Every `properties` key create_item can act on: what the engine's create()
|
|
386
|
+
* and normalizeParams() read (generated — see shape-params.ts), plus the keys
|
|
387
|
+
* generateCreateItemCode consumes itself before the engine sees them.
|
|
388
|
+
*/
|
|
389
|
+
const CREATE_KNOWN_KEYS = new Set([
|
|
390
|
+
...ACCEPTED_CREATE_PARAMS,
|
|
391
|
+
...NORMALIZE_PARAM_READS,
|
|
392
|
+
// withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
|
|
393
|
+
// setDynamicContent for text.
|
|
394
|
+
'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
|
|
395
|
+
// Lifetimes. The editor's create() does not read them, but they are
|
|
396
|
+
// documented on ANY item and the scene renderer schedules by them — calling
|
|
397
|
+
// them "no effect" would steer a caller off the only way to cut between shots.
|
|
398
|
+
'bornAt', 'ttl',
|
|
399
|
+
]);
|
|
400
|
+
/**
|
|
401
|
+
* Load a font family before text is drawn with it, and say if it still is not there.
|
|
402
|
+
*
|
|
403
|
+
* `font list_available` names ~60 families, and drawing with one (Anton,
|
|
404
|
+
* Playfair Display) came out in a serif fallback with a console warning
|
|
405
|
+
* nobody over MCP can read. They are real — the studio's decorative Google
|
|
406
|
+
* Fonts sheet carries them — but it is fetched only when the editor's own UI
|
|
407
|
+
* asks, and text measures its bounds at creation, so a face arriving later
|
|
408
|
+
* leaves the layout measured in the fallback.
|
|
409
|
+
*
|
|
410
|
+
* So: ask checkFont; if missing, ensureFontsLoaded('all') and force the fetch
|
|
411
|
+
* with document.fonts.load (a registered @font-face is not a fetched one),
|
|
412
|
+
* then ask again. `__font` is set only when the first answer was "missing",
|
|
413
|
+
* so an available font adds nothing to the result. Needs an async context.
|
|
414
|
+
*/
|
|
415
|
+
export function emitEnsureFont(family) {
|
|
416
|
+
const primary = family.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
|
|
417
|
+
return `
|
|
418
|
+
let __font = null;
|
|
419
|
+
if (typeof app.checkFont === 'function') {
|
|
420
|
+
const __v0 = app.checkFont(${JSON.stringify(family)});
|
|
421
|
+
if (__v0 && __v0.available === false) {
|
|
422
|
+
if (typeof app.ensureFontsLoaded === 'function') { try { await app.ensureFontsLoaded('all'); } catch (_) { /* reported below */ } }
|
|
423
|
+
try { if (typeof document !== 'undefined' && document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
|
|
424
|
+
let __v1 = app.checkFont(${JSON.stringify(family)});
|
|
425
|
+
let __via = 'studio';
|
|
426
|
+
// Not in the studio's own sheets (Monoton, a common neon face, is not):
|
|
427
|
+
// the page's CSP allows fonts.googleapis.com stylesheets and
|
|
428
|
+
// fonts.gstatic.com files, so ask Google Fonts for the family directly.
|
|
429
|
+
if (!(__v1 && __v1.available === true) && typeof document !== 'undefined' && document.head) {
|
|
430
|
+
__via = 'google-fonts';
|
|
431
|
+
await new Promise(function(res) {
|
|
432
|
+
const id = 'pp-gf-' + ${JSON.stringify(primary)}.replace(/[^a-z0-9]+/gi, '-');
|
|
433
|
+
if (document.getElementById(id)) return res();
|
|
434
|
+
const l = document.createElement('link');
|
|
435
|
+
l.id = id; l.rel = 'stylesheet';
|
|
436
|
+
l.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(${JSON.stringify(primary)}).replace(/%20/g, '+') + '&display=swap';
|
|
437
|
+
l.onload = res; l.onerror = res; setTimeout(res, 5000);
|
|
438
|
+
document.head.appendChild(l);
|
|
439
|
+
});
|
|
440
|
+
try { if (document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
|
|
441
|
+
__v1 = app.checkFont(${JSON.stringify(family)});
|
|
442
|
+
}
|
|
443
|
+
__font = (__v1 && __v1.available === true)
|
|
444
|
+
? { family: ${JSON.stringify(primary)}, available: true, loadedNow: true, via: __via }
|
|
445
|
+
: { family: ${JSON.stringify(primary)}, available: false,
|
|
446
|
+
warning: ${JSON.stringify(primary)} + ' could not be loaded, so this text is drawn in a fallback face. '
|
|
447
|
+
+ ((__v1 && __v1.reason) || '') + ' font list_available names the families this studio can load.' };
|
|
448
|
+
}
|
|
449
|
+
}`;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Auto-fit a text item to a box (FxTool 661224ef: app.fitText). Text that
|
|
453
|
+
* overran its slot — a German translation, a long name on a certificate, a
|
|
454
|
+
* feed value in a variant matrix — was the #1 blocker in rounds 6–9. The box
|
|
455
|
+
* is remembered by the engine, so later content changes refit. On a studio
|
|
456
|
+
* without fitText the result says so rather than leaving the text as it was
|
|
457
|
+
* in silence. `__textFit` must be declared by the caller.
|
|
458
|
+
*/
|
|
459
|
+
/**
|
|
460
|
+
* A text paragraph's base direction (FxTool a3b4e7c3, gap 1.16): 'auto' (the
|
|
461
|
+
* first strong character decides), 'ltr' or 'rtl', under three spellings.
|
|
462
|
+
*
|
|
463
|
+
* Pulled out of the changes and normalised to `direction`, the one spelling
|
|
464
|
+
* the engine reads on both create and modify (modify does not read `dir`). A
|
|
465
|
+
* value that is not a direction is refused here: the engine only console.warns
|
|
466
|
+
* it, which production strips.
|
|
467
|
+
*/
|
|
468
|
+
const DIRECTION_KEYS = ['direction', 'textDirection', 'dir'];
|
|
469
|
+
// The engine's rule (FxTool js/core/TextDirection.js, UAX #9 P2/P3): the
|
|
470
|
+
// first STRONG character decides. Mirrored so a result can say which way
|
|
471
|
+
// 'auto' went.
|
|
472
|
+
const RTL_SCRIPT = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}]/u;
|
|
473
|
+
function baseDirection(text) {
|
|
474
|
+
for (const ch of text) {
|
|
475
|
+
if (RTL_SCRIPT.test(ch))
|
|
476
|
+
return 'rtl';
|
|
477
|
+
if (/\p{L}/u.test(ch))
|
|
478
|
+
return 'ltr';
|
|
479
|
+
}
|
|
480
|
+
return 'ltr';
|
|
481
|
+
}
|
|
482
|
+
function takeDirection(props) {
|
|
483
|
+
const key = DIRECTION_KEYS.find((k) => props[k] !== undefined && props[k] !== null && props[k] !== '');
|
|
484
|
+
const raw = key ? props[key] : undefined;
|
|
485
|
+
for (const k of DIRECTION_KEYS)
|
|
486
|
+
delete props[k];
|
|
487
|
+
if (raw === undefined)
|
|
488
|
+
return null;
|
|
489
|
+
const v = String(raw).trim().toLowerCase();
|
|
490
|
+
if (v === 'auto' || v === 'ltr' || v === 'rtl') {
|
|
491
|
+
props.direction = v;
|
|
492
|
+
return { value: v };
|
|
493
|
+
}
|
|
494
|
+
return { error: `direction ${JSON.stringify(String(raw))} is not one of auto, ltr, rtl — the text keeps its current direction.` };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Sets `__direction` (declared here) to whether the studio took the direction:
|
|
498
|
+
* an engine without RTL support accepts the key and draws left-to-right, so
|
|
499
|
+
* the only evidence is the value it records on the item.
|
|
500
|
+
*/
|
|
501
|
+
function emitDirectionCheck(itemExpr, req) {
|
|
502
|
+
if ('error' in req)
|
|
503
|
+
return `
|
|
504
|
+
const __direction = ${JSON.stringify({ applied: false, error: req.error })};`;
|
|
505
|
+
const v = JSON.stringify(req.value);
|
|
506
|
+
return `
|
|
507
|
+
const __direction = (function(it) {
|
|
508
|
+
if (!it || it.className !== 'PointText') return { applied: false, note: 'direction applies to text items only.' };
|
|
509
|
+
return it.data && it.data.direction === ${v}
|
|
510
|
+
? { applied: true, value: ${v}${req.resolved ? `, resolved: ${JSON.stringify(req.resolved)}` : ''} }
|
|
511
|
+
: { applied: false, note: 'this studio does not set a paragraph direction (it needs an engine with RTL text support): the text is laid out left-to-right.' };
|
|
512
|
+
})(${itemExpr});`;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Tabular (fixed-width) figures (FxTool 3354d51c): a counter or price column
|
|
516
|
+
* that does not jitter as its digits change. The engine reads all three
|
|
517
|
+
* spellings on create and modify itself; this only reports whether it took
|
|
518
|
+
* them, because an engine without them accepts the key and draws
|
|
519
|
+
* proportional digits.
|
|
520
|
+
*/
|
|
521
|
+
const TABULAR_KEYS = ['tabularFigures', 'fontVariantNumeric', 'fontFeatures'];
|
|
522
|
+
function wantsTabular(p) {
|
|
523
|
+
if (!TABULAR_KEYS.some((k) => p[k] !== undefined))
|
|
524
|
+
return null;
|
|
525
|
+
if (p.tabularFigures === true)
|
|
526
|
+
return true;
|
|
527
|
+
if (typeof p.fontVariantNumeric === 'string' && /tabular-nums/.test(p.fontVariantNumeric))
|
|
528
|
+
return true;
|
|
529
|
+
const f = p.fontFeatures;
|
|
530
|
+
const list = Array.isArray(f) ? f.map(String) : typeof f === 'string' ? [f] : f && typeof f === 'object' ? Object.keys(f).filter((k) => f[k]) : [];
|
|
531
|
+
return list.some((x) => /\btnum\b/.test(x));
|
|
532
|
+
}
|
|
533
|
+
function emitTabularCheck(itemExpr) {
|
|
534
|
+
return `
|
|
535
|
+
const __tabular = (function(it) {
|
|
536
|
+
if (!it || it.className !== 'PointText') return { applied: false, note: 'tabular figures apply to text items only.' };
|
|
537
|
+
return it.data && it.data.tabularFigures === true
|
|
538
|
+
? { applied: true }
|
|
539
|
+
: { applied: false, note: 'this studio does not lay out tabular figures (it needs an engine with them): digits keep their proportional widths. A monospaced font is the fallback.' };
|
|
540
|
+
})(${itemExpr});`;
|
|
541
|
+
}
|
|
542
|
+
function takeStrokePosition(props, allowNull) {
|
|
543
|
+
if (!('strokePosition' in props))
|
|
544
|
+
return null;
|
|
545
|
+
const raw = props.strokePosition;
|
|
546
|
+
if (raw === null && allowNull) {
|
|
547
|
+
props.strokePosition = 'center';
|
|
548
|
+
return { value: 'center' };
|
|
549
|
+
}
|
|
550
|
+
const v = String(raw ?? '').trim().toLowerCase();
|
|
551
|
+
if (v === 'outside') {
|
|
552
|
+
props.strokePosition = 'outside';
|
|
553
|
+
return { value: 'outside' };
|
|
554
|
+
}
|
|
555
|
+
if (v === 'center' || v === 'centre') {
|
|
556
|
+
props.strokePosition = 'center';
|
|
557
|
+
return { value: 'center' };
|
|
558
|
+
}
|
|
559
|
+
delete props.strokePosition;
|
|
560
|
+
return { error: `strokePosition ${JSON.stringify(raw)} is not 'outside' or 'center' — the stroke is unchanged.` };
|
|
561
|
+
}
|
|
562
|
+
function emitStrokePositionCheck(itemExpr, req) {
|
|
563
|
+
if ('error' in req)
|
|
564
|
+
return `\nconst __strokePos = ${JSON.stringify({ applied: false, error: req.error })};`;
|
|
565
|
+
if (req.value === 'center')
|
|
566
|
+
return `\nconst __strokePos = { applied: true, value: 'center' };`;
|
|
567
|
+
return `
|
|
568
|
+
const __strokePos = (function(it) {
|
|
569
|
+
if (!it || it.className !== 'PointText') return { applied: false, note: 'strokePosition applies to text items only.' };
|
|
570
|
+
if (!(it.data && it.data.strokePosition === 'outside')) return { applied: false, note: 'this studio does not draw text strokes outside the glyph (it needs an engine with strokePosition): the stroke is centred on the outline, so a thick stroke thins the letters.' };
|
|
571
|
+
return (it.strokeColor && it.strokeWidth > 0) ? { applied: true, value: 'outside' } : { applied: true, value: 'outside', note: 'the text has no stroke yet: set strokeColor and strokeWidth to see it.' };
|
|
572
|
+
})(${itemExpr});`;
|
|
573
|
+
}
|
|
574
|
+
/** 'top' / 'bottom' from a top-* / bottom-* anchor, as the engine derives it. */
|
|
575
|
+
function anchorHold(anchor) {
|
|
576
|
+
const a = String(anchor ?? '').toLowerCase();
|
|
577
|
+
return /^top/.test(a) ? 'top' : /^bottom/.test(a) ? 'bottom' : undefined;
|
|
578
|
+
}
|
|
579
|
+
function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
|
|
580
|
+
return `
|
|
581
|
+
if (typeof app.fitText === 'function') {
|
|
582
|
+
const __fbox = ${JSON.stringify(box)};
|
|
583
|
+
if (__fbox.hold === undefined) { const __h = ${holdFallback}; if (__h) __fbox.hold = __h; }
|
|
584
|
+
const __fr = app.fitText(${itemExpr}, __fbox);
|
|
585
|
+
__textFit = __fr && __fr.ok !== false
|
|
586
|
+
? Object.assign({ applied: true }, __fr, __fr.fits === false ? { warning: 'the text does not fit its box even at the minimum font size — shorten it or widen the box.' } : {})
|
|
587
|
+
: { applied: false, error: (__fr && __fr.error) || 'the studio refused the fit box' };
|
|
588
|
+
} else {
|
|
589
|
+
__textFit = { applied: false, note: 'this studio cannot auto-fit text (no app.fitText): the text keeps its font size and may overrun the box.' };
|
|
590
|
+
}`;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Paper text properties the engine's create() and modifyItem() never pass on.
|
|
594
|
+
*
|
|
595
|
+
* Line spacing, weight and italic are what editorial, Swiss and zine layouts
|
|
596
|
+
* are made of, and all three were dropped without a word: create('text')
|
|
597
|
+
* builds a PointText from content / fontFamily / fontSize / justification
|
|
598
|
+
* only. PointText itself supports them (`leading`, and `fontWeight`, which
|
|
599
|
+
* Paper writes into the canvas font shorthand — so italic rides in it as
|
|
600
|
+
* "italic 700"). Set after the fact, then re-anchored to the edge the engine
|
|
601
|
+
* anchored to, because a heavier weight or taller leading moves the bounds.
|
|
602
|
+
*
|
|
603
|
+
* lineHeight is the CSS habit: <= 4 is a multiple of fontSize, above that px.
|
|
604
|
+
*/
|
|
605
|
+
const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight'];
|
|
606
|
+
function emitTextStyle(itemExpr, props) {
|
|
607
|
+
const weight = props.fontWeight;
|
|
608
|
+
const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
|
|
609
|
+
const lh = typeof props.lineHeight === 'number' ? props.lineHeight : undefined;
|
|
610
|
+
const leading = typeof props.leading === 'number' ? props.leading : undefined;
|
|
611
|
+
if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined)
|
|
612
|
+
return '';
|
|
613
|
+
return `
|
|
614
|
+
(function(it) {
|
|
615
|
+
if (!it || it.className !== 'PointText') return;
|
|
616
|
+
const j = it.justification;
|
|
617
|
+
const edge = function(b) { return j === 'left' ? b.left : j === 'right' ? b.right : b.center.x; };
|
|
618
|
+
const b0 = it.bounds, ax = edge(b0), ay = b0.center.y;
|
|
619
|
+
${weight !== undefined || props.fontStyle !== undefined
|
|
620
|
+
? `it.fontWeight = ${JSON.stringify(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
|
|
621
|
+
: ''}
|
|
622
|
+
${leading !== undefined ? `it.leading = ${leading};` : ''}
|
|
623
|
+
${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
|
|
624
|
+
const b1 = it.bounds;
|
|
625
|
+
it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
|
|
626
|
+
})(${itemExpr});`;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Which data keys a chart plots, when the caller did not say.
|
|
630
|
+
*
|
|
631
|
+
* ChartSystem defaults to fields named `x` and `y`. The tool's own documented
|
|
632
|
+
* example is [{category, value}], so that example drew NO bars — index labels
|
|
633
|
+
* "0 1 2 3 4" and success:true, dataPoints:5 (round 7 Z, 1.53). Inferred here
|
|
634
|
+
* from the data instead: x is a label-like key (category/label/name/…, else
|
|
635
|
+
* the first text key), y is value-like (value/count/…, else the first number
|
|
636
|
+
* key other than x). stackedBar picks its own series from every numeric key,
|
|
637
|
+
* so only x is inferred for it. A y that cannot be found is refused by name,
|
|
638
|
+
* listing the keys that were there, rather than drawn as nothing.
|
|
639
|
+
*/
|
|
640
|
+
function resolveChartFields(chartType, data, options) {
|
|
641
|
+
// No rows yet (a chart created empty and filled by update): nothing to infer.
|
|
642
|
+
if (data.length === 0)
|
|
643
|
+
return { options };
|
|
644
|
+
const row = data.find((d) => d && typeof d === 'object') ?? {};
|
|
645
|
+
const keys = Object.keys(row);
|
|
646
|
+
const out = { ...options };
|
|
647
|
+
const inferred = {};
|
|
648
|
+
const has = (k) => keys.includes(k);
|
|
649
|
+
if (out.xField === undefined && !has('x')) {
|
|
650
|
+
const x = ['category', 'label', 'name', 'date', 'month', 'year', 'key'].find(has)
|
|
651
|
+
?? keys.find((k) => typeof row[k] === 'string') ?? keys[0];
|
|
652
|
+
if (x !== undefined) {
|
|
653
|
+
out.xField = x;
|
|
654
|
+
inferred.xField = x;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const xf = out.xField ?? 'x';
|
|
658
|
+
if (chartType !== 'stackedBar' && out.yField === undefined && !has('y')) {
|
|
659
|
+
const numeric = keys.filter((k) => k !== xf && typeof row[k] === 'number');
|
|
660
|
+
const y = ['value', 'count', 'amount', 'total', 'score'].find((k) => numeric.includes(k)) ?? numeric[0];
|
|
661
|
+
if (y === undefined) {
|
|
662
|
+
return { error: `no numeric field to plot: the data rows have ${keys.length ? keys.join(', ') : 'no keys'}, and none but ${xf} is a number. Pass options.yField naming the numeric key.` };
|
|
663
|
+
}
|
|
664
|
+
out.yField = y;
|
|
665
|
+
inferred.yField = y;
|
|
666
|
+
}
|
|
667
|
+
return Object.keys(inferred).length ? { options: out, inferred } : { options: out };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* A lifetime (bornAt / ttl, seconds) as a VISIBILITY WINDOW, [bornAt, bornAt + ttl).
|
|
671
|
+
*
|
|
672
|
+
* THE ENGINE HAS NO LIFETIME, and the first answer here (5bfc480) wrote one as
|
|
673
|
+
* hard-cut opacity keys merged into the item's own track. That failed four
|
|
674
|
+
* ways once items also had keyframes (ads production): keyframe_animate
|
|
675
|
+
* replaced the track and the ttl cut was lost (1.83); opacity-only cut keys
|
|
676
|
+
* broke the position track through the engine's missing-property rule (2.30 →
|
|
677
|
+
* 1.84); authored opacity and the cuts blended instead of multiplying (1.85);
|
|
678
|
+
* and exact-time hand-offs left a blank frame (1.86).
|
|
679
|
+
*
|
|
680
|
+
* So it is no longer keys. ONE frame callback ('pp_lifetimes') sets
|
|
681
|
+
* item.visible from the window for every item with a lifetime, reading each
|
|
682
|
+
* item's live data.bornAt / ttl, from the frame's own time: event.sceneTime,
|
|
683
|
+
* which both the export's step and sceneAt supply, else app.playbackTime. The
|
|
684
|
+
* export runs frame callbacks inside app.update() before it draws each step
|
|
685
|
+
* (VideoExporter._advanceToTime), so exports honour it. visible, not opacity,
|
|
686
|
+
* so it composes with any keyframes — the engine's own clip window hides the
|
|
687
|
+
* same way for the same reason. A 1-microsecond tolerance makes a hand-off at
|
|
688
|
+
* an exact frame time show exactly one item.
|
|
689
|
+
*
|
|
690
|
+
* Runtime-only: a scene saved and reopened keeps data.bornAt / ttl (the cloud
|
|
691
|
+
* renderer reads those) but not the callback, until the studio reads the
|
|
692
|
+
* fields itself — the result says so. Lifetimes applied the old way are
|
|
693
|
+
* migrated: their tagged opacity keys are removed.
|
|
694
|
+
*/
|
|
695
|
+
function emitLifetime(itemExpr, bornAt, ttl) {
|
|
696
|
+
const b = Number(bornAt);
|
|
697
|
+
const t = Number(ttl);
|
|
698
|
+
const born = Number.isFinite(b) && b > 0 ? b : 0;
|
|
699
|
+
const span = Number.isFinite(t) && t > 0 ? t : null;
|
|
700
|
+
return `
|
|
701
|
+
(function(it) {
|
|
702
|
+
if (!it || !it.data) return;
|
|
703
|
+
it.data.bornAt = ${born};
|
|
704
|
+
${span === null ? 'delete it.data.ttl;' : `it.data.ttl = ${span};`}
|
|
705
|
+
if (Array.isArray(it.data.keyframes) && it.data.keyframes.some(function(k) { return k && k._lifetime; })) {
|
|
706
|
+
it.data.keyframes = it.data.keyframes.filter(function(k) { return !(k && k._lifetime); });
|
|
707
|
+
}
|
|
708
|
+
// THE STUDIO'S OWN LIFETIME, WHEN IT HAS ONE (FxTool bf1a656b): same window,
|
|
709
|
+
// same tolerance, applied in the frame loop, on scrub and in export — and it
|
|
710
|
+
// PERSISTS through save / reload, which the callback below cannot. Passed
|
|
711
|
+
// straight through; the callback is only the fallback for older studios.
|
|
712
|
+
if (typeof app.setLifetime === 'function') {
|
|
713
|
+
const __r = app.setLifetime(it, ${born}, ${span === null ? 'null' : span});
|
|
714
|
+
if (!__r || __r.ok === false) {
|
|
715
|
+
__lifetime = { bornAt: ${born}, ttl: ${span}, applied: false, error: (__r && __r.error) || 'the studio refused the lifetime' };
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const __G = globalThis.__ppMcp;
|
|
719
|
+
if (__G && __G.lifetimeApp === app && __G.lifetimeIds) __G.lifetimeIds.delete(it.data.registryId || it.data.id);
|
|
720
|
+
__lifetime = { bornAt: ${born}, ttl: ${span}, applied: true, via: 'engine', persists: true };
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const rid = it.data.registryId || it.data.id;
|
|
724
|
+
// MCP-owned page state lives on globalThis, not on app: it is not an engine
|
|
725
|
+
// member, and the engine-surface guard reads app.X as a claim that it is.
|
|
726
|
+
const __pp = globalThis.__ppMcp = globalThis.__ppMcp || {};
|
|
727
|
+
// Keyed to THIS studio: a re-initialised app has none of the old one's
|
|
728
|
+
// callbacks, so the registration and the id set start again with it.
|
|
729
|
+
if (__pp.lifetimeApp !== app) { __pp.lifetimeApp = app; __pp.lifetimeIds = new Set(); }
|
|
730
|
+
if (typeof app.addOnFrameCallback !== 'function' || !rid) {
|
|
731
|
+
__lifetime = { bornAt: ${born}, ttl: ${span}, applied: false, note: 'stored on the item, but this studio has no frame callbacks, so it shows for the whole local render (a cloud render honours it).' };
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
__pp.lifetimeIds = __pp.lifetimeIds || new Set();
|
|
735
|
+
__pp.lifetimeIds.add(rid);
|
|
736
|
+
const within = function(x, now) {
|
|
737
|
+
const b0 = x.data.bornAt, t0 = x.data.ttl;
|
|
738
|
+
return now >= b0 - 1e-6 && (typeof t0 !== 'number' || now < b0 + t0 - 1e-6);
|
|
739
|
+
};
|
|
740
|
+
// The visibility pass itself, kept where a still export can call it too
|
|
741
|
+
// (a seek alone does not run frame callbacks).
|
|
742
|
+
__pp.applyLifetimes = function(now) {
|
|
743
|
+
__pp.lifetimeIds.forEach(function(id) {
|
|
744
|
+
const e = app.itemRegistry && app.itemRegistry.get(id);
|
|
745
|
+
const x = e && e.item;
|
|
746
|
+
if (!x || !x.data || typeof x.data.bornAt !== 'number') { __pp.lifetimeIds.delete(id); return; }
|
|
747
|
+
const vis = within(x, now);
|
|
748
|
+
if (x.visible !== vis) x.visible = vis;
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
// Registered EVERY time, not once behind a flag: clearCanvas / start_job
|
|
752
|
+
// remove frame callbacks, and a stale "already registered" flag left every
|
|
753
|
+
// later scene's lifetimes doing nothing (retest of 28a4a0e). Re-adding the
|
|
754
|
+
// same id replaces it.
|
|
755
|
+
app.addOnFrameCallback('pp_lifetimes', function(ev) {
|
|
756
|
+
const now = ev && typeof ev.sceneTime === 'number' ? ev.sceneTime : (typeof app.playbackTime === 'number' ? app.playbackTime : 0);
|
|
757
|
+
__pp.applyLifetimes(now);
|
|
758
|
+
});
|
|
759
|
+
it.visible = within(it, typeof app.playbackTime === 'number' ? app.playbackTime : 0);
|
|
760
|
+
__lifetime = { bornAt: ${born}, ttl: ${span}, applied: true, via: 'visibility',
|
|
761
|
+
note: 'applied as visibility, so it composes with keyframes. Runtime-only until the studio reads bornAt / ttl itself: a scene reopened in the editor keeps the fields (and a cloud render honours them) but must be re-timed to play locally.' };
|
|
762
|
+
if (it.data.clipInPoint !== undefined || it.data.clipOutPoint !== undefined) {
|
|
763
|
+
__lifetime.warning = 'this item also has a clip window (clipInPoint / clipOutPoint), which hides it by the same means — the two can fight. Use one.';
|
|
764
|
+
}
|
|
765
|
+
})(${itemExpr});`;
|
|
766
|
+
}
|
|
202
767
|
/**
|
|
203
768
|
* Template for modifying items
|
|
204
769
|
*/
|
|
205
|
-
function generateModifyItemCode(itemId, properties) {
|
|
770
|
+
function generateModifyItemCode(itemId, properties, dataFlags) {
|
|
206
771
|
const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
|
|
772
|
+
// A new fit box is applied here (emitTextFit, with the hold carried over),
|
|
773
|
+
// not by modifyItem: the engine's modify fits it with no hold, overwriting
|
|
774
|
+
// the one this reads back. fit: null still goes through — that is the
|
|
775
|
+
// engine's "stop fitting".
|
|
776
|
+
if (restProperties.fit && typeof restProperties.fit === 'object')
|
|
777
|
+
delete restProperties.fit;
|
|
778
|
+
const modDirection = takeDirection(restProperties);
|
|
779
|
+
const modTabular = wantsTabular(restProperties) === true;
|
|
780
|
+
const modStrokePos = takeStrokePosition(restProperties, true);
|
|
207
781
|
// modifyItem(id, changes), NOT select() + modify().
|
|
208
782
|
//
|
|
209
783
|
// modify() operates on the SELECTION, so this selected an id and then edited
|
|
@@ -248,11 +822,101 @@ const entry = app.itemRegistry.get('${itemId}');
|
|
|
248
822
|
if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, '${contentType}', ${JSON.stringify(opts)});`;
|
|
249
823
|
}
|
|
250
824
|
}
|
|
825
|
+
// AN AUDIO ITEM'S LEVEL IS NOT AN ITEM PROPERTY. modifyItem applies changes
|
|
826
|
+
// to the Paper item (an audio item's is an invisible sentinel), so
|
|
827
|
+
// {audioGain} / {volume} answered success and moved nothing (measured 0.0 dB).
|
|
828
|
+
// The mix reads registry properties.audioGain; the live player has its own
|
|
829
|
+
// volume. Both are set, and gain is kept in step for the editor.
|
|
830
|
+
const levelKey = ['audioGain', 'volume', 'gain'].find((k) => typeof properties[k] === 'number');
|
|
831
|
+
if (levelKey) {
|
|
832
|
+
const level = Math.max(0, Math.min(1, properties[levelKey]));
|
|
833
|
+
code += `
|
|
834
|
+
const _ae = app.itemRegistry.get('${itemId}');
|
|
835
|
+
if (_ae && _ae.type === 'audio') {
|
|
836
|
+
_ae.properties = _ae.properties || {};
|
|
837
|
+
_ae.properties.audioGain = ${level};
|
|
838
|
+
_ae.properties.gain = ${level};
|
|
839
|
+
const _A = (typeof window !== 'undefined') && window.PinePaperAgent;
|
|
840
|
+
const _m = _A && typeof _A.listMedia === 'function' ? _A.listMedia().find(function(x) { return x.registryId === '${itemId}'; }) : null;
|
|
841
|
+
if (_m && app.audioLayer && typeof app.audioLayer.setVolume === 'function') app.audioLayer.setVolume(${level}, _m.id);
|
|
842
|
+
}`;
|
|
843
|
+
}
|
|
844
|
+
// Raster resampling (pixel art: 'off'). A Paper Raster property modifyItem
|
|
845
|
+
// does not pass on; applied to a raster, or to the raster an image group holds.
|
|
846
|
+
if (typeof properties.smoothing === 'string') {
|
|
847
|
+
code += `
|
|
848
|
+
const _sm = app.itemRegistry.get('${itemId}');
|
|
849
|
+
const _smR = _sm && _sm.item && (_sm.item.className === 'Raster' ? _sm.item
|
|
850
|
+
: (typeof _sm.item.getItem === 'function' ? _sm.item.getItem({ className: 'Raster' }) : null));
|
|
851
|
+
if (_smR) _smR.smoothing = ${JSON.stringify(properties.smoothing)};`;
|
|
852
|
+
}
|
|
853
|
+
// No fill on modify too: null / 'transparent' / 'none' clear it outright,
|
|
854
|
+
// after modifyItem, which would otherwise read 'transparent' as black.
|
|
855
|
+
const fillNone = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
|
|
856
|
+
if (fillNone(properties.fillColor) || (properties.fillColor === undefined && properties.color !== undefined && fillNone(properties.color))) {
|
|
857
|
+
code += `
|
|
858
|
+
const _nf = app.itemRegistry.get('${itemId}');
|
|
859
|
+
if (_nf && _nf.item) _nf.item.fillColor = null;`;
|
|
860
|
+
}
|
|
861
|
+
// SKEW AND AFFINE, FOR MOCKUPS (round 9 HH). A box face or a phone screen in
|
|
862
|
+
// perspective-ish mockups is a shear / affine map; modify_item reported
|
|
863
|
+
// skewX / shear / matrix as ignored and the only route was custom code.
|
|
864
|
+
// Paper does both: item.shear(tan x, tan y) about the centre, and
|
|
865
|
+
// item.transform(Matrix). They are APPLIED to the current shape — a second
|
|
866
|
+
// call compounds — not stored as settable properties; the result says so.
|
|
867
|
+
const skewX = typeof properties.skewX === 'number' ? properties.skewX : 0;
|
|
868
|
+
const skewY = typeof properties.skewY === 'number' ? properties.skewY : 0;
|
|
869
|
+
const matrix = Array.isArray(properties.matrix) && properties.matrix.length === 6 && properties.matrix.every((n) => typeof n === 'number')
|
|
870
|
+
? properties.matrix : null;
|
|
871
|
+
const affine = skewX !== 0 || skewY !== 0 || matrix !== null;
|
|
872
|
+
if (affine) {
|
|
873
|
+
code += `
|
|
874
|
+
const _af = app.itemRegistry.get('${itemId}');
|
|
875
|
+
const _afi = _af && _af.item;
|
|
876
|
+
if (_afi) {
|
|
877
|
+
${skewX !== 0 || skewY !== 0 ? `_afi.shear(Math.tan(${skewX} * Math.PI / 180), Math.tan(${skewY} * Math.PI / 180), _afi.bounds.center);` : ''}
|
|
878
|
+
${matrix ? `_afi.transform(new paper.Matrix(${matrix.join(', ')}));` : ''}
|
|
879
|
+
}`;
|
|
880
|
+
}
|
|
881
|
+
const modFit = properties.fit && typeof properties.fit === 'object' ? properties.fit : null;
|
|
882
|
+
if (modFit) {
|
|
883
|
+
code += `
|
|
884
|
+
let __textFit = null;
|
|
885
|
+
const _tf = app.itemRegistry.get('${itemId}');${emitTextFit('_tf && _tf.item', modFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null) + " || (_tf && _tf.item && _tf.item.data && _tf.item.data.fitBox && _tf.item.data.fitBox.hold)")}`;
|
|
886
|
+
}
|
|
887
|
+
const textStyle = emitTextStyle('_ts && _ts.item', properties);
|
|
888
|
+
if (textStyle) {
|
|
889
|
+
code += `
|
|
890
|
+
const _ts = app.itemRegistry.get('${itemId}');${textStyle}`;
|
|
891
|
+
}
|
|
892
|
+
const modLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
|
|
893
|
+
if (modLifetime) {
|
|
894
|
+
code += `
|
|
895
|
+
let __lifetime = null;
|
|
896
|
+
const _lt = app.itemRegistry.get('${itemId}');${emitLifetime('_lt && _lt.item', properties.bornAt, properties.ttl)}`;
|
|
897
|
+
}
|
|
898
|
+
if (dataFlags && Object.keys(dataFlags).length > 0) {
|
|
899
|
+
code += `
|
|
900
|
+
const _flagged = app.itemRegistry.get('${itemId}');
|
|
901
|
+
if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item.data, ${JSON.stringify(dataFlags)});`;
|
|
902
|
+
}
|
|
903
|
+
const modFont = typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
|
|
904
|
+
// AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
|
|
905
|
+
// raster answered success and left it 16 px; the engine reads `scale`. Same
|
|
906
|
+
// report create_item gives, against the engine's modify path (generated).
|
|
907
|
+
const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k));
|
|
908
|
+
const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
|
|
909
|
+
+ modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
|
|
251
910
|
code += `
|
|
252
911
|
app.historyManager.saveState();
|
|
253
|
-
|
|
912
|
+
${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
|
|
913
|
+
return { success: true, itemId: '${itemId}'${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
|
|
254
914
|
// Wrapped, because the body now RETURNS early on a miss rather than falling
|
|
255
|
-
// through to an unconditional success.
|
|
915
|
+
// through to an unconditional success. Async only when a font must load
|
|
916
|
+
// before modifyItem re-measures the text.
|
|
917
|
+
if (modFont) {
|
|
918
|
+
return `(async function() {${emitEnsureFont(properties.fontFamily)}\n${code.trim()}\n})();`;
|
|
919
|
+
}
|
|
256
920
|
return `(function() {\n${code.trim()}\n})();`;
|
|
257
921
|
}
|
|
258
922
|
/**
|
|
@@ -289,18 +953,143 @@ function generateDeleteItemCode(itemId) {
|
|
|
289
953
|
* Template for adding relations
|
|
290
954
|
*/
|
|
291
955
|
function generateAddRelationCode(sourceId, targetId, relationType, params) {
|
|
956
|
+
// AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
|
|
957
|
+
// stores a relation to an id it cannot resolve, returns true, and says "it
|
|
958
|
+
// will be skipped every frame and nothing will move" only in a warning this
|
|
959
|
+
// tool never sees. Its own test for that warning — _isKnownRelationEndpoint:
|
|
960
|
+
// an item, the camera, a cursor target — is asked first here, after the same
|
|
961
|
+
// '#id' forgiveness the engine applies.
|
|
292
962
|
return `
|
|
293
963
|
// Add ${relationType} relation: ${sourceId} -> ${targetId}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
964
|
+
(function() {
|
|
965
|
+
const norm = function(id) { return typeof app._resolveRelationEndpoint === 'function' ? app._resolveRelationEndpoint(id) : id; };
|
|
966
|
+
if (typeof app._isKnownRelationEndpoint === 'function') {
|
|
967
|
+
const bad = [${JSON.stringify(sourceId)}, ${JSON.stringify(targetId)}].filter(function(id) { return !app._isKnownRelationEndpoint(norm(id)); });
|
|
968
|
+
if (bad.length) {
|
|
969
|
+
return { success: false, relationType: '${relationType}', error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')
|
|
970
|
+
+ ' ' + (bad.length > 1 ? 'are not items' : 'is not an item') + ' on the canvas, so the relation would be stored and skipped every frame. Check the id (pinepaper_get_items lists them).' };
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
|
|
974
|
+
if (!success) {
|
|
975
|
+
return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}.' };
|
|
976
|
+
}
|
|
977
|
+
app.historyManager.saveState();
|
|
978
|
+
return { success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' };
|
|
979
|
+
})();
|
|
302
980
|
`.trim();
|
|
303
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* moves_along_path, from the parameters a caller actually writes.
|
|
984
|
+
*
|
|
985
|
+
* The rule is a SELF relation reading params.path as a point array, with speed
|
|
986
|
+
* (1 = 150 px/s), closed, phase, easing, signal, equation. The natural call —
|
|
987
|
+
* targetId: <a path item>, params {pathId, duration, delay} — named none of
|
|
988
|
+
* those, so the relation had no path, returned success:true, and the whiteboard
|
|
989
|
+
* hand never moved (round 7 AA, 2.25). Here:
|
|
990
|
+
* - a path ITEM (targetId other than the source, or params.pathId) is
|
|
991
|
+
* sampled into points in the page, closed taken from the item;
|
|
992
|
+
* - duration (seconds for one traversal) becomes speed;
|
|
993
|
+
* - delay becomes the relation's window start (relation time is
|
|
994
|
+
* window-relative, so the ride starts at `delay`);
|
|
995
|
+
* - with neither points, a path item nor an equation, it is refused by name.
|
|
996
|
+
*/
|
|
997
|
+
function generateMovesAlongPathCode(sourceId, targetId, raw) {
|
|
998
|
+
const { pathId, duration, delay, offset, ...params } = raw;
|
|
999
|
+
const ref = (typeof pathId === 'string' && pathId) || (targetId && targetId !== sourceId ? targetId : null);
|
|
1000
|
+
const off = Array.isArray(offset) && offset.length >= 2 ? [Number(offset[0]) || 0, Number(offset[1]) || 0] : null;
|
|
1001
|
+
const hasDuration = typeof duration === 'number' && duration > 0;
|
|
1002
|
+
const hasDelay = typeof delay === 'number' && delay > 0;
|
|
1003
|
+
return `
|
|
1004
|
+
// moves_along_path: ${sourceId}${ref ? ` along ${ref}` : ''}
|
|
1005
|
+
(function() {
|
|
1006
|
+
const params = ${JSON.stringify(params)};
|
|
1007
|
+
const hasPoints = Array.isArray(params.path) && params.path.length >= 2;
|
|
1008
|
+
// A STUDIO THAT RIDES THE PATH ITSELF. From FxTool c16c9dd3 the rule
|
|
1009
|
+
// declares duration / delay / offset and, with a path item as its target and
|
|
1010
|
+
// no points, follows that path's LIVE geometry on the playhead. Sampling it
|
|
1011
|
+
// into static points here would shadow that, so on such a build everything
|
|
1012
|
+
// is passed straight through and the target is the route.
|
|
1013
|
+
const rule = app.relationRegistry && typeof app.relationRegistry.getRule === 'function'
|
|
1014
|
+
? app.relationRegistry.getRule('moves_along_path') : null;
|
|
1015
|
+
const native = !!(rule && rule.params && rule.params.duration);
|
|
1016
|
+
if (native && !hasPoints && !params.equation ${ref ? '' : '&& false'}) {
|
|
1017
|
+
${hasDuration ? `params.duration = ${duration};` : ''}
|
|
1018
|
+
${hasDelay ? `params.delay = ${delay};` : ''}
|
|
1019
|
+
${off ? `params.offset = ${JSON.stringify(off)};` : ''}
|
|
1020
|
+
const okN = app.addRelation(${JSON.stringify(sourceId)}, ${JSON.stringify(ref ?? sourceId)}, 'moves_along_path', params);
|
|
1021
|
+
if (!okN) return { success: false, error: 'the studio refused the moves_along_path relation.' };
|
|
1022
|
+
app.historyManager.saveState();
|
|
1023
|
+
return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'live-path', target: ${JSON.stringify(ref)}, params: params };
|
|
1024
|
+
}
|
|
1025
|
+
if (!hasPoints && !params.equation) {
|
|
1026
|
+
${ref ? `const e = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(ref)});
|
|
1027
|
+
let p = e && e.item;
|
|
1028
|
+
if (p && p.className !== 'Path' && p.className !== 'CompoundPath' && typeof p.getItem === 'function') {
|
|
1029
|
+
p = p.getItem({ className: 'Path' });
|
|
1030
|
+
}
|
|
1031
|
+
if (!p || typeof p.getPointAt !== 'function' || !(p.length > 0)) {
|
|
1032
|
+
return { success: false, error: ${JSON.stringify(ref)} + ' is not a path with length — moves_along_path needs a path item, or params.path as points [[x, y], …].' };
|
|
1033
|
+
}
|
|
1034
|
+
const N = Math.min(240, Math.max(16, Math.ceil(p.length / 6)));
|
|
1035
|
+
params.path = Array.from({ length: N + 1 }, function(_, i) { const q = p.getPointAt(Math.min(p.length, p.length * i / N)); return [q.x, q.y]; });
|
|
1036
|
+
if (params.closed === undefined) params.closed = !!p.closed;` : `return { success: false, error: 'moves_along_path needs a path: pass targetId (or params.pathId) naming a path item, or params.path as points [[x, y], …], or params.equation. With none, the item would not move.' };`}
|
|
1037
|
+
}
|
|
1038
|
+
${off ? `// offset, with the ENGINE's meaning (c16c9dd3): [dx, dy] is added to the
|
|
1039
|
+
// point on the path to place the item — so a hand whose pen tip sits
|
|
1040
|
+
// (-dx, -dy) from its centre keeps the tip on the stroke. The same value
|
|
1041
|
+
// therefore behaves the same on a build that reads offset natively.
|
|
1042
|
+
if (Array.isArray(params.path)) params.path = params.path.map(function(q) {
|
|
1043
|
+
const x = Array.isArray(q) ? q[0] : q.x, y = Array.isArray(q) ? q[1] : q.y;
|
|
1044
|
+
return [x + ${off[0]}, y + ${off[1]}];
|
|
1045
|
+
});` : ''}
|
|
1046
|
+
${hasDuration ? `if (params.speed === undefined && Array.isArray(params.path)) {
|
|
1047
|
+
const pts = params.path.map(function(q) { return Array.isArray(q) ? { x: q[0], y: q[1] } : q; });
|
|
1048
|
+
const len = pts.slice(1).reduce(function(acc, q, i) { return acc + Math.hypot(q.x - pts[i].x, q.y - pts[i].y); }, 0);
|
|
1049
|
+
if (len > 0) params.speed = len / (150 * ${duration});
|
|
1050
|
+
if (params.closed === undefined) params.closed = false;
|
|
1051
|
+
}` : ''}
|
|
1052
|
+
${hasDelay ? `if (!params.window) params.window = { start: ${delay} };` : ''}
|
|
1053
|
+
// Before its window opens the relation does not run, so the item stays where
|
|
1054
|
+
// it was left — the retest saw it parked at the path END. Put it at the start.
|
|
1055
|
+
if (Array.isArray(params.path) && params.path.length) {
|
|
1056
|
+
const src = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(sourceId)});
|
|
1057
|
+
const q0 = params.path[0];
|
|
1058
|
+
if (src && src.item && src.item.position) { src.item.position.x = Array.isArray(q0) ? q0[0] : q0.x; src.item.position.y = Array.isArray(q0) ? q0[1] : q0.y; }
|
|
1059
|
+
}
|
|
1060
|
+
const ok = app.addRelation(${JSON.stringify(sourceId)}, ${JSON.stringify(sourceId)}, 'moves_along_path', params);
|
|
1061
|
+
if (!ok) return { success: false, error: 'the studio refused the moves_along_path relation.' };
|
|
1062
|
+
app.historyManager.saveState();
|
|
1063
|
+
return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'sampled-points',
|
|
1064
|
+
points: Array.isArray(params.path) ? params.path.length : 0, speed: params.speed, closed: params.closed,
|
|
1065
|
+
${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window };
|
|
1066
|
+
})();`.trim();
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Parameter names a generator's docs used that the engine does not read.
|
|
1070
|
+
*
|
|
1071
|
+
* drawWindField was documented with `direction` and `speed`; the engine reads
|
|
1072
|
+
* windDirection and windSpeed, so the documented call drew wind blowing the
|
|
1073
|
+
* default way (round 8 BB, 5.56). The docs are corrected, and the published
|
|
1074
|
+
* spellings are mapped because callers followed them. An explicit engine name
|
|
1075
|
+
* always wins.
|
|
1076
|
+
*/
|
|
1077
|
+
const GENERATOR_PARAM_ALIASES = {
|
|
1078
|
+
drawWindField: { direction: 'windDirection', speed: 'windSpeed' },
|
|
1079
|
+
};
|
|
1080
|
+
function aliasGeneratorParams(name, params) {
|
|
1081
|
+
const map = GENERATOR_PARAM_ALIASES[name];
|
|
1082
|
+
if (!map)
|
|
1083
|
+
return params;
|
|
1084
|
+
const out = { ...params };
|
|
1085
|
+
for (const [from, to] of Object.entries(map)) {
|
|
1086
|
+
if (out[from] !== undefined && out[to] === undefined) {
|
|
1087
|
+
out[to] = out[from];
|
|
1088
|
+
delete out[from];
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return out;
|
|
1092
|
+
}
|
|
304
1093
|
/**
|
|
305
1094
|
* Template for removing relations
|
|
306
1095
|
*/
|
|
@@ -416,6 +1205,28 @@ function normalizeKeyframePositions(keyframes) {
|
|
|
416
1205
|
/**
|
|
417
1206
|
* Template for keyframe animation
|
|
418
1207
|
*/
|
|
1208
|
+
/**
|
|
1209
|
+
* AN UNKNOWN itemId IS A SILENT SUCCESS, ACROSS A WHOLE FAMILY OF TOOLS.
|
|
1210
|
+
*
|
|
1211
|
+
* `app.addAnimation('c0', …)` on an id that does not exist calls
|
|
1212
|
+
* console.warn and returns undefined — and production strips console.warn, so
|
|
1213
|
+
* over MCP nothing is said at all. The emitter then returned
|
|
1214
|
+
* `{success: true}` because the call did not throw. A pilot animated a
|
|
1215
|
+
* connector id that was never registered and got success for every keyframe.
|
|
1216
|
+
*
|
|
1217
|
+
* Emitted as a shared preamble so the check reads the same everywhere and a
|
|
1218
|
+
* new tool taking an itemId can adopt it in one line. It resolves through the
|
|
1219
|
+
* registry the way the engine does, and names the id it could not find —
|
|
1220
|
+
* "item_7 is not on the canvas" is actionable, silence is not.
|
|
1221
|
+
*/
|
|
1222
|
+
function requireItem(itemId, what) {
|
|
1223
|
+
return `
|
|
1224
|
+
const _target = (app.getItemById && app.getItemById(${JSON.stringify(itemId)}))
|
|
1225
|
+
|| (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}));
|
|
1226
|
+
if (!_target) {
|
|
1227
|
+
return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
|
|
1228
|
+
}`.trim();
|
|
1229
|
+
}
|
|
419
1230
|
function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
|
|
420
1231
|
const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
|
|
421
1232
|
const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
|
|
@@ -428,11 +1239,64 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
428
1239
|
opts.clipOutPoint = clipOutPoint;
|
|
429
1240
|
if (timeUnits !== undefined)
|
|
430
1241
|
opts.timeUnits = timeUnits;
|
|
1242
|
+
// A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
|
|
1243
|
+
// the audio item's sentinel, and the mix reads one static audioGain per clip,
|
|
1244
|
+
// so {audioGain: 0→1} answered success, duration 15, and exported at a flat
|
|
1245
|
+
// level (measured). Refused by name, before anything is added, and only for
|
|
1246
|
+
// audio items — a `volume` key on a shape is some other thing.
|
|
1247
|
+
const levelKeys = [...new Set(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))]
|
|
1248
|
+
.filter((k) => k === 'audioGain' || k === 'volume' || k === 'gain');
|
|
1249
|
+
// UNLESS THE STUDIO ANIMATES IT. From FxTool fcec0194 keyed gain / volume
|
|
1250
|
+
// are real — fades and ducks bake into the export at the measured level —
|
|
1251
|
+
// and listAnimatableProperties lists them with appliesTo 'audio'. That list
|
|
1252
|
+
// is the feature check: a key it names for audio passes through, anything
|
|
1253
|
+
// else on an audio item is still refused (prod, until that build ships).
|
|
1254
|
+
const audioLevelGuard = levelKeys.length === 0 ? '' : `
|
|
1255
|
+
const __ae = app.itemRegistry && app.itemRegistry.get('${itemId}');
|
|
1256
|
+
let __audioKeyed = [];
|
|
1257
|
+
try {
|
|
1258
|
+
const __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null;
|
|
1259
|
+
__audioKeyed = ((__l && __l.properties) || []).filter(function(p) { return p && p.appliesTo === 'audio'; }).map(function(p) { return p.name; });
|
|
1260
|
+
} catch (_) { /* treat as unsupported */ }
|
|
1261
|
+
const __unsupported = ${JSON.stringify(levelKeys)}.filter(function(k) { return __audioKeyed.indexOf(k) === -1; });
|
|
1262
|
+
if (__ae && __ae.type === 'audio' && __unsupported.length) {
|
|
1263
|
+
return { success: false, itemId: '${itemId}', error: __unsupported.join(', ') + ' cannot be keyframed on an audio item in this studio: it mixes each audio clip at one fixed level, so a fade would be accepted and not heard. Nothing was added. '
|
|
1264
|
+
+ (__audioKeyed.length ? 'Keyable on audio here: ' + __audioKeyed.join(', ') + '. ' : '')
|
|
1265
|
+
+ 'Set a static level with pinepaper_modify_item {audioGain: 0..1}.' };
|
|
1266
|
+
}`;
|
|
1267
|
+
// Easing is read from the DESTINATION keyframe, so easing on the first one
|
|
1268
|
+
// never applies — a Ken Burns eased only there rendered linear. Said in the
|
|
1269
|
+
// result rather than silently moved: moving it would change what the other
|
|
1270
|
+
// keys the caller wrote mean.
|
|
1271
|
+
// A KEY THAT LEAVES A PROPERTY OUT (round 9 HH, engine 2.30): the engine
|
|
1272
|
+
// treats the gap as a break in that property's track rather than holding or
|
|
1273
|
+
// interpolating across it, silently. Named here — which keys lack which
|
|
1274
|
+
// properties — with the fix a caller can make now: give every key the full set.
|
|
1275
|
+
const propSets = keyframes.map((k) => Object.keys(k.properties ?? {}));
|
|
1276
|
+
const allProps = [...new Set(propSets.flat())];
|
|
1277
|
+
const gaps = keyframes
|
|
1278
|
+
.map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
|
|
1279
|
+
.filter((g) => g.missing.length);
|
|
1280
|
+
const gapNote = gaps.length && keyframes.length > 1
|
|
1281
|
+
? `, propertyGaps: ${JSON.stringify(gaps.slice(0, 10))}, gapWarning: ${JSON.stringify(`${gaps.length} keyframe(s) leave out a property other keys animate (${[...new Set(gaps.flatMap((g) => g.missing))].join(', ')}). The engine can break that property's track at such a key; repeat the value on every key — hold it by repeating the previous value.`)}`
|
|
1282
|
+
: '';
|
|
1283
|
+
const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
|
|
1284
|
+
// loop:true repeats in the editor; an export of the measured build played
|
|
1285
|
+
// the keyframes ONCE and held (round 7, 2.24). Said where it is set, with
|
|
1286
|
+
// the two ways that do export, until the engine's export clock wraps them.
|
|
1287
|
+
const loopNote = loop
|
|
1288
|
+
? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
|
|
1289
|
+
: '';
|
|
1290
|
+
const easingNote = first?.easing && first.easing !== 'linear'
|
|
1291
|
+
? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
|
|
1292
|
+
: '';
|
|
431
1293
|
return `
|
|
432
1294
|
// Apply keyframe animation to ${itemId}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
(
|
|
1295
|
+
(function() {
|
|
1296
|
+
${requireItem(itemId, 'the animation')}${audioLevelGuard}
|
|
1297
|
+
app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
|
|
1298
|
+
return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote} };
|
|
1299
|
+
})();
|
|
436
1300
|
`.trim();
|
|
437
1301
|
}
|
|
438
1302
|
/**
|
|
@@ -469,17 +1333,41 @@ function generateImportMotionCaptureCode(bvh, mode, skeletonId, opts = {}) {
|
|
|
469
1333
|
})()`;
|
|
470
1334
|
}
|
|
471
1335
|
function generateExecuteGeneratorCode(generatorName, params, region) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1336
|
+
const where = !region ? '' : region.width !== undefined
|
|
1337
|
+
? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})`
|
|
1338
|
+
: ` in the shape of ${region.shape.itemId}`;
|
|
475
1339
|
return `
|
|
476
|
-
// Execute ${generatorName} generator${
|
|
477
|
-
(async function() {
|
|
478
|
-
await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)}, ${JSON.stringify(options)});
|
|
479
|
-
return { success: true, generator: '${generatorName}'${region ? ', region: ' + JSON.stringify(region) : ''} };
|
|
1340
|
+
// Execute ${generatorName} generator${where}
|
|
1341
|
+
(async function() {${emitGeneratorRun(generatorName, JSON.stringify(params, null, 2), region)}
|
|
480
1342
|
})();
|
|
481
1343
|
`.trim();
|
|
482
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Run a generator, optionally into a region, and return its result. Shared by
|
|
1347
|
+
* execute_generator and the batch op. FxTool signature:
|
|
1348
|
+
* app.executeGenerator(name, params, options), region in options (c81781c).
|
|
1349
|
+
*
|
|
1350
|
+
* A SHAPED REGION (c68524a2) falls back to a plain rectangle with only a
|
|
1351
|
+
* console.warn — for a missing item, or one that is not a path — and an older
|
|
1352
|
+
* engine drops the shape without a word (and, given only {itemId}, has no box
|
|
1353
|
+
* at all and draws full-canvas). So the item is checked first and refused by
|
|
1354
|
+
* name, and the result says whether this studio clips to shapes.
|
|
1355
|
+
*/
|
|
1356
|
+
function emitGeneratorRun(generatorName, paramsJson, region) {
|
|
1357
|
+
const options = JSON.stringify(region ? { region } : {});
|
|
1358
|
+
const itemId = region && typeof region.shape === 'object' ? region.shape.itemId : null;
|
|
1359
|
+
const shaped = !!region && region.shape !== undefined && region.shape !== 'rect';
|
|
1360
|
+
return `${itemId ? `
|
|
1361
|
+
{
|
|
1362
|
+
const __se = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(itemId)});
|
|
1363
|
+
const __si = __se && __se.item;
|
|
1364
|
+
if (!__si) return { success: false, error: ${JSON.stringify(`region.shape names no item "${itemId}" — nothing was drawn. List items with pinepaper_get_items.`)} };
|
|
1365
|
+
if (__si.className !== 'Path' && __si.className !== 'CompoundPath') return { success: false, error: ${JSON.stringify(`region.shape item "${itemId}" is a `)} + __si.className + ', and a clip needs a path (a shape, not text, a group or an image) — nothing was drawn.' };
|
|
1366
|
+
}` : ''}
|
|
1367
|
+
await app.executeGenerator('${generatorName}', ${paramsJson}, ${options});${shaped ? `
|
|
1368
|
+
const __shapes = !!(app.generatorRegistry && typeof app.generatorRegistry._regionClipPath === 'function');` : ''}
|
|
1369
|
+
return { success: true, generator: '${generatorName}'${region ? `, region: ${JSON.stringify(region)}` : ''}${shaped ? `, ...(__shapes ? {} : { regionShapeApplied: false, warning: ${JSON.stringify(`this studio does not clip generator regions to a shape, so ${typeof region.shape === 'object' ? 'the region had no box and the generator drew across the whole canvas' : `the ${region.shape} was drawn as a rectangle`}. It needs an engine with region shapes.`)} })` : ''} };`;
|
|
1370
|
+
}
|
|
483
1371
|
/**
|
|
484
1372
|
* Template for applying effects
|
|
485
1373
|
*/
|
|
@@ -784,6 +1672,21 @@ const size = app.getCanvasSize();
|
|
|
784
1672
|
({ width: size.width, height: size.height });
|
|
785
1673
|
`.trim();
|
|
786
1674
|
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Uploaded audio / video, removed through the media pipeline.
|
|
1677
|
+
*
|
|
1678
|
+
* clearCanvas empties the item groups and registries, but an uploaded clip
|
|
1679
|
+
* also lives in the audio / video layers — so a video from the previous job
|
|
1680
|
+
* survived agent_start_job {clearCanvas:true} and played in the next scene
|
|
1681
|
+
* (round 8 retest, 1.66). removeMedia is the full removal (registry entry,
|
|
1682
|
+
* stored blob, layer). Run before clearCanvas; forEach, not a generated loop.
|
|
1683
|
+
*/
|
|
1684
|
+
const MEDIA_PURGE = `
|
|
1685
|
+
let __mediaRemoved = 0;
|
|
1686
|
+
const __PA = (typeof window !== 'undefined') && window.PinePaperAgent;
|
|
1687
|
+
if (__PA && typeof __PA.listMedia === 'function' && typeof __PA.removeMedia === 'function') {
|
|
1688
|
+
__PA.listMedia().forEach(function(m) { try { if (__PA.removeMedia(m.id)) __mediaRemoved++; } catch (_) { /* keep clearing */ } });
|
|
1689
|
+
}`;
|
|
787
1690
|
/**
|
|
788
1691
|
* Template for clearing all items from canvas
|
|
789
1692
|
*/
|
|
@@ -796,6 +1699,7 @@ function generateClearCanvasCode() {
|
|
|
796
1699
|
// entries don't expose those — getItemById wants the registry id), so it
|
|
797
1700
|
// removed 0 items, and it called the non-existent app.clearAllRelations.
|
|
798
1701
|
const before = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
|
|
1702
|
+
${MEDIA_PURGE}
|
|
799
1703
|
|
|
800
1704
|
if (typeof app.clearCanvas === 'function') {
|
|
801
1705
|
app.clearCanvas();
|
|
@@ -811,7 +1715,7 @@ if (app.historyManager) app.historyManager.saveState();
|
|
|
811
1715
|
|
|
812
1716
|
const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
|
|
813
1717
|
|
|
814
|
-
({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining });
|
|
1718
|
+
({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });
|
|
815
1719
|
`.trim();
|
|
816
1720
|
}
|
|
817
1721
|
/**
|
|
@@ -989,64 +1893,100 @@ export function planKeyframeMerges(operations) {
|
|
|
989
1893
|
return plan;
|
|
990
1894
|
}
|
|
991
1895
|
/**
|
|
992
|
-
*
|
|
1896
|
+
* A create / modify snippet as the body of a function that RETURNS its value.
|
|
1897
|
+
* Such a snippet's value is its last top-level (-led statement; the
|
|
1898
|
+
* font-loading form is an async IIFE, which is awaited instead.
|
|
1899
|
+
*/
|
|
1900
|
+
function asReturningBody(snippet) {
|
|
1901
|
+
const single = snippet.trim();
|
|
1902
|
+
if (single.startsWith('(async function()'))
|
|
1903
|
+
return `return await ${single.replace(/;\s*$/, '')};`;
|
|
1904
|
+
const lines = single.split('\n');
|
|
1905
|
+
let at = -1;
|
|
1906
|
+
lines.forEach((l, i) => { if (l.startsWith('('))
|
|
1907
|
+
at = i; });
|
|
1908
|
+
if (at >= 0)
|
|
1909
|
+
lines[at] = 'return ' + lines[at];
|
|
1910
|
+
return lines.join('\n');
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Template for batch create — create_item for each item, one history save.
|
|
1914
|
+
*
|
|
1915
|
+
* This called app.batchCreate, a third create path, and dropped fontWeight
|
|
1916
|
+
* (and every other create_item fix) with no ignoredProperties (round 9 II,
|
|
1917
|
+
* 1.79) — the same drift f1d0b05 removed from agent_batch_execute. Each item
|
|
1918
|
+
* now runs create_item's own emitter with its per-item history save taken
|
|
1919
|
+
* out, and the batch saves once at the end, which is what batchCreate was
|
|
1920
|
+
* for. An item that fails is reported by index; the rest are still created.
|
|
993
1921
|
*/
|
|
994
1922
|
function generateBatchCreateCode(items) {
|
|
995
|
-
const
|
|
1923
|
+
const blocks = items.map((it, i) => {
|
|
1924
|
+
const { x, y, position, ...props } = (it.params || {});
|
|
1925
|
+
const p = Array.isArray(position) ? { x: Number(position[0]), y: Number(position[1]) }
|
|
1926
|
+
: (position && typeof position === 'object') ? { x: Number(position.x), y: Number(position.y) }
|
|
1927
|
+
: { x: typeof x === 'number' ? x : 400, y: typeof y === 'number' ? y : 300 };
|
|
1928
|
+
const given = position !== undefined || typeof x === 'number' || typeof y === 'number';
|
|
1929
|
+
const body = asReturningBody(generateCreateItemCode(it.type, p, props, undefined, given))
|
|
1930
|
+
.replace(/app\.historyManager\.saveState\(\);/g, '');
|
|
1931
|
+
return `
|
|
1932
|
+
try {
|
|
1933
|
+
const r${i} = await (async function() {
|
|
1934
|
+
${body}
|
|
1935
|
+
})();
|
|
1936
|
+
if (r${i} && r${i}.success === false) errors.push({ index: ${i}, error: r${i}.error });
|
|
1937
|
+
else results.push(Object.assign({ index: ${i} }, r${i}));
|
|
1938
|
+
} catch (e) { errors.push({ index: ${i}, error: (e && e.message) || String(e) }); }`;
|
|
1939
|
+
});
|
|
996
1940
|
return `
|
|
997
|
-
// Batch create ${items.length} items
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
position: item.position ? { x: item.position.x, y: item.position.y } : null
|
|
1006
|
-
}));
|
|
1007
|
-
|
|
1008
|
-
({ success: true, items: results, count: results.length });
|
|
1009
|
-
`.trim();
|
|
1941
|
+
// Batch create ${items.length} items — create_item for each, one history save
|
|
1942
|
+
(async function() {
|
|
1943
|
+
const results = [];
|
|
1944
|
+
const errors = [];
|
|
1945
|
+
${blocks.join('\n')}
|
|
1946
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
1947
|
+
return { success: errors.length === 0, items: results, count: results.length, errors: errors };
|
|
1948
|
+
})();`.trim();
|
|
1010
1949
|
}
|
|
1011
1950
|
/**
|
|
1012
|
-
* Template for batch modify
|
|
1951
|
+
* Template for batch modify — modify_item for each item, one history save.
|
|
1952
|
+
*
|
|
1953
|
+
* This called app.batchModify, which applies raw params and so missed every
|
|
1954
|
+
* modify_item fix (text weight / leading, font loading, audio level, no-fill,
|
|
1955
|
+
* lifetimes, smoothing, the unread-property report) — the drift f1d0b05 and
|
|
1956
|
+
* 8f9b075 removed from the other batch paths. Each item now runs modify_item's
|
|
1957
|
+
* own emitter with its history save removed; the batch saves once. The
|
|
1958
|
+
* result keeps the old shape (count, requested, skipped with reasons, and an
|
|
1959
|
+
* error naming the skipped when any were).
|
|
1013
1960
|
*/
|
|
1014
1961
|
function generateBatchModifyCode(modifications) {
|
|
1015
|
-
const
|
|
1962
|
+
const blocks = modifications.map((m, i) => {
|
|
1963
|
+
const snippet = generateModifyItemCode(m.itemId, (m.params || {})).trim()
|
|
1964
|
+
.replace(/app\.historyManager\.saveState\(\);/g, '')
|
|
1965
|
+
.replace(/;\s*$/, '');
|
|
1966
|
+
return `
|
|
1967
|
+
try {
|
|
1968
|
+
const r${i} = await ${snippet};
|
|
1969
|
+
if (r${i} && r${i}.success === false) skipped.push({ itemId: ${JSON.stringify(m.itemId)}, reason: r${i}.error || 'not modified' });
|
|
1970
|
+
else { count++; if (r${i} && r${i}.ignoredProperties) ignored.push({ itemId: ${JSON.stringify(m.itemId)}, ignoredProperties: r${i}.ignoredProperties }); }
|
|
1971
|
+
} catch (e) { skipped.push({ itemId: ${JSON.stringify(m.itemId)}, reason: (e && e.message) || String(e) }); }`;
|
|
1972
|
+
});
|
|
1016
1973
|
return `
|
|
1017
|
-
// Batch modify ${modifications.length} items
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// The engine records what it could not do rather than throwing — read it,
|
|
1034
|
-
// instead of reporting a clean success over a half-applied batch.
|
|
1035
|
-
//
|
|
1036
|
-
// Read defensively (|| []): the property arrived with the engine-side
|
|
1037
|
-
// batchModify fix, so an older studio reports a count and no skip list rather
|
|
1038
|
-
// than failing. The batch still works; only the explanation is thinner.
|
|
1039
|
-
const skipped = app.lastBatchModifySkipped || [];
|
|
1040
|
-
const count = typeof results === 'number' ? results : (Array.isArray(results) ? results.length : 0);
|
|
1041
|
-
|
|
1042
|
-
({
|
|
1043
|
-
success: skipped.length === 0 && count === modifications.length,
|
|
1044
|
-
count,
|
|
1045
|
-
requested: modifications.length,
|
|
1046
|
-
skipped: skipped.map(s => ({ itemId: s.itemId, reason: s.reason })),
|
|
1047
|
-
...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ' + modifications.length + ' items; skipped: ' + skipped.map(s => s.itemId + ' (' + s.reason + ')').join(', ') } : {}),
|
|
1048
|
-
});
|
|
1049
|
-
`.trim();
|
|
1974
|
+
// Batch modify ${modifications.length} items — modify_item for each, one history save
|
|
1975
|
+
(async function() {
|
|
1976
|
+
let count = 0;
|
|
1977
|
+
const skipped = [];
|
|
1978
|
+
const ignored = [];
|
|
1979
|
+
${blocks.join('\n')}
|
|
1980
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
1981
|
+
return {
|
|
1982
|
+
success: skipped.length === 0 && count === ${modifications.length},
|
|
1983
|
+
count: count,
|
|
1984
|
+
requested: ${modifications.length},
|
|
1985
|
+
skipped: skipped,
|
|
1986
|
+
...(ignored.length ? { ignoredProperties: ignored } : {}),
|
|
1987
|
+
...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ${modifications.length} items; skipped: ' + skipped.map(function(s) { return s.itemId + ' (' + s.reason + ')'; }).join(', ') } : {}),
|
|
1988
|
+
};
|
|
1989
|
+
})();`.trim();
|
|
1050
1990
|
}
|
|
1051
1991
|
/**
|
|
1052
1992
|
* Template for create grid - convenience method for creating line grids
|
|
@@ -1103,6 +2043,8 @@ const PLATFORM_TO_CANVAS_PRESET = {
|
|
|
1103
2043
|
web: 'hd-720p', // 1280x720
|
|
1104
2044
|
'print-a4': 'a4-portrait',
|
|
1105
2045
|
'print-letter': 'letter-portrait',
|
|
2046
|
+
'print-a4-landscape': 'a4-landscape',
|
|
2047
|
+
'print-letter-landscape': 'letter-landscape',
|
|
1106
2048
|
};
|
|
1107
2049
|
/** The engine key for a platform, falling back to the value itself. */
|
|
1108
2050
|
export function canvasPresetFor(platform) {
|
|
@@ -1199,12 +2141,56 @@ const INLINE_REMOTE_IMAGES = `
|
|
|
1199
2141
|
return { svg: svgText, notes: notes };
|
|
1200
2142
|
}
|
|
1201
2143
|
`;
|
|
2144
|
+
/**
|
|
2145
|
+
* A HEX COLOUR IN THE 3D WORLD POISONS EVERY LATER EXPORT.
|
|
2146
|
+
*
|
|
2147
|
+
* addWorldObject hands `color` to a WebGL uniform — its own example is
|
|
2148
|
+
* `color: [0.9, 0.3, 0.2]`, three floats in 0..1. Give it '#ef4444' and
|
|
2149
|
+
* uniform3fv throws "cannot be converted to a sequence"; the object is
|
|
2150
|
+
* reported as added, and then EVERY subsequent MP4 export fails, because the
|
|
2151
|
+
* broken uniform is in the scene from then on. Same shape as the tainted
|
|
2152
|
+
* canvas: one bad value, success reported, and the damage surfaces somewhere
|
|
2153
|
+
* else entirely.
|
|
2154
|
+
*
|
|
2155
|
+
* Converted here. A caller writing '#ef4444' is not wrong — it is the colour
|
|
2156
|
+
* spelling every other tool in this package takes — so the emitter absorbs the
|
|
2157
|
+
* difference rather than the caller learning a second convention.
|
|
2158
|
+
*/
|
|
2159
|
+
function world3dColor(value) {
|
|
2160
|
+
if (Array.isArray(value)) {
|
|
2161
|
+
// Already a triple. Accept 0-255 too, since that is the other common form.
|
|
2162
|
+
const nums = value.filter((v) => typeof v === 'number');
|
|
2163
|
+
if (nums.length < 3)
|
|
2164
|
+
return value;
|
|
2165
|
+
return nums.slice(0, 3).map((n) => (n > 1 ? n / 255 : n));
|
|
2166
|
+
}
|
|
2167
|
+
if (typeof value !== 'string')
|
|
2168
|
+
return value;
|
|
2169
|
+
const hex = value.trim().replace(/^#/, '');
|
|
2170
|
+
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
|
2171
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
2172
|
+
return value;
|
|
2173
|
+
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
|
|
2174
|
+
}
|
|
2175
|
+
/** Recursively convert every `color` field in a world3d payload. */
|
|
2176
|
+
function world3dColors(value) {
|
|
2177
|
+
if (Array.isArray(value))
|
|
2178
|
+
return value.map((v) => world3dColors(v));
|
|
2179
|
+
if (!value || typeof value !== 'object')
|
|
2180
|
+
return value;
|
|
2181
|
+
const out = {};
|
|
2182
|
+
for (const [k, v] of Object.entries(value)) {
|
|
2183
|
+
out[k] = k === 'color' ? world3dColor(v) : world3dColors(v);
|
|
2184
|
+
}
|
|
2185
|
+
return out;
|
|
2186
|
+
}
|
|
1202
2187
|
export class PinePaperCodeGenerator {
|
|
1203
2188
|
/**
|
|
1204
2189
|
* Generate code for creating an item
|
|
1205
2190
|
*/
|
|
1206
2191
|
generateCreateItem(input) {
|
|
1207
2192
|
const validated = CreateItemInputSchema.parse(input);
|
|
2193
|
+
const positionGiven = input.position !== undefined;
|
|
1208
2194
|
const properties = { ...validated.properties };
|
|
1209
2195
|
if (validated.animationType !== undefined)
|
|
1210
2196
|
properties.animationType = validated.animationType;
|
|
@@ -1216,14 +2202,16 @@ export class PinePaperCodeGenerator {
|
|
|
1216
2202
|
properties.animationDelay = validated.animationDelay;
|
|
1217
2203
|
if (validated.keyframes !== undefined)
|
|
1218
2204
|
properties.keyframes = validated.keyframes;
|
|
1219
|
-
|
|
2205
|
+
if (validated.anchor !== undefined && properties.anchor === undefined && properties.origin === undefined)
|
|
2206
|
+
properties.anchor = validated.anchor;
|
|
2207
|
+
return generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven);
|
|
1220
2208
|
}
|
|
1221
2209
|
/**
|
|
1222
2210
|
* Generate code for modifying an item
|
|
1223
2211
|
*/
|
|
1224
2212
|
generateModifyItem(input) {
|
|
1225
2213
|
const validated = ModifyItemInputSchema.parse(input);
|
|
1226
|
-
return generateModifyItemCode(validated.itemId, validated.properties);
|
|
2214
|
+
return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
|
|
1227
2215
|
}
|
|
1228
2216
|
/**
|
|
1229
2217
|
* Generate code for deleting an item
|
|
@@ -1258,6 +2246,9 @@ export class PinePaperCodeGenerator {
|
|
|
1258
2246
|
return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
|
|
1259
2247
|
})();`.trim();
|
|
1260
2248
|
}
|
|
2249
|
+
if (validated.relationType === 'moves_along_path') {
|
|
2250
|
+
return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
|
|
2251
|
+
}
|
|
1261
2252
|
return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
|
|
1262
2253
|
}
|
|
1263
2254
|
/**
|
|
@@ -1312,7 +2303,7 @@ export class PinePaperCodeGenerator {
|
|
|
1312
2303
|
*/
|
|
1313
2304
|
generateExecuteGenerator(input) {
|
|
1314
2305
|
const validated = ExecuteGeneratorInputSchema.parse(input);
|
|
1315
|
-
return generateExecuteGeneratorCode(validated.generatorName, validated.params, validated.region);
|
|
2306
|
+
return generateExecuteGeneratorCode(validated.generatorName, aliasGeneratorParams(validated.generatorName, (validated.params ?? {})), validated.region);
|
|
1316
2307
|
}
|
|
1317
2308
|
/**
|
|
1318
2309
|
* Generate code for applying an effect
|
|
@@ -1844,72 +2835,41 @@ if (typeof _gen === 'function') {
|
|
|
1844
2835
|
for (const item of items) {
|
|
1845
2836
|
const { name, itemType, position = { x: 400, y: 300 }, properties = {} } = item;
|
|
1846
2837
|
// Build properties string
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2838
|
+
// CREATE_SCENE HAND-BUILT ITS OWN SHAPES, AND MOST OF THEM WERE WRONG.
|
|
2839
|
+
//
|
|
2840
|
+
// This emitted a per-type switch of raw Paper constructors — a parallel
|
|
2841
|
+
// implementation of app.create covering four cases, whose DEFAULT branch
|
|
2842
|
+
// made a 30px circle. So ellipse, triangle, polygon, path, line and arc —
|
|
2843
|
+
// six of the ten types this schema declares — silently rendered as a
|
|
2844
|
+
// small blue circle, and every property the switch did not name was
|
|
2845
|
+
// dropped. A pilot reported ellipse width/height being ignored; the same
|
|
2846
|
+
// values work through batch because batch calls app.create.
|
|
2847
|
+
//
|
|
2848
|
+
// Routed through app.create now, which is the only thing that knows all
|
|
2849
|
+
// 28 item types, the shape registry's own parameters, anchor, label,
|
|
2850
|
+
// rotation and the registry id. Deleting the copy is the fix: a second
|
|
2851
|
+
// implementation of "make a shape" could only ever drift from the first.
|
|
2852
|
+
const sceneParams = {
|
|
2853
|
+
x: position.x,
|
|
2854
|
+
y: position.y,
|
|
2855
|
+
...properties,
|
|
2856
|
+
};
|
|
1851
2857
|
codeParts.push(`
|
|
1852
2858
|
// Create item: ${name}
|
|
1853
2859
|
(function() {
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
let item;
|
|
1860
|
-
switch ('${itemType}') {
|
|
1861
|
-
case 'circle':
|
|
1862
|
-
item = new paper.Path.Circle({
|
|
1863
|
-
center: [${position.x}, ${position.y}],
|
|
1864
|
-
radius: ${properties.radius || 50},
|
|
1865
|
-
fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
|
|
1866
|
-
parent: app.textItemGroup
|
|
1867
|
-
});
|
|
1868
|
-
break;
|
|
1869
|
-
case 'rectangle':
|
|
1870
|
-
item = new paper.Path.Rectangle({
|
|
1871
|
-
point: [${position.x - (properties.width || 100) / 2}, ${position.y - (properties.height || 60) / 2}],
|
|
1872
|
-
size: [${properties.width || 100}, ${properties.height || 60}],
|
|
1873
|
-
fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
|
|
1874
|
-
parent: app.textItemGroup
|
|
1875
|
-
});
|
|
1876
|
-
break;
|
|
1877
|
-
case 'star':
|
|
1878
|
-
item = new paper.Path.Star({
|
|
1879
|
-
center: [${position.x}, ${position.y}],
|
|
1880
|
-
points: ${properties.points || 5},
|
|
1881
|
-
radius1: ${properties.radius1 || 40},
|
|
1882
|
-
radius2: ${properties.radius2 || 20},
|
|
1883
|
-
fillColor: '${properties.color || properties.fillColor || '#fbbf24'}',
|
|
1884
|
-
parent: app.textItemGroup
|
|
1885
|
-
});
|
|
1886
|
-
break;
|
|
1887
|
-
case 'text':
|
|
1888
|
-
item = new paper.PointText({
|
|
1889
|
-
point: [${position.x}, ${position.y}],
|
|
1890
|
-
content: '${properties.content || 'Text'}',
|
|
1891
|
-
fontSize: ${properties.fontSize || 24},
|
|
1892
|
-
fillColor: '${properties.color || properties.fillColor || '#ffffff'}',
|
|
1893
|
-
fontFamily: '${properties.fontFamily || 'Inter'}',
|
|
1894
|
-
justification: 'center',
|
|
1895
|
-
parent: app.textItemGroup
|
|
1896
|
-
});
|
|
1897
|
-
break;
|
|
1898
|
-
default:
|
|
1899
|
-
item = new paper.Path.Circle({
|
|
1900
|
-
center: [${position.x}, ${position.y}],
|
|
1901
|
-
radius: 30,
|
|
1902
|
-
fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
|
|
1903
|
-
parent: app.textItemGroup
|
|
1904
|
-
});
|
|
2860
|
+
const item = app.create('${itemType}', ${JSON.stringify(sceneParams)});
|
|
2861
|
+
if (!item) {
|
|
2862
|
+
results.items.push({ name: '${name}', error: 'app.create returned nothing for type ${itemType} — check the type and its properties.' });
|
|
2863
|
+
return;
|
|
1905
2864
|
}
|
|
1906
|
-
|
|
1907
|
-
const itemId =
|
|
2865
|
+
// create() registers and mints the id; only fall back for an older studio.
|
|
2866
|
+
const itemId = (item.data && item.data.id)
|
|
2867
|
+
? item.data.id
|
|
2868
|
+
: app.registerItem(item, '${itemType}', { source: 'mcp-scene' });
|
|
1908
2869
|
if (item.bringToFront) item.bringToFront();
|
|
1909
2870
|
nameToId['${name}'] = itemId;
|
|
1910
2871
|
results.items.push({ name: '${name}', itemId, type: '${itemType}' });
|
|
1911
|
-
})();
|
|
1912
|
-
`);
|
|
2872
|
+
})();`);
|
|
1913
2873
|
}
|
|
1914
2874
|
// Establish relations
|
|
1915
2875
|
for (const relation of relations) {
|
|
@@ -1930,8 +2890,12 @@ if (typeof _gen === 'function') {
|
|
|
1930
2890
|
}
|
|
1931
2891
|
// Apply animations
|
|
1932
2892
|
for (const animation of animations) {
|
|
1933
|
-
const { target, type, speed = 1.0, params = {} } = animation;
|
|
1934
|
-
const paramsStr = JSON.stringify({
|
|
2893
|
+
const { target, type, speed = 1.0, startTime, params = {} } = animation;
|
|
2894
|
+
const paramsStr = JSON.stringify({
|
|
2895
|
+
...params,
|
|
2896
|
+
speed,
|
|
2897
|
+
...(startTime !== undefined ? { animationDelay: startTime } : {}),
|
|
2898
|
+
});
|
|
1935
2899
|
codeParts.push(`
|
|
1936
2900
|
// Apply animation: ${type} to ${target}
|
|
1937
2901
|
(function() {
|
|
@@ -1940,8 +2904,13 @@ if (typeof _gen === 'function') {
|
|
|
1940
2904
|
const targetItem = app.itemRegistry.get(targetId);
|
|
1941
2905
|
if (targetItem) {
|
|
1942
2906
|
const params = ${paramsStr};
|
|
2907
|
+
// startTime becomes animationDelay, which is what the engine reads
|
|
2908
|
+
// (PinePaper.js stores it on item.data.animationDelay). These are ambient
|
|
2909
|
+
// loops with no end, so there is no duration to map and none is invented
|
|
2910
|
+
// — staggering the STARTS is what makes a scene read as choreographed
|
|
2911
|
+
// rather than everything moving at once from frame zero.
|
|
1943
2912
|
app.animate(targetItem, { animationType: '${type}', ...params });
|
|
1944
|
-
results.animations.push({ target: '${target}', type: '${type}' });
|
|
2913
|
+
results.animations.push({ target: '${target}', type: '${type}'${startTime !== undefined ? `, startTime: ${startTime}` : ''} });
|
|
1945
2914
|
}
|
|
1946
2915
|
}
|
|
1947
2916
|
})();
|
|
@@ -2032,7 +3001,25 @@ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
|
|
|
2032
3001
|
const connectorId = connector.data?.registryId || connector.id;
|
|
2033
3002
|
app.historyManager.saveState();
|
|
2034
3003
|
|
|
2035
|
-
|
|
3004
|
+
// A REQUESTED id IS NOT NECESSARILY THE id YOU GOT.
|
|
3005
|
+
//
|
|
3006
|
+
// Connector itself honours 'config.id || <generated>', but DiagramSystem.connect
|
|
3007
|
+
// rebuilds the config from an allowlist — routing, lineColor, lineWidth,
|
|
3008
|
+
// headStyle, headSize, boltEnabled — and 'id' is not on it. So a caller's id is
|
|
3009
|
+
// dropped before the Connector sees it and a timestamp id is minted instead,
|
|
3010
|
+
// which then makes update_connector / remove_connector say "not found" for the
|
|
3011
|
+
// id the caller chose. Engine-side, raised with fxtool; until it lands, the
|
|
3012
|
+
// mismatch is REPORTED rather than left for a later call to trip over.
|
|
3013
|
+
({
|
|
3014
|
+
connectorId,
|
|
3015
|
+
sourceItemId: '${sourceItemId}',
|
|
3016
|
+
targetItemId: '${targetItemId}',
|
|
3017
|
+
...(${JSON.stringify(id ?? null)} && connectorId !== ${JSON.stringify(id ?? null)}
|
|
3018
|
+
? { requestedId: ${JSON.stringify(id ?? null)},
|
|
3019
|
+
idHonoured: false,
|
|
3020
|
+
note: 'this studio dropped the id you asked for and minted its own — use connectorId above for update_connector and remove_connector, not the one you passed.' }
|
|
3021
|
+
: {}),
|
|
3022
|
+
});
|
|
2036
3023
|
`.trim();
|
|
2037
3024
|
}
|
|
2038
3025
|
/**
|
|
@@ -2294,7 +3281,7 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2294
3281
|
`;
|
|
2295
3282
|
if (shouldClear) {
|
|
2296
3283
|
code += `
|
|
2297
|
-
// Clear canvas
|
|
3284
|
+
// Clear canvas — uploaded media first (it lives outside the item groups)${MEDIA_PURGE}
|
|
2298
3285
|
if (app.clearCanvas) {
|
|
2299
3286
|
app.clearCanvas();
|
|
2300
3287
|
} else {
|
|
@@ -2357,7 +3344,9 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2357
3344
|
*/
|
|
2358
3345
|
generateAgentEndJob(input) {
|
|
2359
3346
|
const validated = AgentEndJobInputSchema.parse(input);
|
|
2360
|
-
const {
|
|
3347
|
+
const { analyzeContent } = validated;
|
|
3348
|
+
// includeScreenshot is the published name; takeScreenshot the older one.
|
|
3349
|
+
const takeScreenshot = validated.includeScreenshot ?? validated.takeScreenshot;
|
|
2361
3350
|
const shouldAnalyze = analyzeContent !== false;
|
|
2362
3351
|
let code = `
|
|
2363
3352
|
// End agent job
|
|
@@ -2445,12 +3434,16 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2445
3434
|
|
|
2446
3435
|
// Check relations
|
|
2447
3436
|
if (app.relationRegistry) {
|
|
2448
|
-
|
|
2449
|
-
analysis
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
3437
|
+
// getAll() has never existed on the registry, so this branch always took
|
|
3438
|
+
// the empty fallback and every analysis reported NO relations. getStats()
|
|
3439
|
+
// is the real accessor and answers both questions directly:
|
|
3440
|
+
// associationsByType is keyed by relation type.
|
|
3441
|
+
const relStats = typeof app.relationRegistry.getStats === 'function'
|
|
3442
|
+
? app.relationRegistry.getStats()
|
|
3443
|
+
: null;
|
|
3444
|
+
(Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
|
|
2453
3445
|
analysis.relationTypes = Array.from(relationSet);
|
|
3446
|
+
analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
|
|
2454
3447
|
if (analysis.hasRelations) analysis.hasAnimations = true;
|
|
2455
3448
|
}
|
|
2456
3449
|
|
|
@@ -2651,21 +3644,22 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2651
3644
|
generateBatchOperationCode(op, index, mergePlan) {
|
|
2652
3645
|
switch (op.type) {
|
|
2653
3646
|
case 'create': {
|
|
3647
|
+
// THE SAME CREATE AS create_item, NOT A SECOND ONE.
|
|
3648
|
+
//
|
|
3649
|
+
// This case was its own app.create call, and every create_item fix since
|
|
3650
|
+
// — fontWeight / leading, font loading, countdown content, no-fill,
|
|
3651
|
+
// dashes, lifetimes, the unread-property report — passed it by: a batch
|
|
3652
|
+
// read fontWeight 700 back as normal and a countdown stayed at 00:10
|
|
3653
|
+
// for a 12 s MP4 (round 8 DD, 1.67). It now runs generateCreateItemCode,
|
|
3654
|
+
// the exact emitter create_item uses, adapted to return its result
|
|
3655
|
+
// inside this op's function.
|
|
2654
3656
|
const pos = op.position || { x: 400, y: 300 };
|
|
2655
3657
|
const createProps = withRadiusAxes((op.properties || {}));
|
|
2656
|
-
const
|
|
3658
|
+
const returning = asReturningBody(generateCreateItemCode(op.itemType, pos, { ...(op.properties || {}) }, undefined, op.position !== undefined));
|
|
2657
3659
|
let createCode = `
|
|
2658
|
-
const
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
// underlying Paper.js item supports them directly — apply post-create so
|
|
2662
|
-
// callers can pass them inline in `properties` and have them take effect.
|
|
2663
|
-
if (createProps.blendMode !== undefined) {
|
|
2664
|
-
createCode += `\nif ('blendMode' in item) item.blendMode = ${JSON.stringify(createProps.blendMode)};`;
|
|
2665
|
-
}
|
|
2666
|
-
if (createProps.opacity !== undefined) {
|
|
2667
|
-
createCode += `\nif ('opacity' in item) item.opacity = ${JSON.stringify(createProps.opacity)};`;
|
|
2668
|
-
}
|
|
3660
|
+
const __created = await (async function() {
|
|
3661
|
+
${returning}
|
|
3662
|
+
})();`;
|
|
2669
3663
|
// Coordinate-built items (path from segments/pathData, line/arc from
|
|
2670
3664
|
// from/through/to) derive their geometry from those coordinates and
|
|
2671
3665
|
// IGNORE params.position — so `create` at a point silently produced an
|
|
@@ -2677,30 +3671,35 @@ const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item,
|
|
|
2677
3671
|
createCode += `
|
|
2678
3672
|
// create() builds this item type from its own coordinates and ignores
|
|
2679
3673
|
// params.position — apply the caller's explicit position after the fact.
|
|
2680
|
-
|
|
3674
|
+
const __it = __created && __created.itemId ? app.getItemById(__created.itemId) : null;
|
|
3675
|
+
if (__it && __it.position) __it.position = new paper.Point(${pos.x}, ${pos.y});`;
|
|
2681
3676
|
}
|
|
2682
3677
|
createCode += `
|
|
2683
|
-
|
|
2684
|
-
if (item.bringToFront) item.bringToFront();
|
|
2685
|
-
return { itemId };
|
|
3678
|
+
return __created;
|
|
2686
3679
|
`;
|
|
2687
3680
|
return createCode;
|
|
2688
3681
|
}
|
|
2689
|
-
case 'modify':
|
|
3682
|
+
case 'modify': {
|
|
2690
3683
|
const itemRef = op.itemId?.startsWith('$')
|
|
2691
3684
|
? `itemIds[${op.itemId.substring(1)}]`
|
|
2692
3685
|
: `'${op.itemId}'`;
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
//
|
|
2697
|
-
//
|
|
2698
|
-
//
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
3686
|
+
// modify_item's own emitter, as create is create_item's (see above):
|
|
3687
|
+
// the batch's modifyItem call missed text styles, font loading, audio
|
|
3688
|
+
// level, no-fill, lifetimes, smoothing and the unread-property report.
|
|
3689
|
+
// The emitter embeds the id as a literal; a batch ref ($N) is only known
|
|
3690
|
+
// at run time, so it is generated with a placeholder and the runtime
|
|
3691
|
+
// variable put in its place.
|
|
3692
|
+
const single = generateModifyItemCode('__MID__', (op.properties || {})).trim()
|
|
3693
|
+
.replace(/'__MID__'/g, '__mid')
|
|
3694
|
+
.replace(/__MID__/g, "' + __mid + '")
|
|
3695
|
+
.replace(/;\s*$/, '');
|
|
3696
|
+
return `
|
|
3697
|
+
const __mid = ${itemRef};
|
|
3698
|
+
const __r = await ${single};
|
|
3699
|
+
if (__r && __r.success !== false) __r.modified = true;
|
|
3700
|
+
return __r;
|
|
2703
3701
|
`;
|
|
3702
|
+
}
|
|
2704
3703
|
case 'animate':
|
|
2705
3704
|
const animItemRef = op.itemId?.startsWith('$')
|
|
2706
3705
|
? `itemIds[${op.itemId.substring(1)}]`
|
|
@@ -2762,11 +3761,8 @@ return { success: true, backgroundColor: '${bgColor}' };
|
|
|
2762
3761
|
`;
|
|
2763
3762
|
case 'execute_generator':
|
|
2764
3763
|
const genName = op.generatorName || 'drawSunburst';
|
|
2765
|
-
const genParams = JSON.stringify(op.generatorParams || {});
|
|
2766
|
-
|
|
2767
|
-
return `
|
|
2768
|
-
await app.executeGenerator('${genName}', ${genParams}, ${genOptions});
|
|
2769
|
-
return { success: true, generator: '${genName}'${op.generatorRegion ? ', region: ' + JSON.stringify(op.generatorRegion) : ''} };
|
|
3764
|
+
const genParams = JSON.stringify(aliasGeneratorParams(genName, (op.generatorParams || {})));
|
|
3765
|
+
return `${emitGeneratorRun(genName, genParams, op.generatorRegion)}
|
|
2770
3766
|
`;
|
|
2771
3767
|
case 'set_canvas_size': {
|
|
2772
3768
|
const w = op.width || 1080;
|
|
@@ -2941,7 +3937,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2941
3937
|
}
|
|
2942
3938
|
generateAgentExport(input) {
|
|
2943
3939
|
const validated = AgentExportInputSchema.parse(input);
|
|
2944
|
-
const { platform, format, quality, framing, duration, estimateOnly, scale, fps } = validated;
|
|
3940
|
+
const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, ad: adOpts } = validated;
|
|
3941
|
+
// Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
|
|
3942
|
+
const videoEncodeOpts = {};
|
|
3943
|
+
if (broadcast)
|
|
3944
|
+
videoEncodeOpts.broadcast = true;
|
|
3945
|
+
// Headroom defaults to 12 with broadcast (retest of caa9560): on a
|
|
3946
|
+
// hard-edged white-on-black scene it measured 17-233 with nothing out of
|
|
3947
|
+
// range, where 0 left 0.34% out and a post-hoc clip made it worse. 0 is
|
|
3948
|
+
// still available by asking for it.
|
|
3949
|
+
const headroomDefaulted = !!broadcast && broadcastHeadroom === undefined;
|
|
3950
|
+
if (broadcast)
|
|
3951
|
+
videoEncodeOpts.broadcastHeadroom = broadcastHeadroom ?? 12;
|
|
3952
|
+
if (bitrate !== undefined)
|
|
3953
|
+
videoEncodeOpts.bitrate = bitrate;
|
|
3954
|
+
if (minBitrate !== undefined)
|
|
3955
|
+
videoEncodeOpts.minBitrate = minBitrate;
|
|
3956
|
+
if (bitrateMode !== undefined)
|
|
3957
|
+
videoEncodeOpts.bitrateMode = bitrateMode;
|
|
3958
|
+
const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
|
|
2945
3959
|
const qualityLevel = quality || 'standard';
|
|
2946
3960
|
const videoDuration = duration ?? 5;
|
|
2947
3961
|
// Quality settings (bitrate in bps for VideoEncoder)
|
|
@@ -2962,6 +3976,8 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2962
3976
|
'web': { width: 800, height: 600, staticFormat: 'svg', animatedFormat: 'svg' },
|
|
2963
3977
|
'print-a4': { width: 2480, height: 3508, staticFormat: 'pdf', animatedFormat: 'pdf' },
|
|
2964
3978
|
'print-letter': { width: 2550, height: 3300, staticFormat: 'pdf', animatedFormat: 'pdf' },
|
|
3979
|
+
'print-a4-landscape': { width: 3508, height: 2480, staticFormat: 'pdf', animatedFormat: 'pdf' },
|
|
3980
|
+
'print-letter-landscape': { width: 3300, height: 2550, staticFormat: 'pdf', animatedFormat: 'pdf' },
|
|
2965
3981
|
};
|
|
2966
3982
|
const preset = platformPresets[platform] || platformPresets['web'];
|
|
2967
3983
|
// SCALE is the only honest size control this surface has.
|
|
@@ -3008,6 +4024,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3008
4024
|
// for by name. Dimensions, framing, fps and bitrate are all absent rather
|
|
3009
4025
|
// than passed and ignored.
|
|
3010
4026
|
// @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio ensureHeavyModules
|
|
4027
|
+
// CAPTIONS AS A FILE (round 8 EE, 8.19). format 'srt' was "Invalid input",
|
|
4028
|
+
// yet caption text staged with bornAt / ttl already carries frame-exact
|
|
4029
|
+
// times on item.data — the cues are in the scene. Every text item with a
|
|
4030
|
+
// lifetime becomes one cue, in start order; a text item without one is
|
|
4031
|
+
// on screen for the whole piece and is not a caption, so it is left out
|
|
4032
|
+
// and counted.
|
|
4033
|
+
// scc (CEA-608, 8.37) takes the same cues; the server encodes them
|
|
4034
|
+
// (src/utils/scc.ts), where the byte work is testable.
|
|
4035
|
+
if (exportFormat === 'srt' || exportFormat === 'vtt' || exportFormat === 'scc') {
|
|
4036
|
+
const fmt = exportFormat;
|
|
4037
|
+
return `
|
|
4038
|
+
// Export: captions (${fmt}) from text items with a lifetime
|
|
4039
|
+
(function() {
|
|
4040
|
+
if (!app.itemRegistry || typeof app.itemRegistry.getAll !== 'function') {
|
|
4041
|
+
return { success: false, format: '${fmt}', error: 'this studio cannot list items (app.itemRegistry.getAll) — update PinePaper Studio.' };
|
|
4042
|
+
}
|
|
4043
|
+
const texts = app.itemRegistry.getAll().filter(function(e) {
|
|
4044
|
+
const it = e && e.item;
|
|
4045
|
+
return it && it.className === 'PointText' && typeof it.content === 'string' && it.content.trim() !== '';
|
|
4046
|
+
});
|
|
4047
|
+
const timed = texts.filter(function(e) { return e.item.data && typeof e.item.data.bornAt === 'number'; });
|
|
4048
|
+
if (!timed.length) {
|
|
4049
|
+
return { success: false, format: '${fmt}', error: 'no captions to export: no text item has a lifetime. Stage each caption with properties.bornAt / ttl (seconds) on create_item or modify_item, then export ${fmt}.', untimedText: texts.length };
|
|
4050
|
+
}
|
|
4051
|
+
const end = ${videoDuration};
|
|
4052
|
+
const ts = function(t, sep) {
|
|
4053
|
+
const ms = Math.max(0, Math.round(t * 1000));
|
|
4054
|
+
const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60, r = ms % 1000;
|
|
4055
|
+
const p = function(n, w) { return String(n).padStart(w, '0'); };
|
|
4056
|
+
return p(h, 2) + ':' + p(m, 2) + ':' + p(s, 2) + sep + p(r, 3);
|
|
4057
|
+
};
|
|
4058
|
+
const cues = timed.map(function(e) {
|
|
4059
|
+
const d = e.item.data;
|
|
4060
|
+
const start = d.bornAt;
|
|
4061
|
+
const stop = typeof d.ttl === 'number' ? start + d.ttl : end;
|
|
4062
|
+
return { start: start, stop: Math.max(start, stop), text: e.item.content.trim() };
|
|
4063
|
+
}).sort(function(a, b) { return a.start - b.start; });
|
|
4064
|
+
${fmt === 'scc' ? ` return { success: true, format: 'scc', sccCues: cues, cues: cues.length, untimedText: texts.length - timed.length };
|
|
4065
|
+
` : ''} const body = cues.map(function(c, i) {
|
|
4066
|
+
return ${fmt === 'srt' ? `(i + 1) + '\\n' + ts(c.start, ',') + ' --> ' + ts(c.stop, ',')` : `ts(c.start, '.') + ' --> ' + ts(c.stop, '.')`} + '\\n' + c.text;
|
|
4067
|
+
}).join('\\n\\n') + '\\n';
|
|
4068
|
+
const text = ${fmt === 'vtt' ? `'WEBVTT\\n\\n' + body` : 'body'};
|
|
4069
|
+
return { success: true, format: '${fmt}', data: text, mimeType: '${fmt === 'srt' ? 'application/x-subrip' : 'text/vtt'}', size: text.length, cues: cues.length, untimedText: texts.length - timed.length };
|
|
4070
|
+
})();`.trim();
|
|
4071
|
+
}
|
|
3011
4072
|
if (exportFormat === 'wav') {
|
|
3012
4073
|
const sr = validated.sampleRate ?? 48000;
|
|
3013
4074
|
const bd = validated.bitDepth ?? 16;
|
|
@@ -3085,7 +4146,30 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3085
4146
|
const __even = (n) => Math.max(2, Math.round((n * ${scale !== undefined ? scale : 1}) / 2) * 2);
|
|
3086
4147
|
const dimensions = (__canvasDims && __canvasDims.width && __canvasDims.height)
|
|
3087
4148
|
? { width: __even(__canvasDims.width), height: __even(__canvasDims.height) }
|
|
3088
|
-
: __presetDims
|
|
4149
|
+
: __presetDims;
|
|
4150
|
+
const __fit = null;` : ` // A PRESET IS A SIZE, NOT PERMISSION TO STRETCH.
|
|
4151
|
+
//
|
|
4152
|
+
// 'instagram' is 1080x1080, and a 4:5 (1080x1350) canvas exported under it
|
|
4153
|
+
// came out squashed to 0.8 vertically, text distorted, with nothing said —
|
|
4154
|
+
// 4:5 is itself a valid Instagram feed size. When the canvas aspect differs
|
|
4155
|
+
// from the preset's, the canvas aspect is kept and its SHORT edge takes the
|
|
4156
|
+
// preset's short edge: 4:5 on instagram is 1080x1350, a square canvas on
|
|
4157
|
+
// youtube is 1080x1080. The result says so in platformFit.
|
|
4158
|
+
const __presetBox = ${JSON.stringify(scaled)};
|
|
4159
|
+
const __cv = app.canvasSize || (app.view && app.view.viewSize) || null;
|
|
4160
|
+
let dimensions = __presetBox;
|
|
4161
|
+
let __fit = null;
|
|
4162
|
+
if (__cv && __cv.width > 0 && __cv.height > 0) {
|
|
4163
|
+
const ca = __cv.width / __cv.height, pa = __presetBox.width / __presetBox.height;
|
|
4164
|
+
if (Math.abs(ca / pa - 1) > 0.01) {
|
|
4165
|
+
const short = Math.min(__presetBox.width, __presetBox.height);
|
|
4166
|
+
const ev = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
4167
|
+
dimensions = ca >= 1 ? { width: ev(short * ca), height: ev(short) } : { width: ev(short), height: ev(short / ca) };
|
|
4168
|
+
__fit = { preset: __presetBox.width + 'x' + __presetBox.height, used: dimensions.width + 'x' + dimensions.height,
|
|
4169
|
+
canvas: __cv.width + 'x' + __cv.height,
|
|
4170
|
+
note: 'the canvas aspect differs from the ' + platform + ' preset, so the canvas aspect was kept rather than stretched. Resize the canvas to the preset to get exactly ' + __presetBox.width + 'x' + __presetBox.height + '.' };
|
|
4171
|
+
}
|
|
4172
|
+
}`}
|
|
3089
4173
|
|
|
3090
4174
|
// WHAT THIS SCENE LOSES TO THIS FORMAT, asked at the moment you would care.
|
|
3091
4175
|
//
|
|
@@ -3094,17 +4178,97 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3094
4178
|
// silence. The engine derived it so that 117 tool descriptions would not
|
|
3095
4179
|
// have to answer "does this survive export?" by hand and drift.
|
|
3096
4180
|
//
|
|
3097
|
-
//
|
|
3098
|
-
//
|
|
3099
|
-
//
|
|
3100
|
-
//
|
|
4181
|
+
// THREE STATES, ALL DISTINGUISHABLE. This used to be reported only when
|
|
4182
|
+
// there was something to report, so that an empty list could not be misread
|
|
4183
|
+
// as "this format is lossless". That solved one misreading and created a
|
|
4184
|
+
// worse one: a caller could not tell "checked, nothing lost" from "never
|
|
4185
|
+
// checked", and a pilot reported fidelity as null in EVERY scene with no way
|
|
4186
|
+
// to know whether that was good news or a broken check.
|
|
4187
|
+
//
|
|
4188
|
+
// So it is always present: available:false when the studio cannot check,
|
|
4189
|
+
// warnings:[] plus a note when it checked and found nothing, and the
|
|
4190
|
+
// warnings themselves otherwise. The note carries the caveat that absence
|
|
4191
|
+
// was standing in for.
|
|
4192
|
+
//
|
|
4193
|
+
// STAND-INS ARE THIS TOOL'S OWN DOING, SO THIS TOOL REPORTS THEM. create_item
|
|
4194
|
+
// draws 'shader' and 'field' locally as a flat plate tagged data.renderAs; the
|
|
4195
|
+
// engine's check sees an ordinary rectangle and cannot know a surface was
|
|
4196
|
+
// meant. So a local export of a sea came out a flat blue slab with nothing
|
|
4197
|
+
// said. Reported whatever the engine check can or cannot do.
|
|
4198
|
+
function standIns() {
|
|
4199
|
+
try {
|
|
4200
|
+
// Probed with === : a studio without it degrades to "no stand-ins".
|
|
4201
|
+
const canList = app.itemRegistry && typeof app.itemRegistry.getAll === 'function';
|
|
4202
|
+
if (!canList) return [];
|
|
4203
|
+
const found = {};
|
|
4204
|
+
for (const entry of app.itemRegistry.getAll()) {
|
|
4205
|
+
const kind = entry && entry.item && entry.item.data && entry.item.data.renderAs;
|
|
4206
|
+
if (kind === 'shader' || kind === 'field') (found[kind] = found[kind] || []).push(entry.itemId);
|
|
4207
|
+
}
|
|
4208
|
+
return Object.keys(found).map(function(kind) {
|
|
4209
|
+
const ids = found[kind];
|
|
4210
|
+
return {
|
|
4211
|
+
code: 'render_time_surface_stand_in',
|
|
4212
|
+
message: ids.length + ' ' + kind + ' item' + (ids.length > 1 ? 's are' : ' is')
|
|
4213
|
+
+ ' exported as the flat plate that stands in for ' + (ids.length > 1 ? 'them' : 'it')
|
|
4214
|
+
+ ' on a local canvas. The surface itself is drawn only by a cloud render.',
|
|
4215
|
+
items: ids.slice(0, 6),
|
|
4216
|
+
};
|
|
4217
|
+
});
|
|
4218
|
+
} catch (e) { return []; }
|
|
4219
|
+
}
|
|
4220
|
+
// TRANSPARENCY THE FORMAT CANNOT CARRY. A scene with no background exported
|
|
4221
|
+
// to webm came out on BLACK while the engine's check said it lost nothing
|
|
4222
|
+
// (round 7, 8.12). Only said when the scene is evidently transparent — no
|
|
4223
|
+
// solid CSS background colour AND nothing in the background layer — so a
|
|
4224
|
+
// generator or pattern backdrop never trips it.
|
|
4225
|
+
function alphaLoss(fmt) {
|
|
4226
|
+
try {
|
|
4227
|
+
if (['mp4', 'webm', 'jpg'${format === 'apng' && transparent === false ? ", 'apng'" : ''}].indexOf(fmt) === -1) return [];
|
|
4228
|
+
const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
|
|
4229
|
+
const hasColor = !!bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg);
|
|
4230
|
+
const hasBgItems = !!(app.patternGroup && app.patternGroup.children && app.patternGroup.children.length);
|
|
4231
|
+
if (hasColor || hasBgItems) return [];
|
|
4232
|
+
return [{ code: 'alpha_dropped', message: fmt === 'apng'
|
|
4233
|
+
? 'apng with transparent: false and no background colour: the frames are filled BLACK. Set a background colour, or drop transparent: false to keep the alpha.'
|
|
4234
|
+
: fmt === 'jpg'
|
|
4235
|
+
? 'jpg has no transparency and this scene has no background, so transparent areas are filled white. Use png or webp to keep them.'
|
|
4236
|
+
: fmt + ' is exported without transparency and this scene has no background, so transparent areas come out BLACK. Set a background colour, or export apng (animated, full alpha) or png to keep it.' }];
|
|
4237
|
+
} catch (e) { return []; }
|
|
4238
|
+
}
|
|
4239
|
+
// TEXT THAT DOES NOT FIT (FxTool 661224ef: textOverflowReport) — text past
|
|
4240
|
+
// its fit box at the minimum size, or off the frame. The pre-batch check for
|
|
4241
|
+
// certificates, speaker cards and localised variants, on every export.
|
|
4242
|
+
function textOverflow() {
|
|
4243
|
+
try {
|
|
4244
|
+
const rep = (typeof app.textOverflowReport === 'function' && app.textOverflowReport()) || [];
|
|
4245
|
+
return rep.slice(0, 10).map(function(o) {
|
|
4246
|
+
return { code: 'text_overflow', message: (o.id || 'a text item') + ' (' + JSON.stringify(String(o.content || '').slice(0, 40)) + '): ' + (o.reason || 'overflows'), items: [o.id] };
|
|
4247
|
+
});
|
|
4248
|
+
} catch (e) { return []; }
|
|
4249
|
+
}
|
|
3101
4250
|
function fidelity(fmt) {
|
|
4251
|
+
const own = standIns().concat(alphaLoss(fmt), textOverflow());
|
|
3102
4252
|
try {
|
|
3103
|
-
if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function')
|
|
4253
|
+
if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') {
|
|
4254
|
+
return { fidelity: { available: false, reason: 'this studio cannot check export fidelity — update PinePaper Studio.', ...(own.length ? { warnings: own } : {}) } };
|
|
4255
|
+
}
|
|
3104
4256
|
const r = app.exportEngine.exportFidelity(fmt);
|
|
3105
|
-
if (!r
|
|
3106
|
-
|
|
3107
|
-
|
|
4257
|
+
if (!r) { return { fidelity: { available: false, reason: 'the fidelity check returned nothing.', ...(own.length ? { warnings: own } : {}) } }; }
|
|
4258
|
+
const warnings = (r.warnings || []).concat(own);
|
|
4259
|
+
return {
|
|
4260
|
+
fidelity: {
|
|
4261
|
+
available: true,
|
|
4262
|
+
warnings: warnings,
|
|
4263
|
+
checked: r.checked,
|
|
4264
|
+
...(warnings.length === 0
|
|
4265
|
+
? { note: 'this scene loses nothing to ' + fmt + '. That is a statement about THIS SCENE, not a claim that the format is lossless.' }
|
|
4266
|
+
: {}),
|
|
4267
|
+
},
|
|
4268
|
+
};
|
|
4269
|
+
} catch (e) {
|
|
4270
|
+
return { fidelity: { available: false, reason: (e && e.message) || 'the fidelity check threw.', ...(own.length ? { warnings: own } : {}) } };
|
|
4271
|
+
}
|
|
3108
4272
|
}
|
|
3109
4273
|
|
|
3110
4274
|
// Preflight: same resolved settings as the real export, but render nothing.
|
|
@@ -3146,8 +4310,8 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3146
4310
|
// drives motion during export — framing only fixes the output frame.
|
|
3147
4311
|
let cameraDims = null;
|
|
3148
4312
|
if (framing === 'camera') {
|
|
3149
|
-
if (!['gif', 'mp4', 'webm'].includes(format)) {
|
|
3150
|
-
return { success: false, platform, format, framing, error: 'framing: "camera" is only supported for
|
|
4313
|
+
if (!['gif', 'apng', 'mp4', 'webm'].includes(format)) {
|
|
4314
|
+
return { success: false, platform, format, framing, error: 'framing: "camera" is only supported for animated formats (gif, apng, mp4, webm). Use format: "mp4" / "gif" / "webm" or omit framing.' };
|
|
3151
4315
|
}
|
|
3152
4316
|
const rr = app.relationRegistry;
|
|
3153
4317
|
const hasWalkthrough = rr && typeof rr.hasCameraAnimation === 'function' && rr.hasCameraAnimation();
|
|
@@ -3158,19 +4322,99 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3158
4322
|
if (!params || (params.mode || 'keyframes') !== 'keyframes' || !params.keyframes || !params.keyframes.length) {
|
|
3159
4323
|
return { success: false, platform, format, framing, error: 'framing: "camera" requires a keyframe-mode camera walkthrough with at least one keyframe.' };
|
|
3160
4324
|
}
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
4325
|
+
// MATCH THE ENGINE'S FRAME EXACTLY, AND DO NOT SECOND-GUESS THE ZOOM.
|
|
4326
|
+
//
|
|
4327
|
+
// This divided canvasEl.width — device pixels — so a camera export came
|
|
4328
|
+
// out 2234x1472 whatever the board was. That is fixed by reading the
|
|
4329
|
+
// artboard, and the chain below is deliberately the same one the engine's
|
|
4330
|
+
// own _exportFrameSize() uses: getCanvasSize(), then app.canvasSize, then
|
|
4331
|
+
// a known frame. Two places computing one number is how they drift.
|
|
4332
|
+
//
|
|
4333
|
+
// The zoom divide is gone too, and that is the part worth explaining.
|
|
4334
|
+
// VideoExporter._frameCropRect returns the WHOLE canvas element whenever a
|
|
4335
|
+
// camera animation exists — the camera owns the view, so it captures
|
|
4336
|
+
// everything the camera frames. Dividing by the first keyframe's zoom
|
|
4337
|
+
// therefore never reframed anything; it only shrank the output, so a
|
|
4338
|
+
// zoom-2 export produced a half-size video of the same content. Asking for
|
|
4339
|
+
// the artboard's own size is both simpler and right.
|
|
4340
|
+
//
|
|
4341
|
+
// NOTE: the engine currently scales that whole-canvas crop into the output
|
|
4342
|
+
// frame without preserving aspect, so the picture is squashed by the ratio
|
|
4343
|
+
// between the workspace canvas and the board (measured: 1.17 landscape,
|
|
4344
|
+
// 0.37 portrait). That is VideoExporter._frameCropRect, not this — raised
|
|
4345
|
+
// with fxtool-f2. Matching frames here means that when it is fixed, these
|
|
4346
|
+
// two cannot disagree about the aspect on top of it.
|
|
4347
|
+
let camBase = null;
|
|
4348
|
+
if (typeof app.getCanvasSize === 'function') {
|
|
4349
|
+
const cs = app.getCanvasSize();
|
|
4350
|
+
if (cs && cs.width > 0) camBase = { width: cs.width, height: cs.height };
|
|
4351
|
+
}
|
|
4352
|
+
if (!camBase && app.canvasSize && app.canvasSize.width > 0) {
|
|
4353
|
+
camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
|
|
4354
|
+
}
|
|
4355
|
+
if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
|
|
4356
|
+
cameraDims = { width: camBase.width, height: camBase.height };
|
|
3168
4357
|
}
|
|
3169
4358
|
|
|
3170
|
-
|
|
4359
|
+
// AN EXPORTER ANSWERS A BLOB OR A RECORD AROUND ONE.
|
|
4360
|
+
//
|
|
4361
|
+
// Declared ABOVE the switch. They used to be consts inside the video case's
|
|
4362
|
+
// block, so the pdf case — which 04ea8d2 pointed at asBlob — threw
|
|
4363
|
+
// "asBlob is not defined" on every PDF export.
|
|
4364
|
+
//
|
|
4365
|
+
// exportPDF resolves to {blob, width, height, format}, and this passed
|
|
4366
|
+
// the whole record to FileReader.readAsDataURL — "parameter 1 is not of
|
|
4367
|
+
// type 'Blob'", every PDF export, with an error naming FileReader and
|
|
4368
|
+
// nothing about PDFs. The engine has the same normaliser for the same
|
|
4369
|
+
// reason (AgentMode._asBlob), so this matches its behaviour rather than
|
|
4370
|
+
// inventing a second convention.
|
|
4371
|
+
const asBlob = (r) => {
|
|
4372
|
+
if (typeof Blob !== 'undefined' && r instanceof Blob) return r;
|
|
4373
|
+
if (r && typeof Blob !== 'undefined' && r.blob instanceof Blob) return r.blob;
|
|
4374
|
+
// Duck-typed last resort: a studio may hand back something Blob-like
|
|
4375
|
+
// that fails instanceof across a realm boundary.
|
|
4376
|
+
if (r && typeof r.size === 'number' && typeof r.slice === 'function') return r;
|
|
4377
|
+
if (r && r.blob && typeof r.blob.size === 'number') return r.blob;
|
|
4378
|
+
return null;
|
|
4379
|
+
};
|
|
4380
|
+
const blobToDataUrl = (b) => new Promise(resolve => {
|
|
4381
|
+
const r = new FileReader();
|
|
4382
|
+
r.onloadend = () => resolve(r.result);
|
|
4383
|
+
r.readAsDataURL(b);
|
|
4384
|
+
});
|
|
3171
4385
|
|
|
4386
|
+
let result = { success: false, platform, format, quality, framing };
|
|
4387
|
+
// A STILL SHOWS WHAT IS ALIVE AT ITS MOMENT. Lifetimes that fall back to the
|
|
4388
|
+
// MCP's frame callback are applied by the frame loop, and a seek does not run
|
|
4389
|
+
// it — so a png at time 0.8 s drew nothing staged at 0.8 s, and an item past
|
|
4390
|
+
// its ttl still showed (retest). The visibility pass runs here for stills.
|
|
4391
|
+
if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1) {
|
|
4392
|
+
const __L = globalThis.__ppMcp;
|
|
4393
|
+
if (__L && __L.lifetimeApp === app && typeof __L.applyLifetimes === 'function') {
|
|
4394
|
+
try { __L.applyLifetimes(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"}); } catch (_) { /* the still renders either way */ }
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
// The engine's report on the audio track of THIS export (FxTool b41860aa:
|
|
4398
|
+
// encoder delay compensated, padding past the picture dropped). Cleared
|
|
4399
|
+
// first so a report left by an earlier export is never read as this one's.
|
|
4400
|
+
const __vx = app.exportEngine && app.exportEngine.videoExporter;
|
|
4401
|
+
if (__vx && 'lastAudioReport' in __vx) __vx.lastAudioReport = null;
|
|
4402
|
+
if (__vx && 'lastVideoReport' in __vx) __vx.lastVideoReport = null;
|
|
4403
|
+
${stillTime !== undefined ? `
|
|
4404
|
+
// A STILL AT A CHOSEN MOMENT (round 7 X, 1.57). Without this a png is
|
|
4405
|
+
// whatever frame the playhead is on — two identical builds gave PNGs that
|
|
4406
|
+
// differed in the animated streak while their MP4s were byte-identical.
|
|
4407
|
+
// Seek, render, and put the playhead back afterwards.
|
|
4408
|
+
const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
|
|
4409
|
+
if (typeof app.setPlaybackTime !== 'function') {
|
|
4410
|
+
return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen time.' };
|
|
4411
|
+
}
|
|
4412
|
+
app.setPlaybackTime(${stillTime});
|
|
4413
|
+
if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
|
|
4414
|
+
` : ''}
|
|
3172
4415
|
try {
|
|
3173
|
-
|
|
4416
|
+
// jpg / webp render as png, then re-encode below.
|
|
4417
|
+
switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {
|
|
3174
4418
|
case 'svg':
|
|
3175
4419
|
const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();
|
|
3176
4420
|
result = {
|
|
@@ -3185,15 +4429,92 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3185
4429
|
break;
|
|
3186
4430
|
|
|
3187
4431
|
case 'png':
|
|
4432
|
+
${region ? `// A REGION, not the whole board — carousel slices, crops. renderRegionToDataURL
|
|
4433
|
+
// re-renders that world rect offscreen without touching the live view;
|
|
4434
|
+
// it was reachable only from custom code. A differing output aspect is
|
|
4435
|
+
// covered by the engine, not stretched.
|
|
4436
|
+
if (typeof app.renderRegionToDataURL !== 'function') {
|
|
4437
|
+
result = { success: false, platform, format: 'png', error: 'app.renderRegionToDataURL unavailable — update PinePaper Studio to export a region.' };
|
|
4438
|
+
break;
|
|
4439
|
+
}
|
|
4440
|
+
{
|
|
4441
|
+
const rw = ${region.outputWidth ?? Math.round(region.width)}, rh = ${region.outputHeight ?? Math.round(region.height)};
|
|
4442
|
+
// OWNERSHIP BY CENTRE (1.78, FxTool 78ec5916): excludeForeign hides, for
|
|
4443
|
+
// this render, a top-level item that reaches in but is centred outside —
|
|
4444
|
+
// a neighbouring card's overflowing headline. Older studios ignore the
|
|
4445
|
+
// fourth argument, and leave lastRegionExcluded unset.
|
|
4446
|
+
// @engine-surface-exempt lastRegionExcluded — a report property, not a
|
|
4447
|
+
// method, so it cannot be typeof-probed; read only through Array.isArray.
|
|
4448
|
+
// Branch-only until 78ec5916 reaches origin/main.
|
|
4449
|
+
if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
|
|
4450
|
+
const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
|
|
4451
|
+
const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
|
|
4452
|
+
// ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
|
|
4453
|
+
// recipe — one big canvas, one region per card — text that overflows
|
|
4454
|
+
// its cell spills into the next card's export (up to 5,826 foreign px),
|
|
4455
|
+
// silently. A region cannot know which card an item belongs to, so it
|
|
4456
|
+
// cannot clip per card; it can name every item that is only partly
|
|
4457
|
+
// inside it, which is exactly the overflow in either direction.
|
|
4458
|
+
const __rx = ${region.x}, __ry = ${region.y}, __rr = ${region.x + region.width}, __rb = ${region.y + region.height};
|
|
4459
|
+
const crossing = [], coveringExcluded = [];
|
|
4460
|
+
if (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
|
|
4461
|
+
app.itemRegistry.getAll().forEach(function(e) {
|
|
4462
|
+
const b = e && e.item && e.item.visible !== false && e.item.bounds;
|
|
4463
|
+
if (!b || !(b.width > 0)) return;
|
|
4464
|
+
const r = b.x + b.width, bt = b.y + b.height;
|
|
4465
|
+
const intersects = b.x < __rr && r > __rx && b.y < __rb && bt > __ry;
|
|
4466
|
+
const inside = b.x >= __rx - 0.5 && r <= __rr + 0.5 && b.y >= __ry - 0.5 && bt <= __rb + 0.5;
|
|
4467
|
+
const covers = b.x <= __rx && r >= __rr && b.y <= __ry && bt >= __rb; // a full-bleed backdrop is not overflow
|
|
4468
|
+
if (intersects && !inside && !covers && crossing.length < 20) crossing.push(e.itemId);
|
|
4469
|
+
if (covers && __excluded && (__excluded.indexOf(e.itemId) >= 0 || (e.item.data && __excluded.indexOf(e.item.data.id) >= 0))) coveringExcluded.push(e.itemId);
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
result = { success: true, platform, format: 'png', data: regionUrl, mimeType: 'image/png',
|
|
4473
|
+
size: Math.round(String(regionUrl).length * 0.75), dimensions: { width: rw, height: rh },
|
|
4474
|
+
region: ${JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height })} };
|
|
4475
|
+
if (__excluded && __excluded.length) {
|
|
4476
|
+
result.excludedItems = __excluded;
|
|
4477
|
+
result.excludedNote = 'centred outside this region, so treated as belonging to a neighbouring region and left out of this image.';
|
|
4478
|
+
}
|
|
4479
|
+
if (coveringExcluded.length) {
|
|
4480
|
+
result.warning = 'backdrop item(s) ' + coveringExcluded.join(', ') + ' cover this whole region but are centred outside it, so they were left out as foreign — this image has no background from them. Re-export with region.excludeForeign: false, or give each card its own backdrop.';
|
|
4481
|
+
}
|
|
4482
|
+
if (crossing.length) {
|
|
4483
|
+
result.crossingItems = crossing;
|
|
4484
|
+
result.warning = (result.warning ? result.warning + ' ' : '') + crossing.length + ' item(s) are only partly inside this region, so part of them is cut off here or spills into a neighbouring region: ' + crossing.join(', ') + '. In a sheet of cards this is text overflowing its card — shorten it, shrink it, or wrap it.';
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
break;` : ''}
|
|
3188
4488
|
if (app.exportEngine && app.exportEngine.exportPNG) {
|
|
3189
|
-
|
|
4489
|
+
// PNG IGNORED THE PLATFORM ENTIRELY.
|
|
4490
|
+
//
|
|
4491
|
+
// exportPNG takes dpi or scale and no dimensions, so passing only
|
|
4492
|
+
// dpi scaled the CANVAS by dpi/96 and the preset was never applied:
|
|
4493
|
+
// platform 'youtube-thumbnail' (1280x720) and platform 'auto' on a
|
|
4494
|
+
// 1920x1080 board both returned 3000x1688, which is 1920 x 150/96.
|
|
4495
|
+
// Third member of the same family as the camera dims and analyze —
|
|
4496
|
+
// a pixel count that came from the rendering scale rather than from
|
|
4497
|
+
// what the caller asked for.
|
|
4498
|
+
//
|
|
4499
|
+
// dimensions is already resolved: the preset for a named platform,
|
|
4500
|
+
// the board itself for auto. Turning it into a scale is the only
|
|
4501
|
+
// lever exportPNG offers, so that is what it gets.
|
|
4502
|
+
const pngBoard = (typeof app.getCanvasSize === 'function' && app.getCanvasSize())
|
|
4503
|
+
|| app.canvasSize
|
|
4504
|
+
|| { width: dimensions.width, height: dimensions.height };
|
|
4505
|
+
const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
|
|
4506
|
+
const png = await app.exportEngine.exportPNG({ scale: pngScale });
|
|
4507
|
+
const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
|
|
3190
4508
|
result = {
|
|
3191
4509
|
success: true,
|
|
3192
4510
|
platform,
|
|
3193
4511
|
format: 'png',
|
|
3194
4512
|
data: dataUrl,
|
|
3195
4513
|
mimeType: 'image/png',
|
|
3196
|
-
size: Math.round(dataUrl.length * 0.75)
|
|
4514
|
+
size: Math.round(String(dataUrl).length * 0.75),
|
|
4515
|
+
dimensions: (png && png.width)
|
|
4516
|
+
? { width: png.width, height: png.height }
|
|
4517
|
+
: { width: dimensions.width, height: dimensions.height },
|
|
3197
4518
|
};
|
|
3198
4519
|
} else {
|
|
3199
4520
|
const canvas = document.querySelector('canvas');
|
|
@@ -3211,10 +4532,52 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3211
4532
|
}
|
|
3212
4533
|
break;
|
|
3213
4534
|
|
|
4535
|
+
case 'html5-ad':
|
|
4536
|
+
case 'playable': {
|
|
4537
|
+
// AN AD FROM THE WIDGET PAGE (8.21 / 8.24). The page is the studio's
|
|
4538
|
+
// standalone widget; the server adds the network's wrapper and builds
|
|
4539
|
+
// the zip (src/utils/ad-package.ts). Here: the page, its size, the CTA
|
|
4540
|
+
// box in percent of the canvas, and a backup image for html5-ad.
|
|
4541
|
+
if (!app.exportEngine || typeof app.exportEngine.exportWidgetHTML !== 'function') {
|
|
4542
|
+
result = { success: false, platform, format, error: 'this studio cannot export its standalone widget page (no exportWidgetHTML), which an ad is built from.' };
|
|
4543
|
+
break;
|
|
4544
|
+
}
|
|
4545
|
+
// The artboard, never the canvas element: its backing store is scaled
|
|
4546
|
+
// by the renderer, and an ad's size is what was asked for.
|
|
4547
|
+
const __as = (typeof app.getCanvasSize === 'function' && app.getCanvasSize()) || app.canvasSize || { width: 0, height: 0 };
|
|
4548
|
+
const __cw = __as.width, __ch = __as.height;
|
|
4549
|
+
let __cta = null;
|
|
4550
|
+
${adOpts?.ctaItemId ? `{
|
|
4551
|
+
const __ce = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(adOpts.ctaItemId)});
|
|
4552
|
+
const __cb = __ce && __ce.item && __ce.item.bounds;
|
|
4553
|
+
if (!__cb || !(__cb.width > 0) || !(__cw > 0)) {
|
|
4554
|
+
result = { success: false, platform, format, error: ${JSON.stringify(`ad.ctaItemId "${adOpts.ctaItemId}" is not an item on the canvas with a size — nothing was exported. List items with pinepaper_get_items.`)} };
|
|
4555
|
+
break;
|
|
4556
|
+
}
|
|
4557
|
+
__cta = { left: __cb.x / __cw * 100, top: __cb.y / __ch * 100, width: __cb.width / __cw * 100, height: __cb.height / __ch * 100 };
|
|
4558
|
+
}` : ''}
|
|
4559
|
+
const __w = await app.exportEngine.exportWidgetHTML({ download: false });
|
|
4560
|
+
if (!__w || typeof __w.html !== 'string' || !__w.html) {
|
|
4561
|
+
result = { success: false, platform, format, error: 'the studio returned no widget page to build the ad from.' };
|
|
4562
|
+
break;
|
|
4563
|
+
}
|
|
4564
|
+
let __backup = null;
|
|
4565
|
+
if (format === 'html5-ad' && typeof app.captureFrameDataURL === 'function') {
|
|
4566
|
+
try { __backup = app.captureFrameDataURL(1); } catch (_) { __backup = null; }
|
|
4567
|
+
}
|
|
4568
|
+
// The background the PNG export uses (the canvas element's). The widget
|
|
4569
|
+
// page prefers another record that can be stale, which served a white
|
|
4570
|
+
// page under white text (8.9 again); the server pins this one.
|
|
4571
|
+
const __bgc = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
|
|
4572
|
+
result = { success: true, platform, format, adPage: __w.html, adSize: { width: __cw, height: __ch }, cta: __cta, backupImage: __backup, background: __bgc };
|
|
4573
|
+
break;
|
|
4574
|
+
}
|
|
4575
|
+
|
|
3214
4576
|
case 'gif':
|
|
4577
|
+
case 'apng':
|
|
3215
4578
|
case 'mp4':
|
|
3216
4579
|
case 'webm': {
|
|
3217
|
-
const videoMimeType = { mp4: 'video/mp4', webm: 'video/webm', gif: 'image/gif' }[format];
|
|
4580
|
+
const videoMimeType = { mp4: 'video/mp4', webm: 'video/webm', gif: 'image/gif', apng: 'image/apng' }[format];
|
|
3218
4581
|
// FxTool VideoExporter._calculateBitrate multiplies a per-resolution
|
|
3219
4582
|
// base by settings.quality (0.5-1.0). Our existing per-tier compression
|
|
3220
4583
|
// values (0.6 / 0.85 / 0.95) map directly to that scale. Without this,
|
|
@@ -3224,12 +4587,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3224
4587
|
// width/height ride along ONLY when a scale was asked for. The buffered
|
|
3225
4588
|
// fallback paths otherwise export at canvas size, and quietly changing
|
|
3226
4589
|
// that for every existing caller is not what a new optional knob does.
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
r.readAsDataURL(b);
|
|
3232
|
-
});
|
|
4590
|
+
// loop rides in the BASE settings for a gif, so every route carries it:
|
|
4591
|
+
// the direct one below, and also camera framing and a studio without
|
|
4592
|
+
// _quickExportVideo, which build from these and used to drop it.
|
|
4593
|
+
const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration}${scale !== undefined && scale !== 1 ? ', width: dimensions.width, height: dimensions.height' : ''}${gifLoop !== undefined ? `, ...(format === 'gif' || format === 'apng' ? { loop: ${JSON.stringify(gifLoop)} } : {})` : ''}${encodeOptsJs}${transparent !== undefined ? `, transparent: ${transparent}` : ''} };
|
|
3233
4594
|
|
|
3234
4595
|
// THE EXPORT STORE. A long export cannot come back as one base64
|
|
3235
4596
|
// string: base64 of a gigabyte is larger than the gigabyte, and it
|
|
@@ -3247,7 +4608,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3247
4608
|
&& typeof app.exportEngine.readExport === 'function')
|
|
3248
4609
|
? app.exportEngine : null;
|
|
3249
4610
|
|
|
3250
|
-
if (store && format !== 'gif') {
|
|
4611
|
+
if (store && format !== 'gif' && format !== 'apng') {
|
|
3251
4612
|
const stored = await store.exportToStore({
|
|
3252
4613
|
format,
|
|
3253
4614
|
duration: baseVideoSettings.duration,
|
|
@@ -3258,7 +4619,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3258
4619
|
// VideoEncoder.configure.
|
|
3259
4620
|
quality: settings.compression,
|
|
3260
4621
|
width: cameraDims ? cameraDims.width : dimensions.width,
|
|
3261
|
-
height: cameraDims ? cameraDims.height : dimensions.height
|
|
4622
|
+
height: cameraDims ? cameraDims.height : dimensions.height,${encodeOptsJs ? `
|
|
4623
|
+
// Broadcast / bitrate. A store that rebuilds its options without
|
|
4624
|
+
// them is caught by the video report below, not assumed.
|
|
4625
|
+
${encodeOptsJs.slice(2)},` : ''}
|
|
3262
4626
|
});
|
|
3263
4627
|
if (!stored || stored.ok === false) {
|
|
3264
4628
|
result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why' };
|
|
@@ -3294,7 +4658,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3294
4658
|
// refusing.
|
|
3295
4659
|
const INLINE_MAX_BYTES = ${INLINE_MAX_BYTES};
|
|
3296
4660
|
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
3297
|
-
const deliver = async (
|
|
4661
|
+
const deliver = async (raw) => {
|
|
4662
|
+
// Normalise first: a {blob, …} record would otherwise be reported as
|
|
4663
|
+
// "streamed to a file instead of returning bytes", which is a true
|
|
4664
|
+
// sentence about the wrong problem.
|
|
4665
|
+
const blob = asBlob(raw) || raw;
|
|
3298
4666
|
if (!blob || typeof blob.size !== 'number' || typeof blob.slice !== 'function') {
|
|
3299
4667
|
return {
|
|
3300
4668
|
success: false, platform, format,
|
|
@@ -3313,7 +4681,20 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3313
4681
|
|
|
3314
4682
|
// Camera framing requires going direct to videoExporter so width/height
|
|
3315
4683
|
// pass through — _quickExportVideo strips dim fields.
|
|
3316
|
-
|
|
4684
|
+
// ANIMATED PNG (8.12, FxTool 76312a55): stepped PNG frames with full
|
|
4685
|
+
// alpha, transparent unless transparent: false. Asked of the exporter
|
|
4686
|
+
// directly; a studio that lacks it says so in its capability table, and
|
|
4687
|
+
// is refused by name rather than handed a format it would reject.
|
|
4688
|
+
const __apngVx = format === 'apng' ? (app.exportEngine && app.exportEngine.videoExporter) : null;
|
|
4689
|
+
if (format === 'apng' && !(__apngVx && typeof __apngVx.export === 'function' && __apngVx.constructor
|
|
4690
|
+
&& typeof __apngVx.constructor.getCapabilities === 'function' && __apngVx.constructor.getCapabilities().apng)) {
|
|
4691
|
+
result = { success: false, platform, format, error: 'this studio cannot export APNG (it needs an engine with animated PNG). For animation with alpha, export a png sequence (pinepaper_interchange export_png_sequence with options.transparent: true); gif keeps only 1-bit transparency.' };
|
|
4692
|
+
} else if (format === 'apng') {
|
|
4693
|
+
const blob = await __apngVx.export(cameraDims ? { ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height } : baseVideoSettings);
|
|
4694
|
+
result = await deliver(blob);
|
|
4695
|
+
if (result.success) result.transparent = ${transparent !== false};
|
|
4696
|
+
if (result.success && cameraDims) result.dimensions = cameraDims;
|
|
4697
|
+
} else if (cameraDims && app.exportEngine && app.exportEngine.videoExporter) {
|
|
3317
4698
|
const blob = await app.exportEngine.videoExporter.export({ ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height });
|
|
3318
4699
|
result = await deliver(blob);
|
|
3319
4700
|
if (result.success) result.dimensions = cameraDims;
|
|
@@ -3325,12 +4706,43 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3325
4706
|
} else if (app.exportEngine && app.exportEngine._quickExportVideo) {
|
|
3326
4707
|
// GIF path (working): _quickExportVideo forwards gifQuality to
|
|
3327
4708
|
// gif.js. Also serves as the fallback if videoExporter is absent.
|
|
3328
|
-
|
|
4709
|
+
${gifLoop !== undefined ? `// A LOOP COUNT (8.30): _quickExportVideo rebuilds its settings without
|
|
4710
|
+
// it, so a GIF with loop set goes straight to videoExporter.export.
|
|
4711
|
+
// true = forever, false / 0 / 1 = once, n = n plays (engine 952d6d35).
|
|
4712
|
+
const videoResult = (format === 'gif' && app.exportEngine.videoExporter && typeof app.exportEngine.videoExporter.export === 'function')
|
|
4713
|
+
? { blob: await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', loop: ${JSON.stringify(gifLoop)} })) }
|
|
4714
|
+
: await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);` : `const videoResult = await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);`}
|
|
3329
4715
|
if (videoResult && videoResult.blob) {
|
|
3330
4716
|
result = await deliver(videoResult.blob);
|
|
3331
4717
|
} else {
|
|
3332
4718
|
result = { success: false, error: format.toUpperCase() + ' export returned no data' };
|
|
3333
4719
|
}
|
|
4720
|
+
${maxBytes !== undefined ? `// A BYTE BUDGET (round 9 GG, 8.31 — email wants <= 1 MB). gif.js has no
|
|
4721
|
+
// colour-count knob, so size is the lever: GIF bytes scale roughly with
|
|
4722
|
+
// pixel count, so the frame is shrunk by sqrt(budget / size) with a
|
|
4723
|
+
// margin and re-encoded, at most twice. Every attempt is reported.
|
|
4724
|
+
if (format === 'gif' && result && result.success && result.size > ${maxBytes}) {
|
|
4725
|
+
const attempts = [{ width: dimensions.width, height: dimensions.height, bytes: result.size }];
|
|
4726
|
+
let w = dimensions.width, h = dimensions.height;
|
|
4727
|
+
for (let k = 0; k < 2 && result.success && result.size > ${maxBytes}; k++) {
|
|
4728
|
+
const f = Math.sqrt(${maxBytes} / result.size) * 0.9;
|
|
4729
|
+
w = Math.max(16, Math.round(w * f / 2) * 2); h = Math.max(16, Math.round(h * f / 2) * 2);
|
|
4730
|
+
// videoExporter.export, not _quickExportVideo: the latter rebuilds
|
|
4731
|
+
// its settings without width / height, so a "smaller" retry
|
|
4732
|
+
// through it came out the same size.
|
|
4733
|
+
if (!app.exportEngine.videoExporter || typeof app.exportEngine.videoExporter.export !== 'function') break;
|
|
4734
|
+
const again = await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', width: w, height: h }${gifLoop !== undefined ? `, { loop: ${JSON.stringify(gifLoop)} }` : ''}));
|
|
4735
|
+
if (!again) break;
|
|
4736
|
+
result = await deliver(again);
|
|
4737
|
+
attempts.push({ width: w, height: h, bytes: result.size });
|
|
4738
|
+
}
|
|
4739
|
+
result.budget = { maxBytes: ${maxBytes}, met: !!(result.success && result.size <= ${maxBytes}), attempts: attempts };
|
|
4740
|
+
if (result.success && result.size > ${maxBytes}) {
|
|
4741
|
+
result.warning = 'still ' + result.size + ' bytes after shrinking to ' + w + 'x' + h + ' — over the ' + ${maxBytes} + '-byte budget. Lower fps, shorten duration, or use quality "draft".';
|
|
4742
|
+
} else if (result.success) {
|
|
4743
|
+
result.dimensions = { width: w, height: h };
|
|
4744
|
+
}
|
|
4745
|
+
}` : ''}
|
|
3334
4746
|
} else if (app.exportEngine && app.exportEngine.videoExporter) {
|
|
3335
4747
|
const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
|
|
3336
4748
|
result = await deliver(blob);
|
|
@@ -3341,14 +4753,134 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3341
4753
|
}
|
|
3342
4754
|
|
|
3343
4755
|
case 'pdf':
|
|
4756
|
+
${pdfOpts?.pages ? `// ONE PAGE PER SCENE (round 7 Z, 8.16). exportPDF renders the current
|
|
4757
|
+
// canvas only, so a deck of 8 saved scenes exported 1 page. Built here
|
|
4758
|
+
// the way exportPDF builds its one page — the engine's jsPDF, a frame
|
|
4759
|
+
// from captureFrameDataURL, the scene's own size at the canvas DPI —
|
|
4760
|
+
// one scene at a time, and the scene the user was on is put back.
|
|
4761
|
+
{
|
|
4762
|
+
const SM = app.sceneManager;
|
|
4763
|
+
if (!SM || typeof SM.listScenes !== 'function' || typeof SM.loadScene !== 'function') {
|
|
4764
|
+
result = { success: false, platform, format: 'pdf', error: 'scenes are unavailable on this studio (app.sceneManager) — a multi-page PDF needs saved scenes.' }; break;
|
|
4765
|
+
}
|
|
4766
|
+
if (!app.exportEngine || typeof app.exportEngine._loadPDFLibraries !== 'function' || typeof app.captureFrameDataURL !== 'function') {
|
|
4767
|
+
result = { success: false, platform, format: 'pdf', error: 'this studio cannot build a multi-page PDF (no PDF library loader or frame capture) — update PinePaper Studio.' }; break;
|
|
4768
|
+
}
|
|
4769
|
+
const all = SM.listScenes();
|
|
4770
|
+
const wanted = ${JSON.stringify(pdfOpts.pages)};
|
|
4771
|
+
// A page is { id, size? }. Scenes do not record their canvas size —
|
|
4772
|
+
// their "dimensions" is the browser viewport and loadScene does not
|
|
4773
|
+
// resize the canvas — so without a size every page is the current
|
|
4774
|
+
// canvas (the retest: three pages, all square).
|
|
4775
|
+
const pageSpecs = wanted === 'scenes'
|
|
4776
|
+
? all.map(function(sc) { return { id: sc.id }; })
|
|
4777
|
+
: wanted.map(function(w) { return typeof w === 'string' ? { id: w } : { id: w.sceneId, size: { width: w.width, height: w.height } }; });
|
|
4778
|
+
const ids = pageSpecs.map(function(p) { return p.id; });
|
|
4779
|
+
const sized = pageSpecs.some(function(p) { return p.size; });
|
|
4780
|
+
const missing = ids.filter(function(id) { return !all.some(function(sc) { return sc.id === id; }); });
|
|
4781
|
+
if (!ids.length || missing.length) {
|
|
4782
|
+
result = { success: false, platform, format: 'pdf', error: ids.length ? 'no saved scene ' + missing.join(', ') + '. Saved: ' + all.map(function(sc) { return sc.id; }).join(', ') : 'there are no saved scenes — save each page with pinepaper_manage_scenes first.' }; break;
|
|
4783
|
+
}
|
|
4784
|
+
const back = SM.currentSceneId;
|
|
4785
|
+
const backSize = app.canvasSize ? { width: app.canvasSize.width, height: app.canvasSize.height } : null;
|
|
4786
|
+
const lib = await app.exportEngine._loadPDFLibraries();
|
|
4787
|
+
const targetDpi = ${pdfOpts.dpi ?? 'settings.dpi'};
|
|
4788
|
+
// JPEG pages: a PNG per page made a 3-slide deck 34 MB.
|
|
4789
|
+
const toJpeg = function(url) {
|
|
4790
|
+
return new Promise(function(resolve) {
|
|
4791
|
+
const img = new Image();
|
|
4792
|
+
img.onload = function() {
|
|
4793
|
+
const c = document.createElement('canvas');
|
|
4794
|
+
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
|
4795
|
+
const x = c.getContext('2d');
|
|
4796
|
+
x.fillStyle = '#ffffff'; x.fillRect(0, 0, c.width, c.height);
|
|
4797
|
+
x.drawImage(img, 0, 0);
|
|
4798
|
+
resolve(c.toDataURL('image/jpeg', settings.compression));
|
|
4799
|
+
};
|
|
4800
|
+
img.onerror = function() { resolve(null); };
|
|
4801
|
+
img.src = url;
|
|
4802
|
+
});
|
|
4803
|
+
};
|
|
4804
|
+
let doc = null;
|
|
4805
|
+
const pagesOut = [];
|
|
4806
|
+
// A SEARCHABLE TEXT LAYER PER PAGE (8.10, FxTool 3e22c0df): the
|
|
4807
|
+
// engine lays the loaded scene's text invisibly over the page box;
|
|
4808
|
+
// the per-page reports are summed into result.pdf.
|
|
4809
|
+
const __wantLayer = ${pdfOpts.searchableText !== false};
|
|
4810
|
+
const __layer = __wantLayer && typeof app.exportEngine.addTextLayer === 'function';
|
|
4811
|
+
const __tl = { written: 0, skipped: 0, reasons: [] };
|
|
4812
|
+
try {
|
|
4813
|
+
for (const spec of pageSpecs) {
|
|
4814
|
+
const id = spec.id;
|
|
4815
|
+
await SM.loadScene(id);
|
|
4816
|
+
if (spec.size && typeof app.setCanvasSize === 'function') app.setCanvasSize({ width: spec.size.width, height: spec.size.height });
|
|
4817
|
+
const cs = app.canvasSize || { width: app.canvasEl.width, height: app.canvasEl.height };
|
|
4818
|
+
const dpi = (typeof app.getDPI === 'function' && app.getDPI()) || 96;
|
|
4819
|
+
const wMM = cs.width / dpi * 25.4, hMM = cs.height / dpi * 25.4;
|
|
4820
|
+
const orient = wMM > hMM ? 'landscape' : 'portrait';
|
|
4821
|
+
const png = app.captureFrameDataURL(Math.max(1, targetDpi / dpi));
|
|
4822
|
+
const jpg = await toJpeg(png);
|
|
4823
|
+
if (!doc) doc = new lib.jsPDF({ orientation: orient, unit: 'mm', format: [wMM, hMM] });
|
|
4824
|
+
else doc.addPage([wMM, hMM], orient);
|
|
4825
|
+
if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
|
|
4826
|
+
if (__layer) {
|
|
4827
|
+
try {
|
|
4828
|
+
const tr = await app.exportEngine.addTextLayer(doc, { x: 0, y: 0, width: wMM, height: hMM });
|
|
4829
|
+
if (tr && tr.ok !== false) { __tl.written += tr.linesWritten || 0; __tl.skipped += tr.linesSkipped || 0; }
|
|
4830
|
+
else __tl.reasons.push(id + ': ' + ((tr && tr.reason) || 'refused'));
|
|
4831
|
+
} catch (e) { __tl.reasons.push(id + ': ' + e.message); }
|
|
4832
|
+
}
|
|
4833
|
+
pagesOut.push({ sceneId: id, widthMM: Math.round(wMM * 10) / 10, heightMM: Math.round(hMM * 10) / 10 });
|
|
4834
|
+
}
|
|
4835
|
+
} finally {
|
|
4836
|
+
if (sized && backSize && typeof app.setCanvasSize === 'function') { try { app.setCanvasSize(backSize); } catch (_) { /* best effort */ } }
|
|
4837
|
+
if (back && typeof SM.loadScene === 'function') { try { await SM.loadScene(back); } catch (_) { /* reported by the page count */ } }
|
|
4838
|
+
}
|
|
4839
|
+
const blob = doc.output('blob');
|
|
4840
|
+
const dataUrl = await blobToDataUrl(blob);
|
|
4841
|
+
result = { success: true, platform, format: 'pdf', data: dataUrl, mimeType: 'application/pdf', size: blob.size, pages: pagesOut.length, pageList: pagesOut,
|
|
4842
|
+
pdf: !__wantLayer ? { searchableText: false, reason: 'off' }
|
|
4843
|
+
: !__layer ? { searchableText: false, reason: 'this studio adds no text layer to multi-page PDFs' }
|
|
4844
|
+
: Object.assign({ searchableText: __tl.written > 0 || __tl.reasons.length < pagesOut.length, linesWritten: __tl.written, linesSkipped: __tl.skipped },
|
|
4845
|
+
__tl.reasons.length ? { pagesWithoutLayer: __tl.reasons } : {},
|
|
4846
|
+
__tl.written === 0 && __tl.reasons.length === pagesOut.length ? { reason: __tl.reasons.join('; ') } : {}) };
|
|
4847
|
+
if (!sized) result.warning = 'every page used the current canvas size: saved scenes do not record theirs. To give a page its own size, pass pages as [{sceneId, width, height}, …].';
|
|
4848
|
+
}
|
|
4849
|
+
break;` : ''}
|
|
3344
4850
|
if (app.exportEngine && app.exportEngine.exportPDF) {
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
4851
|
+
// Print options reach the engine (they were engine-only before), and
|
|
4852
|
+
// download:false, because the tool delivers the bytes itself.
|
|
4853
|
+
// lastPdfReport is a report PROPERTY (9917cfa5), read through the
|
|
4854
|
+
// engine object like the exporter's audio / video reports, and only
|
|
4855
|
+
// when present.
|
|
4856
|
+
const __pee = app.exportEngine;
|
|
4857
|
+
if ('lastPdfReport' in __pee) __pee.lastPdfReport = null;
|
|
4858
|
+
const pdfOut = await app.exportEngine.exportPDF({
|
|
4859
|
+
dpi: ${pdfOpts?.dpi ?? 'settings.dpi'},
|
|
4860
|
+
download: false,${pdfOpts?.searchableText === false ? '\n searchableText: false,' : ''}${pdfOpts?.paperFormat ? `\n format: ${JSON.stringify(pdfOpts.paperFormat)},` : ''}${pdfOpts?.orientation ? `\n orientation: ${JSON.stringify(pdfOpts.orientation)},` : ''}${pdfOpts?.bleed !== undefined ? `\n includeBleed: ${pdfOpts.bleed > 0}, bleed: ${pdfOpts.bleed},` : ''}${pdfOpts?.trimMarks !== undefined ? `\n trimMarks: ${pdfOpts.trimMarks},` : ''}
|
|
3350
4861
|
});
|
|
3351
|
-
|
|
4862
|
+
const blob = asBlob(pdfOut);
|
|
4863
|
+
if (!blob) {
|
|
4864
|
+
const got = pdfOut === null ? 'null'
|
|
4865
|
+
: typeof pdfOut === 'object' ? 'an object with keys [' + Object.keys(pdfOut).join(', ') + ']'
|
|
4866
|
+
: typeof pdfOut;
|
|
4867
|
+
result = { success: false, platform, format: 'pdf',
|
|
4868
|
+
error: 'the PDF export did not produce a file: expected a Blob, got ' + got + '.' };
|
|
4869
|
+
break;
|
|
4870
|
+
}
|
|
4871
|
+
const dataUrl = await blobToDataUrl(blob);
|
|
4872
|
+
result = {
|
|
4873
|
+
success: true, platform, format: 'pdf', data: dataUrl,
|
|
4874
|
+
mimeType: 'application/pdf', size: blob.size, dpi: settings.dpi,
|
|
4875
|
+
// The record carries the page geometry the engine chose; it is the
|
|
4876
|
+
// only place a caller can learn it.
|
|
4877
|
+
...(pdfOut && pdfOut.width ? { dimensions: { width: pdfOut.width, height: pdfOut.height } } : {}),
|
|
4878
|
+
...(pdfOut && pdfOut.format ? { paperFormat: pdfOut.format } : {}),${pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && ((pdfOpts.bleed ?? 0) > 0 || pdfOpts.trimMarks) ? `
|
|
4879
|
+
// Bleed and trim marks on a NAMED paper size: the studio keeps the
|
|
4880
|
+
// page at that size and centres the art, so neither appears (prod
|
|
4881
|
+
// retest: MediaBox = TrimBox = A4, no marks). Said, not silent.
|
|
4882
|
+
warnings: [${JSON.stringify(`bleed / trimMarks were passed with paperFormat '${pdfOpts.paperFormat}', and this studio applies them only to paperFormat 'custom' (page = canvas + bleed): the page stayed ${pdfOpts.paperFormat} with no bleed and no marks. For print with bleed, size the canvas with a PRINT preset (agent_start_job canvasPreset 'print-a4' / 'print-letter' / 'print-a4-landscape' / 'print-letter-landscape', which carry 300 dpi — a plain 2480x3508 canvas is read at 96 dpi and comes out ~656 mm wide) and pass paperFormat 'custom'.`)}],` : ''}
|
|
4883
|
+
};
|
|
3352
4884
|
} else {
|
|
3353
4885
|
result = { success: false, error: 'PDF export failed' };
|
|
3354
4886
|
}
|
|
@@ -3364,7 +4896,154 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3364
4896
|
// One site, after every branch has set its result: a successful export says
|
|
3365
4897
|
// what this scene lost to this format, rather than the caller finding out by
|
|
3366
4898
|
// opening the file. Only when there is something to say — see fidelity().
|
|
4899
|
+
// RE-ENCODE A STILL FOR A BYTE BUDGET. The png path rendered it (whole board
|
|
4900
|
+
// or region); the browser's own encoder makes jpg / webp at the tier's
|
|
4901
|
+
// compression. jpg has no alpha, so it is flattened onto the scene background
|
|
4902
|
+
// (white when there is none) rather than left to the encoder's black.
|
|
4903
|
+
if (result && result.success && (format === 'jpg' || format === 'webp') && typeof result.data === 'string') {
|
|
4904
|
+
const mime = format === 'jpg' ? 'image/jpeg' : 'image/webp';
|
|
4905
|
+
try {
|
|
4906
|
+
const encoded = await new Promise(function(resolve, reject) {
|
|
4907
|
+
const img = new Image();
|
|
4908
|
+
img.onload = function() {
|
|
4909
|
+
const c = document.createElement('canvas');
|
|
4910
|
+
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
|
4911
|
+
const x = c.getContext('2d');
|
|
4912
|
+
// The scene's own background when one is set — white text on a
|
|
4913
|
+
// transparent board vanished into a white flatten. White otherwise.
|
|
4914
|
+
if (mime === 'image/jpeg') {
|
|
4915
|
+
const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
|
|
4916
|
+
x.fillStyle = (bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg)) ? bg : '#ffffff';
|
|
4917
|
+
x.fillRect(0, 0, c.width, c.height);
|
|
4918
|
+
}
|
|
4919
|
+
x.drawImage(img, 0, 0);
|
|
4920
|
+
resolve(c.toDataURL(mime, settings.compression));
|
|
4921
|
+
};
|
|
4922
|
+
img.onerror = function() { reject(new Error('the rendered frame could not be decoded for re-encoding')); };
|
|
4923
|
+
img.src = result.data;
|
|
4924
|
+
});
|
|
4925
|
+
if (String(encoded).indexOf('data:' + mime) !== 0) {
|
|
4926
|
+
result = { success: false, platform, format, error: 'this browser cannot encode ' + format + ' (it returned ' + String(encoded).slice(5, 20) + '…).' };
|
|
4927
|
+
} else {
|
|
4928
|
+
result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });
|
|
4929
|
+
}
|
|
4930
|
+
} catch (e) {
|
|
4931
|
+
result = { success: false, platform, format, error: (e && e.message) || String(e) };
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
|
|
3367
4935
|
if (result && result.success) Object.assign(result, fidelity(format));
|
|
4936
|
+
if (result && result.success && __fit) result.platformFit = __fit;
|
|
4937
|
+
// AUDIO TIMING, WHERE THE STUDIO REPORTS IT (6.27). An MP4's AAC track used
|
|
4938
|
+
// to start ~44 ms late and run past the picture — a 6.000 s bumper with sound
|
|
4939
|
+
// then failed its cap. The engine now reports the track; it is surfaced as
|
|
4940
|
+
// result.audio, and its warning (sound in the trimmed lead-in) and any audio
|
|
4941
|
+
// longer than the picture go into fidelity.warnings, where a caller looks.
|
|
4942
|
+
// WHAT THE ENCODER WAS GIVEN AND WHAT IT MADE (FxTool 16719759, 8.34-8.36).
|
|
4943
|
+
// The browser's H.264 treats a bitrate as a ceiling, so an 8 Mbps broadcast
|
|
4944
|
+
// request can come out at 1.8 — the engine measures it and warns. A request
|
|
4945
|
+
// the report does not reflect (an engine without the options, or a route
|
|
4946
|
+
// that rebuilt its settings without them) is named, not assumed.
|
|
4947
|
+
// A PDF'S TEXT LAYER (8.10, FxTool 9917cfa5): invisible text at each line's
|
|
4948
|
+
// drawn position, so the PDF is searchable and screen-readable. The built-in
|
|
4949
|
+
// font is WinAnsi only, so Arabic / Hebrew / CJK lines are left out of the
|
|
4950
|
+
// layer (drawn, but not selectable) — counted by the engine, said here.
|
|
4951
|
+
const __ee = app.exportEngine;
|
|
4952
|
+
const __pr = format === 'pdf' && ((result && result.pdf) || (__ee && __ee.lastPdfReport));
|
|
4953
|
+
if (result && result.success && __pr && typeof __pr === 'object') {
|
|
4954
|
+
result.pdf = __pr;
|
|
4955
|
+
const __pw = [];
|
|
4956
|
+
// Which scripts a PDF can carry depends on the studio: older ones have
|
|
4957
|
+
// only the built-in Latin font, newer ones embed the scene's fonts. So the
|
|
4958
|
+
// cause is the engine's report (fonts it could not embed), not a claim here.
|
|
4959
|
+
const __ff = (__pr.fonts && Array.isArray(__pr.fonts.families)) ? __pr.fonts.families.filter(function(f) { return f && f.reason; }) : [];
|
|
4960
|
+
const __fnames = __ff.slice(0, 4).map(function(f) { return f.family + ' (' + f.reason + ')'; }).join('; ');
|
|
4961
|
+
if (__pr.searchableText && __pr.linesSkipped > 0) __pw.push({ code: 'pdf_text_lines_skipped', message: __pr.linesSkipped + ' text line(s) are not in the searchable layer — no font in the PDF covers their characters — so they are drawn but cannot be selected, searched or read by a screen reader.' + (__fnames ? ' Fonts that could not be embedded: ' + __fnames + '.' : '') });
|
|
4962
|
+
if (__ff.length && !(__pr.linesSkipped > 0)) __pw.push({ code: 'pdf_font_not_embedded', message: 'these fonts could not be embedded, so their text uses a fallback font in the searchable layer: ' + __fnames + '.' });
|
|
4963
|
+
if (__pr.searchableText && __pr.pagesWithoutLayer) __pw.push({ code: 'pdf_no_text_layer', message: 'some pages have no searchable text layer — ' + __pr.pagesWithoutLayer.join('; ') });
|
|
4964
|
+
if (!__pr.searchableText && __pr.reason && __pr.reason !== 'off') __pw.push({ code: 'pdf_no_text_layer', message: 'no searchable text layer was written (' + __pr.reason + '): the text is drawn but cannot be selected, searched or read by a screen reader.' });
|
|
4965
|
+
if (__pw.length) {
|
|
4966
|
+
result.fidelity = result.fidelity || { warnings: [] };
|
|
4967
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat(__pw);
|
|
4968
|
+
if (result.fidelity.note) delete result.fidelity.note;
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
const __vr = __vx && __vx.lastVideoReport;
|
|
4972
|
+
if (result && result.success && __vr && typeof __vr === 'object') result.video = __vr;${headroomDefaulted ? `
|
|
4973
|
+
// The applied value is the engine's own report where it gives one
|
|
4974
|
+
// (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
|
|
4975
|
+
if (result && result.success) result.broadcastHeadroom = { value: (__vr && typeof __vr.headroom === 'number') ? __vr.headroom : 12, defaulted: true, note: 'broadcast defaults to 12 luma codes of headroom, which keeps encoder ringing legal; pass broadcastHeadroom: 0 for the full range.' };` : ''}
|
|
4976
|
+
// AN ODD SIDE ROUNDED UP (FxTool e64a5260). H.264 needs even dimensions, so
|
|
4977
|
+
// 1200x675 came out 1200x676 — and platform specs are exact-pixel. The
|
|
4978
|
+
// result's dimensions become what the file IS, and fidelity says why.
|
|
4979
|
+
const __vd = __vr && __vr.dimensions;
|
|
4980
|
+
if (result && result.success && __vd && Array.isArray(__vd.requested) && Array.isArray(__vd.output)
|
|
4981
|
+
&& (__vd.requested[0] !== __vd.output[0] || __vd.requested[1] !== __vd.output[1])) {
|
|
4982
|
+
result.dimensions = { width: __vd.output[0], height: __vd.output[1] };
|
|
4983
|
+
result.fidelity = result.fidelity || { warnings: [] };
|
|
4984
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'dimensions_rounded',
|
|
4985
|
+
message: 'the file is ' + __vd.output[0] + 'x' + __vd.output[1] + ', not the requested ' + __vd.requested[0] + 'x' + __vd.requested[1] + ': H.264 needs even dimensions, so an odd side was rounded up by 1 px (the extra row / column is background). For an exact-pixel spec, use an even canvas size, or crop the extra line when re-encoding (ffmpeg -vf crop=' + __vd.requested[0] + ':' + __vd.requested[1] + ':0:0).' }]);
|
|
4986
|
+
if (result.fidelity.note) delete result.fidelity.note;
|
|
4987
|
+
}
|
|
4988
|
+
// LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
|
|
4989
|
+
// decoded back and measured: H.264 ringing at hard edges puts a fraction of
|
|
4990
|
+
// a percent of samples out of range, which QC rejects. broadcastHeadroom
|
|
4991
|
+
// cuts it but never to zero, so a legaliser pass is the requirement.
|
|
4992
|
+
if (result && result.success && __vr && __vr.luma && __vr.luma.outOfRange > 0 && result.fidelity) {
|
|
4993
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'luma_out_of_range',
|
|
4994
|
+
message: 'broadcast QC will reject this as delivered: ' + ((__vr.luma.outOfRangeFraction || 0) * 100).toFixed(3) + '% of decoded luma is outside 16-235 (min ' + __vr.luma.min + ', max ' + __vr.luma.max + ', broadcastHeadroom ' + (typeof __vr.headroom === 'number' ? __vr.headroom : ${JSON.stringify(broadcastHeadroom ?? 12)}) + '). First re-export with a larger broadcastHeadroom (12 measured clean on hard white-on-black edges; up to 40). Otherwise a legaliser pass is REQUIRED before delivery — clip into a NARROWER band and re-encode at high quality, because a plain 16-235 clip followed by a re-encode brings the edge overshoot back: ffmpeg -i in.mp4 -vf "lutyuv=y=clip(val\\\\,28\\\\,223):u=clip(val\\\\,16\\\\,240):v=clip(val\\\\,16\\\\,240)" -c:v libx264 -crf 18 -color_range tv -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a copy out.mp4 (measured 20-229). Verify the luma range after re-encoding.' }]);
|
|
4995
|
+
if (result.fidelity.note) delete result.fidelity.note;
|
|
4996
|
+
}
|
|
4997
|
+
${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
|
|
4998
|
+
const __asked = ${JSON.stringify(videoEncodeOpts)};
|
|
4999
|
+
const __vw = [];
|
|
5000
|
+
// The engine joins its warnings into one string (bitrate floor, then
|
|
5001
|
+
// luma), so each is raised from its own numbers, not from that text.
|
|
5002
|
+
if (__vr && typeof __vr.achievedBitrate === 'number' && typeof __vr.bitrateFloor === 'number' && __vr.achievedBitrate < __vr.bitrateFloor * 0.95) {
|
|
5003
|
+
__vw.push({ code: 'bitrate_below_floor', message: 'achieved ' + (__vr.achievedBitrate / 1e6).toFixed(2) + ' Mbps against a ' + (__vr.bitrateFloor / 1e6).toFixed(1) + ' Mbps floor: the browser encoder treats the bitrate as a ceiling and does not pad simple content. Re-encode for delivery (e.g. ffmpeg -b:v with -minrate / -bufsize) if the spec requires the floor.' });
|
|
5004
|
+
}
|
|
5005
|
+
if (!__vr || typeof __vr !== 'object') {
|
|
5006
|
+
__vw.push({ code: 'encode_options_not_applied', message: 'this studio did not report its encode, so ' + Object.keys(__asked).join(', ') + ' cannot be confirmed — it likely has no broadcast / bitrate support, and the file is an ordinary web encode.' });
|
|
5007
|
+
} else {
|
|
5008
|
+
if (__asked.broadcast && __vr.broadcast !== true) __vw.push({ code: 'broadcast_not_applied', message: 'broadcast was asked for but the encode was not broadcast-safe (the export route dropped the option): full-range, untagged colour.' });
|
|
5009
|
+
if (__asked.bitrate !== undefined && typeof __vr.bitrate === 'number' && __vr.bitrate < __asked.bitrate * 0.99) __vw.push({ code: 'bitrate_not_applied', message: 'bitrate ' + __asked.bitrate + ' was asked for; the encoder was given ' + __vr.bitrate + '.' });
|
|
5010
|
+
}
|
|
5011
|
+
if (__vw.length && result.fidelity) {
|
|
5012
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat(__vw);
|
|
5013
|
+
if (result.fidelity.note) delete result.fidelity.note;
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
` : ''} const __ar = __vx && __vx.lastAudioReport;
|
|
5017
|
+
if (result && result.success && __ar && typeof __ar === 'object') {
|
|
5018
|
+
result.audio = __ar;
|
|
5019
|
+
const __aw = [];
|
|
5020
|
+
if (__ar.warning) __aw.push({ code: 'audio_lead_trimmed', message: String(__ar.warning) });
|
|
5021
|
+
if (typeof __ar.audioDurationS === 'number' && typeof __ar.videoDurationS === 'number' && __ar.audioDurationS > __ar.videoDurationS + 0.0105) {
|
|
5022
|
+
__aw.push({ code: 'audio_longer_than_video', message: 'the audio track is ' + __ar.audioDurationS.toFixed(4) + ' s against ' + __ar.videoDurationS.toFixed(4) + ' s of picture — a platform with a hard length cap may read the longer one.' });
|
|
5023
|
+
}
|
|
5024
|
+
if (__aw.length && result.fidelity) {
|
|
5025
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat(__aw);
|
|
5026
|
+
if (result.fidelity.note) delete result.fidelity.note;
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
${(() => {
|
|
5030
|
+
// A DURATION THAT OVERRUNS AT AN NTSC RATE (round 9 FF, 1.76): 6 s at
|
|
5031
|
+
// 29.97 is round(6 x 30000/1001) = 180 frames = 6.006 s, over a 6.000 s
|
|
5032
|
+
// bumper cap, silently. Said, with the longest duration that fits.
|
|
5033
|
+
if (fps === undefined || Number.isInteger(fps))
|
|
5034
|
+
return '';
|
|
5035
|
+
const frames = Math.round(videoDuration * fps);
|
|
5036
|
+
const actual = frames / fps;
|
|
5037
|
+
if (actual <= videoDuration + 1e-9)
|
|
5038
|
+
return '';
|
|
5039
|
+
const fit = Math.floor(videoDuration * fps) / fps;
|
|
5040
|
+
return ` if (result && result.success && ['mp4', 'webm', 'gif', 'apng'].indexOf(format) !== -1) result.timing = ${JSON.stringify({
|
|
5041
|
+
frames, seconds: Math.round(actual * 1e6) / 1e6, requested: videoDuration,
|
|
5042
|
+
warning: `${frames} frames at ${Math.round(fps * 1000) / 1000} fps is ${actual.toFixed(3)} s — ${((actual - videoDuration) * 1000).toFixed(1)} ms over the ${videoDuration} s asked for. For a hard cap (e.g. a 6 s bumper) use duration ${fit.toFixed(4)} (${Math.floor(videoDuration * fps)} frames).`,
|
|
5043
|
+
})};\n`;
|
|
5044
|
+
})()}
|
|
5045
|
+
${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
|
|
5046
|
+
if (result && result.success) result.time = ${stillTime};` : ''}
|
|
3368
5047
|
|
|
3369
5048
|
return result;
|
|
3370
5049
|
})();
|
|
@@ -3460,12 +5139,16 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3460
5139
|
|
|
3461
5140
|
// Check relations
|
|
3462
5141
|
if (app.relationRegistry) {
|
|
3463
|
-
|
|
3464
|
-
analysis
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
5142
|
+
// getAll() has never existed on the registry, so this branch always took
|
|
5143
|
+
// the empty fallback and every analysis reported NO relations. getStats()
|
|
5144
|
+
// is the real accessor and answers both questions directly:
|
|
5145
|
+
// associationsByType is keyed by relation type.
|
|
5146
|
+
const relStats = typeof app.relationRegistry.getStats === 'function'
|
|
5147
|
+
? app.relationRegistry.getStats()
|
|
5148
|
+
: null;
|
|
5149
|
+
(Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
|
|
3468
5150
|
analysis.relationTypes = Array.from(relationSet);
|
|
5151
|
+
analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
|
|
3469
5152
|
if (analysis.hasRelations) analysis.hasAnimations = true;
|
|
3470
5153
|
}
|
|
3471
5154
|
|
|
@@ -3631,7 +5314,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3631
5314
|
// interactivity flags have other names entirely. Every one was accepted,
|
|
3632
5315
|
// JSON-stringified into the call and dropped — a caller who set fillColor
|
|
3633
5316
|
// got the default grey with no indication why.
|
|
3634
|
-
const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, ...passthrough } = raw;
|
|
5317
|
+
const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, options: extraOptions, ...passthrough } = raw;
|
|
3635
5318
|
// The engine's own style vocabulary — see stylePresets in MapSystem.js.
|
|
3636
5319
|
const styles = {};
|
|
3637
5320
|
if (fillColor !== undefined)
|
|
@@ -3653,7 +5336,15 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3653
5336
|
// learns why their outline did not move.
|
|
3654
5337
|
if (hoverStroke !== undefined)
|
|
3655
5338
|
styles.hoverStroke = hoverStroke;
|
|
3656
|
-
|
|
5339
|
+
// `options` was declared by the tool's description and stripped by the
|
|
5340
|
+
// schema, so `{style: 'dark'}` never reached loadMap. It carries the
|
|
5341
|
+
// options not named individually above — style presets, showOcean,
|
|
5342
|
+
// fitBounds, preserve — and is spread FIRST so an explicitly named field
|
|
5343
|
+
// still wins over the same key inside it.
|
|
5344
|
+
const engineOptions = {
|
|
5345
|
+
...(extraOptions && typeof extraOptions === 'object' ? extraOptions : {}),
|
|
5346
|
+
...passthrough,
|
|
5347
|
+
};
|
|
3657
5348
|
if (Object.keys(styles).length > 0)
|
|
3658
5349
|
engineOptions.styles = styles;
|
|
3659
5350
|
if (enableHover !== undefined)
|
|
@@ -3693,7 +5384,28 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3693
5384
|
generateHighlightRegions(input) {
|
|
3694
5385
|
const validated = HighlightRegionsInputSchema.parse(input);
|
|
3695
5386
|
const regionIds = JSON.stringify(validated.regionIds);
|
|
3696
|
-
|
|
5387
|
+
// highlightRegions(ids, style) reads style.fill / .stroke / .strokeWidth.
|
|
5388
|
+
// This passed fillColor / strokeColor / strokeWidth, so the first two were
|
|
5389
|
+
// dropped and every highlight came out the default blue. A caller reaching
|
|
5390
|
+
// for `color` — the obvious name, and the one a pilot actually used — was
|
|
5391
|
+
// rejected by the schema before it got this far; it is accepted now and
|
|
5392
|
+
// means the fill.
|
|
5393
|
+
const ho = (validated.options ?? {});
|
|
5394
|
+
const style = {};
|
|
5395
|
+
const fill = ho.color ?? ho.fillColor ?? ho.fill;
|
|
5396
|
+
if (fill !== undefined)
|
|
5397
|
+
style.fill = fill;
|
|
5398
|
+
const stroke = ho.strokeColor ?? ho.stroke;
|
|
5399
|
+
if (stroke !== undefined)
|
|
5400
|
+
style.stroke = stroke;
|
|
5401
|
+
if (ho.strokeWidth !== undefined)
|
|
5402
|
+
style.strokeWidth = ho.strokeWidth;
|
|
5403
|
+
for (const [k, v] of Object.entries(ho)) {
|
|
5404
|
+
if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
|
|
5405
|
+
style[k] = v;
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
const optionsStr = JSON.stringify(style);
|
|
3697
5409
|
return `
|
|
3698
5410
|
// Highlight map regions
|
|
3699
5411
|
(function() {
|
|
@@ -3738,7 +5450,45 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3738
5450
|
generateApplyDataColors(input) {
|
|
3739
5451
|
const validated = ApplyDataColorsInputSchema.parse(input);
|
|
3740
5452
|
const dataStr = JSON.stringify(validated.data);
|
|
3741
|
-
|
|
5453
|
+
// THE ENGINE TAKES A COLOUR RAMP, NOT THE NAME OF ONE.
|
|
5454
|
+
//
|
|
5455
|
+
// applyDataColors destructures `colorScale = ['#f7fbff', '#08519c']` — two
|
|
5456
|
+
// endpoints it interpolates between — plus `domain` and `legend`. This tool
|
|
5457
|
+
// published a preset NAME ('greens'), which the engine then indexed as an
|
|
5458
|
+
// array: colorScale[0] of a string is 'g', and every region came out
|
|
5459
|
+
// near-black. It also published minValue/maxValue/showLegend, none of which
|
|
5460
|
+
// the engine reads.
|
|
5461
|
+
//
|
|
5462
|
+
// Translated here rather than in the schema: the names are the friendlier
|
|
5463
|
+
// surface and this is ordinary cross-repo vocabulary drift, which is what
|
|
5464
|
+
// the emitter exists to absorb.
|
|
5465
|
+
const RAMPS = {
|
|
5466
|
+
blues: ['#f7fbff', '#08519c'],
|
|
5467
|
+
greens: ['#f7fcf5', '#006d2c'],
|
|
5468
|
+
reds: ['#fff5f0', '#a50f15'],
|
|
5469
|
+
oranges: ['#fff5eb', '#a63603'],
|
|
5470
|
+
purples: ['#fcfbfd', '#54278f'],
|
|
5471
|
+
heat: ['#ffffb2', '#bd0026'],
|
|
5472
|
+
};
|
|
5473
|
+
const o = validated.options ?? {};
|
|
5474
|
+
const engineOptions = {};
|
|
5475
|
+
if (o.colorScale)
|
|
5476
|
+
engineOptions.colorScale = RAMPS[o.colorScale] ?? RAMPS.blues;
|
|
5477
|
+
if (o.minValue !== undefined || o.maxValue !== undefined) {
|
|
5478
|
+
const values = Object.values(validated.data);
|
|
5479
|
+
engineOptions.domain = [
|
|
5480
|
+
o.minValue ?? Math.min(...values),
|
|
5481
|
+
o.maxValue ?? Math.max(...values),
|
|
5482
|
+
];
|
|
5483
|
+
}
|
|
5484
|
+
if (o.showLegend !== undefined)
|
|
5485
|
+
engineOptions.legend = o.showLegend;
|
|
5486
|
+
for (const [k, v] of Object.entries(o)) {
|
|
5487
|
+
if (!['colorScale', 'minValue', 'maxValue', 'showLegend'].includes(k) && v !== undefined) {
|
|
5488
|
+
engineOptions[k] = v;
|
|
5489
|
+
}
|
|
5490
|
+
}
|
|
5491
|
+
const optionsStr = JSON.stringify(engineOptions);
|
|
3742
5492
|
return `
|
|
3743
5493
|
// Apply choropleth data colors
|
|
3744
5494
|
(function() {
|
|
@@ -3805,8 +5555,18 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3805
5555
|
}
|
|
3806
5556
|
|
|
3807
5557
|
try {
|
|
3808
|
-
|
|
3809
|
-
|
|
5558
|
+
// addLabels() has never existed. addRegionLabel(regionId, options) is the
|
|
5559
|
+
// engine's own per-region call, and this tool's input is a list of regions,
|
|
5560
|
+
// so the loop IS the mapping — applyCustomLabels takes a
|
|
5561
|
+
// {regionId: text} map instead, which is a different question.
|
|
5562
|
+
const _labelled = [];
|
|
5563
|
+
const _missed = [];
|
|
5564
|
+
for (const _r of ${regionsStr}) {
|
|
5565
|
+
const _item = app.mapSystem.addRegionLabel(_r, ${optionsStr});
|
|
5566
|
+
(_item ? _labelled : _missed).push(_r);
|
|
5567
|
+
}
|
|
5568
|
+
return { success: _labelled.length > 0, labelled: _labelled, count: _labelled.length,
|
|
5569
|
+
...(_missed.length ? { notLabelled: _missed, note: 'no region on this map matched these ids — check get_highlighted_map_regions or the map you loaded.' } : {}) };
|
|
3810
5570
|
} catch (error) {
|
|
3811
5571
|
return { success: false, error: error.message };
|
|
3812
5572
|
}
|
|
@@ -3831,8 +5591,13 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3831
5591
|
}
|
|
3832
5592
|
|
|
3833
5593
|
try {
|
|
3834
|
-
|
|
3835
|
-
|
|
5594
|
+
// panTo() has never existed on any studio, and there is no lat/lon pan for
|
|
5595
|
+
// a flat map to route this to. Refused by NAME with the calls that do work,
|
|
5596
|
+
// rather than letting the caller take an undefined-is-not-a-function.
|
|
5597
|
+
return { success: false, error: 'pan_map is not supported: the engine has no lat/lon pan. '
|
|
5598
|
+
+ 'On a globe, rotateGlobeTo brings a coordinate to the front; on a flat map, zoom to a REGION '
|
|
5599
|
+
+ '(zoomToRegion) or call resetView for the whole view. This tool accepted coordinates and did '
|
|
5600
|
+
+ 'nothing for as long as it existed.' };
|
|
3836
5601
|
} catch (error) {
|
|
3837
5602
|
return { success: false, error: error.message };
|
|
3838
5603
|
}
|
|
@@ -3857,8 +5622,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3857
5622
|
}
|
|
3858
5623
|
|
|
3859
5624
|
try {
|
|
3860
|
-
|
|
3861
|
-
|
|
5625
|
+
// zoomTo(level) has never existed. The engine zooms to a REGION, not to a
|
|
5626
|
+
// numeric level, so there is nothing to convert a level into.
|
|
5627
|
+
return { success: false, error: 'zoom_map takes a numeric level and the engine has no such call: it zooms to a REGION. '
|
|
5628
|
+
+ 'Use zoomToRegion with a region id, or resetView for the full view. A level was accepted here and did '
|
|
5629
|
+
+ 'nothing for as long as this tool existed.' };
|
|
3862
5630
|
} catch (error) {
|
|
3863
5631
|
return { success: false, error: error.message };
|
|
3864
5632
|
}
|
|
@@ -3877,8 +5645,15 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3877
5645
|
}
|
|
3878
5646
|
|
|
3879
5647
|
try {
|
|
3880
|
-
|
|
3881
|
-
|
|
5648
|
+
// exportMap() has never existed. exportGeoJSON() is the real one, and it
|
|
5649
|
+
// returns the geometry rather than a "configuration" — named accordingly
|
|
5650
|
+
// instead of being spread into a shape the caller cannot rely on.
|
|
5651
|
+
if (typeof app.mapSystem.exportGeoJSON !== 'function') {
|
|
5652
|
+
return { success: false, error: 'this studio cannot export map data — app.mapSystem.exportGeoJSON is unavailable.' };
|
|
5653
|
+
}
|
|
5654
|
+
const geojson = app.mapSystem.exportGeoJSON();
|
|
5655
|
+
const info = typeof app.mapSystem.getMapSourceInfo === 'function' ? app.mapSystem.getMapSourceInfo() : null;
|
|
5656
|
+
return { success: true, geojson, ...(info ? { source: info } : {}) };
|
|
3882
5657
|
} catch (error) {
|
|
3883
5658
|
return { success: false, error: error.message };
|
|
3884
5659
|
}
|
|
@@ -3945,7 +5720,16 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3945
5720
|
}
|
|
3946
5721
|
|
|
3947
5722
|
try {
|
|
3948
|
-
|
|
5723
|
+
// getRegionAtPoint() has never existed; the engine exposes no hit test.
|
|
5724
|
+
// canvasToGeo turns a point into COORDINATES, which is a different answer,
|
|
5725
|
+
// so it is offered rather than quietly substituted.
|
|
5726
|
+
if (typeof app.mapSystem.canvasToGeo === 'function') {
|
|
5727
|
+
const coord = app.mapSystem.canvasToGeo(${validated.x}, ${validated.y});
|
|
5728
|
+
return { success: false, coordinate: coord,
|
|
5729
|
+
error: 'the engine has no region hit test. That point is at ' + JSON.stringify(coord)
|
|
5730
|
+
+ ' — find regions by id with get_highlighted_map_regions or getSelectedRegions.' };
|
|
5731
|
+
}
|
|
5732
|
+
const region = null;
|
|
3949
5733
|
if (region) {
|
|
3950
5734
|
return { success: true, regionId: region.id, regionName: region.name, properties: region.properties };
|
|
3951
5735
|
}
|
|
@@ -3999,7 +5783,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
3999
5783
|
}
|
|
4000
5784
|
|
|
4001
5785
|
try {
|
|
4002
|
-
const result = app.mapSystem.
|
|
5786
|
+
const result = app.mapSystem.animateRegionsWave({
|
|
4003
5787
|
duration: ${validated.duration || 10},
|
|
4004
5788
|
loop: ${validated.loop !== false},
|
|
4005
5789
|
colors: ${colorsStr},
|
|
@@ -4697,6 +6481,17 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
4697
6481
|
const keyframesStr = validated.keyframes ? JSON.stringify(validated.keyframes) : 'null';
|
|
4698
6482
|
const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
|
|
4699
6483
|
const preset = validated.preset || validated.maskType || 'wipeLeft';
|
|
6484
|
+
// MODE 3 PASSED AN ANIMATION PRESET WHERE A MASK SHAPE GOES.
|
|
6485
|
+
//
|
|
6486
|
+
// applyCustomMask(item, maskType, keyframes, options) wants a SHAPE —
|
|
6487
|
+
// 'rectangle', 'circle', 'star'. With preset + keyframes this handed it
|
|
6488
|
+
// 'iris' or 'wipeLeft', which is an animation name, so the shape was
|
|
6489
|
+
// whatever the engine falls back to and the keyframes drove the wrong
|
|
6490
|
+
// geometry. maskType is the field that carries the shape, and it is only
|
|
6491
|
+
// used as the preset above when no preset was given at all.
|
|
6492
|
+
const customShape = validated.maskType && validated.maskType !== preset
|
|
6493
|
+
? validated.maskType
|
|
6494
|
+
: 'rectangle';
|
|
4700
6495
|
// Per-preset options. Custom keyframes are an animated-mask extension —
|
|
4701
6496
|
// when supplied, fall through to maskingSystem.applyCustomMask instead.
|
|
4702
6497
|
return `
|
|
@@ -4712,7 +6507,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
4712
6507
|
const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
|
|
4713
6508
|
let maskedGroup;
|
|
4714
6509
|
if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
|
|
4715
|
-
maskedGroup = app.maskingSystem.applyCustomMask(item, '${
|
|
6510
|
+
maskedGroup = app.maskingSystem.applyCustomMask(item, '${customShape}', ${keyframesStr}, mergedOpts);
|
|
4716
6511
|
} else {
|
|
4717
6512
|
maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
|
|
4718
6513
|
}
|
|
@@ -4984,7 +6779,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
4984
6779
|
`.trim();
|
|
4985
6780
|
}
|
|
4986
6781
|
generateImportImage(input) {
|
|
4987
|
-
const { url, position, maxWidth, maxHeight, mask } = input;
|
|
6782
|
+
const { url, position, maxWidth, maxHeight, mask, smoothing } = input;
|
|
4988
6783
|
// Each fragment used to be written with a LEADING comma, as if it followed
|
|
4989
6784
|
// an entry that no longer exists — so the first one opened the object with
|
|
4990
6785
|
// `{, position: …}` and the emitted code was a syntax error. Passing any
|
|
@@ -5016,6 +6811,18 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
5016
6811
|
// the network again — so a host that allows fetch but blocks the image
|
|
5017
6812
|
// load (CORS on <img>, a tainted-canvas refusal) now works too.
|
|
5018
6813
|
let src = '${url}';
|
|
6814
|
+
// A STAGED KEY, not the bytes. The handler writes anything over ~64KB to
|
|
6815
|
+
// window.__ppStage and passes the key instead, because the governor's
|
|
6816
|
+
// loop-guard transform bails on a code string that large and takes the
|
|
6817
|
+
// run's return value with it. See ExecuteCodeOptions.stage.
|
|
6818
|
+
if (src.indexOf('__ppStage:') === 0) {
|
|
6819
|
+
const _key = src.slice('__ppStage:'.length);
|
|
6820
|
+
const _staged = window.__ppStage && window.__ppStage[_key];
|
|
6821
|
+
if (!_staged) {
|
|
6822
|
+
return { success: false, error: 'the image bytes were staged as ' + _key + ' and are not on the page — this is a bug in the MCP server, not your call.' };
|
|
6823
|
+
}
|
|
6824
|
+
src = _staged;
|
|
6825
|
+
}
|
|
5019
6826
|
if (!src.startsWith('data:')) {
|
|
5020
6827
|
let res;
|
|
5021
6828
|
try {
|
|
@@ -5041,7 +6848,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
5041
6848
|
const entry = await app.imageTools.uploadFromURL(src);
|
|
5042
6849
|
const opts = ${optsLiteral};
|
|
5043
6850
|
const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
|
|
5044
|
-
${
|
|
6851
|
+
${smoothing ? ` // Pixel art wants 'off': Paper's Raster draws with smoothing 'low' by
|
|
6852
|
+
// default, which blurs every scaled-up pixel edge. Read at every draw,
|
|
6853
|
+
// export frames included. Set before a mask, which clones the raster.
|
|
6854
|
+
if (raster && 'smoothing' in raster) raster.smoothing = ${JSON.stringify(smoothing)};\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
|
|
5045
6855
|
// fell through to \`raster.id\` — a Paper.js NUMBER — and the handle the tool
|
|
5046
6856
|
// returned could not be used as a relation endpoint or with modify/animate.
|
|
5047
6857
|
// Register the raster if placeImage somehow left it unregistered, so the
|
|
@@ -5053,11 +6863,28 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5053
6863
|
if (!itemId) {
|
|
5054
6864
|
return { error: 'Image placed but not registered — no usable item id. This is a bug; report the scene.' };
|
|
5055
6865
|
}
|
|
6866
|
+
let placed = raster;
|
|
6867
|
+
${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds a
|
|
6868
|
+
// clipping group from a CLONE and removes the original, so the id and bounds
|
|
6869
|
+
// read off \`raster\` afterwards described an item no longer on the canvas.
|
|
6870
|
+
// The group is what is drawn: the registry id is rebound to it (the
|
|
6871
|
+
// registry's own method for exactly this), and a mask that did not apply is
|
|
6872
|
+
// refused instead of answered with the raster's bounds.
|
|
6873
|
+
const masked = app.imageTools.applyMask(raster, ${JSON.stringify(mask)});
|
|
6874
|
+
if (!masked) {
|
|
6875
|
+
return { success: false, itemId: itemId, error: 'the ${mask} mask was not applied — the image may not be a raster yet. The unmasked image is on the canvas as ' + itemId + '.' };
|
|
6876
|
+
}
|
|
6877
|
+
if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
|
|
6878
|
+
placed = masked;
|
|
6879
|
+
` : ''} const b = placed.bounds;
|
|
6880
|
+
if (!b || !(b.width > 0) || !(b.height > 0)) {
|
|
6881
|
+
return { success: false, itemId: itemId, error: 'the image was placed with no size (' + (b ? b.width + 'x' + b.height : 'no bounds') + ') — it may not have loaded. Nothing visible was drawn.' };
|
|
6882
|
+
}
|
|
5056
6883
|
return {
|
|
5057
6884
|
success: true,
|
|
5058
6885
|
itemId: itemId,
|
|
5059
6886
|
message: 'Image imported and placed on canvas.',
|
|
5060
|
-
bounds: { x:
|
|
6887
|
+
bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}
|
|
5061
6888
|
};
|
|
5062
6889
|
} catch (e) {
|
|
5063
6890
|
return { error: 'Failed to import image: ' + e.message };
|
|
@@ -5437,9 +7264,27 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5437
7264
|
return { success: false, error: 'apply needs itemIds, in the order the stagger should follow (row-major for a grid)' };
|
|
5438
7265
|
}
|
|
5439
7266
|
const r = app.staggerItems(ids, ${S(validated.opts || {})});
|
|
7267
|
+
// KEYFRAME TRACKS DO NOT READ animationDelay (round 7 X, 2.2). staggerItems
|
|
7268
|
+
// writes the delay to data.animationDelay, which the loop presets honour;
|
|
7269
|
+
// a tile that already had a keyframe fly-in ignored it, and all 64 tiles of
|
|
7270
|
+
// a shatter mosaic arrived on the same frames. A keyframed item's clock is
|
|
7271
|
+
// its clip offset, data.timeOffset, so the delay goes there too — on top of
|
|
7272
|
+
// the offset it had before the first stagger (remembered), so re-staggering
|
|
7273
|
+
// replaces rather than stacks.
|
|
7274
|
+
let shiftedTracks = 0;
|
|
7275
|
+
if (r && r.ok && Array.isArray(r.delays)) {
|
|
7276
|
+
const items = ids.map(function(id) { const e = app.itemRegistry && app.itemRegistry.get(id); return e && e.item; }).filter(Boolean);
|
|
7277
|
+
items.forEach(function(item, i) {
|
|
7278
|
+
if (!item.data || !Array.isArray(item.data.keyframes) || !item.data.keyframes.length) return;
|
|
7279
|
+
if (item.data._staggerBaseOffset === undefined) item.data._staggerBaseOffset = Number(item.data.timeOffset) || 0;
|
|
7280
|
+
item.data.timeOffset = item.data._staggerBaseOffset + (Number(r.delays[i]) || 0);
|
|
7281
|
+
shiftedTracks++;
|
|
7282
|
+
});
|
|
7283
|
+
}
|
|
5440
7284
|
return {
|
|
5441
7285
|
success: r && r.ok === true,
|
|
5442
7286
|
action: 'apply',
|
|
7287
|
+
shiftedKeyframeTracks: shiftedTracks,
|
|
5443
7288
|
// 'applied' can be lower than the ids given — an id that resolves to
|
|
5444
7289
|
// nothing is skipped, and a stagger that silently covered fewer items than
|
|
5445
7290
|
// asked for is the failure worth seeing.
|
|
@@ -5606,7 +7451,25 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5606
7451
|
generateBrandKit(input) {
|
|
5607
7452
|
const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
|
|
5608
7453
|
const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
|
|
5609
|
-
|
|
7454
|
+
const facade = this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
|
|
7455
|
+
// A KIT'S FONTS ARE LOADED BEFORE IT RESTYLES TEXT — the same fallback as
|
|
7456
|
+
// create_item (see emitEnsureFont): the kit named Anton, the headings were
|
|
7457
|
+
// re-measured in Times. Each family is checked, loaded if missing, and the
|
|
7458
|
+
// outcome for any that needed it rides on the facade's own result.
|
|
7459
|
+
const fonts = input.kit.fonts;
|
|
7460
|
+
const families = [...new Set(Object.values(fonts ?? {}).filter((f) => typeof f === 'string' && f.trim() !== ''))];
|
|
7461
|
+
if (input.action !== 'apply' || families.length === 0)
|
|
7462
|
+
return facade;
|
|
7463
|
+
const loads = families.map((f) => ` r = await (async function() {${emitEnsureFont(f).replace(/\n/g, '\n ')}\n return __font;\n })();\n if (r) __fonts.push(r);`).join('\n');
|
|
7464
|
+
return `
|
|
7465
|
+
// Brand kit: load the kit's fonts, then apply
|
|
7466
|
+
(async function() {
|
|
7467
|
+
const __fonts = [];
|
|
7468
|
+
let r;
|
|
7469
|
+
${loads}
|
|
7470
|
+
const out = await ${facade.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
|
|
7471
|
+
return __fonts.length && out && typeof out === 'object' ? { ...out, fonts: __fonts } : out;
|
|
7472
|
+
})();`.trim();
|
|
5610
7473
|
}
|
|
5611
7474
|
generateComponent(input) {
|
|
5612
7475
|
const ids = JSON.stringify(input.itemIds || []);
|
|
@@ -5625,13 +7488,55 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5625
7488
|
const r = app.defineComponent(items, ${JSON.stringify({ name: input.name })});
|
|
5626
7489
|
return { success: r.ok !== false, ...r };
|
|
5627
7490
|
})();`.trim();
|
|
7491
|
+
// A COMPONENT'S PARTS, BY THE KEY AN OVERRIDE NEEDS (round 9 II, 1.80).
|
|
7492
|
+
// Overrides are {componentKey: {prop: value}}, and the keys are generated
|
|
7493
|
+
// names (ck_12) — list gave only a count and instantiate returned a raw
|
|
7494
|
+
// Paper item with no id, so the component route to batch creative was
|
|
7495
|
+
// unusable. Both now return parts: each key with its part's type, name and
|
|
7496
|
+
// text, which is how a caller finds the headline's key.
|
|
5628
7497
|
case 'list':
|
|
5629
|
-
return
|
|
7498
|
+
return `
|
|
7499
|
+
// Component: list, with each definition's parts
|
|
7500
|
+
(function() {
|
|
7501
|
+
if (typeof app.listComponents !== 'function') return { success: false, error: 'app.listComponents() unavailable — update PinePaper Studio.' };
|
|
7502
|
+
const partsOf = function(root) {
|
|
7503
|
+
const out = [];
|
|
7504
|
+
const walk = function(n) { if (!n) return; if (n.data && n.data.componentKey) out.push(Object.assign({ key: n.data.componentKey, type: n.className }, typeof n.content === 'string' ? { content: n.content } : {}, n.name ? { name: n.name } : {})); (n.children || []).forEach(walk); };
|
|
7505
|
+
(root && root.children || []).forEach(walk);
|
|
7506
|
+
return out;
|
|
7507
|
+
};
|
|
7508
|
+
const defs = app._components;
|
|
7509
|
+
const components = app.listComponents().map(function(c) {
|
|
7510
|
+
let parts = null;
|
|
7511
|
+
try {
|
|
7512
|
+
const d = defs && typeof defs.get === 'function' ? defs.get(c.id) : null;
|
|
7513
|
+
if (d && d.json && typeof paper !== 'undefined') {
|
|
7514
|
+
const g = new paper.Group({ insert: false });
|
|
7515
|
+
g.importJSON(d.json);
|
|
7516
|
+
parts = partsOf(g.children.length === 1 && g.children[0].children ? g.children[0] : g);
|
|
7517
|
+
g.remove();
|
|
7518
|
+
}
|
|
7519
|
+
} catch (_) { /* parts unknown on this build */ }
|
|
7520
|
+
return Object.assign({}, c, parts ? { parts: parts } : {});
|
|
7521
|
+
});
|
|
7522
|
+
return { success: true, components: components, count: components.length };
|
|
7523
|
+
})();`.trim();
|
|
5630
7524
|
case 'instantiate':
|
|
5631
|
-
return
|
|
7525
|
+
return `
|
|
7526
|
+
// Component: instantiate — the instance id, and the keys its overrides use
|
|
7527
|
+
(function() {
|
|
7528
|
+
if (typeof app.instantiateComponent !== 'function') return { success: false, error: 'app.instantiateComponent() unavailable — update PinePaper Studio.' };
|
|
7529
|
+
const r = app.instantiateComponent(${JSON.stringify(input.componentId || '')}, ${JSON.stringify({
|
|
5632
7530
|
...(input.position ? { position: input.position } : {}),
|
|
5633
7531
|
...(input.overrides ? { overrides: input.overrides } : {}),
|
|
5634
|
-
})}
|
|
7532
|
+
})});
|
|
7533
|
+
if (!r || r.ok === false) return { success: false, error: (r && r.error) || 'the component was not placed' };
|
|
7534
|
+
const parts = [];
|
|
7535
|
+
const walk = function(n) { if (!n) return; if (n.data && n.data.componentKey) parts.push(Object.assign({ key: n.data.componentKey, type: n.className }, typeof n.content === 'string' ? { content: n.content } : {}, n.name ? { name: n.name } : {})); (n.children || []).forEach(walk); };
|
|
7536
|
+
walk(r.item);
|
|
7537
|
+
return { success: true, instanceId: r.id, itemId: r.id, parts: parts, orphaned: r.orphaned || [],
|
|
7538
|
+
...(r.orphaned && r.orphaned.length ? { warning: 'overrides for ' + r.orphaned.map(function(o) { return o.key; }).join(', ') + ' matched no part of this component and were not applied. parts lists the keys it has.' } : {}) };
|
|
7539
|
+
})();`.trim();
|
|
5635
7540
|
case 'set_override':
|
|
5636
7541
|
return this._facadeCall('setComponentOverride', `${JSON.stringify(input.instanceId || '')}, ${JSON.stringify(input.componentKey || '')}, ${JSON.stringify(input.prop || '')}, ${JSON.stringify(input.value ?? null)}`, 'Component: set override');
|
|
5637
7542
|
case 'sync':
|
|
@@ -5734,14 +7639,48 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5734
7639
|
}
|
|
5735
7640
|
}
|
|
5736
7641
|
generateAudioBeats(input) {
|
|
7642
|
+
// UPLOADED MEDIA IS THE AUDIO A CALLER HAS, AND IT WAS NOT A SOURCE.
|
|
7643
|
+
//
|
|
7644
|
+
// analyzeAudio treats a bare id as a storage asset and wants its data as a
|
|
7645
|
+
// string, so the id or registryId that upload_audio returned gave "could not
|
|
7646
|
+
// decode audio" — while the detector itself is accurate (3.4 ms mean error,
|
|
7647
|
+
// measured). An uploaded clip's player already holds its bytes at a blob:
|
|
7648
|
+
// URL, which analyzeAudio does accept; either id resolves to that here.
|
|
7649
|
+
// Anything else (asset id, data:, URL) passes through untouched.
|
|
7650
|
+
// @engine-methods analyzeAudio animateToBeat
|
|
7651
|
+
const resolveSource = ` let __src = ${JSON.stringify(input.source || '')};
|
|
7652
|
+
const __A = (typeof window !== 'undefined') && window.PinePaperAgent;
|
|
7653
|
+
const __m = (__src && __A && typeof __A.listMedia === 'function')
|
|
7654
|
+
? __A.listMedia().find(function(x) { return x.id === __src || x.registryId === __src; })
|
|
7655
|
+
: null;
|
|
7656
|
+
if (__m) {
|
|
7657
|
+
const layer = __m.kind === 'video' ? app.videoLayer && app.videoLayer.videos : app.audioLayer && app.audioLayer.audios;
|
|
7658
|
+
const raster = layer && typeof layer.get === 'function' ? layer.get(__m.id) : null;
|
|
7659
|
+
if (!raster || !raster.url) {
|
|
7660
|
+
return { success: false, error: 'uploaded media ' + JSON.stringify(__src) + ' has no playable source to analyse in this studio.' };
|
|
7661
|
+
}
|
|
7662
|
+
__src = raster.url;
|
|
7663
|
+
}`;
|
|
7664
|
+
const call = (method, args, label) => `
|
|
7665
|
+
// ${label}
|
|
7666
|
+
(async function() {
|
|
7667
|
+
if (typeof app.${method} !== 'function') {
|
|
7668
|
+
return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
|
|
7669
|
+
}
|
|
7670
|
+
${resolveSource}
|
|
7671
|
+
const r = await app.${method}(${args});
|
|
7672
|
+
if (r && typeof r === 'object' && 'ok' in r) {
|
|
7673
|
+
return { success: r.ok !== false, ...r };
|
|
7674
|
+
}
|
|
7675
|
+
return { success: true, result: r };
|
|
7676
|
+
})();`.trim();
|
|
5737
7677
|
if (input.action === 'analyze') {
|
|
5738
|
-
return
|
|
7678
|
+
return call('analyzeAudio', `__src, ${JSON.stringify({
|
|
5739
7679
|
...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
|
|
5740
7680
|
...(input.minGap !== undefined ? { minGap: input.minGap } : {}),
|
|
5741
7681
|
})}`, 'Audio: analyze beats');
|
|
5742
7682
|
}
|
|
5743
|
-
|
|
5744
|
-
...(input.source ? { source: input.source } : {}),
|
|
7683
|
+
const opts = JSON.stringify({
|
|
5745
7684
|
...(input.beats ? { beats: input.beats } : {}),
|
|
5746
7685
|
...(input.grid ? { grid: true } : {}),
|
|
5747
7686
|
...(input.property ? { property: input.property } : {}),
|
|
@@ -5749,19 +7688,111 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5749
7688
|
...(input.accent !== undefined ? { accent: input.accent } : {}),
|
|
5750
7689
|
...(input.decay !== undefined ? { decay: input.decay } : {}),
|
|
5751
7690
|
...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
|
|
5752
|
-
})
|
|
7691
|
+
});
|
|
7692
|
+
return call('animateToBeat', `${JSON.stringify(input.itemId || '')}, Object.assign(${opts}, __src ? { source: __src } : {})`, 'Audio: animate to beat');
|
|
5753
7693
|
}
|
|
5754
7694
|
generateTemplateParams(input) {
|
|
5755
7695
|
if (input.action === 'get') {
|
|
5756
7696
|
return this._facadeCall('getTemplateParams', JSON.stringify(input.templateId), 'Template params: get');
|
|
5757
7697
|
}
|
|
5758
|
-
|
|
7698
|
+
// AN UNDECLARED PARAM CHANGED NOTHING, AND THE CALL SAID SUCCESS.
|
|
7699
|
+
// apply {templateId:'sale-announcement', params:{headline, date}} loaded
|
|
7700
|
+
// the template, left its text untouched, and answered success:true — and 0
|
|
7701
|
+
// of 195 templates declare any params (round 7 Z, 1.52). The template's own
|
|
7702
|
+
// declaration is read first and anything outside it is named in the result;
|
|
7703
|
+
// the template still loads, because the caller did ask for it.
|
|
7704
|
+
// @engine-methods getTemplateParams applyTemplateWithParams
|
|
7705
|
+
const params = input.params || {};
|
|
7706
|
+
return `
|
|
7707
|
+
// Template params: apply, naming params the template does not declare
|
|
7708
|
+
(async function() {
|
|
7709
|
+
if (typeof app.applyTemplateWithParams !== 'function') {
|
|
7710
|
+
return { success: false, error: 'app.applyTemplateWithParams() unavailable — update PinePaper Studio to a build that has it.' };
|
|
7711
|
+
}
|
|
7712
|
+
const given = ${JSON.stringify(Object.keys(params))};
|
|
7713
|
+
let declared = null;
|
|
7714
|
+
if (typeof app.getTemplateParams === 'function') {
|
|
7715
|
+
try {
|
|
7716
|
+
const d = await app.getTemplateParams(${JSON.stringify(input.templateId)});
|
|
7717
|
+
if (d && Array.isArray(d.params)) declared = d.params.map(function(p) { return p && (p.name || p.key || p.id); }).filter(Boolean);
|
|
7718
|
+
} catch (_) { /* reported as unknown below */ }
|
|
7719
|
+
}
|
|
7720
|
+
const r = await app.applyTemplateWithParams(${JSON.stringify(input.templateId)}, ${JSON.stringify(params)});
|
|
7721
|
+
const out = (r && typeof r === 'object' && 'ok' in r) ? Object.assign({ success: r.ok !== false }, r)
|
|
7722
|
+
: { success: r !== false, templateId: ${JSON.stringify(input.templateId)} };
|
|
7723
|
+
if (given.length && declared) {
|
|
7724
|
+
const ignored = given.filter(function(k) { return declared.indexOf(k) === -1; });
|
|
7725
|
+
if (ignored.length) {
|
|
7726
|
+
out.ignoredParams = ignored;
|
|
7727
|
+
out.declaredParams = declared;
|
|
7728
|
+
out.warning = declared.length === 0
|
|
7729
|
+
? 'this template declares no parameters, so ' + ignored.join(', ') + ' changed nothing — the template loaded as designed. Edit its text items with pinepaper_modify_item instead.'
|
|
7730
|
+
: ignored.join(', ') + ' ' + (ignored.length > 1 ? 'are' : 'is') + ' not declared by this template and changed nothing. It declares: ' + declared.join(', ') + '.';
|
|
7731
|
+
}
|
|
7732
|
+
} else if (given.length && !declared) {
|
|
7733
|
+
out.warning = 'could not read which parameters this template declares, so whether ' + given.join(', ') + ' applied is unverified.';
|
|
7734
|
+
}
|
|
7735
|
+
return out;
|
|
7736
|
+
})();`.trim();
|
|
5759
7737
|
}
|
|
5760
7738
|
generateSceneDiff(input) {
|
|
5761
7739
|
if (input.action === 'version') {
|
|
5762
7740
|
return this._facadeCall('diffAgainstVersion', JSON.stringify(input.versionId || ''), 'Scene diff: vs version');
|
|
5763
7741
|
}
|
|
5764
|
-
|
|
7742
|
+
// "NO CHANGES" AND "THE SNAPSHOTS DO NOT REFLECT THE CANVAS" ARE DIFFERENT
|
|
7743
|
+
// ANSWERS.
|
|
7744
|
+
//
|
|
7745
|
+
// A pilot added six items and six tracks and was told nothing had changed,
|
|
7746
|
+
// with unchanged:0 — the tell, since a real "no changes" compares items and
|
|
7747
|
+
// finds them equal, so it can never report zero of everything.
|
|
7748
|
+
//
|
|
7749
|
+
// I FIRST DIAGNOSED THIS WRONG, and the wrong cause was in this file's
|
|
7750
|
+
// emitted error for one commit. I read that HistoryManager stores entries
|
|
7751
|
+
// as JSON strings and concluded diffScenes never parsed them. It does:
|
|
7752
|
+
// SceneDiff's asObject() JSON.parses a string at the extraction entry, and
|
|
7753
|
+
// fxtool-f2 verified it two ways — a stringified Paper tree diffs correctly,
|
|
7754
|
+
// and a live browser run on string-stored history returned real added ids.
|
|
7755
|
+
//
|
|
7756
|
+
// The actual cause was GeneratedCodeRunner writing its single history entry
|
|
7757
|
+
// only when items were CREATED, so a run that merely applied an effect, a
|
|
7758
|
+
// style or a keyframe track wrote none. The two entries in those histories
|
|
7759
|
+
// were both empty scenes, and two empty snapshots honestly diffed ARE "no
|
|
7760
|
+
// changes". Fixed in FxTool c95c1d72.
|
|
7761
|
+
//
|
|
7762
|
+
// The backstop stays, for a reason that outlives that fix: a snapshot pair
|
|
7763
|
+
// that does not reflect the canvas must never present as an affirmative
|
|
7764
|
+
// nothing-changed. HistoryManager's own _itemIdsOf states the rule —
|
|
7765
|
+
// "null means could not tell, never empty" — and this is the same rule at
|
|
7766
|
+
// the tool boundary. It no longer guesses WHY, because guessing why is
|
|
7767
|
+
// exactly what went wrong.
|
|
7768
|
+
return `
|
|
7769
|
+
// Scene diff: what changed between two history states
|
|
7770
|
+
(function() {
|
|
7771
|
+
if (typeof app.diffHistoryStates !== 'function') {
|
|
7772
|
+
return { success: false, error: 'app.diffHistoryStates unavailable — update PinePaper Studio.' };
|
|
7773
|
+
}
|
|
7774
|
+
const d = app.diffHistoryStates(${input.indexA ?? 0}, ${input.indexB ?? 0});
|
|
7775
|
+
if (!d || d.ok === false) {
|
|
7776
|
+
return { success: false, error: (d && (d.error || d.reason)) || 'the diff refused without saying why' };
|
|
7777
|
+
}
|
|
7778
|
+
const saw = (d.added || []).length + (d.removed || []).length + (d.changed || []).length + (d.unchanged || 0);
|
|
7779
|
+
const live = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function')
|
|
7780
|
+
? (app.itemRegistry.getAll() || []).length
|
|
7781
|
+
: 0;
|
|
7782
|
+
if (saw === 0 && live > 0) {
|
|
7783
|
+
return {
|
|
7784
|
+
success: false,
|
|
7785
|
+
error: 'the diff read NO items from either history state while the canvas holds ' + live
|
|
7786
|
+
+ ' — so those snapshots do not reflect this canvas, which is not the same as "nothing changed". '
|
|
7787
|
+
+ 'The usual cause is that no history entry was written for the changes you made: some studio builds '
|
|
7788
|
+
+ 'snapshot only when items are CREATED, so applying an effect, a style or a keyframe track records nothing. '
|
|
7789
|
+
+ 'Compare with pinepaper_get_items before and after instead, or use action "version".',
|
|
7790
|
+
liveItems: live,
|
|
7791
|
+
raw: d,
|
|
7792
|
+
};
|
|
7793
|
+
}
|
|
7794
|
+
return { success: true, ...d };
|
|
7795
|
+
})();`.trim();
|
|
5765
7796
|
}
|
|
5766
7797
|
generateTransform(input) {
|
|
5767
7798
|
switch (input.action) {
|
|
@@ -5872,7 +7903,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5872
7903
|
// Get history state
|
|
5873
7904
|
(function() {
|
|
5874
7905
|
${guard}
|
|
5875
|
-
const state = app.historyManager.
|
|
7906
|
+
const state = app.historyManager.getInfo();
|
|
5876
7907
|
return { success: true, action: 'get_state', ...state };
|
|
5877
7908
|
})();`.trim();
|
|
5878
7909
|
default:
|
|
@@ -5883,6 +7914,28 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5883
7914
|
// IMAGE PROCESSING
|
|
5884
7915
|
// ===========================================================================
|
|
5885
7916
|
generateImageFilter(input) {
|
|
7917
|
+
// BLUR TAKES `radius`, AND THIS TOOL'S OWN EXAMPLE SAID `amount`.
|
|
7918
|
+
//
|
|
7919
|
+
// FilterSystem's blur reads params.radius and does Math.floor(undefined) =
|
|
7920
|
+
// NaN, which passes its own `radius <= 0` guard and then blurs by nothing.
|
|
7921
|
+
// So {amount: 18} was accepted, ran, and changed no pixels — while the
|
|
7922
|
+
// schema example told callers to write exactly that. The example is fixed;
|
|
7923
|
+
// the alias stays because the wrong spelling was documented and anyone who
|
|
7924
|
+
// followed it should not have to discover the difference twice.
|
|
7925
|
+
//
|
|
7926
|
+
// BLUR ONLY. grain, bloom, chromaticAberration, displace, datamosh and
|
|
7927
|
+
// paletteMap all read a real `amount`; renaming it for them would turn a
|
|
7928
|
+
// working call into the same silent no-op this alias exists to prevent.
|
|
7929
|
+
const blurAlias = (name, params) => {
|
|
7930
|
+
const out = { ...(params ?? {}) };
|
|
7931
|
+
if (name === 'blur' && out.amount !== undefined && out.radius === undefined) {
|
|
7932
|
+
out.radius = out.amount;
|
|
7933
|
+
delete out.amount;
|
|
7934
|
+
}
|
|
7935
|
+
return out;
|
|
7936
|
+
};
|
|
7937
|
+
const filterParams = blurAlias(input.filterName, input.params);
|
|
7938
|
+
const chainFilters = (input.filters || []).map((f) => ({ ...f, params: blurAlias(f.name, f.params) }));
|
|
5886
7939
|
// Resolve the raster from the registry id (unwrapping groups when the
|
|
5887
7940
|
// build has the _resolveRaster helper), then use the real GPU-filter
|
|
5888
7941
|
// facades. The previous emitter called app.imageTools.applyFilter, a
|
|
@@ -5899,7 +7952,13 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5899
7952
|
(async function() {
|
|
5900
7953
|
${resolve}
|
|
5901
7954
|
try {
|
|
5902
|
-
await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(
|
|
7955
|
+
const r = await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(filterParams)});
|
|
7956
|
+
// applyImageFilter THROWS for a missing raster and answers the item
|
|
7957
|
+
// otherwise; a falsy answer is a refusal it did not raise.
|
|
7958
|
+
if (r === false || r === null) {
|
|
7959
|
+
return { success: false, action: 'apply', itemId: ${JSON.stringify(input.itemId)},
|
|
7960
|
+
error: 'the filter did not apply — ${input.filterName || ''} may not be available on this studio.' };
|
|
7961
|
+
}
|
|
5903
7962
|
return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
|
|
5904
7963
|
} catch (e) {
|
|
5905
7964
|
return { error: 'Failed to apply filter: ' + e.message };
|
|
@@ -5911,7 +7970,7 @@ ${resolve}
|
|
|
5911
7970
|
(async function() {
|
|
5912
7971
|
${resolve}
|
|
5913
7972
|
try {
|
|
5914
|
-
const filters = ${JSON.stringify(
|
|
7973
|
+
const filters = ${JSON.stringify(chainFilters)};
|
|
5915
7974
|
await app.applyImageFilterChain(item, filters);
|
|
5916
7975
|
return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
|
|
5917
7976
|
} catch (e) {
|
|
@@ -5949,38 +8008,118 @@ ${resolve}
|
|
|
5949
8008
|
}
|
|
5950
8009
|
}
|
|
5951
8010
|
generateLasso(input) {
|
|
5952
|
-
|
|
8011
|
+
// LASSO IS A MOUSE TOOL. IT HAS NO HEADLESS FORM.
|
|
8012
|
+
//
|
|
8013
|
+
// These called app.imageTools.activateLasso and .applyLasso, neither of
|
|
8014
|
+
// which exists — imageTools is an ImageToolsManager and the lasso lives on
|
|
8015
|
+
// app.lassoTool. `activate` is real there, but the extraction is driven by
|
|
8016
|
+
// startStroke / continueStroke / endStroke: a freehand path the USER draws.
|
|
8017
|
+
// There is no applyLasso to finish it, so wiring activate alone would leave
|
|
8018
|
+
// a caller in a mode it cannot get out of or complete.
|
|
8019
|
+
//
|
|
8020
|
+
// Refused by name, with the tools that do the same job without a pointer.
|
|
8021
|
+
const why = 'activate / apply drive the interactive, mouse-based lasso. Use action "cut" with itemId and points (a polygon in canvas coordinates) — it drives the same lasso headlessly. '
|
|
8022
|
+
+ 'To cut a region without a pointer, use pinepaper_extract_object (detects and extracts by description), '
|
|
8023
|
+
+ 'or pinepaper_cutout_style / the mask actions on pinepaper_image_tools to shape an existing raster.';
|
|
5953
8024
|
switch (input.action) {
|
|
5954
|
-
case '
|
|
8025
|
+
case 'cut': {
|
|
8026
|
+
// A POLYGON CUT, DRIVEN THROUGH THE LASSO'S OWN CLICK PATH (round 7 X,
|
|
8027
|
+
// 1.55). The interactive tool has no headless form, but its polygon
|
|
8028
|
+
// mode is click-by-click: startStroke/endStroke at a point adds a vertex,
|
|
8029
|
+
// and a click within 12 px of the first vertex closes the shape. That is
|
|
8030
|
+
// public API, so this drives it exactly as a user would, then applies.
|
|
8031
|
+
//
|
|
8032
|
+
// applyLasso DELETES the image it cut from ("chosen behaviour" in the
|
|
8033
|
+
// editor). Over MCP that removed the photo a parallax needs underneath,
|
|
8034
|
+
// silently. keepOriginal (default) cuts from a temporary clone, which is
|
|
8035
|
+
// what gets consumed; the result says which happened.
|
|
8036
|
+
const pts = (input.points ?? []).map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : p));
|
|
8037
|
+
if (!input.itemId)
|
|
8038
|
+
return `({ success: false, action: 'cut', error: 'cut needs itemId — the image to cut from.' });`;
|
|
8039
|
+
if (pts.length < 3)
|
|
8040
|
+
return `({ success: false, action: 'cut', error: 'cut needs at least 3 points (canvas coordinates) around the region.' });`;
|
|
8041
|
+
const keep = input.keepOriginal !== false;
|
|
5955
8042
|
return `
|
|
5956
|
-
//
|
|
8043
|
+
// Lasso cut along a polygon
|
|
5957
8044
|
(async function() {
|
|
5958
|
-
|
|
8045
|
+
const L = app.lassoTool;
|
|
8046
|
+
if (!L || typeof L.activate !== 'function' || typeof L.applyLasso !== 'function') {
|
|
8047
|
+
return { success: false, action: 'cut', error: 'app.lassoTool unavailable — update PinePaper Studio.' };
|
|
8048
|
+
}
|
|
8049
|
+
const entry = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(input.itemId)});
|
|
8050
|
+
const original = entry && entry.item;
|
|
8051
|
+
if (!original) return { success: false, action: 'cut', error: 'no item ' + ${JSON.stringify(input.itemId)} };
|
|
8052
|
+
const target = ${keep} ? original.clone() : original;
|
|
8053
|
+
// A clone carries a COPY of data — including the registry id — and
|
|
8054
|
+
// applyLasso deletes by that id, which would take the original anyway.
|
|
8055
|
+
if (${keep} && target.data) {
|
|
8056
|
+
target.data = Object.assign({}, target.data);
|
|
8057
|
+
delete target.data.registryId; delete target.data.id;
|
|
8058
|
+
}
|
|
8059
|
+
const pts = ${JSON.stringify(pts)}.map(function(p) { return new paper.Point(p.x, p.y); });
|
|
8060
|
+
const first = pts[0];
|
|
8061
|
+
let skipped = 0;
|
|
8062
|
+
L.activate(target);
|
|
8063
|
+
// POLYGON MODE, SET — not assumed. On production the lasso's remembered mode
|
|
8064
|
+
// was 'freehand', where a click adds no vertex, so every cut was refused as
|
|
8065
|
+
// "did not close". The user's own mode is put back afterwards.
|
|
8066
|
+
const __prevMode = L._forcedMode;
|
|
8067
|
+
if (typeof L.setMode === 'function') L.setMode('polygon');
|
|
5959
8068
|
try {
|
|
5960
|
-
|
|
5961
|
-
|
|
8069
|
+
L.startStroke(first); L.endStroke(first);
|
|
8070
|
+
for (let i = 1; i < pts.length; i++) {
|
|
8071
|
+
if (pts[i].getDistance(first) < 12) { skipped++; continue; }
|
|
8072
|
+
L.startStroke(pts[i]); L.endStroke(pts[i]);
|
|
8073
|
+
}
|
|
8074
|
+
L.startStroke(first); L.endStroke(first); // a click at the first vertex closes it
|
|
8075
|
+
if (!L.isDrawn()) {
|
|
8076
|
+
L.cancel();
|
|
8077
|
+
if (${keep}) target.remove();
|
|
8078
|
+
return { success: false, action: 'cut', error: 'the polygon did not close — it needs at least 3 vertices at least 12 px from the first one.', skipped: skipped };
|
|
8079
|
+
}
|
|
8080
|
+
const cutout = await L.applyLasso();
|
|
8081
|
+
if (!cutout) {
|
|
8082
|
+
if (${keep} && target.parent) target.remove();
|
|
8083
|
+
return { success: false, action: 'cut', error: 'the lasso produced no cutout — the item may not be an image.' };
|
|
8084
|
+
}
|
|
8085
|
+
let cutoutId = cutout.data && (cutout.data.registryId || cutout.data.id);
|
|
8086
|
+
if (!cutoutId && typeof app.registerItem === 'function') cutoutId = app.registerItem(cutout, 'image', { source: 'mcp-lasso' });
|
|
8087
|
+
return { success: true, action: 'cut', cutoutId: cutoutId, originalId: ${JSON.stringify(input.itemId)},
|
|
8088
|
+
originalKept: ${keep}, vertices: pts.length - skipped, skipped: skipped,
|
|
8089
|
+
bounds: { x: cutout.bounds.x, y: cutout.bounds.y, width: cutout.bounds.width, height: cutout.bounds.height } };
|
|
5962
8090
|
} catch (e) {
|
|
5963
|
-
|
|
8091
|
+
try { L.cancel(); } catch (_) { /* already down */ }
|
|
8092
|
+
if (${keep} && target && target.parent) target.remove();
|
|
8093
|
+
return { success: false, action: 'cut', error: (e && e.message) || String(e) };
|
|
8094
|
+
} finally {
|
|
8095
|
+
if (typeof L.setMode === 'function' && __prevMode) { try { L.setMode(__prevMode); } catch (_) { /* cosmetic */ } }
|
|
5964
8096
|
}
|
|
5965
8097
|
})();`.trim();
|
|
8098
|
+
}
|
|
8099
|
+
case 'activate':
|
|
5966
8100
|
case 'apply':
|
|
5967
8101
|
return `
|
|
5968
|
-
//
|
|
5969
|
-
(
|
|
5970
|
-
${guard}
|
|
5971
|
-
try {
|
|
5972
|
-
const result = await app.imageTools.applyLasso();
|
|
5973
|
-
return { success: true, action: 'apply' };
|
|
5974
|
-
} catch (e) {
|
|
5975
|
-
return { error: 'Failed to apply lasso: ' + e.message };
|
|
5976
|
-
}
|
|
5977
|
-
})();`.trim();
|
|
8102
|
+
// Lasso — refused, rather than calling into a method that does not exist
|
|
8103
|
+
({ success: false, action: ${JSON.stringify(input.action)}, error: ${JSON.stringify(why)} });`.trim();
|
|
5978
8104
|
default:
|
|
5979
8105
|
return `(function() { return { error: 'Unknown lasso action: ${input.action}' }; })();`;
|
|
5980
8106
|
}
|
|
5981
8107
|
}
|
|
5982
8108
|
generateCutoutStyle(input) {
|
|
5983
|
-
|
|
8109
|
+
// THE FACADE WAS THE WRONG ONE ENTIRELY.
|
|
8110
|
+
//
|
|
8111
|
+
// These called app.imageTools.applyCutoutStyle / .getCutoutStyles, and
|
|
8112
|
+
// neither exists anywhere in the engine — imageTools is an
|
|
8113
|
+
// ImageToolsManager and knows nothing about cutout presets. Reported from
|
|
8114
|
+
// production as "apply returns success:true with the error nested inside",
|
|
8115
|
+
// which is what awaiting a call on undefined inside a try looks like.
|
|
8116
|
+
//
|
|
8117
|
+
// The real surface is app.cutoutStyles (a CutoutStylePresets), with
|
|
8118
|
+
// applyPreset(item, name, options) and getPresets(). The parity guard
|
|
8119
|
+
// could not see this: readFacades resolved a class by CONVENTION —
|
|
8120
|
+
// imageTools -> ImageTools, which is not the class's name — so the facade
|
|
8121
|
+
// was never mapped, and an unmapped facade is not checked.
|
|
8122
|
+
const guard = `if (!app.cutoutStyles) return { success: false, error: 'cutout styles are unavailable on this studio (app.cutoutStyles is absent) — update PinePaper Studio.' };`;
|
|
5984
8123
|
switch (input.action) {
|
|
5985
8124
|
case 'apply':
|
|
5986
8125
|
return `
|
|
@@ -5988,7 +8127,11 @@ ${resolve}
|
|
|
5988
8127
|
(async function() {
|
|
5989
8128
|
${guard}
|
|
5990
8129
|
try {
|
|
5991
|
-
const
|
|
8130
|
+
const target = app.getItemById && app.getItemById(${JSON.stringify(input.itemId || '')});
|
|
8131
|
+
if (!target) {
|
|
8132
|
+
return { success: false, action: 'apply', error: 'no item ' + ${JSON.stringify(input.itemId || '')} + ' — applyPreset takes a live item, so the id has to resolve first.' };
|
|
8133
|
+
}
|
|
8134
|
+
const result = await app.cutoutStyles.applyPreset(target, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
|
|
5992
8135
|
// An unknown preset returns the very item it was given, unchanged — so a
|
|
5993
8136
|
// truthy result proves nothing. The engine records the refusal on it.
|
|
5994
8137
|
const cutRej = result && result.data && result.data.cutoutStyleRejected;
|
|
@@ -6005,7 +8148,9 @@ ${resolve}
|
|
|
6005
8148
|
// List available cutout styles
|
|
6006
8149
|
(function() {
|
|
6007
8150
|
${guard}
|
|
6008
|
-
const styles = app.
|
|
8151
|
+
const styles = typeof app.getCutoutStylePresets === 'function'
|
|
8152
|
+
? app.getCutoutStylePresets()
|
|
8153
|
+
: app.cutoutStyles.getPresets();
|
|
6009
8154
|
return { success: true, action: 'list', styles };
|
|
6010
8155
|
})();`.trim();
|
|
6011
8156
|
default:
|
|
@@ -6046,14 +8191,25 @@ ${resolve}
|
|
|
6046
8191
|
|
|
6047
8192
|
const precomp = app.createPrecomp(items, opts);
|
|
6048
8193
|
if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
|
|
6049
|
-
|
|
8194
|
+
// STRINGIFIED, because the schema declares precompId as a string and the
|
|
8195
|
+
// engine mints a NUMBER. Returning the number meant the id could not be
|
|
8196
|
+
// round-tripped: pass it back to add/remove and zod rejects it before the
|
|
8197
|
+
// call is made.
|
|
8198
|
+
const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
|
|
8199
|
+
return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
|
|
6050
8200
|
})();`.trim();
|
|
6051
8201
|
}
|
|
6052
8202
|
case 'add':
|
|
6053
8203
|
return `
|
|
6054
8204
|
// Add item to precomp
|
|
6055
8205
|
(function() {
|
|
6056
|
-
|
|
8206
|
+
// addToPrecomp answers falsy when the precomp or the item is not found, and
|
|
8207
|
+
// this reported success regardless — the same silent-success shape as an
|
|
8208
|
+
// unknown itemId elsewhere.
|
|
8209
|
+
const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
|
|
8210
|
+
if (_ok === false || _ok === null || _ok === undefined) {
|
|
8211
|
+
return { success: false, action: 'add', error: 'nothing was added — check the precompId (create returns it as a string) and the itemId with pinepaper_get_items.' };
|
|
8212
|
+
}
|
|
6057
8213
|
return { success: true, action: 'add', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
|
|
6058
8214
|
})();`.trim();
|
|
6059
8215
|
case 'remove':
|
|
@@ -6572,7 +8728,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6572
8728
|
// Trigger interaction action
|
|
6573
8729
|
(function() {
|
|
6574
8730
|
${guard}
|
|
6575
|
-
app.interactionSystem.
|
|
8731
|
+
app.interactionSystem.executeAction('${input.actionType}', ${params});
|
|
6576
8732
|
return { success: true, action: 'trigger_action', actionType: '${input.actionType}' };
|
|
6577
8733
|
})();`.trim();
|
|
6578
8734
|
}
|
|
@@ -6606,7 +8762,28 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6606
8762
|
(async function() {
|
|
6607
8763
|
if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
|
|
6608
8764
|
const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
|
|
6609
|
-
|
|
8765
|
+
// THE EMBED BOX WAS ALWAYS 16:9, AND IT IS NOT SELF-CONTAINED (round 9 GG,
|
|
8766
|
+
// 8.32). The engine writes aspect-ratio: 16/9 and max-width: 800px whatever
|
|
8767
|
+
// the scene, so a 300x250 ad sat letterboxed in a wide box. The box is sized
|
|
8768
|
+
// from the canvas here. The embed also loads PineWidget.js from
|
|
8769
|
+
// pinepaper.studio and fetches the scene JSON at runtime — said, with the
|
|
8770
|
+
// zero-dependency alternative.
|
|
8771
|
+
const cs = (typeof app.getCanvasSize === 'function' && app.getCanvasSize()) || app.canvasSize || null;
|
|
8772
|
+
let embedCode = String(result.embedCode || '');
|
|
8773
|
+
if (cs && cs.width > 0 && cs.height > 0) {
|
|
8774
|
+
// RegExp from strings with no backslashes — a template literal eats them.
|
|
8775
|
+
embedCode = embedCode
|
|
8776
|
+
.replace(new RegExp('aspect-ratio:[ ]*[0-9.]+[ ]*/[ ]*[0-9.]+'), 'aspect-ratio: ' + cs.width + '/' + cs.height)
|
|
8777
|
+
.replace(new RegExp('max-width:[ ]*[0-9.]+px'), 'max-width: ' + cs.width + 'px');
|
|
8778
|
+
}
|
|
8779
|
+
const deps = [];
|
|
8780
|
+
const scriptSrc = /<script[^>]+src="([^"]+)"/.exec(embedCode);
|
|
8781
|
+
if (scriptSrc) deps.push({ kind: 'script', url: scriptSrc[1] });
|
|
8782
|
+
if (result.filename) deps.push({ kind: 'scene-json', url: result.filename, note: 'fetched at runtime — host it beside the page' });
|
|
8783
|
+
return { success: true, json: result.json, filename: result.filename, embedCode: embedCode, metadata: result.data?.metadata,
|
|
8784
|
+
size: cs ? { width: cs.width, height: cs.height } : null,
|
|
8785
|
+
dependencies: deps,
|
|
8786
|
+
note: 'this embed needs the files in dependencies at runtime. For a single self-contained file with no network calls (ads, email-safe hosting), use pinepaper_export_widget_html.' };
|
|
6610
8787
|
})();`.trim();
|
|
6611
8788
|
}
|
|
6612
8789
|
generateExportWidgetHtml(input) {
|
|
@@ -6620,7 +8797,26 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6620
8797
|
(async function() {
|
|
6621
8798
|
if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
|
|
6622
8799
|
const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
|
|
6623
|
-
|
|
8800
|
+
${input.lang || input.dir || input.alt ? `// lang / dir / alt on the engine's page (it writes <html lang="en"> and no
|
|
8801
|
+
// text alternative). Applied as string edits on the one <html> tag and the
|
|
8802
|
+
// first <canvas>; the result says which landed.
|
|
8803
|
+
const __esc = function(v) { return String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); };
|
|
8804
|
+
let __html = String(result.html || '');
|
|
8805
|
+
const __applied = [];
|
|
8806
|
+
${input.lang || input.dir ? `__html = __html.replace(/<html\\b[^>]*>/i, function() { __applied.push('html'); return '<html lang="' + __esc(${JSON.stringify(input.lang ?? 'en')}) + '"' + ${JSON.stringify(input.dir ? ` dir="${input.dir}"` : '')} + '>'; });` : ''}
|
|
8807
|
+
${input.alt ? `// The engine's widget draws its canvas from script, so there is no <canvas>
|
|
8808
|
+
// tag to label (prod retest): its accessible name is div#w's aria-label,
|
|
8809
|
+
// mirrored in the screen-reader text #w-desc. Both carry the alt; a literal
|
|
8810
|
+
// <canvas> is labelled too when a page has one. RegExp from strings, so no
|
|
8811
|
+
// backslash escapes pass through this template.
|
|
8812
|
+
const __alt = __esc(${JSON.stringify(input.alt)});
|
|
8813
|
+
__html = __html.replace(new RegExp('<div id="w"([^>]*?)aria-label="[^"]*"', 'i'), function(m, mid) { __applied.push('widget-label'); return '<div id="w"' + mid + 'aria-label="' + __alt + '"'; });
|
|
8814
|
+
__html = __html.replace(new RegExp('(<div[^>]*id="w-desc"[^>]*>)[^<]*(</div>)', 'i'), function(m, open, close) { __applied.push('widget-desc'); return open + __alt + close; });
|
|
8815
|
+
__html = __html.replace(new RegExp('<canvas(?=[\\\\s>])', 'i'), function(m) { __applied.push('canvas'); return m + ' role="img" aria-label="' + __alt + '"'; });
|
|
8816
|
+
__html = __html.replace(/<head>/i, function(m) { __applied.push('meta'); return m + '<meta name="description" content="' + __esc(${JSON.stringify(input.alt)}) + '">'; });` : ''}
|
|
8817
|
+
result.html = __html;
|
|
8818
|
+
result.a11y = __applied;` : ''}
|
|
8819
|
+
return { success: true, html: result.html,${input.lang || input.dir || input.alt ? ' a11y: result.a11y,' : ''} estimatedSize: result.estimatedSize, analysis: { itemTypes: [...result.analysis.itemTypes], relationTypes: [...result.analysis.relationTypes], hasSimpleAnimations: result.analysis.hasSimpleAnimations, hasKeyframeAnimations: result.analysis.hasKeyframeAnimations, hasMasks: result.analysis.hasMasks } };
|
|
6624
8820
|
})();`.trim();
|
|
6625
8821
|
}
|
|
6626
8822
|
// ===========================================================================
|
|
@@ -6634,8 +8830,11 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6634
8830
|
return `(function() { return { error: 'chartType is required for create' }; })();`;
|
|
6635
8831
|
if (!input.data)
|
|
6636
8832
|
return `(function() { return { error: 'data is required for create' }; })();`;
|
|
8833
|
+
const fields = resolveChartFields(input.chartType, input.data, input.options || {});
|
|
8834
|
+
if ('error' in fields)
|
|
8835
|
+
return `(function() { return { success: false, error: ${JSON.stringify(fields.error)} }; })();`;
|
|
6637
8836
|
const dataStr = JSON.stringify(input.data);
|
|
6638
|
-
const optsStr = JSON.stringify(
|
|
8837
|
+
const optsStr = JSON.stringify(fields.options);
|
|
6639
8838
|
return `
|
|
6640
8839
|
// Create chart
|
|
6641
8840
|
(function() {
|
|
@@ -6643,7 +8842,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6643
8842
|
const group = app.createChart(${JSON.stringify(input.chartType)}, ${dataStr}, ${optsStr});
|
|
6644
8843
|
if (!group) return { error: 'Failed to create chart' };
|
|
6645
8844
|
const chartId = app.chartSystem.getLastChartId();
|
|
6646
|
-
return { success: true, action: 'create', chartType: ${JSON.stringify(input.chartType)}, chartId, dataPoints: ${input.data.length} };
|
|
8845
|
+
return { success: true, action: 'create', chartType: ${JSON.stringify(input.chartType)}, chartId, dataPoints: ${input.data.length}${fields.inferred ? `, fields: ${JSON.stringify(fields.inferred)}` : ''} };
|
|
6647
8846
|
})();`.trim();
|
|
6648
8847
|
}
|
|
6649
8848
|
case 'update': {
|
|
@@ -6962,6 +9161,23 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6962
9161
|
* with asNodes promotes each detection to a typed, image-anchored design node
|
|
6963
9162
|
* (pp:Detected*) instead of a labeled box. Async (runs an on-device ML model).
|
|
6964
9163
|
*/
|
|
9164
|
+
// THE MODEL CANNOT BE DOWNLOADED, AND THE ERROR SAID ONLY "Failed to fetch".
|
|
9165
|
+
//
|
|
9166
|
+
// Detection runs transformers.js in the page, which pulls the model weights
|
|
9167
|
+
// from huggingface.co. pinepaper.studio's connect-src lists neither that host
|
|
9168
|
+
// nor its CDN (checked against the served header), so the download is refused
|
|
9169
|
+
// for every page on production — an agent read "Failed to fetch
|
|
9170
|
+
// (huggingface.co)" and could not tell whether to retry, pass another image,
|
|
9171
|
+
// or give up. It is a deployment fix (allow the host, or serve the weights
|
|
9172
|
+
// from the studio's own origin), and the error now says so.
|
|
9173
|
+
static MODEL_FETCH_HINT = `function(msg) {
|
|
9174
|
+
if (/fail(ed)? to fetch|networkerror|load failed/i.test(msg) && /huggingface|hf\\.co/i.test(msg)) {
|
|
9175
|
+
return 'the detection model could not be downloaded: the page could not fetch its weights from huggingface.co. '
|
|
9176
|
+
+ 'The studio has to allow that host in its content-security policy, or serve the weights itself — no argument to this call works around it, and retrying will not help. '
|
|
9177
|
+
+ 'Other image tools are unaffected. (' + msg + ')';
|
|
9178
|
+
}
|
|
9179
|
+
return msg;
|
|
9180
|
+
}`;
|
|
6965
9181
|
generateDetectObjects(input) {
|
|
6966
9182
|
const args = JSON.stringify({
|
|
6967
9183
|
...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
|
|
@@ -6977,8 +9193,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6977
9193
|
}
|
|
6978
9194
|
try {
|
|
6979
9195
|
const res = await app.detectObjects(${args});
|
|
6980
|
-
|
|
6981
|
-
|
|
9196
|
+
const out = Object.assign({ success: !!(res && res.ok) }, res || {});
|
|
9197
|
+
if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
|
|
9198
|
+
return out;
|
|
9199
|
+
} catch (e) { return { success: false, error: (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(String((e && e.message) || e)) }; }
|
|
6982
9200
|
})();`.trim();
|
|
6983
9201
|
}
|
|
6984
9202
|
/**
|
|
@@ -7001,8 +9219,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7001
9219
|
}
|
|
7002
9220
|
try {
|
|
7003
9221
|
const res = await app.extractObject(${args});
|
|
7004
|
-
|
|
7005
|
-
|
|
9222
|
+
const out = Object.assign({ success: !!(res && res.ok) }, res || {});
|
|
9223
|
+
if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
|
|
9224
|
+
return out;
|
|
9225
|
+
} catch (e) { return { success: false, error: (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(String((e && e.message) || e)) }; }
|
|
7006
9226
|
})();`.trim();
|
|
7007
9227
|
}
|
|
7008
9228
|
/**
|
|
@@ -7059,7 +9279,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7059
9279
|
app.groupManager.addItemsToGroup(items, group);
|
|
7060
9280
|
if (app.historyManager) app.historyManager.saveState();
|
|
7061
9281
|
const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
|
|
7062
|
-
|
|
9282
|
+
// itemId too (round 8 BB, 1.70): every other create answers itemId, and a
|
|
9283
|
+
// group is an item every other tool addresses by that id. In a batch this
|
|
9284
|
+
// does not claim a $N slot — only create ops do.
|
|
9285
|
+
return { success: true, groupId: groupId, itemId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
|
|
7063
9286
|
}
|
|
7064
9287
|
/**
|
|
7065
9288
|
* Group a set of items into one draggable entity, or ungroup one back into loose
|
|
@@ -7078,7 +9301,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7078
9301
|
if (!result || !result.parts || !result.parts.length) return { success: false, error: 'Nothing to break apart (no sub-parts found)' };
|
|
7079
9302
|
const partIds = result.parts.map(function(p) { return p && p.data && (p.data.registryId || p.data.id); }).filter(Boolean);
|
|
7080
9303
|
const groupId = (result.group && result.group.data && (result.group.data.registryId || result.group.data.groupId)) || null;
|
|
7081
|
-
return { success: true, action: 'break_apart', groupId: groupId, partIds: partIds, partCount: partIds.length };
|
|
9304
|
+
return { success: true, action: 'break_apart', groupId: groupId, itemId: groupId, partIds: partIds, partCount: partIds.length };
|
|
7082
9305
|
})();`.trim();
|
|
7083
9306
|
}
|
|
7084
9307
|
if (input.action === 'ungroup') {
|
|
@@ -7134,6 +9357,68 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7134
9357
|
* sceneAt(t). Returns a cheap per-frame hash by default (token-light) so an agent can
|
|
7135
9358
|
* verify determinism (re-run → identical hashes) and whether frames actually change.
|
|
7136
9359
|
*/
|
|
9360
|
+
/**
|
|
9361
|
+
* Measurements for pinepaper_accessibility_check; the verdicts are made on
|
|
9362
|
+
* the server (src/utils/a11y.ts). Text: fill, size, weight, and the solid
|
|
9363
|
+
* fill of the topmost item under its centre. Flash: mean relative luminance
|
|
9364
|
+
* of a 3 x 3 grid per sampled frame, through captureFramesAt (deterministic
|
|
9365
|
+
* seek). Pixel work is native reduce / Array.from, not generated loops, so
|
|
9366
|
+
* the loop budget does not count it.
|
|
9367
|
+
*/
|
|
9368
|
+
generateAccessibilityCheck(input) {
|
|
9369
|
+
const checks = input.checks ?? ['contrast', 'flash'];
|
|
9370
|
+
const fps = input.fps ?? 20;
|
|
9371
|
+
const duration = input.duration ?? 5;
|
|
9372
|
+
const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
|
|
9373
|
+
return `
|
|
9374
|
+
// Accessibility measurements (${checks.join(', ')})
|
|
9375
|
+
(function() {
|
|
9376
|
+
const out = { success: true, fps: ${fps}, duration: ${duration} };
|
|
9377
|
+
const rgba = function(c) {
|
|
9378
|
+
if (!c || c.gradient || typeof c.red !== 'number') return null;
|
|
9379
|
+
return { r: Math.round(c.red * 255), g: Math.round(c.green * 255), b: Math.round(c.blue * 255), a: typeof c.alpha === 'number' ? c.alpha : 1 };
|
|
9380
|
+
};
|
|
9381
|
+
out.page = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
|
|
9382
|
+
${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
|
|
9383
|
+
const texts = all.filter(function(e) { return e && e.item && e.item.className === 'PointText' && e.item.visible !== false && String(e.item.content || '').trim() !== ''; });
|
|
9384
|
+
const solids = all.filter(function(e) { return e && e.item && e.item.className !== 'PointText' && e.item.visible !== false && rgba(e.item.fillColor) && e.item.bounds; });
|
|
9385
|
+
out.texts = texts.map(function(e) {
|
|
9386
|
+
const it = e.item, c = it.bounds.center;
|
|
9387
|
+
const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
|
|
9388
|
+
const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
|
|
9389
|
+
const w = String(it.fontWeight || '');
|
|
9390
|
+
return { id: e.itemId, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
|
|
9391
|
+
bold: w === 'bold' || Number(w) >= 700, behind: top ? rgba(top.item.fillColor) : null, behindId: top ? top.itemId : undefined };
|
|
9392
|
+
});
|
|
9393
|
+
` : ''}${checks.includes('flash') ? ` if (typeof app.captureFramesAt !== 'function') {
|
|
9394
|
+
out.flashUnavailable = 'this studio has no deterministic frame capture (app.captureFramesAt), so flashing was not checked.';
|
|
9395
|
+
} else {
|
|
9396
|
+
const W = 48, H = 27;
|
|
9397
|
+
const small = document.createElement('canvas');
|
|
9398
|
+
small.width = W; small.height = H;
|
|
9399
|
+
const ctx = small.getContext('2d', { willReadFrequently: true });
|
|
9400
|
+
const lut = Array.from({ length: 256 }, function(_, v) { const x = v / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); });
|
|
9401
|
+
const times = Array.from({ length: ${n} }, function(_, i) { return i / ${fps}; });
|
|
9402
|
+
out.series = app.captureFramesAt(times, { capture: function(c) {
|
|
9403
|
+
// Transparent canvas shows the page's CSS background, so composite on it.
|
|
9404
|
+
ctx.fillStyle = out.page || '#ffffff';
|
|
9405
|
+
ctx.fillRect(0, 0, W, H);
|
|
9406
|
+
ctx.drawImage(c, 0, 0, W, H);
|
|
9407
|
+
const px = ctx.getImageData(0, 0, W, H).data;
|
|
9408
|
+
const acc = Array.prototype.reduce.call(px, function(a, v, k) {
|
|
9409
|
+
if (k % 4 !== 3) return a;
|
|
9410
|
+
const p = (k - 3) / 4, x = p % W, y = (p - x) / W;
|
|
9411
|
+
const cell = Math.min(2, Math.floor(y * 3 / H)) * 3 + Math.min(2, Math.floor(x * 3 / W));
|
|
9412
|
+
a.sum[cell] += 0.2126 * lut[px[k - 3]] + 0.7152 * lut[px[k - 2]] + 0.0722 * lut[px[k - 1]];
|
|
9413
|
+
a.n[cell] += 1;
|
|
9414
|
+
return a;
|
|
9415
|
+
}, { sum: [0, 0, 0, 0, 0, 0, 0, 0, 0], n: [0, 0, 0, 0, 0, 0, 0, 0, 0] });
|
|
9416
|
+
return acc.sum.map(function(s, i) { return acc.n[i] ? s / acc.n[i] : 0; });
|
|
9417
|
+
} });
|
|
9418
|
+
}
|
|
9419
|
+
` : ''} return out;
|
|
9420
|
+
})();`.trim();
|
|
9421
|
+
}
|
|
7137
9422
|
generateCaptureFrames(input) {
|
|
7138
9423
|
const timesJson = JSON.stringify(input.times);
|
|
7139
9424
|
const seed = input.seed !== undefined ? input.seed : 0;
|
|
@@ -7145,7 +9430,18 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7145
9430
|
return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
|
|
7146
9431
|
}
|
|
7147
9432
|
const includeDataUrls = ${includeDataUrls};
|
|
7148
|
-
|
|
9433
|
+
// THE HASH LOOP RAN ONCE PER CHARACTER OF A DATA URL, inside generated code,
|
|
9434
|
+
// where every loop site is budgeted at 2,000,000 iterations — so any frame
|
|
9435
|
+
// over ~2 MB (three photos, a shader backdrop) died "Loop budget exceeded".
|
|
9436
|
+
// app.hashFrame is the same djb2-xor in engine code, outside the budget, so
|
|
9437
|
+
// hashes stay comparable with builds that only have the fallback.
|
|
9438
|
+
const hashStr = (typeof app.hashFrame === 'function')
|
|
9439
|
+
? function(s) { return app.hashFrame(s); }
|
|
9440
|
+
// The fallback must not be a for loop either: the budget is per loop SITE
|
|
9441
|
+
// across the whole run, so 50 frames of ~40 KB data URLs exhausted it on a
|
|
9442
|
+
// plain 9-text scene (round 7 X, #7) on studios without hashFrame. A native
|
|
9443
|
+
// reduce is not a generated loop and is not counted; same djb2-xor.
|
|
9444
|
+
: function(s) { return Array.prototype.reduce.call(s, function(h, c) { return (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0; }, 5381).toString(16); };
|
|
7149
9445
|
const frames = app.captureFramesAt(${timesJson}, {
|
|
7150
9446
|
seed: ${seed},
|
|
7151
9447
|
capture: function(c, t, i) {
|
|
@@ -7263,6 +9559,47 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7263
9559
|
generateMedia(input) {
|
|
7264
9560
|
const guard = ` const A = (typeof window !== 'undefined') && window.PinePaperAgent;
|
|
7265
9561
|
if (!A || typeof A.uploadVideo !== 'function') { return { success: false, error: 'window.PinePaperAgent media API unavailable — update FxTool to a media-capable build' }; }`;
|
|
9562
|
+
// The URL to upload from: the staged bytes when the handler put a large
|
|
9563
|
+
// data: URL on window.__ppStage (see pinepaper_media in handlers.ts).
|
|
9564
|
+
//
|
|
9565
|
+
// A data: URL IS DECODED HERE INTO A File, NOT HANDED OVER AS A URL. The
|
|
9566
|
+
// engine's upload fetch()es a string, and production's CSP refuses fetch()
|
|
9567
|
+
// of data: — so every data: upload failed on prod with "Failed to fetch ()"
|
|
9568
|
+
// at every size (a local engine without the CSP accepted small ones, which
|
|
9569
|
+
// is what made it look like a size limit). uploadAudio/uploadVideo take a
|
|
9570
|
+
// File directly. Uint8Array.from with a map, not a for loop: a generated
|
|
9571
|
+
// loop is budgeted per iteration and a few MB of audio would exhaust it.
|
|
9572
|
+
const srcExpr = (url, kind) => `(function(u) {
|
|
9573
|
+
if (u.indexOf('__ppStage:') === 0) {
|
|
9574
|
+
const k = u.slice('__ppStage:'.length);
|
|
9575
|
+
const v = window.__ppStage && window.__ppStage[k];
|
|
9576
|
+
if (!v) throw new Error('the media bytes were staged as ' + k + ' and are not on the page — a bug in the MCP server, not your call.');
|
|
9577
|
+
u = v;
|
|
9578
|
+
}
|
|
9579
|
+
if (u.indexOf('data:') !== 0) return u;
|
|
9580
|
+
const comma = u.indexOf(',');
|
|
9581
|
+
const meta = u.slice(5, comma);
|
|
9582
|
+
const mime = meta.split(';')[0] || '${kind === 'audio' ? 'audio/mpeg' : 'video/mp4'}';
|
|
9583
|
+
const body = u.slice(comma + 1);
|
|
9584
|
+
const bin = /;base64/i.test(meta) ? atob(body) : decodeURIComponent(body);
|
|
9585
|
+
const bytes = Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
|
|
9586
|
+
const ext = (mime.split('/')[1] || '${kind === 'audio' ? 'mp3' : 'mp4'}').replace(/[^a-z0-9]/gi, '');
|
|
9587
|
+
return new File([bytes], '${kind}.' + ext, { type: mime });
|
|
9588
|
+
})(${JSON.stringify(url ?? '')})`;
|
|
9589
|
+
// TWO ID SPACES, AND THE CALLER HOLDS THE WRONG ONE.
|
|
9590
|
+
//
|
|
9591
|
+
// upload answers {id, registryId}; every other tool on this surface speaks
|
|
9592
|
+
// registry ids (item_N), so that is the one callers keep. But remove,
|
|
9593
|
+
// setMediaPlaybackRate and setMediaClip look the id up in the audio/video
|
|
9594
|
+
// layers, which are keyed by MEDIA id — so set_playback_rate with the
|
|
9595
|
+
// registryId returned false, reported as a failure with no reason. Either
|
|
9596
|
+
// form is resolved here; a miss names the id and what was looked for.
|
|
9597
|
+
const resolveMedia = ` const __ref = ${JSON.stringify(input.id ?? '')};
|
|
9598
|
+
const __m = (typeof A.listMedia === 'function')
|
|
9599
|
+
? A.listMedia().find(function(x) { return x.id === __ref || x.registryId === __ref; })
|
|
9600
|
+
: null;
|
|
9601
|
+
const __mid = __m ? __m.id : __ref;
|
|
9602
|
+
const __miss = { success: false, error: 'no uploaded audio or video matches ' + JSON.stringify(__ref) + ' — pass the id or registryId that upload returned (media list shows both).' };`;
|
|
7266
9603
|
switch (input.action) {
|
|
7267
9604
|
case 'upload_video': {
|
|
7268
9605
|
const opts = JSON.stringify({
|
|
@@ -7276,7 +9613,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
7276
9613
|
// Upload video from URL
|
|
7277
9614
|
(async function() {
|
|
7278
9615
|
${guard}
|
|
7279
|
-
const info = await A.uploadVideo(${
|
|
9616
|
+
const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${opts});
|
|
7280
9617
|
return { success: true, action: 'upload_video', media: info };
|
|
7281
9618
|
})();`.trim();
|
|
7282
9619
|
}
|
|
@@ -7291,7 +9628,13 @@ ${guard}
|
|
|
7291
9628
|
// Upload audio from URL
|
|
7292
9629
|
(async function() {
|
|
7293
9630
|
${guard}
|
|
7294
|
-
const info = await A.uploadAudio(${
|
|
9631
|
+
const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${opts});${input.volume !== undefined ? `
|
|
9632
|
+
// THE LEVEL IS STORED UNDER ONE KEY AND EXPORTED FROM ANOTHER. The upload
|
|
9633
|
+
// records volume as \`gain\` on the registry entry; the video exporter's mix
|
|
9634
|
+
// reads \`audioGain\`. So a bed uploaded at 0.25 exported at unity (measured:
|
|
9635
|
+
// 0.0 dB change). Written to both until the engine reads one.
|
|
9636
|
+
const __ae = info && info.registryId && app.itemRegistry ? app.itemRegistry.get(info.registryId) : null;
|
|
9637
|
+
if (__ae) { __ae.properties = __ae.properties || {}; __ae.properties.audioGain = ${input.volume}; }` : ''}
|
|
7295
9638
|
return { success: true, action: 'upload_audio', media: info };
|
|
7296
9639
|
})();`.trim();
|
|
7297
9640
|
}
|
|
@@ -7307,16 +9650,24 @@ ${guard}
|
|
|
7307
9650
|
// Remove media
|
|
7308
9651
|
(function() {
|
|
7309
9652
|
${guard}
|
|
7310
|
-
|
|
7311
|
-
|
|
9653
|
+
${resolveMedia}
|
|
9654
|
+
if (!__m) return __miss;
|
|
9655
|
+
const removed = A.removeMedia(__mid);
|
|
9656
|
+
return removed
|
|
9657
|
+
? { success: true, action: 'remove', id: __mid, registryId: __m.registryId, removed: true }
|
|
9658
|
+
: { success: false, action: 'remove', id: __mid, error: 'the studio did not remove ' + __mid + '.' };
|
|
7312
9659
|
})();`.trim();
|
|
7313
9660
|
case 'set_playback_rate':
|
|
7314
9661
|
return `
|
|
7315
9662
|
// Set media playback rate
|
|
7316
9663
|
(function() {
|
|
7317
9664
|
${guard}
|
|
7318
|
-
|
|
7319
|
-
|
|
9665
|
+
${resolveMedia}
|
|
9666
|
+
if (!__m) return __miss;
|
|
9667
|
+
const ok = A.setMediaPlaybackRate(__mid, ${input.rate});
|
|
9668
|
+
return ok
|
|
9669
|
+
? { success: true, action: 'set_playback_rate', id: __mid, registryId: __m.registryId, rate: ${input.rate} }
|
|
9670
|
+
: { success: false, action: 'set_playback_rate', id: __mid, error: 'the studio refused rate ${input.rate} for ' + __mid + '.' };
|
|
7320
9671
|
})();`.trim();
|
|
7321
9672
|
case 'set_clip':
|
|
7322
9673
|
return `
|
|
@@ -7324,8 +9675,12 @@ ${guard}
|
|
|
7324
9675
|
(function() {
|
|
7325
9676
|
${guard}
|
|
7326
9677
|
if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
|
|
7327
|
-
|
|
7328
|
-
|
|
9678
|
+
${resolveMedia}
|
|
9679
|
+
if (!__m) return __miss;
|
|
9680
|
+
const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint});
|
|
9681
|
+
return ok
|
|
9682
|
+
? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} }
|
|
9683
|
+
: { success: false, action: 'set_clip', id: __mid, error: 'the studio refused the clip window ${input.inPoint}–${input.outPoint} for ' + __mid + '.' };
|
|
7329
9684
|
})();`.trim();
|
|
7330
9685
|
// ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
|
|
7331
9686
|
// facades), not the agent media API, so they carry their own guards.
|
|
@@ -7337,7 +9692,33 @@ ${guard}
|
|
|
7337
9692
|
(function() {
|
|
7338
9693
|
if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
|
|
7339
9694
|
const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
|
|
7340
|
-
|
|
9695
|
+
if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'remap failed' };
|
|
9696
|
+
// WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
|
|
9697
|
+
// the timeline only for its trimmed window, and past that it is HIDDEN
|
|
9698
|
+
// whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
|
|
9699
|
+
// 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
|
|
9700
|
+
// of the curve is a hold. Both answered success before.
|
|
9701
|
+
const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
|
|
9702
|
+
const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
|
|
9703
|
+
const __p = (__e && __e.properties) || {};
|
|
9704
|
+
const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
|
|
9705
|
+
const __warnings = [];
|
|
9706
|
+
const __out = typeof __p.outPoint === 'number' ? __p.outPoint : __p.duration;
|
|
9707
|
+
if (__tr.length > 1 && Number.isFinite(__out)) {
|
|
9708
|
+
const __in = typeof __p.inPoint === 'number' ? __p.inPoint : 0;
|
|
9709
|
+
const __start = typeof __p.clipStartTime === 'number' ? __p.clipStartTime : 0;
|
|
9710
|
+
const __end = __start + Math.max(0.001, __out - __in);
|
|
9711
|
+
const __last = __tr[__tr.length - 1].time;
|
|
9712
|
+
if (__last > __end + 0.001) {
|
|
9713
|
+
__warnings.push('the remap runs to ' + __last + ' s but the clip is on the timeline only until ' + __end.toFixed(3) + ' s (start + trimmed length); after that it is hidden, not extended. Lengthen the clip window first.');
|
|
9714
|
+
}
|
|
9715
|
+
const __vals = __tr.map(function(k) { return k.value; });
|
|
9716
|
+
const __hi = Math.max.apply(null, __vals), __lo = Math.min.apply(null, __vals);
|
|
9717
|
+
if (__hi > __out + 0.001 || __lo < __in - 0.001) {
|
|
9718
|
+
__warnings.push('remap source times run ' + __lo.toFixed(3) + '–' + __hi.toFixed(3) + ' s but the clip only has ' + __in.toFixed(3) + '–' + __out.toFixed(3) + ' s; the part outside is clamped, which plays as a hold.');
|
|
9719
|
+
}
|
|
9720
|
+
}
|
|
9721
|
+
return Object.assign({ success: true, action: 'set_time_remap', points: r.points }, __warnings.length ? { warnings: __warnings } : {});
|
|
7341
9722
|
})();`.trim();
|
|
7342
9723
|
}
|
|
7343
9724
|
case 'speed_ramp': {
|
|
@@ -7347,7 +9728,33 @@ ${guard}
|
|
|
7347
9728
|
(function() {
|
|
7348
9729
|
if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
|
|
7349
9730
|
const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
|
|
7350
|
-
|
|
9731
|
+
if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'speed ramp failed' };
|
|
9732
|
+
// WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
|
|
9733
|
+
// the timeline only for its trimmed window, and past that it is HIDDEN
|
|
9734
|
+
// whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
|
|
9735
|
+
// 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
|
|
9736
|
+
// of the curve is a hold. Both answered success before.
|
|
9737
|
+
const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
|
|
9738
|
+
const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
|
|
9739
|
+
const __p = (__e && __e.properties) || {};
|
|
9740
|
+
const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
|
|
9741
|
+
const __warnings = [];
|
|
9742
|
+
const __out = typeof __p.outPoint === 'number' ? __p.outPoint : __p.duration;
|
|
9743
|
+
if (__tr.length > 1 && Number.isFinite(__out)) {
|
|
9744
|
+
const __in = typeof __p.inPoint === 'number' ? __p.inPoint : 0;
|
|
9745
|
+
const __start = typeof __p.clipStartTime === 'number' ? __p.clipStartTime : 0;
|
|
9746
|
+
const __end = __start + Math.max(0.001, __out - __in);
|
|
9747
|
+
const __last = __tr[__tr.length - 1].time;
|
|
9748
|
+
if (__last > __end + 0.001) {
|
|
9749
|
+
__warnings.push('the remap runs to ' + __last + ' s but the clip is on the timeline only until ' + __end.toFixed(3) + ' s (start + trimmed length); after that it is hidden, not extended. Lengthen the clip window first.');
|
|
9750
|
+
}
|
|
9751
|
+
const __vals = __tr.map(function(k) { return k.value; });
|
|
9752
|
+
const __hi = Math.max.apply(null, __vals), __lo = Math.min.apply(null, __vals);
|
|
9753
|
+
if (__hi > __out + 0.001 || __lo < __in - 0.001) {
|
|
9754
|
+
__warnings.push('remap source times run ' + __lo.toFixed(3) + '–' + __hi.toFixed(3) + ' s but the clip only has ' + __in.toFixed(3) + '–' + __out.toFixed(3) + ' s; the part outside is clamped, which plays as a hold.');
|
|
9755
|
+
}
|
|
9756
|
+
}
|
|
9757
|
+
return Object.assign({ success: true, action: 'speed_ramp', points: r.points }, __warnings.length ? { warnings: __warnings } : {});
|
|
7351
9758
|
})();`.trim();
|
|
7352
9759
|
}
|
|
7353
9760
|
case 'match_cut': {
|
|
@@ -7468,6 +9875,9 @@ ${guard}
|
|
|
7468
9875
|
...(r.note ? { note: r.note } : {}) };
|
|
7469
9876
|
})();`.trim();
|
|
7470
9877
|
}
|
|
9878
|
+
// 'list' is the spelling pinepaper_text_effect uses for the same action,
|
|
9879
|
+
// accepted here so moving between the two tools does not dead-end.
|
|
9880
|
+
case 'list':
|
|
7471
9881
|
case 'list_styles': {
|
|
7472
9882
|
return `
|
|
7473
9883
|
// Text styles + palettes + font axes — the picker surface
|
|
@@ -7486,9 +9896,19 @@ ${guard}
|
|
|
7486
9896
|
// Text: write it as STROKED handwriting — a path, not a glyph
|
|
7487
9897
|
(function() {
|
|
7488
9898
|
if (typeof app.createCursiveText !== 'function') { return { success: false, error: 'app.createCursiveText unavailable — update FxTool' }; }
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
9899
|
+
// createCursiveText answers { items, group, totalWidth, bounds } — not an
|
|
9900
|
+
// item — so reading .data.id off the answer returned no id, every time. The
|
|
9901
|
+
// group is registered only on one of its two branches; the other is
|
|
9902
|
+
// registered here so the caller always gets an addressable id.
|
|
9903
|
+
const r = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
|
|
9904
|
+
const g = r && (r.group || (r.data ? r : null));
|
|
9905
|
+
if (!g) { return { success: false, error: 'the cursive text produced no path' }; }
|
|
9906
|
+
let itemId = g.data && (g.data.id || g.data.registryId);
|
|
9907
|
+
if (!itemId && app.itemRegistry && typeof app.itemRegistry.register === 'function') {
|
|
9908
|
+
itemId = app.itemRegistry.register(g, 'cursiveText', { content: ${S(input.text)} }, 'user');
|
|
9909
|
+
}
|
|
9910
|
+
if (!itemId) { return { success: false, error: 'the cursive text was drawn but not registered — no usable item id.' }; }
|
|
9911
|
+
return { success: true, itemId: itemId, strokes: (r.items || []).length, width: r.totalWidth };
|
|
7492
9912
|
})();`.trim();
|
|
7493
9913
|
case 'wrap':
|
|
7494
9914
|
return `
|
|
@@ -7703,9 +10123,42 @@ ${guard}
|
|
|
7703
10123
|
// r.items are live Paper objects — NOT serializable, deliberately dropped.
|
|
7704
10124
|
// The source item id is GONE unless keepSource was set, so r.ids is now the
|
|
7705
10125
|
// caller's entire handle set; returning it is what makes the tool usable.
|
|
10126
|
+
// EVERY EFFECT PLAYS FROM t=0, so in a multi-shot video they all fire on the
|
|
10127
|
+
// opening frame and only the first title reads. The engine has no start
|
|
10128
|
+
// delay, so the keyframes it just wrote are shifted here — the same thing a
|
|
10129
|
+
// caller was doing by hand through execute_custom_code.
|
|
10130
|
+
//
|
|
10131
|
+
// Shifted, then held: without an explicit invisible keyframe at 0 the
|
|
10132
|
+
// characters sit in their FIRST pose until the effect starts, which for most
|
|
10133
|
+
// of the 37 is a scatter that looks like a bug. hideAfter closes the other
|
|
10134
|
+
// end, because they otherwise survive to the end of the clip.
|
|
10135
|
+
const _startAt = ${S(input.startAt ?? 0)};
|
|
10136
|
+
const _hideAfter = ${S(input.hideAfter ?? null)};
|
|
10137
|
+
let _shifted = 0;
|
|
10138
|
+
if ((_startAt > 0 || _hideAfter) && Array.isArray(r.ids)) {
|
|
10139
|
+
for (const _id of r.ids) {
|
|
10140
|
+
const _it = app.getItemById && app.getItemById(_id);
|
|
10141
|
+
const _kfs = _it && _it.data && _it.data.keyframes;
|
|
10142
|
+
if (!Array.isArray(_kfs) || _kfs.length === 0) continue;
|
|
10143
|
+
if (_startAt > 0) {
|
|
10144
|
+
for (const _k of _kfs) _k.time += _startAt;
|
|
10145
|
+
_kfs.unshift({ time: 0, properties: { opacity: 0 }, easing: 'linear' });
|
|
10146
|
+
_kfs.splice(1, 0, { time: Math.max(0, _startAt - 0.001), properties: { opacity: 0 }, easing: 'linear' });
|
|
10147
|
+
}
|
|
10148
|
+
if (_hideAfter) {
|
|
10149
|
+
_kfs.push({ time: Math.max(0, _hideAfter - 0.001), properties: { opacity: 1 }, easing: 'linear' });
|
|
10150
|
+
_kfs.push({ time: _hideAfter, properties: { opacity: 0 }, easing: 'linear' });
|
|
10151
|
+
}
|
|
10152
|
+
_kfs.sort((a, b) => a.time - b.time);
|
|
10153
|
+
_shifted++;
|
|
10154
|
+
}
|
|
10155
|
+
}
|
|
7706
10156
|
return {
|
|
7707
10157
|
success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
|
|
7708
|
-
duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
|
|
10158
|
+
duration: (r.duration || 0) + _startAt, sourceRemoved: ${S(input.keepSource !== true)},
|
|
10159
|
+
...(_startAt > 0 ? { startAt: _startAt } : {}),
|
|
10160
|
+
...(_hideAfter ? { hideAfter: _hideAfter } : {}),
|
|
10161
|
+
...((_startAt > 0 || _hideAfter) ? { retimed: _shifted } : {}),
|
|
7709
10162
|
};
|
|
7710
10163
|
})();`.trim();
|
|
7711
10164
|
}
|
|
@@ -7828,7 +10281,7 @@ ${guard}
|
|
|
7828
10281
|
// World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
|
|
7829
10282
|
(async function() {
|
|
7830
10283
|
if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
|
|
7831
|
-
await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
|
|
10284
|
+
await app.createWorld3D(${S(world3dColors(input.spec ?? 'forest'))}, ${opts});
|
|
7832
10285
|
// describe() is the world's own parameter schema — return the preset list +
|
|
7833
10286
|
// top-level keys so the agent knows what configure can touch, without the
|
|
7834
10287
|
// full multi-KB schema on every create.
|
|
@@ -7849,7 +10302,7 @@ ${guard}
|
|
|
7849
10302
|
(function() {
|
|
7850
10303
|
if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
|
|
7851
10304
|
${needWorld}
|
|
7852
|
-
const r = app.configureWorld3D(${S(input.patch)});
|
|
10305
|
+
const r = app.configureWorld3D(${S(world3dColors(input.patch))});
|
|
7853
10306
|
// The validator names the right key on a wrong one — forward it verbatim.
|
|
7854
10307
|
return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
|
|
7855
10308
|
})();`.trim();
|
|
@@ -7905,7 +10358,7 @@ ${needWorld}
|
|
|
7905
10358
|
// World3D: place an object (y defaults to sitting on the terrain)
|
|
7906
10359
|
(function() {
|
|
7907
10360
|
${needWorld}
|
|
7908
|
-
const id = app.addWorldObject(${S(input.object)});
|
|
10361
|
+
const id = app.addWorldObject(${S(world3dColors(input.object))});
|
|
7909
10362
|
return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
|
|
7910
10363
|
})();`.trim();
|
|
7911
10364
|
case 'remove_object':
|
|
@@ -7939,6 +10392,25 @@ ${needWorld}
|
|
|
7939
10392
|
mesh.rotY = mesh.rotYDegrees * Math.PI / 180;
|
|
7940
10393
|
delete mesh.rotYDegrees;
|
|
7941
10394
|
}
|
|
10395
|
+
// A COLOUR, NOT A BLACK MESH (round 9 HH, 5.64). The default mesh
|
|
10396
|
+
// shader reads `uniform vec3 diffuse` (0..1) plus metalness / roughness,
|
|
10397
|
+
// and extrude / lathe passed no uniforms — so every authored mesh
|
|
10398
|
+
// rendered black and there was no parameter to change it. color (hex,
|
|
10399
|
+
// [r,g,b] 0..255 or 0..1) becomes diffuse; with none given it is a
|
|
10400
|
+
// neutral light grey, which lights readably. Explicit uniforms win.
|
|
10401
|
+
{
|
|
10402
|
+
const uniforms = { ...(mesh.uniforms ?? {}) };
|
|
10403
|
+
if (uniforms.diffuse === undefined)
|
|
10404
|
+
uniforms.diffuse = mesh.color !== undefined ? world3dColor(mesh.color) : [0.8, 0.8, 0.8];
|
|
10405
|
+
if (uniforms.metalness === undefined && typeof mesh.metalness === 'number')
|
|
10406
|
+
uniforms.metalness = mesh.metalness;
|
|
10407
|
+
if (uniforms.roughness === undefined)
|
|
10408
|
+
uniforms.roughness = typeof mesh.roughness === 'number' ? mesh.roughness : 0.6;
|
|
10409
|
+
delete mesh.color;
|
|
10410
|
+
delete mesh.metalness;
|
|
10411
|
+
delete mesh.roughness;
|
|
10412
|
+
mesh.uniforms = uniforms;
|
|
10413
|
+
}
|
|
7942
10414
|
return `
|
|
7943
10415
|
// World3D: ${input.action} — a canvas path becomes real geometry
|
|
7944
10416
|
(async function() {
|
|
@@ -8440,8 +10912,34 @@ ${guard('exportBVH')}
|
|
|
8440
10912
|
// Interchange: one PNG per frame
|
|
8441
10913
|
(async function() {
|
|
8442
10914
|
${guard('exportPNGSequence')}
|
|
10915
|
+
// The studio does not size a sequence to the scene: without duration / fps
|
|
10916
|
+
// it writes its own default (measured 45 frames = 3 s at 15 fps for a 1 s
|
|
10917
|
+
// scene, 29 of them duplicates). Said in the result when that happened.
|
|
10918
|
+
const __pngSeqDefaults = ${JSON.stringify((() => {
|
|
10919
|
+
const o = (input.options ?? {});
|
|
10920
|
+
const missing = ['duration', 'fps'].filter((k) => o[k] === undefined);
|
|
10921
|
+
return missing.length ? { defaultsUsed: missing, note: `${missing.join(' and ')} not given, so the studio's own default length was used — it does not follow the scene (runs have measured 45 and 90 frames). Pass options.duration and options.fps to match the scene.` } : {};
|
|
10922
|
+
})())};
|
|
8443
10923
|
const out = await app.exportPNGSequence(${opts});
|
|
8444
10924
|
if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
|
|
10925
|
+
// A BLOB SERIALISES TO {}. exporter.export() answers a Blob, and returning it
|
|
10926
|
+
// raw produced a success whose result was an empty object — which reads as
|
|
10927
|
+
// "it worked and gave me nothing" rather than "the bytes could not cross
|
|
10928
|
+
// the bridge". Structured clone drops a Blob's
|
|
10929
|
+
// contents; only a string survives.
|
|
10930
|
+
if (typeof Blob !== 'undefined' && out instanceof Blob) {
|
|
10931
|
+
const dataUrl = await new Promise(function(res, rej) {
|
|
10932
|
+
const fr = new FileReader();
|
|
10933
|
+
fr.onload = function() { res(fr.result); };
|
|
10934
|
+
fr.onerror = function() { rej(new Error('could not read the PNG sequence')); };
|
|
10935
|
+
fr.readAsDataURL(out);
|
|
10936
|
+
});
|
|
10937
|
+
return Object.assign({ success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size }, __pngSeqDefaults);
|
|
10938
|
+
}
|
|
10939
|
+
if (Array.isArray(out)) {
|
|
10940
|
+
return { success: true, format: 'png-sequence', frames: out.length,
|
|
10941
|
+
note: 'the frames stayed in the page — pass download: true to write them out.' };
|
|
10942
|
+
}
|
|
8445
10943
|
return { success: true, format: 'png-sequence', result: out };
|
|
8446
10944
|
})();`.trim();
|
|
8447
10945
|
}
|
|
@@ -8586,15 +11084,72 @@ ${guard('renderSoundtrackWav')}
|
|
|
8586
11084
|
};
|
|
8587
11085
|
})();`.trim();
|
|
8588
11086
|
}
|
|
8589
|
-
case 'create':
|
|
11087
|
+
case 'create': {
|
|
11088
|
+
const v = (input.visual ?? {});
|
|
11089
|
+
const { startTime, duration, ...visualOnly } = v;
|
|
11090
|
+
const placement = (startTime !== undefined || duration !== undefined)
|
|
11091
|
+
? { ...(startTime !== undefined ? { startTime } : {}), ...(duration !== undefined ? { duration } : {}) }
|
|
11092
|
+
: null;
|
|
8590
11093
|
return `
|
|
8591
11094
|
// Sound: draw it AS a waveform path — the item and the sound are one thing
|
|
8592
11095
|
(function() {
|
|
8593
11096
|
${guard('createSound')}
|
|
8594
|
-
const item = app.createSound(${S(input.spec ?? {})}, ${S(
|
|
11097
|
+
const item = app.createSound(${S(input.spec ?? {})}, ${S(visualOnly)});
|
|
8595
11098
|
if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
|
|
8596
|
-
|
|
11099
|
+
const _sid = item.data && item.data.id;
|
|
11100
|
+
// ONE CALL PER CUE, NOT TWO. startTime/duration were stripped from visual,
|
|
11101
|
+
// so placing a cue meant create + set_placement every time — a pilot made
|
|
11102
|
+
// 160 calls where 80 would do. Applied here when given, using the same
|
|
11103
|
+
// setSoundPlacement the separate action calls.
|
|
11104
|
+
${placement ? `if (_sid && app.setSoundPlacement) { app.setSoundPlacement(_sid, ${S(placement)}); }` : ''}
|
|
11105
|
+
return { success: true, itemId: _sid, itemType: 'sound'${placement ? `, placement: ${S(placement)}` : ''} };
|
|
8597
11106
|
})();`.trim();
|
|
11107
|
+
}
|
|
11108
|
+
case 'sequence': {
|
|
11109
|
+
// ONE CALL FOR A WHOLE BED.
|
|
11110
|
+
//
|
|
11111
|
+
// Every cue was create + set_placement, so a 112-cue music bed cost 112
|
|
11112
|
+
// round trips once visual.startTime landed and 224 before it. Nothing
|
|
11113
|
+
// about the work needs a trip each: the cues are independent, they
|
|
11114
|
+
// differ only in time and spec, and the page can loop.
|
|
11115
|
+
//
|
|
11116
|
+
// Each cue is reported individually. A bed where three cues failed and
|
|
11117
|
+
// 109 worked is not a failure, and it is not a success either — the
|
|
11118
|
+
// caller needs to know WHICH, so placed/failed come back by index.
|
|
11119
|
+
const cues = (input.cues ?? []);
|
|
11120
|
+
return `
|
|
11121
|
+
// Sound: a whole sequence of cues, created and placed in one pass
|
|
11122
|
+
(function() {
|
|
11123
|
+
${guard('createSound')}
|
|
11124
|
+
const cues = ${S(cues)};
|
|
11125
|
+
const placed = [];
|
|
11126
|
+
const failed = [];
|
|
11127
|
+
for (let i = 0; i < cues.length; i++) {
|
|
11128
|
+
const c = cues[i];
|
|
11129
|
+
try {
|
|
11130
|
+
const spec = c.spec || (c.preset ? { preset: c.preset, note: c.note } : null);
|
|
11131
|
+
if (!spec) { failed.push({ index: i, t: c.t, error: 'neither spec nor preset' }); continue; }
|
|
11132
|
+
const item = app.createSound(spec, c.visual || {});
|
|
11133
|
+
if (!item) { failed.push({ index: i, t: c.t, error: 'the cue produced no waveform path' }); continue; }
|
|
11134
|
+
const id = item.data && item.data.id;
|
|
11135
|
+
if (id && app.setSoundPlacement) {
|
|
11136
|
+
app.setSoundPlacement(id, { startTime: c.t, ...(c.duration ? { duration: c.duration } : {}) });
|
|
11137
|
+
}
|
|
11138
|
+
placed.push({ index: i, itemId: id, t: c.t });
|
|
11139
|
+
} catch (e) {
|
|
11140
|
+
failed.push({ index: i, t: c.t, error: (e && e.message) || 'cue failed' });
|
|
11141
|
+
}
|
|
11142
|
+
}
|
|
11143
|
+
return {
|
|
11144
|
+
success: failed.length === 0,
|
|
11145
|
+
action: 'sequence',
|
|
11146
|
+
placed: placed.length,
|
|
11147
|
+
itemIds: placed.map(function(p) { return p.itemId; }),
|
|
11148
|
+
cues: placed,
|
|
11149
|
+
...(failed.length ? { failed: failed, error: failed.length + ' of ' + cues.length + ' cues did not sound — see failed[] for which.' } : {}),
|
|
11150
|
+
};
|
|
11151
|
+
})();`.trim();
|
|
11152
|
+
}
|
|
8598
11153
|
case 'timbre_from_path':
|
|
8599
11154
|
return `
|
|
8600
11155
|
// Sound: read a drawn path's shape as harmonic content
|