@rig-c/pose 0.16.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
  }
package/src/retarget.ts CHANGED
@@ -76,8 +76,8 @@ import type { Joint } from './pose.ts';
76
76
  import { Problems, Refusal } from './problems.ts';
77
77
  import type { ArtRange, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
78
78
  import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
79
- import type { Feet, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
80
- import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
79
+ import type { BlendHold, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
80
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
81
81
 
82
82
  const DEG = 180 / Math.PI;
83
83
 
@@ -108,6 +108,8 @@ export interface RetargetOptions extends SwitchOptions {
108
108
  plantFeet?: boolean;
109
109
  /** With plantFeet: a free ankle within this many leg lengths of the floor plants both feet, its leg stretched to reach it (`legs.ts` stanceOf, #60); 'off' (the default) plants one. */
110
110
  stance?: number | 'off';
111
+ /** With plantFeet: the floor's world y every support span is planted on (#85); absent, the median of the spans' planted heights. */
112
+ floor?: number;
111
113
  /** While a set whose arm is drawn raised shows, its wrist is placed against the rig's head in eye distances (rig-c/pose#62); false (the default) keeps the pose's angles. */
112
114
  raisedByHead?: boolean;
113
115
  }
@@ -288,6 +290,10 @@ export interface Retarget {
288
290
  planted: boolean;
289
291
  /** With --stance: the frames planted both and lifted, and the free leg's stretch (#60). */
290
292
  stance: Stance | null;
293
+ /** With --plant-feet: the one floor every support span was planted on, and each span's shift onto it (#85). */
294
+ floor: Floor | null;
295
+ /** With --plant-feet: the supporting leg stretched on the blend frames so its ankle stays on the floor (#85). */
296
+ blendHold: BlendHold | null;
291
297
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
292
298
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
293
299
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
@@ -609,6 +615,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
609
615
  const legScale: LegScale[] = [];
610
616
  const scales = new Map<string, number[]>();
611
617
  let stance: Stance | null = null;
618
+ let floor: Floor | null = null;
619
+ let blendHold: BlendHold | null = null;
612
620
  let feet: Feet | null = null;
613
621
  let legs: ReturnType<typeof legsOf> | null = null;
614
622
  let legPath: string[] | null = null;
@@ -664,6 +672,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
664
672
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
665
673
  }
666
674
  if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
675
+ if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
676
+ if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
667
677
  p.throwIfAny();
668
678
  /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
669
679
  const poseLegs = (i: number): PathPose => {
@@ -684,32 +694,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
684
694
  return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
685
695
  });
686
696
  const tb = rest.get(t!.bone)!;
687
- const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
697
+ // #85: every support span on one floor — the median of their planted heights, or --floor.
698
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor);
699
+ const hip = planted.hip;
700
+ floor = planted.floor;
688
701
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
689
- // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
690
- // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
691
- if (opts.stance !== undefined && opts.stance !== 'off') {
692
- const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
693
- const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
694
- const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
695
- stance = stanceOf(feet!, ankleSeries, opts.stance, (i) => {
696
- const lp = poseLegs(i);
697
- return {
698
- root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
699
- ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
700
- };
701
- });
702
+ const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
703
+ const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
704
+ /** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
705
+ const posedFeet = (i: number) => {
706
+ const lp = poseLegs(i);
707
+ return {
708
+ root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
709
+ ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
710
+ };
711
+ };
712
+ /** Every bone of a side's chain has its scalex multiplied by the stretch keyed on that side. */
713
+ const stretchChain = (stretch: ReadonlyArray<{ side: Side; k: number } | null>) => {
702
714
  for (const side of SIDES) {
703
- if (!stance.stretch.some((x) => x?.side === side)) continue;
715
+ if (!stretch.some((x) => x?.side === side)) continue;
704
716
  for (const b of L[side].chain.bones) {
705
717
  const own = scales.get(b);
706
718
  const s = own === undefined ? new Array<number>(n).fill(1) : [...own];
707
- stance.stretch.forEach((x, i) => {
719
+ stretch.forEach((x, i) => {
708
720
  if (x?.side === side) s[i] = s[i]! * x.k;
709
721
  });
710
722
  scales.set(b, s);
711
723
  }
712
724
  }
725
+ };
726
+ // #85: on a blend frame the hip is between two derivations; the supporting leg is stretched
727
+ // (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
728
+ // floor is that supporting ankle.
729
+ blendHold = holdBlend(planted.blends, n, posedFeet);
730
+ stretchChain(blendHold.stretch);
731
+ // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
732
+ // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
733
+ if (opts.stance !== undefined && opts.stance !== 'off') {
734
+ const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
735
+ stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf);
736
+ // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
737
+ for (let i = 0; i < n; i++) {
738
+ const b = blendHold.stretch[i];
739
+ if (b !== null && b !== undefined && stance.stretch[i]?.side === b.side) p.add('LEGS_STRETCH', `frame ${i}: chain "${L[b.side].chain.name}" is stretched both as the supporting leg of a blend (k ${b.k}) and as the free leg of --stance (k ${stance.stretch[i]!.k}); one chain takes one stretch`);
740
+ }
741
+ p.throwIfAny();
742
+ stretchChain(stance.stretch);
713
743
  }
714
744
  }
715
745
  if (t !== null && scale !== null) {
@@ -1110,7 +1140,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1110
1140
  animation: { duration: keyTimes[n - 1]!, tracks },
1111
1141
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1112
1142
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1113
- legScale, scales, feet, planted: plantOn, stance, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised,
1143
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised,
1114
1144
  };
1115
1145
  }
1116
1146