cozyclay 3.1.2 → 3.1.4

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 (114) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-runner.mjs +160 -151
  4. package/bin/agent/agent-stream.mjs +86 -0
  5. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  6. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  7. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  8. package/bin/agent/anthropic-subscription.mjs +73 -0
  9. package/bin/agent/claude-code-executable.mjs +93 -0
  10. package/bin/agent/cliproxy-provider.mjs +25 -11
  11. package/bin/agent/model-roles.mjs +112 -0
  12. package/bin/agent/openai-compatible-provider.mjs +95 -0
  13. package/bin/agent/pi-tools.mjs +19 -10
  14. package/bin/agent/provider-login.mjs +3 -1
  15. package/bin/agent/provider-routes.mjs +2 -0
  16. package/bin/agent/providers.mjs +35 -130
  17. package/bin/cozyclay.mjs +2 -48
  18. package/bin/star-prompt.mjs +123 -0
  19. package/dist/app/index.html +5 -5
  20. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  21. package/dist/assets/app-BdRlMf4J.css +1 -0
  22. package/dist/assets/app-KXdgBC-S.js +5144 -0
  23. package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
  24. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  25. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  26. package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/workflow/index.html +3 -3
  29. package/package.json +6 -4
  30. package/src/App.jsx +28 -28
  31. package/src/agent-image-client.js +2 -2
  32. package/src/asset-pane.jsx +14 -14
  33. package/src/character-kind.js +13 -12
  34. package/src/commands/project.js +2 -1
  35. package/src/commands/shot.js +1 -1
  36. package/src/commands/view.js +2 -2
  37. package/src/domains/cast.js +15 -15
  38. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  39. package/src/domains/motion.js +11 -11
  40. package/src/domains/objects.js +20 -30
  41. package/src/domains/scenes.js +14 -21
  42. package/src/domains/shots.js +3 -3
  43. package/src/first-success-guide.jsx +19 -3
  44. package/src/hierarchy-panel.jsx +6 -7
  45. package/src/locale-zh/app.js +124 -0
  46. package/src/locale-zh/domains.js +36 -0
  47. package/src/locale-zh/labels.js +148 -0
  48. package/src/locale-zh/panels.js +225 -0
  49. package/src/locale-zh/shell.js +230 -0
  50. package/src/locale-zh/timeline.js +145 -0
  51. package/src/locale-zh/workflow.js +104 -0
  52. package/src/locale-zh/workspace.js +190 -0
  53. package/src/locale-zh.js +21 -0
  54. package/src/locale.js +16 -6
  55. package/src/main.jsx +1 -1
  56. package/src/motion/beat-board-model.js +4 -4
  57. package/src/motion/beat-board.jsx +1 -1
  58. package/src/motion/ik-pick.js +24 -0
  59. package/src/motion/take-segments.jsx +5 -5
  60. package/src/motion/timeline.jsx +30 -40
  61. package/src/motion/waypoints.js +6 -6
  62. package/src/object-catalog.jsx +4 -5
  63. package/src/panels/CameraPanel.jsx +3 -3
  64. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  65. package/src/panels/ObjectTransformPanel.jsx +19 -29
  66. package/src/panels/PoseImagePanel.jsx +8 -8
  67. package/src/panels/PosePanel.jsx +2 -2
  68. package/src/panels/ReferenceImageField.jsx +2 -2
  69. package/src/panels/RigControlPanel.jsx +2 -2
  70. package/src/panels/SculptPanel.jsx +9 -9
  71. package/src/panels/SubjectBox.jsx +3 -3
  72. package/src/panels/SubjectsPanel.jsx +1 -1
  73. package/src/posestudio.jsx +10 -8
  74. package/src/project-browser.css +7 -6
  75. package/src/project-browser.jsx +7 -6
  76. package/src/project-open-file.js +19 -0
  77. package/src/qa-label.js +1 -1
  78. package/src/range-pin-panel.jsx +14 -14
  79. package/src/resource-status.jsx +3 -3
  80. package/src/result-modal.jsx +10 -10
  81. package/src/settings-menu.jsx +4 -1
  82. package/src/shell/BoardSlot.jsx +5 -5
  83. package/src/shell/CompositionGuideButton.jsx +1 -1
  84. package/src/shell/GhostsMenuGroup.jsx +1 -1
  85. package/src/shell/StatusBar.jsx +4 -4
  86. package/src/shell/ViewportToolbar.jsx +3 -3
  87. package/src/shell/add-camera-shot.js +2 -2
  88. package/src/shell/details-selection.js +1 -1
  89. package/src/shell/preference-controls.jsx +2 -1
  90. package/src/shot-guides.js +5 -5
  91. package/src/stage-hdr.js +3 -3
  92. package/src/stage-labels-ko.js +2 -2
  93. package/src/stage-startup-defaults.js +3 -3
  94. package/src/studio-actions.js +2 -2
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles.css +1 -1
  97. package/src/use-case-question.jsx +18 -18
  98. package/src/workflow/AgentPanel.jsx +4 -4
  99. package/src/workflow/AgentPlanCard.jsx +1 -1
  100. package/src/workflow/AgentProgressCard.jsx +4 -4
  101. package/src/workflow/AgentTurnSteps.jsx +1 -1
  102. package/src/workflow/LoginPanel.jsx +1 -1
  103. package/src/workflow/TaskBoard.jsx +1 -1
  104. package/src/workflow/TaskInline.jsx +1 -1
  105. package/src/workflow/agent-client.js +1 -1
  106. package/src/workflow/agent-turn.js +5 -5
  107. package/src/workflow/task-board.js +4 -4
  108. package/tools/loc-baseline.json +2 -2
  109. package/tools/run-tests.mjs +5 -5
  110. package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
  111. package/dist/assets/app-Bhe0TpGz.css +0 -1
  112. package/dist/assets/app-C4QTcDcb.js +0 -5144
  113. package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
  114. package/dist/assets/task-board-BqReHzOi.js +0 -15
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.2\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"4d8d2ca94381f4bdb9a2983a6c3f0378ea513c73e1408c6c18baef2b2db6fc05\"}","signature":"bb1tql+IuEnYxCIbq+Xh/MJa3bL9WYcnI3Bw7STxCTwRoi7NB/L2VNd0/jR1d3qVEK4E3MGLA5GavrN5E+/7DA=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.4\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"446eb39e6cfa4c6433606a13d003df6415fc2a3e46b141a0927fbd46d56fb528\"}","signature":"N1D4JWm0u9nVkXEHFe9jlavu/aG1/3wqaEwUFMYa4un3l63+tsV3RqN1ixPu0KNErwQLT0tz8YPSci1qD1OlDA=="}
@@ -8,12 +8,12 @@
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-C87p6E1E.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-BqReHzOi.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-qoxcdrhj.js">
16
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Bz59Ghdz.js">
17
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
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,7 @@ 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
155
  import { saveExport } from "./export-workspace.js";
