@rig-c/pose 0.8.0 → 0.8.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/README.md +4 -1
- package/cli.ts +4 -1
- package/package.json +1 -1
- package/src/pose.ts +17 -4
- package/src/rigc.ts +13 -4
- package/src/run.ts +45 -5
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ Nothing is written until every check passes:
|
|
|
38
38
|
|
|
39
39
|
and what the run cannot decide for you it lists:
|
|
40
40
|
|
|
41
|
+
- `RIGC` — the rig-c version and entry that gated and posed the run (also `rigc` in the report): a later rig-c can change the bytes.
|
|
41
42
|
- `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
|
|
42
43
|
- `SHORT` — a segment shorter in the picture than `--short` of its median length (with `--short <fraction>`; off by default) has its angle there interpolated, not read; one line per span, with the chain, the segment, the frames and the lowest fraction. Below.
|
|
43
44
|
- `END-ON` — a `SHORT` run lasting at least 0.15 s: the limb points at the camera, a pose the rig cannot draw.
|
|
@@ -109,6 +110,8 @@ Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2
|
|
|
109
110
|
|
|
110
111
|
**From COCO-17** — the layout most pose models and datasets use — `rig-pose convert --coco in.json --out pose.json`, or `retarget --coco`. The input is `{ "format": "coco17-sequence/1", "width", "height", "source", "frames": [{ "t", "keypoints" }] }`, keypoints in COCO's order as 17 `[x, y, score]` or 51 numbers, or `null` for a frame with nobody. The mapping: COCO's `left_*`/`right_*` → body-18's `l_*`/`r_*` (both are the subject's sides), a score of 0 → `missing`, and body-18's `neck`, which COCO lacks, is the shoulders' midpoint scored the lower of the two (OpenPose's definition).
|
|
111
112
|
|
|
113
|
+
|
|
114
|
+
**The older name.** `rig-c-pose-frames/0`, written by the extraction jobs this package's demos were made from, is the same file under another spec string. It has `space.units` "painting-px" and an `fps` key; each frame's `t` is the time read. It is read as it is.
|
|
112
115
|
## The map: `rig-pose-map/1`
|
|
113
116
|
|
|
114
117
|
Which bones carry which joints. You write it for your rig:
|
|
@@ -178,7 +181,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
|
|
|
178
181
|
**TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds and smoothing, held and smoothed as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
|
|
179
182
|
|
|
180
183
|
|
|
181
|
-
**A stopgap window: `frames`.** A set may state `"frames": [[first, last], …]`: it shows only inside those windows, and on them it is chosen over any set within tolerance that has no window. It is for two sets whose key skeletons the angles cannot tell apart. Measured on HQ's dancer, the hands clasped behind the back and the hands low in front differ by only 2–21°, and the wrist and hand-point scores do not separate them either; the cue that might is #33's depth state. It is recorded in the map as a stopgap, not a selection rule.
|
|
184
|
+
**A stopgap window: `frames`.** A set may state `"frames": [[first, last], …]`: it shows only inside those windows, and on them it is chosen over any set within tolerance that has no window. It is for two sets whose key skeletons the angles cannot tell apart. Measured on HQ's dancer, the hands clasped behind the back and the hands low in front differ by only 2–21°, and the wrist and hand-point scores do not separate them either; the cue that might is #33's depth state. It is recorded in the map as a stopgap, not a selection rule. A window **restricts** a set; it never forces one. Where the set does not show inside its window, the run says why in a `FRAMES` line: the stretch, and whether the pose there was past the tolerance (with the largest distance), or within it but the run was dropped.
|
|
182
185
|
|
|
183
186
|
**How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
|
|
184
187
|
## Compare: the rig's render against the video
|
package/cli.ts
CHANGED
|
@@ -33,6 +33,8 @@ retarget
|
|
|
33
33
|
--coco read --pose as coco17-sequence/1 and convert it first
|
|
34
34
|
--person <id> the person to follow when the sequence holds more than one
|
|
35
35
|
--base-motion <path> the rig's motion spec, when it is not motion.json beside rig.json
|
|
36
|
+
--images <dir> passed to rigc build: where the rig spec's images are, when its own "images" path
|
|
37
|
+
does not reach them (a rig.json copied away from its images)
|
|
36
38
|
--min-score <s> a joint scored below s is not good on that frame and holds its last good
|
|
37
39
|
value (default: 0.3); a joint with no score is good
|
|
38
40
|
--smooth <n> Savitzky–Golay over n frames of joint positions: a centred least-squares cubic,
|
|
@@ -154,7 +156,7 @@ function shortOf(f: Map<string, string>): number | 'off' {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function retargetArgs(argv: string[]): RetargetArgs {
|
|
157
|
-
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report'], ['coco']);
|
|
159
|
+
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report'], ['coco']);
|
|
158
160
|
const bends = f.get('bends') ?? 'refuse';
|
|
159
161
|
if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
|
|
160
162
|
const switchAt = f.get('switch-at') ?? 'first';
|
|
@@ -172,6 +174,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
|
|
|
172
174
|
coco: f.has('coco'),
|
|
173
175
|
person: f.get('person'),
|
|
174
176
|
baseMotion: f.get('base-motion'),
|
|
177
|
+
images: f.get('images'),
|
|
175
178
|
minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
|
|
176
179
|
smooth: smoothOf(f),
|
|
177
180
|
flip: flipOf(f),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/pose.ts
CHANGED
|
@@ -50,6 +50,16 @@ export interface PoseSequence {
|
|
|
50
50
|
|
|
51
51
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The older name the same file went by: `rig-c-pose-frames/0`, written by the
|
|
55
|
+
* extraction jobs this package's own demos were made from. It is this format
|
|
56
|
+
* under another spec string, with `space.units` "painting-px" and an `fps` key
|
|
57
|
+
* (the time is in each frame's `t`, which wins). It is read as it is; nothing
|
|
58
|
+
* else about it differs.
|
|
59
|
+
*/
|
|
60
|
+
export const POSE_SPEC_FRAMES0 = 'rig-c-pose-frames/0';
|
|
61
|
+
const FRAMES0_SPACE = { units: 'painting-px', origin: 'top-left', y: 'down' } as const;
|
|
62
|
+
|
|
53
63
|
/** Read and check a pose sequence; every problem is collected and thrown once. */
|
|
54
64
|
export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
55
65
|
const doc = parseJson(text, where, 'POSE_JSON');
|
|
@@ -59,12 +69,15 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
59
69
|
p.throwIfAny();
|
|
60
70
|
throw new Error('unreachable');
|
|
61
71
|
}
|
|
62
|
-
|
|
72
|
+
const frames0 = doc.spec === POSE_SPEC_FRAMES0;
|
|
73
|
+
refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, 'fps'] : TOP_KEYS);
|
|
63
74
|
for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
|
|
64
|
-
if ('spec' in doc && doc.spec !== POSE_SPEC) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}"`);
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
if ('spec' in doc && doc.spec !== POSE_SPEC && !frames0) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" (and the older name "${POSE_SPEC_FRAMES0}")`);
|
|
76
|
+
const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
|
|
77
|
+
if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
|
|
78
|
+
p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; for "${frames0 ? POSE_SPEC_FRAMES0 : POSE_SPEC}" it must be exactly ${JSON.stringify(space)}`);
|
|
67
79
|
}
|
|
80
|
+
if (frames0 && 'fps' in doc && !(typeof doc.fps === 'number' && Number.isFinite(doc.fps) && doc.fps > 0)) p.add('POSE_FPS', `${where} "fps" is ${typeName(doc.fps)}; it must be a positive number (each frame's "t" is the time read)`);
|
|
68
81
|
for (const k of ['width', 'height'] as const) {
|
|
69
82
|
if (k in doc && !(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('POSE_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
|
|
70
83
|
}
|
package/src/rigc.ts
CHANGED
|
@@ -96,11 +96,11 @@ export interface Gate {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
|
|
99
|
-
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
|
|
99
|
+
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string, images?: string): Gate {
|
|
100
100
|
mkdirSync(dir, { recursive: true });
|
|
101
101
|
const motionPath = join(dir, 'motion.json');
|
|
102
102
|
writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
|
|
103
|
-
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
|
|
103
|
+
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'), images);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
@@ -112,8 +112,8 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
|
|
|
112
112
|
* never copied out, which would carry a path climbing from the scratch
|
|
113
113
|
* directory to the filesystem root. --copy-images puts the pages beside it.
|
|
114
114
|
*/
|
|
115
|
-
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
|
|
116
|
-
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
|
|
115
|
+
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string, images?: string): Gate {
|
|
116
|
+
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(images === undefined ? [] : ['--images', images]), ...(report === undefined ? [] : ['--report', report])]);
|
|
117
117
|
const lines = run.out.split('\n');
|
|
118
118
|
return {
|
|
119
119
|
run,
|
|
@@ -196,3 +196,12 @@ export function folds(build: string, animation: string, dir: string, fps: number
|
|
|
196
196
|
frames: setDir,
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
|
|
201
|
+
export function rigcVersion(): { version: string; entry: string } {
|
|
202
|
+
const r = runRigc(['--version']);
|
|
203
|
+
const lines = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
204
|
+
const version = lines.find((l) => /^\d+\.\d+\.\d+/.test(l)) ?? lines[0] ?? 'unknown';
|
|
205
|
+
const entry = lines.find((l) => l.startsWith('entry:'))?.slice('entry:'.length).trim() ?? 'not stated';
|
|
206
|
+
return { version, entry };
|
|
207
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import { readMap, resolveMap } from './map.ts';
|
|
10
|
+
import { readMap, resolveMap, type JointMap } from './map.ts';
|
|
11
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
12
12
|
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
13
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
|
-
import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
14
|
+
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
15
|
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
16
16
|
import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
|
|
17
17
|
|
|
@@ -24,6 +24,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
24
24
|
coco: boolean;
|
|
25
25
|
person?: string;
|
|
26
26
|
baseMotion?: string;
|
|
27
|
+
/** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
|
|
28
|
+
images?: string;
|
|
27
29
|
profile: 'spine' | 'spine-html';
|
|
28
30
|
/** Keep the gated build here. */
|
|
29
31
|
build?: string;
|
|
@@ -72,17 +74,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
72
74
|
const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
|
|
73
75
|
try {
|
|
74
76
|
// The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
|
|
75
|
-
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
|
|
77
|
+
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
|
|
76
78
|
if (!g0.green) {
|
|
77
79
|
refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
78
80
|
}
|
|
79
81
|
const doc0 = readBuild(g0.build);
|
|
82
|
+
const rigc = rigcVersion();
|
|
80
83
|
const rest = restOf(doc0);
|
|
81
84
|
const slots = slotsOf(doc0);
|
|
82
85
|
resolveMap(map, rest, a.map, slots);
|
|
83
86
|
|
|
84
87
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
85
88
|
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
|
|
89
|
+
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
86
90
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
87
91
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
88
92
|
if (a.short !== 'off') {
|
|
@@ -96,12 +100,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
96
100
|
if (map.alternates.length > 0) {
|
|
97
101
|
if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
|
|
98
102
|
for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
|
|
103
|
+
for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
|
|
99
104
|
}
|
|
100
105
|
for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
|
|
101
106
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
102
107
|
|
|
103
108
|
const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
|
|
104
|
-
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
|
|
109
|
+
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
|
|
105
110
|
if (!g1.green) {
|
|
106
111
|
refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
107
112
|
}
|
|
@@ -139,7 +144,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
139
144
|
if (a.build !== undefined) {
|
|
140
145
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
141
146
|
rmSync(a.build, { recursive: true, force: true });
|
|
142
|
-
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
|
|
147
|
+
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile, undefined, a.images);
|
|
143
148
|
if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
|
|
144
149
|
}
|
|
145
150
|
writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
|
|
@@ -150,6 +155,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
150
155
|
if (a.report !== undefined) {
|
|
151
156
|
writeFileSync(a.report, `${JSON.stringify({
|
|
152
157
|
spec: 'rig-pose-report/1',
|
|
158
|
+
rigc,
|
|
153
159
|
rig: input.name,
|
|
154
160
|
animation: a.name,
|
|
155
161
|
frames: seq.frames.length,
|
|
@@ -195,3 +201,37 @@ function spans(ts: number[], step: number): string {
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
export { lineOf };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* For each set with `frames` windows: every stretch of a window the set did not
|
|
207
|
+
* show on, and why — the pose there was past the tolerance (with the largest
|
|
208
|
+
* distance), or within it but the run was dropped (shorter than --switch-min, or
|
|
209
|
+
* taken by another set's span). A window never forces a set; this says so.
|
|
210
|
+
*/
|
|
211
|
+
function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frames: [number, number] }>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], tolerance: number): string[] {
|
|
212
|
+
const out: string[] = [];
|
|
213
|
+
for (const set of map.alternates) {
|
|
214
|
+
if (set.frames === null) continue;
|
|
215
|
+
const shown = new Set<number>();
|
|
216
|
+
for (const sw of switches) if (sw.set === set.name) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) shown.add(i);
|
|
217
|
+
const d = distances.get(set.name)!;
|
|
218
|
+
for (const [first, last] of set.frames) {
|
|
219
|
+
const lastIn = Math.min(last, times.length - 1);
|
|
220
|
+
const parts: string[] = [];
|
|
221
|
+
for (let i = first; i <= lastIn;) {
|
|
222
|
+
if (shown.has(i)) { i++; continue; }
|
|
223
|
+
let j = i;
|
|
224
|
+
while (j + 1 <= lastIn && !shown.has(j + 1)) j++;
|
|
225
|
+
const ds = d.slice(i, j + 1);
|
|
226
|
+
const worst = Math.max(...ds);
|
|
227
|
+
const over = ds.filter((x) => x > tolerance).length;
|
|
228
|
+
const span = i === j ? `frame ${i}` : `frames ${i}–${j}`;
|
|
229
|
+
parts.push(over === ds.length ? `${span} outside the tolerance (up to ${worst.toFixed(1)}° > ${tolerance}°)` : over === 0 ? `${span} within the tolerance but not shown (a run shorter than --switch-min, or another set's span)` : `${span} partly outside the tolerance (up to ${worst.toFixed(1)}°)`);
|
|
230
|
+
i = j + 1;
|
|
231
|
+
}
|
|
232
|
+
const head = `set "${set.name}" window ${first}–${last}`;
|
|
233
|
+
out.push(parts.length === 0 ? `${head}: shown on every frame` : `${head}: ${parts.join('; ')} — a window restricts a set, it never forces one`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|