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
@@ -5,9 +5,10 @@ import * as THREE from "three";
5
5
  import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
6
6
  import { poseThumbnail, warmThumbnailModels } from "./pose-thumbs.js";
7
7
  import { IK_TRACKS, MID_TRACKS, ikControlIsExposed } from "./motion/ik.js";
8
+ import { pickIkHit } from "./motion/ik-pick.js";
8
9
  import { shouldRefreshIkExposure } from "./use-render-activity.js";
9
10
  import { POSER_LAYER } from "./dualview.jsx";
10
- import { ko, isKo } from "./locale.js";
11
+ import { ko } from "./locale.js";
11
12
  import { ROTATION_AXES, backFaceFade, bindQuaternionOf, handleColor as sideColor, ringAngle, rotationFromBindDeg, rotationReadout } from "./pose-gizmo.js";
12
13
  import "./pose-gizmo.css";
13
14
 
@@ -754,7 +755,9 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
754
755
  halo.material.opacity = mesh.material.opacity * (focused ? 0.6 : 0.4);
755
756
  }
756
757
  }
757
- const focusExposed = !!focus && visibilityRef.current.get(focus) !== false;
758
+ // A focused control keeps its gizmo and rings even when the body hides it:
759
+ // the user picked it, and while focused nothing else can take a press.
760
+ const focusExposed = !!focus;
758
761
  if (gizmoRef.current) gizmoRef.current.visible = focusExposed;
759
762
  if (ringRef.current) ringRef.current.visible = focusExposed;
760
763
 
@@ -938,10 +941,11 @@ const CLICK_PX = 4;
938
941
  if (ev.button !== 0) return;
939
942
  const downXY = [ev.clientX, ev.clientY];
940
943
  const focused = focusIdRef.current;
944
+ // A hidden unfocused control keeps only its sphere: its gizmo and rings are not drawn.
941
945
  let picks = pickRefs.current.filter((p) =>
942
946
  p.mesh?.visible &&
943
947
  handleRefs.current[p.track.id]?.visible &&
944
- handleRefs.current[p.track.id]?.userData.ikExposed !== false
948
+ (handleRefs.current[p.track.id]?.userData.ikExposed !== false || !p.part || p.track.id === focused)
945
949
  );
946
950
  if (focused && !selectionOnly) picks = picks.filter((p) => p.track.id === focused);
947
951
  else picks = picks.filter((p) => !p.axisDir); // spheres only
@@ -955,13 +959,11 @@ const CLICK_PX = 4;
955
959
  -((ev.clientY - rect.top) / rect.height) * 2 + 1
956
960
  );
957
961
  raycaster.setFromCamera(ndc, camera);
958
- const hits = raycaster.intersectObjects(picks.map((p) => p.mesh), false);
959
- if (!hits.length) {
962
+ const pick = pickIkHit(raycaster.intersectObjects(picks.map((p) => p.mesh), false), picks, handleRefs.current);
963
+ if (!pick) {
960
964
  if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
961
965
  return;
962
966
  }
963
- const pick = picks.find((p) => p.mesh === hits[0].object);
964
- if (!pick) return;
965
967
  if (typeof window !== "undefined") {
966
968
  window.__ikLastPick = { trackId: pick.track.id, kind: pick.kind, part: pick.part };
967
969
  }
@@ -1298,7 +1300,7 @@ export function PoseStudioPanel({ subject, model, poses, selectedId, onSelect, o
1298
1300
  return (
1299
1301
  <div className={"pose-studio" + (docked ? " docked" : "") + (closing ? " closing" : "")}>
1300
1302
  <div className="studio-head">
1301
- <span>{isKo ? `포즈 스튜디오 · 인물 ${subject}` : `Pose Studio · Subject ${subject}`}</span>
1303
+ <span>{ko(`Pose Studio · Subject ${subject}`, `포즈 스튜디오 · 인물 ${subject}`, `姿势工作室 · 角色 ${subject}`)}</span>
1302
1304
  <button type="button" className="x" onClick={onClose} aria-label={ko("Close pose studio", "포즈 스튜디오 닫기")}>
1303
1305
  ✕
1304
1306
  </button>
@@ -390,7 +390,7 @@
390
390
  }
391
391
 
392
392
  .v2-start-screen .v2-start-recent-actions button,
393
- .v2-start-screen .v2-start-file-button,
393
+ .v2-start-screen .v2-start-empty-open,
394
394
  .v2-start-screen .v2-start-learn-action {
395
395
  padding: 0;
396
396
  border: 0;
@@ -400,7 +400,7 @@
400
400
  }
401
401
 
402
402
  .v2-start-screen .v2-start-recent-actions button:hover,
403
- .v2-start-screen .v2-start-file-button:hover,
403
+ .v2-start-screen .v2-start-empty-open:hover,
404
404
  .v2-start-screen .v2-start-learn-action:hover {
405
405
  color: var(--text-1, #e4e4e7);
406
406
  }
@@ -510,10 +510,11 @@
510
510
  line-height: 1.55;
511
511
  }
512
512
 
513
- .v2-start-screen .v2-start-file-actions {
514
- margin-top: auto;
515
- padding-top: var(--sp-1, 8px);
516
- }
513
+ /* A toast raised from this screen (a project that would not open) shows above it, not behind it. */
514
+ .app:has(.v2-start-screen) .toast { z-index: calc(var(--start-z-index, 200) + 1); }
515
+
516
+ /* The open card's art is solid: it names a real file, not a blank slot. */
517
+ .v2-start-screen .v2-start-open-card .v2-start-add-art { border-style: solid; }
517
518
 
518
519
  /* --------------------------------------------------------------- preview --- */
519
520
 
@@ -294,7 +294,7 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
294
294
  <button type="button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button>
295
295
  </div>
296
296
  </div>
297
- <RecentList entries={recentEntries} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
297
+ <RecentList entries={recentEntries} currentName={currentName} onOpen={onOpen} onOpenFile={onOpenFile} onRemove={async (entry) => {
298
298
  await removeRecentProject(entry.name);
299
299
  setRecents(await loadRecentProjects());
300
300
  }} />
@@ -326,18 +326,18 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
326
326
  <span>{ko("Start from an empty stage", "빈 무대에서 시작")}</span>
327
327
  </span>
328
328
  </button>
329
+ <button type="button" role="listitem" className="v2-start-add-card v2-start-open-card" data-testid="start-open-project-card" onClick={onOpenFile}><span className="v2-start-add-art" aria-hidden="true"><svg viewBox="0 0 20 20" width="20" height="20"><path d="M2.5 6.5V15a1 1 0 0 0 1 1h13a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H10L8.5 5H3.5a1 1 0 0 0-1 1.5Z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg></span><span className="v2-start-template-copy"><strong>{ko("Open project", "프로젝트 열기")}</strong><span>{ko("A saved .cclayproject file", "저장한 .cclayproject 파일")}</span></span></button>
329
330
  </div>
330
331
 
331
332
  <div className="v2-start-recent-block">
332
333
  <div className="v2-start-recent-heading"><span>{ko("Recent", "최근")}</span></div>
333
- <RecentList entries={recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt }))} currentName={currentName} onOpen={onOpen} onRemove={async (entry) => {
334
+ <RecentList entries={recents.map((entry) => ({ ...entry, key: `recent-${entry.name}`, removable: true, date: entry.openedAt }))} currentName={currentName} onOpen={onOpen} onOpenFile={onOpenFile} onRemove={async (entry) => {
334
335
  await removeRecentProject(entry.name);
335
336
  setRecents(await loadRecentProjects());
336
337
  }} compact />
337
338
  </div>
338
339
  </>
339
340
  )}
