@pinepaper.studio/mcp-server 1.6.15 → 1.6.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +110 -4
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/build-info.json +2 -2
  11. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  12. package/dist/ontology/upstream-vocabulary.js +2 -2
  13. package/dist/tools/definitions.d.ts.map +1 -1
  14. package/dist/tools/definitions.js +164 -19
  15. package/dist/tools/definitions.js.map +1 -1
  16. package/dist/tools/engine-methods.d.ts.map +1 -1
  17. package/dist/tools/engine-methods.js +4 -0
  18. package/dist/tools/engine-methods.js.map +1 -1
  19. package/dist/tools/engine-surface.d.ts +1 -1
  20. package/dist/tools/engine-surface.d.ts.map +1 -1
  21. package/dist/tools/engine-surface.js +30 -9
  22. package/dist/tools/engine-surface.js.map +1 -1
  23. package/dist/tools/handlers/beats.d.ts +64 -0
  24. package/dist/tools/handlers/beats.d.ts.map +1 -0
  25. package/dist/tools/handlers/beats.js +83 -0
  26. package/dist/tools/handlers/beats.js.map +1 -0
  27. package/dist/tools/handlers/camera.d.ts.map +1 -1
  28. package/dist/tools/handlers/camera.js +11 -0
  29. package/dist/tools/handlers/camera.js.map +1 -1
  30. package/dist/tools/handlers/generate.d.ts +324 -0
  31. package/dist/tools/handlers/generate.d.ts.map +1 -0
  32. package/dist/tools/handlers/generate.js +264 -0
  33. package/dist/tools/handlers/generate.js.map +1 -0
  34. package/dist/tools/handlers.d.ts.map +1 -1
  35. package/dist/tools/handlers.js +79 -9
  36. package/dist/tools/handlers.js.map +1 -1
  37. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  38. package/dist/tools/minimal-descriptions.js +5 -0
  39. package/dist/tools/minimal-descriptions.js.map +1 -1
  40. package/dist/tools/shape-params.d.ts +2 -2
  41. package/dist/tools/shape-params.js +2 -2
  42. package/dist/tools/stick-vocabulary.d.ts +1 -1
  43. package/dist/tools/stick-vocabulary.js +1 -1
  44. package/dist/tools/toolkits.d.ts.map +1 -1
  45. package/dist/tools/toolkits.js +5 -0
  46. package/dist/tools/toolkits.js.map +1 -1
  47. package/dist/types/code-generator.d.ts +38 -0
  48. package/dist/types/code-generator.d.ts.map +1 -1
  49. package/dist/types/code-generator.js +552 -44
  50. package/dist/types/code-generator.js.map +1 -1
  51. package/dist/types/schemas.d.ts +614 -82
  52. package/dist/types/schemas.d.ts.map +1 -1
  53. package/dist/types/schemas.js +53 -21
  54. package/dist/types/schemas.js.map +1 -1
  55. package/dist/utils/a11y.d.ts +2 -0
  56. package/dist/utils/a11y.d.ts.map +1 -1
  57. package/dist/utils/a11y.js +1 -1
  58. package/dist/utils/a11y.js.map +1 -1
  59. package/dist/utils/beats.d.ts +58 -0
  60. package/dist/utils/beats.d.ts.map +1 -0
  61. package/dist/utils/beats.js +110 -0
  62. package/dist/utils/beats.js.map +1 -0
  63. package/dist/utils/svg-size.d.ts +22 -0
  64. package/dist/utils/svg-size.d.ts.map +1 -0
  65. package/dist/utils/svg-size.js +43 -0
  66. package/dist/utils/svg-size.js.map +1 -0
  67. package/dist/vendor/design/design-style.js +1 -1
  68. package/manifest.json +21 -1
  69. package/package.json +1 -1
@@ -4,6 +4,7 @@
4
4
  * Converts MCP tool inputs to executable PinePaper JavaScript code.
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
+ import { normalizeSvgSize } from '../utils/svg-size.js';
7
8
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
8
9
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
9
10
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
@@ -917,8 +918,15 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
917
918
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
918
919
  code += `
919
920
  app.historyManager.saveState();
921
+ ${properties.content !== undefined ? `
922
+ // CONTENT UNDER A CONTENT TRACK (round 10, 10.13): the keyframes set the text
923
+ // on every frame, so a direct edit is overwritten at once — it answered
924
+ // success and changed nothing visible.
925
+ const __ctItem = app.getItemById('${itemId}');
926
+ const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
927
+ ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
920
928
  ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
921
- 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} };`;
929
+ return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${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} };`;
922
930
  // Wrapped, because the body now RETURNS early on a miss rather than falling
923
931
  // through to an unconditional success. Async only when a font must load
924
932
  // before modifyItem re-measures the text.
@@ -1235,7 +1243,7 @@ if (!_target) {
1235
1243
  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.' };
1236
1244
  }`.trim();
1237
1245
  }
1238
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
1246
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false) {
1239
1247
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1240
1248
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1241
1249
  const opts = { duration: calculatedDuration, loop };
@@ -1247,6 +1255,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1247
1255
  opts.clipOutPoint = clipOutPoint;
1248
1256
  if (timeUnits !== undefined)
1249
1257
  opts.timeUnits = timeUnits;
1258
+ // addAnimation SETS the item's track; mode 'merge' adds to it (FxTool
1259
+ // 2475333b). An engine without merge ignores the option and replaces.
1260
+ if (append)
1261
+ opts.mode = 'merge';
1250
1262
  // A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
1251
1263
  // the audio item's sentinel, and the mix reads one static audioGain per clip,
1252
1264
  // so {audioGain: 0→1} answered success, duration 15, and exported at a flat
@@ -1285,7 +1297,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1285
1297
  const gaps = keyframes
1286
1298
  .map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
1287
1299
  .filter((g) => g.missing.length);
1288
- const gapNote = gaps.length && keyframes.length > 1
1300
+ // With append the gaps that matter are the MERGED track's (an earlier key
1301
+ // with no scale beside a new key with one), so that check runs in the page
1302
+ // on the result instead (below).
1303
+ const gapNote = !append && gaps.length && keyframes.length > 1
1289
1304
  ? `, 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.`)}`
1290
1305
  : '';
1291
1306
  const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
@@ -1302,8 +1317,25 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1302
1317
  // Apply keyframe animation to ${itemId}
1303
1318
  (function() {
1304
1319
  ${requireItem(itemId, 'the animation')}${audioLevelGuard}
1320
+ // The track as it was, so the result can say what happened to it: merged
1321
+ // (every earlier key still there) or replaced. That is the evidence of
1322
+ // merge support — an older engine takes the option and replaces anyway.
1323
+ const __ki = _target && (_target.item || _target);
1324
+ const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1305
1325
  app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
1306
- return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote} };
1326
+ const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1327
+ const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
1328
+ // Property gaps across the merged track (the same check a single call makes
1329
+ // on its own keys at generation time).
1330
+ const __all = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes : [];
1331
+ const __props = __all.reduce(function(acc, k) { Object.keys((k && k.properties) || {}).forEach(function(p) { if (acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
1332
+ const __gaps = __all.length > 1 ? __all.map(function(k) { const have = Object.keys((k && k.properties) || {}); return { time: k && k.time, missing: __props.filter(function(p) { return have.indexOf(p) < 0; }) }; }).filter(function(g) { return g.missing.length; }) : [];` : ''}
1333
+ const __track = !__prev.length ? null
1334
+ : ${append ? `(__kept
1335
+ ? { appended: true, previousKeys: __prev.length, keys: __now.length }
1336
+ : { appended: false, replaced: true, previousKeys: __prev.length, warning: 'append was asked for, but this studio replaced the existing track (' + __prev.length + ' key(s)) instead of adding to it: it has no merge mode. Put every key in one keyframe_animate call.' })`
1337
+ : `{ replaced: true, previousKeys: __prev.length, note: 'the item already had a keyframe track (' + __prev.length + ' key(s)); this call replaced it. To add keys to it instead, pass append: true.' }`};
1338
+ return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote} };
1307
1339
  })();
1308
1340
  `.trim();
1309
1341
  }
@@ -1666,7 +1698,8 @@ function generateSetCanvasSizeCode(width, height, preset) {
1666
1698
  // echoing the request back.
1667
1699
  return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
1668
1700
  ...(r && r.preset ? { preset: r.preset } : {}),
1669
- ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {}) };
1701
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && (width > 8192 || height > 8192) ? `,
1702
+ note: 'above 8192 px per side: Chrome and Firefox render it (the studio allows up to 16384 safely), but Safari and iOS may produce a blank or corrupt canvas — export from Chrome.'` : ''} };
1670
1703
  })();
