cozyclay 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,664 @@
1
+ import {
2
+ I2V_MOTION_STILL_OUTPUT, I2V_MOTION_SHOT_ASPECT, I2V_MOTION_MIN_DURATION,
3
+ waitForMotionJob, submitMotionJob, buildH3MotionPrompt,
4
+ } from "../../i2v-motion-client.js";
5
+ import * as THREE from "three";
6
+ import { isKo, ko } from "../../locale.js";
7
+ import {
8
+ isPlatformPageUrl,
9
+ normalizeSourceUrl,
10
+ sourceLabel,
11
+ requestBridgeFootage,
12
+ fetchFootageBlob,
13
+ probeFootage,
14
+ requestBridgeExtract,
15
+ } from "../../multimodel-ingest.js";
16
+ import { characterScaleFor, loadMotionFromUrl } from "../../ardy/npz.js";
17
+ import { autoRoofDrop, applyAutoFall } from "../../ardy/root-drop.js";
18
+ import { takeAnchor, createCharacterEntry, isProxyFigure } from "../../scenes.js";
19
+ import { kindRefusal } from "../../character-kind.js";
20
+ import { retimeMotion } from "../../ardy/retime.js";
21
+ import { createMotionEdit } from "../../ardy/motion-edit.js";
22
+ import { DEFAULT_POSE } from "../../poses.js";
23
+ import { startMotionRequest } from "../../analytics.js";
24
+ import { MULTIMODEL_REASONS, nextCharacterId, TIMELINE_FPS } from "../../app-stage.jsx";
25
+
26
+ export function createMotionVideoCapture({
27
+ appContext,
28
+ bridge,
29
+ i2vMotion,
30
+ setI2vMotion,
31
+ multiModelUrl,
32
+ multiModelSource,
33
+ multiModelFootage,
34
+ multiModelExtract,
35
+ setMultiModelUrl,
36
+ setMultiModelSource,
37
+ setMultiModelStatus,
38
+ setMultiModelStage,
39
+ setMultiModelProgress,
40
+ setMultiModelFootage,
41
+ setMultiModelError,
42
+ setMultiModelTake,
43
+ setMultiModelExtract,
44
+ setMultiModelExtractProgress,
45
+ setMultiModelExtractError,
46
+ loadMotion,
47
+ authoredSupportDescriptors,
48
+ applyAuthoredSupportRise,
49
+ }) {
50
+ /* ---------------------------- video capture ----------------------------
51
+ * Footage in, takes out. The ingest downloads and decodes a source so the
52
+ * timeline is sized by what was actually read; extraction then turns it
53
+ * into one take per tracked performer. Take 0 belongs to the ACTIVE
54
+ * character's layer, the rest are landed on their own cast members. */
55
+ // A picked file is already local bytes: no download stage, straight to the
56
+ // probe that produces the timeline numbers.
57
+ function chooseMultiModelFile(event) {
58
+ const file = event.target.files?.[0] ?? null;
59
+ if (!file) return;
60
+ setMultiModelUrl("");
61
+ ingestFootage({ kind: "file", name: file.name, blob: file, bytes: file.size });
62
+ }
63
+
64
+ async function pasteMultiModelUrl() {
65
+ try {
66
+ const text = await navigator.clipboard.readText();
67
+ if (!text.trim()) return;
68
+ setMultiModelUrl(text.trim());
69
+ setMultiModelStatus("idle");
70
+ } catch {
71
+ appContext.notify(isKo ? "클립보드를 읽지 못했어요 — 직접 붙여넣어 주세요" : "Clipboard is unavailable — paste into the field directly");
72
+ }
73
+ }
74
+
75
+ function useMultiModelUrl() {
76
+ const raw = multiModelUrl.trim();
77
+ // A platform page (YouTube, Vimeo, …) is never browser-fetchable, but
78
+ // the dev bridge can fetch it server-side. With the bridge up the
79
+ // address goes there; without it the named refusal below stands.
80
+ if (isPlatformPageUrl(raw) && bridge?.ok) {
81
+ ingestPlatformFootage(raw);
82
+ return;
83
+ }
84
+ const normalized = normalizeSourceUrl(raw);
85
+ if (!normalized.ok) {
86
+ setMultiModelStatus("error");
87
+ setMultiModelStage("error");
88
+ setMultiModelError(MULTIMODEL_REASONS[normalized.reason]?.[isKo ? 1 : 0] ?? normalized.reason);
89
+ return;
90
+ }
91
+ ingestFootage({ kind: "url", name: sourceLabel(normalized.url), url: normalized.url });
92
+ }
93
+
94
+ /** Platform page → bridge download (yt-dlp + normalize) → the local
95
+ * /ardy/footage/… address rides the ordinary ingest unchanged. */
96
+ async function ingestPlatformFootage(pageUrl) {
97
+ const run = appContext.shared.multiModelRunRef.current + 1;
98
+ appContext.shared.multiModelRunRef.current = run;
99
+ const live = () => appContext.shared.multiModelRunRef.current === run;
100
+ setMultiModelSource({ kind: "url", name: sourceLabel(pageUrl), url: pageUrl });
101
+ setMultiModelFootage(null);
102
+ setMultiModelError("");
103
+ setMultiModelStatus("busy");
104
+ setMultiModelStage("fetching");
105
+ setMultiModelProgress(null);
106
+ setMultiModelTake(null);
107
+ setMultiModelExtract("idle");
108
+ setMultiModelExtractProgress(null);
109
+ setMultiModelExtractError("");
110
+ try {
111
+ const footage = await requestBridgeFootage(pageUrl, {
112
+ onProgress: ({ ratio }) => {
113
+ if (live()) setMultiModelProgress(Number.isFinite(ratio) ? ratio : null);
114
+ },
115
+ });
116
+ if (!live()) return;
117
+ ingestFootage({ kind: "url", name: footage.title || sourceLabel(pageUrl), url: footage.url, fps: footage.fps, bridgeId: footage.footage ?? null });
118
+ } catch (error) {
119
+ if (!live()) return;
120
+ const code = error?.message ?? String(error);
121
+ setMultiModelStage("error");
122
+ setMultiModelStatus("error");
123
+ setMultiModelProgress(null);
124
+ setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
125
+ }
126
+ }
127
+
128
+ /** Download (when remote), decode, measure, then size the timeline from what
129
+ * was actually read. Each run carries a token so a slow first source can
130
+ * never land its numbers after a second one replaced it. */
131
+ async function ingestFootage(source, commandContext = null) {
132
+ const run = appContext.shared.multiModelRunRef.current + 1;
133
+ appContext.shared.multiModelRunRef.current = run;
134
+ const live = () => appContext.shared.multiModelRunRef.current === run;
135
+ setMultiModelSource(source);
136
+ setMultiModelFootage(null);
137
+ setMultiModelError("");
138
+ setMultiModelStatus("busy");
139
+ setMultiModelProgress(null);
140
+ // A take baked from the PREVIOUS clip must not read as this one's
141
+ // result, so the extraction state resets with the source.
142
+ setMultiModelTake(null);
143
+ setMultiModelExtract("idle");
144
+ setMultiModelExtractProgress(null);
145
+ setMultiModelExtractError("");
146
+ try {
147
+ let blob = source.blob ?? null;
148
+ let bytes = source.bytes ?? 0;
149
+ if (!blob) {
150
+ setMultiModelStage("fetching");
151
+ const downloaded = await fetchFootageBlob(source.url, {
152
+ onProgress: ({ ratio }) => {
153
+ if (live()) setMultiModelProgress(ratio);
154
+ },
155
+ });
156
+ if (!live()) return;
157
+ blob = downloaded.blob;
158
+ bytes = downloaded.bytes;
159
+ }
160
+ setMultiModelStage("probing");
161
+ if (appContext.shared.multiModelObjectUrlRef.current) URL.revokeObjectURL(appContext.shared.multiModelObjectUrlRef.current);
162
+ const objectUrl = URL.createObjectURL(blob);
163
+ appContext.shared.multiModelObjectUrlRef.current = objectUrl;
164
+ const probed = await probeFootage(objectUrl, {
165
+ createVideo: () => document.createElement("video"),
166
+ // The bridge normalized the clip and DECLARED its rate; a probe
167
+ // that re-guesses it would overrule a measurement with a guess.
168
+ knownFps: Number.isFinite(source.fps) ? source.fps : null,
169
+ });
170
+ if (!live()) return;
171
+ commandContext?.check();
172
+ const footage = { ...probed, bytes, objectUrl, blob, bridgeId: source.bridgeId ?? null };
173
+ setMultiModelFootage(footage);
174
+ setMultiModelStage("ready");
175
+ setMultiModelStatus("ready");
176
+ setMultiModelProgress(1);
177
+ // The timeline is the point: the playhead now spans the footage that
178
+ // was actually decoded, at the rate that was actually measured.
179
+ appContext.shared.setTlFps(footage.fps);
180
+ appContext.shared.setTlFrameCount(footage.frames);
181
+ appContext.shared.setTlFrame(0);
182
+ appContext.shared.setTlPlaying(false);
183
+ appContext.notify((isKo, ko) => isKo
184
+ ? `${source.name} 인제스트됨 — ${footage.frames}프레임 @ ${footage.fps} fps`
185
+ : `Ingested ${source.name} — ${footage.frames} frames @ ${footage.fps} fps`);
186
+ return footage;
187
+ } catch (error) {
188
+ if (commandContext && (commandContext.signal.aborted || error.code === "STALE_TARGET")) throw error;
189
+ if (!live()) return;
190
+ const code = error?.message ?? String(error);
191
+ setMultiModelStage("error");
192
+ setMultiModelStatus("error");
193
+ setMultiModelProgress(null);
194
+ setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
195
+ }
196
+ }
197
+
198
+ /** Extract motion from the ingested footage. With the bridge up this goes
199
+ * to the GPU box (GVHMR: whole-clip temporal context, real 3D body
200
+ * prior — previs-grade). If the bridge is unavailable or is configured for
201
+ * another backend, extraction stops with a named error. */
202
+ async function extractMultiModelMotion() {
203
+ if (!bridge?.ok) {
204
+ setMultiModelExtract("error");
205
+ setMultiModelExtractError(MULTIMODEL_REASONS["extract-bridge-required"]?.[isKo ? 1 : 0] ?? "extract-bridge-required");
206
+ return;
207
+ }
208
+ if (bridge.extractionBackend !== "gvhmr") {
209
+ setMultiModelExtract("error");
210
+ setMultiModelExtractError(MULTIMODEL_REASONS["extract-backend-unsupported"]?.[isKo ? 1 : 0] ?? "extract-backend-unsupported");
211
+ return;
212
+ }
213
+ return extractMultiModelMotionGpu();
214
+ }
215
+
216
+ async function extractMultiModelMotionGpu() {
217
+ const footage = multiModelFootage;
218
+ if (!footage || multiModelExtract === "running") return;
219
+ if (isProxyFigure(appContext.shared.activeChar)) {
220
+ setMultiModelExtract("error");
221
+ setMultiModelExtractError(kindRefusal("mocap", isKo));
222
+ return;
223
+ }
224
+ const run = appContext.shared.multiModelRunRef.current;
225
+ const live = () => appContext.shared.multiModelRunRef.current === run;
226
+ setMultiModelExtract("running");
227
+ setMultiModelExtractProgress(null);
228
+ setMultiModelExtractError("");
229
+ setMultiModelTake(null);
230
+ try {
231
+ const done = await requestBridgeExtract(
232
+ footage.bridgeId ? { footage: footage.bridgeId } : footage.blob,
233
+ {
234
+ onProgress: ({ ratio }) => {
235
+ if (live()) setMultiModelExtractProgress(Number.isFinite(ratio) ? ratio : null);
236
+ },
237
+ }
238
+ );
239
+ if (!live()) return;
240
+ if (done.quality && done.quality.pass === false) {
241
+ const failed = Array.isArray(done.quality.checks)
242
+ ? done.quality.checks.filter((check) => check && check.pass === false).map((check) => check.name).join(", ")
243
+ : "quality";
244
+ appContext.notify(isKo
245
+ ? `모캡 품질 경고: ${failed || "검증 실패"} — 결과는 로드하지만 보정이 필요합니다`
246
+ : `Mocap quality warning: ${failed || "validation failed"} — loaded for review, correction required`);
247
+ }
248
+ // One take per tracked performer. An older bridge sends a single
249
+ // motionUrl and no list; that is the same thing with one entry.
250
+ const takes = Array.isArray(done.takes) && done.takes.length
251
+ ? done.takes
252
+ : [{ motionUrl: done.motionUrl, personScale: done.personScale, offsetX: 0, offsetZ: 0 }];
253
+ const label = multiModelSource?.name ?? "extracted take";
254
+ // The cast can change while the GPU works, so the destination is read
255
+ // now, once, and every take is placed against THIS character.
256
+ const active = appContext.live.characters.find((entry) => entry.id === appContext.shared.activeChar.id) ?? appContext.shared.activeChar;
257
+ // Take 0 arrives as an ordinary motion npz; loadMotion decodes,
258
+ // retimes to the 24 fps timeline, snapshots the rig baseline and
259
+ // applies the stature stored in the take itself — the body matches
260
+ // the FILMED person because the file carries the measurement, not
261
+ // because this handler remembered to re-apply it afterwards.
262
+ const fallbackScale = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
263
+ let personScale = await loadMotion(takes[0].motionUrl ?? done.motionUrl, label, active.rot, null, active.id, null, { personScaleFallback: fallbackScale });
264
+ if (!live()) return;
265
+ // loadMotion reports the stature it applied — 1 for a take that
266
+ // stores none, nothing at all if the load failed. A failed load is
267
+ // not a finished extraction: say so with the named reason instead
268
+ // of a receipt for a take nobody can play.
269
+ if (!Number.isFinite(personScale)) throw new Error("extract-convert-failed");
270
+ // Only a take that stores NO stature gets the fallback for an npz
271
+ // that predates person_scale (or an older bridge): the response
272
+ // still carries the estimate, clamped the same way, because a bad
273
+ // leg estimate must never produce a giant or a gnome.
274
+ const declared = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
275
+ if (personScale === 1 && Number.isFinite(declared)) {
276
+ personScale = characterScaleFor(null, declared);
277
+ }
278
+ // The clip itself is session-only; this reference is what a save
279
+ // keeps and restoreMotionRefs re-fetches on the next session.
280
+ const leadRef = {
281
+ url: takes[0].motionUrl ?? done.motionUrl,
282
+ prompt: label,
283
+ rotationDeg: active.rot,
284
+ anchorX: active.x,
285
+ anchorZ: active.z,
286
+ };
287
+ if (!appContext.storeDomain('motion')) appContext.shared.publishStudioCharacters((list) => list.map((entry) => entry.id === active.id
288
+ ? { ...entry, scale: personScale, motionRef: leadRef }
289
+ : entry));
290
+ const placed = await deliverExtraTakes(takes.slice(1), active, label);
291
+ if (!live()) return;
292
+ const persons = 1 + placed;
293
+ setMultiModelTake({ frames: done.frames, fps: done.fps, gpu: true, personScale, persons, trajectory: done.performance?.trajectory, segmentation: done.segmentation ?? done.performance?.segmentation ?? takes[0]?.segmentation ?? null, quality: done.quality ?? takes[0]?.quality ?? null });
294
+ setMultiModelExtract("done");
295
+ appContext.notify(isKo
296
+ ? `GPU 모션 추출됨 — ${done.frames}프레임 @ ${done.fps} fps${persons > 1 ? ` · ${persons}명` : ""} · 인물 스케일 ×${personScale.toFixed(2)}`
297
+ : `GPU motion extracted — ${done.frames} frames @ ${done.fps} fps${persons > 1 ? ` · ${persons} performers` : ""} · person scale ×${personScale.toFixed(2)}`);
298
+ } catch (error) {
299
+ if (!live()) return;
300
+ const code = error?.message ?? String(error);
301
+ setMultiModelExtract("error");
302
+ setMultiModelExtractError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
303
+ }
304
+ }
305
+
306
+ async function deliverExtraTakes(extras, active, label) {
307
+ const decoded = await Promise.all(extras.map(async (take, index) => {
308
+ if (typeof take?.motionUrl !== "string" || !take.motionUrl) return null;
309
+ try {
310
+ // Inbound boundary, exactly like every other clip: decode, then
311
+ // retime onto the production clock before anything counts frames.
312
+ const anchor = takeAnchor(active, take.offsetX, take.offsetZ);
313
+ const clip = retimeMotion(await loadMotionFromUrl(take.motionUrl), TIMELINE_FPS);
314
+ const scale = characterScaleFor(clip, take.personScale);
315
+ const raised = applyAuthoredSupportRise(clip, { ...anchor, y: active.y ?? 0 }, active.rot, scale);
316
+ const staging = autoRoofDrop(
317
+ raised,
318
+ { x: anchor.x, z: anchor.z, y: active.y ?? 0, rotationDeg: active.rot },
319
+ authoredSupportDescriptors(),
320
+ { worldScale: scale },
321
+ );
322
+ const stagedClip = staging ? applyAutoFall(raised, staging, { worldScale: scale }) : raised;
323
+ return {
324
+ url: take.motionUrl,
325
+ prompt: `${label} · ${index + 2}`,
326
+ rotationDeg: active.rot,
327
+ anchor,
328
+ // A second performer is a DIFFERENT body: their take carries
329
+ // their own stature, and the response estimate is only the
330
+ // fallback for an npz that stores none.
331
+ scale: characterScaleFor(clip, take.personScale),
332
+ clip: stagedClip,
333
+ };
334
+ } catch {
335
+ return null; // one unreadable take never voids the others
336
+ }
337
+ }));
338
+ const usable = decoded.filter(Boolean);
339
+ if (!usable.length) return 0;
340
+ const owned = appContext.storeDomain('motion');
341
+ // Plan against the cast as it stands, so ids are decided once and the
342
+ // full-take map can be seeded with them.
343
+ const list = appContext.live.characters;
344
+ const taken = new Set([active.id]);
345
+ let idPool = list;
346
+ const assignments = usable.map((take) => {
347
+ // Reuse a visible cast member with no clip of its own before adding
348
+ // another body to the set.
349
+ const reuse = list.find((entry) => !entry.hidden && !isProxyFigure(entry) && !taken.has(entry.id) && !entry.sessionMotion && !entry.motionRef);
350
+ const id = reuse ? reuse.id : nextCharacterId(idPool);
351
+ if (!reuse) idPool = [...idPool, { id }];
352
+ taken.add(id);
353
+ return {
354
+ id,
355
+ spawn: !reuse,
356
+ patch: {
357
+ hidden: false,
358
+ x: take.anchor.x,
359
+ z: take.anchor.z,
360
+ rot: take.rotationDeg,
361
+ scale: take.scale,
362
+ motionRef: {
363
+ url: take.url,
364
+ prompt: take.prompt,
365
+ rotationDeg: take.rotationDeg,
366
+ anchorX: take.anchor.x,
367
+ anchorZ: take.anchor.z,
368
+ },
369
+ sessionMotion: {
370
+ ...take.clip,
371
+ url: take.url,
372
+ prompt: take.prompt,
373
+ anchorX: take.anchor.x,
374
+ anchorZ: take.anchor.z,
375
+ anchorFrame: 0,
376
+ rotationDeg: take.rotationDeg,
377
+ editSegments: createMotionEdit(take.clip.frames),
378
+ },
379
+ },
380
+ };
381
+ });
382
+ const place = current => {
383
+ let next = current;
384
+ for (const { id, spawn, patch } of assignments) {
385
+ next = spawn && !next.some((entry) => entry.id === id)
386
+ ? [...next, { ...createCharacterEntry({ id, model: active.model, pose: DEFAULT_POSE, subject: "a person" }, next.length), ...patch }]
387
+ : next.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry));
388
+ }
389
+ return next;
390
+ };
391
+ if (owned) owned.runPrepared(active.id, () => {
392
+ owned.castWrite(place);
393
+ for (const { id, patch } of assignments) { owned.replace(id, patch.sessionMotion); owned.persistTake(patch.sessionMotion, patch.motionRef); }
394
+ return { affectedIds: assignments.map(row => row.id) };
395
+ });
396
+ else {
397
+ appContext.shared.publishStudioCharacters(place);
398
+ // Their takes are trimmable the moment they become the active layer.
399
+ for (const { id, patch } of assignments) appContext.shared.motionFullRef.current.set(id, patch.sessionMotion);
400
+ }
401
+ return assignments.length;
402
+ }
403
+
404
+ function captureI2vStill() {
405
+ // H3 480P renders 832x480. The still is captured at exactly that canvas
406
+ // (x2) regardless of the Studio's shot ratio; markI2vPose also switches
407
+ // the viewport to the matching ratio so what the user framed is what
408
+ // gets sent.
409
+ const captured = appContext.shared.captureLiveFraming({ output: I2V_MOTION_STILL_OUTPUT });
410
+ if (!captured?.dataUrl?.startsWith("data:image/")) throw new Error(ko("The shot renderer is not ready.", "렌더러가 준비되지 않았어요."));
411
+ if (captured.width !== I2V_MOTION_STILL_OUTPUT.width || captured.height !== I2V_MOTION_STILL_OUTPUT.height) {
412
+ throw new Error(ko(`The H3 480P reference must be captured at ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}.`, `H3 480P 참조 캡처는 ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}이어야 해요.`));
413
+ }
414
+ // H3 must see the same complete subject in both endpoints. A clipped
415
+ // foot or head makes the model invent the missing geometry during the
416
+ // transition, which is exactly the bad motion this flow is meant to avoid.
417
+ const rig = appContext.live.state.rigs?.[appContext.shared.activeChar.id];
418
+ const cam = appContext.shared.shotCamRef.current;
419
+ if (!rig || !cam) throw new Error(ko("The full-body frame is not ready yet. Wait a moment and try the reference capture again.", "전신 프레임을 확인할 수 없어 참조를 캡처할 수 없어요. 잠시 후 다시 시도하세요."));
420
+ rig.updateWorldMatrix(true, true);
421
+ cam.updateMatrixWorld(true);
422
+ const bounds = new THREE.Box3().setFromObject(rig);
423
+ const corners = [
424
+ new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
425
+ new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
426
+ new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
427
+ new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
428
+ new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
429
+ new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
430
+ new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
431
+ new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
432
+ ].map((corner) => corner.project(cam));
433
+ const margin = 0.94;
434
+ const clipped = corners.some((corner) =>
435
+ corner.z < -1 || corner.z > 1 || Math.abs(corner.x) > margin || Math.abs(corner.y) > margin
436
+ );
437
+ if (clipped) {
438
+ throw new Error(ko(
439
+ "The full character is not inside the A/B reference. In the shot view, pull the camera back until the head and both feet are visible, then capture again.",
440
+ "A/B 참조에 캐릭터 전신이 다 안 들어왔어요. 샷 시점에서 머리와 양발이 화면 안에 들어오도록 카메라를 뒤로 빼고 다시 캡처하세요."
441
+ ));
442
+ }
443
+ if (!appContext.shared.i2vMotionSegmentationReady || !Array.isArray(captured.partColours) || captured.partColours.length === 0) {
444
+ throw new Error(ko("Enable View → Body part colours → Shaded before capturing an A/B reference.", "A/B 참조는 View에서 부위 색상 → 음영을 켜야 캡처할 수 있어요."));
445
+ }
446
+ return { ...captured, framing: appContext.shared.captureCurrentFraming() };
447
+ }
448
+ /** Put the viewport on the Fal canvas and hand the fly controls to the
449
+ * shot camera, so the user composes the A/B reference on exactly the
450
+ * 832x480 frame the clip will have. Idempotent; capture does not need it. */
451
+ function enterI2vFraming() {
452
+ appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
453
+ if (!appContext.shared.lookThroughShot) appContext.shared.enterShotLook();
454
+ }
455
+ function markI2vPose(slot) {
456
+ try {
457
+ if (slot === "b" && i2vMotion.a && framingDistance(i2vMotion.a.framing, appContext.shared.captureCurrentFraming()) > 0.001) {
458
+ throw new Error(ko("The camera moved between A and B. Capture both refs with the same camera framing.", "A와 B 사이에서 카메라가 이동했어요. 같은 카메라 프레이밍으로 다시 캡처하세요."));
459
+ }
460
+ const still = captureI2vStill();
461
+ // The capture is already on the Fal canvas; make the viewport agree so
462
+ // the user sees the frame that was just sent.
463
+ appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
464
+ setI2vMotion((current) => ({ ...current, [slot]: still, status: "idle", error: "" }));
465
+ if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
466
+ appContext.notify(isKo ? `포즈 ${slot.toUpperCase()} 캡처됨 · ${still.width}×${still.height}` : `Pose ${slot.toUpperCase()} captured · ${still.width}×${still.height}`);
467
+ } catch (error) {
468
+ setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
469
+ }
470
+ }
471
+ function clearI2vPose(slot) {
472
+ setI2vMotion((current) => ({ ...current, [slot]: null, status: "idle", error: "", job: null }));
473
+ if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
474
+ }
475
+ function clearI2vMotion() {
476
+ setI2vMotion({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", promptOverride: "", duration: I2V_MOTION_MIN_DURATION, dailyRemaining: null });
477
+ appContext.shared.setI2vMotionCameraUnlocked(false);
478
+ }
479
+ function restoreI2vCamera() {
480
+ const framing = i2vMotion.a?.framing;
481
+ const camera = appContext.shared.shotCamRef.current;
482
+ if (!framing || !camera) return;
483
+ camera.position.set(framing.pos.x, framing.pos.y, framing.pos.z);
484
+ camera.rotation.order = "YXZ";
485
+ camera.rotation.set(framing.pitch, framing.yaw, 0);
486
+ camera.fov = framing.fovDeg;
487
+ camera.updateProjectionMatrix();
488
+ appContext.shared.look.current.yaw = framing.yaw;
489
+ appContext.shared.look.current.pitch = framing.pitch;
490
+ appContext.shared.shotCameraPosRef.current = { ...framing.pos };
491
+ appContext.shared.setCameraPos({ ...framing.pos });
492
+ appContext.shared.setFovDeg(framing.fovDeg);
493
+ appContext.shared.setI2vMotionCameraUnlocked(false);
494
+ setI2vMotion((current) => ({ ...current, error: "", status: "idle" }));
495
+ appContext.notify(isKo ? "A 캡처 카메라로 복원했어요." : "Restored the camera used for A.");
496
+ }
497
+ function framingDistance(a, b) {
498
+ if (!a || !b) return Infinity;
499
+ return Math.max(
500
+ Math.abs(a.pos.x - b.pos.x), Math.abs(a.pos.y - b.pos.y), Math.abs(a.pos.z - b.pos.z),
501
+ Math.abs(a.yaw - b.yaw), Math.abs(a.pitch - b.pitch), Math.abs(a.fovDeg - b.fovDeg),
502
+ );
503
+ }
504
+ /** The AI motion card's lock line: AI video motion is not enabled for this account. */
505
+ function showI2vMotionLock() {
506
+ setI2vMotion((current) => ({ ...current, error: ko("AI motion generation is locked during QA.", "AI 모션 생성은 QA 중 잠겨 있어요."), status: "error" }));
507
+ }
508
+ /** The AI motion card shows every failure itself. For motion.generateFromVideo the
509
+ * answer says what happened: `{ failed }` with the reason in English, or the
510
+ * finished job, the footage it was ingested as (null when ingest failed) and
511
+ * the account's daily generations left. */
512
+ async function generateI2vMotion(kind = "interpolate", instructionOverride = null, commandContext = null) {
513
+ // One explicit request on the motion:* contract (#466). While the route is
514
+ // gated, the lock itself is the demand signal. Telemetry never decides.
515
+ const motionRequest = startMotionRequest({
516
+ surface: !commandContext ? "fal_card" : commandContext.origin === "mcp" || commandContext.origin === "live" ? "mcp" : "agent",
517
+ input_mode: kind === "interpolate" ? "a_to_b" : "still",
518
+ });
519
+ if (!appContext.shared.i2vMotionEnabled) {
520
+ motionRequest.block("locked");
521
+ showI2vMotionLock();
522
+ return { failed: "AI video motion (Fal) is not enabled for this account." };
523
+ }
524
+ let source = i2vMotion;
525
+ if (kind === "act" && !source.a) {
526
+ try { source = { ...source, a: captureI2vStill() }; setI2vMotion((current) => ({ ...current, a: source.a })); }
527
+ catch (error) {
528
+ motionRequest.block("missing_input");
529
+ setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
530
+ return { failed: "Could not capture the character's pose frame: the full body must be inside the shot frame, shaded part colours must be on (view.setPartColours { mode: \"shaded\" }), and the renderer and rig must be ready." };
531
+ }
532
+ }
533
+ if (kind === "interpolate" && (!source.a || !source.b)) {
534
+ motionRequest.block("missing_input");
535
+ setI2vMotion((current) => ({ ...current, error: ko("Capture both A and B poses first.", "A와 B 포즈를 먼저 캡처하세요."), status: "error" }));
536
+ return { failed: "Capture both A and B poses first." };
537
+ }
538
+ if (!source.a?.partColours || (kind === "interpolate" && !source.b?.partColours)) {
539
+ motionRequest.block("missing_input");
540
+ setI2vMotion((current) => ({ ...current, error: ko("Recapture A/B refs with shaded body-part segmentation enabled.", "색 세그멘테이션이 포함된 음영 A/B 참조를 다시 캡처하세요."), status: "error" }));
541
+ return { failed: "The captured pose frame has no shaded body-part segmentation; the user must recapture it in the Fal card with shaded part colours on." };
542
+ }
543
+ if (kind === "interpolate" && framingDistance(source.a.framing, source.b.framing) > 0.001) {
544
+ motionRequest.block("missing_input");
545
+ setI2vMotion((current) => ({ ...current, error: ko("The camera changed between A and B. Capture both poses with the same camera.", "A와 B 사이에서 카메라가 바뀌었어요. 같은 카메라로 다시 캡처하세요."), status: "error" }));
546
+ return { failed: "The camera changed between poses A and B; capture both with the same camera." };
547
+ }
548
+ // A hand-edited prompt wins verbatim; otherwise build from the description.
549
+ // Interpolate now honours the description too (#380): the bare pose
550
+ // difference lets the model invent the transition and produces junk.
551
+ const description = instructionOverride || source.instruction || "";
552
+ const prompt = source.promptOverride?.trim()
553
+ ? source.promptOverride.trim()
554
+ : buildH3MotionPrompt(description || (kind === "interpolate" ? "" : "Make the character perform the requested action."), { interpolate: kind === "interpolate" });
555
+ setI2vMotion((current) => ({ ...current, status: "submitting", error: "", job: null }));
556
+ motionRequest.pass("fal");
557
+ motionRequest.start();
558
+ try {
559
+ const fetchImpl = commandContext ? (url, options) => fetch(url, { ...options, signal: commandContext.signal }) : undefined;
560
+ const submitted = await submitMotionJob({
561
+ kind,
562
+ stillA: source.a?.dataUrl,
563
+ stillB: source.b?.dataUrl,
564
+ still: source.a?.dataUrl,
565
+ prompt,
566
+ duration: source.duration ?? I2V_MOTION_MIN_DURATION,
567
+ }, fetchImpl);
568
+ const id = submitted?.job?.id;
569
+ if (!id) throw Object.assign(new Error(ko("The server did not return a motion job ID.", "생성 작업 ID를 받지 못했어요.")), { reason: "The motion server did not return a job id." });
570
+ setI2vMotion((current) => ({ ...current, status: "queued", job: submitted.job, dailyRemaining: submitted.dailyRemaining }));
571
+ const finished = await waitForMotionJob(id, {
572
+ fetchImpl,
573
+ onUpdate: (job) => setI2vMotion((current) => ({ ...current, job, status: job?.status ?? current.status })),
574
+ });
575
+ commandContext?.check();
576
+ const job = finished?.job;
577
+ if (job?.status !== "done") throw Object.assign(new Error(job?.error || ko("AI motion generation failed.", "AI 모션 생성에 실패했어요.")), job?.error ? {} : { reason: "The AI video generation failed." });
578
+ motionRequest.succeed();
579
+ setI2vMotion((current) => ({ ...current, job, status: "done", dailyRemaining: finished.dailyRemaining }));
580
+ let footage = null;
581
+ if (job.video?.url) {
582
+ const motionSource = { kind: "url", url: job.video.url, name: `Fal H3 Max Turbo · ${job.resolution}` };
583
+ setMultiModelSource(motionSource);
584
+ // Put the completed clip through the same probe/ingest path as a
585
+ // manually supplied URL so GVHMR sees measured fps, duration and
586
+ // a ready extraction card without another generation request.
587
+ footage = (await ingestFootage(motionSource, commandContext)) ?? null;
588
+ appContext.shared.setResult({
589
+ mode: "video",
590
+ modelLabel: "Fal H3 Max Turbo",
591
+ prompt,
592
+ frame: source.a?.dataUrl ?? null,
593
+ videoUrl: job.video.url,
594
+ motion: {
595
+ videoUrl: job.video.url,
596
+ resolution: job.resolution,
597
+ width: job.width,
598
+ height: job.height,
599
+ fps: job.fps,
600
+ duration: job.resultDuration ?? job.duration,
601
+ cost: job.cost,
602
+ },
603
+ });
604
+ appContext.shared.setResultOpen(true);
605
+ // Applied = the clip is in the Studio, ready for GVHMR extraction.
606
+ motionRequest.apply();
607
+ // The result modal and the studio modal are both z-30; never stack them.
608
+ appContext.shared.setI2vMotionStudioOpen(false);
609
+ appContext.notify((isKo, ko) => isKo ? "AI 영상이 준비됐어요 · 추출 패널에서 GVHMR을 실행하세요" : "AI video is ready · run GVHMR from the extraction panel");
610
+ }
611
+ return { job, footage, dailyRemaining: finished.dailyRemaining ?? null };
612
+ } catch (error) {
613
+ const cancelled = Boolean(commandContext && (commandContext.signal.aborted || error?.code === "STALE_TARGET"));
614
+ // 429 is the account's daily cap (workers/api daily_cap).
615
+ motionRequest.fail(error, cancelled ? "aborted" : error?.status === 429 ? "quota" : "generation_failed");
616
+ if (cancelled) throw error;
617
+ setI2vMotion((current) => ({ ...current, status: "error", error: error.message || String(error) }));
618
+ return { failed: error.reason ?? `The AI video generation failed: ${error.message || error}` };
619
+ }
620
+ }
621
+ /** Why the chip cannot start an AI video motion now, as the user reads it:
622
+ * one already running, or none left today. The lock has its own AI motion card line. */
623
+ function i2vMotionUnavailable() {
624
+ if (!["idle", "done", "error", "failed"].includes(i2vMotion.status)) return ko("A generation is already running", "이미 생성이 돌고 있어요");
625
+ if (i2vMotion.dailyRemaining === 0) return ko("No AI video motion generations left today", "오늘 남은 AI 영상 모션 생성이 없어요");
626
+ return null;
627
+ }
628
+ function generateI2vMotionFromUi(instruction) {
629
+ if (!appContext.shared.i2vMotionEnabled) {
630
+ // Locked, the action's availability check refuses before
631
+ // generateI2vMotion runs, so the chip records its own lock (#466).
632
+ startMotionRequest({ surface: "agent", input_mode: "still" }).block("locked");
633
+ showI2vMotionLock();
634
+ } else {
635
+ // The chip clears the typed instruction when clicked, so a refusal says why.
636
+ const reason = i2vMotionUnavailable();
637
+ if (reason) { appContext.notify(reason); return null; }
638
+ }
639
+ return appContext.shared.runStudioAction("motion.generateFromVideo", { instruction });
640
+ }
641
+ function updateI2vMotionQuota(dailyRemaining) { setI2vMotion((current) => ({ ...current, dailyRemaining })); }
642
+ return {
643
+ chooseMultiModelFile,
644
+ pasteMultiModelUrl,
645
+ useMultiModelUrl,
646
+ ingestPlatformFootage,
647
+ ingestFootage,
648
+ extractMultiModelMotion,
649
+ extractMultiModelMotionGpu,
650
+ deliverExtraTakes,
651
+ captureI2vStill,
652
+ enterI2vFraming,
653
+ markI2vPose,
654
+ clearI2vPose,
655
+ clearI2vMotion,
656
+ restoreI2vCamera,
657
+ framingDistance,
658
+ showI2vMotionLock,
659
+ generateI2vMotion,
660
+ i2vMotionUnavailable,
661
+ generateI2vMotionFromUi,
662
+ updateI2vMotionQuota,
663
+ };
664
+ }