156
156
  import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
157
157
  import { STARTER_SCENES, resolveSceneParam } from "./starter-scenes.js";
@@ -1147,7 +1147,7 @@ export default function App() {
1147
1147
  // A capsule figure has no rig at all, so it gets its own reason rather than
1148
1148
  // the "load a character" one, which would read as a loading problem.
1149
1149
  const activeCapabilities = characterCapabilities(activeChar);
1150
- 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 编辑其骨骼");
1151
1151
  function selectWorkflowMode(next) {
1152
1152
  if (next === "pose" && poseRefusal) {
1153
1153
  setToast(poseRefusal);
@@ -1155,7 +1155,7 @@ export default function App() {
1155
1155
  }
1156
1156
  // A storyboard is stills: there is no Motion department to open.
1157
1157
  if (next === "motion" && storyboardProject) {
1158
- 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));
1159
1159
  return;
1160
1160
  }
1161
1161
  setWorkflowMode(next);
@@ -1258,7 +1258,7 @@ export default function App() {
1258
1258
  setSculptBlueprint((current) => ({ ...current, state: "idle", sheet: null, error: null }));
1259
1259
  setSculptReference(await objectsDomain.storeSculptReference(file));
1260
1260
  } catch (error) {
1261
- 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}`));
1262
1262
  }
1263
1263
  }
1264
1264
  function startSculptRun() {
@@ -1478,7 +1478,7 @@ export default function App() {
1478
1478
 
1479
1479
  const rejectUnsupportedDrop = (count) => setToast(ko(
1480
1480
  `${count} file${count > 1 ? "s" : ""} not supported — use PNG, JPG, WebP, GIF or a .glb / .obj / .fbx (iPhone HEIC photos need converting first)`,
1481
- `지원하지 않는 파일 ${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 照片需先转换)`,
1482
1482
  ));
1483
1483
  const stageDrop = {
1484
1484
  onImages: (files) => importCutouts(files),
@@ -1786,7 +1786,7 @@ export default function App() {
1786
1786
  // so run_action can give the agent the reason the user was shown.
1787
1787
  const toastSinkRef = useRef(new Set());
1788
1788
  const setToast = useCallback((value, english) => {
1789
- const toast = resolveStudioToast(value, isKo, ko);
1789
+ const toast = resolveStudioToast(value, isKo, ko, isZh);
1790
1790
  if (english !== undefined) toast.message = english;
1791
1791
  if (typeof toast.uiMessage === "string" && toast.uiMessage) for (const sink of toastSinkRef.current) sink(toast);
1792
1792
  showToast(toast.uiMessage);
@@ -1948,7 +1948,7 @@ export default function App() {
1948
1948
  deleted = true;
1949
1949
  return true;
1950
1950
  } catch (error) {
1951
- 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}`));
1952
1952
  return false;
1953
1953
  } finally {
1954
1954
  db?.close?.();
@@ -1971,9 +1971,9 @@ export default function App() {
1971
1971
  setAssetTrash((current) => current.filter((asset) => asset.id !== record.id));
1972
1972
  setAssetUndoOffered(false);
1973
1973
  restored = true;
1974
- setToast(isKo ? `${record.name || "이미지"} 복원됨` : `${record.name || "Image"} restored`);
1974
+ setToast(ko(`${record.name || "Image"} restored`, `${record.name || "이미지"} 복원됨`, `${record.name || "图片"}已恢复`));
1975
1975
  } catch (error) {
1976
- 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}`));
1977
1977
  } finally {
1978
1978
  if (restored) await refreshAssetShelf();
1979
1979
  setDeletingAssetId(null);
@@ -3012,7 +3012,7 @@ export default function App() {
3012
3012
  const name = fileName ?? `cozyclay-${slate}.mp4`;
3013
3013
  await saveExport(result.blob, name);
3014
3014
  setRecordedVideoName(name);
3015
- setToast((isKo, ko) => isKo ? `${name} 내보내기 완료 · ${result.frameCount}프레임` : `${name} exported · ${result.frameCount} frames`);
3015
+ setToast((isKo, ko) => ko(`${name} exported · ${result.frameCount} frames`, `${name} 내보내기 완료 · ${result.frameCount}프레임`, `${name} 已导出 · ${result.frameCount} 帧`));
3016
3016
  return { ...result, fileName: name };
3017
3017
  }
3018
3018
  return result;
@@ -3083,7 +3083,7 @@ export default function App() {
3083
3083
  : phase === "preparing" ? ko("Preparing the renderer and checking MP4 support…", "렌더러를 준비하고 MP4 지원을 확인하고 있어요…")
3084
3084
  : ko("Encoding the requested frames, not yet a completed file.", "요청한 프레임을 인코딩 중이에요. 아직 파일이 완성되지 않았어요.")}</p>}
3085
3085
  {retryable && <p>{ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요.")}</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 저장은 확인할 수 없으니 내보내기 폴더나 브라우저 다운로드를 확인하세요.`)}</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>}
3087
3087
  <div className="export-status-actions">