1671
1704
  `.trim();
1672
1705
  }
@@ -2219,6 +2252,9 @@ export class PinePaperCodeGenerator {
2219
2252
  */
2220
2253
  generateModifyItem(input) {
2221
2254
  const validated = ModifyItemInputSchema.parse(input);
2255
+ if (validated.atTime !== undefined) {
2256
+ return generateAutoKeyCode(validated.itemId, validated.properties, validated.atTime, validated.easing);
2257
+ }
2222
2258
  return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
2223
2259
  }
2224
2260
  /**
@@ -2283,7 +2319,7 @@ export class PinePaperCodeGenerator {
2283
2319
  */
2284
2320
  generateKeyframeAnimate(input) {
2285
2321
  const validated = KeyframeAnimateInputSchema.parse(input);
2286
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
2322
+ return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2287
2323
  }
2288
2324
  /**
2289
2325
  * Generate code for importing / retargeting a motion-capture clip
@@ -2435,12 +2471,21 @@ ${INLINE_REMOTE_IMAGES}
2435
2471
  imported.scale(${scale});
2436
2472
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2437
2473
  app.historyManager.saveState();
2474
+ const _b = imported.bounds;
2475
+ const _tiny = _b && _b.width < 16 && _b.height < 16;
2438
2476
  return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
2477
+ size: _b ? { width: Math.round(_b.width * 10) / 10, height: Math.round(_b.height * 10) / 10 } : null,
2478
+ ...(_tiny ? { warning: 'the SVG came in at ' + _b.width.toFixed(1) + 'x' + _b.height.toFixed(1) + ' px — too small to see (a root sized in em or % reads as 1 px). Pass scale, or give the SVG a width / height in px.' } : {}),
2439
2479
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2440
2480
  })();
2441
2481
  `.trim();
2442
2482
  }
2443
2483
  if (svgString) {
2484
+ // A root sized in em / % (icon sets) renders ~5 px: sized from the
2485
+ // viewBox instead, and the result says so (see utils/svg-size.ts).
2486
+ const sized = normalizeSvgSize(svgString);
2487
+ svgString = sized.svg;
2488
+ const sizeFix = sized.fix;
2444
2489
  // Escape the SVG string for embedding in code
2445
2490
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
2446
2491
  return `
@@ -2459,7 +2504,14 @@ imported.position = new paper.Point(${position.x}, ${position.y});
2459
2504
  imported.scale(${scale});
2460
2505
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2461
2506
  app.historyManager.saveState();
2507
+ // A SPECK IS NOT A SUCCESS WORTH REPORTING QUIETLY: under 16 px both ways,
2508
+ // the import is said to be tiny, with the fix.
2509
+ const _b = imported.bounds;
2510
+ const _tiny = _b && _b.width < 16 && _b.height < 16;
2462
2511
  return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
2512
+ size: _b ? { width: Math.round(_b.width * 10) / 10, height: Math.round(_b.height * 10) / 10 } : null,
2513
+ ${sizeFix ? `sizeFix: ${JSON.stringify(sizeFix)},` : ''}
2514
+ ...(_tiny ? { warning: 'the SVG came in at ' + _b.width.toFixed(1) + 'x' + _b.height.toFixed(1) + ' px — too small to see. Pass scale, or give the SVG a width / height in px.' } : {}),
2463
2515
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2464
2516
  })();
2465
2517
  `.trim();
@@ -3945,7 +3997,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3945
3997
  }
3946
3998
  generateAgentExport(input) {
3947
3999
  const validated = AgentExportInputSchema.parse(input);
3948
- const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, alphaQuantizer, ad: adOpts } = validated;
4000
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, alphaQuantizer, deterministic, ad: adOpts, frames } = validated;
3949
4001
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
3950
4002
  const videoEncodeOpts = {};
3951
4003
  if (broadcast)
@@ -3963,6 +4015,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3963
4015
  videoEncodeOpts.minBitrate = minBitrate;
3964
4016
  if (bitrateMode !== undefined)
3965
4017
  videoEncodeOpts.bitrateMode = bitrateMode;
4018
+ // Byte-identical repeats (FxTool 3b335567): the container timestamps are
4019
+ // the only bytes that varied, and this pins them.
4020
+ if (deterministic)
4021
+ videoEncodeOpts.deterministic = true;
3966
4022
  // A transparent WebM (FxTool 1833b397) rides the store route too.
3967
4023
  const webmAlpha = format === 'webm' && transparent === true;
3968
4024
  if (webmAlpha) {
@@ -3972,13 +4028,22 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3972
4028
  }
3973
4029
  const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
3974
4030
  const qualityLevel = quality || 'standard';
3975
- const videoDuration = duration ?? 5;
4031
+ let videoDuration = duration ?? 5;
3976
4032
  // Quality settings (bitrate in bps for VideoEncoder)
3977
4033
  const qualitySettings = {
3978
4034
  draft: { compression: 0.6, fps: 15, dpi: 72, bitrate: 2_000_000 },
3979
4035
  standard: { compression: 0.85, fps: 30, dpi: 150, bitrate: 5_000_000 },
3980
4036
  high: { compression: 0.95, fps: 60, dpi: 300, bitrate: 8_000_000 },
3981
4037
  }[qualityLevel];
4038
+ // FRAMES, EXACTLY (showcase film, 11.1). The engine makes ceil(duration x
4039
+ // fps) frames for mp4 / webm / gif and floor(...) for apng, so 7.91667 s
4040
+ // at 24 fps was 191 frames, not 190 — and N / fps itself can land a hair
4041
+ // above N in floating point (190 / 24 * 24 > 190). The duration is nudged
4042
+ // a microsecond toward the side that rounds to N.
4043
+ if (frames !== undefined) {
4044
+ const fpsEff = fps ?? qualitySettings.fps;
4045
+ videoDuration = frames / fpsEff + (format === 'apng' ? 1e-6 : -1e-6);
4046
+ }
3982
4047
  // Platform presets
3983
4048
  const platformPresets = {
3984
4049
  'instagram': { width: 1080, height: 1080, staticFormat: 'png', animatedFormat: 'mp4' },
@@ -4459,6 +4524,9 @@ ${stillTime !== undefined ? `
4459
4524
  // a neighbouring card's overflowing headline. Older studios ignore the
4460
4525
  // fourth argument, and leave lastRegionExcluded unset.
4461
4526
  if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
4527
+ // A video in the region is waited for at this time (round 12, 12.5):
4528
+ // the render drew before the clip's seek landed.
4529
+ if (typeof app.awaitMediaAt === 'function') await app.awaitMediaAt(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"});
4462
4530
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
4463
4531
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
4464
4532
  // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
@@ -4688,7 +4756,12 @@ ${stillTime !== undefined ? `
4688
4756
  + ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',
4689
4757
  };
4690
4758
  }
4691
- return { success: true, platform, format, framing, data: await blobToDataUrl(blob), mimeType: videoMimeType, size: blob.size };
4759
+ // The container the file IS: an encoder that cannot do H.264 at this
4760
+ // size writes VP9 WebM (round 12: mp4 at 12000 px came back WebM),
4761
+ // and naming it .mp4 breaks players. Said below in fidelity.
4762
+ const __mime = (blob.type && String(blob.type).split(';')[0]) || videoMimeType;
4763
+ const __fmt = __mime === 'video/webm' ? 'webm' : __mime === 'video/mp4' ? 'mp4' : format;
4764
+ return { success: true, platform, format: __fmt, framing, data: await blobToDataUrl(blob), mimeType: __mime, size: blob.size };
4692
4765
  };
4693
4766
 
4694
4767
  // Camera framing requires going direct to videoExporter so width/height
