@rig-c/pose 0.16.0 → 0.18.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/map.ts CHANGED
@@ -50,6 +50,9 @@
50
50
  * directions are read), holding every joint its `chains` use; `drawn` is each
51
51
  * bone's world angle as painted (a reading: `artTurn`); `offsets` is a
52
52
  * translate per bone, in its parent's axes, held while the set shows.
53
+ * `place: "by-hip"` (rig-c/pose#93) places the wrist of each of its chains that
54
+ * `pose` puts within one hip width of the mid-hip by its offset from the hip
55
+ * (`byHipChains`; the retarget solves it).
53
56
  * `switch.ts` decides when each one shows.
54
57
  */
55
58
  import type { RawBone } from 'rig-c/core';
@@ -95,6 +98,8 @@ export interface AlternateSet {
95
98
  turn: Record<string, [number, number]>;
96
99
  /** A stopgap: the only frames the set may show on, a list of [first, last] windows; on them it is chosen over any set without windows. Null: any frame. */
97
100
  frames: Array<[number, number]> | null;
101
+ /** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
102
+ place?: { by: 'hip'; chains: string[] } | null;
98
103
  }
99
104
  export interface JointMap {
100
105
  chains: Chain[];
@@ -228,7 +233,39 @@ export function readMap(text: string, where: string): JointMap {
228
233
  return { chains, translate, alternates };
229
234
  }
230
235
 
231
- const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …] }';
236
+ const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
237
+
238
+ /** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
239
+ export const WRISTS: readonly string[] = ['r_wrist', 'l_wrist'];
240
+
241
+ /**
242
+ * `place: "by-hip"` (rig-c/pose#93): the set's chains that carry a wrist (their
243
+ * last joint) which its key skeleton puts within one hip width of the mid-hip —
244
+ * |wrist − (r_hip + l_hip) / 2| ≤ |r_hip − l_hip| — and each wrist chain's
245
+ * distance in hip widths; or why the key skeleton cannot be read so.
246
+ */
247
+ export function byHipChains(pose: Readonly<Record<string, readonly [number, number]>>, chains: readonly Chain[]): { chains: string[]; distances: Array<{ chain: string; wrist: string; hipWidths: number }> } | { problem: string } {
248
+ const r = pose.r_hip;
249
+ const l = pose.l_hip;
250
+ if (r === undefined || l === undefined) return { problem: `its "pose" has no ${[r === undefined ? '"r_hip"' : '', l === undefined ? '"l_hip"' : ''].filter((x) => x !== '').join(' or ')}; "by-hip" reads the hip width and the mid-hip off the key skeleton's two hips, which the map's author supplies` };
251
+ const noWrist = chains.filter((c) => WRISTS.includes(c.joints[c.joints.length - 1]!) && pose[c.joints[c.joints.length - 1]!] === undefined);
252
+ if (noWrist.length > 0) return { problem: noWrist.map((c) => `its "pose" has no "${c.joints[c.joints.length - 1]}", the wrist of chain "${c.name}"`).join('; ') + '; "by-hip" reads each wrist\'s distance from the mid-hip off the key skeleton' };
253
+ const width = Math.hypot(l[0] - r[0], l[1] - r[1]);
254
+ if (!(width > 1e-9)) return { problem: `its "pose" puts r_hip and l_hip at the same point; a hip width of 0 is no unit` };
255
+ const mid: [number, number] = [(r[0] + l[0]) / 2, (r[1] + l[1]) / 2];
256
+ const distances: Array<{ chain: string; wrist: string; hipWidths: number }> = [];
257
+ for (const c of chains) {
258
+ const wrist = c.joints[c.joints.length - 1]!;
259
+ if (!WRISTS.includes(wrist)) continue;
260
+ const w = pose[wrist];
261
+ if (w === undefined) continue;
262
+ distances.push({ chain: c.name, wrist, hipWidths: Math.hypot(w[0] - mid[0], w[1] - mid[1]) / width });
263
+ }
264
+ if (distances.length === 0) return { problem: `none of its chains (${chains.map((c) => c.name).join(', ')}) ends at a wrist (${WRISTS.join(', ')}); "by-hip" places a wrist` };
265
+ const near = distances.filter((d) => d.hipWidths <= 1);
266
+ if (near.length === 0) return { problem: `its "pose" puts no wrist within one hip width of the mid-hip (${distances.map((d) => `chain "${d.chain}" ${d.wrist} ${d.hipWidths.toFixed(2)} hip widths`).join(', ')}); "by-hip" places a hand that meets the body` };
267
+ return { chains: near.map((d) => d.chain), distances };
268
+ }
232
269
 
233
270
  /** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
234
271
  function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
@@ -245,7 +282,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
245
282
  p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
246
283
  return;
247
284
  }
248
- refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames']);
285
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place']);
249
286
  const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
250
287
  if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
251
288
  else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
@@ -354,7 +391,16 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
354
391
  p.add('MAP_ALT_FRAMES', `${label} "frames" is ${JSON.stringify(f)}; it must be a non-empty list of windows [[first, last], …], whole frame numbers ≥ 0 with first ≤ last`);
355
392
  } else frames = (f as Array<[number, number]>).map((w) => [w[0], w[1]]);
356
393
  }
357
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames });
394
+ let place: AlternateSet['place'] = null;
395
+ if ('place' in a) {
396
+ if (a.place !== 'by-hip') p.add('MAP_ALT_PLACE', `${label} "place" is ${JSON.stringify(a.place)}; the one placement a set may ask for is "by-hip"`);
397
+ else if (isObject(a.pose) && own.length > 0) {
398
+ const by = byHipChains(pose, own);
399
+ if ('problem' in by) p.add('MAP_ALT_PLACE', `${label} "place" is "by-hip", and ${by.problem}`);
400
+ else place = { by: 'hip', chains: by.chains };
401
+ }
402
+ }
403
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }) });
358
404
  });
359
405
  return out;
360
406
  }
package/src/pose.ts CHANGED
@@ -70,16 +70,91 @@ export interface CleanSpike {
70
70
  }
71
71
  /**
72
72
  * Who said the hips are `inferred` (#63): `declared` — the person running
73
- * `rig-pose clean --hips-inferred` said so. A measured producer would record
74
- * itself under another `by`; this reader reads only `declared` today.
73
+ * `rig-pose clean --hips-inferred` said so; `measured` (#86) — a tool read the
74
+ * pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
75
+ * hip pair to a measured angle on every frame. The exact shape:
76
+ *
77
+ * { by: "measured", method: string,
78
+ * read: Run[], interpolated: Run[], held: Run[],
79
+ * declined: Array<{ frames: Run, why: string }> } Run = [first, last]
80
+ *
81
+ * `method` names the reading. Runs are integer frame indices inside the
82
+ * sequence, first ≤ last, increasing and not overlapping within each list.
83
+ * Every frame of the sequence is in exactly one of `read` (the frame's own
84
+ * reading), `interpolated` (a declined frame between two reads close in time,
85
+ * the angle interpolated) and `held` (any other declined frame, the earlier
86
+ * read carried to the end of its run; at the clip's start, the later read). `declined` lists, with the reason, exactly the frames that are
87
+ * interpolated or held. Anything else is refused by name (POSE_CLEANED).
75
88
  */
76
- export interface HipsInferred {
77
- by: 'declared';
89
+ export type Run = [number, number];
90
+ export type HipsInferred =
91
+ | { by: 'declared' }
92
+ | { by: 'measured'; method: string; read: Run[]; interpolated: Run[]; held: Run[]; declined: Array<{ frames: Run; why: string }> };
93
+
94
+ /** A copy of the record, in the writer's key order. */
95
+ export function copyHipsInferred(h: HipsInferred): HipsInferred {
96
+ const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
97
+ return h.by === 'declared'
98
+ ? { by: 'declared' }
99
+ : { by: 'measured', method: h.method, read: runs(h.read), interpolated: runs(h.interpolated), held: runs(h.held), declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })) };
100
+ }
101
+
102
+ /** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
103
+ export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
104
+ if (h === undefined || h.by !== 'measured') return null;
105
+ const out = new Set<number>();
106
+ for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
107
+ return out;
108
+ }
109
+
110
+ /** The stillness hold's figures (#91): speed in trunk lengths per frame, reach in trunk lengths, the shortest hold in seconds, the blend in frames. */
111
+ export interface HoldParams {
112
+ speed: number;
113
+ reach: number;
114
+ minSeconds: number;
115
+ blend: number;
116
+ }
117
+ /**
118
+ * One stillness hold of one joint: its frames (inclusive), and the joint's
119
+ * jitter inside them before and after, px/f². `after` is 0 on a hold held at
120
+ * its median throughout; on a hold that carries a seam's cross-fade it is the
121
+ * ramp's own curvature, expected and not noise (the lines print "ramp").
122
+ */
123
+ export interface CleanHold {
124
+ joint: Body18;
125
+ frames: [number, number];
126
+ jitter: { before: number | null; after: number | null };
127
+ }
128
+ /**
129
+ * A run of frames (`frames`, inside a region the holds cover) on which a joint
130
+ * has a position and no hold, with the test it fails there: `speed` (its
131
+ * largest median speed, trunk lengths a frame; null where its speed is not
132
+ * known on some frame), `reach` (its largest distance from its median over the
133
+ * run, trunk lengths), `short` (it passes both and the run lasts under the
134
+ * minimum; the figure is its seconds), or `span` (it passes all three and its
135
+ * spans broke elsewhere; the figure is its reach).
136
+ */
137
+ /** Two holds of one joint meeting (back to back, or a gap of under 6 frames): the second's first frame, the step between their medians in trunk lengths, and the gap in frames (cross-faded). */
138
+ export interface CleanSeam {
139
+ joint: Body18;
140
+ frame: number;
141
+ step: number;
142
+ gap: number;
143
+ }
144
+ export interface CleanUnheld {
145
+ joint: Body18;
146
+ frames: [number, number];
147
+ test: 'speed' | 'reach' | 'short' | 'span';
148
+ value: number | null;
78
149
  }
