@pinepaper.studio/mcp-server 1.6.14 → 1.6.16

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 (62) hide show
  1. package/README.de.md +6 -1
  2. package/README.es.md +6 -1
  3. package/README.fr.md +6 -1
  4. package/README.hi.md +6 -1
  5. package/README.ja.md +6 -1
  6. package/README.ko.md +6 -1
  7. package/README.md +130 -6
  8. package/README.pt-BR.md +6 -1
  9. package/README.zh-CN.md +6 -1
  10. package/assets/brand/pinepaper-lockup-horizontal-white.svg +1 -0
  11. package/assets/brand/pinepaper-lockup-horizontal.svg +1 -0
  12. package/assets/edit-whenever.svg +1 -1
  13. package/dist/browser/puppeteer-controller.js +2 -2
  14. package/dist/browser/puppeteer-controller.js.map +1 -1
  15. package/dist/build-info.json +2 -2
  16. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  17. package/dist/ontology/upstream-vocabulary.js +2 -2
  18. package/dist/tools/definitions.d.ts.map +1 -1
  19. package/dist/tools/definitions.js +107 -9
  20. package/dist/tools/definitions.js.map +1 -1
  21. package/dist/tools/engine-methods.d.ts.map +1 -1
  22. package/dist/tools/engine-methods.js +1 -0
  23. package/dist/tools/engine-methods.js.map +1 -1
  24. package/dist/tools/engine-surface.d.ts +1 -1
  25. package/dist/tools/engine-surface.d.ts.map +1 -1
  26. package/dist/tools/engine-surface.js +59 -12
  27. package/dist/tools/engine-surface.js.map +1 -1
  28. package/dist/tools/handlers/camera.d.ts.map +1 -1
  29. package/dist/tools/handlers/camera.js +11 -0
  30. package/dist/tools/handlers/camera.js.map +1 -1
  31. package/dist/tools/handlers/generate.d.ts +324 -0
  32. package/dist/tools/handlers/generate.d.ts.map +1 -0
  33. package/dist/tools/handlers/generate.js +264 -0
  34. package/dist/tools/handlers/generate.js.map +1 -0
  35. package/dist/tools/handlers.d.ts.map +1 -1
  36. package/dist/tools/handlers.js +39 -4
  37. package/dist/tools/handlers.js.map +1 -1
  38. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  39. package/dist/tools/minimal-descriptions.js +3 -0
  40. package/dist/tools/minimal-descriptions.js.map +1 -1
  41. package/dist/tools/shape-params.d.ts +2 -2
  42. package/dist/tools/shape-params.d.ts.map +1 -1
  43. package/dist/tools/shape-params.js +20 -3
  44. package/dist/tools/shape-params.js.map +1 -1
  45. package/dist/tools/stick-vocabulary.d.ts +1 -1
  46. package/dist/tools/stick-vocabulary.js +1 -1
  47. package/dist/tools/toolkits.d.ts.map +1 -1
  48. package/dist/tools/toolkits.js +3 -0
  49. package/dist/tools/toolkits.js.map +1 -1
  50. package/dist/types/code-generator.d.ts +15 -0
  51. package/dist/types/code-generator.d.ts.map +1 -1
  52. package/dist/types/code-generator.js +339 -29
  53. package/dist/types/code-generator.js.map +1 -1
  54. package/dist/types/schemas.d.ts +532 -82
  55. package/dist/types/schemas.d.ts.map +1 -1
  56. package/dist/types/schemas.js +26 -10
  57. package/dist/types/schemas.js.map +1 -1
  58. package/dist/vendor/design/design-style.js +1 -1
  59. package/icon.png +0 -0
  60. package/icon.svg +1 -0
  61. package/manifest.json +15 -2
  62. package/package.json +3 -1
@@ -263,8 +263,16 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
263
263
  // the handful this emitter consumes itself are added. Reported back, not
264
264
  // refused: the item is still what the caller asked for in every other way,
265
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')));
266
+ //
267
+ // The generated allowlist says what create() READS, for any type; the text
268
+ // keys it reads apply only to text (the engine gates them on PointText). So
269
+ // on another type they are reported whatever the list says. `direction` is
270
+ // not among them: an arc reads it as its sweep.
271
+ const textOnly = (k) => TEXT_STYLE_KEYS.includes(k) || k === 'fit' || k === 'textDirection' || k === 'dir'
272
+ || TABULAR_KEYS.includes(k) || k === 'strokePosition';
273
+ const ignored = Object.keys(properties).filter((k) => itemType === 'text'
274
+ ? !CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k)
275
+ : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
268
276
  // Build the code
269
277
  let code = `
270
278
  // Create ${itemType} item
@@ -909,8 +917,15 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
909
917
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
910
918
  code += `
911
919
  app.historyManager.saveState();
