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,224 @@
1
+ import { Euler, Matrix4, Quaternion, Vector3 } from "three";
2
+ import { objectTransformAt } from "./object-path.js";
3
+ import { routeOwnerFor } from "./route-owner.js";
4
+ import { EMPTY_KIND } from "./scene-objects.js";
5
+
6
+ /**
7
+ * Travel through the grouping hierarchy.
8
+ *
9
+ * A route belongs to one record, but a group is one body: a car built from a
10
+ * chassis with its cabin, fenders and wheels parented under it has to drive as
11
+ * a whole when the chassis gets the route. Records stay flat and world-space
12
+ * (a parent only carries its children while authoring), so the carry happens
13
+ * here at sample time: every routed ancestor's motion — where its route puts
14
+ * it, relative to where it was authored — is applied rigidly to everything
15
+ * under it. Scale never rides along: the motion is the routed record's own
16
+ * route sample against its own authored pose, both at its own scale.
17
+ *
18
+ * The inverse holds for an Empty, which is a pure group node: when it has no
19
+ * route of its own but a routed descendant owns the group's travel (a car
20
+ * Empty over its Chassis), it is drawn carried by that descendant's motion, so
21
+ * its marker and gizmo stay on the car. Ordinary meshes keep their authored
22
+ * place — only ancestors carry what is below them.
23
+ *
24
+ * Carried props are left to the attach frame that already places them: a
25
+ * record that rides a character is not walked, and neither is an ancestor
26
+ * that does.
27
+ */
28
+
29
+ const DEG = Math.PI / 180;
30
+
31
+ const scratchPos = new Vector3();
32
+ const scratchQuat = new Quaternion();
33
+ const scratchScale = new Vector3();
34
+ const scratchEuler = new Euler();
35
+ const scratchAuthored = new Matrix4();
36
+ const scratchOwn = new Matrix4();
37
+ const scratchMotion = new Matrix4();
38
+
39
+ function compose(out, x, y, z, rotX, rot, rotZ, object, order = "XYZ") {
40
+ scratchPos.set(x, y, z);
41
+ scratchQuat.setFromEuler(scratchEuler.set(rotX * DEG, rot * DEG, rotZ * DEG, order));
42
+ scratchScale.set(object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1);
43
+ return out.compose(scratchPos, scratchQuat, scratchScale);
44
+ }
45
+
46
+ function authoredMatrix(object, out) {
47
+ return compose(out, object.x ?? 0, object.y ?? 0, object.z ?? 0, object.rotX ?? 0, object.rot ?? 0, object.rotZ ?? 0, object);
48
+ }
49
+
50
+ /**
51
+ * The pose a route sample gives a record, as the Euler triple of
52
+ * R = Ry(yaw) · Rx(pitch) · Rz(bank), applied as three.js's "YXZ" order: the
53
+ * yaw turns the body in the world, the pitch tilts it about its own lateral
54
+ * axis, the bank rolls it about its own forward axis. An authored rotX/rotZ is
55
+ * the body's own pitch and roll, so it stays about the body's axes however the
56
+ * heading turns (in the plain XYZ order rotX stayed about world X, and a pitch
57
+ * became a roll once the route turned). The route's lean rides on top:
58
+ * `bank` positive = right side down, `pitch` positive = nose up, which is
59
+ * the opposite sign to rotX (positive rotX tips the nose down).
60
+ */
61
+ export function travelPose(object, at) {
62
+ return {
63
+ rotX: (object.rotX ?? 0) - (at.pitch ?? 0),
64
+ rot: at.rot ?? object.rot ?? 0,
65
+ rotZ: (object.rotZ ?? 0) + (at.bank ?? 0),
66
+ };
67
+ }
68
+
69
+ /** The record's own route sample as a matrix, or null when it does not travel. */
70
+ function ownTravelMatrix(object, frame, take, out) {
71
+ if (!object.path) return null;
72
+ const at = objectTransformAt(object, frame, take);
73
+ if (!at) return null;
74
+ const pose = travelPose(object, at);
75
+ return compose(out, at.x, at.y, at.z, pose.rotX, pose.rot, pose.rotZ, object, "YXZ");
76
+ }
77
+
78
+ const asLookup = (objects) => {
79
+ if (objects instanceof Map) return objects;
80
+ const lookup = new Map();
81
+ for (const object of objects ?? []) if (object && typeof object.id === "string") lookup.set(object.id, object);
82
+ return lookup;
83
+ };
84
+
85
+ /**
86
+ * The world matrix a record is drawn with at `frame`, or null when neither it
87
+ * nor any ancestor travels (its authored transform is then the answer).
88
+ *
89
+ * @param {Map<string, object>|object[]} objects authored records, by id or as the list
90
+ */
91
+ /** Parent first, up to the root; stops at a cycle, a missing record or a carried one. */
92
+ function ancestorsOf(lookup, object) {
93
+ const chain = [];
94
+ const seen = new Set([object.id]);
95
+ let parentId = typeof object.parent === "string" ? object.parent : null;
96
+ while (parentId && !seen.has(parentId)) {
97
+ seen.add(parentId);
98
+ const parent = lookup.get(parentId);
99
+ if (!parent || parent.attach) break;
100
+ chain.push(parent);
101
+ parentId = typeof parent.parent === "string" ? parent.parent : null;
102
+ }
103
+ return chain;
104
+ }
105
+
106
+ const scratchRide = new Matrix4();
107
+
108
+ /** The routed descendant an Empty rides, or null for anything else. */
109
+ function ridden(lookup, object) {
110
+ if (object.path || object.attach || object.renderer !== EMPTY_KIND) return null;
111
+ const owner = routeOwnerFor(lookup, object.id);
112
+ return owner && owner !== object ? owner : null;
113
+ }
114
+
115
+ export function sceneObjectTravelMatrixAt(objects, id, frame, take = {}, out = new Matrix4()) {
116
+ const lookup = asLookup(objects);
117
+ const object = lookup.get(id);
118
+ if (!object || object.attach) return null;
119
+ const owner = ridden(lookup, object);
120
+ // The descendant's whole carry (its own ancestors' motion included, which
121
+ // the Empty shares), applied to where the Empty was authored. Its own scale
122
+ // never enters: the carry is rigid.
123
+ if (owner && sceneObjectCarryMatrixAt(lookup, owner.id, frame, take, scratchRide)) {
124
+ return out.multiplyMatrices(scratchRide, authoredMatrix(object, scratchAuthored));
125
+ }
126
+ const chain = ancestorsOf(lookup, object);
127
+ let moved = false;
128
+ scratchMotion.identity();
129
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
130
+ const ancestor = chain[index];
131
+ if (!ownTravelMatrix(ancestor, frame, take, scratchOwn)) continue;
132
+ scratchMotion.multiply(scratchOwn).multiply(authoredMatrix(ancestor, scratchAuthored).invert());
133
+ moved = true;
134
+ }
135
+ const own = ownTravelMatrix(object, frame, take, scratchOwn);
136
+ if (!moved && !own) return null;
137
+ return out.multiplyMatrices(scratchMotion, own ?? authoredMatrix(object, scratchAuthored));
138
+ }
139
+
140
+ const scratchCarry = new Matrix4();
141
+
142
+ /**
143
+ * The rigid motion a record's travel hands to whatever rides it at `frame`:
144
+ * where it is drawn against where it was authored. Its own scale cancels out,
145
+ * so a rider keeps its size under a scaled chassis. Null when the record does
146
+ * not travel (nor anything above it), is missing, or is carried by a character.
147
+ */
148
+ export function sceneObjectCarryMatrixAt(objects, id, frame, take = {}, out = new Matrix4()) {
149
+ const lookup = asLookup(objects);
150
+ const object = lookup.get(id);
151
+ if (!object || object.attach) return null;
152
+ if (!sceneObjectTravelMatrixAt(lookup, id, frame, take, out)) return null;
153
+ return out.multiply(authoredMatrix(object, scratchCarry).invert());
154
+ }
155
+
156
+ const scratchCarried = new Matrix4();
157
+ const scratchPoint = new Vector3();
158
+
159
+ /**
160
+ * Where a point riding `objectId` stands at `frame`: the point (a grouped
161
+ * character's played root) through the object's carry. The point comes back
162
+ * unchanged when there is no object or it does not travel; `y` is used for
163
+ * the carry and returned only when the point had one.
164
+ */
165
+ export function carriedPointAt(objects, objectId, point, frame, take = {}) {
166
+ if (!point || !objectId) return point;
167
+ const carry = sceneObjectCarryMatrixAt(objects, objectId, frame, take, scratchCarried);
168
+ if (!carry) return point;
169
+ scratchPoint.set(point.x, point.y ?? 0, point.z).applyMatrix4(carry);
170
+ return point.y === undefined ? { x: scratchPoint.x, z: scratchPoint.z } : { x: scratchPoint.x, y: scratchPoint.y, z: scratchPoint.z };
171
+ }
172
+
173
+ const wrapDegrees = (value) => {
174
+ const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
175
+ return wrapped === -180 ? 180 : wrapped;
176
+ };
177
+
178
+ /**
179
+ * The records as they stand at `frame`: x/y/z and rotation replaced for every
180
+ * record that travels on its own route or under a routed ancestor. Untouched
181
+ * records are returned as-is, and so is the list when nothing moves.
182
+ */
183
+ export function sceneObjectsAt(objects, frame, take = {}) {
184
+ if (!objects.some((object) => object.path)) return objects;
185
+ const lookup = asLookup(objects);
186
+ const matrix = new Matrix4();
187
+ const position = new Vector3();
188
+ const quaternion = new Quaternion();
189
+ const scale = new Vector3();
190
+ const euler = new Euler();
191
+ /** The record for a pose given as a matrix: x/y/z plus the XYZ Euler the records speak. */
192
+ const recordAt = (object, pose) => {
193
+ pose.decompose(position, quaternion, scale);
194
+ const next = { ...object, x: position.x, y: position.y, z: position.z };
195
+ // A pure turn about the vertical keeps the authored zero pitch/roll and
196
+ // reads back as one yaw, instead of the 180/x/180 Euler a decompose of a
197
+ // large yaw gives.
198
+ if (!(object.rotX ?? 0) && !(object.rotZ ?? 0) && Math.abs(quaternion.x) < 1e-9 && Math.abs(quaternion.z) < 1e-9) {
199
+ next.rot = wrapDegrees((2 * Math.atan2(quaternion.y, quaternion.w)) / DEG);
200
+ } else {
201
+ euler.setFromQuaternion(quaternion, "XYZ");
202
+ next.rotX = euler.x / DEG;
203
+ next.rot = euler.y / DEG;
204
+ next.rotZ = euler.z / DEG;
205
+ }
206
+ return next;
207
+ };
208
+ return objects.map((object) => {
209
+ if (object.attach || (!ancestorsOf(lookup, object).some((ancestor) => ancestor.path) && !ridden(lookup, object))) {
210
+ // Nothing above it travels (and nothing below carries it): the route
211
+ // sample alone, exactly as before the hierarchy carry — for a carried
212
+ // prop, in the frame its numbers already live in.
213
+ const at = objectTransformAt(object, frame, take);
214
+ if (!at) return object;
215
+ // A level, upright route is a yaw and a place. One that leans, or a
216
+ // body with its own pitch/roll, is an orientation (travelPose).
217
+ if (!at.bank && !at.pitch && !(object.rotX ?? 0) && !(object.rotZ ?? 0)) return { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot };
218
+ const pose = travelPose(object, at);
219
+ return recordAt(object, compose(matrix, at.x, at.y, at.z, pose.rotX, pose.rot, pose.rotZ, object, "YXZ"));
220
+ }
221
+ if (!sceneObjectTravelMatrixAt(lookup, object.id, frame, take, matrix)) return object;
222
+ return recordAt(object, matrix);
223
+ });
224
+ }
package/src/otio.js CHANGED
@@ -5,9 +5,10 @@ import { isEffectivelyHidden } from "./scene-objects.js";
5
5
  import { sampleAt } from "./sample-at.js";
