@pinepaper.studio/mcp-server 1.6.7 → 1.6.8

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 (164) hide show
  1. package/README.de.md +9 -5
  2. package/README.es.md +9 -5
  3. package/README.fr.md +9 -5
  4. package/README.hi.md +8 -5
  5. package/README.ja.md +8 -5
  6. package/README.ko.md +8 -4
  7. package/README.md +171 -17
  8. package/README.pt-BR.md +9 -5
  9. package/README.zh-CN.md +6 -4
  10. package/dist/character/data/concepts.json +1 -0
  11. package/dist/character/data/depictions.json +1 -0
  12. package/dist/character/data/styles.json +1 -0
  13. package/dist/character/duration-scale.d.ts +13 -0
  14. package/dist/character/duration-scale.d.ts.map +1 -0
  15. package/dist/character/duration-scale.js +13 -0
  16. package/dist/character/duration-scale.js.map +1 -0
  17. package/dist/character/path-bbox.d.ts +82 -0
  18. package/dist/character/path-bbox.d.ts.map +1 -0
  19. package/dist/character/path-bbox.js +496 -0
  20. package/dist/character/path-bbox.js.map +1 -0
  21. package/dist/character/perform.d.ts +149 -0
  22. package/dist/character/perform.d.ts.map +1 -0
  23. package/dist/character/perform.js +626 -0
  24. package/dist/character/perform.js.map +1 -0
  25. package/dist/character/resolve.d.ts +51 -0
  26. package/dist/character/resolve.d.ts.map +1 -0
  27. package/dist/character/resolve.js +239 -0
  28. package/dist/character/resolve.js.map +1 -0
  29. package/dist/design/design-systems.d.ts +126 -0
  30. package/dist/design/design-systems.d.ts.map +1 -0
  31. package/dist/design/design-systems.js +240 -0
  32. package/dist/design/design-systems.js.map +1 -0
  33. package/dist/execution/canvas-state.d.ts +7 -0
  34. package/dist/execution/canvas-state.d.ts.map +1 -1
  35. package/dist/execution/canvas-state.js +20 -8
  36. package/dist/execution/canvas-state.js.map +1 -1
  37. package/dist/index.d.ts +20 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +12 -4
  40. package/dist/index.js.map +1 -1
  41. package/dist/ontology/design-graph.d.ts +9 -0
  42. package/dist/ontology/design-graph.d.ts.map +1 -1
  43. package/dist/ontology/design-graph.js +98 -3
  44. package/dist/ontology/design-graph.js.map +1 -1
  45. package/dist/ontology/types.d.ts +42 -0
  46. package/dist/ontology/types.d.ts.map +1 -1
  47. package/dist/ontology/vocabulary.d.ts.map +1 -1
  48. package/dist/ontology/vocabulary.js +20 -0
  49. package/dist/ontology/vocabulary.js.map +1 -1
  50. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  51. package/dist/tools/compact-descriptions.js +25 -0
  52. package/dist/tools/compact-descriptions.js.map +1 -1
  53. package/dist/tools/definitions.d.ts +1 -1
  54. package/dist/tools/definitions.d.ts.map +1 -1
  55. package/dist/tools/definitions.js +343 -24
  56. package/dist/tools/definitions.js.map +1 -1
  57. package/dist/tools/engine-methods.d.ts +18 -0
  58. package/dist/tools/engine-methods.d.ts.map +1 -0
  59. package/dist/tools/engine-methods.js +239 -0
  60. package/dist/tools/engine-methods.js.map +1 -0
  61. package/dist/tools/handlers/character.d.ts +121 -0
  62. package/dist/tools/handlers/character.d.ts.map +1 -0
  63. package/dist/tools/handlers/character.js +315 -0
  64. package/dist/tools/handlers/character.js.map +1 -0
  65. package/dist/tools/handlers.d.ts +2 -0
  66. package/dist/tools/handlers.d.ts.map +1 -1
  67. package/dist/tools/handlers.js +316 -16
  68. package/dist/tools/handlers.js.map +1 -1
  69. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  70. package/dist/tools/minimal-descriptions.js +13 -2
  71. package/dist/tools/minimal-descriptions.js.map +1 -1
  72. package/dist/tools/toolkits.d.ts.map +1 -1
  73. package/dist/tools/toolkits.js +7 -0
  74. package/dist/tools/toolkits.js.map +1 -1
  75. package/dist/types/code-generator.d.ts +96 -1
  76. package/dist/types/code-generator.d.ts.map +1 -1
  77. package/dist/types/code-generator.js +1200 -12
  78. package/dist/types/code-generator.js.map +1 -1
  79. package/dist/types/schemas.d.ts +6792 -151
  80. package/dist/types/schemas.d.ts.map +1 -1
  81. package/dist/types/schemas.js +587 -17
  82. package/dist/types/schemas.js.map +1 -1
  83. package/dist/vendor/design/art-deco-generator.d.ts +52 -0
  84. package/dist/vendor/design/art-deco-generator.d.ts.map +1 -0
  85. package/dist/vendor/design/art-deco-generator.js +144 -0
  86. package/dist/vendor/design/art-deco-generator.js.map +1 -0
  87. package/dist/vendor/design/art-nouveau-generator.d.ts +46 -0
  88. package/dist/vendor/design/art-nouveau-generator.d.ts.map +1 -0
  89. package/dist/vendor/design/art-nouveau-generator.js +134 -0
  90. package/dist/vendor/design/art-nouveau-generator.js.map +1 -0
  91. package/dist/vendor/design/bauhaus-generator.d.ts +53 -0
  92. package/dist/vendor/design/bauhaus-generator.d.ts.map +1 -0
  93. package/dist/vendor/design/bauhaus-generator.js +126 -0
  94. package/dist/vendor/design/bauhaus-generator.js.map +1 -0
  95. package/dist/vendor/design/brutalist-generator.d.ts +53 -0
  96. package/dist/vendor/design/brutalist-generator.d.ts.map +1 -0
  97. package/dist/vendor/design/brutalist-generator.js +148 -0
  98. package/dist/vendor/design/brutalist-generator.js.map +1 -0
  99. package/dist/vendor/design/claymorphism-generator.d.ts +46 -0
  100. package/dist/vendor/design/claymorphism-generator.d.ts.map +1 -0
  101. package/dist/vendor/design/claymorphism-generator.js +135 -0
  102. package/dist/vendor/design/claymorphism-generator.js.map +1 -0
  103. package/dist/vendor/design/corporate-memphis-generator.d.ts +47 -0
  104. package/dist/vendor/design/corporate-memphis-generator.d.ts.map +1 -0
  105. package/dist/vendor/design/corporate-memphis-generator.js +144 -0
  106. package/dist/vendor/design/corporate-memphis-generator.js.map +1 -0
  107. package/dist/vendor/design/cyberpunk-generator.d.ts +52 -0
  108. package/dist/vendor/design/cyberpunk-generator.d.ts.map +1 -0
  109. package/dist/vendor/design/cyberpunk-generator.js +162 -0
  110. package/dist/vendor/design/cyberpunk-generator.js.map +1 -0
  111. package/dist/vendor/design/de-stijl-generator.d.ts +50 -0
  112. package/dist/vendor/design/de-stijl-generator.d.ts.map +1 -0
  113. package/dist/vendor/design/de-stijl-generator.js +154 -0
  114. package/dist/vendor/design/de-stijl-generator.js.map +1 -0
  115. package/dist/vendor/design/design-style.d.ts +3 -0
  116. package/dist/vendor/design/design-style.d.ts.map +1 -0
  117. package/dist/vendor/design/design-style.js +12 -0
  118. package/dist/vendor/design/design-style.js.map +1 -0
  119. package/dist/vendor/design/design-systems-dtcg.d.ts +59 -0
  120. package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -0
  121. package/dist/vendor/design/design-systems-dtcg.js +571 -0
  122. package/dist/vendor/design/design-systems-dtcg.js.map +1 -0
  123. package/dist/vendor/design/frutiger-aero-generator.d.ts +46 -0
  124. package/dist/vendor/design/frutiger-aero-generator.d.ts.map +1 -0
  125. package/dist/vendor/design/frutiger-aero-generator.js +133 -0
  126. package/dist/vendor/design/frutiger-aero-generator.js.map +1 -0
  127. package/dist/vendor/design/memphis-generator.d.ts +56 -0
  128. package/dist/vendor/design/memphis-generator.d.ts.map +1 -0
  129. package/dist/vendor/design/memphis-generator.js +158 -0
  130. package/dist/vendor/design/memphis-generator.js.map +1 -0
  131. package/dist/vendor/design/mid-century-upa-generator.d.ts +46 -0
  132. package/dist/vendor/design/mid-century-upa-generator.d.ts.map +1 -0
  133. package/dist/vendor/design/mid-century-upa-generator.js +137 -0
  134. package/dist/vendor/design/mid-century-upa-generator.js.map +1 -0
  135. package/dist/vendor/design/neumorphic-generator.d.ts +53 -0
  136. package/dist/vendor/design/neumorphic-generator.d.ts.map +1 -0
  137. package/dist/vendor/design/neumorphic-generator.js +182 -0
  138. package/dist/vendor/design/neumorphic-generator.js.map +1 -0
  139. package/dist/vendor/design/op-art-generator.d.ts +45 -0
  140. package/dist/vendor/design/op-art-generator.d.ts.map +1 -0
  141. package/dist/vendor/design/op-art-generator.js +116 -0
  142. package/dist/vendor/design/op-art-generator.js.map +1 -0
  143. package/dist/vendor/design/pixel-art-generator.d.ts +45 -0
  144. package/dist/vendor/design/pixel-art-generator.d.ts.map +1 -0
  145. package/dist/vendor/design/pixel-art-generator.js +143 -0
  146. package/dist/vendor/design/pixel-art-generator.js.map +1 -0
  147. package/dist/vendor/design/pop-art-generator.d.ts +53 -0
  148. package/dist/vendor/design/pop-art-generator.d.ts.map +1 -0
  149. package/dist/vendor/design/pop-art-generator.js +164 -0
  150. package/dist/vendor/design/pop-art-generator.js.map +1 -0
  151. package/dist/vendor/design/risograph-generator.d.ts +53 -0
  152. package/dist/vendor/design/risograph-generator.d.ts.map +1 -0
  153. package/dist/vendor/design/risograph-generator.js +170 -0
  154. package/dist/vendor/design/risograph-generator.js.map +1 -0
  155. package/dist/vendor/design/swiss-style-generator.d.ts +55 -0
  156. package/dist/vendor/design/swiss-style-generator.d.ts.map +1 -0
  157. package/dist/vendor/design/swiss-style-generator.js +147 -0
  158. package/dist/vendor/design/swiss-style-generator.js.map +1 -0
  159. package/dist/vendor/design/vaporwave-generator.d.ts +51 -0
  160. package/dist/vendor/design/vaporwave-generator.d.ts.map +1 -0
  161. package/dist/vendor/design/vaporwave-generator.js +139 -0
  162. package/dist/vendor/design/vaporwave-generator.js.map +1 -0
  163. package/manifest.json +29 -1
  164. package/package.json +13 -5
