@nodetool-ai/execution 0.8.1 → 0.8.2

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 (158) hide show
  1. package/README.md +23 -127
  2. package/dist/.nodetool-build-stamp +1 -1
  3. package/dist/app-debug/app-spec.d.ts.map +1 -1
  4. package/dist/app-debug/app-spec.js +3 -0
  5. package/dist/app-debug/app-spec.js.map +1 -1
  6. package/dist/app-debug/application-target.d.ts +1 -1
  7. package/dist/app-debug/application-target.d.ts.map +1 -1
  8. package/dist/app-debug/application-target.js +5 -3
  9. package/dist/app-debug/application-target.js.map +1 -1
  10. package/dist/app-debug/index.d.ts +4 -2
  11. package/dist/app-debug/index.d.ts.map +1 -1
  12. package/dist/app-debug/index.js +3 -2
  13. package/dist/app-debug/index.js.map +1 -1
  14. package/dist/app-debug/run-recording.d.ts +20 -0
  15. package/dist/app-debug/run-recording.d.ts.map +1 -0
  16. package/dist/app-debug/run-recording.js +105 -0
  17. package/dist/app-debug/run-recording.js.map +1 -0
  18. package/dist/app-debug/runtime.d.ts +2 -0
  19. package/dist/app-debug/runtime.d.ts.map +1 -1
  20. package/dist/app-debug/runtime.js +14 -3
  21. package/dist/app-debug/runtime.js.map +1 -1
  22. package/dist/app-debug/simulate.d.ts +10 -1
  23. package/dist/app-debug/simulate.d.ts.map +1 -1
  24. package/dist/app-debug/simulate.js +57 -26
  25. package/dist/app-debug/simulate.js.map +1 -1
  26. package/dist/app-debug/summarize.d.ts +1 -0
  27. package/dist/app-debug/summarize.d.ts.map +1 -1
  28. package/dist/app-debug/summarize.js +1 -0
  29. package/dist/app-debug/summarize.js.map +1 -1
  30. package/dist/app-debug/types.d.ts +6 -4
  31. package/dist/app-debug/types.d.ts.map +1 -1
  32. package/dist/app-run-generation.d.ts +8 -0
  33. package/dist/app-run-generation.d.ts.map +1 -0
  34. package/dist/app-run-generation.js +61 -0
  35. package/dist/app-run-generation.js.map +1 -0
  36. package/dist/browser-app-run.d.ts +10 -0
  37. package/dist/browser-app-run.d.ts.map +1 -0
  38. package/dist/browser-app-run.js +108 -0
  39. package/dist/browser-app-run.js.map +1 -0
  40. package/dist/browser-run-spans.d.ts +4 -0
  41. package/dist/browser-run-spans.d.ts.map +1 -0
  42. package/dist/browser-run-spans.js +115 -0
  43. package/dist/browser-run-spans.js.map +1 -0
  44. package/dist/browser-trace-policy.d.ts +12 -0
  45. package/dist/browser-trace-policy.d.ts.map +1 -0
  46. package/dist/browser-trace-policy.js +40 -0
  47. package/dist/browser-trace-policy.js.map +1 -0
  48. package/dist/cost-ledger.d.ts +3 -0
  49. package/dist/cost-ledger.d.ts.map +1 -1
  50. package/dist/cost-ledger.js +23 -2
  51. package/dist/cost-ledger.js.map +1 -1
  52. package/dist/debug/types.d.ts +1 -0
  53. package/dist/debug/types.d.ts.map +1 -1
  54. package/dist/generation-lifecycle.d.ts +5 -4
  55. package/dist/generation-lifecycle.d.ts.map +1 -1
  56. package/dist/generation-lifecycle.js +71 -10
  57. package/dist/generation-lifecycle.js.map +1 -1
  58. package/dist/generation-recovery-worker.d.ts.map +1 -1
  59. package/dist/generation-recovery-worker.js +2 -0
  60. package/dist/generation-recovery-worker.js.map +1 -1
  61. package/dist/generation-tracker.d.ts +2 -15
  62. package/dist/generation-tracker.d.ts.map +1 -1
  63. package/dist/generation-tracker.js +92 -6
  64. package/dist/generation-tracker.js.map +1 -1
  65. package/dist/index.d.ts +9 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +6 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/js-script-debug/index.d.ts +1 -0
  70. package/dist/js-script-debug/index.d.ts.map +1 -1
  71. package/dist/js-script-debug/index.js +1 -0
  72. package/dist/js-script-debug/index.js.map +1 -1
  73. package/dist/js-script-debug/type-check.d.ts +16 -0
  74. package/dist/js-script-debug/type-check.d.ts.map +1 -0
  75. package/dist/js-script-debug/type-check.js +139 -0
  76. package/dist/js-script-debug/type-check.js.map +1 -0
  77. package/dist/js-script-debug/validate.d.ts +8 -0
  78. package/dist/js-script-debug/validate.d.ts.map +1 -1
  79. package/dist/js-script-debug/validate.js +4 -0
  80. package/dist/js-script-debug/validate.js.map +1 -1
  81. package/dist/message-stream.d.ts +1 -32
  82. package/dist/message-stream.d.ts.map +1 -1
  83. package/dist/message-stream.js +1 -57
  84. package/dist/message-stream.js.map +1 -1
  85. package/dist/preflight.d.ts +43 -2
  86. package/dist/preflight.d.ts.map +1 -1
  87. package/dist/preflight.js +78 -4
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/run-trace-store.d.ts +9 -0
  90. package/dist/run-trace-store.d.ts.map +1 -0
  91. package/dist/run-trace-store.js +57 -0
  92. package/dist/run-trace-store.js.map +1 -0
  93. package/dist/runs.d.ts +16 -0
  94. package/dist/runs.d.ts.map +1 -0
  95. package/dist/runs.js +431 -0
  96. package/dist/runs.js.map +1 -0
  97. package/dist/service/app-debug-service.d.ts +4 -2
  98. package/dist/service/app-debug-service.d.ts.map +1 -1
  99. package/dist/service/app-debug-service.js +73 -31
  100. package/dist/service/app-debug-service.js.map +1 -1
  101. package/dist/service/app-operation.d.ts +38 -0
  102. package/dist/service/app-operation.d.ts.map +1 -0
  103. package/dist/service/app-operation.js +269 -0
  104. package/dist/service/app-operation.js.map +1 -0
  105. package/dist/service/app-run-server.d.ts +3 -1
  106. package/dist/service/app-run-server.d.ts.map +1 -1
  107. package/dist/service/app-run-server.js +22 -14
  108. package/dist/service/app-run-server.js.map +1 -1
  109. package/dist/service/index.d.ts +4 -0
  110. package/dist/service/index.d.ts.map +1 -1
  111. package/dist/service/index.js +2 -0
  112. package/dist/service/index.js.map +1 -1
  113. package/dist/service/run-trace-lifecycle.d.ts +34 -0
  114. package/dist/service/run-trace-lifecycle.d.ts.map +1 -0
  115. package/dist/service/run-trace-lifecycle.js +135 -0
  116. package/dist/service/run-trace-lifecycle.js.map +1 -0
  117. package/dist/service/workflow-run.d.ts +5 -16
  118. package/dist/service/workflow-run.d.ts.map +1 -1
  119. package/dist/service/workflow-run.js +54 -52
  120. package/dist/service/workflow-run.js.map +1 -1
  121. package/dist/session.d.ts +1 -1
  122. package/dist/session.d.ts.map +1 -1
  123. package/dist/session.js +109 -100
  124. package/dist/session.js.map +1 -1
  125. package/dist/sketch-debug/markdown.d.ts.map +1 -1
  126. package/dist/sketch-debug/markdown.js +1 -0
  127. package/dist/sketch-debug/markdown.js.map +1 -1
  128. package/dist/sketch-debug/report.d.ts.map +1 -1
  129. package/dist/sketch-debug/report.js +4 -4
  130. package/dist/sketch-debug/report.js.map +1 -1
  131. package/dist/sketch-debug/types.d.ts +2 -0
  132. package/dist/sketch-debug/types.d.ts.map +1 -1
  133. package/dist/timeline-debug/frame-geometry.d.ts +118 -0
  134. package/dist/timeline-debug/frame-geometry.d.ts.map +1 -0
  135. package/dist/timeline-debug/frame-geometry.js +231 -0
  136. package/dist/timeline-debug/frame-geometry.js.map +1 -0
  137. package/dist/timeline-debug/index.d.ts +1 -0
  138. package/dist/timeline-debug/index.d.ts.map +1 -1
  139. package/dist/timeline-debug/index.js +1 -0
  140. package/dist/timeline-debug/index.js.map +1 -1
  141. package/dist/timeline-debug/legibility.d.ts +8 -4
  142. package/dist/timeline-debug/legibility.d.ts.map +1 -1
  143. package/dist/timeline-debug/legibility.js +47 -18
  144. package/dist/timeline-debug/legibility.js.map +1 -1
  145. package/dist/timeline-debug/score.d.ts +110 -0
  146. package/dist/timeline-debug/score.d.ts.map +1 -0
  147. package/dist/timeline-debug/score.js +231 -0
  148. package/dist/timeline-debug/score.js.map +1 -0
  149. package/dist/timeline-debug/showcase.d.ts +12 -0
  150. package/dist/timeline-debug/showcase.d.ts.map +1 -1
  151. package/dist/timeline-debug/showcase.js +669 -13
  152. package/dist/timeline-debug/showcase.js.map +1 -1
  153. package/dist/timeline-debug/validate.d.ts.map +1 -1
  154. package/dist/timeline-debug/validate.js +121 -2
  155. package/dist/timeline-debug/validate.js.map +1 -1
  156. package/dist/types.d.ts +18 -10
  157. package/dist/types.d.ts.map +1 -1
  158. package/package.json +13 -13