920
+ ${properties.content !== undefined ? `
921
+ // CONTENT UNDER A CONTENT TRACK (round 10, 10.13): the keyframes set the text
922
+ // on every frame, so a direct edit is overwritten at once — it answered
923
+ // success and changed nothing visible.
924
+ const __ctItem = app.getItemById('${itemId}');
925
+ const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
926
+ ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
912
927
  ${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} };`;
928
+ 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} };`;
914
929
  // Wrapped, because the body now RETURNS early on a miss rather than falling
915
930
  // through to an unconditional success. Async only when a font must load
916
931
  // before modifyItem re-measures the text.
@@ -1227,7 +1242,7 @@ if (!_target) {
1227
1242
  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
1243
  }`.trim();
1229
1244
  }
1230
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
1245
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false) {
1231
1246
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1232
1247
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1233
1248
  const opts = { duration: calculatedDuration, loop };
@@ -1239,6 +1254,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1239
1254
  opts.clipOutPoint = clipOutPoint;
1240
1255
  if (timeUnits !== undefined)
1241
1256
  opts.timeUnits = timeUnits;
1257
+ // addAnimation SETS the item's track; mode 'merge' adds to it (FxTool
1258
+ // 2475333b). An engine without merge ignores the option and replaces.
1259
+ if (append)
1260
+ opts.mode = 'merge';
1242
1261
  // A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
1243
1262
  // the audio item's sentinel, and the mix reads one static audioGain per clip,
1244
1263
  // so {audioGain: 0→1} answered success, duration 15, and exported at a flat
@@ -1277,7 +1296,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1277
1296
  const gaps = keyframes
1278
1297
  .map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
1279
1298
  .filter((g) => g.missing.length);
1280
- const gapNote = gaps.length && keyframes.length > 1
1299
+ // With append the gaps that matter are the MERGED track's (an earlier key
1300
+ // with no scale beside a new key with one), so that check runs in the page
1301
+ // on the result instead (below).
1302
+ const gapNote = !append && gaps.length && keyframes.length > 1
1281
1303
  ? `, 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
1304
  : '';
1283
1305
  const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
@@ -1294,8 +1316,25 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1294
1316
  // Apply keyframe animation to ${itemId}
1295
1317
  (function() {
1296
1318
  ${requireItem(itemId, 'the animation')}${audioLevelGuard}
1319
+ // The track as it was, so the result can say what happened to it: merged
1320
+ // (every earlier key still there) or replaced. That is the evidence of
1321
+ // merge support — an older engine takes the option and replaces anyway.
1322
+ const __ki = _target && (_target.item || _target);
1323
+ const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1297
1324
  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} };
1325
+ const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1326
+ const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
1327
+ // Property gaps across the merged track (the same check a single call makes
1328
+ // on its own keys at generation time).
1329
+ const __all = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes : [];
1330
+ 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; }, []);
1331
+ 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; }) : [];` : ''}
1332
+ const __track = !__prev.length ? null
1333
+ : ${append ? `(__kept
1334
+ ? { appended: true, previousKeys: __prev.length, keys: __now.length }
1335
+ : { 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.' })`
1336
+ : `{ 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.' }`};
1337
+ 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} };
1299
1338
  })();
1300
1339
  `.trim();
1301
1340
  }
@@ -2275,7 +2314,7 @@ export class PinePaperCodeGenerator {
2275
2314
  */
2276
2315
  generateKeyframeAnimate(input) {
2277
2316
  const validated = KeyframeAnimateInputSchema.parse(input);
2278
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
2317
+ return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2279
2318
  }
2280
2319
  /**
2281
2320
  * Generate code for importing / retargeting a motion-capture clip
@@ -3937,7 +3976,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3937
3976
  }
3938
3977
  generateAgentExport(input) {
3939
3978
  const validated = AgentExportInputSchema.parse(input);
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;
3979
+ 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 } = validated;
3941
3980
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
3942
3981
  const videoEncodeOpts = {};
3943
3982
  if (broadcast)
@@ -3955,6 +3994,17 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3955
3994
  videoEncodeOpts.minBitrate = minBitrate;
3956
3995
  if (bitrateMode !== undefined)
3957
3996
  videoEncodeOpts.bitrateMode = bitrateMode;
3997
+ // Byte-identical repeats (FxTool 3b335567): the container timestamps are
3998
+ // the only bytes that varied, and this pins them.
3999
+ if (deterministic)
4000
+ videoEncodeOpts.deterministic = true;
4001
+ // A transparent WebM (FxTool 1833b397) rides the store route too.
4002
+ const webmAlpha = format === 'webm' && transparent === true;
4003
+ if (webmAlpha) {
4004
+ videoEncodeOpts.transparent = true;
4005
+ if (alphaQuantizer !== undefined)
4006
+ videoEncodeOpts.alphaQuantizer = alphaQuantizer;
4007
+ }
3958
4008
  const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
3959
4009
  const qualityLevel = quality || 'standard';
3960
4010
  const videoDuration = duration ?? 5;
@@ -4224,7 +4274,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4224
4274
  // generator or pattern backdrop never trips it.
4225
4275
  function alphaLoss(fmt) {
4226
4276
  try {
4227
- if (['mp4', 'webm', 'jpg'${format === 'apng' && transparent === false ? ", 'apng'" : ''}].indexOf(fmt) === -1) return [];
4277
+ if (['mp4', 'jpg'${format === 'webm' && transparent === true ? '' : ", 'webm'"}${format === 'apng' && transparent === false ? ", 'apng'" : ''}].indexOf(fmt) === -1) return [];
4228
4278
  const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
4229
4279
  const hasColor = !!bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg);
4230
4280
  const hasBgItems = !!(app.patternGroup && app.patternGroup.children && app.patternGroup.children.length);
@@ -4443,9 +4493,6 @@ ${stillTime !== undefined ? `
4443
4493
  // this render, a top-level item that reaches in but is centred outside —
