cozyclay 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-routes.mjs +4 -4
  4. package/bin/agent/agent-runner.mjs +160 -151
  5. package/bin/agent/agent-stream.mjs +86 -0
  6. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  7. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  8. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  9. package/bin/agent/anthropic-subscription.mjs +73 -0
  10. package/bin/agent/claude-code-executable.mjs +93 -0
  11. package/bin/agent/cliproxy-provider.mjs +25 -11
  12. package/bin/agent/main-agent.mjs +21 -7
  13. package/bin/agent/main-prompt.mjs +7 -6
  14. package/bin/agent/model-roles.mjs +112 -0
  15. package/bin/agent/openai-compatible-provider.mjs +95 -0
  16. package/bin/agent/pi-tools.mjs +19 -10
  17. package/bin/agent/provider-login.mjs +3 -1
  18. package/bin/agent/provider-routes.mjs +2 -0
  19. package/bin/agent/providers.mjs +35 -130
  20. package/bin/agent/script-tool.mjs +2 -1
  21. package/bin/agent/studio-prompt.mjs +1 -1
  22. package/bin/agent/studio-rebase.mjs +5 -4
  23. package/bin/agent/studio-task-routes.mjs +1 -1
  24. package/bin/agent/studio-tasks.mjs +116 -25
  25. package/bin/agent/studio-tools.mjs +3 -2
  26. package/bin/cozyclay.mjs +4 -2
  27. package/bin/live-endpoint.mjs +2 -0
  28. package/dist/app/index.html +6 -6
  29. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  30. package/dist/assets/app-BdRlMf4J.css +1 -0
  31. package/dist/assets/app-DwETmoQZ.js +5144 -0
  32. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  33. package/dist/assets/motion3-compile-DDG3wklD.js +1 -0
  34. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  35. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  36. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  37. package/dist/assets/workflow-BMJGd_gZ.js +179 -0
  38. package/dist/cozyclay-package.json +1 -1
  39. package/dist/index.html +1 -1
  40. package/dist/sitemap.xml +1 -1
  41. package/dist/workflow/index.html +4 -4
  42. package/mcp/README.md +1 -1
  43. package/mcp/live-hub.mjs +5 -3
  44. package/mcp/tool-handlers.mjs +5 -5
  45. package/package.json +6 -4
  46. package/src/App.jsx +65 -87
  47. package/src/agent-image-client.js +2 -2
  48. package/src/asset-pane.jsx +14 -14
  49. package/src/bus-task-scopes.js +59 -11
  50. package/src/bus-task-targets.js +35 -9
  51. package/src/bus-tasks.js +23 -10
  52. package/src/character-kind.js +13 -12
  53. package/src/command-bus.js +7 -4
  54. package/src/commands/ai.js +1 -1
  55. package/src/commands/export.js +1 -1
  56. package/src/commands/motion3.js +27 -7
  57. package/src/commands/project.js +2 -1
  58. package/src/commands/shot.js +1 -1
  59. package/src/commands/view.js +2 -2
  60. package/src/domains/cast.js +15 -15
  61. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  62. package/src/domains/motion.js +11 -11
  63. package/src/domains/objects.js +20 -30
  64. package/src/domains/scenes.js +14 -21
  65. package/src/domains/shots.js +3 -3
  66. package/src/export-workspace.js +244 -0
  67. package/src/first-success-guide.jsx +19 -3
  68. package/src/hierarchy-panel.jsx +6 -7
  69. package/src/keyframe-pack.js +5 -1
  70. package/src/live-control.js +14 -7
  71. package/src/locale-zh/app.js +124 -0
  72. package/src/locale-zh/domains.js +36 -0
  73. package/src/locale-zh/labels.js +148 -0
  74. package/src/locale-zh/panels.js +225 -0
  75. package/src/locale-zh/shell.js +230 -0
  76. package/src/locale-zh/timeline.js +145 -0
  77. package/src/locale-zh/workflow.js +104 -0
  78. package/src/locale-zh/workspace.js +190 -0
  79. package/src/locale-zh.js +21 -0
  80. package/src/locale.js +16 -6
  81. package/src/main.jsx +1 -1
  82. package/src/motion/beat-board-model.js +4 -4
  83. package/src/motion/beat-board.jsx +1 -1
  84. package/src/motion/ik-pick.js +24 -0
  85. package/src/motion/take-segments.jsx +5 -5
  86. package/src/motion/timeline.jsx +30 -40
  87. package/src/motion/waypoints.js +6 -6
  88. package/src/motion3/compile-job.js +15 -3
  89. package/src/motion3/compiler/channels.js +74 -24
  90. package/src/motion3/compiler/compile.js +2 -1
  91. package/src/motion3/compiler/idle.js +2 -0
  92. package/src/motion3/compiler/overlap.js +180 -0
  93. package/src/motion3/compiler/pose.js +6 -4
  94. package/src/motion3/compiler/validate-common.js +1 -0
  95. package/src/motion3/compiler/validate-parts.js +5 -1
  96. package/src/motion3/compiler/validate.js +30 -10
  97. package/src/motion3/correct.js +236 -0
  98. package/src/motion3/docs.js +8 -6
  99. package/src/motion3/face-lean.js +31 -0
  100. package/src/motion3/fix/index.js +5 -1
  101. package/src/motion3/fix/reach-body.js +172 -0
  102. package/src/motion3/hint-check.js +44 -0
  103. package/src/motion3/json-patch.js +120 -12
  104. package/src/motion3/lenient.js +46 -4
  105. package/src/motion3/object-keys.js +17 -5
  106. package/src/motion3/pipeline.js +46 -9
  107. package/src/motion3/qa/arm-checks.js +96 -0
  108. package/src/motion3/qa/asked.js +113 -0
  109. package/src/motion3/qa/goals.js +37 -8
  110. package/src/motion3/qa/hints-goal.js +6 -5
  111. package/src/motion3/qa/index.js +5 -2
  112. package/src/motion3/qa/key-checks.js +4 -3
  113. package/src/motion3/qa/motion-life.js +243 -0
  114. package/src/motion3/qa/spec-checks.js +15 -9
  115. package/src/motion3/qa/verdict.js +50 -0
  116. package/src/motion3/refs.js +38 -14
  117. package/src/motion3/repair-watch.js +132 -0
  118. package/src/motion3/repair.js +52 -19
  119. package/src/motion3/report.js +50 -9
  120. package/src/motion3/schema/topics.js +6 -4
  121. package/src/motion3/schema/types.js +1 -1
  122. package/src/motion3/skills/basic.js +15 -8
  123. package/src/motion3/skills/index.js +52 -18
  124. package/src/motion3/skills/info.js +1 -1
  125. package/src/motion3/skills/stance.js +6 -0
  126. package/src/motion3/skills/walk-fit.js +170 -0
  127. package/src/object-catalog.jsx +4 -5
  128. package/src/panels/CameraPanel.jsx +3 -3
  129. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  130. package/src/panels/ObjectTransformPanel.jsx +19 -29
  131. package/src/panels/PoseImagePanel.jsx +11 -10
  132. package/src/panels/PosePanel.jsx +2 -2
  133. package/src/panels/ReferenceImageField.jsx +2 -2
  134. package/src/panels/RigControlPanel.jsx +2 -2
  135. package/src/panels/SculptPanel.jsx +11 -11
  136. package/src/panels/SubjectBox.jsx +3 -3
  137. package/src/panels/SubjectsPanel.jsx +1 -1
  138. package/src/posestudio.jsx +10 -8
  139. package/src/project-browser.css +7 -6
  140. package/src/project-browser.jsx +7 -6
  141. package/src/project-open-file.js +19 -0
  142. package/src/qa-label.js +1 -1
  143. package/src/range-pin-panel.jsx +14 -14
  144. package/src/resource-status.jsx +3 -3
  145. package/src/result-modal.jsx +13 -13
  146. package/src/settings-menu.jsx +4 -1
  147. package/src/shell/BoardSlot.jsx +5 -5
  148. package/src/shell/CompositionGuideButton.jsx +1 -1
  149. package/src/shell/ExportFolderPreference.jsx +103 -0
  150. package/src/shell/GhostsMenuGroup.jsx +1 -1
  151. package/src/shell/PreferencesDialog.jsx +3 -166
  152. package/src/shell/StatusBar.jsx +4 -4
  153. package/src/shell/ViewportToolbar.jsx +3 -3
  154. package/src/shell/add-camera-shot.js +2 -2
  155. package/src/shell/details-selection.js +1 -1
  156. package/src/shell/preference-controls.jsx +115 -0
  157. package/src/shell/preference-pages.jsx +72 -0
  158. package/src/shell/preferences.css +46 -0
  159. package/src/shot-guides.js +5 -5
  160. package/src/shot-prompt.js +29 -9
  161. package/src/stage-hdr.js +3 -3
  162. package/src/stage-labels-ko.js +2 -2
  163. package/src/stage-startup-defaults.js +3 -3
  164. package/src/studio-actions.js +4 -4
  165. package/src/studio-agent-motion.js +19 -9
  166. package/src/studio-agent-protocol.js +1 -1
  167. package/src/studio-app-binding.js +3 -2
  168. package/src/styles/tokens.css +1 -1
  169. package/src/styles.css +1 -1
  170. package/src/use-case-question.jsx +18 -18
  171. package/src/workflow/AgentPanel.jsx +6 -6
  172. package/src/workflow/AgentPlanCard.jsx +1 -1
  173. package/src/workflow/AgentProgressCard.jsx +4 -4
  174. package/src/workflow/AgentTurnSteps.jsx +1 -1
  175. package/src/workflow/CozySceneNode.jsx +1 -1
  176. package/src/workflow/LoginPanel.jsx +1 -1
  177. package/src/workflow/TaskBoard.jsx +1 -1
  178. package/src/workflow/TaskInline.jsx +1 -1
  179. package/src/workflow/WorkflowBuilder.jsx +2 -2
  180. package/src/workflow/agent-client.js +1 -1
  181. package/src/workflow/agent-turn.js +5 -5
  182. package/src/workflow/keyframe-pack-request.js +3 -10
  183. package/src/workflow/task-board.css +1 -1
  184. package/src/workflow/task-board.js +11 -9
  185. package/tools/loc-baseline.json +2 -3
  186. package/tools/run-tests.mjs +9 -9
  187. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  188. package/dist/assets/app-BlwOpest.css +0 -1
  189. package/dist/assets/app-DmR7jBkz.js +0 -5144
  190. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  191. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  192. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  193. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  194. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  195. package/dist/assets/workflow-zQM-EKW1.js +0 -179
