cozyclay 3.1.4 → 3.1.6

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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-BLS1Zaru.js +5144 -0
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/mcp/project-camera.mjs +43 -0
  38. package/package.json +2 -1
  39. package/src/App.jsx +59 -70
  40. package/src/analytics-controls.js +17 -0
  41. package/src/analytics-friction.js +124 -0
  42. package/src/analytics-strict.js +139 -0
  43. package/src/analytics-values.js +49 -0
  44. package/src/analytics.js +37 -133
  45. package/src/asset-pane.jsx +4 -5
  46. package/src/click-friction.js +101 -0
  47. package/src/command-bus-telemetry.js +155 -0
  48. package/src/command-bus.js +10 -8
  49. package/src/commands/motion3.js +7 -2
  50. package/src/controls.jsx +3 -2
  51. package/src/crash-telemetry.js +45 -0
  52. package/src/domains/cast.js +2 -11
  53. package/src/domains/motion.js +49 -38
  54. package/src/domains/objects.js +3 -0
  55. package/src/domains/scene-undo.js +27 -0
  56. package/src/domains/scenes.js +1 -1
  57. package/src/error-boundary.jsx +2 -0
  58. package/src/export-attempt.js +100 -0
  59. package/src/export-workspace.js +12 -1
  60. package/src/first-success-guide.jsx +11 -7
  61. package/src/guide-telemetry.js +60 -0
  62. package/src/hierarchy-panel.jsx +2 -1
  63. package/src/live-control.js +2 -9
  64. package/src/locale-zh/domains.js +1 -0
  65. package/src/main.jsx +5 -0
  66. package/src/motion/timeline.jsx +9 -5
  67. package/src/motion-draft-telemetry.js +62 -0
  68. package/src/motion3/compiler/channels.js +7 -5
  69. package/src/motion3/compiler/compile.js +6 -2
  70. package/src/motion3/compiler/finger-close.js +84 -7
  71. package/src/motion3/compiler/head-lead.js +70 -0
  72. package/src/motion3/compiler/head-steady.js +1 -1
  73. package/src/motion3/compiler/limbs.js +10 -3
  74. package/src/motion3/compiler/pose.js +1 -1
  75. package/src/motion3/compiler/validate.js +5 -3
  76. package/src/motion3/pipeline.js +4 -0
  77. package/src/motion3/qa/explain.js +2 -1
  78. package/src/motion3/qa/finger-checks.js +80 -0
  79. package/src/motion3/qa/gait-observe.js +88 -0
  80. package/src/motion3/qa/grasp-checks.js +3 -0
  81. package/src/motion3/qa/hints-grasp.js +8 -0
  82. package/src/motion3/qa/index.js +2 -1
  83. package/src/motion3/qa/motion-life.js +26 -3
  84. package/src/motion3/schema/topics.js +1 -1
  85. package/src/playback-telemetry.js +107 -0
  86. package/src/post-turn-edits.js +62 -0
  87. package/src/search-friction.js +49 -0
  88. package/src/semantic-edit.js +19 -6
  89. package/src/shell/BoardSlot.jsx +3 -3
  90. package/src/shell/BottomDock.jsx +1 -1
  91. package/src/shell/DetailsSlot.jsx +1 -1
  92. package/src/shell/MenuBar.jsx +13 -13
  93. package/src/shell/StudioShell.jsx +7 -1
  94. package/src/shell/TopBar.jsx +2 -0
  95. package/src/shell/ViewportToolbar.jsx +2 -2
  96. package/src/shortcut-friction.js +122 -0
  97. package/src/stage-canvas-rigs.jsx +3 -4
  98. package/src/studio-telemetry.js +97 -0
  99. package/src/thrash-friction.js +90 -0
  100. package/src/update/UpdateChip.jsx +86 -0
  101. package/src/update/UpdateDialog.jsx +147 -0
  102. package/src/update/update-client.js +188 -0
  103. package/src/update/update-telemetry.js +78 -0
  104. package/src/update/update.css +94 -0
  105. package/src/use-webgl-available.js +35 -0
  106. package/src/workflow/AgentPanel.jsx +29 -30
  107. package/src/workflow/CozySceneNode.jsx +1 -1
  108. package/src/workflow/LoginPanel.jsx +15 -5
  109. package/src/workflow/agent-client.js +10 -91
  110. package/src/workflow/agent-failure-codes.js +47 -0
  111. package/src/workflow/agent-turn-telemetry.js +222 -0
  112. package/src/workflow/connect-telemetry.js +86 -0
  113. package/src/workflow/prompt-telemetry.js +105 -0
  114. package/tools/dev/pages/privacy.html +44 -17
  115. package/tools/loc-baseline.json +10 -10
  116. package/tools/run-tests.mjs +8 -8
  117. package/tools/verify-package-tarball.mjs +29 -1
  118. package/dist/assets/app-BdRlMf4J.css +0 -1
  119. package/dist/assets/app-KXdgBC-S.js +0 -5144
  120. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  121. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  122. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  123. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  124. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  125. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  126. package/dist/media/playground-poster.jpg +0 -0
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { ko } from "./locale.js";
3
+ import { noteGuideDismissed, noteGuideOpened, noteGuideStep } from "./guide-telemetry.js";
3
4
 
