@pinepaper.studio/mcp-server 1.6.16 → 1.6.18

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 (90) 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 +142 -4
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/browser/puppeteer-controller.d.ts +61 -1
  11. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  12. package/dist/browser/puppeteer-controller.js +168 -6
  13. package/dist/browser/puppeteer-controller.js.map +1 -1
  14. package/dist/build-info.json +2 -2
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +75 -10
  18. package/dist/index.js.map +1 -1
  19. package/dist/ontology/ontology-validator.d.ts.map +1 -1
  20. package/dist/ontology/ontology-validator.js +9 -5
  21. package/dist/ontology/ontology-validator.js.map +1 -1
  22. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  23. package/dist/ontology/upstream-vocabulary.js +2 -2
  24. package/dist/tools/definitions.d.ts.map +1 -1
  25. package/dist/tools/definitions.js +198 -47
  26. package/dist/tools/definitions.js.map +1 -1
  27. package/dist/tools/engine-lists.d.ts +20 -0
  28. package/dist/tools/engine-lists.d.ts.map +1 -0
  29. package/dist/tools/engine-lists.js +86 -0
  30. package/dist/tools/engine-lists.js.map +1 -0
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +8 -0
  33. package/dist/tools/engine-methods.js.map +1 -1
  34. package/dist/tools/engine-surface.d.ts +1 -1
  35. package/dist/tools/engine-surface.d.ts.map +1 -1
  36. package/dist/tools/engine-surface.js +20 -4
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/beats.d.ts +64 -0
  39. package/dist/tools/handlers/beats.d.ts.map +1 -0
  40. package/dist/tools/handlers/beats.js +83 -0
  41. package/dist/tools/handlers/beats.js.map +1 -0
  42. package/dist/tools/handlers/brand-intake.d.ts +95 -0
  43. package/dist/tools/handlers/brand-intake.d.ts.map +1 -0
  44. package/dist/tools/handlers/brand-intake.js +242 -0
  45. package/dist/tools/handlers/brand-intake.js.map +1 -0
  46. package/dist/tools/handlers.d.ts +23 -11
  47. package/dist/tools/handlers.d.ts.map +1 -1
  48. package/dist/tools/handlers.js +265 -14
  49. package/dist/tools/handlers.js.map +1 -1
  50. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  51. package/dist/tools/minimal-descriptions.js +5 -3
  52. package/dist/tools/minimal-descriptions.js.map +1 -1
  53. package/dist/tools/shape-params.d.ts +2 -2
  54. package/dist/tools/shape-params.js +2 -2
  55. package/dist/tools/stick-vocabulary.d.ts +1 -1
  56. package/dist/tools/stick-vocabulary.js +1 -1
  57. package/dist/tools/toolkits.d.ts.map +1 -1
  58. package/dist/tools/toolkits.js +2 -0
  59. package/dist/tools/toolkits.js.map +1 -1
  60. package/dist/types/code-generator.d.ts +63 -2
  61. package/dist/types/code-generator.d.ts.map +1 -1
  62. package/dist/types/code-generator.js +533 -49
  63. package/dist/types/code-generator.js.map +1 -1
  64. package/dist/types/schemas.d.ts +2199 -640
  65. package/dist/types/schemas.d.ts.map +1 -1
  66. package/dist/types/schemas.js +186 -50
  67. package/dist/types/schemas.js.map +1 -1
  68. package/dist/utils/a11y.d.ts +2 -0
  69. package/dist/utils/a11y.d.ts.map +1 -1
  70. package/dist/utils/a11y.js +1 -1
  71. package/dist/utils/a11y.js.map +1 -1
  72. package/dist/utils/beats.d.ts +58 -0
  73. package/dist/utils/beats.d.ts.map +1 -0
  74. package/dist/utils/beats.js +110 -0
  75. package/dist/utils/beats.js.map +1 -0
  76. package/dist/utils/mcp-log.d.ts +16 -0
  77. package/dist/utils/mcp-log.d.ts.map +1 -0
  78. package/dist/utils/mcp-log.js +10 -0
  79. package/dist/utils/mcp-log.js.map +1 -0
  80. package/dist/utils/progress.d.ts +28 -0
  81. package/dist/utils/progress.d.ts.map +1 -0
  82. package/dist/utils/progress.js +24 -0
  83. package/dist/utils/progress.js.map +1 -0
  84. package/dist/utils/svg-size.d.ts +22 -0
  85. package/dist/utils/svg-size.d.ts.map +1 -0
  86. package/dist/utils/svg-size.js +43 -0
  87. package/dist/utils/svg-size.js.map +1 -0
  88. package/dist/vendor/design/design-style.js +1 -1
  89. package/manifest.json +9 -1
  90. package/package.json +7 -4
@@ -4,6 +4,8 @@
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';
8
+ import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
7
9
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
8
10
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
9
11
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
@@ -23,7 +25,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
23
25
  // Custom relation/code types
24
26
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
25
27
  // Mask types
26
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, } from './schemas.js';
28
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, } from './schemas.js';
27
29
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
28
30
  // =============================================================================
29
31
  // HELPER FUNCTIONS
@@ -1697,7 +1699,8 @@ function generateSetCanvasSizeCode(width, height, preset) {
1697
1699
  // echoing the request back.
1698
1700
  return { success: true, width: (r && r.width) || ${width}, height: (r && r.height) || ${height},
1699
1701
  ...(r && r.preset ? { preset: r.preset } : {}),
1700
- ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {}) };
1702
+ ...(r && r.unbounded !== undefined ? { unbounded: r.unbounded } : {})${!preset && (width > 8192 || height > 8192) ? `,
1703
+ 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
1704
  })();
1702
1705
  `.trim();
1703
1706
  }
@@ -2211,6 +2214,39 @@ function world3dColor(value) {
2211
2214
  return value;
2212
2215
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
2213
2216
  }
2217
+ /**
2218
+ * The world spec's colour fields: GENERATED from FxTool WORLD_SCHEMA's kind
2219
+ * 'color' entries (scripts/sync-engine-lists.mjs). None of them is named
2220
+ * `color`, so world3dColors never touched them and a hex `env.zenith` reached
2221
+ * the engine as a string, where the validator refused it.
2222
+ */
2223
+ export { WORLD3D_COLOR_PATHS };
2224
+ /** A hex colour to [r, g, b] 0..1; anything else unchanged (arrays too: light colours may exceed 1). */
2225
+ export function world3dHex(value) {
2226
+ return typeof value === 'string' ? world3dColor(value) : value;
2227
+ }
2228
+ /** Convert hex strings at the world spec's colour paths (configure / create). */
2229
+ export function world3dSpecColors(spec) {
2230
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec))
2231
+ return spec;
2232
+ const out = JSON.parse(JSON.stringify(spec));
2233
+ for (const path of WORLD3D_COLOR_PATHS) {
2234
+ const [group, key] = path.split('.');
2235
+ const g = out[group];
2236
+ if (g && typeof g === 'object' && key in g)
2237
+ g[key] = world3dHex(g[key]);
2238
+ }
2239
+ return out;
2240
+ }
2241
+ /** A light or material spec with hex colours (color, emissive, sheenColor) converted; an array is left exactly as given. */
2242
+ function world3dHexColor(spec) {
2243
+ const out = { ...(spec ?? {}) };
2244
+ for (const key of ['color', 'emissive', 'sheenColor']) {
2245
+ if (typeof out[key] === 'string')
2246
+ out[key] = world3dColor(out[key]);
2247
+ }
2248
+ return out;
2249
+ }
2214
2250
  /** Recursively convert every `color` field in a world3d payload. */
