@glissade/cli 0.61.0-pre.0 → 0.61.0-pre.1

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 (2) hide show
  1. package/dist/semanticParity.js +121 -32
  2. package/package.json +10 -10
@@ -186,6 +186,7 @@ function parseWarn(msg) {
186
186
  const ORPHAN_RADIUS = 64;
187
187
  const COVERAGE_MIN = .34;
188
188
  const MIN_ORPHAN_TILES = 3;
189
+ const BENIGN_MEAN_FLOOR = .92;
189
190
  /** How many 8×8 tiles a node's device bbox spans (≥1) — the coverage denominator. */
190
191
  function bboxTileArea(box, win) {
191
192
  return Math.max(1, (box.maxX - box.minX) / win) * Math.max(1, (box.maxY - box.minY) / win);
@@ -267,19 +268,63 @@ function edgeDistance(x, y, b) {
267
268
  const dy = Math.max(b.minY - y, 0, y - b.maxY);
268
269
  return Math.hypot(dx, dy);
269
270
  }
270
- /** Walk `id`'s ided ancestor chain; return the nearest ancestor a DROP warn names
271
- * (a render-only wrapper camera/shake/motionBlur/echo whose drop explains this
272
- * descendant's divergence), so the residual coalesces to ONE finding at that drop
273
- * instead of N unexplained leaves. Deterministic (the tree is fixed). */
274
- function nearestWarnedAncestor(scene, id, warnByNode) {
275
- let p = scene.nodes.get(id)?.parent ?? null;
276
- while (p) {
277
- if (p.id !== void 0 && warnByNode.has(p.id)) return {
278
- id: p.id,
279
- warn: warnByNode.get(p.id)
280
- };
281
- p = p.parent;
282
- }
271
+ /** id-less render-only warns the scene `describeType`(s) that produce them, so a
272
+ * warn with no quoted id still resolves to its render-only node(s) structurally. */
273
+ const FEATURE_TYPES = {
274
+ "motion-blur": ["MotionBlur"],
275
+ "echo-trails": ["Echo"],
276
+ "camera-shake": ["Camera"],
277
+ shake: ["Camera"],
278
+ followpath: ["FollowPath"],
279
+ orienttopath: ["OrientToPath"],
280
+ lookat: ["LookAt"],
281
+ "text-cursor": ["TextCursor"]
282
+ };
283
+ /** Collect a node's id-bearing subtree (itself + descendants). */
284
+ function collectSubtreeIds(node, into) {
285
+ if (node.id !== void 0) into.add(node.id);
286
+ const children = node.children;
287
+ if (Array.isArray(children)) for (const c of children) collectSubtreeIds(c, into);
288
+ }
289
+ /**
290
+ * Resolve each export warn to the set of scene node ids its drop STRUCTURALLY
291
+ * explains: a WRAPPER (motionBlur/echo/camera) explains its whole SUBTREE; a DRIVER
292
+ * (followPath/orientToPath/lookAt) explains its `.target`'s subtree (the target is a
293
+ * SIBLING it mutates, which an ancestry walk would miss); a leaf (Image/Video/Text)
294
+ * explains only ITSELF. This structural link — never bare geometric overlap — is what
295
+ * keeps an INDEPENDENT residual that merely sits inside a drop's bbox UNEXPLAINED.
296
+ */
297
+ function buildDropExtents(scene, warns) {
298
+ const byType = /* @__PURE__ */ new Map();
299
+ const indexTree = (node) => {
300
+ const t = node.describeType;
301
+ (byType.get(t) ?? byType.set(t, []).get(t)).push(node);
302
+ const children = node.children;
303
+ if (Array.isArray(children)) for (const c of children) indexTree(c);
304
+ };
305
+ indexTree(scene.root);
306
+ const out = [];
307
+ warns.forEach((warn, i) => {
308
+ const resolved = [];
309
+ if (warn.node !== void 0) {
310
+ const n = scene.nodes.get(warn.node);
311
+ if (n) resolved.push(n);
312
+ } else for (const type of FEATURE_TYPES[warn.property] ?? []) resolved.push(...byType.get(type) ?? []);
313
+ if (resolved.length === 0) return;
314
+ const ids = /* @__PURE__ */ new Set();
315
+ for (const n of resolved) {
316
+ collectSubtreeIds(n, ids);
317
+ const target = n.target;
318
+ if (target && typeof target === "object") collectSubtreeIds(target, ids);
319
+ }
320
+ out.push({
321
+ key: warn.node ?? `${warn.property}@${i}`,
322
+ ...warn.node !== void 0 ? { node: warn.node } : {},
323
+ warn,
324
+ ids
325
+ });
326
+ });
327
+ return out;
283
328
  }
284
329
  function regionRole(region, w, h) {
285
330
  const cx = (region.minX + region.maxX) / 2;
@@ -378,6 +423,7 @@ async function semanticParityCommand(opts) {
378
423
  if (!ex || res.worst < ex.worst) nodeAcc.set(id, {
379
424
  region: res.region,
380
425
  worst: res.worst,
426
+ mean: res.sum / res.count,
381
427
  frame,
382
428
  role: regionRole(res.region, w, h),
383
429
  coverage
@@ -394,19 +440,29 @@ async function semanticParityCommand(opts) {
394
440
  }
395
441
  }
396
442
  const findings = [];
397
- const warnedNodesEmitted = /* @__PURE__ */ new Set();
443
+ const emittedCauses = /* @__PURE__ */ new Set();
398
444
  const warnByNode = /* @__PURE__ */ new Map();
399
445
  for (const pw of parsedWarns) if (pw.node !== void 0 && !warnByNode.has(pw.node)) warnByNode.set(pw.node, pw);
446
+ const dropExtents = buildDropExtents(refScene, parsedWarns);
447
+ const explainedBy = /* @__PURE__ */ new Map();
448
+ for (const ext of dropExtents) for (const id of ext.ids) {
449
+ const list = explainedBy.get(id);
450
+ if (list) list.push(ext);
451
+ else explainedBy.set(id, [ext]);
452
+ }
400
453
  const attributed = /* @__PURE__ */ new Map();
401
- const mergeAttribution = (key, acc, warn, real) => {
454
+ const merge = (key, node, acc, warn, real, from) => {
402
455
  const ex = attributed.get(key);
403
456
  if (!ex) {
404
457
  attributed.set(key, {
405
458
  region: { ...acc.region },
406
459
  worst: acc.worst,
460
+ mean: acc.mean,
407
461
  frame: acc.frame,
408
462
  role: acc.role,
409
463
  ...warn ? { warn } : {},
464
+ ...node !== void 0 ? { node } : {},
465
+ coalesced: new Set(key === from ? [] : [from]),
410
466
  real
411
467
  });
412
468
  return;
@@ -417,34 +473,44 @@ async function semanticParityCommand(opts) {
417
473
  ex.region.maxY = Math.max(ex.region.maxY, acc.region.maxY);
418
474
  if (acc.worst < ex.worst) {
419
475
  ex.worst = acc.worst;
476
+ ex.mean = acc.mean;
420
477
  ex.frame = acc.frame;
421
478
  ex.role = acc.role;
422
479
  }
480
+ if (key !== from) ex.coalesced.add(from);
423
481
  ex.real = ex.real || real;
424
482
  };
425
- for (const [id, acc] of nodeAcc) if (warnByNode.has(id)) mergeAttribution(id, acc, warnByNode.get(id), true);
426
- else {
427
- const anc = nearestWarnedAncestor(refScene, id, warnByNode);
428
- if (anc) mergeAttribution(anc.id, acc, anc.warn, true);
429
- else mergeAttribution(id, acc, void 0, acc.coverage >= COVERAGE_MIN);
483
+ for (const [id, acc] of nodeAcc) {
484
+ if (warnByNode.has(id)) {
485
+ merge(id, id, acc, warnByNode.get(id), true, id);
486
+ continue;
487
+ }
488
+ const exts = explainedBy.get(id);
489
+ if (exts && exts.length > 0) {
490
+ const ext = exts.reduce((a, b) => a.key <= b.key ? a : b);
491
+ merge(ext.key, ext.node, acc, ext.warn, true, id);
492
+ continue;
493
+ }
494
+ merge(id, id, acc, void 0, acc.coverage >= COVERAGE_MIN, id);
430
495
  }
431
- for (const [id, at] of attributed) {
496
+ for (const [, at] of attributed) {
432
497
  if (!at.real) continue;
433
498
  anyResidual = true;
434
- const mean = round(at.worst);
499
+ const coalesced = [...at.coalesced].sort();
435
500
  if (at.warn) {
436
- warnedNodesEmitted.add(id);
437
- findings.push(dropFinding(at.warn, id, at.region, at.frame, mean, at.role));
501
+ emittedCauses.add(at.warn.cause);
502
+ findings.push(dropFinding(at.warn, at.node, at.region, at.frame, round(at.worst), at.role, coalesced));
438
503
  } else {
439
- const node = refScene.nodes.get(id);
440
- if (node?.hasAnchor === true && (node.anchor[0] !== .5 || node.anchor[1] !== .5)) findings.push(anchorFinding(id, node.anchor, at.region, at.frame, mean, at.role));
441
- else findings.push(unexplained(id, at.region, at.frame, mean, at.role));
504
+ const node = at.node !== void 0 ? refScene.nodes.get(at.node) : void 0;
505
+ if (node?.hasAnchor === true && (node.anchor[0] !== .5 || node.anchor[1] !== .5)) findings.push(anchorFinding(at.node, node.anchor, at.region, at.frame, round(at.worst), at.role));
506
+ else if (at.mean >= BENIGN_MEAN_FLOOR) findings.push(compositingApprox(at.node, at.region, at.frame, round(at.mean), at.role));
507
+ else findings.push(unexplained(at.node, at.region, at.frame, round(at.worst), at.role));
442
508
  }
443
509
  }
444
510
  for (const warn of parsedWarns) {
445
- if (warn.node !== void 0 && warnedNodesEmitted.has(warn.node)) continue;
446
- findings.push(dropFinding(warn, warn.node, null, frames[0] ?? 0, null, null));
447
- if (warn.node !== void 0) warnedNodesEmitted.add(warn.node);
511
+ if (emittedCauses.has(warn.cause)) continue;
512
+ findings.push(dropFinding(warn, warn.node, null, frames[0] ?? 0, null, null, []));
513
+ emittedCauses.add(warn.cause);
448
514
  }
449
515
  if (orphanAcc) findings.push(unexplained(void 0, orphanAcc.region, orphanAcc.frame, round(orphanAcc.worst), regionRole(orphanAcc.region, w, h)));
450
516
  const sorted = sortDiagnostics(findings);
@@ -480,7 +546,7 @@ async function semanticParityCommand(opts) {
480
546
  report: formatReport(result, opts)
481
547
  };
482
548
  }
483
- function dropFinding(warn, node, region, frame, ssim, role) {
549
+ function dropFinding(warn, node, region, frame, ssim, role, coalesced = []) {
484
550
  return {
485
551
  schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
486
552
  code: warn.approximate ? "LOTTIE_APPROXIMATE" : "LOTTIE_DROP",
@@ -498,7 +564,30 @@ function dropFinding(warn, node, region, frame, ssim, role) {
498
564
  ...role ? {
499
565
  role: role.role,
500
566
  roleWeight: role.weight
501
- } : {}
567
+ } : {},
568
+ ...coalesced.length > 0 ? { coalesced } : {}
569
+ }
570
+ };
571
+ }
572
+ /** Class III: an UNWARNED but BENIGN sub-pixel compositing residual (Stack/sequence
573
+ * rounding) — tagged LOTTIE_APPROXIMATE (expected:true → masked from the default
574
+ * error view) so it doesn't alarm as an episode-breaking UNEXPLAINED. */
575
+ function compositingApprox(node, region, frame, meanSsim, role) {
576
+ return {
577
+ schemaVersion: DIAGNOSTIC_SCHEMA_VERSION,
578
+ code: "LOTTIE_APPROXIMATE",
579
+ severity: "warning",
580
+ source: "parity",
581
+ ...node !== void 0 ? { node } : {},
582
+ message: `${node ? `node '${node}'` : "a region"} shows a BENIGN sub-pixel compositing difference on the Lottie round-trip (mean ssim ${meanSsim}, no feature dropped) — a rounding/layer-order approximation, not a loss.`,
583
+ detail: {
584
+ property: "compositing-approx",
585
+ frame,
586
+ region: roundBox(region),
587
+ ssim: meanSsim,
588
+ expected: true,
589
+ role: role.role,
590
+ roleWeight: role.weight
502
591
  }
503
592
  };
504
593
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.61.0-pre.0",
3
+ "version": "0.61.0-pre.1",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.61.0-pre.0",
32
- "@glissade/core": "0.61.0-pre.0",
33
- "@glissade/interact": "0.61.0-pre.0",
34
- "@glissade/lottie": "0.61.0-pre.0",
35
- "@glissade/narrate": "0.61.0-pre.0",
36
- "@glissade/player": "0.61.0-pre.0",
37
- "@glissade/scene": "0.61.0-pre.0",
38
- "@glissade/sfx": "0.61.0-pre.0",
39
- "@glissade/svg": "0.61.0-pre.0"
31
+ "@glissade/backend-skia": "0.61.0-pre.1",
32
+ "@glissade/core": "0.61.0-pre.1",
33
+ "@glissade/interact": "0.61.0-pre.1",
34
+ "@glissade/lottie": "0.61.0-pre.1",
35
+ "@glissade/narrate": "0.61.0-pre.1",
36
+ "@glissade/player": "0.61.0-pre.1",
37
+ "@glissade/scene": "0.61.0-pre.1",
38
+ "@glissade/sfx": "0.61.0-pre.1",
39
+ "@glissade/svg": "0.61.0-pre.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",