4444
4494
  // a neighbouring card's overflowing headline. Older studios ignore the
4445
4495
  // 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
4496
  if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
4450
4497
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
4451
4498
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
@@ -4808,7 +4855,7 @@ ${stillTime !== undefined ? `
4808
4855
  // the per-page reports are summed into result.pdf.
4809
4856
  const __wantLayer = ${pdfOpts.searchableText !== false};
4810
4857
  const __layer = __wantLayer && typeof app.exportEngine.addTextLayer === 'function';
4811
- const __tl = { written: 0, skipped: 0, reasons: [] };
4858
+ const __tl = { written: 0, skipped: 0, reasons: [], fonts: null };
4812
4859
  try {
4813
4860
  for (const spec of pageSpecs) {
4814
4861
  const id = spec.id;
@@ -4820,8 +4867,29 @@ ${stillTime !== undefined ? `
4820
4867
  const orient = wMM > hMM ? 'landscape' : 'portrait';
4821
4868
  const png = app.captureFrameDataURL(Math.max(1, targetDpi / dpi));
4822
4869
  const jpg = await toJpeg(png);
4870
+ const __first = !doc;
4823
4871
  if (!doc) doc = new lib.jsPDF({ orientation: orient, unit: 'mm', format: [wMM, hMM] });
4824
4872
  else doc.addPage([wMM, hMM], orient);
4873
+ // The scene's fonts (FxTool dbe8441d). Once per document on an
4874
+ // engine whose repeat call re-registers over page 1's face; per
4875
+ // page (this page's scene) where the engine caches faces on the
4876
+ // document — it leaves doc.__ppFontCache after the first call
4877
+ // (3b9499f2), which is the evidence, not a version guess.
4878
+ if (__layer && typeof app.exportEngine.embedTextFonts === 'function' && (__first || doc.__ppFontCache)) {
4879
+ try {
4880
+ const fr = await app.exportEngine.embedTextFonts(doc);
4881
+ if (!__tl.fonts) __tl.fonts = fr;
4882
+ else if (fr) {
4883
+ const seen = {};
4884
+ __tl.fonts.families = (__tl.fonts.families || []).concat(fr.families || []).filter(function(f) {
4885
+ const k = f && (f.family + '|' + f.weight + '|' + f.italic);
4886
+ if (!k || seen[k]) return false; seen[k] = true; return true;
4887
+ });
4888
+ if (typeof fr.embedded === 'number') __tl.fonts.embedded = fr.embedded; // cumulative for the document
4889
+ if (typeof fr.hiddenTextItems === 'number') __tl.fonts.hiddenTextItems = (__tl.fonts.hiddenTextItems || 0) + fr.hiddenTextItems;
4890
+ }
4891
+ } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: e && e.message }; }
4892
+ }
4825
4893
  if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