@@ -4968,6 +5041,18 @@ ${stillTime !== undefined ? `
4968
5041
 
4969
5042
  if (result && result.success) Object.assign(result, fidelity(format));
4970
5043
  if (result && result.success && __fit) result.platformFit = __fit;
5044
+ // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5045
+ // aspect nuance but the wrong deliverable: tiktok on a 16:9 canvas came out
5046
+ // 1920x1080 with only a note (round 10, 10.7). Said where a caller looks.
5047
+ if (result && result.success && __fit) {
5048
+ const __pw = __fit.preset.split('x').map(Number), __uw = __fit.used.split('x').map(Number);
5049
+ if ((__pw[0] > __pw[1]) !== (__uw[0] > __uw[1]) && __pw[0] !== __pw[1]) {
5050
+ result.fidelity = result.fidelity || { warnings: [] };
5051
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'platform_orientation_mismatch',
5052
+ message: platform + ' is ' + (__pw[0] > __pw[1] ? 'landscape' : 'portrait') + ' (' + __fit.preset + '), but the canvas is ' + (__uw[0] > __uw[1] ? 'landscape' : 'portrait') + ', so the file is ' + __fit.used + '. For a ' + platform + ' deliverable, resize the canvas to ' + __fit.preset + ' (pinepaper_set_canvas_size) and re-lay the scene for it; the export does not reframe a scene.' }]);
5053
+ if (result.fidelity.note) delete result.fidelity.note;
5054
+ }
5055
+ }
4971
5056
  // AUDIO TIMING, WHERE THE STUDIO REPORTS IT (6.27). An MP4's AAC track used
4972
5057
  // to start ~44 ms late and run past the picture — a 6.000 s bumper with sound
4973
5058
  // then failed its cap. The engine now reports the track; it is surfaced as
@@ -5025,6 +5110,56 @@ ${stillTime !== undefined ? `
5025
5110
  // The applied value is the engine's own report where it gives one
5026
5111
  // (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
5027
5112
  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.' };` : ''}
5113
+ // FROZEN OUTPUT (round 12, 12.3): constant bitrate at 3552x1080 answered
5114
+ // success with 600 identical frames. The file cannot be decoded here on the
5115
+ // store route, so this is a size check: an animated scene whose video
5116
+ // carries under 0.002 bits per pixel per frame is almost certainly one
5117
+ // frame repeated. Labelled as a suspicion, with how to confirm it.
5118
+ if (result && result.success && (result.format === 'mp4' || result.format === 'webm') && result.fidelity && result.fidelity.checked && result.fidelity.checked.animated && typeof result.size === 'number') {
5119
+ const __d = result.dimensions || dimensions;
5120
+ const __frames = Math.max(1, Math.round(${videoDuration} * (settings.fps || 30)));
5121
+ const __bpp = (result.size * 8) / Math.max(1, __d.width * __d.height * __frames);
5122
+ // The studio's own count of distinct frames it fed the encoder, where it
5123
+ // reports one, decides; the size is only the fallback (a small dot moving on
5124
+ // a flat field is also a tiny file).
5125
+ const __vf = __vr && __vr.frames;
5126
+ const __distinct = __vf && typeof __vf.distinct === 'number' ? __vf.distinct : null;
5127
+ // The studio's decoded-output test (output_frozen) is the real answer for
5128
+ // an encoder freeze; the size guess stays out when it has spoken.
5129
+ const __engineFrozen = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'output_frozen'; }));
5130
+ if (!__engineFrozen && (__distinct !== null ? __distinct <= 1 : __bpp < 0.002)) {
5131
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'possibly_frozen',
5132
+ message: __distinct !== null
5133
+ ? 'the scene animates, but the studio rendered ' + __distinct + ' distinct frame(s) for the encoder — the animation did not play during the export (not an encoder fault). Check that the animation runs on the timeline over the exported range.'
5134
+ : 'the video carries ' + __bpp.toFixed(5) + ' bits per pixel per frame (' + result.size + ' bytes for ' + __frames + ' frames at ' + __d.width + 'x' + __d.height + ') — the encoder has most likely repeated one frame. Check two frames (ffprobe, or pinepaper_capture_frames against the scene); a smaller size, bitrateMode variable, or format webm usually avoids it.' }]);
5135
+ if (result.fidelity.note) delete result.fidelity.note;
5136
+ }
5137
+ }
5138
+ // THE STUDIO'S OWN CODED VIDEO WARNINGS (e.g. an output-side freeze the
5139
+ // input-frame count cannot see): passed through when they carry a code this
5140
+ // tool does not already raise from the numbers. Plain-string warnings are
5141
+ // the bitrate / luma texts, raised above from their numbers instead.
5142
+ if (result && result.success && __vr && Array.isArray(__vr.warnings)) {
5143
+ // format_substituted is NOT here: the studio's own (agreed with FxTool)
5144
+ // is the better-informed one, so it passes and this tool's is skipped.
5145
+ const __mine = ['bitrate_below_floor', 'luma_out_of_range', 'determinism_not_pinned', 'dimensions_rounded', 'possibly_frozen', 'alpha_not_applied', 'alpha_keyframe_mismatch'];
5146
+ const __extra = __vr.warnings.filter(function(w) { return w && typeof w === 'object' && typeof w.code === 'string' && __mine.indexOf(w.code) < 0; })
5147
+ .map(function(w) { return { code: w.code, message: String(w.message || w.code) }; });
5148
+ if (__extra.length) {
5149
+ result.fidelity = result.fidelity || { warnings: [] };
5150
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__extra);
5151
+ if (result.fidelity.note) delete result.fidelity.note;
5152
+ }
5153
+ }
5154
+ // A DIFFERENT CONTAINER THAN ASKED (round 12, 12.7): the file is named by
5155
+ // what it is, and the substitution is said, not left to a player to find.
5156
+ const __studioSaidFormat = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'format_substituted'; }));
5157
+ if (!__studioSaidFormat && result && result.success && (result.format === 'mp4' || result.format === 'webm') && result.format !== format && (format === 'mp4' || format === 'webm')) {
5158
+ result.fidelity = result.fidelity || { warnings: [] };
5159
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'format_substituted',
5160
+ message: format + ' was asked for, but the browser encoder could not make ' + format + ' at ' + ((result.dimensions && result.dimensions.width) || '') + 'x' + ((result.dimensions && result.dimensions.height) || '') + ((__vr && __vr.codecFallback && __vr.codecFallback.reason) ? ' (' + __vr.codecFallback.reason + ')' : '') + ', so the file is ' + result.format + ' (saved with that extension). For ' + format + ' at this size, export smaller (scale) and upscale, or re-encode the ' + result.format + '.' }]);
5161
+ if (result.fidelity.note) delete result.fidelity.note;
5162
+ }
5028
5163
  // AN ODD SIDE ROUNDED UP (FxTool e64a5260). H.264 needs even dimensions, so
5029
5164
  // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The
5030
5165
  // result's dimensions become what the file IS, and fidelity says why.
@@ -5037,7 +5172,34 @@ ${stillTime !== undefined ? `
5037
5172
  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).' }]);
5038
5173
  if (result.fidelity.note) delete result.fidelity.note;
5039
5174
  }
5040
- // LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
5175
+ ${deterministic ? ` // DETERMINISTIC: the container timestamps are pinned either way; the PIXELS
5176
+ // are only identical run to run on the software encoder, because the
5177
+ // browser may pick a different hardware backend each time (FxTool
5178
+ // e34ae03d: 92/255 between backends, 2/255 run to run on hardware). The
5179
+ // engine requests software and reports what it got.
5180
+ // Where the studio says outright whether the file repeats (bitExact, FxTool
5181
+ // PR #31: it test-encodes and keeps whichever encoder does — often the
5182
+ // HARDWARE one on a Mac, since Chrome's software H.264 is not repeatable at
5183
+ // some sizes), that answer decides; the encoder name is only the fallback
5184
+ // for studios that do not report it.
5185
+ if (result && result.success) {
5186
+ const __hw = __vr && __vr.hardwareAcceleration;
5187
+ const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;
5188
+ if (__exact === false) {
5189
+ result.fidelity = result.fidelity || { warnings: [] };
5190
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',
5191
+ message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked): the pixels may differ slightly between runs. Do not cache this file by hash; a different frame size may repeat.' }]);
5192
+ if (result.fidelity.note) delete result.fidelity.note;
5193
+ } else if (__exact === null && __hw !== 'prefer-software') {
5194
+ result.fidelity = result.fidelity || { warnings: [] };
5195
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',
5196
+ message: __hw
5197
+ ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.stringify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'
5198
+ : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between runs if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);
5199
+ if (result.fidelity.note) delete result.fidelity.note;
5200
+ }
5201
+ }
5202
+ ` : ''} // LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
5041
5203
  // decoded back and measured: H.264 ringing at hard edges puts a fraction of
5042
5204
  // a percent of samples out of range, which QC rejects. broadcastHeadroom
5043
5205
  // cuts it but never to zero, so a legaliser pass is the requirement.
@@ -5051,7 +5213,10 @@ ${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
5051
5213
  const __vw = [];
5052
5214
  // The engine joins its warnings into one string (bitrate floor, then
5053
5215
  // luma), so each is raised from its own numbers, not from that text.
5054
- if (__vr && typeof __vr.achievedBitrate === 'number' && typeof __vr.bitrateFloor === 'number' && __vr.achievedBitrate < __vr.bitrateFloor * 0.95) {
5216
+ // The studio's own measured bitrate_not_honoured, where it sends one,
5217
+ // replaces this estimate.
5218
+ const __engineBitrate = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'bitrate_not_honoured'; }));
5219
+ if (!__engineBitrate && __vr && typeof __vr.achievedBitrate === 'number' && typeof __vr.bitrateFloor === 'number' && __vr.achievedBitrate < __vr.bitrateFloor * 0.95) {
5055
5220
  __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.' });
5056
5221
  }
5057
5222
  if (!__vr || typeof __vr !== 'object') {
@@ -6831,7 +6996,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6831
6996
  `.trim();
