cozyclay 1.9.0 → 2.0.0

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 (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -1,16 +1,16 @@
1
- export const FAL_MOTION_MODEL = "minimax/h3-max-turbo/image-to-video";
2
- export const FAL_MOTION_RESOLUTION = "480P";
3
- export const FAL_MOTION_MIN_DURATION = 5;
4
- export const FAL_MOTION_MAX_DURATION = 15;
5
- // The durations the Fal card offers. 15 s (362 frames) is the measured upper
1
+ export const I2V_MOTION_MODEL = "minimax/h3-max-turbo/image-to-video";
2
+ export const I2V_MOTION_RESOLUTION = "480P";
3
+ export const I2V_MOTION_MIN_DURATION = 5;
4
+ export const I2V_MOTION_MAX_DURATION = 15;
5
+ // The durations the AI motion card offers. 15 s (362 frames) is the measured upper
6
6
  // end the H3 endpoint accepts and the extractor handles (#380 sit-to-bench).
7
- export const FAL_MOTION_DURATIONS = Object.freeze([5, 10, 15]);
7
+ export const I2V_MOTION_DURATIONS = Object.freeze([5, 10, 15]);
8
8
  // H3's 480P output is 832x480. The reference still is captured at exactly
9
9
  // twice that canvas, and the Studio switches its viewport to the matching
10
10
  // "fal 480P" ratio when A is captured, so the user composes the shot on the
11
11
  // canvas the clip will have.
12
- export const FAL_MOTION_STILL_OUTPUT = Object.freeze({ width: 1664, height: 960 });
13
- export const FAL_MOTION_SHOT_ASPECT = "fal 480P";
12
+ export const I2V_MOTION_STILL_OUTPUT = Object.freeze({ width: 1664, height: 960 });
13
+ export const I2V_MOTION_SHOT_ASPECT = "fal 480P";
14
14
 
15
15
  export function motionApiOrigin(location = globalThis.location) {
16
16
  const configured = globalThis.__COZYCLAY_MOTION_API__;
@@ -51,24 +51,24 @@ async function request(path, body, fetchImpl = fetch) {
51
51
  return payload;
52
52
  }
53
53
 
54
- export function submitFalMotion(input, fetchImpl = fetch) {
54
+ export function submitMotionJob(input, fetchImpl = fetch) {
55
55
  const kind = input?.kind === "interpolate" ? "interpolate" : "act";
56
56
  const body = {
57
57
  ...(kind === "interpolate" ? { stillA: input.stillA, stillB: input.stillB } : { still: input.still }),
58
58
  prompt: input.prompt,
59
- duration: Number(input.duration ?? FAL_MOTION_MIN_DURATION),
59
+ duration: Number(input.duration ?? I2V_MOTION_MIN_DURATION),
60
60
  };
61
61
  return request(`/v1/motion/${kind}`, body, fetchImpl);
62
62
  }
63
63
 
64
- export function getFalMotionJob(id, fetchImpl = fetch) {
64
+ export function getMotionJob(id, fetchImpl = fetch) {
65
65
  return request(`/v1/motion/jobs/${encodeURIComponent(id)}`, undefined, fetchImpl);
66
66
  }
67
67
 
68
- export async function waitForFalMotionJob(id, { intervalMs = 500, timeoutMs = 120_000, fetchImpl = fetch, onUpdate } = {}) {
68
+ export async function waitForMotionJob(id, { intervalMs = 500, timeoutMs = 120_000, fetchImpl = fetch, onUpdate } = {}) {
69
69
  const deadline = Date.now() + timeoutMs;
70
70
  while (Date.now() < deadline) {
71
- const payload = await getFalMotionJob(id, fetchImpl);
71
+ const payload = await getMotionJob(id, fetchImpl);
72
72
  const job = payload?.job ?? null;
73
73
  onUpdate?.(job, payload);
74
74
  if (job?.status === "done" || job?.status === "failed") return payload;
@@ -0,0 +1,180 @@
1
+ import { ko, isKo } from "./locale.js";
2
+ import { buildH3MotionPrompt, I2V_MOTION_DURATIONS, I2V_MOTION_MIN_DURATION } from "./i2v-motion-client.js";
3
+
4
+ // The AI motion workflow split by what needs the viewport (#407 UX). The
5
+ // card stays in the Pose foldout and holds only the steps that operate on the
6
+ // 3D scene (framing, shaded mode, A/B capture, camera lock). The modal takes
7
+ // the text-heavy authoring (mode, description, duration, prompt, generate)
8
+ // onto a wide surface. A blocking overlay would hide the viewport, so capture
9
+ // never lives in the modal.
10
+ //
11
+ // App builds one `model` (state + derived booleans) and one `actions` object
12
+ // (handlers) and passes both to each surface, so the two cannot drift.
13
+
14
+ function Stepper({ step }) {
15
+ return (
16
+ <div className="i2v-motion-stepper" aria-label={ko("AI motion steps", "AI 모션 진행 단계")}>
17
+ {[
18
+ [1, ko("Shaded", "음영")],
19
+ [2, "A"],
20
+ [3, "B"],
21
+ [4, ko("Generate", "생성")],
22
+ ].map(([n, label]) => <span key={n} className={step === n ? "active" : step > n ? "done" : ""}><b>{n}</b>{label}</span>)}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ function CameraStatus({ model, actions }) {
28
+ const { hasA, hasB, cameraMatch, cameraUnlocked, currentCameraMatch } = model;
29
+ return (
30
+ <>
31
+ <p className={"i2v-motion-camera-status" + (cameraMatch ? " ready" : "")} data-testid="i2v-motion-camera-status">
32
+ {hasA
33
+ ? cameraUnlocked
34
+ ? ko("Camera unlocked · recapture A to save a new reference.", "카메라 잠금 해제됨 · A를 다시 캡처하면 새 기준을 저장합니다.")
35
+ : currentCameraMatch
36
+ ? cameraMatch ? ko("✓ A/B camera framing matched · camera locked", "✓ A/B 카메라 프레이밍 일치 확인 · 카메라 잠금") : ko("✓ A camera saved · camera locked", "✓ A 카메라 기준 저장 · 카메라 잠금")
37
+ : ko("The camera changed after A. Restore the A camera.", "A 이후 카메라가 바뀌었어요. A 카메라로 복원하세요.")
38
+ : ko("Capture A first to save the camera reference.", "A를 먼저 캡처하면 카메라 기준을 저장합니다.")}
39
+ </p>
40
+ {hasA && <div className="i2v-motion-camera-actions">
41
+ <button type="button" className="btn ghost" onClick={actions.toggleCameraLock}>{cameraUnlocked ? ko("Lock camera", "카메라 잠그기") : ko("Unlock camera", "카메라 잠금 해제")}</button>
42
+ {!currentCameraMatch && <button type="button" className="btn ghost" onClick={actions.restoreCamera}>{ko("Restore A camera", "A 카메라로 복원")}</button>}
43
+ </div>}
44
+ </>
45
+ );
46
+ }
47
+
48
+ function Thumbs({ model }) {
49
+ const { i2vMotion } = model;
50
+ if (!i2vMotion.a && !i2vMotion.b) return null;
51
+ return (
52
+ <div className="i2v-motion-thumbs" aria-label={ko("AI motion reference poses", "AI motion reference poses")}>
53
+ {i2vMotion.a && <figure><img src={i2vMotion.a.dataUrl} alt="Pose A" /><figcaption>A · {i2vMotion.a.width}×{i2vMotion.a.height}</figcaption></figure>}
54
+ {i2vMotion.b && <figure><img src={i2vMotion.b.dataUrl} alt="Pose B" /><figcaption>B · {i2vMotion.b.width}×{i2vMotion.b.height}</figcaption></figure>}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ /** The viewport-side strip in the Pose foldout. Capture and framing only. */
60
+ export function I2vMotionCaptureCard({ model, actions, onOpen }) {
61
+ const { i2vMotion, mode, enabled, segmentationReady, step, hasA, hasB, cameraUnlocked, framingActive } = model;
62
+ return (
63
+ <section className="i2v-motion-card" data-testid="i2v-motion-studio" data-motion-enabled={enabled ? "true" : "false"}>
64
+ <div className="i2v-motion-head">
65
+ <strong>{ko("AI motion", "AI 모션 생성")}</strong>
66
+ <span>{!enabled ? ko("QA lock", "QA 잠금") : i2vMotion.job?.status === "done" ? ko("Done", "완료") : i2vMotion.status === "error" ? ko("Needs attention", "확인 필요") : "H3 Max Turbo · 480P"}</span>
67
+ </div>
68
+ <Stepper step={step} />
69
+ <div className="i2v-motion-segmentation-row">
70
+ <p className="inspector-hint i2v-motion-ratio">{ko("Frame the shot at the H3 ratio (832×480) in the shot view.", "샷 시점을 H3 비율(832×480)로 맞추고 구도를 잡으세요.")}</p>
71
+ <button type="button" className="btn i2v-motion-flat-cta" data-testid="i2v-motion-frame-shot" onClick={actions.enterFraming} disabled={framingActive}>{ko("480P shot view", "480P 비율로 샷 시점")}</button>
72
+ </div>
73
+ <div className={"i2v-motion-segmentation-row" + (segmentationReady ? " ready" : "")}>
74
+ <p className="inspector-hint i2v-motion-segmentation">{segmentationReady ? ko("Shaded body-part segmentation ON · A/B capture ready", "색 세그멘테이션 음영 모드 ON · A/B 캡처 가능") : ko("Shaded body-part colours are required for A/B refs.", "A/B 참조에는 부위 색상 음영 모드가 필요합니다.")}</p>
75
+ {!segmentationReady && <button type="button" className="btn i2v-motion-flat-cta" onClick={actions.enableShaded}>{ko("Enable Shaded", "음영 모드 켜기")}</button>}
76
+ </div>
77
+ <div className="i2v-motion-capture-status" aria-live="polite" data-testid="i2v-motion-capture-status">
78
+ <div className={"i2v-motion-capture-slot" + (hasA ? " captured" : "")} data-testid="i2v-motion-ref-a-status">
79
+ <strong>A · {hasA ? ko("Captured", "캡처 완료") : ko("Not captured", "미캡처")}</strong>
80
+ <span>{hasA ? `${i2vMotion.a.width}×${i2vMotion.a.height} · Shaded ${i2vMotion.a.partColours.length}개` : ko("Press Capture A on the current frame", "현재 프레임에서 A 캡처를 누르세요")}</span>
81
+ </div>
82
+ <div className={"i2v-motion-capture-slot" + (hasB ? " captured" : "")} data-testid="i2v-motion-ref-b-status">
83
+ <strong>B · {hasB ? ko("Captured", "캡처 완료") : ko("Not captured", "미캡처")}</strong>
84
+ <span>{hasB ? `${i2vMotion.b.width}×${i2vMotion.b.height} · Shaded ${i2vMotion.b.partColours.length}개` : ko("Set the B pose, keep the camera still, then press Capture B", "B 포즈를 만든 뒤 카메라를 움직이지 말고 B 캡처를 누르세요")}</span>
85
+ {hasB && <button type="button" className="i2v-motion-slot-action" onClick={() => actions.clearPose("b")}>{ko("Remove B", "B 제거")}</button>}
86
+ </div>
87
+ </div>
88
+ <CameraStatus model={model} actions={actions} />
89
+ <div className="i2v-motion-pose-row">
90
+ <button type="button" className={i2vMotion.a ? "btn active" : "btn"} disabled={!segmentationReady} onClick={() => actions.markPose("a")}>{i2vMotion.a ? ko("Recapture A", "A 재캡처") : ko("Capture A", "A 캡처")}</button>
91
+ {mode === "interpolate" && <button type="button" className={i2vMotion.b ? "btn active" : "btn"} disabled={!segmentationReady || cameraUnlocked} onClick={() => actions.markPose("b")}>{i2vMotion.b ? ko("Recapture B", "B 재캡처") : ko("Capture B", "B 캡처")}</button>}
92
+ {(i2vMotion.a || i2vMotion.b) && <button type="button" className="btn ghost" onClick={actions.clear}>{ko("Clear", "초기화")}</button>}
93
+ </div>
94
+ <Thumbs model={model} />
95
+ <div className="i2v-motion-open-row">
96
+ <button type="button" className="btn primary i2v-motion-open" data-testid="i2v-motion-open" onClick={onOpen}>{ko("Describe & generate", "동작 설명 · 생성")}</button>
97
+ </div>
98
+ </section>
99
+ );
100
+ }
101
+
102
+ /** The wide authoring surface: description, duration, prompt, generate. */
103
+ export function I2vMotionModal({ model, actions, onClose }) {
104
+ const { i2vMotion, mode, enabled, hasA, hasB, step } = model;
105
+ const interpolate = mode === "interpolate";
106
+ const generateDisabled = interpolate
107
+ ? (!enabled || i2vMotion.status === "submitting" || i2vMotion.status === "queued" || !hasA || !hasB)
108
+ : (!enabled || i2vMotion.status === "submitting" || i2vMotion.status === "queued" || !hasA || !!i2vMotion.b || !i2vMotion.instruction.trim());
109
+ const generateTitle = !enabled
110
+ ? ko("Locked until owner testing is complete.", "소유자 테스트가 끝날 때까지 잠겨 있습니다.")
111
+ : interpolate && !hasB
112
+ ? ko("Capture B before generating.", "B 포즈를 먼저 캡처하세요.")
113
+ : !interpolate && !!i2vMotion.b
114
+ ? ko("Remove B for A-only action mode.", "A만 동작 모드에서는 B를 제거하세요.")
115
+ : !interpolate && !i2vMotion.instruction.trim()
116
+ ? ko("Enter an action description.", "동작 설명을 입력하세요.")
117
+ : "";
118
+ const busy = i2vMotion.status === "submitting" || i2vMotion.status === "queued";
119
+ return (
120
+ <div className="modal-overlay" onClick={onClose}>
121
+ <div className="modal i2v-motion-modal" role="dialog" aria-modal="true" aria-labelledby="i2v-motion-title" onClick={(event) => event.stopPropagation()}>
122
+ <div className="modal-head">
123
+ <h3 id="i2v-motion-title">{ko("AI motion", "AI 모션 생성")}</h3>
124
+ <button type="button" className="x" onClick={onClose} aria-label={ko("Close", "닫기")}>✕</button>
125
+ </div>
126
+ <div className="i2v-motion-modal-body">
127
+ <div className="i2v-motion-modal-left">
128
+ <Stepper step={step} />
129
+ <Thumbs model={model} />
130
+ {step < 4 && <p className="inspector-hint">{ko("Capture A/B in the viewport — close this and use the card.", "A/B 캡처는 뷰포트에서 합니다. 닫고 카드에서 캡처하세요.")}</p>}
131
+ {i2vMotion.job?.status === "done" && i2vMotion.job.video?.url && <video className="i2v-motion-video" src={i2vMotion.job.video.url} controls playsInline preload="metadata" />}
132
+ </div>
133
+ <div className="i2v-motion-modal-right">
134
+ <div className="i2v-motion-mode-tabs" role="tablist" aria-label={ko("AI motion mode", "AI 모션 방식")}>
135
+ <button type="button" role="tab" aria-selected={interpolate} className={interpolate ? "active" : ""} onClick={() => actions.setMode("interpolate")}>{ko("A→B interpolate", "A→B 보간")}</button>
136
+ <button type="button" role="tab" aria-selected={!interpolate} className={!interpolate ? "active" : ""} onClick={() => actions.setMode("act")}>{ko("A-only action", "A만 동작")}</button>
137
+ </div>
138
+ <p className="inspector-hint i2v-motion-mode-hint">
139
+ {interpolate
140
+ ? ko("Describe how the character moves from A to B.", "A와 B 사이를 어떻게 움직일지 설명과 함께 만듭니다.")
141
+ : ko("Creates motion from one A pose and an action description.", "A 포즈 하나와 동작 설명으로 움직임을 만듭니다.")}
142
+ </p>
143
+ <textarea
144
+ className="i2v-motion-instruction"
145
+ value={i2vMotion.instruction}
146
+ placeholder={interpolate
147
+ ? ko("Describe the motion from A to B. Example: walk to the bench, turn, and sit", "A에서 B로 어떻게 움직이는지 적으세요. 예: 벤치로 걸어가 돌아서 앉는다")
148
+ : ko("With A only, describe the action. Example: swing the sword overhead and step forward", "A만 캡처한 뒤 동작을 적으세요. 예: 검을 머리 위로 휘두르고 한 걸음 전진")}
149
+ onChange={(event) => actions.setI2vMotion((current) => ({ ...current, instruction: event.target.value }))}
150
+ />
151
+ <div className="i2v-motion-duration-row">
152
+ <span className="inspector-hint">{ko("Length", "길이")}</span>
153
+ {I2V_MOTION_DURATIONS.map((seconds) => (
154
+ <button key={seconds} type="button" className={"btn small" + ((i2vMotion.duration ?? I2V_MOTION_MIN_DURATION) === seconds ? " active" : "")} onClick={() => actions.setI2vMotion((current) => ({ ...current, duration: seconds }))}>{seconds}{ko("s", "초")}</button>
155
+ ))}
156
+ </div>
157
+ <details className="i2v-motion-prompt-edit">
158
+ <summary>{ko("Prompt to send", "보낼 프롬프트")}</summary>
159
+ <textarea
160
+ className="i2v-motion-instruction i2v-motion-prompt-override"
161
+ value={i2vMotion.promptOverride ?? ""}
162
+ placeholder={buildH3MotionPrompt(i2vMotion.instruction, { interpolate })}
163
+ onChange={(event) => actions.setI2vMotion((current) => ({ ...current, promptOverride: event.target.value }))}
164
+ />
165
+ <p className="inspector-hint">{ko("Left blank, this is built from the description above. Edit it and your text is sent verbatim.", "비워 두면 위 설명으로 자동 생성됩니다. 직접 고치면 그대로 전송됩니다.")}</p>
166
+ </details>
167
+ {i2vMotion.status === "error" && <p className="studio-hint error i2v-motion-inline-error" role="alert">{i2vMotion.error}</p>}
168
+ <div className="i2v-motion-actions">
169
+ <button type="button" className="btn primary" title={generateTitle} disabled={generateDisabled} onClick={() => actions.generate(mode)}>
170
+ {busy ? ko("Generating…", "생성 중…") : interpolate ? ko("Interpolate A→B", "A→B 보간 생성") : ko("Generate action", "동작 생성")}
171
+ </button>
172
+ </div>
173
+ {!enabled && <p className="inspector-hint">{ko("Generation requests stay blocked on the server until owner testing is complete.", "소유자 테스트가 끝날 때까지 생성 요청은 서버에서 차단됩니다.")}</p>}
174
+ {i2vMotion.dailyRemaining !== null && <p className="inspector-hint">{ko(`${i2vMotion.dailyRemaining} motion generations left today`, `오늘 남은 생성 ${i2vMotion.dailyRemaining}회`)}</p>}
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,3 @@
1
+ export function chooseIkEntryPose({ rememberedPose, editorPose, shotPose, lookThroughShot }) {
2
+ return rememberedPose ?? (lookThroughShot ? shotPose : editorPose);
3
+ }
package/src/main.jsx CHANGED
@@ -1,5 +1,10 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ // First stylesheet on the page: it fixes the cascade-layer order before any
4
+ // component sheet (topbar.css, viewport.css, pose.css...) opens a layer.
5
+ import "./styles/tokens.css";
6
+ import "./styles/themes.css";
7
+ import { applyTheme, readTheme } from "./theme.js";
3
8
  import App from "./App.jsx";
4
9
  import ErrorBoundary from "./error-boundary.jsx";
5
10
  import "./styles.css";
@@ -11,6 +16,7 @@ import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPla
11
16
  registerPwa();
12
17
  void initAnalytics();
13
18
  document.documentElement.lang = LOCALE;
19
+ applyTheme(readTheme());
14
20
 
15
21
  async function boot() {
16
22
  // The landing-page playground needs its preset in hand before the first
@@ -0,0 +1,105 @@
1
+ // Pure request construction. The caller supplies one rolled seed and sampled
2
+ // poses; no rig, store, random source, notification or network is touched here.
3
+ import { StudioProtocolError, compileStudioBeats } from '../studio-agent-protocol.js';
4
+ import { TIMELINE_FRAME_FPS as FPS } from '../scenes.js';
5
+ import { judgeAuthoredPath, alignArdyPath } from '../ardy/waypoints.js';
6
+ import { planPosePin, PIN_BLOCKED } from '../ardy/pose-pin.js';
7
+ import { blocksFromRequest, replayPayload, replayTruncated } from '../take-recipe.js';
8
+
9
+ // Same half-open timeline schedule used by the editor: gaps carry the base
10
+ // prompt, and spans shorter than three frames cannot carry a generation.
11
+ function promptSchedule(clips, clipFrames, prompt) {
12
+ const segments = [];
13
+ let cursor = 0;
14
+ for (const clip of clips) {
15
+ const startFrame = Math.max(cursor, Math.min(clipFrames, clip.startFrame));
16
+ const endFrame = Math.max(startFrame, Math.min(clipFrames, clip.endFrame));
17
+ if (startFrame > cursor) segments.push({ startFrame: cursor, endFrame: startFrame, prompt });
18
+ if (endFrame - startFrame >= 3) segments.push({ startFrame, endFrame, prompt: clip.text.trim() || prompt });
19
+ cursor = Math.max(cursor, endFrame);
20
+ if (cursor >= clipFrames) break;
21
+ }
22
+ if (cursor < clipFrames) segments.push({ startFrame: cursor, endFrame: clipFrames, prompt });
23
+ if (!segments.length) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
24
+ return segments;
25
+ }
26
+
27
+ export function generationArgs({ characterId, source }) {
28
+ if (source.kind !== 'generate') throw generationRefusal('CAPABILITY_MISSING', 'Artifact reuse is handled by the retained artifact runtime.');
29
+ const schedule = compileStudioBeats(source);
30
+ return { characterId, durationSeconds: schedule.durationSeconds,
31
+ blocks: schedule.blocks.map((block, index) => ({ id: `generation-block-${index}`, text: block.text, startFrame: block.startFrame, endFrame: block.endFrameExclusive })),
32
+ ...(source.seed === undefined ? {} : { seed: source.seed }) };
33
+ }
34
+
35
+ export function generationRefusal(code, message, uiMessage = message) {
36
+ return Object.assign(new StudioProtocolError(code, message), { uiMessage });
37
+ }
38
+
39
+ export function buildGenerationRequest({ character, prompt, durationSeconds, seed,
40
+ waypoints = [], waypointMode = waypoints.length > 0, blocks = [], motion = null,
41
+ ikKeys = new Map(), startFromPose = false, posePlacement = 'start', frame = 0, poses = [],
42
+ preserveStrength = 0.5, recipe = null, fresh = false,
43
+ }) {
44
+ const text = String(prompt ?? '').trim();
45
+ if (!text) throw generationRefusal('INVALID_ARGUMENT', 'Motion prompt is required - describe what the subject should do before generating');
46
+ if (text.length > 500) throw generationRefusal('INVALID_ARGUMENT', `Motion prompt is capped at 500 characters (currently ${text.length}) - shorten it before generating`);
47
+ const ikFrames = [...ikKeys.keys()].sort((a, b) => a - b);
48
+ const duration = motion && ikFrames.length ? motion.frames / motion.fps : Number(durationSeconds);
49
+ const clipFrames = Math.round(duration * FPS);
50
+ if (!Number.isFinite(duration) || duration < 1 || duration > 1200) throw generationRefusal('INVALID_RANGE', 'Duration must be between 1 and 1200 seconds');
51
+ if (!Number.isInteger(seed) || seed < 0 || seed > 2 ** 31 - 1) throw generationRefusal('INVALID_ARGUMENT', 'Seed must be an integer in 0..2147483647');
52
+ const warnings = [];
53
+ const clips = blocks.filter(clip => clip.text.trim()).sort((a, b) => a.startFrame - b.startFrame);
54
+ const segments = promptSchedule(clips, clipFrames, text), hasPromptSchedule = segments.length > 1;
55
+ const rootPath = waypointMode ? [{ frame: 0, x: character.x, z: character.z, heading: null }, ...waypoints] : [];
56
+ if (waypointMode) {
57
+ if (!waypoints.length) throw generationRefusal('INVALID_RANGE', 'Add at least one root destination before generating');
58
+ if (rootPath.length > 32) throw generationRefusal('INVALID_RANGE', 'The root path is capped at 32 sparse waypoints');
59
+ if (waypoints.some(point => point.frame <= 0 || point.frame >= clipFrames)) throw generationRefusal('INVALID_RANGE', `Root waypoint frames must stay inside 1..${clipFrames - 1}`);
60
+ const verdict = judgeAuthoredPath(rootPath, FPS, clipFrames, { chained: hasPromptSchedule });
61
+ if (verdict.errors.length) throw generationRefusal('INVALID_RANGE', `Not generated - ${verdict.errors[0]}`);
62
+ warnings.push(...verdict.warnings);
63
+ }
64
+ const longBlock = clips.length && segments.find(segment => segment.endFrame - segment.startFrame > 5 * FPS);
65
+ if (longBlock) throw generationRefusal('INVALID_RANGE', `Not generated - prompt blocks are capped at 5 s; split the ${((longBlock.endFrame - longBlock.startFrame) / FPS).toFixed(1)} s block`);
66
+ if (segments.some(segment => segment.prompt.length > 500)) throw generationRefusal('INVALID_ARGUMENT', 'Each motion prompt block is capped at 500 characters');
67
+ const aligned = waypointMode ? alignArdyPath(rootPath, character.rot, 32) : null;
68
+ const editedSegments = motion?.url && hasPromptSchedule
69
+ ? segments.filter(segment => ikFrames.some(at => at >= segment.startFrame && at < segment.endFrame)) : [];
70
+ const hasBlockEdits = editedSegments.length > 0;
71
+ const last = clipFrames - 1;
72
+ const poseFrame = posePlacement === 'end' ? last : posePlacement === 'middle' ? Math.round(last / 2)
73
+ : posePlacement === 'playhead' ? Math.max(0, Math.min(last, frame)) : 0;
74
+ const plan = planPosePin({ startFromPose, poseFrame, hasPromptSchedule, hasBlockEdits, waypointMode, ikFrames, clipFrames, segments, editedSegments });
75
+ if (plan.blockedBy === PIN_BLOCKED.SCHEDULE) warnings.push('Prompt blocks and a pose start cannot be combined - generating from the prompt alone.');
76
+ const body = { prompt: text, duration, posePin: plan.pin, seed };
77
+ let committedEditKeys = [];
78
+ if (plan.pin && !hasBlockEdits) body.poses = poses.map(entry => ({ ...entry }));
79
+ if (aligned) {
80
+ Object.assign(body, { waypoints: aligned.waypoints, rootMargin: 0.08, historyFrames: 4 * FPS });
81
+ if (hasPromptSchedule && !hasBlockEdits) body.segments = segments;
82
+ } else if (hasBlockEdits) {
83
+ const entries = plan.frames.map(at => ({ frame: at, tracks: [...(ikKeys.get(at)?.keys() ?? [])], pose: poses.find(entry => entry.frame === at)?.pose }));
84
+ committedEditKeys = entries.map(({ frame, tracks }) => ({ frame, tracks }));
85
+ body.motionEdit = { sourceMotion: motion.url, startFrame: Math.min(...editedSegments.map(segment => segment.startFrame)),
86
+ endFrame: Math.max(...editedSegments.map(segment => segment.endFrame)), contextBefore: 40, contextAfter: 20, edits: entries };
87
+ } else if (hasPromptSchedule) body.segments = segments;
88
+ const requestBlocks = blocksFromRequest(body, FPS), recipeBlocks = recipe?.blocks;
89
+ const durationFits = motion?.frames > 0 && Math.abs(motion.frames / FPS - duration) <= 1 / FPS + 1e-9;
90
+ const promptMatches = body.motionEdit !== undefined || (recipeBlocks?.length === requestBlocks.length && recipeBlocks.every((block, i) => block.prompt.trim() === requestBlocks[i].prompt.trim()));
91
+ if (!fresh && motion?.url && preserveStrength > 0 && durationFits && promptMatches && !body.segments) {
92
+ body.preserve = { sourceMotion: motion.url, strength: preserveStrength,
93
+ editRanges: editedSegments.map(({ startFrame, endFrame }) => {
94
+ const tracks = [...new Set(ikFrames.filter(at => at >= startFrame && at < endFrame).flatMap(at => [...ikKeys.get(at).keys()]))];
95
+ return { startFrame, endFrame, ...(tracks.length ? { tracks } : {}) };
96
+ }).filter(range => range.endFrame > range.startFrame) };
97
+ }
98
+ const replay = fresh || hasBlockEdits || !Number.isInteger(recipe?.seed) ? [] : replayPayload(recipe);
99
+ if (replay.length) {
100
+ body.replay = replay;
101
+ if (replayTruncated(recipe)) warnings.push(`Only ${replay.length} refinements can be replayed at once - the first ${replay.length} carry over`);
102
+ }
103
+ return { body, rootRotationDeg: aligned?.rotationDeg ?? character.rot, warnings,
104
+ constraintFrames: plan.frames, hasBlockEdits, hasPromptSchedule, committedEditKeys };
105
+ }
@@ -43,7 +43,7 @@ export function MotionSetup({ state, checking, onRetry }) {
43
43
  "Motion generation is optional. Sample motion, camera editing and exports work without it. Your scene and prompt blocks stay here while you set it up.",
44
44
  "모션 생성은 선택 사항이에요. 연결 없이도 샘플 모션, 카메라 편집, 내보내기를 쓸 수 있어요. 설정하는 동안 씬과 프롬프트 블록은 그대로 유지됩니다.",
45
45
  )}</p>
46
- <p>{ko("Use an existing Kimodo host, then restart your local launcher with:", "기존 Kimodo 호스트를 사용하려면 로컬 실행기에 다음 환경변수를 설정하세요:")}</p>
46
+ <p>{ko("Use an existing motion generation host, then restart your local launcher with:", "기존 모션 생성 호스트를 사용하려면 로컬 실행기에 다음 환경변수를 설정하세요:")}</p>
47
47
  <code>{`CCLAY_KIMODO_HOST=user@your-gpu-box npx cozyclay --port ${port} --no-open`}</code>
48
48
  <p>{localStudio ? ko(
49
49
  "Keep this tab open. After restarting the bridge on the same port, retry the connection below.",
@@ -53,11 +53,11 @@ export function MotionSetup({ state, checking, onRetry }) {
53
53
  "새 모션 생성은 로컬 Studio에서 실행해요. 여기서 프로젝트를 저장하고 로컬 Studio에서 그 파일을 열면 편집 내용을 이어갈 수 있어요.",
54
54
  )}</p>
55
55
  <p>{ko(
56
- "Local MLX/cpp supports one unconstrained prompt. Sequencing, pinned poses, paths and preserve need the CUDA/SSH route. Line editing needs a configured ProjFlow route.",
57
- "로컬 MLX/cpp는 제약 없는 단일 프롬프트를 지원해요. 블록 연결, 포즈 고정, 경로, 테이크 보존에는 CUDA/SSH가 필요하고, 라인 편집에는 ProjFlow 설정이 필요해요.",
56
+ "Local MLX/cpp supports one unconstrained prompt. Sequencing, pinned poses, paths and preserve need the CUDA/SSH route. Line editing needs a configured line-edit route.",
57
+ "로컬 MLX/cpp는 제약 없는 단일 프롬프트를 지원해요. 블록 연결, 포즈 고정, 경로, 테이크 보존에는 CUDA/SSH가 필요하고, 라인 편집에는 라인 편집 경로 설정이 필요해요.",
58
58
  )}</p>
59
59
  <a href="https://github.com/NomaDamas/CozyClay/blob/main/docs/kimodo-setup.md" target="_blank" rel="noreferrer">
60
- {ko("Kimodo setup and route guide", "Kimodo 설정 및 경로 안내")}
60
+ {ko("Motion generation setup and route guide", "모션 생성 설정 및 경로 안내")}
61
61
  </a>
62
62
  <button type="button" className="btn" data-testid="motion-health-retry" disabled={checking} onClick={onRetry}>
63
63
  {checking ? ko("Checking…", "확인 중…") : ko("Recheck connection", "연결 다시 확인")}
@@ -7,6 +7,17 @@ export function hasLineEditCapability(health) {
7
7
  return Array.isArray(capabilities) ? capabilities.includes("lineEdit") : capabilities?.lineEdit === true;
8
8
  }
9
9
 
10
+ /**
11
+ * Decide whether the hosted demo take may be seeded onto the stage. The seed
12
+ * is for a session with no bridge (the hosted demo). The first healthy probe
13
+ * latches `bridgeSeenOk`: after it, a failed probe is a blip, not "no bridge",
14
+ * and must never load the demo take onto the character.
15
+ */
16
+ export function demoSeedGate(health, bridgeSeenOk = false) {
17
+ const seenOk = bridgeSeenOk || Boolean(health?.ok);
18
+ return { bridgeSeenOk: seenOk, seed: Boolean(health) && !health.ok && !seenOk };
19
+ }
20
+
10
21
  /**
11
22
  * Derive the generation affordance state from bridge health and request shape.
12
23
  * Health is deliberately interpreted through the analytics preflight contract;