@@ -130,7 +130,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
130
130
  { id: "view.setGhosts", label: "Ghosts", kind: "transient", input: input({ mode: { type: "string", enum: ["off", "step", "keys"] } }, { count: { type: "integer", minimum: 1, maximum: 6 }, step: { type: "integer", minimum: 1, maximum: 24 } }),
131
131
  description: "Onion skin in the editor viewport, like the View menu's Ghosts: translucent poses of the active character's installed take, count (1-6, default 3) before the playhead in blue and as many after it in orange, fading with distance in time; props the take carries (a kit's bat) ghost with the body. mode step spaces them every step frames (1-24, default 4), keys puts them on the spec's key frames, off hides them; e.g. { mode: \"step\", count: 3, step: 4 }. An editor overlay: never in capture_frame, exports or contact sheets (verify_result ghosts: true draws them on a views sheet). A viewer setting: never saved or undone." },
132
132
  { id: "export.shotVideo", exposure: "confirm", confirmationReason: "Exporting writes a video file outside the Studio.", label: "Export video (mp4)", kind: "job", timeoutMs: 300_000, input: input({}, { ...shotId, scope: { type: "string", enum: ["timeline", "shot"], description: "timeline: the whole timeline, every shot back to back; shot: one shot (shotId, or the one under the playhead)." } }),
133
- description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and ask the browser to download it. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
133
+ description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and hand it to the configured export folder or, when none is usable, the browser download. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
134
134
  { id: "scene.create", label: "New scene", kind: "document", input: input(),
135
135
  description: `Add an empty scene (named SCENE 01, SCENE 02, ...) after the others and open it, like the scene menu's New scene. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
136
136
  { id: "scene.duplicate", label: "Duplicate scene", kind: "document", input: input(sceneId),
@@ -152,7 +152,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
152
152
  { id: "object.duplicate", label: "Duplicate object", kind: "mutation", undoDomain: "objects", input: input({}, { objectId: idSchema }),
153
153
  description: "Copy a scene object (the selected one when objectId is omitted) and place the copy half a metre beside it." },
154
154
  { id: "ai.prepareShot", label: "Send to AI", kind: "job", input: input({}, { mode: { type: "string", enum: ["image", "video"] }, model: { type: "string", enum: AI_IMAGE_MODELS } }),
155
- description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and downloads the frames; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
155
+ description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and exports the frames to the configured export folder or browser downloads; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
156
156
  // The instruction leads the H3 prompt, which the motion API caps at 4000
157
157
  // characters with its camera-lock text (about 225) appended.
158
158
  ]);
@@ -168,8 +168,8 @@ export function studioActionRefusal(code, message, uiMessage) {
168
168
  }
169
169
  // A localized toast producer is evaluated twice, without showing it twice:
170
170
  // once for the existing UI and once in English for command evidence.
171
- export function resolveStudioToast(value, isKo, ko) {
172
- return typeof value === "function" ? { uiMessage: value(isKo, ko), message: value(false, en => en) } : { uiMessage: value, message: value };
171
+ export function resolveStudioToast(value, isKo, ko, isZh = false) {
172
+ return typeof value === "function" ? { uiMessage: value(isKo, ko, isZh), message: value(false, en => en, false) } : { uiMessage: value, message: value };
173
173
  }
174
174
  const unknown = (id, known) => fail("INVALID_ARGUMENT", `Unknown Studio action "${id}". Known actions: ${known.join(", ")}. List them with inspect_studio { scope: "actions" }.`);
175
175
 
@@ -12,7 +12,8 @@ import { buildCollisionCapsules, detectPenetrations, supportsCollisionCleanup }
12
12
  import { collisionBlockers, blockerSummary } from './motion/collision-blockers.js';
13
13
  import { createGroundSampler } from './motion/ground.js';
14
14
  import { OBJECT_LIBRARY } from './scene-objects.js';
15
- import { objectTransformAt } from './object-path.js';
15
+ import { sceneObjectsAt } from './object-travel.js';
16
+ import { objectTracksOf } from './motion3/object-tracks.js';
16
17
  import { sampleAt } from './sample-at.js';
17
18
  import { isProxyFigure } from './scenes.js';
18
19
 
@@ -88,7 +89,7 @@ function stageCast(env, characterId, cast) {
88
89
 
89
90
  /* One take under evaluation, `t`: {evaluator, motion, env, cast, characterId,
90
91
  * character, protectedFrames, range, poseCast?, yieldTask, checkpoint}. */
91
- function blockersAt(t, frame) {
92
+ function blockersAt(t, frame, objects) {
92
93
  t.checkpoint();
93
94
  const rigs = {};
94
95
  for (const member of t.cast) {
@@ -99,18 +100,19 @@ function blockersAt(t, frame) {
99
100
  rigs[member.character.id] = member.evaluator.rig;
100
101
  }
101
102
  const cast = t.cast.map((member) => member.character).filter(Boolean);
102
- return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: t.env.objects, library: OBJECT_LIBRARY, frame, take: { frameCount: t.env.frameCount, fps: 24 } });
103
+ // `objects` already stand where this frame renders them (routes and take tracks), so no re-sampling here.
104
+ return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: objects, library: OBJECT_LIBRARY });
103
105
  }
104
106
  async function samples(t, layer) {
105
107
  const rows = [], poses = [], collisions = [], blockers = [];
106
108
  const cast = t.cast.map((member) => member.character).filter(Boolean);
107
109
  for (let frame = t.range.startFrame; frame < t.range.endFrameExclusive; frame++) {
108
110
  t.checkpoint(); poseFrame(t.evaluator, t.motion, layer, frame);
109
- const shapes = blockersAt(t, frame);
110
- const objects = t.env.objects.map(object => {
111
- const at = objectTransformAt(object, frame, { frameCount: t.env.frameCount, fps: 24 });
112
- return at ? { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot } : object;
113
- });
111
+ // Objects as the editor renders them at this frame: travel routes plus the
112
+ // object tracks the installed takes bake (object-travel.js sceneObjectsAt).
113
+ const objects = sceneObjectsAt(t.env.objects, frame, t.objectTake);
114
+ for (const o of objects) if (!o.hidden && (o.rotX || o.rotZ)) t.tilted.add(o.id);
115
+ const shapes = blockersAt(t, frame, objects);
114
116
  rows.push(readSample(t.evaluator, createGroundSampler(objects, { floorY: t.env.floor.y, characters: cast })));
115
117
  poses.push(poseValues(t.evaluator.rest));
116
118
  const capsules = buildCollisionCapsules(t.evaluator.rig);
@@ -136,7 +138,7 @@ function judge(t, before, after) {
136
138
  }
137
139
  const supportedCollisionFrames = after.collisions.filter(p => p.length).length;
138
140
  const elevatedFrames = after.rows.flatMap((row, f) => Object.values(row.ground).some(y => y !== t.env.floor.y) ? [t.range.startFrame + f] : []);
139
- const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ);
141
+ const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ || t.tilted.has(o.id));
140
142
  const missingCast = t.cast.filter(m => !m.evaluator || ((m.character.layer?.waypoints?.length ?? 0) > 0 && !t.poseCast));
141
143
  const measuredSupportFrames = after.support.frames.filter(f => f.measured).length;
142
144
  const flat = t.env.floor.model === 'flat' && elevatedFrames.length === 0 && Math.abs(t.character.y ?? 0) < 1e-8;
@@ -164,6 +166,13 @@ function receiptVerification(v) {
164
166
  limitations: v.limitations, surfaceMeasured: m.surfaceMeasured, maxFloorPenetrationM: m.maxFloorPenetrationM, maxContactSlipM: m.maxContactSlipM, maxContactFloatM: m.maxContactFloatM,
165
167
  unsupportedFrames: m.unsupportedFrames, supportedCollisionFrames: m.supportedCollisionFrames, continuityRegressed: m.continuityRegressed, semanticStatus: 'unavailable' };
166
168
  }
169
+ /** Object tracks the installed takes bake (this take and the other cast's), keyed by object id. */
170
+ function installedObjectTracks(target, environment) {
171
+ const takes = new Map((environment.cast ?? []).map(member => [member.character?.id, member.motion]));
172
+ takes.set(target.character.id, target.motion);
173
+ const characters = [...takes.keys()].filter(id => typeof id === 'string').map(id => ({ id }));
174
+ return objectTracksOf({ motionFor: id => takes.get(id) }, characters);
175
+ }
167
176
  /** Motion verification of an installed take, on a private rig clone over
168
177
  * `range` (default and upper bound: the whole take). target: {character, rig,
169
178
  * motion, ikState?, protectedFrames?}; environment has readEnvironment()'s
@@ -177,6 +186,7 @@ export async function verifyInstalledTake({ target, environment, range, poseCast
177
186
  const deadline = now() + verificationBudget(endFrameExclusive - startFrame, verificationMs);
178
187
  const t = { evaluator: isolatedRig(target.rig), motion, env, cast: [], characterId: target.character.id, character: target.character,
179
188
  protectedFrames: target.protectedFrames ?? [], range: { startFrame, endFrameExclusive }, poseCast, yieldTask,
189
+ objectTake: { frameCount: env.frameCount, fps: 24, objectTracks: installedObjectTracks(target, environment) }, tilted: new Set(),
180
190
  checkpoint: () => { if (now() > deadline) fail('VERIFICATION_FAILED', 'Private verification budget exceeded.'); } };
181
191
  try {
182
192
  stageCast(environment, t.characterId, t.cast);
@@ -307,7 +307,7 @@ const receiptSchema = union(...Object.values(receiptVariants));
307
307
  const failureSchema = object({ ok: literal(false), commandId: id, host: identity, code: choices(STUDIO_ERROR_CODES), phase: choices(["admission", "execution", "prepare", "verify", "repair", "commit", "reconcile", "undo"]),
308
308
  affectedIds: ids(100, 0), expectedTargets: array(guardSchema, 24), currentTargets: array(guardSchema, 24), mutated: union(bool, literal("unknown")),
309
309
  preserved: object({ authoredState: choices(["unchanged", "changed", "unknown"]) }), recovery: object({ action: choices(["none", "inspect", "retry", "new_intent", "reconcile", "sign_in"]) }, { retryAllowed: bool }),
310
- }, { message: text(500), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
310
+ }, { message: text(2000), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
311
311
 
312
312
  // These are JSON Schema data, not validators with hidden browser dependencies.
313
313
  // x-studio-range is the sole relational schema annotation: end > start.
@@ -196,9 +196,10 @@ export function createStudioAppBinding(ports) {
196
196
  ...(childIds.length ? { childIds: childIds.slice(0, 12), ...(childIds.length > 12 ? { childCount: childIds.length } : {}) } : {}) };
197
197
  };
198
198
  if (patched.length) return { patched, ...extras() };
199
- const shot = s.shots.find(row => row.id === id);
199
+ // A camera key lives inside its shot: still present there means retained, not removed (#863).
200
+ const shot = s.shots.find(row => row.id === id), key = !shot && !entity && s.shots.flatMap(row => row.cameraKeys?.map(k => ({ shotId: row.id, frame: k.frame, id: k.id })) ?? []).find(k => k.id === id);
200
201
  if (shot) return { name: shot.name || shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 } };
201
- if (!entity) return { removed: true };
202
+ if (!entity) return key ? { shotId: key.shotId, frame: key.frame } : { removed: true };
202
203
  const brief = { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
203
204
  if (compact) return brief;
204
205
  if (!object) return { ...brief, yawDeg: entity.rot ?? 0, scale: entity.scale ?? 1, ...extras() };
@@ -63,7 +63,7 @@
63
63
  --sp-3: 16px;
64
64
 
65
65
  /* Self-hosted UI families with the existing Korean fallbacks retained. */
66
- --sans: "IBM Plex Sans", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
66
+ --sans: "IBM Plex Sans", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
67
67
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
68
68
  }
69
69
  }
package/src/styles.css CHANGED
@@ -66,7 +66,7 @@
66
66
  --grad: var(--primary);
67
67
  --grad-soft: var(--primary);
68
68
  --display: var(--sans);
69
- --sans: "IBM Plex Sans", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
69
+ --sans: "IBM Plex Sans", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
70
70
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
71
71
  --radius: var(--r-1);
72
72
  --shadow: 0 1px 2px rgba(0, 0, 0, .4);
@@ -3,16 +3,16 @@ import { useEffect, useState } from "react";
3
3
  // Closed choices only (analytics USE_CASE_VALUES / TEAM_VALUES): no free text
4
4
  // ever leaves the browser from this card.
5
5
  const USE_CASES = Object.freeze([
6
- ["animation", "Animation", "애니메이션"],
7
- ["film", "Film / drama", "영화·드라마"],
8
- ["game", "Game", "게임"],
9
- ["ad_mv", "Ad / music video", "광고·뮤비"],
10
- ["personal", "Personal project", "개인 작업"],
11
- ["other", "Something else", "기타"],
6
+ ["animation", "Animation", "애니메이션", "动画"],
7
+ ["film", "Film / drama", "영화·드라마", "影视剧"],
8
+ ["game", "Game", "게임", "游戏"],
9
+ ["ad_mv", "Ad / music video", "광고·뮤비", "广告 / MV"],
10
+ ["personal", "Personal project", "개인 작업", "个人项目"],
11
+ ["other", "Something else", "기타", "其他"],
12
12
  ]);
13
13
  const TEAMS = Object.freeze([
14
- ["team", "With a team or company", "팀·회사에서"],
15
- ["solo", "On my own", "혼자"],
14
+ ["team", "With a team or company", "팀·회사에서", "与团队或公司"],
15
+ ["solo", "On my own", "혼자", "我自己"],
16
16
  ]);
17
17
 
18
18
  /**
@@ -20,9 +20,9 @@ const TEAMS = Object.freeze([
20
20
  * what the export is for, then team or solo. Closing it at any point counts as
21
21
  * a skip, and the card never comes back on this browser profile.
22
22
  */
23
- export default function UseCaseQuestion({ open, isKo, onAnswer }) {
23
+ export default function UseCaseQuestion({ open, isKo, isZh, onAnswer }) {
24
24
  const [useCase, setUseCase] = useState(null);
25
- const label = (en, ko) => (isKo ? ko : en);
25
+ const label = (en, korean, chinese) => (isKo ? korean : isZh ? chinese : en);
26
26
  useEffect(() => {
27
27
  if (!open) setUseCase(null);
28
28
  }, [open]);
@@ -39,28 +39,28 @@ export default function UseCaseQuestion({ open, isKo, onAnswer }) {
39
39
  }}
40
40
  >
41
41
  <div className="use-case-ask-head">
42
- <strong id="use-case-ask-title">{label("Quick question (optional)", "짧은 질문 (선택)")}</strong>
43
- <button type="button" className="use-case-ask-close" aria-label={label("Close", "닫기")} onClick={close}>
42
+ <strong id="use-case-ask-title">{label("Quick question (optional)", "짧은 질문 (선택)", "快速提问(可选)")}</strong>
43
+ <button type="button" className="use-case-ask-close" aria-label={label("Close", "닫기", "关闭")} onClick={close}>
44
44
  ×
45
45
  </button>
46
46
  </div>
47
47
  <p className="use-case-ask-prompt">
48
48
  {useCase
49
- ? label("Who are you making it with?", "누구와 작업하나요?")
50
- : label("What are you making with CozyClay?", "CozyClay로 뭘 만들고 있나요?")}
49
+ ? label("Who are you making it with?", "누구와 작업하나요?", "你和谁一起做?")
50
+ : label("What are you making with CozyClay?", "CozyClay로 뭘 만들고 있나요?", "你在用 CozyClay 做什么?")}
51
51
  </p>
52
- <div className="use-case-ask-choices" role="group" aria-label={useCase ? label("Team or solo", "팀 또는 혼자") : label("Use case", "용도")}>
53
- {(useCase ? TEAMS : USE_CASES).map(([value, en, ko]) => (
52
+ <div className="use-case-ask-choices" role="group" aria-label={useCase ? label("Team or solo", "팀 또는 혼자", "团队或个人") : label("Use case", "용도", "用途")}>
53
+ {(useCase ? TEAMS : USE_CASES).map(([value, en, ko, zh]) => (
54
54
  <button
55
55
  key={value}
56
56
  type="button"
57
57
  onClick={() => (useCase ? onAnswer(useCase, value) : setUseCase(value))}
58
58
  >
59
- {label(en, ko)}
59
+ {label(en, ko, zh)}
60
60
  </button>
61
61
  ))}
62
62
  </div>
63
- <p className="use-case-ask-note">{label("Anonymous. Only the choice is sent.", "익명이에요. 고른 항목만 전송돼요.")}</p>
63
+ <p className="use-case-ask-note">{label("Anonymous. Only the choice is sent.", "익명이에요. 고른 항목만 전송돼요.", "匿名。只发送所选项目。")}</p>
64
64
  </section>
65
65
  );
66
66
  }
@@ -3,7 +3,7 @@ import { FiAlertTriangle, FiArrowUp, FiCamera, FiCheck, FiChevronRight, FiClock,
3
3
  import { ATTACHMENT_ACCEPT, ATTACHMENT_FAILED_NOTICE, ATTACHMENT_LIMIT_NOTICE, ATTACHMENT_TYPE_NOTICE, attachmentFilesFromTransfer, attachmentFromFile, transferCarriesFiles } from "./attachment-image.js";
4
4
  import { isImeComposing } from "../ime.js";
5
5
  import { useStudioShell } from "../shell/studio-shell-context.js";
6
- import { ko } from "../locale.js";
6
+ import { ko } from "../locale.js"; import { saveExport } from "../export-workspace.js";
7
7
  import {
8
8
  AGENT_PANEL_OVERLAY_BREAKPOINT,
9
9
  AGENT_PANEL_RAIL_WIDTH,
@@ -73,7 +73,7 @@ function ImageResultCard({ image, onUse, onUndo, onRegenerate, onOpen }) {
73
73
  ? <div className="agent-placed"><StatusDot tone="ok" />Placed<button type="button" className="agent-image-undo" disabled={applying} onClick={() => onUndo(image)}>{applying ? "Removing…" : "Undo"}</button></div>
74
74
  : <div className="agent-image-actions">
75
75
  <button type="button" className="primary agent-image-use" disabled={applying} onClick={() => onUse(image)}><FiImage size={11} /> {applying ? "Applying…" : "Use in scene"}</button>
76
- <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`}><FiDownload size={11} /> Download</a>
76
+ <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`} onClick={(event) => { event.preventDefault(); void saveExport(image.dataUrl, `${image.imageId || "agent-image"}.png`); }}><FiDownload size={11} /> Download</a>
77
77
  <button type="button" className="agent-image-regenerate" onClick={() => onRegenerate(image)}><FiRotateCw size={11} /> Regenerate</button>
78
78
  </div>}
79
79
  {/* The card states what the editor actually did: an unacknowledged or
@@ -89,9 +89,9 @@ function OfflineCard({ probing, onRetry }) {
89
89
  <FiPower className="agent-offline-icon" size={18} aria-hidden="true" />
90
90
  <h3>{ko("Agent server is not running", "에이전트 서버가 실행 중이 아니에요")}</h3>
91
91
  <p>{ko(<>Start CozyClay with <code>npx cozyclay</code> (or <code>npm run dev</code> in a checkout) to sign in.</>,
92
- <>로그인하려면 <code>npx cozyclay</code>(체크아웃에서는 <code>npm run dev</code>)로 CozyClay를 시작하세요.</>)}</p>
92
+ <>로그인하려면 <code>npx cozyclay</code>(체크아웃에서는 <code>npm run dev</code>)로 CozyClay를 시작하세요.</>, <>使用 <code>npx cozyclay</code> 启动 CozyClay(在代码检出中使用 <code>npm run dev</code>)即可登录。</>)}</p>
93
93
  <button type="button" className="agent-ghost-button agent-offline-retry" onClick={onRetry} disabled={probing} aria-busy={probing}>
94
- <FiRotateCw size={11} aria-hidden="true" /> {probing ? ko("Checking…", "확인 중…") : ko("Retry", "다시 확인")}
94
+ <FiRotateCw size={11} aria-hidden="true" /> {probing ? ko("Checking…", "확인 중…") : ko("Retry", "다시 확인", "重新检查")}
95
95
  </button>
96
96
  </div>;
97
97
  }
@@ -966,7 +966,7 @@ export default function AgentPanel({
966
966
  <select className="agent-model-select" aria-label="Model" title="Model" value={model} disabled={!models.length} onChange={(event) => chooseModel(event.target.value)}>
967
967
  {modelProviders.length
968
968
  ? modelProviders.map((provider) => <optgroup key={provider.id} label={provider.label}>
969
- {provider.models.length
969
+ {provider.error && <option value={`${provider.id}/error`} disabled>{provider.error}</option>}{provider.models.length
970
970
  ? provider.models.map((entry) => <option key={entry.key} value={entry.key} disabled={!provider.signedIn}>{provider.signedIn ? entry.label : `${entry.label} — add key`}</option>)
971
971
  : <option value={`${provider.id}/`} disabled>{provider.signedIn ? "No models" : "No models — add key"}</option>}
972
972
  </optgroup>)
@@ -982,7 +982,7 @@ export default function AgentPanel({
982
982
  </select>
983
983
  )}
984
984
  {surface === "studio" && (
985
- <button type="button" className={`agent-supervisor-toggle agent-tasks-toggle${taskMode ? " is-on" : ""}`} role="switch" aria-checked={taskMode} aria-label={ko("Task mode", "작업 모드")} title={ko(taskMode ? "Task mode is on: the agent answers at once and runs your requests as parallel tasks" : "Task mode is off: one request at a time", taskMode ? "작업 모드 켜짐: 에이전트가 바로 답하고 요청을 병렬 작업으로 돌려요" : "작업 모드 꺼짐: 한 번에 요청 하나")} onClick={toggleTaskMode} disabled={streaming}>
985
+ <button type="button" className={`agent-supervisor-toggle agent-tasks-toggle${taskMode ? " is-on" : ""}`} role="switch" aria-checked={taskMode} aria-label={ko("Task mode", "작업 모드")} title={ko(taskMode ? "Task mode is on: the agent answers at once and runs your requests as parallel tasks" : "Task mode is off: one request at a time", taskMode ? "작업 모드 켜짐: 에이전트가 바로 답하고 요청을 병렬 작업으로 돌려요" : "작업 모드 꺼짐: 한 번에 요청 하나", taskMode ? "任务模式已开启:智能体会立即回答,并将请求作为并行任务运行" : "任务模式已关闭:一次只处理一个请求")} onClick={toggleTaskMode} disabled={streaming}>
986
986
  {ko("Tasks", "작업")} {taskMode ? "ON" : "OFF"}
987
987
  </button>
988
988
  )}
@@ -24,7 +24,7 @@ export function PlanCard({ plan, live }) {
24
24
  const total = plan.steps.length, done = plan.steps.filter(step => step.status === "done").length;
25
25
  const state = live ? "live" : plan.outcome ?? "done";
26
26
  return <div className="agent-row agent-plan-row" data-plan-live={live ? "true" : "false"}>
27
- <section className="agent-card agent-plan" data-agent-card="plan" data-plan-state={state} aria-label={ko(`Plan, ${done} of ${total} done`, `계획, ${total}단계 중 ${done}단계 완료`)}>
27
+ <section className="agent-card agent-plan" data-agent-card="plan" data-plan-state={state} aria-label={ko(`Plan, ${done} of ${total} done`, `계획, ${total}단계 중 ${done}단계 완료`, `计划,已完成 ${done} / ${total}`)}>
28
28
  <div className="agent-plan-head">
29
29
  <span className="agent-plan-title">{ko("Plan", "계획")}</span>
30
30
  <span className="agent-plan-bar" aria-hidden="true"><span className="agent-plan-fill" style={{ "--agent-plan-done": total ? done / total : 0 }} /></span>
@@ -19,7 +19,7 @@ function Trend({ totals }) {
19
19
  const { points, first, last } = trendPoints(totals, { width: W, height: H });
20
20
  if (!points.length) return null;
21
21
  const [lx, ly] = points[points.length - 1];
22
- const label = ko(`Problems by draft: ${totals.join(", ")}`, `초안별 문제 수: ${totals.join(", ")}`);
22
+ const label = ko(`Problems by draft: ${totals.join(", ")}`, `초안별 문제 수: ${totals.join(", ")}`, `各草稿的问题数:${totals.join(", ")}`);
23
23
  return <div className="agent-progress-trend">
24
24
  <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={label} preserveAspectRatio="none">
25
25
  <line className="base" x1={4} y1={H - 4} x2={W - 4} y2={H - 4} />
@@ -67,17 +67,17 @@ export function AgentProgressCard({ items, since = null, live = false }) {
67
67
  return <div className="agent-row">
68
68
  <div className="agent-card agent-progress-card" data-agent-card="progress" data-drafts={drafts.length} data-installed={latest?.installed ? "true" : "false"} role="status" aria-live="polite">
69
69
  {latest && <div className="agent-progress-head">
70
- <span className="agent-progress-title" title={ko(`${latest.qa.total} frame-level issues`, `프레임 단위 문제 ${latest.qa.total}건`)}>{draftLabel(latest)}</span>
70
+ <span className="agent-progress-title" title={ko(`${latest.qa.total} frame-level issues`, `프레임 단위 문제 ${latest.qa.total}건`, `${latest.qa.total} 个帧级问题`)}>{draftLabel(latest)}</span>
71
71
  <span className={`agent-progress-chip ${latest.installed ? "installed" : "draft"}`}>{latest.installed ? ko("Installed", "설치됨") : live ? ko("Not installed yet", "아직 설치 전") : ko("Not installed", "설치 안 됨")}</span>
72
72
  </div>}
73
73
  {latest?.label && <p className="agent-progress-label">{latest.label}</p>}
74
74
  {drafts.length > 0 && <section className="agent-progress-section">
75
- <h4>{ko(`Problems across ${drafts.length} draft${drafts.length === 1 ? "" : "s"}`, `초안 ${drafts.length}개의 문제 수`)}</h4>
75
+ <h4>{ko(`Problems across ${drafts.length} draft${drafts.length === 1 ? "" : "s"}`, `초안 ${drafts.length}개의 문제 수`, `${drafts.length} 个草稿的问题`)}</h4>
76
76
  <Trend totals={drafts.map(d => qaProblems(d.qa))} />
77
77
  </section>}
78
78
  {goals.length > 0 && <section className="agent-progress-section">
79
79
  <button type="button" className="agent-progress-fold" aria-expanded={!!shown.goals} onClick={() => toggle("goals")}>
80
- <h4>{ko("Goals", "목표")}</h4><span>{ko(`${goals.filter(g => g.ok).length} of ${goals.length} met`, `${goals.length}개 중 ${goals.filter(g => g.ok).length}개 충족`)}</span>
80
+ <h4>{ko("Goals", "목표")}</h4><span>{ko(`${goals.filter(g => g.ok).length} of ${goals.length} met`, `${goals.length}개 중 ${goals.filter(g => g.ok).length}개 충족`, `${goals.length} 个目标中已达成 ${goals.filter(g => g.ok).length} 个`)}</span>
81
81
  </button>
82
82
  {shown.goals && <ul className="agent-progress-goals">
83
83
  {goals.map((g, i) => <li key={i} data-goal-ok={g.ok ? "true" : "false"}>
@@ -45,7 +45,7 @@ function Step({ step, live, isLast, now, toolLabels }) {
45
45
  </li>;
46
46
  }
47
47
  const running = step.kind === "compose" || step.status === "running";
48
- const label = step.kind === "compose" ? ko(`Preparing ${stepLabel(step, toolLabels)}`, `${stepLabel(step, toolLabels)} 준비 중`) : stepLabel(step, toolLabels);
48
+ const label = step.kind === "compose" ? ko(`Preparing ${stepLabel(step, toolLabels)}`, `${stepLabel(step, toolLabels)} 준비 중`, `正在准备 ${stepLabel(step, toolLabels)}`) : stepLabel(step, toolLabels);
49
49
  const result = running ? (step.progress ? `${scriptNow(step)}${step.progress.summary ? ` — ${step.progress.summary}` : ""}` : "") : stepResult(step);
50
50
  const time = running ? now - (step.startedAt ?? now) : step.elapsedMs;
51
51
  return <li className={`agent-step ${step.kind}${step.status === "failed" ? " failed" : ""}`} data-step-kind={step.kind} data-tool-name={step.name} data-tool-status={step.kind === "compose" ? "composing" : step.status} data-action={step.action}>
@@ -58,7 +58,7 @@ export default function CozySceneNode({ id = "cozy-scene", data: rawData = {}, s
58
58
  // No shotId: the Scene node tracks a scene, not a shot, so the embed
59
59
  // packs the shot under its own playhead — the one being previewed.
60
60
  const result = await requestKeyframePack(frame, { surface: "workflow" });
61
- downloadPack(result);
61
+ await downloadPack(result);
62
62
  attempt.succeed();
63
63
  track("export:keyframe_pack", { entries: result.entries.length, source: "workflow" });
64
64
  // QA hook, mirroring window.__cozyclay in the Studio: the analytics
@@ -28,7 +28,7 @@ function PromptForm({ prompt, onSubmit }) {
28
28
  <div className="agent-key-controls">
29
29
  <input id={inputId} className="agent-key-input" type={ask.type === "secret" ? "password" : "text"} autoComplete="off" spellCheck="false"
30
30
  placeholder={ask.placeholder ?? ""} value={value} autoFocus onChange={(event) => setValue(event.target.value)} />
31
- <button type="submit" className="agent-key-save" disabled={ask.type === "secret" && !value.trim()}>Continue</button>
31
+ <button type="submit" className="agent-key-save" disabled={ask.type === "secret" && !ask.optional && !value.trim()}>Continue</button>
32
32
  </div>
33
33
  </form>;
34
34
  }
@@ -19,7 +19,7 @@ function useScopeBadges(scopes, titles) {
19
19
  const paint = () => {
20
20
  for (const row of document.querySelectorAll("[data-node-id]")) {
21
21
  const title = owner.get(row.getAttribute("data-node-id"));
22
- if (title) { row.setAttribute("data-task-scope", title); row.setAttribute("title", ko(`In task: ${title}`, `작업 중: ${title}`)); }
22
+ if (title) { row.setAttribute("data-task-scope", title); row.setAttribute("title", ko(`In task: ${title}`, `작업 중: ${title}`, `任务中:${title}`)); }
23
23
  else if (row.hasAttribute("data-task-scope")) { row.removeAttribute("data-task-scope"); row.removeAttribute("title"); }
24
24
  }
25
25
  };
@@ -31,7 +31,7 @@ export function TaskRows({ steps, items }) {
31
31
  return <div className="task-rows">{ids.map(id => <TaskRow key={id} task={board.tasks[id]} activity={activity} />)}</div>;
32
32
  }
33
33
 
34
- const GROUPS = [["failed", ko("need a look", "확인 필요")], ["running", ko("running", "실행 중")], ["waiting", ko("waiting", "대기")], ["done", ko("done", "완료")]];
34
+ const GROUPS = [["failed", ko("need a look", "확인 필요")], ["running", ko("running", "실행 중", "执行中")], ["waiting", ko("waiting", "대기")], ["done", ko("done", "완료")]];
35
35
 
36
36
  export function TaskStrip() {
37
37
  const { board, activity } = useTaskView();
@@ -13,7 +13,7 @@ import { FiActivity, FiBox, FiCode, FiFileText, FiFilm, FiImage, FiLink, FiMusic
13
13
  import { Toaster, toast } from "react-hot-toast";
14
14
  import { loadWorkflowGraph, normalizeWorkflowGraph, storeWorkflowGraph, WORKFLOW_STORAGE_KEY } from "../project.js";
15
15
  import AgentPanel from "./AgentPanel.jsx";
16
- import CozySceneNode from "./CozySceneNode.jsx";
16
+ import CozySceneNode from "./CozySceneNode.jsx"; import { saveExport } from "../export-workspace.js";
17
17
  import { applyCozyScenePatch, normalizeCozySceneData, sceneConnectionAllowed } from "./cozy-scene-node.js";
18
18
  import { activeSceneCharacters, characterHandleId, characterIdFromHandle, normalizeMotionInputData, motionInputOutput, parseMotionSpec } from "./motion-input.js";
19
19
  import { DEFAULT_NODE_SCHEMAS, defaultFormValues, schemaCategoryForType, schemaModelEntries, schemaProperties } from "./node-schema.js";
@@ -652,7 +652,7 @@ export default function WorkflowBuilder() {
652
652
  },
653
653
  })), [changeModel, nodeSchemas, nodes, runScene, runWorkflow, sceneCharacters, sceneContext, sendSceneToVideo, updateNode, updateScene, uploadFile, useVersionAsReference]);
654
654
 
655
- const exportGraph = useCallback(() => { const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = "cozyclay-workflow.json"; anchor.click(); URL.revokeObjectURL(url); toast.success("Workflow exported"); }, [graph]);
655
+ const exportGraph = useCallback(async () => { const blob = new Blob([JSON.stringify(graph, null, 2)], { type: "application/json" }); await saveExport(blob, "cozyclay-workflow.json"); toast.success("Workflow exported"); }, [graph]);
656
656
  const onConnect = useCallback((params) => {
657
657
  const target = nodes.find((node) => node.id === params.target);
658
658
  const source = nodes.find((node) => node.id === params.source);
@@ -204,7 +204,7 @@ export const PANEL_PRESENTATIONS = Object.freeze({
204
204
  history: true,
205
205
  persistWidth: false,
206
206
  emptyTitle: ko("Direct the scene", "\uC7A5\uBA74\uC744 \uC5F0\uCD9C\uD558\uC138\uC694"),
207
- emptyHint: (sceneName) => ko(`Ask for blocking, a camera move, or a motion take in \u201C${sceneName}\u201D.`, `\u201C${sceneName}\u201D\uC5D0\uC11C \uBE14\uB85C\uD0B9, \uCE74\uBA54\uB77C \uC6C0\uC9C1\uC784, \uBAA8\uC158 \uD14C\uC774\uD06C\uB97C \uC694\uCCAD\uD558\uC138\uC694.`),
207
+ emptyHint: (sceneName) => ko(`Ask for blocking, a camera move, or a motion take in \u201C${sceneName}\u201D.`, `\u201C${sceneName}\u201D\uC5D0\uC11C \uBE14\uB85C\uD0B9, \uCE74\uBA54\uB77C \uC6C0\uC9C1\uC784, \uBAA8\uC158 \uD14C\uC774\uD06C\uB97C \uC694\uCCAD\uD558\uC138\uC694.`, `在“${sceneName}”中请求摆位、摄影机运动或动作片段。`),
208
208
  composerPlaceholder: ko("Ask the agent to block, frame or animate\u2026", "\uBE14\uB85C\uD0B9\u00B7\uAD6C\uB3C4\u00B7\uBAA8\uC158\uC744 \uC694\uCCAD\uD558\uC138\uC694\u2026"),
209
209
  }),
210
210
  });
@@ -66,9 +66,9 @@ export function stepResult(step) {
66
66
  if (step.name === "script") {
67
67
  const calls = Array.isArray(result.calls) ? result.calls : [];
68
68
  const refused = calls.filter(call => call?.ok === false);
69
- const head = ko(`${calls.length} call${calls.length === 1 ? "" : "s"}`, `호출 ${calls.length}회`);
69
+ const head = ko(`${calls.length} call${calls.length === 1 ? "" : "s"}`, `호출 ${calls.length}회`, `调用 ${calls.length} 次`);
70
70
  if (result.ok === false) return clip(`${head} · ${result.error?.message ?? ko("failed", "실패")}${result.rollback ? ko(" · its edits were rolled back", " · 편집 되돌림") : ""}`);
71
- return clip(refused.length ? `${head} · ${ko(`${refused.length} refused`, `${refused.length}회 거부`)}: ${refused[0].code ?? refused[0].message ?? ""}` : head);
71
+ return clip(refused.length ? `${head} · ${ko(`${refused.length} refused`, `${refused.length}회 거부`, `${refused.length} 次拒绝`)}: ${refused[0].code ?? refused[0].message ?? ""}` : head);
72
72
  }
73
73
  if (step.name === "verify_result") return clip(verifySummary(result, step.args));
74
74
  if (typeof result.summary === "string" && result.summary) return clip(result.summary.replace(/\.$/, ""));
@@ -81,7 +81,7 @@ export function scriptNow(step) {
81
81
  const now = step.progress;
82
82
  if (!now) return "";
83
83
  const label = now.label ?? (now.action ? actionWords(now.action) : actionWords(now.name ?? "call"));
84
- return ko(`${label} (call ${now.index})`, `${label} (${now.index}번째 호출)`);
84
+ return ko(`${label} (call ${now.index})`, `${label} (${now.index}번째 호출)`, `${label}(第 ${now.index} 次调用)`);
85
85
  }
86
86
 
87
87
  /** The reasoning text so far as one line: its latest paragraph, or the one before while the latest has only begun. */
@@ -157,7 +157,7 @@ export function turnLayout(items, { streaming = false } = {}) {
157
157
  /** The header of a turn's step list: how many steps, how it ended and how long it took. */
158
158
  export function stepsHeader(block, now, formatClock) {
159
159
  const count = block.steps.filter(step => step.kind === "tool").length;
160
- const steps = count ? ` · ${ko(`${count} step${count === 1 ? "" : "s"}`, `${count}단계`)}` : "";
160
+ const steps = count ? ` · ${ko(`${count} step${count === 1 ? "" : "s"}`, `${count}단계`, `${count} 步`)}` : "";
161
161
  if (block.live) {
162
162
  const started = block.steps[0]?.startedAt;
163
163
  return { tone: "busy", text: `${ko("Working", "작업 중")}${steps}${Number.isFinite(started) ? ` · ${formatClock(now - started)}` : ""}` };
@@ -179,7 +179,7 @@ export function changedLine(ids, context) {
179
179
  const rest = ids.filter(id => !names.has(id)).length;
180
180
  if (!named.length && !rest) return ko("Changed the scene", "장면을 바꿈");
181
181
  const shown = named.slice(0, 4).join(", "), more = Math.max(0, named.length - 4) + rest;
182
- return ko(`Changed ${shown || "the scene"}${more > 0 ? ` and ${more} more` : ""}`, `${shown || "장면"}${more > 0 ? ` 외 ${more}개` : ""} 변경`);
182
+ return ko(`Changed ${shown || "the scene"}${more > 0 ? ` and ${more} more` : ""}`, `${shown || "장면"}${more > 0 ? ` 외 ${more}개` : ""} 변경`, `修改了 ${shown || "场景"}${more > 0 ? ` 以及另外 ${more} 项` : ""}`);
183
183
  }
184
184
 
185
185
  /** What a stopped turn left behind, as far as its steps tell: their edits stay (one undo entry), or none landed. */
@@ -8,6 +8,7 @@
8
8
  * window and the listener pair.
9
9
  */
10
10
  import { exportFailureCode } from "../analytics.js";
11
+ import { saveExport } from "../export-workspace.js";
11
12
 
12
13
  export const KEYFRAME_PACK_REQUEST = "cozyclay:export-keyframe-pack";
13
14
  export const KEYFRAME_PACK_RESULT = "cozyclay:export-keyframe-pack-result";
@@ -64,15 +65,7 @@ export function requestKeyframePack(iframeWindow, {
64
65
  });
65
66
  }
66
67
 
67
- /** Save a pack the browser already holds in memory. */
68
+ /** Save a pack the browser already holds in memory; resolves when handed off. */
68
69
  export function downloadPack({ name, bytes }) {
69
- const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
70
- const anchor = document.createElement("a");
71
- anchor.href = url;
72
- anchor.download = name || "cozyclay-shot.zip";
73
- anchor.click();
74
- // Revoking synchronously can race the download in some browsers; one task
75
- // later is enough for the click to have been handed off.
76
- setTimeout(() => URL.revokeObjectURL(url), 0);
77
- return { name: anchor.download, url };
70
+ return saveExport(new Blob([bytes], { type: "application/zip" }), name || "cozyclay-shot.zip");
78
71
  }
@@ -31,7 +31,7 @@
31
31
 
32
32
  .task-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--text-3, var(--agent-text-dim)); }
33
33
  [data-status="running"] > .task-dot, [data-status="starting"] > .task-dot { background: var(--select, var(--agent-accent)); animation: task-dot-pulse 1.2s ease-in-out infinite; }
34
- [data-status="waiting"] > .task-dot, [data-status="queued"] > .task-dot { background: none; box-shadow: inset 0 0 0 1.5px var(--text-3, var(--agent-text-dim)); }
34
+ [data-status="waiting"] > .task-dot, [data-status="queued"] > .task-dot, [data-status="review"] > .task-dot { background: none; box-shadow: inset 0 0 0 1.5px var(--text-3, var(--agent-text-dim)); }
35
35
  [data-status="done"] > .task-dot { background: var(--ok, var(--agent-ok)); }
36
36
  [data-status="failed"] > .task-dot, [data-status="paused"] > .task-dot { background: var(--danger, var(--agent-alert)); }
37
37
  @keyframes task-dot-pulse { 50% { opacity: 0.35; } }
@@ -2,7 +2,7 @@
2
2
  // toggle) or the server's COZYCLAY_STUDIO_TASKS flag: the chat then talks to the main agent (POST /agent/main), which
3
3
  // answers at once and dispatches tasks; this board shows them. Pure parts here (the reducer, labels, the switch's
4
4
  // storage); the network side is createTaskBoardClient; the view is TaskBoard.jsx.
5
- // server events (GET /agent/main/<sessionId>/events): task.queued|waiting|started|resumed|paused|done|failed|cancelled
5
+ // server events (GET /agent/main/<sessionId>/events): task.queued|waiting|started|resumed|paused|review|done|failed|cancelled
6
6
  // carry the task's view { taskId, title, status, scope, reason?, turnId?, usage, summary? }; task.sceneChanged
7
7
  // { taskId, keys, midCommand }; main.turn { turnId } (the main agent reporting on its own)
8
8
  // editor events (the command bus): task.userEdit { taskId, keys } (the user changed something in its scope)
@@ -25,6 +25,7 @@ export const TASK_STATUS_COPY = {
25
25
  starting: ko("Starting", "시작 중"),
26
26
  running: ko("Running", "작업 중"),
27
27
  paused: ko("Paused: Studio lost", "일시정지: Studio 연결 끊김"),
28
+ review: ko("Being checked", "확인 중"),
28
29
  done: ko("Done", "완료"),
29
30
  failed: ko("Failed", "실패"),
30
31
  cancelled: ko("Cancelled", "취소됨"),
@@ -44,8 +45,8 @@ export function reduceBoard(board, event) {
44
45
  return { ...board, after, order: known ? board.order : [...board.order, taskId], tasks: { ...board.tasks, [taskId]: task } };
45
46
  };
46
47
  const note = (taskId, text) => (board.tasks[taskId] ? patch(taskId, { notes: [...board.tasks[taskId].notes, text].slice(-4) }) : { ...board, after });
47
- if (event.type === "task.sceneChanged") return note(event.taskId, ko(`The scene changed during a reload: ${event.keys.join(", ")}`, `새로고침 중에 장면이 바뀜: ${event.keys.join(", ")}`));
48
- if (event.type === "task.userEdit") return note(event.taskId, ko(`You changed ${event.keys.join(", ")}; the task reads it again`, `${event.keys.join(", ")}을(를) 직접 바꿈: 작업이 다시 읽어요`));
48
+ if (event.type === "task.sceneChanged") return note(event.taskId, ko(`The scene changed during a reload: ${event.keys.join(", ")}`, `새로고침 중에 장면이 바뀜: ${event.keys.join(", ")}`, `重新加载期间场景发生变化:${event.keys.join(", ")}`));
49
+ if (event.type === "task.userEdit") return note(event.taskId, ko(`You changed ${event.keys.join(", ")}; the task reads it again`, `${event.keys.join(", ")}을(를) 직접 바꿈: 작업이 다시 읽어요`, `你修改了 ${event.keys.join(", ")};任务会重新读取`));
49
50
  if (event.type === "task.undone") return board.tasks[event.taskId] ? patch(event.taskId, { undone: true }) : board;
50
51
  if (typeof event.type === "string" && event.type.startsWith("task.") && event.taskId && event.status) {
51
52
  const { type, at, eventSeq, ...view } = event;
@@ -62,7 +63,7 @@ export function seedBoard(board, tasks = []) {
62
63
  /** The line a running card shows: the last tool it started, or the compile it waits for. */
63
64
  export function progressLine(frame, labelOf = call => call.name) {
64
65
  if (frame?.type === "tool.start") return labelOf(frame);
65
- if (frame?.type === "error") return ko(`Error: ${frame.message ?? frame.code}`, `오류: ${frame.message ?? frame.code}`);
66
+ if (frame?.type === "error") return ko(`Error: ${frame.message ?? frame.code}`, `오류: ${frame.message ?? frame.code}`, `错误:${frame.message ?? frame.code}`);
66
67
  return null;
67
68
  }
68
69
 
@@ -70,7 +71,7 @@ export function progressLine(frame, labelOf = call => call.name) {
70
71
  export function taskReason(reason) {
71
72
  // Greedy to the last ")": a title may hold parentheses of its own.
72
73
  const after = /(?:waits for|held by) task \S+ \((.+)\)/.exec(String(reason ?? ""))?.[1];
73
- if (after) return ko(`Starts after "${after}"`, `'${after}' 끝나면 시작`);
74
+ if (after) return ko(`Starts after "${after}"`, `'${after}' 끝나면 시작`, `在“${after}”之后开始`);
74
75
  if (/scope is held/.test(String(reason ?? ""))) return ko("Starts when another task frees its part", "다른 작업이 끝나면 시작");
75
76
  return reason ?? "";
76
77
  }
@@ -92,15 +93,16 @@ export function boardGroups(board) {
92
93
  const groups = { failed: [], running: [], waiting: [], done: [] };
93
94
  for (const id of board.order) {
94
95
  const task = board.tasks[id];
95
- const group = task.status === "failed" || task.status === "paused" ? "failed" : task.status === "running" || task.status === "starting" ? "running" : ACTIVE_STATUSES.has(task.status) ? "waiting" : "done";
96
+ // review: the run ended but the coordinator still checks it, so it waits (and offers no cancel: not in ACTIVE_STATUSES).
97
+ const group = task.status === "failed" || task.status === "paused" ? "failed" : task.status === "running" || task.status === "starting" ? "running" : ACTIVE_STATUSES.has(task.status) || task.status === "review" ? "waiting" : "done";
96
98
  groups[group].push(task);
97
99
  }
98
100
  return groups;
99
101
  }
100
102
 
101
- /** The task ids a turn's dispatch_task steps started, in order (a duplicate points at the task it repeats). */
102
- export const dispatchedTaskIds = steps => (steps ?? []).filter(step => step.kind === "tool" && step.name === "dispatch_task")
103
- .map(step => step.result?.taskId ?? step.result?.duplicateOf).filter(Boolean);
103
+ /** The task ids a turn's dispatch_task and dispatch_plan steps started, in order (a duplicate points at the task it repeats). */
104
+ export const dispatchedTaskIds = steps => (steps ?? []).filter(step => step.kind === "tool" && ["dispatch_task", "dispatch_plan"].includes(step.name))
105
+ .flatMap(step => (step.name === "dispatch_plan" ? step.result?.tasks ?? [] : [step.result])).map(result => result?.taskId ?? result?.duplicateOf).filter(Boolean);
104
106
 
105
107
  /** What the task view shares between the headless board (TaskBoard.jsx) and the rows in the chat (TaskInline.jsx). */
106
108
  function createTaskViewStore() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "bin/agent/agent-routes.mjs": 804,
3
3
  "bin/agent/agent-runner.mjs": 495,
4
- "bin/agent/providers.mjs": 338,
4
+ "bin/agent/providers.mjs": 255,
5
5
  "bin/agent/session-store.mjs": 324,
6
6
  "bin/agent/supervisor.mjs": 283,
7
7
  "bin/agent/video-adapters.mjs": 334,
@@ -15,7 +15,7 @@
15
15
  "mcp/verify-live-p0.mjs": 264,
16
16
  "mcp/verify-live.mjs": 416,
17
17
  "src/analytics.js": 1038,
18
- "src/App.jsx": 4476,
18
+ "src/App.jsx": 4448,
19
19
  "src/asset-pane.jsx": 643,
20
20
  "src/camera-follow.js": 505,
21
21
  "src/camera-move.js": 289,
@@ -58,7 +58,6 @@
58
58
  "src/shell/BottomDock.jsx": 416,
59
59
  "src/shell/DetailsSlot.jsx": 334,
60
60
  "src/shell/MenuBar.jsx": 632,
61
- "src/shell/PreferencesDialog.jsx": 269,
62
61
  "src/shell/ViewportToolbar.jsx": 567,
63
62
  "src/shot-authoring.js": 345,
64
63
  "src/stage-canvas-rigs.jsx": 350,