spine-rigc 0.3.0 → 0.5.0

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/src/framing.ts CHANGED
@@ -32,6 +32,19 @@
32
32
  * measurement error in this file cancels. What is left is a procedure whose noise
33
33
  * shrinks as the candidate improves, which is the only shape of noise an authoring
34
34
  * loop can work against.
35
+ *
36
+ * ## And one pass after that, on the MAE itself
37
+ *
38
+ * The extent fit has a floor it cannot see past, because the best fit of two
39
+ * extents is not the best alignment of two pictures. On a hard shot that floor is
40
+ * a **constant** pixel worth a tenth of the headline figure (issue #146), which a
41
+ * loop reads as motion. So a fitted box gets one final pass — `OffsetScan` — that
42
+ * searches whole-pixel translations in a ±`REFINE_RADIUS` window for the lowest
43
+ * *reference-denominator MAE* and moves the box when the gain clears
44
+ * `REFINE_MIN_GAIN`. It optimises the reported figure directly rather than a proxy
45
+ * for it, which is what separates it from the extent refinement measured and
46
+ * rejected in `frameByDeclaredBox`: that one walked off the answer because the
47
+ * answer was not what it was minimising.
35
48
  */
36
49
  import { Plate, type RGBA } from '../tools/plate.ts';
37
50
  import {
@@ -515,6 +528,273 @@ function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [numb
515
528
  */
516
529
  export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
517
530
 
531
+ // ---------------------------------------------------------------------------
532
+ // the MAE-refined final pass
533
+ // ---------------------------------------------------------------------------
534
+
535
+ /**
536
+ * How far the MAE-refined pass may move a fitted box, in frame pixels.
537
+ *
538
+ * Two, because what it is there to recover is *one* pixel. The extent fit lands
539
+ * within a fraction of a pixel of its own optimum and its optimum is the wrong
540
+ * one by about that much (`fitFraming`, "the floor, stated plainly"), so the
541
+ * distance between "where the extents agree" and "where the pictures agree" is a
542
+ * pixel or two and never more — measured across the committed corpus, every
543
+ * offset the pass applies is `(0, ±1)`, `(±1, 0)`, `(±1, ±1)`, `(−1, 2)` or
544
+ * `(−2, ≤1)`, and none of the 86 sets wants a corner of the window. A wider
545
+ * window would start being able to absorb a real displacement, which is the one
546
+ * thing the framing must not do.
547
+ */
548
+ export const REFINE_RADIUS = 2;
549
+
550
+ /**
551
+ * How much of the figure a shift must buy before it is allowed to move the box,
552
+ * as a fraction of the set's own reference-denominator MAE...
553
+ *
554
+ * ...and `REFINE_MIN_GAIN_MAE` beside it, absolutely, because a ratio alone means
555
+ * nothing on a shot whose MAE is already 3.
556
+ *
557
+ * ## What the corpus says, and what the threshold is therefore for
558
+ *
559
+ * Measured over the 86 compared sets of the committed runs: the best offset in
560
+ * ±2 px is the **exact identity** on 52 of them — every set framed by
561
+ * `frames.json`'s own box among them, which is the `idle`-class control issue #146
562
+ * asked for — and on the other 34 the gain runs **0.9 % … 30.9 %** (0.40 … 14.34
563
+ * MAE), clustering at 3 % and above with two lone readings at 1.0 % and 0.9 %.
564
+ *
565
+ * ⚠️ So this is **not** a threshold separating two measured populations, and it
566
+ * must not be quoted as one: it is a floor under a continuum. What makes a low
567
+ * floor the right shape here is that the pass minimises the reported figure
568
+ * *itself*, so the cost of applying a marginal offset is bounded by the threshold
569
+ * — a hundredth of the figure — while the cost of refusing one is a constant pixel
570
+ * left inside a number an author reads as motion. Erring towards applying is the
571
+ * cheap direction, and the report prints what was applied and what it was worth
572
+ * either way.
573
+ */
574
+ export const REFINE_MIN_GAIN = 0.01;
575
+ /** ...and how much of the figure that is, in MAE points, whatever the ratio says. */
576
+ export const REFINE_MIN_GAIN_MAE = 0.1;
577
+
578
+ /** What the best whole-pixel offset in a window is worth, over a set's frames. */
579
+ export interface OffsetGain {
580
+ dx: number;
581
+ dy: number;
582
+ /** Mean reference-denominator MAE at the identity — the figure as it stands. */
583
+ identity: number;
584
+ /** ...and at `dx, dy`, which is the same figure with one constant taken out. */
585
+ best: number;
586
+ /** How far the search looked, and how many frames it pooled. */
587
+ radius: number;
588
+ frames: number;
589
+ }
590
+
591
+ /**
592
+ * The set's reference-denominator MAE at every whole-pixel offset in a window,
593
+ * accumulated one frame at a time.
594
+ *
595
+ * ## What this is for: the constant pixel a settled fit still leaves
596
+ *
597
+ * `fitFraming` registers two shots by their **extent**, and a shot whose
598
+ * silhouette genuinely differs has its best extent fit about a third of a pixel
599
+ * from its best alignment. Measured on the spineboy candidates (issue #146), that
600
+ * floor is not a rounding detail: a **constant** translation of one or two pixels
601
+ * is worth 12 % of `death`'s headline MAE and up to 30 % of a fitted set's,
602
+ * while the genuinely per-frame remainder is a tenth of it. A loop reading those
603
+ * numbers as motion is reading a framing offset.
604
+ *
605
+ * ## Why the objective is the reference denominator
606
+ *
607
+ * Because it is the figure the report tells an author to optimise against
608
+ * (`FrameCheck.maeReference`), and because it is the one the candidate cannot
609
+ * grow: minimising the union MAE would let a shift that drags more cheap pixels
610
+ * into the denominator win, which is issue #119 arriving by another door.
611
+ *
612
+ * ## Why whole pixels, and why a plate shift rather than a re-render
613
+ *
614
+ * The projector is `px = (wx − minX)·k`, so moving the box by exactly `dx/k`
615
+ * moves every sample point by exactly one pixel and samples the same texels —
616
+ * the render at the shifted box **is** the render shifted, but for content
617
+ * outside the old frame. That makes a 25-offset search cost one render per frame
618
+ * instead of 25, and it is why the window is whole pixels: a sub-pixel offset
619
+ * changes the resampling, so nothing could be searched without re-rendering it.
620
+ * The offset that wins is then applied to the viewport and everything the report
621
+ * prints is measured on a real render at a real box.
622
+ */
623
+ export class OffsetScan {
624
+ readonly radius: number;
625
+ private readonly span: number;
626
+ /** Σ over frames of the per-frame reference-denominator MAE, per offset. */
627
+ private readonly sums: Float64Array;
628
+ private counted = 0;
629
+
630
+ constructor(radius: number = REFINE_RADIUS) {
631
+ this.radius = Math.max(0, Math.round(radius));
632
+ this.span = this.radius * 2 + 1;
633
+ this.sums = new Float64Array(this.span * this.span);
634
+ }
635
+
636
+ /**
637
+ * One frame: the candidate as it was rendered, its own coverage mask, and the
638
+ * reference as it is on disk.
639
+ *
640
+ * ⭐ The figure accumulated here is `FrameCheck.maeReference` **exactly** —
641
+ * the difference summed over the pixels either side covers, over the count of
642
+ * the ones the *reference* covers — because the line the report prints and the
643
+ * line this pass minimises have to be the same line. Taking the numerator over
644
+ * the reference's pixels alone would be a near neighbour of it and a different
645
+ * number, and a "54.31 → 48.47" that did not match the MAE line under it would
646
+ * be two measurements wearing one name.
647
+ *
648
+ * A frame the reference drew nothing in counts as zero rather than being
649
+ * skipped, which is again what `checkOneFrame` does with it: an empty
650
+ * denominator is not a measurement, and dropping the frame instead would divide
651
+ * this pass's mean by a different frame count than the report's.
652
+ */
653
+ add(candidate: Plate, coverage: Uint8Array, reference: Plate, background: RGBA): void {
654
+ const { width, height } = reference;
655
+ this.counted++;
656
+ // The reference's own drawn pixels, by the predicate `checkOneFrame` uses.
657
+ const drawn = new Uint8Array(width * height);
658
+ const drawnAt: number[] = [];
659
+ for (let y = 0; y < height; y++) {
660
+ for (let x = 0; x < width; x++) {
661
+ if (!isContent(reference, x, y, background)) continue;
662
+ const at = y * width + x;
663
+ drawn[at] = 1;
664
+ drawnAt.push(at);
665
+ }
666
+ }
667
+ if (drawnAt.length === 0) return;
668
+ // ...and the candidate's, which move with the offset. Held as a list because
669
+ // the second sum below walks them in candidate coordinates.
670
+ const inkAt: number[] = [];
671
+ for (let at = 0; at < coverage.length; at++) if (coverage[at] === 1) inkAt.push(at);
672
+
673
+ const a = candidate.data;
674
+ const b = reference.data;
675
+ const bg = background;
676
+ /** |candidate at `from` − reference at `to`|, mean over RGB, either off-grid. */
677
+ const delta = (from: number, to: number): number => {
678
+ const j = to * 4;
679
+ const i = from * 4;
680
+ const ar = from < 0 ? bg[0] : a[i];
681
+ const ag = from < 0 ? bg[1] : a[i + 1];
682
+ const ab = from < 0 ? bg[2] : a[i + 2];
683
+ const br = to < 0 ? bg[0] : b[j];
684
+ const bgc = to < 0 ? bg[1] : b[j + 1];
685
+ const bb = to < 0 ? bg[2] : b[j + 2];
686
+ return (Math.abs(ar - br) + Math.abs(ag - bgc) + Math.abs(ab - bb)) / 3;
687
+ };
688
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
689
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
690
+ let sum = 0;
691
+ // Every reference-drawn pixel, against whatever the shifted candidate puts
692
+ // there — background where the shift pulls it off its own grid, which is
693
+ // what the render at the shifted box would draw.
694
+ for (let i = 0; i < drawnAt.length; i++) {
695
+ const at = drawnAt[i];
696
+ const x = at % width;
697
+ const y = (at - x) / width;
698
+ const sx = x - dx;
699
+ const sy = y - dy;
700
+ sum += delta(sx < 0 || sy < 0 || sx >= width || sy >= height ? -1 : sy * width + sx, at);
701
+ }
702
+ // ...and every pixel the candidate covers that the reference does not,
703
+ // which is the other half of the union. A pixel the shift carries out of
704
+ // the frame is clipped there, so it leaves the sum entirely.
705
+ for (let i = 0; i < inkAt.length; i++) {
706
+ const at = inkAt[i];
707
+ const x = at % width;
708
+ const y = (at - x) / width;
709
+ const tx = x + dx;
710
+ const ty = y + dy;
711
+ if (tx < 0 || ty < 0 || tx >= width || ty >= height) continue;
712
+ const to = ty * width + tx;
713
+ if (drawn[to] === 1) continue;
714
+ sum += delta(at, to);
715
+ }
716
+ this.sums[this.index(dx, dy)] += sum / drawnAt.length;
717
+ }
718
+ }
719
+ }
720
+
721
+ /**
722
+ * The offset with the lowest figure, or `null` when no frame carried reference
723
+ * ink.
724
+ *
725
+ * Ties go to the smaller displacement and then to the lower `dy`, `dx`, so the
726
+ * answer is a function of the pixels and not of the iteration order —
727
+ * `A18_DETERMINISTIC_EMIT`'s discipline applied to a measurement. The identity
728
+ * therefore wins any tie it is in, which is what makes "no constant offset
729
+ * here" a reachable answer rather than an arbitrary one.
730
+ */
731
+ best(): OffsetGain | null {
732
+ if (this.counted === 0) return null;
733
+ let bestDx = 0;
734
+ let bestDy = 0;
735
+ let bestSum = this.sums[this.index(0, 0)];
736
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
737
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
738
+ const sum = this.sums[this.index(dx, dy)];
739
+ if (sum > bestSum) continue;
740
+ if (sum === bestSum && !closerToHome(dx, dy, bestDx, bestDy)) continue;
741
+ bestSum = sum;
742
+ bestDx = dx;
743
+ bestDy = dy;
744
+ }
745
+ }
746
+ return {
747
+ dx: bestDx,
748
+ dy: bestDy,
749
+ identity: this.sums[this.index(0, 0)] / this.counted,
750
+ best: bestSum / this.counted,
751
+ radius: this.radius,
752
+ frames: this.counted,
753
+ };
754
+ }
755
+
756
+ private index(dx: number, dy: number): number {
757
+ return (dy + this.radius) * this.span + (dx + this.radius);
758
+ }
759
+ }
760
+
761
+ /** Is `(dx, dy)` the smaller displacement, ties broken by `dy` then `dx`? */
762
+ function closerToHome(dx: number, dy: number, atX: number, atY: number): boolean {
763
+ const mine = dx * dx + dy * dy;
764
+ const theirs = atX * atX + atY * atY;
765
+ if (mine !== theirs) return mine < theirs;
766
+ if (dy !== atY) return dy < atY;
767
+ return dx < atX;
768
+ }
769
+
770
+ /** Is this offset worth moving a box for? See `REFINE_MIN_GAIN`. */
771
+ export function offsetIsWorthApplying(gain: OffsetGain): boolean {
772
+ if (gain.dx === 0 && gain.dy === 0) return false;
773
+ const won = gain.identity - gain.best;
774
+ return won >= REFINE_MIN_GAIN_MAE && gain.identity > 0 && won / gain.identity >= REFINE_MIN_GAIN;
775
+ }
776
+
777
+ /**
778
+ * The same box moved by whole frame pixels, at the same scale.
779
+ *
780
+ * `applyFit` with a scale of exactly 1, written out rather than routed through
781
+ * it, because the refined pass changes no scale at all and a fit-shaped argument
782
+ * with `scale: 1` in it would invite one.
783
+ */
784
+ export function shiftViewport(
785
+ viewport: Viewport,
786
+ dx: number,
787
+ dy: number,
788
+ pixelWidth: number,
789
+ pixelHeight: number,
790
+ ): Viewport {
791
+ const minX = viewport.minX - dx / viewport.scale;
792
+ const maxY = viewport.maxY + dy / viewport.scale;
793
+ const width = pixelWidth / viewport.scale;
794
+ const height = pixelHeight / viewport.scale;
795
+ return viewportOfSize(minX, maxY - height, width, height, viewport.scale, pixelWidth, pixelHeight);
796
+ }
797
+
518
798
  /**
519
799
  * The viewport that renders the candidate where the fit says it belongs.
520
800
  *
package/src/render.ts CHANGED
@@ -124,6 +124,27 @@ export const FRAMING_FPS = 60;
124
124
  export const FRAMES_SIDECAR = 'frames.json';
125
125
  export const FRAMES_SPEC = 'rigc-frames/1';
126
126
 
127
+ /**
128
+ * The contact sheet beside a frame set, and the one number its layout needs.
129
+ *
130
+ * ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
131
+ * commits a couple of stills and folds every sampled frame into one PNG, so for
132
+ * such a set the sheet is the only picture of the 309 frames in between, and
133
+ * `check` compares against its tiles (issue #36). That makes the layout a
134
+ * contract between two programs — `bench/render_reference.ts` writes the grid and
135
+ * `src/check.ts` reads it — so the column count lives here rather than in either.
136
+ *
137
+ * The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
138
+ * reader can measure it exactly off the sheet's own dimensions given the frame
139
+ * count and the column count (`check`'s `sheetGeometry` does), so recording it
140
+ * would be a second definition of something already written down in pixels.
141
+ */
142
+ export const SHEET_COLUMNS = 8;
143
+ /** The sheet's file name inside a frame directory. */
144
+ export const SHEET_FILE = 'contact.png';
145
+ /** One pixel of rule between tiles, and one around the outside. */
146
+ export const SHEET_GAP = 1;
147
+
127
148
  /** One rendered frame directory: which animation, at what rate, and what is on disk. */
