@rig-c/pose 0.6.0 → 0.8.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/README.md +87 -3
- package/cli.ts +71 -4
- package/package.json +1 -1
- package/src/compare.ts +1046 -0
- package/src/retarget.ts +2 -1
- package/src/run.ts +1 -1
- package/src/runcompare.ts +119 -0
- package/src/sheet.ts +106 -0
- package/src/switch.ts +151 -8
package/src/retarget.ts
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
*
|
|
42
42
|
* Then the switches (rig-c/pose#6, `switch.ts`): with every target known —
|
|
43
43
|
* reaches included — each alternate set's distance to the pose is read, and the
|
|
44
|
-
* spans each set shows are chosen
|
|
44
|
+
* spans each set shows are chosen, each boundary keyed at its window's speed
|
|
45
|
+
* peak (#31). Only then the range clamp: outside the spans
|
|
45
46
|
* of a set depicting its chain, a bone whose rotate key would leave its
|
|
46
47
|
* `range` is clamped at the limit (its target becomes the clamped world angle),
|
|
47
48
|
* in depth order, so a bone below takes the clamped parent into its own key
|
package/src/run.ts
CHANGED
|
@@ -154,7 +154,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
154
154
|
animation: a.name,
|
|
155
155
|
frames: seq.frames.length,
|
|
156
156
|
duration: r.animation.duration,
|
|
157
|
-
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, profile: a.profile },
|
|
157
|
+
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile },
|
|
158
158
|
scale: r.scale,
|
|
159
159
|
holds: r.holds,
|
|
160
160
|
short: r.short,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-pose compare`, end to end: read the two sequences, the map and the
|
|
3
|
+
* optional inputs, compare (`compare.ts`), print a summary line per unit and a
|
|
4
|
+
* line per finding, and write the report and the sheet when asked. A finding is
|
|
5
|
+
* not a failure: the run exits 0 whenever it compared.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { compare, findingLine, readCloth, readHands, readSpans, summaryLine, writeComparison, type ClothWindow, type CompareInput, type Comparison } from './compare.ts';
|
|
10
|
+
import { readMap } from './map.ts';
|
|
11
|
+
import { personTrack, readPoseSequence, type PoseSequence } from './pose.ts';
|
|
12
|
+
import { Problems, Refusal } from './problems.ts';
|
|
13
|
+
import { drawable, drawSheet } from './sheet.ts';
|
|
14
|
+
|
|
15
|
+
export interface CompareArgs {
|
|
16
|
+
reference: string;
|
|
17
|
+
render: string;
|
|
18
|
+
map: string;
|
|
19
|
+
retargetReport?: string;
|
|
20
|
+
referenceHands?: string;
|
|
21
|
+
renderHands?: string;
|
|
22
|
+
clothReference?: string;
|
|
23
|
+
clothRender?: string;
|
|
24
|
+
clothWindows?: ClothWindow[];
|
|
25
|
+
report?: string;
|
|
26
|
+
sheet?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runCompare(a: CompareArgs, log: (line: string) => void): Comparison {
|
|
30
|
+
const p = new Problems();
|
|
31
|
+
const inputs: Array<[string, string | undefined]> = [
|
|
32
|
+
['--reference', a.reference], ['--render', a.render], ['--map', a.map], ['--retarget-report', a.retargetReport],
|
|
33
|
+
['--reference-hands', a.referenceHands], ['--render-hands', a.renderHands], ['--cloth-reference', a.clothReference], ['--cloth-render', a.clothRender],
|
|
34
|
+
];
|
|
35
|
+
for (const [flag, path] of inputs) {
|
|
36
|
+
if (path === undefined) continue;
|
|
37
|
+
if (!existsSync(path)) p.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
38
|
+
else if (!statSync(path).isFile()) p.add('ARG_PATH', `${flag} ${path} is not a file`);
|
|
39
|
+
}
|
|
40
|
+
for (const [flag, path] of [['--report', a.report], ['--sheet', a.sheet]] as const) {
|
|
41
|
+
if (path === undefined) continue;
|
|
42
|
+
const dir = dirname(path);
|
|
43
|
+
if (!existsSync(dir)) p.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
|
|
44
|
+
else if (!statSync(dir).isDirectory()) p.add('ARG_PATH', `${flag} ${path} — ${dir} is not a directory`);
|
|
45
|
+
}
|
|
46
|
+
p.throwIfAny();
|
|
47
|
+
|
|
48
|
+
// Every reader runs, and every refusal is reported together.
|
|
49
|
+
const all: Refusal[] = [];
|
|
50
|
+
const read = <T>(f: () => T): T | null => {
|
|
51
|
+
try {
|
|
52
|
+
return f();
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e instanceof Refusal) {
|
|
55
|
+
all.push(e);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const seqOf = (path: string) => read(() => {
|
|
62
|
+
const seq = readPoseSequence(readFileSync(path, 'utf8'), path);
|
|
63
|
+
return { seq, frames: personTrack(seq, undefined, path) };
|
|
64
|
+
});
|
|
65
|
+
const ref = seqOf(a.reference);
|
|
66
|
+
const ren = seqOf(a.render);
|
|
67
|
+
const map = read(() => readMap(readFileSync(a.map, 'utf8'), a.map));
|
|
68
|
+
const report = a.retargetReport === undefined ? null : read(() => readSpans(readFileSync(a.retargetReport!, 'utf8'), a.retargetReport!));
|
|
69
|
+
const handsA = a.referenceHands === undefined ? null : read(() => readHands(readFileSync(a.referenceHands!, 'utf8'), a.referenceHands!));
|
|
70
|
+
const handsB = a.renderHands === undefined ? null : read(() => readHands(readFileSync(a.renderHands!, 'utf8'), a.renderHands!));
|
|
71
|
+
const clothA = a.clothReference === undefined ? null : read(() => readCloth(readFileSync(a.clothReference!, 'utf8'), a.clothReference!));
|
|
72
|
+
const clothB = a.clothRender === undefined ? null : read(() => readCloth(readFileSync(a.clothRender!, 'utf8'), a.clothRender!));
|
|
73
|
+
if (all.length > 0) throw new Refusal(all.flatMap((e) => e.problems));
|
|
74
|
+
if (report !== null && report.frames !== ref!.seq.frames.length) {
|
|
75
|
+
throw new Refusal([{ code: 'COMPARE_REPORT', text: `${a.retargetReport} is a retarget over ${report.frames} frame(s); the reference ${a.reference} has ${ref!.seq.frames.length} — it is the report of another run` }]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const side = (path: string, s: { seq: PoseSequence; frames: CompareInput['reference']['frames'] }) => ({ where: path, times: s.seq.frames.map((f) => f.t), frames: s.frames });
|
|
79
|
+
const input: CompareInput = {
|
|
80
|
+
reference: side(a.reference, ref!),
|
|
81
|
+
render: side(a.render, ren!),
|
|
82
|
+
map: map!,
|
|
83
|
+
spans: report === null ? null : report.spans,
|
|
84
|
+
retargetScale: report === null ? null : report.scale,
|
|
85
|
+
hands: handsA !== null && handsB !== null ? { reference: handsA, render: handsB } : null,
|
|
86
|
+
cloth: clothA !== null && clothB !== null ? { reference: clothA, render: clothB, windows: a.clothWindows ?? [] } : null,
|
|
87
|
+
};
|
|
88
|
+
const c = compare(input);
|
|
89
|
+
|
|
90
|
+
log(`rig-pose compare: ${c.frames} frame(s), ${c.duration.toFixed(3)}s — reference ${a.reference}, render ${a.render}; median hip width ${px(c.hipWidth.reference)} and ${px(c.hipWidth.render)}${a.retargetReport === undefined ? '' : `; spans from ${a.retargetReport}`}`);
|
|
91
|
+
for (const u of c.units) log(` ${u.unit.toUpperCase().padEnd(6)} ${summaryLine(u, c.times)}`);
|
|
92
|
+
for (const f of c.findings) log(` FIND ${findingLine(f, c.times)}`);
|
|
93
|
+
if (c.cloth !== null) {
|
|
94
|
+
for (const w of c.cloth.windows) {
|
|
95
|
+
const fig = (x: { lag: number; swingRatio: number | null; overshoot: number | null; strokes: number }) => `lag ${x.lag.toFixed(3)}, swing ${x.swingRatio === null ? '—' : x.swingRatio.toFixed(3)}, overshoot ${x.overshoot === null ? '—' : x.overshoot.toFixed(3)} (${x.strokes} strokes)`;
|
|
96
|
+
log(` CLOTH window ${w.name} ${w.t[0]}–${w.t[1]}s (frames ${w.frames[0]}–${w.frames[1]}): reference ${fig(w.reference)}; render ${fig(w.render)}; strokes from ${c.cloth.stroke.reference} and ${c.cloth.stroke.render.toFixed(4)} hip widths (reference, render)`);
|
|
97
|
+
}
|
|
98
|
+
if (c.cloth.windows.length > 0) log(` CLOTH the render's stroke threshold ${c.cloth.stroke.render.toFixed(4)} of its hip widths = ${c.cloth.stroke.carried}`);
|
|
99
|
+
}
|
|
100
|
+
if (a.report !== undefined) {
|
|
101
|
+
writeFileSync(a.report, writeComparison(c, {
|
|
102
|
+
reference: a.reference, render: a.render, map: a.map, retargetReport: a.retargetReport ?? null,
|
|
103
|
+
referenceHands: a.referenceHands ?? null, renderHands: a.renderHands ?? null, clothReference: a.clothReference ?? null, clothRender: a.clothRender ?? null,
|
|
104
|
+
}));
|
|
105
|
+
log(` wrote ${a.report}`);
|
|
106
|
+
}
|
|
107
|
+
if (a.sheet !== undefined) {
|
|
108
|
+
const plate = drawSheet({ frames: ref!.frames, width: ref!.seq.width, height: ref!.seq.height }, { frames: ren!.frames, width: ren!.seq.width, height: ren!.seq.height }, c.findings);
|
|
109
|
+
if (plate === null) log(` SHEET no finding has a frame to draw (${c.findings.length} finding(s), none outside cloth); ${a.sheet} not written`);
|
|
110
|
+
else {
|
|
111
|
+
plate.writePng(a.sheet);
|
|
112
|
+
log(` SHEET ${drawable(c.findings).length} finding(s), reference | render at each worst frame: wrote ${a.sheet}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
log(` ${c.findings.length} finding(s)`);
|
|
116
|
+
return c;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const px = (v: number | null) => (v === null ? 'none' : `${v.toFixed(1)}px`);
|
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comparison's sheet (rig-c/pose#38): for each finding, the reference
|
|
3
|
+
* skeleton beside the render skeleton at its worst frame, one row per finding,
|
|
4
|
+
* worst first within each unit. Skeletons only — no video frame is ever drawn.
|
|
5
|
+
* The finding's own points are drawn over the limbs in yellow.
|
|
6
|
+
*
|
|
7
|
+
* A cloth finding is a figure over a window with no single frame, so it has no
|
|
8
|
+
* row. Pure: it returns the plate; the caller writes it.
|
|
9
|
+
*/
|
|
10
|
+
import { textWidth } from 'rig-c/font5x7';
|
|
11
|
+
import { Plate, type RGBA } from 'rig-c/plate';
|
|
12
|
+
import type { Body18 } from './joints.ts';
|
|
13
|
+
import { positionOf, type Finding, type Frames } from './compare.ts';
|
|
14
|
+
|
|
15
|
+
const RIGHT: RGBA = [214, 69, 65, 255];
|
|
16
|
+
const LEFT: RGBA = [52, 120, 214, 255];
|
|
17
|
+
const MID: RGBA = [120, 120, 120, 255];
|
|
18
|
+
const MARK: RGBA = [236, 180, 20, 255];
|
|
19
|
+
const PANE: RGBA = [232, 232, 232, 255];
|
|
20
|
+
const PAPER: RGBA = [255, 255, 255, 255];
|
|
21
|
+
const INK: RGBA = [30, 30, 30, 255];
|
|
22
|
+
|
|
23
|
+
const LIMBS: Array<[Body18, Body18, RGBA]> = [
|
|
24
|
+
['r_shoulder', 'r_elbow', RIGHT], ['r_elbow', 'r_wrist', RIGHT],
|
|
25
|
+
['r_hip', 'r_knee', RIGHT], ['r_knee', 'r_ankle', RIGHT],
|
|
26
|
+
['l_shoulder', 'l_elbow', LEFT], ['l_elbow', 'l_wrist', LEFT],
|
|
27
|
+
['l_hip', 'l_knee', LEFT], ['l_knee', 'l_ankle', LEFT],
|
|
28
|
+
['r_shoulder', 'l_shoulder', MID], ['r_hip', 'l_hip', MID],
|
|
29
|
+
['r_shoulder', 'r_hip', RIGHT], ['l_shoulder', 'l_hip', LEFT],
|
|
30
|
+
['neck', 'nose', MID], ['nose', 'r_eye', RIGHT], ['nose', 'l_eye', LEFT],
|
|
31
|
+
['r_eye', 'r_ear', RIGHT], ['l_eye', 'l_ear', LEFT],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface SheetSide {
|
|
35
|
+
frames: Frames;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The pane height in px; each pane keeps its picture's aspect. */
|
|
41
|
+
export const SHEET_PANE = 320;
|
|
42
|
+
const LABEL = 28;
|
|
43
|
+
const GAP = 8;
|
|
44
|
+
|
|
45
|
+
/** The findings a sheet draws: every one with a frame (not cloth). */
|
|
46
|
+
export function drawable(findings: readonly Finding[]): Finding[] {
|
|
47
|
+
return findings.filter((f) => f.unit !== 'cloth');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The sheet as a plate, or null when no finding has a frame to draw. */
|
|
51
|
+
export function drawSheet(reference: SheetSide, render: SheetSide, findings: readonly Finding[]): Plate | null {
|
|
52
|
+
const rows = drawable(findings);
|
|
53
|
+
if (rows.length === 0) return null;
|
|
54
|
+
const wa = Math.round((reference.width * SHEET_PANE) / reference.height);
|
|
55
|
+
const wb = Math.round((render.width * SHEET_PANE) / render.height);
|
|
56
|
+
const width = Math.max(wa + GAP + wb, 480) + 2 * GAP;
|
|
57
|
+
const rowH = LABEL + SHEET_PANE + GAP;
|
|
58
|
+
const plate = new Plate(width, GAP + rows.length * rowH);
|
|
59
|
+
plate.rect(0, 0, plate.width, plate.height, PAPER);
|
|
60
|
+
rows.forEach((f, k) => {
|
|
61
|
+
const y = GAP + k * rowH;
|
|
62
|
+
const title = label(`${k + 1} ${f.unit} ${f.measure} ${f.subject} frame ${f.worst.frame} t=${f.worst.t.toFixed(3)}`);
|
|
63
|
+
plate.text(title, GAP, y, textWidth(title, 2) <= width - 2 * GAP ? 2 : 1, INK);
|
|
64
|
+
plate.text('reference', GAP, y + 16, 1, INK);
|
|
65
|
+
plate.text('render', GAP + wa + GAP, y + 16, 1, INK);
|
|
66
|
+
pane(plate, reference, f, GAP, y + LABEL, wa);
|
|
67
|
+
pane(plate, render, f, GAP + wa + GAP, y + LABEL, wb);
|
|
68
|
+
});
|
|
69
|
+
return plate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pane(plate: Plate, side: SheetSide, f: Finding, x0: number, y0: number, w: number): void {
|
|
73
|
+
plate.rect(x0, y0, w, SHEET_PANE, PANE);
|
|
74
|
+
const k = SHEET_PANE / side.height;
|
|
75
|
+
const joints = side.frames[f.worst.frame] ?? {};
|
|
76
|
+
const at = (n: string): [number, number] | null => {
|
|
77
|
+
const p = positionOf(joints, n);
|
|
78
|
+
if (p === null) return null;
|
|
79
|
+
const x = x0 + p[0] * k;
|
|
80
|
+
const y = y0 + p[1] * k;
|
|
81
|
+
// A point outside the picture is not drawn outside its pane.
|
|
82
|
+
return x < x0 || x >= x0 + w || y < y0 || y >= y0 + SHEET_PANE ? null : [x, y];
|
|
83
|
+
};
|
|
84
|
+
const t = 3;
|
|
85
|
+
for (const [a, b, colour] of LIMBS) {
|
|
86
|
+
const pa = at(a);
|
|
87
|
+
const pb = at(b);
|
|
88
|
+
if (pa !== null && pb !== null) plate.line(pa[0], pa[1], pb[0], pb[1], t, colour);
|
|
89
|
+
}
|
|
90
|
+
// A hand's points are not body-18: its wrist is marked.
|
|
91
|
+
const marks = f.measure === 'visibility' ? [f.subject === 'hand_r' ? 'r_wrist' : 'l_wrist'] : f.points;
|
|
92
|
+
for (let i = 0; i + 1 < marks.length; i++) {
|
|
93
|
+
const pa = at(marks[i]!);
|
|
94
|
+
const pb = at(marks[i + 1]!);
|
|
95
|
+
if (pa !== null && pb !== null) plate.line(pa[0], pa[1], pb[0], pb[1], t + 2, MARK);
|
|
96
|
+
}
|
|
97
|
+
for (const q of marks) {
|
|
98
|
+
const pq = at(q);
|
|
99
|
+
if (pq !== null) plate.disc(pq[0], pq[1], t * 2, MARK);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The 5×7 font has capitals, digits and a little punctuation; anything else becomes a space. */
|
|
104
|
+
function label(s: string): string {
|
|
105
|
+
return s.toUpperCase().replace(/[^A-Z0-9 _\-.,:/()!+=<>]/g, ' ');
|
|
106
|
+
}
|
package/src/switch.ts
CHANGED
|
@@ -31,6 +31,26 @@
|
|
|
31
31
|
* time lands below it (rig-c docs/AUTHORING.md §4.5), and the pose's times and
|
|
32
32
|
* a renderer's `i / fps` are two grids that disagree by more than that.
|
|
33
33
|
* Halfway, both see it on frame i.
|
|
34
|
+
*
|
|
35
|
+
* Where a switch is keyed (rig-c/pose#31): a boundary — a frame where a set
|
|
36
|
+
* starts showing, or where the base art or the next set comes back — is moved,
|
|
37
|
+
* after the selection above, to the fastest frame within ±`switchMin`/2 of it,
|
|
38
|
+
* so the change of drawing lands in motion, not on an arm slowing into the
|
|
39
|
+
* pose. The speed on a frame is the largest |angular speed|, degrees per frame
|
|
40
|
+
* (central difference of the target world angles; one-sided on the first and
|
|
41
|
+
* last frame), over the segments of the chains the boundary changes, the
|
|
42
|
+
* leaving set's and the arriving set's; read on the same targets as the
|
|
43
|
+
* distance, before the range clamp and any set's `turn`, which depend on where
|
|
44
|
+
* the spans are. The window is clipped so every span keeps `switchMin` (a gap
|
|
45
|
+
* between two spans of one set too, so the bridging holds), no boundary
|
|
46
|
+
* reaches its neighbour, and a set with `frames` shows only inside them; and
|
|
47
|
+
* a frame qualifies only if the set the move newly shows is within twice the
|
|
48
|
+
* tolerance of its key skeleton on every frame it newly shows it on (speed
|
|
49
|
+
* alone showed art up to 135° from its pose on the dancer). Ties
|
|
50
|
+
* go to the frame nearest the original, then the earlier. A boundary between
|
|
51
|
+
* two sets moves once. `switchAt: 'first'` keeps every boundary where the
|
|
52
|
+
* selection put it; it is the CLI's default, because on HQ's dancer the bounded
|
|
53
|
+
* peak still raised the sets' ART-RANGE runs from 24 to 31 (#31, round 2).
|
|
34
54
|
*/
|
|
35
55
|
import type { AlternateSet, JointMap, Rest, RigSlots } from './map.ts';
|
|
36
56
|
import { descends } from './map.ts';
|
|
@@ -43,6 +63,21 @@ export interface SwitchOptions {
|
|
|
43
63
|
switchTolerance: number;
|
|
44
64
|
/** Seconds: a run of one set shorter than this is dropped. */
|
|
45
65
|
switchMin: number;
|
|
66
|
+
/** Where a boundary is keyed: 'first' (the CLI's default) keeps the frame the selection gave it; 'peak' moves it to the fastest qualifying frame of its window (#31). */
|
|
67
|
+
switchAt: 'peak' | 'first';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
|
|
71
|
+
export interface Move {
|
|
72
|
+
/** The frame the boundary showed on as selected, then as keyed. */
|
|
73
|
+
frame: [number, number];
|
|
74
|
+
/** Signed: frames, and the seconds between the two frames' times. */
|
|
75
|
+
frames: number;
|
|
76
|
+
seconds: number;
|
|
77
|
+
/** The frames it could move to, after clipping: first and last. */
|
|
78
|
+
window: [number, number];
|
|
79
|
+
/** Degrees per frame on the old and the new frame: the largest |angular speed| over the segments of the chains it changes. */
|
|
80
|
+
speed: { old: number; new: number };
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
export interface Switch {
|
|
@@ -63,6 +98,8 @@ export interface Switch {
|
|
|
63
98
|
distance: { max: number; median: number };
|
|
64
99
|
/** Per bone the set has a `drawn` angle for: |wrap(target − drawn)| over the frames shown, degrees. */
|
|
65
100
|
artTurn: Record<string, { max: number; median: number }>;
|
|
101
|
+
/** The boundary it starts on (null from the first frame) and the one it ends on (null to the end); a boundary between two sets is in both. */
|
|
102
|
+
moved: { on: Move | null; off: Move | null };
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
export interface AttachmentKey {
|
|
@@ -169,6 +206,89 @@ function competing(sets: readonly AlternateSet[]): AlternateSet[][] {
|
|
|
169
206
|
return [...groups.values()];
|
|
170
207
|
}
|
|
171
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Per frame, the largest |angular speed| over the given bones' target world
|
|
211
|
+
* angles, degrees per frame: a central difference, one-sided on the first and
|
|
212
|
+
* the last frame.
|
|
213
|
+
*/
|
|
214
|
+
export function speeds(bones: readonly string[], targets: ReadonlyMap<string, readonly number[]>, n: number): number[] {
|
|
215
|
+
const out: number[] = [];
|
|
216
|
+
for (let k = 0; k < n; k++) {
|
|
217
|
+
const lo = Math.max(0, k - 1);
|
|
218
|
+
const hi = Math.min(n - 1, k + 1);
|
|
219
|
+
let v = 0;
|
|
220
|
+
if (hi > lo) for (const b of bones) v = Math.max(v, Math.abs(wrap(targets.get(b)![hi]! - targets.get(b)![lo]!)) / (hi - lo));
|
|
221
|
+
out.push(v);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A moved boundary may newly show a set only on frames within this many times the tolerance of its key skeleton (#31). */
|
|
227
|
+
export const MOVE_BOUND = 2;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Move each boundary of one group's segments to the fastest frame of its
|
|
231
|
+
* window (`switchAt: 'peak'`), in time order, each clipped against its
|
|
232
|
+
* neighbours as they stand; `vals[s]` is what segment s shows (null: the base
|
|
233
|
+
* art) and `pos[m]` the frame boundary m shows on, between segments m and m+1.
|
|
234
|
+
* A candidate frame qualifies only if every frame the move newly shows a set
|
|
235
|
+
* on is within `MOVE_BOUND` × the tolerance of it; none qualifying, the
|
|
236
|
+
* boundary stays. Returns one `Move` per boundary.
|
|
237
|
+
*/
|
|
238
|
+
function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly number[], map: JointMap, targets: ReadonlyMap<string, readonly number[]>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, opts: SwitchOptions): Move[] {
|
|
239
|
+
const n = times.length;
|
|
240
|
+
const at = [...pos];
|
|
241
|
+
const end = (b: number) => (b < n ? times[b]! : duration);
|
|
242
|
+
const len = (a: number, b: number) => end(b) - times[a]!;
|
|
243
|
+
/** What a segment must keep: the minimum span for a set's, and for a gap between two spans of one set (the bridging); never less than it had. */
|
|
244
|
+
const need = (s: number, had: number): number => {
|
|
245
|
+
const v = vals[s];
|
|
246
|
+
const kept = v !== null || (s > 0 && s + 1 < vals.length && vals[s - 1] !== null && vals[s - 1] === vals[s + 1]);
|
|
247
|
+
return kept ? Math.min(opts.switchMin, had) : 0;
|
|
248
|
+
};
|
|
249
|
+
const allowed = (a: AlternateSet | null, k: number) => a === null || a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
|
|
250
|
+
const bonesOf = (a: AlternateSet | null): string[] => (a === null ? [] : a.chains.flatMap((cn) => map.chains.find((c) => c.name === cn)!.bones));
|
|
251
|
+
const moves: Move[] = [];
|
|
252
|
+
for (let m = 0; m < at.length; m++) {
|
|
253
|
+
const b = at[m]!;
|
|
254
|
+
const prev = m === 0 ? 0 : at[m - 1]!;
|
|
255
|
+
const next = m + 1 < at.length ? at[m + 1]! : n;
|
|
256
|
+
const leaving = vals[m] ?? null;
|
|
257
|
+
const arriving = vals[m + 1] ?? null;
|
|
258
|
+
const needPrev = need(m, len(prev, b));
|
|
259
|
+
const needNext = need(m + 1, len(b, next));
|
|
260
|
+
// The pose's times are read to the microsecond, so a frame exactly half a minimum span away may read a hair past it.
|
|
261
|
+
const half = opts.switchMin / 2 + 1e-6;
|
|
262
|
+
// Earlier: frames b' … b−1 show the arriving segment.
|
|
263
|
+
let lo = b;
|
|
264
|
+
while (lo - 1 > prev && times[b]! - times[lo - 1]! <= half && len(prev, lo - 1) >= needPrev - 1e-9 && allowed(arriving, lo - 1)) lo--;
|
|
265
|
+
// Later: frames b … b'−1 show the leaving one.
|
|
266
|
+
let hi = b;
|
|
267
|
+
while (hi + 1 < next && times[hi + 1]! - times[b]! <= half && len(hi + 1, next) >= needNext - 1e-9 && allowed(leaving, hi)) hi++;
|
|
268
|
+
const sp = speeds([...new Set([...bonesOf(leaving), ...bonesOf(arriving)])], targets, n);
|
|
269
|
+
// A frame qualifies only if the set it newly shows is within twice the tolerance of its key skeleton on every
|
|
270
|
+
// frame it newly shows it on (round 1 on the dancer: speed alone showed a set up to 135° from its pose).
|
|
271
|
+
const near = (a: AlternateSet | null, from: number, to: number): boolean => {
|
|
272
|
+
if (a === null) return true;
|
|
273
|
+
const d = distances.get(a.name)!;
|
|
274
|
+
for (let k = from; k < to; k++) if (d[k]! > MOVE_BOUND * opts.switchTolerance) return false;
|
|
275
|
+
return true;
|
|
276
|
+
};
|
|
277
|
+
const qualifies = (k: number) => (k < b ? near(arriving, k, b) : near(leaving, b, k));
|
|
278
|
+
let best = b;
|
|
279
|
+
if (opts.switchAt === 'peak') {
|
|
280
|
+
for (let k = lo; k <= hi; k++) {
|
|
281
|
+
if (!qualifies(k)) continue;
|
|
282
|
+
const d = sp[k]! - sp[best]!;
|
|
283
|
+
if (d > 1e-9 || (Math.abs(d) <= 1e-9 && (Math.abs(k - b) < Math.abs(best - b) || (Math.abs(k - b) === Math.abs(best - b) && k < best)))) best = k;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
at[m] = best;
|
|
287
|
+
moves.push({ frame: [b, best], frames: best - b, seconds: r6(times[best]! - times[b]!), window: [lo, hi], speed: { old: r3(sp[b]!), new: r3(sp[best]!) } });
|
|
288
|
+
}
|
|
289
|
+
return moves;
|
|
290
|
+
}
|
|
291
|
+
|
|
172
292
|
/** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
|
|
173
293
|
const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
|
|
174
294
|
|
|
@@ -220,20 +340,34 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
220
340
|
}
|
|
221
341
|
i = j + 1;
|
|
222
342
|
}
|
|
343
|
+
// A run shorter than the minimum span shows the base art.
|
|
223
344
|
for (let i = 0; i < n;) {
|
|
224
345
|
const a = pick[i];
|
|
225
346
|
let j = i;
|
|
226
347
|
while (j + 1 < n && pick[j + 1] === a) j++;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const to = j + 1 < n ? times[j + 1]! : duration;
|
|
230
|
-
if (to - from >= opts.switchMin - 1e-9) {
|
|
231
|
-
const ds = distances.get(a.name)!.slice(i, j + 1);
|
|
232
|
-
switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {} });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
348
|
+
const to = j + 1 < n ? times[j + 1]! : duration;
|
|
349
|
+
if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
|
|
235
350
|
i = j + 1;
|
|
236
351
|
}
|
|
352
|
+
// The segments between boundaries, then each boundary moved (#31).
|
|
353
|
+
const vals: Array<AlternateSet | null> = [pick[0] ?? null];
|
|
354
|
+
const pos: number[] = [];
|
|
355
|
+
for (let i = 1; i < n; i++) {
|
|
356
|
+
if (pick[i] === pick[i - 1]) continue;
|
|
357
|
+
pos.push(i);
|
|
358
|
+
vals.push(pick[i] ?? null);
|
|
359
|
+
}
|
|
360
|
+
const moves = moveBoundaries(vals, pos, map, targets, distances, times, duration, opts);
|
|
361
|
+
for (let s = 0; s < vals.length; s++) {
|
|
362
|
+
const a = vals[s];
|
|
363
|
+
if (a === null || a === undefined) continue;
|
|
364
|
+
const i = s === 0 ? 0 : moves[s - 1]!.frame[1];
|
|
365
|
+
const j = s < pos.length ? moves[s]!.frame[1] - 1 : n - 1;
|
|
366
|
+
const from = times[i]!;
|
|
367
|
+
const to = j + 1 < n ? times[j + 1]! : duration;
|
|
368
|
+
const ds = distances.get(a.name)!.slice(i, j + 1);
|
|
369
|
+
switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {}, moved: { on: s === 0 ? null : moves[s - 1]!, off: s < pos.length ? moves[s]! : null } });
|
|
370
|
+
}
|
|
237
371
|
}
|
|
238
372
|
const order = (name: string) => map.alternates.findIndex((a) => a.name === name);
|
|
239
373
|
switches.sort((x, y) => x.frames[0] - y.frames[0] || order(x.set) - order(y.set));
|
|
@@ -433,9 +567,18 @@ export function switchLine(s: Switch, times: readonly number[]): string {
|
|
|
433
567
|
if (offs.length > 0) parts.push(`offsets ${offs.map(([b, o]) => `"${b}" (${o[0]}, ${o[1]})`).join(', ')}`);
|
|
434
568
|
const art = Object.entries(s.artTurn);
|
|
435
569
|
if (art.length > 0) parts.push(`art turn ${art.map(([b, a]) => `"${b}" max ${deg(a.max)}, median ${deg(a.median)}`).join('; ')}`);
|
|
570
|
+
const moves = (['on', 'off'] as const).filter((k) => s.moved[k] !== null).map((k) => `${k} ${moveText(s.moved[k]!)}`);
|
|
571
|
+
if (moves.length > 0) parts.push(`moved ${moves.join(', ')}`);
|
|
436
572
|
return parts.join('; ');
|
|
437
573
|
}
|
|
438
574
|
|
|
575
|
+
const spd = (v: number) => `${v.toFixed(1)}°/frame`;
|
|
576
|
+
const win = (w: [number, number]) => (w[0] === w[1] ? `window frame ${w[0]}` : `window frames ${w[0]}–${w[1]}`);
|
|
577
|
+
function moveText(m: Move): string {
|
|
578
|
+
if (m.frames === 0) return `frame ${m.frame[0]} stays (${spd(m.speed.old)}; ${win(m.window)})`;
|
|
579
|
+
return `frame ${m.frame[0]} → ${m.frame[1]} (${m.frames > 0 ? '+' : '−'}${Math.abs(m.frames)} frame${Math.abs(m.frames) === 1 ? '' : 's'}, ${m.seconds > 0 ? '+' : '−'}${Math.abs(m.seconds).toFixed(3)}s; ${spd(m.speed.old)} → ${spd(m.speed.new)}; ${win(m.window)})`;
|
|
580
|
+
}
|
|
581
|
+
|
|
439
582
|
export function artRangeLine(e: ArtRange, times: readonly number[]): string {
|
|
440
583
|
return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
|
|
441
584
|
}
|