@pinepaper.studio/mcp-server 1.6.16 → 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 (51) 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 +50 -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/tools/definitions.d.ts.map +1 -1
  12. package/dist/tools/definitions.js +60 -12
  13. package/dist/tools/definitions.js.map +1 -1
  14. package/dist/tools/engine-methods.d.ts.map +1 -1
  15. package/dist/tools/engine-methods.js +4 -0
  16. package/dist/tools/engine-methods.js.map +1 -1
  17. package/dist/tools/handlers/beats.d.ts +64 -0
  18. package/dist/tools/handlers/beats.d.ts.map +1 -0
  19. package/dist/tools/handlers/beats.js +83 -0
  20. package/dist/tools/handlers/beats.js.map +1 -0
  21. package/dist/tools/handlers.d.ts.map +1 -1
  22. package/dist/tools/handlers.js +40 -5
  23. package/dist/tools/handlers.js.map +1 -1
  24. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  25. package/dist/tools/minimal-descriptions.js +2 -0
  26. package/dist/tools/minimal-descriptions.js.map +1 -1
  27. package/dist/tools/toolkits.d.ts.map +1 -1
  28. package/dist/tools/toolkits.js +2 -0
  29. package/dist/tools/toolkits.js.map +1 -1
  30. package/dist/types/code-generator.d.ts +23 -0
  31. package/dist/types/code-generator.d.ts.map +1 -1
  32. package/dist/types/code-generator.js +274 -24
  33. package/dist/types/code-generator.js.map +1 -1
  34. package/dist/types/schemas.d.ts +87 -0
  35. package/dist/types/schemas.d.ts.map +1 -1
  36. package/dist/types/schemas.js +34 -14
  37. package/dist/types/schemas.js.map +1 -1
  38. package/dist/utils/a11y.d.ts +2 -0
  39. package/dist/utils/a11y.d.ts.map +1 -1
  40. package/dist/utils/a11y.js +1 -1
  41. package/dist/utils/a11y.js.map +1 -1
  42. package/dist/utils/beats.d.ts +58 -0
  43. package/dist/utils/beats.d.ts.map +1 -0
  44. package/dist/utils/beats.js +110 -0
  45. package/dist/utils/beats.js.map +1 -0
  46. package/dist/utils/svg-size.d.ts +22 -0
  47. package/dist/utils/svg-size.d.ts.map +1 -0
  48. package/dist/utils/svg-size.js +43 -0
  49. package/dist/utils/svg-size.js.map +1 -0
  50. package/manifest.json +9 -1
  51. 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';
@@ -1697,7 +1698,8 @@ function generateSetCanvasSizeCode(width, height, preset) {
1697
1698
  // echoing the request back.
1698
1699
  return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
1699
1700
  ...(r && r.preset ? { preset: r.preset } : {}),
1700
- ...(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.'` : ''} };
1701
1703
  })();
1702
1704
  `.trim();
1703
1705
  }
@@ -2250,6 +2252,9 @@ export class PinePaperCodeGenerator {
2250
2252
  */
2251
2253
  generateModifyItem(input) {
2252
2254
  const validated = ModifyItemInputSchema.parse(input);
2255
+ if (validated.atTime !== undefined) {
2256
+ return generateAutoKeyCode(validated.itemId, validated.properties, validated.atTime, validated.easing);
2257
+ }
2253
2258
  return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
2254
2259
  }
2255
2260
  /**
@@ -2466,12 +2471,21 @@ ${INLINE_REMOTE_IMAGES}
2466
2471
  imported.scale(${scale});
2467
2472
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2468
2473
  app.historyManager.saveState();
2474
+ const _b = imported.bounds;
2475
+ const _tiny = _b && _b.width < 16 && _b.height < 16;
2469
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.' } : {}),
2470
2479
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2471
2480
  })();
2472
2481
  `.trim();
2473
2482
  }
2474
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;
2475
2489
  // Escape the SVG string for embedding in code
2476
2490
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
2477
2491
  return `
@@ -2490,7 +2504,14 @@ imported.position = new paper.Point(${position.x}, ${position.y});
2490
2504
  imported.scale(${scale});
2491
2505
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2492
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;
2493
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.' } : {}),
2494
2515
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2495
2516
  })();
2496
2517
  `.trim();
