rig-parts 1.5.1 → 1.5.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 CHANGED
@@ -37,8 +37,8 @@ is taken from the full run, because the twin tails leave the head crop sideways
37
37
  (a plan edit). <code>check</code> printed, verbatim:<br/>
38
38
  <code>50 assertions: 24 measured (24 passed, 0 failed), 26 skipped, 0 not in profile "spine-html"</code><br/>
39
39
  <code>loop: idle 49 frame(s) at 12 fps, f0000 vs f0048 (t = 4s): max |d| 0 (0 required)</code><br/>
40
- <code>seam: setup pose at 714x1216, scale 0.9422: mean |d| 0.324 (&lt;= 1.0), 2 px over 40 (&lt;= 50), 0 px over 80 (reported)</code><br/>
41
- <code>pack: skeleton.png 922x1348, 22 region(s), 95.0% covered, padding 2, page edges free, shape polygon</code><br/>
40
+ <code>seam: setup pose at 714x1216, scale 0.9422: mean |d| 0.234 (&lt;= 1.0), 2 px over 40, 2.25 rig px (the count / s²; &lt;= 50), 0 px over 80 (reported)</code><br/>
41
+ <code>pack: skeleton.png 922x1348, 22 region(s), 94.9% covered, padding 2, page edges free, shape polygon</code><br/>
42
42
  <code>--seam silhouette</code> repaired the navy blobs the default rule leaves on the white
43
43
  blouse (recomposite error pixels 11,050 → 9,540); the default stays
44
44
  <code>near-white</code>, the reference implementation's rule, so the examples stay
@@ -60,7 +60,7 @@ made the contact sheet.
60
60
  <p align="center"><em>
61
61
  The plain fixture, <code>examples/sample</code>: same checkpoint, no LoRA, and a rig
62
62
  built from its own proposal with no hand edit at all —
63
- <code>seam: … mean |d| 0.207 (&lt;= 1.0), 0 px over 40 (&lt;= 50)</code>, loop max |d| 0.
63
+ <code>seam: setup pose at 379x1216, scale 1.0116: mean |d| 0.204 (&lt;= 1.0), 2 px over 40, 1.95 rig px (the count / s²; &lt;= 50), 0 px over 80 (reported)</code>, loop max |d| 0.
64
64
  </em></p>
65
65
 
66
66
  <p align="center"><em>
package/cli.ts CHANGED
@@ -225,12 +225,17 @@ usage:
225
225
  contact.png, motion_heat.png and check.json (with rigc_entry, the rigc
226
226
  entry that gated the build, and pack_mode, the two pack flags). PASS needs every gate summary
227
227
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
228
- |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
229
- frame at t = duration) at max |d| 0. A failing seam, or any seam under
230
- --seam-pairs, is split by the pair of parts each counted pixel lies
231
- between: one "seam pair" line each, worst first, with its count, frame
232
- and rig boxes and a picture under seam/, and check.json's seam_pairs
233
- (AUTHORING §7). --keep-stills leaves the setup-pose still the seam reads,
228
+ |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} (the frame pixels over ${SEAM_PX_LEVEL}
229
+ divided by s², s the frame scale the seam: line prints and check.json's
230
+ seam_frame_scale holds, so the bar means the same at every scale), and
231
+ the loop (idle frame 0 vs the frame at t = duration) at max |d| 0. A
232
+ failing seam, or any seam under --seam-pairs, is split by the pair of
233
+ parts each counted pixel lies between: one "seam pair" line each, worst
234
+ first, with its count in frame px, frame and rig boxes and a picture
235
+ under seam/, and check.json's seam_pairs (AUTHORING §7). A seam picture
236
+ is the setup-pose frame with that pair's counted pixels painted magenta
237
+ (255, 0, 255) — magenta = a counted pixel — and a cyan (0, 255, 255)
238
+ box one pixel outside them. --keep-stills leaves the setup-pose still the seam reads,
234
239
  rigc's render directory with its frames.json (viewport and background),
235
240
  under setup_still/. Then six judgement lines, each in
236
241
  check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
@@ -694,12 +699,13 @@ function rigcRunner(bin: string): RigcRunner {
694
699
  };
695
700
  }
696
701
 