@@ -1,24 +1,34 @@
1
+ import { isRecord } from "@nodetool-ai/protocol";
1
2
  import { compileClipAnimations, isKnownShapeKind, parseClipEffectType, resolveBeatAnimations } from "@nodetool-ai/timeline";
3
+ import { buildShapeSegments, flattenSegments, resolveClipLayoutsWithDiagnostics, trimFlatPath } from "@nodetool-ai/timeline/scene";
4
+ import { ancestorChain, composedMatrix, containsBox, frameBoxFor, hasPerspectiveAncestor, isBackgroundBed, projectPoint } from "./frame-geometry.js";
2
5
  const MIN_LAYERS = 3;
3
6
  const MIN_COVERAGE = 0.8;
4
- // Shipped showcases (excluding t-minus-30), measured from their visible spans:
5
- // kite: 23 groups, 100/15 custom/s, 6 effect types, 140/15 clips/s
6
- // prism: 16, 55/18, 10, 87/18; serein: 60, 173/26, 7, 678/26
7
- // tidewater: 22, 39/16, 6, 180/16; voltra: 12, 103/23, 13, 124/23.
8
- // Medians: 22 groups, 103/23 custom/s, 7 effects, 140/15 clips/s.
7
+ // Shipped showcases (excluding t-minus-30), measured from their visible spans —
8
+ // re-measured from `packages/execution/tests/timeline-showcase.test.ts`'s own
9
+ // "measures the medians used in the validator derivation" test, which computes
10
+ // these straight from the shipped files; re-run it and update both this
11
+ // comment and the two constants below together whenever an example changes
12
+ // its groups/animations/effects/clip count, so this file and that test never
13
+ // silently disagree about what "the shipped examples" measure to.
14
+ // cadence: 29 groups, 106000/17767 custom/s, 6 effect types, 163000/17767 clips/s
15
+ // kite: 32, 95/15, 6, 149/15; prism: 22, 57000/18467, 10, 95000/18467
16
+ // serein: 145, 172/26, 7, 764/26; tidewater: 23, 41000/15958, 6, 181000/15958
17
+ // voltra: 16, 125000/22735, 14, 130000/22735.
18
+ // Medians (the upper one of six): 29 groups, 106000/17767 custom/s, 7 effects, 149/15 clips/s.
9
19
  // A median floor would reject shipped examples. Use their lower envelope for
10
20
  // warnings, report the median target, and require rendered example comparison.
