cozyclay 3.1.1 → 3.1.3

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 (195) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-routes.mjs +4 -4
  4. package/bin/agent/agent-runner.mjs +160 -151
  5. package/bin/agent/agent-stream.mjs +86 -0
  6. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  7. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  8. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  9. package/bin/agent/anthropic-subscription.mjs +73 -0
  10. package/bin/agent/claude-code-executable.mjs +93 -0
  11. package/bin/agent/cliproxy-provider.mjs +25 -11
  12. package/bin/agent/main-agent.mjs +21 -7
  13. package/bin/agent/main-prompt.mjs +7 -6
  14. package/bin/agent/model-roles.mjs +112 -0
  15. package/bin/agent/openai-compatible-provider.mjs +95 -0
  16. package/bin/agent/pi-tools.mjs +19 -10
  17. package/bin/agent/provider-login.mjs +3 -1
  18. package/bin/agent/provider-routes.mjs +2 -0
  19. package/bin/agent/providers.mjs +35 -130
  20. package/bin/agent/script-tool.mjs +2 -1
  21. package/bin/agent/studio-prompt.mjs +1 -1
  22. package/bin/agent/studio-rebase.mjs +5 -4
  23. package/bin/agent/studio-task-routes.mjs +1 -1
  24. package/bin/agent/studio-tasks.mjs +116 -25
  25. package/bin/agent/studio-tools.mjs +3 -2
  26. package/bin/cozyclay.mjs +4 -2
  27. package/bin/live-endpoint.mjs +2 -0
  28. package/dist/app/index.html +6 -6
  29. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  30. package/dist/assets/app-BdRlMf4J.css +1 -0
  31. package/dist/assets/app-DwETmoQZ.js +5144 -0
  32. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  33. package/dist/assets/motion3-compile-DDG3wklD.js +1 -0
  34. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  35. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  36. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  37. package/dist/assets/workflow-BMJGd_gZ.js +179 -0
  38. package/dist/cozyclay-package.json +1 -1
  39. package/dist/index.html +1 -1
  40. package/dist/sitemap.xml +1 -1
  41. package/dist/workflow/index.html +4 -4
  42. package/mcp/README.md +1 -1
  43. package/mcp/live-hub.mjs +5 -3
  44. package/mcp/tool-handlers.mjs +5 -5
  45. package/package.json +6 -4
  46. package/src/App.jsx +65 -87
  47. package/src/agent-image-client.js +2 -2
  48. package/src/asset-pane.jsx +14 -14
  49. package/src/bus-task-scopes.js +59 -11
  50. package/src/bus-task-targets.js +35 -9
  51. package/src/bus-tasks.js +23 -10
  52. package/src/character-kind.js +13 -12
  53. package/src/command-bus.js +7 -4
  54. package/src/commands/ai.js +1 -1
  55. package/src/commands/export.js +1 -1
  56. package/src/commands/motion3.js +27 -7
  57. package/src/commands/project.js +2 -1
  58. package/src/commands/shot.js +1 -1
  59. package/src/commands/view.js +2 -2
  60. package/src/domains/cast.js +15 -15
  61. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  62. package/src/domains/motion.js +11 -11
  63. package/src/domains/objects.js +20 -30
  64. package/src/domains/scenes.js +14 -21
  65. package/src/domains/shots.js +3 -3
  66. package/src/export-workspace.js +244 -0
  67. package/src/first-success-guide.jsx +19 -3
  68. package/src/hierarchy-panel.jsx +6 -7
  69. package/src/keyframe-pack.js +5 -1
  70. package/src/live-control.js +14 -7
  71. package/src/locale-zh/app.js +124 -0
  72. package/src/locale-zh/domains.js +36 -0
  73. package/src/locale-zh/labels.js +148 -0
  74. package/src/locale-zh/panels.js +225 -0
  75. package/src/locale-zh/shell.js +230 -0
  76. package/src/locale-zh/timeline.js +145 -0
  77. package/src/locale-zh/workflow.js +104 -0
  78. package/src/locale-zh/workspace.js +190 -0
  79. package/src/locale-zh.js +21 -0
  80. package/src/locale.js +16 -6
  81. package/src/main.jsx +1 -1
  82. package/src/motion/beat-board-model.js +4 -4
  83. package/src/motion/beat-board.jsx +1 -1
  84. package/src/motion/ik-pick.js +24 -0
  85. package/src/motion/take-segments.jsx +5 -5
  86. package/src/motion/timeline.jsx +30 -40
  87. package/src/motion/waypoints.js +6 -6
  88. package/src/motion3/compile-job.js +15 -3
  89. package/src/motion3/compiler/channels.js +74 -24
  90. package/src/motion3/compiler/compile.js +2 -1
  91. package/src/motion3/compiler/idle.js +2 -0
  92. package/src/motion3/compiler/overlap.js +180 -0
  93. package/src/motion3/compiler/pose.js +6 -4
  94. package/src/motion3/compiler/validate-common.js +1 -0
  95. package/src/motion3/compiler/validate-parts.js +5 -1
  96. package/src/motion3/compiler/validate.js +30 -10
  97. package/src/motion3/correct.js +236 -0
  98. package/src/motion3/docs.js +8 -6
  99. package/src/motion3/face-lean.js +31 -0
  100. package/src/motion3/fix/index.js +5 -1
  101. package/src/motion3/fix/reach-body.js +172 -0
  102. package/src/motion3/hint-check.js +44 -0
  103. package/src/motion3/json-patch.js +120 -12
  104. package/src/motion3/lenient.js +46 -4
  105. package/src/motion3/object-keys.js +17 -5
  106. package/src/motion3/pipeline.js +46 -9
  107. package/src/motion3/qa/arm-checks.js +96 -0
  108. package/src/motion3/qa/asked.js +113 -0
  109. package/src/motion3/qa/goals.js +37 -8
  110. package/src/motion3/qa/hints-goal.js +6 -5
  111. package/src/motion3/qa/index.js +5 -2
  112. package/src/motion3/qa/key-checks.js +4 -3
  113. package/src/motion3/qa/motion-life.js +243 -0
  114. package/src/motion3/qa/spec-checks.js +15 -9
  115. package/src/motion3/qa/verdict.js +50 -0
  116. package/src/motion3/refs.js +38 -14
  117. package/src/motion3/repair-watch.js +132 -0
  118. package/src/motion3/repair.js +52 -19
  119. package/src/motion3/report.js +50 -9
  120. package/src/motion3/schema/topics.js +6 -4
  121. package/src/motion3/schema/types.js +1 -1
  122. package/src/motion3/skills/basic.js +15 -8
  123. package/src/motion3/skills/index.js +52 -18
  124. package/src/motion3/skills/info.js +1 -1
  125. package/src/motion3/skills/stance.js +6 -0
  126. package/src/motion3/skills/walk-fit.js +170 -0
  127. package/src/object-catalog.jsx +4 -5
  128. package/src/panels/CameraPanel.jsx +3 -3
  129. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  130. package/src/panels/ObjectTransformPanel.jsx +19 -29
  131. package/src/panels/PoseImagePanel.jsx +11 -10
  132. package/src/panels/PosePanel.jsx +2 -2
  133. package/src/panels/ReferenceImageField.jsx +2 -2
  134. package/src/panels/RigControlPanel.jsx +2 -2
  135. package/src/panels/SculptPanel.jsx +11 -11
  136. package/src/panels/SubjectBox.jsx +3 -3
  137. package/src/panels/SubjectsPanel.jsx +1 -1
  138. package/src/posestudio.jsx +10 -8
  139. package/src/project-browser.css +7 -6
  140. package/src/project-browser.jsx +7 -6
  141. package/src/project-open-file.js +19 -0
  142. package/src/qa-label.js +1 -1
  143. package/src/range-pin-panel.jsx +14 -14
  144. package/src/resource-status.jsx +3 -3
  145. package/src/result-modal.jsx +13 -13
  146. package/src/settings-menu.jsx +4 -1
  147. package/src/shell/BoardSlot.jsx +5 -5
  148. package/src/shell/CompositionGuideButton.jsx +1 -1
  149. package/src/shell/ExportFolderPreference.jsx +103 -0
  150. package/src/shell/GhostsMenuGroup.jsx +1 -1
  151. package/src/shell/PreferencesDialog.jsx +3 -166
  152. package/src/shell/StatusBar.jsx +4 -4
  153. package/src/shell/ViewportToolbar.jsx +3 -3
  154. package/src/shell/add-camera-shot.js +2 -2
  155. package/src/shell/details-selection.js +1 -1
  156. package/src/shell/preference-controls.jsx +115 -0
  157. package/src/shell/preference-pages.jsx +72 -0
  158. package/src/shell/preferences.css +46 -0
  159. package/src/shot-guides.js +5 -5
  160. package/src/shot-prompt.js +29 -9
  161. package/src/stage-hdr.js +3 -3
  162. package/src/stage-labels-ko.js +2 -2
  163. package/src/stage-startup-defaults.js +3 -3
  164. package/src/studio-actions.js +4 -4
  165. package/src/studio-agent-motion.js +19 -9
  166. package/src/studio-agent-protocol.js +1 -1
  167. package/src/studio-app-binding.js +3 -2
  168. package/src/styles/tokens.css +1 -1
  169. package/src/styles.css +1 -1
  170. package/src/use-case-question.jsx +18 -18
  171. package/src/workflow/AgentPanel.jsx +6 -6
  172. package/src/workflow/AgentPlanCard.jsx +1 -1
  173. package/src/workflow/AgentProgressCard.jsx +4 -4
  174. package/src/workflow/AgentTurnSteps.jsx +1 -1
  175. package/src/workflow/CozySceneNode.jsx +1 -1
  176. package/src/workflow/LoginPanel.jsx +1 -1
  177. package/src/workflow/TaskBoard.jsx +1 -1
  178. package/src/workflow/TaskInline.jsx +1 -1
  179. package/src/workflow/WorkflowBuilder.jsx +2 -2
  180. package/src/workflow/agent-client.js +1 -1
  181. package/src/workflow/agent-turn.js +5 -5
  182. package/src/workflow/keyframe-pack-request.js +3 -10
  183. package/src/workflow/task-board.css +1 -1
  184. package/src/workflow/task-board.js +11 -9
  185. package/tools/loc-baseline.json +2 -3
  186. package/tools/run-tests.mjs +9 -9
  187. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  188. package/dist/assets/app-BlwOpest.css +0 -1
  189. package/dist/assets/app-DmR7jBkz.js +0 -5144
  190. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  191. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  192. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  193. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  194. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  195. package/dist/assets/workflow-zQM-EKW1.js +0 -179
