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
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"65554e5162b89f817b066016390471333bfcabcee80e0ae0ee46939bdc759599\"}","signature":"M4R6haq7YzLWxDPcdy5h4zI/w7nVEBubTszIj4UzgWEKB4XZgZYtrjsQJmjt2rF3IooyCKnvMlzNiYB/arAGDg=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.3\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"9c94e22b92abbaaee2aefc22fdf0e2a5fe5de745d82739751ae08884b1f655bd\"}","signature":"sY+Pss1UzGZHXmVNKQRKwpxGLw6xN1iMtej6DnUUrBusSndF/2SePRXbdL0PO1CiM6Y6nsECF9EeuIZo125ZDQ=="}
package/dist/index.html CHANGED
@@ -394,7 +394,7 @@
394
394
  <div class="wrap start">
395
395
  <div class="start-head">
396
396
  <h2>Quick start</h2>
397
- <p>Node.js 22.13+ and a Chromium browser.</p>
397
+ <p>Node.js 22.19+ and a Chromium browser.</p>
398
398
  <a href="#controls" id="controls-link">Full control list</a>
399
399
  </div>
400
400
  <div class="start-body">
package/dist/sitemap.xml CHANGED
@@ -2,7 +2,7 @@
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
3
  <url>
4
4
  <loc>https://cozyclay.org/</loc>
5
- <lastmod>2026-10-21</lastmod>
5
+ <lastmod>2026-10-23</lastmod>
6
6
  </url>
7
7
  <url>
8
8
  <loc>https://cozyclay.org/greybox-to-video/</loc>
@@ -8,13 +8,13 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-zQM-EKW1.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-BMJGd_gZ.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
14
- <link rel="modulepreload" crossorigin href="/assets/task-board-ErQvfoaz.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/task-board-DFfNa0xg.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
16
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Cgdtr1Yw.js">
17
- <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-CBx7XRti.css">
16
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Bz59Ghdz.js">
17
+ <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
18
18
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
19
19
  </head>
20
20
  <body>
package/mcp/README.md CHANGED
@@ -35,7 +35,7 @@ Then point a client at it. For Claude Desktop, in `claude_desktop_config.json`:
35
35
  }
36
36
  ```
37
37
 
38
- Restart the client; 28 tools appear.
38
+ Restart the client; 27 tools appear.
39
39
 
40
40
  Prefer a long-lived endpoint? `node server.mjs --http 5183` serves Streamable HTTP at
41
41
  `http://127.0.0.1:5183/mcp` (one isolated session per client), with a plain status page at `/`.
package/mcp/live-hub.mjs CHANGED
@@ -436,10 +436,12 @@ export class LiveHub {
436
436
  void this.serveController(socket, frame);
437
437
  return;
438
438
  }
439
- if (frame?.type === "event" && typeof frame.name === "string" && frame.payload && typeof frame.payload === "object") {
440
- const workspaceHandle = [...this.editors.entries()].find(([, editor]) => editor === socket)?.[0];
439
+ // Meta and event frames both act for this socket's workspace. A meta frame
440
+ // relabels the tab after it opens another project; the handle stays.
441
+ const workspaceHandle = [...this.editors.entries()].find(([, editor]) => editor === socket)?.[0];
442
+ if (frame?.type === "meta" && workspaceHandle && frame.meta && typeof frame.meta === "object") this.workspaceMeta.set(workspaceHandle, frame.meta);
443
+ else if (frame?.type === "event" && typeof frame.name === "string" && frame.payload && typeof frame.payload === "object") {
441
444
  if (workspaceHandle) this.onEvent?.({ workspaceHandle, workspaceId: this.workspaceId(workspaceHandle), name: frame.name, payload: frame.payload });
442
- return;
443
445
  }
444
446
  if (frame?.type !== "result" || typeof frame.id !== "string") return;
445
447
  const pending = this.pending.get(frame.id);
@@ -71,6 +71,7 @@ import {
71
71
  import { classifyMove, captureFraming, moveSlate } from "../src/camera-move.js";
72
72
  import { DEFAULT_PREVIS_MODE, PREVIS_MODES, createProjectDocument, readProjectDocument } from "../src/project.js";
73
73
  import { kindRefusal } from "../src/character-kind.js";
74
+ import { documentWithCamera, splitSavedCamera } from "./project-camera.mjs";
74
75
 
75
76
  /** The authoring state. One scene document, one camera, one project name. */
76
77
  export const state = {
@@ -1697,7 +1698,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1697
1698
  // the current stage shape rather than trusted as-is.
1698
1699
  const scenes = readSceneDocument(serializeSceneDocument(result.project.scenesDocument));
1699
1700
  if (!scenes.document) return text(`That project was written by a newer CozyClay: ${full}`);
1700
- const nextDocument = scenes.document;
1701
+ const { document: nextDocument, camera: savedCamera } = splitSavedCamera(scenes.document);
1701
1702
  if (liveHub?.connected) {
1702
1703
  try {
1703
1704
  const loaded = await executeStudioCommand({ ...admission, action: "load_scenes", args: { document: nextDocument } });
@@ -1713,11 +1714,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1713
1714
  }
1714
1715
  }
1715
1716
  state.doc = nextDocument;
1717
+ Object.assign(state.camera, savedCamera);
1716
1718
  state.previsMode = result.project.previsMode ?? DEFAULT_PREVIS_MODE;
1717
1719
  state.name = result.project.name;
1718
- state.focus = null;
1719
- state.focusLocked = false;
1720
- state.markedFraming = null;
1720
+ Object.assign(state, { focus: null, focusLocked: false, markedFraming: null });
1721
1721
  return text(`Opened ${full}.\n\n${sceneReport()}`);
1722
1722
  },
1723
1723
  ),
@@ -1753,7 +1753,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1753
1753
  return text(`Could not write project: ${error.message}`);
1754
1754
  }