3088
3088
  {busy && cancellable && <button type="button" data-testid="export-cancel" onClick={stopShotRecording}>{ko("Cancel export", "내보내기 취소")}</button>}
3089
3089
  {retryable && <button type="button" data-testid="export-retry" disabled={recState === "recording"} onClick={() => void retryExport()}>{ko("Retry same export", "같은 내보내기 재시도")}</button>}
@@ -3117,9 +3117,9 @@ export default function App() {
3117
3117
  .replace(/^-+|-+$/g, "") || "cut-list";
3118
3118
  await saveExport(blob, `cozyclay-${slug}.otio`);
3119
3119
  const frameCount = shots.reduce((total, shot) => total + shot.endFrame - shot.startFrame + 1, 0);
3120
- setToast(isKo
3121
- ? `OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`
3122
- : `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} 帧`));
3123
3123
  } catch (error) {
3124
3124
  setToast(error?.message || String(error));
3125
3125
  }
@@ -3201,13 +3201,13 @@ export default function App() {
3201
3201
  updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
3202
3202
  await exportBoundary(job);
3203
3203
  const packShot = { title: entry.name, index: index + 1, startFrame: entry.startFrame, endFrame: entry.endFrame };
3204
- onProgress?.(ko(`Rendering frames for "${entry.name}"`, `"${entry.name}" 프레임 렌더링 중`));
3204
+ onProgress?.(ko(`Rendering frames for "${entry.name}"`, `"${entry.name}" 프레임 렌더링 중`, `正在渲染“${entry.name}”的帧`));
3205
3205
  const firstUrl = captureShotFramePng(entry.startFrame);
3206
3206
  if (!firstUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
3207
3207
  await exportBoundary(job);
3208
3208
  const lastUrl = entry.endFrame > entry.startFrame ? captureShotFramePng(entry.endFrame) : null;
3209
3209
  await exportBoundary(job);
3210
- onProgress?.(ko(`Recording the clip for "${entry.name}"`, `"${entry.name}" 클립 녹화 중`));
3210
+ onProgress?.(ko(`Recording the clip for "${entry.name}"`, `"${entry.name}" 클립 녹화 중`, `正在录制“${entry.name}”的片段`));
3211
3211
  const recorded = await runShotExport({ startFrame: entry.startFrame, endFrame: entry.endFrame, download: false }, job);
3212
3212
  updateExportStatus(job, "finalizing", { stage: "archive", cancellable: true });
3213
3213
  await exportBoundary(job);
@@ -3260,12 +3260,12 @@ export default function App() {
3260
3260
  if (!job.request.context.shots.length) throw new Error(ko("Add at least one Shot before exporting a keyframe pack", "키프레임 팩을 내보내려면 샷을 하나 이상 추가하세요"));
3261
3261
  for (const [order, index] of targets.entries()) {
3262
3262
  if (job.request.handedOff.has(index)) continue;
3263
- 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}`);
3264
3264
  const pack = await buildShotKeyframePack(job.request.context.shots[index], index, null, job);
3265
3265
  job.controller.signal.throwIfAborted();
3266
3266
  await saveExport(new Blob([pack.bytes], { type: "application/zip" }), pack.name);
3267
3267
  job.request.handedOff.add(index);
3268
- setToast(isKo ? `${pack.name} 내보내기 완료 · 파일 ${pack.entries.length}개` : `${pack.name} exported · ${pack.entries.length} files`);
3268
+ setToast(ko(`${pack.name} exported · ${pack.entries.length} files`, `${pack.name} 내보내기 완료 · 파일 ${pack.entries.length}개`, `${pack.name} 已导出 · ${pack.entries.length} 个文件`));
3269
3269
  await exportBoundary(job);
3270
3270
  }
3271
3271
  }));
@@ -3336,7 +3336,7 @@ export default function App() {
3336
3336
  createCanvas: storyboardCanvas,
3337
3337
  });
3338
3338
  await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3339
- setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
3339
+ setToast(ko(`Storyboard saved · ${cells.length} shots`, `스토리보드 저장됨 · ${cells.length}샷`, `故事板已保存 · ${cells.length} 个镜头`));
3340
3340
  trackFeature("export_storyboard");
3341
3341
  } catch (error) {
3342
3342
  setToast(error?.message || String(error));
@@ -3427,7 +3427,7 @@ export default function App() {
3427
3427
  const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
3428
3428
  job.controller.signal.throwIfAborted();
3429
3429
  await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3430
- setToast(isKo ? `콘택트 시트 내보내기 완료 · 패널 ${cells.length}개` : `Contact sheet exported · ${cells.length} panels`);
3430
+ setToast(ko(`Contact sheet exported · ${cells.length} panels`, `콘택트 시트 내보내기 완료 · 패널 ${cells.length}개`, `缩略图总览已导出 · ${cells.length} 个面板`));
3431
3431
  return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
3432
3432
  }));
3433
3433
  }
@@ -3470,7 +3470,7 @@ export default function App() {
3470
3470
  updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
3471
3471
  const packs = [];
3472
3472
  for (const [step, entry] of targets.entries()) {
3473
- 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}`);
3474
3474
  packs.push(await panelPackArgs(entry, contextPanels.indexOf(entry), all));
3475
3475
  await exportBoundary(job);
3476
3476
  }
@@ -3481,7 +3481,7 @@ export default function App() {
3481
3481
  const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
3482
3482
  await exportBoundary(job);
3483
3483
  await saveExport(new Blob([bytes], { type: "application/zip" }), name);
3484
- setToast(isKo ? `${name} 내보내기 완료 · 파일 ${entries.length}개` : `${name} exported · ${entries.length} files`);
3484
+ setToast(ko(`${name} exported · ${entries.length} files`, `${name} 내보내기 완료 · 파일 ${entries.length}개`, `${name} 已导出 · ${entries.length} 个文件`));
3485
3485
  return { fileName: name, entries: entries.map((entry) => entry.name) };
3486
3486
  }));