6
6
  import { TIMELINE_FRAME_FPS } from "./scenes.js";
7
7
  import { usedSensorHeightMm } from "./shot.js";
8
+ import { shotHold } from "./shot-authoring.js";
8
9
 
9
- export const OTIO_TIMELINE_FPS = TIMELINE_FRAME_FPS;
10
- export const OTIO_START_TIMECODE = "00:00:00:00";
10
+ const OTIO_TIMELINE_FPS = TIMELINE_FRAME_FPS;
11
+ const OTIO_START_TIMECODE = "00:00:00:00";
11
12
 
12
13
  const finiteOr = (value, fallback = 0) => Number.isFinite(value) ? value : fallback;
13
14
 
@@ -143,6 +144,10 @@ function gap(startFrame, durationFrames) {
143
144
 
144
145
  function clip(scene, shot) {
145
146
  const metadata = shotMetadata(scene, shot);
147
+ // A still (storyboard panel) is one picture held for its hold: the clip
148
+ // runs exactly that many frames and says so.
149
+ const still = shot.kind === "still";
150
+ if (still) Object.assign(metadata, { kind: "still", hold: shotHold(shot), caption: typeof shot.caption === "string" ? shot.caption : "" });
146
151
  return {
147
152
  OTIO_SCHEMA: "Clip.1",
148
153
  effects: [],
@@ -156,7 +161,7 @@ function clip(scene, shot) {
156
161
  },
157
162
  metadata: { cozyclay: metadata },
158
163
  name: typeof shot.name === "string" && shot.name.trim() ? shot.name.trim() : "Shot",
159
- source_range: timeRange(0, shot.endFrame - shot.startFrame + 1),
164
+ source_range: timeRange(0, still ? shotHold(shot) : shot.endFrame - shot.startFrame + 1),
160
165
  };
161
166
  }
162
167
 
@@ -2,9 +2,17 @@ import Foldout from "./Foldout.jsx";
2
2
  import { ko } from "../locale.js";
3
3
  import { Vector3Row, Slider } from "../ui.jsx";
4
4
  import { useCastTransaction } from '../domains/cast.js';
5
+ import { useContext } from "react";
6
+ import { AppContext } from "../app-context.js";
7
+ import { shotAtFrame } from "../cuts.js";
5
8
 
6
9
  export default function CharacterTransformPanel({ workflowMode, isCharacterSelection, activeChar }) {
7
10
  const { run, begin, commit } = useCastTransaction();
11
+ const app = useContext(AppContext);
12
+ const live = app?.ports.read?.();
13
+ const still = live ? shotAtFrame(live.shots, live.view.frame) : null;
14
+ const shown = live?.targets?.get?.(activeChar.id)?.motion || activeChar.layer?.waypoints?.length
15
+ ? activeChar : { ...activeChar, ...(still?.cast?.[activeChar.id] ?? {}) };
8
16
  return (
9
17
  <Foldout
10
18
  key={workflowMode === "motion" ? "placement" : "transform"}
@@ -20,11 +28,16 @@ export default function CharacterTransformPanel({ workflowMode, isCharacterSelec
20
28
  <Vector3Row
21
29
  label={ko("Position", "위치")}
22
30
  fields={[
23
- { axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
24
- { axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
31
+ { axis: "X", value: shown.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
32
+ { axis: "Z", value: shown.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
25
33
  ]}
26
34
  />
27
- <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
35
+ <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={shown.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
36
+ {still?.kind === "still" && (
37
+ <button type="button" className="secondary-button" onClick={() => run("shot.setCastOverride", { shotId: still.id, characterId: activeChar.id, override: null })}>
38
+ {ko("Use stage placement", "무대 배치 사용")}
39
+ </button>
40
+ )}
28
41
  </div>
29
42
  ) : (
30
43
  <>
@@ -34,13 +47,18 @@ export default function CharacterTransformPanel({ workflowMode, isCharacterSelec
34
47
  <Vector3Row
35
48
  label={ko("Position", "위치")}
36
49
  fields={[
37
- { axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
50
+ { axis: "X", value: shown.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
38
51
  { axis: "Y", value: activeChar.y ?? 0, step: 0.05, precision: 2, scrubRange: 5, onChange: (y) => run('character.update', { characterId: activeChar.id, patch: { y } }), onScrubStart: begin, onScrubEnd: commit },
39
- { axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
52
+ { axis: "Z", value: shown.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
40
53
  ]}
41
54
  />
42
- <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
55
+ <Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={shown.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
43
56
  <Slider compact label={ko("Scale", "크기")} min={0.2} max={3} step={0.05} value={activeChar.scale ?? 1} unit="×" onChange={(scale) => run('character.update', { characterId: activeChar.id, patch: { scale } })} />
57
+ {still?.kind === "still" && (
58
+ <button type="button" className="secondary-button" onClick={() => run("shot.setCastOverride", { shotId: still.id, characterId: activeChar.id, override: null })}>
59
+ {ko("Use stage placement", "무대 배치 사용")}
60
+ </button>
61
+ )}
44
62
  </>
45
63
  )}
46
64
  </Foldout>
@@ -4,9 +4,10 @@ import Foldout from "./Foldout.jsx";
4
4
  import { ko, isKo } from "../locale.js";
5
5
  import { Field, Vector3Row } from "../ui.jsx";
6
6
  import { sceneObjectNameDisplayKo } from "../app-stage.jsx";
7
- import { MESH_KIND, CUTOUT_KIND, CUTOUT_DEFAULT_HEIGHT, OBJECT_COLORS, normalizeObjectColor } from "../scene-objects.js";
7
+ import { MESH_KIND, CUTOUT_KIND, EMPTY_KIND, SCULPT_KIND, CUTOUT_DEFAULT_HEIGHT, OBJECT_COLORS, normalizeObjectColor } from "../scene-objects.js";
8
8
  import { MESH_HEIGHT_MIN } from "../scene-mesh.js";
9
9
  import { autoColorHex } from "../auto-color.js";
10
+ import { isImeComposing } from "../ime.js";
10
11
 
11
12
  export default function ObjectTransformPanel({
12
13
  selectedSceneObject, snapEnabled, setSnapEnabled, attachTargetLabel,
@@ -39,7 +40,7 @@ export default function ObjectTransformPanel({
39
40
  ? ['run.update', { txId, args: { id: selectedSceneObject.id, patch } }]
40
41
  : ['object.update', { id: selectedSceneObject.id, patch }];
41
42
  const endKey = event => {
42
- if (event.key === 'Enter' || event.key === 'Escape') {
43
+ if ((event.key === 'Enter' && !isImeComposing(event)) || event.key === 'Escape') {
43
44
  event.stopPropagation(); finish(event.key === 'Enter'); setObjectColorDraft(null);
44
45
  }
45
46
  };
@@ -452,7 +453,7 @@ export default function ObjectTransformPanel({
452
453
  </p>
453
454
  </>
454
455
  )}
455
- {selectedSceneObject.renderer !== CUTOUT_KIND && (selectedSceneObject.renderer !== MESH_KIND || selectedSceneObject.clay) && (
456
+ {selectedSceneObject.renderer !== CUTOUT_KIND && selectedSceneObject.renderer !== EMPTY_KIND && selectedSceneObject.renderer !== SCULPT_KIND && (selectedSceneObject.renderer !== MESH_KIND || selectedSceneObject.clay) && (
456
457
  // One swatch shows the colour; the row opens only when you want
457
458
  // to change it, instead of six chips sitting there all day.
458
459
  <details className="object-colors-pop">
@@ -0,0 +1,205 @@
1
+ import { useEffect, useReducer, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import Foldout from "./Foldout.jsx";
4
+ import { ko, isKo } from "../locale.js";
5
+ import { readReferenceImage } from "./ReferenceImageField.jsx";
6
+ import { createHttpTransport } from "../workflow/agent-client.js";
7
+ import "./pose-image.css";
8
+
9
+ // The sidecar accepts at most 6 references per request, shared with the scene's identity and
10
+ // environment sheets.
11
+ const REFERENCES_MAX = 6;
12
+ const USER_REFERENCES_MAX = 4;
13
+ const RESULTS_KEPT = 8;
14
+
15
+ // Module-level so leaving Pose mode (which unmounts the Details pane) keeps the references, the
16
+ // prompt, the results and an in-flight generation.
17
+ // `shot` is the pinned capture ({ dataUrl, references }): Generate sends exactly what the panel shows.
18
+ const store = { source: "view", refs: [], prompt: "", shot: null, results: [], selected: 0, busySince: null, error: "", listeners: new Set() };
19
+ function setPoseImageState(patch) {
20
+ Object.assign(store, patch);
21
+ for (const listener of store.listeners) listener();
22
+ }
23
+
24
+ function poseImageErrorMessage(error) {
25
+ if (error?.status === 401 || error?.code === "auth") return ko("Sign in with ChatGPT in the Agent panel first.", "먼저 Agent 패널에서 ChatGPT로 로그인하세요.");
26
+ return isKo ? `생성 실패 — ${error?.message || "알 수 없는 오류"}` : `Generation failed — ${error?.message || "unknown error"}`;
27
+ }
28
+
29
+ async function generatePoseImage(capturePoseImage) {
30
+ const prompt = store.prompt.trim();
31
+ if (!prompt || store.busySince) return;
32
+ const shot = store.shot ?? capturePoseImage?.(store.source);
33
+ if (!shot) { setPoseImageState({ error: ko("The shot renderer is not ready yet.", "샷 렌더러가 아직 준비되지 않았어요.") }); return; }
34
+ const references = [
35
+ ...shot.references,
36
+ ...store.refs.map((dataUrl, index) => ({ role: "reference", name: String(index + 1), dataUrl })),
37
+ ];
38
+ if (references.length > REFERENCES_MAX) {
39
+ setPoseImageState({ shot, error: isKo
40
+ ? `참조 이미지는 최대 ${REFERENCES_MAX}장이에요 (인물·환경 참조 ${shot.references.length}장 포함).`
41
+ : `At most ${REFERENCES_MAX} reference images (${shot.references.length} identity/environment included).` });
42
+ return;
43
+ }
44
+ setPoseImageState({ shot, busySince: Date.now(), error: "" });
45
+ try {
46
+ const output = await createHttpTransport().image({ prompt, imageDataUrl: shot.dataUrl, references, composition: "frame", quality: "auto" });
47
+ setPoseImageState({ results: [{ dataUrl: output.dataUrl, prompt, at: Date.now() }, ...store.results].slice(0, RESULTS_KEPT), selected: 0 });
48
+ } catch (error) {
49
+ setPoseImageState({ error: poseImageErrorMessage(error) });
50
+ } finally {
51
+ setPoseImageState({ busySince: null });
52
+ }
53
+ }
54
+
55
+ export default function PoseImagePanel({ hidden, capturePoseImage }) {
56
+ const [, rerender] = useReducer((tick) => tick + 1, 0);
57
+ const [viewing, setViewing] = useState(false);
58
+ const fileRef = useRef(null);
59
+ useEffect(() => {
60
+ store.listeners.add(rerender);
61
+ return () => { store.listeners.delete(rerender); };
62
+ }, []);
63
+ useEffect(() => {
64
+ if (!store.busySince) return undefined;
65
+ const timer = setInterval(rerender, 1000);
66
+ return () => clearInterval(timer);
67
+ }, [store.busySince]);
68
+
69
+ const { source, refs, prompt, shot, results, selected, busySince, error } = store;
70
+ const capture = (next = source) => setPoseImageState({ source: next, shot: capturePoseImage?.(next) ?? null });
71
+ const result = results[selected] ?? null;
72
+ const elapsed = busySince ? Math.max(0, Math.round((Date.now() - busySince) / 1000)) : 0;
73
+ const addFiles = async (files) => {
74
+ const room = USER_REFERENCES_MAX - store.refs.length;
75
+ const picked = [...files].filter((file) => file.type.startsWith("image/")).slice(0, room);
76
+ try {
77
+ const read = await Promise.all(picked.map((file) => readReferenceImage(file)));
78
+ setPoseImageState({ refs: [...store.refs, ...read].slice(0, USER_REFERENCES_MAX), error: "" });
79
+ } catch (failure) {
80
+ setPoseImageState({ error: isKo ? `이미지를 불러오지 못했어요 — ${failure.message}` : `Could not load that image — ${failure.message}` });
81
+ }
82
+ };
83
+
84
+ return (
85
+ <Foldout hidden={hidden} defaultOpen={false} title={ko("Image from pose", "포즈로 이미지 생성")}>
86
+ <div className="pose-image" data-testid="pose-image">
87
+ <div className="pose-image-frame">
88
+ <div className="pose-image-source" role="radiogroup" aria-label={ko("Frame source", "프레임 시점")}>
89
+ {[["view", ko("Current view", "지금 보는 화면")], ["shot", ko("Shot camera", "샷 카메라")]].map(([id, label]) => (
90
+ <button key={id} type="button" role="radio" aria-checked={source === id} data-active={source === id ? "true" : undefined}
91
+ data-testid={`pose-image-source-${id}`} onClick={() => capture(id)}>{label}</button>
92
+ ))}
93
+ </div>
94
+ <div className="pose-image-label">
95
+ <span>{shot ? ko("This frame is sent", "이 프레임으로 생성해요") : source === "view" ? ko("Frame the viewport, then capture", "뷰포트를 맞춘 뒤 캡처") : ko("The shot camera's view", "샷 카메라 시점")}</span>
96
+ <button type="button" className="btn ghost small" data-testid="pose-image-capture" onClick={() => capture()}>
97
+ {shot ? ko("Recapture", "다시 캡처") : ko("Capture", "캡처")}
98
+ </button>
99
+ </div>
100
+ {shot
101
+ ? <img className="pose-image-shot" src={shot.dataUrl} alt={ko("The frame sent to the image model", "이미지 모델에 보낼 프레임")} />
102
+ : <p className="inspector-hint">{ko("Not captured yet — Generate captures this view.", "아직 캡처 전 — 생성하면 지금 화면을 캡처해요.")}</p>}
103
+ </div>
104
+
105
+ <div className="pose-image-label"><span>{ko("References", "참조 이미지")}</span><small>{refs.length}/{USER_REFERENCES_MAX}</small></div>
106
+ <div className="pose-image-refs"
107
+ onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
108
+ onDrop={(event) => { event.preventDefault(); addFiles(event.dataTransfer?.files ?? []); }}>
109
+ {refs.map((dataUrl, index) => (
110
+ <div key={index} className="pose-image-ref">
111
+ <img src={dataUrl} alt={isKo ? `참조 ${index + 1}` : `Reference ${index + 1}`} />
112
+ <button type="button" className="pose-image-ref-remove" aria-label={ko("Remove reference", "참조 삭제")}
113
+ onClick={() => setPoseImageState({ refs: store.refs.filter((_, at) => at !== index) })}>×</button>
114
+ </div>
115
+ ))}
116
+ {refs.length < USER_REFERENCES_MAX && (
117
+ <button type="button" className="pose-image-ref pose-image-ref-add" data-testid="pose-image-add-ref"
118
+ title={ko("Add a reference picture (or drop one here)", "참조 이미지 추가 (여기로 끌어놓아도 돼요)")}
119
+ onClick={() => fileRef.current?.click()}>+</button>
120
+ )}
121
+ </div>
122
+ <input ref={fileRef} type="file" accept="image/*" multiple className="multimodel-file-input" data-testid="pose-image-ref-input"
123
+ onChange={(event) => { const files = [...(event.target.files ?? [])]; event.target.value = ""; addFiles(files); }} />
124
+
125
+ <textarea
126
+ className="pose-image-prompt"
127
+ data-testid="pose-image-prompt"
128
+ rows={3}
129
+ value={prompt}
130
+ placeholder={ko("Describe the image — e.g. a knight in silver armour, dusk light, cinematic", "어떤 이미지인지 적어 주세요 — 예: 은빛 갑옷의 기사, 해질녘 빛, 영화 같은 톤")}
131
+ onChange={(event) => setPoseImageState({ prompt: event.target.value })}
132
+ onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); generatePoseImage(capturePoseImage); } }}
133
+ />
134
+ <button type="button" className="btn primary full" data-testid="pose-image-generate"
135
+ disabled={!prompt.trim() || Boolean(busySince)}
136
+ onClick={() => generatePoseImage(capturePoseImage)}>
137
+ {busySince ? (isKo ? `생성 중… ${elapsed}초` : `Generating… ${elapsed}s`) : ko("Generate image", "이미지 생성")}
138
+ </button>
139
+ {error && <p className="inspector-hint pose-image-error" role="status" data-testid="pose-image-error">{error}</p>}
140
+
141
+ {result && (
142
+ <div className="pose-image-result" data-testid="pose-image-result">
143
+ <button type="button" className="pose-image-result-open" data-testid="pose-image-open"
144
+ title={ko("View larger", "크게 보기")} onClick={() => setViewing(true)}>
145
+ <img src={result.dataUrl} alt={result.prompt} />
146
+ </button>
147
+ <div className="pose-image-result-actions">
148
+ <a className="btn ghost small" href={result.dataUrl} download={`cozyclay-pose-${result.at}.png`}>{ko("Download", "다운로드")}</a>
149
+ <button type="button" className="btn ghost small" disabled={refs.length >= USER_REFERENCES_MAX}
150
+ onClick={() => setPoseImageState({ refs: [...store.refs, result.dataUrl].slice(0, USER_REFERENCES_MAX) })}>
151
+ {ko("Use as reference", "참조로 쓰기")}
152
+ </button>
153
+ </div>
154
+ {results.length > 1 && (
155
+ <div className="pose-image-history">
156
+ {results.map((entry, index) => (
157
+ <button key={entry.at} type="button" data-active={index === selected ? "true" : undefined}
158
+ title={entry.prompt} onClick={() => setPoseImageState({ selected: index })}>
159
+ <img src={entry.dataUrl} alt="" />
160
+ </button>
161
+ ))}
162
+ </div>
163
+ )}
164
+ </div>
165
+ )}
166
+ </div>
167
+ {viewing && result && createPortal(
168
+ <PoseImageViewer results={results} selected={selected} onSelect={(index) => setPoseImageState({ selected: index })} onClose={() => setViewing(false)} />,
169
+ document.body,
170
+ )}
171
+ </Foldout>
172
+ );
173
+ }
174
+
175
+ function PoseImageViewer({ results, selected, onSelect, onClose }) {
176
+ const entry = results[selected];
177
+ useEffect(() => {
178
+ const onKey = (event) => {
179
+ if (event.key === "Escape") onClose();
180
+ else if (event.key === "ArrowLeft" && selected < results.length - 1) onSelect(selected + 1);
181
+ else if (event.key === "ArrowRight" && selected > 0) onSelect(selected - 1);
182
+ else return;
183
+ event.preventDefault();
184
+ event.stopPropagation();
185
+ };
186
+ window.addEventListener("keydown", onKey, true);
187
+ return () => window.removeEventListener("keydown", onKey, true);
188
+ }, [results.length, selected, onSelect, onClose]);
189
+ return (
190
+ <div className="pose-image-viewer" role="dialog" aria-modal="true" aria-label={ko("Generated image", "생성된 이미지")} data-testid="pose-image-viewer"
191
+ onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
192
+ <figure className="pose-image-viewer-body">
193
+ <img src={entry.dataUrl} alt={entry.prompt} />
194
+ <figcaption>
195
+ <span className="pose-image-viewer-prompt">{entry.prompt}</span>
196
+ {results.length > 1 && <span className="pose-image-viewer-count">{results.length - selected}/{results.length}</span>}
197
+ <a className="btn ghost small" href={entry.dataUrl} download={`cozyclay-pose-${entry.at}.png`}>{ko("Download", "다운로드")}</a>
198
+ <button type="button" className="btn ghost small" data-testid="pose-image-viewer-close" onClick={onClose}>{ko("Close", "닫기")}</button>
199
+ </figcaption>
200
+ </figure>
201
+ {selected < results.length - 1 && <button type="button" className="pose-image-viewer-nav prev" aria-label={ko("Older", "이전")} onClick={() => onSelect(selected + 1)}>‹</button>}
202
+ {selected > 0 && <button type="button" className="pose-image-viewer-nav next" aria-label={ko("Newer", "다음")} onClick={() => onSelect(selected - 1)}>›</button>}
203
+ </div>
204
+ );
205
+ }
@@ -21,9 +21,11 @@ export default function PromptBlocksPanel({
21
21
  readinessState, ardyRunning, cancelArdy, ardyStatus, ardyOutcome,
22
22
  }) {
23
23
  const { run, begin, commit, characterId } = useCastTransaction();
24
- const { refineDisabledReason, workflowMode } = useStudioShell();
24
+ const { refineDisabledReason, workflowMode, previsMode } = useStudioShell();
25
25
  const selectedClip = promptClips.find((clip) => clip.id === selectedPromptId) ?? null;
26
26
  const refineReason = refineDisabledReason();
27
+ // A storyboard has no prompt blocks to author or edit.
28
+ if (previsMode === "storyboard") return null;
27
29
  // Generate, Start over, Take it again and Add block live in the top bar's
28
30
  // Generate Motion button and its caret menu, their one home (R1).
29
31
  return (
@@ -0,0 +1,56 @@
1
+ import Foldout from "./Foldout.jsx";
2
+ import { ko } from "../locale.js";
3
+ import { POSTURES, isProxyFigure } from "../scenes.js";
4
+ import { useBus, AppContext } from "../app-context.js";
5
+ import { useContext } from "react";
6
+ import { shotAtFrame } from "../cuts.js";
7
+
8
+ const POSTURE_LABELS = {
9
+ stand: () => ko("Stand", "서기"),
10
+ sit: () => ko("Sit", "앉기"),
11
+ lie: () => ko("Lie", "눕기"),
12
+ };
13
+
14
+ // Side-on silhouettes on a 24 grid, the stroke family of the shell's icons.
15
+ const POSTURE_GLYPHS = {
16
+ stand: "M12 3.5a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M12 9.5v11",
17
+ sit: "M10 4a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M10 10v6h6v4.5",
18
+ lie: "M4 13a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M8 15h12.5",
19
+ };
20
+
21
+ /** Details for a capsule figure: it has no rig and no bones, so the one body
22
+ * choice is its posture. Rig figures never see this panel. */
23
+ export default function ProxyFigurePanel({ isCharacterSelection, activeChar }) {
24
+ const { run } = useBus();
25
+ const app = useContext(AppContext);
26
+ const live = app?.ports.read?.();
27
+ const shot = live ? shotAtFrame(live.shots, live.view.frame) : null;
28
+ const selectedPosture = live?.targets?.get?.(activeChar.id)?.motion || activeChar.layer?.waypoints?.length
29
+ ? activeChar.posture : shot?.cast?.[activeChar.id]?.posture ?? activeChar.posture;
30
+ if (!isProxyFigure(activeChar)) return null;
31
+ return (
32
+ <Foldout hidden={!isCharacterSelection} title={ko("Capsule figure", "캡슐 인물")}>
33
+ <div className="rig-picker" role="radiogroup" aria-label={ko("Posture", "자세")} data-testid="proxy-posture" style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}>
34
+ {POSTURES.map((posture) => (
35
+ <button
36
+ type="button"
37
+ key={posture}
38
+ role="radio"
39
+ aria-checked={selectedPosture === posture}
40
+ className={"rig-option" + (selectedPosture === posture ? " active" : "")}
41
+ data-posture={posture}
42
+ onClick={() => {
43
+ if (selectedPosture === posture) return;
44
+ run("character.update", { characterId: activeChar.id, patch: { posture } });
45
+ }}
46
+ >
47
+ <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
48
+ <path d={POSTURE_GLYPHS[posture]} />
49
+ </svg>
50
+ <span>{POSTURE_LABELS[posture]()}</span>
51
+ </button>
52
+ ))}
53
+ </div>
54
+ </Foldout>
55
+ );
56
+ }
@@ -4,7 +4,7 @@ import { ASSET_IMAGE_TYPES, downscaleTarget } from "../scene-assets.js";
4
4
 
5
5
  const REFERENCE_IMAGE_MAX_DIMENSION = 1024;
6
6
 
7
- async function readReferenceImage(file, { maxDimension = REFERENCE_IMAGE_MAX_DIMENSION } = {}) {
7
+ export async function readReferenceImage(file, { maxDimension = REFERENCE_IMAGE_MAX_DIMENSION } = {}) {
8
8
  if (!file) throw new Error("No file");
9
9
  if (!ASSET_IMAGE_TYPES.includes(String(file.type).toLowerCase())) {
10
10
  throw new Error("unsupported image type");