697
- function flags(args: string[], known: readonly string[], command: string, optional: readonly string[] = []): Map<string, string> | string {
702
+ /** `switches` are the command's bare flags, taken out by the caller before this reads the valued ones; they are only named in the unknown-flag refusal, so it lists every flag the command takes. */
703
+ function flags(args: string[], known: readonly string[], command: string, optional: readonly string[] = [], switches: readonly string[] = []): Map<string, string> | string {
698
704
  const got = new Map<string, string>();
699
705
  const all = [...known, ...optional];
700
706
  for (let i = 0; i < args.length; i += 2) {
701
707
  const flag = args[i];
702
- if (!all.includes(flag)) return `${command} does not take "${flag}"; it takes ${all.join(', ')}`;
708
+ if (!all.includes(flag)) return `${command} does not take "${flag}"; it takes ${[...all, ...switches].join(', ')}`;
703
709
  if (args[i + 1] === undefined) return `${flag} needs a value`;
704
710
  if (got.has(flag)) return `${flag} is given twice`;
705
711
  got.set(flag, args[i + 1]);
@@ -719,6 +725,7 @@ function cmdCheck(args: string[]): number {
719
725
  ['--rig', '--out'],
720
726
  'check',
721
727
  ['--parts', '--source', '--page-edges', '--pack-shape', '--requirements'],
728
+ ['--seam-pairs', '--keep-stills'],
722
729
  );
723
730
  if (typeof f === 'string') return usage(f);
724
731
  const rig = f.get('--rig') as string;
package/docs/AUTHORING.md CHANGED
@@ -1685,7 +1685,7 @@ See-through with another seed.
1685
1685
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
1686
1686
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
1687
1687
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted); with a physics constraint, the frames of the rig with every physics constraint left out, and the object says so (§7, *loop*) | a track whose last key is not its first |
1688
- | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | read the `seam pair` lines and `check/seam/` pictures (§7): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png`. A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
1688
+ | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | read the `seam pair` lines and `check/seam/` pictures (§7; magenta = a counted pixel, cyan = the box round them): the pair, its boxes and the pixels counted. Usually a region or mesh placed off its part; compare with `recomposite_rig.png`. A pair of one layer cut in two with `differing_copies_px` is the cut line: move the polygon's edge, not `overlap` |
1689
1689
  | `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
1690
1690
  | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
1691
1691
  | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
@@ -1753,7 +1753,19 @@ assemble*, at the end of this section):
1753
1753
  `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` on the packed pass, 16 not in `spine`; `CH09`
1754
1754
  holds it on every fetched example);
1755
1755
  - **seam**: the setup-pose render against the flat composite of `parts/`: mean
1756
- max-channel |d| ≤ 1.0 of 255, and at most 50 pixels over 40. The bar reads one
1756
+ max-channel |d| ≤ 1.0 of 255, and at most 50 **rig** pixels over 40 — the
1757
+ frame pixels over 40 divided by s², `s` the frame scale the `seam:` line
1758
+ prints and `check.json`'s `seam_frame_scale` holds (issue #216). The frame
1759
+ holds s² pixels per rig pixel, so a stack that parts from the render over a
1760
+ fixed area of the rig reads s² times as many frame pixels: on the public
1761
+ `sample` a part moved 2 rig px read 400, 891 and 1,560 frame px over 40 at
1762
+ s = 1.0, 1.5 and 2.0, and 400, 396 and 390 rig px; the sample as built read
1763
+ 3, 3 and 7 frame px [measured, issue #216]. Divided by s² the bar reads the
1764
+ same rig at every scale, and at s = 1 it is the reference's count. The mean
1765
+ is a share of the frame and needs no such division (0.242, 0.188 and 0.226 on
1766
+ the sample as built; 0.353, 0.305 and 0.340 with the part moved).
1767
+ `seam_px_over_40` and `seam_px_over_80` stay frame pixels, and so does each
1768
+ pair's count below. The bar reads one
1757
1769
  frame, the setup-pose still, never the idle. When it fails (or when `check
1758
1770
  --seam-pairs` asks, pass or fail), the pixels over 40 are split by the pair of
1759
1771
  parts that answers for each (issue #202): the two parts drawn last among those
@@ -1761,7 +1773,8 @@ assemble*, at the end of this section):
1761
1773
  sample reads for that frame pixel, lower first, `(no art)` standing in where
1762
1774
  fewer than two do. Each pair is a console line `seam pair <i> of <n>: "<a>" /
1763
1775
  "<b>" at the setup pose: <count> px over 40 (<count> over 80), frame box x
1764
- <x0>..<x1>, y <y0>..<y1>, rig box …, picture seam/<i>_<a>_<b>.png`, worst first,
1776
+ <x0>..<x1>, y <y0>..<y1>, rig box …, picture seam/<i>_<a>_<b>.png` (in the
1777
+ picture, **magenta = a counted pixel**, cyan = the box round them), worst first,
1765
1778
  and a row of `check.json`'s `seam_pairs` (`parts`, `px_over_40`, `px_over_80`,
1766
1779
  `frame_box` in frame pixels, `rig_box` in the rig pixels of `parts.json` and
1767
1780
  `cuts[].polygon`, `picture`); `CHECK_SEAM_WITHIN_BAR`'s line quotes the worst.
package/docs/STABILITY.md CHANGED
@@ -158,7 +158,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
158
158
  | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from`, the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite` |
159
159
  | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
160
160
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions`; or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
161
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
161
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `RECOMPOSITE_HOLES`, `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
162
162
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
163
163
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
164
164
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { BARS, besideIdleLine, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
73
+ import { BARS, besideIdleLine, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
74
  import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
75
  import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
@@ -981,7 +981,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
981
981
  else {
982
982
  log(
983
983
  ` seam: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
984
- `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL} (<= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
984
+ `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL}, ${fig.seam_frame_scale === null || fig.seam_px_over_40 === null ? '' : seamRigPxText(fig.seam_px_over_40, fig.seam_frame_scale)} rig px (the count / s²; <= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
985
985
  );
