spine-rigc 1.4.0 → 1.5.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.
package/src/atlas.ts CHANGED
@@ -543,12 +543,18 @@ export interface EmitPage {
543
543
  *
544
544
  * Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
545
545
  * it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
546
- * noise: the nearest other width on the `FREE_EDGE_STEP` grid moves P5's
547
- * unturned page by 18.13 %, and searching every width instead of every 32nd
548
- * finds an unturned P5 page 10.22 % smaller with nothing turned. With every
546
+ * noise: the nearest other width on the 32-px grid the search then used moves
547
+ * P5's unturned page by 18.13 %, and searching every width instead of every
548
+ * 32nd finds an unturned P5 page 10.22 % smaller with nothing turned. With every
549
549
  * width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
550
550
  * (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
551
551
  * packer still never turns a region.
552
+ *
553
+ * ⚠️ The `free` column and the 18.13 % are measurements of the search as it was
554
+ * on that day, a 32-px width grid. Issue #872 replaced it with every width (see
555
+ * `FREE_EDGE_STEP`), so today's `free` pages for these sets are the every-width
556
+ * ones the paragraph above compares against, and the 5.05 % is the turn gain
557
+ * measured on the search that ships.
552
558
  */
553
559
  export const PACK_NO_ROTATE = 0;
554
560
 
@@ -628,7 +634,7 @@ export const DEFAULT_PADDING = 2;
628
634
  * What a packed page's edges may be — `--page-edges`. See `packAtlas`, *Page size*.
629
635
  *
630
636
  * `pot` is the default and the only value until issue #860: both edges are powers
631
- * of two. `free` lets the width be any multiple of `FREE_EDGE_STEP` and the height
637
+ * of two. `free` lets the width be any whole number of pixels and the height
632
638
  * whatever the placement needs. What `free` costs is measured rather than
633
639
  * conceded: a region attachment's sampling coordinate stops being exact and moves
634
640
  * to `PK05`'s bound of one least significant bit, the bound a mesh already had.
@@ -636,8 +642,28 @@ export const DEFAULT_PADDING = 2;
636
642
  export const PAGE_EDGES = ['pot', 'free'] as const;
637
643
  export type PageEdges = (typeof PAGE_EDGES)[number];
638
644
  export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
639
- /** The step a `free` page's width is tried on. See `packAtlas`, *Page size*. */
640
- export const FREE_EDGE_STEP = 32;
645
+ /**
646
+ * The step a `free` page's width is tried on: **1, every width** (issue #872).
647
+ * See `smallestFreePageFor` for the search and `packAtlas`, *Page size*, for
648
+ * what it buys.
649
+ *
650
+ * It was 32 until #872, and the grid was the cost. On twelve region sets (two
651
+ * painting rigs and ten production rigs of 20 to 22 parts) the 32-px grid's page
652
+ * was larger than the every-width page on eleven, by up to 11.38 % (P5: 1344x1768
653
+ * against 1427x1495). Finer fixed steps and coarse-to-fine searches were measured
654
+ * against the every-width page too, and none of them is safe: the area as a
655
+ * function of the width has minima one pixel wide, so a step-4 grid refined
656
+ * over ±3 px of its best four widths still misses one (the fetched
657
+ * `5-squash-and-stretch` atlas: 867x415 against 863x415), and the coarse-to-fine
658
+ * search that was exact on all twelve sets missed by 0.82 % on the first
659
+ * held-out atlas it met. Every width is exact by construction; what keeps it
660
+ * cheap is the two bounds `smallestFreePageFor` prunes with, and they are exact
661
+ * too.
662
+ *
663
+ * Kept as a named constant rather than deleted because it is importable, and a
664
+ * reader of the old value should find the new one rather than a missing name.
665
+ */
666
+ export const FREE_EDGE_STEP = 1;
641
667
 
642
668
  /** The part a page is packed from: its region name and its pixels. */
643
669
  export interface PackInput {
@@ -751,9 +777,8 @@ function smallestPageFor(
751
777
  *
752
778
  * The rule, which `docs/AUTHORING.md` §0.1 states in the same words:
753
779
  *
754
- * * the candidate WIDTHS are the multiples of `FREE_EDGE_STEP` from the widest
755
- * cell up to `maxEdge`, plus `maxEdge` itself (it is a power of two, so this
756
- * only adds anything when it is smaller than the step);
780
+ * * the candidate WIDTHS are every whole width from the widest cell up to
781
+ * `maxEdge` (`FREE_EDGE_STEP` is 1);
757
782
  * * at each width the cells are placed by the same MaxRects pass as a `pot`
758
783
  * page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
759
784
  * lowest cell — what that placement needs, not rounded;
@@ -766,26 +791,84 @@ function smallestPageFor(
766
791
  * in the page height (a shorter page changes which free rectangle scores best),
767
792
  * so "the least height that fits" is not a quantity a bisection could find, and a
768
793
  * definition that could not be computed exactly would not be deterministic.
794
+ *
795
+ * ⭐ **Two bounds make every width cheap, and neither can change the answer**
796
+ * (issue #872). Both compare against the best page found so far, and both are
797
+ * strict, so a width that could still TIE the best — and win on the squarer or
798
+ * narrower key — is always placed to the end:
799
+ *
800
+ * * a width is not placed at all when the cells' own area exceeds
801
+ * `width x maxEdge` (it cannot fit), or when `width x` the tallest cell
802
+ * already exceeds the best area (no page at that width is shorter than its
803
+ * tallest cell);
804
+ * * a placement is abandoned the moment its lowest cell's bottom edge makes
805
+ * `width x bottom` exceed the best area. The bottom edge only grows as cells
806
+ * are added, so the page that placement would have finished is larger still.
807
+ *
808
+ * Neither changes a placement that runs to the end — the pass is the `pot` pass,
809
+ * on the same page, and abandoning it only stops it early — so the winner is the
810
+ * every-width winner, and `PK75` compares the two on every set it builds. What
811
+ * they save, measured on the twelve sets: 391,854 cell placements for an
812
+ * unbounded every-width search, 47,978 bounded, against 12,361 for the retired
813
+ * 32-px grid.
769
814
  */
770
815
  function smallestFreePageFor(
771
816
  cells: Array<{ w: number; h: number }>,
772
817
  maxEdge: number,
773
818
  ): { width: number; height: number; rects: Rect[] } | null {
819
+ return freePageSearch(cells, maxEdge).page;
820
+ }
821
+
822
+ /** What one `free` page search did: the page it chose, and what each width cost. */
823
+ export interface FreePageSearch {
824
+ page: { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null;
825
+ /** Candidate widths, from the widest cell to `maxEdge`. */
826
+ widths: number;
827
+ /** Widths whose placement ran to the end. */
828
+ completed: number;
829
+ /** Widths whose placement stopped once its lowest cell made the page larger than the best so far. */
830
+ abandoned: number;
831
+ /** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
832
+ skipped: number;
833
+ }
834
+
835
+ /**
836
+ * `smallestFreePageFor`'s search with its bookkeeping — exported so the
837
+ * selftest can compare its page against an unbounded every-width reference and
838
+ * see that the bounds did prune (`PK75`). The page is the whole of what the
839
+ * packer uses; the counts are for the control.
840
+ */
841
+ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number): FreePageSearch {
774
842
  let widest = 0;
775
- for (const cell of cells) widest = Math.max(widest, cell.w);
776
- const widths: number[] = [];
777
- for (let w = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; w <= maxEdge; w += FREE_EDGE_STEP) widths.push(w);
778
- if (widths[widths.length - 1] !== maxEdge && widest <= maxEdge) widths.push(maxEdge);
843
+ let tallest = 0;
844
+ let cellArea = 0;
845
+ for (const cell of cells) {
846
+ widest = Math.max(widest, cell.w);
847
+ tallest = Math.max(tallest, cell.h);
848
+ cellArea += cell.w * cell.h;
849
+ }
850
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
779
851
  let best: { width: number; height: number; rects: Rect[] } | null = null;
780
- for (const width of widths) {
781
- const attempt = packOnePage(cells, width, maxEdge);
852
+ for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
853
+ out.widths++;
854
+ const bestArea = best === null ? Infinity : best.width * best.height;
855
+ if (cellArea > width * maxEdge || width * tallest > bestArea) {
856
+ out.skipped++;
857
+ continue;
858
+ }
859
+ const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
860
+ const attempt = packOnePage(cells, width, maxEdge, maxBottom);
861
+ let height = 0;
862
+ for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
863
+ if (height > maxBottom) {
864
+ out.abandoned++;
865
+ continue;
866
+ }
867
+ out.completed++;
782
868
  if (attempt.some((r) => r === null)) continue;
783
869
  const rects = attempt as Rect[];
784
- let height = 0;
785
- for (const r of rects) height = Math.max(height, r.y + r.h);
786
870
  if (best !== null) {
787
871
  const area = width * height;
788
- const bestArea = best.width * best.height;
789
872
  if (area > bestArea) continue;
790
873
  if (area === bestArea) {
791
874
  const square = Math.abs(width - height);
@@ -796,7 +879,28 @@ function smallestFreePageFor(
796
879
  }
797
880
  best = { width, height, rects };
798
881
  }
799
- return best;
882
+ out.page = best;
883
+ return out;
884
+ }
885
+
886
+ /**
887
+ * One `free` candidate, unbounded: the cells placed at `width` on a page
888
+ * `maxEdge` tall, and the height that placement needs — or `null` when they do
889
+ * not all fit. It is the pass `freePageSearch` runs at each width with no
890
+ * bound, exported so the selftest can rebuild a reference search from the same
891
+ * placement (`PK74`'s 32-px grid, `PK75`'s every width, `PK76`'s `pot` search).
892
+ */
893
+ export function placeAtWidth(
894
+ cells: Array<{ w: number; h: number }>,
895
+ width: number,
896
+ height: number,
897
+ ): { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null {
898
+ const attempt = packOnePage(cells, width, height);
899
+ if (attempt.some((r) => r === null)) return null;
900
+ const rects = attempt as Rect[];
901
+ let bottom = 0;
902
+ for (const r of rects) bottom = Math.max(bottom, r.y + r.h);
903
+ return { width, height: bottom, rects };
800
904
  }
801
905
 
802
906
  /**
@@ -813,8 +917,18 @@ function smallestFreePageFor(
813
917
  * to be pushed. So the tie-break is spelled out and total — smallest long-side
814
918
  * leftover, then topmost, then leftmost — and the caller sorts its input before
815
919
  * calling. Same inputs, byte-identical page.
920
+ *
921
+ * `maxBottom` is the `free` search's abandon bound (see `freePageSearch`): once a
922
+ * placed cell's bottom edge passes it the pass stops, and the cells not placed
923
+ * are `null`. The default, `Infinity`, never stops a pass — which is every `pot`
924
+ * call, so a `pot` page is placed exactly as it was before the bound existed.
816
925
  */
817
- function packOnePage(cells: Array<{ w: number; h: number }>, pageW: number, pageH: number): Array<Rect | null> {
926
+ function packOnePage(
927
+ cells: Array<{ w: number; h: number }>,
928
+ pageW: number,
929
+ pageH: number,
930
+ maxBottom = Infinity,
931
+ ): Array<Rect | null> {
818
932
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
819
933
  const placed: Array<Rect | null> = [];
820
934
 
@@ -848,6 +962,10 @@ function packOnePage(cells: Array<{ w: number; h: number }>, pageW: number, page
848
962
  }
849
963
  const put: Rect = { x: best.x, y: best.y, w: cell.w, h: cell.h };
850
964
  placed.push(put);
965
+ if (put.y + put.h > maxBottom) {
966
+ while (placed.length < cells.length) placed.push(null);
967
+ return placed;
968
+ }
851
969
 
852
970
  // Split every free rectangle the placement overlaps, then prune.
853
971
  const next: Rect[] = [];
@@ -981,15 +1099,18 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
981
1099
  * Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
982
1100
  * and the editor's own packer writes one by default — 9 of the 10 atlases in the
983
1101
  * fetched `examples/` corpus are power-of-two on both edges. Under `free` the
984
- * search is `smallestFreePageFor`: widths on a `FREE_EDGE_STEP` grid, the height
985
- * the placement needs, least area first, then squarer, then narrower. Everything
1102
+ * search is `smallestFreePageFor`: every width from the widest cell up (issue
1103
+ * #872; a 32-px grid until then), the height the placement needs, least area
1104
+ * first, then squarer, then narrower, with two exact bounds that keep it cheap. Everything
986
1105
  * else is shared — the MaxRects pass, the packing order, the spill rule (which
987
1106
  * parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
988
1107
  * as the ceiling on both edges, floored to a power of two exactly as under `pot`.
989
1108
  *
990
1109
  * What it buys and what it costs, observed on two 20- and 22-part painting rigs:
991
- * the page went from 1024x2048 to 1888x697 (2,097,152 to 1,315,936 texels,
992
- * -37.3 %) and from 512x2048 to 480x1166 (1,048,576 to 559,680, -46.6 %). The cost is the exactness the paragraph above describes:
1110
+ * the page goes from 1024x2048 to 967x1338 (2,097,152 to 1,293,846 texels,
1111
+ * -38.3 %) and from 512x2048 to 479x1166 (1,048,576 to 558,514, -46.7 %). Under
1112
+ * the 32-px grid #860 shipped with they were 1888x697 (-37.3 %) and 480x1166
1113
+ * (-46.6 %). The cost is the exactness the paragraph above describes:
993
1114
  * `x / pageWidth` is no longer exact, so a REGION attachment's sampling
994
1115
  * coordinate joins a mesh's at `PK05`'s bound of one least significant bit
995
1116
  * against the loose build. It does not go past it, and no region's bytes change
package/src/check.ts CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  PAD,
81
81
  FRAMES_SIDECAR,
82
82
  FRAMES_SPEC,
83
+ nonFinitePoseOf,
83
84
  SHEET_COLUMNS,
84
85
  SHEET_FILE,
85
86
  SHEET_GAP,
@@ -1225,6 +1226,27 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1225
1226
  // to compare it — and posing twice is both slower and a chance for the framing
1226
1227
  // and the comparison to disagree about what they measured.
1227
1228
  const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as, poseOptions));
1229
+ // 🔒 A pose that is not finite is refused before anything measures it (issue
1230
+ // #873), in the words `render` and the geometry export use for it. Measured
1231
+ // on a planted bone at x=1e309 before this: an overflowing root read "posed no
1232
+ // drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
1233
+ // rotation at 1e309 exited 0 with MAE 1.00 — the part missing from every
1234
+ // frame, and nothing said why.
1235
+ const posed = prepared.filter((p) => p.missing === null);
1236
+ const overflow = posed.some((p) =>
1237
+ p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
1238
+ );
1239
+ if (overflow) {
1240
+ const found = nonFinitePoseOf(
1241
+ posable.data,
1242
+ options.skin,
1243
+ posed.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
1244
+ );
1245
+ if (found === null) {
1246
+ throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is — the two were posed differently');
1247
+ }
1248
+ throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
1249
+ }
1228
1250
  const pairs = prepared.flatMap((p) => p.pairs);
1229
1251
  if (pairs.length === 0) {
1230
1252
  notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
package/src/compile.ts CHANGED
@@ -38,7 +38,7 @@ import { parseJsonWithPosition } from './json-position.ts';
38
38
  // The "did you mean" list on a missing atlas region, from the one implementation
39
39
  // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
- import { nearMisses } from './keys.ts';
41
+ import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry } from './keys.ts';
42
42
  import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.ts';
43
43
  import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
44
44
  import {
@@ -2030,6 +2030,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2030
2030
  // and which refusals it deliberately leaves in this file.
2031
2031
  const motion = parseMotionSpec(readJson<unknown>(motionPath), motionPath);
2032
2032
  const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
2033
+ // The manifest is the record of what was measured, and its numbers become
2034
+ // bone positions and the stage: a `crop.h` of 1e309 built as `"height": null`
2035
+ // before issue #881. The rig spec's walk is `parseRigSpec`'s; this is the
2036
+ // same rule over the other file whose numbers reach the skeleton.
2037
+ if (manifest !== null && manifestPath !== null) refuseNumbersTheFileCannotCarry(manifest, manifestPath, dottedPath);
2033
2038
 
2034
2039
  // The rig spec names its own path in every message `parseRigSpec` throws (its
2035
2040
  // `where` argument, above). The motion spec gets no such treatment below —
package/src/keys.ts CHANGED
@@ -115,3 +115,90 @@ export function refuseUnknownKeys(
115
115
  (known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
116
116
  );
117
117
  }
118
+
119
+ /**
120
+ * The largest float32, `3.4028234663852886e38`. The skeleton file is written at
121
+ * float32 precision (`f32` in `src/compile.ts` rounds every emitted number), so
122
+ * this is the largest magnitude a stated number can have and still come out of
123
+ * the emitter as a number.
124
+ */
125
+ export const FLOAT32_MAX = 3.4028234663852886e38;
126
+
127
+ /**
128
+ * Refuse the first number in an input file that the skeleton file cannot carry
129
+ * — one that is not finite, or that is finite only as a double (issue #881).
130
+ *
131
+ * 🚨 The defect this closes was a green build. `JSON.parse` reads `1e309` as
132
+ * `Infinity`, the emitter wrote it, and `JSON.stringify(Infinity)` is `null`:
133
+ * a bone stated at `x: 1e309` built green on the overlay probe as `"x": null`
134
+ * and was read as 0, a value the spec never stated. `1e308` did exactly the same
135
+ * — it is a finite double, but every emitted number is rounded to a float32 and
136
+ * `Math.fround(1e308)` is `Infinity` — so the line is float32's range and not
137
+ * the double's: `3.4028234663852886e38` built green and was emitted as itself,
138
+ * the next double up that rounds past it (`3.4028235677973366e38`) was emitted
139
+ * as `null`. The predicate is therefore `Number.isFinite(Math.fround(n))`,
140
+ * which is `NaN`, both infinities and exactly that overflow.
141
+ *
142
+ * ⭐ It walks the file, not the emitter's route — `checkRigSpecKeys`'s design
143
+ * and its reason. Before this, a handful of readers in `compile.ts` carried a
144
+ * finite check of their own (vertex arrays, a path's lengths, a segments
145
+ * falloff, a slider's mapping, an event's payload), each right about its own
146
+ * field, and none of them was a rule. Measured when this was written, planting
147
+ * `1e309` at every numeric leaf of nine rig specs — 610 plants over 57 kinds
148
+ * of field: 19 kinds were refused by a rule of their own (30 of those 70
149
+ * refusals printing the Infinity as `null`, and some only by a later symptom
150
+ * such as *hull Infinity disagrees with the triangles*), 1 was caught only by
151
+ * the gate, and 37 built green with a `null` in them on at least one rig. One
152
+ * pass over every number the document holds cannot miss a reader added next
153
+ * month, and it runs before any of them — so a range rule further down (a
154
+ * radius that is positive, a weight in 0..1) never has to print `NaN`.
155
+ *
156
+ * 🔒 The report order is fixed: `Object.keys` of parsed JSON is the
157
+ * document's key order (integer-like keys first, as the language orders them)
158
+ * and arrays are visited by index, so the refusal names the same number on
159
+ * every run.
160
+ *
161
+ * ⚠️ What it cannot see is a number spelled as something else. `"x": "NaN"`
162
+ * is a string; there is no number here to refuse, and the reader downstream
163
+ * that coerces it is a type question this walk does not answer.
164
+ *
165
+ * `place` turns a path into the words the refusal uses for it.
166
+ */
167
+ export function refuseNumbersTheFileCannotCarry(
168
+ raw: unknown,
169
+ where: string,
170
+ place: (path: ReadonlyArray<string | number>) => string,
171
+ ): void {
172
+ const visit = (node: unknown, path: Array<string | number>): void => {
173
+ if (typeof node === 'number') {
174
+ if (Number.isFinite(Math.fround(node))) return;
175
+ const why = Number.isNaN(node)
176
+ ? ''
177
+ : Number.isFinite(node)
178
+ ? ' This one is finite as a double and has no float32 but Infinity.'
179
+ : ' JSON has no spelling for Infinity: a literal past ±1.7976931348623157e308, such as 1e309, parses to it.';
180
+ throw new CompileError(
181
+ `${where}: ${place(path)} is ${String(node)}; a number in this file is finite at float32 precision, at most ` +
182
+ `±${String(FLOAT32_MAX)}, because the skeleton is written as float32 — past that it would be emitted as ` +
183
+ `Infinity, which JSON writes as null, a value the file never stated.${why}`,
184
+ );
185
+ }
186
+ if (Array.isArray(node)) {
187
+ node.forEach((child, i) => visit(child, [...path, i]));
188
+ } else if (node !== null && typeof node === 'object') {
189
+ const record = node as Record<string, unknown>;
190
+ for (const key of Object.keys(record)) visit(record[key], [...path, key]);
191
+ }
192
+ };
193
+ visit(raw, []);
194
+ }
195
+
196
+ /** A path as the file spells it: `.key`, `."odd key"`, `[3]`. */
197
+ export function dottedPath(path: ReadonlyArray<string | number>): string {
198
+ let out = '';
199
+ for (const step of path) {
200
+ if (typeof step === 'number') out += `[${step}]`;
201
+ else out += /^[A-Za-z_][A-Za-z0-9_]*$/.test(step) ? `${out === '' ? '' : '.'}${step}` : `${out === '' ? '' : '.'}${JSON.stringify(step)}`;
202
+ }
203
+ return out;
204
+ }
package/src/motion.ts CHANGED
@@ -54,7 +54,7 @@
54
54
  * [`keys.ts`](keys.ts) shared with the rig parser.
55
55
  */
56
56
  import { CompileError } from './errors.ts';
57
- import { refuseUnknownKeys } from './keys.ts';
57
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys } from './keys.ts';
58
58
  import { SEQUENCE_MODES } from './timelines.ts';
59
59
  import type { MotionSpec } from './types.ts';
60
60
 
@@ -665,5 +665,11 @@ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
665
665
  parseAnimation(anim, where, name);
666
666
  }
667
667
 
668
+ // Last, so every field check above keeps its own sentence for a number that
669
+ // is not finite; what reaches this line is a finite double the float32 file
670
+ // cannot carry — a key at 1e308 built green with a `null` in it before issue
671
+ // #881 — and any number in a field no check above reads.
672
+ refuseNumbersTheFileCannotCarry(raw, where, (path) => `\`${dottedPath(path)}\``);
673
+
668
674
  return raw as unknown as MotionSpec;
669
675
  }