11
- const SHOWCASE_FLOOR = {
12
- groups: 12,
13
- customPerSecond: 39 / 16,
21
+ export const SHOWCASE_FLOOR = {
22
+ groups: 16,
23
+ customPerSecond: 41000 / 15958,
14
24
  effects: 6,
15
- clipsPerSecond: 87 / 18
25
+ clipsPerSecond: 95000 / 18467
16
26
  };
17
- const SHOWCASE_TARGET = {
18
- groups: 22,
19
- customPerSecond: 103 / 23,
27
+ export const SHOWCASE_TARGET = {
28
+ groups: 29,
29
+ customPerSecond: 106000 / 17767,
20
30
  effects: 7,
21
- clipsPerSecond: 140 / 15
31
+ clipsPerSecond: 149 / 15
22
32
  };
23
33
  // Segment-average speed, normalized to canvas dimensions. Sharp cuts can
24
34
  // intentionally exceed this proxy, so the skill requires a visual justification.
@@ -159,6 +169,640 @@ function layerCoverage(windows) {
159
169
  }
160
170
  return covered / (last.time - first.time);
161
171
  }
172
+ // ── Craft checks: frame coverage, text collision, motion range, dead motion ──
173
+ //
174
+ // Static geometry only, same rule as `hasVisualContent`: no rendered pixels,
175
+ // no sampled animation curves, no occlusion. A box is the clip's own authored
176
+ // transform projected into frame pixels — the scene model's placement math
177
+ // (`buildTransformMatrix`, `layoutTextBlock`), not a second implementation of
178
+ // it — composed through every ancestor group's transform in one matrix, the
179
+ // way `parentMatrix` composes it for a real render.
180
+ /**
181
+ * Any shape's stroked outline as chords in frame pixels — rect, ellipse,
182
+ * polygon, star and an authored `path` all resolve through the one geometry
183
+ * builder every render surface draws through (`buildShapeSegments`), flattened
184
+ * to a polyline (`flattenSegments`) so a curve's chords track it closely. Used
185
+ * to test whether the *stroke itself* crosses a box, not just its bounding
186
+ * rectangle — a ring's bbox looks solid but its ink is a thin ring around an
187
+ * empty middle, and a ray's bbox can cover most of the frame while the
188
+ * 1px-wide line inside it touches almost none of that area. Respects
189
+ * `trimStart`/`trimEnd` (the clip's own authored values — this file samples
190
+ * statically, not an animated instant) so a partially-drawn stroke does not
191
+ * collide along the portion it never draws.
192
+ */
193
+ function shapeStrokeChordsInFrame(clip, shape, canvas, byId, resolved, sizes) {
194
+ // A flex-managed shape's stroke has to be built at its *resolved* size,
195
+ // not its authored `width`/`height` (see `localBox` in `frame-geometry.ts`
196
+ // for the same gap on a filled plate) — a ring or an outlined pill
197
+ // stretched to fill a flex slot draws its outline at that stretched size.
198
+ // `sizes` gives only the size, pinned to the top-left the same way a
199
+ // resized plate's local box is: `clip`'s *position* still comes from the
200
+ // ordinary `resolved`/`composedMatrix` path below, flex-resized or not, so
201
+ // this shape gets the one position/rotation/scale composition every other
202
+ // clip goes through rather than a second, absolute-box-shaped one.
203
+ const size = sizes?.get(clip.id);
204
+ const effectiveShape = size
205
+ ? { ...shape, x: 0, y: 0, width: size.width / canvas.width, height: size.height / canvas.height }
206
+ : shape;
207
+ const segments = buildShapeSegments(effectiveShape, canvas.width, canvas.height);
208
+ if (!segments) {
209
+ return undefined;
210
+ }
211
+ const flat = flattenSegments(segments);
212
+ const trimStart = shape.trimStart ?? 0;
213
+ const trimEnd = shape.trimEnd ?? 1;
214
+ const runs = trimStart > 0 || trimEnd < 1 ? trimFlatPath(flat, trimStart, trimEnd) : flat.map((sub) => sub.points);
215
+ const matrix = composedMatrix(clip, canvas, byId, resolved);
216
+ const chords = [];
217
+ for (const points of runs) {
218
+ const projected = points.map((p) => projectPoint(p.x, p.y, matrix, canvas));
219
+ for (let i = 1; i < projected.length; i += 1) {
220
+ chords.push([projected[i - 1], projected[i]]);
221
+ }
222
+ }
223
+ return chords;
224
+ }
225
+ function intersectionArea(a, b) {
226
+ const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
227
+ const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
228
+ return w > 0 && h > 0 ? w * h : 0;
229
+ }
230
+ function pointInBox(p, box) {
231
+ return p.x >= box.x && p.x <= box.x + box.width && p.y >= box.y && p.y <= box.y + box.height;
232
+ }
233
+ function cross(o, a, b) {
234
+ return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
235
+ }
236
+ /** Proper (strict) segment-segment crossing. Touching/collinear endpoints
237
+ * are not reported as a crossing — a stroke ending exactly on a box edge is
238
+ * not the "cuts through" case this proxy is built to catch, and treating it
239
+ * as one would false-positive on every hairline-tolerance coincidence. */
240
+ function segmentsCross(p1, p2, p3, p4) {
241
+ const d1 = cross(p3, p4, p1);
242
+ const d2 = cross(p3, p4, p2);
243
+ const d3 = cross(p1, p2, p3);
244
+ const d4 = cross(p1, p2, p4);
245
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
246
+ }
247
+ /** Whether the stroke `p1..p2`, inflated by half its width, reaches `box` —
248
+ * a real proximity test against the line itself, not its bounding box. */
249
+ function segmentIntersectsBox(p1, p2, box, inflatePx) {
250
+ const inflated = {
251
+ x: box.x - inflatePx,
252
+ y: box.y - inflatePx,
253
+ width: box.width + 2 * inflatePx,
254
+ height: box.height + 2 * inflatePx
255
+ };
256
+ if (pointInBox(p1, inflated) || pointInBox(p2, inflated)) {
257
+ return true;
258
+ }
259
+ const corners = [
260
+ { x: inflated.x, y: inflated.y },
261
+ { x: inflated.x + inflated.width, y: inflated.y },
262
+ { x: inflated.x + inflated.width, y: inflated.y + inflated.height },
263
+ { x: inflated.x, y: inflated.y + inflated.height }
264
+ ];
265
+ for (let i = 0; i < 4; i += 1) {
266
+ if (segmentsCross(p1, p2, corners[i], corners[(i + 1) % 4])) {
267
+ return true;
268
+ }
269
+ }
270
+ return false;
271
+ }
272
+ function clipToCanvas(box, canvas) {
273
+ const x0 = Math.max(0, box.x);
274
+ const y0 = Math.max(0, box.y);
275
+ const x1 = Math.min(canvas.width, box.x + box.width);
276
+ const y1 = Math.min(canvas.height, box.y + box.height);
277
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
278
+ }
279
+ /** Exact union area of axis-aligned boxes by coordinate-compression grid.
280
+ * Scene-scale inputs (tens of boxes), so the O(n²) grid is cheap. */
281
+ function unionArea(boxes, canvas) {
282
+ const clipped = boxes.map((box) => clipToCanvas(box, canvas)).filter((box) => box.width > 0 && box.height > 0);
283
+ if (clipped.length === 0) {
284
+ return 0;
285
+ }
286
+ const xs = Array.from(new Set(clipped.flatMap((box) => [box.x, box.x + box.width]))).sort((a, b) => a - b);
287
+ const ys = Array.from(new Set(clipped.flatMap((box) => [box.y, box.y + box.height]))).sort((a, b) => a - b);
288
+ let area = 0;
289
+ for (let i = 0; i < xs.length - 1; i += 1) {
290
+ for (let j = 0; j < ys.length - 1; j += 1) {
291
+ const cx = (xs[i] + xs[i + 1]) / 2;
292
+ const cy = (ys[j] + ys[j + 1]) / 2;
293
+ if (clipped.some((box) => cx >= box.x && cx <= box.x + box.width && cy >= box.y && cy <= box.y + box.height)) {
294
+ area += (xs[i + 1] - xs[i]) * (ys[j + 1] - ys[j]);
295
+ }
296
+ }
297
+ }
298
+ return area;
299
+ }
300
+ function childrenByParentMap(doc) {
301
+ const children = new Map();
302
+ for (const clip of doc.clips) {
303
+ if (!clip.parentId) {
304
+ continue;
305
+ }
306
+ const siblings = children.get(clip.parentId) ?? [];
307
+ siblings.push(clip);
308
+ children.set(clip.parentId, siblings);
309
+ }
310
+ return children;
311
+ }
312
+ function descendantClips(sceneId, children) {
313
+ const result = [];
314
+ const stack = [...(children.get(sceneId) ?? [])];
315
+ while (stack.length > 0) {
316
+ const clip = stack.pop();
317
+ result.push(clip);
318
+ if (clip.mediaType === "group") {
319
+ stack.push(...(children.get(clip.id) ?? []));
320
+ }
321
+ }
322
+ return result;
323
+ }
324
+ // Measured on every shipped scene, at five sample points (10/30/50/70/90% of
325
+ // each scene's visible span; kite, prism, serein, tidewater, voltra —
326
+ // t-minus-30 excluded, as above): coverage ranges from serein's S4a
327
+ // ("Security" panel, a deliberately minimal beat) at 4.2% up past 80%. The
328
+ // floor sits with margin under that lowest legitimate beat.
329
+ //
330
+ // This still leaves the check weak against the pattern the agent fixture
331
+ // shows. Its own six scenes measure 7.1%-17.3% — every one of them *above*
332
+ // this floor and inside the range legitimate scenes occupy, so nothing here
333
+ // fires on it, even though every rendered frame is a small ring on an
334
+ // otherwise empty field. The reason: AABB union coverage cannot see ink
335
+ // density. A thin ring's *bounding box* is the same size a chunky filled
336
+ // shape covering the same span would have; the metric has no way to tell
337
+ // "a shape fills this region" from "a hairline outline passes through this
338
+ // region leaving most of it empty." A pixel-sampling or stroke-area-aware
339
+ // metric would separate them; this one does not. Flagged rather than forced
340
+ // — a floor cannot fix a metric that is measuring the wrong thing.
341
+ const FRAME_COVERAGE_FLOOR = 0.03;
342
+ /** Frame content is a ~300px ring in a 1920x1080 field: dense structure,
343
+ * near-empty frame. Union AABB coverage of visible non-background layers,
344
+ * sampled through each scene, catches it without a pixel render. */
345
+ function checkFrameUnderfilled(doc, canvas, windows, byId, children, resolved, sizes) {
346
+ const issues = [];
347
+ const scenes = doc.clips.filter((clip) => clip.mediaType === "group" && !clip.parentId);
348
+ for (const scene of scenes) {
349
+ const sceneWindow = windows.get(scene.id);
350
+ if (!sceneWindow) {
351
+ continue;
352
+ }
353
+ // Unlike text_collision, a perspective-tilted clip is not excluded here:
354
+ // a projective AABB is unsound for a *precise* box, but coverage only
355
+ // asks "roughly how much of the frame has content on it", and an
356
+ // untilted footprint is a closer read of that than the real (tilted) one
357
+ // — excluding it entirely is worse either way: a whole tilted card
358
+ // (kite's S4 chart, 29 clips under one `rotationX`/`perspective` group)
359
+ // then reads as an empty frame with two small text stacks floating in
360
+ // it. (A suspected cache-key collision in `buildTransformMatrix` — two
361
+ // parent matrices differing only by tilt hashing to the same key —
362
+ // turned out not to reproduce once tested directly against a
363
+ // cache-bypassing reference; see packages/timeline
364
+ // `tests/render.transform.test.ts`. `affineKey` samples exactly the
365
+ // entries a tilt varies.)
366
+ const content = descendantClips(scene.id, children).filter((clip) => hasVisualContent(clip) && windows.get(clip.id));
367
+ if (content.length === 0) {
368
+ continue;
369
+ }
370
+ let best = 0;
371
+ for (const frac of [0.1, 0.3, 0.5, 0.7, 0.9]) {
372
+ const t = sceneWindow.start + frac * (sceneWindow.end - sceneWindow.start);
373
+ const boxes = [];
374
+ for (const clip of content) {
375
+ const window = windows.get(clip.id);
376
+ if (t < window.start || t >= window.end) {
377
+ continue;
378
+ }
379
+ const box = frameBoxFor(clip, canvas, byId, resolved, undefined, true, sizes);
380
+ if (!box || box.width <= 0 || box.height <= 0 || isBackgroundBed(box, canvas)) {
381
+ continue;
382
+ }
383
+ boxes.push(box);
384
+ }
385
+ best = Math.max(best, unionArea(boxes, canvas) / (canvas.width * canvas.height));
386
+ }
387
+ if (best < FRAME_COVERAGE_FLOOR) {
388
+ issues.push({
389
+ severity: "warning",
390
+ code: "showcase_frame_underfilled",
391
+ clipId: scene.id,
392
+ message: `Frame is underfilled: "${scene.name || scene.id}"'s visible content covers at most ${(best * 100).toFixed(1)}% of the frame at its sampled instants, under the ${(FRAME_COVERAGE_FLOOR * 100).toFixed(0)}% floor. See kite: bed, midground and foreground fill the field, not a single centred mark. Add supporting geometry, type or a bed that reaches the frame edges.`
393
+ });
394
+ }
395
+ }
396
+ return issues;
397
+ }
398
+ /** A shape with a stroke and no fill has ink only along its outline — of any
399
+ * kind, including a hand-drawn `path` ring. This must stay independent of
400
+ * `kind`: gating it to "ellipse"/"rect" made every unfilled `path` ring (the
401
+ * shape the real ring-crosses-text defect uses) read as "filled" by omission
402
+ * and fall into the opaque-backdrop z-order skip below, so a stroke-only ring
403
+ * drawn behind text never got checked at all. */
404
+ function isUnfilled(style) {
405
+ if (!style.stroke || !((style.strokeWidthPx ?? 0) > 0)) {
406
+ return false;
407
+ }
408
+ if (style.fillStyle) {
409
+ return false;
410
+ }
411
+ const fill = typeof style.fill === "string" ? style.fill : undefined;
412
+ return fill === undefined || fill === "transparent" || fill === "none";
413
+ }
414
+ /**
415
+ * `layoutTextBlock`'s box is the full line-height leading, not the glyphs'
416
+ * ink — a title and a label stacked with a deliberately tight gap (a common,
417
+ * legitimate motion-design pattern) then read as colliding even though no
418
+ * pixel touches. Shrinking a text box toward its true vertical ink extent
419
+ * before comparing it to *another text clip* keeps the leading out of the
420
+ * comparison; a shape's box is real geometry already and is left alone.
421
+ *
422
+ * The retained fraction depends on case: mixed case adds ascenders above and
423
+ * descenders below its cap line; all-caps has no descenders but, at a bold
424
+ * weight and large size, its cap height alone already reaches ~0.72em of a
425
+ * 1.2em line box (see "HALO", 190px/800-weight — its rendered glyphs reach
426
+ * close to the line box edge). Calibrated against two failure directions at
427
+ * once: too low under-shrinks all-caps and misses a real crossing (that
428
+ * "HALO" against "Find your focus." below it); too high over-shrinks mixed
429
+ * case and flags tightly leaded captions that never touch in a render (kite
430
+ * "Every purchase" / "rounds up to savings.", tidewater "Three warm nights"
431
+ * / "of jazz on the pier.").
432
+ */
433
+ const ALL_CAPS_INK_HEIGHT_FRAC = 0.72;
434
+ const MIXED_CASE_INK_HEIGHT_FRAC = 0.7;
435
+ function textInkBox(box, text) {
436
+ const frac = /[a-z]/.test(text) ? MIXED_CASE_INK_HEIGHT_FRAC : ALL_CAPS_INK_HEIGHT_FRAC;
437
+ const shrink = (box.height * (1 - frac)) / 2;
438
+ return { x: box.x, y: box.y + shrink, width: box.width, height: box.height - 2 * shrink };
439
+ }
440
+ /** How far an enabled `dropShadow` effect can visually extend a clip's hard
441
+ * footprint — its offset, not its blur radius. Blur is soft, falling light
442
+ * whose edge is nowhere near opaque; treating it as a hard extension of the
443
+ * shape false-positived on pairs a rendered frame shows clear (t314/t316,
444
+ * t342). A glow is soft over its whole radius the same way and contributes
445
+ * nothing here for the same reason. The offset is the one part of a drop
446
+ * shadow that is a real, solid-ish silhouette repeated a fixed distance
447
+ * away — see "HALO"'s shadow (`offsetY: 10`) reaching toward the tagline
448
+ * below it. */
449
+ function effectExpansionPx(clip) {
450
+ let expand = 0;
451
+ for (const effect of clip.effects ?? []) {
452
+ if (!effect.enabled) {
453
+ continue;
454
+ }
455
+ // `ClipEffect` is a union of a discriminated known shape and a loose
456
+ // forward-compat one (I2), so `effect.type === "dropShadow"` alone does
457
+ // not narrow the field types — `isRecord` reads them with evidence
458
+ // instead of an assertion.
459
+ if (effect.type === "dropShadow" && isRecord(effect)) {
460
+ const offsetX = typeof effect.offsetX === "number" ? effect.offsetX : 0;
461
+ const offsetY = typeof effect.offsetY === "number" ? effect.offsetY : 0;
462
+ expand = Math.max(expand, Math.abs(offsetX) + Math.abs(offsetY));
463
+ }
464
+ }
465
+ return expand;
466
+ }
467
+ function inflateBox(box, px) {
468
+ if (px <= 0) {
469
+ return box;
470
+ }
471
+ return { x: box.x - px, y: box.y - px, width: box.width + 2 * px, height: box.height + 2 * px };
472
+ }
473
+ /** Set at or past this fraction of the frame height, a text clip is display
474
+ * typography — a giant background numeral or wordmark other content is meant
475
+ * to sit over or inside, not a caption competing for its own clear space. */
476
+ const DECORATIVE_TEXT_HEIGHT_FRAC = 0.4;
477
+ function isDecorativeText(clip, canvas) {
478
+ return (clip.textStyle?.fontSizePx ?? 0) >= DECORATIVE_TEXT_HEIGHT_FRAC * canvas.height;
479
+ }
480
+ /** A hard cut's brief crossfade — two clips at the same spot for the seam,
481
+ * one fading out under the other's fade-in — is not two pieces of content
482
+ * competing for the frame. Below this, an overlap is presumed to be a seam. */
483
+ const TEXT_COLLISION_MIN_OVERLAP_MS = 450;
484
+ /** A small accent — a superscript badge, a corner mark — tucked inside a much
485
+ * bigger headline's own bounding box sits in the headline's whitespace, not
486
+ * across its ink. Only a comparably sized element is treated as competing
487
+ * for the same space. */
488
+ const NESTED_ACCENT_AREA_FRAC = 0.25;
489
+ /** A text clip at or above this fraction of the frame height is set with
490
+ * glyph strokes thick enough to fully hide a thin background stroke wherever
491
+ * they overlap — see the "behind LARGE text" exemption above. The real
492
+ * fixture's "INTRODUCING" kicker sits at 26px on a 1080px frame (2.4%); the
493
+ * shipped "PRISM" wordmark this exemption is calibrated against sits at
494
+ * 300px (27.8%). The floor has wide margin either side of that gap. */
495
+ const OCCLUDING_TEXT_HEIGHT_FRAC = 0.05;
496
+ /** Below this, an overlap is a graze from box estimation slack — leading a
497
+ * layout-estimate width does not claim — rather than ink actually crossing. */
498
+ const COLLISION_MIN_OVERLAP_FRAC = 0.2;
499
+ /** A text clip's box crossing a shape's stroke, sitting under an unrelated
500
+ * shape or text, or clipped by an unfilled ring — the collisions a frame
501
+ * preview shows and a structural check used to leave unsaid. */
502
+ function checkTextCollision(doc, canvas, windows, byId, resolved, sizes) {
503
+ const issues = [];
504
+ // Track z is `1000 - index` (I9): a higher index draws underneath. A filled
505
+ // shape drawn behind an opaque text clip is a backdrop the text is fully
506
+ // legible over, not a collision — the same z rule `legibility.ts` reads for
507
+ // a backing plate.
508
+ const trackIndex = new Map(doc.tracks.filter((track) => track.type !== "audio" && track.visible).map((track) => [track.id, track.index]));
509
+ const texts = doc.clips.filter((clip) => clip.mediaType === "text" &&
510
+ hasVisualContent(clip) &&
511
+ windows.get(clip.id) &&
512
+ !isDecorativeText(clip, canvas) &&
513
+ !hasPerspectiveAncestor(clip, byId));
514
+ const shapesAndText = doc.clips.filter((clip) => (clip.mediaType === "shape" || clip.mediaType === "text") &&
515
+ hasVisualContent(clip) &&
516
+ windows.get(clip.id) &&
517
+ !(clip.mediaType === "text" && isDecorativeText(clip, canvas)) &&
518
+ !hasPerspectiveAncestor(clip, byId));
519
+ for (const text of texts) {
520
+ const textWindow = windows.get(text.id);
521
+ const rawTextBox = frameBoxFor(text, canvas, byId, resolved, undefined, false, sizes);
522
+ if (!rawTextBox) {
523
+ continue;
524
+ }
525
+ const textBox = inflateBox(textInkBox(rawTextBox, text.textStyle?.text ?? ""), effectExpansionPx(text));
526
+ for (const other of shapesAndText) {
527
+ if (other.id === text.id) {
528
+ continue;
529
+ }
530
+ const otherWindow = windows.get(other.id);
531
+ const overlapMs = Math.min(otherWindow.end, textWindow.end) - Math.max(otherWindow.start, textWindow.start);
532
+ if (overlapMs < TEXT_COLLISION_MIN_OVERLAP_MS) {
533
+ continue;
534
+ }
535
+ if (other.parentId === text.id || text.parentId === other.id) {
536
+ continue;
537
+ }
538
+ // The same words set twice, a few pixels apart, at the same time is a
539
+ // duotone/chromatic-split pass — one echo layer over another, not two
540
+ // pieces of content sharing the frame.
541
+ if (other.mediaType === "text" && other.textStyle?.text === text.textStyle?.text) {
542
+ continue;
543
+ }
544
+ const rawOtherBox = frameBoxFor(other, canvas, byId, resolved, undefined, false, sizes);
545
+ if (!rawOtherBox || isBackgroundBed(rawOtherBox, canvas)) {
546
+ continue;
547
+ }
548
+ const otherBox = inflateBox(other.mediaType === "text" ? textInkBox(rawOtherBox, other.textStyle?.text ?? "") : rawOtherBox, effectExpansionPx(other));
549
+ const textArea = textBox.width * textBox.height;
550
+ const otherArea = otherBox.width * otherBox.height;
551
+ const minArea = Math.min(textArea, otherArea);
552
+ // A sliver graze — two rows in a tightly leaded list a hair too close —
553
+ // is not the same finding as one box crossing deep into another. Only
554
+ // an overlap that eats a real share of the smaller box reads as a
555
+ // collision a frame preview would actually show.
556
+ if (minArea <= 0 || intersectionArea(textBox, otherBox) / minArea < COLLISION_MIN_OVERLAP_FRAC) {
557
+ continue;
558
+ }
559
+ if ((containsBox(textBox, otherBox) && otherArea < NESTED_ACCENT_AREA_FRAC * textArea) ||
560
+ (containsBox(otherBox, textBox) && textArea < NESTED_ACCENT_AREA_FRAC * otherArea)) {
561
+ continue;
562
+ }
563
+ if (other.mediaType === "shape" && other.shapeStyle) {
564
+ const unfilled = isUnfilled(other.shapeStyle);
565
+ const textIdx = trackIndex.get(text.trackId);
566
+ const otherIdx = trackIndex.get(other.trackId);
567
+ // z, `1000-index`: a higher index draws underneath.
568
+ const behindText = textIdx !== undefined && otherIdx !== undefined && otherIdx > textIdx;
569
+ if (!unfilled) {
570
+ // A solid plate that fully contains the text is a background the
571
+ // text sits on, not a collision.
572
+ if (containsBox(otherBox, textBox)) {
573
+ continue;
574
+ }
575
+ // A filled shape drawn behind the text is a backdrop the opaque
576
+ // text renders over — fully legible regardless of overlap, at any
577
+ // text size.
578
+ if (behindText) {
579
+ continue;
580
+ }
581
+ }
582
+ else if (behindText && (text.textStyle?.fontSizePx ?? 0) >= OCCLUDING_TEXT_HEIGHT_FRAC * canvas.height) {
583
+ // An unfilled stroke behind LARGE text (a sunburst ray, a stack of
584
+ // concentric rings behind a display wordmark) reads as fully
585
+ // hidden wherever it overlaps: a big glyph's own stroke width is
586
+ // wide enough to cover a thin background line. A SMALL caption's
587
+ // glyph strokes are thin enough that the same background stroke
588
+ // can show through the gaps between letters — see the real defect
589
+ // this file was written for: a 26px kicker with a ring behind it.
590
+ continue;
591
+ }
592
+ else {
593
+ // An unfilled shape's bounding box is the wrong test regardless of
594
+ // kind — a ring's box looks solid but is empty in the middle, a
595
+ // ray's box spans a diagonal its 1px line barely touches. Resolve
596
+ // its real outline (rect/ellipse/polygon/star/path all go through
597
+ // the one geometry builder every render surface uses) and test
598
+ // whether the stroke itself, inflated by half its width, reaches
599
+ // the text's box. A box fully inside a ring's hole touches no
600
+ // segment; a box straddling the curve does.
601
+ const chords = shapeStrokeChordsInFrame(other, other.shapeStyle, canvas, byId, resolved, sizes);
602
+ const inflate = (other.shapeStyle.strokeWidthPx ?? 2) / 2 + effectExpansionPx(other);
603
+ if (chords && !chords.some(([a, b]) => segmentIntersectsBox(a, b, textBox, inflate))) {
604
+ continue;
605
+ }
606
+ }
607
+ }
608
+ issues.push({
609
+ severity: "warning",
610
+ code: "showcase_text_collision",
611
+ clipId: text.id,
612
+ message: `Text collision: "${text.name || text.id}" overlaps ${other.mediaType} "${other.name || other.id}". See serein or voltra: type sits clear of the shapes around it, or on a plate sized to hold it. Move the text, resize the shape, or give the text a background plate.`
613
+ });
614
+ }
615
+ }
616
+ return issues;
617
+ }
618
+ // Measured on every shipped example: the fewest distinct animated properties
619
+ // on a scene that still passed the other showcase floors was tidewater's 5
620
+ // (secondary curves plus one style track). Floor is set with margin below it.
621
+ const MOTION_RANGE_PROPERTY_FLOOR = 3;
622
+ /** Counts distinct animated properties across custom/preset curves, style
623
+ * tracks, text animators, staggers and authored transitions. A cut leaning on
624
+ * one or two properties (opacity, scale) reads as thin even when it is dense. */
625
+ function checkMotionRangeNarrow(doc, canvas, windows) {
626
+ const properties = new Set();
627
+ let styleTrackCount = 0;
628
+ let transitionCount = 0;
629
+ for (const clip of doc.clips) {
630
+ const window = windows.get(clip.id);
631
+ if (!window) {
632
+ continue;
633
+ }
634
+ const animations = resolveBeatAnimations(clip, doc.tempo);
635
+ const animationsById = new Map((animations ?? []).map((a) => [a.id, a]));
636
+ const compiled = compileClipAnimations(animations, clip.durationMs, canvas);
637
+ for (const animation of compiled) {
638
+ if (clip.startMs + animation.windowStartMs >= window.end || clip.startMs + animation.windowEndMs <= window.start) {
639
+ continue;
640
+ }
641
+ for (const curve of animation.curves) {
642
+ if (curve.keyframes.some((k) => k.value !== curve.keyframes[0]?.value)) {
643
+ properties.add(curve.property);
644
+ }
645
+ }
646
+ const source = animationsById.get(animation.id);
647
+ for (const track of source?.styleTracks ?? []) {
648
+ if (track.keyframes.some((k) => k.value !== track.keyframes[0]?.value)) {
649
+ properties.add(`style:${track.target}`);
650
+ styleTrackCount += 1;
651
+ }
652
+ }
653
+ if (source?.textAnimator) {
654
+ properties.add(`textAnimator:${source.textAnimator.kind}`);
655
+ }
656
+ if (source?.stagger) {
657
+ properties.add(`stagger:${source.stagger.unit}`);
658
+ }
659
+ }
660
+ if (clip.transitionIn) {
661
+ properties.add(`transition:${clip.transitionIn.type}`);
662
+ transitionCount += 1;
663
+ }
664
+ }
665
+ const noStructure = styleTrackCount === 0 && transitionCount === 0;
666
+ if (!noStructure && properties.size >= MOTION_RANGE_PROPERTY_FLOOR) {
667
+ return [];
668
+ }
669
+ return [
670
+ {
671
+ severity: "warning",
672
+ code: "showcase_motion_range_narrow",
673
+ message: `Motion-range gap: ${properties.size} distinct animated properties (floor ${MOTION_RANGE_PROPERTY_FLOOR}), ${styleTrackCount} active style tracks, ${transitionCount} authored transitions. See tidewater: secondary curves widen the range past opacity and scale, and style tracks or an authored transition add a texture curves alone do not.`
674
+ }
675
+ ];
676
+ }
677
+ // A clock/countdown format (`mm:ss`, `h:mm:ss`) is unambiguous. A bare number
678
+ // or a currency/percent amount ("14", "$9.99", "18%") is not — a calendar day,
679
+ // a fixed price and a static stat are common, legitimate uses of digits that
680
+ // are correct not to animate, and flagging every one of them would drown the
681
+ // real timers and tickers in noise.
682
+ const COUNTER_TEXT = /^\d{1,2}:\d{2}(:\d{2})?$/;
683
+ const COUNTER_MIN_VISIBLE_MS = 2000;
684
+ /** A ticker that has not started by this fraction of the clip's own visible
685
+ * span has spent most of its screen time showing the un-animated start
686
+ * value — the count never visibly moves for the viewer. */
687
+ const TICKER_PREROLL_FLOOR = 0.5;
688
+ /** Below this, a clock-formatted string is small UI chrome — a scrubber
689
+ * timestamp, a schedule-list row — not a hero display asking to be watched.
690
+ * Measured on every shipped example: the largest of serein's ~90 static
691
+ * clock labels (a scrubber list, deliberately unmoving) sets at 26px; the
692
+ * floor sits with margin above it. */
693
+ const COUNTER_FONT_HEIGHT_FRAC = 0.05;
694
+ function looksLikeCounter(clip, canvas) {
695
+ const style = clip.textStyle;
696
+ if (!style || (style.fontSizePx ?? 0) < COUNTER_FONT_HEIGHT_FRAC * canvas.height) {
697
+ return false;
698
+ }
699
+ const trimmed = style.text.trim();
700
+ return trimmed.length > 0 && COUNTER_TEXT.test(trimmed);
701
+ }
702
+ function sceneIdFor(clip, byId) {
703
+ const chain = ancestorChain(clip, byId);
704
+ const root = chain[0];
705
+ return root && root.mediaType === "group" && !root.parentId ? root.id : undefined;
706
+ }
707
+ /** A frozen timer/counter and a ticker whose count-up window misses the
708
+ * clip's own visible span — motion the structural checks never noticed
709
+ * because the document still has *an* animation on the clip. */
710
+ function checkDeadMotion(doc, canvas, windows, byId) {
711
+ const issues = [];
712
+ const sceneMotion = new Map();
713
+ const changing = new Map();
714
+ for (const clip of doc.clips) {
715
+ const window = windows.get(clip.id);
716
+ if (!window) {
717
+ continue;
718
+ }
719
+ const animations = resolveBeatAnimations(clip, doc.tempo);
720
+ const compiled = compileClipAnimations(animations, clip.durationMs, canvas);
721
+ const hasChange = compiled.some((animation) => clip.startMs + animation.windowStartMs < window.end &&
722
+ clip.startMs + animation.windowEndMs > window.start &&
723
+ animation.curves.some((curve) => curve.keyframes.some((k) => k.value !== curve.keyframes[0]?.value)));
724
+ changing.set(clip.id, hasChange);
725
+ if (hasChange) {
726
+ const scene = sceneIdFor(clip, byId);
727
+ if (scene) {
728
+ const set = sceneMotion.get(scene) ?? new Set();
729
+ set.add(clip.id);
730
+ sceneMotion.set(scene, set);
731
+ }
732
+ }
733
+ }
734
+ for (const clip of doc.clips) {
735
+ if (clip.mediaType !== "text" || !clip.textStyle || !hasVisualContent(clip)) {
736
+ continue;
737
+ }
738
+ const window = windows.get(clip.id);
739
+ if (!window || window.end - window.start < COUNTER_MIN_VISIBLE_MS) {
740
+ continue;
741
+ }
742
+ if (!looksLikeCounter(clip, canvas)) {
743
+ continue;
744
+ }
745
+ const animations = resolveBeatAnimations(clip, doc.tempo);
746
+ // A fade-in or scale-in entrance is normal on any title, ticking or not,
747
+ // and does not change the digits on screen — only a textAnimator (a
748
+ // ticker or a scramble) or a style track that touches the text itself
749
+ // does. Excluding on *any* changing curve (position, opacity, scale)
750
+ // missed a real frozen "25:00": the clip's own entrance fade satisfied
751
+ // that check while the number never moved.
752
+ const changesDisplayedText = (animations ?? []).some((a) => a.textAnimator || a.styleTracks?.some((track) => track.target.startsWith("textStyle.")));
753
+ if (changesDisplayedText) {
754
+ continue;
755
+ }
756
+ const scene = sceneIdFor(clip, byId);
757
+ const sceneAnimated = scene ? (sceneMotion.get(scene)?.size ?? 0) > 0 : false;
758
+ if (!sceneAnimated) {
759
+ continue;
760
+ }
761
+ issues.push({
762
+ severity: "warning",
763
+ code: "showcase_dead_motion",
764
+ clipId: clip.id,
765
+ message: `Dead motion: "${clip.name || clip.id}" reads as a counter ("${clip.textStyle.text}") but never changes across ${((window.end - window.start) / 1000).toFixed(1)}s while its scene animates around it. See prism or voltra: a ticker textAnimator (\`preset: "custom"\`, \`textAnimator: { kind: "ticker", from, to }\`) counts it up on screen.`
766
+ });
767
+ }
768
+ for (const clip of doc.clips) {
769
+ const window = windows.get(clip.id);
770
+ if (!window) {
771
+ continue;
772
+ }
773
+ const animations = resolveBeatAnimations(clip, doc.tempo);
774
+ const animationsById = new Map((animations ?? []).map((a) => [a.id, a]));
775
+ const compiled = compileClipAnimations(animations, clip.durationMs, canvas);
776
+ for (const animation of compiled) {
777
+ const source = animationsById.get(animation.id);
778
+ if (source?.textAnimator?.kind !== "ticker") {
779
+ continue;
780
+ }
781
+ const tickerStart = clip.startMs + animation.windowStartMs;
782
+ const visibleMs = window.end - window.start;
783
+ if (visibleMs <= 0) {
784
+ continue;
785
+ }
786
+ // A ticker that finishes before the clip is visible just holds its
787
+ // counted-up value, same as one that runs at the very start — a normal
788
+ // "count once, then hold" reveal. Only a ticker that has not *started*
789
+ // for most of the clip's visible time is dead: the viewer watches the
790
+ // un-animated `from` value with nothing telling them it will move.
791
+ const neverStarts = tickerStart >= window.end;
792
+ const preRollMs = Math.max(0, Math.min(tickerStart, window.end) - window.start);
793
+ if (neverStarts || preRollMs / visibleMs > TICKER_PREROLL_FLOOR) {
794
+ const relation = neverStarts ? "never starts within" : "does not start until most of the way through";
795
+ issues.push({
796
+ severity: "warning",
797
+ code: "showcase_dead_motion",
798
+ clipId: clip.id,
799
+ message: `Dead motion: "${clip.name || clip.id}"'s ticker ${relation} its ${(visibleMs / 1000).toFixed(1)}s visible span, so it shows the un-animated start value for most of the time it is on screen. See prism or voltra: the ticker's window sits inside the clip's own visible span. Move the clip's start or the animation's delay so they align.`
800
+ });
801
+ }
802
+ }
803
+ }
804
+ return issues;
805
+ }
162
806
  export function checkShowcase(doc, canvas) {
163
807
  const windows = activeWindows(doc);
164
808
  const visual = doc.clips.filter((clip) => windows.get(clip.id) && hasVisualContent(clip));
@@ -342,6 +986,18 @@ export function checkShowcase(doc, canvas) {
342
986
  if (fastWithoutBlur.length) {
343
987
  warn("showcase_motion_blur_missing", `Motion-blur gap: ${fastWithoutBlur.join(", ")} exceed ${FAST_POSITION_PER_SECOND} canvas widths/heights per second or ${FAST_SCALE_PER_SECOND} scale units per second without a positive shutter and at least two samples. See kite: blur on the fast logo move.`);
344
988
  }
989
+ const children = childrenByParentMap(doc);
990
+ // The same layout resolver the renderer places a `row`/`stack`/`relative`
991
+ // container's children with (packages/timeline/src/render/layout.ts). No
992
+ // `measureText` is supplied: execution has no canvas dependency, so this
993
+ // falls back to the module's own `text.length * fontSizePx * 0.6`
994
+ // estimate — the exact fallback the renderer itself uses headless when it
995
+ // has no measurer either, so this file's own text-box estimate elsewhere
996
+ // and the layout resolver's estimate here read from the same yardstick.
997
+ const layoutResolved = resolveClipLayoutsWithDiagnostics(doc.clips, canvas);
998
+ const resolved = layoutResolved.transforms;
999
+ const sizes = layoutResolved.sizes;
1000
+ issues.push(...checkFrameUnderfilled(doc, canvas, windows, byId, children, resolved, sizes), ...checkTextCollision(doc, canvas, windows, byId, resolved, sizes), ...checkMotionRangeNarrow(doc, canvas, windows), ...checkDeadMotion(doc, canvas, windows, byId));
345
1001
  return issues;
346
1002
  }
347
1003
  //# sourceMappingURL=showcase.js.map