4
5
  const ANIMATION_STEPS = [
5
6
  {
@@ -76,13 +77,16 @@ const STEPS_BY_MODE = { animation: ANIMATION_STEPS, storyboard: STORYBOARD_STEPS
76
77
  /** A compact, dismissible first-run checklist that follows the v2 shell grammar. */
77
78
  export default function FirstSuccessGuide({ open, onDismiss, previsMode = "animation" }) {
78
79
  const storyboard = previsMode === "storyboard";
79
- const STEPS = STEPS_BY_MODE[storyboard ? "storyboard" : "animation"];
80
+ const mode = storyboard ? "storyboard" : "animation";
81
+ const STEPS = STEPS_BY_MODE[mode];
80
82
  const [step, setStep] = useState(0);
81
83
  useEffect(() => {
82
- if (open) setStep(0);
83
- }, [open]);
84
+ if (open) { setStep(0); noteGuideOpened(mode); }
85
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps -- the mode at opening time
84
86
  if (!open) return null;
85
87
  const complete = step >= STEPS.length;
88
+ // Closing after the last step is not a drop-off: only an unfinished guide reports guide:dismissed.
89
+ const dismiss = () => { if (!complete) noteGuideDismissed(mode, step + 1); onDismiss(); };
86
90
 
87
91
  return (
88
92
  <aside className="v2-first-success-guide first-success-guide" role="dialog" aria-labelledby="first-success-guide-title" aria-describedby="first-success-guide-description">
@@ -91,14 +95,14 @@ export default function FirstSuccessGuide({ open, onDismiss, previsMode = "anima
91
95
  <strong id="first-success-guide-title">{ko("Your first 60 seconds", "첫 60초")}</strong>
92
96
  <span>{complete ? ko("Core loop complete", "핵심 흐름 완료", "核心流程完成") : ko(`Step ${step + 1} of ${STEPS.length}`, `${step + 1} / ${STEPS.length} 단계`, `第 ${step + 1} / ${STEPS.length} 步`)}</span>
93
97
  </div>
94
- <button type="button" className="v2-first-success-guide-close first-success-guide-close" onClick={onDismiss} aria-label={ko("Dismiss guide", "가이드 닫기")}>×</button>
98
+ <button type="button" className="v2-first-success-guide-close first-success-guide-close" onClick={dismiss} aria-label={ko("Dismiss guide", "가이드 닫기")}>×</button>
95
99
  </div>
96
100
  {complete ? (
97
101
  <div className="v2-first-success-guide-complete first-success-guide-complete" id="first-success-guide-description" role="status">
98
102
  <div className="v2-first-success-guide-check first-success-guide-check">✓</div>
99
103
  <strong>{storyboard ? ko("You made your first panel.", "첫 패널을 만들었어요.") : ko("You made your first shot.", "첫 샷을 만들었어요.")}</strong>
100
104
  <p>{storyboard ? ko("Describe, review on the Board, then stylize or export. You can close this guide and keep adding panels.", "설명하고, 보드에서 확인한 뒤 스타일을 입히거나 내보내세요. 가이드를 닫고 패널을 계속 추가하세요.") : ko("Select, move, ask, and play are the core CozyClay loop. You can close this guide and keep blocking.", "선택하고, 움직이고, 요청하고, 재생하는 것이 CozyClay의 핵심 흐름입니다. 가이드를 닫고 계속 블로킹하세요.")}</p>
101
- <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={onDismiss}>{ko("Continue editing", "편집 계속하기")}</button>
105
+ <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={dismiss}>{ko("Continue editing", "편집 계속하기")}</button>
102
106
  </div>
103
107
  ) : (
104
108
  <>
@@ -111,12 +115,12 @@ export default function FirstSuccessGuide({ open, onDismiss, previsMode = "anima
111
115
  </li>
112
116
  ))}
113
117
  </ol>
114
- <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={() => setStep((value) => value + 1)}>
118
+ <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={() => { noteGuideStep(mode, step + 1, STEPS.length); setStep(step + 1); }}>
115
119
  {ko(step === STEPS.length - 1 ? "Mark complete" : "I did this", step === STEPS.length - 1 ? "완료로 표시" : "했어요", step === STEPS.length - 1 ? "标记完成" : "我做了这一步")}
116
120
  </button>
117
121
  </>
118
122
  )}
119
- <button type="button" className="v2-first-success-guide-skip first-success-guide-skip" onClick={onDismiss}>{ko("Skip guide", "가이드 건너뛰기")}</button>
123
+ <button type="button" className="v2-first-success-guide-skip first-success-guide-skip" onClick={dismiss}>{ko("Skip guide", "가이드 건너뛰기")}</button>
120
124
  </aside>
121
125
  );
122
126
  }
@@ -0,0 +1,60 @@
1
+ // guide:opened, guide:step, guide:dismissed, guide:completed: the first-60-seconds
2
+ // guide (first-success-guide.jsx) as a funnel. The mode and step numbers are the
3
+ // component's own (its STEPS_BY_MODE keys, 1-based step index); each event and step
4
+ // is sent at most once per session. Only the mode, a step number and a duration
5
+ // bucket leave: never step text or what the user did.
6
+ import { initAnalytics, track } from "./analytics.js";
7
+ import { admit } from "./analytics-friction.js";
8
+ import { bucketLong } from "./analytics-strict.js";
9
+
10
+ const GUIDE_MODES = Object.freeze(["animation", "storyboard"]);
11
+ const GUIDE_STEP_VALUES = Object.freeze(["1", "2", "3", "4"]);
12
+
13
+ // The guide can open before the SDK has loaded; initAnalytics() is idempotent (main.jsx started
14
+ // it), so waiting on it keeps an early guide:opened instead of spending its cap on a no-op.
15
+ let send = (event, props) => { initAnalytics().then(() => track(event, props), () => {}); }, clock = () => Date.now();
16
+ let stepStartedAt = null;
17
+
18
+ const stepValue = (step) => (GUIDE_STEP_VALUES.includes(String(step)) ? String(step) : null);
19
+ const guarded = (run) => { try { run(); } catch { /* Telemetry must never affect the guide. */ } };
20
+
21
+ /** The guide became visible in `mode`; its first step's clock starts. */
22
+ export function noteGuideOpened(mode) {
23
+ guarded(() => {
24
+ if (!GUIDE_MODES.includes(mode)) return;
25
+ stepStartedAt = clock();
26
+ if (admit("guide:opened", 1)) send("guide:opened", { mode });
27
+ });
28
+ }
29
+
30
+ /** The user marked step `step` (1-based) of `total` done; the last one also completes the guide. */
31
+ export function noteGuideStep(mode, step, total) {
32
+ guarded(() => {
33
+ const value = stepValue(step);
34
+ if (!GUIDE_MODES.includes(mode) || !value) return;
35
+ const now = clock();
36
+ const stepMs = stepStartedAt === null ? NaN : now - stepStartedAt;
37
+ stepStartedAt = now;
38
+ if (admit(`guide:step:${value}`, 1)) send("guide:step", { mode, step: value, step_ms: bucketLong(stepMs) });
39
+ if (step === total && admit("guide:completed", 1)) send("guide:completed", { mode });
40
+ });
41
+ }
42
+
43
+ /** The guide was closed before its last step; `reached` is the step it was showing (1-based). */
44
+ export function noteGuideDismissed(mode, reached) {
45
+ guarded(() => {
46
+ const value = stepValue(reached);
47
+ if (!GUIDE_MODES.includes(mode) || !value) return;
48
+ stepStartedAt = null;
49
+ if (admit("guide:dismissed", 1)) send("guide:dismissed", { mode, reached: value });
50
+ });
51
+ }
52
+
53
+ /** Tests only: capture what is sent and drive the clock; returns the restore. */
54
+ export function guideTelemetrySeam(capture, now) {
55
+ const previous = [send, clock];
56
+ send = capture;
57
+ if (now) clock = now;
58
+ stepStartedAt = null;
59
+ return () => { [send, clock] = previous; stepStartedAt = null; };
60
+ }
@@ -7,6 +7,7 @@ import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
7
7
  import { Dropdown } from "./ui.jsx";
8
8
  import { usePopover } from "./popover.js";
9
9
  import { isImeComposing } from "./ime.js";
10
+ import { SearchEmpty } from "./search-friction.js";
10
11
  import "./hierarchy-panel.css";
11
12
 
12
13
  const HIERARCHY_LABELS_KO = {
@@ -881,7 +882,7 @@ export default function HierarchyPanel({
881
882
  </label>
882
883
  <div className="hierarchy-tree" role="tree" ref={treeRef} onKeyDown={onTreeKeyDown} onContextMenu={openCreateMenu}>
883
884
  {filteredHierarchyNodes.length > 0 ? renderNodes(filteredHierarchyNodes) : (
884
- <div className="v2-outliner-empty" role="status">{ko("No matches", "일치하는 항목 없음")}</div>
885
+ <SearchEmpty surface="outliner" query={search}><div className="v2-outliner-empty" role="status">{ko("No matches", "일치하는 항목 없음")}</div></SearchEmpty>
885
886
  )}
886
887
  </div>
887
888
  {sceneHint && (
@@ -4,11 +4,7 @@
4
4
  import { sanitizeProps, track } from "./analytics.js";
5
5
  import { EXECUTION_TELEMETRY_EVENTS, EXECUTION_TELEMETRY_PROPERTY_KEYS } from "./execution-telemetry.js";
6
6
 
7
- const MOTION_TELEMETRY_EVENTS = new Set([
8
- "motion:generate_requested", "motion:preflight_blocked", "motion:preflight_passed",
9
- "motion:job_started", "motion:job_succeeded", "motion:job_failed", "motion:result_applied",
10
- ]);
11
- const LIVE_TELEMETRY_EVENTS = new Set([...MOTION_TELEMETRY_EVENTS, ...EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:"))]);
7
+ const LIVE_TELEMETRY_EVENTS = new Set(EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:")));
12
8
 
13
9
  const LIVE_CONTROL_PORT = import.meta.env?.VITE_COZYCLAY_LIVE_PORT ?? "5184";
14
10
  // The npm launcher serves a prebuilt bundle, so the build-time port above is
@@ -159,7 +155,6 @@ export function createLiveControl({
159
155
  // A duplicated tab carries a copy of this tab's id; the hub refuses the
160
156
  // second socket, so the loser mints and stores a new identity, then retries.
161
157
  onDuplicate = () => mintLiveWorkspaceId(),
162
- captureMotionTelemetry = track,
163
158
  captureTelemetry = track,
164
159
  workspaceId = "",
165
160
  // Optional identity for the hub's live_status listing (scene/project names).
@@ -191,13 +186,11 @@ export function createLiveControl({
191
186
  if (!correlationId) return;
192
187
  if (EXECUTION_TELEMETRY_EVENTS.includes(payload.event)
193
188
  && EXECUTION_TELEMETRY_PROPERTY_KEYS[payload.event]?.some((key) => !Object.hasOwn(props, key))) return;
194
- if ((payload.event === "motion:generate_requested" || payload.event.startsWith("motion:preflight_")) && props.surface !== "mcp") return;
195
189
  const key = `${correlationId}:${payload.event}`;
196
190
  if (capturedStages.has(key)) return;
197
191
  capturedStages.add(key);
198
- const capture = payload.event.startsWith("motion:") ? captureMotionTelemetry : captureTelemetry;
199
192
  try {
200
- Promise.resolve(capture(payload.event, props)).catch(() => {
193
+ Promise.resolve(captureTelemetry(payload.event, props)).catch(() => {
201
194
  // SDK failures must not affect command dispatch or generation.
202
195
  });
203
196
  } catch {
@@ -9,6 +9,7 @@ export const ZH_DOMAINS = {
9
9
  "Edited pose": "已编辑的姿势",
10
10
  "Pose saved": "姿势已保存",
11
11
  "Nothing to undo": "没有可撤销的操作",
12
+ "IK edit undone": "已撤销 IK 修改",
12
13
  "Nothing to redo": "没有可重做的操作",
13
14
  "Confirm Replace existing keys before applying this pin.": "应用此固定前请确认替换现有键。",
14
15
  "Pose mode — preview the correction, then apply it to the selected motion block": "姿势模式 — 先预览修正,再应用到所选动作片段",
package/src/main.jsx CHANGED
@@ -11,11 +11,14 @@ import "./styles.css";
11
11
  import { registerPwa } from "./pwa.js";
12
12
  import { LOCALE } from "./locale.js";
13
13
  import { initAnalytics } from "./analytics.js";
14
+ import { StudioReadyMark } from "./studio-telemetry.js";
15
+ import { listenForUnboundShortcuts } from "./shortcut-friction.js";
14
16
  import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
15
17
  import { preloadBodies } from "./motion3/body.js";
16
18
 
17
19
  registerPwa();
18
20
  void initAnalytics();
21
+ listenForUnboundShortcuts();
19
22
  document.documentElement.lang = LOCALE === "zh" ? "zh-CN" : LOCALE;
20
23
  applyTheme(readTheme());
21
24
 
@@ -32,6 +35,8 @@ async function boot() {
32
35
  <StrictMode>
33
36
  <ErrorBoundary>
34
37
  <App />
38
+ {/* studio:ready: one frame after this first commit, without delaying it. */}
39
+ <StudioReadyMark />
35
40
  </ErrorBoundary>
36
41
  </StrictMode>,
37
42
  );
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { frameFromClientX, groupKeyRuns, KEY_RUN_MIN, rangePinBandGeometry, shotBlockGeometry } from "./timeline-coordinates.js";
3
3
  import { createPlaybackClock } from "./playback-clock.js";
4
+ import { startPlaybackRun } from "../playback-telemetry.js";
4
5
 
5
6
  import { ko } from "../locale.js";
6
7
  import { isImeComposing } from "../ime.js";
@@ -1195,14 +1196,17 @@ export default function Timeline({
1195
1196
  useEffect(() => {
1196
1197
  if (!playing) return;
1197
1198
  const elapsedFrames = createPlaybackClock(fps, playbackSpeed, performance.now());
1199
+ // playback:summary: steps > 1 is a late tick (an estimate of dropped frames, not a measured one).
1200
+ const run = startPlaybackRun(fps, playbackSpeed);
1198
1201
  const id = window.setInterval(
1199
1202
  () => {
1200
1203
  const steps = elapsedFrames(performance.now());
1204
+ run.tick(steps);
1201
1205
  if (steps) handlers.current.onAdvance?.(steps);
1202
1206
  },
1203
1207
  1000 / Math.max(1, fps * playbackSpeed),
1204
1208
  );
1205
- return () => window.clearInterval(id);
1209
+ return () => { window.clearInterval(id); run.end(); };
1206
1210
  }, [playing, fps, playbackSpeed]);
1207
1211
 
1208
1212
  // Space toggles playback, j/k step the playhead, p toggles waypoint mode —
@@ -1592,7 +1596,7 @@ export default function Timeline({
1592
1596
  </button>
1593
1597
  <button
1594
1598
  type="button"
1595
- className={"tl-btn play" + (playing ? " on" : "")}
1599
+ className={"tl-btn play" + (playing ? " on" : "")} data-fid="tl_play"
1596
1600
  aria-label={playing ? ko("Pause playback", "재생 일시중지") : ko("Play playback", "재생 시작")}
1597
1601
  title={ko("Play / pause (Space)", "재생/일시중지 (Space)")}
1598
1602
  onClick={() => handlers.current.onPlayToggle?.()}
@@ -1849,7 +1853,7 @@ export default function Timeline({
1849
1853
  {name === SHOTS_LANE && (
1850
1854
  <button
1851
1855
  type="button"
1852
- className="tl-track-add cut"
1856
+ className="tl-track-add cut" data-fid="shot_add"
1853
1857
  disabled={shotCutDisabled}
1854
1858
  title={ko("Add a 2 second shot without changing existing shots", "기존 샷을 바꾸지 않고 2초 샷 추가")}
1855
1859
  onClick={() => handlers.current.onShotCut?.()}
@@ -1993,7 +1997,7 @@ export default function Timeline({
1993
1997
  <span className="tl-shot-actions">
1994
1998
  <button type="button" title={ko("Split at the playhead", "재생 헤드에서 분할")} disabled={frame <= shot.startFrame || frame > shot.endFrame} onClick={(e) => { e.stopPropagation(); handlers.current.onShotSplit?.(shot.id); }}>{ko("Split", "분할")}</button>
1995
1999
  <button type="button" title={ko("Duplicate shot", "샷 복제")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotDuplicate?.(shot.id); }}>{ko("Duplicate", "복제")}</button>
1996
- <button type="button" title={ko("Delete shot and leave free-camera time", "샷을 지우고 자유 카메라 구간으로 비우기")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotRemove?.(shot.id); }}>{ko("Delete", "삭제")}</button>
2000
+ <button type="button" data-fid="shot_delete" title={ko("Delete shot and leave free-camera time", "샷을 지우고 자유 카메라 구간으로 비우기")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotRemove?.(shot.id); }}>{ko("Delete", "삭제")}</button>
1997
2001
  </span>
1998
2002
  <span className="tl-shot-camera-summary">
1999
2003
  <span className="tl-camera-block-state">{stateLabel}</span>
@@ -2055,7 +2059,7 @@ export default function Timeline({
2055
2059
  one gesture cannot both reorder a shot and edit a curve. */}
2056
2060
  <button
2057
2061
  type="button"
2058
- className="tl-shot-key-surface"
2062
+ className="tl-shot-key-surface" data-fid="camera_key_add"
2059
2063
  aria-label={shot.camera?.mode === "rail" && shot.camera?.craneHeight
2060
2064
  ? ko(`Add crane point in ${shot.name}`, `${shot.name}에 크레인 점 추가`, `在 ${shot.name} 中添加摇臂点`)
2061
2065
  : ko(`Add camera key in ${shot.name}`, `${shot.name}에 카메라 키 추가`, `在 ${shot.name} 中添加摄影机关键帧`)}
@@ -0,0 +1,62 @@
1
+ // motion:draft_compiled: one event per motion.compile draft (commands/motion3.js, right
2
+ // after publishDraft), so the compile -> QA -> repair loop is visible per draft. Only
3
+ // counts, buckets and flags leave: never the spec, the take, skill names or QA text.
4
+ // At most 50 per session; past the cap only the friction counter `compiles` moves.
5
+ import { track } from "./analytics.js";
6
+ import { admit, bucketFrictionCount, bucketShortMs, bump } from "./analytics-friction.js";
7
+ import { qaProblems } from "./qa-label.js";
8
+
9
+ const DRAFTS_PER_SESSION = 50;
10
+ let send = track;
11
+
12
+ function bucketQaFrames(n) {
13
+ if (!(n > 0)) return "0";
14
+ if (n <= 10) return "1-10";
15
+ if (n <= 50) return "11-50";
16
+ if (n <= 200) return "51-200";
17
+ return "gte201";
18
+ }
19
+
20
+ function bucketFrames(n) {
21
+ if (!(n >= 30)) return "lt30";
22
+ if (n <= 120) return "30-120";
23
+ if (n <= 480) return "121-480";
24
+ return "gte481";
25
+ }
26
+
27
+ /**
28
+ * The draft publishDraft just stored (`n`: its ordinal for the character this turn,
29
+ * from 1) and the compile result `r`: qa, ms (the whole draft: ms.repair, the repair
30
+ * loop's wall time with every candidate, when it ran, else ms.total, which includes QA;
31
+ * the pipeline has no separate QA time), frameCount, search. `flags` are the
32
+ * handler's own: installed, stuck, repaired, adjusted.
33
+ */
34
+ export function noteDraftCompiled(draft, r, { installed = false, stuck = false, repaired = false, adjusted = false } = {}) {
35
+ try {
36
+ bump("compiles");
37
+ const problems = qaProblems(r?.qa);
38
+ const props = {
39
+ draft_index: bucketFrictionCount(draft?.n),
40
+ qa_clean: problems === 0,
41
+ qa_problems: bucketFrictionCount(problems),
42
+ // QA records one issue per frame per group (qa-label.js): frame-level issues, not distinct frames.
43
+ qa_frames: bucketQaFrames(r?.qa?.total ?? 0),
44
+ // A repaired draft compiled many candidates: ms.total is only the one it kept (compile-job.js).
45
+ compile: bucketShortMs(r?.ms?.repair ?? r?.ms?.total),
46
+ installed: Boolean(installed), stuck: Boolean(stuck), repaired: Boolean(repaired),
47
+ searched: Boolean(r?.search), adjusted: Boolean(adjusted),
48
+ frames: bucketFrames(r?.frameCount),
49
+ };
50
+ // The payload is built first: a malformed result takes no slot of the cap.
51
+ if (admit("motion:draft_compiled", DRAFTS_PER_SESSION)) send("motion:draft_compiled", props);
52
+ } catch {
53
+ // Telemetry must never affect a compile.
54
+ }
55
+ }
56
+
57
+ /** Tests only: capture what is sent; returns the restore. */
58
+ export function draftTelemetrySeam(capture) {
59
+ const previous = send;
60
+ send = capture;
61
+ return () => { send = previous; };
62
+ }
@@ -42,23 +42,25 @@ function pchipEval(x, y, m, q) {
42
42
  }
43
43
 
44
44
  const FLOW_SMOOTH = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
45
- const FLOW_LAG = { spine: 0, head: 2, arms: 3 };
45
+ const FLOW_LAG = { spine: 0, head: 2, arms: 3 }, HEAD_LEAD = 5;
46
46
  const ARM = /^(hand|armw|elbow|aswing|abend|wflex|wtwist)_[lr]$/; // one side's arm: trails as one (overlap.js sideTrail)
47
47
  const OVER_FRAC = 0.15, OVER_MAX = 6; // a smooth segment's overshoot: at most this share of its change, and degrees
48
48
  const ikSides = ch => (/^(spine|chest|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend|wflex|wtwist)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
49
49
  const lagFamily = ch => (/^(spine|chest)_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
50
50
 
51
51
  /**
52
- * spec.flow -> { smooth, lag: { spine, head, arms }, overlap, seed, hands }: undefined is the default, false turns all off.
53
- * overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js); seed (the spec's label) picks which side trails;
52
+ * spec.flow -> { smooth, lag: { spine, head, arms }, overlap, headLead, seed, hands }: undefined is the default, false turns all off.
53
+ * overlap: true (1) | false (0) | a scale of its delays and settle (overlap.js); headLead: the frames the head leads the trunk's
54
+ * turn by, true (HEAD_LEAD) | false (0) | frames (head-lead.js); seed (the spec's label) picks which side trails;
54
55
  * hands: the frames each hand works on something (overlap.js handBusy), where a free IK hand's trail stops.
55
56
  */
56
57
  export function flowOptions(flow, seed = "", hands = { l: [], r: [] }) {
57
- if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, seed, hands };
58
+ if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 }, overlap: 0, headLead: 0, seed, hands };
58
59
  const f = flow ?? {}, lag = f.lag === false ? 0 : f.lag;
59
60
  const each = typeof lag === "number" ? { spine: lag, head: lag, arms: lag } : { ...FLOW_LAG, ...(lag ?? {}) };
60
61
  const overlap = f.overlap === false ? 0 : typeof f.overlap === "number" ? f.overlap : 1;
61
- return { smooth: f.smooth !== false, lag: each, overlap, seed, hands };
62
+ const headLead = f.headLead === false ? 0 : typeof f.headLead === "number" ? Math.round(f.headLead) : HEAD_LEAD;
63
+ return { smooth: f.smooth !== false, lag: each, overlap, headLead, seed, hands };
62
64
  }
63
65
 
64
66
  /** Catmull-Rom slopes, flat at a hold and at the ends, each segment's overshoot bounded. */
@@ -31,12 +31,13 @@ import { idleLayer, withIdle, busyHands } from "./idle.js";
31
31
  import { resolveAnchor, measureProps, armReach } from "./anchors.js";
32
32
  import { expandGenerators } from "./walk.js";
33
33
  import { steadyHead } from "./head-steady.js";
34
+ import { leadHead } from "./head-lead.js";
34
35
  import { fixOptions, applyFixes } from "../fix/index.js";
35
36
  import { fitWeight } from "../fix/self.js";
36
37
  import { holdHook } from "../object-keys.js";
37
38
  import { HAND_CLEAR, keepOut, wristBody } from "../body.js";
38
39
  import { objectPoseAt, handOf, restingAt, frameAt, spanOf } from "../object-pose.js";
39
- import { heldFor } from "./finger-close.js";
40
+ import { heldFor, carryFingers } from "./finger-close.js";
40
41
  import { sagOf } from "../load.js";
41
42
 
42
43
  const smooth = x => x * x * (3 - 2 * x);
@@ -143,6 +144,7 @@ function hooksFor(ctx, c, f, inFit = false, lead = true, fitSide = null) {
143
144
  sag: inFit ? undefined : (k, s, target) => sagOf(ctx, k, f, s, target, (1 - fitWeight(ctx.spec, c, k)) * Math.min(1, Math.max(0, c[`armw_${k}`] ?? 0))),
144
145
  // fingers close on what the hand holds, on the final pose only (finger-close.js)
145
146
  heldShapes: inFit || !ctx.grasp ? undefined : (k, s) => heldFor(ctx, c, f, k, s),
147
+ fingerWeights: inFit || !ctx.grasp ? undefined : k => carryFingers(ctx, c, f, k),
146
148
  };
147
149
  }
148
150
 
@@ -209,10 +211,12 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
209
211
  const engine = {
210
212
  rebake(keys) {
211
213
  out.keys = keys;
212
- out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow, spec.label ?? "", handBusy(spec)));
214
+ const flow = flowOptions(spec.flow, spec.label ?? "", handBusy(spec));
215
+ out.baked = bakeChannels(keys, frameCount, flow);
213
216
  ctx.socketTurns = new Map();
214
217
  ctx.graspPlans = new Map();
215
218
  steadyHead(out.baked, keys, frameCount);
219
+ leadHead(out.baked, keys, frameCount, flow.headLead);
216
220
  // a held pose breathes and shifts its weight (idle.js): offsets solveFrame adds, contacts and out.baked stay as keyed
217
221
  ctx.idle = idleLayer(out.baked, { spec, sk, frameCount });
218
222
  ctx.busy = busyHands(out.baked, spec, frameCount, out.fps);
@@ -8,14 +8,15 @@
8
8
  // then turned once; weighted by how far the hand holds (a fit's weight), so the fingers close over the reach and open
9
9
  // on the release. A joint the preset curled into the body straightens when that leaves the finger shallower; a
10
10
  // joint whose sub-chain already touches stays, and the next joint out closes on: the finger wraps the body.
11
- import { add, sub, scale, norm, cross, apply, axisRot } from "./math.js";
11
+ import { add, sub, scale, norm, cross, dot, apply, axisRot } from "./math.js";
12
12
  import { rotateSubtree } from "./skeleton.js";
13
13
  import { palmAxes, fingerCurl, fistCurl } from "./limbs.js";
14
- import { capsuleDepth } from "../shapes.js";
14
+ import { capsuleDepth, shapeOf } from "../shapes.js";
15
+ import { GRASP_FINGERS } from "./vocab.js";
15
16
  import { partsOf } from "../object-pose.js";
16
17
  import { seatsInHand } from "../seat.js";
17
18
 
18
- const PHALANX = 0.009, TOUCH = 0.002, STEPS = 8, THUMB_FLEX = 70;
19
+ const PHALANX = 0.009, TOUCH = 0.002, STEPS = 8, THUMB_FLEX = 70, SPREAD_STEP = 5, SPREAD_MAX = 15, SELF_GAP = 0.014;
19
20
  const FINGERS = ["Index", "Middle", "Ring", "Pinky"];
20
21
  // the hand stays open for most of a reach and closes as it arrives: from OPEN_UNTIL of the fit's weight to CLOSED_AT
21
22
  const OPEN_UNTIL = 0.6, CLOSED_AT = 0.9;
@@ -49,6 +50,34 @@ function clearTurn(points, pivot, axis, limit, entries) {
49
50
  return hi;
50
51
  }
51
52
 
53
+ const saveChain = (sk, s, j) => new Map(sk.subtree[j].map(i => [i, [s.P[i].slice(), s.R[i].map(r => r.slice())]]));
54
+ const restoreChain = (s, saved) => { for (const [i, [p, r]] of saved) { s.P[i] = p.slice(); s.R[i] = r.map(row => row.slice()); } };
55
+
56
+ /** Degrees each of joints 1..3 of finger f is flexed: each phalanx against the one before about the knuckle line. */
57
+ function flexion(sk, s, side, f) {
58
+ const P = i => s.P[sk.index[`${side}Hand${f}${i}`]], { d, l } = palmAxes(sk, s, side), axis = scale(l, side === "Left" ? -1 : 1);
59
+ const flat = v => norm(sub(v, scale(axis, dot(v, axis)))), bone = [1, 2, 3].map(i => flat(norm(sub(P(i + 1), P(i)))));
60
+ return [flat(d), bone[0], bone[1]].map((a, i) => (Math.atan2(dot(cross(a, bone[i]), axis), dot(a, bone[i])) * 180) / Math.PI);
61
+ }
62
+
63
+ /** Closest distance between segments p1-q1 and p2-q2. */
64
+ function segmentGap(p1, q1, p2, q2) {
65
+ 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);
66
+ const den = a * e - b * b, clamp = x => Math.min(1, Math.max(0, x));
67
+ let t1 = den > 1e-12 ? clamp((b * f - c * e) / den) : 0, t2 = e > 1e-12 ? (b * t1 + f) / e : 0;
68
+ if (t2 < 0) { t2 = 0; t1 = clamp(-c / a); } else if (t2 > 1) { t2 = 1; t1 = clamp((b - c) / a); }
69
+ return Math.hypot(...sub(add(p1, scale(d1, t1)), add(p2, scale(d2, t2))));
70
+ }
71
+
72
+ /** True when no phalanx of finger f lies within SELF_GAP of a phalanx of another (non-thumb) finger. */
73
+ function clearOfNeighbours(sk, s, side, f) {
74
+ const P = name => s.P[sk.index[`${side}Hand${name}`]];
75
+ for (const g of FINGERS) if (g !== f) for (let a = 1; a <= 3; a++) for (let b = 1; b <= 3; b++) {
76
+ if (segmentGap(P(`${f}${a}`), P(`${f}${a + 1}`), P(`${g}${b}`), P(`${g}${b + 1}`)) < SELF_GAP) return false;
77
+ }
78
+ return true;
79
+ }
80
+
52
81
  /** Turns joint j by the touching turn about `axis` (or `limit` when it never touches, unless `air` is false), weighted; true when it touched. */
53
82
  function closeJoint(sk, s, j, chain, axis, limit, entries, weight, air = true) {
54
83
  const turn = touchTurn(chain.map(i => s.P[i]), s.P[j], axis, limit, entries), deg = turn ?? (air ? limit : 0);
@@ -66,9 +95,7 @@ export function closeOnShape(sk, s, side, { entries: held, stops = [], weight, p
66
95
  // a finger stops on what it holds or on what that rests against (a table under a ball)
67
96
  const entries = [...held, ...stops];
68
97
  const sign = side === "Left" ? -1 : 1, J = name => sk.index[`${side}Hand${name}`], touched = [];
69
- for (const f of FINGERS) {
70
- const joints = [1, 2, 3, 4].map(i => J(`${f}${i}`));
71
- if (joints.some(j => j === undefined)) continue;
98
+ const close = (f, joints) => {
72
99
  let tip = false;
73
100
  // a finger the preset curled into the body straightens, joint by joint, when the straight finger is shallower (a
74
101
  // finger lying across a box would go deeper); each joint then closes until the finger touches
@@ -80,6 +107,35 @@ export function closeOnShape(sk, s, side, { entries: held, stops = [], weight, p
80
107
  if (open > 0) rotateSubtree(sk, s, joints[k], s.P[joints[k]].slice(), axisRot(axis, -open * weight));
81
108
  tip = closeJoint(sk, s, joints[k], joints.slice(k), axis, Math.max(0, fistCurl(f, k) - curl + open), entries, weight, false);
82
109
  }
110
+ return tip;
111
+ };
112
+ for (const f of FINGERS) {
113
+ const joints = [1, 2, 3, 4].map(i => J(`${f}${i}`));
114
+ if (joints.some(j => j === undefined)) continue;
115
+ const before = saveChain(sk, s, joints[0]);
116
+ let tip = close(f, joints);
117
+ // a finger whose curl passes beside what it holds (a ring or little finger off a ball's side) turns toward it at the
118
+ // knuckle, SPREAD_STEP at a time up to SPREAD_MAX, and closes again; kept when it touches without lying in a neighbour
119
+ if (!tip && f !== "Middle") {
120
+ const after = saveChain(sk, s, joints[0]), { l, n } = palmAxes(sk, s, side);
121
+ const toward = Math.sign(dot(sub(held[0].pos, before.get(joints[0])[0]), l)) * sign;
122
+ for (let deg = SPREAD_STEP; deg <= SPREAD_MAX && !tip; deg += SPREAD_STEP) {
123
+ restoreChain(s, before);
124
+ rotateSubtree(sk, s, joints[0], s.P[joints[0]].slice(), axisRot(n, toward * sign * deg * weight));
125
+ tip = close(f, joints) && clearOfNeighbours(sk, s, side, f);
126
+ }
127
+ if (!tip) restoreChain(s, after);
128
+ }
129
+ // a finger that still cannot reach it curls with the finger next in toward the middle (the ring and little fingers
130
+ // move together), joint by joint as far as that finger's flexion, stopping where it touches
131
+ const inner = { Ring: "Middle", Pinky: "Ring" }[f];
132
+ if (!tip && inner) {
133
+ const axis = scale(palmAxes(sk, s, side).l, sign);
134
+ for (let k = 0; k < 3; k++) {
135
+ const more = flexion(sk, s, side, inner)[k] - flexion(sk, s, side, f)[k];
136
+ if (more > 1) tip = closeJoint(sk, s, joints[k], joints.slice(k), axis, more, entries, weight, true);
137
+ }
138
+ }
83
139
  if (tip) touched.push(f);
84
140
  }
85
141
  const thumb = [1, 2, 3, 4].map(i => J(`Thumb${i}`));
@@ -119,6 +175,27 @@ export function heldFor(ctx, c, f, k, s) {
119
175
  return best ?? carriedFor(ctx, c, f, k, s);
120
176
  }
121
177
 
178
+ // A carry outlives the reach that took it: reachGrip lets its grip go after `hold` while the object stays in the hand, and
179
+ // the thumb, wrapped by the grip preset (limbs.js THUMB_WRAP), swung back out 2.3 cm off the ball it held. While hand k
180
+ // holds a seated carry, its grasp's preset is on at full weight, eased in over SETTLE frames from the grab and out over
181
+ // SETTLE before a release (a carry that runs to the end of the take stays held).
182
+ const SETTLE = 4;
183
+ const graspOf = (o, scene, id) => o.grasp ?? ({ sphere: "spherical", cylinder: "power" }[partsOf(scene, id).length === 1 ? shapeOf(scene.objects[partsOf(scene, id)[0]]).kind : "box"] ?? "palm");
184
+
185
+ /** The finger preset weights of hand k at frame f with channels c: the channel's, with a seated carry's grasp preset held on. */
186
+ export function carryFingers(ctx, c, f, k) {
187
+ const { spec, scene } = ctx, own = c[`fingers_${k}`];
188
+ for (const [id, o] of Object.entries(spec.objects ?? {})) {
189
+ if (!seatsInHand(spec, o, scene, id) || spec.props?.[o.prop]?.hand !== k) continue;
190
+ const [grab, release] = ctx.carrySpan(o), on = Math.min(1, (f - grab) / SETTLE, o.release === undefined ? 1 : (release - f) / SETTLE);
191
+ if (!(on > 0)) continue;
192
+ const preset = GRASP_FINGERS[graspOf(o, scene, id)];
193
+ if (preset === "relaxed") return own;
194
+ return { ...own, [preset]: Math.max(own?.[preset] ?? 0, on) };
195
+ }
196
+ return own;
197
+ }
198
+
122
199
  /** A carry on the grasp path hand k holds at frame f, at its seat on pose s (seat.js), as { entries, weight, preset }. */
123
200
  function carriedFor(ctx, c, f, k, s) {
124
201
  const { spec, scene } = ctx;
@@ -131,7 +208,7 @@ function carriedFor(ctx, c, f, k, s) {
131
208
  // where it is on this pose (object-pose.js: easing into its seat over the settle, as it is drawn), not the full seat
132
209
  const entries = partsOf(scene, id).map(p => ({ ...scene.objects[p], ...ctx.objectPose(p, f, { s }) }));
133
210
  const near = Object.entries(ctx.objectsAt(f)).filter(([key, e]) => !partsOf(scene, id).includes(key) && e.half && !e.carried && entries.some(h => Math.hypot(...h.pos.map((v, i) => v - e.pos[i])) < 0.25 + Math.hypot(...e.half))).map(([, e]) => e);
134
- if (entries.length) return { entries, stops: near, weight, preset: c[`fingers_${k}`] };
211
+ if (entries.length) return { entries, stops: near, weight, preset: carryFingers(ctx, c, f, k) };
135
212
  }
136
213
  return null;
137
214
  }
@@ -0,0 +1,70 @@
1
+ // When the trunk turns the head turns first: a person's head leads the pelvis into a turn by about 200 ms (about 5
2
+ // frames at 24 fps), walking or standing. The head takes the turn the trunk (the root's and the spine's yaw) makes
3
+ // over the next LEAD frames, no more than MAX degrees (half the neck's turn one way); inside a walk its heading, so the
4
+ // head leads the walk's turns, not each step's sway (head-steady.js steadies that). Inside every walk (start to last
5
+ // step, head-steady.js walkSpans) and around every turn of the trunk faster than TURN deg/frame (from LEAD + FADE
6
+ // frames before it, so the head starts first), faded in and out over FADE frames, and never where the spec's own keys move
7
+ // head_yaw during that span (keys on it, or on either side of it, that differ): a keyed look wins; a head held still
8
+ // (a stance's head_yaw 0) leads.
9
+ import { walkSpans } from "./head-steady.js";
10
+
11
+ const MAX = 30, TURN = 0.3, FADE = 8, STRIDE = 33;
12
+
13
+ /** Inside a walk the trunk's heading, its sway each step taken out: the median over about a stride keeps a turn's timing. */
14
+ function heading(trunk, walks) {
15
+ const out = trunk.slice(), half = Math.floor(STRIDE / 2), n = trunk.length;
16
+ for (const [f0, f1] of walks) for (let f = Math.max(0, f0); f <= Math.min(n - 1, f1); f++) {
17
+ const w = trunk.slice(Math.max(0, f - half), Math.min(n, f + half + 1)).sort((a, b) => a - b);
18
+ out[f] = w.length % 2 ? w[(w.length - 1) / 2] : (w[w.length / 2 - 1] + w[w.length / 2]) / 2;
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /** Frame runs [f0, f1] (inclusive) where the trunk turns faster than TURN, widened by `before` and `after` frames. */
24
+ function turnSpans(trunk, frameCount, before, after) {
25
+ const spans = [];
26
+ for (let f = 0; f < frameCount - 1; f++) {
27
+ if (Math.abs(trunk[f + 1] - trunk[f]) <= TURN) continue;
28
+ const a = Math.max(0, f - before), b = Math.min(frameCount - 1, f + 1 + after), last = spans.at(-1);
29
+ if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
30
+ else spans.push([a, b]);
31
+ }
32
+ return spans;
33
+ }
34
+
35
+ /** Overlapping or touching spans joined. */
36
+ function merged(spans) {
37
+ const out = [];
38
+ for (const [a, b] of [...spans].sort((x, y) => x[0] - y[0])) {
39
+ const last = out.at(-1);
40
+ if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
41
+ else out.push([a, b]);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** The keys outside walks that move head_yaw during [f0, f1]: those in it and the nearest on either side differ. */
47
+ function looked(keys, f0, f1) {
48
+ const looks = keys.filter(k => k.set?.head_yaw !== undefined && !/\bwalk(To)? (start|step \d+|end)$/.test(k.src ?? "")).sort((a, b) => a.f - b.f);
49
+ const near = [looks.filter(k => k.f < f0).at(-1), ...looks.filter(k => k.f >= f0 && k.f <= f1), looks.find(k => k.f > f1)].filter(Boolean);
50
+ return near.some(k => JSON.stringify(k.set.head_yaw) !== JSON.stringify(near[0].set.head_yaw));
51
+ }
52
+
53
+ /** Adds the turn the trunk makes over the next `lead` frames to the baked head yaw (in place); lead 0 is off. */
54
+ export function leadHead(baked, keys, frameCount, lead = 5) {
55
+ if (!(lead > 0)) return;
56
+ const zero = new Array(frameCount).fill(0), yaw = baked.yaw ?? zero, spine = baked.spine_yaw ?? zero;
57
+ // a retime keeps its keys on fractional frames (fix/retime.js): the spans are frame indices
58
+ const walks = walkSpans(keys).map(([a, b]) => [Math.ceil(a), Math.floor(b)]), trunk = heading(yaw.map((v, f) => v + spine[f]), walks), at = f => trunk[Math.min(frameCount - 1, f)];
59
+ const spans = merged([...walks, ...turnSpans(trunk, frameCount, lead + FADE, FADE)]);
60
+ let head = null;
61
+ for (const [f0, f1] of spans) {
62
+ if (looked(keys, f0, f1)) continue;
63
+ head ??= (baked.head_yaw ?? zero).slice();
64
+ for (let f = Math.max(0, f0); f <= Math.min(frameCount - 1, f1); f++) {
65
+ const fade = Math.min(1, (f - f0) / FADE, (f1 - f) / FADE), d = Math.max(-MAX, Math.min(MAX, at(f + lead) - trunk[f]));
66
+ head[f] += d * fade * fade * (3 - 2 * fade);
67
+ }
68
+ }
69
+ if (head) baked.head_yaw = head;
70
+ }
@@ -9,7 +9,7 @@ const STEADY = { yaw: 0.5, pitch: 0.2, roll: 0.7 }, LIMIT = { yaw: 4, pitch: 6,
9
9
  const AXES = ["yaw", "pitch", "roll"];
10
10
 
11
11
  /** The walk spans in the keys, start to last step: [[f0, f1], ...]. */
12
- function walkSpans(keys) {
12
+ export function walkSpans(keys) {
13
13
  const spans = new Map();
14
14
  for (const k of keys) {
15
15
  const m = / (start|step \d+)$/.exec(k.src ?? ""), tag = m && k.src.slice(0, -m[0].length);