4826
4894
  if (__layer) {
4827
4895
  try {
@@ -4842,6 +4910,7 @@ ${stillTime !== undefined ? `
4842
4910
  pdf: !__wantLayer ? { searchableText: false, reason: 'off' }
4843
4911
  : !__layer ? { searchableText: false, reason: 'this studio adds no text layer to multi-page PDFs' }
4844
4912
  : Object.assign({ searchableText: __tl.written > 0 || __tl.reasons.length < pagesOut.length, linesWritten: __tl.written, linesSkipped: __tl.skipped },
4913
+ __tl.fonts ? { fonts: __tl.fonts } : {},
4845
4914
  __tl.reasons.length ? { pagesWithoutLayer: __tl.reasons } : {},
4846
4915
  __tl.written === 0 && __tl.reasons.length === pagesOut.length ? { reason: __tl.reasons.join('; ') } : {}) };
4847
4916
  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}, …].';
@@ -4934,6 +5003,18 @@ ${stillTime !== undefined ? `
4934
5003
 
4935
5004
  if (result && result.success) Object.assign(result, fidelity(format));
4936
5005
  if (result && result.success && __fit) result.platformFit = __fit;
5006
+ // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5007
+ // aspect nuance but the wrong deliverable: tiktok on a 16:9 canvas came out
5008
+ // 1920x1080 with only a note (round 10, 10.7). Said where a caller looks.
5009
+ if (result && result.success && __fit) {
5010
+ const __pw = __fit.preset.split('x').map(Number), __uw = __fit.used.split('x').map(Number);
5011
+ if ((__pw[0] > __pw[1]) !== (__uw[0] > __uw[1]) && __pw[0] !== __pw[1]) {
5012
+ result.fidelity = result.fidelity || { warnings: [] };
5013
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'platform_orientation_mismatch',
5014
+ 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.' }]);
5015
+ if (result.fidelity.note) delete result.fidelity.note;
5016
+ }
5017
+ }
4937
5018
  // AUDIO TIMING, WHERE THE STUDIO REPORTS IT (6.27). An MP4's AAC track used
4938
5019
  // to start ~44 ms late and run past the picture — a 6.000 s bumper with sound
4939
5020
  // then failed its cap. The engine now reports the track; it is surfaced as
@@ -4959,6 +5040,10 @@ ${stillTime !== undefined ? `
4959
5040
  const __ff = (__pr.fonts && Array.isArray(__pr.fonts.families)) ? __pr.fonts.families.filter(function(f) { return f && f.reason; }) : [];
4960
5041
  const __fnames = __ff.slice(0, 4).map(function(f) { return f.family + ' (' + f.reason + ')'; }).join('; ');
4961
5042
  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 + '.' : '') });
5043
+ // Text hidden at the render moment (a lifetime window, visible: false,
5044
+ // opacity 0) is in neither the picture nor the layer (FxTool 3b9499f2).
5045
+ const __hid = __pr.fonts && typeof __pr.fonts.hiddenTextItems === 'number' ? __pr.fonts.hiddenTextItems : 0;
5046
+ if (__hid > 0) __pw.push({ code: 'pdf_text_hidden', message: __hid + ' text item(s) were hidden when the PDF was rendered (outside their bornAt / ttl window, visible: false, or opacity 0), so they are not in it. Export at a time they show (time), or change their lifetime.' });
4962
5047
  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
5048
  if (__pr.searchableText && __pr.pagesWithoutLayer) __pw.push({ code: 'pdf_no_text_layer', message: 'some pages have no searchable text layer — ' + __pr.pagesWithoutLayer.join('; ') });
4964
5049
  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.' });
@@ -4969,7 +5054,21 @@ ${stillTime !== undefined ? `
4969
5054
  }
4970
5055
  }
4971
5056
  const __vr = __vx && __vx.lastVideoReport;
4972
- if (result && result.success && __vr && typeof __vr === 'object') result.video = __vr;${headroomDefaulted ? `
5057
+ if (result && result.success && __vr && typeof __vr === 'object') result.video = __vr;${webmAlpha ? `
5058
+ // A TRANSPARENT WEBM is confirmed from the encoder's own record: an alpha
5059
+ // stream with frames in it. A studio without WebM alpha writes an opaque
5060
+ // file that looks like it worked.
5061
+ if (result && result.success) {
5062
+ const __al = __vr && __vr.alpha;
5063
+ const __aw = [];
5064
+ if (!(__al && __al.frames > 0)) __aw.push({ code: 'alpha_not_applied', message: 'transparent was asked for, but this studio wrote no alpha stream: the WebM is opaque (transparent areas black). Export apng, or a png sequence, for transparency here.' });
5065
+ else if (__al.keyframeMismatches > 0) __aw.push({ code: 'alpha_keyframe_mismatch', message: __al.keyframeMismatches + ' frame(s) had colour and alpha keyframes out of step; some players may show those frames without transparency.' });
5066
+ if (__aw.length) {
5067
+ result.fidelity = result.fidelity || { warnings: [] };
5068
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__aw);
5069
+ if (result.fidelity.note) delete result.fidelity.note;
5070
+ }
5071
+ }` : ''}${headroomDefaulted ? `
4973
5072
  // The applied value is the engine's own report where it gives one
4974
5073
  // (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
4975
5074
  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.' };` : ''}
@@ -4985,7 +5084,34 @@ ${stillTime !== undefined ? `
4985
5084
  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
5085
  if (result.fidelity.note) delete result.fidelity.note;
4987
5086
  }
4988
- // LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
5087
+ ${deterministic ? ` // DETERMINISTIC: the container timestamps are pinned either way; the PIXELS
5088
+ // are only identical run to run on the software encoder, because the
5089
+ // browser may pick a different hardware backend each time (FxTool
5090
+ // e34ae03d: 92/255 between backends, 2/255 run to run on hardware). The
5091
+ // engine requests software and reports what it got.
5092
+ // Where the studio says outright whether the file repeats (bitExact, FxTool
5093
+ // PR #31: it test-encodes and keeps whichever encoder does — often the
5094
+ // HARDWARE one on a Mac, since Chrome's software H.264 is not repeatable at
5095
+ // some sizes), that answer decides; the encoder name is only the fallback
5096
+ // for studios that do not report it.
5097
+ if (result && result.success) {
5098
+ const __hw = __vr && __vr.hardwareAcceleration;
5099
+ const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;
5100
+ if (__exact === false) {
5101
+ result.fidelity = result.fidelity || { warnings: [] };
5102
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',
5103
+ 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.' }]);
5104
+ if (result.fidelity.note) delete result.fidelity.note;
5105
+ } else if (__exact === null && __hw !== 'prefer-software') {
5106
+ result.fidelity = result.fidelity || { warnings: [] };
5107
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',
5108
+ message: __hw
5109
+ ? '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.'
5110
+ : '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.' }]);
5111
+ if (result.fidelity.note) delete result.fidelity.note;
5112
+ }
5113
+ }
5114
+ ` : ''} // LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
4989
5115
  // decoded back and measured: H.264 ringing at hard edges puts a fraction of
4990
5116
  // a percent of samples out of range, which QC rejects. broadcastHeadroom
4991
5117
  // cuts it but never to zero, so a legaliser pass is the requirement.
@@ -7649,6 +7775,12 @@ ${loads}
7649
7775
  // Anything else (asset id, data:, URL) passes through untouched.
7650
7776
  // @engine-methods analyzeAudio animateToBeat
7651
7777
  const resolveSource = ` let __src = ${JSON.stringify(input.source || '')};