@@ -2,7 +2,7 @@ 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
4
 
5
- import { ko, isKo } from "../locale.js";
5
+ import { ko } from "../locale.js";
6
6
  import { isImeComposing } from "../ime.js";
7
7
  import { buildRail, craneHeightAt } from "../camera-follow.js";
8
8
  import { insertPathMark, pathMarks, pathMetrics, pathProgressAt, MAX_PATH_LEAN } from "../object-path.js";
@@ -276,7 +276,7 @@ function CraneHeightEditor({ crane, railRange, durationFrames, selectedIndex, on
276
276
  // an end point sits ON the window edge; clamp it inside so the knob stays whole. The clamp is the
277
277
  // knob's own radius: a wider one pinned every knob of a 20 px Shot strip to one row, off its curve.
278
278
  style={{ left: `clamp(5px, ${xFor(point.t) * 100}%, calc(100% - 5px))`, top: `clamp(5px, ${yFor(point.height) * 100}%, calc(100% - 5px))` }}
279
- aria-label={ko(`Crane point ${index + 1}, ${point.height.toFixed(1)} metres`, `크레인 점 ${index + 1}, ${point.height.toFixed(1)}미터`)}
279
+ aria-label={ko(`Crane point ${index + 1}, ${point.height.toFixed(1)} metres`, `크레인 점 ${index + 1}, ${point.height.toFixed(1)}미터`, `摇臂点 ${index + 1},${point.height.toFixed(1)} 米`)}
280
280
  onPointerDown={(event) => {
281
281
  event.preventDefault();
282
282
  event.stopPropagation();
@@ -780,7 +780,7 @@ function ObjectTravelTrack({ object, frame, frameCount, fps, pathDraw, onPathDra
780
780
  <span className="tl-track-label">{ko("Speed", "속도 곡선")}</span>
781
781
  <div className="tl-lane sg-lane">
782
782
  <SpeedGraph
783
- facts={metrics && span ? `${metrics.length.toFixed(1)} m · ${seconds.toFixed(1)}${isKo ? "초" : "s"}` : null}
783
+ facts={metrics && span ? `${metrics.length.toFixed(1)} m · ${seconds.toFixed(1)}${ko("s", "초", "秒")}` : null}
784
784
  timing={path.timing ?? null}
785
785
  windowFrac={span.fills ? 1 : span.end / Math.max(1, frameCount - 1)}
786
786
  frame={frame}
@@ -828,7 +828,7 @@ function CameraBlockEditor({
828
828
  const numberValue = (event) => Number(event.currentTarget.value);
829
829
  const metric = (value, places = 1) => Number(value).toFixed(places);
830
830
  return (
831
- <section className="tl-camera-editor" aria-label={ko(`Camera controls for ${shot.name}`, `${shot.name} 카메라 컨트롤`)}>
831
+ <section className="tl-camera-editor" aria-label={ko(`Camera controls for ${shot.name}`, `${shot.name} 카메라 컨트롤`, `${shot.name} 的摄影机控制`)}>
832
832
  <strong className="tl-camera-editor-title">
833
833
  <span className="tl-subject-kind">{ko("CAMERA", "카메라")}</span>
834
834
  {shot.name}
@@ -1666,7 +1666,7 @@ export default function Timeline({
1666
1666
  title={ko("Enable or disable root path constraints (P)", "루트 경로 제약 켜기/끄기 (P)")}
1667
1667
  onClick={() => handlers.current.onWaypointToggle?.()}
1668
1668
  >
1669
- {isKo ? `웨이포인트 ${waypointMode ? "켜짐" : "꺼짐"}` : `Waypoint ${waypointMode ? "on" : "off"}`}
1669
+ {ko(`Waypoint ${waypointMode ? "on" : "off"}`, `웨이포인트 ${waypointMode ? "켜짐" : "꺼짐"}`, `路径点${waypointMode ? "开" : "关"}`)}
1670
1670
  </button>
1671
1671
  </div>
1672
1672
  <div className="tl-head-group tl-pose-tools" role="group" aria-label={ko("Pose correction tools", "포즈 보정 도구")}>
@@ -1687,7 +1687,7 @@ export default function Timeline({
1687
1687
  title={ko("Foot snap — keep the feet planted while you move the body (hips); the knees bend instead of the feet sinking through the floor", "발 스냅 — 몸(엉덩이)을 움직여도 발을 바닥에 고정합니다. 발이 바닥으로 가라앉는 대신 무릎이 구부러집니다")}
1688
1688
  onClick={() => handlers.current.onFootSnapToggle?.()}
1689
1689
  >
1690
- {isKo ? `스냅 ${footSnap ? "켜짐" : "꺼짐"}` : `Snap ${footSnap ? "on" : "off"}`}
1690
+ {ko(`Snap ${footSnap ? "on" : "off"}`, `스냅 ${footSnap ? "켜짐" : "꺼짐"}`, `吸附${footSnap ? "开" : "关"}`)}
1691
1691
  </button>
1692
1692
  <button
1693
1693
  type="button"
@@ -1697,7 +1697,7 @@ export default function Timeline({
1697
1697
  title={ko("Body contact — keep hands, knees, feet, head, and hips above the floor", "바닥 접촉 — 손, 무릎, 발, 머리, 엉덩이가 바닥 아래로 내려가지 않게 합니다")}
1698
1698
  onClick={() => handlers.current.onBodyContactToggle?.()}
1699
1699
  >
1700
- {isKo ? `바닥 접촉 ${bodyContact ? "켜짐" : "꺼짐"}` : `Body contact ${bodyContact ? "on" : "off"}`}
1700
+ {ko(`Body contact ${bodyContact ? "on" : "off"}`, `바닥 접촉 ${bodyContact ? "켜짐" : "꺼짐"}`, `身体接触${bodyContact ? "开" : "关"}`)}
1701
1701
  </button>
1702
1702
  </>)}
1703
1703
  </>)}
@@ -1719,18 +1719,10 @@ export default function Timeline({
1719
1719
  {waypointMode && (
1720
1720
  <span className={"tl-wp-hint" + (waypointFrames.length < 2 || pathSpeed?.warn ? " warn" : "")}>
1721
1721
  {waypointFrames.length < 2
1722
- ? ko("Click the set floor in the Shot view to drop waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 놓으세요")
1723
- : isKo
1724
- ? `루트 웨이포인트 ${waypointFrames.length}개` +
1725
- (pathSpeed
1726
- ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — 자연스러운 이동 속도 0.5–3 m/s 범위를 벗어남" : ""}`
1727
- : "") +
1728
- " · 세트 바닥을 클릭해 더 추가"
1729
- : `${waypointFrames.length} root waypoints` +
1730
- (pathSpeed
1731
- ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — outside the natural 0.5–3 m/s locomotion band" : ""}`
1732
- : "") +
1733
- " · click the set floor to add more"}
1722
+ ? ko("Click the set floor in the Shot view to drop waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 놓으세요", "在镜头视图中点击布景地面放置路径点")
1723
+ : ko(`${waypointFrames.length} root waypoints` + (pathSpeed ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — outside the natural 0.5–3 m/s locomotion band" : ""}` : "") + " · click the set floor to add more",
1724
+ `루트 웨이포인트 ${waypointFrames.length}개` + (pathSpeed ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — 자연스러운 이동 속도 0.5–3 m/s 범위를 벗어남" : ""}` : "") + " · 세트 바닥을 클릭해 더 추가",
1725
+ `${waypointFrames.length} 个根路径点` + (pathSpeed ? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — 超出自然行走 0.5–3 m/s 区间" : ""}` : "") + " · 点击布景地面继续添加")}
1734
1726
  </span>
1735
1727
  )}
1736
1728
  {badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
@@ -1766,7 +1758,7 @@ export default function Timeline({
1766
1758
  <div className="tl-body" ref={bodyRef}>
1767
1759
  <div className={"tl-surface" + (!shots.length ? " empty-shots" : "") + (pathObject ? " prop-subject" : "")} style={{ "--tl-zoom": surfaceZoom }}>
1768
1760
  <div className="tl-ruler">
1769
- <span className="tl-ruler-label">{ko("Frame", "프레임")}</span>
1761
+ <span className="tl-ruler-label">{ko("Frame", "프레임", "帧")}</span>
1770
1762
  <div
1771
1763
  className="tl-ruler-lane"
1772
1764
  ref={rulerRef}
@@ -1846,7 +1838,7 @@ export default function Timeline({
1846
1838
  {name === "2D Root" && pathSpeed && (
1847
1839
  <em
1848
1840
  className={"tl-path-speed" + (pathSpeed.warn ? " warn" : "")}
1849
- title={isKo ? `핀 구간 속도 ${pathSpeed.min.toFixed(1)}~${pathSpeed.max.toFixed(1)} m/s — 자연 보행은 0.8~1.2 m/s` : `Leg speeds ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s — natural gait is 0.8–1.2 m/s`}
1841
+ title={ko(`Leg speeds ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s — natural gait is 0.8–1.2 m/s`, `핀 구간 속도 ${pathSpeed.min.toFixed(1)}~${pathSpeed.max.toFixed(1)} m/s — 자연 보행은 0.8~1.2 m/s`, `腿速 ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s — 自然步态为 0.8–1.2 m/s`)}
1850
1842
  >
1851
1843
  {pathSpeed.min === pathSpeed.max
1852
1844
  ? `${pathSpeed.min.toFixed(1)} m/s`
@@ -1869,7 +1861,7 @@ export default function Timeline({
1869
1861
  <button
1870
1862
  className="tl-track-add ik"
1871
1863
  type="button"
1872
- title={isKo ? `현재 포즈를 ${frame}프레임에 키로 저장` : `Key the current pose at frame ${frame}`}
1864
+ title={ko(`Key the current pose at frame ${frame}`, `현재 포즈를 ${frame}프레임에 키로 저장`, `在当前帧 ${frame} 记录姿势关键帧`)}
1873
1865
  onClick={() => handlers.current.onIkKeyframeAdd?.()}
1874
1866
  >
1875
1867
  +
@@ -1895,7 +1887,7 @@ export default function Timeline({
1895
1887
  const endFrame = Number.isFinite(Number(range?.endFrame)) ? Number(range.endFrame) : Number(range?.end ?? startFrame);
1896
1888
  const start = Math.max(0, Math.min(displayFrameCount - 1, Math.round(startFrame)));
1897
1889
  const end = Math.max(start, Math.min(displayFrameCount - 1, Math.round(endFrame)));
1898
- return <span key={`preview:${index}`} className="tl-preview-range" style={{ "--tl-f-start": framePct(start, displayFrameCount), "--tl-f-end": framePct(end, displayFrameCount) }} aria-label={ko(`Preview range ${start}–${end}`, `미리보기 범위 ${start}–${end}`)} />;
1890
+ return <span key={`preview:${index}`} className="tl-preview-range" style={{ "--tl-f-start": framePct(start, displayFrameCount), "--tl-f-end": framePct(end, displayFrameCount) }} aria-label={ko(`Preview range ${start}–${end}`, `미리보기 범위 ${start}–${end}`, `预览范围 ${start}–${end}`)} />;
1899
1891
  })}
1900
1892
  {name === SHOTS_LANE && shots.length === 0 && (
1901
1893
  <div className="tl-shot-empty">
@@ -1940,7 +1932,7 @@ export default function Timeline({
1940
1932
  className={"tl-shot-block" + (index === cameraBlockIdx ? " selected" : "") + (index === activeShotIdx ? " active" : "") + (movingShotId === shot.id ? " moving" : "") + (!railOff && railRange && railLengthByShot.has(shot.id) ? " has-dolly" : "")}
1941
1933
  data-shot-kind={shot.kind === "still" ? "still" : undefined}
1942
1934
  style={{ "--tl-f-start": geometry.startPct, "--tl-f-end": geometry.endPct }}
1943
- title={isKo ? `${shot.name} · ${shot.startFrame}–${lastFrame}프레임 · 드래그해 순서 이동, 양끝으로 컷 조절, 아래 빈 줄을 클릭해 카메라 키 추가` : `${shot.name} · frames ${shot.startFrame}–${lastFrame} · drag to reorder, trim cuts at either edge, click the empty lower strip to add a camera key`}
1935
+ title={ko(`${shot.name} · frames ${shot.startFrame}–${lastFrame} · drag to reorder, trim cuts at either edge, click the empty lower strip to add a camera key`, `${shot.name} · ${shot.startFrame}–${lastFrame}프레임 · 드래그해 순서 이동, 양끝으로 컷 조절, 아래 빈 줄을 클릭해 카메라 키 추가`, `${shot.name} · 第 ${shot.startFrame}–${lastFrame} 帧 · 拖动调整顺序,拖动两端修剪剪辑,点击下方空条添加摄影机关键帧`)}
1944
1936
  onPointerDown={(e) => beginShotMove(e, shot, index)}
1945
1937
  onPointerMove={moveShot}
1946
1938
  onPointerUp={endShotMove}
@@ -1951,7 +1943,7 @@ export default function Timeline({
1951
1943
  <button
1952
1944
  type="button"
1953
1945
  className="tl-shot-edge start"
1954
- aria-label={ko(`Resize start of ${shot.name}`, `${shot.name} 시작점 조절`)}
1946
+ aria-label={ko(`Resize start of ${shot.name}`, `${shot.name} 시작점 조절`, `调整 ${shot.name} 的起点`)}
1955
1947
  onPointerDown={(e) => beginShotBoundaryDrag(e, index, "start")}
1956
1948
  onPointerMove={moveShotBoundary}
1957
1949
  onPointerUp={endShotBoundaryDrag}
@@ -1962,7 +1954,7 @@ export default function Timeline({
1962
1954
  <button
1963
1955
  type="button"
1964
1956
  className="tl-shot-edge end"
1965
- aria-label={ko(`Resize end of ${shot.name}`, `${shot.name} 끝 길이 조절`)}
1957
+ aria-label={ko(`Resize end of ${shot.name}`, `${shot.name} 끝 길이 조절`, `调整 ${shot.name} 的终点`)}
1966
1958
  onPointerDown={(e) => beginShotBoundaryDrag(e, index, "end")}
1967
1959
  onPointerMove={moveShotBoundary}
1968
1960
  onPointerUp={endShotBoundaryDrag}
@@ -2065,8 +2057,8 @@ export default function Timeline({
2065
2057
  type="button"
2066
2058
  className="tl-shot-key-surface"
2067
2059
  aria-label={shot.camera?.mode === "rail" && shot.camera?.craneHeight
2068
- ? ko(`Add crane point in ${shot.name}`, `${shot.name}에 크레인 점 추가`)
2069
- : ko(`Add camera key in ${shot.name}`, `${shot.name}에 카메라 키 추가`)}
2060
+ ? ko(`Add crane point in ${shot.name}`, `${shot.name}에 크레인 점 추가`, `在 ${shot.name} 中添加摇臂点`)
2061
+ : ko(`Add camera key in ${shot.name}`, `${shot.name}에 카메라 키 추가`, `在 ${shot.name} 中添加摄影机关键帧`)}
2070
2062
  title={shot.camera?.mode === "rail" && shot.camera?.craneHeight
2071
2063
  ? ko("Click the crane graph to add a point; click a point and drag it to change height", "크레인 그래프를 클릭해 점 추가 · 점을 눌러 끌어 높이 조절")
2072
2064
  : ko("Click at a frame to store the current camera framing", "프레임 위치를 클릭해 현재 카메라 프레이밍을 저장합니다")}
@@ -2089,7 +2081,7 @@ export default function Timeline({
2089
2081
  "--tl-pin-ramp-end": geometry.rampEndPct,
2090
2082
  "--tl-pin-color": "var(--select, #e8a33d)",
2091
2083
  }}
2092
- title={ko(`${pin.track} pin · frames ${pin.startFrame}–${pin.endFrame}`, `${pin.track} 고정 · ${pin.startFrame}–${pin.endFrame}프레임`)}
2084
+ title={ko(`${pin.track} pin · frames ${pin.startFrame}–${pin.endFrame}`, `${pin.track} 고정 · ${pin.startFrame}–${pin.endFrame}프레임`, `${pin.track} 固定 · 第 ${pin.startFrame}–${pin.endFrame} 帧`)}
2093
2085
  onPointerDown={(event) => {
2094
2086
  if (event.button !== 0) return;
2095
2087
  event.stopPropagation();
@@ -2108,7 +2100,7 @@ export default function Timeline({
2108
2100
  return <span
2109
2101
  className="tl-pin-pending"
2110
2102
  style={{ "--tl-f-start": geometry.startPct, "--tl-f-end": geometry.endPct }}
2111
- aria-label={ko(`Pending pin range ${geometry.startFrame}–${geometry.endFrame}`, `대기 중인 고정 범위 ${geometry.startFrame}–${geometry.endFrame}`)}
2103
+ aria-label={ko(`Pending pin range ${geometry.startFrame}–${geometry.endFrame}`, `대기 중인 고정 범위 ${geometry.startFrame}–${geometry.endFrame}`, `待定固定范围 ${geometry.startFrame}–${geometry.endFrame}`)}
2112
2104
  />;
2113
2105
  })()}
2114
2106
  {name === IK_LANE && motion?.segments && <TakeSegmentClips motion={motion} frame={frame} frameCount={frameCount} displayFrameCount={displayFrameCount} clipPct={clipPct} tools={motionTools} />}{name === IK_LANE && <BeatBoard motion={motion} frame={frame} displayFrameCount={displayFrameCount} clipPct={clipPct} onJump={(f) => handlers.current.onScrub?.(f)} />}
@@ -2121,7 +2113,7 @@ export default function Timeline({
2121
2113
  key={f}
2122
2114
  className={"tl-marker ik" + (f === frame ? " current" : "")}
2123
2115
  style={{ "--tl-f": framePct(f, displayFrameCount) }}
2124
- title={isKo ? `${f}프레임의 전신 IK 키 — 클릭해 이동, 오른쪽 클릭으로 삭제` : `Full-body IK key at frame ${f} — click to jump, right-click to remove`}
2116
+ title={ko(`Full-body IK key at frame ${f} — click to jump, right-click to remove`, `${f}프레임의 전신 IK 키 — 클릭해 이동, 오른쪽 클릭으로 삭제`, `第 ${f} 帧的全身 IK 键 — 点击跳转,右键删除`)}
2125
2117
  onPointerDown={(e) => {
2126
2118
  if (e.button !== 0) return;
2127
2119
  e.stopPropagation();
@@ -2142,9 +2134,9 @@ export default function Timeline({
2142
2134
  key={`run:${run.start}`}
2143
2135
  className={"tl-ik-run" + (frame >= run.start && frame <= run.end ? " current" : "")}
2144
2136
  style={{ "--tl-f": framePct(run.start, displayFrameCount), "--tl-f2": framePct(run.end, displayFrameCount) }}
2145
- title={isKo
2146
- ? `${run.start}–${run.end} 전신 IK 키 (${run.length}개) — 클릭해 그 프레임으로 이동, 오른쪽 클릭으로 그 프레임 키 삭제`
2147
- : `IK keys ${run.start}–${run.end} (${run.length}) — click to jump to that frame, right-click to remove that frame's key`}
2137
+ title={ko(`IK keys ${run.start}–${run.end} (${run.length}) — click to jump to that frame, right-click to remove that frame's key`,
2138
+ `${run.start}–${run.end} 전신 IK 키 (${run.length}개) — 클릭해 그 프레임으로 이동, 오른쪽 클릭으로 그 프레임 키 삭제`,
2139
+ `IK 键 ${run.start}–${run.end}(${run.length} 个)— 点击跳转到该帧,右键删除该帧的键`)}
2148
2140
  onPointerDown={(e) => {
2149
2141
  if (e.button !== 0) return;
2150
2142
  e.stopPropagation();
@@ -2181,7 +2173,7 @@ export default function Timeline({
2181
2173
  key={f}
2182
2174
  className={"tl-marker wp" + (f === pendingWaypointFrame ? " pending" : "")}
2183
2175
  style={{ "--tl-f": framePct(f, displayFrameCount) }}
2184
- title={isKo ? `${f}프레임의 루트 웨이포인트 — 클릭해 선택, 오른쪽 클릭으로 삭제. 탑뷰에서 ${waypointFrames.indexOf(f) + 1}번 마커를 드래그해 이동` : `Root waypoint at frame ${f} — click to select, right-click to remove; drag marker ${waypointFrames.indexOf(f) + 1} in Top-View to move`}
2176
+ title={ko(`Root waypoint at frame ${f} — click to select, right-click to remove; drag marker ${waypointFrames.indexOf(f) + 1} in Top-View to move`, `${f}프레임의 루트 웨이포인트 — 클릭해 선택, 오른쪽 클릭으로 삭제. 탑뷰에서 ${waypointFrames.indexOf(f) + 1}번 마커를 드래그해 이동`, `第 ${f} 帧的根路径点 — 点击选中,右键删除;在顶视图中拖动第 ${waypointFrames.indexOf(f) + 1} 个标记移动`)}
2185
2177
  onPointerDown={(e) => {
2186
2178
  // Select only on the primary button — a right click
2187
2179
  // must reach contextmenu so it removes without scrubbing.
@@ -2202,7 +2194,7 @@ export default function Timeline({
2202
2194
  key={`${shot.id}:${key.frame}`}
2203
2195
  className="tl-marker cam" data-edge={key.frame <= shot.startFrame ? "start" : key.frame >= shot.endFrame ? "end" : undefined}
2204
2196
  style={{ "--tl-f": framePct(key.frame, displayFrameCount) }}
2205
- title={isKo ? `${key.frame}프레임의 카메라 키 — 클릭해 이동, 드래그로 시간 변경, 오른쪽 클릭으로 삭제` : `Camera key at frame ${key.frame} — click to jump, drag to re-time, right-click to remove`}
2197
+ title={ko(`Camera key at frame ${key.frame} — click to jump, drag to re-time, right-click to remove`, `${key.frame}프레임의 카메라 키 — 클릭해 이동, 드래그로 시간 변경, 오른쪽 클릭으로 삭제`, `第 ${key.frame} 帧的摄影机关键帧 — 点击跳转,拖动改变时间,右键删除`)}
2206
2198
  onPointerDown={(e) => beginCameraKeyDrag(e, key, index)}
2207
2199
  onPointerMove={moveCameraKeyDrag}
2208
2200
  onPointerUp={endCameraKeyDrag}
@@ -2229,10 +2221,8 @@ export default function Timeline({
2229
2221
  {waypointMode && (
2230
2222
  <span className={"tl-wp-hint" + (waypointFrames.length < 2 ? " warn" : "")}>
2231
2223
  {waypointFrames.length < 2
2232
- ? ko("Click the set floor in the Shot view to add waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 추가하세요")
2233
- : isKo
2234
- ? `루트 웨이포인트 ${waypointFrames.length}개 · 세트 바닥을 클릭해 더 추가`
2235
- : `${waypointFrames.length} root waypoints · click the set floor to add more`}
2224
+ ? ko("Click the set floor in the Shot view to add waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 추가하세요", "在镜头视图中点击布景地面添加路径点")
2225
+ : ko(`${waypointFrames.length} root waypoints · click the set floor to add more`, `루트 웨이포인트 ${waypointFrames.length}개 · 세트 바닥을 클릭해 더 추가`, `${waypointFrames.length} 个根路径点 · 点击布景地面继续添加`)}
2236
2226
  </span>
2237
2227
  )}
2238
2228
  <button
@@ -1,4 +1,4 @@
1
- import { isKo } from "../locale.js";
1
+ import { ko } from "../locale.js";
2
2
 
3
3
  // Root path pins: the limits keep an authored path a person could walk, and a
4
4
  // capsule figure that rides the path moves at a believable pace.
@@ -28,7 +28,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
28
28
  if (gap < PATH_LIMITS.minPinGapFrames) {
29
29
  return {
30
30
  ok: false,
31
- error: isKo ? `핀 사이에는 최소 ${PATH_LIMITS.minPinGapFrames}프레임(${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)}초)이 필요해요 — ${last.frame + PATH_LIMITS.minPinGapFrames}프레임 이후로 이동해 주세요` : `pins need at least ${PATH_LIMITS.minPinGapFrames} frames between them (${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} s) — scrub past frame ${last.frame + PATH_LIMITS.minPinGapFrames}`,
31
+ error: ko(`pins need at least ${PATH_LIMITS.minPinGapFrames} frames between them (${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} s) — scrub past frame ${last.frame + PATH_LIMITS.minPinGapFrames}`, `핀 사이에는 최소 ${PATH_LIMITS.minPinGapFrames}프레임(${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)}초)이 필요해요 — ${last.frame + PATH_LIMITS.minPinGapFrames}프레임 이후로 이동해 주세요`, `路径点之间至少需要 ${PATH_LIMITS.minPinGapFrames} 帧(${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} 秒)— 请移动到第 ${last.frame + PATH_LIMITS.minPinGapFrames} 帧之后`),
32
32
  warnings: [],
33
33
  };
34
34
  }
@@ -40,7 +40,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
40
40
  const neededFrames = Math.ceil((dist / PATH_LIMITS.speedMaxMps) * fps);
41
41
  return {
42
42
  ok: false,
43
- error: isKo ? `${speed.toFixed(1)} m/s는 사람이 움직이기엔 너무 빨라요(최대 ${PATH_LIMITS.speedMaxMps}) — ${last.frame + neededFrames}프레임 이후에 핀을 찍거나 더 가까운 곳을 눌러 주세요` : `${speed.toFixed(1)} m/s is faster than anyone moves (max ${PATH_LIMITS.speedMaxMps}) — pin frame ${last.frame + neededFrames} or later, or click closer`,
43
+ error: ko(`${speed.toFixed(1)} m/s is faster than anyone moves (max ${PATH_LIMITS.speedMaxMps}) — pin frame ${last.frame + neededFrames} or later, or click closer`, `${speed.toFixed(1)} m/s는 사람이 움직이기엔 너무 빨라요(최대 ${PATH_LIMITS.speedMaxMps}) — ${last.frame + neededFrames}프레임 이후에 핀을 찍거나 더 가까운 곳을 눌러 주세요`, `${speed.toFixed(1)} m/s 比人体移动速度快(上限 ${PATH_LIMITS.speedMaxMps})— 请在第 ${last.frame + neededFrames} 帧或之后打点,或点击更近的位置`),
44
44
  warnings: [],
45
45
  };
46
46
  }
@@ -49,7 +49,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
49
49
  const neededDist = (PATH_LIMITS.speedMinMps * dt).toFixed(1);
50
50
  return {
51
51
  ok: false,
52
- error: isKo ? `${speed.toFixed(2)} m/s는 자연스럽게 걷기엔 너무 느려요(최소 ${PATH_LIMITS.speedMinMps}) — 대신 약 ${walkFrame}프레임에 핀을 찍거나 최소 ${neededDist} m 이상 떨어진 곳을 눌러 주세요` : `${speed.toFixed(2)} m/s is below a sustainable walk (min ${PATH_LIMITS.speedMinMps}) — pin frame ~${walkFrame} instead, or click at least ${neededDist} m away`,
52
+ error: ko(`${speed.toFixed(2)} m/s is below a sustainable walk (min ${PATH_LIMITS.speedMinMps}) — pin frame ~${walkFrame} instead, or click at least ${neededDist} m away`, `${speed.toFixed(2)} m/s는 자연스럽게 걷기엔 너무 느려요(최소 ${PATH_LIMITS.speedMinMps}) — 대신 약 ${walkFrame}프레임에 핀을 찍거나 최소 ${neededDist} m 이상 떨어진 곳을 눌러 주세요`, `${speed.toFixed(2)} m/s 低于可持续步行速度(下限 ${PATH_LIMITS.speedMinMps})— 请改为在第 ${walkFrame} 帧附近打点,或点击至少 ${neededDist} m 外的位置`),
53
53
  warnings: [],
54
54
  };
55
55
  }
@@ -60,7 +60,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
60
60
  const prevSpeed = prevDist / ((last.frame - prev.frame) / fps);
61
61
  const ratio = Math.max(speed, prevSpeed) / Math.max(Math.min(speed, prevSpeed), 1e-6);
62
62
  if (ratio > PATH_LIMITS.speedRatioWarn) {
63
- warnings.push(isKo ? `이전 구간보다 속도가 ${ratio.toFixed(1)}배 달라요 — 걸음 변화가 눈에 보일 수 있어요` : `${ratio.toFixed(1)}x speed change from the previous leg — expect a visible gait shift`);
63
+ warnings.push(ko(`${ratio.toFixed(1)}x speed change from the previous leg — expect a visible gait shift`, `이전 구간보다 속도가 ${ratio.toFixed(1)}배 달라요 — 걸음 변화가 눈에 보일 수 있어요`, `与前一段相比速度变化 ${ratio.toFixed(1)} 倍 — 步态变化可能很明显`));
64
64
  }
65
65
  const before = Math.atan2(last.x - prev.x, last.z - prev.z);
66
66
  const after = Math.atan2(candidate.x - last.x, candidate.z - last.z);
@@ -68,7 +68,7 @@ export function judgeNextWaypoint(last, candidate, fps, prev = null) {
68
68
  if (turn > Math.PI) turn = 2 * Math.PI - turn;
69
69
  const turnRate = (turn * 180) / Math.PI / dt;
70
70
  if (turnRate > PATH_LIMITS.turnRateWarnDegPerS) {
71
- warnings.push(isKo ? `${dt.toFixed(1)}초 동안 ${Math.round((turn * 180) / Math.PI)}° 회전은 걷는 동선치고 너무 급해요 — 프레임이나 거리를 더 주세요` : `${Math.round((turn * 180) / Math.PI)}° turn in ${dt.toFixed(1)} s is sharper than a walking arc — give it more frames or distance`);
71
+ warnings.push(ko(`${Math.round((turn * 180) / Math.PI)}° turn in ${dt.toFixed(1)} s is sharper than a walking arc — give it more frames or distance`, `${dt.toFixed(1)}초 동안 ${Math.round((turn * 180) / Math.PI)}° 회전은 걷는 동선치고 너무 급해요 — 프레임이나 거리를 더 주세요`, `${dt.toFixed(1)} 秒内转向 ${Math.round((turn * 180) / Math.PI)}° 比步行弧线更急 — 请增加帧数或距离`));
72
72
  }
73
73
  }
74
74
  }
@@ -3,11 +3,13 @@
3
3
  // name, the motion scene (scene.js motionScene of the editor's objects: oriented boxes; shapes.js reads a sphere or a cylinder round from them), the spec as sent, and
4
4
  // what to do with it (probes, checks, lenient, repair, bake). The answer is plain data and typed arrays
5
5
  // only (the compiler's out holds closures), so it crosses a worker boundary: QA, observations, the stored
6
- // spec, and the baked take when asked.
6
+ // spec, and the baked take when asked. The hint patches QA offers are compiled first (hint-check.js).
7
7
  // A refusal is an Error with a code (INVALID_ARGUMENT, MOTION_SPEC_INVALID turned into it), which the
8
8
  // client turns back into the Studio's protocol error.
9
9
  import { compileSpec, liftRequest } from "./pipeline.js";
10
+ import { prepareSpec } from "./correct.js";
10
11
  import { repairSpec } from "./repair.js";
12
+ import { checkHints } from "./hint-check.js";
11
13
  import { bakeTake } from "./take.js";
12
14
  import { skeletonFor } from "./rigs/index.js";
13
15
  import { keptPeople } from "./people.js";
@@ -49,7 +51,17 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
49
51
  const r = asArgument(() => compileSpec(sent, { sk, scene }, { ...opts, probes }));
50
52
  return { ...r, stored: r.spec === sent ? spec : r.spec };
51
53
  };
52
- const t0 = performance.now(), fixed = repair ? repairSpec(asked, repair, once) : { spec: asked, result: once(asked) };
54
+ // repair patches address the stored spec, so a repair starts from it corrected (correct.js), and says what was
55
+ const corrected = [], start = repair && !lenient ? prepareSpec(asked, { sk, scene }, corrected) : asked;
56
+ // every compile's QA by its spec, so a hint patch repair already tried is not compiled again (hint-check.js)
57
+ const seen = new Map(), measured = s => { const r = once(s); seen.set(JSON.stringify(s), r.qa); return r; };
58
+ const t0 = performance.now(), fixed = repair ? repairSpec(start, repair, measured) : { spec: asked, result: measured(asked) };
59
+ // a hint's patch is offered only when its compile helped (hint-check.js), and only by a compile that repairs: repair has
60
+ // compiled most of the patches already (seen), where a plain compile paid two more compiles for them (bench replay: first
61
+ // compile 1.6 -> 5.2 s median) and the agent applied an offered patch as sent once in the 60 bench runs (374 direct
62
+ // compiles, 319 with repair; a plain compile is mostly a variant in a sweep); a plain compile keeps the hint's words only
63
+ if (repair) checkHints(fixed.spec, fixed.result.qa, s => seen.get(JSON.stringify(s)) ?? measured(s).qa);
64
+ else for (const g of fixed.result.qa?.groups ?? []) delete g.patch;
53
65
  // ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
54
66
  const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
55
67
  const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
@@ -57,7 +69,7 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
57
69
  const people = take && keptPeople(scene, [r.expanded, r.stored]);
58
70
  if (people) take.people = people;
59
71
  return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
60
- ...(r.probes ? { probes: r.probes } : {}), ...(r.notes ? { notes: r.notes } : {}), ms, frameCount: r.out.frameCount, label,
72
+ ...(r.probes ? { probes: r.probes } : {}), ...(r.notes || corrected.length ? { notes: [...corrected, ...(r.notes ?? [])] } : {}), ms, frameCount: r.out.frameCount, label,
61
73
  ...(fixed.repair ? { repair: fixed.repair } : {}), ...(take ? { take } : {}) };
62
74
  }
63
75
 
@@ -12,7 +12,11 @@
12
12
  // flow is gone by the time the arm is a quarter of the way onto it: an arm's swing and bend by its
13
13
  // own armw, the spine (which carries the shoulders) and the head (the hands work near it) by the
14
14
  // larger of the two; a reach or a held prop lands as keyed. A hold (two equal keys in a row) stays
15
- // exactly flat either way.
15
+ // exactly flat either way. Overlap (overlap.js) breaks each change along the chains on top of that: the
16
+ // lower spine, the chest (chest_*: the top spine bone's share, baked beside spine_*), the head, the upper
17
+ // arm, the forearm and the wrist each take a later delay, one side of the body trails the other, and the
18
+ // arm's swing and bend overshoot and settle where they stop.
19
+ import { overlapDelay, sideTrail, settleBump, ikGap, contactGap, OVERLAP_SLOPE } from "./overlap.js";
16
20
 
17
21
  function pchipSlopes(x, y) {
18
22
  const n = y.length, m = new Array(n).fill(0);
@@ -37,18 +41,24 @@ function pchipEval(x, y, m, q) {
37
41
  return (2 * t3 - 3 * t2 + 1) * y[i] + (t3 - 2 * t2 + t) * h * m[i] + (-2 * t3 + 3 * t2) * y[i + 1] + (t3 - t2) * h * m[i + 1];
38
42
  }
39
43
 
40
- const FLOW_SMOOTH = /^(spine|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
44
+ const FLOW_SMOOTH = /^(spine|chest|head)_(yaw|pitch|roll)$|^(aswing|abend)_[lr]$/;
41
45
  const FLOW_LAG = { spine: 0, head: 2, arms: 3 };
46
+ const ARM = /^(hand|armw|elbow|aswing|abend|wflex|wtwist)_[lr]$/; // one side's arm: trails as one (overlap.js sideTrail)
42
47
  const OVER_FRAC = 0.15, OVER_MAX = 6; // a smooth segment's overshoot: at most this share of its change, and degrees
43
- const ikSides = ch => (/^(spine|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
44
- const lagFamily = ch => (/^spine_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
48
+ const ikSides = ch => (/^(spine|chest|head)_/.test(ch) ? ["l", "r"] : /^(aswing|abend|wflex|wtwist)_([lr])$/.test(ch) ? [ch.slice(-1)] : []);
49
+ const lagFamily = ch => (/^(spine|chest)_/.test(ch) ? "spine" : /^head_/.test(ch) ? "head" : /^(aswing|abend)_[lr]$/.test(ch) ? "arms" : null);
45
50
 
46
- /** spec.flow -> { smooth, lag: { spine, head, arms } }: undefined is the default, false turns both off. */
47
- export function flowOptions(flow) {
48
- if (flow === false) return { smooth: false, lag: { spine: 0, head: 0, arms: 0 } };
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;
54
+ * hands: the frames each hand works on something (overlap.js handBusy), where a free IK hand's trail stops.
55
+ */
56
+ 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 };
49
58
  const f = flow ?? {}, lag = f.lag === false ? 0 : f.lag;
50
59
  const each = typeof lag === "number" ? { spine: lag, head: lag, arms: lag } : { ...FLOW_LAG, ...(lag ?? {}) };
51
- return { smooth: f.smooth !== false, lag: each };
60
+ const overlap = f.overlap === false ? 0 : typeof f.overlap === "number" ? f.overlap : 1;
61
+ return { smooth: f.smooth !== false, lag: each, overlap, seed, hands };
52
62
  }
53
63
 
54
64
  /** Catmull-Rom slopes, flat at a hold and at the ends, each segment's overshoot bounded. */
@@ -78,11 +88,47 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
78
88
  for (const key of [...keys].sort((a, b) => a.f - b.f)) {
79
89
  for (const [ch, value] of Object.entries(key.set ?? {})) {
80
90
  if (!byChannel.has(ch)) byChannel.set(ch, []);
81
- byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch) });
91
+ byChannel.get(ch).push({ f: key.f, value, step: key.step?.includes(ch), src: key.src });
82
92
  }
83
93
  }
94
+ // the top spine bone's share breaks after the two below it (pose.js), so it is baked as its own channel
95
+ const overlap = flow.overlap ?? 0, entries = [...byChannel];
96
+ if (overlap > 0) for (const [ch, list] of byChannel) if (/^spine_/.test(ch)) entries.push([ch.replace("spine", "chest"), list]);
97
+ // an arm's IK weights first: a link's own delay runs out before an arm goes onto its IK and comes back after it
98
+ // leaves (overlap.js), so the fade below never snaps a delayed move onto its keyed frames. Before them where each
99
+ // hand touches: its anchor and fit weights (never delayed) and its carries and hand goals (flow.hands); a side's
100
+ // trail stops short of those
101
+ const pre = new Map(), near = new Map(), touch = {}, trails = {};
102
+ if (overlap > 0) {
103
+ for (const [ch, list] of entries) if (/^w_.+_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
104
+ for (const k of ["l", "r"]) touch[k] = contactGap(flow.hands?.[k] ?? [], [...pre].filter(([ch]) => ch.endsWith(`_${k}`)).map(([, b]) => b.frames), frameCount);
105
+ // the whole arm of a side trails as one (overlap.js sideTrail), its FK swing with its IK weight, so a blend between
106
+ // them never sets a late weight over an on-time swing
107
+ for (const k of ["l", "r"]) trails[k] = sideTrail([...byChannel].filter(([ch]) => ARM.test(ch) && ch.endsWith(`_${k}`)).map(([ch, list]) => Object.assign([...list], { weight: /^armw_/.test(ch) })), k, overlap, flow.seed ?? "", touch[k]);
108
+ for (const [ch, list] of entries) if (/^armw_[lr]$/.test(ch)) pre.set(ch, bakeOne(ch, list));
109
+ }
110
+ const gapOf = ch => {
111
+ const sides = ikSides(ch).filter(k => pre.has(`armw_${k}`)), key = sides.join("");
112
+ if (!sides.length) return null;
113
+ if (!near.has(key)) near.set(key, ikGap(sides.map(k => pre.get(`armw_${k}`).frames), frameCount));
114
+ return near.get(key);
115
+ };
84
116
  const out = {}, plain = {};
85
- for (const [ch, list] of byChannel) {
117
+ for (const [ch, list] of entries) {
118
+ const baked = pre.get(ch) ?? bakeOne(ch, list, gapOf(ch));
119
+ out[ch] = baked.frames;
120
+ if (baked.plain) plain[ch] = baked.plain;
121
+ }
122
+ // flow fades out as the arm goes onto its IK (the spine with either arm)
123
+ for (const [ch, keyed] of Object.entries(plain)) {
124
+ const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
125
+ if (!ws.length) continue;
126
+ out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
127
+ }
128
+ return out;
129
+
130
+ /** One channel's frames (and, for a flowing body channel, its keyed frames: plain); gap: frames to the nearest IK frame. */
131
+ function bakeOne(ch, list, gap = null) {
86
132
  // later keys at the same frame win
87
133
  const pts = [...new Map(list.map(k => [k.f, k])).values()];
88
134
  const shape = shapeOf(pts[0].value);
@@ -91,15 +137,24 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
91
137
  : shape === "vec" ? Array.from({ length: comps }, (_, i) => pts.map(p => p.value[i]))
92
138
  : [...new Set(pts.flatMap(p => Object.keys(p.value)))].map(name => ({ name, y: pts.map(p => p.value[name] ?? 0) }));
93
139
  const x = pts.map(p => p.f), smooth = flow.smooth && FLOW_SMOOTH.test(ch), lag = flow.lag[lagFamily(ch)] ?? 0;
94
- // the trail eases out over the last 2 * lag frames, so the take ends on the last value
95
- const at = f => (lag > 0 ? f - lag * Math.min(1, (frameCount - 1 - f) / (2 * lag)) : f);
140
+ const delay = shape === "num" ? overlapDelay(ch, pts, overlap, flow.seed ?? "", gap) : null;
141
+ const trail = ARM.test(ch) ? trails[ch.slice(-1)] : null, most = lag + (delay?.max ?? 0);
142
+ const delayAt = f => (delay ? Math.min(delay.at(f), gap ? OVERLAP_SLOPE * gap[Math.round(f)] : Infinity) : 0);
143
+ const end = (f, d) => Math.min(1, (frameCount - 1 - f) / (2 * d));
144
+ // the trail eases out over the last 2 * (its most) frames, so the take ends on the last value (the side's trail over
145
+ // its own: a short ease there would quicken the arm's last move); the side's trail rides the keyed curve too (plain),
146
+ // which an arm on its IK goes back to
147
+ const keyedAt = f => (trail ? f - trail.at(f) * end(f, trail.max) : f);
148
+ const at = f => keyedAt(f) - (most > 0 ? (lag + delayAt(f)) * end(f, most) : 0);
149
+ // a settle near an arm's IK would be cut by the fade below: none within its span and a few frames
150
+ const clear = (a, b) => { if (!gap) return true; for (let f = Math.max(0, Math.floor(a)); f <= Math.min(frameCount - 1, Math.ceil(b + most)); f++) if (gap[f] < 2) return false; return true; };
96
151
  const evalSeries = (y, flowing = true) => {
97
- const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y);
152
+ const m = smooth && flowing ? smoothSlopes(x, y) : pchipSlopes(x, y), bump = flowing && shape === "num" ? settleBump(ch, pts, y, overlap, frameCount - 1, clear) : null;
98
153
  return q => {
99
- const f = flowing ? at(q) : q;
154
+ const f = flowing ? at(q) : keyedAt(q);
100
155
  // a step key holds the previous value right up to its frame
101
156
  for (let i = 1; i < pts.length; i++) if (pts[i].step && f >= x[i - 1] && f < x[i]) return y[i - 1];
102
- return pchipEval(x, y, m, f);
157
+ return pchipEval(x, y, m, f) + (bump ? bump(f) : 0);
103
158
  };
104
159
  };
105
160
  const frames = new Array(frameCount);
@@ -109,16 +164,11 @@ export function bakeChannels(keys, frameCount, flow = flowOptions()) {
109
164
  const es = series.map(({ name, y }) => [name, evalSeries(y)]);
110
165
  for (let f = 0; f < frameCount; f++) frames[f] = Object.fromEntries(es.map(([n, e]) => [n, e(f)]));
111
166
  }
112
- out[ch] = frames;
113
- if (shape === "num" && (smooth || lag > 0) && ikSides(ch).length) { const e = evalSeries(series[0], false); plain[ch] = Array.from({ length: frameCount }, (_, f) => e(f)); }
114
- }
115
- // flow fades out as the arm goes onto its IK (the spine with either arm)
116
- for (const [ch, keyed] of Object.entries(plain)) {
117
- const ws = ikSides(ch).map(k => out[`armw_${k}`]).filter(Boolean);
118
- if (!ws.length) continue;
119
- out[ch] = out[ch].map((v, f) => { const w = Math.min(1, 4 * Math.max(0, ...ws.map(a => a[f]))); return v + (keyed[f] - v) * w * (2 - w); });
167
+ if (!(shape === "num" && (smooth || most > 0) && ikSides(ch).length)) return { frames };
168
+ // an arm's own channels keep the side's trail on its IK too (plain), only the links' own delays and settle fade
169
+ const e = evalSeries(series[0], false);
170
+ return { frames, plain: Array.from({ length: frameCount }, (_, f) => e(f)) };
120
171
  }
121
- return out;
122
172
  }
123
173
 
124
174
  /** Frame f's channel dict. */
@@ -26,6 +26,7 @@ import { pose, SIDES } from "./pose.js";
26
26
  import { graspAnchor } from "./socket-grip.js";
27
27
  import { gripHand } from "./grip.js";
28
28
  import { bakeChannels, channelsAt, flowOptions } from "./channels.js";
29
+ import { handBusy } from "./overlap.js";
29
30
  import { idleLayer, withIdle, busyHands } from "./idle.js";
30
31
  import { resolveAnchor, measureProps, armReach } from "./anchors.js";
31
32
  import { expandGenerators } from "./walk.js";
@@ -208,7 +209,7 @@ export function compileMotion(spec, { sk, scene }, opts = {}) {
208
209
  const engine = {
209
210
  rebake(keys) {
210
211
  out.keys = keys;
211
- out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow));
212
+ out.baked = bakeChannels(keys, frameCount, flowOptions(spec.flow, spec.label ?? "", handBusy(spec)));
212
213
  ctx.socketTurns = new Map();
213
214
  ctx.graspPlans = new Map();
214
215
  steadyHead(out.baked, keys, frameCount);
@@ -109,5 +109,7 @@ export function withIdle(c, layer, f) {
109
109
  if (!d) return c;
110
110
  const out = { ...c };
111
111
  for (const [ch, v] of Object.entries(d)) out[ch] = Array.isArray(v) ? (Array.isArray(c[ch]) ? c[ch].map((x, i) => x + v[i]) : c[ch]) : (Number(c[ch]) || 0) + v;
112
+ // the top spine bone's own share (chest_*, channels.js overlap) breathes and sways with the rest of the spine
113
+ for (const ch of ["chest_pitch", "chest_roll"]) if (c[ch] !== undefined && d[ch.replace("chest", "spine")] !== undefined) out[ch] = c[ch] + d[ch.replace("chest", "spine")];
112
114
  return out;
113
115
  }