@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.
- package/README.md +23 -127
- package/dist/.nodetool-build-stamp +1 -1
- package/dist/app-debug/app-spec.d.ts.map +1 -1
- package/dist/app-debug/app-spec.js +3 -0
- package/dist/app-debug/app-spec.js.map +1 -1
- package/dist/app-debug/application-target.d.ts +1 -1
- package/dist/app-debug/application-target.d.ts.map +1 -1
- package/dist/app-debug/application-target.js +5 -3
- package/dist/app-debug/application-target.js.map +1 -1
- package/dist/app-debug/index.d.ts +4 -2
- package/dist/app-debug/index.d.ts.map +1 -1
- package/dist/app-debug/index.js +3 -2
- package/dist/app-debug/index.js.map +1 -1
- package/dist/app-debug/run-recording.d.ts +20 -0
- package/dist/app-debug/run-recording.d.ts.map +1 -0
- package/dist/app-debug/run-recording.js +105 -0
- package/dist/app-debug/run-recording.js.map +1 -0
- package/dist/app-debug/runtime.d.ts +2 -0
- package/dist/app-debug/runtime.d.ts.map +1 -1
- package/dist/app-debug/runtime.js +14 -3
- package/dist/app-debug/runtime.js.map +1 -1
- package/dist/app-debug/simulate.d.ts +10 -1
- package/dist/app-debug/simulate.d.ts.map +1 -1
- package/dist/app-debug/simulate.js +57 -26
- package/dist/app-debug/simulate.js.map +1 -1
- package/dist/app-debug/summarize.d.ts +1 -0
- package/dist/app-debug/summarize.d.ts.map +1 -1
- package/dist/app-debug/summarize.js +1 -0
- package/dist/app-debug/summarize.js.map +1 -1
- package/dist/app-debug/types.d.ts +6 -4
- package/dist/app-debug/types.d.ts.map +1 -1
- package/dist/app-run-generation.d.ts +8 -0
- package/dist/app-run-generation.d.ts.map +1 -0
- package/dist/app-run-generation.js +61 -0
- package/dist/app-run-generation.js.map +1 -0
- package/dist/browser-app-run.d.ts +10 -0
- package/dist/browser-app-run.d.ts.map +1 -0
- package/dist/browser-app-run.js +108 -0
- package/dist/browser-app-run.js.map +1 -0
- package/dist/browser-run-spans.d.ts +4 -0
- package/dist/browser-run-spans.d.ts.map +1 -0
- package/dist/browser-run-spans.js +115 -0
- package/dist/browser-run-spans.js.map +1 -0
- package/dist/browser-trace-policy.d.ts +12 -0
- package/dist/browser-trace-policy.d.ts.map +1 -0
- package/dist/browser-trace-policy.js +40 -0
- package/dist/browser-trace-policy.js.map +1 -0
- package/dist/cost-ledger.d.ts +3 -0
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +23 -2
- package/dist/cost-ledger.js.map +1 -1
- package/dist/debug/types.d.ts +1 -0
- package/dist/debug/types.d.ts.map +1 -1
- package/dist/generation-lifecycle.d.ts +5 -4
- package/dist/generation-lifecycle.d.ts.map +1 -1
- package/dist/generation-lifecycle.js +71 -10
- package/dist/generation-lifecycle.js.map +1 -1
- package/dist/generation-recovery-worker.d.ts.map +1 -1
- package/dist/generation-recovery-worker.js +2 -0
- package/dist/generation-recovery-worker.js.map +1 -1
- package/dist/generation-tracker.d.ts +2 -15
- package/dist/generation-tracker.d.ts.map +1 -1
- package/dist/generation-tracker.js +92 -6
- package/dist/generation-tracker.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/js-script-debug/index.d.ts +1 -0
- package/dist/js-script-debug/index.d.ts.map +1 -1
- package/dist/js-script-debug/index.js +1 -0
- package/dist/js-script-debug/index.js.map +1 -1
- package/dist/js-script-debug/type-check.d.ts +16 -0
- package/dist/js-script-debug/type-check.d.ts.map +1 -0
- package/dist/js-script-debug/type-check.js +139 -0
- package/dist/js-script-debug/type-check.js.map +1 -0
- package/dist/js-script-debug/validate.d.ts +8 -0
- package/dist/js-script-debug/validate.d.ts.map +1 -1
- package/dist/js-script-debug/validate.js +4 -0
- package/dist/js-script-debug/validate.js.map +1 -1
- package/dist/message-stream.d.ts +1 -32
- package/dist/message-stream.d.ts.map +1 -1
- package/dist/message-stream.js +1 -57
- package/dist/message-stream.js.map +1 -1
- package/dist/preflight.d.ts +43 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +78 -4
- package/dist/preflight.js.map +1 -1
- package/dist/run-trace-store.d.ts +9 -0
- package/dist/run-trace-store.d.ts.map +1 -0
- package/dist/run-trace-store.js +57 -0
- package/dist/run-trace-store.js.map +1 -0
- package/dist/runs.d.ts +16 -0
- package/dist/runs.d.ts.map +1 -0
- package/dist/runs.js +431 -0
- package/dist/runs.js.map +1 -0
- package/dist/service/app-debug-service.d.ts +4 -2
- package/dist/service/app-debug-service.d.ts.map +1 -1
- package/dist/service/app-debug-service.js +73 -31
- package/dist/service/app-debug-service.js.map +1 -1
- package/dist/service/app-operation.d.ts +38 -0
- package/dist/service/app-operation.d.ts.map +1 -0
- package/dist/service/app-operation.js +269 -0
- package/dist/service/app-operation.js.map +1 -0
- package/dist/service/app-run-server.d.ts +3 -1
- package/dist/service/app-run-server.d.ts.map +1 -1
- package/dist/service/app-run-server.js +22 -14
- package/dist/service/app-run-server.js.map +1 -1
- package/dist/service/index.d.ts +4 -0
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/index.js +2 -0
- package/dist/service/index.js.map +1 -1
- package/dist/service/run-trace-lifecycle.d.ts +34 -0
- package/dist/service/run-trace-lifecycle.d.ts.map +1 -0
- package/dist/service/run-trace-lifecycle.js +135 -0
- package/dist/service/run-trace-lifecycle.js.map +1 -0
- package/dist/service/workflow-run.d.ts +5 -16
- package/dist/service/workflow-run.d.ts.map +1 -1
- package/dist/service/workflow-run.js +54 -52
- package/dist/service/workflow-run.js.map +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +109 -100
- package/dist/session.js.map +1 -1
- package/dist/sketch-debug/markdown.d.ts.map +1 -1
- package/dist/sketch-debug/markdown.js +1 -0
- package/dist/sketch-debug/markdown.js.map +1 -1
- package/dist/sketch-debug/report.d.ts.map +1 -1
- package/dist/sketch-debug/report.js +4 -4
- package/dist/sketch-debug/report.js.map +1 -1
- package/dist/sketch-debug/types.d.ts +2 -0
- package/dist/sketch-debug/types.d.ts.map +1 -1
- package/dist/timeline-debug/frame-geometry.d.ts +118 -0
- package/dist/timeline-debug/frame-geometry.d.ts.map +1 -0
- package/dist/timeline-debug/frame-geometry.js +231 -0
- package/dist/timeline-debug/frame-geometry.js.map +1 -0
- package/dist/timeline-debug/index.d.ts +1 -0
- package/dist/timeline-debug/index.d.ts.map +1 -1
- package/dist/timeline-debug/index.js +1 -0
- package/dist/timeline-debug/index.js.map +1 -1
- package/dist/timeline-debug/legibility.d.ts +8 -4
- package/dist/timeline-debug/legibility.d.ts.map +1 -1
- package/dist/timeline-debug/legibility.js +47 -18
- package/dist/timeline-debug/legibility.js.map +1 -1
- package/dist/timeline-debug/score.d.ts +110 -0
- package/dist/timeline-debug/score.d.ts.map +1 -0
- package/dist/timeline-debug/score.js +231 -0
- package/dist/timeline-debug/score.js.map +1 -0
- package/dist/timeline-debug/showcase.d.ts +12 -0
- package/dist/timeline-debug/showcase.d.ts.map +1 -1
- package/dist/timeline-debug/showcase.js +669 -13
- package/dist/timeline-debug/showcase.js.map +1 -1
- package/dist/timeline-debug/validate.d.ts.map +1 -1
- package/dist/timeline-debug/validate.js +121 -2
- package/dist/timeline-debug/validate.js.map +1 -1
- package/dist/types.d.ts +18 -10
- package/dist/types.d.ts.map +1 -1
- 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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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:
|
|
13
|
-
customPerSecond:
|
|
21
|
+
export const SHOWCASE_FLOOR = {
|
|
22
|
+
groups: 16,
|
|
23
|
+
customPerSecond: 41000 / 15958,
|
|
14
24
|
effects: 6,
|
|
15
|
-
clipsPerSecond:
|
|
25
|
+
clipsPerSecond: 95000 / 18467
|
|
16
26
|
};
|
|
17
|
-
const SHOWCASE_TARGET = {
|
|
18
|
-
groups:
|
|
19
|
-
customPerSecond:
|
|
27
|
+
export const SHOWCASE_TARGET = {
|
|
28
|
+
groups: 29,
|
|
29
|
+
customPerSecond: 106000 / 17767,
|
|
20
30
|
effects: 7,
|
|
21
|
-
clipsPerSecond:
|
|
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
|