3487
3487
  }
@@ -3800,7 +3800,7 @@ export default function App() {
3800
3800
  if (!result.ok) return result;
3801
3801
  await rehydrateProjectAssets(result.project, result.warnings);
3802
3802
  applyProject(result.project);
3803
- 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)}`);
3804
3804
  return result;
3805
3805
  },
3806
3806
  manifest: () => projectManifest,
@@ -4984,9 +4984,9 @@ export default function App() {
4984
4984
  setPathPointIndex(null);
4985
4985
  setPathDraw(false);
4986
4986
  const metrics = pathMetrics(createObjectPath({ points }));
4987
- setToast(isKo
4988
- ? `이동 경로 완성 — ${metrics.length.toFixed(1)} m, 점 ${points.length}개`
4989
- : `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} 个点`));
4990
4990
  }}
4991
4991
  onCameraChange={commitManualCameraFraming}
4992
4992
  />
@@ -5306,7 +5306,7 @@ export default function App() {
5306
5306
  <FirstSuccessGuide open={firstSuccessGuideOpen} previsMode={scenesDomain.previsMode} onDismiss={() => setFirstSuccessGuideOpen(false)} />
5307
5307
  <UseCaseQuestion
5308
5308
  open={useCaseAskOpen}
5309
- isKo={isKo}
5309
+ isKo={isKo} isZh={isZh}
5310
5310
  onAnswer={(useCase, team) => {
5311
5311
  recordUseCase(useCase, team);
5312
5312
  setUseCaseAskOpen(false);
@@ -5343,7 +5343,7 @@ export default function App() {
5343
5343
  )}
5344
5344
  {restoreOffer && (
5345
5345
  <div className="asset-delete-toast" role="status">
5346
- <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>
5347
5347
  <button
5348
5348
  type="button"
5349
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);
@@ -162,8 +162,8 @@ function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignatur
162
162
  const failed = thumb === null;
163
163
  const mesh = Boolean(thumb?.mesh);
164
164
  const name = thumb?.name || (failed ? ko("(unreadable image)", "(읽을 수 없는 이미지)") : mesh ? ko("Untitled model", "이름 없는 모델") : ko("Untitled image", "이름 없는 이미지"));
165
- const usageLabel = ko(`Used by ${usageCount} scene object${usageCount === 1 ? "" : "s"}`, `${usageCount}개 씬 오브젝트에서 사용 중`);
166
- const deleteLabel = ko(`Delete ${name} from storage`, `${name}을(를) 저장소에서 삭제`);
165
+ const usageLabel = ko(`Used by ${usageCount} scene object${usageCount === 1 ? "" : "s"}`, `${usageCount}개 씬 오브젝트에서 사용 중`, `被 ${usageCount} 个场景物体使用`);
166
+ const deleteLabel = ko(`Delete ${name} from storage`, `${name}을(를) 저장소에서 삭제`, `从存储中删除 ${name}`);
167
167
  const confirmationId = `asset-storage-warning-${id}`;
168
168
  const kindLabel = thumb?.kind === "mesh" ? ko("Model", "모델") : thumb?.kind === "environment" ? ko("HDR environment", "HDR 환경") : thumb?.kind === "matte" ? ko("Matte", "매트") : ko("Image", "이미지");
169
169
  return (
@@ -183,7 +183,7 @@ function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignatur
183
183
  {confirmation ? (
184
184
  <div className="asset-storage-confirm">
185
185
  <span id={confirmationId} role="alert">{inUse
186
- ? ko(`Permanently delete this image from storage? It is used by ${usageCount} scene object${usageCount === 1 ? "" : "s"} and those objects will lose it.`, `저장소에서 이 이미지를 영구 삭제할까요? ${usageCount}개 씬 오브젝트가 사용 중이며 해당 오브젝트에서 사라집니다.`)
186
+ ? ko(`Permanently delete this image from storage? It is used by ${usageCount} scene object${usageCount === 1 ? "" : "s"} and those objects will lose it.`, `저장소에서 이 이미지를 영구 삭제할까요? ${usageCount}개 씬 오브젝트가 사용 중이며 해당 오브젝트에서 사라집니다.`, `要从存储中永久删除此图片吗?它正被 ${usageCount} 个场景物体使用,那些物体会失去它。`)
187
187
  : ko("Unused by every scene. Delete it?", "모든 씬에서 사용되지 않아요. 삭제할까요?")}</span>
188
188
  <button type="button" className="asset-storage-delete" aria-label={deleteLabel} aria-describedby={confirmationId} disabled={deleting} onClick={async () => {
189
189
  if (await onDelete(id, inUse ? usageCount : undefined, confirmation.graphSignature)) setConfirmation(null);
@@ -209,7 +209,7 @@ function StorageManager({ unusedAssetIds, usedAssetIds, usageCounts, graphSignat
209
209
  <h3>{ko("Manage storage", "저장 공간 관리")}</h3>
210
210
  <p>{ko("Review unused and in-use stored images. Deleted images can be restored until this page is reloaded.", "사용하지 않는 이미지와 사용 중인 저장 이미지를 확인하세요. 삭제한 이미지는 이 페이지를 새로 고치기 전까지 복원할 수 있어요.")}</p>
211
211
  </div>
212
- {trashCount > 0 && <button type="button" className="asset-storage-undo" onClick={onUndo}>{ko(`Undo last delete (${trashCount})`, `마지막 삭제 실행 취소 (${trashCount})`)}</button>}
212
+ {trashCount > 0 && <button type="button" className="asset-storage-undo" onClick={onUndo}>{ko(`Undo last delete (${trashCount})`, `마지막 삭제 실행 취소 (${trashCount})`, `撤销上次删除(${trashCount})`)}</button>}
213
213
  </div>
214
214
  {loading ? (
215
215
  <div className="asset-storage-list" aria-busy="true">
@@ -301,8 +301,8 @@ function ImageAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect,
301
301
  type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Image", "이미지")}
302
302
  failed={failed}
303
303
  title={failed
304
- ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
305
- : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
304
+ ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`, `${label} — 无法解码;请在存储管理中删除`)
305
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${label} 拖入场景,或双击将其放在原点`)}
306
306
  preview={thumb
307
307
  ? <img className="content-tile-img" src={thumb.url} alt="" draggable={false} />
308
308
  : <span className="content-tile-skeleton" aria-hidden="true" />}
@@ -329,8 +329,8 @@ function MeshAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect, q
329
329
  type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Model", "모델")}
330
330
  failed={failed}
331
331
  title={failed
332
- ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
333
- : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
332
+ ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`, `${label} — 无法读取;请在存储管理中删除`)
333
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${label} 拖入场景,或双击将其放在原点`)}
334
334
  preview={failed ? <span className="content-tile-skeleton" aria-hidden="true" /> : <MeshPreview />}
335
335
  selected={selectedKey === key}
336
336
  onSelect={onSelect}
@@ -386,7 +386,7 @@ function catalogueTile(entry, props) {
386
386
  assetKey={key}
387
387
  label={label}
388
388
  type={ko("Shape", "도형")}
389
- title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
389
+ title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${entry.label} 拖入场景,或双击将其放在原点`)}
390
390
  preview={<ObjectPreview kind={entry.kind} color={entry.color} />}
