@rig-c/pose 0.15.0 → 0.17.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/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).
88
+ */
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").
75
122
  */
76
- export interface HipsInferred {
77
- by: 'declared';
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,7 +174,7 @@ 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[];
@@ -234,32 +313,38 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
234
313
  return out;
235
314
  }
236
315
 
237
- /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
316
+ /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }. */
238
317
  function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
239
318
  const cw = `${where} "cleaned"`;
240
- const shape = '{ spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
319
+ const shape = '{ spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }';
241
320
  if (!isObject(c)) {
242
321
  p.add('POSE_CLEANED', `${cw} is ${typeName(c)}; it is the record rig-pose clean writes, ${shape}`);
243
322
  return undefined;
244
323
  }
245
324
  const before = p.list.length;
246
- refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter']);
325
+ refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter', 'holds', 'unheld', 'seams']);
247
326
  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
327
  if ('spec' in c && c.spec !== CLEAN_SPEC) p.add('POSE_CLEANED', `${cw} "spec" is ${typeName(c.spec)}; this reader reads "${CLEAN_SPEC}"`);
249
328
  const window = (v: unknown) => Number.isInteger(v) && (v === 1 || ((v as number) >= 5 && (v as number) % 2 === 1));
250
329
  if ('params' in c) {
251
330
  const q = c.params;
252
- if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred? }`);
331
+ if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred?, hold? }`);
253
332
  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" }`);
333
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold']);
334
+ if ('hold' in q) {
335
+ const h = q.hold;
336
+ const hw = `${cw} "params" "hold"`;
337
+ if (h === 'off') {
338
+ 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`);
339
+ } else if (!isObject(h)) p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be "off" or { speed, reach, minSeconds, blend }`);
258
340
  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)`);
341
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['speed', 'reach', 'minSeconds', 'blend']);
342
+ 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'})`);
343
+ 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`);
344
+ 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
345
  }
262
- }
346
+ } 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"`);
347
+ if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames);
263
348
  if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
264
349
  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
350
  }
@@ -299,20 +384,213 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
299
384
  }
300
385
  }
301
386
  }
387
+ if ('holds' in c) {
388
+ const hs = c.holds;
389
+ 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 } }`);
390
+ else hs.forEach((h: unknown, i: number) => {
391
+ const hw = `${cw} holds[${i}]`;
392
+ if (!isObject(h)) {
393
+ p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; a hold is { joint, frames: [first, last], jitter: { before, after } }`);
394
+ return;
395
+ }
396
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'jitter']);
397
+ 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(', ')}`);
398
+ const f = h.frames;
399
+ 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`);
400
+ const j = h.jitter;
401
+ if (!isObject(j)) p.add('POSE_CLEANED', `${hw} "jitter" is ${typeName(j)}; it must be { before, after }`);
402
+ else {
403
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${hw} "jitter"`, j, ['before', 'after']);
404
+ for (const k of ['before', 'after'] as const) {
405
+ const v = j[k];
406
+ 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)`);
407
+ }
408
+ }
409
+ });
410
+ }
411
+ if ('unheld' in c) {
412
+ const us = c.unheld;
413
+ 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 }`);
414
+ else us.forEach((u: unknown, i: number) => {
415
+ const uw = `${cw} unheld[${i}]`;
416
+ if (!isObject(u)) {
417
+ p.add('POSE_CLEANED', `${uw} is ${typeName(u)}; an unheld joint is { joint, frames: [first, last], test, value }`);
418
+ return;
419
+ }
420
+ refuseUnknownKeys(p, 'POSE_CLEANED', uw, u, ['joint', 'frames', 'test', 'value']);
421
+ 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(', ')}`);
422
+ const f = u.frames;
423
+ 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`);
424
+ 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"`);
425
+ const v = u.value;
426
+ const ok = typeof v === 'number' && Number.isFinite(v) && v >= 0;
427
+ 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`);
428
+ });
429
+ }
430
+ if ('seams' in c) {
431
+ const ss = c.seams;
432
+ if (!Array.isArray(ss)) p.add('POSE_CLEANED', `${cw} "seams" is ${typeName(ss)}; it must be an array of { joint, frame, step, gap }`);
433
+ else ss.forEach((x: unknown, i: number) => {
434
+ const sw = `${cw} seams[${i}]`;
435
+ if (!isObject(x)) {
436
+ p.add('POSE_CLEANED', `${sw} is ${typeName(x)}; a seam is { joint, frame, step, gap }`);
437
+ return;
438
+ }
439
+ refuseUnknownKeys(p, 'POSE_CLEANED', sw, x, ['joint', 'frame', 'step', 'gap']);
440
+ 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`);
441
+ 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(', ')}`);
442
+ if (!frameOk(x.frame)) p.add('POSE_CLEANED', `${sw} "frame" is ${typeName(x.frame)}; it must be a frame index in ${frameRange}`);
443
+ 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`);
444
+ });
445
+ }
302
446
  if (p.list.length > before) return undefined;
