cozyclay 1.0.0 → 1.2.0

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 (94) hide show
  1. package/README.md +59 -24
  2. package/THIRD_PARTY_NOTICES.md +31 -0
  3. package/bin/cozyclay.mjs +105 -11
  4. package/dist/CNAME +1 -0
  5. package/dist/app/index.html +57 -0
  6. package/dist/assets/app-Cgpk2hwX.js +4803 -0
  7. package/dist/assets/app-DgZvaAE1.css +1 -0
  8. package/dist/favicon.ico +0 -0
  9. package/dist/icons/apple-touch-icon.png +0 -0
  10. package/dist/icons/icon-192.png +0 -0
  11. package/dist/icons/icon-32.png +0 -0
  12. package/dist/icons/icon-512.png +0 -0
  13. package/dist/icons/icon-maskable-512.png +0 -0
  14. package/dist/index.html +406 -5
  15. package/dist/manifest.webmanifest +42 -0
  16. package/dist/media/cozyclay-demo-poster.jpg +0 -0
  17. package/dist/media/cozyclay-logo.png +0 -0
  18. package/dist/robots.txt +4 -0
  19. package/dist/sitemap.xml +9 -0
  20. package/dist/sw.js +127 -0
  21. package/package.json +15 -3
  22. package/src/App.jsx +1393 -648
  23. package/src/ardy/client.js +5 -4
  24. package/src/ardy/mp4-duration.js +102 -0
  25. package/src/ardy/timeline-coordinates.js +11 -0
  26. package/src/ardy/timeline.jsx +528 -67
  27. package/src/ardy/waypoints.js +14 -9
  28. package/src/camera-block.js +60 -0
  29. package/src/camera-follow.js +49 -10
  30. package/src/camera-rail-schedule.js +176 -0
  31. package/src/controls.jsx +16 -2
  32. package/src/cuts.js +184 -0
  33. package/src/generation/client.js +56 -0
  34. package/src/generation/session.js +47 -0
  35. package/src/generation/shot-spec.js +40 -0
  36. package/src/generation/use-generation.js +55 -0
  37. package/src/hierarchy-model.js +9 -17
  38. package/src/hierarchy-panel.jsx +170 -23
  39. package/src/locale-toggle.jsx +18 -0
  40. package/src/locale.js +34 -0
  41. package/src/main.jsx +5 -0
  42. package/src/object-catalog.jsx +32 -3
  43. package/src/planview.jsx +174 -66
  44. package/src/pose-extract/browser.js +42 -0
  45. package/src/pose-extract/fit.js +194 -0
  46. package/src/pose-extract/index.js +53 -0
  47. package/src/pose-extract/keyframes.js +74 -0
  48. package/src/pose-extract/landmarks.js +162 -0
  49. package/src/poses.js +114 -0
  50. package/src/posestudio.jsx +75 -13
  51. package/src/pwa.js +42 -0
  52. package/src/result-modal.jsx +124 -0
  53. package/src/scenes.js +223 -0
  54. package/src/shot-authoring.js +204 -77
  55. package/src/shot.js +8 -4
  56. package/src/styles.css +839 -38
  57. package/src/ui.jsx +54 -5
  58. package/tools/ardy/BRIDGE.md +70 -4
  59. package/tools/ardy/README.md +55 -0
  60. package/tools/ardy/bridge.mjs +89 -389
  61. package/tools/ardy/cclay_constrained_generate.py +449 -0
  62. package/tools/ardy/cclay_motion_edit.py +74 -9
  63. package/tools/ardy/cclay_sequence_generate.py +68 -7
  64. package/tools/ardy/cclay_worker.py +304 -0
  65. package/tools/ardy/merge-text-encoder.py +160 -0
  66. package/tools/ardy/run-local.mjs +233 -0
  67. package/tools/ardy/runners/index.mjs +23 -0
  68. package/tools/ardy/runners/local.mjs +452 -0
  69. package/tools/ardy/runners/proc.mjs +125 -0
  70. package/tools/ardy/runners/remote.mjs +246 -0
  71. package/tools/ardy/setup-local.mjs +189 -0
  72. package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
  73. package/tools/ardy/setup-text-encoder.py +296 -0
  74. package/tools/generation/README.md +29 -0
  75. package/tools/generation/bridge.mjs +149 -0
  76. package/tools/generation/job-store.mjs +51 -0
  77. package/tools/generation/providers/kling.mjs +76 -0
  78. package/tools/generation/providers/runway.mjs +101 -0
  79. package/tools/generation/providers/seedance.mjs +87 -0
  80. package/tools/generation/providers/shared.mjs +49 -0
  81. package/tools/generation/providers/veo.mjs +84 -0
  82. package/tools/metrics/archive-traffic.mjs +138 -0
  83. package/dist/assets/index-BW_S1YRy.js +0 -4395
  84. package/dist/assets/index-nRmmJgap.css +0 -1
  85. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  86. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  87. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  88. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  89. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  90. package/tools/qa-browser.mjs +0 -84
  91. package/tools/qa-crop3.mjs +0 -22
  92. package/tools/qa-playview.mjs +0 -49
  93. package/tools/qa-screenshot.mjs +0 -24
  94. package/tools/qa-visual.mjs +0 -51
package/src/App.jsx CHANGED
@@ -6,6 +6,7 @@ import { SkeletonUtils } from "three/examples/jsm/Addons.js";
6
6
  import { buildArdyPose } from "./ardy/export.js";
7
7
  import { checkBridge, generate as ardyGenerate } from "./ardy/client.js";
8
8
  import { loadMotionFromUrl } from "./ardy/npz.js";
9
+ import { repairRecordedMp4 } from "./ardy/mp4-duration.js";
9
10
  import { applyMotionFrame, captureArdyRoot, restorePlaybackBones, snapshotPlaybackBones } from "./ardy/playback.js";
10
11
  import { movePromptClipFrames } from "./ardy/prompt-clips.js";
11
12
  import Timeline from "./ardy/timeline.jsx";
@@ -15,27 +16,58 @@ import HierarchyPanel from "./hierarchy-panel.jsx";
15
16
  import { PlanBoard } from "./planview.jsx";