2215
2251
  function world3dColors(value) {
2216
2252
  if (Array.isArray(value))
@@ -2223,6 +2259,60 @@ function world3dColors(value) {
2223
2259
  }
2224
2260
  return out;
2225
2261
  }
2262
+ /** At most this many tiles in one contact sheet: past it, the tiles are too small to judge. */
2263
+ export const SHEET_MAX_TILES = 48;
2264
+ /**
2265
+ * The times a contact sheet captures, and a label for each. Pure, so the
2266
+ * modes are testable without a browser. Explicit `times` win; otherwise
2267
+ * every / strip / loopSeam. Rounded to the millisecond, clamped at 0.
2268
+ */
2269
+ export function sheetTimes(times, sheet) {
2270
+ const r = (t) => Math.max(0, Math.round(t * 1000) / 1000);
2271
+ const fmt = (t) => `${t.toFixed(2)}s`;
2272
+ let out = [];
2273
+ let labels = null;
2274
+ if (times && times.length) {
2275
+ out = times.map(r);
2276
+ }
2277
+ else if (sheet.strip) {
2278
+ const { at, count, step } = sheet.strip;
2279
+ const first = at - Math.floor(count / 2) * step;
2280
+ out = Array.from({ length: count }, (_, i) => r(first + i * step));
2281
+ }
2282
+ else if (sheet.loopSeam && sheet.duration) {
2283
+ // The frame BEFORE the loop wraps, then the first: a jump between them is the stutter.
2284
+ out = [r(sheet.duration - 1 / 30), 0];
2285
+ labels = [`end ${fmt(out[0])}`, 'start 0.00s'];
2286
+ }
2287
+ else if (sheet.every && sheet.duration) {
2288
+ for (let t = 0; t < sheet.duration - 1e-9 && out.length < SHEET_MAX_TILES; t += sheet.every)
2289
+ out.push(r(t));
2290
+ }
2291
+ out = out.slice(0, SHEET_MAX_TILES);
2292
+ return { times: out, labels: labels ?? out.map(fmt) };
2293
+ }
2294
+ /**
2295
+ * The critique an agent applies to a contact sheet. One scale, the same every
2296
+ * time, so two passes over one film can be compared.
2297
+ */
2298
+ export const SHEET_RUBRIC = {
2299
+ scale: 'Score each 1-10. Under 7 on any line is a fix before export.',
2300
+ criteria: [
2301
+ 'hook: does something worth watching happen in the first 2 s?',
2302
+ 'phone readability: at 360 px wide, can every word be read?',
2303
+ 'motion quality: do moves ease in and out, with nothing jumping or sliding at constant speed?',
2304
+ 'variety: does something change every 2-4 s, with no stretch that looks the same?',
2305
+ 'composition: is there a clear subject, with nothing crammed against an edge?',
2306
+ 'brand accuracy: are the colours, fonts and logo the brand\'s own?',
2307
+ 'sound sync: do cuts and hits land on the beat (check against pinepaper_beat_cuts)?',
2308
+ ],
2309
+ checklist: [
2310
+ 'text overlapping text or a subject',
2311
+ 'dead beats: adjacent tiles that are the same picture',
2312
+ 'labels or logos cut off in a corner',
2313
+ 'a jump between the last and first frame of a loop (loopSeam)',
2314
+ ],
2315
+ };
2226
2316
  export class PinePaperCodeGenerator {
2227
2317
  /**
2228
2318
  * Generate code for creating an item
@@ -2250,6 +2340,9 @@ export class PinePaperCodeGenerator {
2250
2340
  */
2251
2341
  generateModifyItem(input) {
2252
2342
  const validated = ModifyItemInputSchema.parse(input);
2343
+ if (validated.atTime !== undefined) {
2344
+ return generateAutoKeyCode(validated.itemId, validated.properties, validated.atTime, validated.easing);
2345
+ }
2253
2346
  return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
2254
2347
  }
2255
2348
  /**
@@ -2466,12 +2559,21 @@ ${INLINE_REMOTE_IMAGES}
2466
2559
  imported.scale(${scale});
2467
2560
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2468
2561
  app.historyManager.saveState();
2562
+ const _b = imported.bounds;
2563
+ const _tiny = _b && _b.width < 16 && _b.height < 16;
2469
2564
  return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
2565
+ size: _b ? { width: Math.round(_b.width * 10) / 10, height: Math.round(_b.height * 10) / 10 } : null,
2566
+ ...(_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
2567
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2471
2568
  })();
2472
2569
  `.trim();
2473
2570
  }
2474
2571
  if (svgString) {
2572
+ // A root sized in em / % (icon sets) renders ~5 px: sized from the
2573
+ // viewBox instead, and the result says so (see utils/svg-size.ts).
2574
+ const sized = normalizeSvgSize(svgString);
2575
+ svgString = sized.svg;
2576
+ const sizeFix = sized.fix;
2475
2577
  // Escape the SVG string for embedding in code
2476
2578
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
2477
2579
  return `
@@ -2490,7 +2592,14 @@ imported.position = new paper.Point(${position.x}, ${position.y});
2490
2592
  imported.scale(${scale});
2491
2593
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
2492
2594
  app.historyManager.saveState();
2595
+ // A SPECK IS NOT A SUCCESS WORTH REPORTING QUIETLY: under 16 px both ways,
2596
+ // the import is said to be tiny, with the fix.
2597
+ const _b = imported.bounds;
2598
+ const _tiny = _b && _b.width < 16 && _b.height < 16;
2493
2599
  return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
2600
+ size: _b ? { width: Math.round(_b.width * 10) / 10, height: Math.round(_b.height * 10) / 10 } : null,
2601
+ ${sizeFix ? `sizeFix: ${JSON.stringify(sizeFix)},` : ''}
2602
+ ...(_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
2603
  ...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
2495
2604
  })();
2496
2605
  `.trim();
@@ -3976,7 +4085,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3976
4085
  }
3977
4086
  generateAgentExport(input) {
3978
4087
  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;
4088
+ 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, motionBlur, ad: adOpts, frames } = validated;
3980
4089
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
3981
4090
  const videoEncodeOpts = {};
3982
4091
  if (broadcast)
@@ -3998,6 +4107,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3998
4107
  // the only bytes that varied, and this pins them.
3999
4108
  if (deterministic)
4000
4109
  videoEncodeOpts.deterministic = true;
4110
+ // Motion blur (FxTool #55): the engine forwards it on every mp4/webm path
4111
+ // (quickExport, the agent paths, the export store, videoExporter.export).
4112
+ if (motionBlur)
4113
+ videoEncodeOpts.motionBlur = motionBlur;
4001
4114
  // A transparent WebM (FxTool 1833b397) rides the store route too.
4002
4115
  const webmAlpha = format === 'webm' && transparent === true;
4003
4116
  if (webmAlpha) {
@@ -4007,13 +4120,22 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4007
4120
  }
4008
4121
  const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
4009
4122
  const qualityLevel = quality || 'standard';
4010
- const videoDuration = duration ?? 5;
4123
+ let videoDuration = duration ?? 5;
4011
4124
  // Quality settings (bitrate in bps for VideoEncoder)
4012
4125
  const qualitySettings = {
4013
4126
  draft: { compression: 0.6, fps: 15, dpi: 72, bitrate: 2_000_000 },
4014
4127
  standard: { compression: 0.85, fps: 30, dpi: 150, bitrate: 5_000_000 },
4015
4128
  high: { compression: 0.95, fps: 60, dpi: 300, bitrate: 8_000_000 },
4016
4129
  }[qualityLevel];
4130
+ // FRAMES, EXACTLY (showcase film, 11.1). The engine makes ceil(duration x
4131
+ // fps) frames for mp4 / webm / gif and floor(...) for apng, so 7.91667 s
4132
+ // at 24 fps was 191 frames, not 190 — and N / fps itself can land a hair
4133
+ // above N in floating point (190 / 24 * 24 > 190). The duration is nudged
4134
+ // a microsecond toward the side that rounds to N.
4135
+ if (frames !== undefined) {
4136
+ const fpsEff = fps ?? qualitySettings.fps;
4137
+ videoDuration = frames / fpsEff + (format === 'apng' ? 1e-6 : -1e-6);
4138
+ }
4017
4139
  // Platform presets
4018
4140
  const platformPresets = {
4019
4141
  'instagram': { width: 1080, height: 1080, staticFormat: 'png', animatedFormat: 'mp4' },
@@ -4278,7 +4400,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4278
4400
  const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
4279
4401
  const hasColor = !!bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg);
4280
4402
  const hasBgItems = !!(app.patternGroup && app.patternGroup.children && app.patternGroup.children.length);
4281
- if (hasColor || hasBgItems) return [];
4403
+ // A 3D world under the canvas IS the background: since FxTool #50
4404
+ // (747315e3) jpg/webp stills composite it, so nothing turns white/black.
4405
+ const hasWorld = !!app._world3d;
4406
+ if (hasColor || hasBgItems || hasWorld) return [];
4282
4407
  return [{ code: 'alpha_dropped', message: fmt === 'apng'
4283
4408
  ? 'apng with transparent: false and no background colour: the frames are filled BLACK. Set a background colour, or drop transparent: false to keep the alpha.'
4284
4409
  : fmt === 'jpg'
@@ -4335,7 +4460,9 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4335
4460
  fps: settings.fps,
4336
4461
  quality,
4337
4462
  width: dimensions.width,
4338
- height: dimensions.height,
4463
+ height: dimensions.height,${motionBlur ? `
4464
+ // The estimate reports renders (frames x subframes) and renderCost.
4465
+ motionBlur: ${JSON.stringify(motionBlur)},` : ''}
4339
4466
  });
4340
4467
  return {
4341
4468
  success: true,
@@ -4494,6 +4621,9 @@ ${stillTime !== undefined ? `
4494
4621
  // a neighbouring card's overflowing headline. Older studios ignore the
4495
4622
  // fourth argument, and leave lastRegionExcluded unset.
4496
4623
  if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
4624
+ // A video in the region is waited for at this time (round 12, 12.5):
4625
+ // the render drew before the clip's seek landed.
4626
+ if (typeof app.awaitMediaAt === 'function') await app.awaitMediaAt(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"});
4497
4627
  const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
4498
4628
  const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
4499
4629
  // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
@@ -4723,7 +4853,12 @@ ${stillTime !== undefined ? `
4723
4853
  + ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',
4724
4854
  };
4725
4855
  }
4726
- return { success: true, platform, format, framing, data: await blobToDataUrl(blob), mimeType: videoMimeType, size: blob.size };
4856
+ // The container the file IS: an encoder that cannot do H.264 at this
4857
+ // size writes VP9 WebM (round 12: mp4 at 12000 px came back WebM),
4858
+ // and naming it .mp4 breaks players. Said below in fidelity.
4859
+ const __mime = (blob.type && String(blob.type).split(';')[0]) || videoMimeType;
4860
+ const __fmt = __mime === 'video/webm' ? 'webm' : __mime === 'video/mp4' ? 'mp4' : format;
4861
+ return { success: true, platform, format: __fmt, framing, data: await blobToDataUrl(blob), mimeType: __mime, size: blob.size };
4727
4862
  };
4728
4863
 
4729
4864
  // Camera framing requires going direct to videoExporter so width/height
@@ -5072,6 +5207,56 @@ ${stillTime !== undefined ? `
5072
5207
  // The applied value is the engine's own report where it gives one
5073
5208
  // (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
5074
5209
  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.' };` : ''}
5210
+ // FROZEN OUTPUT (round 12, 12.3): constant bitrate at 3552x1080 answered
5211
+ // success with 600 identical frames. The file cannot be decoded here on the
5212
+ // store route, so this is a size check: an animated scene whose video
5213
+ // carries under 0.002 bits per pixel per frame is almost certainly one
5214
+ // frame repeated. Labelled as a suspicion, with how to confirm it.
5215
+ if (result && result.success && (result.format === 'mp4' || result.format === 'webm') && result.fidelity && result.fidelity.checked && result.fidelity.checked.animated && typeof result.size === 'number') {
5216
+ const __d = result.dimensions || dimensions;
5217
+ const __frames = Math.max(1, Math.round(${videoDuration} * (settings.fps || 30)));
5218
+ const __bpp = (result.size * 8) / Math.max(1, __d.width * __d.height * __frames);
5219
+ // The studio's own count of distinct frames it fed the encoder, where it
5220
+ // reports one, decides; the size is only the fallback (a small dot moving on
5221
+ // a flat field is also a tiny file).
5222
+ const __vf = __vr && __vr.frames;
5223
+ const __distinct = __vf && typeof __vf.distinct === 'number' ? __vf.distinct : null;
5224
+ // The studio's decoded-output test (output_frozen) is the real answer for
5225
+ // an encoder freeze; the size guess stays out when it has spoken.
5226
+ const __engineFrozen = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'output_frozen'; }));
5227
+ if (!__engineFrozen && (__distinct !== null ? __distinct <= 1 : __bpp < 0.002)) {
5228
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'possibly_frozen',
5229
+ message: __distinct !== null
5230
+ ? '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.'
5231
+ : '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.' }]);
5232
+ if (result.fidelity.note) delete result.fidelity.note;
5233
+ }
5234
+ }
5235
+ // THE STUDIO'S OWN CODED VIDEO WARNINGS (e.g. an output-side freeze the
5236
+ // input-frame count cannot see): passed through when they carry a code this
5237
+ // tool does not already raise from the numbers. Plain-string warnings are
5238
+ // the bitrate / luma texts, raised above from their numbers instead.
5239
+ if (result && result.success && __vr && Array.isArray(__vr.warnings)) {
5240
+ // format_substituted is NOT here: the studio's own (agreed with FxTool)
5241
+ // is the better-informed one, so it passes and this tool's is skipped.
5242
+ const __mine = ['bitrate_below_floor', 'luma_out_of_range', 'determinism_not_pinned', 'dimensions_rounded', 'possibly_frozen', 'alpha_not_applied', 'alpha_keyframe_mismatch'];
5243
+ const __extra = __vr.warnings.filter(function(w) { return w && typeof w === 'object' && typeof w.code === 'string' && __mine.indexOf(w.code) < 0; })
5244
+ .map(function(w) { return { code: w.code, message: String(w.message || w.code) }; });
5245
+ if (__extra.length) {
5246
+ result.fidelity = result.fidelity || { warnings: [] };
5247
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__extra);
5248
+ if (result.fidelity.note) delete result.fidelity.note;
5249
+ }
5250
+ }
5251
+ // A DIFFERENT CONTAINER THAN ASKED (round 12, 12.7): the file is named by
5252
+ // what it is, and the substitution is said, not left to a player to find.
5253
+ const __studioSaidFormat = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'format_substituted'; }));
5254
+ if (!__studioSaidFormat && result && result.success && (result.format === 'mp4' || result.format === 'webm') && result.format !== format && (format === 'mp4' || format === 'webm')) {
5255
+ result.fidelity = result.fidelity || { warnings: [] };
5256
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'format_substituted',
5257
+ 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 + '.' }]);
5258
+ if (result.fidelity.note) delete result.fidelity.note;
5259
+ }
5075
5260
  // AN ODD SIDE ROUNDED UP (FxTool e64a5260). H.264 needs even dimensions, so
5076
5261
  // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The
5077
5262
  // result's dimensions become what the file IS, and fidelity says why.
@@ -5125,7 +5310,10 @@ ${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
5125
5310
  const __vw = [];
5126
5311
  // The engine joins its warnings into one string (bitrate floor, then
5127
5312
  // 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) {
5313
+ // The studio's own measured bitrate_not_honoured, where it sends one,
5314
+ // replaces this estimate.
5315
+ const __engineBitrate = !!(__vr && Array.isArray(__vr.warnings) && __vr.warnings.some(function(w) { return w && w.code === 'bitrate_not_honoured'; }));
5316
+ if (!__engineBitrate && __vr && typeof __vr.achievedBitrate === 'number' && typeof __vr.bitrateFloor === 'number' && __vr.achievedBitrate < __vr.bitrateFloor * 0.95) {
5129
5317
  __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
5318
  }
5131
5319
  if (!__vr || typeof __vr !== 'object') {
@@ -5139,6 +5327,17 @@ ${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
5139
5327
  if (result.fidelity.note) delete result.fidelity.note;
5140
5328
  }
5141
5329
  }
5330
+ ` : ''}${motionBlur ? ` // What the exporter actually did with motionBlur (FxTool #55 records it as
5331
+ // lastMotionBlur, with renders = frames x subframes). A studio that predates
5332
+ // it ignores the option silently, so that is said by name.
5333
+ if (result && result.success) {
5334
+ const __mb = __vx && __vx.lastMotionBlur;
5335
+ if (__mb) result.motionBlur = __mb;
5336
+ else {
5337
+ result.fidelity = result.fidelity || { warnings: [] };
5338
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'motion_blur_not_applied', message: 'motionBlur was asked for, but this studio did not report applying it: it predates motion blur (FxTool #55), so the video has none.' }]);
5339
+ }
5340
+ }
5142
5341
  ` : ''} const __ar = __vx && __vx.lastAudioReport;
5143
5342
  if (result && result.success && __ar && typeof __ar === 'object') {
5144
5343
  result.audio = __ar;
@@ -6770,19 +6969,25 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6770
6969
  `.trim();
6771
6970
  }
6772
6971
  generateGetAvailableEasings() {
6972
+ // The engine's own list wins: listAnimatableProperties().easings is its
6973
+ // EASING_NAMES, read from the easing table itself. The static list is the
6974
+ // fallback for studios that predate it (fixture-pinned, easing-parity test).
6773
6975
  return `
6774
6976
  // Get available easing functions
6775
6977
  (function() {
6978
+ const fallback = ${JSON.stringify(KEYFRAME_EASINGS)};
6979
+ const descriptions = ${JSON.stringify(EASING_DESCRIPTIONS)};
6980
+ let live = null;
6981
+ try {
6982
+ const r = typeof app !== 'undefined' && app && typeof app.listAnimatableProperties === 'function'
6983
+ ? app.listAnimatableProperties() : null;
6984
+ if (r && Array.isArray(r.easings) && r.easings.length) live = r.easings;
6985
+ } catch (e) { live = null; }
6776
6986
  return {
6777
- easings: ['linear', 'easeIn', 'easeOut', 'easeInOut', 'bounce', 'elastic'],
6778
- descriptions: {
6779
- linear: 'Constant speed',
6780
- easeIn: 'Slow start, fast end',
6781
- easeOut: 'Fast start, slow end',
6782
- easeInOut: 'Slow start and end',
6783
- bounce: 'Bouncing effect at end',
6784
- elastic: 'Spring-like overshoot'
6785
- }
6987
+ easings: live || fallback,
6988
+ source: live ? 'engine' : 'mcp-server fallback list',
6989
+ descriptions,
6990
+ exportNote: 'Every easing renders exactly in video, GIF and APNG. Lottie and CSS get the closest single Bezier: springPlayful loses its wobble, and the in-out quart/quint/expo/circ curves are within 2-4%. SMIL is exact for springs and those four.'
6786
6991
  };
6787
6992
  })();
6788
6993
  `.trim();
@@ -6905,7 +7110,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6905
7110
  `.trim();
6906
7111
  }
6907
7112
  generateImportImage(input) {
6908
- const { url, position, maxWidth, maxHeight, mask, smoothing } = input;
7113
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize } = input;
6909
7114
  // Each fragment used to be written with a LEADING comma, as if it followed
6910
7115
  // an entry that no longer exists — so the first one opened the object with
6911
7116
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -6916,6 +7121,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6916
7121
  position ? `position: { x: ${position.x}, y: ${position.y} }` : '',
6917
7122
  maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
6918
7123
  maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
7124
+ nativeSize ? 'nativeSize: true' : '',
6919
7125
  ].filter(Boolean);
6920
7126
  const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
6921
7127
  return `
@@ -6971,7 +7177,7 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6971
7177
  fr.readAsDataURL(blob);
6972
7178
  });
6973
7179
  }
6974
- const entry = await app.imageTools.uploadFromURL(src);
7180
+ const entry = await app.imageTools.uploadFromURL(src${maxEdge !== undefined ? `, { maxEdge: ${maxEdge === 'native' ? 'Infinity' : maxEdge} }` : ''});
6975
7181
  const opts = ${optsLiteral};
6976
7182
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
6977
7183
  ${smoothing ? ` // Pixel art wants 'off': Paper's Raster draws with smoothing 'low' by
@@ -7010,7 +7216,13 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7010
7216
  success: true,
7011
7217
  itemId: itemId,
7012
7218
  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}'` : ''}
7219
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''},
7220
+ // Stored smaller than its source (the studio's cap): said, with the
7221
+ // way to keep every pixel. The studio reports native size where it can.
7222
+ ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)
7223
+ ? { stored: { width: entry.width, height: entry.height }, native: { width: entry.nativeWidth, height: entry.nativeHeight },
7224
+ 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.' }
7225
+ : {})
7014
7226
  };
7015
7227
  } catch (e) {
7016
7228
  return { error: 'Failed to import image: ' + e.message };
@@ -9525,24 +9737,55 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9525
9737
  const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
9526
9738
  return `
9527
9739
  // Accessibility measurements (${checks.join(', ')})
9528
- (function() {
9740
+ (async function() {
9529
9741
  const out = { success: true, fps: ${fps}, duration: ${duration} };
9530
9742
  const rgba = function(c) {
9531
9743
  if (!c || c.gradient || typeof c.red !== 'number') return null;
9532
9744
  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
9745
  };
9534
9746
  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 };
9747
+ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text is judged where it is on screen.
9748
+ // Reading only the current playhead found no visible text in a scene of
9749
+ // timed captions and passed with nothing checked. The times: 0, inside each
9750
+ // text's lifetime, every keyframe time, and every second of the duration —
9751
+ // the playhead is moved, lifetimes applied, and put back.
9752
+ const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
9753
+ const snap = function(t) {
9754
+ 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() !== ''; });
9755
+ 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; });
9756
+ return texts.map(function(e) {
9757
+ const it = e.item, c = it.bounds.center;
9758
+ const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
9759
+ const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
9760
+ const w = String(it.fontWeight || '');
9761
+ return { id: e.itemId, t: t, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
9762
+ bold: w === 'bold' || Number(w) >= 700, behind: top ? rgba(top.item.fillColor) : null, behindId: top ? top.itemId : undefined };
9763
+ });
9764
+ };
9765
+ const times = [0];
9766
+ all.forEach(function(e) {
9767
+ const d = (e && e.item && e.item.data) || {};
9768
+ if (typeof d.bornAt === 'number') times.push(d.bornAt + Math.min(0.1, (typeof d.ttl === 'number' ? d.ttl : 1) / 2));
9769
+ if (Array.isArray(d.keyframes)) d.keyframes.forEach(function(k) { if (k && typeof k.time === 'number') times.push(k.time); });
9545
9770
  });
9771
+ Array.from({ length: Math.floor(${duration}) + 1 }, function(_, i) { times.push(i); });
9772
+ 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);
9773
+ const L = globalThis.__ppMcp;
9774
+ const at = function(t) {
9775
+ if (typeof app.setPlaybackTime === 'function') app.setPlaybackTime(t);
9776
+ if (L && L.lifetimeApp === app && typeof L.applyLifetimes === 'function') { try { L.applyLifetimes(t); } catch (_) { /* sampled as is */ } }
9777
+ if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
9778
+ };
9779
+ const prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
9780
+ out.texts = [];
9781
+ if (typeof app.setPlaybackTime === 'function') {
9782
+ uniq.forEach(function(t) { at(t); out.texts = out.texts.concat(snap(t)); });
9783
+ at(prevT);
9784
+ out.sampledTimes = uniq.length;
9785
+ } else {
9786
+ out.texts = snap(prevT);
9787
+ out.sampledTimes = 1;
9788
+ }
9546
9789
  ` : ''}${checks.includes('flash') ? ` if (typeof app.captureFramesAt !== 'function') {
9547
9790
  out.flashUnavailable = 'this studio has no deterministic frame capture (app.captureFramesAt), so flashing was not checked.';
9548
9791
  } else {
@@ -9552,7 +9795,9 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9552
9795
  const ctx = small.getContext('2d', { willReadFrequently: true });
9553
9796
  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
9797
  const times = Array.from({ length: ${n} }, function(_, i) { return i / ${fps}; });
9555
- out.series = app.captureFramesAt(times, { capture: function(c) {
9798
+ // Waits for video per frame where the studio can (12.6).
9799
+ const __cap = typeof app.captureFramesAtAsync === 'function' ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
9800
+ out.series = await __cap(times, { capture: function(c) {
9556
9801
  // Transparent canvas shows the page's CSS background, so composite on it.
9557
9802
  ctx.fillStyle = out.page || '#ffffff';
9558
9803
  ctx.fillRect(0, 0, W, H);
@@ -9573,12 +9818,12 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9573
9818
  })();`.trim();
9574
9819
  }
9575
9820
  generateCaptureFrames(input) {
9576
- const timesJson = JSON.stringify(input.times);
9821
+ const timesJson = JSON.stringify(input.times ?? []);
9577
9822
  const seed = input.seed !== undefined ? input.seed : 0;
9578
9823
  const includeDataUrls = !!input.includeDataUrls;
9579
9824
  return `
9580
9825
  // Deterministic frame capture
9581
- (function() {
9826
+ (async function() {
9582
9827
  if (typeof app.captureFramesAt !== 'function') {
9583
9828
  return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
9584
9829
  }
@@ -9595,7 +9840,24 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9595
9840
  // plain 9-text scene (round 7 X, #7) on studios without hashFrame. A native
9596
9841
  // reduce is not a generated loop and is not counted; same djb2-xor.
9597
9842
  : 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}, {
9843
+ // A VIDEO ON SCREEN IS WAITED FOR (round 12, 12.6): the sync capture drew
9844
+ // before a clip's seek landed, so a frame showed the previous time's
9845
+ // picture. captureFramesAtAsync waits per frame; older studios keep the
9846
+ // sync one (and say video-not-synced themselves).
9847
+ //
9848
+ // What is REPORTED is what happened, not what exists (retest of 009f00e: a
9849
+ // "videoSynced: true" capture right after an upload showed the video black).
9850
+ // videoWait says whether the studio waited per frame; firstSeek is the
9851
+ // studio's own answer for the first time (seeked / timedOut / skipped).
9852
+ // A seek that times out later is an engine warning (video-seek-timeout),
9853
+ // forwarded in governor.engineWarnings.
9854
+ const __waits = typeof app.captureFramesAtAsync === 'function';
9855
+ let __firstSeek = null;
9856
+ if (typeof app.awaitMediaAt === 'function' && ${timesJson}.length) {
9857
+ try { __firstSeek = await app.awaitMediaAt(${timesJson}[0]); } catch (e) { __firstSeek = { error: String(e && e.message || e) }; }
9858
+ }
9859
+ const __cap = __waits ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
9860
+ const frames = await __cap(${timesJson}, {
9599
9861
  seed: ${seed},
9600
9862
  capture: function(c, t, i) {
9601
9863
  // Prefer app.captureFrameDataURL(): it renders only the used region, so an
@@ -9610,7 +9872,74 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9610
9872
  }
9611
9873
  });
9612
9874
  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 };
9875
+ return { success: true, seed: ${seed}, frameCount: frames.length, uniqueHashes: uniqueHashes, allIdentical: uniqueHashes <= 1,
9876
+ videoWait: __waits ? 'awaited per frame' : 'not supported by this studio (frames may show a video before its seek lands)',
9877
+ ...(__firstSeek ? { firstSeek: __firstSeek } : {}),
9878
+ ...(__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.' } : {}),
9879
+ frames: frames };
9880
+ })();`.trim();
9881
+ }
9882
+ /**
9883
+ * A contact sheet (G3): capture at the planned times and tile them into one
9884
+ * image in the page. On a studio with sized capture (FxTool #42:
9885
+ * captureFramesAt(times, { width, format })) the engine renders each frame
9886
+ * small; on an older one the full frame is scaled while tiling.
9887
+ */
9888
+ generateCaptureSheet(input) {
9889
+ const sheet = input.sheet;
9890
+ const { times, labels } = sheetTimes(input.times, sheet);
9891
+ const n = times.length;
9892
+ const columns = sheet.columns ?? Math.min(6, Math.max(1, Math.ceil(Math.sqrt(n))));
9893
+ const mime = sheet.format === 'png' ? 'image/png' : 'image/jpeg';
9894
+ const S = (v) => JSON.stringify(v);
9895
+ return `
9896
+ // Contact sheet of ${n} frame(s)
9897
+ (async function() {
9898
+ if (typeof app.captureFramesAt !== 'function') {
9899
+ return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
9900
+ }
9901
+ const times = ${S(times)};
9902
+ const labels = ${S(labels)};
9903
+ const tileW = ${sheet.tileWidth};
9904
+ const columns = ${columns};
9905
+ const sized = typeof app._frameCapture === 'function';
9906
+ const cap = typeof app.captureFramesAtAsync === 'function' ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
9907
+ const urls = sized
9908
+ ? await cap(times, { seed: ${input.seed ?? 0}, width: tileW, format: 'png' })
9909
+ : await cap(times, { seed: ${input.seed ?? 0}, capture: function(c) {
9910
+ return typeof app.captureFrameDataURL === 'function' ? app.captureFrameDataURL() : (c && c.toDataURL ? c.toDataURL() : '');
9911
+ } });
9912
+ const imgs = await Promise.all(urls.map(function(u) {
9913
+ return new Promise(function(res) { const im = new Image(); im.onload = function() { res(im); }; im.onerror = function() { res(null); }; im.src = u; });
9914
+ }));
9915
+ const first = imgs.find(function(im) { return im && im.naturalWidth; });
9916
+ if (!first) return { success: false, error: 'no frame could be captured' };
9917
+ const tileH = Math.max(1, Math.round(tileW * first.naturalHeight / first.naturalWidth));
9918
+ const rows = Math.ceil(imgs.length / columns);
9919
+ const gap = 4;
9920
+ const cv = document.createElement('canvas');
9921
+ cv.width = columns * tileW + (columns + 1) * gap;
9922
+ cv.height = rows * tileH + (rows + 1) * gap;
9923
+ const g = cv.getContext('2d');
9924
+ g.fillStyle = '#202020'; g.fillRect(0, 0, cv.width, cv.height);
9925
+ imgs.forEach(function(im, i) {
9926
+ const x = gap + (i % columns) * (tileW + gap);
9927
+ const y = gap + Math.floor(i / columns) * (tileH + gap);
9928
+ if (im) g.drawImage(im, x, y, tileW, tileH);
9929
+ else { g.fillStyle = '#802020'; g.fillRect(x, y, tileW, tileH); }
9930
+ if (${sheet.labels ? 'true' : 'false'}) {
9931
+ const text = labels[i];
9932
+ g.font = '600 13px system-ui, sans-serif';
9933
+ const w = g.measureText(text).width + 10;
9934
+ g.fillStyle = 'rgba(0,0,0,0.65)'; g.fillRect(x + 4, y + 4, w, 20);
9935
+ g.fillStyle = '#fff'; g.fillText(text, x + 9, y + 19);
9936
+ }
9937
+ });
9938
+ const dataUrl = cv.toDataURL(${S(mime)}${sheet.format === 'png' ? '' : ', 0.82'});
9939
+ return { success: true, dataUrl: dataUrl, tiles: imgs.length, columns: columns, rows: rows,
9940
+ width: cv.width, height: cv.height, tileWidth: tileW, times: times,
9941
+ missing: imgs.filter(function(im) { return !im; }).length,
9942
+ sizedCapture: sized };
9614
9943
  })();`.trim();
9615
9944
  }
9616
9945
  /**
@@ -9758,6 +10087,7 @@ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.i
9758
10087
  const opts = JSON.stringify({
9759
10088
  ...(input.position ? { position: input.position } : {}),
9760
10089
  ...(input.scale !== undefined ? { scale: input.scale } : {}),
10090
+ ...(input.fit !== undefined ? { fit: input.fit } : {}),
9761
10091
  ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
9762
10092
  ...(input.clipInPoint !== undefined ? { clipInPoint: input.clipInPoint } : {}),
9763
10093
  ...(input.clipOutPoint !== undefined ? { clipOutPoint: input.clipOutPoint } : {}),
@@ -9989,6 +10319,45 @@ ${guard}
9989
10319
  // The facade throws on a missing raster/matte rather than returning {ok:false}.
9990
10320
  return { success: false, error: e && e.message ? e.message : String(e) };
9991
10321
  }
10322
+ })();`.trim();
10323
+ }
10324
+ case 'analyze_reference': {
10325
+ const S = (v) => JSON.stringify(v);
10326
+ return `
10327
+ // Measure a reference video's FORMAT: cuts, shot lengths, colours, pacing, beat
10328
+ (async function() {
10329
+ if (typeof app.analyzeReferenceVideo !== 'function') { return { success: false, error: 'app.analyzeReferenceVideo unavailable — update FxTool' }; }
10330
+ const r = await app.analyzeReferenceVideo(${S(input.id)}, { thumbnails: ${input.thumbnails ? 'true' : 'false'} });
10331
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'the reference could not be analysed' }; }
10332
+ // Kept in the page for remake_from_reference, so the analysis does not have
10333
+ // to travel back through the agent.
10334
+ window.__ppLastReference = { analysis: r, id: ${S(input.id)} };
10335
+ const shots = Array.isArray(r.shots) ? r.shots : [];
10336
+ return { success: true, referenceId: ${S(input.id)}, duration: r.duration, aspect: r.aspect, width: r.width, height: r.height,
10337
+ cuts: r.cuts, shots: shots, pacing: r.pacing, audio: r.audio, card: r.card,
10338
+ ...(r.truncated ? { truncated: true } : {}),
10339
+ next: 'remake_from_reference with texts: your own words, ' + shots.length + ' (one per shot). Nothing of the footage, its words or its sound is copied.' };
10340
+ })();`.trim();
10341
+ }
10342
+ case 'remake_from_reference': {
10343
+ const S = (v) => JSON.stringify(v);
10344
+ const opts = {
10345
+ texts: input.texts ?? [],
10346
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
10347
+ ...(input.clear !== undefined ? { clear: input.clear } : {}),
10348
+ };
10349
+ return `
10350
+ // Build a new piece on the analysed reference's structure, in your own words
10351
+ (async function() {
10352
+ if (typeof app.remakeFromReference !== 'function') { return { success: false, error: 'app.remakeFromReference unavailable — update FxTool' }; }
10353
+ const last = window.__ppLastReference;
10354
+ if (!last || !last.analysis) { return { success: false, error: 'no analysed reference in this session: call pinepaper_media analyze_reference first' }; }
10355
+ // reference: the analysed clip stays in the project MUTED; without it the
10356
+ // reference's own sound is exported under the new cards.
10357
+ const r = await app.remakeFromReference(last.analysis, Object.assign(${S(opts)}, { reference: last.id }));
10358
+ return r && r.ok
10359
+ ? { success: true, action: 'remake_from_reference', duration: r.duration, shots: r.shots, items: r.items, referenceMuted: last.id }
10360
+ : { success: false, error: (r && r.error) || 'the remake could not be built' };
9992
10361
  })();`.trim();
9993
10362
  }
9994
10363
  case 'stop_live_matte': {
@@ -10160,6 +10529,21 @@ ${guard}
10160
10529
  const S = (v) => JSON.stringify(v);
10161
10530
  const wrap = (comment, body) => `\n// ${comment}\n(function() {\n${body}\n})();`.trim();
10162
10531
  switch (input.action) {
10532
+ case 'apply':
10533
+ // One engine entry point (FxTool #54): app.applyMedium dispatches to the
10534
+ // medium's own method (DesignMedia MEDIA[key].apply) and refuses an absent
10535
+ // or brush-only medium with the reason. The Paper item it returns is not
10536
+ // passed back (it cannot cross the bridge); its id is.
10537
+ return `
10538
+ // Medium: apply ${input.medium}
10539
+ (async function() {
10540
+ if (typeof app.applyMedium !== 'function') { return { success: false, error: 'app.applyMedium unavailable — update FxTool' }; }
10541
+ const r = await app.applyMedium(${S(input.itemId)}, ${S(input.medium)}, ${S(input.options ?? {})});
10542
+ if (!r || r.ok === false) {
10543
+ return { success: false, error: (r && (r.error || r.reason)) || 'the medium could not be applied', medium: ${S(input.medium)}, ...(r && r.fidelity ? { fidelity: r.fidelity } : {}) };
10544
+ }
10545
+ return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.note ? { note: r.note } : {}) };
10546
+ })();`.trim();
10163
10547
  case 'list_media':
10164
10548
  return wrap('Medium: list', ` if (typeof app.listDesignMedia !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
10165
10549
  const media = app.listDesignMedia();
@@ -10476,12 +10860,15 @@ ${guard}
10476
10860
  // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
10477
10861
  (async function() {
10478
10862
  if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
10479
- await app.createWorld3D(${S(world3dColors(input.spec ?? 'forest'))}, ${opts});
10863
+ await app.createWorld3D(${S(world3dSpecColors(world3dColors(input.spec ?? 'forest')))}, ${opts});
10480
10864
  // describe() is the world's own parameter schema — return the preset list +
10481
10865
  // top-level keys so the agent knows what configure can touch, without the
10482
10866
  // full multi-KB schema on every create.
10483
10867
  const d = typeof app.describeWorld3D === 'function' ? await app.describeWorld3D() : null;
10484
- return { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: d ? Object.keys(d.params || d) : [] };
10868
+ // params is WORLD_SCHEMA: an ARRAY of {path, kind, doc}. Object.keys on it
10869
+ // returned "0", "1", "2"… — the indexes, not a single usable key.
10870
+ const keys = !d ? [] : Array.isArray(d.params) ? d.params.map(function (e) { return e && e.path; }).filter(Boolean) : Object.keys(d.params || d);
10871
+ return { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: keys };
10485
10872
  })();`.trim();
10486
10873
  }
10487
10874
  case 'describe':
@@ -10497,9 +10884,9 @@ ${guard}
10497
10884
  (function() {
10498
10885
  if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
10499
10886
  ${needWorld}
10500
- const r = app.configureWorld3D(${S(world3dColors(input.patch))});
10887
+ const r = app.configureWorld3D(${S(world3dSpecColors(world3dColors(input.patch)))});
10501
10888
  // The validator names the right key on a wrong one — forward it verbatim.
10502
- return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
10889
+ return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && (r.error || (Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : '') || r.reason)) || 'configure failed' };
10503
10890
  })();`.trim();
10504
10891
  case 'add_actor': {
10505
10892
  const actor = S({
@@ -10612,7 +10999,7 @@ ${needWorld}
10612
10999
  // @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
10613
11000
  if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
10614
11001
  const r = await app.${fn}(${S(input.pathId)}, ${S(mesh)});
10615
- if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the world refused the mesh' }; }
11002
+ if (!r || r.ok === false) { return { success: false, error: (r && (r.error || (Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : '') || r.reason)) || 'the world refused the mesh' }; }
10616
11003
  return { success: true, meshId: r.id, op: ${S(input.action === 'extrude_path' ? 'extrude' : 'lathe')}, sourceId: ${S(input.pathId)} };
10617
11004
  })();`.trim();
10618
11005
  }
@@ -10639,8 +11026,8 @@ ${needWorld}
10639
11026
  (function() {
10640
11027
  if (typeof app.addWorldLight !== 'function') { return { success: false, error: 'app.addWorldLight unavailable — update FxTool' }; }
10641
11028
  ${needWorld}
10642
- const r = app.addWorldLight(${S(input.light ?? {})});
10643
- if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the light' }; }
11029
+ const r = app.addWorldLight(${S(world3dHexColor(input.light))});
11030
+ if (!r || r.ok === false) { return { success: false, error: (r && (r.error || (Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : '') || r.reason)) || 'the world refused the light' }; }
10644
11031
  return { success: true, lightId: r.id, light: r.light };
10645
11032
  })();`.trim();
10646
11033
  case 'set_light':
@@ -10648,7 +11035,7 @@ ${needWorld}
10648
11035
  // World3D: edit a light in place
10649
11036
  (function() {
10650
11037
  if (typeof app.setWorldLight !== 'function') { return { success: false, error: 'app.setWorldLight unavailable — update FxTool' }; }
10651
- return { success: !!app.setWorldLight(${S(input.lightId)}, ${S(input.light ?? {})}) };
11038
+ return { success: !!app.setWorldLight(${S(input.lightId)}, ${S(world3dHexColor(input.light))}) };
10652
11039
  })();`.trim();
10653
11040
  case 'remove_light':
10654
11041
  return `
@@ -10671,8 +11058,8 @@ ${needWorld}
10671
11058
  (function() {
10672
11059
  if (typeof app.addWorldMaterial !== 'function') { return { success: false, error: 'app.addWorldMaterial unavailable — update FxTool' }; }
10673
11060
  ${needWorld}
10674
- const r = app.addWorldMaterial(${S(input.material ?? {})});
10675
- if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the material' }; }
11061
+ const r = app.addWorldMaterial(${S(world3dHexColor(input.material))});
11062
+ if (!r || r.ok === false) { return { success: false, error: (r && (r.error || (Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : '') || r.reason)) || 'the world refused the material' }; }
10676
11063
  return { success: true, materialId: r.id, material: r.material };
10677
11064
  })();`.trim();
10678
11065
  case 'set_material':
@@ -10680,7 +11067,7 @@ ${needWorld}
10680
11067
  // World3D: patch a material — every object referencing it changes next frame
10681
11068
  (function() {
10682
11069
  if (typeof app.setWorldMaterial !== 'function') { return { success: false, error: 'app.setWorldMaterial unavailable — update FxTool' }; }
10683
- return { success: !!app.setWorldMaterial(${S(input.materialId)}, ${S(input.material ?? {})}) };
11070
+ return { success: !!app.setWorldMaterial(${S(input.materialId)}, ${S(world3dHexColor(input.material))}) };
10684
11071
  })();`.trim();
10685
11072
  case 'remove_material':
10686
11073
  return `
@@ -10739,7 +11126,7 @@ ${needWorld}
10739
11126
  (function() {
10740
11127
  if (typeof app.setWorldNavTarget !== 'function') { return { success: false, error: 'app.setWorldNavTarget unavailable — update FxTool' }; }
10741
11128
  const r = app.setWorldNavTarget(${S(input.navTarget)});
10742
- if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the nav target was refused' }; }
11129
+ if (!r || r.ok === false) { return { success: false, error: (r && (r.error || (Array.isArray(r.errors) && r.errors.length ? r.errors.join('; ') : '') || r.reason)) || 'the nav target was refused' }; }
10743
11130
  return { success: true, navTarget: ${S(input.navTarget)} };
10744
11131
  })();`.trim();
10745
11132
  case 'get_nav_target':
@@ -11239,7 +11626,10 @@ ${guard(fn)}
11239
11626
  }
11240
11627
  case 'render_soundtrack': {
11241
11628
  const opts = {
11242
- ...(typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
11629
+ // A top-level duration was stripped by the schema and the render
11630
+ // used its default length, silently; both spellings are read now.
11631
+ ...(typeof input.duration === 'number' ? { duration: input.duration }
11632
+ : typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
11243
11633
  ...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}),
11244
11634
  ...(input.bitDepth !== undefined ? { bitDepth: input.bitDepth } : {}),
11245
11635
  };
@@ -12134,4 +12524,98 @@ return {
12134
12524
  fonts: fonts,
12135
12525
  };`;
12136
12526
  }
12527
+ /**
12528
+ * AUTO-KEY FROM AN AGENT (plan C5a): "at t, make it orange" writes a key at t,
12529
+ * the way a user's drag does with auto-key on — not a static edit.
12530
+ *
12531
+ * The key is MERGED into the item's track (addAnimation mode 'merge'): other
12532
+ * keys stay. So the track stays whole:
12533
+ * - the new key also carries every OTHER animated property's value at t
12534
+ * (getInterpolatedState), because a key missing a property the others
12535
+ * animate can break that property's curve;
12536
+ * - a property the track did not animate yet is added to every existing key
12537
+ * (its old value before t, the new one from t), and a first key at 0 holds
12538
+ * the old value, so the change eases in from what it was instead of
12539
+ * jumping.
12540
+ * No duration is passed: addAnimation starts timeline playback when given one.
12541
+ * The result names each property's previous value and what was written.
12542
+ */
12543
+ export function generateAutoKeyCode(itemId, props, atTime, easing) {
12544
+ return `
12545
+ // Auto-key ${itemId} at ${atTime}s
12546
+ (function() {
12547
+ const item = app.getItemById(${JSON.stringify(itemId)});
12548
+ if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas — check the id with pinepaper_get_items.`)} };
12549
+ if (typeof app.addAnimation !== 'function') return { success: false, error: 'this studio cannot write keyframes (no addAnimation).' };
12550
+ const AT = ${atTime};
12551
+ const NEW = ${JSON.stringify(props)};
12552
+ const track = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12553
+ 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; }, []);
12554
+ // The static value of a property the track does not animate yet.
12555
+ const read = function(p) {
12556
+ const c = function(v) { return v && typeof v.toCSS === 'function' ? v.toCSS(true) : v; };
12557
+ if (p === 'x') return item.position && item.position.x;
12558
+ if (p === 'y') return item.position && item.position.y;
12559
+ if (p === 'scale') return item.scaling ? item.scaling.x : 1;
12560
+ if (p === 'scaleX') return item.scaling ? item.scaling.x : 1;
12561
+ if (p === 'scaleY') return item.scaling ? item.scaling.y : 1;
12562
+ if (p === 'color' || p === 'fillColor') return c(item.fillColor);
12563
+ if (p === 'strokeColor') return c(item.strokeColor);
12564
+ if (p === 'width') return item.bounds && item.bounds.width;
12565
+ if (p === 'height') return item.bounds && item.bounds.height;
12566
+ const v = item[p];
12567
+ return (v === undefined || typeof v === 'function') ? undefined : c(v);
12568
+ };
12569
+ const state = (track.length && typeof app.getInterpolatedState === 'function') ? (app.getInterpolatedState(item, AT) || {}) : {};
12570
+ const byTime = {};
12571
+ 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; };
12572
+ const changes = [];
12573
+ Object.keys(NEW).forEach(function(p) {
12574
+ const wasAnimated = animated.indexOf(p) >= 0;
12575
+ const before = wasAnimated ? state[p] : read(p);
12576
+ if (!wasAnimated && before !== undefined) {
12577
+ track.forEach(function(k) { put(k.time, p, k.time < AT ? before : NEW[p]); });
12578
+ if (!track.length && AT > 0) put(0, p, before);
12579
+ }
12580
+ 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') });
12581
+ });
12582
+ // The key at t: the new values, plus every other animated property as it is at t.
12583
+ animated.forEach(function(p) { if (!(p in NEW) && state[p] !== undefined) put(AT, p, state[p]); });
12584
+ Object.keys(NEW).forEach(function(p) { put(AT, p, NEW[p], ${JSON.stringify(easing ?? null)}); });
12585
+ const keys = Object.keys(byTime).map(function(t) { return byTime[t]; }).sort(function(a, b) { return a.time - b.time; });
12586
+ const beforeTimes = track.map(function(k) { return k.time; });
12587
+ app.addAnimation(${JSON.stringify(itemId)}, keys, { mode: 'merge' });
12588
+ const after = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12589
+ const kept = beforeTimes.every(function(t) { return after.some(function(k) { return k.time === t; }); });
12590
+ 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 };
12591
+ if (app.historyManager) app.historyManager.saveState();
12592
+ return { success: true, itemId: ${JSON.stringify(itemId)}, atTime: AT, autoKeyed: true, changes: changes, keys: after.length,
12593
+ note: 'written as keyframes (a temporal edit), not a static change; pinepaper_query_mutations shows the series.' };
12594
+ })();`.trim();
12595
+ }
12596
+ /**
12597
+ * A property's time series (plan C5a): the item's own keyframe track, read
12598
+ * back. Until the engine keeps a mutation log (C3) this is the keys, not the
12599
+ * history of who changed what — the result says so.
12600
+ */
12601
+ export function generateQueryMutationsCode(itemId, property, range, fps = 30) {
12602
+ return `
12603
+ // Keyframe series for ${itemId}${property ? '.' + property : ''}
12604
+ (function() {
12605
+ const item = app.getItemById(${JSON.stringify(itemId)});
12606
+ if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas.`)} };
12607
+ const P = ${JSON.stringify(property ?? null)}, R = ${JSON.stringify(range ?? null)}, FPS = ${fps};
12608
+ const track = (item.data && Array.isArray(item.data.keyframes)) ? item.data.keyframes : [];
12609
+ const series = {};
12610
+ track.forEach(function(k) {
12611
+ if (R && (k.time < R[0] || k.time > R[1])) return;
12612
+ Object.keys((k && k.properties) || {}).forEach(function(p) {
12613
+ if (P && p !== P) return;
12614
+ (series[p] = series[p] || []).push({ t: k.time, frame: Math.round(k.time * FPS), value: k.properties[p], easing: k.easing || null });
12615
+ });
12616
+ });
12617
+ return { success: true, itemId: ${JSON.stringify(itemId)}, source: 'keyframes', series: series, keys: track.length,
12618
+ 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.' };
12619
+ })();`.trim();
12620
+ }
12137
12621
  //# sourceMappingURL=code-generator.js.map