79
150
  export interface CleanRecord {
80
151
  spec: typeof CLEAN_SPEC;
81
- /** `hipsInferred` is present only when clean marked the hips (`--hips-inferred`). */
82
- params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
152
+ /**
153
+ * `hipsInferred` is present only when clean marked the hips (`--hips-inferred`).
154
+ * `hold` (#91): the stillness hold's figures, or "off"; absent in a record
155
+ * written before #91.
156
+ */
157
+ params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
83
158
  /**
84
159
  * `found`: every joint the spike step replaced, by frame (index into `frames`)
85
160
  * and joint. `untested`: the frames the spike step did not test, because a
@@ -88,6 +163,10 @@ export interface CleanRecord {
88
163
  spikes: { found: CleanSpike[]; untested: number[] };
89
164
  /** Mean |second difference| of y, px/f², before and after; null where no joint has three consecutive positions. */
90
165
  jitter: { before: number | null; after: number | null };
166
+ /** The stillness holds (#91), per joint; present, with `unheld`, exactly when `params.hold` is an object. */
167
+ holds?: CleanHold[];
168
+ unheld?: CleanUnheld[];
169
+ seams?: CleanSeam[];
91
170
  }
92
171
  export interface PoseSequence {
93
172
  spec: typeof POSE_SPEC;
@@ -95,17 +174,28 @@ export interface PoseSequence {
95
174
  width: number;
96
175
  height: number;
97
176
  source: string;
98
- /** Optional, only with `camera`: where it came from — "tracked", or "hand-labelled, N keys" (#63). */
177
+ /** Optional, only with `camera`: where it came from — "tracked", "hand-labelled, N keys" (#63), or "hand windows, floor-snapped: …" (#82). */
99
178
  cameraSource?: string;
100
179
  /** Optional: one per frame, when a scan read the camera (#63). */
101
180
  camera?: CameraFrame[];
102
181
  /** Present once `rig-pose clean` has run on the sequence (#67). */
103
182
  cleaned?: CleanRecord;
183
+ /**
184
+ * Optional (rig-c/pose#92): the clip's frame of reference. "in-place" — a
185
+ * gait filmed without the figure travelling (a treadmill or reel walk, a
186
+ * loop) — is the one value; `clean --loop` writes it, and it may be written
187
+ * by hand. Absent: the figure's travel in the picture is its travel.
188
+ */
189
+ travel?: Travel;
104
190
  frames: PoseFrame[];
105
191
  }
106
192
 
193
+ /** The one value of a sequence's `travel` (rig-c/pose#92). */
194
+ export const TRAVEL_IN_PLACE = 'in-place';
195
+ export type Travel = typeof TRAVEL_IN_PLACE;
196
+
107
197
  const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
108
- const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
198
+ const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned', 'travel'];
109
199
  const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
110
200
 
111
201
  /**
@@ -190,10 +280,11 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
190
280
  if (typeof doc.cameraSource !== 'string' || doc.cameraSource.trim() === '') p.add('POSE_CAMERA_SOURCE', `${where} "cameraSource" is ${typeName(doc.cameraSource)}; it must be a non-empty string saying where the camera came from ("tracked", "hand-labelled, N keys")`);
191
281
  else if (!('camera' in doc)) p.add('POSE_CAMERA_SOURCE', `${where} has "cameraSource" ${JSON.stringify(doc.cameraSource)} and no "camera"; it states where a camera came from, so it needs one`);
192
282
  }
283
+ if ('travel' in doc && doc.travel !== TRAVEL_IN_PLACE) p.add('POSE_TRAVEL', `${where} "travel" is ${typeName(doc.travel)}; the one value is "${TRAVEL_IN_PLACE}" (a gait filmed without the figure travelling), and without the key the picture's travel is the figure's`);
193
284
  const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
194
285
  p.throwIfAny();
195
286
  const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
196
- return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames };
287
+ return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), frames };
197
288
  }
198
289
 
199
290
  /** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
@@ -234,32 +325,38 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
234
325
  return out;
235
326
  }
236
327
 
237
- /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
328
+ /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }. */
238
329
  function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
239
330
  const cw = `${where} "cleaned"`;
240
- const shape = '{ spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
331
+ const shape = '{ spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }';
241
332
  if (!isObject(c)) {
242
333
  p.add('POSE_CLEANED', `${cw} is ${typeName(c)}; it is the record rig-pose clean writes, ${shape}`);
243
334
  return undefined;
244
335
  }
245
336
  const before = p.list.length;
246
- refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter']);
337
+ refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter', 'holds', 'unheld', 'seams']);
247
338
  for (const k of ['spec', 'params', 'spikes', 'jitter']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has no "${k}"; the record is ${shape}`);
248
339
  if ('spec' in c && c.spec !== CLEAN_SPEC) p.add('POSE_CLEANED', `${cw} "spec" is ${typeName(c.spec)}; this reader reads "${CLEAN_SPEC}"`);
249
340
  const window = (v: unknown) => Number.isInteger(v) && (v === 1 || ((v as number) >= 5 && (v as number) % 2 === 1));
250
341
  if ('params' in c) {
251
342
  const q = c.params;
252
- if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred? }`);
343
+ if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred?, hold? }`);
253
344
  else {
254
- refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred']);
255
- if ('hipsInferred' in q) {
256
- const h = q.hipsInferred;
257
- if (!isObject(h)) p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" is ${typeName(h)}; it must be { by: "declared" }`);
345
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold']);
346
+ if ('hold' in q) {
347
+ const h = q.hold;
348
+ const hw = `${cw} "params" "hold"`;
349
+ if (h === 'off') {
350
+ for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" with "params" "hold" "off"; a record lists holds only when the hold ran`);
351
+ } else if (!isObject(h)) p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be "off" or { speed, reach, minSeconds, blend }`);
258
352
  else {
259
- refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params" "hipsInferred"`, h, ['by']);
260
- if (h.by !== 'declared') p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" "by" is ${typeName(h.by)}; this reader reads "declared" (the person running rig-pose clean --hips-inferred said so)`);
353
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['speed', 'reach', 'minSeconds', 'blend']);
354
+ for (const k of ['speed', 'reach', 'minSeconds'] as const) if (!(typeof h[k] === 'number' && Number.isFinite(h[k]) && h[k] > 0)) p.add('POSE_CLEANED', `${hw} "${k}" is ${typeName(h[k])}; it must be a number > 0 (${k === 'minSeconds' ? 'seconds' : k === 'speed' ? 'trunk lengths per frame' : 'trunk lengths'})`);
355
+ if (!(Number.isInteger(h.blend) && (h.blend as number) >= 0)) p.add('POSE_CLEANED', `${hw} "blend" is ${typeName(h.blend)}; it must be a whole number of frames ≥ 0`);
356
+ for (const k of ['holds', 'unheld', 'seams']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has "params" "hold" and no "${k}"; a record whose hold ran lists its holds, the joints it did not hold and its seams (an empty array when none)`);
261
357
  }
262
- }
358
+ } else for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" and no "params" "hold"; the holds' figures are recorded in "params" "hold"`);
359
+ if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames);
263
360
  if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