391
391
  selected={props.selectedKey === key}
392
392
  onSelect={props.onSelect}
@@ -418,7 +418,7 @@ export function FolderGrid({ folder, query, ...props }) {
418
418
  assetKey={key}
419
419
  label={asset.label}
420
420
  type={ko("Character", "인물")}
421
- title={ko(`Drag ${asset.label} into the scene, or double-click to place it at the origin`, `${asset.label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
421
+ title={ko(`Drag ${asset.label} into the scene, or double-click to place it at the origin`, `${asset.label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${asset.label} 拖入场景,或双击将其放在原点`)}
422
422
  preview={<CharacterPreview model={asset.id} />}
423
423
  selected={selectedKey === key}
424
424
  onSelect={onSelect}
@@ -454,7 +454,7 @@ export function FolderGrid({ folder, query, ...props }) {
454
454
  assetKey={key}
455
455
  label={label}
456
456
  type={ko("Camera", "카메라")}
457
- title={ko(`${label} — double-click to open it in Camera mode`, `${label} — 더블클릭하면 카메라 모드에서 엽니다`)}
457
+ title={ko(`${label} — double-click to open it in Camera mode`, `${label} — 더블클릭하면 카메라 모드에서 엽니다`, `${label} — 双击可在摄影机模式中打开`)}
458
458
  preview={<CameraPreview />}
459
459
  selected={selectedKey === key}
460
460
  onSelect={onSelect}
@@ -474,7 +474,7 @@ export function FolderGrid({ folder, query, ...props }) {
474
474
  assetKey={key}
475
475
  label={label}
476
476
  type={ko("Motion", "모션")}
477
- title={ko(`${label} — double-click to load this take`, `${label} — 더블클릭하면 이 테이크를 불러옵니다`)}
477
+ title={ko(`${label} — double-click to load this take`, `${label} — 더블클릭하면 이 테이크를 불러옵니다`, `${label} — 双击加载此动作片段`)}
478
478
  preview={<MotionPreview />}
479
479
  selected={selectedKey === key}
480
480
  onSelect={onSelect}
@@ -484,7 +484,7 @@ export function FolderGrid({ folder, query, ...props }) {
484
484
  });
485
485
  if (!takeVersions.length) empty = ko("No takes for this character yet. Ask the agent to compile one.", "이 인물의 테이크가 아직 없어요. 에이전트에게 모션 컴파일을 요청하세요.");
486
486
  } else if (folder === "poses") {
487
- tiles = poses.map((pose, index) => ({ pose, index, label: pose?.name || pose?.label || ko(`Pose ${index + 1}`, `포즈 ${index + 1}`) }))
487
+ tiles = poses.map((pose, index) => ({ pose, index, label: pose?.name || pose?.label || ko(`Pose ${index + 1}`, `포즈 ${index + 1}`, `姿势 ${index + 1}`) }))
488
488
  .filter(({ label }) => matchesQuery(label, query))
489
489
  .map(({ pose, index, label }) => {
490
490
  const key = `pose:${pose?.id ?? index}`;
@@ -634,7 +634,7 @@ export default function AssetPane({
634
634
  data-testid="content-search"
635
635
  value={search}
636
636
  placeholder={tab === "content" ? ko("Search objects", "오브젝트 검색") : ko("Search", "검색")}
637
- aria-label={tab === "content" ? ko(`Search ${folderLabel}`, `${folderLabel} 검색`) : ko("Search the log", "로그 검색")}
637
+ aria-label={tab === "content" ? ko(`Search ${folderLabel}`, `${folderLabel} 검색`, `搜索 ${folderLabel}`) : ko("Search the log", "로그 검색")}
638
638
  onChange={(event) => setSearch(event.target.value)}
639
639
  />
640
640
  </header>
@@ -3,7 +3,7 @@
3
3
  // (PROXY_FIGURE_MODEL) is a placement-only stand-in with no bones at all, so
4
4
  // those tools refuse it by name instead of failing on a missing rig.
5
5
  import { isProxyFigure } from "./scenes.js";
6
- import { isKo } from "./locale.js";
6
+ import { isKo, isZh } from "./locale.js";
7
7
  import { StudioProtocolError } from "./studio-agent-protocol.js";
8
8
 
9
9
  export const CHARACTER_CAPABILITIES = Object.freeze(["rig", "ik", "pose", "motion"]);
@@ -45,29 +45,30 @@ export function characterCapabilities(character) {
45
45
 
46
46
  // The noun each refusal names. A feature id not listed here is used verbatim.
47
47
  const FEATURE_LABELS = {
48
- rig: ["the rig", "리그"],
49
- ik: ["IK", "IK"],
50
- pose: ["Pose mode", "포즈 모드"],
51
- motion: ["Compiled motion", "모션 컴파일"],
52
- take: ["Loading a take", "테이크 불러오기"],
48
+ rig: ["the rig", "리그", "骨骼"],
49
+ ik: ["IK", "IK", "IK"],
50
+ pose: ["Pose mode", "포즈 모드", "姿势模式"],
51
+ motion: ["Compiled motion", "모션 컴파일", "编译动作"],
52
+ take: ["Loading a take", "테이크 불러오기", "加载动作片段"],
53
53
  };
54
54
 
55
55
  // A character's kind (rig or capsule figure) is fixed at creation; character.update refuses a kind change.
56
56
  export const KIND_FIXED_REFUSAL = Object.freeze({
57
57
  en: "A character's kind is chosen when it is created; add a new character instead.",
58
58
  ko: "캐릭터 종류는 만들 때 정합니다. 새 캐릭터를 추가하세요.",
59
+ zh: "角色的类型在创建时确定;请改为添加新角色。",
59
60
  });
60
61
 
61
- export function kindRefusal(feature, ko = false) {
62
- const label = FEATURE_LABELS[feature]?.[ko ? 1 : 0] ?? feature;
63
- return ko
64
- ? `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`
65
- : `Capsule figures have no rig - ${label} works on rigged characters only.`;
62
+ export function kindRefusal(feature, ko = false, zh = false) {
63
+ const label = FEATURE_LABELS[feature]?.[ko ? 1 : zh ? 2 : 0] ?? feature;
64
+ if (ko) return `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`;
65
+ if (zh) return `胶囊人物没有骨骼 — ${label}仅适用于带骨骼的角色。`;
66
+ return `Capsule figures have no rig - ${label} works on rigged characters only.`;
66
67
  }
67
68
 
68
69
  /** Throw TARGET_NOT_READY when `character` is a capsule figure. The protocol
69
70
  * message is English (the agent reads it); `uiMessage` is the locale's. */
70
71
  export function refuseRigOnly(character, feature) {
71
72
  if (!isProxyFigure(character)) return;
72
- throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo) });
73
+ throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo, isZh) });
73
74
  }
@@ -10,7 +10,8 @@ const rename = { id: "project.rename", label: "Rename project", description: "Re
10
10
  const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
11
11
  const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
12
12
  const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
13
- const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
13
+ // The deadline covers the user in the file picker, so it is the hub's ceiling, not the 30 s default.
14
+ const opening = { ...document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token }), timeoutMs: 300_000 };
14
15
  const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
15
16
  const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name });
16
17
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
@@ -68,7 +68,7 @@ export function register(registry, ports) {
68
68
  const next = reflowStillShots(shots), frameCount = stillFrameCount(next);
69
69
  if (frameCount > STILL_FRAME_COUNT_MAX) {
70
70
  const en = `Storyboard is full (${STILL_FRAME_COUNT_MAX} frames); shorten a hold or remove a panel.`;
71
- throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`));
71
+ throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`, `故事板已满(${STILL_FRAME_COUNT_MAX} 帧);请缩短停留时长或删除一个面板。`));
72
72
  }
73
73
  const clip = next.find(shot => shot.kind !== 'still' && next.some(still => still.kind === 'still' && still.startFrame <= shot.endFrame && still.endFrame >= shot.startFrame));
74
74
  if (clip) fail('TARGET_NOT_READY', `The stills would overlap the clip shot ${clip.name} (frames ${clip.startFrame}-${clip.endFrame}); move or remove it first.`);
@@ -14,7 +14,7 @@ const operations = Object.entries({ ...groups, 'view.update': Object.keys(fields
14
14
  }));
15
15
  export const declarations = Object.freeze([...operations, ...["view.setPartColours", "view.setGuideMode", "view.setInset", "view.setGhosts"].map(studioActionDeclaration)]);
16
16
  // A storyboard project has no Motion department; every door says so the same way.
17
- export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.' });
17
+ export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.', zh: '故事板项目不包含动作工具。' });
18
18
  export const viewCommand = args => Object.entries(groups).find(([, keys]) => Object.keys(args).every(key => keys.includes(key)))?.[0] ?? 'view.update';
19
19
 
20
20
  export function register(registry, ports) {
@@ -24,7 +24,7 @@ export function register(registry, ports) {
24
24
  const state = ports.readView();
25
25
  // Pose mode is IK editing: without a rig to solve there is nothing to pose.
26
26
  if (args.mode === 'pose' && args.mode !== state.view.mode && !ports.canPose()) fail('TARGET_NOT_READY', 'Pose mode needs a loaded character rig to edit with IK.');
27
- if (args.mode === 'motion' && state.previsMode === 'storyboard') throw studioActionRefusal('TARGET_NOT_READY', STORYBOARD_MOTION_REFUSAL.en, ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
27
+ if (args.mode === 'motion' && state.previsMode === 'storyboard') throw studioActionRefusal('TARGET_NOT_READY', STORYBOARD_MOTION_REFUSAL.en, ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko, STORYBOARD_MOTION_REFUSAL.zh));
28
28
  const selection = args.selection === undefined ? state.selection : args.selection;
29
29
  if (selection) {
30
30
  const found = selection.kind === 'scene' ? selection.id === state.host.sceneId : selection.kind === 'camera' ? selection.id === 'camera'
@@ -19,7 +19,7 @@ import {
19
19
  nextCharacterId,
20
20
  MAX_WAYPOINTS,
21
21
  } from "../app-stage.jsx";
22
- import { ko, isKo } from "../locale.js";
22
+ import { ko } from "../locale.js";
23
23
  import { createIkState } from "../motion/ik.js";
24
24
  import { judgeNextWaypoint } from "../motion/waypoints.js";
25
25
  import { StudioProtocolError } from "../studio-agent-protocol.js";
@@ -435,7 +435,7 @@ export function useCast(appContext) {
435
435
  appContext.shared.setTlFrame(target);
436
436
  setWaypointMode(true);
437
437
  selectActiveCharacterInHierarchy();
438
- appContext.notify(isKo ? `프레임 ${target}의 루트 웨이포인트를 선택했어요. 탑뷰에서 점을 드래그해 위치를 조정하세요.` : `Root waypoint at frame ${target} selected — drag the pin in the Top-View to reposition.`);
438
+ appContext.notify(ko(`Root waypoint at frame ${target} selected — drag the pin in the Top-View to reposition.`, `프레임 ${target}의 루트 웨이포인트를 선택했어요. 탑뷰에서 점을 드래그해 위치를 조정하세요.`, `已选中第 ${target} 帧的根路径点 — 在顶视图中拖动该点调整位置。`));
439
439
  return;
440
440
  }
441
441
  setPendingWaypointFrame(target);
@@ -443,7 +443,7 @@ export function useCast(appContext) {
443
443
  appContext.shared.setTlFrame(target);
444
444
  setWaypointMode(true);
445
445
  selectActiveCharacterInHierarchy();
446
- appContext.notify(isKo ? `프레임 ${target}이 예약됐어요. 샷 뷰 바닥을 클릭하면 그 위치에 루트 웨이포인트가 생성됩니다.` : `Frame ${target} is reserved — click the Shot-view floor to drop the root waypoint there.`);
446
+ appContext.notify(ko(`Frame ${target} is reserved — click the Shot-view floor to drop the root waypoint there.`, `프레임 ${target}이 예약됐어요. 샷 뷰 바닥을 클릭하면 그 위치에 루트 웨이포인트가 생성됩니다.`, `第 ${target} 帧已预留 — 点击镜头视图地面即可在该位置放置根路径点。`));
447
447
  }
448
448
 
449
449
  /* One root-path core for every cast member, shared by the Shot-view floor
@@ -483,7 +483,7 @@ export function useCast(appContext) {
483
483
  const ordered = [...readCharacterWaypoints(characterId)].sort((a, b) => a.frame - b.frame);
484
484
  if (ordered.length + 1 > MAX_WAYPOINTS) {
485
485
  throw studioActionRefusal("TARGET_NOT_READY", `The root path is capped at ${MAX_WAYPOINTS} waypoints; remove one first.`,
486
- isKo ? `루트 경로는 웨이포인트 ${MAX_WAYPOINTS}개까지 사용할 수 있어요` : `The root path is capped at ${MAX_WAYPOINTS} waypoints`);
486
+ ko(`The root path is capped at ${MAX_WAYPOINTS} waypoints`, `루트 경로는 웨이포인트 ${MAX_WAYPOINTS}개까지 사용할 수 있어요`, `根路径最多 ${MAX_WAYPOINTS} 个路径点`));
487
487
  }
488
488
  const x = clampRootPosition(point.x);
489
489
  const z = clampRootPosition(point.z);
@@ -498,7 +498,7 @@ export function useCast(appContext) {
498
498
  }
499
499
  if (ordered.some((waypoint) => waypoint.frame === at)) {
500
500
  throw studioActionRefusal("INVALID_ARGUMENT", `Frame ${at} already has a root waypoint — pick an empty frame or move that one.`,
501
- isKo ? `프레임 ${at}에는 이미 루트 웨이포인트가 있어요. 타임라인에서 빈 프레임을 선택하세요.` : `Frame ${at} already has a root waypoint — pick an empty frame on the timeline.`);
501
+ ko(`Frame ${at} already has a root waypoint — pick an empty frame on the timeline.`, `프레임 ${at}에는 이미 루트 웨이포인트가 있어요. 타임라인에서 빈 프레임을 선택하세요.`, `第 ${at} 帧已有根路径点 — 请在时间线上选择空白帧。`));
502
502
  }
503
503
  // The generator cannot refuse an impossible pin, so placement is the
504
504
  // last moment to: block out-of-band legs with the fix named.
@@ -507,7 +507,7 @@ export function useCast(appContext) {
507
507
  const waypoint = { id: createStableItemId("waypoint"), frame: at, x, z, heading: null };
508
508
  const next = [...ordered.slice(0, index), waypoint, ...ordered.slice(index)];
509
509
  const verdict = validateWaypointAt(next, index, waypoint, start);
510
- if (!verdict.ok) throw studioActionRefusal("INVALID_ARGUMENT", `Not placed — ${verdict.error}`, isKo ? `배치하지 못했어요 — ${verdict.error}` : `Not placed — ${verdict.error}`);
510
+ if (!verdict.ok) throw studioActionRefusal("INVALID_ARGUMENT", `Not placed — ${verdict.error}`, ko(`Not placed — ${verdict.error}`, `배치하지 못했어요 — ${verdict.error}`, `未能放置 — ${verdict.error}`));
511
511
  writeCharacterWaypoints(characterId, next);
512
512
  return { waypoint, index, warnings: verdict.warnings };
513
513
  }
@@ -523,7 +523,7 @@ export function useCast(appContext) {
523
523
  const verdict = validateWaypointAt(next, index, moved, { frame: 0, x: character.x, z: character.z });
524
524
  if (!verdict.ok) {
525
525
  throw studioActionRefusal("INVALID_ARGUMENT", `This position doesn't fit the root path: ${verdict.error}`,
526
- isKo ? `이 위치는 루트 경로에 맞지 않아요: ${verdict.error}` : `This position doesn't fit the root path: ${verdict.error}`);
526
+ ko(`This position doesn't fit the root path: ${verdict.error}`, `이 위치는 루트 경로에 맞지 않아요: ${verdict.error}`, `此位置不符合根路径:${verdict.error}`));
527
527
  }
