cozyclay 3.0.0 → 3.0.1

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/agent/agent-routes.mjs +3 -3
  3. package/bin/agent/agent-runner.mjs +5 -5
  4. package/bin/agent/session-store.mjs +2 -2
  5. package/bin/agent/studio-context-budget.mjs +93 -0
  6. package/bin/agent/studio-prompt.mjs +1 -1
  7. package/dist/app/index.html +4 -4
  8. package/dist/assets/analytics-BWCqdgWA.js +1 -0
  9. package/dist/assets/app-DfTCxaKk.js +5087 -0
  10. package/dist/assets/compile-worker-BxKXh07s.js +5 -0
  11. package/dist/assets/{first-shot-handoff-CC4xe4HM.js → first-shot-handoff-DNO8kv_N.js} +1 -1
  12. package/dist/assets/{landing-DjHD8u7k.js → landing-yfZaJkWc.js} +1 -1
  13. package/dist/assets/scene-asset-sync-U3kS2rvK.js +20 -0
  14. package/dist/assets/workflow-D78eiKTa.js +179 -0
  15. package/dist/cozyclay-package.json +1 -1
  16. package/dist/index.html +3 -3
  17. package/dist/workflow/index.html +3 -3
  18. package/package.json +1 -1
  19. package/src/App.jsx +1 -1
  20. package/src/agent-progress.js +3 -2
  21. package/src/app-context.js +7 -2
  22. package/src/command-bus.js +2 -2
  23. package/src/commands/motion3.js +12 -5
  24. package/src/commands/shot.js +6 -2
  25. package/src/domains/motion/take-source.js +4 -2
  26. package/src/draft-ghost.js +3 -2
  27. package/src/motion/beat-board-model.js +1 -1
  28. package/src/motion/beat-board.jsx +2 -1
  29. package/src/motion3/compile-job.js +4 -0
  30. package/src/motion3/compiler/anchors.js +8 -0
  31. package/src/motion3/compiler/limbs.js +35 -8
  32. package/src/motion3/compiler/pose.js +17 -2
  33. package/src/motion3/compiler/validate-objects.js +3 -2
  34. package/src/motion3/compiler/validate-parts.js +2 -0
  35. package/src/motion3/compiler/validate.js +3 -3
  36. package/src/motion3/compiler/vocab.js +17 -16
  37. package/src/motion3/describe.js +5 -2
  38. package/src/motion3/docs.js +1 -1
  39. package/src/motion3/free-motion.js +42 -11
  40. package/src/motion3/object-pose.js +1 -1
  41. package/src/motion3/people.js +82 -0
  42. package/src/motion3/qa/checks.js +6 -2
  43. package/src/motion3/qa/hints-contact.js +17 -1
  44. package/src/motion3/qa/index.js +6 -4
  45. package/src/motion3/qa/people-checks.js +37 -0
  46. package/src/motion3/qa/style.js +14 -4
  47. package/src/motion3/refs.js +10 -1
  48. package/src/motion3/schema/fields.js +4 -4
  49. package/src/motion3/schema/topics.js +6 -4
  50. package/src/motion3/schema/types.js +10 -8
  51. package/src/motion3/settle.js +136 -0
  52. package/src/motion3/skills/basic.js +2 -1
  53. package/src/motion3/skills/flip.js +200 -0
  54. package/src/motion3/skills/impact.js +109 -0
  55. package/src/motion3/skills/index.js +3 -1
  56. package/src/motion3/skills/info.js +15 -0
  57. package/src/qa-label.js +25 -0
  58. package/src/studio-actions.js +4 -0
  59. package/src/studio-agent-protocol.js +8 -8
  60. package/src/studio-app-binding.js +2 -2
  61. package/src/studio-error-codes.js +8 -1
  62. package/src/workflow/AgentProgressCard.jsx +6 -4
  63. package/tools/run-tests.mjs +2 -2
  64. package/dist/assets/analytics-C9sUnndd.js +0 -1
  65. package/dist/assets/app-CCh--1Hc.js +0 -5087
  66. package/dist/assets/compile-worker-CkxiD6lH.js +0 -5
  67. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +0 -20
  68. package/dist/assets/workflow-C4yKH1p4.js +0 -179
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"e6a50c77650f2894d8154267dec8faafb86cad41e859b2594f233d5a18348530\"}","signature":"rwtYOUdZUQo57FluVUioxvNg+gKzRgBC38gyKcd7GIrm5nhq3iff5eV90XV7YFAJfVHn466qu3iLBsnjKkpIDA=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"bedf955b7367c9d4e883baa9c0b6fd14a205f6d39a3bf447f628488cd2893564\"}","signature":"+2TEXB+bXFUPN34/ls1YvimKkHfSdSCs0IiIyjIhsHqvz3j+XcKkEkqx1zjXn8MQlybgStGOS290YH40Nw1hBQ=="}
package/dist/index.html CHANGED
@@ -301,9 +301,9 @@
301
301
  .btn, a.card, .seg button, .tutorial-steps li { transition: none; }
302
302
  }
303
303
  </style>
304
- <script type="module" crossorigin src="/assets/landing-DjHD8u7k.js"></script>
305
- <link rel="modulepreload" crossorigin href="/assets/analytics-C9sUnndd.js">
306
- <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-CC4xe4HM.js">
304
+ <script type="module" crossorigin src="/assets/landing-yfZaJkWc.js"></script>
305
+ <link rel="modulepreload" crossorigin href="/assets/analytics-BWCqdgWA.js">
306
+ <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-DNO8kv_N.js">
307
307
  </head>
308
308
  <body>
309
309
  <header class="site">