264
361
  for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
265
362
  }
@@ -299,20 +396,213 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
299
396
  }
300
397
  }
301
398
  }
399
+ if ('holds' in c) {
400
+ const hs = c.holds;
401
+ if (!Array.isArray(hs)) p.add('POSE_CLEANED', `${cw} "holds" is ${typeName(hs)}; it must be an array of { joint, frames: [first, last], jitter: { before, after } }`);
402
+ else hs.forEach((h: unknown, i: number) => {
403
+ const hw = `${cw} holds[${i}]`;
404
+ if (!isObject(h)) {
405
+ p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; a hold is { joint, frames: [first, last], jitter: { before, after } }`);
406
+ return;
407
+ }
408
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'jitter']);
409
+ if (typeof h.joint !== 'string' || !isBody18(h.joint)) p.add('POSE_CLEANED', `${hw} "joint" is ${typeName(h.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
410
+ const f = h.frames;
411
+ if (!(Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number))) p.add('POSE_CLEANED', `${hw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
412
+ const j = h.jitter;
413
+ if (!isObject(j)) p.add('POSE_CLEANED', `${hw} "jitter" is ${typeName(j)}; it must be { before, after }`);
414
+ else {
415
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${hw} "jitter"`, j, ['before', 'after']);
416
+ for (const k of ['before', 'after'] as const) {
417
+ const v = j[k];
418
+ if (!(v === null || (typeof v === 'number' && Number.isFinite(v) && v >= 0))) p.add('POSE_CLEANED', `${hw} "jitter" "${k}" is ${typeName(v)}; it must be px/f² ≥ 0, or null (not measured)`);
419
+ }
420
+ }
421
+ });
422
+ }
423
+ if ('unheld' in c) {
424
+ const us = c.unheld;
425
+ if (!Array.isArray(us)) p.add('POSE_CLEANED', `${cw} "unheld" is ${typeName(us)}; it must be an array of { joint, frames: [first, last], test, value }`);
426
+ else us.forEach((u: unknown, i: number) => {
427
+ const uw = `${cw} unheld[${i}]`;
428
+ if (!isObject(u)) {
429
+ p.add('POSE_CLEANED', `${uw} is ${typeName(u)}; an unheld joint is { joint, frames: [first, last], test, value }`);
430
+ return;
431
+ }
432
+ refuseUnknownKeys(p, 'POSE_CLEANED', uw, u, ['joint', 'frames', 'test', 'value']);
433
+ if (typeof u.joint !== 'string' || !isBody18(u.joint)) p.add('POSE_CLEANED', `${uw} "joint" is ${typeName(u.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
434
+ const f = u.frames;
435
+ if (!(Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number))) p.add('POSE_CLEANED', `${uw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
436
+ if (u.test !== 'speed' && u.test !== 'reach' && u.test !== 'short' && u.test !== 'span') p.add('POSE_CLEANED', `${uw} "test" is ${typeName(u.test)}; it must be "speed", "reach", "short" or "span"`);
437
+ const v = u.value;
438
+ const ok = typeof v === 'number' && Number.isFinite(v) && v >= 0;
439
+ if (!(ok || (v === null && u.test === 'speed'))) p.add('POSE_CLEANED', `${uw} "value" is ${typeName(v)}; it must be a figure ≥ 0 (trunk lengths, or seconds for "short"), or null for a speed not known`);
440
+ });
441
+ }
442
+ if ('seams' in c) {
443
+ const ss = c.seams;
444
+ if (!Array.isArray(ss)) p.add('POSE_CLEANED', `${cw} "seams" is ${typeName(ss)}; it must be an array of { joint, frame, step, gap }`);
445
+ else ss.forEach((x: unknown, i: number) => {
446
+ const sw = `${cw} seams[${i}]`;
447
+ if (!isObject(x)) {
448
+ p.add('POSE_CLEANED', `${sw} is ${typeName(x)}; a seam is { joint, frame, step, gap }`);
449
+ return;
450
+ }
451
+ refuseUnknownKeys(p, 'POSE_CLEANED', sw, x, ['joint', 'frame', 'step', 'gap']);
452
+ if (!(Number.isInteger(x.gap) && (x.gap as number) >= 0)) p.add('POSE_CLEANED', `${sw} "gap" is ${typeName(x.gap)}; it must be a whole number of frames ≥ 0`);
453
+ if (typeof x.joint !== 'string' || !isBody18(x.joint)) p.add('POSE_CLEANED', `${sw} "joint" is ${typeName(x.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
454
+ if (!frameOk(x.frame)) p.add('POSE_CLEANED', `${sw} "frame" is ${typeName(x.frame)}; it must be a frame index in ${frameRange}`);
455
+ if (!(typeof x.step === 'number' && Number.isFinite(x.step) && x.step >= 0)) p.add('POSE_CLEANED', `${sw} "step" is ${typeName(x.step)}; it must be trunk lengths ≥ 0`);
456
+ });
457
+ }
302
458
  if (p.list.length > before) return undefined;
303
- const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
459
+ const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
304
460
  const j = c.jitter as { before: number | null; after: number | null };
305
461
  return {
306
462
  spec: CLEAN_SPEC,
307
- params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: { by: q.hipsInferred.by } } : {}) },
463
+ params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}) },
308
464
  spikes: {
309
465
  found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
310
466
  untested: [...(c.spikes as { untested: number[] }).untested],
311
467
  },
312
468
  jitter: { before: j.before, after: j.after },
469
+ ...('holds' in c ? { holds: (c.holds as CleanHold[]).map(copyHold) } : {}),
470
+ ...('unheld' in c ? { unheld: (c.unheld as CleanUnheld[]).map(copyUnheld) } : {}),
471
+ ...('seams' in c ? { seams: (c.seams as CleanSeam[]).map((x) => ({ joint: x.joint, frame: x.frame, step: x.step, gap: x.gap })) } : {}),
313
472
  };
314
473
  }
315
474
 
475
+ /** `hipsInferred`: { by: "declared" } or the measured record (the shape at `HipsInferred`), refused by name otherwise. */
476
+ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null): void {
477
+ const measuredShape = '{ by: "measured", method, read, interpolated, held: [[first, last]], declined: [{ frames: [first, last], why }] }';
478
+ if (!isObject(h)) {
479
+ p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be { by: "declared" } or ${measuredShape}`);
480
+ return;
481
+ }
482
+ if (h.by === 'declared') {
483
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
484
+ return;
485
+ }
486
+ if (h.by !== 'measured') {
487
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
488
+ p.add('POSE_CLEANED', `${hw} "by" is ${typeName(h.by)}; this reader reads "declared" (the person running rig-pose clean --hips-inferred said so) or "measured" (a tool read the pelvis line off the picture, tools/pelvis.py)`);
489
+ return;
490
+ }
491
+ const before = p.list.length;
492
+ const CLASSES = ['read', 'interpolated', 'held'] as const;
493
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
494
+ for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
495
+ if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
496
+ const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
497
+ /** A run [first, last], or null after naming what is wrong with it. */
498
+ const run = (v: unknown, rw: string): Run | null => {
499
+ if (!(Array.isArray(v) && v.length === 2)) {
500
+ p.add('POSE_CLEANED', `${rw} is ${typeName(v)}; a run is [first, last], two frame indices`);
501
+ return null;
502
+ }
503
+ let ok = true;
504
+ v.forEach((f: unknown, k: number) => {
505
+ if (!(Number.isInteger(f) && (f as number) >= 0 && (frames === null || (f as number) < frames))) {
506
+ p.add('POSE_CLEANED', `${rw}[${k}] is ${typeName(f)}; it must be a frame index in ${range}`);
507
+ ok = false;
508
+ }
509
+ });
510
+ if (!ok) return null;
511
+ const [a, b] = v as Run;
512
+ if (a > b) {
513
+ p.add('POSE_CLEANED', `${rw} is [${a}, ${b}]; a run's first frame must not come after its last`);
514
+ return null;
515
+ }
516
+ return [a, b];
517
+ };
518
+ type Named = { r: Run; w: string };
519
+ /** Runs in order and apart: each starts after the previous one ends. */
520
+ const ordered = (rs: Named[], what: string) => {
521
+ for (let k = 1; k < rs.length; k++) {
522
+ const prev = rs[k - 1]!;
523
+ const cur = rs[k]!;
524
+ if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
525
+ }
526
+ };
527
+ const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
528
+ for (const c of CLASSES) {
529
+ if (!(c in h)) continue;
530
+ const list = h[c];
531
+ if (!Array.isArray(list)) {
532
+ p.add('POSE_CLEANED', `${hw} "${c}" is ${typeName(list)}; it must be an array of runs [first, last]`);
533
+ continue;
534
+ }
535
+ list.forEach((v: unknown, k: number) => {
536
+ const w = `${hw} ${c}[${k}]`;
537
+ const r = run(v, w);
538
+ if (r !== null) classes[c].push({ r, w });
539
+ });
540
+ ordered(classes[c], c);
541
+ }
542
+ const declined: Named[] = [];
543
+ if ('declined' in h) {
544
+ if (!Array.isArray(h.declined)) p.add('POSE_CLEANED', `${hw} "declined" is ${typeName(h.declined)}; it must be an array of { frames: [first, last], why }`);
545
+ else h.declined.forEach((d: unknown, k: number) => {
546
+ const w = `${hw} declined[${k}]`;
547
+ if (!isObject(d)) {
548
+ p.add('POSE_CLEANED', `${w} is ${typeName(d)}; a declined run is { frames: [first, last], why }`);
549
+ return;
550
+ }
551
+ refuseUnknownKeys(p, 'POSE_CLEANED', w, d, ['frames', 'why']);
552
+ if (!('why' in d)) p.add('POSE_CLEANED', `${w} has no "why"; a declined run says why, a non-empty string`);
553
+ else if (!(typeof d.why === 'string' && d.why.trim() !== '')) p.add('POSE_CLEANED', `${w} "why" is ${typeName(d.why)}; a declined run says why, a non-empty string`);
554
+ if (!('frames' in d)) {
555
+ p.add('POSE_CLEANED', `${w} has no "frames"; a declined run is { frames: [first, last], why }`);
556
+ return;
557
+ }
558
+ const r = run(d.frames, `${w} "frames"`);
559
+ if (r !== null) declined.push({ r, w: `${w} "frames"` });
560
+ });
561
+ ordered(declined, 'declined');
562
+ }
563
+ // Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
564
+ for (let x = 0; x < CLASSES.length; x++) {
565
+ for (let y = x + 1; y < CLASSES.length; y++) {
566
+ for (const a of classes[CLASSES[x]!]) {
567
+ for (const b of classes[CLASSES[y]!]) {
568
+ if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of read, interpolated and held`);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ if (p.list.length > before) return;
574
+ const set = (rs: Named[]) => {
575
+ const out = new Set<number>();
576
+ for (const { r } of rs) for (let f = r[0]; f <= r[1]; f++) out.add(f);
577
+ return out;
578
+ };
579
+ const spans = (fs: number[]) => {
580
+ const out: Run[] = [];
581
+ for (const f of fs) {
582
+ const last = out[out.length - 1];
583
+ if (last !== undefined && f === last[1] + 1) last[1] = f;
584
+ else out.push([f, f]);
585
+ }
586
+ return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
587
+ };
588
+ const read = set(classes.read);
589
+ const filled = set([...classes.interpolated, ...classes.held]);
590
+ const dec = set(declined);
591
+ if (frames !== null) {
592
+ const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
593
+ if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of read, interpolated and held; every frame of the sequence is one of them`);
594
+ }
595
+ const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
596
+ if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
597
+ const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
598
+ if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither interpolated nor held; a declined frame takes one of them`);
599
+ const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
600
+ if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are interpolated or held but not declined; only a frame the reading declined is filled, and "declined" says why`);
601
+ }
602
+ const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
603
+ const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
604
+ const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
605
+
316
606
  function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
317
607
  const out: Partial<Record<Body18, Joint>> = {};
318
608
  if (!isObject(joints)) {
@@ -499,6 +789,14 @@ export function writePoseSequence(seq: PoseSequence): string {
499
789
  const camera = seq.camera?.map((c) => ({ s: round(c.s, 6), tx: round(c.tx, 3), ty: round(c.ty, 3), inliers: c.inliers }));
500
790
  const c = seq.cleaned;
501
791
  const jit = (v: number | null) => (v === null ? null : round(v, 4));
502
- const cleaned = c === undefined ? undefined : { spec: c.spec, params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: { by: c.params.hipsInferred.by } } : {}) }, spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] }, jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) } };
503
- return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames }, null, 2)}\n`;
792
+ const cleaned = c === undefined ? undefined : {
793
+ spec: c.spec,
794
+ params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}) },
795
+ spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
796
+ jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
797
+ ...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
798
+ ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
799
+ ...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
800
+ };
801
+ return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), frames }, null, 2)}\n`;
504
802
  }