@@ -0,0 +1 @@
1
+ {"version":3,"file":"perform.d.ts","sourceRoot":"","sources":["../../src/character/perform.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,626 @@
1
+ /* VENDORED from mcp-cloud/src/services/character-perform.ts.
2
+ * sha256: 91737fa6410fbe24e7b969c9d1a97ca4c6694c117854ce3182606efa41e98db7
3
+ * Generator: scripts/sync-character-modules.mjs
4
+ *
5
+ * Do NOT edit here — change it in mcp-cloud and re-run the generator, or the
6
+ * two implementations drift and a figure performs differently depending on
7
+ * which surface a caller reached. Guarded by character.test.ts.
8
+ */
9
+ /**
10
+ * A CHARACTER IS DIRECTED, NOT DRAWN.
11
+ *
12
+ * This is the translation step the declarative graph exists for. A model says
13
+ * WHAT happens — this character, here, this tall, blinks at 1.2s and speaks
14
+ * from 2.0 to 4.5 — and the knowledge of HOW lives in the graph: which parts
15
+ * the figure has, which of them are alternates of one another, how long a blink
16
+ * lasts, how fast a mouth moves. Nothing here asks a model for geometry.
17
+ *
18
+ * WHY IT HAS TO BE THIS WAY. The alternative is what exists today: a model
19
+ * writes thirty-odd correct calls — create the skeleton, add each bone with the
20
+ * right parent and angle, create each shape, attach each to the right bone,
21
+ * then author poses — and one mistake anywhere leaves a broken figure. That is
22
+ * a lot of exactly-right output to ask of a language model, and it is the
23
+ * reported reason characters do not work. A symbol and a list of beats is
24
+ * something a small model can get right, and a small model getting it right is
25
+ * the difference between this running in the cloud and running on someone's
26
+ * laptop.
27
+ *
28
+ * THE THREE MOTIONS ARE THE ONES STORYTELLERS ASKED FOR — blink, speak, turn
29
+ * the head — and each is expressed with machinery both renderers were measured
30
+ * on this week: opacity keyframes and position keyframes. No shape keys, no IK,
31
+ * no deformers. A channel that needs a subsystem the container cannot draw is a
32
+ * channel that works in a preview and fails in the delivered video, which is
33
+ * the failure this whole layer is a response to.
34
+ *
35
+ * NOT LIP SYNC. `say` opens and closes the mouth for the length of a line; it
36
+ * does not match phonemes, because matching phonemes needs the word timings a
37
+ * voice track carries and no voice track exists yet. Called `say` rather than
38
+ * `lipSync` so nobody reads more into it than it does.
39
+ */
40
+ import { DURATION_SCALE_MS } from "./duration-scale.js";
41
+ import { pathBBox, scalePath, shiftPath } from "./path-bbox.js";
42
+ /** Nearest duration token to `ms` — the same rounding the token repair uses. */
43
+ const nearestOnScale = (ms) => DURATION_SCALE_MS.reduce((best, d) => (Math.abs(d - ms) < Math.abs(best - ms) ? d : best), DURATION_SCALE_MS[0]);
44
+ /** Sorted, deduplicated keyframes — a track with two values at one instant is
45
+ * ambiguous and the renderers resolve it differently. */
46
+ function tidy(kfs) {
47
+ const byTime = new Map();
48
+ // LATER WINS AT THE SAME INSTANT, and the zero anchor is pushed last — so it
49
+ // is merged in first here, letting a real beat at t=0 override it.
50
+ for (const k of [...kfs].reverse()) {
51
+ const t = Math.round(k.time * 1000) / 1000;
52
+ byTime.set(t, { ...(byTime.get(t) ?? {}), ...k, time: t });
53
+ }
54
+ return [...byTime.values()].sort((a, b) => a.time - b.time);
55
+ }
56
+ /** Relative luminance of a #rgb/#rrggbb colour, 0..1. Unknown formats read as
57
+ * light, so an unrecognised colour keeps the old stroked behaviour. */
58
+ function luminance(colour) {
59
+ const h = colour.trim().replace(/^#/, "");
60
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
61
+ if (!/^[0-9a-f]{6}$/i.test(full))
62
+ return 1;
63
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
64
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
65
+ }
66
+ /** Is this fill already the drawing's own ink? Such a part must not be
67
+ * outlined — see the note at the stroke below. */
68
+ function isInkFill(colour) {
69
+ return luminance(colour) < 0.22;
70
+ }
71
+ export function performCharacter(input) {
72
+ const { id, depiction: dep, at, height, channels, beats, durationSeconds } = input;
73
+ const k = height / dep.height;
74
+ const byName = new Map(channels.map((c) => [c.name, c]));
75
+ /** Beats naming a channel this concept has no definition for — reported, not hidden. */
76
+ const unknownChannels = [];
77
+ const ops = [];
78
+ /**
79
+ * ONE CONTINUOUS CONTOUR, WHICH IS WHAT MAKES A FIGURE READ AS ONE OBJECT.
80
+ *
81
+ * Stroking each part separately draws the seams between primitives at exactly
82
+ * the weight that separates the figure from the sky. Rendered at 0.9s of
83
+ * `ledge-at-dusk`, the pigeon is a circle-with-an-outline sitting on an
84
+ * ellipse-with-an-outline and the head's contour cuts straight across the
85
+ * body — which is the "the head is not connected" complaint, drawn.
86
+ *
87
+ * The fix is the one flat-vector artwork has always used, and both Rive and
88
+ * Lottie files are built this way: lay the ink down FIRST, as an unfilled
89
+ * double-width stroke of every silhouette part, then paint the fills over it.
90
+ * A stroke is centred on its path, so each fill covers the inner half of its
91
+ * own ink and the inner half of every ink it overlaps. What survives is the
92
+ * outer boundary of the UNION — one contour, no internal seams — without any
93
+ * boolean path arithmetic.
94
+ *
95
+ * The ink copy is `carriedBy` its source, so it inherits that part's motion by
96
+ * construction. There is no second track to drift.
97
+ *
98
+ * Opt-in per style: a style that wants separate outlines keeps them.
99
+ */
100
+ const UNION_INK = dep.ink?.union === true;
101
+ if (UNION_INK) {
102
+ for (const [pid, part] of Object.entries({ ...dep.parts })) {
103
+ // Only the silhouette. Parts that are already ink, or carry reduced
104
+ // interior linework, are detail drawn ON the form and are not part of
105
+ // its boundary.
106
+ if ((part.inkWeight ?? 1) < 1)
107
+ continue;
108
+ if (dep.palette[part.fill ?? ""] ?? part.fill) {
109
+ const fill = dep.palette[part.fill ?? ""] ?? part.fill ?? "";
110
+ if (isInkFill(fill))
111
+ continue;
112
+ }
113
+ dep.parts[`${pid}__ink`] = { at: part.at, path: part.path, carriedBy: pid, inkWeight: 2 };
114
+ }
115
+ }
116
+ /** Ink copies first: everything painted after them covers their inner half. */
117
+ const partIds = Object.keys(dep.parts).sort((a, b) => (a.endsWith("__ink") ? 0 : 1) - (b.endsWith("__ink") ? 0 : 1));
118
+ /**
119
+ * Every part this one carries, at any depth. A chain (skull → beak → nostril)
120
+ * has to propagate the whole way down or the deepest part is the one that
121
+ * tears off, which is the hardest kind to notice.
122
+ *
123
+ * Cycles in the data are ignored rather than thrown on: a malformed depiction
124
+ * should draw a stiff figure, not fail the render.
125
+ */
126
+ /**
127
+ * A BONE'S MOTION, EXPRESSED FOR THE PART IT CARRIES.
128
+ *
129
+ * Translation is the easy half and the only half the first version did: the
130
+ * carrier's dx is the child's dx, so the same offsets are handed straight on.
131
+ *
132
+ * ROTATION IS NOT. A bone that turns swings everything on it AROUND ITS OWN
133
+ * PIVOT — that is what distinguishes a head turning from a head being slid
134
+ * sideways, and it is the difference the eye reads as a figure being solid.
135
+ * Copying the carrier's rotation onto the child instead makes the child spin
136
+ * about its own centre: the beak would rotate on the spot while the skull
137
+ * turns beneath it, which is a worse artefact than the one the bone was
138
+ * built to remove.
139
+ *
140
+ * So a carried part gets BOTH — the same angle, and the displacement its rest
141
+ * position travels along the arc:
142
+ *
143
+ * v = childRest - pivot
144
+ * v' = R(theta) * v
145
+ * offset = v' - v
146
+ *
147
+ * Positions here are the part's placed centre in scene units, which is what
148
+ * `at` resolves to at the emission below, so the arc is computed in the same
149
+ * space it will be drawn in.
150
+ */
151
+ const carry = (acc, carrier, child, property) => {
152
+ if (property !== "rotation")
153
+ return acc;
154
+ const cp = dep.parts[carrier], ch = dep.parts[child];
155
+ if (!cp || !ch)
156
+ return acc;
157
+ const vx = (ch.at[0] - cp.at[0]) * k;
158
+ const vy = (ch.at[1] - cp.at[1]) * k;
159
+ return acc.map((kf) => {
160
+ const deg = typeof kf.rotation === "number" ? kf.rotation : 0;
161
+ const r = (deg * Math.PI) / 180;
162
+ const cos = Math.cos(r), sin = Math.sin(r);
163
+ return {
164
+ ...kf,
165
+ rotation: deg,
166
+ x: vx * cos - vy * sin - vx,
167
+ y: vx * sin + vy * cos - vy,
168
+ };
169
+ });
170
+ };
171
+ const carriedTransitively = (carrier) => {
172
+ const out = [];
173
+ const seen = new Set([carrier]);
174
+ let front = [carrier];
175
+ while (front.length) {
176
+ const next = [];
177
+ for (const id of front) {
178
+ for (const pid of partIds) {
179
+ if (dep.parts[pid]?.carriedBy !== id || seen.has(pid))
180
+ continue;
181
+ seen.add(pid);
182
+ out.push(pid);
183
+ next.push(pid);
184
+ }
185
+ }
186
+ front = next;
187
+ }
188
+ return out;
189
+ };
190
+ // Which drawn parts belong to which alternate group. A part named as the
191
+ // `alternate` of any channel starts hidden; the base starts visible.
192
+ const alternates = new Set();
193
+ for (const c of channels) {
194
+ if (c.alternate)
195
+ alternates.add(c.alternate);
196
+ // Every speech state except the first is an alternate of it: the closed
197
+ // mouth is what the face wears at rest.
198
+ for (const st of (c.states ?? []).slice(1))
199
+ alternates.add(st);
200
+ }
201
+ const partFill = (pid) => {
202
+ const part = dep.parts[pid];
203
+ return dep.palette[part?.fill ?? ""] ?? part?.fill ?? "#1f2937";
204
+ };
205
+ const hidden = (p) => [...alternates].some((a) => p === a || p.startsWith(`${a}_`));
206
+ /**
207
+ * A CONTOUR CANNOT BE WIDER THAN THE THING IT CONTAINS.
208
+ *
209
+ * The union layer strokes at double weight so that each fill covers the inner
210
+ * half and the outer half survives as the silhouette. On a large part that is
211
+ * exactly right. On a SMALL one the two halves meet in the middle and the
212
+ * stroke floods the shape: the pigeon's tail rendered as a black smear, having
213
+ * been a clean wedge before.
214
+ *
215
+ * So the weight is capped by the part's own short side. Half of the smaller
216
+ * dimension is the most that can be drawn before the strokes from opposite
217
+ * edges overlap, and a third leaves the shape still legible as a shape.
218
+ */
219
+ const inkWidthFor = (pid) => {
220
+ const nominal = dep.ink.width * k * 0.6 * (dep.parts[pid]?.inkWeight ?? 1);
221
+ const b = pathBBox(dep.parts[pid]?.path ?? "");
222
+ if (!b)
223
+ return Math.max(0.6, nominal);
224
+ const short = Math.min(b.maxX - b.minX, b.maxY - b.minY) * k;
225
+ if (!Number.isFinite(short) || short <= 0)
226
+ return Math.max(0.6, nominal);
227
+ return Math.max(0.6, Math.min(nominal, short / 3));
228
+ };
229
+ // A CHANNEL MAY NAME SEVERAL PARTS. A vehicle's suspension lifts the body,
230
+ // the glazing and the trim as one thing; declaring that as three identical
231
+ // channels (the pigeon's four bob channels) says the same thing three times
232
+ // and lets them drift apart. A bare string still works and means what it
233
+ // always did.
234
+ const matching = (spec) => {
235
+ const wanted = Array.isArray(spec) ? spec : [spec];
236
+ return partIds.filter((p) => wanted.some((w) => p === w || p.startsWith(`${w}_`)));
237
+ };
238
+ /** The value each transform channel says this part sits at when idle. */
239
+ const restingOf = (pid) => {
240
+ const out = {};
241
+ for (const c of channels) {
242
+ if (c.kind !== "transform" || !c.part || !c.property)
243
+ continue;
244
+ if (!matching(c.part).includes(pid))
245
+ continue;
246
+ const rest = c.rest ?? 1;
247
+ if (rest !== 1)
248
+ out[c.property] = rest;
249
+ }
250
+ return out;
251
+ };
252
+ // ── THE FIGURE ────────────────────────────────────────────────────────────
253
+ // Every part is a path placed in the frame's space. `at` is the part's centre
254
+ // in the figure's own space, so the figure moves as one by construction.
255
+ for (const pid of partIds) {
256
+ const p = dep.parts[pid];
257
+ ops.push({
258
+ tool: "pinepaper_create_item",
259
+ args: {
260
+ itemType: "path",
261
+ position: { x: 0, y: 0 },
262
+ properties: {
263
+ id: `${id}_${pid}`,
264
+ // ONE FIGURE, NOT EIGHT SHAPES. The parts are a single thing that
265
+ // moves and reads together — that is what a rig IS — and saying so
266
+ // is the graph's job, not the caller's. Without it every gate that
267
+ // counts units sees eight competing elements, and
268
+ // `compositionVerdict` (whose own comment says a rigged figure must
269
+ // not be punished for being properly assembled) fails the very
270
+ // piece the rig exists to make.
271
+ groupId: id,
272
+ pathData: shiftPath(scalePath(p.path, k), at.x + p.at[0] * k, at.y + p.at[1] * k),
273
+ closed: true,
274
+ fillColor: partFill(pid),
275
+ // Scaled with the figure: an outline that stays 4px while the drawing
276
+ // grows is a hairline on a large one and a smear on a small one.
277
+ //
278
+ // BUT INK IS NOT OUTLINED. The corpus is FLAT art: its linework is
279
+ // already drawn, as filled dark shapes. A face arrives as ONE
280
+ // compound path of six subpaths — two eyes, brows, nose, mouth — and
281
+ // stroking it adds a 6px outline around every one of those features
282
+ // at a 250px figure height, which merges them into dark smears. The
283
+ // figure has no eyes and its face reads as damage.
284
+ //
285
+ // Invisible at contact-sheet scale, unmissable at full size, which is
286
+ // how it survived review: the eyes were four pixels wide there.
287
+ //
288
+ // So the stroke goes on the SILHOUETTE — the skin and garment shapes
289
+ // an inked scene needs to sit around — and never on a part that is
290
+ // already ink.
291
+ // An ink copy carries NO fill — it exists to be half-covered. Its
292
+ // source loses its own stroke, because the union layer is now the
293
+ // only thing that draws the contour.
294
+ ...(pid.endsWith("__ink") ? { fillColor: "transparent" } : {}),
295
+ // An ink copy is ALWAYS stroked. It declares no fill, so partFill
296
+ // falls back to the dark default and isInkFill reads it as linework
297
+ // and refuses to outline it — which left the union layer invisible
298
+ // and the bird with no contour at all.
299
+ ...(dep.ink
300
+ && (pid.endsWith("__ink") || !isInkFill(partFill(pid)))
301
+ && (dep.parts[pid]?.inkWeight ?? 1) > 0
302
+ && !(UNION_INK && !pid.endsWith("__ink")) ? {
303
+ strokeColor: dep.ink.color,
304
+ // The part's own share of the style's ink — see `inkWeight`. The
305
+ // floor stays, so a light interior line is thin and never absent.
306
+ strokeWidth: inkWidthFor(pid),
307
+ ...(dep.ink.join ? { strokeJoin: dep.ink.join } : {}),
308
+ ...(dep.ink.cap ? { strokeCap: dep.ink.cap } : {}),
309
+ } : {}),
310
+ ...(hidden(pid) ? { opacity: 0 } : {}),
311
+ // A PART STARTS AT ITS RESTING VALUE WHETHER OR NOT IT IS ANIMATED.
312
+ //
313
+ // The resting transform was written only into the keyframe track, and
314
+ // a part with no beats gets no track — so a channel declaring a rest
315
+ // other than 1 was ignored entirely for any figure nobody asked to
316
+ // move. Measured on the Open Peeps cast: an eyelid whose rest is
317
+ // scaleY 0.02 rendered at full height and sat over the eye, so twelve
318
+ // characters had their eyes shut until someone thought to blink them.
319
+ ...restingOf(pid),
320
+ },
321
+ },
322
+ });
323
+ }
324
+ // ── THE PERFORMANCE ───────────────────────────────────────────────────────
325
+ // One track per part, built from every beat that touches it, then written
326
+ // once. Writing a track per beat would leave the last one winning and the
327
+ // rest silently discarded.
328
+ const opacity = new Map();
329
+ const shiftX = new Map();
330
+ const transform = new Map();
331
+ const cycle = new Map();
332
+ /** A part's drawn extent, so a rig amplitude given as a fraction means the
333
+ * same thing at every size the figure is drawn. */
334
+ const partSize = (pid) => {
335
+ const b = pathBBox(dep.parts[pid]?.path ?? "");
336
+ return b ? { w: (b.maxX - b.minX) * k, h: (b.maxY - b.minY) * k } : { w: 10 * k, h: 10 * k };
337
+ };
338
+ const swap = (base, alt, from, to) => {
339
+ // A hard cut, not a dissolve: two drawings of one face cross-fading reads
340
+ // as a double exposure, not as a mouth opening.
341
+ const e = 0.001;
342
+ for (const p of matching(base))
343
+ opacity.set(p, [...(opacity.get(p) ?? []), { time: Math.max(0, from - e), opacity: 1 }, { time: from, opacity: 0 }, { time: to, opacity: 0 }, { time: to + e, opacity: 1 }]);
344
+ for (const p of matching(alt))
345
+ opacity.set(p, [...(opacity.get(p) ?? []), { time: Math.max(0, from - e), opacity: 0 }, { time: from, opacity: 1 }, { time: to, opacity: 1 }, { time: to + e, opacity: 0 }]);
346
+ };
347
+ for (const b of beats) {
348
+ const c = byName.get(b.channel);
349
+ /**
350
+ * A BEAT ON A CHANNEL THIS CONCEPT DOES NOT DECLARE WAS DROPPED IN SILENCE.
351
+ *
352
+ * `continue` alone, with no record. Measured across the shipped corpus
353
+ * 2026-09-18 by mutation — changing the channel name left the scene
354
+ * byte-identical — 28 beats were discarded this way: 14 `blink`,
355
+ * 10 `headTurn`, 4 `say`. The SAME channel names work on other characters,
356
+ * which is what makes it so easy to miss: an author writes `blink` for a
357
+ * cast of four, it animates on three, and the fourth just never blinks.
358
+ *
359
+ * Still dropped, because a beat cannot drive a channel that does not
360
+ * exist — only the silence is removed. The available names go with it,
361
+ * since "blink is not a channel here" is useless without "these are".
362
+ */
363
+ if (!c) {
364
+ unknownChannels.push(`character "${id}": beat at ${b.at}s names channel "${b.channel}", which this concept does not declare. ` +
365
+ `Available: ${channels.map((x) => x.name).join(", ") || "(none)"}.`);
366
+ continue;
367
+ }
368
+ if (c.kind === "alternate" && c.base && c.alternate) {
369
+ swap(c.base, c.alternate, b.at, b.at + (c.holdMs ?? 120) / 1000);
370
+ }
371
+ else if (c.kind === "speak" && c.states && c.states.length >= 2) {
372
+ /**
373
+ * SPEECH IS NOT A METRONOME, AND IT IS NOT TWO FACES.
374
+ *
375
+ * The first version alternated two WHOLE expressions six times a second.
376
+ * Both of those are wrong and the result was reported, correctly, as
377
+ * flashing: swapping whole faces changes the eyes and brows on every
378
+ * syllable, so the character strobes between two people, and an even 6Hz
379
+ * toggle is a machine rather than a mouth.
380
+ *
381
+ * So the states share their eyes — only the mouth differs between them —
382
+ * and a syllable OPENS AND CLOSES THROUGH THE MIDDLE STATE rather than
383
+ * cutting between extremes. That middle is the transition that was
384
+ * missing; with two states there is nowhere for the mouth to be on the
385
+ * way.
386
+ *
387
+ * The rhythm varies because real speech does. The golden ratio spreads
388
+ * the variation evenly without repeating, and it is deterministic, which
389
+ * the content-addressed render cache requires.
390
+ */
391
+ const until = b.until ?? b.at + 1;
392
+ const closed = c.states[0], open = c.states[c.states.length - 1];
393
+ const mid = c.states.length > 2 ? c.states[1] : open;
394
+ const rate = c.syllablesPerSecond ?? 3.6;
395
+ let t = b.at, k = 0;
396
+ while (t < until) {
397
+ // 0.72x to 1.28x of the nominal syllable, never the same twice running.
398
+ const jitter = 0.72 + 0.56 * (((k + 1) * 0.6180339887) % 1);
399
+ const dur = Math.min((1 / rate) * jitter, until - t);
400
+ if (dur < 0.05)
401
+ break;
402
+ // open on the way up, hold, close on the way down, then a beat shut.
403
+ swap(closed, mid, t, t + dur * 0.18);
404
+ swap(closed, open, t + dur * 0.18, t + dur * 0.58);
405
+ swap(closed, mid, t + dur * 0.58, t + dur * 0.76);
406
+ t += dur;
407
+ k++;
408
+ }
409
+ }
410
+ else if (c.kind === "alternate-cycle" && c.base && c.alternate) {
411
+ const until = b.until ?? b.at + 1;
412
+ const period = 1 / (c.rateHz ?? 6);
413
+ // Open on the first half of each period, closed on the second — the
414
+ // mouth has to CLOSE between syllables or the character gapes.
415
+ for (let t = b.at; t < until; t += period)
416
+ swap(c.base, c.alternate, t, Math.min(until, t + period / 2));
417
+ }
418
+ else if (c.kind === "transform" && c.part && c.property) {
419
+ /**
420
+ * A GESTURE IS A DEFORMATION, NOT A DIFFERENT DRAWING.
421
+ *
422
+ * The swap kinds above cut between two shapes, which is right when the
423
+ * shapes genuinely differ — an open eye and a closed lid are drawn, not
424
+ * squashed. It is wrong for everything that merely MOVES: a mouth
425
+ * opening is one mouth changing, and expressing it as a cut throws away
426
+ * the whole middle of the gesture. That missing middle is what reads as
427
+ * flashing however well the two ends are chosen.
428
+ *
429
+ * As a transform it has an attack, a peak and a release, it eases, and
430
+ * the same channel works on any part of any concept — a wing, a sail, a
431
+ * lid. Nothing here is specific to a face.
432
+ */
433
+ const rest = c.rest ?? 1, peak = c.peak ?? 1.6;
434
+ const attack = (c.attackMs ?? 90) / 1000, release = (c.releaseMs ?? 130) / 1000;
435
+ const hold = Math.max(0, (b.until ?? b.at + attack + release) - b.at - attack - release);
436
+ const ease = c.easing ?? "easeInOut";
437
+ const key = (t, v) => ({ time: Math.max(0, t), [c.property]: v, easing: ease });
438
+ for (const p of matching(c.part)) {
439
+ transform.set(p, [...(transform.get(p) ?? []),
440
+ key(b.at, rest), key(b.at + attack, peak),
441
+ key(b.at + attack + hold, peak), key(b.at + attack + hold + release, rest)]);
442
+ }
443
+ }
444
+ else if (c.kind === "cycle" && c.part && c.property) {
445
+ /**
446
+ * A CYCLE IS THE GRAPH'S OWN RIG, FINALLY CONSUMED.
447
+ *
448
+ * `concept_rig` has described exactly this since the class layer was
449
+ * built — a part, a property, an amplitude and a duration — and nothing
450
+ * has ever read it. It is not a gesture with an end: a pigeon bobs while
451
+ * it walks, a tail flicks the whole time it is on screen, so this repeats
452
+ * for as long as the beat asks rather than resolving.
453
+ *
454
+ * THE THROW IS ASYMMETRIC. A sine reads as a metronome and a pigeon does
455
+ * not bob like one: the head goes forward fast and the body catches up
456
+ * slowly. Forward in the first third, recover across the rest.
457
+ *
458
+ * Amplitude is a FRACTION OF THE PART for x and y, degrees for rotation,
459
+ * which is how the graph states it — a pixel amplitude would mean
460
+ * something different at every size the figure is drawn.
461
+ */
462
+ const until = b.until ?? durationSeconds;
463
+ const period = (c.durationMs ?? 800) / 1000;
464
+ const ease = c.easing ?? "easeInOut";
465
+ /**
466
+ * THE THROW, STATED IN THE SYSTEM'S VOCABULARY.
467
+ *
468
+ * A cycle is asymmetric on purpose — a pigeon throws its head forward
469
+ * and the body catches up — and the asymmetry used to be a bare 0.33 of
470
+ * the period. That produced intervals belonging to no scale: an 800ms
471
+ * bob split into 264 and 536, and a piece made mostly of cycles came out
472
+ * at 20% on the duration scale, which is the definition of not having a
473
+ * timing system.
474
+ *
475
+ * Rounding the ATTACK onto the scale and giving the remainder to the
476
+ * release keeps the shape and puts both halves on it: 800 becomes
477
+ * 300/500, 1200 becomes 400/800. The asymmetry survives; the arbitrary
478
+ * number does not.
479
+ */
480
+ const attack = nearestOnScale(period * 0.33 * 1000) / 1000;
481
+ for (const pid of matching(c.part)) {
482
+ // A carried part measures against what carries it — see `spanPart`.
483
+ const ref = c.spanPart ? (matching(c.spanPart)[0] ?? pid) : pid;
484
+ const span = partSize(ref);
485
+ const amp = c.property === "rotation" ? (c.amplitude ?? 10)
486
+ : (c.amplitude ?? 0.3) * (c.property === "x" ? span.w : span.h);
487
+ const acc = [];
488
+ for (let t = b.at; t < until; t += period) {
489
+ acc.push({ time: t, [c.property]: 0, easing: ease });
490
+ acc.push({ time: Math.min(until, t + attack), [c.property]: amp, easing: ease });
491
+ }
492
+ acc.push({ time: until, [c.property]: 0, easing: ease });
493
+ cycle.set(pid, [...(cycle.get(pid) ?? []), ...acc]);
494
+ /**
495
+ * CARRIED PARTS MOVE WITH THEIR CARRIER, not alongside it.
496
+ *
497
+ * The same offsets, the same object, propagated down the carrier chain
498
+ * — so there is no second computation that could round differently,
499
+ * ease differently, or start a beat a frame late. This is the whole
500
+ * fix: a figure cannot come apart at a joint it has no independent
501
+ * motion across.
502
+ */
503
+ for (const child of carriedTransitively(pid)) {
504
+ cycle.set(child, [...(cycle.get(child) ?? []), ...carry(acc, pid, child, c.property)]);
505
+ }
506
+ }
507
+ }
508
+ else if (c.kind === "shift") {
509
+ /**
510
+ * A SHIFT MOVES WHAT IT NAMES — and this moved everything.
511
+ *
512
+ * `part` was read for every other kind of channel and ignored here, so a
513
+ * shift always displaced the ENTIRE figure. `pp:Character.headTurn` got
514
+ * away with it because that depiction is a portrait: head, face and
515
+ * features, no body, so "every part" and "the head" are the same set.
516
+ * `pp:Peep` has body_0..2, and the same channel would slide the whole
517
+ * person sideways to turn their head.
518
+ *
519
+ * Declaring a part now narrows it, and carried parts come along — so a
520
+ * head turn takes the face, the hair and the eyelids with it, which is
521
+ * the entire reason the bone exists. A channel that names nothing still
522
+ * moves everything, because that is what the portrait relies on.
523
+ */
524
+ const dx = (b.value ?? 0) * (c.unitsPerValue ?? 9) * k;
525
+ const settle = b.until ?? b.at + 0.6;
526
+ const named = c.part ? matching(c.part) : partIds;
527
+ const moved = new Set(named);
528
+ for (const pid of named)
529
+ for (const child of carriedTransitively(pid))
530
+ moved.add(child);
531
+ for (const p of moved) {
532
+ shiftX.set(p, [...(shiftX.get(p) ?? []), { time: Math.max(0, b.at - 0.001), dx: 0 }, { time: settle, dx }]);
533
+ }
534
+ }
535
+ }
536
+ // Position tracks need ABSOLUTE x, and a part's x is its own placed centre.
537
+ let tracks = 0;
538
+ for (const pid of partIds) {
539
+ const p = dep.parts[pid];
540
+ const baseX = at.x + p.at[0] * k;
541
+ const kfs = [];
542
+ for (const s of shiftX.get(pid) ?? [])
543
+ kfs.push({ time: s.time, x: baseX + s.dx });
544
+ for (const o of opacity.get(pid) ?? [])
545
+ kfs.push(o);
546
+ for (const tr of transform.get(pid) ?? [])
547
+ kfs.push(tr);
548
+ // A cycle's x/y are OFFSETS from where the part sits; everything else in a
549
+ // keyframe track is absolute, so they are added here rather than earlier.
550
+ for (const cy of cycle.get(pid) ?? []) {
551
+ const out = { time: cy.time, ...(cy.easing ? { easing: cy.easing } : {}) };
552
+ if (typeof cy.x === "number")
553
+ out.x = baseX + cy.x;
554
+ if (typeof cy.y === "number")
555
+ out.y = at.y + p.at[1] * k + cy.y;
556
+ if (typeof cy.rotation === "number")
557
+ out.rotation = cy.rotation;
558
+ if (typeof cy.scaleY === "number")
559
+ out.scaleY = cy.scaleY;
560
+ kfs.push(out);
561
+ }
562
+ if (!kfs.length)
563
+ continue;
564
+ // EVERY TRACK STARTS AT ZERO.
565
+ //
566
+ // An item whose first keyframe is at t=5 is INVISIBLE for the five seconds
567
+ // before it — measured: the head appeared exactly when its shift track
568
+ // began and the face exactly when its blink track began, and the first
569
+ // second of the film was an empty frame. The engine's own player treats
570
+ // "before the track" as "not yet", and this clock runs before it, so the
571
+ // player has the last word.
572
+ //
573
+ // Anchoring at zero with the baseline removes the state entirely rather
574
+ // than arguing with it: there is no "before the track" if the track covers
575
+ // the whole piece.
576
+ const restingTransform = {};
577
+ for (const c of channels) {
578
+ if (c.kind !== "transform" || !c.part || !c.property)
579
+ continue;
580
+ if (matching(c.part).includes(pid))
581
+ restingTransform[c.property] = c.rest ?? 1;
582
+ }
583
+ kfs.push({ time: 0, x: baseX, opacity: hidden(pid) ? 0 : 1, ...restingTransform });
584
+ // NOTHING ORGANIC MOVES AT A CONSTANT SPEED, and a rig is organic by
585
+ // definition. The transform and cycle channels set their own easing; the
586
+ // head-turn shift, the resting anchor and the closing hold did not, so a
587
+ // turn slid across the frame at a dead constant rate — the one tell that
588
+ // reads as "animated by a machine" no matter how good the drawing is.
589
+ // An explicit easing always wins; this only fills the silence.
590
+ const track = tidy(kfs).map((kf) => (kf.easing ? kf : { ...kf, easing: "easeInOut" }));
591
+ /**
592
+ * A track that never returns leaves the figure mid-gesture at the cut — and
593
+ * the hold must carry EVERY property the track animates, not whichever one
594
+ * the last keyframe happened to set.
595
+ *
596
+ * Cloning the final keyframe looked right and was not: the eye's last
597
+ * keyframe is a blink, which sets scaleY alone, so the closing hold dropped
598
+ * x. Every other part of the head ended with an explicit x at the duration
599
+ * and the eye did not — one keyframe's difference between a bound part and
600
+ * its carrier, which is exactly the kind of divergence the bone exists to
601
+ * make impossible. It was harmless here only because the held value and the
602
+ * last value agreed; that is luck, not a guarantee.
603
+ */
604
+ const last = track[track.length - 1];
605
+ if (last.time < durationSeconds) {
606
+ const held = { time: durationSeconds };
607
+ for (const kf of track) {
608
+ for (const [k, v] of Object.entries(kf)) {
609
+ if (k === "time" || k === "easing")
610
+ continue;
611
+ held[k] = v;
612
+ }
613
+ }
614
+ if (last.easing)
615
+ held.easing = last.easing;
616
+ track.push(held);
617
+ }
618
+ ops.push({
619
+ tool: "pinepaper_keyframe_animate",
620
+ args: { itemId: `${id}_${pid}`, duration: durationSeconds, keyframes: track },
621
+ });
622
+ tracks++;
623
+ }
624
+ return { ops, parts: partIds.map((p) => `${id}_${p}`), tracks, unknownChannels };
625
+ }
626
+ //# sourceMappingURL=perform.js.map