7778
+ // A large source was staged beside the code (see the handler); read it back.
7779
+ if (__src.indexOf('__ppStage:') === 0) {
7780
+ const __staged = window.__ppStage && window.__ppStage[__src.slice('__ppStage:'.length)];
7781
+ 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.' };
7782
+ __src = __staged;
7783
+ }
7652
7784
  const __A = (typeof window !== 'undefined') && window.PinePaperAgent;
7653
7785
  const __m = (__src && __A && typeof __A.listMedia === 'function')
7654
7786
  ? __A.listMedia().find(function(x) { return x.id === __src || x.registryId === __src; })
@@ -7672,6 +7804,11 @@ ${resolveSource}
7672
7804
  if (r && typeof r === 'object' && 'ok' in r) {
7673
7805
  return { success: r.ok !== false, ...r };
7674
7806
  }
7807
+ // Nothing back is not a result: it was reported as success with an empty
7808
+ // body (gap 5.5), which reads as "analysed, no beats".
7809
+ if (r === undefined || r === null) {
7810
+ 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.' };
7811
+ }
7675
7812
  return { success: true, result: r };
7676
7813
  })();`.trim();
7677
7814
  if (input.action === 'analyze') {
@@ -8195,7 +8332,12 @@ ${resolve}
8195
8332
  // engine mints a NUMBER. Returning the number meant the id could not be
8196
8333
  // round-tripped: pass it back to add/remove and zod rejects it before the
8197
8334
  // call is made.
8198
- const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
8335
+ // THE REGISTRY ID, not Paper's. createPrecomp returns the group, whose
8336
+ // .id is Paper's numeric id ("49"), which no tool accepts back ("precomp:
8337
+ // no such item(s): 49", round 10, 10.11). The registry id — what
8338
+ // listPrecomps and every other tool use — is on the group's data.
8339
+ const _d = (precomp && precomp.data) || {};
8340
+ const _pid = _d.registryId || _d.id || (precomp && precomp.id !== undefined ? precomp.id : precomp);
8199
8341
  return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
8200
8342
  })();`.trim();
8201
8343
  }
@@ -8206,7 +8348,11 @@ ${resolve}
8206
8348
  // addToPrecomp answers falsy when the precomp or the item is not found, and
8207
8349
  // this reported success regardless — the same silent-success shape as an
8208
8350
  // unknown itemId elsewhere.
8209
- const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
8351
+ // The engine takes the ITEM, not its id (it clones it); an id string made
8352
+ // add throw and could never have worked. Resolved here, and a miss named.
8353
+ const _item = app.getItemById(${JSON.stringify(input.itemId || '')});
8354
+ 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.' };
8355
+ const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, _item);
8210
8356
  if (_ok === false || _ok === null || _ok === undefined) {
8211
8357
  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
8358
  }
@@ -8216,7 +8362,14 @@ ${resolve}
8216
8362
  return `
8217
8363
  // Remove item from precomp
8218
8364
  (function() {
8219
- app.removeFromPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
8365
+ // The item, not its id (the engine checks item.parent); and its answer is
8366
+ // read — null meant nothing was removed, and this said success regardless.
8367
+ const _item = app.getItemById(${JSON.stringify(input.itemId || '')});
8368
+ 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.' };
8369
+ const _r = app.removeFromPrecomp(${JSON.stringify(input.precompId || '')}, _item);
8370
+ if (_r === null || _r === undefined || _r === false) {
8371
+ 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).' };
8372
+ }
8220
8373
  return { success: true, action: 'remove', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
8221
8374
  })();`.trim();
8222
8375
  default:
@@ -9613,7 +9766,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9613
9766
  // Upload video from URL
9614
9767
  (async function() {
9615
9768
  ${guard}
9616
- const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${opts});
9769
+ const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${input.atPlayhead ? `Object.assign(${opts}, { timeOffset: Number(app.playbackTime) || 0 })` : opts});
9617
9770
  return { success: true, action: 'upload_video', media: info };