986
986
  const pairs = fig.seam_pairs ?? [];
987
987
  pairs.forEach((row, i) => log(` seam pair ${i + 1} of ${pairs.length}: ${seamPairText(row)}`));
package/src/check.ts CHANGED
@@ -153,7 +153,17 @@ import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
153
153
 
154
154
  /** Seam: mean max-channel |d| over the frame, in levels of 255, at most this. */
155
155
  export const SEAM_MEAN_BAR = 1.0;
156
- /** Seam: pixels whose max-channel |d| exceeds {@link SEAM_PX_LEVEL}, at most this many. */
156
+ /**
157
+ * Seam: pixels whose max-channel |d| exceeds {@link SEAM_PX_LEVEL}, at most
158
+ * this many **rig** pixels — the frame count divided by s², s the frame scale
159
+ * the setup-pose still renders at (issue #216). The frame holds s² pixels per
160
+ * rig pixel, so a stack that parts from the render over a fixed area of the
161
+ * rig reads s² times as many frame pixels: on the public sample a part moved
162
+ * 2 rig px read 400, 891 and 1,560 frame px over 40 at s = 1.0, 1.5 and 2.0,
163
+ * and 400, 396 and 390 divided by s²; the uncut sample 3, 3 and 7 frame px
164
+ * [measured for issue #216, a scratch run, not tracked]. At s = 1 the bar is
165
+ * the reference's frame count.
166
+ */
157
167
  export const SEAM_PX_BAR = 50;
158
168
  export const SEAM_PX_LEVEL = 40;
159
169
  /** Seam: the second, reported-only level. */
@@ -1372,9 +1382,29 @@ function round3(x: number): number {
1372
1382
  return Number(x.toFixed(3));
1373
1383
  }
1374
1384
 