340
- {activeNav !== "recent" && activeNav !== "learn" && <div className="v2-start-file-actions"><button type="button" className="v2-start-file-button" onClick={onOpenFile}>{ko("Open project file…", "프로젝트 파일 열기…")}</button></div>}
341
341
  </main>
342
342
 
343
343
  <aside className="v2-start-preview" data-testid="start-project-preview" aria-label={ko("Project preview", "프로젝트 미리보기")}>
@@ -392,9 +392,10 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
392
392
  );
393
393
  }
394
394
 
395
- function RecentList({ entries, currentName, onOpen, onRemove, compact = false }) {
395
+ // Recents live in this browser's storage for this address, so a project saved elsewhere opens from its file.
396
+ function RecentList({ entries, currentName, onOpen, onOpenFile, onRemove, compact = false }) {
396
397
  return entries.length === 0 ? (
397
- <p className={`v2-start-empty${compact ? " compact" : ""}`}>{ko("No recent projects yet.", "아직 최근 프로젝트가 없습니다.")}</p>
398
+ <p className={`v2-start-empty${compact ? " compact" : ""}`}>{ko("No recent projects in this browser yet. ", "이 브라우저에 최근 프로젝트가 없습니다. ")}<button type="button" className="v2-start-empty-open" onClick={onOpenFile}>{ko("Open a project file…", "프로젝트 파일 열기…")}</button></p>
398
399
  ) : (
399
400
  <div className={`v2-start-recent-list${compact ? " compact" : ""}`} role="list">
400
401
  {entries.map((entry) => (
@@ -404,7 +405,7 @@ function RecentList({ entries, currentName, onOpen, onRemove, compact = false })
404
405
  <span>{formatDate(entry.date)}</span>
405
406
  <small>{entry.shots ?? "—"}{ko(" shots", " 샷")}</small>
406
407
  </button>
407
- {entry.removable && <button type="button" className="v2-start-recent-remove" aria-label={ko(`Remove ${entry.name} from recent projects`, `최근 프로젝트에서 ${entry.name} 제거`)} title={ko("Remove from list", "목록에서 제거")} onClick={() => onRemove(entry)}>×</button>}
408
+ {entry.removable && <button type="button" className="v2-start-recent-remove" aria-label={ko(`Remove ${entry.name} from recent projects`, `최근 프로젝트에서 ${entry.name} 제거`, `从最近项目中移除 ${entry.name}`)} title={ko("Remove from list", "목록에서 제거")} onClick={() => onRemove(entry)}>×</button>}
408
409
  </div>
409
410
  ))}
410
411
  </div>
@@ -0,0 +1,19 @@
1
+ import { hasFileSystemAccess, openProjectFallback, pickProjectFileForOpen, readProjectFile } from "./project.js";
2
+
3
+ const PICKER_UNREADABLE_KEY = "cozyclay.open-picker-unreadable";
4
+
5
+ /** Pick a project file to open: { file, handle } (file null when the author cancels), or { blocked: true }.
6
+ * Some embedded browsers (the OmO desktop preview is one) hand back a picked handle and then refuse to read it
7
+ * (NotAllowedError on getFile). After one refusal this browser opens projects through a plain file input,
8
+ * which reads the file without a handle; when the click's activation is still live that input opens at once. */
9
+ export async function pickProjectToOpen(storage = globalThis.localStorage, activation = globalThis.navigator?.userActivation) {
10
+ if (!hasFileSystemAccess() || storage?.getItem(PICKER_UNREADABLE_KEY) === "1") return { file: await openProjectFallback(), handle: null };
11
+ const handle = await pickProjectFileForOpen();
12
+ try {
13
+ return { file: await readProjectFile(handle), handle };
14
+ } catch (error) {
15
+ if (error?.name !== "NotAllowedError") throw error;
16
+ try { storage?.setItem(PICKER_UNREADABLE_KEY, "1"); } catch { /* the next open asks the picker again */ }
17
+ return activation?.isActive ? { file: await openProjectFallback(), handle: null } : { blocked: true };
18
+ }
19
+ }
package/src/qa-label.js CHANGED
@@ -14,7 +14,7 @@ export function qaProblems(qa) {
14
14
  export function qaLabel(qa) {
15
15
  const n = qaProblems(qa);
16
16
  if (qa?.ok || !n) return ko("QA clean", "QA 통과");
17
- return ko(`${n} problem${n === 1 ? "" : "s"}`, `문제 ${n}개`);
17
+ return ko(`${n} problem${n === 1 ? "" : "s"}`, `문제 ${n}개`, `${n} 个问题`);
18
18
  }
19
19
 
20
20
  /** A receipt's QA phrase: "clean" | "2 problems (321 frame issues)". */
@@ -5,10 +5,10 @@ import "./range-pin-panel.css";
5
5
  export const RANGE_PIN_TRACKS = Object.freeze(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
6
6
 
7
7
  const PARTS = Object.freeze([
8
- { id: "leftHand", label: "Left Hand", ko: "왼손", color: "var(--range-pin-hand)" },
9
- { id: "rightHand", label: "Right Hand", ko: "오른손", color: "var(--range-pin-hand)" },
10
- { id: "leftFoot", label: "Left Foot", ko: "왼발", color: "var(--range-pin-foot)" },
11
- { id: "rightFoot", label: "Right Foot", ko: "오른발", color: "var(--range-pin-foot)" },
8
+ { id: "leftHand", label: "Left Hand", ko: "왼손", zh: "左手", color: "var(--range-pin-hand)" },
9
+ { id: "rightHand", label: "Right Hand", ko: "오른손", zh: "右手", color: "var(--range-pin-hand)" },
10
+ { id: "leftFoot", label: "Left Foot", ko: "왼발", zh: "左脚", color: "var(--range-pin-foot)" },
11
+ { id: "rightFoot", label: "Right Foot", ko: "오른발", zh: "右脚", color: "var(--range-pin-foot)" },
12
12
  ]);
13
13
 
14
14
  const PIN_ID_PREFIX = "pin";
@@ -77,10 +77,10 @@ function residualWarning(residuals) {
77
77
  frames,
78
78
  label: ko(
79
79
  `Can't reach on ${frames.length} frame${frames.length === 1 ? "" : "s"}`,
80
- `${frames.length}개 프레임에 닿지 않음`,
80
+ `${frames.length}개 프레임에 닿지 않음`, `${frames.length} 帧无法触及`,
81
81
  ),
82
- title: ko(`Residual over 1 cm on frames ${frames.join(", ")}`, `1cm를 넘는 잔여 오차: ${frames.join(", ")}프레임`)
83
- + (feet.length ? ko(`; feet cannot stay planted on frames ${feet.join(", ")}`, `; 발 위치를 유지할 수 없는 프레임: ${feet.join(", ")}`) : ""),
82
+ title: ko(`Residual over 1 cm on frames ${frames.join(", ")}`, `1cm를 넘는 잔여 오차: ${frames.join(", ")}프레임`, `第 ${frames.join(", ")} 帧的残差超过 1 cm`)
83
+ + (feet.length ? ko(`; feet cannot stay planted on frames ${feet.join(", ")}`, `; 발 위치를 유지할 수 없는 프레임: ${feet.join(", ")}`, `;第 ${feet.join(", ")} 帧无法保持双脚踩地`) : ""),
84
84
  };
85
85
  }
86
86
 
@@ -88,7 +88,7 @@ function validationFor(draft, { motion, frameCount, objects }) {
88
88
  if (!motion) return ko("Load a motion before creating a pin.", "핀을 만들기 전에 모션을 불러오세요.");
89
89
  if (!RANGE_PIN_TRACKS.includes(draft.track)) return ko("Choose a hand or foot.", "손이나 발을 선택하세요.");
90
90
  if (!Number.isInteger(Number(draft.startFrame)) || !Number.isInteger(Number(draft.endFrame))) return ko("In and Out must be whole frames.", "In과 Out은 정수 프레임이어야 해요.");
91
- if (draft.startFrame < 0 || draft.endFrame >= frameCount) return ko(`Use frames 0–${Math.max(0, frameCount - 1)}.`, `${Math.max(0, frameCount - 1)}프레임 안에서 선택하세요.`);
91
+ if (draft.startFrame < 0 || draft.endFrame >= frameCount) return ko(`Use frames 0–${Math.max(0, frameCount - 1)}.`, `${Math.max(0, frameCount - 1)}프레임 안에서 선택하세요.`, `请使用第 0–${Math.max(0, frameCount - 1)} 帧。`);
92
92
  if (draft.endFrame < draft.startFrame) return ko("Out must be on or after In.", "Out은 In 이후여야 해요.");
93
93
  if (draft.targetKind === "object" && !draft.objectId) return ko("Choose a scene object.", "씬 오브젝트를 선택하세요.");
94
94
  if (draft.targetKind === "object" && !objects.some((object) => object.id === draft.objectId)) return ko("That scene object is no longer available.", "씬 오브젝트를 찾을 수 없어요.");
@@ -98,7 +98,7 @@ function validationFor(draft, { motion, frameCount, objects }) {
98
98
 
99
99
  function targetLabel(pin, objects) {
100
100
  if (pin.target.space === "object") {
101
- return ko(`Object · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`, `오브젝트 · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`);
101
+ return ko(`Object · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`, `오브젝트 · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`, `物体 · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`);
102
102
  }
103
103
  return ko("Hold at In", "In 위치 고정");
104
104
  }
@@ -233,7 +233,7 @@ export function RangePinPanel({
233
233
  onClick={() => updateDraft({ track: part.id })}
234
234
  >
235
235
  <span className="range-pin-part-dot" aria-hidden="true" />
236
- {ko(part.label, part.ko)}
236
+ {ko(part.label, part.ko, part.zh)}
237
237
  </button>
238
238
  ))}
239
239
  </div>
@@ -300,7 +300,7 @@ export function RangePinPanel({
300
300
  {validation && <p className="range-pin-validation" role="alert">{validation}</p>}
301
301
  {needsReplace && (
302
302
  <div className="range-pin-replace-warning" role="alert">
303
- <span>{ko(`Existing IK keys on ${conflictFrames.length} frame${conflictFrames.length === 1 ? "" : "s"}: ${conflictFrames.join(", ")}`, `기존 IK 키가 ${conflictFrames.length}개 프레임에 있어요: ${conflictFrames.join(", ")}`)}</span>
303
+ <span>{ko(`Existing IK keys on ${conflictFrames.length} frame${conflictFrames.length === 1 ? "" : "s"}: ${conflictFrames.join(", ")}`, `기존 IK 키가 ${conflictFrames.length}개 프레임에 있어요: ${conflictFrames.join(", ")}`, `${conflictFrames.length} 帧上已有 IK 关键帧:${conflictFrames.join(", ")}`)}</span>
304
304
  <button type="button" className="btn ghost small" onClick={() => setReplaceConfirmed((value) => !value)} aria-pressed={replaceConfirmed}>
305
305
  {replaceConfirmed ? ko("Replace confirmed", "교체 확인됨") : ko("Replace existing keys", "기존 키 교체")}
306
306
  </button>
@@ -313,7 +313,7 @@ export function RangePinPanel({
313
313
  </div>
314
314
 
315
315
  <div className="range-pin-list-head">
316
- <span>{ko("Pins", "고정 목록")}</span>
316
+ <span>{ko("Pins", "고정 목록", "固定项")}</span>
317
317
  <small>{sortedPins.length}</small>
318
318
  </div>
319
319
  {sortedPins.length === 0 ? (
@@ -327,11 +327,11 @@ export function RangePinPanel({
327
327
  return (
328
328
  <div key={pin.id} className={"range-pin-item" + (selectedPinId === pin.id ? " selected" : "")} style={{ "--range-pin-color": part.color }}>
329
329
  <button type="button" className="range-pin-item-main" onClick={() => editPin(pin)} aria-pressed={selectedPinId === pin.id}>
330
- <span className="range-pin-item-top"><span className="range-pin-part-dot" aria-hidden="true" />{ko(part.label, part.ko)} <b>{pin.startFrame}–{pin.endFrame}</b></span>
330
+ <span className="range-pin-item-top"><span className="range-pin-part-dot" aria-hidden="true" />{ko(part.label, part.ko, part.zh)} <b>{pin.startFrame}–{pin.endFrame}</b></span>
331
331
  <span className="range-pin-item-target">{missingObject ? ko("Missing object target", "오브젝트 대상 없음") : targetLabel(pin, objects)}</span>
332
332
  {(warning || missingObject) && <span className="range-pin-warning" title={missingObject ? ko("The referenced object was deleted.", "참조한 오브젝트가 삭제됐어요.") : warning.title}>{missingObject ? ko("Invalid target", "대상 없음") : warning.label}</span>}
333
333
  </button>
334
- <button type="button" className="range-pin-delete" data-testid={`range-pin-delete-${pin.id}`} aria-label={ko(`Delete ${part.label} pin`, `${part.ko} 고정 삭제`)} title={ko("Delete pin", "고정 삭제")} onClick={() => onDelete?.(pin.id)}><span aria-hidden="true">×</span></button>
334
+ <button type="button" className="range-pin-delete" data-testid={`range-pin-delete-${pin.id}`} aria-label={ko(`Delete ${part.label} pin`, `${part.ko} 고정 삭제`, `删除${part.zh}固定`)} title={ko("Delete pin", "고정 삭제")} onClick={() => onDelete?.(pin.id)}><span aria-hidden="true">×</span></button>
335
335
  </div>
336
336
  );
337
337
  })}
@@ -106,7 +106,7 @@ export function ResourceStatus({ manifest, compact = false, onSelect }) {
106
106
  <div className="resource-status-total is-missing"><dt>{ko("Missing", "누락")}</dt><dd data-total="missing">{totals.missing}</dd></div>
107
107
  {totals.bytes > 0 ? <div className="resource-status-total is-bytes"><dt>{ko("Size", "용량")}</dt><dd data-total="bytes">{formatMiB(totals.bytes)}</dd></div> : null}
108
108
  </dl>
109
- {missing.length ? <p className="resource-status-warning" role="status">{ko(`${missing.length} resource${missing.length === 1 ? "" : "s"} will not travel with this project file.`, `${missing.length}개 자원이 프로젝트 파일에 담기지 않습니다.`)}</p> : null}
109
+ {missing.length ? <p className="resource-status-warning" role="status">{ko(`${missing.length} resource${missing.length === 1 ? "" : "s"} will not travel with this project file.`, `${missing.length}개 자원이 프로젝트 파일에 담기지 않습니다.`, `${missing.length} 个资源不会随此项目文件一起保存。`)}</p> : null}
110
110
  {!compact && ordered.length ? (
111
111
  <ul className="resource-status-items">
112
112
  {ordered.map((item, index) => <ResourceItem key={itemKey(item, index)} item={item} index={index} onSelect={onSelect} />)}
@@ -127,7 +127,7 @@ function reasonBody(reason) {
127
127
  <>
128
128
  <strong>{ko("The project file would be too large.", "프로젝트 파일이 너무 큽니다.")}</strong>
129
129
  <span className="save-blocked-size" data-bytes={reason.bytes} data-limit={reason.limit}>
130
- {ko(`${formatMiB(reason.bytes)} of embedded resources; the limit is ${formatMiB(reason.limit)}.`, `내장 자원 ${formatMiB(reason.bytes)}, 한도 ${formatMiB(reason.limit)}.`)}
130
+ {ko(`${formatMiB(reason.bytes)} of embedded resources; the limit is ${formatMiB(reason.limit)}.`, `내장 자원 ${formatMiB(reason.bytes)}, 한도 ${formatMiB(reason.limit)}.`, `嵌入资源 ${formatMiB(reason.bytes)};上限为 ${formatMiB(reason.limit)}。`)}
131
131
  </span>
132
132
  </>
133
133
  );
@@ -136,7 +136,7 @@ function reasonBody(reason) {
136
136
  const items = Array.isArray(reason.items) ? reason.items.filter((item) => item && typeof item === "object") : [];
137
137
  return (
138
138
  <>
139
- <strong>{ko(`${items.length} resource${items.length === 1 ? " is" : "s are"} missing.`, `${items.length}개 자원이 누락되었습니다.`)}</strong>
139
+ <strong>{ko(`${items.length} resource${items.length === 1 ? " is" : "s are"} missing.`, `${items.length}개 자원이 누락되었습니다.`, `缺少 ${items.length} 个资源。`)}</strong>
140
140
  <ul className="save-blocked-missing">
141
141
  {items.map((item, index) => <ResourceItem key={itemKey(item, index)} item={item} index={index} />)}
142
142
  </ul>
@@ -1,4 +1,4 @@
1
- import { ko, isKo } from "./locale.js";
1
+ import { ko } from "./locale.js";
2
2
 
3
3
  export default function ResultModal({ result, copied, recordedVideoName, onClose, onCopy, onDownload, downloadDisabled = false, exportFeedback = null }) {
4
4
  const isVideo = result.mode === "video";
@@ -9,9 +9,9 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
9
9
  if (isVideo && result.frameB) {
10
10
  nextStep = (
11
11
  <p>
12
- {ko(`Paste the prompt into ${modelLabel}, set `, `${modelLabel}에 프롬프트를 붙여 넣고 `)}
12
+ {ko(`Paste the prompt into ${modelLabel}, set `, `${modelLabel}에 프롬프트를 붙여 넣고 `, `将提示词粘贴到 ${modelLabel},设置 `)}
13
13
  <code>blocking-frame-A-start.png</code>
14
- {ko(" as the start frame, and ", "를 시작 프레임으로, ")}
14
+ {ko(" as the start frame, and ", "를 시작 프레임으로, ", " 作为起始帧,并将 ")}
15
15
  <code>blocking-frame-B-end.png</code>
16
16
  {ko(" as the end frame.", "를 끝 프레임으로 설정하세요.")}
17
17
  </p>
@@ -19,21 +19,21 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
19
19
  } else if (isVideo && hasFrame) {
20
20
  nextStep = (
21
21
  <p>
22
- {ko(`Paste the prompt into ${modelLabel} and attach `, `${modelLabel}에 프롬프트를 붙여 넣고 `)}
22
+ {ko(`Paste the prompt into ${modelLabel} and attach `, `${modelLabel}에 프롬프트를 붙여 넣고 `, `将提示词粘贴到 ${modelLabel},并附加 `)}
23
23
  <code>blocking-frame.png</code>
24
- {ko(" as the reference frame.", "를 참고 프레임으로 첨부하세요.")}
24
+ {ko(" as the reference frame.", "를 참고 프레임으로 첨부하세요.", " 作为参考帧。")}
25
25
  </p>
26
26
  );
27
27
  } else if (hasFrame) {
28
28
  nextStep = (
29
29
  <p>
30
- {ko(`Paste the prompt into ${modelLabel} and attach `, `${modelLabel}에 프롬프트를 붙여 넣고 `)}
30
+ {ko(`Paste the prompt into ${modelLabel} and attach `, `${modelLabel}에 프롬프트를 붙여 넣고 `, `将提示词粘贴到 ${modelLabel},并附加 `)}
31
31
  <code>blocking-frame.png</code>
32
- {ko(" as the reference image.", "를 참고 이미지로 첨부하세요.")}
32
+ {ko(" as the reference image.", "를 참고 이미지로 첨부하세요.", " 作为参考图像。")}
33
33
  </p>
34
34
  );
35
35
  } else {
36
- nextStep = <p>{ko(`Paste the copied prompt into ${modelLabel} to create the scene.`, `복사한 프롬프트를 ${modelLabel}에 붙여 넣어 장면을 만드세요.`)}</p>;
36
+ nextStep = <p>{ko(`Paste the copied prompt into ${modelLabel} to create the scene.`, `복사한 프롬프트를 ${modelLabel}에 붙여 넣어 장면을 만드세요.`, `将复制的提示词粘贴到 ${modelLabel} 以创建场景。`)}</p>;
37
37
  }
38
38
 
39
39
  return (
@@ -61,7 +61,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
61
61
  )}
62
62
  {result.move && (
63
63
  <div className="move-slate result-move-slate">
64
- <span>{result.move.displaySlate ?? result.move.slate} · {isKo ? `${result.move.spanS}초` : `${result.move.spanS}s`}</span>
64
+ <span>{result.move.displaySlate ?? result.move.slate} · {result.move.spanS}{ko("s", "초", "秒")}</span>
65
65
  <small>{ko("Camera move made from timeline keyframes", "타임라인 키프레임으로 만든 카메라 움직임")}</small>
66
66
  </div>
67
67
  )}
@@ -73,14 +73,14 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
73
73
  </button>
74
74
  {result.frame && (
75
75
  <button type="button" className="btn" onClick={onDownload} disabled={downloadDisabled}>
76
- {result.downloaded ? ko("Download requested", "다운로드 요청됨") : result.frameB ? ko("Download start and end frames", "시작·끝 프레임 다운로드") : ko("Download frame", "프레임 다운로드")}
76
+ {result.downloaded ? ko("Exported", "내보냄") : result.frameB ? ko("Export start and end frames", "시작·끝 프레임 내보내기") : ko("Export frame", "프레임 내보내기")}
77
77
  </button>
78
78
  )}
79
79
  </div>
80
80
 
81
81
  {exportFeedback}
82
82
  <section className="result-next" aria-labelledby="result-next-title">
83
- <span className="result-next-kicker">{ko(`Next · ${modelLabel}`, `다음 · ${modelLabel}`)}</span>
83
+ <span className="result-next-kicker">{ko(`Next · ${modelLabel}`, `다음 · ${modelLabel}`, `下一步 · ${modelLabel}`)}</span>
84
84
  <h4 id="result-next-title">{ko("Handing off to your AI", "AI에 넣는 순서")}</h4>
85
85
  <div className="result-next-intro">{nextStep}</div>
86
86
  <ol className="result-handoff-steps">
@@ -89,8 +89,8 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
89
89
  <span>{copied ? ko("Copied. Paste it into your AI service's prompt box.", "복사됐어요. AI 서비스의 입력창에 붙여 넣으세요.") : ko("Press “Copy prompt” above, then paste it into your AI service.", "위의 ‘프롬프트 복사’를 누른 뒤 AI 서비스 입력창에 붙여 넣으세요.")}</span>
90
90
  </li>
91
91
  <li>
92
- <strong>{ko("Download the frame", "프레임 다운로드")}</strong>
93
- <span>{result.frameB ? ko("Press “Download start and end frames” to save both PNGs.", "‘시작·끝 프레임 다운로드’를 눌러 두 PNG를 저장하세요.") : hasFrame ? ko("Press “Download frame” to save the PNG.", "‘프레임 다운로드’를 눌러 PNG를 저장하세요.") : ko("This result has no frame to download.", "이 결과에는 내려받을 프레임이 없어요.")}</span>
92
+ <strong>{ko("Export the frame", "프레임 내보내기")}</strong>
93
+ <span>{result.frameB ? ko("Press “Export start and end frames” to save both PNGs.", "‘시작·끝 프레임 내보내기’를 눌러 두 PNG를 저장하세요.") : hasFrame ? ko("Press “Export frame” to save the PNG.", "‘프레임 내보내기’를 눌러 PNG를 저장하세요.") : ko("This result has no frame to export.", "이 결과에는 내보낼 프레임이 없어요.")}</span>
94
94
  </li>
95
95
  <li>
96
96
  <strong>{ko("Attach the image to your AI", "AI에 이미지 첨부")}</strong>
@@ -11,6 +11,7 @@ import PreferencesDialog from "./shell/PreferencesDialog.jsx";
11
11
  const LANGUAGES = [
12
12
  { id: "en", label: "English", action: "Switch to English" },
13
13
  { id: "ko", label: "한국어", action: "한국어로 전환" },
14
+ { id: "zh", label: "简体中文", action: "切换到中文" },
14
15
  ];
15
16
 
16
17
  export default function SettingsMenu() {
@@ -25,7 +26,9 @@ export default function SettingsMenu() {
25
26
  // the only visible hint that Korean exists used to be the old locale button.
26
27
  // Until a language is stored, a ko-* browser sees the trigger in Korean.
27
28
  const koreanBrowser = typeof navigator !== "undefined" && (navigator.language ?? "").startsWith("ko");
28
- const label = !localeChosen && koreanBrowser ? "한국어" : ko("Settings", "설정");
29
+ const chineseBrowser = typeof navigator !== "undefined" && (navigator.language ?? "").startsWith("zh");
30
+ const firstRunCue = koreanBrowser ? "한국어" : chineseBrowser ? "简体中文" : null;
31
+ const label = !localeChosen && firstRunCue ? firstRunCue : ko("Settings", "설정", "设置");
29
32
 
30
33
  return (
31
34
  <div className="settings-menu-wrap">
@@ -103,7 +103,7 @@ export default function BoardSlot({ active = true }) {
103
103
  } catch (error) {
104
104
  attempt.fail(error);
105
105
  console.warn(`[cozyclay] panel ${entry.id} could not be sent to Workflow`, error);
106
- setToast(ko(`Could not send the panel to Workflow: ${error?.message || error}`, `패널을 워크플로로 보내지 못했어요: ${error?.message || error}`));
106
+ setToast(ko(`Could not send the panel to Workflow: ${error?.message || error}`, `패널을 워크플로로 보내지 못했어요: ${error?.message || error}`, `无法将面板发送到工作流:${error?.message || error}`));
107
107
  }
108
108
  }
109
109
 
@@ -151,7 +151,7 @@ export default function BoardSlot({ active = true }) {
151
151
  return (
152
152
  <section className="dock-board" aria-label={ko("Board", "보드")} data-testid="dock-board" hidden={!active}>
153
153
  <header className="dock-board-head">
154
- <span className="dock-board-count">{ko(`${stills.length} panels`, `패널 ${stills.length}개`)}</span>
154
+ <span className="dock-board-count">{ko(`${stills.length} panels`, `패널 ${stills.length}개`, `${stills.length} 个面板`)}</span>
155
155
  {/* An empty board offers + Panel once, in its empty state. */}
156
156
  {stills.length > 0 && (
157
157
  <button type="button" className="dock-board-add" data-testid="board-add-panel" title={ko("Add a panel after the last one, framed by the current camera", "현재 카메라 구도로 마지막 패널 뒤에 패널 추가")} onClick={addPanel}>
@@ -245,7 +245,7 @@ function PanelCard({ shot, index, fps, thumb, stylizing, onStylize, onUnstylize,
245
245
  data-stylizing={stylizing || undefined}
246
246
  tabIndex={0}
247
247
  draggable
248
- aria-label={ko(`Panel ${index + 1}`, `패널 ${index + 1}`)}
248
+ aria-label={ko(`Panel ${index + 1}`, `패널 ${index + 1}`, `面板 ${index + 1}`)}
249
249
  aria-current={selected ? "true" : undefined}
250
250
  onClick={onSelect}
251
251
  onKeyDown={(event) => {
@@ -312,7 +312,7 @@ function PanelCard({ shot, index, fps, thumb, stylizing, onStylize, onUnstylize,
312
312
  key={`caption:${shot.caption ?? ""}`}
313
313
  className="dock-board-caption"
314
314
  data-testid="board-card-caption"
315
- aria-label={ko(`Panel ${index + 1} caption`, `패널 ${index + 1} 캡션`)}
315
+ aria-label={ko(`Panel ${index + 1} caption`, `패널 ${index + 1} 캡션`, `面板 ${index + 1} 说明`)}
316
316
  placeholder={ko("Action or dialogue", "동작 또는 대사")}
317
317
  defaultValue={shot.caption ?? ""}
318
318
  maxLength={500}
@@ -330,7 +330,7 @@ function PanelCard({ shot, index, fps, thumb, stylizing, onStylize, onUnstylize,
330
330
  max={STILL_HOLD_MAX / fps}
331
331
  step={0.5}
332
332
  defaultValue={seconds}
333
- aria-label={ko(`Panel ${index + 1} hold in seconds`, `패널 ${index + 1} 홀드 (초)`)}
333
+ aria-label={ko(`Panel ${index + 1} hold in seconds`, `패널 ${index + 1} 홀드 (초)`, `面板 ${index + 1} 停留秒数`)}
334
334
  onBlur={commitHold}
335
335
  onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }}
336
336
  />
@@ -8,7 +8,7 @@ export default function CompositionGuideButton({ guideMode, onCycle }) {
8
8
  className={"vp-guide-cycle vp-embed-guide" + (guideMode === "off" ? "" : " on")}
9
9
  aria-label={ko("Cycle composition guides", "구도 가이드 전환")}
10
10
  aria-pressed={guideMode !== "off"}
11
- title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
11
+ title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko, GUIDE_LABELS[guideMode].zh) + ko(" · click to cycle", " · 클릭으로 전환")}
12
12
  onClick={() => onCycle(nextGuideMode(guideMode))}
13
13
  >
14
14
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
@@ -0,0 +1,103 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ko } from "../locale.js";
3
+ import {
4
+ canChooseExportWorkspace,
5
+ chooseExportWorkspace,
6
+ clearExportWorkspace,
7
+ loadExportWorkspace,
8
+ requestExportWorkspacePermission,
9
+ } from "../export-workspace.js";
10
+ import { PreferenceRow } from "./preference-controls.jsx";
11
+
12
+ const SUPPORTED_DESCRIPTION = ko(
13
+ "When set, exports are written here instead of your browser's Downloads folder.",
14
+ "설정하면 내보낸 파일이 브라우저 다운로드 폴더 대신 이 폴더에 저장됩니다.",
15
+ );
16
+
17
+ function statusCopy(permission) {
18
+ if (permission === "prompt") return ko("Folder access needs to be re-allowed.", "폴더 접근을 다시 허용해야 해요.");
19
+ if (permission === "denied") return ko("Folder access was denied. Choose another folder or use Downloads.", "폴더 접근이 거부되었어요. 다른 폴더를 선택하거나 다운로드를 사용하세요.");
20
+ return null;
21
+ }
22
+
23
+ export default function ExportFolderPreference() {
24
+ const supported = canChooseExportWorkspace();
25
+ const [workspace, setWorkspace] = useState(null);
26
+ const [busy, setBusy] = useState(false);
27
+ const [error, setError] = useState(null);
28
+
29
+ useEffect(() => {
30
+ let active = true;
31
+ loadExportWorkspace().then((value) => {
32
+ if (active) setWorkspace(value);
33
+ });
34
+ return () => { active = false; };
35
+ }, []);
36
+
37
+ const choose = async () => {
38
+ setBusy(true);
39
+ setError(null);
40
+ try {
41
+ const handle = await chooseExportWorkspace();
42
+ setWorkspace({ handle, permission: "granted" });
43
+ } catch (cause) {
44
+ if (cause?.name !== "AbortError") setError(ko("Could not choose the export folder.", "내보내기 폴더를 선택하지 못했어요."));
45
+ } finally {
46
+ setBusy(false);
47
+ }
48
+ };
49
+
50
+ const allow = async () => {
51
+ if (!workspace?.handle) return;
52
+ setBusy(true);
53
+ setError(null);
54
+ try {
55
+ const permission = await requestExportWorkspacePermission(workspace.handle);
56
+ setWorkspace({ ...workspace, permission });
57
+ } finally {
58
+ setBusy(false);
59
+ }
60
+ };
61
+
62
+ const clear = async () => {
63
+ setBusy(true);
64
+ setError(null);
65
+ try {
66
+ const cleared = await clearExportWorkspace();
67
+ if (cleared) setWorkspace(null);
68
+ else setError(ko("Could not clear the export folder.", "내보내기 폴더를 해제하지 못했어요."));
69
+ } finally {
70
+ setBusy(false);
71
+ }
72
+ };
73
+
74
+ const status = workspace ? statusCopy(workspace.permission) : null;
75
+ const folderName = workspace?.handle?.name ?? ko("Browser downloads", "브라우저 다운로드");
76
+ const description = supported ? SUPPORTED_DESCRIPTION : ko("This browser cannot choose an export folder; exports continue to Downloads.", "이 브라우저에서는 내보내기 폴더를 선택할 수 없어요. 내보내기는 다운로드 폴더로 이어집니다.");
77
+
78
+ return (
79
+ <PreferenceRow title={ko("Export folder", "내보내기 폴더")} description={description} testid="preferences-export-folder" full>
80
+ <div className="v2-preferences__export-folder">
81
+ <span className="v2-preferences__export-folder-name" data-testid="preferences-export-folder-name">{folderName}</span>
82
+ {status && <span className="v2-preferences__export-folder-status" role="status">{status}</span>}
83
+ {error && <span className="v2-preferences__export-folder-status" role="alert">{error}</span>}
84
+ <div className="v2-preferences__language-controls" role="group" aria-label={ko("Export folder actions", "내보내기 폴더 작업")}>
85
+ {workspace && workspace.permission !== "granted" && (
86
+ <button type="button" className="v2-preferences__language" data-testid="settings-export-folder-allow" disabled={busy} onClick={allow}>
87
+ {ko("Allow", "허용")}
88
+ </button>
89
+ )}
90
+ <button type="button" className="v2-preferences__language" data-testid="settings-export-folder-choose" disabled={!supported || busy} onClick={choose}>
91
+ {busy ? ko("Choosing…", "선택 중…") : ko("Choose folder…", "폴더 선택…")}
92
+ </button>
93
+ {workspace && (
94
+ <button type="button" className="v2-preferences__language" data-testid="settings-export-folder-clear" disabled={busy} onClick={clear}>
95
+ {ko("Use Downloads", "다운로드 사용")}
96
+ </button>
97
+ )}
98
+ </div>
99
+ </div>
100
+ <span className="v2-preferences__export-folder-note">{ko("Existing files are never overwritten; numbered names are used.", "같은 이름의 파일이 있으면 번호가 붙고 덮어쓰지 않아요.")}</span>
101
+ </PreferenceRow>
102
+ );
103
+ }
@@ -26,7 +26,7 @@ export default function GhostsMenuGroup() {
26
26
  const set = (patch) => runStudioAction("view.setGhosts", { mode: ghosts.mode, count: ghosts.count, step: ghosts.step, ...patch });
27
27
  const options = [
28
28
  { value: "off", label: ko("Off", "끔") },
29
- { value: "step", label: ko(`Every ${ghosts.step} frames`, `${ghosts.step}프레임마다`) },
29
+ { value: "step", label: ko(`Every ${ghosts.step} frames`, `${ghosts.step}프레임마다`, `每 ${ghosts.step} 帧`) },
30
30
  { value: "keys", label: ko("Key frames", "키 프레임") },
31
31
  ];
32
32
  return (