cozyclay 3.1.4 → 3.1.5
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 +86 -0
- package/INSTALL_FOR_AGENTS.md +3 -0
- package/README.md +5 -0
- package/bin/agent/agent-failure-codes.mjs +31 -0
- package/bin/agent/agent-routes.mjs +5 -10
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/cozyclay.mjs +16 -64
- package/bin/studio-routes.mjs +69 -0
- package/bin/update-check.mjs +53 -5
- package/bin/update-install.mjs +136 -0
- package/bin/update-routes.mjs +80 -0
- package/dist/ai-camera-control/index.html +1 -1
- package/dist/app/index.html +5 -6
- package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
- package/dist/assets/app-CEP7jzXB.css +1 -0
- package/dist/assets/app-CTKcyPM-.js +5144 -0
- package/dist/assets/compile-worker-or7djI2v.js +6 -0
- package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
- package/dist/assets/task-board-CkLZGPkv.js +15 -0
- package/dist/assets/workflow-DJwOUzqJ.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +3 -3
- package/dist/index.html +533 -264
- package/dist/media/cozyclay-mark.png +0 -0
- package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
- package/dist/media/step-move-poster.jpg +0 -0
- package/dist/media/step-shoot-poster.jpg +0 -0
- package/dist/media/step-stage-poster.jpg +0 -0
- package/dist/media/truck-greybox-poster.jpg +0 -0
- package/dist/media/truck-seedance-poster.jpg +0 -0
- package/dist/previs-software/index.html +4 -4
- package/dist/privacy/index.html +46 -19
- package/dist/seedance-camera-control/index.html +3 -3
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +3 -4
- package/package.json +1 -1
- package/src/App.jsx +59 -70
- package/src/analytics-controls.js +17 -0
- package/src/analytics-friction.js +124 -0
- package/src/analytics-strict.js +139 -0
- package/src/analytics-values.js +49 -0
- package/src/analytics.js +37 -133
- package/src/asset-pane.jsx +4 -5
- package/src/click-friction.js +101 -0
- package/src/command-bus-telemetry.js +155 -0
- package/src/command-bus.js +10 -8
- package/src/commands/motion3.js +7 -2
- package/src/controls.jsx +3 -2
- package/src/crash-telemetry.js +45 -0
- package/src/domains/cast.js +2 -11
- package/src/domains/motion.js +49 -38
- package/src/domains/objects.js +3 -0
- package/src/domains/scene-undo.js +27 -0
- package/src/domains/scenes.js +1 -1
- package/src/error-boundary.jsx +2 -0
- package/src/export-attempt.js +100 -0
- package/src/export-workspace.js +12 -1
- package/src/first-success-guide.jsx +11 -7
- package/src/guide-telemetry.js +60 -0
- package/src/hierarchy-panel.jsx +2 -1
- package/src/live-control.js +2 -9
- package/src/locale-zh/domains.js +1 -0
- package/src/main.jsx +5 -0
- package/src/motion/timeline.jsx +9 -5
- package/src/motion-draft-telemetry.js +62 -0
- package/src/motion3/compiler/channels.js +7 -5
- package/src/motion3/compiler/compile.js +6 -2
- package/src/motion3/compiler/finger-close.js +84 -7
- package/src/motion3/compiler/head-lead.js +70 -0
- package/src/motion3/compiler/head-steady.js +1 -1
- package/src/motion3/compiler/limbs.js +10 -3
- package/src/motion3/compiler/pose.js +1 -1
- package/src/motion3/compiler/validate.js +5 -3
- package/src/motion3/pipeline.js +4 -0
- package/src/motion3/qa/explain.js +2 -1
- package/src/motion3/qa/finger-checks.js +80 -0
- package/src/motion3/qa/gait-observe.js +88 -0
- package/src/motion3/qa/grasp-checks.js +3 -0
- package/src/motion3/qa/hints-grasp.js +8 -0
- package/src/motion3/qa/index.js +2 -1
- package/src/motion3/qa/motion-life.js +26 -3
- package/src/motion3/schema/topics.js +1 -1
- package/src/playback-telemetry.js +107 -0
- package/src/post-turn-edits.js +62 -0
- package/src/search-friction.js +49 -0
- package/src/semantic-edit.js +19 -6
- package/src/shell/BoardSlot.jsx +3 -3
- package/src/shell/BottomDock.jsx +1 -1
- package/src/shell/DetailsSlot.jsx +1 -1
- package/src/shell/MenuBar.jsx +13 -13
- package/src/shell/StudioShell.jsx +7 -1
- package/src/shell/TopBar.jsx +2 -0
- package/src/shell/ViewportToolbar.jsx +2 -2
- package/src/shortcut-friction.js +122 -0
- package/src/stage-canvas-rigs.jsx +3 -4
- package/src/studio-telemetry.js +97 -0
- package/src/thrash-friction.js +90 -0
- package/src/update/UpdateChip.jsx +86 -0
- package/src/update/UpdateDialog.jsx +147 -0
- package/src/update/update-client.js +188 -0
- package/src/update/update-telemetry.js +78 -0
- package/src/update/update.css +94 -0
- package/src/use-webgl-available.js +35 -0
- package/src/workflow/AgentPanel.jsx +29 -30
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +15 -5
- package/src/workflow/agent-client.js +10 -91
- package/src/workflow/agent-failure-codes.js +47 -0
- package/src/workflow/agent-turn-telemetry.js +222 -0
- package/src/workflow/connect-telemetry.js +86 -0
- package/src/workflow/prompt-telemetry.js +105 -0
- package/tools/dev/pages/privacy.html +44 -17
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-BdRlMf4J.css +0 -1
- package/dist/assets/app-KXdgBC-S.js +0 -5144
- package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
- package/dist/assets/landing-y3EqDgSZ.js +0 -1
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
- package/dist/assets/task-board-DFfNa0xg.js +0 -15
- package/dist/assets/workflow-BMJGd_gZ.js +0 -179
- package/dist/media/playground-poster.jpg +0 -0
|
@@ -164,17 +164,24 @@ const FINGER_PRESETS = {
|
|
|
164
164
|
// thumb side (+), by preset weight; the index leads the thumb round one side, the little finger the other.
|
|
165
165
|
const SPREAD = { spherical: { Index: 9, Middle: 2, Ring: -5, Pinky: -12 } };
|
|
166
166
|
|
|
167
|
+
// Two presets keyed at 1 together (a reach's grip over a held fist) would add their curls: 137 deg at the PIP. Past a
|
|
168
|
+
// sum of 1 the weights share it, so a blend never curls further than its strongest preset.
|
|
169
|
+
function shares(weights) {
|
|
170
|
+
const sum = Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (p === "relaxed" ? 0 : w || 0), 0);
|
|
171
|
+
return sum > 1 ? Object.fromEntries(Object.entries(weights).map(([p, w]) => [p, p === "relaxed" ? w : (w || 0) / sum])) : weights;
|
|
172
|
+
}
|
|
173
|
+
|
|
167
174
|
/** Degrees phalanx k (0..2) of finger f curls at preset weights `weights` (blended over relaxed). */
|
|
168
175
|
export function fingerCurl(weights, f, k) {
|
|
169
176
|
const base = FINGER_PRESETS.relaxed;
|
|
170
|
-
return base[f][k] + Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * ((FINGER_PRESETS[p]?.[f]?.[k] ?? base[f][k]) - base[f][k]), 0);
|
|
177
|
+
return base[f][k] + Object.entries(shares(weights) ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * ((FINGER_PRESETS[p]?.[f]?.[k] ?? base[f][k]) - base[f][k]), 0);
|
|
171
178
|
}
|
|
172
179
|
/** The fist's curl of phalanx k of finger f: as far as a finger closes. */
|
|
173
180
|
export const fistCurl = (f, k) => FINGER_PRESETS.fist[f][k];
|
|
174
181
|
|
|
175
182
|
/** Blend presets by weight over `relaxed` and curl every finger toward the palm. */
|
|
176
|
-
export function fingers(sk, s, side,
|
|
177
|
-
const sign = side === "Left" ? -1 : 1, curl = (f, k) => fingerCurl(weights, f, k);
|
|
183
|
+
export function fingers(sk, s, side, given) {
|
|
184
|
+
const weights = shares(given), sign = side === "Left" ? -1 : 1, curl = (f, k) => fingerCurl(weights, f, k);
|
|
178
185
|
for (const f of ["Index", "Middle", "Ring", "Pinky"]) {
|
|
179
186
|
const spread = Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * (SPREAD[p]?.[f] ?? 0), 0), j = sk.index[`${side}Hand${f}1`];
|
|
180
187
|
if (spread && j !== undefined) rotateSubtree(sk, s, j, s.P[j].slice(), axisRot(palmAxes(sk, s, side).n, spread));
|
|
@@ -165,7 +165,7 @@ export function pose(sk, c, hooks = {}) {
|
|
|
165
165
|
if (fold > ELBOW_FOLD && !hooks.foldFree) diag[`arm_fold_${k}`] = fold;
|
|
166
166
|
}
|
|
167
167
|
wrist(sk, s, side, num(c[`wtwist_${k}`]), num(c[`wflex_${k}`]));
|
|
168
|
-
fingers(sk, s, side, c[`fingers_${k}`]);
|
|
168
|
+
fingers(sk, s, side, hooks.fingerWeights?.(k) ?? c[`fingers_${k}`]);
|
|
169
169
|
const held = hooks.hold?.(k, s);
|
|
170
170
|
// a keyed carry's hand follows its object; else a fit's hand takes a grip on the body under its anchor (grip.js),
|
|
171
171
|
// and while the keys only ease in (before the first key) the arm they ease from takes it too
|
|
@@ -107,14 +107,16 @@ function validateKeys(spec, err, frames, unknown, past = "") {
|
|
|
107
107
|
* checked when given.
|
|
108
108
|
* lenient (a saved take compiled again, take-source.js) ignores unknown fields.
|
|
109
109
|
*/
|
|
110
|
-
/** spec.flow: false, or { smooth?: boolean, lag?: false | 0..6 frames | { spine?, head?, arms? }, overlap?: boolean | 0..2 } (channels.js, overlap.js). */
|
|
110
|
+
/** spec.flow: false, or { smooth?: boolean, lag?: false | 0..6 frames | { spine?, head?, arms? }, overlap?: boolean | 0..2, headLead?: boolean | 0..12 frames } (channels.js, overlap.js, head-lead.js). */
|
|
111
111
|
function validateFlow(flow, err, unknown) {
|
|
112
112
|
if (flow === undefined || flow === false) return;
|
|
113
|
-
if (!flow || typeof flow !== "object" || Array.isArray(flow)) return err(`spec.flow must be false or { smooth?, lag?, overlap? }, got ${fmt(flow)}`);
|
|
114
|
-
for (const k of Object.keys(flow)) if (!["smooth", "lag", "overlap"].includes(k)) unknown(`spec.flow has unknown field "${k}" (fields: smooth, lag, overlap)`);
|
|
113
|
+
if (!flow || typeof flow !== "object" || Array.isArray(flow)) return err(`spec.flow must be false or { smooth?, lag?, overlap?, headLead? }, got ${fmt(flow)}`);
|
|
114
|
+
for (const k of Object.keys(flow)) if (!["smooth", "lag", "overlap", "headLead"].includes(k)) unknown(`spec.flow has unknown field "${k}" (fields: smooth, lag, overlap, headLead)`);
|
|
115
115
|
if (flow.smooth !== undefined && typeof flow.smooth !== "boolean") err(`spec.flow.smooth must be true or false, got ${fmt(flow.smooth)}`);
|
|
116
116
|
const o = flow.overlap;
|
|
117
117
|
if (o !== undefined && typeof o !== "boolean" && !(Number.isFinite(o) && o >= 0 && o <= 2)) err(`spec.flow.overlap must be true, false or a scale 0..2, got ${fmt(o)}`);
|
|
118
|
+
const h = flow.headLead;
|
|
119
|
+
if (h !== undefined && typeof h !== "boolean" && !(Number.isInteger(h) && h >= 0 && h <= 12)) err(`spec.flow.headLead must be true, false or 0..12 whole frames, got ${fmt(h)}`);
|
|
118
120
|
const frames = v => Number.isFinite(v) && v >= 0 && v <= 6, lag = flow.lag, LAG = "false, 0..6 frames or { spine?, head?, arms? }";
|
|
119
121
|
if (lag === undefined || lag === false || frames(lag)) return;
|
|
120
122
|
if (!lag || typeof lag !== "object" || Array.isArray(lag)) return err(`spec.flow.lag must be ${LAG}, got ${fmt(lag)}`);
|
package/src/motion3/pipeline.js
CHANGED
|
@@ -32,6 +32,7 @@ import { observeNear, NEAR_UNITS } from "./qa/near.js";
|
|
|
32
32
|
import { observeWrist, WRIST_UNITS } from "./qa/wrist-obs.js";
|
|
33
33
|
import { observeLoad, LOAD_UNITS } from "./load.js";
|
|
34
34
|
import { observeLife, LIFE_UNITS } from "./qa/motion-life.js";
|
|
35
|
+
import { observeGait, GAIT_UNITS } from "./qa/gait-observe.js";
|
|
35
36
|
import { planWalks, applyWalkPlans } from "./walk-plan.js";
|
|
36
37
|
import { relaxSpec } from "./lenient.js";
|
|
37
38
|
import { observeAsked } from "./qa/asked.js";
|
|
@@ -128,6 +129,9 @@ function compilePrepared(prepared, { sk, scene }, opts, notes, refusals) {
|
|
|
128
129
|
const load = observeLoad(sk, out);
|
|
129
130
|
if (load) Object.assign(observations, { units: `${observations.units}; ${LOAD_UNITS}`, load });
|
|
130
131
|
Object.assign(observations, { units: `${observations.units}; ${LIFE_UNITS}`, life: observeLife(sk, out, resolved?.keys) });
|
|
132
|
+
const gait = observeGait(sk, out, out.keys);
|
|
133
|
+
// a walk too short to read is its steps alone, which need no units (the answer budget)
|
|
134
|
+
if (gait) Object.assign(observations, gait.cadence ? { units: `${observations.units}; ${GAIT_UNITS}`, gait } : { gait });
|
|
131
135
|
// a skill that paced its beats to fit a short span says so where every answer keeps it (C7 tier 1)
|
|
132
136
|
const paced = used.filter(u => u.paced).map(u => `${u.skill} paced ${u.paced.toFixed(2)} to fit f${u.f0}..f${u.end}`);
|
|
133
137
|
if (paced.length) observations.paced = paced;
|
|
@@ -10,9 +10,10 @@ import { goalHints } from "./hints-goal.js";
|
|
|
10
10
|
import { wristHints } from "./wrist-checks.js";
|
|
11
11
|
import { loadHints } from "../load.js";
|
|
12
12
|
import { graspHints } from "./hints-grasp.js";
|
|
13
|
+
import { fingerHints } from "./finger-checks.js";
|
|
13
14
|
import { attachPatches } from "./patch.js";
|
|
14
15
|
|
|
15
|
-
const HINTS = { ...contactHints, ...motionHints, ...objectHints, ...goalHints, ...wristHints, ...loadHints, ...graspHints };
|
|
16
|
+
const HINTS = { ...contactHints, ...motionHints, ...objectHints, ...goalHints, ...wristHints, ...loadHints, ...graspHints, ...fingerHints };
|
|
16
17
|
|
|
17
18
|
function keyTable(out) {
|
|
18
19
|
const byCh = new Map();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Is each finger a finger a hand can make? grasp-checks.js judges a finger only against what the hand holds: a phalanx
|
|
2
|
+
// into it, a fingertip off it. A finger bent backwards, curled past its range or lying in its neighbour passed while the
|
|
3
|
+
// other three tips still touched, and a hand with nothing in it was not judged at all (30 finger mutations of the can and
|
|
4
|
+
// ball takes: 16 passed). These checks read the pose alone, on every frame of either hand:
|
|
5
|
+
// FINGER_ROM "<Bone> bent back" / "<Bone> curled past range": a joint's flexion about the hand's knuckle line, each
|
|
6
|
+
// phalanx against the one before (the first against the palm's finger axis), outside its band in degrees
|
|
7
|
+
// (MCP, PIP, DIP; active ranges of motion: flexion 85-90 / 100-115 / 80-90, extension 30-45 / 0 / 20)
|
|
8
|
+
// FINGER_SELF "<Bone> into <Bone>": two phalanges of different fingers (the thumb aside: it lies on the index by
|
|
9
|
+
// design) closer than two phalanx radii less PEN, as FINGER_PEN measures a phalanx in a held thing
|
|
10
|
+
// A rig without finger joints is not judged.
|
|
11
|
+
import { add, sub, scale, dot, cross, norm } from "../compiler/math.js";
|
|
12
|
+
import { palmAxes } from "../compiler/limbs.js";
|
|
13
|
+
|
|
14
|
+
const PHALANX = 0.009, PEN = 0.004, SIDES = ["Left", "Right"], FINGERS = ["Index", "Middle", "Ring", "Pinky"];
|
|
15
|
+
// degrees per joint (1 MCP, 2 PIP, 3 DIP): [most extension, most flexion]
|
|
16
|
+
const ROM = [[-45, 100], [-5, 115], [-20, 90]];
|
|
17
|
+
const deg = r => (r * 180) / Math.PI;
|
|
18
|
+
|
|
19
|
+
/** Signed angle (deg) from a to b about unit axis, both projected on the plane normal to it. */
|
|
20
|
+
function turn(a, b, axis) {
|
|
21
|
+
const pa = norm(sub(a, scale(axis, dot(a, axis)))), pb = norm(sub(b, scale(axis, dot(b, axis))));
|
|
22
|
+
return deg(Math.atan2(dot(cross(pa, pb), axis), dot(pa, pb)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The flexion (deg) of joints 1..3 of finger f of hand S on pose s; null when the rig lacks the finger. */
|
|
26
|
+
export function fingerFlexion(sk, s, S, f) {
|
|
27
|
+
const J = [1, 2, 3, 4].map(i => sk.index[`${S}Hand${f}${i}`]);
|
|
28
|
+
if (J.some(j => j === undefined)) return null;
|
|
29
|
+
const { d, l } = palmAxes(sk, s, S), knuckle = scale(l, S === "Left" ? -1 : 1);
|
|
30
|
+
const bone = [0, 1, 2].map(i => norm(sub(s.P[J[i + 1]], s.P[J[i]])));
|
|
31
|
+
return [d, bone[0], bone[1]].map((prev, i) => turn(prev, bone[i], knuckle));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function segmentGap(p1, q1, p2, q2) {
|
|
35
|
+
const d1 = sub(q1, p1), d2 = sub(q2, p2), r = sub(p1, p2), a = dot(d1, d1), e = dot(d2, d2), f = dot(d2, r), c = dot(d1, r), b = dot(d1, d2);
|
|
36
|
+
const den = a * e - b * b, clamp = x => Math.min(1, Math.max(0, x));
|
|
37
|
+
let s = den > 1e-12 ? clamp((b * f - c * e) / den) : 0, t = e > 1e-12 ? (b * s + f) / e : 0;
|
|
38
|
+
if (t < 0) { t = 0; s = clamp(-c / a); } else if (t > 1) { t = 1; s = clamp((b - c) / a); }
|
|
39
|
+
return Math.hypot(...sub(add(p1, scale(d1, s)), add(p2, scale(d2, t))));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handIssues(sk, s, S, push, frame) {
|
|
43
|
+
for (const f of FINGERS) {
|
|
44
|
+
const flex = fingerFlexion(sk, s, S, f);
|
|
45
|
+
if (!flex) return;
|
|
46
|
+
flex.forEach((a, i) => {
|
|
47
|
+
const [lo, hi] = ROM[i], bone = `${S}Hand${f}${i + 1}`;
|
|
48
|
+
if (a < lo) push("FINGER_ROM", `${bone} bent back`, frame, -a, -lo);
|
|
49
|
+
else if (a > hi) push("FINGER_ROM", `${bone} curled past range`, frame, a, hi);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const P = name => s.P[sk.index[`${S}Hand${name}`]];
|
|
53
|
+
for (let i = 0; i < FINGERS.length; i++) for (let j = i + 1; j < FINGERS.length; j++) {
|
|
54
|
+
let worst = null;
|
|
55
|
+
for (let a = 1; a <= 3; a++) for (let b = 1; b <= 3; b++) {
|
|
56
|
+
const overlap = 2 * PHALANX - segmentGap(P(`${FINGERS[i]}${a}`), P(`${FINGERS[i]}${a + 1}`), P(`${FINGERS[j]}${b}`), P(`${FINGERS[j]}${b + 1}`));
|
|
57
|
+
if (overlap > PEN && (!worst || overlap > worst.overlap)) worst = { overlap, a, b };
|
|
58
|
+
}
|
|
59
|
+
if (worst) push("FINGER_SELF", `${S}Hand${FINGERS[i]}${worst.a} into ${S}Hand${FINGERS[j]}${worst.b}`, frame, worst.overlap, PEN);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** FINGER_ROM and FINGER_SELF issues of both hands on every frame (frames index out.frames, as every check's). */
|
|
64
|
+
export function fingerChecks(sk, out) {
|
|
65
|
+
const issues = [], push = (kind, msg, frame, value, limit) => issues.push({ kind, msg, frame, value: +value.toFixed(4), limit });
|
|
66
|
+
const sides = SIDES.filter(S => sk.index[`${S}Hand`] !== undefined);
|
|
67
|
+
out.frames.forEach((fr, i) => { for (const S of sides) handIssues(sk, fr.s, S, push, i); });
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const fingerHints = {
|
|
72
|
+
FINGER_ROM(X, g) {
|
|
73
|
+
const k = g.subject.startsWith("Left") ? "l" : "r";
|
|
74
|
+
return { cause: X.cause(`fingers_${k}`, g.worstFrame), hint: `fingers_${k}: a joint is past what a finger does (${g.subject}); keep the preset weights summing to at most 1 and do not turn a phalanx past its curl` };
|
|
75
|
+
},
|
|
76
|
+
FINGER_SELF(X, g) {
|
|
77
|
+
const k = g.subject.startsWith("Left") ? "l" : "r";
|
|
78
|
+
return { cause: X.cause(`fingers_${k}`, g.worstFrame), hint: `fingers_${k}: two fingers lie in each other (${g.subject}); give them one preset so they curl side by side` };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Measured, not judged (output.observations.gait): how a take's walks compare with a person's. Only inside the walks
|
|
2
|
+
// (start to last step, compiler/head-steady.js walkSpans, and the PAD frames after it where the last step lands). A
|
|
3
|
+
// heel strikes where its sole point comes down under QA's plant height (QA_LIMITS.plantH). From the strikes: the
|
|
4
|
+
// steps, the cadence (steps a minute), the step length and speed (the hips' travel strike to strike). Over each
|
|
5
|
+
// stride (a foot's strike to its next): the share of it that foot is on the ground (heel or ball planted), and the
|
|
6
|
+
// pelvis's peak-to-peak motion with the stride's own trend taken out (so a turn or a slope does not count): its turn
|
|
7
|
+
// (the hip line's yaw), its obliquity (the hip line's tilt), its rise and fall (the hips' height) and its side to side
|
|
8
|
+
// (the hips off the stride's line). Warnings past the ranges (RANGES) a person's walk keeps; never a QA group,
|
|
9
|
+
// never qa.ok. No walk, no gait; under MIN_STEPS steps only the steps (too few strides to read; the answer budget).
|
|
10
|
+
// Ranges: pelvis rotation 9.5 +-2 SD and obliquity 7.4 +-2 SD degrees (Bruening et al. 2017, 44 adults); cadence
|
|
11
|
+
// normal 100-115, warned outside 80-140; stance 60% of a stride; the hips' rise and fall 2.7-5 cm is provisional.
|
|
12
|
+
import { walkSpans } from "../compiler/head-steady.js";
|
|
13
|
+
import { groundSole } from "./ground.js";
|
|
14
|
+
import { QA_LIMITS } from "./checks.js";
|
|
15
|
+
|
|
16
|
+
const DEG = 180 / Math.PI, PAD = 12, MIN_STEPS = 4;
|
|
17
|
+
const RANGES = { cadence: [80, 140], stance: [0.55, 0.65], rotation: [3.7, 15.3], obliquity: [2.4, 12.4], vertical: [2.7, 5] };
|
|
18
|
+
const r = (x, d) => Math.round(x * 10 ** d) / 10 ** d;
|
|
19
|
+
|
|
20
|
+
export const GAIT_UNITS = "gait (walks, warnings not QA): cadence [steps/min, normal]; stepLength m; speed m/s; stance [L, R] stride share down; pelvis p-p a stride: rotation, obliquity deg, vertical (range provisional), lateral cm";
|
|
21
|
+
|
|
22
|
+
/** Peak to peak of xs[a..b] less the line from xs[a] to xs[b]. */
|
|
23
|
+
function swing(xs, a, b) {
|
|
24
|
+
let lo = Infinity, hi = -Infinity;
|
|
25
|
+
for (let f = a; f <= b; f++) {
|
|
26
|
+
const v = xs[f] - (xs[a] + ((xs[b] - xs[a]) * (f - a)) / (b - a));
|
|
27
|
+
lo = Math.min(lo, v); hi = Math.max(hi, v);
|
|
28
|
+
}
|
|
29
|
+
return hi - lo;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unwrap = xs => xs.reduce((o, v, i) => (o.push(i ? o[i - 1] + (v - xs[i - 1]) - 360 * Math.round((v - xs[i - 1]) / 360) : v), o), []);
|
|
33
|
+
const mean = xs => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
34
|
+
|
|
35
|
+
/** One stride [a, b]: the pelvis's rotation, obliquity (deg) and vertical, lateral (cm) peak to peak. */
|
|
36
|
+
function pelvisOf(hipLine, hips, a, b) {
|
|
37
|
+
const [ax, , az] = hips[a], dx = hips[b][0] - ax, dz = hips[b][2] - az, d = Math.hypot(dx, dz) || 1;
|
|
38
|
+
const side = hips.map(p => ((p[0] - ax) * dz - (p[2] - az) * dx) / d);
|
|
39
|
+
return [swing(hipLine.yaw, a, b), swing(hipLine.tilt, a, b), 100 * swing(hips.map(p => p[1]), a, b), 100 * swing(side, a, b)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** observeGait(sk, out, keys) -> observations.gait or null; keys are the compiled keys (walk.js tags their src). */
|
|
43
|
+
export function observeGait(sk, out, keys) {
|
|
44
|
+
const F = out.frames, n = F.length, f0 = out.f0 ?? 0, fps = out.fps ?? 24, I = sk.index;
|
|
45
|
+
// a retime keeps its keys on fractional frames (fix/retime.js): the spans are frame indices
|
|
46
|
+
const spans = walkSpans(keys ?? []).map(([a, b]) => [Math.max(0, Math.round(a - f0)), Math.min(n - 1, Math.round(b - f0) + PAD)]).filter(([a, b]) => b > a);
|
|
47
|
+
if (!spans.length) return null;
|
|
48
|
+
const sole = { Left: [], Right: [] };
|
|
49
|
+
for (const [a, b] of spans) for (let f = a; f <= b; f++) for (const S of ["Left", "Right"]) sole[S][f] ??= groundSole(out, f, S);
|
|
50
|
+
const hips = F.map(({ s }) => s.P[I.Hips]), line = F.map(({ s }) => { const p = s.P[I.LeftUpLeg], q = s.P[I.RightUpLeg]; return [q[0] - p[0], q[1] - p[1], q[2] - p[2]]; });
|
|
51
|
+
const hipLine = { yaw: unwrap(line.map(v => Math.atan2(v[0], v[2]) * DEG)), tilt: line.map(v => Math.atan2(v[1], Math.hypot(v[0], v[2])) * DEG) };
|
|
52
|
+
let steps = 0, time = 0, path = 0;
|
|
53
|
+
const stance = { Left: [], Right: [] }, pelvis = [];
|
|
54
|
+
for (const [a, b] of spans) {
|
|
55
|
+
const strikes = [];
|
|
56
|
+
for (const S of ["Left", "Right"]) {
|
|
57
|
+
const own = [];
|
|
58
|
+
for (let f = a + 1; f <= b; f++) if (sole[S][f].heel[1] < QA_LIMITS.plantH && sole[S][f - 1].heel[1] >= QA_LIMITS.plantH) own.push(f);
|
|
59
|
+
strikes.push(...own);
|
|
60
|
+
for (let i = 1; i < own.length; i++) {
|
|
61
|
+
const [s0, s1] = [own[i - 1], own[i]];
|
|
62
|
+
let down = 0;
|
|
63
|
+
for (let f = s0; f < s1; f++) down += Math.min(sole[S][f].heel[1], sole[S][f].ball[1]) < QA_LIMITS.plantH ? 1 : 0;
|
|
64
|
+
stance[S].push(down / (s1 - s0));
|
|
65
|
+
pelvis.push(pelvisOf(hipLine, hips, s0, s1));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
strikes.sort((x, y) => x - y);
|
|
69
|
+
if (strikes.length < 2) continue;
|
|
70
|
+
steps += strikes.length - 1;
|
|
71
|
+
time += (strikes.at(-1) - strikes[0]) / fps;
|
|
72
|
+
for (let i = 1; i < strikes.length; i++) path += Math.hypot(hips[strikes[i]][0] - hips[strikes[i - 1]][0], hips[strikes[i]][2] - hips[strikes[i - 1]][2]);
|
|
73
|
+
}
|
|
74
|
+
if (steps < MIN_STEPS || !pelvis.length) return { steps };
|
|
75
|
+
const cadence = (steps / time) * 60, st = ["Left", "Right"].map(S => (stance[S].length ? r(mean(stance[S]), 2) : null));
|
|
76
|
+
const [rotation, obliquity, vertical, lateral] = [0, 1, 2, 3].map(i => mean(pelvis.map(p => p[i])));
|
|
77
|
+
const warnings = [], warn = (name, v, [lo, hi]) => {
|
|
78
|
+
if (v < lo) warnings.push(`${name} ${v} < ${lo}`);
|
|
79
|
+
else if (v > hi) warnings.push(`${name} ${v} > ${hi}`);
|
|
80
|
+
};
|
|
81
|
+
warn("cadence", r(cadence, 1), RANGES.cadence);
|
|
82
|
+
st.forEach((v, i) => v !== null && warn(`stance ${"LR"[i]}`, v, RANGES.stance));
|
|
83
|
+
warn("rotation", r(rotation, 1), RANGES.rotation);
|
|
84
|
+
warn("obliquity", r(obliquity, 1), RANGES.obliquity);
|
|
85
|
+
warn("vertical", r(vertical, 2), RANGES.vertical);
|
|
86
|
+
return { steps, cadence: [r(cadence, 1), "100-115"], stepLength: r(path / steps, 3), speed: r(path / time, 2), stance: st,
|
|
87
|
+
pelvis: { rotation: r(rotation, 1), obliquity: r(obliquity, 1), vertical: r(vertical, 2), lateral: r(lateral, 2) }, warnings };
|
|
88
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// The fingers are phalanx capsules
|
|
10
10
|
// (PHALANX m) against the held object's shapes (shapes.js: a ball is round, not its box):
|
|
11
11
|
// FINGER_PEN "<Bone> into <id>": a phalanx's skin deeper than PEN in it
|
|
12
|
+
// THUMB_OFF "<side> thumb off <id>": a power or spherical hold whose fingers grip it with the thumb tip farther than GAP
|
|
12
13
|
// FINGER_GAP "<side> fingers off <id>": fewer than 3 of the 4 fingertips within GAP of its surface (pinch: the thumb
|
|
13
14
|
// and the index); the thumb and two fingers hold too (a tripod: a small ball the ring and little fingers do
|
|
14
15
|
// not reach)
|
|
@@ -96,6 +97,8 @@ function frameIssues(sk, s, h, entries, push, f) {
|
|
|
96
97
|
// a pinch holds between the thumb and the index; a small ball in a tripod (the thumb and two fingers); else three fingers
|
|
97
98
|
const gripped = h.grasp === "pinch" ? thumb && tipOn("Index") : fingersOn >= 3 || (thumb && fingersOn >= 2);
|
|
98
99
|
if (!gripped) push("FINGER_GAP", `${side} fingers off ${h.id}`, f, fingersOn + (thumb ? 1 : 0), h.grasp === "pinch" ? 2 : 3);
|
|
100
|
+
// a power or spherical hold closes the thumb against the fingers: three fingertips on the ball with the thumb out passed
|
|
101
|
+
else if (!thumb && (h.grasp === "power" || h.grasp === "spherical")) push("THUMB_OFF", `${side} thumb off ${h.id}`, f, -Math.max(...touch.filter(c => c.finger === "Thumb" && c.tip).map(c => c.depth)), GAP);
|
|
99
102
|
const on = touch.filter(c => c.depth >= -GAP && c.normal);
|
|
100
103
|
const c0 = scale(entries.reduce((acc, e) => add(acc, add(e.pos, apply(e.R, [0, e.half[1], 0]))), [0, 0, 0]), 1 / entries.length);
|
|
101
104
|
const under = on.filter(c => c.normal[1] <= BELOW).map(c => c.at);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Hints for the grasp kinds (#816, qa/grasp-checks.js, skills/catch.js): { cause, hint, fix? } as the other hints-*.js.
|
|
2
2
|
// FINGER_GAP fix.grasp false: the fingers stopped at the preset; fix /fix/grasp true. Else the hand toward the object
|
|
3
3
|
// FINGER_PEN fix.grasp false: the same fix. Else the hand out of it along its surface normal at the phalanx
|
|
4
|
+
// THUMB_OFF the fingers channel: a grip preset held on while the hand holds it (or the object nearer the palm)
|
|
4
5
|
// SUPPORT the carry names the grasp its shape takes (sphere: spherical, cylinder: power, else palm); with
|
|
5
6
|
// fix.grasp false, grasp back on first
|
|
6
7
|
// CATCH catchObject's f0 earlier by the frames the hand came late (the ball passed the catch point that much
|
|
@@ -41,6 +42,13 @@ export const graspHints = {
|
|
|
41
42
|
const into = parts.reduce((m, e) => (signedDistance(e, p) < signedDistance(m, p) ? e : m));
|
|
42
43
|
return moveHint(X, `hand_${k}`, f, scale(normalAt(into, p), g.worst - g.limit + MARGIN), ` (out of ${id})`);
|
|
43
44
|
},
|
|
45
|
+
THUMB_OFF(X, g) {
|
|
46
|
+
if (graspOff(X)) return turnOn;
|
|
47
|
+
const [, side, id] = /^(left|right) thumb off (\S+)$/.exec(g.subject) ?? [];
|
|
48
|
+
if (!id) return null;
|
|
49
|
+
const k = side === "left" ? "l" : "r";
|
|
50
|
+
return { cause: X.cause(`fingers_${k}`, g.worstFrame), hint: `${id}: the thumb is ${(g.worst * 100).toFixed(1)} cm off it; key fingers_${k} with the grip (or spherical) preset at 1 while the hand holds it, or set the hand so ${id} sits in the palm` };
|
|
51
|
+
},
|
|
44
52
|
SUPPORT(X, g) {
|
|
45
53
|
if (graspOff(X)) return turnOn;
|
|
46
54
|
const id = /^(\S+) not supported$/.exec(g.subject)?.[1], o = X.spec?.objects?.[id];
|
package/src/motion3/qa/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { styleChecks, floatChecks } from "./style.js";
|
|
|
9
9
|
import { specChecks } from "./spec-checks.js";
|
|
10
10
|
import { objectChecks } from "./object-checks.js";
|
|
11
11
|
import { graspChecks } from "./grasp-checks.js";
|
|
12
|
+
import { fingerChecks } from "./finger-checks.js";
|
|
12
13
|
import { loadChecks } from "../load.js";
|
|
13
14
|
import { goalChecks } from "./goals.js";
|
|
14
15
|
import { wristIssues } from "./wrist-checks.js";
|
|
@@ -39,7 +40,7 @@ function summarizeIssues(issues) {
|
|
|
39
40
|
export function runMotionQa(sk, out, spec, opts = {}) {
|
|
40
41
|
const checks = spec.checks ?? {}, goals = goalChecks(out, spec.goals ?? []);
|
|
41
42
|
const issues = [...generalChecks(sk, out, { allow: checks.allow, limits: checks.limits, mouthPoints: checks.mouthPoints }), ...specChecks(out, checks),
|
|
42
|
-
...objectChecks(sk, out, spec), ...graspChecks(sk, out), ...loadChecks(sk, out), ...floatChecks(out, checks),
|
|
43
|
+
...objectChecks(sk, out, spec), ...graspChecks(sk, out), ...fingerChecks(sk, out), ...loadChecks(sk, out), ...floatChecks(out, checks),
|
|
43
44
|
...styleChecks(sk, out, checks, { ...QA_LIMITS, ...checks.limits }), ...wristIssues(sk, out), ...peopleChecks(sk, out, checks), ...goals.issues];
|
|
44
45
|
if (out.f0) for (const i of issues) i.frame += out.f0;
|
|
45
46
|
const groups = summarizeIssues(issues);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Measured, not judged (output.observations.life): how mechanical a take reads.
|
|
1
|
+
// Measured, not judged (output.observations.life): how mechanical a take reads. Six cheap readings
|
|
2
2
|
// over the baked frames and the keys the agent wrote, one linear pass each, warnings only (never a
|
|
3
3
|
// QA group, never qa.ok):
|
|
4
4
|
// sync outside walks, the frame each body part (CHAINS) starts and stops moving (its activity,
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
// sides do the same thing on the same frames. score = the share of windows that twin.
|
|
19
19
|
// dead runs of a second or more with no joint moving over DEAD m a frame outside a still goal:
|
|
20
20
|
// a statue (idle false, or a pose held where the idle layer stays off).
|
|
21
|
+
// lead where the pelvis turns (its heading spans LEAD_TURN degrees or more: more than a walk's sway), the lag (frames, -LAG..LAG) that best correlates the head's
|
|
22
|
+
// world yaw change with the pelvis's: positive, the head turns first (a person's leads by about 5 frames at
|
|
23
|
+
// 24 fps, flow.headLead); [lag, correlation], or null without a turn. Read only, never warned.
|
|
21
24
|
// Activity is a part's own motion: the turn (deg/frame) of its local rotation, and for the hips
|
|
22
25
|
// their world turn or travel (cm/frame), so a hand carried by its arm does not count as moving.
|
|
23
26
|
// Why: compiled takes pass QA and still read mechanical (every part starts and stops on one frame,
|
|
@@ -34,9 +37,10 @@ const GRID_CV = 0.15, GRID_KEYS = 4, HALF_SHARE = 0.7;
|
|
|
34
37
|
const SPIKE = 4, JERK_FLOOR = 20;
|
|
35
38
|
const WALK_SPEED = 0.25, FOOT_STILL = 0.005, WINDOW = 48, TWIN = 0.9, TWIN_SWEEP = 15;
|
|
36
39
|
const DEAD = 1e-4;
|
|
40
|
+
const LAG = 10, LEAD_TURN = 15;
|
|
37
41
|
const r2 = x => Math.round(x * 100) / 100;
|
|
38
42
|
|
|
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]";
|
|
43
|
+
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]; lead [frames the head turns before the pelvis, correlation]";
|
|
40
44
|
|
|
41
45
|
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
46
|
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; };
|
|
@@ -206,6 +210,25 @@ function deadOf(sk, F, spec, fps) {
|
|
|
206
210
|
return { frames, best };
|
|
207
211
|
}
|
|
208
212
|
|
|
213
|
+
/** [lag, correlation] of the head's world yaw change against the pelvis's (lag > 0: the head first), null without a turn. */
|
|
214
|
+
function leadOf(sk, F) {
|
|
215
|
+
const I = sk.index, n = F.length, DEG = 180 / Math.PI, wrap = d => d - 360 * Math.round(d / 360);
|
|
216
|
+
const pelvis = F.map(({ s }) => { const a = s.P[I.LeftUpLeg], b = s.P[I.RightUpLeg]; return Math.atan2(b[0] - a[0], b[2] - a[2]) * DEG; });
|
|
217
|
+
const head = F.map(({ s }) => { const R = s.R[I.Head]; return Math.atan2(R[0][2], R[2][2]) * DEG; });
|
|
218
|
+
const dp = pelvis.map((v, f) => (f ? wrap(v - pelvis[f - 1]) : 0)), dh = head.map((v, f) => (f ? wrap(v - head[f - 1]) : 0));
|
|
219
|
+
let heading = 0, lo = 0, hi = 0;
|
|
220
|
+
for (const d of dp) { heading += d; lo = Math.min(lo, heading); hi = Math.max(hi, heading); }
|
|
221
|
+
if (n <= 4 * LAG || hi - lo < LEAD_TURN) return null;
|
|
222
|
+
let best = null;
|
|
223
|
+
for (let lag = -LAG; lag <= LAG; lag++) {
|
|
224
|
+
let ab = 0, aa = 0, bb = 0;
|
|
225
|
+
for (let f = LAG + 2; f < n - LAG - 2; f++) { ab += dp[f] * dh[f - lag]; aa += dp[f] ** 2; bb += dh[f - lag] ** 2; }
|
|
226
|
+
const c = aa > 0 && bb > 0 ? ab / Math.sqrt(aa * bb) : 0;
|
|
227
|
+
if (!best || c > best[1]) best = [lag, c];
|
|
228
|
+
}
|
|
229
|
+
return [best[0], r2(best[1])];
|
|
230
|
+
}
|
|
231
|
+
|
|
209
232
|
/**
|
|
210
233
|
* observeLife(sk, out, keys) -> observations.life; keys are the spec keys as the agent wrote them
|
|
211
234
|
* (a skill's keys are the compiler's). Frames are absolute (out.f0 added). flags names the metrics
|
|
@@ -237,7 +260,7 @@ export function observeLife(sk, out, keys) {
|
|
|
237
260
|
const at = (score, w) => (w ? [score, w[0] + f0, w[1] + f0] : [score]);
|
|
238
261
|
return {
|
|
239
262
|
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),
|
|
263
|
+
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), lead: leadOf(sk, F),
|
|
241
264
|
...(warnings.length ? { flags: warnings.map(w => w[1]), warn: warnings[0][2] } : {}),
|
|
242
265
|
};
|
|
243
266
|
}
|
|
@@ -103,7 +103,7 @@ export const NOTES = {
|
|
|
103
103
|
probes: ["motion.compile { probes } (at most 16); spec.probes are measured too, not stored; answered whether or not it installs.",
|
|
104
104
|
"distance [a, b] -> { min, minAt, max, maxAt, mean, first, last } in m; a bare object:<id> measures to its surface. position -> { at: [{ f, p }] }, at most 16 rows."],
|
|
105
105
|
channels: ["key.set names channels; values ease between keys and hold after the last key. step: [channel] jumps at f instead.",
|
|
106
|
-
"Flow (spec.flow, on by default): the spine, head, arm swing/bend and wrist carry their speed through a key (a small overshoot) and trail the body (spine 1, head 2, arms 3 frames; lag: { spine, head, arms } or a number); and each change breaks along the body (overlap: the lower spine, chest, head, upper arm, forearm and wrist start and settle a few frames apart, one side trailing the other, the arm's swing settling past its stop; less for a short move, none on an arm's IK or a walk); feet, root, hand targets, weights, poles, fingers and objects land on their keys on time. flow: false turns it off; { smooth: false }, { lag: false } or {
|
|
106
|
+
"Flow (spec.flow, on by default): the spine, head, arm swing/bend and wrist carry their speed through a key (a small overshoot) and trail the body (spine 1, head 2, arms 3 frames; lag: { spine, head, arms } or a number); and each change breaks along the body (overlap: the lower spine, chest, head, upper arm, forearm and wrist start and settle a few frames apart, one side trailing the other, the arm's swing settling past its stop; less for a short move, none on an arm's IK or a walk); feet, root, hand targets, weights, poles, fingers and objects land on their keys on time. The head leads the trunk into a turn (headLead: walking or standing, the head takes the turn the trunk makes over the next 5 frames, at most 30 degrees; not where keys move head_yaw). flow: false turns it off; { smooth: false }, { lag: false }, { overlap: false } or { headLead: false } one part (overlap: a number 0..2 scales it; headLead: 0..12 whole frames).",
|
|
107
107
|
"w_<anchor>_<side> (0..1) weights a hand onto an anchor; the weights of one hand add up to at most 1.",
|
|
108
108
|
"Any other numeric channel must come from spec.channels, a hinge (objects.<id>.channel), a check's when or a visible switch; names ending _vis are visibility switches (shown above 0.5)."],
|
|
109
109
|
refs: ["A ref names a point: joint:/bone:<Bone>, prop:<prop>.<point>, anchor:<name>, landmark:mouth, object:<id>[.center|.top|.base|.rim], person:<character id>.<Bone> (another character's joint in its installed take).",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// playback:summary: one event per playback run (play until stop, or until the fps or
|
|
2
|
+
// speed changes), at most 10 per session. `late_ticks` is an ESTIMATE of dropped
|
|
3
|
+
// frames: the share of playback-timer ticks that had to catch up more than one frame
|
|
4
|
+
// (createPlaybackClock steps > 1). It is not a measured render drop; `measured_fps`
|
|
5
|
+
// is the separate rAF sample taken during the same run. Every tick, past the cap too,
|
|
6
|
+
// feeds app:friction_summary.late_tick_ratio through noteTick as it happens, so the
|
|
7
|
+
// session-end snapshot is current even mid-run; a run still playing when the page
|
|
8
|
+
// goes away is ended on pagehide (its summary is sent best effort, like any unload
|
|
9
|
+
// event). Only closed buckets leave: never the scene, the take or the frame range.
|
|
10
|
+
import { analyticsActive, bucketFrameRate, track } from "./analytics.js";
|
|
11
|
+
import { admit, bucketLateTicks, noteTick } from "./analytics-friction.js";
|
|
12
|
+
import { bucketLong } from "./analytics-strict.js";
|
|
13
|
+
|
|
14
|
+
const RUNS_PER_SESSION = 10;
|
|
15
|
+
const IDLE_RUN = Object.freeze({ tick() {}, end() {} });
|
|
16
|
+
let send = track;
|
|
17
|
+
// The run still playing, ended on pagehide: React runs no effect cleanup on unload.
|
|
18
|
+
let activeRun = null, unloadListener = false;
|
|
19
|
+
const endActive = () => { try { activeRun?.end(); } catch { /* telemetry is advisory */ } };
|
|
20
|
+
|
|
21
|
+
/** 1, 2, 3-5, gte6: the most frames one timer tick had to catch up. */
|
|
22
|
+
export function bucketCatchup(steps) {
|
|
23
|
+
if (!(steps >= 2)) return "1";
|
|
24
|
+
if (steps < 3) return "2";
|
|
25
|
+
if (steps < 6) return "3-5";
|
|
26
|
+
return "gte6";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The take's own frame rate: 12, 24, 30, 60, else other. */
|
|
30
|
+
export function bucketPlaybackFps(fps) {
|
|
31
|
+
return [12, 24, 30, 60].includes(fps) ? String(fps) : "other";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Playback speed: 0.5, 1, 2, else other. */
|
|
35
|
+
export function bucketSpeed(speed) {
|
|
36
|
+
return [0.5, 1, 2].includes(speed) ? String(speed) : "other";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start measuring one playback run. The caller reports every timer tick's step
|
|
41
|
+
* count and calls end() when the run stops; end() is idempotent. While analytics
|
|
42
|
+
* is off the run is a no-op and no rAF sampler starts.
|
|
43
|
+
*/
|
|
44
|
+
export function startPlaybackRun(fps, speed, {
|
|
45
|
+
now = () => performance.now(),
|
|
46
|
+
raf = globalThis.requestAnimationFrame,
|
|
47
|
+
cancelRaf = globalThis.cancelAnimationFrame,
|
|
48
|
+
active = analyticsActive,
|
|
49
|
+
} = {}) {
|
|
50
|
+
try {
|
|
51
|
+
if (!active()) return IDLE_RUN;
|
|
52
|
+
const startedAt = now();
|
|
53
|
+
let ticks = 0, late = 0, maxCatchup = 0, frames = 0, firstFrame = null, lastFrame = null, frameId = null, ended = false;
|
|
54
|
+
const sample = (time) => {
|
|
55
|
+
if (ended) return;
|
|
56
|
+
if (firstFrame === null) firstFrame = time;
|
|
57
|
+
else frames += 1;
|
|
58
|
+
lastFrame = time;
|
|
59
|
+
frameId = raf(sample);
|
|
60
|
+
};
|
|
61
|
+
if (typeof raf === "function") frameId = raf(sample);
|
|
62
|
+
if (!unloadListener && typeof globalThis.addEventListener === "function") {
|
|
63
|
+
unloadListener = true;
|
|
64
|
+
globalThis.addEventListener("pagehide", endActive);
|
|
65
|
+
globalThis.addEventListener("beforeunload", endActive);
|
|
66
|
+
}
|
|
67
|
+
const run = {
|
|
68
|
+
tick(steps) {
|
|
69
|
+
if (ended || !Number.isFinite(steps) || steps < 0) return;
|
|
70
|
+
ticks += 1;
|
|
71
|
+
if (steps > 1) late += 1;
|
|
72
|
+
maxCatchup = Math.max(maxCatchup, steps);
|
|
73
|
+
noteTick(steps > 1 ? 1 : 0, 1);
|
|
74
|
+
},
|
|
75
|
+
end() {
|
|
76
|
+
if (ended) return;
|
|
77
|
+
ended = true;
|
|
78
|
+
if (activeRun === run) activeRun = null;
|
|
79
|
+
try {
|
|
80
|
+
if (frameId !== null && typeof cancelRaf === "function") cancelRaf(frameId);
|
|
81
|
+
if (!ticks) return;
|
|
82
|
+
const span = lastFrame - firstFrame;
|
|
83
|
+
const props = {
|
|
84
|
+
duration: bucketLong(now() - startedAt), late_ticks: bucketLateTicks(late, ticks),
|
|
85
|
+
max_catchup: bucketCatchup(maxCatchup), fps: bucketPlaybackFps(fps), speed: bucketSpeed(speed),
|
|
86
|
+
measured_fps: bucketFrameRate(frames > 0 && span > 0 ? (frames * 1000) / span : NaN),
|
|
87
|
+
};
|
|
88
|
+
if (admit("playback:summary", RUNS_PER_SESSION)) send("playback:summary", props);
|
|
89
|
+
} catch {
|
|
90
|
+
// Telemetry must never affect playback.
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
endActive();
|
|
95
|
+
activeRun = run;
|
|
96
|
+
return run;
|
|
97
|
+
} catch {
|
|
98
|
+
return IDLE_RUN;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tests only: capture what is sent; returns the restore. */
|
|
103
|
+
export function playbackTelemetrySeam(capture) {
|
|
104
|
+
const previous = send;
|
|
105
|
+
send = capture;
|
|
106
|
+
return () => { send = previous; };
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What the user changed right after an agent turn: agent:post_turn_edit, one per
|
|
2
|
+
// turn. The window opens when a turn ends and closes 120 s later, when the next
|
|
3
|
+
// turn starts, or when the page goes away, whichever comes first. It counts the
|
|
4
|
+
// user's edits that changed the scene, by group, as the command bus records them
|
|
5
|
+
// (command-bus-telemetry.js: UI-origin receipts only, one drag is one receipt).
|
|
6
|
+
import { bucketFrictionCount } from "./analytics-friction.js";
|
|
7
|
+
import { bucketLong } from "./analytics-strict.js";
|
|
8
|
+
|
|
9
|
+
export const POST_TURN_WINDOW_MS = 120_000;
|
|
10
|
+
const GROUPS = Object.freeze(["pose", "camera", "object", "shot", "prompt"]);
|
|
11
|
+
let open = null;
|
|
12
|
+
let pageListener = false;
|
|
13
|
+
|
|
14
|
+
const defaultTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (id) => clearTimeout(id), now: () => Date.now() };
|
|
15
|
+
|
|
16
|
+
/** Send the open window's summary, if any, and close it. */
|
|
17
|
+
export function closePostTurnWindow() {
|
|
18
|
+
const window = open;
|
|
19
|
+
if (!window) return null;
|
|
20
|
+
open = null;
|
|
21
|
+
try { window.timers.clear(window.timer); } catch { /* a foreign timer cannot keep the window open */ }
|
|
22
|
+
const edited = window.firstAt !== null;
|
|
23
|
+
const props = {
|
|
24
|
+
turn_id: window.turnId, edited,
|
|
25
|
+
// The prompt group is sent as prompt_block: "prompt" is a denied property key.
|
|
26
|
+
...Object.fromEntries(GROUPS.map((group) => [group === "prompt" ? "prompt_block" : group, bucketFrictionCount(window.counts[group])])),
|
|
27
|
+
...(edited ? { first_delay: bucketLong(window.firstAt - window.openedAt) } : {}),
|
|
28
|
+
};
|
|
29
|
+
try { Promise.resolve(window.emit("agent:post_turn_edit", props)).catch(() => {}); } catch { /* telemetry is advisory */ }
|
|
30
|
+
return props;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A turn ended: watch the user's next 120 s. Any window still open is closed first. */
|
|
34
|
+
export function openPostTurnWindow(turnId, emit, timers = defaultTimers) {
|
|
35
|
+
closePostTurnWindow();
|
|
36
|
+
if (typeof turnId !== "string" || typeof emit !== "function") return;
|
|
37
|
+
try {
|
|
38
|
+
const openedAt = timers.now();
|
|
39
|
+
open = { turnId, emit, timers, openedAt, firstAt: null, counts: Object.fromEntries(GROUPS.map((group) => [group, 0])) };
|
|
40
|
+
open.timer = timers.set(closePostTurnWindow, POST_TURN_WINDOW_MS);
|
|
41
|
+
if (!pageListener && typeof globalThis.addEventListener === "function") {
|
|
42
|
+
pageListener = true;
|
|
43
|
+
globalThis.addEventListener("pagehide", closePostTurnWindow);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
open = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One user edit of `group` (the caller passes only the user's own edits); counted while a window is open. */
|
|
51
|
+
export function noteEditGesture(group) {
|
|
52
|
+
if (!open || !GROUPS.includes(group)) return false;
|
|
53
|
+
try {
|
|
54
|
+
const at = open.timers.now();
|
|
55
|
+
if (at - open.openedAt >= POST_TURN_WINDOW_MS) { closePostTurnWindow(); return false; }
|
|
56
|
+
open.counts[group] += 1;
|
|
57
|
+
if (open.firstAt === null) open.firstAt = at;
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// search:no_results: a search box whose query has shown nothing for 1 s.
|
|
2
|
+
// Only the surface and a bucket of the query's length leave; the query text
|
|
3
|
+
// itself never leaves this module.
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { track } from "./analytics.js";
|
|
6
|
+
import { admit, bump } from "./analytics-friction.js";
|
|
7
|
+
|
|
8
|
+
const STABLE_MS = 1000, PER_SURFACE = 3;
|
|
9
|
+
const SURFACES = new Set(["assets", "outliner"]);
|
|
10
|
+
let send = track, timers = { set: (run, ms) => setTimeout(run, ms), clear: (id) => clearTimeout(id) };
|
|
11
|
+
|
|
12
|
+
/** 1, 2-3, 4-8, gte9: how long a query was, never what it said. */
|
|
13
|
+
export function bucketQueryLength(length) {
|
|
14
|
+
if (length <= 1) return "1";
|
|
15
|
+
if (length <= 3) return "2-3";
|
|
16
|
+
if (length <= 8) return "4-8";
|
|
17
|
+
return "gte9";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An empty result shown for `query` on `surface`. Once the query has stayed the
|
|
22
|
+
* same for 1 s, the search_empty counter moves and search:no_results goes out
|
|
23
|
+
* (3 per surface per session). Returns the cancel: a new query or results
|
|
24
|
+
* showing up again before 1 s sends nothing.
|
|
25
|
+
*/
|
|
26
|
+
export function watchNoResults(surface, query) {
|
|
27
|
+
const length = String(query ?? "").trim().length;
|
|
28
|
+
if (!SURFACES.has(surface) || !length) return () => {};
|
|
29
|
+
const id = timers.set(() => {
|
|
30
|
+
try {
|
|
31
|
+
bump("search_empty");
|
|
32
|
+
if (admit(`search:no_results:${surface}`, PER_SURFACE)) send("search:no_results", { surface, query_len: bucketQueryLength(length) });
|
|
33
|
+
} catch { /* telemetry is advisory */ }
|
|
34
|
+
}, STABLE_MS);
|
|
35
|
+
return () => timers.clear(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Wraps a search's empty state: watching while it is shown, restarted when the query changes. */
|
|
39
|
+
export function SearchEmpty({ surface, query, children }) {
|
|
40
|
+
useEffect(() => watchNoResults(surface, query), [surface, query]);
|
|
41
|
+
return children;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Tests only: capture what is sent and drive the timer; returns the restore. */
|
|
45
|
+
export function searchFrictionSeam({ track: capture = track, set = timers.set, clear = timers.clear } = {}) {
|
|
46
|
+
const previous = [send, timers];
|
|
47
|
+
send = capture; timers = { set, clear };
|
|
48
|
+
return () => { [send, timers] = previous; };
|
|
49
|
+
}
|