6832
6997
  }
6833
6998
  generateImportImage(input) {
6834
- const { url, position, maxWidth, maxHeight, mask, smoothing } = input;
6999
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize } = input;
6835
7000
  // Each fragment used to be written with a LEADING comma, as if it followed
6836
7001
  // an entry that no longer exists — so the first one opened the object with
6837
7002
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -6842,6 +7007,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6842
7007
  position ? `position: { x: ${position.x}, y: ${position.y} }` : '',
6843
7008
  maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
6844
7009
  maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
7010
+ nativeSize ? 'nativeSize: true' : '',
6845
7011
  ].filter(Boolean);
6846
7012
  const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
6847
7013
  return `
@@ -6897,7 +7063,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6897
7063
  fr.readAsDataURL(blob);
6898
7064
  });
6899
7065
  }
6900
- const entry = await app.imageTools.uploadFromURL(src);
7066
+ const entry = await app.imageTools.uploadFromURL(src${maxEdge !== undefined ? `, { maxEdge: ${maxEdge === 'native' ? 'Infinity' : maxEdge} }` : ''});
6901
7067
  const opts = ${optsLiteral};
6902
7068
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
6903
7069
  ${smoothing ? ` // Pixel art wants 'off': Paper's Raster draws with smoothing 'low' by
@@ -6936,7 +7102,13 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
6936
7102
  success: true,
6937
7103
  itemId: itemId,
6938
7104
  message: 'Image imported and placed on canvas.',
6939
- bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}
7105
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''},
7106
+ // Stored smaller than its source (the studio's cap): said, with the
7107
+ // way to keep every pixel. The studio reports native size where it can.
7108
+ ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)
7109
+ ? { stored: { width: entry.width, height: entry.height }, native: { width: entry.nativeWidth, height: entry.nativeHeight },
7110
+ note: 'the studio stored this image at ' + entry.width + 'x' + entry.height + ' (its source is ' + entry.nativeWidth + 'x' + entry.nativeHeight + '). Pass maxEdge: "native" to keep every pixel.' }
7111
+ : {})
6940
7112
  };
6941
7113
  } catch (e) {
6942
7114
  return { error: 'Failed to import image: ' + e.message };
@@ -7701,6 +7873,12 @@ ${loads}
7701
7873
  // Anything else (asset id, data:, URL) passes through untouched.
7702
7874
  // @engine-methods analyzeAudio animateToBeat
7703
7875
  const resolveSource = ` let __src = ${JSON.stringify(input.source || '')};
7876
+ // A large source was staged beside the code (see the handler); read it back.
7877
+ if (__src.indexOf('__ppStage:') === 0) {
7878
+ const __staged = window.__ppStage && window.__ppStage[__src.slice('__ppStage:'.length)];
7879
+ if (!__staged) return { success: false, error: 'the staged audio did not reach the page — try again, or upload it with pinepaper_media upload_audio and pass the returned id.' };
7880
+ __src = __staged;
7881
+ }
7704
7882
  const __A = (typeof window !== 'undefined') && window.PinePaperAgent;
7705
7883
  const __m = (__src && __A && typeof __A.listMedia === 'function')
7706
7884
  ? __A.listMedia().find(function(x) { return x.id === __src || x.registryId === __src; })
@@ -7724,6 +7902,11 @@ ${resolveSource}
7724
7902
  if (r && typeof r === 'object' && 'ok' in r) {
7725
7903
  return { success: r.ok !== false, ...r };
7726
7904
  }
7905
+ // Nothing back is not a result: it was reported as success with an empty
7906
+ // body (gap 5.5), which reads as "analysed, no beats".
7907
+ if (r === undefined || r === null) {
7908
+ return { success: false, error: '${method} returned nothing, so there is no result to report. If the source is large, upload it with pinepaper_media upload_audio and pass the returned id.' };
7909
+ }
7727
7910
  return { success: true, result: r };
7728
7911
  })();`.trim();
7729
7912
  if (input.action === 'analyze') {
@@ -8247,7 +8430,12 @@ ${resolve}
8247
8430
  // engine mints a NUMBER. Returning the number meant the id could not be
8248
8431
  // round-tripped: pass it back to add/remove and zod rejects it before the
8249
8432
  // call is made.
8250
- const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
8433
+ // THE REGISTRY ID, not Paper's. createPrecomp returns the group, whose
8434
+ // .id is Paper's numeric id ("49"), which no tool accepts back ("precomp:
8435
+ // no such item(s): 49", round 10, 10.11). The registry id — what
8436
+ // listPrecomps and every other tool use — is on the group's data.
8437
+ const _d = (precomp && precomp.data) || {};
8438
+ const _pid = _d.registryId || _d.id || (precomp && precomp.id !== undefined ? precomp.id : precomp);
8251
8439
  return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
8252
8440
  })();`.trim();
8253
8441
  }
@@ -8258,7 +8446,11 @@ ${resolve}
8258
8446
  // addToPrecomp answers falsy when the precomp or the item is not found, and
8259
8447
  // this reported success regardless — the same silent-success shape as an
8260
8448
  // unknown itemId elsewhere.
8261
- const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
8449
+ // The engine takes the ITEM, not its id (it clones it); an id string made
8450
+ // add throw and could never have worked. Resolved here, and a miss named.
8451
+ const _item = app.getItemById(${JSON.stringify(input.itemId || '')});
8452
+ if (!_item) return { success: false, action: 'add', error: 'no item ' + ${JSON.stringify(JSON.stringify(input.itemId || ''))} + ' on the canvas — check the id with pinepaper_get_items.' };
8453
+ const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, _item);
8262
8454
  if (_ok === false || _ok === null || _ok === undefined) {
8263
8455
  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.' };
8264
8456
  }
@@ -8268,7 +8460,14 @@ ${resolve}
8268
8460
  return `
8269
8461
  // Remove item from precomp