@@ -8,11 +8,11 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-C4yKH1p4.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-D78eiKTa.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="/assets/analytics-C9sUnndd.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/analytics-BWCqdgWA.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
15
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Hx_FzdOH.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-U3kS2rvK.js">
16
16
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-_S8NQl5B.css">
17
17
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
18
18
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
package/src/App.jsx CHANGED
@@ -1282,7 +1282,7 @@ export default function App() {
1282
1282
  const [inspectorActionsOpen, setInspectorActionsOpen] = useState(false);
1283
1283
 
1284
1284
  const objectsDomain = useObjects(appContext.forRender({
1285
- get animatedSceneObjects() { return animatedSceneObjects; },
1285
+ get animatedSceneObjects() { return animatedSceneObjects; }, get attachFrameRef() { return attachFrameRef; },
1286
1286
  get castMemberOf() { return castMemberOf; },
1287
1287
  get charIdFromHierarchyId() { return charIdFromHierarchyId; },
1288
1288
  get characters() { return characters; },
@@ -5,6 +5,7 @@
5
5
  // In-page only: nothing here is saved with the project, undone, or sent to the agent. Each character keeps its
6
6
  // last KEEP drafts; the images keep the last KEEP. A new agent turn starts clean (clearProgress).
7
7
 
8
+ import { qaProblems } from "./qa-label.js";
8
9
  const KEEP = 12;
9
10
  const drafts = new Map(), sheets = [], listeners = new Set();
10
11
  let version = 0, counter = 0;
@@ -21,7 +22,7 @@ const pick = (row, keys) => Object.fromEntries(keys.filter(k => row?.[k] !== und
21
22
  /**
22
23
  * publishDraft(characterId, { take, installed, specId, label, qa, timeline }) -> the stored draft:
23
24
  * { n (1, 2, ... per character), at (ms), characterId, take, frames, installed, specId, label,
24
- * qa: { ok, total, groups: [{ kind, subject, count, first, last, worst }], goals: [{ name, kind, ok, value, need, at }] },
25
+ * qa: { ok, total, problems, groups: [{ kind, subject, count, first, last, worst }], goals: [{ name, kind, ok, value, need, at }] },
25
26
  * timeline: the compile's beats (pipeline.js timelineOf) }.
26
27
  */
27
28
  export function publishDraft(characterId, { take, installed = false, specId = null, label = null, qa = null, timeline = [] }) {
@@ -29,7 +30,7 @@ export function publishDraft(characterId, { take, installed = false, specId = nu
29
30
  const draft = Object.freeze({
30
31
  n: (list.at(-1)?.n ?? 0) + 1, at: Date.now(), seq: ++counter, characterId, take, frames: take?.frameCount ?? 0,
31
32
  installed: !!installed, specId, label,
32
- qa: { ok: !!qa?.ok, total: qa?.total ?? 0,
33
+ qa: { ok: !!qa?.ok, total: qa?.total ?? 0, problems: qaProblems(qa),
33
34
  groups: (qa?.groups ?? []).map(g => pick(g, ["kind", "subject", "count", "first", "last", "worst"])),
34
35
  goals: (qa?.goals ?? []).map(g => pick(g, ["name", "kind", "ok", "value", "need", "at"])) },
35
36
  timeline: timeline ?? [],
@@ -1,8 +1,13 @@
1
- import { createContext, useContext } from 'react';
1
+ import { createContext, useContext, useMemo } from 'react';
2
2
  import { StudioProtocolError } from './studio-agent-protocol.js';
3
3
 
4
4
  export const AppContext = createContext(null);
5
- export function useBus() { return useContext(AppContext).bus; }
5
+ // Reading `bus` refreshes the studio binding (it serializes the whole scene), so a panel must not read it while it
6
+ // renders: playback re-renders panels every frame. The hook hands back a stable handle that opens the bus per call.
7
+ export function useBus() {
8
+ const app = useContext(AppContext);
9
+ return useMemo(() => ({ run: (...args) => app.bus.run(...args), subscribe: (...args) => app.bus.subscribe(...args) }), [app]);
10
+ }
6
11
 
7
12
  const ref = current => ({ current });
8
13
 
@@ -2,7 +2,7 @@
2
2
  // the editor ports own native state/history, and the existing journal owns
3
3
  // idempotency. Synchronous UI actions stay synchronous (including user gestures).
4
4
  import { HISTORY_LIMIT } from './history.js';
5
- import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity } from './studio-agent-protocol.js';
5
+ import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity, refusalCode } from './studio-agent-protocol.js';
6
6
  import { agentTurnControls, markAgentTurn } from './agent-turn-mark.js';
7
7
 
8
8
  // A batching/retention adapter over the existing object history owner. The
@@ -203,7 +203,7 @@ export function createCommandBus({ registry, ports }) {
203
203
  function refusal(request, before, error, mutated) {
204
204
  const changed = mutated ?? (ports.read().revision !== before.revision);
205
205
  return validateReceipt({ ok: false, commandId: request.commandId, host: before.host,
206
- code: error.code ?? 'INVALID_ARGUMENT', phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
206
+ code: refusalCode(error), phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
207
207
  mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
208
208
  message: [...String(error.message || error)].slice(0, 500).join('') });
209
209
  }
@@ -18,9 +18,11 @@ import { fitOutput, reportPage } from "../motion3/report.js";
18
18
  import { keyObject } from "../motion3/key-edit.js";
19
19
  import { expandUse } from "../motion3/skills/index.js";
20
20
  import { publishDraft } from "../agent-progress.js";
21
+ import { qaSay } from "../qa-label.js";
21
22
  import { carryEdit } from "../motion3/edit-carry.js";
22
23
  import { normalizeCompileArgs } from "../motion3/lenient.js";
23
24
  import { createCompileRunner } from "./motion3-compile.js";
25
+ import { peopleOf } from "../motion3/people.js";
24
26
 
25
27
  const TIMELINE_FPS = 24;
26
28
  const id = { type: "string", minLength: 1 };
@@ -91,6 +93,11 @@ export function register(registry, ports) {
91
93
  if (Math.abs((character.scale ?? 1) - 1) > 1e-6) fail("TARGET_NOT_READY", "Set the character's scale back to 1 before compiling motion.");
92
94
  return sk;
93
95
  };
96
+ // the scene a compile sees: the objects, and the other characters' installed takes as moving bodies (people.js)
97
+ const sceneFor = character => {
98
+ const state = ports.state(), people = peopleOf(state.characters, character.id, id => owner().motionFor(id));
99
+ return { ...motionScene(state.objects), ...(Object.keys(people).length ? { people } : {}) };
100
+ };
94
101
  // one compile worker per editor in the browser, the calling thread elsewhere (motion3-compile.js)
95
102
  const runner = createCompileRunner();
96
103
  const workerWarning = () => { const note = runner.takeWarning(); return note ? { warnings: [note] } : {}; };
@@ -101,7 +108,7 @@ export function register(registry, ports) {
101
108
  const compile = (character, spec, opts = {}, bake) => {
102
109
  rigOf(character);
103
110
  const placed = bake && { position: [character.x ?? 0, character.y ?? 0, character.z ?? 0], yaw: character.rot ?? 0, ...bake };
104
- return runner.run({ model: character.model, scene: motionScene(ports.state().objects), spec, ...opts, ...(placed ? { bake: placed } : {}) });
111
+ return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) });
105
112
  };
106
113
  /** The whole output, kept in the store (motion.report, delta) and cut to the receipt budget (report.js). */
107
114
  const report = (r, head, extra = {}) => {
@@ -153,12 +160,12 @@ export function register(registry, ports) {
153
160
  const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: wanted, ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
154
161
  // agent-progress.js: viewport ghost, timeline beats, agent panel
155
162
  publishDraft(characterId, { take, installed: installs || kept, specId: output.specId, label: r.label, qa, timeline: r.timeline });
156
- return { affectedIds: installs ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qa.ok ? "clean" : `${qa.total} issues`}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
163
+ return { affectedIds: installs ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qaSay(qa)}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
157
164
  });
158
165
  } });
159
166
  registry.register({ ...declarations[1], available: mounted, run({ characterId, checks }) {
160
167
  const character = characterOf(ports, characterId), take = installed(characterId);
161
- return after(compile(character, take.spec, { checks }), r => ({ affectedIds: [], summary: `QA ${r.qa.ok ? "clean" : `${r.qa.total} issues`}${adjustedSay(r.notes ?? [])}.`,
168
+ return after(compile(character, take.spec, { checks }), r => ({ affectedIds: [], summary: `QA ${qaSay(r.qa)}${adjustedSay(r.notes ?? [])}.`,
162
169
  output: report(r, adjustedOf(r.notes ?? [])), ...workerWarning() }));
163
170
  } });
164
171
  registry.register({ ...declarations[2], available: mounted, run({ characterId, frames, bones = DEFAULT_BONES }) {
@@ -196,11 +203,11 @@ export function register(registry, ports) {
196
203
  // motion.keyObject (C14): one key of a held object (or a hinge angle) in the installed take's spec, compiled
197
204
  // and installed whatever QA says (a user edit), as one undo entry
198
205
  registry.register({ ...declarations[5], available: mounted, target: args => args.characterId, run({ characterId, ...args }, context) {
199
- const character = characterOf(ports, characterId), take = installed(characterId), scene = motionScene(ports.state().objects);
206
+ const character = characterOf(ports, characterId), take = installed(characterId), scene = sceneFor(character);
200
207
  const { spec, keyed } = asArgument(() => keyObject(take.spec, scene, args, expandUse(take.spec, { sk: rigOf(character), scene, lenient: true }).spec.objects));
201
208
  return after(compile(character, spec, { lenient: true }, { prompt: take.prompt }), r => {
202
209
  const timing = put(character, r.take, context);
203
- return { affectedIds: [characterId], summary: `${keyed.removed ? "Removed the key of" : "Keyed"} ${keyed.objectId} at f${keyed.f}; QA ${r.qa.ok ? "clean" : `${r.qa.total} issues`}; installed.`,
210
+ return { affectedIds: [characterId], summary: `${keyed.removed ? "Removed the key of" : "Keyed"} ${keyed.objectId} at f${keyed.f}; QA ${qaSay(r.qa)}; installed.`,
204
211
  output: report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: true, keyed }, timing ? { timing } : {}), ...workerWarning() };
205
212
  });
206
213
  } });
@@ -22,10 +22,9 @@ const number = { type: 'number' };
22
22
  const frame = { type: 'integer', minimum: 0 };
23
23
  const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
24
24
  const mutation = (id, label, schema, description = label) => ({ id, label, description, kind: 'mutation', undoDomain: 'shot', input: schema });
25
- const existing = ['shot.create', 'shot.createStill', 'shot.setCaption', 'shot.setCastOverride', 'shot.setHold', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
25
+ const existing = ['shot.create', 'shot.createStill', 'shot.setCaption', 'shot.setCastOverride', 'shot.setHold', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.setCameraMode', 'shot.rename', 'shot.reorder'].map(studioActionDeclaration);
26
26
  const extra = [
27
27
  mutation('shot.set', 'Set shot fields', elementSetSchema('shot')),
28
- mutation('shot.rename', 'Rename shot', input({ shotId: id, name: { type: 'string', maxLength: 240 } })),
29
28
  mutation('shot.setCamera', 'Set camera block', input({ shotId: id, patch: { type: 'object', properties: {}, additionalProperties: true } })),
30
29
  mutation('shot.addKey', 'Add camera key', input({ shotId: id, frame })),
31
30
  mutation('shot.moveKey', 'Move camera key', input({ shotId: id, keyId: id, frame })),
@@ -159,6 +158,11 @@ export function register(registry, ports) {
159
158
  patchShot(shotId, shot => ({ ...shot, camera: removeCameraRail(shot.camera) }));
160
159
  },
161
160
  'shot.rename': ({ shotId, name }) => { shotOf(shotId); owner().write(current => renameShot(current, shotId, name)); },
161
+ 'shot.setCameraMode': ({ shotId, mode }) => {
162
+ const shot = shotOf(shotId);
163
+ if (mode === 'rail' && !createCameraBlock(shot.camera).cameraRail) fail('TARGET_NOT_READY', `${shot.name || shotId} has no camera rail; lay one with shot.setCameraRail.`);
164
+ patchShot(shotId, row => ({ ...row, camera: updateCameraBlock(row.camera, { mode }) }));
165
+ },
162
166
  'shot.setCamera': ({ shotId, patch }) => patchShot(shotId, shot => ({ ...shot, camera: updateCameraBlock(shot.camera, patch) })),
163
167
  'shot.addKey': ({ shotId, frame }) => {
164
168
  const shot = shotOf(shotId), target = Math.min(owner().state().frameCount - 1, frame);
@@ -22,7 +22,7 @@ const finite3 = value => Array.isArray(value) && value.length === 3 && value.eve
22
22
  export function takeSource(take, sceneObjects = []) {
23
23
  if (!isTakeV3(take) || !plain(take.spec)) return null;
24
24
  return structuredClone({ schema: TAKE_SCHEMA, rig: take.rig, spec: take.spec,
25
- placement: { position: take.placement?.position ?? [0, 0, 0], yaw: take.placement?.yaw ?? 0 }, scene: motionScene(sceneObjects) });
25
+ placement: { position: take.placement?.position ?? [0, 0, 0], yaw: take.placement?.yaw ?? 0 }, scene: { ...motionScene(sceneObjects), ...(plain(take.people) ? { people: take.people } : {}) } });
26
26
  }
27
27
 
28
28
  /** A saved source (with the Studio take id it carried), checked and owned by the caller; null when it is not one. */
@@ -31,8 +31,10 @@ export function normalizeTakeSource(value) {
31
31
  if (!plain(value.spec) || value.spec.schema !== SPEC_SCHEMA || !Number.isFinite(value.spec.frames)) return null;
32
32
  if (!plain(value.placement) || !finite3(value.placement.position) || !Number.isFinite(value.placement.yaw)) return null;
33
33
  const objects = plain(value.scene?.objects) ? value.scene.objects : {}, others = plain(value.scene?.others) ? { others: value.scene.others } : {};
34
+ // the joints a person anchor named when it was compiled (people.js keptPeople)
35
+ const people = plain(value.scene?.people) && Object.values(value.scene.people).every(p => plain(p) && Array.isArray(p.names) && Array.isArray(p.P) && p.P.length === p.frames * p.names.length * 3) ? { people: value.scene.people } : {};
34
36
  return structuredClone({ schema: TAKE_SCHEMA, rig: value.rig, spec: value.spec,
35
- placement: { position: value.placement.position, yaw: value.placement.yaw }, scene: { objects, ...others },
37
+ placement: { position: value.placement.position, yaw: value.placement.yaw }, scene: { objects, ...others, ...people },
36
38
  ...(typeof value.takeId === "string" && value.takeId ? { takeId: value.takeId } : {}),
37
39
  ...(normalizeTakeEdit(value.edit, value.spec.frames) ? { edit: normalizeTakeEdit(value.edit, value.spec.frames) } : {}) });
38
40
  }
@@ -6,6 +6,7 @@
6
6
  // GIZMO_LAYER, so captures, contact sheets and exports never see it.
7
7
  import { useSyncExternalStore } from "react";
8
8
  import { ko } from "./locale.js";
9
+ import { qaLabel } from "./qa-label.js";
9
10
 
10
11
  /** Teal: apart from the onion skin's blue (before) and orange (after). */
11
12
  export const DRAFT_GHOST_COLOR = "#2fc79a";
@@ -28,8 +29,8 @@ export function draftGhostState(draft, now) {
28
29
  return { shown: true, opacity: +opacity.toFixed(3), frame: Math.floor((age / 1000) * fps) % take.frameCount };
29
30
  }
30
31
 
31
- /** "Draft 3 · QA 31" ("초안 3 · QA 31"). */
32
- export const draftLabel = draft => `${ko("Draft", "초안")} ${draft?.n ?? 0} · QA ${draft?.qa?.total ?? 0}`;
32
+ /** "Draft 3 · 2 problems" ("초안 3 · 문제 2개"), "Draft 7 · QA clean": problems, not frame-level issues (qa-label.js). */
33
+ export const draftLabel = draft => `${ko("Draft", "초안")} ${draft?.n ?? 0} · ${qaLabel(draft?.qa)}`;
33
34
 
34
35
  let shown = true;
35
36
  const listeners = new Set();
@@ -63,7 +63,7 @@ export function beatBoardModel(draft, motion) {
63
63
  for (const block of blocks) block.rows = rows;
64
64
  const spans = groups.map(g => ({ f0: g.first ?? 0, f1: g.last ?? g.first ?? 0, text: `${g.kind} ${g.subject}` })).sort((a, b) => a.f0 - b.f0);
65
65
  const marks = goals.map(g => ({ f: g.at, ok: !!g.ok, text: goalText(g) })).sort((a, b) => a.f - b.f);
66
- return { mode, n: draft.n, total: draft.qa?.total ?? 0, ok: !!draft.qa?.ok, blocks, spans, marks };
66
+ return { mode, n: draft.n, total: draft.qa?.total ?? 0, ok: !!draft.qa?.ok, qa: draft.qa ?? null, blocks, spans, marks };
67
67
  }
68
68
 
69
69
  /** One goal as a line: "✓ name 1.2 cm (≤ 2 cm)"; a distance in metres reads in cm. */
@@ -8,6 +8,7 @@ import { useSyncExternalStore } from "react";
8
8
  import { ko } from "../locale.js";
9
9
  import { latestDraft, progressVersion, subscribeProgress } from "../agent-progress.js";
10
10
  import { beatBoardModel, blockTitle } from "./beat-board-model.js";
11
+ import { draftLabel } from "../draft-ghost.js";
11
12
  import "./beat-board.css";
12
13
 
13
14
  function stateChip(block) {
@@ -51,7 +52,7 @@ export function BeatBoard({ motion, frame, displayFrameCount, clipPct, onJump })
51
52
  {model.mode === "draft" && (
52
53
  <span className={"tl-beat-head" + (model.ok ? " ok" : "")} data-testid="beat-board-head"
53
54
  title={ko("The agent's newest draft, not installed yet", "에이전트가 만든 최신 초안 (아직 적용 전)")}>
54
- {ko(`Draft ${model.n} · QA ${model.total}`, `초안 ${model.n} · QA ${model.total}`)}
55
+ {draftLabel({ n: model.n, qa: model.qa })}
55
56
  </span>
56
57
  )}
57
58
  </div>
@@ -10,6 +10,7 @@ import { compileSpec, liftRequest } from "./pipeline.js";
10
10
  import { repairSpec } from "./repair.js";
11
11
  import { bakeTake } from "./take.js";
12
12
  import { skeletonFor } from "./rigs/index.js";
13
+ import { keptPeople } from "./people.js";
13
14
 
14
15
  const TIMELINE_FPS = 24;
15
16
  const refuse = (code, message) => { throw Object.assign(new Error(message), { code }); };
@@ -52,6 +53,9 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
52
53
  // ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
53
54
  const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
54
55
  const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
56
+ // a person anchor's joints ride with the take, so its saved source compiles the same without the others' takes (people.js)
57
+ const people = take && keptPeople(scene, [r.expanded, r.stored]);
58
+ if (people) take.people = people;
55
59
  return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
56
60
  ...(r.probes ? { probes: r.probes } : {}), ...(r.notes ? { notes: r.notes } : {}), ms, frameCount: r.out.frameCount, label,
57
61
  ...(fixed.repair ? { repair: fixed.repair } : {}), ...(take ? { take } : {}) };
@@ -10,6 +10,8 @@
10
10
  // that frame (hinged, carried, following; object-pose.js objectPoseAt), in its own axes
11
11
  // { kind: "landmark", name: "mouth" } measured face landmark on the Head bone
12
12
  // { kind: "prop", prop: "cig", point: "tip", offset: [x,y,z] } a point on a prop held by the other hand
13
+ // { kind: "person", person: "char-b", bone: "LeftArm", offset: [x,y,z] } world offset from another character's joint
14
+ // where its installed take has it at that frame (people.js)
13
15
  // spec.props = { name: { hand: "r", origin: ["RightHandIndex2", "RightHandMiddle2"],
14
16
  // points: { name: [d, l, n] } } } offsets along the palm axes (m)
15
17
  import { add, sub, scale, len, apply, ry } from "./math.js";
@@ -18,6 +20,7 @@ import { palmAxes } from "./limbs.js";
18
20
  import { SIDES } from "./pose.js";
19
21
  import { HAND_CLEAR, keepOut } from "../body.js";
20
22
  import { groupsOf } from "../scene.js";
23
+ import { personJoint, peopleList } from "../people.js";
21
24
 
22
25
  function armReach(sk, k) {
23
26
  const side = SIDES[k], P = sk.restWorldPos, I = sk.index;
@@ -85,6 +88,11 @@ function anchorPoint(ctx, a, name, s, diag, c, side) {
85
88
  if (!p) throw new Error(`MOTION_SPEC_INVALID: anchor "${name}" needs prop ${a.prop}.${a.point}, which is held by an arm solved later`);
86
89
  return add(p, a.offset ?? [0, 0, 0]);
87
90
  }
91
+ case "person": {
92
+ const p = personJoint(ctx.scene, a.person, a.bone, diag?.frame ?? 0);
93
+ if (!p) throw new Error(`MOTION_SPEC_INVALID: anchor "${name}" names person "${a.person}" bone "${a.bone}", which the scene does not have (people: ${peopleList(ctx.scene)})`);
94
+ return add(p, a.offset ?? [0, 0, 0]);
95
+ }
88
96
  default: throw new Error(`MOTION_SPEC_INVALID: anchor "${name}" has unknown kind "${a.kind}"`);
89
97
  }
90
98
  }
@@ -103,16 +103,21 @@ export function twoBone(sk, s, root, mid, end, target, pole, restBend, stop = nu
103
103
  return inside || d - dc;
104
104
  }
105
105
 
106
- /** Aim a foot: Foot->ToeBase along yaw/pitch of its rest direction, sole kept level. */
107
- export function orientFoot(sk, s, side, yawDeg, pitchDeg) {
106
+ /**
107
+ * Aim a foot: Foot->ToeBase along yaw/pitch of its rest direction, sole kept level. With `tilt` (pose.js tiltFrame: a body
108
+ * turned well past upright) the yaw and pitch are read in that frame and the sole levels to its up, so a foot in a flip
109
+ * points with the shin instead of staying flat to the floor; no toe-off then (the toes are off the ground).
110
+ */
111
+ export function orientFoot(sk, s, side, yawDeg, pitchDeg, tilt = null) {
108
112
  const foot = sk.index[`${side}Foot`], toe = sk.index[`${side}ToeBase`];
109
113
  const rest = sub(sk.restWorldPos[toe], sk.restWorldPos[foot]);
110
- const want = apply(ry(yawDeg), apply(rx(pitchDeg), rest));
114
+ const want = tilt ? apply(tilt.R, apply(ry(yawDeg - tilt.yaw), apply(rx(pitchDeg), rest))) : apply(ry(yawDeg), apply(rx(pitchDeg), rest));
115
+ const up = tilt ? apply(tilt.R, [0, 1, 0]) : [0, 1, 0];
111
116
  // level the sole: the foot's own carried up vector goes to world up (no roll from the leg IK)
112
- rotateSubtree(sk, s, foot, s.P[foot].slice(), align(sub(s.P[toe], s.P[foot]), carried(sk, s, foot, [0, 1, 0]), want, [0, 1, 0]));
117
+ rotateSubtree(sk, s, foot, s.P[foot].slice(), align(sub(s.P[toe], s.P[foot]), carried(sk, s, foot, [0, 1, 0]), want, up));
113
118
  // Toe-off: bend the toe joint so the toes stay flat on the ground while the heel rises.
114
119
  const tip = sk.index[`${side}Toe_End`];
115
- if (pitchDeg > 0 && tip !== undefined) {
120
+ if (!tilt && pitchDeg > 0 && tip !== undefined) {
116
121
  const seg = sub(s.P[tip], s.P[toe]), flat = [seg[0], 0, seg[2]];
117
122
  if (len(flat) > 1e-6) rotateSubtree(sk, s, toe, s.P[toe].slice(), align(seg, [0, 1, 0], flat, [0, 1, 0]));
118
123
  }
@@ -144,11 +149,13 @@ export function wrist(sk, s, side, twistDeg, flexDeg) {
144
149
  rotateSubtree(sk, s, h, s.P[h].slice(), axisRot(axT, clamp(twistDeg, -85, 85)));
145
150
  }
146
151
 
147
- // Finger curl presets, degrees per phalanx (1, 2, 3); thumb is [opposition, curl, curl].
152
+ // Finger curl presets, degrees per phalanx (1, 2, 3); thumb is [opposition, curl, curl]. A fist and a grip keep the
153
+ // relaxed thumb here and close it with THUMB_WRAP: these thumb curls turn about the knuckle line and the hand's long axis,
154
+ // which only swings a thumb out to the side (a fist read as a thumbs-up).
148
155
  const FINGER_PRESETS = {
149
156
  relaxed: { Index: [12, 18, 10], Middle: [15, 20, 12], Ring: [18, 22, 12], Pinky: [20, 24, 14], Thumb: [15, 10, 8] },
150
- fist: { Index: [80, 95, 60], Middle: [85, 95, 60], Ring: [85, 95, 60], Pinky: [85, 95, 60], Thumb: [45, 30, 35] },
151
- grip: { Index: [55, 60, 35], Middle: [60, 62, 35], Ring: [62, 62, 35], Pinky: [65, 62, 35], Thumb: [40, 20, 20] },
157
+ fist: { Index: [80, 95, 60], Middle: [85, 95, 60], Ring: [85, 95, 60], Pinky: [85, 95, 60], Thumb: [15, 10, 8] },
158
+ grip: { Index: [55, 60, 35], Middle: [60, 62, 35], Ring: [62, 62, 35], Pinky: [65, 62, 35], Thumb: [15, 10, 8] },
152
159
  cigarette: { Index: [8, 10, 6], Middle: [10, 12, 8], Ring: [60, 75, 45], Pinky: [65, 80, 45], Thumb: [35, 25, 20] },
153
160
  point: { Index: [0, 0, 0], Middle: [80, 95, 60], Ring: [85, 95, 60], Pinky: [85, 95, 60], Thumb: [40, 30, 30] },
154
161
  };
@@ -166,5 +173,25 @@ export function fingers(sk, s, side, weights) {
166
173
  rotateSubtree(sk, s, j, s.P[j].slice(), axisRot(ax, sign * curl(f, k)));
167
174
  }
168
175
  }
176
+ const wrap = [0, 1, 2, 3].map(i => Object.entries(weights ?? {}).reduce((acc, [p, w]) => acc + (w || 0) * (THUMB_WRAP[p]?.[i] ?? 0), 0));
177
+ if (wrap.some(Boolean)) wrapThumb(sk, s, side, wrap);
178
+ }
179
+
180
+ // A closing hand's thumb, by preset weight after the relaxed one, degrees: turned in about the hand's long axis toward the
181
+ // palm and across it, then flexed at its two joints about their own axes (bone x palm normal). The fist's thumb lies over
182
+ // the index and middle fingers' middle phalanges; the grip's closes around a handle against the index finger. Searched on
183
+ // both rigs within the joints' ranges (thumb tip to the index's middle phalanx: fist 8.4 -> 3.7 cm Y Bot, 6.9 -> 2.4 X Bot).
184
+ const THUMB_WRAP = { fist: [-70, -5, 5, 80], grip: [-30, -30, 30, 10] };
185
+ function wrapThumb(sk, s, side, [oppose, across, flex2, flex3]) {
186
+ const sign = side === "Left" ? -1 : 1, J = k => sk.index[`${side}HandThumb${k}`];
187
+ if (J(1) === undefined) return;
188
+ const { d, n } = palmAxes(sk, s, side);
189
+ rotateSubtree(sk, s, J(1), s.P[J(1)].slice(), axisRot(d, sign * oppose));
190
+ // n = d x l flips with the mirror, so the turn across the palm keeps its sign on both hands
191
+ rotateSubtree(sk, s, J(1), s.P[J(1)].slice(), axisRot(n, across));
192
+ for (const [k, deg] of [[2, flex2], [3, flex3]]) {
193
+ const ax = norm(cross(norm(sub(s.P[J(k + 1)], s.P[J(k)])), n));
194
+ rotateSubtree(sk, s, J(k), s.P[J(k)].slice(), axisRot(ax, sign * deg));
195
+ }
169
196
  }
170
197
 
@@ -82,6 +82,21 @@ function hangOf(sk, down) {
82
82
  return hang;
83
83
  }
84
84
 
85
+ /**
86
+ * The frame the knee and elbow poles and the feet are read in once the whole body turns well past upright (a flip, a
87
+ * cartwheel): the root's yaw, pitch and roll, the tilt eased in from TILT_FROM to TILT_FULL degrees (the larger of the
88
+ * wrapped pitch and roll). Under TILT_FROM it is null and the poles turn with the yaw only, as they always have, so an
89
+ * upright take is unchanged.
90
+ */
91
+ const TILT_FROM = 60, TILT_FULL = 120;
92
+ const wrap180 = a => a - 360 * Math.round(a / 360);
93
+ export function tiltFrame(yaw, pitch, roll) {
94
+ const p = wrap180(pitch), r = wrap180(roll), u = (Math.max(Math.abs(p), Math.abs(r)) - TILT_FROM) / (TILT_FULL - TILT_FROM);
95
+ if (u <= 0) return null;
96
+ const w = u >= 1 ? 1 : u * u * (3 - 2 * u);
97
+ return { yaw, R: mul(ry(yaw), mul(rx(p * w), rz(r * w))) };
98
+ }
99
+
85
100
  export function pose(sk, c, hooks = {}) {
86
101
  const s = restState(sk);
87
102
  const diag = {};
@@ -107,7 +122,7 @@ export function pose(sk, c, hooks = {}) {
107
122
  rotateSubtree(sk, s, hips, s.P[hips].slice(), mul(ry(yaw), mul(rx(num(c.pitch)), rz(num(c.roll)))));
108
123
  const shift = sub(v3(c.root, s.P[hips]), s.P[hips]);
109
124
  for (let j = 0; j < s.P.length; j++) s.P[j] = add(s.P[j], shift);
110
- const body = ry(yaw);
125
+ const tilt = tiltFrame(yaw, num(c.pitch), num(c.roll)), body = tilt?.R ?? ry(yaw);
111
126
  // Legs: two-bone IK to the ankle targets, the knee no further folded than its stop (limbs.js KNEE_STOP), then aim the feet.
112
127
  for (const [k, side] of Object.entries(SIDES)) {
113
128
  if (!c[`foot_${k}`]) continue;
@@ -117,7 +132,7 @@ export function pose(sk, c, hooks = {}) {
117
132
  // a target nearer the hip than the knee folds leaves a residual (leg_miss); leg_fold says what it asked
118
133
  const fold = askedFold(sk, s, side, target, LEG);
119
134
  if (fold > KNEE_FOLD) diag[`leg_fold_${k}`] = fold;
120
- orientFoot(sk, s, side, num(c[`fyaw_${k}`], yaw), num(c[`fpitch_${k}`]));
135
+ orientFoot(sk, s, side, num(c[`fyaw_${k}`], yaw), num(c[`fpitch_${k}`]), tilt);
121
136
  }
122
137
  // Arms: blend the FK hand toward the hook's target, poles blended the same way.
123
138
  for (const k of hooks.armOrder ?? ["r", "l"]) {
@@ -15,7 +15,7 @@ import { isFree } from "../free-motion.js";
15
15
  import { checkRef } from "../refs.js";
16
16
  import { partsOf } from "../object-pose.js";
17
17
 
18
- const KEY = "{ f, pos: [x, y, z], rot: [x, y, z, w] } or { f, pos, aim: [x, y, z], roll? }", FALL = "{ at, onto?, bounce? }";
18
+ const KEY = "{ f, pos: [x, y, z], rot: [x, y, z, w] } or { f, pos, aim: [x, y, z], roll? }", FALL = "{ at, onto?, bounce?, tumble? }";
19
19
  const isQuat = q => Array.isArray(q) && q.length === 4 && q.every(Number.isFinite) && Math.abs(Math.hypot(...q) - 1) <= 0.01;
20
20
  const isDir = v => isVec3(v) && Math.hypot(...v) > 1e-9;
21
21
 
@@ -77,7 +77,7 @@ function frameOk(err, at, v, frames) {
77
77
  return false;
78
78
  }
79
79
 
80
- /** objects.<id>.fall (C17): { at, onto?, bounce? } or a list, each on its own frame; a carry falls only out of hand. */
80
+ /** objects.<id>.fall (C17): { at, onto?, bounce?, tumble? } or a list, each on its own frame; a carry falls only out of hand. */
81
81
  function validateFalls(err, unknown, at, o, { frames, scene, carry }) {
82
82
  const list = Array.isArray(o.fall) ? o.fall : [o.fall], seen = new Set();
83
83
  if (!list.length) err(`${at}.fall is an empty list; give ${FALL} or a list of them`);
@@ -94,6 +94,7 @@ function validateFalls(err, unknown, at, o, { frames, scene, carry }) {
94
94
  }
95
95
  if (fl.onto !== undefined) objectOk(err, scene, `${fat}.onto`, fl.onto, { groups: true });
96
96
  if (fl.bounce !== undefined && !(Number.isFinite(fl.bounce) && fl.bounce >= 0 && fl.bounce <= 0.6)) err(`${fat}.bounce must be 0..0.6 (the share of its landing speed it bounces back with; 0 lands dead), got ${fmt(fl.bounce)}`);
97
+ if (fl.tumble !== undefined && typeof fl.tumble !== "boolean") err(`${fat}.tumble must be true (it rolls to rest on a stable face, the default) or false (it drops straight down as it was), got ${fmt(fl.tumble)}`);
97
98
  });
98
99
  }
99
100
 
@@ -8,6 +8,7 @@ import { ANCHOR_FIELDS, PROP_FIELDS, FIT_FIELDS, WALK_FIELDS, CHECK_FIELDS, CHEC
8
8
  import { boneOk, objectOk, refOk } from "./validate-common.js";
9
9
  import { validateObjects } from "./validate-objects.js";
10
10
  import { validateGoals } from "./validate-goals.js";
11
+ import { personJoint, peopleList } from "../people.js";
11
12
 
12
13
  const SIDE = new Set(["l", "r"]);
13
14
  const isObject = v => Boolean(v) && typeof v === "object" && !Array.isArray(v);
@@ -29,6 +30,7 @@ function validateAnchors(spec, err, { sk, scene, unknown = err }) {
29
30
  if (a.kind === "bone") boneOk(err, sk, at, a.bone);
30
31
  if (a.kind === "segment") { boneOk(err, sk, `${at}.from`, a.from); boneOk(err, sk, `${at}.to`, a.to); if (a.reach !== undefined && !SIDE.has(a.reach)) err(`${at}.reach must be "l" or "r"`); }
31
32
  if (a.kind === "object") objectOk(err, scene, at, a.object, { groups: true });
33
+ if (a.kind === "person" && !personJoint(scene, a.person, a.bone ?? "", 0)) err(`${at} (person) needs person: the id of another character with an installed take (people: ${peopleList(scene)}) and bone: one of its joints, got ${fmt(a.person)} ${fmt(a.bone)}`);
32
34
  if (a.kind === "landmark" && a.name !== "mouth") err(`${at}: the only landmark is "mouth", got ${fmt(a.name)}`);
33
35
  if (a.kind === "prop" && !spec.props?.[a.prop]?.points?.[a.point] && a.point !== "origin") err(`${at} needs prop point ${a.prop}.${a.point}, which spec.props does not define`);
34
36
  }
@@ -23,10 +23,10 @@ function checkValue(err, at, ch, v) {
23
23
  if (!isVec3(v)) return err(`${at} ${ch} must be [x, y, z] (3 finite numbers), got ${fmt(v)}`);
24
24
  if (POLE_CHANNELS.has(ch)) {
25
25
  const l = Math.hypot(...v);
26
- if (l < 1e-6) return err(`${at} ${ch} = ${fmt(v)} has no direction; a pole is ${CONVENTIONS.pole}`);
27
- if (ch.startsWith("knee") && v[2] < -0.7 * l) err(`${at} ${ch} = ${fmt(v)} points backward; knees bend toward +z. A pole is ${CONVENTIONS.pole}`);
26
+ if (l < 1e-6) return err(`${at} ${ch} = ${fmt(v)} has no direction; a pole is a ${CONVENTIONS.pole}`);
27
+ if (ch.startsWith("knee") && v[2] < -0.7 * l) err(`${at} ${ch} = ${fmt(v)} points backward; knees bend toward +z. A pole is a ${CONVENTIONS.pole}`);
28
28
  }
29
- if (ch.startsWith("foot") && v[1] < 0.04) err(`${at} ${ch} y = ${v[1]} puts the ankle into the floor; foot_* is ${CONVENTIONS.foot}`);
29
+ if (ch.startsWith("foot") && v[1] < 0.04) err(`${at} ${ch} y = ${v[1]} puts the ankle into the floor; foot_* is the ${CONVENTIONS.foot}`);
30
30
  return;
31
31
  }
32
32
  if (MAP_CHANNELS.has(ch)) {
@@ -14,33 +14,34 @@ export const FINGER_PRESETS = ["relaxed", "fist", "grip", "cigarette", "point"];
14
14
 
15
15
  // [min, max, convention] for numeric channels whose values have a hard range.
16
16
  const RANGES = [
17
- [/^(armw)_[lr]$/, 0, 1, "0 = FK arm, 1 = IK to the hand target"],
17
+ [/^(armw)_[lr]$/, 0, 1, "0 FK, 1 IK to the hand target"],
18
18
  [/^arm_hang$/, 0, 1, "1 = a free FK forearm hangs plumb"],
19
- [/^w_.+_[lr]$/, 0, 1, "a hand-target weight; the weights of one hand add up to at most 1"],
20
- [/^wtwist_[lr]$/, -85, 85, "wrist twist about the forearm in degrees"],
21
- [/^wflex_[lr]$/, -70, 70, "wrist flex about the knuckle line in degrees"],
22
- [/^fpitch_[lr]$/, -60, 90, "toe-down pitch in degrees: + lifts the heel (the toes stay flat), - lifts the toes"],
23
- [/^(pitch|spine_pitch|head_pitch)$/, -120, 120, "degrees, + bends forward"],
24
- [/^(roll|spine_roll|head_roll)$/, -90, 90, "degrees"],
19
+ [/^w_.+_[lr]$/, 0, 1, "hand-target weight; a hand's sum <= 1"],
20
+ [/^wtwist_[lr]$/, -85, 85, "twist about the forearm, deg"],
21
+ [/^wflex_[lr]$/, -70, 70, "flex about the knuckle line, deg"],
22
+ [/^fpitch_[lr]$/, -60, 90, "toe-down pitch, deg: + lifts the heel (toes flat), - lifts the toes"],
23
+ // the whole-body pitch and roll have none: they turn in full (flips, cartwheels; pose.js tiltFrame)
24
+ [/^(spine_pitch|head_pitch)$/, -120, 120, "degrees, + bends forward"],
25
+ [/^(spine_roll|head_roll)$/, -90, 90, "degrees"],
25
26
  ];
26
27
  export const rangeOf = ch => RANGES.find(([re]) => re.test(ch)) ?? null;
27
28
 
28
29
  export const CONVENTIONS = {
29
30
  yaw: "yaw 0 faces +Z and + turns toward +X",
30
- fyaw: "foot heading in degrees, as yaw",
31
- pole: "a body-frame direction (turned by the root yaw only): +z forward, +x the character's left, +y up",
32
- foot: "the ANKLE target in world metres (standing: 0.087 X Bot, 0.105 Y Bot)",
33
- hand: "the WRIST target in world metres",
34
- root: "the hips joint in world metres (standing: 1.03 X Bot, 0.99 Y Bot)",
31
+ fyaw: "foot heading, as yaw",
32
+ pole: "body-frame direction (root yaw): +z forward, +x its left, +y up",
33
+ foot: "ANKLE target, world m (standing 0.087 X Bot, 0.105 Y Bot)",
34
+ hand: "WRIST target, world m",
35
+ root: "hips joint, world m (standing 1.03 X Bot, 0.99 Y Bot)",
35
36
  };
36
37
 
37
38
  /** What each channel family means (a family drops the _l/_r side), for motion.describe "channels". */
38
39
  export const CHANNEL_DOCS = {
39
- root: CONVENTIONS.root, yaw: `degrees; ${CONVENTIONS.yaw}`, pitch: "whole-body pitch, degrees", roll: "whole-body roll, degrees",
40
- spine_yaw: "degrees", spine_pitch: "degrees, + bends forward", spine_roll: "degrees", head_yaw: "degrees", head_pitch: "degrees, + looks down", head_roll: "degrees",
41
- arm_rest: "FK arms down from the T-pose (default 72)", arm_hang: "1 = a free FK forearm hangs plumb (standIdle and walks key it)", foot: CONVENTIONS.foot, fyaw: CONVENTIONS.fyaw, fpitch: "toe-down pitch, degrees, + heel up",
40
+ root: CONVENTIONS.root, yaw: `deg; ${CONVENTIONS.yaw}`, pitch: "degrees", roll: "degrees",
41
+ spine_yaw: "degrees", spine_pitch: "deg, + bends forward", spine_roll: "degrees", head_yaw: "degrees", head_pitch: "degrees, + looks down", head_roll: "degrees",
42
+ arm_rest: "FK arms down, deg, default 72", arm_hang: "1 = a free FK forearm hangs plumb (standIdle and walks key it)", foot: CONVENTIONS.foot, fyaw: CONVENTIONS.fyaw, fpitch: "toe-down pitch, degrees, + heel up",
42
43
  knee: `knee pole, ${CONVENTIONS.pole}`, armw: "0 = FK arm, 1 = IK to hand_* / the anchors", hand: CONVENTIONS.hand, elbow: `elbow pole, ${CONVENTIONS.pole}`,
43
- wtwist: "wrist twist, degrees", wflex: "wrist flex, degrees", aswing: "FK arm swing, degrees, + forward", abend: "FK elbow bend, degrees",
44
+ wtwist: "wrist twist, degrees", wflex: "wrist flex, degrees", aswing: "FK arm swing, deg, + forward", abend: "FK elbow bend, deg",
44
45
  fingers: "{ preset: weight 0..1 }",
45
46
  };
46
47
  export const channelFamily = ch => ch.replace(/_[lr]$/, "");
@@ -81,7 +81,7 @@ const SCHEMAS = {
81
81
  probes: () => listOf({ ...objectOf(PROBE_FIELDS, PROBE_TYPES, "probes", ["name"]), ...oneOfRequired(["distance", "position"]) }, { maxItems: 16 }),
82
82
  channels: () => ({ type: "object", description: "key.set: channel -> value",
83
83
  properties: Object.fromEntries([...VEC_CHANNELS, ...NUM_CHANNELS, ...MAP_CHANNELS].filter(ch => !SKILL_CHANNELS.has(ch)).map(ch => [ch, channelSchema(ch)])),
84
- patternProperties: { "^w_.+_[lr]$": { type: "number", minimum: 0, maximum: 1, description: rangeOf("w_a_l")[3] }, "_vis$": { type: "number", description: "shown above 0.5" } },
84
+ patternProperties: { "^w_.+_[lr]$": { type: "number", minimum: 0, maximum: 1, description: rangeOf("w_a_l")[3] }, "_vis$": { type: "number", description: "shown > 0.5" } },
85
85
  additionalProperties: { type: "number", description: "spec.channels, an object's or a check's when" } }),
86
86
  refs: () => ({ type: "string", oneOf: REF_KINDS.map(k => ({ pattern: `^${k}:`, description: typeOf(REF_DOCS, k, "refs") })) }),
87
87
  fix: () => ({ oneOf: [{ const: false }, objectOf(FIX_KEYS, FIX_TYPES, "fix")] }),
@@ -103,6 +103,8 @@ function describeSkill(name) {
103
103
  const MEANT = { topics: ["objects", "channels"], skills: ["reachGrip"] };
104
104
  /** Topics no round-2 session needed for a first spec: essentials names their parts, the full topic has the fields. */
105
105
  const SHALLOW = ["checks"];
106
+ /** Skills essentials leaves to topic skills: a flip or cartwheel is no first spec's need, and essentials has no room. */
107
+ const FIRST_SPEC_SKIP = ["flip"];
106
108
 
107
109
  /** A shared shape whose fields are mostly a skill's args (walk, walkTo): its own fields and `...<skill> args`. */
108
110
  function shapeLine(d) {
@@ -134,7 +136,8 @@ function essentials() {
134
136
  }
135
137
  // use.skill without the enum: the skills list below names them
136
138
  if (shared.use) shared.use = objectOf(USE_FIELDS, USE_TYPES, "use", ["skill", "f0"]);
137
- const skills = SKILL_NAMES.map(n => {
139
+ // a skill no first spec needs (FIRST_SPEC_SKIP) is in topic skills only
140
+ const skills = SKILL_NAMES.filter(n => !FIRST_SPEC_SKIP.includes(n)).map(n => {
138
141
  const { args, required = [], needs, frames, adds } = SKILL_INFO[n] ?? bad(`describe: skill ${n} has no entry in skills/info.js`);
139
142
  return { name: n, args: briefType({ type: "object", properties: args, required }, { means: MEANT.skills.includes(n) }), needs, frames, adds };
140
143
  });
@@ -6,7 +6,7 @@ export const MOTION_ACTION_HELP = {
6
6
  + "Send spec, or base (an answered specId) + optional patch (JSON Patch); base alone or patch: [] re-measures (new probes). "
7
7
  + "spec = { schema: \"cozyclay.motion-spec.v1\", fps: 24, frames (number or \"auto\"), ... } (parts: motion.describe); any number may be { range: [lo, hi] } (searched). "
8
8
  + "probes (at most 16, request level; spec.probes: measured, not stored): { name, distance: [ref, ref] | position: ref, frames, every? }. "
9
- + "Refs need a prefix (joint: prop: anchor: landmark: object:; goals[].moved: a bare id too) and take loose bone names (any case); spec.checks need exact ones. "
9
+ + "Refs need a prefix (joint: prop: anchor: landmark: object: person:; goals[].moved: a bare id too) and take loose bone names (any case); spec.checks need exact ones. "
10
10
  + "repair: { rounds } (1-8, default 4) applies group patches while severity drops. "
11
11
  + "qa judges only what is broken; speed, acceleration, balance, knee bend are observations unless checks.limits names them. "
12
12
  + "Answers frames, installed, specId, qa { ok, total, goals, groups (GOAL first, then severity; cause, hint, patch?) }, observations, delta (with base), repair, probes, timeline, fixed, search, skills. output.more: motion.report pages the rest.",