528
528
  writeCharacterWaypoints(characterId, next);
529
529
  return { waypoint: moved, index, warnings: verdict.warnings };
@@ -572,9 +572,9 @@ export function useCast(appContext) {
572
572
  setActiveWaypointId(waypoint.id);
573
573
  setPendingWaypointFrame(null);
574
574
  const { warnings } = validateWaypointAt(path, index, waypoint);
575
- const placed = isKo
576
- ? `루트 웨이포인트 ${index + 1} 추가: 프레임 ${waypoint.frame}${pendingFrame != null ? " (타임라인 예약 프레임)" : pinned ? " (재생 헤드 위치)" : ` (~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}초 걷기 기준)`}`
577
- : `Waypoint ${index + 1} — frame ${waypoint.frame} ${pendingFrame != null ? "(at the reserved frame)" : pinned ? "(at the playhead)" : `(~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}s at a walk)`}`;
575
+ const placed = ko(`Waypoint ${index + 1} — frame ${waypoint.frame} ${pendingFrame != null ? "(at the reserved frame)" : pinned ? "(at the playhead)" : `(~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}s at a walk)`}`,
576
+ `루트 웨이포인트 ${index + 1} 추가: 프레임 ${waypoint.frame}${pendingFrame != null ? " (타임라인 예약 프레임)" : pinned ? " (재생 헤드 위치)" : ` (~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}초 걷기 기준)`}`,
577
+ `已添加第 ${index + 1} 个路径点 — 第 ${waypoint.frame} 帧${pendingFrame != null ? "(预留帧)" : pinned ? "(播放头位置)" : `(按步行约 ${(waypoint.frame / appContext.shared.tlFps).toFixed(1)} 秒)`}`);
578
578
  appContext.notify(warnings.length ? `${placed} · ⚠ ${warnings[0]}` : placed);