9618
9771
  })();`.trim();
9619
9772
  }
@@ -9628,7 +9781,7 @@ ${guard}
9628
9781
  // Upload audio from URL
9629
9782
  (async function() {
9630
9783
  ${guard}
9631
- const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${opts});${input.volume !== undefined ? `
9784
+ const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${input.atPlayhead ? `Object.assign(${opts}, { timeOffset: Number(app.playbackTime) || 0 })` : opts});${input.volume !== undefined ? `
9632
9785
  // THE LEVEL IS STORED UNDER ONE KEY AND EXPORTED FROM ANOTHER. The upload
9633
9786
  // records volume as \`gain\` on the registry entry; the video exporter's mix
9634
9787
  // reads \`audioGain\`. So a bed uploaded at 0.25 exported at unity (measured:
@@ -9677,10 +9830,37 @@ ${guard}
9677
9830
  if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
9678
9831
  ${resolveMedia}
9679
9832
  if (!__m) return __miss;
9680
- const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint});
9833
+ ${input.timeOffset !== undefined ? ` // Moving the clip is the 4th argument, which older studios' setMediaClip
9834
+ // does not take (it dropped it silently): its declared arity is the tell.
9835
+ if (A.setMediaClip.length < 4) {
9836
+ 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.' };
9837
+ }
9838
+ ` : ''} const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint}${input.timeOffset !== undefined ? `, ${input.timeOffset}` : ''});
9681
9839
  return ok
9682
- ? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} }
9840
+ ? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint}${input.timeOffset !== undefined ? `, timeOffset: ${input.timeOffset}` : ''} }
9683
9841
  : { success: false, action: 'set_clip', id: __mid, error: 'the studio refused the clip window ${input.inPoint}–${input.outPoint} for ' + __mid + '.' };
9842
+ })();`.trim();
9843
+ // Split and transitions (FxTool, verified on main): the engine's own
9844
+ // refusals come back as {ok:false, reason} and are errors here.
9845
+ case 'split':
9846
+ return `
9847
+ // Split a media clip at canvas time ${input.at}
9848
+ (async function() {
9849
+ ${guard}
9850
+ 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.' }; }
9851
+ const r = await A.splitMedia(${JSON.stringify(input.id)}, ${input.at});
9852
+ if (!r || r.ok === false) return { success: false, action: 'split', error: (r && r.reason) || 'the studio refused the split' };
9853
+ return Object.assign({ success: true, action: 'split' }, r);
9854
+ })();`.trim();
9855
+ case 'add_transition':
9856
+ return `
9857
+ // ${input.transition?.type ?? 'crossfade'} between ${input.fromItemId} and ${input.toItemId}
9858
+ (function() {
9859
+ ${guard}
9860
+ 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.' }; }
9861
+ const r = A.addTransition(${JSON.stringify(input.fromItemId)}, ${JSON.stringify(input.toItemId)}, ${JSON.stringify(input.transition ?? {})});
9862
+ if (!r || r.ok === false) return { success: false, action: 'add_transition', error: (r && r.reason) || 'the studio refused the transition' };
9863
+ return Object.assign({ success: true, action: 'add_transition' }, r);
9684
9864
  })();`.trim();
9685
9865
  // ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
9686
9866
  // facades), not the agent media API, so they carry their own guards.
@@ -9690,15 +9870,18 @@ ${resolveMedia}
9690
9870
  return `
9691
9871
  // Remap a clip's time — speed ramps, freeze frames, reverse (null clears)
9692
9872
  (function() {
9873
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
9874
+ // registry id these app facades take (round 10, 10.12).
9875
+ 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; };
9693
9876
  if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
9694
- const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
9877
+ const r = app.setTimeRemap(__reg(${S(input.id)}), ${S(input.remapTrack ?? null)});
9695
9878
  if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'remap failed' };
9696
9879
  // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9697
9880
  // the timeline only for its trimmed window, and past that it is HIDDEN
9698
9881
  // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9699
9882
  // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9700
9883
  // 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)};
9884
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(__reg(${S(input.id)})) : ${S(input.id)};
9702
9885
  const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9703
9886
  const __p = (__e && __e.properties) || {};
9704
9887
  const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
@@ -9726,15 +9909,18 @@ ${resolveMedia}
9726
9909
  return `
9727
9910
  // Speed ramp: consecutive {duration, speed} segments compiled to a remap curve
9728
9911
  (function() {
9912
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
9913
+ // registry id these app facades take (round 10, 10.12).
9914
+ 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; };
9729
9915
  if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
9730
- const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
9916
+ const r = app.speedRamp(__reg(${S(input.id)}), ${S(input.segments)});
9731
9917
  if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'speed ramp failed' };
9732
9918
  // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9733
9919
  // the timeline only for its trimmed window, and past that it is HIDDEN
9734
9920
  // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9735
9921
  // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9736
9922
  // 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)};
9923
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(__reg(${S(input.id)})) : ${S(input.id)};
9738
9924
  const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9739
9925
  const __p = (__e && __e.properties) || {};
9740
9926
  const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
@@ -9770,8 +9956,11 @@ ${resolveMedia}
9770
9956
  return `
9771
9957
  // Match cut: align the SUBJECT across the cut (on-device detection)
9772
9958
  (async function() {
9959
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
9960
+ // registry id these app facades take (round 10, 10.12).
9961
+ 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; };
9773
9962
  if (typeof app.matchCut !== 'function') { return { success: false, error: 'app.matchCut unavailable — update FxTool to a match-cut-capable build' }; }
9774
- const r = await app.matchCut(${S(input.fromItemId)}, ${S(input.toItemId)}, ${opts});
9963
+ const r = await app.matchCut(__reg(${S(input.fromItemId)}), __reg(${S(input.toItemId)}), ${opts});
9775
9964
  if (r && r.needsConsent) { return { success: false, needsConsent: true, cost: r.cost, error: 'Detection model download needs consent — re-call with consent: true' }; }
9776
9965
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'match cut failed' }; }
9777
9966
  return { success: true, action: 'match_cut', aspectMismatch: r.aspectMismatch || false };
@@ -9789,9 +9978,12 @@ ${resolveMedia}
9789
9978
  return `
9790
9979
  // Track matte: the matte item's ${input.channel || 'luma'} drives the target's alpha${input.live ? ' (LIVE — re-cuts as the matte animates)' : ''}
9791
9980
  (async function() {
9981
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
9982
+ // registry id these app facades take (round 10, 10.12).
9983
+ 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; };
9792
9984
  if (typeof app.applyTrackMatte !== 'function') { return { success: false, error: 'app.applyTrackMatte unavailable — update FxTool to a track-matte-capable build' }; }
9793
9985
  try {
9794
- await app.applyTrackMatte(${S(input.id)}, ${S(input.matteItemId)}, ${opts});
9986
+ await app.applyTrackMatte(__reg(${S(input.id)}), __reg(${S(input.matteItemId)}), ${opts});
9795
9987
  return { success: true, action: 'apply_track_matte', id: ${S(input.id)}, matte: ${S(input.matteItemId)}, live: ${S(!!input.live)} };
9796
9988
  } catch (e) {
9797
9989
  // The facade throws on a missing raster/matte rather than returning {ok:false}.
@@ -9804,8 +9996,11 @@ ${resolveMedia}
9804
9996
  return `
9805
9997
  // Stop a live matte, leaving the last cut in place
9806
9998
  (function() {
9999
+ // A media id (vraster_ / araster_, what upload returns) is resolved to the
10000
+ // registry id these app facades take (round 10, 10.12).
10001
+ 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; };
9807
10002
  if (typeof app.stopLiveMatte !== 'function') { return { success: false, error: 'app.stopLiveMatte unavailable — update FxTool' }; }
9808
- const r = app.stopLiveMatte(${S(input.id)});
10003
+ const r = app.stopLiveMatte(__reg(${S(input.id)}));
9809
10004
  return r && r.ok ? { success: true, action: 'stop_live_matte' } : { success: false, error: (r && r.error) || 'no live matte on that item' };
9810
10005
  })();`.trim();
9811
10006
  }
@@ -11824,4 +12019,119 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
11824
12019
  }
11825
12020
  // Export singleton instance
11826
12021
  export const codeGenerator = new PinePaperCodeGenerator();
12022
+ /**
12023
+ * WORDS ARE VECTOR (cloud: every generated plate is text-free; the words come
12024
+ * back as textLayers). Lays them out as real PinePaper text over a placed
12025
+ * plate, by role, inside title-safe (5%), sized from the canvas height:
12026
+ * headline top-left (Anton by default, fitted to the safe width), subhead
12027
+ * under it, caption / cta stacked up from the bottom-left, label top-right.
12028
+ * Each text is checked against the plate's average colour under it (WCAG:
12029
+ * 4.5:1, 3:1 for large text) and gets the lightest dark scrim that passes
12030
+ * when it falls short. A body for pinepaper_execute_custom_code (async).
12031
+ */
12032
+ export function emitTextLayersPlacement(layers, plateRef) {
12033
+ const ROLE_FONT = { headline: 'Anton' };
12034
+ const families = [...new Set(layers.map((l) => l.font || ROLE_FONT[l.role ?? ''] || 'Inter'))];
12035
+ const fontBlocks = families.map((f) => `{${emitEnsureFont(f)}
12036
+ if (__font) fonts.push(__font);
12037
+ }`).join('\n');
12038
+ return `
12039
+ const fonts = [];
12040
+ ${fontBlocks}
12041
+ const plate = (function(r) {
12042
+ let it = app.getItemById(r);
12043
+ if (!it && window.PinePaperAgent && typeof window.PinePaperAgent.listMedia === 'function') {
12044
+ const m = window.PinePaperAgent.listMedia().find(function(x) { return x.id === r || x.registryId === r; });
12045
+ if (m) it = app.getItemById(m.registryId);
12046
+ }
12047
+ return it;
12048
+ })(${JSON.stringify(plateRef)});
12049
+ const cs = app.getCanvasSize();
12050
+ const W = cs.width, H = cs.height, mx = Math.round(W * 0.05), my = Math.round(H * 0.05);
12051
+ const LAYERS = ${JSON.stringify(layers)};
12052
+ const SPEC = {
12053
+ headline: { size: 0.075, font: 'Anton', where: 'top' },
12054
+ subhead: { size: 0.036, font: 'Inter', where: 'top' },
12055
+ caption: { size: 0.028, font: 'Inter', where: 'bottom' },
12056
+ cta: { size: 0.032, font: 'Inter', where: 'bottom', bold: true },
12057
+ label: { size: 0.022, font: 'Inter', where: 'topright' },
12058
+ };
12059
+ const lin = function(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
12060
+ const make = function(l, spec, x, y, anchor) {
12061
+ const size = Math.max(8, Math.round(H * spec.size));
12062
+ const it = app.create('text', { content: l.content, x: x, y: y, fontSize: size, fontFamily: l.font || spec.font, color: '#ffffff', anchor: anchor });
12063
+ if (!it) return null;
12064
+ if (spec.bold) it.fontWeight = 'bold';
12065
+ if (typeof app.fitText === 'function') {
12066
+ 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 */ }
12067
+ }
12068
+ return it;
12069
+ };
12070
+ const contrast = function(it, spec) {
12071
+ 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.' };
12072
+ // The BRIGHT end of what is under the text, not its mean: a caption across
12073
+ // bright reflections on a dark road averaged to 15:1 and was unreadable in
12074
+ // the bright part. An 8 x 3 grid of cells, each averaged, judged at the
12075
+ // 90th percentile of luminance.
12076
+ const b0 = it.bounds, lums = [];
12077
+ for (let gy = 0; gy < 3; gy++) {
12078
+ for (let gx = 0; gx < 8; gx++) {
12079
+ const cw = b0.width / 8, ch = b0.height / 3;
12080
+ const cc = plate.getAverageColor(new paper.Rectangle(b0.left + gx * cw, b0.top + gy * ch, cw, ch));
12081
+ if (cc) lums.push(0.2126 * lin(cc.red) + 0.7152 * lin(cc.green) + 0.0722 * lin(cc.blue));
12082
+ }
12083
+ }
12084
+ if (!lums.length) return { checked: false };
12085
+ lums.sort(function(x, y) { return x - y; });
12086
+ const L = lums[Math.floor(0.9 * (lums.length - 1))];
12087
+ const ratio = 1.05 / (L + 0.05);
12088
+ const large = it.fontSize >= 24 || (spec.bold && it.fontSize >= 18.66);
12089
+ const required = large ? 3 : 4.5;
12090
+ 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' };
12091
+ if (ratio >= required) return out;
12092
+ // The lightest black scrim that reaches the requirement: under it the
12093
+ // background luminance is L * (1 - a).
12094
+ const a = Math.min(0.85, Math.max(0.2, 1 - (1.05 / required - 0.05) / L));
12095
+ const pad = Math.round(it.fontSize * 0.35);
12096
+ const b = it.bounds;
12097
+ 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' });
12098
+ if (s) { s.opacity = a; if (typeof s.insertBelow === 'function') s.insertBelow(it); }
12099
+ out.scrim = { itemId: s && s.data && s.data.registryId, opacity: Math.round(a * 100) / 100 };
12100
+ out.ratioAfter = Math.round(1.05 / (L * (1 - a) + 0.05) * 100) / 100;
12101
+ return out;
12102
+ };
12103
+ const role = function(l) { return SPEC[l.role] ? l.role : 'caption'; };
12104
+ const placed = [];
12105
+ let top = my;
12106
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'top'; }).forEach(function(l) {
12107
+ const spec = SPEC[role(l)];
12108
+ const it = make(l, spec, mx, top, 'top-left');
12109
+ if (!it) return;
12110
+ top = it.bounds.bottom + Math.round(H * 0.015);
12111
+ placed.push({ it: it, l: l, spec: spec });
12112
+ });
12113
+ let bottom = H - my;
12114
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'bottom'; }).slice().reverse().forEach(function(l) {
12115
+ const spec = SPEC[role(l)];
12116
+ const it = make(l, spec, mx, bottom, 'bottom-left');
12117
+ if (!it) return;
12118
+ bottom = it.bounds.top - Math.round(H * 0.012);
12119
+ placed.unshift({ it: it, l: l, spec: spec });
12120
+ });
12121
+ let rtop = my;
12122
+ LAYERS.filter(function(l) { return SPEC[role(l)].where === 'topright'; }).forEach(function(l) {
12123
+ const spec = SPEC[role(l)];
12124
+ const it = make(l, spec, W - mx, rtop, 'top-right');
12125
+ if (!it) return;
12126
+ rtop = it.bounds.bottom + Math.round(H * 0.01);
12127
+ placed.push({ it: it, l: l, spec: spec });
12128
+ });
12129
+ if (app.historyManager) app.historyManager.saveState();
12130
+ return {
12131
+ textItems: placed.map(function(p) {
12132
+ 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) };
12133
+ }),
12134
+ fonts: fonts,
12135
+ };`;
12136
+ }
11827
12137
  //# sourceMappingURL=code-generator.js.map