cozyclay 3.1.2 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/bin/agent/agent-runner.mjs +160 -151
- package/bin/agent/agent-stream.mjs +86 -0
- package/bin/agent/anthropic-subscription-events.mjs +120 -0
- package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
- package/bin/agent/anthropic-subscription-stream.mjs +251 -0
- package/bin/agent/anthropic-subscription.mjs +73 -0
- package/bin/agent/claude-code-executable.mjs +93 -0
- package/bin/agent/cliproxy-provider.mjs +25 -11
- package/bin/agent/model-roles.mjs +112 -0
- package/bin/agent/openai-compatible-provider.mjs +95 -0
- package/bin/agent/pi-tools.mjs +19 -10
- package/bin/agent/provider-login.mjs +3 -1
- package/bin/agent/provider-routes.mjs +2 -0
- package/bin/agent/providers.mjs +35 -130
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
- package/dist/assets/app-BdRlMf4J.css +1 -0
- package/dist/assets/app-DwETmoQZ.js +5144 -0
- package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
- package/dist/assets/task-board-DFfNa0xg.js +15 -0
- package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +3 -3
- package/package.json +6 -4
- package/src/App.jsx +28 -28
- package/src/agent-image-client.js +2 -2
- package/src/asset-pane.jsx +14 -14
- package/src/character-kind.js +13 -12
- package/src/commands/project.js +2 -1
- package/src/commands/shot.js +1 -1
- package/src/commands/view.js +2 -2
- package/src/domains/cast.js +15 -15
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion.js +11 -11
- package/src/domains/objects.js +20 -30
- package/src/domains/scenes.js +14 -21
- package/src/domains/shots.js +3 -3
- package/src/first-success-guide.jsx +19 -3
- package/src/hierarchy-panel.jsx +6 -7
- package/src/locale-zh/app.js +124 -0
- package/src/locale-zh/domains.js +36 -0
- package/src/locale-zh/labels.js +148 -0
- package/src/locale-zh/panels.js +225 -0
- package/src/locale-zh/shell.js +230 -0
- package/src/locale-zh/timeline.js +145 -0
- package/src/locale-zh/workflow.js +104 -0
- package/src/locale-zh/workspace.js +190 -0
- package/src/locale-zh.js +21 -0
- package/src/locale.js +16 -6
- package/src/main.jsx +1 -1
- package/src/motion/beat-board-model.js +4 -4
- package/src/motion/beat-board.jsx +1 -1
- package/src/motion/ik-pick.js +24 -0
- package/src/motion/take-segments.jsx +5 -5
- package/src/motion/timeline.jsx +30 -40
- package/src/motion/waypoints.js +6 -6
- package/src/object-catalog.jsx +4 -5
- package/src/panels/CameraPanel.jsx +3 -3
- package/src/panels/HdrEnvironmentControls.jsx +2 -1
- package/src/panels/ObjectTransformPanel.jsx +19 -29
- package/src/panels/PoseImagePanel.jsx +8 -8
- package/src/panels/PosePanel.jsx +2 -2
- package/src/panels/ReferenceImageField.jsx +2 -2
- package/src/panels/RigControlPanel.jsx +2 -2
- package/src/panels/SculptPanel.jsx +9 -9
- package/src/panels/SubjectBox.jsx +3 -3
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/posestudio.jsx +10 -8
- package/src/project-browser.css +7 -6
- package/src/project-browser.jsx +7 -6
- package/src/project-open-file.js +19 -0
- package/src/qa-label.js +1 -1
- package/src/range-pin-panel.jsx +14 -14
- package/src/resource-status.jsx +3 -3
- package/src/result-modal.jsx +10 -10
- package/src/settings-menu.jsx +4 -1
- package/src/shell/BoardSlot.jsx +5 -5
- package/src/shell/CompositionGuideButton.jsx +1 -1
- package/src/shell/GhostsMenuGroup.jsx +1 -1
- package/src/shell/StatusBar.jsx +4 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +2 -2
- package/src/shell/details-selection.js +1 -1
- package/src/shell/preference-controls.jsx +2 -1
- package/src/shot-guides.js +5 -5
- package/src/stage-hdr.js +3 -3
- package/src/stage-labels-ko.js +2 -2
- package/src/stage-startup-defaults.js +3 -3
- package/src/studio-actions.js +2 -2
- package/src/styles/tokens.css +1 -1
- package/src/styles.css +1 -1
- package/src/use-case-question.jsx +18 -18
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +1 -1
- package/src/workflow/AgentProgressCard.jsx +4 -4
- package/src/workflow/AgentTurnSteps.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +1 -1
- package/src/workflow/TaskInline.jsx +1 -1
- package/src/workflow/agent-client.js +1 -1
- package/src/workflow/agent-turn.js +5 -5
- package/src/workflow/task-board.js +4 -4
- package/tools/loc-baseline.json +1 -1
- package/tools/run-tests.mjs +4 -4
- package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
- package/dist/assets/app-Bhe0TpGz.css +0 -1
- package/dist/assets/app-C4QTcDcb.js +0 -5144
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
- 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
|
|
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", "평소보다 오래 걸려요") :
|
|
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={
|
|
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="">{
|
|
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={
|
|
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">{
|
|
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={
|
|
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">{
|
|
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 ? (
|
|
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
|
|
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={
|
|
32
|
-
title={
|
|
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}
|
package/src/posestudio.jsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
959
|
-
if (!
|
|
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>{
|
|
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>
|
package/src/project-browser.css
CHANGED
|
@@ -390,7 +390,7 @@
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
.v2-start-screen .v2-start-recent-actions button,
|
|
393
|
-
.v2-start-screen .v2-start-
|
|
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-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
package/src/project-browser.jsx
CHANGED
|
@@ -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
|
-
|
|
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.", "
|
|
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}
|
|
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)". */
|
package/src/range-pin-panel.jsx
CHANGED
|
@@ -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} 고정
|
|
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
|
})}
|
package/src/resource-status.jsx
CHANGED
|
@@ -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}개 자원이 프로젝트 파일에 담기지
|
|
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}개 자원이
|
|
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>
|
package/src/result-modal.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ko
|
|
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}에 붙여 넣어 장면을
|
|
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} · {
|
|
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">
|
package/src/settings-menu.jsx
CHANGED
|
@@ -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
|
|
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">
|
package/src/shell/BoardSlot.jsx
CHANGED
|
@@ -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}
|
|
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 (
|
package/src/shell/StatusBar.jsx
CHANGED
|
@@ -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
|