@@ -3976,7 +3997,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3976
3997
  }
3977
3998
  generateAgentExport(input) {
3978
3999
  const validated = AgentExportInputSchema.parse(input);
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;
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;
3980
4001
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
3981
4002
  const videoEncodeOpts = {};
3982
4003
  if (broadcast)
@@ -4007,13 +4028,22 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4007
4028
  }
4008
4029
  const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
4009
4030
  const qualityLevel = quality || 'standard';
4010
- const videoDuration = duration ?? 5;
4031
+ let videoDuration = duration ?? 5;
4011
4032
  // Quality settings (bitrate in bps for VideoEncoder)
4012
4033
  const qualitySettings = {
4013
4034
  draft: { compression: 0.6, fps: 15, dpi: 72, bitrate: 2_000_000 },
4014
4035
  standard: { compression: 0.85, fps: 30, dpi: 150, bitrate: 5_000_000 },
4015
4036
  high: { compression: 0.95, fps: 60, dpi: 300, bitrate: 8_000_000 },
4016
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
+ }
4017
4047
  // Platform presets
4018
4048
  const platformPresets = {
4019
4049
  'instagram': { width: 1080, height: 1080, staticFormat: 'png', animatedFormat: 'mp4' },
@@ -4494,6 +4524,9 @@ ${stillTime !== undefined ? `
4494
4524
  // a neighbouring card's overflowing headline. Older studios ignore the
4495
4525
  // fourth argument, and leave lastRegionExcluded unset.
4496
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"});
4497
4530
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
4498
4531
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
4499
4532
  // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
@@ -4723,7 +4756,12 @@ ${stillTime !== undefined ? `
4723
4756
  + ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',
4724
4757
  };
4725
4758
  }
4726
- 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 };
4727
4765
  };
4728
4766
 
4729
4767
  // Camera framing requires going direct to videoExporter so width/height
@@ -5072,6 +5110,56 @@ ${stillTime !== undefined ? `
5072
5110
  // The applied value is the engine's own report where it gives one
5073
5111
  // (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
5074
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
+ }
5075
5163
  // AN ODD SIDE ROUNDED UP (FxTool e64a5260). H.264 needs even dimensions, so
5076
5164
  // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The
5077
5165
  // result's dimensions become what the file IS, and fidelity says why.
@@ -5125,7 +5213,10 @@ ${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
5125
5213
  const __vw = [];
5126
5214
  // The engine joins its warnings into one string (bitrate floor, then
5127
5215
  // luma), so each is raised from its own numbers, not from that text.
5128
- 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) {
5129
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.' });
5130
5221
  }
5131
5222
  if (!__vr || typeof __vr !== 'object') {
@@ -6905,7 +6996,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6905
6996
  `.trim();
6906
6997
  }
6907
6998
  generateImportImage(input) {
6908
- const { url, position, maxWidth, maxHeight, mask, smoothing } = input;
6999
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize } = input;
6909
7000
  // Each fragment used to be written with a LEADING comma, as if it followed
6910
7001
  // an entry that no longer exists — so the first one opened the object with
6911
7002
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -6916,6 +7007,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6916
7007
  position ? `position: { x: ${position.x}, y: ${position.y} }` : '',
6917
7008
  maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
6918
7009
  maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
7010
+ nativeSize ? 'nativeSize: true' : '',
6919
7011
  ].filter(Boolean);
6920
7012
  const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
6921
7013
  return `
@@ -6971,7 +7063,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6971
7063
  fr.readAsDataURL(blob);
6972
7064
  });
6973
7065
  }
6974
- const entry = await app.imageTools.uploadFromURL(src);
7066
+ const entry = await app.imageTools.uploadFromURL(src${maxEdge !== undefined ? `, { maxEdge: ${maxEdge === 'native' ? 'Infinity' : maxEdge} }` : ''});
6975
7067
  const opts = ${optsLiteral};
6976
7068
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
6977
7069
  ${smoothing ? ` // Pixel art wants 'off': Paper's Raster draws with smoothing 'low' by
@@ -7010,7 +7102,13 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7010
7102
  success: true,
7011
7103
  itemId: itemId,
7012
7104
  message: 'Image imported and placed on canvas.',
7013
- 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
+ : {})
7014
7112
  };
