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,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import Foldout from "./Foldout.jsx";
3
- import { ko, isKo } from "../locale.js";
3
+ import { ko } from "../locale.js";
4
4
  import { effortOptions } from "../workflow/agent-client.js"; import { saveExport } from "../export-workspace.js";
5
5
  import "./sculpt.css";
6
6
 
@@ -59,7 +59,7 @@ function NowLine({ waiting, now = Date.now() }) {
59
59
  <span className="sculpt-now-time" data-testid="sculpt-now-time">{Math.floor(waited / 1000)}s</span>
60
60
  {expect !== null && (
61
61
  <span className="sculpt-now-expect">
62
- {over ? ko("taking longer than usual", "평소보다 오래 걸려요") : isKo ? `보통 ~${Math.round(expect / 1000)}초` : `usually ~${Math.round(expect / 1000)}s`}
62
+ {over ? ko("taking longer than usual", "평소보다 오래 걸려요") : ko(`usually ~${Math.round(expect / 1000)}s`, `보통 ~${Math.round(expect / 1000)}초`, `通常约 ${Math.round(expect / 1000)} 秒`)}
63
63
  </span>
64
64
  )}
65
65
  {expect !== null && <span className="sculpt-now-bar" aria-hidden="true"><span style={{ "--sculpt-wait": Math.min(1, waited / expect) }} /></span>}
@@ -78,7 +78,7 @@ function IouTrail({ history, target }) {
78
78
  const delta = history.length > 1 ? last - history.at(-2).iou : null;
79
79
  return (
80
80
  <div className="sculpt-trail" data-testid="sculpt-trail">
81
- <svg viewBox="0 0 100 34" preserveAspectRatio="none" aria-label={isKo ? `라운드별 IoU: ${values.map((v) => v.toFixed(3)).join(", ")}` : `IoU by round: ${values.map((v) => v.toFixed(3)).join(", ")}`} role="img">
81
+ <svg viewBox="0 0 100 34" preserveAspectRatio="none" aria-label={ko(`IoU by round: ${values.map((v) => v.toFixed(3)).join(", ")}`, `라운드별 IoU: ${values.map((v) => v.toFixed(3)).join(", ")}`, `各轮 IoU:${values.map((v) => v.toFixed(3)).join(", ")}`)} role="img">
82
82
  {target !== null && <line className="sculpt-trail-target" x1="0" x2="100" y1={y(target)} y2={y(target)} />}
83
83
  {history.length > 1 && <polyline className="sculpt-trail-line" points={history.map((row, index) => `${x(index)},${y(row.iou)}`).join(" ")} />}
84
84
  {history.map((row, index) => <circle key={index} className="sculpt-trail-dot" data-last={index === history.length - 1 ? "true" : undefined} cx={x(index)} cy={y(row.iou)} r="2.2" />)}
@@ -186,7 +186,7 @@ function ModelPicker({ picker, locked }) {
186
186
  {efforts.length > 0 && (
187
187
  <select className="sculpt-select sculpt-effort" data-testid="sculpt-effort-select" aria-label={ko("Effort", "추론 강도")} value={effort ?? ""} disabled={locked}
188
188
  onChange={(event) => onEffort?.(event.target.value || null)}>
189
- <option value="">{isKo ? `기본 (${efforts[0]})` : `Default (${efforts[0]})`}</option>
189
+ <option value="">{ko(`Default (${efforts[0]})`, `기본 (${efforts[0]})`, `默认(${efforts[0]})`)}</option>
190
190
  {efforts.map((value) => <option key={value} value={value}>{value}</option>)}
191
191
  </select>
192
192
  )}
@@ -293,7 +293,7 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
293
293
  onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
294
294
  onDrop={(event) => { event.preventDefault(); take(event.dataTransfer?.files); }}>
295
295
  <div className="sculpt-ref-frame">
296
- <img src={reference.dataUrl} alt={isKo ? `참조 이미지: ${reference.name}` : `Reference: ${reference.name}`} />
296
+ <img src={reference.dataUrl} alt={ko(`Reference: ${reference.name}`, `참조 이미지: ${reference.name}`, `参考图:${reference.name}`)} />
297
297
  <OutlineScan outline={run?.outline} scanning={stage === "measure" && !run?.outline} />
298
298
  </div>
299
299
  <button type="button" className="btn ghost small sculpt-well-replace" data-testid="sculpt-reference-replace"
@@ -351,7 +351,7 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
351
351
  {running && <NowLine waiting={run.waiting} />}
352
352
 
353
353
  <div className="sculpt-stats">
354
- <span data-testid="sculpt-round">{isKo ? `라운드 ${run.round} / ${run.maxRounds}` : `Round ${run.round} / ${run.maxRounds}`}</span>
354
+ <span data-testid="sculpt-round">{ko(`Round ${run.round} / ${run.maxRounds}`, `라운드 ${run.round} / ${run.maxRounds}`, `第 ${run.round} / ${run.maxRounds} 轮`)}</span>
355
355
  <span data-testid="sculpt-elapsed">{formatElapsed(run.elapsedMs)}</span>
356
356
  </div>
357
357
 
@@ -365,7 +365,7 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
365
365
  data-reached={iou !== null && target !== null && iou >= target ? "true" : undefined}
366
366
  style={{ "--sculpt-iou": iou ?? 0, "--sculpt-target": target ?? 0 }}>
367
367
  <span className="sculpt-iou-fill" />
368
- {target !== null && <span className="sculpt-iou-marker" data-testid="sculpt-iou-target" title={isKo ? `목표 ${formatIou(target)}` : `Target ${formatIou(target)}`} />}
368
+ {target !== null && <span className="sculpt-iou-marker" data-testid="sculpt-iou-target" title={ko(`Target ${formatIou(target)}`, `목표 ${formatIou(target)}`, `目标 ${formatIou(target)}`)} />}
369
369
  </div>
370
370
  <IouTrail history={run.history} target={target} />
371
371
  </div>
@@ -374,7 +374,7 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
374
374
  <ActivityLog activity={run.activity} />
375
375
 
376
376
  {run.message && !hasLog && <p className="inspector-hint sculpt-message" role="status">{run.message}</p>}
377
- {stage === "done" && <p className="inspector-hint sculpt-done" role="status" data-testid="sculpt-done">{isKo ? `완료 — IoU ${formatIou(iou)}` : `Done — IoU ${formatIou(iou)}`}</p>}
377
+ {stage === "done" && <p className="inspector-hint sculpt-done" role="status" data-testid="sculpt-done">{ko(`Done — IoU ${formatIou(iou)}`, `완료 — IoU ${formatIou(iou)}`, `完成 — IoU ${formatIou(iou)}`)}</p>}
378
378
  {stage === "cancelled" && <p className="inspector-hint" role="status" data-testid="sculpt-cancelled">{ko("Cancelled.", "취소했어요.")}</p>}
379
379
  {stage === "failed" && <p className="inspector-hint sculpt-error" role="alert" data-testid="sculpt-error">{run.error || ko("The sculpt run failed.", "조형에 실패했어요.")}</p>}
380
380
  </div>
@@ -391,7 +391,7 @@ export default function SculptPanel({ run, reference, onReference, onStart, onCa
391
391
  )}
392
392
 
393
393
  <button type="button" className="btn full" data-testid="sculpt-place" disabled={!sculptName || running}
394
- aria-label={sculptName ? (isKo ? `${sculptName} 장면에 배치` : `Place ${sculptName} in scene`) : ko("Place in scene", "장면에 배치")}
394
+ aria-label={sculptName ? ko(`Place ${sculptName} in scene`, `${sculptName} 장면에 배치`, `将 ${sculptName} 放入场景`) : ko("Place in scene", "장면에 배치")}
395
395
  onClick={() => onPlaceInScene?.()}>
396
396
  {ko("Place in scene", "장면에 배치")}
397
397
  </button>
@@ -1,4 +1,4 @@
1
- import { ko, isKo } from "../locale.js";
1
+ import { ko } from "../locale.js";
2
2
 
3
3
  export default function SubjectBox({ label, onRemove, onPose, posing, color, onColorChange, onColorEditStart }) {
4
4
  return (
@@ -28,8 +28,8 @@ export default function SubjectBox({ label, onRemove, onPose, posing, color, onC
28
28
  <button
29
29
  type="button"
30
30
  className={"cam-toggle" + (posing ? " active" : "")}
31
- aria-label={isKo ? `${label} 포즈 열기` : `Open pose studio for ${label}`}
32
- title={isKo ? `${label} 포즈` : `Pose ${label}`}
31
+ aria-label={ko(`Open pose studio for ${label}`, `${label} 포즈 열기`, `打开 ${label} 的姿势工作室`)}
32
+ title={ko(`Pose ${label}`, `${label} 포즈`, `${label} 姿势`)}
33
33
  onClick={onPose}
34
34
  >
35
35
  {/* The Pose mode figure, not a keyboard glyph. */}
@@ -14,7 +14,7 @@ export default function SubjectsPanel({
14
14
  {characters.map((entry, index) => entry.hidden ? null : (
15
15
  <SubjectBox
16
16
  key={entry.id}
17
- label={ko(`Subject ${index + 1}`, `인물 ${index + 1}`)}
17
+ label={ko(`Subject ${index + 1}`, `인물 ${index + 1}`, `角色 ${index + 1}`)}
18
18
  onPose={() => openStudio(entry.id)}
19
19
  posing={posing === entry.id}
20
20
  onRemove={index > 0 ? () => run('character.remove', { characterId: entry.id }) : undefined}
@@ -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
  )}
@@ -80,7 +80,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
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">
@@ -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">
@@ -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 (
@@ -47,7 +47,7 @@ function ExportStatus({ status, label, recording }) {
47
47
  const detail = [
48
48
  message,
49
49
  retryable && ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요."),
50
- handedOff > 0 && ko(`${handedOff} file(s) already handed off. Retry skips those files.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요.`),
50
+ handedOff > 0 && ko(`${handedOff} file(s) already handed off. Retry skips those files.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요.`, `已交付 ${handedOff} 个文件。重试时会跳过这些文件。`),
51
51
  busy && stage === "mux" && ko("Finalizing MP4; this stage cannot be interrupted.", "MP4 마무리 중이에요. 이 단계는 중단할 수 없어요."),
52
52
  ].filter(Boolean).join(" ");
53
53
  return (
@@ -86,9 +86,9 @@ function ExportStatus({ status, label, recording }) {
86
86
  function relativeTime(ms) {
87
87
  const minutes = Math.floor(ms / 60000);
88
88
  if (minutes < 1) return ko("just now", "방금");
89
- if (minutes < 60) return ko(`${minutes} min ago`, `${minutes}분 전`);
89
+ if (minutes < 60) return ko(`${minutes} min ago`, `${minutes}분 전`, `${minutes} 分钟前`);
90
90
  const hours = Math.floor(minutes / 60);
91
- return ko(`${hours} h ago`, `${hours}시간 전`);
91
+ return ko(`${hours} h ago`, `${hours}시간 전`, `${hours} 小时前`);
92
92
  }
93
93
 
94
94
  // "Saved 2 min ago" once a save completes in this session; otherwise the
@@ -109,7 +109,7 @@ export function SaveState({ status }) {
109
109
  return () => clearInterval(timer);
110
110
  }, [savedAt]);
111
111
  const text = savedAt !== null && status.state === "saved" && !status.dirty
112
- ? ko(`Saved ${relativeTime(now - savedAt)}`, `저장됨 · ${relativeTime(now - savedAt)}`)
112
+ ? ko(`Saved ${relativeTime(now - savedAt)}`, `저장됨 · ${relativeTime(now - savedAt)}`, `已保存 · ${relativeTime(now - savedAt)}`)
113
113
  : status.text;
114
114
  return (
115
115
  <span
@@ -177,7 +177,7 @@ export default function ViewportToolbar() {
177
177
  aria-pressed={snapEnabled}
178
178
  title={ko(
179
179
  `Grid snapping ${snapEnabled ? "on" : "off"} (${SNAP_MOVE} · ${SNAP_TURN}) — click to toggle, hold Ctrl during a drag to invert`,
180
- `그리드 스냅 ${snapEnabled ? "켜짐" : "꺼짐"} (${SNAP_MOVE} · ${SNAP_TURN}) — 클릭해 전환, 드래그 중 Ctrl을 누르면 반대로 작동`,
180
+ `그리드 스냅 ${snapEnabled ? "켜짐" : "꺼짐"} (${SNAP_MOVE} · ${SNAP_TURN}) — 클릭해 전환, 드래그 중 Ctrl을 누르면 반대로 작동`, `网格吸附 ${snapEnabled ? "开" : "关"}(${SNAP_MOVE} · ${SNAP_TURN})— 点击切换,拖动时按住 Ctrl 反向`,
181
181
  )}
182
182
  onClick={() => setSnapEnabled((value) => !value)}
183
183
  >
@@ -524,12 +524,12 @@ export default function ViewportToolbar() {
524
524
  className={"view-menu-item vp-guide-cycle" + (guideMode === "off" ? "" : " active")}
525
525
  aria-checked={guideMode !== "off"}
526
526
  aria-pressed={guideMode !== "off"}
527
- title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
527
+ title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko, GUIDE_LABELS[guideMode].zh) + ko(" · click to cycle", " · 클릭으로 전환")}
528
528
  onClick={() => runStudioAction("view.setGuideMode", { mode: nextGuideMode(guideMode) })}
529
529
  >
530
530
  <span className="view-menu-mark" aria-hidden="true">{guideMode !== "off" ? "✓" : ""}</span>
531
531
  {ko("Composition guides", "구도 가이드")}
532
- <small>{ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko)}</small>
532
+ <small>{ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko, GUIDE_LABELS[guideMode].zh)}</small>
533
533
  </button>
534
534
  <GhostsMenuGroup />
535
535
  {/* Part colours repaint a BODY, so the section only exists