cozyclay 3.1.0 → 3.1.2
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/CHANGELOG.md +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Measured, not judged (output.observations.life): how mechanical a take reads. Five cheap readings
|
|
2
|
+
// over the baked frames and the keys the agent wrote, one linear pass each, warnings only (never a
|
|
3
|
+
// QA group, never qa.ok):
|
|
4
|
+
// sync outside walks, the frame each body part (CHAINS) starts and stops moving (its activity,
|
|
5
|
+
// below, past START of its usual level after a hold); the starts (stops) of several parts
|
|
6
|
+
// close together are one transition, synchronous where most of them go within +-1 frame.
|
|
7
|
+
// A body does not do that: the pelvis or a hand leads and the trunk, head and arms follow
|
|
8
|
+
// a few frames later. score = the share of transitions that are synchronous.
|
|
9
|
+
// keys the gaps between the agent's key frames (per channel and all channels as one): their
|
|
10
|
+
// coefficient of variation (cv, 0 = an even grid) and the share of keys on a half second.
|
|
11
|
+
// Keys on a beat move every pose at one tempo; keys on the extremes do not.
|
|
12
|
+
// jerk the third difference of each CHAINS joint's position (m/s^3): its 95th percentile on
|
|
13
|
+
// the frames the body moves, and spike frames, where a joint goes past SPIKE times its level
|
|
14
|
+
// (and JERK_FLOOR): the acceleration snaps there, a seam between two keys' curves (a landing
|
|
15
|
+
// too); warned past twice that.
|
|
16
|
+
// twin outside walks (the hips over WALK_SPEED) and jumps (no foot still), in WINDOW-frame windows: the correlation of
|
|
17
|
+
// the left and right limb's bend speeds (elbow and shoulder; knee and hip). Over TWIN both
|
|
18
|
+
// sides do the same thing on the same frames. score = the share of windows that twin.
|
|
19
|
+
// dead runs of a second or more with no joint moving over DEAD m a frame outside a still goal:
|
|
20
|
+
// a statue (idle false, or a pose held where the idle layer stays off).
|
|
21
|
+
// Activity is a part's own motion: the turn (deg/frame) of its local rotation, and for the hips
|
|
22
|
+
// their world turn or travel (cm/frame), so a hand carried by its arm does not count as moving.
|
|
23
|
+
// Why: compiled takes pass QA and still read mechanical (every part starts and stops on one frame,
|
|
24
|
+
// keys every 12 frames, both arms twinned), and nothing in the answer said so.
|
|
25
|
+
import { sub, len } from "../compiler/math.js";
|
|
26
|
+
|
|
27
|
+
const CHAINS = ["Hips", "Spine", "Spine2", "Head", "LeftArm", "RightArm", "LeftForeArm", "RightForeArm", "LeftHand", "RightHand",
|
|
28
|
+
"LeftUpLeg", "RightUpLeg", "LeftLeg", "RightLeg", "LeftFoot", "RightFoot"];
|
|
29
|
+
/** A part moves above MOVE deg (cm) a frame; holds are HOLD frames or more under it; START of a part's usual level starts it. */
|
|
30
|
+
const MOVE = 0.25, HOLD = 3, START = 0.2;
|
|
31
|
+
/** sync: events GAP frames or less apart are one transition; synchronous when SYNC_MIN parts and SYNC_SHARE of its parts go within +-1 frame. */
|
|
32
|
+
const SYNC_MIN = 4, SYNC_SHARE = 0.6, GAP = 3;
|
|
33
|
+
const GRID_CV = 0.15, GRID_KEYS = 4, HALF_SHARE = 0.7;
|
|
34
|
+
const SPIKE = 4, JERK_FLOOR = 20;
|
|
35
|
+
const WALK_SPEED = 0.25, FOOT_STILL = 0.005, WINDOW = 48, TWIN = 0.9, TWIN_SWEEP = 15;
|
|
36
|
+
const DEAD = 1e-4;
|
|
37
|
+
const r2 = x => Math.round(x * 100) / 100;
|
|
38
|
+
|
|
39
|
+
export const LIFE_UNITS = "life (warnings, not QA): [share or s, worst f0, f1]; keys [gap cv, even channels, half-second share]; jerk [p95 m/s^3, joint, spikes]";
|
|
40
|
+
|
|
41
|
+
const p95 = xs => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor(0.95 * s.length))] : 0; };
|
|
42
|
+
const cvOf = gaps => { const m = gaps.reduce((a, b) => a + b, 0) / gaps.length, v = gaps.reduce((a, g) => a + (g - m) ** 2, 0) / gaps.length; return m > 0 ? Math.sqrt(v) / m : 0; };
|
|
43
|
+
function pearson(a, b) {
|
|
44
|
+
const n = a.length, ma = a.reduce((s, x) => s + x, 0) / n, mb = b.reduce((s, x) => s + x, 0) / n;
|
|
45
|
+
let ab = 0, aa = 0, bb = 0;
|
|
46
|
+
for (let i = 0; i < n; i++) { ab += (a[i] - ma) * (b[i] - mb); aa += (a[i] - ma) ** 2; bb += (b[i] - mb) ** 2; }
|
|
47
|
+
return aa > 0 && bb > 0 ? ab / Math.sqrt(aa * bb) : 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Per CHAINS part, activity[f] (from f-1): its local turn in deg; the hips the larger of their world turn and travel in cm. */
|
|
51
|
+
function activities(sk, F) {
|
|
52
|
+
const I = sk.index, n = F.length, DEG = 180 / Math.PI;
|
|
53
|
+
return CHAINS.map(j => {
|
|
54
|
+
const b = I[j], p = j === "Hips" ? -1 : sk.parent[b], act = new Float64Array(n);
|
|
55
|
+
let L = new Float64Array(9), prev = new Float64Array(9);
|
|
56
|
+
for (let f = 0; f < n; f++) {
|
|
57
|
+
const { R, P } = F[f].s, B = R[b], A = p < 0 ? null : R[p];
|
|
58
|
+
// L = A^T B (the local rotation), written out: a pass over every frame of every part must not allocate
|
|
59
|
+
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) L[r * 3 + c] = A ? A[0][r] * B[0][c] + A[1][r] * B[1][c] + A[2][r] * B[2][c] : B[r][c];
|
|
60
|
+
if (f) {
|
|
61
|
+
let tr = 0;
|
|
62
|
+
for (let i = 0; i < 9; i++) tr += L[i] * prev[i];
|
|
63
|
+
const turn = Math.acos(Math.max(-1, Math.min(1, (tr - 1) / 2))) * DEG, Q = F[f - 1].s.P[b];
|
|
64
|
+
act[f] = p < 0 ? Math.max(turn, Math.hypot(P[b][0] - Q[0], P[b][1] - Q[1], P[b][2] - Q[2]) * 100) : turn;
|
|
65
|
+
}
|
|
66
|
+
[L, prev] = [prev, L];
|
|
67
|
+
}
|
|
68
|
+
return act;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Moves between holds: [a, b] runs of frames where some part moves, gaps under HOLD frames joined. */
|
|
73
|
+
function moves(acts, n) {
|
|
74
|
+
const runs = [];
|
|
75
|
+
let a = -1, quiet = 0;
|
|
76
|
+
for (let f = 1; f < n; f++) {
|
|
77
|
+
if (acts.some(act => act[f] > MOVE)) { if (a < 0) a = f; quiet = 0; }
|
|
78
|
+
else if (a >= 0 && ++quiet >= HOLD) { runs.push([a, f - quiet]); a = -1; quiet = 0; }
|
|
79
|
+
}
|
|
80
|
+
if (a >= 0) runs.push([a, n - 1 - quiet]);
|
|
81
|
+
return runs.filter(([x, y]) => y - x >= 3);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Starts and stops outside walks: a part starts where its activity rises past its threshold
|
|
86
|
+
* (MOVE, or START of its usual level: the 90th percentile of its moving frames) after HOLD frames
|
|
87
|
+
* under it, and stops where it falls under it for HOLD frames. Events of several parts that follow
|
|
88
|
+
* one another within GAP frames are one transition; it is synchronous when, of the m parts in it
|
|
89
|
+
* (SYNC_MIN or more), max(SYNC_MIN, SYNC_SHARE * m) go within one 3-frame window.
|
|
90
|
+
*/
|
|
91
|
+
function syncOf(acts, walking, n) {
|
|
92
|
+
const events = [[], []];
|
|
93
|
+
acts.forEach((act, j) => {
|
|
94
|
+
const level = [...act].filter(v => v > MOVE).sort((a, b) => a - b), thr = Math.max(MOVE, START * (level[Math.floor(0.9 * level.length)] ?? 0));
|
|
95
|
+
const on = f => act[f] > thr;
|
|
96
|
+
for (let f = 1; f < n; f++) {
|
|
97
|
+
if (walking[f]) continue;
|
|
98
|
+
if (on(f) && !on(f - 1)) { let q = 1; while (q <= HOLD && f - q >= 0 && !on(f - q)) q++; if (q > HOLD || f - q < 0) events[0].push([f, j]); }
|
|
99
|
+
if (!on(f) && on(f - 1)) { let q = 0; while (q < HOLD && f + q < n && !on(f + q)) q++; if (q >= HOLD && f + q < n) events[1].push([f - 1, j]); }
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
let total = 0, synced = 0, worst = null;
|
|
103
|
+
for (const list of events) {
|
|
104
|
+
list.sort((a, b) => a[0] - b[0]);
|
|
105
|
+
for (let i = 0; i < list.length;) {
|
|
106
|
+
let k = i + 1;
|
|
107
|
+
while (k < list.length && list[k][0] - list[k - 1][0] <= GAP) k++;
|
|
108
|
+
const group = list.slice(i, k), m = new Set(group.map(e => e[1])).size;
|
|
109
|
+
i = k;
|
|
110
|
+
if (m < SYNC_MIN) continue;
|
|
111
|
+
let best = 0, at = group[0][0];
|
|
112
|
+
for (let x = 0, y = 0; y < group.length; y++) {
|
|
113
|
+
while (group[y][0] - group[x][0] > 2) x++;
|
|
114
|
+
const c = new Set(group.slice(x, y + 1).map(e => e[1])).size;
|
|
115
|
+
if (c > best) { best = c; at = group[x][0]; }
|
|
116
|
+
}
|
|
117
|
+
total++;
|
|
118
|
+
if (best >= Math.max(SYNC_MIN, Math.ceil(SYNC_SHARE * m))) synced++;
|
|
119
|
+
if (!worst || best / m > worst.share || (best / m === worst.share && best > worst.k)) worst = { share: best / m, k: best, of: m, at: [at, at + 2] };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { score: total ? r2(synced / total) : 0, total, worst };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The agent's keys: cv of all key gaps, the channels keyed on an even grid, the share on half seconds. */
|
|
126
|
+
function keysOf(keys, fps) {
|
|
127
|
+
const per = new Map(), all = new Set();
|
|
128
|
+
for (const k of keys ?? []) {
|
|
129
|
+
if (!Number.isFinite(k?.f)) continue;
|
|
130
|
+
all.add(k.f);
|
|
131
|
+
for (const ch of Object.keys(k.set ?? {})) (per.get(ch) ?? per.set(ch, new Set()).get(ch)).add(k.f);
|
|
132
|
+
}
|
|
133
|
+
const gaps = fs => { const s = [...fs].sort((a, b) => a - b); return s.slice(1).map((f, i) => f - s[i]); };
|
|
134
|
+
const eligible = [...per].filter(([, fs]) => fs.size >= GRID_KEYS), grid = eligible.filter(([, fs]) => cvOf(gaps(fs)) < GRID_CV).map(([ch]) => ch);
|
|
135
|
+
const inner = [...all].filter(f => f > 0), half = Math.round(fps / 2);
|
|
136
|
+
return { cv: all.size >= GRID_KEYS ? r2(cvOf(gaps(all))) : null, grid, eligible: eligible.length,
|
|
137
|
+
halfSec: inner.length >= GRID_KEYS ? r2(inner.filter(f => f % half === 0).length / inner.length) : null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function jerkOf(sk, F, moving, fps) {
|
|
141
|
+
const k3 = fps ** 3;
|
|
142
|
+
let top = [0, null], worst = null;
|
|
143
|
+
const spikes = new Set();
|
|
144
|
+
for (const j of CHAINS) {
|
|
145
|
+
const X = F.map(fr => fr.s.P[sk.index[j]]), J = new Float64Array(F.length), on = [];
|
|
146
|
+
for (let f = 3; f < F.length; f++) {
|
|
147
|
+
const d = i => X[f][i] - 3 * X[f - 1][i] + 3 * X[f - 2][i] - X[f - 3][i];
|
|
148
|
+
J[f] = Math.hypot(d(0), d(1), d(2)) * k3;
|
|
149
|
+
if (moving[f]) on.push(J[f]);
|
|
150
|
+
}
|
|
151
|
+
const level = p95(on);
|
|
152
|
+
if (level > top[0]) top = [r2(level), j];
|
|
153
|
+
for (let f = 3; f < F.length; f++) {
|
|
154
|
+
if (J[f] <= Math.max(JERK_FLOOR, SPIKE * level)) continue;
|
|
155
|
+
spikes.add(f);
|
|
156
|
+
const ratio = J[f] / Math.max(level, 1e-9);
|
|
157
|
+
if (!worst || ratio > worst.ratio) worst = { ratio, f, j };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { p95: top, spikes: spikes.size, worst };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Bend angles (deg) per frame of one side's limb: [elbow, shoulder] or [knee, hip]. */
|
|
164
|
+
function bends(sk, s, S, arm) {
|
|
165
|
+
const I = sk.index, P = s.P, up = sub(P[I.Spine2], P[I.Hips]);
|
|
166
|
+
const ang = (a, b) => Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / Math.max(1e-9, len(a) * len(b))))) * 180 / Math.PI;
|
|
167
|
+
const [r, m, e] = arm ? [`${S}Arm`, `${S}ForeArm`, `${S}Hand`] : [`${S}UpLeg`, `${S}Leg`, `${S}Foot`];
|
|
168
|
+
const u = sub(P[I[m]], P[I[r]]);
|
|
169
|
+
return [ang(u, sub(P[I[e]], P[I[m]])), ang(u, arm ? up : [-up[0], -up[1], -up[2]])];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function twinOf(sk, F, walking) {
|
|
173
|
+
const res = {};
|
|
174
|
+
for (const [name, arm] of [["arms", true], ["legs", false]]) {
|
|
175
|
+
const L = F.map(({ s }) => bends(sk, s, "Left", arm)), R = F.map(({ s }) => bends(sk, s, "Right", arm));
|
|
176
|
+
let windows = 0, twins = 0, worst = null;
|
|
177
|
+
for (let a = 1; a + WINDOW <= F.length; a += WINDOW / 4) {
|
|
178
|
+
if (walking.slice(a, a + WINDOW).some(Boolean)) continue;
|
|
179
|
+
const dl = [], dr = [];
|
|
180
|
+
for (let f = a; f < a + WINDOW; f++) for (let i = 0; i < 2; i++) { dl.push(L[f][i] - L[f - 1][i]); dr.push(R[f][i] - R[f - 1][i]); }
|
|
181
|
+
const sweep = d => d.reduce((x, v) => x + Math.abs(v), 0);
|
|
182
|
+
if (sweep(dl) < TWIN_SWEEP || sweep(dr) < TWIN_SWEEP) continue;
|
|
183
|
+
const c = pearson(dl, dr);
|
|
184
|
+
windows++;
|
|
185
|
+
if (c > TWIN) twins++;
|
|
186
|
+
if (!worst || c > worst.c) worst = { c, at: [a, a + WINDOW - 1] };
|
|
187
|
+
}
|
|
188
|
+
res[name] = { score: windows ? r2(twins / windows) : 0, windows, worst };
|
|
189
|
+
}
|
|
190
|
+
return res;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function deadOf(sk, F, spec, fps) {
|
|
194
|
+
const still = new Uint8Array(F.length);
|
|
195
|
+
for (const g of spec.goals ?? []) {
|
|
196
|
+
const at = Array.isArray(g?.at) ? g.at : null;
|
|
197
|
+
if (g?.still !== undefined && at?.length) for (let f = Math.max(0, Math.round(at[0])); f <= Math.min(F.length - 1, Math.round(at[at.length - 1])); f++) still[f] = 1;
|
|
198
|
+
}
|
|
199
|
+
let best = null, frames = 0;
|
|
200
|
+
for (let f = 1, a = -1; f <= F.length; f++) {
|
|
201
|
+
const dead = f < F.length && !still[f] && sk.names.every((_, j) => len(sub(F[f].s.P[j], F[f - 1].s.P[j])) < DEAD);
|
|
202
|
+
if (dead) { if (a < 0) a = f; continue; }
|
|
203
|
+
if (a >= 0 && f - a >= fps) { frames += f - a; if (!best || f - a > best[1] - best[0] + 1) best = [a, f - 1]; }
|
|
204
|
+
a = -1;
|
|
205
|
+
}
|
|
206
|
+
return { frames, best };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* observeLife(sk, out, keys) -> observations.life; keys are the spec keys as the agent wrote them
|
|
211
|
+
* (a skill's keys are the compiler's). Frames are absolute (out.f0 added). flags names the metrics
|
|
212
|
+
* past their mark, worst first; warn is what to change for the worst (one line: the receipt budget, report.js).
|
|
213
|
+
*/
|
|
214
|
+
export function observeLife(sk, out, keys) {
|
|
215
|
+
const F = out.frames, n = F.length, fps = out.fps ?? 24, f0 = out.f0 ?? 0, spec = out.ctx.spec, I = sk.index, warnings = [];
|
|
216
|
+
const acts = activities(sk, F), runs = moves(acts, n), moving = new Uint8Array(n);
|
|
217
|
+
for (const [a, b] of runs) moving.fill(1, a, b + 1);
|
|
218
|
+
// a walk: the hips travel over WALK_SPEED, widened by half a window so its first and last steps count
|
|
219
|
+
const walking = new Uint8Array(n), pad = WINDOW / 2;
|
|
220
|
+
for (let f = 1; f < n; f++) {
|
|
221
|
+
const P = F[f].s.P[I.Hips], Q = F[f - 1].s.P[I.Hips];
|
|
222
|
+
if (Math.hypot(P[0] - Q[0], P[2] - Q[2]) * fps > WALK_SPEED) walking.fill(1, Math.max(0, f - pad), Math.min(n, f + pad));
|
|
223
|
+
}
|
|
224
|
+
// twins leave out walks and jumps too: no foot still (a flip, a hop takes both legs together)
|
|
225
|
+
const free = walking.slice();
|
|
226
|
+
for (let f = 1; f < n; f++) if (["LeftFoot", "RightFoot"].every(j => len(sub(F[f].s.P[I[j]], F[f - 1].s.P[I[j]])) > FOOT_STILL)) free.fill(1, Math.max(0, f - pad), Math.min(n, f + pad));
|
|
227
|
+
const sync = syncOf(acts, walking, n), k = keysOf(keys, fps), jerk = jerkOf(sk, F, moving, fps), twin = twinOf(sk, F, free), dead = deadOf(sk, F, spec, fps);
|
|
228
|
+
const span = ([a, b]) => `f${a + f0}-${b + f0}`;
|
|
229
|
+
// [weight, metric, advice]: the answer keeps the worst advice and names every metric it flags
|
|
230
|
+
if (sync.total >= 2 && sync.score >= 0.5) warnings.push([sync.score, "sync", `${sync.worst.k} of ${sync.worst.of} parts start or stop together ${span(sync.worst.at)}: lead with pelvis or a hand, spine/head/arms 2-4 frames later`]);
|
|
231
|
+
const gridKeys = (k.cv !== null && k.cv < GRID_CV) || (k.eligible && k.grid.length / k.eligible >= 0.5) || (k.halfSec ?? 0) >= HALF_SHARE;
|
|
232
|
+
if (gridKeys) warnings.push([0.9, "keys", `keys on a grid: key the extremes (contact, peak, turn), vary the gaps`]);
|
|
233
|
+
if (jerk.worst && jerk.worst.ratio > 2 * SPIKE) warnings.push([0.6, "jerk", `${jerk.worst.j} jerks ${Math.round(jerk.worst.ratio)}x at f${jerk.worst.f + f0}: a key snaps; ease or move it`]);
|
|
234
|
+
for (const [name, t] of Object.entries(twin)) if (t.windows >= 2 && t.score >= 0.4) warnings.push([t.score, name, `${name} mirror ${span(t.worst.at)}: ${name === "legs" ? "weight onto one foot, not straight down" : "offset one side 2-4 frames or pose it apart"}`]);
|
|
235
|
+
if (dead.best) warnings.push([0.7, "dead", `dead still ${span(dead.best)}${spec.idle === false ? " (idle false)" : ""}: let idle run or key a settle`]);
|
|
236
|
+
warnings.sort((a, b) => b[0] - a[0]);
|
|
237
|
+
const at = (score, w) => (w ? [score, w[0] + f0, w[1] + f0] : [score]);
|
|
238
|
+
return {
|
|
239
|
+
sync: at(sync.score, sync.worst?.at), keys: [k.cv, k.grid.length, k.halfSec], jerk: [...jerk.p95, jerk.spikes],
|
|
240
|
+
arms: at(twin.arms.score, twin.arms.worst?.at), legs: at(twin.legs.score, twin.legs.worst?.at), dead: at(r2(dead.frames / fps), dead.best),
|
|
241
|
+
...(warnings.length ? { flags: warnings.map(w => w[1]), warn: warnings[0][2] } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -36,7 +36,7 @@ function graspPart(out, ref) {
|
|
|
36
36
|
return out.ctx.sockets?.get(name)?.owner ?? a.object;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const active = (out, f, when) => (Array.isArray(when) ? f + (out.f0 ?? 0) >= when[0] && f + (out.f0 ?? 0) <= when[1] : when ? (out.frames[f].c[when] ?? 0) > 0.99 : true);
|
|
39
|
+
export const active = (out, f, when) => (Array.isArray(when) ? f + (out.f0 ?? 0) >= when[0] && f + (out.f0 ?? 0) <= when[1] : when ? (out.frames[f].c[when] ?? 0) > 0.99 : true);
|
|
40
40
|
|
|
41
41
|
function alongDir(out, f, along) {
|
|
42
42
|
if (along === "up") return [0, 1, 0];
|
|
@@ -44,19 +44,25 @@ function alongDir(out, f, along) {
|
|
|
44
44
|
return norm(along);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** How far a checks.contacts entry k's point is from its target at frame f (indexes out.frames), m; null when a point is unknown there. */
|
|
48
|
+
export function contactGap(out, f, k) {
|
|
49
|
+
const a = pointOf(out, f, k.point), b = pointOf(out, f, k.target);
|
|
50
|
+
if (!a || !b) return null;
|
|
51
|
+
// on a grasp's anchor the fingers close on the held part wherever the grasp turned them round it: they are on it
|
|
52
|
+
// when they are on its surface (as goals' near on an object), not on one point of it (session 7f15b4ae: 0.4 cm
|
|
53
|
+
// on the cup, read as 5.3 cm from the anchor)
|
|
54
|
+
// and a hand that holds a thing resting in its palm (seat.js) has its fingers on that thing's surface
|
|
55
|
+
const held = graspPart(out, k.target) ?? seatedIn(out, k.point, f + (out.f0 ?? 0));
|
|
56
|
+
return held ? refDistance(out, f, k.point, `object:${held}`) ?? len(sub(a, b)) : len(sub(a, b));
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
export function specChecks(out, checks = {}) {
|
|
48
60
|
const issues = [], n = out.frames.length, sk = out.ctx.sk, I = sk.index;
|
|
49
61
|
const push = (kind, msg, f, value, limit) => issues.push({ kind, msg, frame: f, value: +value.toFixed(4), limit });
|
|
50
62
|
for (let f = 0; f < n; f++) {
|
|
51
63
|
for (const k of checks.contacts ?? []) if (active(out, f, k.when)) {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
// on a grasp's anchor the fingers close on the held part wherever the grasp turned them round it: they are on it
|
|
55
|
-
// when they are on its surface (as goals' near on an object), not on one point of it (session 7f15b4ae: 0.4 cm
|
|
56
|
-
// on the cup, read as 5.3 cm from the anchor)
|
|
57
|
-
// and a hand that holds a thing resting in its palm (seat.js) has its fingers on that thing's surface
|
|
58
|
-
const held = graspPart(out, k.target) ?? seatedIn(out, k.point, f + (out.f0 ?? 0)), e = held ? refDistance(out, f, k.point, `object:${held}`) ?? len(sub(a, b)) : len(sub(a, b));
|
|
59
|
-
if (e > (k.tol ?? 0.01)) push("CONTACT", `${k.name}: ${(e * 100).toFixed(1)} cm apart`, f, e, k.tol ?? 0.01);
|
|
64
|
+
const e = contactGap(out, f, k);
|
|
65
|
+
if (e !== null && e > (k.tol ?? 0.01)) push("CONTACT", `${k.name}: ${(e * 100).toFixed(1)} cm apart`, f, e, k.tol ?? 0.01);
|
|
60
66
|
}
|
|
61
67
|
for (const k of checks.orient ?? []) if (active(out, f, k.when)) {
|
|
62
68
|
const a = pointOf(out, f, k.from), b = pointOf(out, f, k.to);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// C6. Is one compile's QA better than another's: the one rule repair (repair.js) keeps a change by and the hint check
|
|
2
|
+
// (hint-check.js) offers a patch by. Better means all of
|
|
3
|
+
// less the summed severity (severity.js) drops
|
|
4
|
+
// goals no more goals fail, and every goal that held still holds
|
|
5
|
+
// problems no more issue groups than before
|
|
6
|
+
// trade what got worse (a new group, or a group whose severity rose) costs at most half of what got better
|
|
7
|
+
// Logs: a hint patch applied as sent made the take worse 49% of the time, and repair moved the root and broke the arms;
|
|
8
|
+
// a lower sum alone let a change through that traded one failure for several new ones.
|
|
9
|
+
import { groupKey } from "./severity.js";
|
|
10
|
+
|
|
11
|
+
const groupsOf = qa => qa?.groups ?? [];
|
|
12
|
+
const sevOf = qa => groupsOf(qa).reduce((sum, g) => sum + (g.severity ?? 0), 0);
|
|
13
|
+
const heldOf = qa => (qa?.goals ?? []).filter(g => g.ok).map(g => g.name);
|
|
14
|
+
const failedOf = qa => (qa?.goals ?? []).filter(g => !g.ok).length;
|
|
15
|
+
const TRADE = 0.5;
|
|
16
|
+
const r2 = x => Math.round(x * 100) / 100;
|
|
17
|
+
|
|
18
|
+
/** The group severities by key (severity.js groupKey). */
|
|
19
|
+
function byKey(qa) {
|
|
20
|
+
const m = new Map();
|
|
21
|
+
for (const g of groupsOf(qa)) m.set(groupKey(g), (m.get(groupKey(g)) ?? 0) + (g.severity ?? 0));
|
|
22
|
+
return m;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* compareQa(next, base) -> { better, why, sev: [base, next], problems: [base, next], worse: [{ key, severity: [a, b] }] }
|
|
27
|
+
* why: the first rule that failed ("" when better); worse: the groups that got worse, worst first (at most 3).
|
|
28
|
+
*/
|
|
29
|
+
export function compareQa(next, base) {
|
|
30
|
+
const a = byKey(base), b = byKey(next);
|
|
31
|
+
let rise = 0, drop = 0;
|
|
32
|
+
const worse = [];
|
|
33
|
+
for (const [k, v] of b) { const was = a.get(k) ?? 0; if (v > was + 1e-9) { rise += v - was; worse.push({ key: k, severity: [r2(was), r2(v)] }); } else drop += was - v; }
|
|
34
|
+
for (const [k, v] of a) if (!b.has(k)) drop += v;
|
|
35
|
+
worse.sort((x, y) => y.severity[1] - y.severity[0] - (x.severity[1] - x.severity[0]));
|
|
36
|
+
const sev = [r2(sevOf(base)), r2(sevOf(next))], problems = [groupsOf(base).length, groupsOf(next).length];
|
|
37
|
+
const lost = heldOf(base).filter(n => !heldOf(next).includes(n));
|
|
38
|
+
const why = !(sevOf(next) < sevOf(base) - 1e-9) ? `summed severity ${sev[0]} -> ${sev[1]}`
|
|
39
|
+
: failedOf(next) > failedOf(base) || lost.length ? `goal ${lost.length ? `"${lost[0]}" no longer holds` : "failures rose"}`
|
|
40
|
+
: problems[1] > problems[0] ? `problems ${problems[0]} -> ${problems[1]}`
|
|
41
|
+
: rise > TRADE * drop + 1e-9 ? `${worse[0]?.key} got worse (${worse[0]?.severity.join(" -> ")})` : "";
|
|
42
|
+
return { better: !why, why, sev, problems, worse: worse.slice(0, 3) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** next is no worse than base: no more problems (at as many, the summed severity does not rise), no more goals fail, none that held is lost. */
|
|
46
|
+
export const noWorse = (next, base) => (groupsOf(next).length < groupsOf(base).length || (groupsOf(next).length === groupsOf(base).length && sevOf(next) <= sevOf(base) + 1e-9))
|
|
47
|
+
&& failedOf(next) <= failedOf(base) && heldOf(base).every(n => heldOf(next).includes(n));
|
|
48
|
+
|
|
49
|
+
/** Severity of the group with key `key` in qa (0 when it is gone). */
|
|
50
|
+
export const severityIn = (qa, key) => groupsOf(qa).filter(g => groupKey(g) === key).reduce((s, g) => s + (g.severity ?? 0), 0);
|
package/src/motion3/refs.js
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
// anchor:<name> a spec anchor, resolved as the compiler resolves it
|
|
6
6
|
// landmark:mouth the measured face landmark
|
|
7
7
|
// person:<id>.<Bone> another character's joint where its installed take has it (people.js)
|
|
8
|
-
// object:<id>[.center|.top|.base]
|
|
9
|
-
// top-face centre or bottom-face centre of the AABB of its
|
|
10
|
-
// part boxes' corners (default center)
|
|
8
|
+
// object:<id>[.center|.top|.base|.rim] a body or a group (object-pose.js objectBoxesAt): the centre,
|
|
9
|
+
// top-face centre or bottom-face centre of the world AABB of its
|
|
10
|
+
// part boxes' corners (default center); .rim is its opening (a cup's,
|
|
11
|
+
// a can's): the top face of its highest part in the part's own axes,
|
|
12
|
+
// so it tilts with it, and measured against another point the edge of
|
|
13
|
+
// that face nearest the point (where a lip meets a cup). A tilted cup's
|
|
14
|
+
// .top is the box's highest corner height over its middle, not its rim
|
|
11
15
|
// Frames are absolute; a frame outside the compiled window (out.f0 ..) answers null.
|
|
12
16
|
import { REF_KINDS } from "./schema/fields.js";
|
|
13
17
|
import { resolveBone } from "./bones.js";
|
|
@@ -20,14 +24,14 @@ import { add, apply, sub, len } from "./compiler/math.js";
|
|
|
20
24
|
import { sdBox } from "./qa/geometry.js";
|
|
21
25
|
import { personJoint, peopleList } from "./people.js";
|
|
22
26
|
|
|
23
|
-
const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base]", person: "person:<id>.<Bone>" };
|
|
27
|
+
const SYNTAX = { joint: "joint:<Bone>", bone: "bone:<Bone>", prop: "prop:<prop>.<point>", anchor: "anchor:<name>", landmark: "landmark:mouth", object: "object:<id>[.center|.top|.base|.rim]", person: "person:<id>.<Bone>" };
|
|
24
28
|
|
|
25
29
|
function parse(ref) {
|
|
26
30
|
const m = /^([a-z]+):(.+)$/s.exec(String(ref));
|
|
27
31
|
if (!m) return null;
|
|
28
32
|
const [, kind, rest] = m;
|
|
29
33
|
if (kind === "prop") { const [name, point] = rest.split("."); return { kind, name, point: point ?? "" }; }
|
|
30
|
-
if (kind === "object") { const o = /^(.+)\.(center|top|base)$/s.exec(rest); return { kind, name: o ? o[1] : rest, point: o ? o[2] : null }; }
|
|
34
|
+
if (kind === "object") { const o = /^(.+)\.(center|top|base|rim)$/s.exec(rest); return { kind, name: o ? o[1] : rest, point: o ? o[2] : null }; }
|
|
31
35
|
if (kind === "person") { const dot = rest.lastIndexOf("."); return { kind, name: dot > 0 ? rest.slice(0, dot) : rest, point: dot > 0 ? rest.slice(dot + 1) : "" }; }
|
|
32
36
|
return { kind, name: rest, point: null };
|
|
33
37
|
}
|
|
@@ -42,6 +46,18 @@ function boxesPoint(boxes, which) {
|
|
|
42
46
|
return which === "top" ? [c[0], hi[1], c[2]] : which === "base" ? [c[0], lo[1], c[2]] : c;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** The opening of a body or group's boxes: { o, n, r }, the top face of the highest part in its own axes (centre, axis, the smaller half width). */
|
|
50
|
+
export function openingOf(boxes) {
|
|
51
|
+
const tops = (boxes ?? []).map(b => ({ o: add(b.pos, apply(b.R, [0, 2 * b.half[1], 0])), n: [b.R[0][1], b.R[1][1], b.R[2][1]], r: Math.min(b.half[0], b.half[2]) }));
|
|
52
|
+
return tops.length ? tops.reduce((p, q) => (q.o[1] > p.o[1] ? q : p)) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The point of an opening's edge nearest p (its centre when p is on the axis). */
|
|
56
|
+
function rimNear(op, p) {
|
|
57
|
+
const v = sub(p, op.o), along = v[0] * op.n[0] + v[1] * op.n[1] + v[2] * op.n[2], across = sub(v, op.n.map(x => x * along)), d = len(across);
|
|
58
|
+
return d > 1e-6 ? add(op.o, across.map(x => (x * op.r) / d)) : op.o;
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
/** World point [x, y, z] of `ref` at absolute frame f of compile result out, or null. */
|
|
46
62
|
export function refPoint(out, f, ref) {
|
|
47
63
|
const fr = out.frames?.[f - (out.f0 ?? 0)], r = parse(ref);
|
|
@@ -52,7 +68,7 @@ export function refPoint(out, f, ref) {
|
|
|
52
68
|
case "prop": { const p = diag.props?.[r.name]?.[r.point]; return Array.isArray(p) ? p.slice() : null; }
|
|
53
69
|
case "anchor": return out.ctx.spec.anchors?.[r.name] ? resolveAnchor(out.ctx, r.name, s, { ...diag, frame: f }, c) : null;
|
|
54
70
|
case "landmark": return sk.landmarks?.[r.name] ? bonePoint(s, sk.index.Head, sk.landmarks[r.name]) : null;
|
|
55
|
-
case "object": { const boxes = objectBoxesAt(out, r.name, f); return boxes ? boxesPoint(boxes, r.point) : null; }
|
|
71
|
+
case "object": { const boxes = objectBoxesAt(out, r.name, f); return boxes ? (r.point === "rim" ? openingOf(boxes).o.slice() : boxesPoint(boxes, r.point)) : null; }
|
|
56
72
|
case "person": return personJoint(out.ctx.scene, r.name, r.point, f);
|
|
57
73
|
default: return null;
|
|
58
74
|
}
|
|
@@ -71,25 +87,33 @@ export function refDistance(out, f, a, b) {
|
|
|
71
87
|
const p = refPoint(out, f, surf[0]), boxes = objectBoxesAt(out, surf[1].name, f);
|
|
72
88
|
return p && boxes ? Math.max(0, Math.min(...boxes.map(box => sdBox(p, box)))) : null;
|
|
73
89
|
}
|
|
90
|
+
// an opening against another point: the edge of it nearest that point
|
|
91
|
+
const rim = [a, b].map(ref => { const r = parse(ref); return r?.kind === "object" && r.point === "rim" ? r : null; });
|
|
92
|
+
if (rim[0] || rim[1]) {
|
|
93
|
+
const [own, other] = rim[0] ? [rim[0], b] : [rim[1], a], p = refPoint(out, f, other), op = openingOf(objectBoxesAt(out, own.name, f));
|
|
94
|
+
return p && op ? len(sub(p, rimNear(op, p))) : null;
|
|
95
|
+
}
|
|
74
96
|
const p = refPoint(out, f, a), q = refPoint(out, f, b);
|
|
75
97
|
return p && q ? len(sub(p, q)) : null;
|
|
76
98
|
}
|
|
77
99
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function bareHint(spec, { sk, scene }, ref, kinds) {
|
|
83
|
-
if (typeof ref !== "string" || !ref) return "";
|
|
84
|
-
const id = ref.replace(/\.(center|top|base)$/, ""), [prop, point] = ref.split(".");
|
|
100
|
+
/** The prefixed refs a bare name could mean in this spec, rig and scene (lenient.js takes the one when there is one). */
|
|
101
|
+
export function bareRefs(spec, { sk, scene }, ref, kinds = REF_KINDS) {
|
|
102
|
+
if (typeof ref !== "string" || !ref) return [];
|
|
103
|
+
const id = ref.replace(/\.(center|top|base|rim)$/, ""), [prop, point] = ref.split(".");
|
|
85
104
|
const bone = sk && (kinds.includes("joint") || kinds.includes("bone")) ? resolveBone(sk, ref).bone : null;
|
|
86
|
-
|
|
105
|
+
return [
|
|
87
106
|
kinds.includes("object") && (scene?.objects?.[id] || partsOf(scene, id).length) && `object:${ref}`,
|
|
88
107
|
bone && `${kinds.includes("joint") ? "joint" : "bone"}:${bone}`,
|
|
89
108
|
kinds.includes("anchor") && spec.anchors?.[ref] && `anchor:${ref}`,
|
|
90
109
|
kinds.includes("prop") && spec.props?.[prop] && (point === "origin" || spec.props[prop].points?.[point]) && `prop:${ref}`,
|
|
91
110
|
kinds.includes("landmark") && ref === "mouth" && "landmark:mouth",
|
|
92
111
|
].filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `; did you mean "object:empty-2"?` for a bare name (bareRefs), or "". A field that takes more than one ref kind stays strict. */
|
|
115
|
+
function bareHint(spec, ctx, ref, kinds) {
|
|
116
|
+
const known = bareRefs(spec, ctx, ref, kinds);
|
|
93
117
|
return known.length ? `; did you mean ${known.map(k => `"${k}"`).join(" or ")}?` : "";
|
|
94
118
|
}
|
|
95
119
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The stop rule of the compile-repair loop (motion.compile): a short note in the answer when more
|
|
2
|
+
// compiles stopped paying. It never refuses a compile; the agent reads output.stuck and the prompt
|
|
3
|
+
// tells it to obey. The agent's own logs set the numbers: a compile is ~0.3 s but a repair step is
|
|
4
|
+
// ~1,250 output tokens of model time, a turn compiles median 8 / p90 17 times, a failure that survives
|
|
5
|
+
// two retries unchanged survives a third about a third of the time, and about a fifth of the
|
|
6
|
+
// compiles after a take first passes QA failed QA again (re-tweaking what already passed).
|
|
7
|
+
// polish an earlier compile of this character in this turn passed QA: say so, stop unless asked
|
|
8
|
+
// cap REPAIR_CAP compiles after the first: report what still fails on the best take
|
|
9
|
+
// stuck the same failing groups (or more) for STUCK_AFTER compiles in a row, neither fewer nor
|
|
10
|
+
// DELTA_STEP less severe: stop patching them (or re-applying their hints), start over,
|
|
11
|
+
// take another approach, or report what the best take still fails
|
|
12
|
+
// History is per character and agent turn (context.turnId); without a turn (MCP, tests) per character,
|
|
13
|
+
// with the cap counted along the base chain (a whole spec starts it again). In a turn the cap counts agent
|
|
14
|
+
// steps: the compiles of one tool call (context.callId; a script's nested ones share the script's) are one
|
|
15
|
+
// repair step, as a script that compares variants is one try; a compile with no call id is a step of its own.
|
|
16
|
+
// The 60 bench sessions: compiles counted one by one read 10-23 at the agent's 6-12th compiling call. The stuck streak
|
|
17
|
+
// counts the same steps: of the runs of 3 script compiles whose problems and issues did not drop (the SSE frames), 21 of
|
|
18
|
+
// 37 merged (48 of 71 base) lay inside one script, which compared variants; each step's last compile carries it on. A whole spec sent after a
|
|
19
|
+
// stuck note is the fresh start the note asked for, so the streak starts again too.
|
|
20
|
+
// A note never leaves the take empty: the agent obeys it by reporting, and a turn whose compiles all ran
|
|
21
|
+
// install: "ifClean" (or false) unclean ended with no take (5 of 30 bench runs). So the line keeps its best
|
|
22
|
+
// compile per placement (`at`, the last PLACES of them: a take plays in the character's placement frame,
|
|
23
|
+
// take.js, so it only meets the scene where it was baked) with its take, and a note on a compile that did
|
|
24
|
+
// not install itself answers `install`: the best take here, when the character carries no take or one the
|
|
25
|
+
// watch put on that this one beats. The caller installs it (no compile: the take is the one baked then).
|
|
26
|
+
// A take the agent installed (install: true, or an ifClean that installed) is never replaced for the rest
|
|
27
|
+
// of the line, nor one the character had before it (replay of merged r3 S8: the agent's own take was
|
|
28
|
+
// swapped for an earlier draft, so its report no longer named the character's take).
|
|
29
|
+
import { groupKey } from "./qa/severity.js";
|
|
30
|
+
|
|
31
|
+
const STUCK_AFTER = 3;
|
|
32
|
+
const REPAIR_CAP = 10;
|
|
33
|
+
const DELTA_STEP = 0.05;
|
|
34
|
+
const KEPT = 32;
|
|
35
|
+
// lines whose best takes are kept (the newest): a take is ~0.25 MB a 240-frame Y Bot take (rotations, 65 bones), so
|
|
36
|
+
// with PLACES each at most about 3 MB here; an older line keeps its notes but no take (its turn is over)
|
|
37
|
+
const TAKES = 4;
|
|
38
|
+
// placements per line whose best take is kept (S8 r1 moved the body 0.78 -> 0.88 -> 0.94 -> 0.88 m)
|
|
39
|
+
const PLACES = 3;
|
|
40
|
+
const SAY_CODES = 2;
|
|
41
|
+
|
|
42
|
+
const severity = groups => groups.reduce((s, g) => s + (Number.isFinite(g.severity) ? g.severity : 0), 0);
|
|
43
|
+
const superset = (a, b) => [...b].every(k => a.has(k));
|
|
44
|
+
const better = (a, b) => (a.ok !== b.ok ? a.ok : a.n !== b.n ? a.n < b.n : a.sev < b.sev);
|
|
45
|
+
const problems = n => `${n} problem${n === 1 ? "" : "s"}`;
|
|
46
|
+
const cut = s => (s.length > 40 ? `${s.slice(0, 39)}~` : s);
|
|
47
|
+
const named = c => `${c.specId} (${c.ok ? "QA passes" : problems(c.n)})`;
|
|
48
|
+
// a take plays in the character's placement frame: one the watch put on says so (a move misaligns it)
|
|
49
|
+
const POS = "bound to this position (recompile after a move)";
|
|
50
|
+
|
|
51
|
+
export function createRepairWatch() {
|
|
52
|
+
const lines = new Map();
|
|
53
|
+
const lineOf = key => {
|
|
54
|
+
let line = lines.get(key);
|
|
55
|
+
if (line) { lines.delete(key); lines.set(key, line); return line; }
|
|
56
|
+
// picks: the best compile per placement; held: the take the watch put on; mine: the agent's own last install
|
|
57
|
+
line = { compiles: 0, steps: 0, call: null, last: null, before: null, passed: null, best: null, picks: new Map(), held: null, mine: null, noted: false, depth: new Map() };
|
|
58
|
+
lines.set(key, line);
|
|
59
|
+
if (lines.size > KEPT) lines.delete(lines.keys().next().value);
|
|
60
|
+
return line;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* One compile's note. qa is the full QA (every group); base the specId it patched, absent for a whole
|
|
64
|
+
* spec; at the placement key of the compile (character pose and scene), take its baked take (kept while
|
|
65
|
+
* it is the best at `at`), installed whether this compile put its own take on the character, current the
|
|
66
|
+
* specId of the take the character carries now (after this compile).
|
|
67
|
+
* -> { say?, install?: { specId, take } } : install is the best take the caller puts on the character.
|
|
68
|
+
* callId the agent's tool call the compile ran in, or null.
|
|
69
|
+
* @param {{ characterId: string, turnId?: string | null, callId?: string | null, specId: string, base?: string, qa: { ok: boolean, groups: object[] },
|
|
70
|
+
* at?: string, take?: unknown, installed?: boolean, current?: string | null }} c
|
|
71
|
+
*/
|
|
72
|
+
function note({ characterId, turnId = null, callId = null, specId, base, qa, at = "", take, installed = false, current = null }) {
|
|
73
|
+
const line = lineOf(`${characterId}\u0000${turnId ?? ""}`), groups = qa.groups ?? [];
|
|
74
|
+
const now = { specId, at, ok: Boolean(qa.ok), fails: new Set(groups.map(groupKey)), n: groups.length, sev: severity(groups), streak: 1 };
|
|
75
|
+
// core: the failures the whole streak shares (its first compile's; the later ones hold them all)
|
|
76
|
+
now.core = now.fails;
|
|
77
|
+
// a step: one agent call (its compiles share callId) or one compile; the streak compares with the last compile of the
|
|
78
|
+
// step before, so the variants one script compares are one try, not three
|
|
79
|
+
const step = !callId || callId !== line.call;
|
|
80
|
+
if (step) line.before = line.last;
|
|
81
|
+
const prev = line.before, fresh = base === undefined && line.noted;
|
|
82
|
+
if (prev && !fresh && now.fails.size && prev.fails.size && superset(now.fails, prev.fails) && now.n >= prev.n && now.sev > prev.sev * (1 - DELTA_STEP))
|
|
83
|
+
Object.assign(now, { streak: prev.streak + 1, core: prev.core });
|
|
84
|
+
line.compiles += 1;
|
|
85
|
+
if (step) line.steps += 1;
|
|
86
|
+
line.call = callId;
|
|
87
|
+
const depth = base === undefined ? 0 : (line.depth.get(base) ?? 0) + 1;
|
|
88
|
+
line.depth.set(specId, depth);
|
|
89
|
+
const repairs = turnId ? line.steps - 1 : depth, passed = line.passed, best = line.best;
|
|
90
|
+
line.last = now;
|
|
91
|
+
if (now.ok && !line.passed) line.passed = specId;
|
|
92
|
+
if (!best || better(now, best)) line.best = now;
|
|
93
|
+
// the best take at this placement (each of the last PLACES keeps its own), and the agent's own install
|
|
94
|
+
let pick = line.picks.get(at);
|
|
95
|
+
if (!pick || better(now, pick)) {
|
|
96
|
+
pick = { ...now, take };
|
|
97
|
+
let older = lines.size - TAKES;
|
|
98
|
+
for (const other of lines.values()) if (older-- > 0) for (const p of other.picks.values()) p.take = undefined;
|
|
99
|
+
}
|
|
100
|
+
line.picks.delete(at);
|
|
101
|
+
line.picks.set(at, pick);
|
|
102
|
+
if (line.picks.size > PLACES) line.picks.delete(line.picks.keys().next().value);
|
|
103
|
+
if (installed) line.mine = now;
|
|
104
|
+
const past = c => (best && best.specId !== c.specId && better(best, c) ? `; best so far ${named(best)}` : "");
|
|
105
|
+
const kind = passed && passed !== specId ? "polish" : repairs >= REPAIR_CAP && !now.ok ? "cap" : now.streak >= STUCK_AFTER ? "stuck" : null;
|
|
106
|
+
line.noted = Boolean(kind) && !passed;
|
|
107
|
+
if (!kind) return {};
|
|
108
|
+
// the take to leave on the character: this compile's, or the agent's own, when it carries it; else fill an empty
|
|
109
|
+
// take, or upgrade the one the watch put on, with the best here; a take from before this line stays
|
|
110
|
+
const held = line.held, mine = line.mine && line.mine.specId === current, watched = held && held.specId === current;
|
|
111
|
+
const keeps = !installed && !mine && watched && held.at === at && !better(pick, held);
|
|
112
|
+
const fills = !installed && !mine && !keeps && (current === null || watched) && pick.take !== undefined;
|
|
113
|
+
const install = fills ? { specId: pick.specId, take: pick.take } : undefined;
|
|
114
|
+
if (install) line.held = pick;
|
|
115
|
+
// what the character carries after the note, in one sentence; then what to do (a cap note at most ~180 characters)
|
|
116
|
+
const status = installed ? `This take is installed${past(now)}.` : install ? `Installed the best take here, ${named(pick)}, ${POS}.`
|
|
117
|
+
: mine ? `Your installed take ${named(line.mine)} stays${past(line.mine)}.` : keeps ? `${named(held)} stays installed, ${POS}.`
|
|
118
|
+
: current !== null && pick.take !== undefined ? `The earlier take stays; to replace it, install the best take here, ${named(pick)}.`
|
|
119
|
+
: `Install the best take here, ${named(pick)}.`;
|
|
120
|
+
const asked = "stop unless the user asked for a specific change";
|
|
121
|
+
let say;
|
|
122
|
+
if (kind === "polish") say = !install ? `${passed} already passed QA: ${asked}.` : install.specId === passed ? `${passed} passed QA and is now installed, ${POS}: ${asked}.`
|
|
123
|
+
: `${passed} passed QA at another position; the best take here, ${named(pick)}, is installed, ${POS}. Report its problems; ${asked}.`;
|
|
124
|
+
else if (kind === "cap") say = `${repairs} repair ${turnId && callId ? "calls" : "compiles"}. ${status} Report its problems; stop unless asked for more.`;
|
|
125
|
+
else {
|
|
126
|
+
const codes = [...now.core], names = codes.slice(0, SAY_CODES).map(cut).join(", ") + (codes.length > SAY_CODES ? ` +${codes.length - SAY_CODES}` : "");
|
|
127
|
+
say = `${names} unchanged over ${now.streak} ${turnId && callId ? "calls" : "compiles"}. Stop patching it or re-applying its hint: start afresh or try another approach. ${status} If neither helps, report its problems.`;
|
|
128
|
+
}
|
|
129
|
+
return { say, ...(install ? { install } : {}) };
|
|
130
|
+
}
|
|
131
|
+
return { note };
|
|
132
|
+
}
|