7015
7113
  } catch (e) {
7016
7114
  return { error: 'Failed to import image: ' + e.message };
@@ -9525,24 +9623,55 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9525
9623
  const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
9526
9624
  return `
9527
9625
  // Accessibility measurements (${checks.join(', ')})
9528
- (function() {
9626
+ (async function() {
9529
9627
  const out = { success: true, fps: ${fps}, duration: ${duration} };
9530
9628
  const rgba = function(c) {
9531
9629
  if (!c || c.gradient || typeof c.red !== 'number') return null;
9532
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 };
9533
9631
  };
9534
9632
  out.page = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
9535
- ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
9536
- const texts = all.filter(function(e) { return e && e.item && e.item.className === 'PointText' && e.item.visible !== false && String(e.item.content || '').trim() !== ''; });
9537
- 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; });
9538
- out.texts = texts.map(function(e) {
9539
- const it = e.item, c = it.bounds.center;
9540
- const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
9541
- const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
9542
- const w = String(it.fontWeight || '');
9543
- return { id: e.itemId, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
9544
- 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); });
9545
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
+ }
9546
9675
  ` : ''}${checks.includes('flash') ? ` if (typeof app.captureFramesAt !== 'function') {
9547
9676
  out.flashUnavailable = 'this studio has no deterministic frame capture (app.captureFramesAt), so flashing was not checked.';
9548
9677
  } else {
@@ -9552,7 +9681,9 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9552
9681
  const ctx = small.getContext('2d', { willReadFrequently: true });
9553
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); });
9554
9683
  const times = Array.from({ length: ${n} }, function(_, i) { return i / ${fps}; });
9555
- 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) {
9556
9687
  // Transparent canvas shows the page's CSS background, so composite on it.
9557
9688
  ctx.fillStyle = out.page || '#ffffff';
9558
9689
  ctx.fillRect(0, 0, W, H);
@@ -9578,7 +9709,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9578
9709
  const includeDataUrls = !!input.includeDataUrls;
9579
9710
  return `
9580
9711
  // Deterministic frame capture
9581
- (function() {
9712
+ (async function() {
9582
9713
  if (typeof app.captureFramesAt !== 'function') {
9583
9714
  return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
9584
9715
  }
@@ -9595,7 +9726,24 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9595
9726
  // plain 9-text scene (round 7 X, #7) on studios without hashFrame. A native
9596
9727
  // reduce is not a generated loop and is not counted; same djb2-xor.
9597
9728
  : function(s) { return Array.prototype.reduce.call(s, function(h, c) { return (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0; }, 5381).toString(16); };
9598
- 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}, {
9599
9747
  seed: ${seed},
9600
9748
  capture: function(c, t, i) {
9601
9749
  // Prefer app.captureFrameDataURL(): it renders only the used region, so an
@@ -9610,7 +9758,11 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9610
9758
  }
9611
9759
  });
9612
9760
  const uniqueHashes = new Set(frames.map(function(f) { return f.hash; })).size;
9613
- 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 };
9614
9766
  })();`.trim();
9615
9767
  }
9616
9768
  /**
@@ -9758,6 +9910,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9758
9910
  const opts = JSON.stringify({
9759
9911
  ...(input.position ? { position: input.position } : {}),
9760
9912
  ...(input.scale !== undefined ? { scale: input.scale } : {}),
9913
+ ...(input.fit !== undefined ? { fit: input.fit } : {}),
9761
9914
  ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
9762
9915
  ...(input.clipInPoint !== undefined ? { clipInPoint: input.clipInPoint } : {}),
9763
9916
  ...(input.clipOutPoint !== undefined ? { clipOutPoint: input.clipOutPoint } : {}),
@@ -11239,7 +11392,10 @@ ${guard(fn)}
11239
11392
  }
11240
11393
  case 'render_soundtrack': {
11241
11394
  const opts = {
11242
- ...(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 } : {}),
11243
11399
  ...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}),
11244
11400
  ...(input.bitDepth !== undefined ? { bitDepth: input.bitDepth } : {}),
11245
11401
  };
@@ -12134,4 +12290,98 @@ return {
12134
12290
  fonts: fonts,
12135
12291
  };`;
12136
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
+ }
12137
12387
  //# sourceMappingURL=code-generator.js.map