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.
- package/README.md +59 -24
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/cozyclay.mjs +105 -11
- package/dist/CNAME +1 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-Cgpk2hwX.js +4803 -0
- package/dist/assets/app-DgZvaAE1.css +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +406 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +9 -0
- package/dist/sw.js +127 -0
- package/package.json +15 -3
- package/src/App.jsx +1393 -648
- package/src/ardy/client.js +5 -4
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/timeline-coordinates.js +11 -0
- package/src/ardy/timeline.jsx +528 -67
- package/src/ardy/waypoints.js +14 -9
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +9 -17
- package/src/hierarchy-panel.jsx +170 -23
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/object-catalog.jsx +32 -3
- package/src/planview.jsx +174 -66
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +53 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +75 -13
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/scenes.js +223 -0
- package/src/shot-authoring.js +204 -77
- package/src/shot.js +8 -4
- package/src/styles.css +839 -38
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +70 -4
- package/tools/ardy/README.md +55 -0
- package/tools/ardy/bridge.mjs +89 -389
- package/tools/ardy/cclay_constrained_generate.py +449 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +68 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +246 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- 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 {
|
|
19
|
-
|
|
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.
|
|
130
|
-
"
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
"
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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]
|
|
330
|
-
const spanFrames = Math.max(keys[keys.length - 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
|
-
|
|
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; //
|
|
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
|
-
*
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
809
|
+
document,
|
|
674
810
|
saveBlocked: true,
|
|
675
|
-
error:
|
|
676
|
-
toast:
|
|
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 [
|
|
903
|
-
const
|
|
904
|
-
const
|
|
905
|
-
const [
|
|
906
|
-
const [
|
|
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
|
|
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 [
|
|
933
|
-
const [
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
const
|
|
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(
|
|
1419
|
+
const [hasCharSheet, setHasCharSheet] = useState(startupStage.hasCharSheet);
|
|
1285
1420
|
const [hasEnvSheet, setHasEnvSheet] = useState(false);
|
|
1286
|
-
const [subject, setSubject] = useState(
|
|
1287
|
-
const [subject2, setSubject2] = useState("a man in a dark coat");
|
|
1288
|
-
const [environment, setEnvironment] = useState(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1340
|
-
//
|
|
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(
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1479
|
-
|
|
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
|
-
//
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
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)
|
|
1496
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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.
|
|
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
|
|
1530
|
-
//
|
|
1531
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2132
|
+
distance. The bird's-eye board selects and drags existing waypoints. */
|
|
1572
2133
|
function addFloorWaypoint(point) {
|
|
1573
|
-
const
|
|
1574
|
-
const
|
|
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] ??
|
|
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
|
|
1596
|
-
const
|
|
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(
|
|
2170
|
+
setWaypoints(nextWaypoints);
|
|
1602
2171
|
setTlFrame(frame);
|
|
1603
2172
|
setActiveWaypointFrame(frame);
|
|
1604
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
}, [
|
|
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 (!
|
|
2571
|
+
if (!subjectTrack) return null;
|
|
1967
2572
|
const yaw = (charA.rot * Math.PI) / 180;
|
|
1968
|
-
const
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
-
|
|
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 <
|
|
2606
|
+
if (waypoints.length < 1) return null;
|
|
1990
2607
|
let min = Infinity;
|
|
1991
2608
|
let max = 0;
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
const
|
|
1995
|
-
const
|
|
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
|
-
|
|
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
|
|
2101
|
-
const
|
|
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
|
-
//
|
|
2104
|
-
//
|
|
2105
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
})
|
|
2138
|
-
|
|
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(
|
|
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
|
-
//
|
|
2744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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 ===
|
|
2953
|
-
if (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-
|
|
2989
|
-
|
|
2990
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3004
|
-
|
|
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
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
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={
|
|
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={
|
|
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={
|
|
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.
|
|
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
|
|
3074
|
-
: "click
|
|
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
|
-
<
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3150
|
-
|
|
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
|
-
|
|
3854
|
+
ariaLabel={ko("Subject 1 pose", "인물 1 포즈")}
|
|
3153
3855
|
value={poseA?.id}
|
|
3154
|
-
|
|
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
|
-
|
|
3861
|
+
<Field label={ko("Subject 2 pose", "인물 2 포즈")}>
|
|
3160
3862
|
<Dropdown
|
|
3161
|
-
|
|
3863
|
+
ariaLabel={ko("Subject 2 pose", "인물 2 포즈")}
|
|
3162
3864
|
value={poseB?.id}
|
|
3163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3875
|
+
{ko("Image", "이미지")}
|
|
3174
3876
|
</button>
|
|
3175
3877
|
<button className={mode === "video" ? "active" : ""} onClick={() => setMode("video")}>
|
|
3176
|
-
|
|
3878
|
+
{ko("Video", "영상")}
|
|
3177
3879
|
</button>
|
|
3178
3880
|
</div>
|
|
3179
|
-
|
|
3881
|
+
<Field label={ko("Model", "모델")}>
|
|
3180
3882
|
<Dropdown
|
|
3181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3917
|
+
<Field label={ko("Camera move", "카메라 움직임")}>
|
|
3216
3918
|
<Dropdown
|
|
3217
|
-
|
|
3919
|
+
ariaLabel={ko("Camera move", "카메라 움직임")}
|
|
3218
3920
|
value={cameraMove}
|
|
3219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3932
|
+
placeholder={ko("describe the camera move", "카메라 움직임을 설명하세요")}
|
|
3231
3933
|
/>
|
|
3232
3934
|
</Field>
|
|
3233
3935
|
)}
|
|
3234
3936
|
{mode === "video" && moveSequence && (
|
|
3235
|
-
|
|
3937
|
+
<Field label={ko("Camera move", "카메라 움직임")}>
|
|
3236
3938
|
<div
|
|
3237
3939
|
className="move-slate inline"
|
|
3238
|
-
|
|
3940
|
+
title={ko("authored from the timeline keyframings — select Camera in the hierarchy to edit", "타임라인 키프레임으로 만든 움직임입니다. 편집하려면 계층에서 카메라를 선택하세요")}
|
|
3239
3941
|
>
|
|
3240
|
-
|
|
3942
|
+
{moveSequence.displaySlate} · {moveSequence.spanS}{ko("s", "초")}
|
|
3241
3943
|
</div>
|
|
3242
3944
|
</Field>
|
|
3243
3945
|
)}
|
|
3244
|
-
|
|
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
|
-
|
|
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={
|
|
3961
|
+
<Foldout hidden={sidebarTab !== "motion"} title={ko("ARDY motion", "ARDY 모션")}>
|
|
3260
3962
|
{bridge === null ? (
|
|
3261
|
-
<p className="ardy-hint">Checking for the dev bridge
|
|
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
|
|
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
|
|
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
|
-
|
|
4018
|
+
<span>{ko("shape mean error", "형태 평균 오차")}</span>
|
|
3317
4019
|
<b>{fmtMeters(ardyReport.shape_mean_error_m)}</b>
|
|
3318
|
-
|
|
4020
|
+
<span>{ko("shape max error", "형태 최대 오차")}</span>
|
|
3319
4021
|
<b>{fmtMeters(ardyReport.shape_max_error_m)}</b>
|
|
3320
|
-
|
|
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
|
-
|
|
4037
|
+
{ko("Output", "출력")} <code>{ardyOutcome.output}</code> ({ardyOutcome.bytes}{ko(" bytes", "바이트")})
|
|
3336
4038
|
{ardyOutcome.motionUrl && (
|
|
3337
4039
|
<>
|
|
3338
4040
|
{" "}
|
|
3339
|
-
|
|
4041
|
+
· {ko("motion", "모션")} <code>{ardyOutcome.motionUrl}</code>
|
|
3340
4042
|
</>
|
|
3341
4043
|
)}
|
|
3342
4044
|
</p>
|
|
3343
4045
|
{motionBusy ? (
|
|
3344
|
-
|
|
4046
|
+
<p className="ardy-hint">{ko("Decoding motion…", "모션 디코딩 중…")}</p>
|
|
3345
4047
|
) : motion ? (
|
|
3346
4048
|
<p className="ardy-outcome done">
|
|
3347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
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
|
-
|
|
3382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4138
|
+
{isKo ? `프레임 ${tlFrame}에 블록 추가` : `Add block at frame ${tlFrame}`}
|
|
3427
4139
|
</button>
|
|
3428
4140
|
</Foldout>
|
|
3429
4141
|
|
|
3430
|
-
|
|
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
|
-
|
|
3469
|
-
|
|
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
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
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
|
-
|
|
4154
|
+
{ikMode ? ko("Finish rig editing", "리그 편집 끝내기") : ko("Edit rig with IK", "IK로 리그 편집")}
|
|
3478
4155
|
</button>
|
|
3479
4156
|
</Foldout>
|
|
3480
4157
|
|
|
3481
|
-
|
|
4158
|
+
<Foldout hidden={sidebarTab !== "motion"} title={ko("Root Path", "루트 경로")}>
|
|
3482
4159
|
<div className="inspector-status-grid">
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
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
|
-
|
|
4165
|
+
{waypointMode ? ko("Finish path editing", "경로 편집 끝내기") : ko("Edit root path", "루트 경로 편집")}
|
|
3489
4166
|
</button>
|
|
3490
4167
|
{waypointMode && (
|
|
3491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4231
|
+
<span>{ko("I have an environment sheet", "환경 시트가 있어요")}</span>
|
|
3520
4232
|
</label>
|
|
3521
4233
|
{!hasEnvSheet && (
|
|
3522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4260
|
+
<Foldout hidden={sidebarTab !== "inspector" || !selectedSceneObject} title={ko("Object Transform", "오브젝트 변환")}>
|
|
3549
4261
|
{selectedSceneObject && (
|
|
3550
4262
|
<>
|
|
3551
4263
|
<p className="inspector-hint">
|
|
3552
|
-
|
|
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
|
-
|
|
4268
|
+
{ko("Move", "이동")} <kbd>W</kbd>
|
|
3557
4269
|
</button>
|
|
3558
4270
|
<button type="button" className={gizmoMode === "rotate" ? "active" : ""} onClick={() => setGizmoMode("rotate")}>
|
|
3559
|
-
|
|
4271
|
+
{ko("Rotate", "회전")} <kbd>E</kbd>
|
|
3560
4272
|
</button>
|
|
3561
4273
|
<button type="button" className={gizmoMode === "scale" ? "active" : ""} onClick={() => setGizmoMode("scale")}>
|
|
3562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4291
|
+
<Field label={ko("Name", "이름")}>
|
|
3570
4292
|
<input
|
|
3571
4293
|
type="text"
|
|
3572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4344
|
+
title={ko("You can also press Delete or Backspace", "Delete 또는 Backspace를 눌러도 삭제할 수 있어요")}
|
|
3623
4345
|
onClick={deleteSelectedSceneObject}
|
|
3624
4346
|
>
|
|
3625
|
-
|
|
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
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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={() =>
|
|
4427
|
+
onPlayToggle={() => {
|
|
4428
|
+
cameraPreviewEndRef.current = null;
|
|
4429
|
+
manualCameraOverrideRef.current = false;
|
|
4430
|
+
setTlPlaying((v) => !v);
|
|
4431
|
+
}}
|
|
3702
4432
|
onWaypointToggle={toggleWaypointMode}
|
|
3703
|
-
onMarkerSelect={(f) => {
|
|
3704
|
-
|
|
3705
|
-
|
|
4433
|
+
onMarkerSelect={(f) => {
|
|
4434
|
+
const frame = Math.min(f, tlFrameCount - 1);
|
|
4435
|
+
setTlFrame(frame);
|
|
3706
4436
|
setWaypointMode(true);
|
|
3707
|
-
|
|
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
|
-
|
|
4452
|
+
setSidebarTab("motion");
|
|
3714
4453
|
}}
|
|
3715
4454
|
onPromptChange={changePromptClip}
|
|
3716
4455
|
onPromptResize={resizePromptClip}
|
|
3717
4456
|
onPromptMove={movePromptClip}
|
|
3718
4457
|
onPromptRemove={removePromptClip}
|
|
3719
|
-
onCameraMoveSelect={() =>
|
|
4458
|
+
onCameraMoveSelect={() => {
|
|
4459
|
+
setSelectedHierarchyId("camera");
|
|
4460
|
+
setSidebarTab("inspector");
|
|
4461
|
+
}}
|
|
3720
4462
|
onCameraKeyframeAdd={addCameraKeyframe}
|
|
3721
4463
|
onCameraKeyframeMove={moveCameraKeyframe}
|
|
3722
|
-
|
|
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
|
-
<
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
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
|
}
|