1755
1755
  const project = createProjectDocument({
1756
- scenesDocument: state.doc,
1756
+ scenesDocument: documentWithCamera(state.doc, state.camera),
1757
1757
  workspaceLayout: null,
1758
1758
  customPoses: [],
1759
1759
  name: state.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -61,9 +61,11 @@
61
61
  "license": "AGPL-3.0-or-later",
62
62
  "type": "module",
63
63
  "dependencies": {
64
- "@earendil-works/pi-agent-core": "0.85.1",
65
- "@earendil-works/pi-ai": "0.85.1",
66
- "@earendil-works/pi-codemode": "1.1.0"
64
+ "@anthropic-ai/claude-agent-sdk": "^0.3.306",
65
+ "@earendil-works/pi-agent-core": "1.4.0",
66
+ "@earendil-works/pi-ai": "1.4.0",
67
+ "@earendil-works/pi-codemode": "1.4.0",
68
+ "zod": "^4.6.7"
67
69
  },
68
70
  "devDependencies": {
69
71
  "@eslint/js": "^10.0.1",
package/src/App.jsx CHANGED
@@ -151,7 +151,8 @@ import {
151
151
  trackActivation,
152
152
  trackFeature,
153
153
  } from "./analytics.js";
154
- import { ko, isKo } from "./locale.js";
154
+ import { ko, isKo, isZh } from "./locale.js";
155
+ import { saveExport } from "./export-workspace.js";
155
156
  import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
156
157
  import { STARTER_SCENES, resolveSceneParam } from "./starter-scenes.js";
157
158
  import { PART_COLOURS } from "./part-colours.js";
@@ -1146,7 +1147,7 @@ export default function App() {
1146
1147
  // A capsule figure has no rig at all, so it gets its own reason rather than
1147
1148
  // the "load a character" one, which would read as a loading problem.
1148
1149
  const activeCapabilities = characterCapabilities(activeChar);
1149
- const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다");
1150
+ const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo, isZh) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다", "加载角色后才能摆姿势 — 姿势模式用 IK 编辑其骨骼");
1150
1151
  function selectWorkflowMode(next) {
1151
1152
  if (next === "pose" && poseRefusal) {
1152
1153
  setToast(poseRefusal);
@@ -1154,7 +1155,7 @@ export default function App() {
1154
1155
  }
1155
1156
  // A storyboard is stills: there is no Motion department to open.
1156
1157
  if (next === "motion" && storyboardProject) {
1157
- setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
1158
+ setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko, STORYBOARD_MOTION_REFUSAL.zh));
1158
1159
  return;
1159
1160
  }
1160
1161
  setWorkflowMode(next);
@@ -1257,7 +1258,7 @@ export default function App() {
1257
1258
  setSculptBlueprint((current) => ({ ...current, state: "idle", sheet: null, error: null }));
1258
1259
  setSculptReference(await objectsDomain.storeSculptReference(file));
1259
1260
  } catch (error) {
1260
- setToast(ko(`Could not read that image: ${error.message}`, `이미지를 읽지 못했어요: ${error.message}`));
1261
+ setToast(ko(`Could not read that image: ${error.message}`, `이미지를 읽지 못했어요: ${error.message}`, `无法读取该图片:${error.message}`));
1261
1262
  }
1262
1263
  }
1263
1264
  function startSculptRun() {
@@ -1477,7 +1478,7 @@ export default function App() {
1477
1478
 
1478
1479
  const rejectUnsupportedDrop = (count) => setToast(ko(
1479
1480
  `${count} file${count > 1 ? "s" : ""} not supported — use PNG, JPG, WebP, GIF or a .glb / .obj / .fbx (iPhone HEIC photos need converting first)`,
1480
- `지원하지 않는 파일 ${count}개 — PNG, JPG, WebP, GIF 또는 .glb / .obj / .fbx만 가능해요 (아이폰 HEIC 사진은 먼저 변환해 주세요)`,
1481
+ `지원하지 않는 파일 ${count}개 — PNG, JPG, WebP, GIF 또는 .glb / .obj / .fbx만 가능해요 (아이폰 HEIC 사진은 먼저 변환해 주세요)`, `不支持 ${count} 个文件 — 请使用 PNG、JPG、WebP、GIF 或 .glb / .obj / .fbx(iPhone HEIC 照片需先转换)`,
1481
1482
  ));
1482
1483
  const stageDrop = {
1483
1484
  onImages: (files) => importCutouts(files),
@@ -1785,7 +1786,7 @@ export default function App() {
1785
1786
  // so run_action can give the agent the reason the user was shown.
1786
1787
  const toastSinkRef = useRef(new Set());
1787
1788
  const setToast = useCallback((value, english) => {
1788
- const toast = resolveStudioToast(value, isKo, ko);
1789
+ const toast = resolveStudioToast(value, isKo, ko, isZh);
1789
1790
  if (english !== undefined) toast.message = english;
1790
1791
  if (typeof toast.uiMessage === "string" && toast.uiMessage) for (const sink of toastSinkRef.current) sink(toast);
1791
1792
  showToast(toast.uiMessage);
@@ -1947,7 +1948,7 @@ export default function App() {
1947
1948
  deleted = true;
1948
1949
  return true;
1949
1950
  } catch (error) {
1950
- setToast(isKo ? `이미지를 삭제하지 못했어요 — ${error.message}` : `Could not delete that image — ${error.message}`);
1951
+ setToast(ko(`Could not delete that image — ${error.message}`, `이미지를 삭제하지 못했어요 — ${error.message}`, `无法删除该图片 — ${error.message}`));
1951
1952
  return false;
1952
1953
  } finally {
1953
1954
  db?.close?.();
@@ -1970,9 +1971,9 @@ export default function App() {
1970
1971
  setAssetTrash((current) => current.filter((asset) => asset.id !== record.id));
1971
1972
  setAssetUndoOffered(false);
1972
1973
  restored = true;
1973
- setToast(isKo ? `${record.name || "이미지"} 복원됨` : `${record.name || "Image"} restored`);
1974
+ setToast(ko(`${record.name || "Image"} restored`, `${record.name || "이미지"} 복원됨`, `${record.name || "图片"}已恢复`));
1974
1975
  } catch (error) {
1975
- setToast(isKo ? `이미지를 복원하지 못했어요 — ${error.message}` : `Could not restore that image — ${error.message}`);
1976
+ setToast(ko(`Could not restore that image — ${error.message}`, `이미지를 복원하지 못했어요 — ${error.message}`, `无法恢复该图片 — ${error.message}`));
1976
1977
  } finally {
1977
1978
  if (restored) await refreshAssetShelf();
1978
1979
  setDeletingAssetId(null);
@@ -2463,6 +2464,17 @@ export default function App() {
2463
2464
  capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
2464
2465
  };
2465
2466
 
2467
+ const liveSceneName = scenes.find((entry) => entry.id === activeSceneId)?.name ?? "";
2468
+ const liveMeta = () => ({
2469
+ project: projectName ?? "Untitled", scene: liveSceneName, cast: appContext.live.characters.length,
2470
+ // The Workflow page's embedded preview is a live editor too; mark it apart from the authoring tab.
2471
+ // Commands: what this editor answers; a stale tab from an older build answers a different set.
2472
+ ...(embedMode ? { embed: true } : {}), commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
2473
+ });
2474
+ // The client is created once; opening a project must still relabel the tab
2475
+ // in live status and the --workspace <project> selector (#864).
2476
+ useEffect(() => { liveControlRef.current?.setMeta(liveMeta()); }, [projectName, liveSceneName, appContext.live.characters.length]);
2477
+
2466
2478
  useEffect(() => {
2467
2479
  const enabled = import.meta.env.DEV || window.__COZYCLAY_LIVE__ === true;
2468
2480
  if (!enabled) return undefined;
@@ -2474,19 +2486,7 @@ export default function App() {
2474
2486
  // Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
2475
2487
  handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
2476
2488
  workspaceId: liveWorkspaceIdRef.current,
2477
- meta: {
2478
- project: projectName ?? "Untitled",
2479
- scene: scenes.find((entry) => entry.id === activeSceneId)?.name ?? "",
2480
- cast: appContext.live.characters.length,
2481
- // The Workflow page embeds this same Studio as a preview. It is a
2482
- // live editor too, so an agent choosing a workspace must be able to
2483
- // tell the preview apart from the tab the user is authoring in.
2484
- ...(embedMode ? { embed: true } : {}),
2485
- // Which live commands this editor answers. A stale tab from an
2486
- // older build (or a different app on the same port) answers a
2487
- // different set; the agent picks a workspace that has what it needs.
2488
- commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
2489
- },
2489
+ meta: liveMeta(),
2490
2490
  // The shared client intentionally has no disconnect UI callback.
2491
2491
  // Observe only this owned socket; a stale handle must never be sent.
2492
2492
  WebSocketImpl: class extends WebSocket {
@@ -2776,7 +2776,7 @@ export default function App() {
2776
2776
  case "unsupported_codec": return ko("MP4 encoding is unavailable. Use a current browser with H.264 WebCodecs support (such as Chrome or Edge), enable hardware acceleration, then retry.", "MP4 인코딩을 사용할 수 없어요. H.264 WebCodecs를 지원하는 최신 Chrome·Edge 등에서 하드웨어 가속을 켠 뒤 다시 시도하세요.");
2777
2777
  case "encode_failed": return ko("Video encoding failed. Retry the same request. If resources are tight, shorten the shot range or lower output resolution before starting a new export.", "영상 인코딩에 실패했어요. 같은 요청을 다시 시도하세요. 리소스가 부족하면 샷 범위를 줄이거나 출력 해상도를 낮춘 뒤 새로 내보내세요.");
2778
2778
  case "render_failed": return ko("Frame rendering failed. Let the scene finish loading, then retry. If it repeats, shorten the range or reduce output resolution for a new export.", "프레임 렌더링에 실패했어요. 장면 로딩이 끝난 뒤 다시 시도하세요. 반복되면 범위나 출력 해상도를 줄여 새로 내보내세요.");
2779
- case "aborted": return ko("Export cancelled. No further downloads will be requested.", "내보내기를 취소했어요. 추가 다운로드는 요청하지 않아요.");
2779
+ case "aborted": return ko("Export cancelled. No further files will be handed off.", "내보내기를 취소했어요. 추가 파일은 전달하지 않아요.");
2780
2780
  default: return ko("Export could not finish. Retry the same request. For memory or resource problems, close other heavy tabs or use a shorter range / lower output resolution in a new export.", "내보내기를 완료하지 못했어요. 같은 요청을 다시 시도하세요. 메모리·리소스 문제라면 무거운 탭을 닫거나 범위·출력 해상도를 줄여 새로 내보내세요.");
2781
2781
  }
2782
2782
  }
@@ -2821,7 +2821,7 @@ export default function App() {
2821
2821
  job.controller.signal.throwIfAborted();
2822
2822
  attempt?.succeed();
2823
2823
  updateExportStatus(job, "completed", { message: request.download
2824
- ? ko("Export completed. Download requested; check your browser's downloads. The OS save is not confirmed.", "내보내기를 완료하고 다운로드를 요청했어요. 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
2824
+ ? ko("Export completed. Check your export folder or browser downloads; the OS save is not confirmed.", "내보내기를 완료했어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
2825
2825
  : ko("Export pipeline completed. The requesting tool handles the file handoff.", "내보내기 처리를 완료했어요. 요청한 도구에서 파일 전달을 처리합니다.") });
2826
2826
  if (!request.external) {
2827
2827
  if (request.kind === "video") { track("export:video_succeeded", { format: "mp4" }); trackFeature("export_video"); }
@@ -3010,11 +3010,9 @@ export default function App() {
3010
3010
  const slate = (moveSequence?.slate ?? "shot").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "shot";
3011
3011
  // Default plate filename: const name = `cozyclay-${slate}.mp4`
3012
3012
  const name = fileName ?? `cozyclay-${slate}.mp4`;
3013
- const url = URL.createObjectURL(result.blob);
3014
- try { saveDownload(url, name); }
3015
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3013
+ await saveExport(result.blob, name);
3016
3014
  setRecordedVideoName(name);
3017
- setToast((isKo, ko) => isKo ? `${name} 다운로드 요청 · ${result.frameCount}프레임` : `Download requested: ${name} · ${result.frameCount} frames`);
3015
+ setToast((isKo, ko) => ko(`${name} exported · ${result.frameCount} frames`, `${name} 내보내기 완료 · ${result.frameCount}프레임`, `${name} 已导出 · ${result.frameCount} 帧`));
3018
3016
  return { ...result, fileName: name };
3019
3017
  }
3020
3018
  return result;
@@ -3079,13 +3077,13 @@ export default function App() {
3079
3077
  {busy && !message && <p>{stage === "mux"
3080
3078
  ? ko("Finalizing MP4. Progress is indeterminate; this stage cannot be interrupted.", "MP4 마무리 중이에요. 진행률은 알 수 없으며 이 단계는 중단할 수 없어요.")
3081
3079
  : stage === "flush" ? ko("Finishing encoder output. Progress is indeterminate.", "인코더 출력을 마무리하고 있어요. 진행률은 알 수 없어요.")
3082
- : kind === "frame" ? ko("Handing off existing PNGs. Synchronous downloads cannot be cancelled.", "기존 PNG를 전달하고 있어요. 동기 다운로드는 취소할 수 없어요.")
3083
- : stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before download.", "ZIP을 만드는 중이에요. 취소는 현재 작업이 끝난 뒤 다운로드 전에 적용돼요.")
3084
- : stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before download.", "취소하면 다운로드 전 다음 프레임 경계에서 멈춰요.")
3080
+ : kind === "frame" ? ko("Handing off existing PNGs. The file handoff cannot be cancelled.", "기존 PNG를 전달하고 있어요. 파일 전달은 취소할 수 없어요.")
3081
+ : stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before the file handoff.", "ZIP을 만드는 중이에요. 취소는 현재 작업이 끝난 뒤 파일 전달 전에 적용돼요.")
3082
+ : stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before the file handoff.", "취소하면 파일 전달 전 다음 프레임 경계에서 멈춰요.")
3085
3083
  : phase === "preparing" ? ko("Preparing the renderer and checking MP4 support…", "렌더러를 준비하고 MP4 지원을 확인하고 있어요…")
3086
3084
  : ko("Encoding the requested frames, not yet a completed file.", "요청한 프레임을 인코딩 중이에요. 아직 파일이 완성되지 않았어요.")}</p>}
3087
3085
  {retryable && <p>{ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요.")}</p>}
3088
- {handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those downloads; check browser downloads because OS saves cannot be confirmed.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 다운로드는 건너뛰어요. OS 저장은 확인할 수 없으니 브라우저 다운로드를 확인하세요.`)}</p>}
3086
+ {handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those files; check your export folder or browser downloads because OS saves cannot be confirmed.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요. OS 저장은 확인할 수 없으니 내보내기 폴더나 브라우저 다운로드를 확인하세요.`, `已交付 ${handedOff} 个文件。重试时会跳过这些文件;由于无法确认系统保存,请检查导出文件夹或浏览器下载。`)}</p>}
3089
3087
  <div className="export-status-actions">
3090
3088
  {busy && cancellable && <button type="button" data-testid="export-cancel" onClick={stopShotRecording}>{ko("Cancel export", "내보내기 취소")}</button>}
3091
3089
  {retryable && <button type="button" data-testid="export-retry" disabled={recState === "recording"} onClick={() => void retryExport()}>{ko("Retry same export", "같은 내보내기 재시도")}</button>}
@@ -3094,7 +3092,7 @@ export default function App() {
3094
3092
  );
3095
3093
  }
3096
3094
 
3097
- function downloadOtioCutList() {
3095
+ async function downloadOtioCutList() {
3098
3096
  if (!shots.length) {
3099
3097
  setToast(ko("Add at least one Shot before exporting OTIO", "OTIO를 내보내려면 샷을 하나 이상 추가하세요"));
3100
3098
  return;
@@ -3113,20 +3111,15 @@ export default function App() {
3113
3111
  };
3114
3112
  const serialized = serializeOtio(exportScene, shots);
3115
3113
  const blob = new Blob([serialized], { type: "application/json" });
3116
- const url = URL.createObjectURL(blob);
3117
- const anchor = document.createElement("a");
3118
3114
  const slug = (activeScene?.name ?? "cut-list")
3119
3115
  .toLowerCase()
3120
3116
  .replace(/[^a-z0-9]+/g, "-")
3121
3117
  .replace(/^-+|-+$/g, "") || "cut-list";
3122
- anchor.href = url;
3123
- anchor.download = `cozyclay-${slug}.otio`;
3124
- anchor.click();
3125
- setTimeout(() => URL.revokeObjectURL(url), 10_000);
3118
+ await saveExport(blob, `cozyclay-${slug}.otio`);
3126
3119
  const frameCount = shots.reduce((total, shot) => total + shot.endFrame - shot.startFrame + 1, 0);
3127
- setToast(isKo
3128
- ? `OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`
3129
- : `OTIO saved · ${shots.length} shots · ${frameCount} frames`);
3120
+ setToast(ko(`OTIO saved · ${shots.length} shots · ${frameCount} frames`,
3121
+ `OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`,
3122
+ `OTIO 已保存 · ${shots.length} 个镜头 · ${frameCount} 帧`));
3130
3123
  } catch (error) {
3131
3124
  setToast(error?.message || String(error));
3132
3125
  }
@@ -3141,15 +3134,6 @@ export default function App() {
3141
3134
  * - a storyboard contact sheet of the whole cut.
3142
3135
  */
3143
3136
 
3144
- function saveDownload(href, name) {
3145
- const anchor = document.createElement("a");
3146
- anchor.href = href;
3147
- anchor.download = name;
3148
- document.body.appendChild(anchor);
3149
- anchor.click();
3150
- anchor.remove();
3151
- }
3152
-
3153
3137
  function loadImage(dataUrl) {
3154
3138
  return new Promise((resolve, reject) => {
3155
3139
  const image = new Image();
@@ -3217,13 +3201,13 @@ export default function App() {
3217
3201
  updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
3218
3202
  await exportBoundary(job);
3219
3203
  const packShot = { title: entry.name, index: index + 1, startFrame: entry.startFrame, endFrame: entry.endFrame };
3220
- onProgress?.(ko(`Rendering frames for "${entry.name}"`, `"${entry.name}" 프레임 렌더링 중`));
3204
+ onProgress?.(ko(`Rendering frames for "${entry.name}"`, `"${entry.name}" 프레임 렌더링 중`, `正在渲染“${entry.name}”的帧`));
3221
3205
  const firstUrl = captureShotFramePng(entry.startFrame);
3222
3206
  if (!firstUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
3223
3207
  await exportBoundary(job);
3224
3208
  const lastUrl = entry.endFrame > entry.startFrame ? captureShotFramePng(entry.endFrame) : null;
3225
3209
  await exportBoundary(job);
3226
- onProgress?.(ko(`Recording the clip for "${entry.name}"`, `"${entry.name}" 클립 녹화 중`));
3210
+ onProgress?.(ko(`Recording the clip for "${entry.name}"`, `"${entry.name}" 클립 녹화 중`, `正在录制“${entry.name}”的片段`));
3227
3211
  const recorded = await runShotExport({ startFrame: entry.startFrame, endFrame: entry.endFrame, download: false }, job);
3228
3212
  updateExportStatus(job, "finalizing", { stage: "archive", cancellable: true });
3229
3213
  await exportBoundary(job);
@@ -3276,24 +3260,23 @@ export default function App() {
3276
3260
  if (!job.request.context.shots.length) throw new Error(ko("Add at least one Shot before exporting a keyframe pack", "키프레임 팩을 내보내려면 샷을 하나 이상 추가하세요"));
3277
3261
  for (const [order, index] of targets.entries()) {
3278
3262
  if (job.request.handedOff.has(index)) continue;
3279
- job.label = ko(`Shot ${order + 1} of ${targets.length}`, `샷 ${order + 1} / ${targets.length}`);
3263
+ job.label = ko(`Shot ${order + 1} of ${targets.length}`, `샷 ${order + 1} / ${targets.length}`, `镜头 ${order + 1} / ${targets.length}`);
3280
3264
  const pack = await buildShotKeyframePack(job.request.context.shots[index], index, null, job);
3281
3265
  job.controller.signal.throwIfAborted();
3282
- const url = URL.createObjectURL(new Blob([pack.bytes], { type: "application/zip" }));
3283
- try { saveDownload(url, pack.name); job.request.handedOff.add(index); }
3284
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3285
- setToast(isKo ? `${pack.name} 다운로드 요청 · 파일 ${pack.entries.length}개` : `Download requested: ${pack.name} · ${pack.entries.length} files`);
3266
+ await saveExport(new Blob([pack.bytes], { type: "application/zip" }), pack.name);
3267
+ job.request.handedOff.add(index);
3268
+ setToast(ko(`${pack.name} exported · ${pack.entries.length} files`, `${pack.name} 내보내기 완료 · 파일 ${pack.entries.length}개`, `${pack.name} 已导出 · ${pack.entries.length} 个文件`));
3286
3269
  await exportBoundary(job);
3287
3270
  }
3288
3271
  }));
3289
3272
  }
3290
3273
 
3291
3274
  /** Depth and normal conditioning passes of the framing on screen now. */
3292
- function exportRenderPasses() {
3275
+ async function exportRenderPasses() {
3293
3276
  try {
3294
3277
  const dataUrls = renderPassDataUrls();
3295
- for (const kind of ["depth", "normal"]) saveDownload(dataUrls[kind], passFileName(kind));
3296
- setToast(ko("Depth and normal passes downloaded", "뎁스·노멀 패스를 다운로드했어요"));
3278
+ for (const kind of ["depth", "normal"]) await saveExport(dataUrls[kind], passFileName(kind));
3279
+ setToast(ko("Depth and normal passes exported", "뎁스·노멀 패스를 내보냈어요"));
3297
3280
  trackFeature("export_render_pass");
3298
3281
  } catch (error) {
3299
3282
  setToast(error?.message || String(error));
@@ -3352,8 +3335,8 @@ export default function App() {
3352
3335
  cell: { width: 480, height: 300 },
3353
3336
  createCanvas: storyboardCanvas,
3354
3337
  });
3355
- saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3356
- setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
3338
+ await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3339
+ setToast(ko(`Storyboard saved · ${cells.length} shots`, `스토리보드 저장됨 · ${cells.length}샷`, `故事板已保存 · ${cells.length} 个镜头`));
3357
3340
  trackFeature("export_storyboard");
3358
3341
  } catch (error) {
3359
3342
  setToast(error?.message || String(error));
@@ -3443,8 +3426,8 @@ export default function App() {
3443
3426
  }
3444
3427
  const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
3445
3428
  job.controller.signal.throwIfAborted();
3446
- saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3447
- setToast(isKo ? `콘택트 시트 다운로드 요청 · 패널 ${cells.length}개` : `Download requested: contact sheet · ${cells.length} panels`);
3429
+ await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3430
+ setToast(ko(`Contact sheet exported · ${cells.length} panels`, `콘택트 시트 내보내기 완료 · 패널 ${cells.length}개`, `缩略图总览已导出 · ${cells.length} 个面板`));
3448
3431
  return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
3449
3432
  }));
3450
3433
  }
@@ -3487,7 +3470,7 @@ export default function App() {
3487
3470
  updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
3488
3471
  const packs = [];
3489
3472
  for (const [step, entry] of targets.entries()) {
3490
- job.label = ko(`Panel ${step + 1} of ${targets.length}`, `패널 ${step + 1} / ${targets.length}`);
3473
+ job.label = ko(`Panel ${step + 1} of ${targets.length}`, `패널 ${step + 1} / ${targets.length}`, `面板 ${step + 1} / ${targets.length}`);
3491
3474
  packs.push(await panelPackArgs(entry, contextPanels.indexOf(entry), all));
3492
3475
  await exportBoundary(job);
3493
3476
  }
@@ -3497,10 +3480,8 @@ export default function App() {
3497
3480
  const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
3498
3481
  const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
3499
3482
  await exportBoundary(job);
3500
- const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
3501
- try { saveDownload(url, name); }
3502
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3503
- setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
3483
+ await saveExport(new Blob([bytes], { type: "application/zip" }), name);
3484
+ setToast(ko(`${name} exported · ${entries.length} files`, `${name} 내보내기 완료 · 파일 ${entries.length}개`, `${name} 已导出 · ${entries.length} 个文件`));
3504
3485
  return { fileName: name, entries: entries.map((entry) => entry.name) };
3505
3486
  }));
3506
3487
  }
@@ -3819,7 +3800,7 @@ export default function App() {
3819
3800
  if (!result.ok) return result;
3820
3801
  await rehydrateProjectAssets(result.project, result.warnings);
3821
3802
  applyProject(result.project);
3822
- setToast(`${isKo ? `프로젝트 열림: ${result.project.name}` : `Project opened: ${result.project.name}`}${projectProblemsNotice(result.problems)}`);
3803
+ setToast(`${ko(`Project opened: ${result.project.name}`, `프로젝트 열림: ${result.project.name}`, `项目已打开:${result.project.name}`)}${projectProblemsNotice(result.problems)}`);
3823
3804
  return result;
3824
3805
  },
3825
3806
  manifest: () => projectManifest,
@@ -4186,27 +4167,24 @@ export default function App() {
4186
4167
  }
4187
4168
  const snapshot = structuredClone(result);
4188
4169
  const frameJob = { source: result, done: false, request: null };
4189
- frameJob.request = exportRequest("frame", (job) => {
4170
+ frameJob.request = exportRequest("frame", async (job) => {
4190
4171
  if (!snapshot?.frame) throw Object.assign(new Error("The captured frame is not ready"), { exportFailureCode: "render_failed" });
4191
4172
  updateExportStatus(job, "finalizing", { stage: "download", cancellable: false });
4192
- const save = (href, name) => {
4173
+ const save = async (source, name) => {
4193
4174
  if (job.request.handedOff.has(name)) return;
4194
- saveDownload(href, name);
4175
+ await saveExport(source, name);
4195
4176
  job.request.handedOff.add(name);
4196
4177
  };
4197
4178
  if (snapshot.partColours && !job.request.handedOff.has("blocking-frame-palette.json")) {
4198
- const blob = new Blob([JSON.stringify({ partColours: snapshot.partColours }, null, 2)], { type: "application/json" });
4199
- const url = URL.createObjectURL(blob);
4200
- try { save(url, "blocking-frame-palette.json"); }
4201
- finally { setTimeout(() => URL.revokeObjectURL(url), 1000); }
4179
+ await save(new Blob([JSON.stringify({ partColours: snapshot.partColours }, null, 2)], { type: "application/json" }), "blocking-frame-palette.json");
4202
4180
  }
4203
4181
  if (snapshot.frameB) {
4204
- save(snapshot.frame, "blocking-frame-A-start.png");
4205
- save(snapshot.frameB, "blocking-frame-B-end.png");
4206
- } else save(snapshot.frame, "blocking-frame.png");
4182
+ await save(snapshot.frame, "blocking-frame-A-start.png");
4183
+ await save(snapshot.frameB, "blocking-frame-B-end.png");
4184
+ } else await save(snapshot.frame, "blocking-frame.png");
4207
4185
  frameJob.done = true;
4208
4186
  setResult((current) => current === frameJob.source ? { ...current, downloaded: true } : current);
4209
- setToast(ko("Frame download requested. Check your browser's downloads.", "프레임 다운로드를 요청했어요. 브라우저 다운로드를 확인하세요."));
4187
+ setToast(ko("Frame export finished. Check your export folder or browser downloads.", "프레임 내보내기가 끝났어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요."));
4210
4188
  });
4211
4189
  frameExportRef.current = frameJob;
4212
4190
  return executeExportRequest(frameJob.request);
@@ -5006,9 +4984,9 @@ export default function App() {
5006
4984
  setPathPointIndex(null);
5007
4985
  setPathDraw(false);
5008
4986
  const metrics = pathMetrics(createObjectPath({ points }));
5009
- setToast(isKo
5010
- ? `이동 경로 완성 — ${metrics.length.toFixed(1)} m, 점 ${points.length}개`
5011
- : `Travel path drawn — ${metrics.length.toFixed(1)} m, ${points.length} points`);
4987
+ setToast(ko(`Travel path drawn — ${metrics.length.toFixed(1)} m, ${points.length} points`,
4988
+ `이동 경로 완성 — ${metrics.length.toFixed(1)} m, 점 ${points.length}개`,
4989
+ `移动路径已完成 — ${metrics.length.toFixed(1)} m,${points.length} 个点`));
5012
4990
  }}
5013
4991
  onCameraChange={commitManualCameraFraming}
5014
4992
  />
@@ -5328,7 +5306,7 @@ export default function App() {
5328
5306
  <FirstSuccessGuide open={firstSuccessGuideOpen} previsMode={scenesDomain.previsMode} onDismiss={() => setFirstSuccessGuideOpen(false)} />
5329
5307
  <UseCaseQuestion
5330
5308
  open={useCaseAskOpen}
5331
- isKo={isKo}
5309
+ isKo={isKo} isZh={isZh}
5332
5310
  onAnswer={(useCase, team) => {
5333
5311
  recordUseCase(useCase, team);
5334
5312
  setUseCaseAskOpen(false);
@@ -5365,7 +5343,7 @@ export default function App() {
5365
5343
  )}
5366
5344
  {restoreOffer && (
5367
5345
  <div className="asset-delete-toast" role="status">
5368
- <span>{isKo ? `마지막 프로젝트 복원${restoreOffer.name ? `: ${restoreOffer.name}` : ""}` : `Restore last project${restoreOffer.name ? `: ${restoreOffer.name}` : ""}`}</span>
5346
+ <span>{ko(`Restore last project${restoreOffer.name ? `: ${restoreOffer.name}` : ""}`, `마지막 프로젝트 복원${restoreOffer.name ? `: ${restoreOffer.name}` : ""}`, `恢复上次项目${restoreOffer.name ? `:${restoreOffer.name}` : ""}`)}</span>
5369
5347
  <button
5370
5348
  type="button"
5371
5349
  onClick={async () => {
@@ -76,7 +76,7 @@ export function stylizeErrorMessage(error) {
76
76
  if (error?.code === "entitlement") return ko("This account cannot generate images.", "이 계정은 이미지를 생성할 수 없어요.");
77
77
  if (error?.status === 401 || error?.code === "auth") return ko("Sign in with ChatGPT in the Agent panel first.", "먼저 Agent 패널에서 ChatGPT로 로그인하세요.");
78
78
  const reason = error?.message || ko("unknown error", "알 수 없는 오류");
79
- return ko(`Stylize failed — ${reason}`, `스타일화 실패 — ${reason}`);
79
+ return ko(`Stylize failed — ${reason}`, `스타일화 실패 — ${reason}`, `风格化失败 — ${reason}`);
80
80
  }
81
81
 
82
82
  /** The generated PNG's bytes, refused past `maxBytes` with a reason. */
@@ -86,7 +86,7 @@ export function stylizedPngBytes(dataUrl, maxBytes) {
86
86
  const binary = atob(match[1]);
87
87
  if (binary.length > maxBytes) {
88
88
  const mb = Math.round(maxBytes / (1024 * 1024));
89
- throw new Error(ko(`The stylized image is larger than ${mb} MB, so it was not added.`, `스타일화된 이미지가 ${mb} MB보다 커서 추가하지 않았어요.`));
89
+ throw new Error(ko(`The stylized image is larger than ${mb} MB, so it was not added.`, `스타일화된 이미지가 ${mb} MB보다 커서 추가하지 않았어요.`, `风格化图像超过 ${mb} MB,因此未添加。`));
90
90
  }
91
91
  const bytes = new Uint8Array(binary.length);
92
92
  for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);