579
579
  }
580
580
 
@@ -587,7 +587,7 @@ export function useCast(appContext) {
587
587
  const path = readCharacterWaypoints(activeChar.id);
588
588
  const index = path.findIndex((entry) => entry.id === id);
589
589
  const { warnings } = index === -1 ? { warnings: [] } : validateWaypointAt(path, index, path[index]);
590
- if (warnings.length) appContext.notify(isKo ? `루트 웨이포인트 이동됨: ${warnings[0]}` : `Root waypoint moved: ${warnings[0]}`);
590
+ if (warnings.length) appContext.notify(ko(`Root waypoint moved: ${warnings[0]}`, `루트 웨이포인트 이동됨: ${warnings[0]}`, `根路径点已移动:${warnings[0]}`));
591
591
  }
592
592
 
593
593
  function removeWaypoint(id) {
@@ -639,7 +639,7 @@ export function useCast(appContext) {
639
639
  trackFeature("pose_save");
640
640
  const pose = {
641
641
  id: `custom_${Date.now()}`,
642
- label: isKo ? `내 포즈 ${customPoses.length + 1}` : `My Pose ${customPoses.length + 1}`,
642
+ label: ko(`My Pose ${customPoses.length + 1}`, `내 포즈 ${customPoses.length + 1}`, `我的姿势 ${customPoses.length + 1}`),
643
643
  prompt: "in the exact body pose shown in the blocking frame",
644
644
  bones: capturePose(activeRig),
645
645
  // A take frame carries its measured hips height; bottling the frame
@@ -650,8 +650,8 @@ export function useCast(appContext) {
650
650
  domain.run('cast.savePose', { pose });
651
651
  setStudioPick(pose.id);
652
652
  appContext.notify(appContext.shared.motion
653
- ? ko(`Saved this frame's pose to the library as “${pose.label}”`, `지금 프레임의 자세를 “${pose.label}”로 라이브러리에 저장했어요`)
654
- : ko(`Saved the current pose to the library as “${pose.label}”`, `지금 자세를 “${pose.label}”로 라이브러리에 저장했어요`));
653
+ ? ko(`Saved this frame's pose to the library as “${pose.label}”`, `지금 프레임의 자세를 “${pose.label}”로 라이브러리에 저장했어요`, `已将当前帧的姿势以“${pose.label}”保存到姿势库`)
654
+ : ko(`Saved the current pose to the library as “${pose.label}”`, `지금 자세를 “${pose.label}”로 라이브러리에 저장했어요`, `已将当前姿势以“${pose.label}”保存到姿势库`));
655
655
  }
656
656
 
657
657
  /** A handle drag is an authored pose. When it ends, the rig as it now
@@ -677,7 +677,7 @@ export function useCast(appContext) {
677
677
  trackFeature("pose_save");
678
678
  const pose = {
679
679
  id: `custom_${Date.now()}`,
680
- label: isKo ? `내 포즈 ${customPoses.length + 1}` : `My Pose ${customPoses.length + 1}`,
680
+ label: ko(`My Pose ${customPoses.length + 1}`, `내 포즈 ${customPoses.length + 1}`, `我的姿势 ${customPoses.length + 1}`),
681
681
  prompt: "in the exact body pose shown in the blocking frame",
682
682
  bones: capturePose(rig),
683
683
  custom: true,
@@ -19,5 +19,5 @@ export function overlappingRangePin(draft, state) {
19
19
  return [...ensureRangePinState(state).pins.values()].find((pin) => pin.id !== draft.id && rangePinTracks(pin).some((track) => rangePinTracks(draft).includes(track)) && Math.max(pin.startFrame, draft.startFrame) <= Math.min(pin.endFrame, draft.endFrame));
20
20
  }
21
21
  export function rangePinOverlapMessage(pin) {
22
- return pin ? ko(`A required body or limb track already has an overlapping pin (${pin.startFrame}–${pin.endFrame}).`, `필요한 몸통 또는 팔다리 파츠에 겹치는 고정이 있어요 (${pin.startFrame}–${pin.endFrame}).`) : "";
22
+ return pin ? ko(`A required body or limb track already has an overlapping pin (${pin.startFrame}–${pin.endFrame}).`, `필요한 몸통 또는 팔다리 파츠에 겹치는 고정이 있어요 (${pin.startFrame}–${pin.endFrame}).`, `所需的身体或肢体轨道上已有重叠的固定(${pin.startFrame}–${pin.endFrame})。`) : "";
23
23
  }