8270
8462
  (function() {
8271
- app.removeFromPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
8463
+ // The item, not its id (the engine checks item.parent); and its answer is
8464
+ // read — null meant nothing was removed, and this said success regardless.
8465
+ const _item = app.getItemById(${JSON.stringify(input.itemId || '')});
8466
+ if (!_item) return { success: false, action: 'remove', error: 'no item ' + ${JSON.stringify(JSON.stringify(input.itemId || ''))} + ' on the canvas — check the id with pinepaper_get_items.' };
8467
+ const _r = app.removeFromPrecomp(${JSON.stringify(input.precompId || '')}, _item);
8468
+ if (_r === null || _r === undefined || _r === false) {
8469
+ return { success: false, action: 'remove', error: 'nothing was removed — the item is not in that precomp, or the precompId is not a precomp (create returns it; list shows them).' };
8470
+ }
8272
8471
  return { success: true, action: 'remove', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
8273
8472
  })();`.trim();
8274
8473
  default:
@@ -9424,24 +9623,55 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9424
9623
  const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
9425
9624
  return `
9426
9625
  // Accessibility measurements (${checks.join(', ')})
9427
- (function() {
9626
+ (async function() {
9428
9627
  const out = { success: true, fps: ${fps}, duration: ${duration} };
9429
9628
  const rgba = function(c) {
9430
9629
  if (!c || c.gradient || typeof c.red !== 'number') return null;
9431
9630
  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 };
9432
9631
  };
9433
9632
  out.page = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
9434
- ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
9435
- const texts = all.filter(function(e) { return e && e.item && e.item.className === 'PointText' && e.item.visible !== false && String(e.item.content || '').trim() !== ''; });
9436
- 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; });
9437
- out.texts = texts.map(function(e) {
9438
- const it = e.item, c = it.bounds.center;
9439
- const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
9440
- const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
9441
- const w = String(it.fontWeight || '');
9442
- return { id: e.itemId, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
9443
- bold: w === 'bold' || Number(w) >= 700, behind: top ? rgba(top.item.fillColor) : null, behindId: top ? top.itemId : undefined };
9633
+ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text is judged where it is on screen.
9634
+ // Reading only the current playhead found no visible text in a scene of
9635
+ // timed captions and passed with nothing checked. The times: 0, inside each
9636
+ // text's lifetime, every keyframe time, and every second of the duration —
9637
+ // the playhead is moved, lifetimes applied, and put back.
9638
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
9639
+ const snap = function(t) {
9640
+ const texts = all.filter(function(e) { return e && e.item && e.item.className === 'PointText' && e.item.visible !== false && e.item.opacity !== 0 && String(e.item.content || '').trim() !== ''; });
9641
+ 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; });
9642
+ return texts.map(function(e) {
9643
+ const it = e.item, c = it.bounds.center;
9644
+ const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
9645
+ const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
9646
+ const w = String(it.fontWeight || '');
9647
+ return { id: e.itemId, t: t, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
9648
+ bold: w === 'bold' || Number(w) >= 700, behind: top ? rgba(top.item.fillColor) : null, behindId: top ? top.itemId : undefined };
9649
+ });
9650
+ };
9651
+ const times = [0];
9652
+ all.forEach(function(e) {
9653
+ const d = (e && e.item && e.item.data) || {};
9654
+ if (typeof d.bornAt === 'number') times.push(d.bornAt + Math.min(0.1, (typeof d.ttl === 'number' ? d.ttl : 1) / 2));
9655
+ if (Array.isArray(d.keyframes)) d.keyframes.forEach(function(k) { if (k && typeof k.time === 'number') times.push(k.time); });
9444
9656
  });
9657
+ Array.from({ length: Math.floor(${duration}) + 1 }, function(_, i) { times.push(i); });
9658
+ const uniq = times.filter(function(t, i) { return t >= 0 && t <= ${duration} + 1e-6 && times.indexOf(t) === i; }).sort(function(a, b) { return a - b; }).slice(0, 60);
9659
+ const L = globalThis.__ppMcp;
9660
+ const at = function(t) {
9661
+ if (typeof app.setPlaybackTime === 'function') app.setPlaybackTime(t);
9662
+ if (L && L.lifetimeApp === app && typeof L.applyLifetimes === 'function') { try { L.applyLifetimes(t); } catch (_) { /* sampled as is */ } }
9663
+ if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
9664
+ };
9665
+ const prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
9666
+ out.texts = [];
9667
+ if (typeof app.setPlaybackTime === 'function') {
9668
+ uniq.forEach(function(t) { at(t); out.texts = out.texts.concat(snap(t)); });
9669
+ at(prevT);
9670
+ out.sampledTimes = uniq.length;
9671
+ } else {
9672
+ out.texts = snap(prevT);
9673
+ out.sampledTimes = 1;
9674
+ }
9445
9675
  ` : ''}${checks.includes('flash') ? ` if (typeof app.captureFramesAt !== 'function') {
9446
9676
  out.flashUnavailable = 'this studio has no deterministic frame capture (app.captureFramesAt), so flashing was not checked.';
9447
9677
  } else {
@@ -9451,7 +9681,9 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9451
9681
  const ctx = small.getContext('2d', { willReadFrequently: true });
9452
9682
  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); });
9453
9683
  const times = Array.from({ length: ${n} }, function(_, i) { return i / ${fps}; });