1375
- /** The seam bar, on the figures as check.json writes them: the mean (rounded to three places) over {@link SEAM_MEAN_BAR}, or more than {@link SEAM_PX_BAR} pixels over {@link SEAM_PX_LEVEL}. */
1376
- function seamFails(mean: number, over40: number): boolean {
1377
- return mean > SEAM_MEAN_BAR || over40 > SEAM_PX_BAR;
1385
+ /** Python's `round(x, 6)` for the frame scale written: to six places, on the value's exact decimal expansion. */
1386
+ function round6(x: number): number {
1387
+ return Number(x.toFixed(6));
1388
+ }
1389
+
1390
+ /** The seam's count in rig pixels (issue #216): the frame pixels over {@link SEAM_PX_LEVEL} divided by the square of the frame scale. */
1391
+ export function seamRigPx(over40: number, scale: number): number {
1392
+ return over40 / (scale * scale);
1393
+ }
1394
+
1395
+ /** {@link seamRigPx} as the console and the refusal print it, to two places. */
1396
+ export function seamRigPxText(over40: number, scale: number): string {
1397
+ return seamRigPx(over40, scale).toFixed(2);
1398
+ }
1399
+
1400
+ /**
1401
+ * The seam bar, on the figures as check.json writes them: the mean (rounded
1402
+ * to three places) over {@link SEAM_MEAN_BAR}, or more than
1403
+ * {@link SEAM_PX_BAR} rig pixels over {@link SEAM_PX_LEVEL} — the frame count
1404
+ * over the square of the written frame scale (issue #216).
1405
+ */
1406
+ function seamFails(mean: number, over40: number, scale: number): boolean {
1407
+ return mean > SEAM_MEAN_BAR || seamRigPx(over40, scale) > SEAM_PX_BAR;
1378
1408
  }
1379
1409
 
1380
1410
  /** A seam row as the console prints it: the pair, the frame, the counts, both boxes and the picture. */
@@ -1536,10 +1566,16 @@ export interface CheckFigures {
1536
1566
  * read from. Reported, not judged; a rig without one writes the keys it always wrote.
1537
1567
  */
1538
1568
  loop_physics?: LoopPhysics[];
1539
- /** The three seam figures: null when there is no parts.json (issue #77); `skipped.seam` then says why. */
1569
+ /** The seam figures: null when there is no parts.json (issue #77); `skipped.seam` then says why. The two counts are frame pixels. */
1540
1570
  seam_mean: number | null;
1541
1571
  seam_px_over_40: number | null;
1542
1572
  seam_px_over_80: number | null;
1573
+ /**
1574
+ * The frame scale s the setup-pose still rendered at (rigc's viewport
1575
+ * scale, to six places), issue #216: the seam bar reads `seam_px_over_40`
1576
+ * divided by its square ({@link seamRigPx}). Null with the other three.
1577
+ */
1578
+ seam_frame_scale: number | null;
1543
1579
  /**
1544
1580
  * Written only when the seam bar fails, or when it was asked for (`check
1545
1581
  * --seam-pairs`), issue #202: the counted pixels split by the pair of parts
@@ -1600,7 +1636,9 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1600
1636
  const out: string[] = [];
1601
1637
  if (fig.gate_spine_html_green !== true) out.push('gate');
1602
1638
  if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
1603
- if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40)) out.push('seam');
1639
+ // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1640
+ const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1641
+ if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
1604
1642
  for (const n of JUDGEMENT_LINES) {
1605
1643
  const l = fig[n];
1606
1644
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
@@ -1895,7 +1933,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1895
1933
  if (inp.parts !== null) {
1896
1934
  const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
1897
1935
  seam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background);
1898
- if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40)) {
1936
+ if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40, round6(stillSet.viewport.scale))) {
1899
1937
  const placed = inp.parts.parts.map((p) => ({ name: p.name, from: p.from, x: p.x, y: p.y, image: readPng(join(inp.partsDir, `${p.name}.png`)) }));
1900
1938
  const split = seamPairRows(seam, placed, inp.parts.rig_size, stillSet.viewport);
1901
1939
  mkdirSync(seamDir, { recursive: true });
@@ -1955,6 +1993,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1955
1993
  seam_mean: seam === null ? null : round3(seam.mean),
1956
1994
  seam_px_over_40: seam === null ? null : seam.over40,
1957
1995
  seam_px_over_80: seam === null ? null : seam.over80,
1996
+ seam_frame_scale: seam === null || seamViewport === null ? null : round6(seamViewport.scale),
1958
1997
  ...(seamPairs === null ? {} : { seam_pairs: seamPairs }),
1959
1998
  BREATH_VISIBLE: breath,
1960
1999
  BLINK_NO_HOLE: blink,
@@ -1978,13 +2017,13 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
1978
2017
  detail: `max |d| ${loop.max}/255, first at pixel ${loop.x},${loop.y} of ${loopSet.viewport.pixelWidth}x${loopSet.viewport.pixelHeight}; ${LOOP_MAX_BAR} is required — the idle's last key must equal its first`,
1979
2018
  });
1980
2019
  }
1981
- if (figures.seam_mean !== null && figures.seam_px_over_40 !== null && seamFails(figures.seam_mean, figures.seam_px_over_40)) {
2020
+ if (figures.seam_mean !== null && figures.seam_px_over_40 !== null && figures.seam_frame_scale !== null && seamFails(figures.seam_mean, figures.seam_px_over_40, figures.seam_frame_scale)) {
1982
2021
  const worst = seamPairs === null || seamPairs.length === 0 ? null : seamPairs[0];
1983
2022
  barProblems.push({
1984
2023
  code: 'CHECK_SEAM_WITHIN_BAR',
1985
2024
  object: 'the setup-pose render vs the flat composite of parts/',
1986
2025
  detail:
1987
- `mean |d| ${figures.seam_mean}/255 and ${figures.seam_px_over_40} px over ${SEAM_PX_LEVEL} (${figures.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL} are required` +
2026
+ `mean |d| ${figures.seam_mean}/255 and ${figures.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${figures.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${figures.seam_frame_scale}, ${seamRigPxText(figures.seam_px_over_40, figures.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
1988
2027
  (worst === null
1989
2028
  ? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
1990
2029
  : `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),