128
149
  export interface FrameSet {
129
150
  /** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
package/src/slots.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * The cheap matcher labels the reference frame's connected components and asks
7
7
  * which one each of the candidate's slots landed on. It is right whenever the
8
- * parts of a shot are separate blobs, and it has two failure modes that two
9
- * honest ladder runs hit head-on (issue #34):
8
+ * parts of a shot are separate blobs, and it has three failure modes that honest
9
+ * ladder runs hit head-on (issues #34 and #37) — all three the same mistake, which
10
+ * is treating a blob as a part:
10
11
  *
11
12
  * - **Parts that touch label as one component.** Rung 4 is a disc with five chain
12
13
  * links hanging off it; they touch in every frame of every animation, so every
@@ -15,6 +16,13 @@
15
16
  * 4 px ball, on the frames where it rests against the course and has no component
16
17
  * of its own, matched the floating girder 47 px away — and the summary line
17
18
  * reported **48.3 px of drift for a 4 px ball**, unflagged.
19
+ * - **A blob one part dominates passes for that part.** Rung 2's reference merges
20
+ * the course, the water, the panel and both rings into one component in which the
21
+ * course is 81 % of the ink, so it is 1.24x the course's own and no wider than its
22
+ * box — both merge tests see nothing, and the run reported *"course drift
23
+ * 11.2 px"*, the distance to a five-part centroid (issue #37). `occupantsOf`
24
+ * answers that one with the label map: anything else the candidate drew inside the
25
+ * blob makes the blob's centroid nobody's position.
18
26
  *
19
27
  * So: components first, and when a component cannot be attributed to one slot, the
20
28
  * slot's own rendered quad is **template-matched** against the reference in a
@@ -93,6 +101,21 @@ export interface Component {
93
101
  maxY: number;
94
102
  }
95
103
 
104
+ /**
105
+ * The reference frame's components, and which one each pixel belongs to.
106
+ *
107
+ * The label map is what makes "is anything ELSE inside this blob?" a measurement
108
+ * rather than a guess from bounding boxes — see `occupantsOf`. It is indexed
109
+ * row-major on the frame's own grid, and `-1` is background or a component too
110
+ * small to be a part.
111
+ */
112
+ export interface ComponentField {
113
+ components: Component[];
114
+ labels: Int32Array;
115
+ width: number;
116
+ height: number;
117
+ }
118
+
96
119
  /**
97
120
  * Connected components of "not the background colour", 8-connected.
98
121
  *
@@ -101,6 +124,11 @@ export interface Component {
101
124
  * twenty and every match is ambiguous for a reason that is about the labeller.
102
125
  */
103
126
  export function componentsOf(plate: Plate, background: RGBA): Component[] {
127
+ return componentField(plate, background).components;
128
+ }
129
+
130
+ /** The same labelling, with the map kept — see `ComponentField`. */
131
+ export function componentField(plate: Plate, background: RGBA): ComponentField {
104
132
  const { width, height } = plate;
105
133
  const label = new Int32Array(width * height).fill(-1);
106
134
  const out: Component[] = [];
@@ -145,7 +173,54 @@ export function componentsOf(plate: Plate, background: RGBA): Component[] {
145
173
  out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
146
174
  }
147
175
  }
148
- return out.filter((c) => c.pixels >= MIN_COMPONENT_PIXELS).sort((a, b) => b.pixels - a.pixels);
176
+ // Crumbs out, biggest first — and the label map carried through the reorder, so
177
+ // a label is always an index into the array the caller is handed. Renumbering
178
+ // rather than sorting the map is what keeps the two from drifting apart.
179
+ const keep = out.map((c, id) => ({ c, id })).filter(({ c }) => c.pixels >= MIN_COMPONENT_PIXELS);
180
+ keep.sort((a, b) => b.c.pixels - a.c.pixels);
181
+ const renumbered = new Int32Array(out.length).fill(-1);
182
+ keep.forEach(({ id }, index) => {
183
+ renumbered[id] = index;
184
+ });
185
+ for (let at = 0; at < label.length; at++) label[at] = label[at] < 0 ? -1 : renumbered[label[at]];
186
+ return { components: keep.map(({ c }) => c), labels: label, width, height };
187
+ }
188
+
189
+ /**
190
+ * Which drawn slots' ink sits inside each component, by centroid.
191
+ *
192
+ * ## Why this exists: a blob one part dominates is still a blob
193
+ *
194
+ * The size and bounding-box tests below catch a merge when the merged neighbour is
195
+ * a material fraction of the blob. They cannot catch the case issue #37 filed:
196
+ * rung 2's reference merges the course, the water, the panel and both rings into
197
+ * one component, and the **course is 81 % of it**, so the blob is only 1.24x the
198
+ * course's own ink and barely wider than the course's own box. It passed every
199
+ * test, and the summary line reported *"course drift 11.2 px"* — the distance from
200
+ * the course's centroid to the centroid of a blob holding four other parts, which
201
+ * is not a measurement of the course at all.
202
+ *
203
+ * One label lookup per drawn slot answers it exactly: if anything else the
204
+ * candidate drew lands on this component's own pixels, the component is more than
205
+ * one part and its centroid is nobody's position. That is the same judgement the
206
+ * two-claimants rule below already makes; what was missing is that a slot which
207
+ * never got as far as *claiming* the blob — because it was refused for being 13x
208
+ * too small, or diverted to the template matcher — still proves the blob is shared.
209
+ */
210
+ function occupantsOf(field: ComponentField, footprints: Map<string, Footprint>): Map<number, string[]> {
211
+ const out = new Map<number, string[]>();
212
+ for (const [slot, foot] of footprints) {
213
+ if (foot.pixels === 0) continue;
214
+ const x = Math.floor(foot.cx);
215
+ const y = Math.floor(foot.cy);
216
+ if (x < 0 || y < 0 || x >= field.width || y >= field.height) continue;
217
+ const label = field.labels[y * field.width + x];
218
+ if (label < 0) continue;
219
+ const seen = out.get(label) ?? [];
220
+ seen.push(slot);
221
+ out.set(label, seen);
222
+ }
223
+ return out;
149
224
  }