9454
- out.series = app.captureFramesAt(times, { capture: function(c) {
9684
+ // Waits for video per frame where the studio can (12.6).
9685
+ const __cap = typeof app.captureFramesAtAsync === 'function' ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
9686
+ out.series = await __cap(times, { capture: function(c) {
9455
9687
  // Transparent canvas shows the page's CSS background, so composite on it.
9456
9688
  ctx.fillStyle = out.page || '#ffffff';
9457
9689
  ctx.fillRect(0, 0, W, H);
@@ -9477,7 +9709,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9477
9709
  const includeDataUrls = !!input.includeDataUrls;
9478
9710
  return `
9479
9711
  // Deterministic frame capture
9480
- (function() {
9712
+ (async function() {
9481
9713
  if (typeof app.captureFramesAt !== 'function') {
9482
9714
  return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
9483
9715
  }
@@ -9494,7 +9726,24 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9494
9726
  // plain 9-text scene (round 7 X, #7) on studios without hashFrame. A native
9495
9727
  // reduce is not a generated loop and is not counted; same djb2-xor.
9496
9728
  : function(s) { return Array.prototype.reduce.call(s, function(h, c) { return (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0; }, 5381).toString(16); };
9497
- const frames = app.captureFramesAt(${timesJson}, {
9729
+ // A VIDEO ON SCREEN IS WAITED FOR (round 12, 12.6): the sync capture drew
9730
+ // before a clip's seek landed, so a frame showed the previous time's
9731
+ // picture. captureFramesAtAsync waits per frame; older studios keep the
9732
+ // sync one (and say video-not-synced themselves).
9733
+ //
9734
+ // What is REPORTED is what happened, not what exists (retest of 009f00e: a
9735
+ // "videoSynced: true" capture right after an upload showed the video black).
9736
+ // videoWait says whether the studio waited per frame; firstSeek is the
9737
+ // studio's own answer for the first time (seeked / timedOut / skipped).
9738
+ // A seek that times out later is an engine warning (video-seek-timeout),
9739
+ // forwarded in governor.engineWarnings.
9740
+ const __waits = typeof app.captureFramesAtAsync === 'function';
9741
+ let __firstSeek = null;
9742
+ if (typeof app.awaitMediaAt === 'function' && ${timesJson}.length) {
9743
+ try { __firstSeek = await app.awaitMediaAt(${timesJson}[0]); } catch (e) { __firstSeek = { error: String(e && e.message || e) }; }
9744
+ }
9745
+ const __cap = __waits ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
9746
+ const frames = await __cap(${timesJson}, {
9498
9747
  seed: ${seed},
9499
9748
  capture: function(c, t, i) {
9500
9749
  // Prefer app.captureFrameDataURL(): it renders only the used region, so an
@@ -9509,7 +9758,11 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9509
9758
  }
9510
9759
  });
9511
9760
  const uniqueHashes = new Set(frames.map(function(f) { return f.hash; })).size;
9512
- return { success: true, seed: ${seed}, frameCount: frames.length, uniqueHashes: uniqueHashes, allIdentical: uniqueHashes <= 1, frames: frames };
9761
+ return { success: true, seed: ${seed}, frameCount: frames.length, uniqueHashes: uniqueHashes, allIdentical: uniqueHashes <= 1,
9762
+ videoWait: __waits ? 'awaited per frame' : 'not supported by this studio (frames may show a video before its seek lands)',
9763
+ ...(__firstSeek ? { firstSeek: __firstSeek } : {}),
9764
+ ...(__firstSeek && __firstSeek.timedOut > 0 ? { videoWarning: 'the video seek for the first frame timed out: its video may show an earlier picture. Retry after the clip has loaded.' } : {}),
9765
+ frames: frames };
9513
9766
  })();`.trim();
9514
9767
  }
9515
9768
  /**
@@ -9657,6 +9910,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9657
9910
  const opts = JSON.stringify({
9658
9911
  ...(input.position ? { position: input.position } : {}),
9659
9912
  ...(input.scale !== undefined ? { scale: input.scale } : {}),
9913
+ ...(input.fit !== undefined ? { fit: input.fit } : {}),
9660
9914
  ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
9661
9915
  ...(input.clipInPoint !== undefined ? { clipInPoint: input.clipInPoint } : {}),
9662
9916
  ...(input.clipOutPoint !== undefined ? { clipOutPoint: input.clipOutPoint } : {}),
@@ -9665,7 +9919,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9665
9919
  // Upload video from URL
9666
9920
  (async function() {
9667
9921
  ${guard}
9668
- const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${opts});
9922
+ const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${input.atPlayhead ? `Object.assign(${opts}, { timeOffset: Number(app.playbackTime) || 0 })` : opts});
9669
9923
  return { success: true, action: 'upload_video', media: info };
9670
9924
  })();`.trim();
9671
9925
  }
@@ -9680,7 +9934,7 @@ ${guard}
9680
9934
  // Upload audio from URL
9681
9935
  (async function() {
9682
9936
  ${guard}
9683
- const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${opts});${input.volume !== undefined ? `
9937
+ const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${input.atPlayhead ? `Object.assign(${opts}, { timeOffset: Number(app.playbackTime) || 0 })` : opts});${input.volume !== undefined ? `
9684
9938
  // THE LEVEL IS STORED UNDER ONE KEY AND EXPORTED FROM ANOTHER. The upload
9685
9939
  // records volume as \`gain\` on the registry entry; the video exporter's mix
9686
9940
  // reads \`audioGain\`. So a bed uploaded at 0.25 exported at unity (measured:
@@ -9729,10 +9983,37 @@ ${guard}
9729
9983
  if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
9730
9984
  ${resolveMedia}
9731
9985
  if (!__m) return __miss;
9732
- const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint});
9986
+ ${input.timeOffset !== undefined ? ` // Moving the clip is the 4th argument, which older studios' setMediaClip
9987
+ // does not take (it dropped it silently): its declared arity is the tell.
9988
+ if (A.setMediaClip.length < 4) {
9989
+ return { success: false, action: 'set_clip', id: __mid, error: 'this studio can re-trim a clip but not move it (setMediaClip takes no start time here) — nothing was changed. Update the studio, or remove and re-upload the clip with timeOffset.' };
9990
+ }
9991
+ ` : ''} const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint}${input.timeOffset !== undefined ? `, ${input.timeOffset}` : ''});
9733
9992
  return ok
9734
- ? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} }
9993
+ ? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint}${input.timeOffset !== undefined ? `, timeOffset: ${input.timeOffset}` : ''} }
9735
9994
  : { success: false, action: 'set_clip', id: __mid, error: 'the studio refused the clip window ${input.inPoint}–${input.outPoint} for ' + __mid + '.' };
9995
+ })();`.trim();
9996
+ // Split and transitions (FxTool, verified on main): the engine's own
9997
+ // refusals come back as {ok:false, reason} and are errors here.
9998
+ case 'split':
9999
+ return `
10000
+ // Split a media clip at canvas time ${input.at}
10001
+ (async function() {
10002
+ ${guard}
10003
+ if (typeof A.splitMedia !== 'function') { return { success: false, action: 'split', error: 'this studio is too old to split a clip (no splitMedia) — nothing was changed.' }; }
10004
+ const r = await A.splitMedia(${JSON.stringify(input.id)}, ${input.at});
10005
+ if (!r || r.ok === false) return { success: false, action: 'split', error: (r && r.reason) || 'the studio refused the split' };
10006
+ return Object.assign({ success: true, action: 'split' }, r);
10007
+ })();`.trim();
10008
+ case 'add_transition':
10009
+ return `
10010
+ // ${input.transition?.type ?? 'crossfade'} between ${input.fromItemId} and ${input.toItemId}
10011
+ (function() {
10012
+ ${guard}
10013
+ if (typeof A.addTransition !== 'function') { return { success: false, action: 'add_transition', error: 'this studio is too old for clip transitions (no addTransition) — nothing was changed.' }; }
10014
+ const r = A.addTransition(${JSON.stringify(input.fromItemId)}, ${JSON.stringify(input.toItemId)}, ${JSON.stringify(input.transition ?? {})});
10015
+ if (!r || r.ok === false) return { success: false, action: 'add_transition', error: (r && r.reason) || 'the studio refused the transition' };
10016
+ return Object.assign({ success: true, action: 'add_transition' }, r);
9736
10017
  })();`.trim();
9737
10018
  // ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
9738
10019
  // facades), not the agent media API, so they carry their own guards.
@@ -9742,15 +10023,18 @@ ${resolveMedia}
9742
10023
  return `
9743
10024
  // Remap a clip's time — speed ramps, freeze frames, reverse (null clears)
9744
10025
  (function() {
10026
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10027
+ // registry id these app facades take (round 10, 10.12).
10028
+ const __reg = function(r) { const __A = window.PinePaperAgent; const __m = (__A && typeof __A.listMedia === 'function') ? __A.listMedia().find(function(x) { return x.id === r; }) : null; return (__m && __m.registryId) || r; };
9745
10029
  if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
9746
- const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
10030
+ const r = app.setTimeRemap(__reg(${S(input.id)}), ${S(input.remapTrack ?? null)});
9747
10031
  if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'remap failed' };
9748
10032
  // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9749
10033
  // the timeline only for its trimmed window, and past that it is HIDDEN
9750
10034
  // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9751
10035
  // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9752
10036
  // of the curve is a hold. Both answered success before.
9753
- const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
10037
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(__reg(${S(input.id)})) : ${S(input.id)};
9754
10038
  const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9755
10039
  const __p = (__e && __e.properties) || {};
9756
10040
  const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
@@ -9778,15 +10062,18 @@ ${resolveMedia}
9778
10062
  return `
9779
10063
  // Speed ramp: consecutive {duration, speed} segments compiled to a remap curve
9780
10064
  (function() {
10065
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10066
+ // registry id these app facades take (round 10, 10.12).
10067
+ const __reg = function(r) { const __A = window.PinePaperAgent; const __m = (__A && typeof __A.listMedia === 'function') ? __A.listMedia().find(function(x) { return x.id === r; }) : null; return (__m && __m.registryId) || r; };
9781
10068
  if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
9782
- const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
10069
+ const r = app.speedRamp(__reg(${S(input.id)}), ${S(input.segments)});
9783
10070
  if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'speed ramp failed' };
9784
10071
  // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9785
10072
  // the timeline only for its trimmed window, and past that it is HIDDEN
9786
10073
  // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9787
10074
  // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9788
10075
  // of the curve is a hold. Both answered success before.
9789
- const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
10076
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(__reg(${S(input.id)})) : ${S(input.id)};
9790
10077
  const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9791
10078
  const __p = (__e && __e.properties) || {};
9792
10079
  const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
@@ -9822,8 +10109,11 @@ ${resolveMedia}
9822
10109
  return `
9823
10110
  // Match cut: align the SUBJECT across the cut (on-device detection)
9824
10111
  (async function() {
10112
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10113
+ // registry id these app facades take (round 10, 10.12).
10114
+ const __reg = function(r) { const __A = window.PinePaperAgent; const __m = (__A && typeof __A.listMedia === 'function') ? __A.listMedia().find(function(x) { return x.id === r; }) : null; return (__m && __m.registryId) || r; };
9825
10115
  if (typeof app.matchCut !== 'function') { return { success: false, error: 'app.matchCut unavailable — update FxTool to a match-cut-capable build' }; }
9826
- const r = await app.matchCut(${S(input.fromItemId)}, ${S(input.toItemId)}, ${opts});
10116
+ const r = await app.matchCut(__reg(${S(input.fromItemId)}), __reg(${S(input.toItemId)}), ${opts});
9827
10117
  if (r && r.needsConsent) { return { success: false, needsConsent: true, cost: r.cost, error: 'Detection model download needs consent — re-call with consent: true' }; }
9828
10118
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'match cut failed' }; }
9829
10119
  return { success: true, action: 'match_cut', aspectMismatch: r.aspectMismatch || false };
@@ -9841,9 +10131,12 @@ ${resolveMedia}
9841
10131
  return `
9842
10132
  // Track matte: the matte item's ${input.channel || 'luma'} drives the target's alpha${input.live ? ' (LIVE — re-cuts as the matte animates)' : ''}
9843
10133
  (async function() {
10134
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10135
+ // registry id these app facades take (round 10, 10.12).
10136
+ const __reg = function(r) { const __A = window.PinePaperAgent; const __m = (__A && typeof __A.listMedia === 'function') ? __A.listMedia().find(function(x) { return x.id === r; }) : null; return (__m && __m.registryId) || r; };
9844
10137
  if (typeof app.applyTrackMatte !== 'function') { return { success: false, error: 'app.applyTrackMatte unavailable — update FxTool to a track-matte-capable build' }; }
9845
10138
  try {
9846
- await app.applyTrackMatte(${S(input.id)}, ${S(input.matteItemId)}, ${opts});
10139
+ await app.applyTrackMatte(__reg(${S(input.id)}), __reg(${S(input.matteItemId)}), ${opts});
9847
10140
  return { success: true, action: 'apply_track_matte', id: ${S(input.id)}, matte: ${S(input.matteItemId)}, live: ${S(!!input.live)} };
9848
10141
  } catch (e) {
9849
10142
  // The facade throws on a missing raster/matte rather than returning {ok:false}.
@@ -9856,8 +10149,11 @@ ${resolveMedia}
9856
10149
  return `
9857
10150
  // Stop a live matte, leaving the last cut in place
9858
10151
  (function() {
10152
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10153
+ // registry id these app facades take (round 10, 10.12).
10154
+ const __reg = function(r) { const __A = window.PinePaperAgent; const __m = (__A && typeof __A.listMedia === 'function') ? __A.listMedia().find(function(x) { return x.id === r; }) : null; return (__m && __m.registryId) || r; };
9859
10155
  if (typeof app.stopLiveMatte !== 'function') { return { success: false, error: 'app.stopLiveMatte unavailable — update FxTool' }; }
9860
- const r = app.stopLiveMatte(${S(input.id)});
10156
+ const r = app.stopLiveMatte(__reg(${S(input.id)}));
9861
10157
  return r && r.ok ? { success: true, action: 'stop_live_matte' } : { success: false, error: (r && r.error) || 'no live matte on that item' };
9862
10158
  })();`.trim();
9863
10159
  }
@@ -11096,7 +11392,10 @@ ${guard(fn)}
11096
11392
  }
11097
11393
  case 'render_soundtrack': {
11098
11394
  const opts = {
11099
- ...(typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
11395
+ // A top-level duration was stripped by the schema and the render
11396
+ // used its default length, silently; both spellings are read now.
11397
+ ...(typeof input.duration === 'number' ? { duration: input.duration }
11398
+ : typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
11100
11399
  ...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}),
11101
11400
  ...(input.bitDepth !== undefined ? { bitDepth: input.bitDepth } : {}),
11102
11401
  };
@@ -11876,4 +12175,213 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
11876
12175
  }
11877
12176
  // Export singleton instance
11878
12177
  export const codeGenerator = new PinePaperCodeGenerator();
12178
+ /**
12179
+ * WORDS ARE VECTOR (cloud: every generated plate is text-free; the words come
12180
+ * back as textLayers). Lays them out as real PinePaper text over a placed
12181
+ * plate, by role, inside title-safe (5%), sized from the canvas height:
12182
+ * headline top-left (Anton by default, fitted to the safe width), subhead
12183
+ * under it, caption / cta stacked up from the bottom-left, label top-right.
12184
+ * Each text is checked against the plate's average colour under it (WCAG:
12185
+ * 4.5:1, 3:1 for large text) and gets the lightest dark scrim that passes
12186
+ * when it falls short. A body for pinepaper_execute_custom_code (async).
12187
+ */
12188
+ export function emitTextLayersPlacement(layers, plateRef) {
12189
+ const ROLE_FONT = { headline: 'Anton' };
12190
+ const families = [...new Set(layers.map((l) => l.font || ROLE_FONT[l.role ?? ''] || 'Inter'))];
12191
+ const fontBlocks = families.map((f) => `{${emitEnsureFont(f)}
12192
+ if (__font) fonts.push(__font);
12193
+ }`).join('\n');
12194
+ return `
12195
+ const fonts = [];
12196
+ ${fontBlocks}
12197
+ const plate = (function(r) {
12198
+ let it = app.getItemById(r);
12199
+ if (!it && window.PinePaperAgent && typeof window.PinePaperAgent.listMedia === 'function') {
12200
+ const m = window.PinePaperAgent.listMedia().find(function(x) { return x.id === r || x.registryId === r; });
12201
+ if (m) it = app.getItemById(m.registryId);
12202
+ }
12203
+ return it;
12204
+ })(${JSON.stringify(plateRef)});
12205
+ const cs = app.getCanvasSize();
12206
+ const W = cs.width, H = cs.height, mx = Math.round(W * 0.05), my = Math.round(H * 0.05);
12207
+ const LAYERS = ${JSON.stringify(layers)};
12208
+ const SPEC = {
12209
+ headline: { size: 0.075, font: 'Anton', where: 'top' },
12210
+ subhead: { size: 0.036, font: 'Inter', where: 'top' },
12211
+ caption: { size: 0.028, font: 'Inter', where: 'bottom' },
12212
+ cta: { size: 0.032, font: 'Inter', where: 'bottom', bold: true },
12213
+ label: { size: 0.022, font: 'Inter', where: 'topright' },
12214
+ };
12215
+ const lin = function(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
12216
+ const make = function(l, spec, x, y, anchor) {
12217
+ const size = Math.max(8, Math.round(H * spec.size));
12218
+ const it = app.create('text', { content: l.content, x: x, y: y, fontSize: size, fontFamily: l.font || spec.font, color: '#ffffff', anchor: anchor });
12219
+ if (!it) return null;
12220
+ if (spec.bold) it.fontWeight = 'bold';
12221
+ if (typeof app.fitText === 'function') {
12222
+ try { app.fitText(it, { maxWidth: W - 2 * mx, maxFontSize: size, minFontSize: Math.max(8, Math.round(size * 0.4)), hold: anchor.indexOf('bottom') === 0 ? 'bottom' : 'top' }); } catch (_) { /* keeps its size */ }
12223
+ }
12224
+ return it;
12225
+ };
12226
+ const contrast = function(it, spec) {
12227
+ if (!plate || typeof plate.getAverageColor !== 'function') return { checked: false, note: 'the plate is not an image the studio can sample (a video plate): contrast was not checked.' };
12228
+ // The BRIGHT end of what is under the text, not its mean: a caption across
12229
+ // bright reflections on a dark road averaged to 15:1 and was unreadable in
12230
+ // the bright part. An 8 x 3 grid of cells, each averaged, judged at the
12231
+ // 90th percentile of luminance.
12232
+ const b0 = it.bounds, lums = [];
12233
+ for (let gy = 0; gy < 3; gy++) {
12234
+ for (let gx = 0; gx < 8; gx++) {
12235
+ const cw = b0.width / 8, ch = b0.height / 3;
12236
+ const cc = plate.getAverageColor(new paper.Rectangle(b0.left + gx * cw, b0.top + gy * ch, cw, ch));
12237
+ if (cc) lums.push(0.2126 * lin(cc.red) + 0.7152 * lin(cc.green) + 0.0722 * lin(cc.blue));
12238
+ }
12239
+ }
12240
+ if (!lums.length) return { checked: false };
12241
+ lums.sort(function(x, y) { return x - y; });
12242
+ const L = lums[Math.floor(0.9 * (lums.length - 1))];
12243
+ const ratio = 1.05 / (L + 0.05);
12244
+ const large = it.fontSize >= 24 || (spec.bold && it.fontSize >= 18.66);
12245
+ const required = large ? 3 : 4.5;
12246
+ const out = { checked: true, ratio: Math.round(ratio * 100) / 100, required: required, approximate: 'against the bright end (90th percentile of 24 sampled cells) of the plate behind the text' };
12247
+ if (ratio >= required) return out;
12248
+ // The lightest black scrim that reaches the requirement: under it the
12249
+ // background luminance is L * (1 - a).
12250
+ const a = Math.min(0.85, Math.max(0.2, 1 - (1.05 / required - 0.05) / L));
12251
+ const pad = Math.round(it.fontSize * 0.35);
12252
+ const b = it.bounds;
12253
+ const s = app.create('rectangle', { x: b.left - pad, y: b.top - pad, width: b.width + 2 * pad, height: b.height + 2 * pad, color: '#000000', anchor: 'top-left' });
12254
+ if (s) { s.opacity = a; if (typeof s.insertBelow === 'function') s.insertBelow(it); }
12255
+ out.scrim = { itemId: s && s.data && s.data.registryId, opacity: Math.round(a * 100) / 100 };
12256
+ out.ratioAfter = Math.round(1.05 / (L * (1 - a) + 0.05) * 100) / 100;
12257
+ return out;
12258
+ };
12259
+ const role = function(l) { return SPEC[l.role] ? l.role : 'caption'; };
12260
+ const placed = [];
12261
+ let top = my;
12262
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'top'; }).forEach(function(l) {
12263
+ const spec = SPEC[role(l)];
12264
+ const it = make(l, spec, mx, top, 'top-left');
12265
+ if (!it) return;
12266
+ top = it.bounds.bottom + Math.round(H * 0.015);
12267
+ placed.push({ it: it, l: l, spec: spec });
12268
+ });
12269
+ let bottom = H - my;
12270
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'bottom'; }).slice().reverse().forEach(function(l) {
12271
+ const spec = SPEC[role(l)];
12272
+ const it = make(l, spec, mx, bottom, 'bottom-left');
12273
+ if (!it) return;
12274
+ bottom = it.bounds.top - Math.round(H * 0.012);
12275
+ placed.unshift({ it: it, l: l, spec: spec });
12276
+ });
12277
+ let rtop = my;
12278
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'topright'; }).forEach(function(l) {
12279
+ const spec = SPEC[role(l)];
12280
+ const it = make(l, spec, W - mx, rtop, 'top-right');
12281
+ if (!it) return;
12282
+ rtop = it.bounds.bottom + Math.round(H * 0.01);
12283
+ placed.push({ it: it, l: l, spec: spec });
12284
+ });
12285
+ if (app.historyManager) app.historyManager.saveState();
12286
+ return {
12287
+ textItems: placed.map(function(p) {
12288
+ return { itemId: p.it.data && p.it.data.registryId, role: role(p.l), content: p.l.content, fontFamily: p.it.fontFamily, fontSize: Math.round(p.it.fontSize), contrast: contrast(p.it, p.spec) };
12289
+ }),
12290
+ fonts: fonts,
12291
+ };`;
12292
+ }
12293
+ /**
12294
+ * AUTO-KEY FROM AN AGENT (plan C5a): "at t, make it orange" writes a key at t,
12295
+ * the way a user's drag does with auto-key on — not a static edit.
12296
+ *
12297
+ * The key is MERGED into the item's track (addAnimation mode 'merge'): other
12298
+ * keys stay. So the track stays whole:
12299
+ * - the new key also carries every OTHER animated property's value at t
12300
+ * (getInterpolatedState), because a key missing a property the others
12301
+ * animate can break that property's curve;
12302
+ * - a property the track did not animate yet is added to every existing key
12303
+ * (its old value before t, the new one from t), and a first key at 0 holds
12304
+ * the old value, so the change eases in from what it was instead of
12305
+ * jumping.
12306
+ * No duration is passed: addAnimation starts timeline playback when given one.
12307
+ * The result names each property's previous value and what was written.
12308
+ */
12309
+ export function generateAutoKeyCode(itemId, props, atTime, easing) {
12310
+ return `
12311
+ // Auto-key ${itemId} at ${atTime}s
12312
+ (function() {
12313
+ const item = app.getItemById(${JSON.stringify(itemId)});
12314
+ if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas — check the id with pinepaper_get_items.`)} };
12315
+ if (typeof app.addAnimation !== 'function') return { success: false, error: 'this studio cannot write keyframes (no addAnimation).' };
12316
+ const AT = ${atTime};
12317
+ const NEW = ${JSON.stringify(props)};
12318
+ const track = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12319
+ const animated = track.reduce(function(a, k) { Object.keys((k && k.properties) || {}).forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []);
12320
+ // The static value of a property the track does not animate yet.
12321
+ const read = function(p) {
12322
+ const c = function(v) { return v && typeof v.toCSS === 'function' ? v.toCSS(true) : v; };
12323
+ if (p === 'x') return item.position && item.position.x;
12324
+ if (p === 'y') return item.position && item.position.y;
12325
+ if (p === 'scale') return item.scaling ? item.scaling.x : 1;
12326
+ if (p === 'scaleX') return item.scaling ? item.scaling.x : 1;
12327
+ if (p === 'scaleY') return item.scaling ? item.scaling.y : 1;
12328
+ if (p === 'color' || p === 'fillColor') return c(item.fillColor);
12329
+ if (p === 'strokeColor') return c(item.strokeColor);
12330
+ if (p === 'width') return item.bounds && item.bounds.width;
12331
+ if (p === 'height') return item.bounds && item.bounds.height;
12332
+ const v = item[p];
12333
+ return (v === undefined || typeof v === 'function') ? undefined : c(v);
12334
+ };
12335
+ const state = (track.length && typeof app.getInterpolatedState === 'function') ? (app.getInterpolatedState(item, AT) || {}) : {};
12336
+ const byTime = {};
12337
+ const put = function(t, p, v, e) { const k = byTime[t] || (byTime[t] = { time: t, properties: {} }); k.properties[p] = v; if (e) k.easing = e; };
12338
+ const changes = [];
12339
+ Object.keys(NEW).forEach(function(p) {
12340
+ const wasAnimated = animated.indexOf(p) >= 0;
12341
+ const before = wasAnimated ? state[p] : read(p);
12342
+ if (!wasAnimated && before !== undefined) {
12343
+ track.forEach(function(k) { put(k.time, p, k.time < AT ? before : NEW[p]); });
12344
+ if (!track.length && AT > 0) put(0, p, before);
12345
+ }
12346
+ changes.push({ property: p, before: before === undefined ? null : before, after: NEW[p], easedFrom: before === undefined ? 'unknown — keyed at t only' : (wasAnimated ? 'the track' : 'its value before t') });
12347
+ });
12348
+ // The key at t: the new values, plus every other animated property as it is at t.
12349
+ animated.forEach(function(p) { if (!(p in NEW) && state[p] !== undefined) put(AT, p, state[p]); });
12350
+ Object.keys(NEW).forEach(function(p) { put(AT, p, NEW[p], ${JSON.stringify(easing ?? null)}); });
12351
+ const keys = Object.keys(byTime).map(function(t) { return byTime[t]; }).sort(function(a, b) { return a.time - b.time; });
12352
+ const beforeTimes = track.map(function(k) { return k.time; });
12353
+ app.addAnimation(${JSON.stringify(itemId)}, keys, { mode: 'merge' });
12354
+ const after = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12355
+ const kept = beforeTimes.every(function(t) { return after.some(function(k) { return k.time === t; }); });
12356
+ if (!kept) return { success: false, error: 'this studio replaced the track instead of merging into it (no merge mode): the earlier keys are gone. Put every key in one keyframe_animate call.', atTime: AT };
12357
+ if (app.historyManager) app.historyManager.saveState();
12358
+ return { success: true, itemId: ${JSON.stringify(itemId)}, atTime: AT, autoKeyed: true, changes: changes, keys: after.length,
12359
+ note: 'written as keyframes (a temporal edit), not a static change; pinepaper_query_mutations shows the series.' };
12360
+ })();`.trim();
12361
+ }
12362
+ /**
12363
+ * A property's time series (plan C5a): the item's own keyframe track, read
12364
+ * back. Until the engine keeps a mutation log (C3) this is the keys, not the
12365
+ * history of who changed what — the result says so.
12366
+ */
12367
+ export function generateQueryMutationsCode(itemId, property, range, fps = 30) {
12368
+ return `
12369
+ // Keyframe series for ${itemId}${property ? '.' + property : ''}
12370
+ (function() {
12371
+ const item = app.getItemById(${JSON.stringify(itemId)});
12372
+ if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas.`)} };
12373
+ const P = ${JSON.stringify(property ?? null)}, R = ${JSON.stringify(range ?? null)}, FPS = ${fps};
12374
+ const track = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12375
+ const series = {};
12376
+ track.forEach(function(k) {
12377
+ if (R && (k.time < R[0] || k.time > R[1])) return;
12378
+ Object.keys((k && k.properties) || {}).forEach(function(p) {
12379
+ if (P && p !== P) return;
12380
+ (series[p] = series[p] || []).push({ t: k.time, frame: Math.round(k.time * FPS), value: k.properties[p], easing: k.easing || null });
12381
+ });
12382
+ });
12383
+ return { success: true, itemId: ${JSON.stringify(itemId)}, source: 'keyframes', series: series, keys: track.length,
12384
+ note: 'the item\\'s keyframe track, read back. Who changed what and the before value per edit need the studio\\'s mutation log, which this studio does not expose yet.' };
12385
+ })();`.trim();
12386
+ }
11879
12387
  //# sourceMappingURL=code-generator.js.map