303
- const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
447
+ const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
304
448
  const j = c.jitter as { before: number | null; after: number | null };
305
449
  return {
306
450
  spec: CLEAN_SPEC,
307
- params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: { by: q.hipsInferred.by } } : {}) },
451
+ 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
452
  spikes: {
309
453
  found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
310
454
  untested: [...(c.spikes as { untested: number[] }).untested],
311
455
  },
312
456
  jitter: { before: j.before, after: j.after },
457
+ ...('holds' in c ? { holds: (c.holds as CleanHold[]).map(copyHold) } : {}),
458
+ ...('unheld' in c ? { unheld: (c.unheld as CleanUnheld[]).map(copyUnheld) } : {}),
459
+ ...('seams' in c ? { seams: (c.seams as CleanSeam[]).map((x) => ({ joint: x.joint, frame: x.frame, step: x.step, gap: x.gap })) } : {}),
313
460
  };
314
461
  }
315
462
 
463
+ /** `hipsInferred`: { by: "declared" } or the measured record (the shape at `HipsInferred`), refused by name otherwise. */
464
+ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null): void {
465
+ const measuredShape = '{ by: "measured", method, read, interpolated, held: [[first, last]], declined: [{ frames: [first, last], why }] }';
466
+ if (!isObject(h)) {
467
+ p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be { by: "declared" } or ${measuredShape}`);
468
+ return;
469
+ }
470
+ if (h.by === 'declared') {
471
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
472
+ return;
473
+ }
474
+ if (h.by !== 'measured') {
475
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by']);
476
+ 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)`);
477
+ return;
478
+ }
479
+ const before = p.list.length;
480
+ const CLASSES = ['read', 'interpolated', 'held'] as const;
481
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
482
+ for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
483
+ 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`);
484
+ const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
485
+ /** A run [first, last], or null after naming what is wrong with it. */
486
+ const run = (v: unknown, rw: string): Run | null => {
487
+ if (!(Array.isArray(v) && v.length === 2)) {
488
+ p.add('POSE_CLEANED', `${rw} is ${typeName(v)}; a run is [first, last], two frame indices`);
489
+ return null;
490
+ }
491
+ let ok = true;
492
+ v.forEach((f: unknown, k: number) => {
493
+ if (!(Number.isInteger(f) && (f as number) >= 0 && (frames === null || (f as number) < frames))) {
494
+ p.add('POSE_CLEANED', `${rw}[${k}] is ${typeName(f)}; it must be a frame index in ${range}`);
495
+ ok = false;
496
+ }
497
+ });
498
+ if (!ok) return null;
499
+ const [a, b] = v as Run;
500
+ if (a > b) {
501
+ p.add('POSE_CLEANED', `${rw} is [${a}, ${b}]; a run's first frame must not come after its last`);
502
+ return null;
503
+ }
504
+ return [a, b];
505
+ };
506
+ type Named = { r: Run; w: string };
507
+ /** Runs in order and apart: each starts after the previous one ends. */
508
+ const ordered = (rs: Named[], what: string) => {
509
+ for (let k = 1; k < rs.length; k++) {
510
+ const prev = rs[k - 1]!;
511
+ const cur = rs[k]!;
512
+ 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`);
513
+ }
514
+ };
515
+ const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
516
+ for (const c of CLASSES) {
517
+ if (!(c in h)) continue;
518
+ const list = h[c];
519
+ if (!Array.isArray(list)) {
520
+ p.add('POSE_CLEANED', `${hw} "${c}" is ${typeName(list)}; it must be an array of runs [first, last]`);
521
+ continue;
522
+ }
523
+ list.forEach((v: unknown, k: number) => {
524
+ const w = `${hw} ${c}[${k}]`;
525
+ const r = run(v, w);
526
+ if (r !== null) classes[c].push({ r, w });
527
+ });
528
+ ordered(classes[c], c);
529
+ }
530
+ const declined: Named[] = [];
531
+ if ('declined' in h) {
532
+ 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 }`);
533
+ else h.declined.forEach((d: unknown, k: number) => {
534
+ const w = `${hw} declined[${k}]`;
535
+ if (!isObject(d)) {
536
+ p.add('POSE_CLEANED', `${w} is ${typeName(d)}; a declined run is { frames: [first, last], why }`);
537
+ return;
538
+ }
539
+ refuseUnknownKeys(p, 'POSE_CLEANED', w, d, ['frames', 'why']);
540
+ if (!('why' in d)) p.add('POSE_CLEANED', `${w} has no "why"; a declined run says why, a non-empty string`);
541
+ 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`);
542
+ if (!('frames' in d)) {
543
+ p.add('POSE_CLEANED', `${w} has no "frames"; a declined run is { frames: [first, last], why }`);
544
+ return;
545
+ }
546
+ const r = run(d.frames, `${w} "frames"`);
547
+ if (r !== null) declined.push({ r, w: `${w} "frames"` });
548
+ });
549
+ ordered(declined, 'declined');
550
+ }
551
+ // Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
552
+ for (let x = 0; x < CLASSES.length; x++) {
553
+ for (let y = x + 1; y < CLASSES.length; y++) {
554
+ for (const a of classes[CLASSES[x]!]) {
555
+ for (const b of classes[CLASSES[y]!]) {
556
+ 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`);
557
+ }
558
+ }
559
+ }
560
+ }
561
+ if (p.list.length > before) return;
562
+ const set = (rs: Named[]) => {
563
+ const out = new Set<number>();
564
+ for (const { r } of rs) for (let f = r[0]; f <= r[1]; f++) out.add(f);
565
+ return out;
566
+ };
567
+ const spans = (fs: number[]) => {
568
+ const out: Run[] = [];
569
+ for (const f of fs) {
570
+ const last = out[out.length - 1];
571
+ if (last !== undefined && f === last[1] + 1) last[1] = f;
572
+ else out.push([f, f]);
573
+ }
574
+ return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
575
+ };
576
+ const read = set(classes.read);
577
+ const filled = set([...classes.interpolated, ...classes.held]);
578
+ const dec = set(declined);
579
+ if (frames !== null) {
580
+ const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
581
+ 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`);
582
+ }
583
+ const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
584
+ 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`);
585
+ const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
586
+ 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`);
587
+ const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
588
+ 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`);
589
+ }
590
+ const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
591
+ const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
592
+ const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
593
+
316
594
  function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
317
595
  const out: Partial<Record<Body18, Joint>> = {};
318
596
  if (!isObject(joints)) {
@@ -499,6 +777,14 @@ export function writePoseSequence(seq: PoseSequence): string {
499
777
  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
778
  const c = seq.cleaned;
501
779
  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) } };
780
+ const cleaned = c === undefined ? undefined : {
781
+ spec: c.spec,
782
+ 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) } : {}) },
783
+ spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
784
+ jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
785
+ ...(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) } })) } : {}),
786
+ ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
787
+ ...(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) })) } : {}),
788
+ };
503
789
  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`;
504
790
  }