150
225
 
151
226
  /** How the drift beside a slot was arrived at. `none` means it could not be. */
@@ -213,11 +288,16 @@ interface Pending {
213
288
  */
214
289
  export function matchSlots(
215
290
  footprints: Map<string, Footprint>,
216
- components: Component[],
291
+ field: ComponentField,
217
292
  source: SlotSource | null,
218
293
  ): { tracks: SlotTrack[]; matchedComponents: number } {
294
+ const components = field.components;
219
295
  const pending: Pending[] = [];
220
296
  const takenBy = new Map<Component, string[]>();
297
+ const occupants = occupantsOf(field, footprints);
298
+ /** Which component each one is, so an occupancy list can be looked up by it. */
299
+ const idOf = new Map<Component, number>();
300
+ components.forEach((component, id) => idOf.set(component, id));
221
301
 
222
302
  for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
223
303
  const track = blankTrack(slot);
@@ -320,6 +400,24 @@ export function matchSlots(
320
400
  }
321
401
  }
322
402
 
403
+ // ...and a component ONE slot claimed while other ink of the candidate's sits
404
+ // inside it is the same blob by the other route — the one that reported a
405
+ // dominant part's distance to a five-part blob as that part's drift (#37). The
406
+ // claim goes to the template matcher for the same reason: a centroid shared by
407
+ // several parts is not this part's position.
408
+ for (const entry of pending) {
409
+ if (entry.claimed === null || entry.track.ambiguity !== null) continue;
410
+ const id = idOf.get(entry.claimed);
411
+ if (id === undefined) continue;
412
+ const others = (occupants.get(id) ?? []).filter((slot) => slot !== entry.track.slot);
413
+ if (others.length === 0) continue;
414
+ entry.track.ambiguity =
415
+ `this slot's reference component also holds ${others.map((s) => JSON.stringify(s)).join(', ')} — ` +
416
+ `${entry.claimed.pixels} px of blob against this slot's own ${Math.round(entry.foot.pixels)} px, so its ` +
417
+ "centroid is the merged shape's and not this part's";
418
+ clearMatch(entry.track);
419
+ }
420
+
323
421
  // The fallback: anything the components could not attribute, correlated against
324
422
  // its own rendered pixels.
325
423
  if (source) {