16
17
  import { DualRender, GIZMO_LAYER, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
17
18
  import { Room, StageLights } from "./room.jsx";
18
- import { SHOT_AUTHORING_KEY, loadShotAuthoring, serializeShotAuthoring } from "./shot-authoring.js";
19
- import { FOLLOW_DEFAULTS, buildFollowTrack, buildRail, buildRailFollowTrack, simplifyStroke } from "./camera-follow.js";
19
+ import {
20
+ SHOT_AUTHORING_KEY,
21
+ SHOT_AUTHORING_LEGACY_KEY,
22
+ SHOT_AUTHORING_LEGACY_KEYS,
23
+ SHOT_AUTHORING_QUARANTINE_KEY,
24
+ createShotAuthoringDocument,
25
+ readShotAuthoringDocument,
26
+ readShotAuthoring,
27
+ } from "./shot-authoring.js";
28
+ import { buildFollowTrack, buildRail, buildRailFollowTrack, followFramingFromCamera, simplifyStroke } from "./camera-follow.js";
29
+ import { createCameraBlock, updateCameraBlock } from "./camera-block.js";
30
+ import {
31
+ RAIL_SCHEDULE_LEGACY,
32
+ RAIL_SCHEDULE_RANGE,
33
+ clampRailRange,
34
+ defaultRailRange,
35
+ moveRailRange,
36
+ resizeRailRange,
37
+ resolveRailSchedule,
38
+ } from "./camera-rail-schedule.js";
20
39
  import { SetProps } from "./props.jsx";
21
40
  import {
22
41
  DEFAULT_SCENE_OBJECTS,
23
42
  OBJECT_COLORS,
24
- SCENE_QUARANTINE_KEY,
25
- SCENE_STORAGE_KEY,
26
43
  createSceneObject,
27
44
  dropToSurfacePatch,
28
- loadScene,
29
45
  objectSize,
30
46
  placementInFront,
31
47
  removeSceneObject,
32
48
  sceneObjectIdFromHierarchy,
33
- serializeScene,
34
49
  updateSceneObject,
35
50
  } from "./scene-objects.js";
36
51
  import { createSceneHistoryStore } from "./scene-history.js";
52
+ import {
53
+ SCENES_QUARANTINE_KEY,
54
+ SCENES_STORAGE_KEY,
55
+ SCENES_VERSION,
56
+ activeSceneIndex,
57
+ addScene,
58
+ createSceneStage,
59
+ createSceneDocument,
60
+ duplicateScene,
61
+ loadSceneDocumentFromStorage,
62
+ removeScene,
63
+ renameScene,
64
+ serializeSceneDocument,
65
+ } from "./scenes.js";
37
66
  import ObjectGizmo from "./object-gizmo.jsx";
38
67
  import AddObjectMenu from "./object-catalog.jsx";
68
+ import ResultModal from "./result-modal.jsx";
69
+ import LocaleToggle from "./locale-toggle.jsx";
70
+ import { ko, isKo } from "./locale.js";
39
71
  import {
40
72
  BUILT_IN_POSES,
41
73
  POSE_BONES,
@@ -67,6 +99,7 @@ import {
67
99
  } from "./ardy/ik.js";
68
100
  import { Dropdown, Field, Slider, Toast, Vector3Row } from "./ui.jsx";
69
101
  import { RENDER_ACTIVITY_EVENT, useRenderActivity } from "./use-render-activity.js";
102
+ import { useGeneration } from "./generation/use-generation.js";
70
103
  import {
71
104
  CAMERA_MOVES,
72
105
  CUSTOM_MOVE,
@@ -78,18 +111,30 @@ import {
78
111
  slateLine,
79
112
  } from "./shot.js";
80
113
  import { captureFraming, classifyMove, cameraMoveAt, moveSequenceSlate, moveSequencePhrase } from "./camera-move.js";
114
+ import {
115
+ cameraAtFrame,
116
+ addShotAtFrame,
117
+ cutAtFrame,
118
+ duplicateShot,
119
+ initialShots,
120
+ removeShot,
121
+ renameShot,
122
+ reorderShot,
123
+ resizeShot,
124
+ shotIndexAtFrame,
125
+ } from "./cuts.js";
81
126
 
82
127
  // Stated the way a crew states a setup: how far back, which side, how high the
83
128
  // lens rides, and what glass is on it. Order matters — Medium is the setup a
84
129
  // director reaches for first, so it leads.
85
130
  const PRESETS = {
86
- medium: { label: "Medium", distance: 2.6, azimuth: 22, elevation: 6, fov: 45, targetY: 1.35, two: false },
87
- wide: { label: "Wide", distance: 7, azimuth: 25, elevation: 4, fov: 38, targetY: 1.2, two: false },
88
- closeup: { label: "Close-Up", distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
89
- low: { label: "Low Angle", distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
90
- high: { label: "High Angle", distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
131
+ medium: { label: ko("Medium", "미디엄"), distance: 2.6, azimuth: 22, elevation: 6, fov: 45, targetY: 1.35, two: false },
132
+ wide: { label: ko("Wide", "와이드"), distance: 7, azimuth: 25, elevation: 4, fov: 38, targetY: 1.2, two: false },
133
+ closeup: { label: ko("Close-Up", "클로즈업"), distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
134
+ low: { label: ko("Low Angle", "로우 앵글"), distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
135
+ high: { label: ko("High Angle", "하이 앵글"), distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
91
136
  ots: {
92
- label: "Over Shoulder",
137
+ label: ko("Over Shoulder", "오버 숄더"),
93
138
  distance: 2.8,
94
139
  azimuth: 200,
95
140
  elevation: 8,
@@ -100,7 +145,7 @@ const PRESETS = {
100
145
  charB: { x: 0.62, z: 1.05, rot: 196 },
101
146
  },
102
147
  two: {
103
- label: "Two Shot",
148
+ label: ko("Two Shot", "투샷"),
104
149
  distance: 6,
105
150
  azimuth: 18,
106
151
  elevation: 5,
@@ -122,29 +167,150 @@ const RIG_HIERARCHY_FOCUS = {
122
167
  };
123
168
 
124
169
  const HIERARCHY_INSPECTOR_TITLES = {
125
- shot: "Shot settings",
126
- camera: "Camera",
127
- characters: "Characters",
128
- characterA: "Character 1",
129
- "characterA.character": "Character 1",
130
- "characterA.motion": "Motion",
131
- "characterA.baseMotion": "Base Motion",
132
- "characterA.promptBlocks": "Prompt Blocks",
133
- "characterA.ik": "IK Corrections",
134
- "characterA.rig": "Rig",
135
- characterB: "Character 2",
136
- "characterB.character": "Character 2",
137
- rootPath: "Root Path",
138
- environment: "Environment",
139
- props: "Props",
140
- "rig.hips": "Root / Hips",
141
- "rig.spine": "Spine",
142
- "rig.leftArm": "Left Arm",
143
- "rig.rightArm": "Right Arm",
144
- "rig.leftLeg": "Left Leg",
145
- "rig.rightLeg": "Right Leg",
170
+ shot: ko("Shot settings", "샷 설정"),
171
+ camera: ko("Camera", "카메라"),
172
+ characters: ko("Characters", "인물"),
173
+ characterA: ko("Character 1", "인물 1"),
174
+ "characterA.rig": ko("Rig", "리그"),
175
+ characterB: ko("Character 2", "인물 2"),
176
+ environment: ko("Environment", "환경"),
177
+ props: ko("Props", "소품"),
178
+ "rig.hips": ko("Root / Hips", "루트 / 엉덩이"),
179
+ "rig.spine": ko("Spine", "척추"),
180
+ "rig.leftArm": ko("Left Arm", "왼팔"),
181
+ "rig.rightArm": ko("Right Arm", "오른팔"),
182
+ "rig.leftLeg": ko("Left Leg", "왼다리"),
183
+ "rig.rightLeg": ko("Right Leg", "오른다리"),
146
184
  };
147
185
 
186
+ const CAMERA_MOVE_LABELS_KO = new Map([
187
+ ["Static / locked-off", ko("Static / locked-off", "고정 샷")],
188
+ ["Zoom in", ko("Zoom in", "줌 인")],
189
+ ["Zoom out", ko("Zoom out", "줌 아웃")],
190
+ ["Push-in (dolly in)", ko("Push-in (dolly in)", "푸시인(돌리 인)")],
191
+ ["Pull-out (dolly out)", ko("Pull-out (dolly out)", "풀아웃(돌리 아웃)")],
192
+ ["Pan left", ko("Pan left", "왼쪽 팬")],
193
+ ["Pan right", ko("Pan right", "오른쪽 팬")],
194
+ ["Tilt up", ko("Tilt up", "틸트 업")],
195
+ ["Tilt down", ko("Tilt down", "틸트 다운")],
196
+ ["Tracking / follow", ko("Tracking / follow", "트래킹 / 팔로우")],
197
+ ["Orbit / arc", ko("Orbit / arc", "오빗 / 아크")],
198
+ ["Crane up", ko("Crane up", "크레인 업")],
199
+ ["Crane down", ko("Crane down", "크레인 다운")],
200
+ ["Handheld", ko("Handheld", "핸드헬드")],
201
+ ["Crash zoom in", ko("Crash zoom in", "크래시 줌 인")],
202
+ ["Dolly-zoom (vertigo)", ko("Dolly-zoom (vertigo)", "돌리 줌(버티고)")],
203
+ ["Whip pan", ko("Whip pan", "휩 팬")],
204
+ ["Aerial / drone", ko("Aerial / drone", "공중 / 드론")],
205
+ [CUSTOM_MOVE, ko(CUSTOM_MOVE, "직접 입력…")],
206
+ ]);
207
+
208
+ const POSE_LABELS_KO = new Map([
209
+ ["T-pose", ko("T-pose", "T 포즈")],
210
+ ["Relaxed", ko("Relaxed", "편안한 자세")],
211
+ ["Contrapposto", ko("Contrapposto", "콘트라포스토")],
212
+ ["Walking", ko("Walking", "걷는 자세")],
213
+ ["Seated", ko("Seated", "앉은 자세")],
214
+ ["Arms crossed", ko("Arms crossed", "팔짱")],
215
+ ["Pointing", ko("Pointing", "가리키기")],
216
+ ["Hands on hips", ko("Hands on hips", "허리에 손")],
217
+ ["Looking back", ko("Looking back", "뒤돌아보기")],
218
+ ["Hands up", ko("Hands up", "손 올리기")],
219
+ ]);
220
+
221
+ const SHOT_SIZE_LABELS_KO = new Map([
222
+ ["extreme close-up", ko("extreme close-up", "익스트림 클로즈업")],
223
+ ["close-up", ko("close-up", "클로즈업")],
224
+ ["medium close-up", ko("medium close-up", "미디엄 클로즈업")],
225
+ ["medium shot", ko("medium shot", "미디엄 샷")],
226
+ ["medium-wide shot", ko("medium-wide shot", "미디엄 와이드 샷")],
227
+ ["wide shot", ko("wide shot", "와이드 샷")],
228
+ ["extreme wide shot", ko("extreme wide shot", "익스트림 와이드 샷")],
229
+ ]);
230
+
231
+ const SHOT_LEVEL_LABELS_KO = new Map([
232
+ ["overhead", ko("overhead", "오버헤드")],
233
+ ["high angle", ko("high angle", "하이 앵글")],
234
+ ["eye level", ko("eye level", "아이 레벨")],
235
+ ["chest level", ko("chest level", "가슴 높이")],
236
+ ["hip level", ko("hip level", "엉덩이 높이")],
237
+ ["knee level", ko("knee level", "무릎 높이")],
238
+ ["ground level", ko("ground level", "바닥 높이")],
239
+ ]);
240
+
241
+ const SCENE_RENDERER_LABELS_KO = new Map([
242
+ ["cube", ko("cube", "큐브")],
243
+ ["sphere", ko("sphere", "구")],
244
+ ["capsule", ko("capsule", "캡슐")],
245
+ ["cylinder", ko("cylinder", "원기둥")],
246
+ ["cone", ko("cone", "원뿔")],
247
+ ["plane", ko("plane", "평면")],
248
+ ["chair", ko("chair", "의자")],
249
+ ["car", ko("car", "자동차")],
250
+ ["aircraft", ko("aircraft", "비행기")],
251
+ ]);
252
+
253
+ const SCENE_OBJECT_NAME_LABELS_KO = new Map([
254
+ ["Cube", ko("Cube", "큐브")],
255
+ ["Sphere", ko("Sphere", "구")],
256
+ ["Capsule", ko("Capsule", "캡슐")],
257
+ ["Cylinder", ko("Cylinder", "원기둥")],
258
+ ["Cone", ko("Cone", "원뿔")],
259
+ ["Plane", ko("Plane", "평면")],
260
+ ["Chair", ko("Chair", "의자")],
261
+ ["Car", ko("Car", "자동차")],
262
+ ["Plane (aircraft)", ko("Plane (aircraft)", "비행기")],
263
+ ]);
264
+
265
+ function poseLabelKo(pose) {
266
+ return pose?.custom ? pose.label : POSE_LABELS_KO.get(pose?.label) ?? pose?.label ?? "";
267
+ }
268
+
269
+ function cameraMoveLabelKo(move) {
270
+ return CAMERA_MOVE_LABELS_KO.get(move) ?? move;
271
+ }
272
+
273
+ function sceneRendererLabelKo(renderer) {
274
+ return SCENE_RENDERER_LABELS_KO.get(renderer) ?? renderer;
275
+ }
276
+
277
+ function sceneObjectNameDisplayKo(name) {
278
+ const match = /^(.+?)(?: ([2-9]\d*))?$/.exec(name ?? "");
279
+ if (!match) return name;
280
+ const [, base, suffix] = match;
281
+ const label = SCENE_OBJECT_NAME_LABELS_KO.get(base);
282
+ return label ? `${label}${suffix ? ` ${suffix}` : ""}` : name;
283
+ }
284
+
285
+ function viewShortKo(viewShort) {
286
+ if (viewShort === "front") return ko("front", "정면");
287
+ if (viewShort === "back") return ko("back", "후면");
288
+ if (viewShort?.includes("profile")) return viewShort.startsWith("left") ? ko("left profile", "왼쪽 측면") : ko("right profile", "오른쪽 측면");
289
+ if (viewShort?.startsWith("front ¾")) return / L$/.test(viewShort) ? ko("front ¾ L", "정면 ¾ 왼쪽") : ko("front ¾ R", "정면 ¾ 오른쪽");
290
+ if (viewShort?.startsWith("rear ¾")) return / L$/.test(viewShort) ? ko("rear ¾ L", "후면 ¾ 왼쪽") : ko("rear ¾ R", "후면 ¾ 오른쪽");
291
+ return viewShort;
292
+ }
293
+
294
+ function slateLineKo(shot) {
295
+ return [
296
+ SHOT_SIZE_LABELS_KO.get(shot.sizeLabel) ?? shot.sizeLabel,
297
+ viewShortKo(shot.viewShort),
298
+ SHOT_LEVEL_LABELS_KO.get(shot.levelLabel) ?? shot.levelLabel,
299
+ `${shot.focalMm}mm`,
300
+ ].filter(Boolean).join(" · ");
301
+ }
302
+
303
+ function moveSequenceSlateKo(segments) {
304
+ if (!segments.length) return "";
305
+ if (segments.length === 1) {
306
+ const seg = segments[0];
307
+ return `${slateLineKo(seg.from)} → ${slateLineKo(seg.to)} · ${cameraMoveLabelKo(seg.label)}`;
308
+ }
309
+ const parts = [slateLineKo(segments[0].from)];
310
+ for (const seg of segments) parts.push(`${cameraMoveLabelKo(seg.label)} → ${slateLineKo(seg.to)}`);
311
+ return parts.join(" · ");
312
+ }
313
+
148
314
  function hierarchyIdForIkFocus(focus) {
149
315
  if (!focus) return null;
150
316
  if (focus === "hips") return "rig.hips";
@@ -160,15 +326,20 @@ const CAPTURE_W = 1920;
160
326
  const CAPTURE_H = 1080;
161
327
  // Pre-generated clip shipped with the build so a bridge-less session (a hosted
162
328
  // static demo, or `npm run dev:ui`) still shows real generated motion.
163
- // Relative on purpose: it has to resolve under a project sub-path too.
164
- // Same reason as DEMO_MOTION_URL: a leading slash breaks the app the moment
165
- // the build is served from a project sub-path (a GitHub Pages project site).
166
- const CHARACTER_MODEL_URL = "models/y-bot-tpose.fbx";
167
- const DEMO_MOTION_URL = "demo/walk-then-stop.npz";
329
+ // Root-absolute on purpose: the studio is served from "/app/" while these
330
+ // public files stay at the site root, so a page-relative path would resolve
331
+ // to "/app/models/..." and 404. Vite's base is "/" (own apex domain), so a
332
+ // leading slash is now the correct — and only — form that works from both
333
+ // the dev server and the built site.
334
+ const CHARACTER_MODEL_URL = "/models/y-bot-tpose.fbx";
335
+ const DEMO_MOTION_URL = "/demo/walk-then-stop.npz";
168
336
  const DEMO_MOTION_PROMPT = "a person walking then a person stops";
169
337
  const CLAY = "#f2eee6";
170
338
  const CLAY_B = "#ddd6ca";
171
339
  const DEFAULT_POSE = BUILT_IN_POSES.find((p) => p.id === "relaxed") ?? BUILT_IN_POSES[0];
340
+ const DEFAULT_SUBJECT = "a young woman in a tan coat";
341
+ const DEFAULT_ENVIRONMENT = "a sunlit modern living room";
342
+ const DEFAULT_CAMERA_POSITION = { x: 0.97, y: 1.62, z: 2.39 };
172
343
  const REST_BONES = Object.fromEntries(POSE_BONES.map((b) => [b.id, [0, 0, 0]]));
173
344
  const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × 20 frames
174
345
  const DEFAULT_PLAYBACK_SPEED = 1;
@@ -303,7 +474,7 @@ function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, c
303
474
  follow mode slaves the move to the timeline playhead, so the camera and
304
475
  the character motion are two views of the same time axis — playing or
305
476
  scrubbing frame 60 at 20 fps puts the camera exactly 3 s into its move. */
306
- function MoveRig({ playing, following, followFrame, fps, keys, anchor, camRef, look, isInterrupted, onDone }) {
477
+ function MoveRig({ playing, following, followFrame, fps, keys, shots, anchor, camRef, look, isInterrupted, onDone }) {
307
478
  const clock = useRef(0);
308
479
  const invalidate = useThree((state) => state.invalidate);
309
480
  // The follow branch only re-applies when its time changes; in demand mode
@@ -314,7 +485,7 @@ function MoveRig({ playing, following, followFrame, fps, keys, anchor, camRef, l
314
485
  // component owns no three objects, so in demand mode nothing else will.
315
486
  appliedFrame.current = null;
316
487
  if (following) invalidate();
317
- }, [keys, anchor, fps, following, invalidate]);
488
+ }, [keys, shots, anchor, fps, following, invalidate]);
318
489
  useEffect(() => {
319
490
  // A paused scrub changes the playhead without starting the render loop.
320
491
  if (following && !playing) invalidate();
@@ -323,13 +494,19 @@ function MoveRig({ playing, following, followFrame, fps, keys, anchor, camRef, l
323
494
  if (playing) clock.current = 0;
324
495
  }, [playing]);
325
496
  useFrame((_, delta) => {
326
- if (keys.length < 1) return;
497
+ if ((playing && keys.length < 1) || (!playing && following && !shots.some((shot) => shot.cameraKeys.length))) return;
327
498
  const cam = camRef.current;
328
499
  if (!cam) return;
329
- const firstFrame = keys[0].frame;
330
- const spanFrames = Math.max(keys[keys.length - 1].frame - firstFrame, 1);
500
+ const firstFrame = keys[0]?.frame ?? 0;
501
+ const spanFrames = Math.max((keys[keys.length - 1]?.frame ?? firstFrame) - firstFrame, 1);
331
502
  const apply = (frame) => {
332
- const f = cameraMoveAt(keys, anchor, frame);
503
+ // Timeline/PlayView sampling follows the editorial strips. Standalone
504
+ // preview stays scoped to the selected strip's keys. Entering a Shot
505
+ // applies its authored camera immediately (a hard cut). Leaving into a
506
+ // gap returns null and deliberately applies nothing: the last physical
507
+ // camera pose stays put and FlyControls regain ownership, so we do not
508
+ // invent a second cut to an arbitrary "free camera" preset.
509
+ const f = following ? cameraAtFrame(shots, anchor, frame) : cameraMoveAt(keys, anchor, frame);
333
510
  if (!f) return null;
334
511
  cam.position.set(f.pos.x, f.pos.y, f.pos.z);
335
512
  look.current.yaw = f.yaw;
@@ -355,7 +532,7 @@ function MoveRig({ playing, following, followFrame, fps, keys, anchor, camRef, l
355
532
  return;
356
533
  }
357
534
  if (!following) return;
358
- if (isInterrupted?.()) return; // the user is flying; yield until released
535
+ if (isInterrupted?.()) return; // manual viewport framing owns the paused camera
359
536
  if (appliedFrame.current === followFrame) return;
360
537
  appliedFrame.current = followFrame;
361
538
  apply(followFrame);
@@ -619,66 +796,41 @@ function loadWorkspaceLayout() {
619
796
  }
620
797
  }
621
798
 
622
- /**
623
- * The startup scene load (plan §8.3): a tagged result from loadScene plus the
624
- * session's durability posture. Runs once inside a lazy initializer, so the
625
- * quarantine write happens before the first render and the toast/error ride
626
- * along as initial UI state. A throwing getItem counts as absent — private
627
- * browsing must never crash the studio.
628
- */
799
+ /** Load the Scene envelope once. Legacy single-scene objects are migrated by
800
+ * scenes.js; App only supplies the familiar starter set for a truly new room. */
629
801
  function loadSceneStartup() {
630
- let raw = null;
631
- try {
632
- raw = localStorage.getItem(SCENE_STORAGE_KEY);
633
- } catch {
634
- raw = null;
635
- }
636
- // DEFAULT_SCENE_OBJECTS stays the fallback, never a seed over storage
637
- // (plan §8.5); the clone keeps the record footprint copies independent.
638
802
  const defaults = () => DEFAULT_SCENE_OBJECTS.map((object) => ({ ...object, footprint: { ...object.footprint } }));
639
- const result = loadScene(raw);
640
- if (result.status === "valid") {
641
- return {
642
- objects: result.objects,
643
- saveBlocked: false,
644
- error: null,
645
- toast: result.dropped > 0 ? `${result.dropped} saved object(s) could not be restored` : null,
646
- };
647
- }
648
- if (result.status === "future") {
649
- // A newer build wrote this scene. Overwriting it with our older schema
650
- // would destroy newer data, so this session never writes the primary key.
651
- return {
652
- objects: defaults(),
653
- saveBlocked: true,
654
- error: null,
655
- toast: "Saved scene was written by a newer CozyClay — it has been left untouched and this session will not save",
656
- };
657
- }
658
- if (result.status === "corrupt") {
659
- try {
660
- // Keep the unreadable bytes under the quarantine key before the
661
- // first save overwrites the primary.
662
- localStorage.setItem(SCENE_QUARANTINE_KEY, raw);
663
- return {
664
- objects: defaults(),
665
- saveBlocked: false,
666
- error: null,
667
- toast: "Saved scene was unreadable — starting empty; the old data is kept under cozyclay.scene.v1.quarantine",
668
- };
669
- } catch {
670
- // If the backup write fails, overwriting the corrupt primary would
671
- // lose the only copy of the data — saving stays blocked instead.
803
+ try {
804
+ const result = loadSceneDocumentFromStorage(localStorage);
805
+ if (result.status === "future") {
806
+ const document = createSceneDocument();
807
+ document.scenes[0].objects = defaults();
672
808
  return {
673
- objects: defaults(),
809
+ document,
674
810
  saveBlocked: true,
675
- error: "Saved scene was unreadable and could not be backed up — this session will not save",
676
- toast: null,
811
+ error: null,
812
+ toast: ko("Saved scenes were written by a newer CozyClay — they have been left untouched and this session will not save", "저장된 장면은 더 최신 CozyClay에서 만들어졌어요. 이 세션에서는 건드리지 않고 저장도 하지 않습니다."),
677
813
  };
678
814
  }
815
+ const document = result.document;
816
+ if ((result.status === "absent" || result.status === "corrupt") && document.scenes[0].objects.length === 0) {
817
+ document.scenes[0].objects = defaults();
818
+ }
819
+ return {
820
+ document,
821
+ saveBlocked: false,
822
+ error: null,
823
+ toast: result.status === "corrupt"
824
+ ? ko(`Saved scenes were unreadable — starting fresh; the old data is kept under ${SCENES_QUARANTINE_KEY}`, `저장된 장면을 읽을 수 없어 새로 시작합니다. 기존 데이터는 ${SCENES_QUARANTINE_KEY}에 보관했어요.`)
825
+ : result.dropped > 0
826
+ ? (isKo ? `저장된 장면 ${result.dropped}개를 복원하지 못했어요` : `${result.dropped} saved scene(s) could not be restored`)
827
+ : null,
828
+ };
829
+ } catch {
830
+ const document = createSceneDocument();
831
+ document.scenes[0].objects = defaults();
832
+ return { document, saveBlocked: false, error: null, toast: null };
679
833
  }
680
- // absent: nothing saved, or a read that threw — either way a fresh scene.
681
- return { objects: defaults(), saveBlocked: false, error: null, toast: null };
682
834
  }
683
835
 
684
836
  export default function App() {
@@ -696,6 +848,11 @@ globalThis.playMode = centerTab === "play";
696
848
  // PlayView is the player for the finished motion: entering starts playback,
697
849
  // leaving pauses it. Scene stays the manipulation surface.
698
850
  const [tlPlaying, setTlPlaying] = useState(false);
851
+ const cameraPreviewEndRef = useRef(null);
852
+ // Once the operator touches the viewport, the physical camera stays in
853
+ // their hands. Follow/Rail only take it back through an explicit Preview or
854
+ // timeline Play, avoiding the snap-back that used to happen on pointer-up.
855
+ const manualCameraOverrideRef = useRef(false);
699
856
  useEffect(() => {
700
857
  // The player always starts the finished piece from frame 0; auto-play
701
858
  // only exists once there is a motion to play.
@@ -899,18 +1056,21 @@ globalThis.playMode = centerTab === "play";
899
1056
  window.addEventListener("pointerup", onUp);
900
1057
  window.addEventListener("pointercancel", onUp);
901
1058
  }
902
- const [showB, setShowB] = useState(false);
903
- const [charA, setCharA] = useState({ x: 0, z: 0, rot: 0 });
904
- const [charB, setCharB] = useState({ x: 1.15, z: 0.1, rot: -14 });
905
- const [poseA, setPoseA] = useState(DEFAULT_POSE);
906
- const [poseB, setPoseB] = useState(DEFAULT_POSE);
1059
+ const [startup] = useState(loadSceneStartup);
1060
+ const startupScene = startup.document.scenes[activeSceneIndex(startup.document.scenes, startup.document.activeSceneId)];
1061
+ const startupStage = createSceneStage(startupScene.stage);
1062
+ const [showB, setShowB] = useState(startupStage.showB);
1063
+ const [charA, setCharA] = useState(startupStage.charA);
1064
+ const [charB, setCharB] = useState(startupStage.charB);
1065
+ const [poseA, setPoseA] = useState(startupStage.poseA ?? DEFAULT_POSE);
1066
+ const [poseB, setPoseB] = useState(startupStage.poseB ?? DEFAULT_POSE);
907
1067
  const [customPoses, setCustomPoses] = useState(() => loadCustomPoses());
908
1068
  const [posing, setPosing] = useState(null);
909
1069
  const [posingClosing, setPosingClosing] = useState(false);
910
1070
  const [studioPick, setStudioPick] = useState(null);
911
1071
  const [rigA, setRigA] = useState(null);
912
1072
  const [rigB, setRigB] = useState(null);
913
- const [, setPoseTick] = useState(0);
1073
+ const [poseRevision, setPoseTick] = useState(0);
914
1074
 
915
1075
  /* ------------------------------ IK layer ------------------------------ */
916
1076
  // IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
@@ -924,13 +1084,19 @@ globalThis.playMode = centerTab === "play";
924
1084
  const [ikChains, setIkChains] = useState(null);
925
1085
  const [ikFkJoints, setIkFkJoints] = useState(null);
926
1086
  const [ikFocus, setIkFocus] = useState(null);
927
- const [selectedHierarchyId, setSelectedHierarchyId] = useState("characterA.motion");
1087
+ const [selectedHierarchyId, setSelectedHierarchyId] = useState("characterA");
1088
+ // Right-sidebar tab. "inspector" shows the selection's properties; "shot"
1089
+ // holds shot-global settings (type presets, prompt); "motion" holds the
1090
+ // ARDY workflow in pipeline order. Selecting anything in the scene routes
1091
+ // to the inspector tab; the root SHOT row routes to the shot tab.
1092
+ const [sidebarTab, setSidebarTab] = useState("motion");
928
1093
  // Scene persistence (plan §8): the startup load runs once in a lazy
929
1094
  // initializer so the store below can seed from the restored scene; the
930
1095
  // quarantine write and the save-block decision happen before the first
931
1096
  // render, and the toast/error they produce ride along as initial UI state.
932
- const [startup] = useState(loadSceneStartup);
933
- const [sceneObjects, setSceneObjects] = useState(startup.objects);
1097
+ const [scenes, setScenes] = useState(startup.document.scenes);
1098
+ const [activeSceneId, setActiveSceneId] = useState(startup.document.activeSceneId);
1099
+ const [sceneObjects, setSceneObjects] = useState(startupScene.objects);
934
1100
  const [sceneSaveError, setSceneSaveError] = useState(startup.error);
935
1101
  const saveBlockedRef = useRef(startup.saveBlocked);
936
1102
  const dirtyRef = useRef(false);
@@ -947,56 +1113,6 @@ globalThis.playMode = centerTab === "play";
947
1113
  storeRef.current = createSceneHistoryStore(sceneObjects, { onObjects: setSceneObjects });
948
1114
  }
949
1115
  const store = storeRef.current;
950
-
951
- // ---- scene persistence (plan §8.4) ----
952
- // Every write serialises the store at invocation time — the LIVE read,
953
- // never a value captured by the caller. A stale closure would flush
954
- // startup state over the latest edit, so flushScene closes over refs only.
955
- function flushScene() {
956
- if (saveBlockedRef.current || !dirtyRef.current) return;
957
- try {
958
- localStorage.setItem(SCENE_STORAGE_KEY, serializeScene(storeRef.current.objects));
959
- dirtyRef.current = false;
960
- setSceneSaveError(null);
961
- // The next successful write clears the whole failure surface (plan
962
- // §8.4) without clobbering an unrelated toast that replaced it.
963
- if (saveFailureToastRef.current) {
964
- saveFailureToastRef.current = false;
965
- setToast("");
966
- }
967
- } catch (err) {
968
- const message = `Scene not saved: ${err?.name || "StorageError"}`;
969
- setSceneSaveError(message);
970
- if (!saveFailureToastRef.current) {
971
- saveFailureToastRef.current = true;
972
- setToast(message);
973
- }
974
- }
975
- }
976
- // Debounced write: the cleanup clears the timer only — clearing IS the
977
- // debounce; flushing there would write on every tick.
978
- useEffect(() => {
979
- dirtyRef.current = true;
980
- const timer = setTimeout(flushScene, 400);
981
- return () => clearTimeout(timer);
982
- }, [sceneObjects]);
983
- // Lifecycle flush: pagehide covers navigation/tab close, visibilitychange
984
- // covers backgrounding, the effect cleanup covers unmount. `beforeunload`
985
- // is deliberately not used — it defeats bfcache. The [] deps capture only
986
- // refs and flushScene, so this closure can never hold a stale scene.
987
- useEffect(() => {
988
- const onPageHide = () => flushScene();
989
- const onVisibility = () => {
990
- if (document.visibilityState === "hidden") flushScene();
991
- };
992
- window.addEventListener("pagehide", onPageHide);
993
- document.addEventListener("visibilitychange", onVisibility);
994
- return () => {
995
- window.removeEventListener("pagehide", onPageHide);
996
- document.removeEventListener("visibilitychange", onVisibility);
997
- flushScene(); // unmount: the last chance to persist a dirty scene
998
- };
999
- }, []);
1000
1116
  const selectedSceneObjectId = sceneObjectIdFromHierarchy(selectedHierarchyId);
1001
1117
  const selectedSceneObject = sceneObjects.find((object) => object.id === selectedSceneObjectId) ?? null;
1002
1118
  // Foot snap (ground plant): while ON, body (hips) drags keep the feet at
@@ -1026,6 +1142,9 @@ globalThis.playMode = centerTab === "play";
1026
1142
  // applied tick re-renders, every drag would die after exactly one tick.
1027
1143
  store.settle();
1028
1144
  setSelectedHierarchyId(id);
1145
+ // The root SHOT row is the one non-object row kept as a bridge to the
1146
+ // shot-global settings; everything else is a scene entity.
1147
+ setSidebarTab(id === "shot" ? "shot" : "inspector");
1029
1148
  const focus = RIG_HIERARCHY_FOCUS[id];
1030
1149
  if (focus && ikMode) setIkFocus(focus);
1031
1150
  }
@@ -1043,7 +1162,10 @@ globalThis.playMode = centerTab === "play";
1043
1162
  function focusIkHandle(focus) {
1044
1163
  setIkFocus(focus);
1045
1164
  const hierarchyId = hierarchyIdForIkFocus(focus);
1046
- if (hierarchyId) setSelectedHierarchyId(hierarchyId);
1165
+ if (hierarchyId) {
1166
+ setSelectedHierarchyId(hierarchyId);
1167
+ setSidebarTab("inspector");
1168
+ }
1047
1169
  }
1048
1170
 
1049
1171
  // App's single scene-object mutation entry (plan §6.1). A token means a
@@ -1082,11 +1204,11 @@ globalThis.playMode = centerTab === "play";
1082
1204
  if (!object) return;
1083
1205
  const patch = dropToSurfacePatch(object, sceneObjects.filter((item) => item.id !== object.id));
1084
1206
  if (patch === null) {
1085
- setToast("Nothing to drop");
1207
+ setToast(ko("Nothing to drop", "내려놓을 대상이 없어요"));
1086
1208
  return;
1087
1209
  }
1088
1210
  changeSceneObject(object.id, patch);
1089
- setToast(`${object.name} dropped to surface`);
1211
+ setToast(isKo ? `${sceneObjectNameDisplayKo(object.name)}을 표면 위에 내려놓았어요` : `${object.name} dropped to surface`);
1090
1212
  }
1091
1213
 
1092
1214
  const [gizmoMode, setGizmoMode] = useState("move");
@@ -1107,12 +1229,13 @@ globalThis.playMode = centerTab === "play";
1107
1229
  if (!object) return;
1108
1230
  store.applyAtomic((objects) => [...objects, object]);
1109
1231
  setSelectedHierarchyId(`object:${object.id}`);
1232
+ setSidebarTab("inspector");
1110
1233
  // Deliberate divergence from Unity's rename-on-create: creating an object
1111
1234
  // here is followed by placing it, and dropping focus into a text field
1112
1235
  // swallows the very next W/E/R. Renaming stays on F2/Return and the row's
1113
1236
  // context menu. (docs/unity-reference.md §9.7)
1114
1237
  setGizmoMode("move");
1115
- setToast(`${object.name} added — W move, E rotate, R scale`);
1238
+ setToast(isKo ? `${sceneObjectNameDisplayKo(object.name)} 추가됨 — W 이동, E 회전, R 크기` : `${object.name} added — W move, E rotate, R scale`);
1116
1239
  }
1117
1240
 
1118
1241
  function duplicateSelectedSceneObject(id = selectedSceneObjectId) {
@@ -1132,7 +1255,8 @@ globalThis.playMode = centerTab === "play";
1132
1255
  const placed = { ...object, id: copy.id, name: copy.name, x: object.x + 0.5 };
1133
1256
  store.applyAtomic((objects) => [...objects, placed]);
1134
1257
  setSelectedHierarchyId(`object:${placed.id}`);
1135
- setToast(`${placed.name} duplicated`);
1258
+ setSidebarTab("inspector");
1259
+ setToast(isKo ? `${sceneObjectNameDisplayKo(placed.name)} 복제됨` : `${placed.name} duplicated`);
1136
1260
  }
1137
1261
 
1138
1262
  /** Frame the selection: fly the shot camera to a comfortable distance along
@@ -1170,25 +1294,25 @@ globalThis.playMode = centerTab === "play";
1170
1294
  function undoScene() {
1171
1295
  const restored = store.undo();
1172
1296
  if (restored === null) {
1173
- setToast("Nothing to undo");
1297
+ setToast(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
1174
1298
  return;
1175
1299
  }
1176
1300
  if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
1177
1301
  setSelectedHierarchyId("props");
1178
1302
  }
1179
- setToast("Undone");
1303
+ setToast(ko("Undone", "실행 취소됨"));
1180
1304
  }
1181
1305
 
1182
1306
  function redoScene() {
1183
1307
  const restored = store.redo();
1184
1308
  if (restored === null) {
1185
- setToast("Nothing to redo");
1309
+ setToast(ko("Nothing to redo", "다시 실행할 작업이 없어요"));
1186
1310
  return;
1187
1311
  }
1188
1312
  if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
1189
1313
  setSelectedHierarchyId("props");
1190
1314
  }
1191
- setToast("Redone");
1315
+ setToast(ko("Redone", "다시 실행됨"));
1192
1316
  }
1193
1317
 
1194
1318
  useEffect(() => {
@@ -1249,49 +1373,62 @@ globalThis.playMode = centerTab === "play";
1249
1373
  const [mode, setMode] = useState("image");
1250
1374
  const [imageModel, setImageModel] = useState("gpt_image_2");
1251
1375
  const [videoModel, setVideoModel] = useState("seedance_2");
1376
+ const generation = useGeneration();
1252
1377
  const [cameraMove, setCameraMove] = useState(CAMERA_MOVES[1]);
1253
1378
  const [customMove, setCustomMove] = useState("");
1254
1379
  // Authored shot state (camera keys, waypoints, clip length) restored from
1255
1380
  // the last session — camera moves must survive a reload like the scene does.
1256
1381
  const [shotStartup] = useState(() => {
1257
1382
  try {
1258
- return loadShotAuthoring(localStorage.getItem(SHOT_AUTHORING_KEY));
1383
+ const currentRaw = localStorage.getItem(SHOT_AUTHORING_KEY);
1384
+ let sourceKey = SHOT_AUTHORING_KEY;
1385
+ let raw = currentRaw;
1386
+ let loaded = readShotAuthoring(raw);
1387
+ for (const legacyKey of SHOT_AUTHORING_LEGACY_KEYS ?? [SHOT_AUTHORING_LEGACY_KEY]) {
1388
+ if (loaded.status !== "absent") break;
1389
+ sourceKey = legacyKey;
1390
+ raw = localStorage.getItem(sourceKey);
1391
+ loaded = readShotAuthoring(raw);
1392
+ }
1393
+ if (loaded.status === "corrupt") {
1394
+ // Preserve the unreadable roll byte-for-byte before a fresh v3 save.
1395
+ localStorage.setItem(SHOT_AUTHORING_QUARANTINE_KEY, raw);
1396
+ localStorage.removeItem(sourceKey);
1397
+ return { state: null, saveBlocked: false };
1398
+ }
1399
+ // A future body belongs to a future build. Do not replace it merely
1400
+ // because this build cannot project it onto today's controls.
1401
+ if (loaded.status === "future") return { state: null, saveBlocked: true };
1402
+ return { state: loaded.state, saveBlocked: false };
1259
1403
  } catch {
1260
- return null;
1404
+ return { state: null, saveBlocked: false };
1261
1405
  }
1262
1406
  });
1263
- // The camera move workspace: frame-unique keyframings authored on the
1264
- // timeline's Camera lane. The move's name is never stored — it is
1265
- // re-derived from the framings segment by segment.
1266
- const [cameraKeys, setCameraKeys] = useState(shotStartup?.cameraKeys ?? []); // [{ frame, framing }] sorted by frame
1407
+ const nestedShotStartup = readShotAuthoringDocument(startupScene.shotDocument ?? undefined);
1408
+ // The nested Scene document wins. The root v3 key remains a migration
1409
+ // fallback for users arriving from the single-scene build.
1410
+ const startupShotState = nestedShotStartup.state ?? shotStartup.state;
1411
+ // Each editorial strip owns its camera keys. The playhead chooses the
1412
+ // active strip; there is no shared key list that could blend through a cut.
1413
+ const [shots, setShots] = useState(() => startupShotState?.shots ?? initialShots(startupShotState?.frameCount ?? DEFAULT_DURATION_S * 20));
1267
1414
  const [movePlaying, setMovePlaying] = useState(false);
1268
1415
  // Follow slaves the move to the timeline playhead so camera and character
1269
1416
  // motion share one time axis; off frees the camera while both stay set.
1270
1417
  const [moveFollow, setMoveFollow] = useState(true);
1271
- // Follow cam: the camera is DERIVED from the subject's trajectory (a
1272
- // steadicam behind it, or a dolly on a drawn Top-View rail) instead of
1273
- // interpolated between keys. Parameters and the rail survive reloads.
1274
- const [followCam, setFollowCam] = useState(() => ({
1275
- enabled: false,
1276
- distance: FOLLOW_DEFAULTS.distance,
1277
- height: FOLLOW_DEFAULTS.height,
1278
- response: FOLLOW_DEFAULTS.response,
1279
- lead: FOLLOW_DEFAULTS.lead,
1280
- ...(shotStartup?.followCam ?? {}),
1281
- }));
1282
- const [cameraRail, setCameraRail] = useState(shotStartup?.cameraRail ?? null); // simplified control points [{x,z}]
1283
1418
  const [railDraw, setRailDraw] = useState(false);
1284
- const [hasCharSheet, setHasCharSheet] = useState(false);
1419
+ const [hasCharSheet, setHasCharSheet] = useState(startupStage.hasCharSheet);
1285
1420
  const [hasEnvSheet, setHasEnvSheet] = useState(false);
1286
- const [subject, setSubject] = useState("a young woman in a tan coat");
1287
- const [subject2, setSubject2] = useState("a man in a dark coat");
1288
- const [environment, setEnvironment] = useState("a sunlit modern living room");
1421
+ const [subject, setSubject] = useState(startupStage.subject ?? DEFAULT_SUBJECT);
1422
+ const [subject2, setSubject2] = useState(startupStage.subject2 ?? "a man in a dark coat");
1423
+ const [environment, setEnvironment] = useState(DEFAULT_ENVIRONMENT);
1289
1424
  const [style, setStyle] = useState("moody cinematic lighting, 35mm film look");
1290
1425
 
1291
- const [cameraPos, setCameraPos] = useState({ x: 0.97, y: 1.62, z: 2.39 });
1426
+ const [cameraPos, setCameraPos] = useState(DEFAULT_CAMERA_POSITION);
1292
1427
  const [subjectVisible, setSubjectVisible] = useState(true);
1293
1428
  const [result, setResult] = useState(null);
1429
+ const [resultOpen, setResultOpen] = useState(false);
1294
1430
  const [copied, setCopied] = useState(false);
1431
+ const [recordedVideoName, setRecordedVideoName] = useState(null);
1295
1432
  const [toast, setToast] = useState(startup.toast ?? "");
1296
1433
  const [bridge, setBridge] = useState(null);
1297
1434
  const [ardyPrompt, setArdyPrompt] = useState("");
@@ -1321,30 +1458,290 @@ globalThis.playMode = centerTab === "play";
1321
1458
  const [tlFrame, setTlFrame] = useState(0);
1322
1459
 
1323
1460
  const renderActive = useRenderActivity(tlPlaying || movePlaying);
1324
- const [tlFrameCount, setTlFrameCount] = useState(shotStartup?.frameCount ?? DEFAULT_DURATION_S * 20); // the generation clip length @ 20 fps
1461
+ const [tlFrameCount, setTlFrameCount] = useState(startupShotState?.frameCount ?? DEFAULT_DURATION_S * 20); // the generation clip length @ 20 fps
1325
1462
  const [tlFps, setTlFps] = useState(20);
1463
+ const activeShotIdx = shotIndexAtFrame(shots, tlFrame);
1464
+ const activeShot = shots[activeShotIdx] ?? null;
1465
+ const cameraKeys = activeShot?.cameraKeys ?? [];
1466
+ const activeCamera = createCameraBlock(activeShot?.camera);
1467
+ const followCam = activeCamera.followCam;
1468
+ const cameraRail = activeCamera.cameraRail;
1469
+ const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
1470
+ const hasCameraKeys = shots.some((shot) => shot.cameraKeys.length > 0);
1471
+ function changeActiveCamera(patch, index = activeShotIdx) {
1472
+ setShots((current) => current.map((shot, shotIndex) => shotIndex === index
1473
+ ? { ...shot, camera: updateCameraBlock(shot.camera, patch) }
1474
+ : shot));
1475
+ }
1476
+ function syncActiveCameraFraming() {
1477
+ const cam = shotCamRef.current;
1478
+ if (!cam || !activeShot || ikMode || playMode) return;
1479
+ const subjectPosition = motionPos ?? charA;
1480
+ const measured = followFramingFromCamera(cam.position, look.current.pitch, subjectPosition, followCam.aimHeight);
1481
+ setShots((current) => current.map((shot, shotIndex) => {
1482
+ if (shotIndex !== activeShotIdx) return shot;
1483
+ const camera = createCameraBlock(shot.camera);
1484
+ const previous = camera.followCam;
1485
+ if (
1486
+ previous.distance === measured.distance &&
1487
+ previous.height === measured.height &&
1488
+ previous.pitchOffsetDeg === measured.pitchOffsetDeg
1489
+ ) return shot;
1490
+ return { ...shot, camera: updateCameraBlock(camera, { followCam: { ...previous, ...measured } }) };
1491
+ }));
1492
+ }
1493
+ function commitManualCameraFraming() {
1494
+ if (ikMode || playMode) return;
1495
+ manualCameraOverrideRef.current = true;
1496
+ syncActiveCameraFraming();
1497
+ }
1498
+ function changeCameraRail(points) {
1499
+ changeActiveCamera({
1500
+ cameraRail: points,
1501
+ railFollow: points ? (activeCamera.railFollow ?? defaultRailRange(activeShotDuration)) : null,
1502
+ mode: points ? "rail" : activeCamera.mode === "rail" ? "follow" : activeCamera.mode,
1503
+ });
1504
+ }
1505
+ function toggleCameraRailDraw() {
1506
+ if (!activeShot || waypointMode) return;
1507
+ // The viewport is the framing control. Capture it before Rail takes over
1508
+ // the camera so the dolly opens at the distance, height and tilt the
1509
+ // operator is actually looking through.
1510
+ syncActiveCameraFraming();
1511
+ if (activeCamera.mode !== "rail") {
1512
+ changeActiveCamera({
1513
+ mode: "rail",
1514
+ railFollow: activeCamera.railFollow?.mode === "off" ? defaultRailRange(activeShotDuration) : activeCamera.railFollow,
1515
+ });
1516
+ }
1517
+ const next = !railDraw;
1518
+ setRailDraw(next);
1519
+ if (next) {
1520
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
1521
+ setToast(ko("Draw the selected Shot's rail in the Top-View", "탑뷰에서 선택한 샷의 레일을 그리세요"));
1522
+ }
1523
+ }
1524
+ function previewCameraShot(index) {
1525
+ const selected = shots[index];
1526
+ if (!selected || waypointMode) return;
1527
+ if (tlPlaying && cameraPreviewEndRef.current === selected.endFrame) {
1528
+ cameraPreviewEndRef.current = null;
1529
+ setTlPlaying(false);
1530
+ return;
1531
+ }
1532
+ setMovePlaying(false);
1533
+ manualCameraOverrideRef.current = false;
1534
+ cameraPreviewEndRef.current = selected.endFrame;
1535
+ setTlFrame(selected.startFrame);
1536
+ setTlPlaying(true);
1537
+ }
1538
+ function editRailSchedule(index, edit) {
1539
+ setShots((current) => current.map((shot, shotIndex) => {
1540
+ if (shotIndex !== index) return shot;
1541
+ const camera = createCameraBlock(shot.camera);
1542
+ const duration = shot.endFrame - shot.startFrame + 1;
1543
+ const resolved = resolveRailSchedule({ railFollow: camera.railFollow, cameraRail: camera.cameraRail, frameCount: duration });
1544
+ const base = resolved.kind === RAIL_SCHEDULE_RANGE || resolved.kind === RAIL_SCHEDULE_LEGACY
1545
+ ? { startFrame: resolved.startFrame, endFrame: resolved.endFrame }
1546
+ : defaultRailRange(duration);
1547
+ const railFollow = base ? edit(base, duration) : null;
1548
+ return railFollow ? { ...shot, camera: updateCameraBlock(camera, { railFollow: { mode: "range", ...railFollow } }) } : shot;
1549
+ }));
1550
+ }
1326
1551
  const frameCountRef = useRef(DEFAULT_DURATION_S * 20);
1327
1552
  frameCountRef.current = tlFrameCount;
1328
1553
  // Root waypoints {frame, x, z, heading: null}, kept sorted by frame —
1329
1554
  // the fixed bridge contract rejects out-of-order or duplicate frames.
1330
1555
  const [waypointMode, setWaypointMode] = useState(false);
1331
- const [waypoints, setWaypoints] = useState(shotStartup?.waypoints ?? []);
1556
+ const [waypoints, setWaypoints] = useState(startupShotState?.waypoints ?? []);
1332
1557
  const [activeWaypointFrame, setActiveWaypointFrame] = useState(null);
1558
+ const [pendingWaypointFrame, setPendingWaypointFrame] = useState(null);
1333
1559
  useEffect(() => {
1334
1560
  // Subject 1 is the sole frame-zero root start. Drop any legacy seeded
1335
1561
  // waypoint so Top-View never renders two start markers.
1336
1562
  setWaypoints((current) => current.filter((waypoint) => waypoint.frame !== 0));
1337
1563
  setActiveWaypointFrame((current) => (current === 0 ? null : current));
1564
+ setPendingWaypointFrame((current) => (current === 0 ? null : current));
1338
1565
  }, []);
1339
- // Every authoring change lands in storage immediately: reloads (and dev
1340
- // hot updates) must never cost an authored shot again.
1566
+
1567
+ // Shot trims also trim their local Rail Follow card once. Growing a shot
1568
+ // later never resurrects time that the editor already cut away.
1341
1569
  useEffect(() => {
1570
+ setShots((current) => {
1571
+ let changed = false;
1572
+ const next = current.map((shot, index) => {
1573
+ const railFollow = shot.camera?.railFollow;
1574
+ if (railFollow?.mode !== "range") return shot;
1575
+ const duration = shot.endFrame - shot.startFrame + 1;
1576
+ const clamped = clampRailRange(railFollow, duration);
1577
+ if (!clamped || (clamped.startFrame === railFollow.startFrame && clamped.endFrame === railFollow.endFrame)) return shot;
1578
+ changed = true;
1579
+ return { ...shot, camera: updateCameraBlock(shot.camera, { railFollow: { mode: "range", ...clamped } }) };
1580
+ });
1581
+ return changed ? next : current;
1582
+ });
1583
+ }, [tlFrameCount, shots.map((shot) => shot.startFrame).join(":")]);
1584
+
1585
+ /* --------------------------- Scene documents --------------------------- */
1586
+ // Refs make a Scene switch synchronous: the outgoing room is sealed with
1587
+ // its latest objects and camera department envelope before React opens the
1588
+ // destination room.
1589
+ const scenesRef = useRef(scenes);
1590
+ const activeSceneIdRef = useRef(activeSceneId);
1591
+ const shotDocumentRef = useRef(null);
1592
+ const actorStageRef = useRef(null);
1593
+ scenesRef.current = scenes;
1594
+ activeSceneIdRef.current = activeSceneId;
1595
+ shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
1596
+ actorStageRef.current = { charA, charB, showB, poseA, poseB, hasCharSheet, subject, subject2 };
1597
+
1598
+ function snapshotActiveScene(sourceScenes = scenesRef.current) {
1599
+ return sourceScenes.map((scene) => scene.id === activeSceneIdRef.current
1600
+ ? { ...scene, objects: storeRef.current.objects, shotDocument: shotDocumentRef.current, stage: actorStageRef.current }
1601
+ : scene);
1602
+ }
1603
+
1604
+ function persistScenes(nextScenes, nextActiveSceneId) {
1605
+ if (saveBlockedRef.current) return false;
1342
1606
  try {
1343
- localStorage.setItem(SHOT_AUTHORING_KEY, serializeShotAuthoring({ cameraKeys, waypoints, frameCount: tlFrameCount, followCam, cameraRail }));
1344
- } catch {
1345
- // quota or blocked storage: authoring simply won't survive a reload
1607
+ localStorage.setItem(SCENES_STORAGE_KEY, serializeSceneDocument({
1608
+ version: SCENES_VERSION,
1609
+ activeSceneId: nextActiveSceneId,
1610
+ scenes: nextScenes,
1611
+ }));
1612
+ dirtyRef.current = false;
1613
+ setSceneSaveError(null);
1614
+ if (saveFailureToastRef.current) {
1615
+ saveFailureToastRef.current = false;
1616
+ setToast("");
1617
+ }
1618
+ return true;
1619
+ } catch (err) {
1620
+ const message = `Scenes not saved: ${err?.name || "StorageError"}`;
1621
+ setSceneSaveError(message);
1622
+ if (!saveFailureToastRef.current) {
1623
+ saveFailureToastRef.current = true;
1624
+ setToast(message);
1625
+ }
1626
+ return false;
1627
+ }
1628
+ }
1629
+
1630
+ function flushScenes() {
1631
+ if (!dirtyRef.current) return;
1632
+ persistScenes(snapshotActiveScene(), activeSceneIdRef.current);
1633
+ }
1634
+
1635
+ function restoredShotState(scene) {
1636
+ const restored = readShotAuthoringDocument(scene?.shotDocument ?? undefined);
1637
+ if (restored.state) return restored.state;
1638
+ const frameCount = DEFAULT_DURATION_S * 20;
1639
+ return { shots: initialShots(frameCount), waypoints: [], frameCount };
1640
+ }
1641
+
1642
+ function openScene(scene, nextScenes) {
1643
+ const shotState = restoredShotState(scene);
1644
+ const stage = createSceneStage(scene.stage);
1645
+ const objects = Array.isArray(scene.objects) ? scene.objects : [];
1646
+ storeRef.current = createSceneHistoryStore(objects, { onObjects: setSceneObjects });
1647
+ setSceneObjects(objects);
1648
+ setShots(shotState.shots);
1649
+ setWaypoints(shotState.waypoints);
1650
+ setTlFrameCount(shotState.frameCount ?? DEFAULT_DURATION_S * 20);
1651
+ setCharA(stage.charA);
1652
+ setCharB(stage.charB);
1653
+ setShowB(stage.showB);
1654
+ setPoseA(stage.poseA ?? DEFAULT_POSE);
1655
+ setPoseB(stage.poseB ?? DEFAULT_POSE);
1656
+ setHasCharSheet(stage.hasCharSheet);
1657
+ setSubject(stage.subject ?? DEFAULT_SUBJECT);
1658
+ setSubject2(stage.subject2 ?? "a man in a dark coat");
1659
+ setTlFrame(0);
1660
+ setMovePlaying(false);
1661
+ manualCameraOverrideRef.current = false;
1662
+ setRailDraw(false);
1663
+ setActiveWaypointFrame(null);
1664
+ setPendingWaypointFrame(null);
1665
+ setSelectedHierarchyId("shot");
1666
+ scenesRef.current = nextScenes;
1667
+ activeSceneIdRef.current = scene.id;
1668
+ setScenes(nextScenes);
1669
+ setActiveSceneId(scene.id);
1670
+ }
1671
+
1672
+ function selectSceneDocument(sceneId) {
1673
+ if (sceneId === activeSceneIdRef.current) return;
1674
+ const savedScenes = snapshotActiveScene();
1675
+ const target = savedScenes.find((scene) => scene.id === sceneId);
1676
+ if (!target) return;
1677
+ persistScenes(savedScenes, sceneId);
1678
+ openScene(target, savedScenes);
1679
+ }
1680
+
1681
+ function createSceneDocumentFromUi() {
1682
+ const savedScenes = snapshotActiveScene();
1683
+ const nextScenes = addScene(savedScenes);
1684
+ const target = nextScenes[nextScenes.length - 1];
1685
+ persistScenes(nextScenes, target.id);
1686
+ openScene(target, nextScenes);
1687
+ }
1688
+
1689
+ function duplicateSceneDocumentFromUi(sceneId) {
1690
+ const savedScenes = snapshotActiveScene();
1691
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
1692
+ if (index < 0) return;
1693
+ const nextScenes = duplicateScene(savedScenes, index);
1694
+ const target = nextScenes[index + 1];
1695
+ persistScenes(nextScenes, target.id);
1696
+ openScene(target, nextScenes);
1697
+ }
1698
+
1699
+ function renameSceneDocumentFromUi(sceneId, name) {
1700
+ const savedScenes = snapshotActiveScene();
1701
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
1702
+ if (index < 0) return;
1703
+ const nextScenes = renameScene(savedScenes, index, name);
1704
+ scenesRef.current = nextScenes;
1705
+ setScenes(nextScenes);
1706
+ persistScenes(nextScenes, activeSceneIdRef.current);
1707
+ }
1708
+
1709
+ function deleteSceneDocumentFromUi(sceneId) {
1710
+ const savedScenes = snapshotActiveScene();
1711
+ const index = savedScenes.findIndex((scene) => scene.id === sceneId);
1712
+ if (index < 0 || savedScenes.length <= 1) return;
1713
+ const nextScenes = removeScene(savedScenes, index);
1714
+ if (sceneId !== activeSceneIdRef.current) {
1715
+ scenesRef.current = nextScenes;
1716
+ setScenes(nextScenes);
1717
+ persistScenes(nextScenes, activeSceneIdRef.current);
1718
+ return;
1346
1719
  }
1347
- }, [cameraKeys, waypoints, tlFrameCount, followCam, cameraRail]);
1720
+ const target = nextScenes[Math.min(index, nextScenes.length - 1)];
1721
+ persistScenes(nextScenes, target.id);
1722
+ openScene(target, nextScenes);
1723
+ }
1724
+
1725
+ // One debounced Scene-document save owns both departments. Switching calls
1726
+ // persistScenes directly, so no outgoing edit can be overtaken by a render.
1727
+ useEffect(() => {
1728
+ dirtyRef.current = true;
1729
+ const timer = setTimeout(flushScenes, 400);
1730
+ return () => clearTimeout(timer);
1731
+ }, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, subject, subject2, scenes, activeSceneId]);
1732
+ useEffect(() => {
1733
+ const onPageHide = () => flushScenes();
1734
+ const onVisibility = () => {
1735
+ if (document.visibilityState === "hidden") flushScenes();
1736
+ };
1737
+ window.addEventListener("pagehide", onPageHide);
1738
+ document.addEventListener("visibilitychange", onVisibility);
1739
+ return () => {
1740
+ window.removeEventListener("pagehide", onPageHide);
1741
+ document.removeEventListener("visibilitychange", onVisibility);
1742
+ flushScenes();
1743
+ };
1744
+ }, []);
1348
1745
  const [promptClips, setPromptClips] = useState(() => DEFAULT_PROMPT_CLIPS.map((clip) => ({ ...clip })));
1349
1746
  const [selectedPromptId, setSelectedPromptId] = useState(null);
1350
1747
  // Loaded motion: decoded arrays plus the world anchor captured at load.
@@ -1384,6 +1781,7 @@ globalThis.playMode = centerTab === "play";
1384
1781
  return {
1385
1782
  segs,
1386
1783
  slate: moveSequenceSlate(segs),
1784
+ displaySlate: moveSequenceSlateKo(segs),
1387
1785
  phrase: moveSequencePhrase(segs),
1388
1786
  fromShot: segs[0].from,
1389
1787
  spanS: Math.round(((cameraKeys[cameraKeys.length - 1].frame - cameraKeys[0].frame) / tlFps) * 10) / 10,
@@ -1392,7 +1790,7 @@ globalThis.playMode = centerTab === "play";
1392
1790
  // With Follow armed, Preview means "watch the shot": it plays the timeline
1393
1791
  // from frame 0 so character motion and the camera move share one clock.
1394
1792
  // Follow off keeps the camera-only preview on its own clock.
1395
- const followPreviewArmed = moveFollow && cameraKeys.length >= 1 && !ikMode && !waypointMode && !posing;
1793
+ const followPreviewArmed = moveFollow && hasCameraKeys && !ikMode && !waypointMode && !posing;
1396
1794
  const previewActive = movePlaying || (followPreviewArmed && tlPlaying);
1397
1795
 
1398
1796
  /* --------------------------- shot video export --------------------------- */
@@ -1410,19 +1808,35 @@ globalThis.playMode = centerTab === "play";
1410
1808
  tlPlayingRef.current = tlPlaying;
1411
1809
  const playModeRef = useRef(false);
1412
1810
  playModeRef.current = centerTab === "play";
1811
+ const tlFrameCountRef2 = useRef(tlFrameCount);
1812
+ tlFrameCountRef2.current = tlFrameCount;
1813
+ const tlFpsRef2 = useRef(tlFps);
1814
+ tlFpsRef2.current = tlFps;
1815
+ // While recording, the recorder's wall-clock loop is the playhead's
1816
+ // master clock (fixed 1/RECORD_FPS cadence, immune to interval jitter);
1817
+ // the timeline's own interval stands down.
1818
+ const recordingRef = useRef(false);
1819
+ recordingRef.current = recState === "recording";
1413
1820
 
1414
1821
  function stopShotRecording(reason) {
1415
1822
  const rec = recRef.current;
1416
1823
  if (!rec) return;
1417
1824
  recRef.current = null;
1418
- cancelAnimationFrame(rec.raf);
1825
+ clearTimeout(rec.timer);
1419
1826
  if (reason === "abort") rec.aborted = true;
1827
+ recordingRef.current = false;
1420
1828
  setTlPlaying(false);
1421
1829
  setRecState("idle");
1422
1830
  if (rec.recorder.state !== "inactive") rec.recorder.stop();
1423
- else if (!rec.armed) setToast("Recording cancelled before any frame was captured");
1831
+ else if (!rec.armed) setToast(ko("Recording cancelled before any frame was captured", "프레임을 캡처하기 전에 녹화가 취소됐어요"));
1424
1832
  }
1425
1833
 
1834
+ // Seedance's reference-video floor is 24 fps while ARDY clips run at 20.
1835
+ // Recording plays the clip at 20/24 real time and stamps each captured
1836
+ // frame 41.67 ms apart, so the exported file is exactly 24 fps at true
1837
+ // motion speed — valid as a camera/motion reference out of the box.
1838
+ const RECORD_FPS = 24;
1839
+
1426
1840
  function toggleShotRecording() {
1427
1841
  if (recRef.current) {
1428
1842
  stopShotRecording("manual");
@@ -1436,34 +1850,61 @@ globalThis.playMode = centerTab === "play";
1436
1850
  const MIMES = ["video/mp4;codecs=avc1.640028", "video/mp4", "video/webm;codecs=vp9", "video/webm"];
1437
1851
  const mime = typeof MediaRecorder !== "undefined" ? MIMES.find((m) => MediaRecorder.isTypeSupported(m)) : null;
1438
1852
  if (!mime) {
1439
- setToast("This browser cannot encode video (MediaRecorder unavailable)");
1853
+ setToast(ko("This browser cannot encode video (MediaRecorder unavailable)", "이 브라우저는 영상 인코딩을 지원하지 않아요(MediaRecorder 없음)"));
1440
1854
  return;
1441
1855
  }
1442
1856
  const mirror = document.createElement("canvas");
1443
1857
  mirror.width = 1600;
1444
1858
  mirror.height = 900;
1445
1859
  const ctx = mirror.getContext("2d");
1446
- const recorder = new MediaRecorder(mirror.captureStream(30), { mimeType: mime, videoBitsPerSecond: 12_000_000 });
1860
+ // Frames go in manually (and only when one was actually drawn): the
1861
+ // track must be exactly RECORD_FPS, not whatever the display runs at.
1862
+ const track = mirror.captureStream(0).getVideoTracks()[0];
1863
+ const recorder = new MediaRecorder(new MediaStream([track]), { mimeType: mime, videoBitsPerSecond: 12_000_000 });
1447
1864
  const chunks = [];
1448
1865
  const slate = (moveSequence?.slate ?? "shot").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "shot";
1449
- const rec = { recorder, raf: 0, armed: false, started: false, lastFrame: -1, warmup: 2, aborted: false };
1866
+ const rec = {
1867
+ recorder,
1868
+ track,
1869
+ // all wall-clock anchors land on the first frame so a startup
1870
+ // hiccup never stretches clip time
1871
+ t0: 0,
1872
+ playheadT0: 0,
1873
+ outputFrames: 0,
1874
+ armed: false,
1875
+ warmup: 2,
1876
+ aborted: false,
1877
+ done: false,
1878
+ };
1450
1879
  recorder.ondataavailable = (e) => {
1451
1880
  if (e.data && e.data.size > 0) chunks.push(e.data);
1452
1881
  };
1453
- recorder.onstop = () => {
1882
+ recorder.onstop = async () => {
1454
1883
  if (rec.aborted || chunks.length === 0) return;
1455
1884
  const ext = mime.startsWith("video/mp4") ? "mp4" : "webm";
1456
1885
  const name = `cozyclay-${slate}.${ext}`;
1457
- const url = URL.createObjectURL(new Blob(chunks, { type: mime }));
1886
+ const recordedBlob = new Blob(chunks, { type: mime });
1887
+ let downloadBlob = recordedBlob;
1888
+ if (ext === "mp4") {
1889
+ try {
1890
+ downloadBlob = await repairRecordedMp4(recordedBlob);
1891
+ } catch {
1892
+ // An unfamiliar muxer layout must never block the user's recording.
1893
+ downloadBlob = recordedBlob;
1894
+ }
1895
+ }
1896
+ const url = URL.createObjectURL(downloadBlob);
1458
1897
  const anchor = document.createElement("a");
1459
1898
  anchor.href = url;
1460
1899
  anchor.download = name;
1461
1900
  anchor.click();
1462
1901
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
1463
- setToast(`Saved ${name}`);
1902
+ setRecordedVideoName(name);
1903
+ setToast(isKo ? `${name} 저장됨` : `Saved ${name}`);
1464
1904
  };
1465
1905
  recRef.current = rec;
1466
1906
  setRecState("recording");
1907
+ recordingRef.current = true;
1467
1908
  // PlayView is the finished-output player, so record there: no gizmo, no
1468
1909
  // inset, and the move rides the playhead regardless of Follow. Playing
1469
1910
  // is forced even without a loaded motion so a camera-only move records.
@@ -1471,32 +1912,51 @@ globalThis.playMode = centerTab === "play";
1471
1912
  setTlFrame(0);
1472
1913
  setTlPlaying(true);
1473
1914
 
1474
- const tick = () => {
1915
+ // The tick runs in setTimeout (the recorder keeps the event loop alive
1916
+ // even in a background tab, where rAF is throttled to zero): wall-clock
1917
+ // pacing must not depend on display refresh.
1918
+ const timeoutTick = () => {
1475
1919
  if (recRef.current !== rec) return;
1476
- rec.raf = requestAnimationFrame(tick);
1477
1920
  const main = mainPaneRef.current;
1478
- if (!playModeRef.current || !main || !stage) return;
1479
- const frame = tlFrameRef.current;
1480
- // clip end: the play clock wraps to 0 after the last frame
1481
- if (rec.started && frame < rec.lastFrame) {
1482
- stopShotRecording("end");
1921
+ if (!playModeRef.current || !main || !stage) {
1922
+ rec.timer = setTimeout(timeoutTick, 20);
1483
1923
  return;
1484
1924
  }
1485
- // user paused from the transport: keep what was captured so far
1486
- if (rec.started && !tlPlayingRef.current) {
1487
- stopShotRecording("manual");
1925
+ // a couple of ticks for the PlayView layout + first playMode draw to
1926
+ // land, so the file never opens on a stale Scene-tab composite
1927
+ if (rec.warmup > 0) {
1928
+ rec.warmup -= 1;
1929
+ rec.timer = setTimeout(timeoutTick, 20);
1930
+ return;
1931
+ }
1932
+ const now = performance.now();
1933
+ if (!rec.armed) {
1934
+ rec.armed = true;
1935
+ rec.t0 = now;
1936
+ rec.playheadT0 = now;
1937
+ recorder.start();
1938
+ }
1939
+ // drive the playhead at fps/RECORD_FPS real time ourselves — the
1940
+ // timeline's own clock is suspended while recording (below), so the
1941
+ // exported speed is exact regardless of interval jitter
1942
+ const clipSeconds = tlFrameCountRef2.current / tlFpsRef2.current;
1943
+ const elapsed = now - rec.playheadT0;
1944
+ const frame = Math.floor(elapsed / (1000 / RECORD_FPS));
1945
+ if (frame >= tlFrameCountRef2.current || elapsed / 1000 > clipSeconds) {
1946
+ rec.done = true;
1947
+ setTlPlaying(false);
1948
+ setRecState("idle");
1949
+ recordingRef.current = false;
1950
+ recRef.current = null;
1951
+ recorder.stop();
1488
1952
  return;
1489
1953
  }
1490
- rec.lastFrame = Math.max(rec.lastFrame, frame);
1491
- if (frame > 0) rec.started = true;
1954
+ setTlFrame(frame);
1492
1955
  const stageRect = stage.getBoundingClientRect();
1493
1956
  const mainRect = main.getBoundingClientRect();
1494
1957
  const pane = { x: mainRect.left - stageRect.left, y: mainRect.top - stageRect.top, w: mainRect.width, h: mainRect.height };
1495
- if (pane.w < 2 || pane.h < 2) return;
1496
- // a couple of ticks for the PlayView layout + first playMode draw to
1497
- // land, so the file never opens on a stale Scene-tab composite
1498
- if (rec.warmup > 0) {
1499
- rec.warmup -= 1;
1958
+ if (pane.w < 2 || pane.h < 2) {
1959
+ rec.timer = setTimeout(timeoutTick, 20);
1500
1960
  return;
1501
1961
  }
1502
1962
  const img = fitAspect(pane, SHOT_ASPECT);
@@ -1512,12 +1972,20 @@ globalThis.playMode = centerTab === "play";
1512
1972
  mirror.width,
1513
1973
  mirror.height,
1514
1974
  );
1515
- if (!rec.armed) {
1516
- rec.armed = true;
1517
- recorder.start();
1975
+ // fixed output cadence: each captured frame spans exactly one
1976
+ // 1/RECORD_FPS of the clip, so a stalled render repeats its frame
1977
+ // (hold) instead of compressing the clip
1978
+ const nextStamp = rec.t0 + (rec.outputFrames * 1000) / RECORD_FPS;
1979
+ rec.outputFrames += 1;
1980
+ if (typeof track.requestFrame === "function") {
1981
+ track.requestFrame();
1982
+ setTimeout(() => {
1983
+ if (typeof track.requestFrame === "function") track.requestFrame();
1984
+ }, 1000 / (2 * RECORD_FPS));
1518
1985
  }
1986
+ rec.timer = setTimeout(timeoutTick, Math.max(0, nextStamp - now + 1000 / RECORD_FPS));
1519
1987
  };
1520
- rec.raf = requestAnimationFrame(tick);
1988
+ rec.timer = setTimeout(timeoutTick, 20);
1521
1989
  }
1522
1990
 
1523
1991
  function captureCurrentFraming() {
@@ -1526,34 +1994,94 @@ globalThis.playMode = centerTab === "play";
1526
1994
  return captureFraming({ pos: { x: pos.x, y: pos.y, z: pos.z }, yaw: look.current.yaw, pitch: look.current.pitch, fovDeg });
1527
1995
  }
1528
1996
 
1529
- // Key authoring lives on the timeline's Camera lane: clicking an empty
1530
- // cell keys the CURRENT framing at that frame. Re-keying a keyed frame
1531
- // overwrites its framing with wherever the camera is now.
1532
- function addCameraKeyframe(frame) {
1997
+ // Key authoring lives in each unified Shot block's lower key strip: clicking
1998
+ // an empty point stores the CURRENT framing there. Re-keying overwrites it.
1999
+ function addCameraKeyframe(frame, requestedIndex = null) {
1533
2000
  const framing = captureCurrentFraming();
1534
2001
  const target = Math.max(0, Math.min(Math.round(frame), tlFrameCount - 1));
1535
- setCameraKeys((keys) => keys.filter((k) => k.frame !== target).concat({ frame: target, framing }).sort((a, b) => a.frame - b.frame));
2002
+ setShots((current) => {
2003
+ const index = requestedIndex ?? shotIndexAtFrame(current, target);
2004
+ if (index < 0 || target < current[index].startFrame || target > current[index].endFrame) return current;
2005
+ return current.map((shot, shotIndex) => shotIndex === index ? {
2006
+ ...shot,
2007
+ cameraKeys: shot.cameraKeys.filter((key) => key.frame !== target)
2008
+ .concat({ frame: target, framing }).sort((a, b) => a.frame - b.frame),
2009
+ } : shot);
2010
+ });
1536
2011
  setSelectedHierarchyId("camera");
2012
+ setSidebarTab("inspector");
1537
2013
  }
1538
2014
 
1539
2015
  // Re-time a key by dragging its dot along the lane. Landing on another
1540
2016
  // key's frame is rejected — keys stay frame-unique.
1541
- function moveCameraKeyframe(from, to) {
1542
- const target = Math.max(0, Math.min(Math.round(to), tlFrameCount - 1));
2017
+ function moveCameraKeyframe(from, to, requestedIndex = null) {
2018
+ const sourceIndex = requestedIndex ?? shotIndexAtFrame(shots, from);
2019
+ const min = shots[sourceIndex]?.startFrame ?? 0;
2020
+ const max = shots[sourceIndex]?.endFrame ?? tlFrameCount - 1;
2021
+ const target = Math.max(min, Math.min(Math.round(to), max));
1543
2022
  if (target === from) return;
1544
- setCameraKeys((keys) => {
1545
- if (keys.some((k) => k.frame === target)) return keys;
1546
- return keys.map((k) => (k.frame === from ? { ...k, frame: target } : k)).sort((a, b) => a.frame - b.frame);
2023
+ setShots((current) => {
2024
+ const index = requestedIndex ?? shotIndexAtFrame(current, from);
2025
+ if (index < 0) return current;
2026
+ const keys = current[index]?.cameraKeys ?? [];
2027
+ if (keys.some((key) => key.frame === target)) return current;
2028
+ return current.map((shot, shotIndex) => shotIndex === index ? {
2029
+ ...shot,
2030
+ cameraKeys: keys.map((key) => key.frame === from ? { ...key, frame: target } : key).sort((a, b) => a.frame - b.frame),
2031
+ } : shot);
1547
2032
  });
1548
2033
  }
1549
2034
 
1550
- function removeCameraKeyframe(frame) {
1551
- setCameraKeys((keys) => keys.filter((k) => k.frame !== frame));
2035
+ function removeCameraKeyframe(frame, requestedIndex = null) {
2036
+ setShots((current) => {
2037
+ const index = requestedIndex ?? shotIndexAtFrame(current, frame);
2038
+ if (index < 0) return current;
2039
+ return current.map((shot, shotIndex) => shotIndex === index
2040
+ ? { ...shot, cameraKeys: shot.cameraKeys.filter((key) => key.frame !== frame) }
2041
+ : shot);
2042
+ });
1552
2043
  }
1553
2044
 
1554
2045
  function clearMove() {
1555
2046
  setMovePlaying(false);
1556
- setCameraKeys([]);
2047
+ setShots((current) => current.map((shot, index) => index === activeShotIdx ? { ...shot, cameraKeys: [] } : shot));
2048
+ }
2049
+
2050
+ function addTimelineShot() {
2051
+ setMovePlaying(false);
2052
+ setShots((current) => addShotAtFrame(current, tlFrame, tlFrameCount, captureCurrentFraming()));
2053
+ }
2054
+
2055
+ function splitTimelineShot(index) {
2056
+ setMovePlaying(false);
2057
+ setShots((current) => {
2058
+ const shot = current[index];
2059
+ if (!shot || tlFrame <= shot.startFrame || tlFrame > shot.endFrame) return current;
2060
+ return cutAtFrame(current, tlFrame, captureCurrentFraming());
2061
+ });
2062
+ }
2063
+
2064
+ function selectTimelineShot(index) {
2065
+ const selected = shots[index];
2066
+ if (!selected) return;
2067
+ manualCameraOverrideRef.current = false;
2068
+ setTlFrame(selected.startFrame);
2069
+ setSelectedHierarchyId("camera");
2070
+ setSidebarTab("inspector");
2071
+ }
2072
+
2073
+ function duplicateTimelineShot(index) {
2074
+ const next = duplicateShot(shots, index, tlFrameCount);
2075
+ setShots(next);
2076
+ if (next !== shots) {
2077
+ const sourceId = shots[index]?.id;
2078
+ const duplicate = next.find((shot) => shot.id !== sourceId && !shots.some((existing) => existing.id === shot.id));
2079
+ if (duplicate) setTlFrame(duplicate.startFrame);
2080
+ }
2081
+ }
2082
+
2083
+ function moveTimelineShot(fromIndex, targetFrame) {
2084
+ setShots((current) => reorderShot(current, fromIndex, targetFrame, tlFrameCount));
1557
2085
  }
1558
2086
 
1559
2087
  const allPoses = useMemo(() => [...BUILT_IN_POSES, ...customPoses], [customPoses]);
@@ -1565,63 +2093,140 @@ globalThis.playMode = centerTab === "play";
1565
2093
  // A walking pace turns clicked distance into clip time, so pins land at
1566
2094
  // frames the character can actually reach without ice-skating.
1567
2095
  const WALK_SPEED_MPS = 1.4;
2096
+ const ROOT_ROOM_LIMIT = 11;
2097
+ const clampRootPosition = (value) => Math.max(-ROOT_ROOM_LIMIT, Math.min(ROOT_ROOM_LIMIT, value));
2098
+ const rootStart = () => ({ frame: 0, x: charA.x, z: charA.z });
2099
+
2100
+ function validateWaypointAt(ordered, index, candidate) {
2101
+ const previous = index > 0 ? ordered[index - 1] : rootStart();
2102
+ const beforePrevious = index > 1 ? ordered[index - 2] : null;
2103
+ const inbound = judgeNextWaypoint(previous, candidate, tlFps, beforePrevious);
2104
+ if (!inbound.ok) return inbound;
2105
+ const next = ordered[index + 1];
2106
+ if (!next) return inbound;
2107
+ const outbound = judgeNextWaypoint(candidate, next, tlFps, previous);
2108
+ if (!outbound.ok) return outbound;
2109
+ return { ok: true, warnings: [...inbound.warnings, ...outbound.warnings] };
2110
+ }
1568
2111
 
2112
+ function queueRootWaypointFrame(frame) {
2113
+ const target = Math.max(1, Math.min(Math.round(frame), tlFrameCount - 1));
2114
+ if (waypoints.some((waypoint) => waypoint.frame === target)) {
2115
+ setActiveWaypointFrame(target);
2116
+ setPendingWaypointFrame(null);
2117
+ setTlFrame(target);
2118
+ setWaypointMode(true);
2119
+ setSidebarTab("motion");
2120
+ setToast(isKo ? `프레임 ${target}의 루트 웨이포인트를 선택했어요. 탑뷰에서 점을 드래그해 위치를 조정하세요.` : `Root waypoint at frame ${target} selected — drag the pin in the Top-View to reposition.`);
2121
+ return;
2122
+ }
2123
+ setPendingWaypointFrame(target);
2124
+ setActiveWaypointFrame(null);
2125
+ setTlFrame(target);
2126
+ setWaypointMode(true);
2127
+ setSidebarTab("motion");
2128
+ setToast(isKo ? `프레임 ${target}이 예약됐어요. 샷 뷰 바닥을 클릭하면 그 위치에 루트 웨이포인트가 생성됩니다.` : `Frame ${target} is reserved — click the Shot-view floor to drop the root waypoint there.`);
2129
+ }
1569
2130
  /** ARDY-demo style authoring: each empty-floor press in the Shot view drops
1570
2131
  the next waypoint where it was clicked; the frame gap comes from walking
1571
- distance. The bird's-eye board only displays the result. */
2132
+ distance. The bird's-eye board selects and drags existing waypoints. */
1572
2133
  function addFloorWaypoint(point) {
1573
- const clampToRoom = (value) => Math.max(-11, Math.min(11, value));
1574
- const x = clampToRoom(point.x);
1575
- const z = clampToRoom(point.z);
2134
+ const x = clampRootPosition(point.x);
2135
+ const z = clampRootPosition(point.z);
1576
2136
  const ordered = [...waypoints].sort((a, b) => a.frame - b.frame);
1577
- const last = ordered[ordered.length - 1] ?? { frame: 0, x: charA.x, z: charA.z };
2137
+ const last = ordered[ordered.length - 1] ?? rootStart();
1578
2138
  if (waypoints.length + 1 > MAX_WAYPOINTS) {
1579
- setToast(`The root path is capped at ${MAX_WAYPOINTS} waypoints`);
2139
+ setToast(isKo ? `루트 경로는 웨이포인트 ${MAX_WAYPOINTS}개까지 사용할 수 있어요` : `The root path is capped at ${MAX_WAYPOINTS} waypoints`);
2140
+ return;
2141
+ }
2142
+ const pendingFrame = pendingWaypointFrame == null ? null : Math.max(1, Math.min(Math.round(pendingWaypointFrame), tlFrameCount - 1));
2143
+ if (pendingFrame != null && ordered.some((waypoint) => waypoint.frame === pendingFrame)) {
2144
+ setToast(isKo ? `프레임 ${pendingFrame}에는 이미 루트 웨이포인트가 있어요. 타임라인에서 빈 프레임을 선택하세요.` : `Frame ${pendingFrame} already has a root waypoint — pick an empty frame on the timeline.`);
2145
+ setPendingWaypointFrame(null);
1580
2146
  return;
1581
2147
  }
1582
2148
  // A scrubbed playhead is an explicit statement of time: a click lands on
1583
2149
  // that exact frame. An untouched playhead (it snaps to the last pin
1584
2150
  // after every placement) falls back to walking-distance pacing.
1585
2151
  const playhead = Math.round(tlFrame);
1586
- const pinned = playhead > last.frame;
2152
+ const pinned = pendingFrame != null || playhead > last.frame;
1587
2153
  const walkGap = Math.max(8, Math.round((Math.hypot(x - last.x, z - last.z) / WALK_SPEED_MPS) * tlFps));
1588
- const frame = pinned ? Math.min(playhead, tlFrameCount - 1) : last.frame + walkGap;
2154
+ const frame = pendingFrame ?? (pinned ? Math.min(playhead, tlFrameCount - 1) : last.frame + walkGap);
1589
2155
  if (frame > tlFrameCount - 1) {
1590
- setToast("The path already fills the clip — extend the duration or clear a waypoint");
2156
+ setToast(ko("The path already fills the clip — extend the duration or clear a waypoint", "경로가 이미 클립 길이를 채웠어요. 시간을 늘리거나 웨이포인트를 지워 주세요"));
1591
2157
  return;
1592
2158
  }
1593
2159
  // The generator cannot refuse an impossible pin, so the click is the
1594
2160
  // last moment a human can: block out-of-band legs with the fix named.
1595
- const before = ordered[ordered.length - 2] ?? (ordered.length === 1 ? { frame: 0, x: charA.x, z: charA.z } : null);
1596
- const verdict = judgeNextWaypoint(last, { frame, x, z }, tlFps, before);
2161
+ const insertAt = ordered.findIndex((waypoint) => waypoint.frame > frame);
2162
+ const index = insertAt === -1 ? ordered.length : insertAt;
2163
+ const waypoint = { frame, x, z, heading: null };
2164
+ const nextWaypoints = [...ordered.slice(0, index), waypoint, ...ordered.slice(index)];
2165
+ const verdict = validateWaypointAt(nextWaypoints, index, waypoint);
1597
2166
  if (!verdict.ok) {
1598
- setToast(`Not placed — ${verdict.error}`);
2167
+ setToast(isKo ? `배치하지 못했어요 — ${verdict.error}` : `Not placed — ${verdict.error}`);
1599
2168
  return;
1600
2169
  }
1601
- setWaypoints((prev) => [...prev, { frame, x, z, heading: null }].sort((a, b) => a.frame - b.frame));
2170
+ setWaypoints(nextWaypoints);
1602
2171
  setTlFrame(frame);
1603
2172
  setActiveWaypointFrame(frame);
1604
- const placed = `Waypoint ${ordered.length + 1} — frame ${frame} ${pinned ? "(at the playhead)" : `(~${(frame / tlFps).toFixed(1)}s at a walk)`}`;
2173
+ setPendingWaypointFrame(null);
2174
+ const placed = isKo
2175
+ ? `루트 웨이포인트 ${index + 1} 추가: 프레임 ${frame}${pendingFrame != null ? " (타임라인 예약 프레임)" : pinned ? " (재생 헤드 위치)" : ` (~${(frame / tlFps).toFixed(1)}초 걷기 기준)`}`
2176
+ : `Waypoint ${ordered.length + 1} — frame ${frame} ${pendingFrame != null ? "(at the reserved frame)" : pinned ? "(at the playhead)" : `(~${(frame / tlFps).toFixed(1)}s at a walk)`}`;
1605
2177
  setToast(verdict.warnings.length ? `${placed} · ⚠ ${verdict.warnings[0]}` : placed);
1606
2178
  }
1607
2179
 
2180
+ function moveWaypoint(frame, x, z) {
2181
+ const target = Math.round(frame);
2182
+ const ordered = [...waypoints].sort((a, b) => a.frame - b.frame);
2183
+ const index = ordered.findIndex((waypoint) => waypoint.frame === target);
2184
+ if (index === -1) return;
2185
+ const nextWaypoint = {
2186
+ ...ordered[index],
2187
+ x: clampRootPosition(x),
2188
+ z: clampRootPosition(z),
2189
+ };
2190
+ const nextOrdered = ordered.map((waypoint, i) => (i === index ? nextWaypoint : waypoint));
2191
+ const verdict = validateWaypointAt(nextOrdered, index, nextWaypoint);
2192
+ if (!verdict.ok) {
2193
+ setToast(isKo ? `이 위치는 루트 경로에 맞지 않아요: ${verdict.error}` : `This position doesn't fit the root path: ${verdict.error}`);
2194
+ return;
2195
+ }
2196
+ setWaypoints(nextOrdered);
2197
+ setActiveWaypointFrame(target);
2198
+ setPendingWaypointFrame((current) => (current === target ? null : current));
2199
+ if (verdict.warnings.length) setToast(isKo ? `루트 웨이포인트 이동됨: ${verdict.warnings[0]}` : `Root waypoint moved: ${verdict.warnings[0]}`);
2200
+ }
2201
+
1608
2202
  function removeWaypoint(frame) {
1609
2203
  setWaypoints((prev) => prev.filter((w) => w.frame !== frame));
1610
2204
  setActiveWaypointFrame((current) => (current === frame ? null : current));
2205
+ setPendingWaypointFrame((current) => (current === frame ? null : current));
1611
2206
  }
1612
2207
 
1613
2208
  function toggleWaypointMode() {
1614
2209
  const next = !waypointMode;
1615
2210
  setWaypointMode(next);
1616
2211
  if (!next) {
1617
- setToast("2D Root path constraints off");
2212
+ setPendingWaypointFrame(null);
2213
+ setToast(ko("2D Root path constraints off", "2D 루트 경로 제약 꺼짐"));
1618
2214
  return;
1619
2215
  }
1620
2216
 
1621
- setToast("2D Root path on — click the set floor in the Shot view to drop waypoints; Subject 1 is the frame 0 start");
2217
+ setToast(ko("2D Root path on — click the set floor in the Shot view to drop waypoints; Subject 1 is the frame 0 start", "2D 루트 경로 켜짐 — 샷 뷰의 세트 바닥을 클릭해 웨이포인트를 놓으세요. 인물 1이 0프레임 시작점입니다"));
1622
2218
  }
1623
2219
 
1624
2220
  function advanceFrame() {
2221
+ // recording paces the playhead on its own fixed-cadence clock
2222
+ if (recordingRef.current) return;
2223
+ const previewEnd = cameraPreviewEndRef.current;
2224
+ if (previewEnd != null && tlFrameRef.current >= previewEnd - 1) {
2225
+ cameraPreviewEndRef.current = null;
2226
+ setTlFrame(previewEnd);
2227
+ setTlPlaying(false);
2228
+ return;
2229
+ }
1625
2230
  setTlFrame((f) => (f >= frameCountRef.current - 1 ? 0 : f + 1));
1626
2231
  }
1627
2232
 
@@ -1649,7 +2254,7 @@ globalThis.playMode = centerTab === "play";
1649
2254
  if (previous) restorePlaybackBones(previous.rig, previous.bones);
1650
2255
  const decoded = await loadMotionFromUrl(url);
1651
2256
  const rig = rigA;
1652
- if (!rig) throw new Error("Subject 1 rig is not loaded");
2257
+ if (!rig) throw new Error(ko("Subject 1 rig is not loaded", "인물 1 리그가 로드되지 않았어요"));
1653
2258
  restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
1654
2259
  setMotion({
1655
2260
  // Capture the exact prompt this motion was generated from; the
@@ -1667,7 +2272,7 @@ globalThis.playMode = centerTab === "play";
1667
2272
  setTlFps(decoded.fps);
1668
2273
  setTlFrame(0);
1669
2274
  setTlPlaying(false);
1670
- setToast(`Motion loaded: ${decoded.frames} frames @ ${decoded.fps} fps`);
2275
+ setToast(isKo ? `모션 로드됨: ${decoded.frames}프레임 @ ${decoded.fps} fps` : `Motion loaded: ${decoded.frames} frames @ ${decoded.fps} fps`);
1671
2276
  } catch (err) {
1672
2277
  setMotion(null);
1673
2278
  setMotionError(err?.message || String(err));
@@ -1759,8 +2364,8 @@ globalThis.playMode = centerTab === "play";
1759
2364
  }
1760
2365
  setIkMode(true);
1761
2366
  setToast(motion
1762
- ? "IK mode — correct the motion; drag end keys the fix at this frame"
1763
- : "IK mode — drag handles in the main view; the shot camera stays frozen in the inset");
2367
+ ? ko("IK mode — correct the motion; drag end keys the fix at this frame", "IK 모드 — 모션을 보정합니다. 드래그를 끝내면 이 프레임에 보정 키가 찍혀요")
2368
+ : ko("IK mode — drag handles in the main view; the shot camera stays frozen in the inset", "IK 모드 — 메인 뷰에서 핸들을 드래그하세요. 샷 카메라는 인셋에 고정됩니다"));
1764
2369
  return;
1765
2370
  }
1766
2371
  // Exit: the keyed pose stays — the evaluate effect re-applies the
@@ -1768,7 +2373,7 @@ globalThis.playMode = centerTab === "play";
1768
2373
  // the user authored is lost by toggling. Untracked/unkeyed parts keep
1769
2374
  // their current (FK) pose.
1770
2375
  setIkMode(false);
1771
- setToast("IK mode off — keyed poses keep playing");
2376
+ setToast(ko("IK mode off — keyed poses keep playing", "IK 모드 꺼짐 — 키로 찍은 포즈는 계속 재생됩니다"));
1772
2377
  }
1773
2378
 
1774
2379
  // Drag solve, routed by handle kind: chain targets solve the two-bone
@@ -1848,7 +2453,7 @@ globalThis.playMode = centerTab === "play";
1848
2453
  if (!ikChains) return;
1849
2454
  ikBakeKeyframe(ikChains, ikStateRef.current, tlFrame, ikFkJoints);
1850
2455
  setIkTick((n) => n + 1);
1851
- setToast(`Full-body IK key at frame ${tlFrame}`);
2456
+ setToast(isKo ? `${tlFrame}프레임에 전신 IK 키를 추가했어요` : `Full-body IK key at frame ${tlFrame}`);
1852
2457
  }
1853
2458
 
1854
2459
  function ikDeleteKeyframe(frame) {
@@ -1943,7 +2548,7 @@ globalThis.playMode = centerTab === "play";
1943
2548
  // is derived from. Without a loaded motion the subject stands still and
1944
2549
  // the follow camera simply composes a static frame.
1945
2550
  const subjectTrack = useMemo(() => {
1946
- if (!followCam.enabled) return null;
2551
+ if (!shots.some((shot) => shot.camera?.mode === "follow" || shot.camera?.mode === "rail")) return null;
1947
2552
  const frames = Math.max(tlFrameCount, 1);
1948
2553
  if (!motion) return Array.from({ length: frames }, () => ({ x: charA.x, z: charA.z }));
1949
2554
  const a = Math.min(motion.anchorFrame, motion.frames - 1);
@@ -1956,43 +2561,56 @@ globalThis.playMode = centerTab === "play";
1956
2561
  );
1957
2562
  return { x: motion.anchorX + offset.x, z: motion.anchorZ + offset.z };
1958
2563
  });
1959
- }, [followCam.enabled, motion, tlFrameCount, charA.x, charA.z]);
2564
+ }, [shots, motion, tlFrameCount, charA.x, charA.z]);
1960
2565
 
1961
2566
  // The dense rail (spline through the drawn control points) — shared by
1962
2567
  // the follow controller and the Top-View display.
1963
2568
  const railCurve = useMemo(() => (cameraRail ? buildRail(cameraRail) : null), [cameraRail]);
1964
2569
 
1965
2570
  const followTrack = useMemo(() => {
1966
- if (!followCam.enabled || !subjectTrack) return null;
2571
+ if (!subjectTrack) return null;
1967
2572
  const yaw = (charA.rot * Math.PI) / 180;
1968
- const params = {
1969
- distance: followCam.distance,
1970
- height: followCam.height,
1971
- response: followCam.response,
1972
- lead: followCam.lead,
1973
- initialDir: { x: Math.sin(yaw), z: Math.cos(yaw) },
1974
- };
1975
- return railCurve
1976
- ? buildRailFollowTrack(subjectTrack, tlFps, railCurve, params)
1977
- : buildFollowTrack(subjectTrack, tlFps, params);
1978
- }, [followCam, subjectTrack, railCurve, tlFps, charA.rot]);
2573
+ const combined = new Array(subjectTrack.length).fill(null);
2574
+ for (let index = 0; index < shots.length; index += 1) {
2575
+ const shot = shots[index];
2576
+ const camera = createCameraBlock(shot.camera);
2577
+ if (camera.mode === "keys") continue;
2578
+ const start = shot.startFrame;
2579
+ const end = Math.min(subjectTrack.length, shot.endFrame + 1);
2580
+ const subjectSlice = subjectTrack.slice(start, end);
2581
+ const params = { ...camera.followCam, initialDir: { x: Math.sin(yaw), z: Math.cos(yaw) } };
2582
+ const rail = camera.mode === "rail" ? buildRail(camera.cameraRail) : null;
2583
+ if (rail) {
2584
+ const schedule = resolveRailSchedule({ railFollow: camera.railFollow, cameraRail: camera.cameraRail, frameCount: subjectSlice.length });
2585
+ if (schedule.kind !== RAIL_SCHEDULE_RANGE && schedule.kind !== RAIL_SCHEDULE_LEGACY) continue;
2586
+ const railSlice = subjectSlice.slice(schedule.startFrame, schedule.endFrame + 1);
2587
+ const track = buildRailFollowTrack(railSlice, tlFps, rail, params);
2588
+ track.forEach((sample, offset) => { combined[start + schedule.startFrame + offset] = sample; });
2589
+ } else {
2590
+ const track = buildFollowTrack(subjectSlice, tlFps, params);
2591
+ track.forEach((sample, offset) => { combined[start + offset] = sample; });
2592
+ }
2593
+ }
2594
+ return combined;
2595
+ }, [shots, subjectTrack, tlFps, charA.rot]);
1979
2596
 
1980
2597
  // The follow camera owns the shot camera in the same situations key
1981
2598
  // following would: never while an authoring mode holds the viewport.
1982
2599
  const followCamActive =
1983
- followCam.enabled && !!followTrack && (centerTab === "play" || (!ikMode && !waypointMode && !posing));
2600
+ activeCamera.mode !== "keys" && !!followTrack?.[tlFrame] && (centerTab === "play" || (!ikMode && !waypointMode && !posing));
1984
2601
 
1985
2602
  // Implied locomotion speed per authored segment (@ 20 fps). Shown in the
1986
2603
  // timeline hint so a path that forces a crawl or a sprint is visible
1987
2604
  // before spending a generation on it.
1988
2605
  const pathSpeed = useMemo(() => {
1989
- if (waypoints.length < 2) return null;
2606
+ if (waypoints.length < 1) return null;
1990
2607
  let min = Infinity;
1991
2608
  let max = 0;
1992
- for (let i = 1; i < waypoints.length; i += 1) {
1993
- const a = waypoints[i - 1];
1994
- const b = waypoints[i];
1995
- const seconds = (b.frame - a.frame) / 20;
2609
+ const path = [rootStart(), ...[...waypoints].sort((a, b) => a.frame - b.frame)];
2610
+ for (let i = 1; i < path.length; i += 1) {
2611
+ const a = path[i - 1];
2612
+ const b = path[i];
2613
+ const seconds = (b.frame - a.frame) / tlFps;
1996
2614
  if (seconds <= 0) continue;
1997
2615
  const speed = Math.hypot(b.x - a.x, b.z - a.z) / seconds;
1998
2616
  min = Math.min(min, speed);
@@ -2000,14 +2618,14 @@ globalThis.playMode = centerTab === "play";
2000
2618
  }
2001
2619
  if (!Number.isFinite(min)) return null;
2002
2620
  return { min, max, warn: min < 0.5 || max > 3 };
2003
- }, [waypoints]);
2621
+ }, [charA.x, charA.z, tlFps, waypoints]);
2004
2622
 
2005
2623
  const stateBadge = ardyRunning
2006
- ? { label: "GENERATING", kind: "generating" }
2624
+ ? { label: ko("GENERATING", "생성 중"), kind: "generating" }
2007
2625
  : motion
2008
- ? { label: "PLAYBACK", kind: "playback" }
2626
+ ? { label: ko("PLAYBACK", "재생"), kind: "playback" }
2009
2627
  : waypointMode
2010
- ? { label: "ROOT PATH", kind: "root" }
2628
+ ? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
2011
2629
  : null;
2012
2630
 
2013
2631
  function openStudio(which) {
@@ -2030,7 +2648,7 @@ globalThis.playMode = centerTab === "play";
2030
2648
  if (!rig) return;
2031
2649
  const pose = {
2032
2650
  id: `custom_${Date.now()}`,
2033
- label: `My Pose ${customPoses.length + 1}`,
2651
+ label: isKo ? `내 포즈 ${customPoses.length + 1}` : `My Pose ${customPoses.length + 1}`,
2034
2652
  prompt: "in the exact body pose shown in the blocking frame",
2035
2653
  bones: capturePose(rig),
2036
2654
  custom: true,
@@ -2040,7 +2658,7 @@ globalThis.playMode = centerTab === "play";
2040
2658
  saveCustomPoses(next);
2041
2659
  setStudioPick(pose.id);
2042
2660
  setPosed(pose);
2043
- setToast("Pose saved");
2661
+ setToast(ko("Pose saved", "포즈 저장됨"));
2044
2662
  }
2045
2663
 
2046
2664
  function removePose(id) {
@@ -2097,45 +2715,110 @@ globalThis.playMode = centerTab === "play";
2097
2715
  return buffer ? bufferToPng(buffer) : null;
2098
2716
  }
2099
2717
 
2100
- function generate() {
2101
- const models = mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
2718
+ function copyPrompt(prompt) {
2719
+ const write = navigator.clipboard?.writeText(prompt);
2720
+ if (!write) return;
2721
+ write
2722
+ .then(() => {
2723
+ setCopied(true);
2724
+ setToast(ko("Prompt copied to clipboard", "프롬프트를 클립보드에 복사했어요"));
2725
+ })
2726
+ .catch(() => {});
2727
+ }
2728
+
2729
+ async function generate() {
2730
+ generation.reset();
2731
+ const models = mode === "video" && generation.models.length ? generation.models : mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
2102
2732
  const model = models.find((m) => m.id === (mode === "video" ? videoModel : imageModel));
2103
- // An authored keyframe move outranks the dropdown: the phrase is derived
2104
- // from the framings, and the exported frames become first/last conditioning.
2105
- const movePlan = mode === "video" ? moveSequence : null;
2733
+ // Generation follows the Camera Block owned by the Shot under the playhead.
2734
+ // Keys use their authored endpoints; Follow/Rail use the deterministic
2735
+ // track already used by playback, so prompt and conditioning describe the
2736
+ // same camera department instruction the editor previews.
2737
+ const movePlan = mode === "video" && activeCamera.mode === "keys" ? moveSequence : null;
2738
+ const trackedFramings = activeShot && activeCamera.mode !== "keys"
2739
+ ? followTrack
2740
+ ?.slice(activeShot.startFrame, activeShot.endFrame + 1)
2741
+ .filter(Boolean)
2742
+ .map((sample) => ({ ...sample, fovDeg })) ?? []
2743
+ : [];
2744
+ const blockFramings = activeCamera.mode === "keys"
2745
+ ? cameraKeys.map((key) => key.framing)
2746
+ : trackedFramings;
2747
+ const framingA = blockFramings[0] ?? null;
2748
+ const framingB = blockFramings.length > 1 ? blockFramings[blockFramings.length - 1] : null;
2749
+ const promptSubject = activeShot && subjectTrack?.[activeShot.startFrame]
2750
+ ? { ...charA, ...subjectTrack[activeShot.startFrame] }
2751
+ : charA;
2752
+ const blockMove = mode === "video" && activeShot && activeCamera.mode !== "keys"
2753
+ ? activeCamera.mode === "rail"
2754
+ ? "a continuous rail tracking move following the subject"
2755
+ : "a continuous follow-camera move maintaining the authored framing"
2756
+ : null;
2106
2757
  const prompt = composePrompt({
2107
2758
  mode,
2108
2759
  model,
2109
- shot: movePlan ? movePlan.fromShot : shot,
2760
+ shot: movePlan?.fromShot ?? (framingA
2761
+ ? deriveShot(framingA.pos, promptSubject, (framingA.fovDeg * Math.PI) / 180, SUBJECT_HEIGHT_M)
2762
+ : shot),
2110
2763
  subject,
2111
2764
  subject2: showB ? subject2 : null,
2112
2765
  posePhrase: poseA?.prompt ?? "",
2113
2766
  pose2Phrase: showB ? (poseB?.prompt ?? "") : "",
2114
2767
  environment,
2115
2768
  style,
2116
- cameraMove: movePlan ? CUSTOM_MOVE : cameraMove,
2117
- customMove: movePlan ? movePlan.phrase : customMove,
2769
+ cameraMove: movePlan || blockMove ? CUSTOM_MOVE : cameraMove,
2770
+ customMove: movePlan?.phrase ?? blockMove ?? customMove,
2118
2771
  hasCharSheet,
2119
2772
  hasEnvSheet,
2120
2773
  });
2121
2774
  let frame = null;
2122
2775
  let frameB = null;
2123
- if (movePlan) {
2776
+ if (activeCamera.mode === "keys" && cameraKeys.length) {
2124
2777
  frame = captureFramingPng(cameraKeys[0].framing);
2125
- frameB = captureFramingPng(cameraKeys[cameraKeys.length - 1].framing);
2778
+ frameB = cameraKeys.length > 1 ? captureFramingPng(cameraKeys[cameraKeys.length - 1].framing) : null;
2779
+ } else if (framingA) {
2780
+ frame = captureFramingPng(framingA);
2781
+ frameB = framingB ? captureFramingPng(framingB) : null;
2126
2782
  } else {
2127
2783
  const buffer = captureRef.current?.render();
2128
2784
  frame = buffer ? bufferToPng(buffer) : null;
2129
2785
  }
2130
- setResult({ prompt, frame, frameB, move: movePlan });
2786
+ const nextResult = {
2787
+ prompt,
2788
+ frame,
2789
+ frameB,
2790
+ move: movePlan,
2791
+ mode,
2792
+ modelLabel: model?.label,
2793
+ downloaded: false,
2794
+ shot: activeShot ? {
2795
+ id: activeShot.id,
2796
+ name: activeShot.name,
2797
+ startFrame: activeShot.startFrame,
2798
+ endFrame: activeShot.endFrame,
2799
+ } : null,
2800
+ fps: tlFps,
2801
+ camera: activeShot ? {
2802
+ mode: activeCamera.mode,
2803
+ followCam: activeCamera.followCam,
2804
+ cameraRail: activeCamera.cameraRail,
2805
+ railFollow: activeCamera.railFollow,
2806
+ keys: cameraKeys,
2807
+ } : null,
2808
+ subjects: [subject, ...(showB ? [subject2] : [])],
2809
+ };
2810
+ setResult(nextResult);
2811
+ setResultOpen(true);
2131
2812
  setCopied(false);
2132
- navigator.clipboard
2133
- ?.writeText(prompt)
2134
- .then(() => {
2135
- setCopied(true);
2136
- setToast("Prompt copied to clipboard");
2137
- })
2138
- .catch(() => {});
2813
+ setRecordedVideoName(null);
2814
+ copyPrompt(prompt);
2815
+ if (mode === "video" && generation.models.some((candidate) => candidate.id === model?.id && candidate.provider === model?.provider)) {
2816
+ try {
2817
+ await generation.startResult(nextResult, model);
2818
+ } catch (error) {
2819
+ setToast(error?.message || String(error));
2820
+ }
2821
+ }
2139
2822
  }
2140
2823
 
2141
2824
  function download() {
@@ -2151,16 +2834,18 @@ globalThis.playMode = centerTab === "play";
2151
2834
  // named for the seat they take in a first/last-frame video request
2152
2835
  save(result.frame, "blocking-frame-A-start.png");
2153
2836
  save(result.frameB, "blocking-frame-B-end.png");
2154
- setToast("Start & end frames downloaded");
2837
+ setToast(ko("Start & end frames downloaded", "시작·끝 프레임 다운로드됨"));
2838
+ setResult((current) => current ? { ...current, downloaded: true } : current);
2155
2839
  return;
2156
2840
  }
2157
2841
  save(result.frame, "blocking-frame.png");
2158
- setToast("Frame downloaded");
2842
+ setToast(ko("Frame downloaded", "프레임 다운로드됨"));
2843
+ setResult((current) => current ? { ...current, downloaded: true } : current);
2159
2844
  }
2160
2845
  function downloadArdyPose() {
2161
2846
  const rig = posedRig();
2162
2847
  if (!rig) {
2163
- setToast("Character not loaded yet");
2848
+ setToast(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
2164
2849
  return;
2165
2850
  }
2166
2851
  const pose = buildArdyPose({
@@ -2181,7 +2866,7 @@ globalThis.playMode = centerTab === "play";
2181
2866
  a.click();
2182
2867
  a.remove();
2183
2868
  URL.revokeObjectURL(url);
2184
- setToast("ARDY pose exported");
2869
+ setToast(ko("ARDY pose exported", "ARDY 포즈 내보내기 완료"));
2185
2870
  }
2186
2871
  // Probe the dev sidecar exactly once. A missing bridge is an expected
2187
2872
  // state — CozyClay is a static app and the sidecar is an optional dev
@@ -2274,7 +2959,7 @@ globalThis.playMode = centerTab === "play";
2274
2959
  .filter((clip) => clip.text.trim())
2275
2960
  .sort((a, b) => a.startFrame - b.startFrame);
2276
2961
  if (!clips.length) {
2277
- setToast("Add at least one Prompt Block before generating");
2962
+ setToast(ko("Add at least one Prompt Block before generating", "생성하기 전에 프롬프트 블록을 하나 이상 추가하세요"));
2278
2963
  return;
2279
2964
  }
2280
2965
  const totalFrames = Math.max(...clips.map((clip) => clip.endFrame));
@@ -2295,7 +2980,7 @@ globalThis.playMode = centerTab === "play";
2295
2980
  } = {}) {
2296
2981
  const rig = posedRig();
2297
2982
  if (!rig) {
2298
- setToast("Character not loaded yet");
2983
+ setToast(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
2299
2984
  return;
2300
2985
  }
2301
2986
  // Root guidance sends only authored sparse keys. ARDY owns every
@@ -2304,11 +2989,11 @@ globalThis.playMode = centerTab === "play";
2304
2989
  // values here, before any pose build or network, with a specific toast.
2305
2990
  const prompt = promptOverride.trim();
2306
2991
  if (!prompt) {
2307
- setToast("Motion prompt is required — describe what the subject should do before generating");
2992
+ setToast(ko("Motion prompt is required — describe what the subject should do before generating", "모션 프롬프트가 필요해요 — 생성 전에 피사체가 할 동작을 설명하세요"));
2308
2993
  return;
2309
2994
  }
2310
2995
  if (prompt.length > ARDY_PROMPT_MAX) {
2311
- setToast(`Motion prompt is capped at ${ARDY_PROMPT_MAX} characters (currently ${prompt.length}) — shorten it before generating`);
2996
+ setToast(isKo ? `모션 프롬프트는 ${ARDY_PROMPT_MAX}자까지예요(현재 ${prompt.length}자). 생성 전에 줄여 주세요` : `Motion prompt is capped at ${ARDY_PROMPT_MAX} characters (currently ${prompt.length}) — shorten it before generating`);
2312
2997
  return;
2313
2998
  }
2314
2999
  // Regeneration must keep the loaded clip's exact frame count. The form
@@ -2318,12 +3003,12 @@ globalThis.playMode = centerTab === "play";
2318
3003
  ? motion.frames / motion.fps
2319
3004
  : Math.round(Number(durationOverride)) || ARDY_DURATION_MIN;
2320
3005
  if (duration < ARDY_DURATION_MIN || duration > ARDY_DURATION_MAX) {
2321
- setToast(`Duration must be between ${ARDY_DURATION_MIN} and ${ARDY_DURATION_MAX} seconds`);
3006
+ setToast(isKo ? `길이는 ${ARDY_DURATION_MIN}초에서 ${ARDY_DURATION_MAX}초 사이여야 해요` : `Duration must be between ${ARDY_DURATION_MIN} and ${ARDY_DURATION_MAX} seconds`);
2322
3007
  return;
2323
3008
  }
2324
3009
  const seed = ardySeed === "" ? null : Number(ardySeed);
2325
3010
  if (seed !== null && (!Number.isInteger(seed) || seed < 0 || seed > ARDY_SEED_MAX)) {
2326
- setToast(`Seed must be an integer in 0..${ARDY_SEED_MAX} — clear it to let the box pick one`);
3011
+ setToast(isKo ? `Seed는 0..${ARDY_SEED_MAX} 범위의 정수여야 해요. 비워 두면 자동으로 선택됩니다` : `Seed must be an integer in 0..${ARDY_SEED_MAX} — clear it to let the box pick one`);
2327
3012
  return;
2328
3013
  }
2329
3014
  // Prompt clips are real generation blocks. Gaps inherit the current
@@ -2352,15 +3037,15 @@ globalThis.playMode = centerTab === "play";
2352
3037
  : [];
2353
3038
  if (waypointMode) {
2354
3039
  if (waypoints.length < 1) {
2355
- setToast("Add at least one root destination before generating");
3040
+ setToast(ko("Add at least one root destination before generating", "생성하기 전에 루트 목적지를 하나 이상 추가하세요"));
2356
3041
  return;
2357
3042
  }
2358
3043
  if (rootPath.length > MAX_WAYPOINTS) {
2359
- setToast(`The root path is capped at ${MAX_WAYPOINTS} sparse waypoints`);
3044
+ setToast(isKo ? `루트 경로는 드문 웨이포인트 ${MAX_WAYPOINTS}개까지 사용할 수 있어요` : `The root path is capped at ${MAX_WAYPOINTS} sparse waypoints`);
2360
3045
  return;
2361
3046
  }
2362
3047
  if (waypoints.some((waypoint) => waypoint.frame <= 0 || waypoint.frame >= clipFrames)) {
2363
- setToast(`Root waypoint frames must stay inside 1..${clipFrames - 1}`);
3048
+ setToast(isKo ? `루트 웨이포인트 프레임은 1..${clipFrames - 1} 안에 있어야 해요` : `Root waypoint frames must stay inside 1..${clipFrames - 1}`);
2364
3049
  return;
2365
3050
  }
2366
3051
  // Placement-time checks can be invalidated afterwards (removing a
@@ -2370,13 +3055,15 @@ globalThis.playMode = centerTab === "play";
2370
3055
  // block instead of the whole clip.
2371
3056
  const pathVerdict = judgeAuthoredPath(rootPath, 20, clipFrames, { chained: hasPromptSchedule });
2372
3057
  if (pathVerdict.errors.length > 0) {
2373
- setToast(`Not generated — ${pathVerdict.errors[0]}`);
3058
+ setToast(isKo ? `생성하지 못했어요 — ${pathVerdict.errors[0]}` : `Not generated — ${pathVerdict.errors[0]}`);
2374
3059
  return;
2375
3060
  }
2376
3061
  if (hasPromptSchedule) {
2377
3062
  const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame > PATH_LIMITS.clipMaxS * 20);
2378
3063
  if (longBlock) {
2379
- setToast(`Not generated — with a root path every prompt block must stay within ${PATH_LIMITS.clipMaxS} s (ARDY's trained window per chained call); split the ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)} s block`);
3064
+ setToast(isKo
3065
+ ? `생성하지 못했어요 — 루트 경로가 있을 때 각 프롬프트 블록은 ${PATH_LIMITS.clipMaxS}초 이내여야 해요(ARDY 연쇄 호출 학습 범위). ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)}초 블록을 나눠 주세요`
3066
+ : `Not generated — with a root path every prompt block must stay within ${PATH_LIMITS.clipMaxS} s (ARDY's trained window per chained call); split the ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)} s block`);
2380
3067
  return;
2381
3068
  }
2382
3069
  }
@@ -2449,7 +3136,7 @@ globalThis.playMode = centerTab === "play";
2449
3136
  const controller = new AbortController();
2450
3137
  ardyAbortRef.current = controller;
2451
3138
  setArdyRunning(true);
2452
- reportArdyStatus("connecting…");
3139
+ reportArdyStatus(ko("connecting…", "연결 중…"));
2453
3140
  setArdyReport(null);
2454
3141
  setArdyOutcome(null);
2455
3142
  try {
@@ -2465,7 +3152,7 @@ globalThis.playMode = centerTab === "play";
2465
3152
  if (hasPromptSchedule && !hasBlockEdits) body.segments = segments;
2466
3153
  } else if (hasBlockEdits) {
2467
3154
  if (!motion?.url) {
2468
- throw new Error("The current motion has no bridge source; generate the prompt blocks once before regenerating IK edits");
3155
+ throw new Error(ko("The current motion has no bridge source; generate the prompt blocks once before regenerating IK edits", "현재 모션에 브리지 원본이 없어요. 프롬프트 블록을 한 번 생성한 뒤 IK 보정을 다시 생성하세요"));
2469
3156
  }
2470
3157
  const startFrame = Math.min(...editedSegments.map((segment) => segment.startFrame));
2471
3158
  const endFrame = Math.max(...editedSegments.map((segment) => segment.endFrame));
@@ -2504,7 +3191,7 @@ globalThis.playMode = centerTab === "play";
2504
3191
  )
2505
3192
  )
2506
3193
  ) {
2507
- throw new Error("ARDY returned motion without verified authored IK keys");
3194
+ throw new Error(ko("ARDY returned motion without verified authored IK keys", "ARDY가 검증된 수동 IK 키 없이 모션을 반환했어요"));
2508
3195
  }
2509
3196
  setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: rootRotationDeg });
2510
3197
  // Fetch and decode the real npz right away; decode errors are shown
@@ -2520,13 +3207,13 @@ globalThis.playMode = centerTab === "play";
2520
3207
  ikStateRef.current.plants.clear();
2521
3208
  setIkTick((value) => value + 1);
2522
3209
  }
2523
- setToast("ARDY motion generated");
3210
+ setToast(ko("ARDY motion generated", "ARDY 모션 생성됨"));
2524
3211
  } catch (err) {
2525
3212
  // Cancellation surfaces as an AbortError; everything else is the
2526
3213
  // bridge or the generator explaining itself.
2527
3214
  setArdyOutcome({
2528
3215
  ok: false,
2529
- message: err?.name === "AbortError" ? "Cancelled" : err?.message || String(err),
3216
+ message: err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err),
2530
3217
  });
2531
3218
  } finally {
2532
3219
  setArdyRunning(false);
@@ -2538,7 +3225,12 @@ globalThis.playMode = centerTab === "play";
2538
3225
  ardyAbortRef.current?.abort();
2539
3226
  }
2540
3227
 
2541
- const models = mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
3228
+ const models = mode === "video" && generation.models.length ? generation.models : mode === "video" ? VIDEO_MODELS : IMAGE_MODELS;
3229
+ useEffect(() => {
3230
+ if (mode === "video" && generation.models.length && !generation.models.some((model) => model.id === videoModel)) {
3231
+ setVideoModel(generation.models[0].id);
3232
+ }
3233
+ }, [mode, videoModel, generation.models]);
2542
3234
 
2543
3235
  return (
2544
3236
  <div className={"app" + (renderActive ? "" : " render-idle")}>
@@ -2548,22 +3240,30 @@ globalThis.playMode = centerTab === "play";
2548
3240
  Cozy <span>Clay</span>
2549
3241
  </span>
2550
3242
  </div>
2551
-
3243
+ <div className="topbar-actions">
3244
+ <LocaleToggle />
3245
+ </div>
2552
3246
  </header>
2553
3247
 
2554
3248
  <div className="main" style={workspaceStyle}>
2555
3249
  <div className="workspace">
2556
- <aside className="panel hierarchy-left" aria-label="Hierarchy">
3250
+ <aside className="panel hierarchy-left" aria-label={ko("Hierarchy", "계층")}>
2557
3251
  <HierarchyPanel
2558
3252
  selectedId={selectedHierarchyId}
2559
3253
  onSelect={selectHierarchy}
2560
3254
  showB={showB}
2561
3255
  motionFrames={motion?.frames ?? 0}
2562
- promptCount={promptClips.length}
2563
3256
  ikFrames={ikFrames.length}
2564
3257
  ikMode={ikMode}
2565
3258
  waypointCount={waypoints.length}
2566
3259
  sceneObjects={sceneObjects}
3260
+ scenes={scenes}
3261
+ activeSceneId={activeSceneId}
3262
+ onSceneSelect={selectSceneDocument}
3263
+ onSceneCreate={createSceneDocumentFromUi}
3264
+ onSceneDuplicate={duplicateSceneDocumentFromUi}
3265
+ onSceneRename={renameSceneDocumentFromUi}
3266
+ onSceneDelete={deleteSceneDocumentFromUi}
2567
3267
  onAddObject={addSceneObject}
2568
3268
  onRenameObject={renameSceneObject}
2569
3269
  onDuplicateObject={duplicateSelectedSceneObject}
@@ -2574,11 +3274,11 @@ globalThis.playMode = centerTab === "play";
2574
3274
  <div
2575
3275
  className="workspace-splitter workspace-splitter-vertical"
2576
3276
  role="separator"
2577
- aria-label="Resize hierarchy panel"
3277
+ aria-label={ko("Resize hierarchy panel", "계층 패널 크기 조절")}
2578
3278
  onPointerDown={(event) => beginWorkspaceResize("hierarchy", event)}
2579
3279
  />
2580
3280
  <div className="viewport">
2581
- <div className="pane-tabs" role="tablist" aria-label="Center view">
3281
+ <div className="pane-tabs" role="tablist" aria-label={ko("Center view", "가운데 보기")}>
2582
3282
  <button
2583
3283
  type="button"
2584
3284
  role="tab"
@@ -2586,7 +3286,7 @@ globalThis.playMode = centerTab === "play";
2586
3286
  className={centerTab === "scene" ? "active" : ""}
2587
3287
  onClick={() => setCenterTab("scene")}
2588
3288
  >
2589
- Scene
3289
+ {ko("Scene", "장면")}
2590
3290
  </button>
2591
3291
  <button
2592
3292
  type="button"
@@ -2595,51 +3295,51 @@ globalThis.playMode = centerTab === "play";
2595
3295
  className={centerTab === "play" ? "active" : ""}
2596
3296
  onClick={() => setCenterTab("play")}
2597
3297
  >
2598
- PlayView
3298
+ {ko("PlayView", "재생 보기")}
2599
3299
  </button>
2600
3300
  </div>
2601
3301
  {centerTab === "scene" && (
2602
- <div className="editor-toolbar scene-tools" aria-label="Scene tools">
2603
- <div className="tool-switch" role="group" aria-label="Gizmo tool">
3302
+ <div className="editor-toolbar scene-tools" aria-label={ko("Scene tools", "장면 도구")}>
3303
+ <div className="tool-switch" role="group" aria-label={ko("Gizmo tool", "기즈모 도구")}>
2604
3304
  <button
2605
3305
  type="button"
2606
3306
  className={gizmoMode === "move" ? "active" : ""}
2607
- title="Move tool (W)"
3307
+ title={ko("Move tool (W)", "이동 도구 (W)")}
2608
3308
  aria-pressed={gizmoMode === "move"}
2609
3309
  onClick={() => setGizmoMode("move")}
2610
3310
  >
2611
3311
  <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><path d="M8 1v14M1 8h14" stroke="currentColor" strokeWidth="1.4"/><path d="M8 1 6 3h4L8 1zM8 15l-2-2h4l-2 2zM1 8l2-2v4L1 8zM15 8l-2-2v4l2-2z" fill="currentColor"/></svg>
2612
- Move
3312
+ {ko("Move", "이동")}
2613
3313
  </button>
2614
3314
  <button
2615
3315
  type="button"
2616
3316
  className={gizmoMode === "rotate" ? "active" : ""}
2617
- title="Rotate tool (E)"
3317
+ title={ko("Rotate tool (E)", "회전 도구 (E)")}
2618
3318
  aria-pressed={gizmoMode === "rotate"}
2619
3319
  onClick={() => setGizmoMode("rotate")}
2620
3320
  >
2621
3321
  <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><circle cx="8" cy="8" r="5.4" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M13.4 8l2-2v4l-2 2z" fill="currentColor" transform="rotate(45 13.4 8)"/></svg>
2622
- Rotate
3322
+ {ko("Rotate", "회전")}
2623
3323
  </button>
2624
3324
  <button
2625
3325
  type="button"
2626
3326
  className={gizmoMode === "scale" ? "active" : ""}
2627
- title="Scale tool (R)"
3327
+ title={ko("Scale tool (R)", "크기 도구 (R)")}
2628
3328
  aria-pressed={gizmoMode === "scale"}
2629
3329
  onClick={() => setGizmoMode("scale")}
2630
3330
  >
2631
3331
  <svg viewBox="0 0 16 16" aria-hidden="true" className="tool-icon"><rect x="3" y="3" width="7" height="7" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M13 13h-4M13 13V9M13 13l-3.5-3.5" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
2632
- Scale
3332
+ {ko("Scale", "크기")}
2633
3333
  </button>
2634
3334
  </div>
2635
3335
  <button
2636
3336
  type="button"
2637
3337
  className={"snap-switch" + (snapEnabled ? " active" : "")}
2638
- title="Grid snapping — hold Ctrl during a drag to invert"
3338
+ title={ko("Grid snapping — hold Ctrl during a drag to invert", "그리드 스냅 — 드래그 중 Ctrl을 누르면 반대로 작동")}
2639
3339
  aria-pressed={snapEnabled}
2640
3340
  onClick={() => setSnapEnabled((v) => !v)}
2641
3341
  >
2642
- Snap
3342
+ {ko("Snap", "스냅")}
2643
3343
  </button>
2644
3344
  </div>
2645
3345
  )}
@@ -2740,15 +3440,17 @@ globalThis.playMode = centerTab === "play";
2740
3440
  // PlayView is the finished-output player: the move always rides
2741
3441
  // the playhead there. The Follow toggle and authoring-mode gates
2742
3442
  // only protect the Scene tab's manipulation surfaces.
2743
- // A follow cam owns the shot camera outright; keys resume when it is off.
2744
- following={!followCamActive && cameraKeys.length >= 1 && (centerTab === "play" || (moveFollow && !ikMode && !waypointMode && !posing))}
3443
+ // This shot's Camera Block owns the camera while Follow or Rail is active;
3444
+ // editorial camera keys resume when the block returns to Keys mode.
3445
+ following={!followCamActive && hasCameraKeys && (centerTab === "play" || (moveFollow && !ikMode && !waypointMode && !posing))}
2745
3446
  followFrame={tlFrame}
2746
3447
  fps={tlFps}
2747
3448
  keys={cameraKeys}
3449
+ shots={shots}
2748
3450
  anchor={charA}
2749
3451
  camRef={shotCamRef}
2750
3452
  look={look}
2751
- isInterrupted={() => flyingRef.current}
3453
+ isInterrupted={() => flyingRef.current || manualCameraOverrideRef.current}
2752
3454
  onDone={(finalFov) => {
2753
3455
  setMovePlaying(false);
2754
3456
  setFovDeg(Math.round(finalFov * 10) / 10);
@@ -2760,7 +3462,7 @@ globalThis.playMode = centerTab === "play";
2760
3462
  track={followTrack}
2761
3463
  camRef={shotCamRef}
2762
3464
  look={look}
2763
- isInterrupted={() => flyingRef.current}
3465
+ isInterrupted={() => flyingRef.current || manualCameraOverrideRef.current}
2764
3466
  />
2765
3467
  {/* Camera stays live in IK mode but drives the POSER camera,
2766
3468
  never the shot camera: the handle layer only consumes
@@ -2778,6 +3480,7 @@ globalThis.playMode = centerTab === "play";
2778
3480
  onFlyStateChange={(flying) => {
2779
3481
  flyingRef.current = flying;
2780
3482
  }}
3483
+ onCameraChange={commitManualCameraFraming}
2781
3484
  />
2782
3485
  <PoseHandles
2783
3486
  root={posing === "B" ? rigB : rigA}
@@ -2808,8 +3511,7 @@ globalThis.playMode = centerTab === "play";
2808
3511
  waypoints={waypoints}
2809
3512
  activeWaypointFrame={activeWaypointFrame}
2810
3513
  onSelectWaypoint={(frame) => { setActiveWaypointFrame(frame); setTlFrame(Math.min(frame, tlFrameCount - 1)); setWaypointMode(true); }}
2811
- // The bird's-eye board displays and selects; placing and moving
2812
- // waypoints live on the Shot-view floor now, so no move handler.
3514
+ onMoveWaypoint={moveWaypoint}
2813
3515
  // Selection switch first, then the producer begins its
2814
3516
  // transaction (plan §6.4): the settle here commits any
2815
3517
  // previously open drag as one entry so the fresh token
@@ -2825,14 +3527,16 @@ globalThis.playMode = centerTab === "play";
2825
3527
  onObjectMoveEnd={endSceneTransaction}
2826
3528
  cameraRailPoints={railCurve ? railCurve.points : null}
2827
3529
  railDraw={railDraw}
3530
+ subjectTrack={motion ? subjectTrack : null}
2828
3531
  onRailStroke={(stroke) => {
2829
3532
  const simplified = simplifyStroke(stroke, 0.12);
2830
3533
  if (simplified.length < 2) return;
2831
- setCameraRail(simplified);
3534
+ changeCameraRail(simplified);
2832
3535
  setRailDraw(false);
2833
3536
  const curve = buildRail(simplified);
2834
- setToast(`Camera rail drawn — ${curve ? curve.length.toFixed(1) : "?"} m, ${simplified.length} control points`);
3537
+ setToast(isKo ? `카메라 레일 완성 — ${curve ? curve.length.toFixed(1) : "?"} m, 제어점 ${simplified.length}개` : `Camera rail drawn — ${curve ? curve.length.toFixed(1) : "?"} m, ${simplified.length} control points`);
2835
3538
  }}
3539
+ onCameraChange={commitManualCameraFraming}
2836
3540
  />
2837
3541
  {/* Object gizmo: the shot pane's direct manipulation. Off while
2838
3542
  the plan owns the big pane (the pucks are the handles there)
@@ -2887,7 +3591,7 @@ globalThis.playMode = centerTab === "play";
2887
3591
  >
2888
3592
  <span
2889
3593
  className="vp-inset-tag"
2890
- title={workspaceLayout.insetCollapsed ? "Click or ▸ to expand · drag to move" : "Click or ▾ to fold · drag to move"}
3594
+ title={workspaceLayout.insetCollapsed ? ko("Click or ▸ to expand · drag to move", "클릭 또는 ▸로 펼치기 · 드래그로 이동") : ko("Click or ▾ to fold · drag to move", "클릭 또는 ▾로 접기 · 드래그로 이동")}
2891
3595
  onPointerDown={beginInsetDrag}
2892
3596
  >
2893
3597
  <span
@@ -2895,8 +3599,8 @@ globalThis.playMode = centerTab === "play";
2895
3599
  role="button"
2896
3600
  tabIndex={-1}
2897
3601
  aria-expanded={!workspaceLayout.insetCollapsed}
2898
- aria-label={workspaceLayout.insetCollapsed ? "Expand inset view" : "Collapse inset view"}
2899
- title={workspaceLayout.insetCollapsed ? "Expand inset view" : "Collapse inset view"}
3602
+ aria-label={workspaceLayout.insetCollapsed ? ko("Expand inset view", "인셋 보기 펼치기") : ko("Collapse inset view", "인셋 보기 접기")}
3603
+ title={workspaceLayout.insetCollapsed ? ko("Expand inset view", "인셋 보기 펼치기") : ko("Collapse inset view", "인셋 보기 접기")}
2900
3604
  onPointerDown={(e) => e.stopPropagation()}
2901
3605
  // Same one-fold-per-gesture rule as the tag: ignore the
2902
3606
  // second click of a double-click (detail=2).
@@ -2909,7 +3613,7 @@ globalThis.playMode = centerTab === "play";
2909
3613
  >
2910
3614
  {workspaceLayout.insetCollapsed ? "▸" : "▾"}
2911
3615
  </span>
2912
- {planIsMain || ikMode ? "Shot view" : "Top-View"}
3616
+ {planIsMain || ikMode ? ko("Shot view", "샷 뷰") : ko("Top-View", "탑뷰")}
2913
3617
  {!planIsMain && !ikMode && workspaceLayout.planZoom !== 1 && !workspaceLayout.insetCollapsed && (
2914
3618
  <em className="vp-inset-zoom">{workspaceLayout.planZoom.toFixed(2).replace(/\.?0+$/, "")}×</em>
2915
3619
  )}
@@ -2918,7 +3622,7 @@ globalThis.playMode = centerTab === "play";
2918
3622
  <span
2919
3623
  className="vp-inset-resize"
2920
3624
  role="separator"
2921
- aria-label="Resize inset view"
3625
+ aria-label={ko("Resize inset view", "인셋 보기 크기 조절")}
2922
3626
  onPointerDown={beginInsetResize}
2923
3627
  />
2924
3628
  )}
@@ -2931,13 +3635,13 @@ globalThis.playMode = centerTab === "play";
2931
3635
  <span />
2932
3636
  </div>
2933
3637
  <div className={"caption" + (subjectVisible ? "" : " off")} hidden={playMode}>
2934
- {subjectVisible ? slateLine(shot) : "SUBJECT OUT OF FRAME"}
3638
+ {subjectVisible ? slateLineKo(shot) : ko("SUBJECT OUT OF FRAME", "피사체가 프레임 밖에 있어요")}
2935
3639
  </div>
2936
3640
 
2937
3641
  {playMode && !motion && (
2938
3642
  <div className="playview-empty" role="status">
2939
- <strong>No motion yet</strong>
2940
- <span>Generate motion in the Scene tab — PlayView plays the finished result.</span>
3643
+ <strong>{ko("No motion yet", "아직 모션이 없어요")}</strong>
3644
+ <span>{ko("Generate motion in the Scene tab — PlayView plays the finished result.", "장면 탭에서 모션을 생성하세요. 재생 보기는 완성 결과를 보여줍니다.")}</span>
2941
3645
  </div>
2942
3646
  )}
2943
3647
 
@@ -2947,30 +3651,41 @@ globalThis.playMode = centerTab === "play";
2947
3651
  poses={allPoses}
2948
3652
  selectedId={studioPick}
2949
3653
  closing={posingClosing}
3654
+ motionActive={Boolean(motion)}
2950
3655
  onSelect={setStudioPick}
2951
- onApply={() => {
2952
- const pose = allPoses.find((p) => p.id === studioPick);
2953
- if (pose) setPosed(pose);
3656
+ onApply={(selectedPoseId) => {
3657
+ const pose = allPoses.find((p) => p.id === selectedPoseId);
3658
+ if (pose) {
3659
+ const hadMotion = Boolean(motion);
3660
+ if (hadMotion) clearMotion();
3661
+ setPosed(pose);
3662
+ closeStudio();
3663
+ setToast(hadMotion ? ko("Cleared the current motion and applied the pose", "현재 모션을 지우고 포즈를 적용했어요") : ko("Pose applied", "포즈를 적용했어요"));
3664
+ } else {
3665
+ setToast(ko("Couldn't find the selected pose — pick again", "선택한 포즈를 찾지 못했어요. 다시 골라 주세요"));
3666
+ }
2954
3667
  }}
2955
3668
  onReset={() => {
3669
+ if (motion) clearMotion();
2956
3670
  setStudioPick(DEFAULT_POSE.id);
2957
3671
  setPosed(DEFAULT_POSE);
3672
+ setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
2958
3673
  }}
2959
3674
  onSave={savePose}
2960
3675
  onDelete={removePose}
2961
3676
  onClose={closeStudio}
2962
3677
  />
2963
3678
  )}
3679
+ </div>
2964
3680
  </div>
2965
- </div>
2966
3681
 
2967
3682
  <div
2968
3683
  className="workspace-splitter workspace-splitter-vertical"
2969
3684
  role="separator"
2970
- aria-label="Resize hierarchy and inspector panel"
3685
+ aria-label={ko("Resize hierarchy and inspector panel", "계층 및 속성 패널 크기 조절")}
2971
3686
  onPointerDown={(event) => beginWorkspaceResize("sidebar", event)}
2972
3687
  />
2973
- <aside className="panel hierarchy-sidebar inspector-sidebar" data-inspector={selectedHierarchyId}>
3688
+ <aside className="panel hierarchy-sidebar inspector-sidebar" data-inspector={selectedHierarchyId} data-tab={sidebarTab}>
2974
3689
  {/* Save failures live above the tab content, not inside the Props
2975
3690
  card: that card is hidden whenever any hierarchy node is
2976
3691
  selected, and saves fire exactly while objects are being
@@ -2985,12 +3700,32 @@ globalThis.playMode = centerTab === "play";
2985
3700
  </p>
2986
3701
  )}
2987
3702
  <section className="inspector-pane">
2988
- <div className="inspector-heading">
2989
- <strong>Inspector</strong>
2990
- <span className="inspector-heading-selection">{selectedSceneObject?.name ?? HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId] ?? "Selection"}</span>
3703
+ <div className="inspector-tabs" role="tablist" aria-label={ko("Sidebar tabs", "사이드바 탭")}>
3704
+ {[
3705
+ ["inspector", ko("Inspector", "속성")],
3706
+ ["shot", ko("Shot", "샷")],
3707
+ ["motion", ko("Motion", "모션")],
3708
+ ].map(([tab, label]) => (
3709
+ <button
3710
+ key={tab}
3711
+ type="button"
3712
+ role="tab"
3713
+ aria-selected={sidebarTab === tab}
3714
+ className={sidebarTab === tab ? "active" : ""}
3715
+ onClick={() => setSidebarTab(tab)}
3716
+ >
3717
+ {label}
3718
+ </button>
3719
+ ))}
2991
3720
  </div>
3721
+ {sidebarTab === "inspector" && (
3722
+ <div className="inspector-heading">
3723
+ <strong>{ko("Inspector", "속성")}</strong>
3724
+ <span className="inspector-heading-selection">{selectedSceneObject ? sceneObjectNameDisplayKo(selectedSceneObject.name) : HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId] ?? ko("Selection", "선택 항목")}</span>
3725
+ </div>
3726
+ )}
2992
3727
  <div className="inspector-scroll">
2993
- <Foldout hidden={selectedHierarchyId !== "shot"} title="Shot type">
3728
+ <Foldout hidden={sidebarTab !== "shot"} title={ko("Shot type", "샷 종류")}>
2994
3729
  <div className="presets">
2995
3730
  {Object.entries(PRESETS).map(([key, p]) => (
2996
3731
  <button key={key} className={preset === key ? "active" : ""} onClick={() => applyPreset(key)}>
@@ -3000,32 +3735,34 @@ globalThis.playMode = centerTab === "play";
3000
3735
  </div>
3001
3736
  </Foldout>
3002
3737
 
3003
- <Foldout hidden={selectedHierarchyId !== "camera"} title="Camera">
3004
- <Slider label="Lens (FOV)" min={14} max={90} step={1} value={fovDeg} unit="°" onChange={setFovDeg} />
3738
+ {/* Camera animation is authored against the same playhead as motion,
3739
+ so keep its controls beside the Motion tools as well as Shot setup. */}
3740
+ <Foldout hidden={!(sidebarTab === "shot" || sidebarTab === "motion" || (sidebarTab === "inspector" && selectedHierarchyId === "camera"))} title={ko("Camera", "카메라")}>
3741
+ <Slider label={ko("Lens (FOV)", "렌즈 (FOV)")} min={14} max={90} step={1} value={fovDeg} unit="°" onChange={setFovDeg} />
3005
3742
  <div className="readout">
3006
- <span title="camera to subject">{shot.distance.toFixed(2)} m</span>
3007
- <span title="nearest prime on a full-frame sensor">{shot.focalMm} mm</span>
3008
- <span title="angle relative to the subject's eyes">{shot.elevationDeg.toFixed(0)}°</span>
3743
+ <span title={ko("camera to subject", "카메라와 피사체 거리")}>{shot.distance.toFixed(2)} m</span>
3744
+ <span title={ko("nearest prime on a full-frame sensor", "풀프레임 기준 가장 가까운 단렌즈")}>{shot.focalMm} mm</span>
3745
+ <span title={ko("angle relative to the subject's eyes", "피사체 눈높이 기준 각도")}>{shot.elevationDeg.toFixed(0)}°</span>
3009
3746
  </div>
3010
3747
  <button className="btn ghost" onClick={() => setNonce((n) => n + 1)}>
3011
- Recenter on subject
3748
+ {ko("Recenter on subject", "피사체 다시 맞추기")}
3012
3749
  </button>
3013
3750
 
3014
- <h3 className="move-head">Move keys</h3>
3751
+ <h3 className="move-head">{ko("Move keys", "움직임 키")}</h3>
3015
3752
  <div className="move-ab">
3016
3753
  <button
3017
3754
  type="button"
3018
3755
  className="btn ghost"
3019
- title="Key the current framing at the playhead frame"
3756
+ title={ko("Key the current framing at the playhead frame", "현재 프레이밍을 재생 헤드 프레임에 키로 저장")}
3020
3757
  onClick={() => addCameraKeyframe(tlFrame)}
3021
3758
  >
3022
- + Key here
3759
+ {ko("+ Key here", "+ 여기 키 찍기")}
3023
3760
  </button>
3024
3761
  <button
3025
3762
  type="button"
3026
3763
  className="btn ghost"
3027
- disabled={cameraKeys.length < 1}
3028
- title="Play the move. With Follow on it plays the timeline too, so character motion rides along; right-drag interrupts"
3764
+ disabled={!hasCameraKeys}
3765
+ title={ko("Play the move. With Follow on it plays the timeline too, so character motion rides along; right-drag interrupts", "카메라 움직임을 재생합니다. 따라가기가 켜져 있으면 타임라인도 함께 재생되어 캐릭터 모션이 따라옵니다. 오른쪽 드래그로 중단됩니다")}
3029
3766
  onClick={() => {
3030
3767
  if (followPreviewArmed) {
3031
3768
  if (tlPlaying) {
@@ -3039,89 +3776,54 @@ globalThis.playMode = centerTab === "play";
3039
3776
  setMovePlaying((playing) => !playing);
3040
3777
  }}
3041
3778
  >
3042
- {previewActive ? "Stop" : "Preview"}
3779
+ {previewActive ? ko("Stop", "정지") : ko("Preview", "미리보기")}
3043
3780
  </button>
3044
3781
  <button
3045
3782
  type="button"
3046
3783
  className="btn ghost"
3047
- disabled={cameraKeys.length < 1}
3048
- title="Slave the move to the timeline: play or scrub and the camera rides along. Turn off to fly freely while keys stay set"
3784
+ disabled={!hasCameraKeys}
3785
+ title={ko("Slave the move to the timeline: play or scrub and the camera rides along. Turn off to fly freely while keys stay set", "움직임을 타임라인에 연결합니다. 재생하거나 스크럽하면 카메라가 함께 움직입니다. 끄면 키는 유지한 채 자유롭게 이동할 수 있습니다")}
3049
3786
  onClick={() => setMoveFollow((follow) => !follow)}
3050
3787
  >
3051
- {moveFollow ? "Follow ✓" : "Follow"}
3788
+ {moveFollow ? ko("Follow ✓", "따라가기 ✓") : ko("Follow", "따라가기")}
3052
3789
  </button>
3053
3790
  <button type="button" className="btn ghost" disabled={cameraKeys.length < 1} onClick={clearMove}>
3054
- Clear
3791
+ {ko("Clear", "지우기")}
3055
3792
  </button>
3056
3793
  <button
3057
3794
  type="button"
3058
3795
  className={"btn ghost" + (recState === "recording" ? " rec-live" : "")}
3059
- disabled={cameraKeys.length < 1 && !motion}
3060
- title="Play the piece in PlayView and save it as a video file — camera move and character motion, no editor chrome"
3796
+ disabled={!hasCameraKeys && !motion}
3797
+ title={ko("Play the piece in PlayView and save it as a video file — camera move and character motion, no editor chrome", "재생 보기에서 장면을 재생하고 영상 파일로 저장합니다. 카메라 움직임과 캐릭터 모션만 담고 편집 UI는 제외됩니다")}
3061
3798
  onClick={toggleShotRecording}
3062
3799
  >
3063
- {recState === "recording" ? "■ Stop rec" : "● Record"}
3800
+ {recState === "recording" ? ko("■ Stop rec", "■ 녹화 정지") : ko("● Record", "● 녹화")}
3064
3801
  </button>
3065
3802
  </div>
3066
3803
  {moveSequence ? (
3067
- <div className="move-slate" title="derived from the keyframings, not chosen from a list">
3068
- {moveSequence.slate} · {moveSequence.spanS}s
3804
+ <div className="move-slate" title={ko("derived from the keyframings, not chosen from a list", "목록에서 고른 값이 아니라 키프레임에서 계산된 움직임입니다")}>
3805
+ {moveSequence.displaySlate} · {moveSequence.spanS}{ko("s", "초")}
3069
3806
  </div>
3070
3807
  ) : (
3071
3808
  <div className="move-slate">
3072
3809
  {cameraKeys.length === 1
3073
- ? `locked-off hold from frame ${cameraKeys[0].frame} — click the Camera lane at another frame to add a move`
3074
- : "click the Camera timeline lane to key the current framing at that frame"}
3810
+ ? (isKo ? `프레임 ${cameraKeys[0].frame}부터 고정 샷 — 샷 블록 아래 빈 줄을 클릭해 움직임을 추가하세요` : `locked-off hold from frame ${cameraKeys[0].frame} — click the empty lower strip in a Shot block to add a move`)
3811
+ : ko("click a Shot block's lower strip to key the current framing at that frame", "샷 블록 아래 빈 줄을 클릭하면 해당 프레임에 현재 프레이밍을 저장합니다")}
3075
3812
  </div>
3076
3813
  )}
3077
3814
 
3078
- <h3 className="move-head">Follow cam</h3>
3079
- <div className="move-ab">
3080
- <button
3081
- type="button"
3082
- className="btn ghost"
3083
- title="Derive the camera from the subject's trajectory: a steadicam trailing behind, or a dolly on a drawn rail. Keys pause while it owns the camera"
3084
- onClick={() => setFollowCam((c) => ({ ...c, enabled: !c.enabled }))}
3085
- >
3086
- {followCam.enabled ? "Follow ●" : "Follow cam"}
3087
- </button>
3088
- <button
3089
- type="button"
3090
- className={"btn ghost" + (railDraw ? " rec-live" : "")}
3091
- disabled={!followCam.enabled}
3092
- title="Draw the camera rail in the Top-View: drag one stroke across the deck. Esc mid-stroke cancels"
3093
- onClick={() => {
3094
- const next = !railDraw;
3095
- setRailDraw(next);
3096
- if (next) {
3097
- setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
3098
- setToast("Draw the rail in the Top-View — drag one stroke across the deck");
3099
- }
3100
- }}
3101
- >
3102
- {railDraw ? "Drawing…" : "✏ Rail"}
3103
- </button>
3104
- <button type="button" className="btn ghost" disabled={!cameraRail} onClick={() => setCameraRail(null)}>
3105
- Clear rail
3106
- </button>
3107
- </div>
3108
- {followCam.enabled && (
3109
- <>
3110
- <Slider label="Distance" min={1} max={8} step={0.1} value={followCam.distance} unit=" m" onChange={(distance) => setFollowCam((c) => ({ ...c, distance }))} />
3111
- <Slider label="Height" min={0.4} max={4} step={0.05} value={followCam.height} unit=" m" onChange={(height) => setFollowCam((c) => ({ ...c, height }))} />
3112
- <Slider label="Response" min={0.2} max={2} step={0.05} value={followCam.response} unit=" s" onChange={(response) => setFollowCam((c) => ({ ...c, response }))} />
3113
- <Slider label="Lead" min={0} max={0.6} step={0.05} value={followCam.lead} unit=" s" onChange={(lead) => setFollowCam((c) => ({ ...c, lead }))} />
3114
- <div className="move-slate" title="what the rig is doing, derived from the setup — a rail makes it a dolly, none makes it a steadicam">
3115
- {railCurve ? `DOLLY ON RAIL · ${railCurve.length.toFixed(1)} m` : "STEADICAM FOLLOW"} · HOLD {followCam.distance.toFixed(1)} m
3116
- </div>
3117
- </>
3118
- )}
3815
+ <h3 className="move-head">{ko("Follow cam", "팔로우 카메라")}</h3>
3816
+ <p className="camera-editor-pointer">
3817
+ {activeShot
3818
+ ? ko(`Editing ${activeShot.name} in the timeline camera bar below.`, `아래 타임라인 카메라 바에서 ${activeShot.name}을 편집합니다.`)
3819
+ : ko("Select a Shot block below to edit its camera.", "아래에서 샷 블록을 선택하면 카메라를 편집할 수 있습니다.")}
3820
+ </p>
3119
3821
  </Foldout>
3120
3822
 
3121
- <Foldout hidden={!["characters", "characterA", "characterA.character", "characterB", "characterB.character"].includes(selectedHierarchyId)} title={showB ? "Subjects" : "Subject"}>
3823
+ <Foldout hidden={sidebarTab !== "inspector" || !["characters", "characterA", "characterB"].includes(selectedHierarchyId)} title={showB ? ko("Subjects", "인물들") : ko("Subject", "인물")}>
3122
3824
  <div className={"subjects-row" + (showB ? "" : " single")}>
3123
3825
  <SubjectBox
3124
- label="Subject 1"
3826
+ label={ko("Subject 1", "인물 1")}
3125
3827
  value={charA}
3126
3828
  onChange={setCharA}
3127
3829
  onPose={() => openStudio("A")}
@@ -3129,7 +3831,7 @@ globalThis.playMode = centerTab === "play";
3129
3831
  />
3130
3832
  {showB && (
3131
3833
  <SubjectBox
3132
- label="Subject 2"
3834
+ label={ko("Subject 2", "인물 2")}
3133
3835
  value={charB}
3134
3836
  onChange={setCharB}
3135
3837
  onRemove={() => setShowB(false)}
@@ -3141,44 +3843,44 @@ globalThis.playMode = centerTab === "play";
3141
3843
  {!showB && (
3142
3844
  <button type="button" className="add-subject" onClick={() => setShowB(true)}>
3143
3845
  <span className="as-plus">+</span>
3144
- <span>Add second subject</span>
3846
+ <span>{ko("Add second subject", "두 번째 인물 추가")}</span>
3145
3847
  </button>
3146
3848
  )}
3147
3849
  </Foldout>
3148
3850
 
3149
- <Foldout hidden={!["characters", "characterA", "characterA.character", "characterB", "characterB.character"].includes(selectedHierarchyId)} title="Pose">
3150
- <Field label={showB ? "Subject 1 pose" : "Pose"}>
3851
+ <Foldout hidden={sidebarTab !== "inspector" || !["characters", "characterA", "characterB"].includes(selectedHierarchyId)} title={ko("Pose", "포즈")}>
3852
+ <Field label={showB ? ko("Subject 1 pose", "인물 1 포즈") : ko("Pose", "포즈")}>
3151
3853
  <Dropdown
3152
- ariaLabel="Subject 1 pose"
3854
+ ariaLabel={ko("Subject 1 pose", "인물 1 포즈")}
3153
3855
  value={poseA?.id}
3154
- options={allPoses.map((p) => ({ value: p.id, label: p.label }))}
3856
+ options={allPoses.map((p) => ({ value: p.id, label: poseLabelKo(p) }))}
3155
3857
  onChange={(id) => setPoseA(allPoses.find((p) => p.id === id))}
3156
3858
  />
3157
3859
  </Field>
3158
3860
  {showB && (
3159
- <Field label="Subject 2 pose">
3861
+ <Field label={ko("Subject 2 pose", "인물 2 포즈")}>
3160
3862
  <Dropdown
3161
- ariaLabel="Subject 2 pose"
3863
+ ariaLabel={ko("Subject 2 pose", "인물 2 포즈")}
3162
3864
  value={poseB?.id}
3163
- options={allPoses.map((p) => ({ value: p.id, label: p.label }))}
3865
+ options={allPoses.map((p) => ({ value: p.id, label: poseLabelKo(p) }))}
3164
3866
  onChange={(id) => setPoseB(allPoses.find((p) => p.id === id))}
3165
3867
  />
3166
3868
  </Field>
3167
3869
  )}
3168
3870
  </Foldout>
3169
3871
 
3170
- <Foldout hidden={selectedHierarchyId !== "shot"} title="Prompt">
3872
+ <Foldout hidden={sidebarTab !== "shot"} title={ko("Prompt", "프롬프트")}>
3171
3873
  <div className="segmented" data-active={mode}>
3172
3874
  <button className={mode === "image" ? "active" : ""} onClick={() => setMode("image")}>
3173
- Image
3875
+ {ko("Image", "이미지")}
3174
3876
  </button>
3175
3877
  <button className={mode === "video" ? "active" : ""} onClick={() => setMode("video")}>
3176
- Video
3878
+ {ko("Video", "영상")}
3177
3879
  </button>
3178
3880
  </div>
3179
- <Field label="Model">
3881
+ <Field label={ko("Model", "모델")}>
3180
3882
  <Dropdown
3181
- ariaLabel="Model"
3883
+ ariaLabel={ko("Model", "모델")}
3182
3884
  value={mode === "video" ? videoModel : imageModel}
3183
3885
  options={models.map((m) => ({ value: m.id, label: m.label }))}
3184
3886
  onChange={mode === "video" ? setVideoModel : setImageModel}
@@ -3188,95 +3890,95 @@ globalThis.playMode = centerTab === "play";
3188
3890
  <div className="sheet-checks">
3189
3891
  <label className="check">
3190
3892
  <input type="checkbox" checked={hasCharSheet} onChange={(e) => setHasCharSheet(e.target.checked)} />
3191
- <span>I have a character sheet</span>
3893
+ <span>{ko("I have a character sheet", "캐릭터 시트가 있어요")}</span>
3192
3894
  </label>
3193
3895
  <label className="check">
3194
3896
  <input type="checkbox" checked={hasEnvSheet} onChange={(e) => setHasEnvSheet(e.target.checked)} />
3195
- <span>I have an environment sheet</span>
3897
+ <span>{ko("I have an environment sheet", "환경 시트가 있어요")}</span>
3196
3898
  </label>
3197
3899
  </div>
3198
3900
 
3199
3901
  {!hasCharSheet && (
3200
- <Field label={showB ? "Subject 1" : "Subject"}>
3902
+ <Field label={showB ? ko("Subject 1", "인물 1") : ko("Subject", "인물")}>
3201
3903
  <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} />
3202
3904
  </Field>
3203
3905
  )}
3204
3906
  {!hasCharSheet && showB && (
3205
- <Field label="Subject 2">
3907
+ <Field label={ko("Subject 2", "인물 2")}>
3206
3908
  <input type="text" value={subject2} onChange={(e) => setSubject2(e.target.value)} />
3207
3909
  </Field>
3208
3910
  )}
3209
3911
  {!hasEnvSheet && (
3210
- <Field label="Environment">
3912
+ <Field label={ko("Environment", "환경")}>
3211
3913
  <input type="text" value={environment} onChange={(e) => setEnvironment(e.target.value)} />
3212
3914
  </Field>
3213
3915
  )}
3214
3916
  {mode === "video" && !moveSequence && (
3215
- <Field label="Camera move">
3917
+ <Field label={ko("Camera move", "카메라 움직임")}>
3216
3918
  <Dropdown
3217
- ariaLabel="Camera move"
3919
+ ariaLabel={ko("Camera move", "카메라 움직임")}
3218
3920
  value={cameraMove}
3219
- options={CAMERA_MOVES.map((m) => ({ value: m, label: m }))}
3921
+ options={CAMERA_MOVES.map((m) => ({ value: m, label: cameraMoveLabelKo(m) }))}
3220
3922
  onChange={setCameraMove}
3221
3923
  />
3222
3924
  </Field>
3223
3925
  )}
3224
3926
  {mode === "video" && !moveSequence && cameraMove === CUSTOM_MOVE && (
3225
- <Field label="Custom camera move">
3927
+ <Field label={ko("Custom camera move", "직접 쓴 카메라 움직임")}>
3226
3928
  <input
3227
3929
  type="text"
3228
3930
  value={customMove}
3229
3931
  onChange={(e) => setCustomMove(e.target.value)}
3230
- placeholder="describe the camera move"
3932
+ placeholder={ko("describe the camera move", "카메라 움직임을 설명하세요")}
3231
3933
  />
3232
3934
  </Field>
3233
3935
  )}
3234
3936
  {mode === "video" && moveSequence && (
3235
- <Field label="Camera move">
3937
+ <Field label={ko("Camera move", "카메라 움직임")}>
3236
3938
  <div
3237
3939
  className="move-slate inline"
3238
- title="authored from the timeline keyframings — select Camera in the hierarchy to edit"
3940
+ title={ko("authored from the timeline keyframings — select Camera in the hierarchy to edit", "타임라인 키프레임으로 만든 움직임입니다. 편집하려면 계층에서 카메라를 선택하세요")}
3239
3941
  >
3240
- {moveSequence.slate} · {moveSequence.spanS}s
3942
+ {moveSequence.displaySlate} · {moveSequence.spanS}{ko("s", "초")}
3241
3943
  </div>
3242
3944
  </Field>
3243
3945
  )}
3244
- <Field label="Look / style">
3946
+ <Field label={ko("Look / style", "룩 / 스타일")}>
3245
3947
  <input type="text" value={style} onChange={(e) => setStyle(e.target.value)} />
3246
3948
  </Field>
3247
3949
 
3248
3950
  <button
3249
3951
  className="btn ghost full"
3250
- title="Export the posed character as an ARDY CozyClayPoseV1 JSON"
3952
+ title={ko("Export the posed character as an ARDY CozyClayPoseV1 JSON", "포즈가 잡힌 캐릭터를 ARDY CozyClayPoseV1 JSON으로 내보내기")}
3251
3953
  onClick={downloadArdyPose}
3252
3954
  >
3253
- Export ARDY pose
3955
+ {ko("Export ARDY pose", "ARDY 포즈 내보내기")}
3254
3956
  </button>
3255
3957
  <button className="btn primary full generate" onClick={generate}>
3256
- Generate
3958
+ {ko("Generate", "만들기")}
3257
3959
  </button>
3258
3960
  </Foldout>
3259
- <Foldout hidden={!["characterA.motion", "characterA.baseMotion"].includes(selectedHierarchyId)} title="ARDY motion">
3961
+ <Foldout hidden={sidebarTab !== "motion"} title={ko("ARDY motion", "ARDY 모션")}>
3260
3962
  {bridge === null ? (
3261
- <p className="ardy-hint">Checking for the dev bridge…</p>
3963
+ <p className="ardy-hint">{ko("Checking for the dev bridge…", "개발 브리지를 확인하는 중…")}</p>
3262
3964
  ) : bridge.ok ? (
3263
3965
  <>
3264
3966
  <p className="ardy-meta">
3265
- {bridge.host ?? "box"} · {bridge.device ?? "device?"} · encoder{" "}
3266
- {bridge.encoder ?? "?"}
3967
+ {bridge.host ?? ko("box", "로컬")} · {isKo ? `장치 ${bridge.device ?? "알 수 없음"}` : bridge.device ?? "device?"} · {ko("encoder", "인코더")}{" "}
3968
+ {bridge.encoder ?? ko("?", "알 수 없음")}
3267
3969
  </p>
3268
- <p className="ardy-hint">Neutral skeleton · block boundaries and IK keys are pinned automatically.</p>
3269
- <Field label="Motion prompt">
3970
+ <p className="ardy-hint">{ko("Neutral skeleton · block boundaries and IK keys are pinned automatically.", "중립 스켈레톤 · 블록 경계와 IK 키는 자동으로 고정됩니다.")}</p>
3971
+ <Field label={ko("Motion prompt", "모션 프롬프트")}>
3270
3972
  <input
3271
3973
  type="text"
3272
3974
  value={ardyPrompt}
3273
3975
  onChange={(e) => setArdyPrompt(e.target.value)}
3274
- placeholder="what the subject should do"
3976
+ placeholder={ko("what the subject should do", "피사체가 할 동작을 설명하세요")}
3275
3977
  maxLength={ARDY_PROMPT_MAX}
3276
3978
  />
3277
- <span className="ardy-clamp">max {ARDY_PROMPT_MAX} chars · required</span>
3979
+ <span className="ardy-clamp">{isKo ? `최대 ${ARDY_PROMPT_MAX}자 · 필수` : `max ${ARDY_PROMPT_MAX} chars · required`}</span>
3278
3980
  </Field>
3279
- <Field label="Duration (s)">
3981
+ <Field label={ko("Duration (s)", "길이 (초)")}>
3280
3982
  <input
3281
3983
  type="number"
3282
3984
  min={ARDY_DURATION_MIN}
@@ -3286,19 +3988,19 @@ globalThis.playMode = centerTab === "play";
3286
3988
  onChange={(e) => changeDuration(e.target.value)}
3287
3989
  />
3288
3990
  </Field>
3289
- <Field label="Seed (optional)">
3991
+ <Field label={ko("Seed (optional)", "Seed (선택)")}>
3290
3992
  <input
3291
3993
  type="text"
3292
3994
  inputMode="numeric"
3293
3995
  value={ardySeed}
3294
3996
  onChange={(e) => changeArdySeed(e.target.value)}
3295
- placeholder="empty = random"
3997
+ placeholder={ko("empty = random", "비우면 랜덤")}
3296
3998
  />
3297
- <span className="ardy-clamp">RT default 2 · empty = random · 0..{ARDY_SEED_MAX}</span>
3999
+ <span className="ardy-clamp">{isKo ? `RT 기본값 2 · 비우면 랜덤 · 0..${ARDY_SEED_MAX}` : `RT default 2 · empty = random · 0..${ARDY_SEED_MAX}`}</span>
3298
4000
  </Field>
3299
4001
  {ardyRunning ? (
3300
4002
  <button type="button" className="btn ghost full" onClick={cancelArdy}>
3301
- Cancel run
4003
+ {ko("Cancel run", "실행 취소")}
3302
4004
  </button>
3303
4005
  ) : (
3304
4006
  <button
@@ -3306,56 +4008,56 @@ globalThis.playMode = centerTab === "play";
3306
4008
  className="btn primary full generate"
3307
4009
  onClick={() => runArdy()}
3308
4010
  >
3309
- Generate motion
4011
+ {ko("Generate motion", "모션 생성")}
3310
4012
  </button>
3311
4013
  )}
3312
4014
  {ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
3313
4015
  {ardyReport && (
3314
4016
  <div className="ardy-report">
3315
4017
  <div className="ardy-report-grid">
3316
- <span>shape mean error</span>
4018
+ <span>{ko("shape mean error", "형태 평균 오차")}</span>
3317
4019
  <b>{fmtMeters(ardyReport.shape_mean_error_m)}</b>
3318
- <span>shape max error</span>
4020
+ <span>{ko("shape max error", "형태 최대 오차")}</span>
3319
4021
  <b>{fmtMeters(ardyReport.shape_max_error_m)}</b>
3320
- <span>max jump</span>
4022
+ <span>{ko("max jump", "최대 점프")}</span>
3321
4023
  <b>{fmtMeters(ardyReport.continuity?.max_jump_m)}</b>
3322
4024
  </div>
3323
4025
  <p className="ardy-caveat">
3324
- Shape error proves joint-center placement only —{" "}
4026
+ {ko("Shape error proves joint-center placement only —", "형태 오차는 관절 중심 배치만 검증합니다 —")}{" "}
3325
4027
  {ardyReport.surface_contact_verified
3326
- ? "contact verified"
3327
- : "foot-to-floor contact NOT verified"}{" "}
3328
- · target_space {ardyReport.target_space ?? "unknown"}
4028
+ ? ko("contact verified", "접촉 검증됨")
4029
+ : ko("foot-to-floor contact NOT verified", "발과 바닥의 접촉은 검증되지 않음")}{" "}
4030
+ · {ko("target_space", "대상 좌표계")} {ardyReport.target_space ?? ko("unknown", "알 수 없음")}
3329
4031
  </p>
3330
4032
  </div>
3331
4033
  )}
3332
4034
  {ardyOutcome?.ok && (
3333
4035
  <>
3334
4036
  <p className="ardy-outcome done">
3335
- Output <code>{ardyOutcome.output}</code> ({ardyOutcome.bytes} bytes)
4037
+ {ko("Output", "출력")} <code>{ardyOutcome.output}</code> ({ardyOutcome.bytes}{ko(" bytes", "바이트")})
3336
4038
  {ardyOutcome.motionUrl && (
3337
4039
  <>
3338
4040
  {" "}
3339
- · motion <code>{ardyOutcome.motionUrl}</code>
4041
+ · {ko("motion", "모션")} <code>{ardyOutcome.motionUrl}</code>
3340
4042
  </>
3341
4043
  )}
3342
4044
  </p>
3343
4045
  {motionBusy ? (
3344
- <p className="ardy-hint">Decoding motion…</p>
4046
+ <p className="ardy-hint">{ko("Decoding motion…", "모션 디코딩 중…")}</p>
3345
4047
  ) : motion ? (
3346
4048
  <p className="ardy-outcome done">
3347
- Motion loaded — {motion.frames} frames @ {motion.fps} fps, playing on Subject 1
4049
+ {isKo ? `모션 로드됨 — ${motion.frames}프레임 @ ${motion.fps} fps, 인물 1에 재생 중` : `Motion loaded — ${motion.frames} frames @ ${motion.fps} fps, playing on Subject 1`}
3348
4050
  </p>
3349
4051
  ) : motionError ? (
3350
4052
  <>
3351
- <p className="ardy-outcome error">Motion decode failed: {motionError}</p>
4053
+ <p className="ardy-outcome error">{ko("Motion decode failed:", "모션 디코딩 실패:")} {motionError}</p>
3352
4054
  {ardyOutcome.motionUrl && (
3353
4055
  <button
3354
4056
  type="button"
3355
4057
  className="btn ghost full"
3356
4058
  onClick={() => loadMotion(ardyOutcome.motionUrl, undefined, ardyOutcome.rotationDeg ?? charA.rot)}
3357
4059
  >
3358
- Retry load
4060
+ {ko("Retry load", "다시 로드")}
3359
4061
  </button>
3360
4062
  )}
3361
4063
  </>
@@ -3369,17 +4071,27 @@ globalThis.playMode = centerTab === "play";
3369
4071
  ) : (
3370
4072
  <>
3371
4073
  <p className="ardy-hint">
3372
- Motion generation runs on your own machine, so it is off here. The clip
3373
- on the timeline was generated ahead of time; staging, paths, cameras and
3374
- playback all work without it. To generate your own, clone the repo and
3375
- start the bridge with <code>node tools/ardy/bridge.mjs</code>.
4074
+ {isKo ? (
4075
+ <>
4076
+ 모션 생성은 사용자의 컴퓨터에서 실행되므로 여기서는 꺼져 있어요. 타임라인의 클립은 미리 생성된 샘플입니다.
4077
+ 스테이징, 경로, 카메라, 재생은 브리지 없이도 작동합니다. 직접 생성하려면 저장소를 클론하고
4078
+ <code>node tools/ardy/bridge.mjs</code>로 브리지를 시작하세요.
4079
+ </>
4080
+ ) : (
4081
+ <>
4082
+ Motion generation runs on your own machine, so it is off here. The clip
4083
+ on the timeline was generated ahead of time; staging, paths, cameras and
4084
+ playback all work without it. To generate your own, clone the repo and
4085
+ start the bridge with <code>node tools/ardy/bridge.mjs</code>.
4086
+ </>
4087
+ )}
3376
4088
  </p>
3377
4089
  {bridge.reason && <p className="ardy-hint">{bridge.reason}</p>}
3378
4090
  </>
3379
4091
  )}
3380
4092
  </Foldout>
3381
- <Foldout hidden={selectedHierarchyId !== "characterA.promptBlocks"} title="Prompt Blocks">
3382
- <p className="inspector-hint">Blocks define what ARDY generates over each frame range. Selecting one also moves editing context to that prompt.</p>
4093
+ <Foldout hidden={sidebarTab !== "motion"} title={ko("Prompt Blocks", "프롬프트 블록")}>
4094
+ <p className="inspector-hint">{ko("Blocks define what ARDY generates over each frame range. Selecting one also moves editing context to that prompt.", "블록은 각 프레임 범위에서 ARDY가 생성할 내용을 정합니다. 블록을 선택하면 편집 기준도 해당 프롬프트로 이동합니다.")}</p>
3383
4095
  <div className="inspector-list">
3384
4096
  {promptClips.map((clip) => (
3385
4097
  <button
@@ -3392,13 +4104,13 @@ globalThis.playMode = centerTab === "play";
3392
4104
  setTlFrame(Math.min(clip.startFrame, tlFrameCount - 1));
3393
4105
  }}
3394
4106
  >
3395
- <span>{clip.text || "Untitled motion"}</span>
4107
+ <span>{clip.text || ko("Untitled motion", "이름 없는 모션")}</span>
3396
4108
  <small>{clip.startFrame}–{clip.endFrame}f</small>
3397
4109
  </button>
3398
4110
  ))}
3399
4111
  </div>
3400
4112
  {selectedPromptId && (
3401
- <Field label="Selected block prompt">
4113
+ <Field label={ko("Selected block prompt", "선택한 블록 프롬프트")}>
3402
4114
  <input
3403
4115
  type="text"
3404
4116
  value={promptClips.find((clip) => clip.id === selectedPromptId)?.text ?? ""}
@@ -3406,7 +4118,7 @@ globalThis.playMode = centerTab === "play";
3406
4118
  changePromptClip(selectedPromptId, event.target.value);
3407
4119
  setArdyPrompt(event.target.value);
3408
4120
  }}
3409
- placeholder="describe this motion block"
4121
+ placeholder={ko("describe this motion block", "이 모션 블록을 설명하세요")}
3410
4122
  />
3411
4123
  </Field>
3412
4124
  )}
@@ -3417,78 +4129,43 @@ globalThis.playMode = centerTab === "play";
3417
4129
  onClick={runAllPromptBlocks}
3418
4130
  >
3419
4131
  {ardyRunning
3420
- ? `Generating ${promptClips.length} blocks…`
3421
- : `Generate all ${promptClips.length} blocks`}
4132
+ ? (isKo ? `${promptClips.length}개 블록 생성 중…` : `Generating ${promptClips.length} blocks…`)
4133
+ : (isKo ? `${promptClips.length}개 블록 모두 생성` : `Generate all ${promptClips.length} blocks`)}
3422
4134
  </button>
3423
- {!bridge?.ok && <p className="ardy-hint">Start the ARDY bridge to enable generation.</p>}
4135
+ {!bridge?.ok && <p className="ardy-hint">{ko("Start the ARDY bridge to enable generation.", "생성을 사용하려면 ARDY 브리지를 시작하세요.")}</p>}
3424
4136
  {ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
3425
4137
  <button type="button" className="btn ghost full" onClick={() => addPromptClip(tlFrame)}>
3426
- Add block at frame {tlFrame}
4138
+ {isKo ? `프레임 ${tlFrame}에 블록 추가` : `Add block at frame ${tlFrame}`}
3427
4139
  </button>
3428
4140
  </Foldout>
3429
4141
 
3430
- <Foldout hidden={selectedHierarchyId !== "characterA.ik"} title="IK Corrections">
3431
- <div className="inspector-status-grid">
3432
- <span>IK mode</span><b>{ikMode ? "ON" : "OFF"}</b>
3433
- <span>Current frame</span><b>{tlFrame}</b>
3434
- <span>Keys</span><b>{ikFrames.length}</b>
3435
- </div>
3436
- <button type="button" className={"btn full" + (ikMode ? " primary" : "")} onClick={toggleIkMode} disabled={!ikChains}>
3437
- {ikMode ? "Exit IK mode" : "Enter IK mode"}
3438
- </button>
3439
- <label className="check">
3440
- <input type="checkbox" checked={footSnap} onChange={() => setFootSnap((value) => !value)} />
3441
- <span>Keep feet planted during body edits</span>
3442
- </label>
3443
- <button type="button" className="btn ghost full" onClick={ikAddKeyframe} disabled={!ikChains}>
3444
- Add key at frame {tlFrame}
3445
- </button>
3446
- <div className="inspector-list compact">
3447
- {ikFrames.map((frame) => (
3448
- <button
3449
- type="button"
3450
- key={frame}
3451
- className={tlFrame === frame ? "active" : ""}
3452
- onClick={() => setTlFrame(frame)}
3453
- onContextMenu={(event) => {
3454
- event.preventDefault();
3455
- ikDeleteKeyframe(frame);
3456
- }}
3457
- >
3458
- <span>Frame {frame}</span>
3459
- <small>{tlFrame === frame ? "Current" : "right-click removes"}</small>
3460
- </button>
3461
- ))}
3462
- </div>
3463
- </Foldout>
3464
-
3465
- <Foldout hidden={!(selectedHierarchyId === "characterA.rig" || selectedHierarchyId.startsWith("rig."))} title="Rig Control">
4142
+ <Foldout hidden={sidebarTab !== "inspector" || !(selectedHierarchyId === "characterA.rig" || selectedHierarchyId.startsWith("rig."))} title={ko("Rig Control", "리그 제어")}>
3466
4143
  <p className="inspector-hint">
3467
4144
  {selectedHierarchyId.startsWith("rig.")
3468
- ? `${HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId]} is the active control group.`
3469
- : "Choose a body group in the hierarchy, then manipulate its handle in the main view."}
4145
+ ? (isKo ? `${HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId]}이 활성 제어 그룹입니다.` : `${HIERARCHY_INSPECTOR_TITLES[selectedHierarchyId]} is the active control group.`)
4146
+ : ko("Choose a body group in the hierarchy, then manipulate its handle in the main view.", "계층에서 몸 그룹을 고른 뒤 메인 뷰의 핸들을 조작하세요.")}
3470
4147
  </p>
3471
4148
  <div className="inspector-status-grid">
3472
- <span>Rig</span><b>{ikChains ? "Ready" : "Unavailable"}</b>
3473
- <span>Focus</span><b>{ikFocus ?? "None"}</b>
3474
- <span>Foot lock</span><b>{footSnap ? "ON" : "OFF"}</b>
4149
+ <span>{ko("Rig", "리그")}</span><b>{ikChains ? ko("Ready", "준비됨") : ko("Unavailable", "사용 불가")}</b>
4150
+ <span>{ko("Focus", "초점")}</span><b>{ikFocus ?? ko("None", "없음")}</b>
4151
+ <span>{ko("Foot lock", "발 고정")}</span><b>{footSnap ? ko("ON", "켜짐") : ko("OFF", "꺼짐")}</b>
3475
4152
  </div>
3476
4153
  <button type="button" className={"btn full" + (ikMode ? " primary" : "")} onClick={toggleIkMode} disabled={!ikChains}>
3477
- {ikMode ? "Finish rig editing" : "Edit rig with IK"}
4154
+ {ikMode ? ko("Finish rig editing", "리그 편집 끝내기") : ko("Edit rig with IK", "IK로 리그 편집")}
3478
4155
  </button>
3479
4156
  </Foldout>
3480
4157
 
3481
- <Foldout hidden={selectedHierarchyId !== "rootPath"} title="Root Path">
4158
+ <Foldout hidden={sidebarTab !== "motion"} title={ko("Root Path", "루트 경로")}>
3482
4159
  <div className="inspector-status-grid">
3483
- <span>Path mode</span><b>{waypointMode ? "ON" : "OFF"}</b>
3484
- <span>Waypoints</span><b>{waypoints.length}</b>
3485
- <span>Current frame</span><b>{tlFrame}</b>
4160
+ <span>{ko("Path mode", "경로 모드")}</span><b>{waypointMode ? ko("ON", "켜짐") : ko("OFF", "꺼짐")}</b>
4161
+ <span>{ko("Waypoints", "웨이포인트")}</span><b>{waypoints.length}</b>
4162
+ <span>{ko("Current frame", "현재 프레임")}</span><b>{tlFrame}</b>
3486
4163
  </div>
3487
4164
  <button type="button" className={"btn full" + (waypointMode ? " primary" : "")} onClick={toggleWaypointMode}>
3488
- {waypointMode ? "Finish path editing" : "Edit root path"}
4165
+ {waypointMode ? ko("Finish path editing", "경로 편집 끝내기") : ko("Edit root path", "루트 경로 편집")}
3489
4166
  </button>
3490
4167
  {waypointMode && (
3491
- <p className="inspector-hint">Click the set floor in the Shot view to drop the next waypoint. Scrub the playhead past the last pin first to choose its exact frame; left alone, time between pins follows walking distance.</p>
4168
+ <p className="inspector-hint">{ko("Pick a frame on the timeline's 2D root lane first, and the next Shot-view floor click lands there. Already-placed pins can be dragged in the Top-View.", "타임라인 2D 루트 레인에서 프레임을 먼저 고르면 다음 샷 뷰 바닥 클릭이 그 프레임에 놓입니다. 이미 놓은 점은 탑뷰에서 드래그해 위치를 조정하세요.")}</p>
3492
4169
  )}
3493
4170
  <div className="inspector-list compact">
3494
4171
  {waypoints.map((waypoint) => (
@@ -3506,31 +4183,66 @@ globalThis.playMode = centerTab === "play";
3506
4183
  removeWaypoint(waypoint.frame);
3507
4184
  }}
3508
4185
  >
3509
- <span>Frame {waypoint.frame}</span>
4186
+ <span>{isKo ? `프레임 ${waypoint.frame}` : `Frame ${waypoint.frame}`}</span>
3510
4187
  <small>{waypoint.x.toFixed(2)}, {waypoint.z.toFixed(2)}</small>
3511
4188
  </button>
3512
4189
  ))}
3513
4190
  </div>
3514
4191
  </Foldout>
3515
4192
 
3516
- <Foldout hidden={selectedHierarchyId !== "environment"} title="Environment">
4193
+ <Foldout hidden={sidebarTab !== "motion"} title={ko("IK Corrections", "IK 보정")}>
4194
+ <div className="inspector-status-grid">
4195
+ <span>{ko("IK mode", "IK 모드")}</span><b>{ikMode ? ko("ON", "켜짐") : ko("OFF", "꺼짐")}</b>
4196
+ <span>{ko("Current frame", "현재 프레임")}</span><b>{tlFrame}</b>
4197
+ <span>{ko("Keys", "키")}</span><b>{ikFrames.length}</b>
4198
+ </div>
4199
+ <button type="button" className={"btn full" + (ikMode ? " primary" : "")} onClick={toggleIkMode} disabled={!ikChains}>
4200
+ {ikMode ? ko("Exit IK mode", "IK 모드 나가기") : ko("Enter IK mode", "IK 모드 들어가기")}
4201
+ </button>
4202
+ <label className="check">
4203
+ <input type="checkbox" checked={footSnap} onChange={() => setFootSnap((value) => !value)} />
4204
+ <span>{ko("Keep feet planted during body edits", "몸을 편집하는 동안 발 고정")}</span>
4205
+ </label>
4206
+ <button type="button" className="btn ghost full" onClick={ikAddKeyframe} disabled={!ikChains}>
4207
+ {isKo ? `프레임 ${tlFrame}에 키 추가` : `Add key at frame ${tlFrame}`}
4208
+ </button>
4209
+ <div className="inspector-list compact">
4210
+ {ikFrames.map((frame) => (
4211
+ <button
4212
+ type="button"
4213
+ key={frame}
4214
+ className={tlFrame === frame ? "active" : ""}
4215
+ onClick={() => setTlFrame(frame)}
4216
+ onContextMenu={(event) => {
4217
+ event.preventDefault();
4218
+ ikDeleteKeyframe(frame);
4219
+ }}
4220
+ >
4221
+ <span>{isKo ? `프레임 ${frame}` : `Frame ${frame}`}</span>
4222
+ <small>{tlFrame === frame ? ko("Current", "현재") : ko("right-click removes", "오른쪽 클릭으로 삭제")}</small>
4223
+ </button>
4224
+ ))}
4225
+ </div>
4226
+ </Foldout>
4227
+
4228
+ <Foldout hidden={sidebarTab !== "inspector" || selectedHierarchyId !== "environment"} title={ko("Environment", "환경")}>
3517
4229
  <label className="check">
3518
4230
  <input type="checkbox" checked={hasEnvSheet} onChange={(event) => setHasEnvSheet(event.target.checked)} />
3519
- <span>I have an environment sheet</span>
4231
+ <span>{ko("I have an environment sheet", "환경 시트가 있어요")}</span>
3520
4232
  </label>
3521
4233
  {!hasEnvSheet && (
3522
- <Field label="Environment description">
4234
+ <Field label={ko("Environment description", "환경 설명")}>
3523
4235
  <input type="text" value={environment} onChange={(event) => setEnvironment(event.target.value)} />
3524
4236
  </Field>
3525
4237
  )}
3526
- <Field label="Look / style">
4238
+ <Field label={ko("Look / style", "룩 / 스타일")}>
3527
4239
  <input type="text" value={style} onChange={(event) => setStyle(event.target.value)} />
3528
4240
  </Field>
3529
4241
  </Foldout>
3530
4242
 
3531
- <Foldout hidden={selectedHierarchyId !== "props"} title="Props">
3532
- <p className="inspector-hint">Everything you add to the set lives here. Pick one to edit it, or click it in the shot view.</p>
3533
- <AddObjectMenu onAdd={addSceneObject} label="Add object to the set" />
4243
+ <Foldout hidden={sidebarTab !== "inspector" || selectedHierarchyId !== "props"} title={ko("Props", "소품")}>
4244
+ <p className="inspector-hint">{ko("Everything you add to the set lives here. Pick one to edit it, or click it in the shot view.", "세트에 추가한 모든 소품이 여기에 모입니다. 편집하려면 하나를 고르거나 샷 뷰에서 클릭하세요.")}</p>
4245
+ <AddObjectMenu onAdd={addSceneObject} label={ko("Add object to the set", "세트에 오브젝트 추가")} />
3534
4246
  <div className="inspector-list compact">
3535
4247
  {sceneObjects.map((object) => (
3536
4248
  <button
@@ -3538,43 +4250,53 @@ globalThis.playMode = centerTab === "play";
3538
4250
  key={object.id}
3539
4251
  onClick={() => selectHierarchy(`object:${object.id}`)}
3540
4252
  >
3541
- <span>{object.name}</span>
3542
- <small>{object.renderer}</small>
4253
+ <span>{sceneObjectNameDisplayKo(object.name)}</span>
4254
+ <small>{sceneRendererLabelKo(object.renderer)}</small>
3543
4255
  </button>
3544
4256
  ))}
3545
4257
  </div>
3546
4258
  </Foldout>
3547
4259
 
3548
- <Foldout hidden={!selectedSceneObject} title="Object Transform">
4260
+ <Foldout hidden={sidebarTab !== "inspector" || !selectedSceneObject} title={ko("Object Transform", "오브젝트 변환")}>
3549
4261
  {selectedSceneObject && (
3550
4262
  <>
3551
4263
  <p className="inspector-hint">
3552
- Type a value and press Enter, or drag an axis letter to scrub.
4264
+ {ko("Type a value and press Enter, or drag an axis letter to scrub.", "값을 입력하고 Enter를 누르거나 축 글자를 드래그해 조절하세요.")}
3553
4265
  </p>
3554
4266
  <div className="presets gizmo-modes">
3555
4267
  <button type="button" className={gizmoMode === "move" ? "active" : ""} onClick={() => setGizmoMode("move")}>
3556
- Move <kbd>W</kbd>
4268
+ {ko("Move", "이동")} <kbd>W</kbd>
3557
4269
  </button>
3558
4270
  <button type="button" className={gizmoMode === "rotate" ? "active" : ""} onClick={() => setGizmoMode("rotate")}>
3559
- Rotate <kbd>E</kbd>
4271
+ {ko("Rotate", "회전")} <kbd>E</kbd>
3560
4272
  </button>
3561
4273
  <button type="button" className={gizmoMode === "scale" ? "active" : ""} onClick={() => setGizmoMode("scale")}>
3562
- Scale <kbd>R</kbd>
4274
+ {ko("Scale", "크기")} <kbd>R</kbd>
3563
4275
  </button>
3564
4276
  </div>
3565
4277
  <label className="check snap-toggle">
3566
4278
  <input type="checkbox" checked={snapEnabled} onChange={(event) => setSnapEnabled(event.target.checked)} />
3567
- <span>Snap to grid — hold <kbd>Ctrl</kbd> to invert</span>
4279
+ <span>
4280
+ {isKo ? (
4281
+ <>
4282
+ 그리드 스냅 — <kbd>Ctrl</kbd>을 누르면 반대로 작동
4283
+ </>
4284
+ ) : (
4285
+ <>
4286
+ Snap to grid — hold <kbd>Ctrl</kbd> to invert
4287
+ </>
4288
+ )}
4289
+ </span>
3568
4290
  </label>
3569
- <Field label="Name">
4291
+ <Field label={ko("Name", "이름")}>
3570
4292
  <input
3571
4293
  type="text"
3572
- value={selectedSceneObject.name}
4294
+ value={sceneObjectNameDisplayKo(selectedSceneObject.name)}
3573
4295
  onChange={(event) => changeSceneObject(selectedSceneObject.id, { name: event.target.value })}
3574
4296
  />
3575
4297
  </Field>
3576
4298
  <Vector3Row
3577
- label="Position"
4299
+ label={ko("Position", "위치")}
3578
4300
  fields={[
3579
4301
  { axis: "X", value: selectedSceneObject.x, step: 0.05, precision: 2, onChange: (x) => changeSceneObject(selectedSceneObject.id, { x }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3580
4302
  { axis: "Y", value: selectedSceneObject.y ?? 0, step: 0.05, precision: 2, onChange: (y) => changeSceneObject(selectedSceneObject.id, { y }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
@@ -3582,7 +4304,7 @@ globalThis.playMode = centerTab === "play";
3582
4304
  ]}
3583
4305
  />
3584
4306
  <Vector3Row
3585
- label="Rotation"
4307
+ label={ko("Rotation", "회전")}
3586
4308
  fields={[
3587
4309
  { axis: "X", value: selectedSceneObject.rotX ?? 0, step: 1, precision: 1, onChange: (rotX) => changeSceneObject(selectedSceneObject.id, { rotX }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3588
4310
  { axis: "Y", value: selectedSceneObject.rot, step: 1, precision: 1, onChange: (rot) => changeSceneObject(selectedSceneObject.id, { rot }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
@@ -3590,21 +4312,21 @@ globalThis.playMode = centerTab === "play";
3590
4312
  ]}
3591
4313
  />
3592
4314
  <Vector3Row
3593
- label="Scale"
4315
+ label={ko("Scale", "크기")}
3594
4316
  fields={[
3595
4317
  { axis: "X", value: selectedSceneObject.scaleX ?? 1, step: 0.05, precision: 2, onChange: (scaleX) => changeSceneObject(selectedSceneObject.id, { scaleX }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3596
4318
  { axis: "Y", value: selectedSceneObject.scaleY ?? 1, step: 0.05, precision: 2, onChange: (scaleY) => changeSceneObject(selectedSceneObject.id, { scaleY }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3597
4319
  { axis: "Z", value: selectedSceneObject.scaleZ ?? 1, step: 0.05, precision: 2, onChange: (scaleZ) => changeSceneObject(selectedSceneObject.id, { scaleZ }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
3598
4320
  ]}
3599
4321
  />
3600
- <div className="object-colors" role="group" aria-label="Object colour">
4322
+ <div className="object-colors" role="group" aria-label={ko("Object colour", "오브젝트 색상")}>
3601
4323
  {OBJECT_COLORS.map((color) => (
3602
4324
  <button
3603
4325
  type="button"
3604
4326
  key={color}
3605
4327
  className={"object-color" + (selectedSceneObject.color === color ? " active" : "")}
3606
4328
  style={{ background: color }}
3607
- aria-label={`Colour ${color}`}
4329
+ aria-label={isKo ? `색상 ${color}` : `Colour ${color}`}
3608
4330
  aria-pressed={selectedSceneObject.color === color}
3609
4331
  onClick={() => changeSceneObject(selectedSceneObject.id, { color })}
3610
4332
  />
@@ -3619,10 +4341,10 @@ globalThis.playMode = centerTab === "play";
3619
4341
  <button
3620
4342
  type="button"
3621
4343
  className="btn ghost full"
3622
- title="You can also press Delete or Backspace"
4344
+ title={ko("You can also press Delete or Backspace", "Delete 또는 Backspace를 눌러도 삭제할 수 있어요")}
3623
4345
  onClick={deleteSelectedSceneObject}
3624
4346
  >
3625
- Remove {selectedSceneObject.name}
4347
+ {isKo ? `${sceneObjectNameDisplayKo(selectedSceneObject.name)} 삭제` : `Remove ${selectedSceneObject.name}`}
3626
4348
  </button>
3627
4349
  </div>
3628
4350
  )}
@@ -3633,18 +4355,18 @@ globalThis.playMode = centerTab === "play";
3633
4355
  <div
3634
4356
  className="workspace-splitter timeline-splitter"
3635
4357
  role="separator"
3636
- aria-label="Resize frame monitor"
4358
+ aria-label={ko("Resize frame monitor", "프레임 모니터 크기 조절")}
3637
4359
  onPointerDown={(event) => beginWorkspaceResize("timeline", event)}
3638
4360
  />
3639
4361
  <div className="bottom-window">
3640
- <nav className="bottom-window-tabs" aria-label="Bottom window">
4362
+ <nav className="bottom-window-tabs" aria-label={ko("Bottom window", "하단 창")}>
3641
4363
  <button
3642
4364
  type="button"
3643
4365
  className={bottomTab === "timeline" ? "active" : ""}
3644
4366
  aria-pressed={bottomTab === "timeline"}
3645
4367
  onClick={() => setBottomTab("timeline")}
3646
4368
  >
3647
- Animation
4369
+ {ko("Animation", "애니메이션")}
3648
4370
  </button>
3649
4371
  <button
3650
4372
  type="button"
@@ -3652,12 +4374,12 @@ globalThis.playMode = centerTab === "play";
3652
4374
  aria-pressed={bottomTab === "console"}
3653
4375
  onClick={() => setBottomTab("console")}
3654
4376
  >
3655
- Console
4377
+ {ko("Console", "콘솔")}
3656
4378
  </button>
3657
4379
  </nav>
3658
4380
  <div className="console-pane" hidden={bottomTab !== "console"}>
3659
4381
  {consoleLines.length === 0 ? (
3660
- <p className="console-empty">No messages yet — ARDY status lines appear here.</p>
4382
+ <p className="console-empty">{ko("No messages yet — ARDY status lines appear here.", "아직 메시지가 없어요 — ARDY 상태 줄이 여기에 표시됩니다.")}</p>
3661
4383
  ) : (
3662
4384
  consoleLines.map((entry, index) => (
3663
4385
  <p className="console-line" key={index}>
@@ -3677,7 +4399,7 @@ globalThis.playMode = centerTab === "play";
3677
4399
  waypointMode={waypointMode}
3678
4400
  waypointFrames={waypoints.map((w) => w.frame)}
3679
4401
  pathSpeed={pathSpeed}
3680
- pendingWaypointFrame={activeWaypointFrame}
4402
+ pendingWaypointFrame={pendingWaypointFrame}
3681
4403
  promptClips={promptClips}
3682
4404
  selectedPromptId={selectedPromptId}
3683
4405
  badge={stateBadge}
@@ -3685,41 +4407,99 @@ globalThis.playMode = centerTab === "play";
3685
4407
  ikDisabled={!ikChains}
3686
4408
  ikFrames={ikFrames}
3687
4409
  footSnap={footSnap}
3688
- cameraKeyFrames={cameraKeys.map((k) => k.frame)}
4410
+ shots={shots}
4411
+ activeShotIdx={activeShotIdx}
4412
+ railDraw={railDraw}
4413
+ cameraRailLength={railCurve?.length ?? null}
4414
+ shotCutDisabled={!!posing || ikMode || waypointMode}
3689
4415
  onIkToggle={toggleIkMode}
3690
4416
  onIkKeyframeAdd={ikAddKeyframe}
3691
4417
  onIkKeyframeRemove={ikDeleteKeyframe}
3692
4418
  onFootSnapToggle={() => {
3693
4419
  setFootSnap((v) => {
3694
- setToast(v ? "Foot snap off — the feet follow the body" : "Foot snap on — the feet stay planted while the body moves");
4420
+ setToast(v ? ko("Foot snap off — the feet follow the body", "발 스냅 꺼짐 — 발이 몸을 따라갑니다") : ko("Foot snap on — the feet stay planted while the body moves", "발 스냅 켜짐 — 몸이 움직여도 발은 바닥에 고정됩니다"));
3695
4421
  return !v;
3696
4422
  });
3697
4423
  }}
3698
4424
  onScrub={setTlFrame}
3699
4425
  onAdvance={advanceFrame}
3700
4426
  onStep={stepFrame}
3701
- onPlayToggle={() => setTlPlaying((v) => !v)}
4427
+ onPlayToggle={() => {
4428
+ cameraPreviewEndRef.current = null;
4429
+ manualCameraOverrideRef.current = false;
4430
+ setTlPlaying((v) => !v);
4431
+ }}
3702
4432
  onWaypointToggle={toggleWaypointMode}
3703
- onMarkerSelect={(f) => { setTlFrame(Math.min(f, tlFrameCount - 1)); setActiveWaypointFrame(f); setWaypointMode(true); setSelectedHierarchyId("rootPath"); }}
3704
- onMarkerRemove={removeWaypoint}
3705
- onRootKeyframeAdd={() => {
4433
+ onMarkerSelect={(f) => {
4434
+ const frame = Math.min(f, tlFrameCount - 1);
4435
+ setTlFrame(frame);
3706
4436
  setWaypointMode(true);
3707
- setToast("Root path on — click the set floor in the Shot view to drop waypoints");
4437
+ setSidebarTab("motion");
4438
+ if (waypoints.some((waypoint) => waypoint.frame === frame)) {
4439
+ setActiveWaypointFrame(frame);
4440
+ setPendingWaypointFrame(null);
4441
+ } else {
4442
+ setActiveWaypointFrame(null);
4443
+ setPendingWaypointFrame(frame);
4444
+ }
3708
4445
  }}
4446
+ onMarkerRemove={removeWaypoint}
4447
+ onRootKeyframeAdd={queueRootWaypointFrame}
3709
4448
  onPromptAdd={addPromptClip}
3710
4449
  onPromptSelect={(id) => {
3711
4450
  setSelectedPromptId(id);
3712
4451
  setArdyPrompt(promptClips.find((clip) => clip.id === id)?.text ?? "");
3713
- setSelectedHierarchyId("characterA.promptBlocks");
4452
+ setSidebarTab("motion");
3714
4453
  }}
3715
4454
  onPromptChange={changePromptClip}
3716
4455
  onPromptResize={resizePromptClip}
3717
4456
  onPromptMove={movePromptClip}
3718
4457
  onPromptRemove={removePromptClip}
3719
- onCameraMoveSelect={() => setSelectedHierarchyId("camera")}
4458
+ onCameraMoveSelect={() => {
4459
+ setSelectedHierarchyId("camera");
4460
+ setSidebarTab("inspector");
4461
+ }}
3720
4462
  onCameraKeyframeAdd={addCameraKeyframe}
3721
4463
  onCameraKeyframeMove={moveCameraKeyframe}
3722
- onCameraKeyframeRemove={removeCameraKeyframe}
4464
+ onCameraKeyframeRemove={removeCameraKeyframe}
4465
+ onCameraBlockSelect={(index) => {
4466
+ const selected = shots[index];
4467
+ if (!selected) return;
4468
+ setTlFrame(selected.startFrame);
4469
+ setSelectedHierarchyId("camera");
4470
+ setSidebarTab("motion");
4471
+ }}
4472
+ onCameraBlockChange={(patch) => {
4473
+ const nextPatch = patch.mode === "rail" && activeCamera.railFollow?.mode === "off"
4474
+ ? { ...patch, railFollow: defaultRailRange(activeShotDuration) }
4475
+ : patch;
4476
+ changeActiveCamera(nextPatch);
4477
+ if (patch.mode === "rail" && !cameraRail) {
4478
+ setRailDraw(true);
4479
+ setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
4480
+ setToast(ko("Draw this Camera Block's rail in the Top-View", "탑뷰에서 이 카메라 블록의 레일을 그리세요"));
4481
+ }
4482
+ }}
4483
+ onCameraPreview={previewCameraShot}
4484
+ onCameraRailDrawToggle={toggleCameraRailDraw}
4485
+ onRailSelect={(index) => {
4486
+ selectTimelineShot(index);
4487
+ setSelectedHierarchyId("camera");
4488
+ setSidebarTab("motion");
4489
+ }}
4490
+ onRailMove={(index, startFrame) => editRailSchedule(index, (base, duration) => moveRailRange(base, startFrame - base.startFrame, duration))}
4491
+ onRailRangeChange={(index, edge, frame) => editRailSchedule(index, (base, duration) => resizeRailRange(base, edge, frame, duration))}
4492
+ onRailRemove={(index) => setShots((current) => current.map((shot, shotIndex) => shotIndex === index
4493
+ ? { ...shot, camera: updateCameraBlock(shot.camera, { railFollow: { mode: "off" } }) }
4494
+ : shot))}
4495
+ onShotSelect={selectTimelineShot}
4496
+ onShotBoundaryMove={(index, edge, frame) => setShots((current) => resizeShot(current, index, edge, frame, tlFrameCount))}
4497
+ onShotRename={(index, name) => setShots((current) => renameShot(current, index, name))}
4498
+ onShotRemove={(index) => setShots((current) => removeShot(current, index))}
4499
+ onShotDuplicate={duplicateTimelineShot}
4500
+ onShotCut={addTimelineShot}
4501
+ onShotSplit={splitTimelineShot}
4502
+ onShotMove={moveTimelineShot}
3723
4503
  onClearMotion={motion ? clearMotion : null}
3724
4504
  />
3725
4505
  </div>
@@ -3732,52 +4512,17 @@ globalThis.playMode = centerTab === "play";
3732
4512
  </span>
3733
4513
  </footer>
3734
4514
 
3735
- {result && (
3736
- <div className="modal-overlay" onClick={() => setResult(null)}>
3737
- <div className="modal" onClick={(e) => e.stopPropagation()}>
3738
- <div className="modal-head">
3739
- <h3>Your shot is ready</h3>
3740
- <button className="x" onClick={() => setResult(null)}>
3741
- ✕
3742
- </button>
3743
- </div>
3744
- {result.frameB ? (
3745
- <div className="move-frames">
3746
- <figure>
3747
- <img className="preview" src={result.frame} alt="move start frame" />
3748
- <figcaption>A · start</figcaption>
3749
- </figure>
3750
- <figure>
3751
- <img className="preview" src={result.frameB} alt="move end frame" />
3752
- <figcaption>B · end</figcaption>
3753
- </figure>
3754
- </div>
3755
- ) : (
3756
- result.frame && <img className="preview" src={result.frame} alt="framed shot" />
3757
- )}
3758
- {result.move && <div className="move-slate">{result.move.slate} · {result.move.spanS}s</div>}
3759
- <label className="modal-label">Prompt {copied && <em>· copied</em>}</label>
3760
- <div className="promptbox">{result.prompt}</div>
3761
- <div className="modal-actions">
3762
- <button
3763
- className="btn"
3764
- onClick={() =>
3765
- navigator.clipboard?.writeText(result.prompt).then(() => {
3766
- setCopied(true);
3767
- setToast("Prompt copied to clipboard");
3768
- })
3769
- }
3770
- >
3771
- {copied ? "Copied ✓" : "Copy prompt"}
3772
- </button>
3773
- {result.frame && (
3774
- <button className="btn" onClick={download}>
3775
- {result.frameB ? "Download start & end frames" : "Download frame"}
3776
- </button>
3777
- )}
3778
- </div>
3779
- </div>
3780
- </div>
4515
+ {result && resultOpen && (
4516
+ <ResultModal
4517
+ result={result}
4518
+ generation={generation.state}
4519
+ copied={copied}
4520
+ recordedVideoName={result.mode === "video" ? recordedVideoName : null}
4521
+ onClose={() => setResultOpen(false)}
4522
+ onCopy={() => copyPrompt(result.prompt)}
4523
+ onDownload={download}
4524
+ onCancelGeneration={generation.cancel}
4525
+ />
3781
4526
  )}
3782
4527
 
3783
4528
  <Toast message={toast} onDone={() => setToast("")} />
@@ -3819,22 +4564,22 @@ function SubjectBox({ label, value, onChange, onRemove, onPose, posing }) {
3819
4564
  <button
3820
4565
  type="button"
3821
4566
  className={"cam-toggle" + (posing ? " active" : "")}
3822
- title={`Pose ${label}`}
4567
+ title={isKo ? `${label} 포즈` : `Pose ${label}`}
3823
4568
  onClick={onPose}
3824
4569
  >
3825
4570
  ⌘
3826
4571
  </button>
3827
4572
  )}
3828
4573
  {onRemove && (
3829
- <button type="button" className="sb-remove" title="Remove subject" onClick={onRemove}>
4574
+ <button type="button" className="sb-remove" title={ko("Remove subject", "인물 제거")} onClick={onRemove}>
3830
4575
  ✕
3831
4576
  </button>
3832
4577
  )}
3833
4578
  </div>
3834
4579
  </div>
3835
- <Slider compact label="Left / right" min={-4} max={4} step={0.1} value={value.x} onChange={set("x")} />
3836
- <Slider compact label="Depth" min={-4} max={4} step={0.1} value={value.z} onChange={set("z")} />
3837
- <Slider compact label="Rotate" min={-180} max={180} step={1} value={value.rot} unit="°" onChange={set("rot")} />
4580
+ <Slider compact label={ko("Left / right", "좌 / 우")} min={-4} max={4} step={0.1} value={value.x} onChange={set("x")} />
4581
+ <Slider compact label={ko("Depth", "깊이")} min={-4} max={4} step={0.1} value={value.z} onChange={set("z")} />
4582
+ <Slider compact label={ko("Rotate", "회전")} min={-180} max={180} step={1} value={value.rot} unit="°" onChange={set("rot")} />
3838
4583
  </div>
3839
4584
  );
3840
4585
  }