cozyclay 1.2.0 → 1.3.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/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/App.jsx
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
3
3
|
import { OrthographicCamera, PerspectiveCamera, Text, useFBX } from "@react-three/drei";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
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
|
-
import { loadMotionFromUrl } from "./ardy/npz.js";
|
|
8
|
+
import { characterScaleFor, loadMotionFromUrl } from "./ardy/npz.js";
|
|
9
|
+
import { retimeMotion } from "./ardy/retime.js";
|
|
10
|
+
import { sliceMotion } from "./ardy/trim.js";
|
|
11
|
+
import { fetchFootageBlob, footageSummary, isPlatformPageUrl, normalizeSourceUrl, probeFootage, requestBridgeExtract, requestBridgeFootage, sourceLabel } from "./multimodel-ingest.js";
|
|
12
|
+
import { bakeExtractedTake, collectLandmarkTrack, createPoseDetector, sampleTimes, videoFrames } from "./pose-extract/index.js";
|
|
9
13
|
import { repairRecordedMp4 } from "./ardy/mp4-duration.js";
|
|
10
14
|
import { applyMotionFrame, captureArdyRoot, restorePlaybackBones, snapshotPlaybackBones } from "./ardy/playback.js";
|
|
11
15
|
import { movePromptClipFrames } from "./ardy/prompt-clips.js";
|
|
12
16
|
import Timeline from "./ardy/timeline.jsx";
|
|
13
17
|
import { alignArdyPath, judgeAuthoredPath, judgeNextWaypoint, toSceneRootOffset, PATH_LIMITS } from "./ardy/waypoints.js";
|
|
14
18
|
import { FlyControls, aimAt, forwardFrom } from "./controls.jsx";
|
|
19
|
+
import { createLiveControl } from "./live-control.js";
|
|
15
20
|
import HierarchyPanel from "./hierarchy-panel.jsx";
|
|
16
21
|
import { PlanBoard } from "./planview.jsx";
|
|
17
|
-
import { DualRender, GIZMO_LAYER,
|
|
22
|
+
import { DualRender, GIZMO_LAYER, fitAspect } from "./dualview.jsx";
|
|
18
23
|
import { Room, StageLights } from "./room.jsx";
|
|
19
24
|
import {
|
|
20
25
|
SHOT_AUTHORING_KEY,
|
|
@@ -38,32 +43,68 @@ import {
|
|
|
38
43
|
} from "./camera-rail-schedule.js";
|
|
39
44
|
import { SetProps } from "./props.jsx";
|
|
40
45
|
import {
|
|
46
|
+
CUTOUT_DEFAULT_HEIGHT,
|
|
47
|
+
CUTOUT_KIND,
|
|
48
|
+
CUTOUT_MAX_HEIGHT,
|
|
41
49
|
DEFAULT_SCENE_OBJECTS,
|
|
42
50
|
OBJECT_COLORS,
|
|
51
|
+
createCutoutObject,
|
|
43
52
|
createSceneObject,
|
|
44
53
|
dropToSurfacePatch,
|
|
45
54
|
objectSize,
|
|
46
55
|
placementInFront,
|
|
47
56
|
removeSceneObject,
|
|
57
|
+
setSceneObjectParent,
|
|
48
58
|
sceneObjectIdFromHierarchy,
|
|
49
59
|
updateSceneObject,
|
|
50
60
|
} from "./scene-objects.js";
|
|
51
61
|
import { createSceneHistoryStore } from "./scene-history.js";
|
|
62
|
+
import { ASSET_IMAGE_TYPES, assetAspect, imageFilesFrom, importImageFile } from "./scene-assets.js";
|
|
63
|
+
import { assetRecord, rememberAsset } from "./scene-asset-cache.js";
|
|
64
|
+
import { cutOutBackground, decodeMask, maskAsset } from "./matte.js";
|
|
65
|
+
import { createMatteEditor } from "./matte-editor.js";
|
|
52
66
|
import {
|
|
53
67
|
SCENES_QUARANTINE_KEY,
|
|
54
68
|
SCENES_STORAGE_KEY,
|
|
55
69
|
SCENES_VERSION,
|
|
56
70
|
activeSceneIndex,
|
|
57
71
|
addScene,
|
|
72
|
+
createCharacterEntry,
|
|
73
|
+
createCharacterLayer,
|
|
58
74
|
createSceneStage,
|
|
59
75
|
createSceneDocument,
|
|
76
|
+
DEFAULT_CHARACTER_MODEL,
|
|
77
|
+
DEFAULT_SUBJECT_ONE,
|
|
78
|
+
DEFAULT_SUBJECT_TWO,
|
|
60
79
|
duplicateScene,
|
|
61
80
|
loadSceneDocumentFromStorage,
|
|
81
|
+
migrateStageFrames,
|
|
82
|
+
readSceneDocument,
|
|
62
83
|
removeScene,
|
|
63
84
|
renameScene,
|
|
64
85
|
serializeSceneDocument,
|
|
86
|
+
takeAnchor,
|
|
65
87
|
} from "./scenes.js";
|
|
88
|
+
import {
|
|
89
|
+
clearStoredProjectHandle,
|
|
90
|
+
createProjectDocument,
|
|
91
|
+
downloadProjectFallback,
|
|
92
|
+
hasFileSystemAccess,
|
|
93
|
+
loadStoredProjectHandle,
|
|
94
|
+
openProjectFallback,
|
|
95
|
+
pickProjectFileForOpen,
|
|
96
|
+
pickProjectFileForSave,
|
|
97
|
+
queryHandlePermission,
|
|
98
|
+
readProjectDocument,
|
|
99
|
+
readProjectFile,
|
|
100
|
+
storeProjectHandle,
|
|
101
|
+
rememberRecentProject,
|
|
102
|
+
writeProjectFile,
|
|
103
|
+
PROJECT_EXTENSION,
|
|
104
|
+
} from "./project.js";
|
|
105
|
+
import ProjectBrowser from "./project-browser.jsx";
|
|
66
106
|
import ObjectGizmo from "./object-gizmo.jsx";
|
|
107
|
+
import AssetPane from "./asset-pane.jsx";
|
|
67
108
|
import AddObjectMenu from "./object-catalog.jsx";
|
|
68
109
|
import ResultModal from "./result-modal.jsx";
|
|
69
110
|
import LocaleToggle from "./locale-toggle.jsx";
|
|
@@ -78,7 +119,7 @@ import {
|
|
|
78
119
|
loadCustomPoses,
|
|
79
120
|
saveCustomPoses,
|
|
80
121
|
} from "./poses.js";
|
|
81
|
-
import { IkHandles, PoseHandles, PoseStudioPanel } from "./posestudio.jsx";
|
|
122
|
+
import { IkHandles, PoseHandles, PoseStudioPanel, warmPoseThumbnails } from "./posestudio.jsx";
|
|
82
123
|
import {
|
|
83
124
|
MID_TRACKS,
|
|
84
125
|
createIkState,
|
|
@@ -108,6 +149,8 @@ import {
|
|
|
108
149
|
VIDEO_MODELS,
|
|
109
150
|
composePrompt,
|
|
110
151
|
deriveShot,
|
|
152
|
+
focalMmToFov,
|
|
153
|
+
fovToFocalMm,
|
|
111
154
|
slateLine,
|
|
112
155
|
} from "./shot.js";
|
|
113
156
|
import { captureFraming, classifyMove, cameraMoveAt, moveSequenceSlate, moveSequencePhrase } from "./camera-move.js";
|
|
@@ -133,37 +176,29 @@ const PRESETS = {
|
|
|
133
176
|
closeup: { label: ko("Close-Up", "클로즈업"), distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
|
|
134
177
|
low: { label: ko("Low Angle", "로우 앵글"), distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
|
|
135
178
|
high: { label: ko("High Angle", "하이 앵글"), distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
|
|
136
|
-
ots: {
|
|
137
|
-
label: ko("Over Shoulder", "오버 숄더"),
|
|
138
|
-
distance: 2.8,
|
|
139
|
-
azimuth: 200,
|
|
140
|
-
elevation: 8,
|
|
141
|
-
fov: 40,
|
|
142
|
-
targetY: 1.5,
|
|
143
|
-
two: true,
|
|
144
|
-
// the foreground shoulder must sit between the lens and subject 1
|
|
145
|
-
charB: { x: 0.62, z: 1.05, rot: 196 },
|
|
146
|
-
},
|
|
147
|
-
two: {
|
|
148
|
-
label: ko("Two Shot", "투샷"),
|
|
149
|
-
distance: 6,
|
|
150
|
-
azimuth: 18,
|
|
151
|
-
elevation: 5,
|
|
152
|
-
fov: 42,
|
|
153
|
-
targetY: 1.2,
|
|
154
|
-
two: true,
|
|
155
|
-
charB: { x: 1.15, z: 0.1, rot: -14 },
|
|
156
|
-
aimMid: true,
|
|
157
|
-
},
|
|
158
179
|
};
|
|
159
180
|
|
|
160
181
|
const RIG_HIERARCHY_FOCUS = {
|
|
182
|
+
"rig.torso": "spine",
|
|
161
183
|
"rig.hips": "hips",
|
|
162
184
|
"rig.spine": "spine",
|
|
185
|
+
"rig.chest": "chest",
|
|
186
|
+
"rig.neck": "neck",
|
|
187
|
+
"rig.head": "head",
|
|
163
188
|
"rig.leftArm": "leftHand",
|
|
189
|
+
"rig.leftShoulder": "leftShoulder",
|
|
190
|
+
"rig.leftElbow": "leftElbow",
|
|
191
|
+
"rig.leftHand": "leftHand",
|
|
164
192
|
"rig.rightArm": "rightHand",
|
|
193
|
+
"rig.rightShoulder": "rightShoulder",
|
|
194
|
+
"rig.rightElbow": "rightElbow",
|
|
195
|
+
"rig.rightHand": "rightHand",
|
|
165
196
|
"rig.leftLeg": "leftFoot",
|
|
197
|
+
"rig.leftKnee": "leftKnee",
|
|
198
|
+
"rig.leftFoot": "leftFoot",
|
|
166
199
|
"rig.rightLeg": "rightFoot",
|
|
200
|
+
"rig.rightKnee": "rightKnee",
|
|
201
|
+
"rig.rightFoot": "rightFoot",
|
|
167
202
|
};
|
|
168
203
|
|
|
169
204
|
const HIERARCHY_INSPECTOR_TITLES = {
|
|
@@ -175,12 +210,26 @@ const HIERARCHY_INSPECTOR_TITLES = {
|
|
|
175
210
|
characterB: ko("Character 2", "인물 2"),
|
|
176
211
|
environment: ko("Environment", "환경"),
|
|
177
212
|
props: ko("Props", "소품"),
|
|
213
|
+
"rig.torso": ko("Torso", "몸통"),
|
|
178
214
|
"rig.hips": ko("Root / Hips", "루트 / 엉덩이"),
|
|
179
215
|
"rig.spine": ko("Spine", "척추"),
|
|
216
|
+
"rig.chest": ko("Chest", "가슴"),
|
|
217
|
+
"rig.neck": ko("Neck", "목"),
|
|
218
|
+
"rig.head": ko("Head", "머리"),
|
|
180
219
|
"rig.leftArm": ko("Left Arm", "왼팔"),
|
|
220
|
+
"rig.leftShoulder": ko("Left Shoulder", "왼쪽 어깨"),
|
|
221
|
+
"rig.leftElbow": ko("Left Elbow", "왼쪽 팔꿈치"),
|
|
222
|
+
"rig.leftHand": ko("Left Hand", "왼손"),
|
|
181
223
|
"rig.rightArm": ko("Right Arm", "오른팔"),
|
|
224
|
+
"rig.rightShoulder": ko("Right Shoulder", "오른쪽 어깨"),
|
|
225
|
+
"rig.rightElbow": ko("Right Elbow", "오른쪽 팔꿈치"),
|
|
226
|
+
"rig.rightHand": ko("Right Hand", "오른손"),
|
|
182
227
|
"rig.leftLeg": ko("Left Leg", "왼다리"),
|
|
228
|
+
"rig.leftKnee": ko("Left Knee", "왼쪽 무릎"),
|
|
229
|
+
"rig.leftFoot": ko("Left Foot", "왼발"),
|
|
183
230
|
"rig.rightLeg": ko("Right Leg", "오른다리"),
|
|
231
|
+
"rig.rightKnee": ko("Right Knee", "오른쪽 무릎"),
|
|
232
|
+
"rig.rightFoot": ko("Right Foot", "오른발"),
|
|
184
233
|
};
|
|
185
234
|
|
|
186
235
|
const CAMERA_MOVE_LABELS_KO = new Map([
|
|
@@ -248,6 +297,7 @@ const SCENE_RENDERER_LABELS_KO = new Map([
|
|
|
248
297
|
["chair", ko("chair", "의자")],
|
|
249
298
|
["car", ko("car", "자동차")],
|
|
250
299
|
["aircraft", ko("aircraft", "비행기")],
|
|
300
|
+
[CUTOUT_KIND, ko("cutout", "컷아웃")],
|
|
251
301
|
]);
|
|
252
302
|
|
|
253
303
|
const SCENE_OBJECT_NAME_LABELS_KO = new Map([
|
|
@@ -313,17 +363,22 @@ function moveSequenceSlateKo(segments) {
|
|
|
313
363
|
|
|
314
364
|
function hierarchyIdForIkFocus(focus) {
|
|
315
365
|
if (!focus) return null;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return "characterA.rig";
|
|
366
|
+
const exact = Object.entries(RIG_HIERARCHY_FOCUS)
|
|
367
|
+
.find(([id, mappedFocus]) =>
|
|
368
|
+
!["rig.torso", "rig.leftArm", "rig.rightArm", "rig.leftLeg", "rig.rightLeg"].includes(id) &&
|
|
369
|
+
mappedFocus === focus
|
|
370
|
+
);
|
|
371
|
+
return exact?.[0] ?? "characterA.rig";
|
|
323
372
|
}
|
|
324
373
|
|
|
325
374
|
const CAPTURE_W = 1920;
|
|
326
375
|
const CAPTURE_H = 1080;
|
|
376
|
+
const SHOT_ASPECT_PRESETS = Object.freeze({
|
|
377
|
+
"16:9": Object.freeze({ label: "16:9", aspect: 16 / 9, width: 1920, height: 1080 }),
|
|
378
|
+
"9:16": Object.freeze({ label: "9:16", aspect: 9 / 16, width: 1080, height: 1920 }),
|
|
379
|
+
"1:1": Object.freeze({ label: "1:1", aspect: 1, width: 1080, height: 1080 }),
|
|
380
|
+
"4:3": Object.freeze({ label: "4:3", aspect: 4 / 3, width: 1440, height: 1080 }),
|
|
381
|
+
});
|
|
327
382
|
// Pre-generated clip shipped with the build so a bridge-less session (a hosted
|
|
328
383
|
// static demo, or `npm run dev:ui`) still shows real generated motion.
|
|
329
384
|
// Root-absolute on purpose: the studio is served from "/app/" while these
|
|
@@ -331,19 +386,75 @@ const CAPTURE_H = 1080;
|
|
|
331
386
|
// to "/app/models/..." and 404. Vite's base is "/" (own apex domain), so a
|
|
332
387
|
// leading slash is now the correct — and only — form that works from both
|
|
333
388
|
// the dev server and the built site.
|
|
334
|
-
|
|
389
|
+
// Per-character rig model: the stage entry's `model` is the FBX file stem in
|
|
390
|
+
// public/models AND the wire rig name sent to ARDY, so mesh and export can
|
|
391
|
+
// never drift apart.
|
|
392
|
+
const characterModelUrl = (model) => `/models/${model}.fbx`;
|
|
393
|
+
|
|
394
|
+
/** Sequential ids for spawned characters, collision-free against the cast. */
|
|
395
|
+
function nextCharacterId(list) {
|
|
396
|
+
const ids = new Set(list.map((entry) => entry.id));
|
|
397
|
+
let n = list.length + 1;
|
|
398
|
+
let id = `char-${n}`;
|
|
399
|
+
while (ids.has(id)) {
|
|
400
|
+
n += 1;
|
|
401
|
+
id = `char-${n}`;
|
|
402
|
+
}
|
|
403
|
+
return id;
|
|
404
|
+
}
|
|
335
405
|
const DEMO_MOTION_URL = "/demo/walk-then-stop.npz";
|
|
336
406
|
const DEMO_MOTION_PROMPT = "a person walking then a person stops";
|
|
337
407
|
const CLAY = "#f2eee6";
|
|
338
408
|
const CLAY_B = "#ddd6ca";
|
|
409
|
+
// X Bot's shell is smooth (no raised exoskeleton like Y Bot's), so it gets a
|
|
410
|
+
// brighter, whiter clay to keep it readable against the set.
|
|
411
|
+
const CLAY_X = "#faf8f2";
|
|
412
|
+
// Model/role default for a cast member; a user-picked entry.tint always wins
|
|
413
|
+
// over this at render time.
|
|
414
|
+
const defaultCharacterTint = (entry, index) => (entry.model === "x-bot-tpose" ? CLAY_X : index === 0 ? CLAY : CLAY_B);
|
|
339
415
|
const DEFAULT_POSE = BUILT_IN_POSES.find((p) => p.id === "relaxed") ?? BUILT_IN_POSES[0];
|
|
340
|
-
const DEFAULT_SUBJECT =
|
|
416
|
+
const DEFAULT_SUBJECT = DEFAULT_SUBJECT_ONE;
|
|
417
|
+
const DEFAULT_SUBJECT2 = DEFAULT_SUBJECT_TWO;
|
|
341
418
|
const DEFAULT_ENVIRONMENT = "a sunlit modern living room";
|
|
342
419
|
const DEFAULT_CAMERA_POSITION = { x: 0.97, y: 1.62, z: 2.39 };
|
|
343
420
|
const REST_BONES = Object.fromEntries(POSE_BONES.map((b) => [b.id, [0, 0, 0]]));
|
|
344
|
-
|
|
421
|
+
// Two clocks, one boundary. The app timeline runs the production 24 fps —
|
|
422
|
+
// the rate the reference footage and the recorded export are counted in.
|
|
423
|
+
// ARDY Core generates on its trained 20 fps clock and that cannot move, so
|
|
424
|
+
// takes are RETIMED inbound (bridge → app, retimeMotion) and frame numbers
|
|
425
|
+
// are CONVERTED outbound (app → bridge, toArdyFrame). Nothing between the
|
|
426
|
+
// boundaries may mix the clocks.
|
|
427
|
+
const TIMELINE_FPS = 24;
|
|
428
|
+
const ARDY_FPS = 20;
|
|
429
|
+
const toArdyFrame = (frame) => Math.round((frame * ARDY_FPS) / TIMELINE_FPS);
|
|
430
|
+
|
|
431
|
+
// Outbound converters: timeline-frame entries → strictly-ascending bridge
|
|
432
|
+
// frames. Rounding can land two timeline frames on one bridge frame; the
|
|
433
|
+
// first wins — the bridge refuses non-ascending lists outright.
|
|
434
|
+
function toArdyFrameEntries(entries) {
|
|
435
|
+
const out = [];
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
const frame = toArdyFrame(entry.frame);
|
|
438
|
+
if (out.length && frame <= out[out.length - 1].frame) continue;
|
|
439
|
+
out.push({ ...entry, frame });
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function toArdySegments(segments) {
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const segment of segments) {
|
|
447
|
+
const startFrame = toArdyFrame(segment.startFrame);
|
|
448
|
+
const endFrame = toArdyFrame(segment.endFrame);
|
|
449
|
+
if (endFrame <= startFrame) continue; // a zero-length rounding remnant covers nothing
|
|
450
|
+
out.push({ ...segment, startFrame, endFrame });
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × TIMELINE_FPS frames
|
|
345
456
|
const DEFAULT_PLAYBACK_SPEED = 1;
|
|
346
|
-
const ARDY_PROMPT_HORIZON_FRAMES =
|
|
457
|
+
const ARDY_PROMPT_HORIZON_FRAMES = 2 * TIMELINE_FPS; // core model horizon: 2 seconds, counted on the timeline clock
|
|
347
458
|
const MAX_WAYPOINTS = 32; // ARDY bridge contract: a root path holds 2..32 distinct waypoint frames
|
|
348
459
|
const ARDY_PROMPT_MAX = 500; // bridge contract: prompt must be non-empty, capped at 500 chars
|
|
349
460
|
const ARDY_DURATION_MIN = 1; // the UI works in whole seconds; the bridge floor is 0.15 s
|
|
@@ -351,18 +462,83 @@ const ARDY_DURATION_MAX = 1200; // bridge contract: duration capped at 1200 s
|
|
|
351
462
|
const ARDY_SEED_MAX = 2 ** 31 - 1; // bridge contract: optional seed, integer in 0..2**31-1
|
|
352
463
|
const DEFAULT_PROMPT_CLIPS = [];
|
|
353
464
|
|
|
465
|
+
// Named ingest failures, in both locales. A reason the user cannot act on is
|
|
466
|
+
// not a message: each line says what was wrong with THIS source.
|
|
467
|
+
const MULTIMODEL_REASONS = {
|
|
468
|
+
"url-empty": ["Enter a video URL first", "영상 URL을 먼저 입력하세요"],
|
|
469
|
+
"url-protocol-relative": ["Use a full https:// address", "https:// 로 시작하는 전체 주소를 쓰세요"],
|
|
470
|
+
"url-scheme-unsupported": ["Only http(s) or a local /path is accepted", "http(s) 또는 로컬 /경로만 사용할 수 있어요"],
|
|
471
|
+
"url-platform-page": [
|
|
472
|
+
"YouTube/Vimeo pages are players, not video files, and block browser downloads. Start the dev bridge to use the URL directly, or download the clip and use Choose video.",
|
|
473
|
+
"유튜브·비메오 주소는 영상 파일이 아니라 플레이어 페이지이고 브라우저 다운로드를 막습니다. 개발 브리지를 켜면 URL을 그대로 쓸 수 있고, 아니면 영상을 내려받아 '영상 선택'을 쓰세요.",
|
|
474
|
+
],
|
|
475
|
+
"url-malformed": ["That address could not be parsed", "주소를 해석할 수 없어요"],
|
|
476
|
+
"fetch-failed": ["The host refused a browser download (CORS or offline)", "호스트가 브라우저 다운로드를 거부했어요(CORS 또는 오프라인)"],
|
|
477
|
+
"not-a-video": ["That address did not return a video (check the path)", "그 주소는 영상을 반환하지 않았어요(경로를 확인하세요)"],
|
|
478
|
+
"decode-failed": ["This browser cannot decode that video", "이 브라우저가 디코딩할 수 없는 영상이에요"],
|
|
479
|
+
"duration-unreadable": ["The clip length could not be read", "클립 길이를 읽지 못했어요"],
|
|
480
|
+
"dimensions-unreadable": ["The frame size could not be read", "프레임 크기를 읽지 못했어요"],
|
|
481
|
+
"probe-timeout": ["The video never reported metadata", "영상이 메타데이터를 보내지 않았어요"],
|
|
482
|
+
"fetch-unavailable": ["This browser cannot download files", "이 브라우저는 파일을 내려받을 수 없어요"],
|
|
483
|
+
"footage-load-timeout": ["The clip never became seekable", "클립이 탐색 가능한 상태가 되지 않았어요"],
|
|
484
|
+
"seek-timeout": ["Seeking inside the clip stalled", "클립 내부 탐색이 멈췄어요"],
|
|
485
|
+
"pose-runtime-unavailable": ["The pose engine is missing from this build", "이 빌드에 포즈 엔진이 없어요"],
|
|
486
|
+
"pose-model-download-failed": ["The pose model could not be downloaded (offline?)", "포즈 모델을 내려받지 못했어요(오프라인인가요?)"],
|
|
487
|
+
"pose-engine-download-failed": ["The pose engine could not be downloaded (offline?)", "포즈 엔진을 내려받지 못했어요(오프라인인가요?)"],
|
|
488
|
+
"pose-engine-init-failed": ["The pose engine failed to start on this device", "이 기기에서 포즈 엔진을 시작하지 못했어요"],
|
|
489
|
+
"rest-unavailable": ["The rig rest data (/ardy/cskel27-rest.json) could not be loaded", "리그 레스트 데이터(/ardy/cskel27-rest.json)를 불러오지 못했어요"],
|
|
490
|
+
"no-person-found": ["No person was detected in the footage", "영상에서 사람을 감지하지 못했어요"],
|
|
491
|
+
"no-usable-pose": ["A person was seen, but no stable pose could be fitted", "사람은 보였지만 안정적인 포즈를 만들지 못했어요"],
|
|
492
|
+
"rig-not-loaded": ["Subject 1's rig is not loaded yet", "인물 1 리그가 아직 로드되지 않았어요"],
|
|
493
|
+
"bridge-unreachable": ["The dev bridge did not answer", "개발 브리지가 응답하지 않아요"],
|
|
494
|
+
"bridge-footage-incomplete": ["The bridge stream ended without footage", "브리지 전송이 영상 없이 끝났어요"],
|
|
495
|
+
"footage-url-invalid": ["That address could not be used for a bridge download", "이 주소는 브리지 다운로드에 쓸 수 없어요"],
|
|
496
|
+
"footage-probe-failed": ["The bridge could not read that page's video info", "브리지가 그 페이지의 영상 정보를 읽지 못했어요"],
|
|
497
|
+
"footage-live-unsupported": ["Live streams have no fixed length and cannot be ingested", "라이브 스트림은 길이가 없어 인제스트할 수 없어요"],
|
|
498
|
+
"footage-too-long": ["That video is over 15 minutes — trim or pick a shorter one", "15분이 넘는 영상이에요 — 잘라내거나 더 짧은 걸 골라 주세요"],
|
|
499
|
+
"footage-download-failed": ["The bridge could not download that video", "브리지가 영상을 내려받지 못했어요"],
|
|
500
|
+
"footage-normalize-failed": ["The bridge could not convert that video for extraction", "브리지가 영상을 추출용으로 변환하지 못했어요"],
|
|
501
|
+
"footage-timeout": ["The bridge download took too long and was stopped", "브리지 다운로드가 너무 오래 걸려 중단됐어요"],
|
|
502
|
+
"bridge-extract-incomplete": ["The bridge stream ended without a take", "브리지 전송이 테이크 없이 끝났어요"],
|
|
503
|
+
"extract-host-missing": ["The bridge has no GPU box configured (CCLAY_ARDY_HOST)", "브리지에 GPU 박스가 설정돼 있지 않아요(CCLAY_ARDY_HOST)"],
|
|
504
|
+
"extract-upload-failed": ["The footage could not be copied to the GPU box", "영상을 GPU 박스로 복사하지 못했어요"],
|
|
505
|
+
"extract-upload-too-large": ["That clip is too large to upload for extraction (300 MB cap)", "추출 업로드 한도(300MB)를 넘는 영상이에요"],
|
|
506
|
+
"extract-upload-empty": ["No video bytes arrived at the bridge", "브리지에 영상 데이터가 도착하지 않았어요"],
|
|
507
|
+
"extract-footage-unknown": ["The bridge no longer holds that download — re-ingest the URL", "브리지에 그 다운로드가 더 이상 없어요 — URL을 다시 넣어 주세요"],
|
|
508
|
+
"extract-run-failed": ["SAM-3D-Body failed on the GPU box (see the bridge log)", "GPU 박스에서 SAM-3D-Body 실행이 실패했어요(브리지 로그 확인)"],
|
|
509
|
+
"extract-no-person": ["The GPU box tracked no person in that footage", "GPU 박스가 영상에서 사람을 추적하지 못했어요"],
|
|
510
|
+
"extract-convert-failed": ["The extracted take could not be converted for the timeline", "추출된 테이크를 타임라인용으로 변환하지 못했어요"],
|
|
511
|
+
"extract-timeout": ["GPU extraction took too long and was stopped", "GPU 추출이 너무 오래 걸려 중단됐어요"],
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// Extraction samples and bakes straight onto the production clock — an
|
|
515
|
+
// extracted take never touches ARDY, so it never needs retiming either.
|
|
516
|
+
const MULTIMODEL_SAMPLE_FPS = TIMELINE_FPS;
|
|
517
|
+
|
|
354
518
|
/* ------------------------------------------------------------------ 3D --- */
|
|
355
519
|
|
|
356
|
-
|
|
520
|
+
// Memoized: unchanged cast members skip re-rendering on every playhead tick.
|
|
521
|
+
const Character = memo(function Character({ url, position, rot, tint, pose, scale = 1, onRig, pickId }) {
|
|
357
522
|
const fbx = useFBX(url);
|
|
358
523
|
const model = useMemo(() => {
|
|
359
524
|
const clone = SkeletonUtils.clone(fbx);
|
|
360
|
-
|
|
525
|
+
// Mixamo exports centimetres; `scale` is the FILMED person's stature.
|
|
526
|
+
// THE INVARIANT: extraction divided this take's root travel by that
|
|
527
|
+
// stature, so the clip and the scale must travel together — apply one
|
|
528
|
+
// without the other and every stride overshoots by the same factor and
|
|
529
|
+
// the feet skate. It belongs HERE, on the world transform, and never
|
|
530
|
+
// inside rig space: playback.js derives prep.scale from the bind pose,
|
|
531
|
+
// so a scaled bone hierarchy would be measured as a different rig.
|
|
532
|
+
clone.scale.setScalar(0.01 * scale);
|
|
533
|
+
// The joint shells (Alpha_Joints / Beta_Joints — elbows, knees, the
|
|
534
|
+
// exoskeleton bands) render in a darkened shade of the body tint so
|
|
535
|
+
// the segments read as separate pieces.
|
|
536
|
+
const jointTint = new THREE.Color(tint).multiplyScalar(0.45);
|
|
361
537
|
clone.traverse((child) => {
|
|
362
538
|
if (child.isMesh) {
|
|
363
539
|
// warm clay reads as a maquette; cold grey reads as a broken render
|
|
364
540
|
child.material = new THREE.MeshStandardMaterial({
|
|
365
|
-
color: tint,
|
|
541
|
+
color: /_Joints$/i.test(child.name) ? jointTint : tint,
|
|
366
542
|
roughness: 0.82,
|
|
367
543
|
metalness: 0,
|
|
368
544
|
});
|
|
@@ -375,6 +551,13 @@ function Character({ url, position, rot, tint, pose, onRig, pickId }) {
|
|
|
375
551
|
return clone;
|
|
376
552
|
}, [fbx, tint]);
|
|
377
553
|
|
|
554
|
+
// A new stature (a fresh extraction on this character) must not rebuild the
|
|
555
|
+
// clone — that would drop the rig the playback effects hold. Only the world
|
|
556
|
+
// transform moves; the same invariant as above applies.
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
model.scale.setScalar(0.01 * scale);
|
|
559
|
+
}, [model, scale]);
|
|
560
|
+
|
|
378
561
|
useEffect(() => {
|
|
379
562
|
// During playback the pose prop is null and the motion drives the rig;
|
|
380
563
|
// this effect can flush AFTER the playback effect in a later commit
|
|
@@ -386,9 +569,13 @@ function Character({ url, position, rot, tint, pose, onRig, pickId }) {
|
|
|
386
569
|
applyPose(model, { ...REST_BONES, ...pose.bones });
|
|
387
570
|
}, [model, pose]);
|
|
388
571
|
|
|
572
|
+
const onRigRef = useRef(onRig);
|
|
573
|
+
onRigRef.current = onRig;
|
|
574
|
+
// Report on MODEL change only — a per-render callback identity change
|
|
575
|
+
// must not re-run this effect.
|
|
389
576
|
useEffect(() => {
|
|
390
|
-
|
|
391
|
-
}, [model
|
|
577
|
+
onRigRef.current?.(model);
|
|
578
|
+
}, [model]);
|
|
392
579
|
|
|
393
580
|
return (
|
|
394
581
|
// characterPick makes the body a first-class click target: the Scene
|
|
@@ -398,9 +585,11 @@ function Character({ url, position, rot, tint, pose, onRig, pickId }) {
|
|
|
398
585
|
<primitive object={model} />
|
|
399
586
|
</group>
|
|
400
587
|
);
|
|
401
|
-
}
|
|
588
|
+
});
|
|
402
589
|
|
|
403
|
-
/**
|
|
590
|
+
/** Selection marker for the picked cast member: a Unity-style XYZ tripod
|
|
591
|
+
* plus a ground ring at the feet. X/Z arrows also drag the character on the
|
|
592
|
+
* floor (Y is display-only — characters stand on the deck). */
|
|
404
593
|
function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics }) {
|
|
405
594
|
useEffect(() => {
|
|
406
595
|
const cam = camRef.current;
|
|
@@ -473,7 +662,7 @@ function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, c
|
|
|
473
662
|
Two clocks can drive the move. Standalone preview runs on its own clock;
|
|
474
663
|
follow mode slaves the move to the timeline playhead, so the camera and
|
|
475
664
|
the character motion are two views of the same time axis — playing or
|
|
476
|
-
scrubbing frame
|
|
665
|
+
scrubbing frame 72 at 24 fps puts the camera exactly 3 s into its move. */
|
|
477
666
|
function MoveRig({ playing, following, followFrame, fps, keys, shots, anchor, camRef, look, isInterrupted, onDone }) {
|
|
478
667
|
const clock = useRef(0);
|
|
479
668
|
const invalidate = useThree((state) => state.invalidate);
|
|
@@ -699,34 +888,50 @@ function ShotPathPreview({ waypoints, start, activeWaypointFrame }) {
|
|
|
699
888
|
);
|
|
700
889
|
}
|
|
701
890
|
|
|
702
|
-
/** Unity-style scene grid
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
891
|
+
/** Unity-style scene grid over the whole open stage, editor chrome only.
|
|
892
|
+
Two tiers keep it legible at any zoom: 1 m minor cells for placement and a
|
|
893
|
+
darker 10 m major line that survives wide framings where minor lines blur
|
|
894
|
+
into a wash. Both ride the gizmo layer so exports and the plan never pick
|
|
895
|
+
them up, sit a hair above the deck to dodge z-fighting, and never swallow
|
|
896
|
+
a raycast — floor clicks pass straight through to the ground plane. Warm
|
|
897
|
+
greys tuned to the clay floor (#e7e1d7). */
|
|
707
898
|
function SceneGrid() {
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
899
|
+
const grids = useMemo(() => {
|
|
900
|
+
const make = (divisions, color, opacity, y) => {
|
|
901
|
+
const helper = new THREE.GridHelper(500, divisions, color, color);
|
|
902
|
+
helper.material.transparent = true;
|
|
903
|
+
helper.material.opacity = opacity;
|
|
904
|
+
helper.material.depthWrite = false;
|
|
905
|
+
helper.position.y = y;
|
|
906
|
+
helper.layers.set(GIZMO_LAYER);
|
|
907
|
+
helper.raycast = () => null;
|
|
908
|
+
return helper;
|
|
909
|
+
};
|
|
910
|
+
return [
|
|
911
|
+
// Value hierarchy against the bright deck (#f4f0e8): quiet 1 m cells
|
|
912
|
+
// for placement, assertive 10 m lines for structure. The gap between
|
|
913
|
+
// floor and minor keeps the tiling from reading as a repeating texture.
|
|
914
|
+
make(500, 0xa89d8a, 0.4, 0.002), // minor: 1 m cells, subtle
|
|
915
|
+
make(50, 0x6a5f4e, 0.9, 0.003), // major: 10 m lines, clearly darker
|
|
916
|
+
];
|
|
717
917
|
}, []);
|
|
718
|
-
return
|
|
918
|
+
return (
|
|
919
|
+
<>
|
|
920
|
+
<primitive object={grids[0]} />
|
|
921
|
+
<primitive object={grids[1]} />
|
|
922
|
+
</>
|
|
923
|
+
);
|
|
719
924
|
}
|
|
720
925
|
|
|
721
|
-
/** Offscreen
|
|
722
|
-
function CaptureRig({ apiRef, camRef }) {
|
|
926
|
+
/** Offscreen aspect-aware read-back, always from the shot camera. */
|
|
927
|
+
function CaptureRig({ apiRef, camRef, width = CAPTURE_W, height = CAPTURE_H }) {
|
|
723
928
|
const { gl, scene } = useThree();
|
|
724
929
|
useEffect(() => {
|
|
725
930
|
apiRef.current = {
|
|
726
931
|
render() {
|
|
727
932
|
const source = camRef.current;
|
|
728
933
|
if (!source) return null;
|
|
729
|
-
const target = new THREE.WebGLRenderTarget(
|
|
934
|
+
const target = new THREE.WebGLRenderTarget(width, height, {
|
|
730
935
|
colorSpace: THREE.SRGBColorSpace,
|
|
731
936
|
samples: 4,
|
|
732
937
|
});
|
|
@@ -737,20 +942,20 @@ function CaptureRig({ apiRef, camRef }) {
|
|
|
737
942
|
// with — the browser suite asserts GIZMO_LAYER (the gizmo AND
|
|
738
943
|
// the selection cage) is never in it.
|
|
739
944
|
window.__captureCameraMask = cam.layers.mask;
|
|
740
|
-
cam.aspect =
|
|
945
|
+
cam.aspect = width / height;
|
|
741
946
|
cam.updateProjectionMatrix();
|
|
742
947
|
const previous = gl.getRenderTarget();
|
|
743
948
|
gl.setRenderTarget(target);
|
|
744
949
|
gl.render(scene, cam);
|
|
745
|
-
const buffer = new Uint8Array(
|
|
746
|
-
gl.readRenderTargetPixels(target, 0, 0,
|
|
950
|
+
const buffer = new Uint8Array(width * height * 4);
|
|
951
|
+
gl.readRenderTargetPixels(target, 0, 0, width, height, buffer);
|
|
747
952
|
gl.setRenderTarget(previous);
|
|
748
953
|
target.dispose();
|
|
749
954
|
return buffer;
|
|
750
955
|
},
|
|
751
956
|
};
|
|
752
957
|
// QA hook: run one real export render and report what the capture
|
|
753
|
-
// camera saw — the layer mask plus an amber scan of the
|
|
958
|
+
// camera saw — the layer mask plus an amber scan of the output
|
|
754
959
|
// frame (the selection cage's warm tone). Lets the suite prove the
|
|
755
960
|
// deliverable frame stays free of editor furniture without driving
|
|
756
961
|
// the whole generation form.
|
|
@@ -766,10 +971,55 @@ function CaptureRig({ apiRef, camRef }) {
|
|
|
766
971
|
}
|
|
767
972
|
return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
|
|
768
973
|
};
|
|
769
|
-
}, [gl, scene, camRef, apiRef]);
|
|
974
|
+
}, [gl, scene, camRef, apiRef, width, height]);
|
|
770
975
|
return null;
|
|
771
976
|
}
|
|
772
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Anything that accepts a dropped picture. Returns the handlers to spread onto
|
|
980
|
+
* an element and whether a drag is currently over it, so the target can say so
|
|
981
|
+
* — a drop zone that gives no sign it is armed reads as a dead area, and the
|
|
982
|
+
* file gets dropped on the desktop instead.
|
|
983
|
+
*
|
|
984
|
+
* `dragover` must preventDefault, or the browser navigates away to the file.
|
|
985
|
+
*/
|
|
986
|
+
function useImageDrop(onFiles) {
|
|
987
|
+
const [over, setOver] = useState(false);
|
|
988
|
+
const depth = useRef(0);
|
|
989
|
+
const carriesFiles = (event) => !!event.dataTransfer?.types?.includes?.("Files");
|
|
990
|
+
return {
|
|
991
|
+
over,
|
|
992
|
+
handlers: {
|
|
993
|
+
onDragEnter: (event) => {
|
|
994
|
+
if (!carriesFiles(event)) return;
|
|
995
|
+
event.preventDefault();
|
|
996
|
+
depth.current += 1;
|
|
997
|
+
setOver(true);
|
|
998
|
+
},
|
|
999
|
+
onDragOver: (event) => {
|
|
1000
|
+
if (!carriesFiles(event)) return;
|
|
1001
|
+
event.preventDefault();
|
|
1002
|
+
event.dataTransfer.dropEffect = "copy";
|
|
1003
|
+
},
|
|
1004
|
+
// dragleave fires for every child the pointer crosses, so the
|
|
1005
|
+
// highlight is refcounted rather than toggled.
|
|
1006
|
+
onDragLeave: () => {
|
|
1007
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
1008
|
+
if (!depth.current) setOver(false);
|
|
1009
|
+
},
|
|
1010
|
+
onDrop: (event) => {
|
|
1011
|
+
const files = imageFilesFrom(event.dataTransfer);
|
|
1012
|
+
depth.current = 0;
|
|
1013
|
+
setOver(false);
|
|
1014
|
+
if (!files.length) return;
|
|
1015
|
+
event.preventDefault();
|
|
1016
|
+
event.stopPropagation();
|
|
1017
|
+
onFiles(files);
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
773
1023
|
/* ----------------------------------------------------------------- app --- */
|
|
774
1024
|
|
|
775
1025
|
// Unity's tool keys. They are free because camera movement now lives behind a
|
|
@@ -834,9 +1084,14 @@ function loadSceneStartup() {
|
|
|
834
1084
|
}
|
|
835
1085
|
|
|
836
1086
|
export default function App() {
|
|
1087
|
+
const [startup] = useState(loadSceneStartup);
|
|
1088
|
+
const startupScene = startup.document.scenes[activeSceneIndex(startup.document.scenes, startup.document.activeSceneId)];
|
|
1089
|
+
const startupStage = createSceneStage(startupScene.stage);
|
|
837
1090
|
const [workspaceLayout, setWorkspaceLayout] = useState(loadWorkspaceLayout);
|
|
838
1091
|
const [preset, setPreset] = useState("medium");
|
|
839
1092
|
const [fovDeg, setFovDeg] = useState(PRESETS.medium.fov);
|
|
1093
|
+
const [shotAspectKey, setShotAspectKey] = useState(startupStage.shotAspect);
|
|
1094
|
+
const shotOutput = SHOT_ASPECT_PRESETS[shotAspectKey] ?? SHOT_ASPECT_PRESETS["16:9"];
|
|
840
1095
|
const [nonce, setNonce] = useState(0);
|
|
841
1096
|
// The Top-View is always the inset: the old double-click swap that let the
|
|
842
1097
|
// plan own the big pane is gone, so there is no view mode to toggle.
|
|
@@ -1056,22 +1311,156 @@ globalThis.playMode = centerTab === "play";
|
|
|
1056
1311
|
window.addEventListener("pointerup", onUp);
|
|
1057
1312
|
window.addEventListener("pointercancel", onUp);
|
|
1058
1313
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const [
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1314
|
+
// The cast is ONE list now: every character (position, rig model, pose,
|
|
1315
|
+
// subject line) lives in `characters`, and the legacy A/B view of the
|
|
1316
|
+
// world is derived below so the rest of the studio keeps working while
|
|
1317
|
+
// spawned extras ride the same rails.
|
|
1318
|
+
const [characters, setCharacters] = useState(startupStage.characters);
|
|
1319
|
+
// The cast as of this render, for async handlers: an extraction that
|
|
1320
|
+
// started three renders ago must place its takes against the CURRENT cast,
|
|
1321
|
+
// not the one its closure captured.
|
|
1322
|
+
const charactersRef = useRef(characters);
|
|
1323
|
+
charactersRef.current = characters;
|
|
1324
|
+
// Character undo stack lives next to the cast: the store creation below
|
|
1325
|
+
// and the undo handlers further down both read these refs.
|
|
1326
|
+
const charHistoryRef = useRef({ past: [], future: [] });
|
|
1327
|
+
const opClockRef = useRef(0);
|
|
1328
|
+
const lastObjectOpRef = useRef(0);
|
|
1329
|
+
const suppressObjectClockRef = useRef(false);
|
|
1067
1330
|
const [customPoses, setCustomPoses] = useState(() => loadCustomPoses());
|
|
1068
1331
|
const [posing, setPosing] = useState(null);
|
|
1069
1332
|
const [posingClosing, setPosingClosing] = useState(false);
|
|
1070
1333
|
const [studioPick, setStudioPick] = useState(null);
|
|
1071
|
-
const [
|
|
1072
|
-
const [rigB, setRigB] = useState(null);
|
|
1334
|
+
const [rigs, setRigs] = useState({});
|
|
1073
1335
|
const [poseRevision, setPoseTick] = useState(0);
|
|
1074
1336
|
|
|
1337
|
+
/* --------------------- derived cast view + shims ---------------------- */
|
|
1338
|
+
|
|
1339
|
+
// Fallback second slot mirrors the old charB defaults so preset math and
|
|
1340
|
+
// the two-subject inspector never see a hole before B exists.
|
|
1341
|
+
const charA = characters[0] ?? createCharacterEntry(null, 0);
|
|
1342
|
+
const charB = characters[1] ?? { ...createCharacterEntry(null, 1), x: 1.15, z: 0.1, rot: -14 };
|
|
1343
|
+
const showB = characters.some((entry, index) => index > 0 && !entry.hidden);
|
|
1344
|
+
const poseA = charA.pose ?? DEFAULT_POSE;
|
|
1345
|
+
const poseB = charB.pose ?? DEFAULT_POSE;
|
|
1346
|
+
const subject = charA.subject ?? DEFAULT_SUBJECT;
|
|
1347
|
+
const subject2 = charB.subject ?? DEFAULT_SUBJECT2;
|
|
1348
|
+
const rigA = rigs[charA.id] ?? null;
|
|
1349
|
+
const rigB = (characters[1] ? rigs[charB.id] : null) ?? null;
|
|
1350
|
+
|
|
1351
|
+
function updateCharacterAt(index, next) {
|
|
1352
|
+
setCharacters((list) => list.map((entry, i) => {
|
|
1353
|
+
if (i !== index) return entry;
|
|
1354
|
+
const resolved = typeof next === "function" ? next(entry) : next;
|
|
1355
|
+
return { ...entry, ...resolved };
|
|
1356
|
+
}));
|
|
1357
|
+
}
|
|
1358
|
+
// The shims keep the legacy call sites (inspector sliders, presets, pose
|
|
1359
|
+
// studio, prompts) untouched while the list stays the source of truth.
|
|
1360
|
+
const setCharA = (next) => updateCharacterAt(0, next);
|
|
1361
|
+
const setCharB = (next) => updateCharacterAt(1, next);
|
|
1362
|
+
const setPoseA = (pose) => updateCharacterAt(0, (entry) => ({ pose: typeof pose === "function" ? pose(entry.pose ?? DEFAULT_POSE) : pose }));
|
|
1363
|
+
const setPoseB = (pose) => updateCharacterAt(1, (entry) => ({ pose: typeof pose === "function" ? pose(entry.pose ?? DEFAULT_POSE) : pose }));
|
|
1364
|
+
const setSubject = (value) => updateCharacterAt(0, (entry) => ({ subject: typeof value === "function" ? value(entry.subject) : value }));
|
|
1365
|
+
const setSubject2 = (value) => updateCharacterAt(1, (entry) => ({ subject: typeof value === "function" ? value(entry.subject) : value }));
|
|
1366
|
+
function setShowB(next) {
|
|
1367
|
+
recordCharacterUndo();
|
|
1368
|
+
setCharacters((list) => {
|
|
1369
|
+
const anyVisibleExtra = list.some((entry, i) => i > 0 && !entry.hidden);
|
|
1370
|
+
const on = typeof next === "function" ? next(anyVisibleExtra) : next;
|
|
1371
|
+
if (on) {
|
|
1372
|
+
if (anyVisibleExtra) return list;
|
|
1373
|
+
const hiddenIdx = list.findIndex((entry, i) => i > 0 && entry.hidden);
|
|
1374
|
+
if (hiddenIdx > 0) return list.map((entry, i) => (i === hiddenIdx ? { ...entry, hidden: false } : entry));
|
|
1375
|
+
return [...list, createCharacterEntry({ id: nextCharacterId(list), x: 1.15, z: 0.1, rot: -14, pose: DEFAULT_POSE, subject: DEFAULT_SUBJECT2 }, list.length)];
|
|
1376
|
+
}
|
|
1377
|
+
return list.map((entry, i) => (i > 0 && !entry.hidden ? { ...entry, hidden: true } : entry));
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
function moveCharacter(charId, next) {
|
|
1381
|
+
setCharacters((list) => list.map((entry) => {
|
|
1382
|
+
if (entry.id !== charId) return entry;
|
|
1383
|
+
const resolved = typeof next === "function" ? next(entry) : next;
|
|
1384
|
+
return { ...entry, ...resolved };
|
|
1385
|
+
}));
|
|
1386
|
+
}
|
|
1387
|
+
function removeCharacter(charId) {
|
|
1388
|
+
const list = charactersRef.current;
|
|
1389
|
+
if (list.length <= 1) return;
|
|
1390
|
+
recordCharacterUndo();
|
|
1391
|
+
const nextCharacters = list.filter((entry) => entry.id !== charId);
|
|
1392
|
+
charactersRef.current = nextCharacters;
|
|
1393
|
+
setCharacters(nextCharacters);
|
|
1394
|
+
// The deleted layer's untrimmed take goes with it: a recycled id must
|
|
1395
|
+
// never inherit a stranger's take, and its stature left with the entry.
|
|
1396
|
+
motionFullRef.current.delete(charId);
|
|
1397
|
+
setRigs((current) => {
|
|
1398
|
+
if (!(charId in current)) return current;
|
|
1399
|
+
const next = { ...current };
|
|
1400
|
+
delete next[charId];
|
|
1401
|
+
return next;
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
// Per-character rig report: stable callback identity per character so
|
|
1405
|
+
// the Character effect does not re-fire on every App render.
|
|
1406
|
+
const rigReportersRef = useRef(new Map());
|
|
1407
|
+
const reportRig = (charId) => {
|
|
1408
|
+
if (!rigReportersRef.current.has(charId)) {
|
|
1409
|
+
rigReportersRef.current.set(charId, (rig) => setRigs((current) => (current[charId] === rig ? current : { ...current, [charId]: rig })));
|
|
1410
|
+
}
|
|
1411
|
+
return rigReportersRef.current.get(charId);
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
/* --------------------------- asset dragging ---------------------------- */
|
|
1415
|
+
|
|
1416
|
+
// A grabbed asset card follows the pointer as a DOM ghost; dropping over
|
|
1417
|
+
// the shot pane raycasts to the floor and spawns the character there.
|
|
1418
|
+
const [assetDrag, setAssetDrag] = useState(null);
|
|
1419
|
+
const spawnCharacter = (model, x, z) => {
|
|
1420
|
+
recordCharacterUndo();
|
|
1421
|
+
const id = nextCharacterId(characters);
|
|
1422
|
+
setCharacters((list) => [...list, createCharacterEntry({ id, model, x, z, pose: DEFAULT_POSE, subject: "a person" }, list.length)]);
|
|
1423
|
+
setSelectedHierarchyId(`character:${id}`);
|
|
1424
|
+
setSidebarTab("inspector");
|
|
1425
|
+
setToast(ko("Character added to the scene", "인물을 씬에 추가했어요"));
|
|
1426
|
+
};
|
|
1427
|
+
function beginAssetDrag(asset, event) {
|
|
1428
|
+
const start = { x: event.clientX, y: event.clientY };
|
|
1429
|
+
setAssetDrag({ asset, x: start.x, y: start.y });
|
|
1430
|
+
const onMove = (move) => setAssetDrag((drag) => (drag ? { ...drag, x: move.clientX, y: move.clientY } : drag));
|
|
1431
|
+
const onUp = (up) => {
|
|
1432
|
+
window.removeEventListener("pointermove", onMove);
|
|
1433
|
+
window.removeEventListener("pointerup", onUp);
|
|
1434
|
+
window.removeEventListener("pointercancel", onUp);
|
|
1435
|
+
setAssetDrag(null);
|
|
1436
|
+
const host = mainPaneRef.current;
|
|
1437
|
+
const cam = shotCamRef.current;
|
|
1438
|
+
if (!host || !cam) return;
|
|
1439
|
+
const rect = host.getBoundingClientRect();
|
|
1440
|
+
if (up.clientX < rect.left || up.clientX > rect.right || up.clientY < rect.top || up.clientY > rect.bottom) return;
|
|
1441
|
+
const pointer = new THREE.Vector2(
|
|
1442
|
+
((up.clientX - rect.left) / rect.width) * 2 - 1,
|
|
1443
|
+
-((up.clientY - rect.top) / rect.height) * 2 + 1,
|
|
1444
|
+
);
|
|
1445
|
+
const raycaster = new THREE.Raycaster();
|
|
1446
|
+
raycaster.setFromCamera(pointer, cam);
|
|
1447
|
+
const hit = new THREE.Vector3();
|
|
1448
|
+
if (!raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit)) return;
|
|
1449
|
+
spawnCharacter(asset.id, THREE.MathUtils.clamp(hit.x, -4, 4), THREE.MathUtils.clamp(hit.z, -4, 4));
|
|
1450
|
+
};
|
|
1451
|
+
window.addEventListener("pointermove", onMove);
|
|
1452
|
+
window.addEventListener("pointerup", onUp);
|
|
1453
|
+
window.addEventListener("pointercancel", onUp);
|
|
1454
|
+
}
|
|
1455
|
+
// Viewport picks tag bodies with "A"/"B"/charId and surfaces route the
|
|
1456
|
+
// result to a hierarchy row: the first two keep their legacy row ids.
|
|
1457
|
+
const charKeyToHierarchyId = (key) => {
|
|
1458
|
+
if (key === "A" || key === "a" || key === "char:A") return "characterA";
|
|
1459
|
+
if (key === "B" || key === "b" || key === "char:B") return "characterB";
|
|
1460
|
+
return `character:${key.startsWith("char:") ? key.slice(5) : key}`;
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
|
|
1075
1464
|
/* ------------------------------ IK layer ------------------------------ */
|
|
1076
1465
|
// IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
|
|
1077
1466
|
// joint and solves its chain backward (two-bone analytic IK) on top of
|
|
@@ -1085,6 +1474,38 @@ globalThis.playMode = centerTab === "play";
|
|
|
1085
1474
|
const [ikFkJoints, setIkFkJoints] = useState(null);
|
|
1086
1475
|
const [ikFocus, setIkFocus] = useState(null);
|
|
1087
1476
|
const [selectedHierarchyId, setSelectedHierarchyId] = useState("characterA");
|
|
1477
|
+
|
|
1478
|
+
/* ------------------- active character (motion layer) ------------------- */
|
|
1479
|
+
|
|
1480
|
+
// Every character owns an animation layer (root path, prompt blocks,
|
|
1481
|
+
// generated clip, IK keys). The studio's motion machinery edits ONE layer
|
|
1482
|
+
// at a time — the ACTIVE character's — and selection decides who that is.
|
|
1483
|
+
const charIdFromHierarchyId = (hierarchyId) => {
|
|
1484
|
+
if (hierarchyId === "characterA") return characters[0]?.id ?? null;
|
|
1485
|
+
if (hierarchyId === "characterB") return characters[1]?.id ?? null;
|
|
1486
|
+
if (hierarchyId?.startsWith("character:")) return hierarchyId.slice(10);
|
|
1487
|
+
return null;
|
|
1488
|
+
};
|
|
1489
|
+
// State, not a ref: a ref written inside an effect never re-renders, so
|
|
1490
|
+
// with an idle app the active character silently stayed behind the row
|
|
1491
|
+
// the user just clicked.
|
|
1492
|
+
const [activeCharacterId, setActiveCharacterId] = useState(characters[0]?.id ?? null);
|
|
1493
|
+
useEffect(() => {
|
|
1494
|
+
const id = charIdFromHierarchyId(selectedHierarchyId);
|
|
1495
|
+
if (id && characters.some((entry) => entry.id === id)) setActiveCharacterId(id);
|
|
1496
|
+
}, [selectedHierarchyId, characters]);
|
|
1497
|
+
const activeChar = characters.find((entry) => entry.id === activeCharacterId) ?? characters[0] ?? charA;
|
|
1498
|
+
const activeCharIndex = Math.max(0, characters.findIndex((entry) => entry.id === activeChar.id));
|
|
1499
|
+
const activeRig = rigs[activeChar.id] ?? null;
|
|
1500
|
+
// Read-only previews of the other cast members' layers for the timeline,
|
|
1501
|
+
// memoized: a fresh array every render would re-render every lane on
|
|
1502
|
+
// every playhead tick.
|
|
1503
|
+
const ghostLayers = useMemo(() => characters.flatMap((entry, index) => entry.id === activeChar.id || entry.hidden ? [] : [{
|
|
1504
|
+
owner: `S${index + 1}`,
|
|
1505
|
+
promptClips: entry.layer?.promptClips ?? [],
|
|
1506
|
+
waypointFrames: (entry.layer?.waypoints ?? []).map((waypoint) => waypoint.frame),
|
|
1507
|
+
}]), [characters, activeChar.id]);
|
|
1508
|
+
|
|
1088
1509
|
// Right-sidebar tab. "inspector" shows the selection's properties; "shot"
|
|
1089
1510
|
// holds shot-global settings (type presets, prompt); "motion" holds the
|
|
1090
1511
|
// ARDY workflow in pipeline order. Selecting anything in the scene routes
|
|
@@ -1110,7 +1531,14 @@ globalThis.playMode = centerTab === "play";
|
|
|
1110
1531
|
// store is constructed once, seeded with the initial scene.
|
|
1111
1532
|
const storeRef = useRef(null);
|
|
1112
1533
|
if (!storeRef.current) {
|
|
1113
|
-
storeRef.current = createSceneHistoryStore(sceneObjects, {
|
|
1534
|
+
storeRef.current = createSceneHistoryStore(sceneObjects, {
|
|
1535
|
+
onObjects: (objects) => {
|
|
1536
|
+
// Object-side ops join the shared undo clock here; undo/redo of the
|
|
1537
|
+
// object store bumps the clock explicitly in undoScene/redoScene.
|
|
1538
|
+
if (!suppressObjectClockRef.current) lastObjectOpRef.current = ++opClockRef.current;
|
|
1539
|
+
setSceneObjects(objects);
|
|
1540
|
+
},
|
|
1541
|
+
});
|
|
1114
1542
|
}
|
|
1115
1543
|
const store = storeRef.current;
|
|
1116
1544
|
const selectedSceneObjectId = sceneObjectIdFromHierarchy(selectedHierarchyId);
|
|
@@ -1121,7 +1549,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
1121
1549
|
const [footSnap, setFootSnap] = useState(true);
|
|
1122
1550
|
const ikBodyDragRef = useRef(false); // true while a body drag is active
|
|
1123
1551
|
// How far (in frames) a correction eases back to the underlying motion
|
|
1124
|
-
// outside its keyed range. 6 frames @
|
|
1552
|
+
// outside its keyed range. 6 frames @ 24 fps = 0.25 s — long enough to
|
|
1125
1553
|
// hide the seam, short enough that a mid-clip fix stays visibly local.
|
|
1126
1554
|
const IK_CORRECTION_BLEND_FRAMES = 6;
|
|
1127
1555
|
|
|
@@ -1142,9 +1570,12 @@ globalThis.playMode = centerTab === "play";
|
|
|
1142
1570
|
// applied tick re-renders, every drag would die after exactly one tick.
|
|
1143
1571
|
store.settle();
|
|
1144
1572
|
setSelectedHierarchyId(id);
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
|
|
1573
|
+
// Character rows route to the Motion tab: picking a player means the
|
|
1574
|
+
// operator wants their animation layer — its prompt, queue status and
|
|
1575
|
+
// generate action all live there. SHOT keeps its bridge to shot-global
|
|
1576
|
+
// settings; everything else is a scene entity.
|
|
1577
|
+
const isCharacter = id === "characters" || id === "characterA" || id === "characterB" || id?.startsWith("character:");
|
|
1578
|
+
setSidebarTab(id === "shot" ? "shot" : isCharacter ? "motion" : "inspector");
|
|
1148
1579
|
const focus = RIG_HIERARCHY_FOCUS[id];
|
|
1149
1580
|
if (focus && ikMode) setIkFocus(focus);
|
|
1150
1581
|
}
|
|
@@ -1211,6 +1642,57 @@ globalThis.playMode = centerTab === "play";
|
|
|
1211
1642
|
setToast(isKo ? `${sceneObjectNameDisplayKo(object.name)}을 표면 위에 내려놓았어요` : `${object.name} dropped to surface`);
|
|
1212
1643
|
}
|
|
1213
1644
|
|
|
1645
|
+
/** The hidden file input behind "Import image as cutout". */
|
|
1646
|
+
const cutoutInputRef = useRef(null);
|
|
1647
|
+
// One drop zone shared by every surface that accepts a picture: the Props
|
|
1648
|
+
// branch of the hierarchy, the Props inspector, and the shot view itself.
|
|
1649
|
+
// One per surface, so only the thing under the cursor lights up.
|
|
1650
|
+
const propsDrop = useImageDrop((files) => importCutouts(files));
|
|
1651
|
+
const inspectorDrop = useImageDrop((files) => importCutouts(files));
|
|
1652
|
+
const viewportDrop = useImageDrop((files) => importCutouts(files));
|
|
1653
|
+
// How much of the wall counts as the wall, and how wide the brush that
|
|
1654
|
+
// argues with the answer is.
|
|
1655
|
+
const [matteTolerance, setMatteTolerance] = useState(0.18);
|
|
1656
|
+
const [matteMode, setMatteMode] = useState("paint");
|
|
1657
|
+
const [matteStats, setMatteStats] = useState({ painted: 0, coverage: 0, zoom: 1, canUndo: false, canRedo: false });
|
|
1658
|
+
const [matteBusy, setMatteBusy] = useState(false);
|
|
1659
|
+
const matteCanvasRef = useRef(null);
|
|
1660
|
+
const matteEditorRef = useRef(null);
|
|
1661
|
+
// The editor always works on the photograph, never on the cut picture the
|
|
1662
|
+
// set renders — that is what makes a cut re-editable rather than a one-way
|
|
1663
|
+
// door. The saved purple comes back with it.
|
|
1664
|
+
const matteSourceId = selectedSceneObject?.renderer === CUTOUT_KIND
|
|
1665
|
+
? selectedSceneObject.sourceAssetId || selectedSceneObject.assetId
|
|
1666
|
+
: null;
|
|
1667
|
+
const matteSelectionId = selectedSceneObject?.renderer === CUTOUT_KIND ? selectedSceneObject.matteAssetId || "" : "";
|
|
1668
|
+
|
|
1669
|
+
useEffect(() => {
|
|
1670
|
+
const canvas = matteCanvasRef.current;
|
|
1671
|
+
if (!canvas || !matteSourceId) return undefined;
|
|
1672
|
+
const editor = createMatteEditor(canvas, { onChange: setMatteStats });
|
|
1673
|
+
matteEditorRef.current = editor;
|
|
1674
|
+
editor.setTolerance(matteTolerance);
|
|
1675
|
+
editor.setMode(matteMode);
|
|
1676
|
+
let cancelled = false;
|
|
1677
|
+
(async () => {
|
|
1678
|
+
const asset = await assetRecord(matteSourceId);
|
|
1679
|
+
if (!asset || cancelled) return;
|
|
1680
|
+
await editor.load(asset);
|
|
1681
|
+
if (cancelled || !matteSelectionId) return;
|
|
1682
|
+
const stored = await assetRecord(matteSelectionId);
|
|
1683
|
+
if (!stored || cancelled) return;
|
|
1684
|
+
const { mask, width, height } = await decodeMask(stored);
|
|
1685
|
+
if (!cancelled) editor.setMask(mask, width, height);
|
|
1686
|
+
})().catch(() => setMatteStats({ painted: 0, coverage: 0, zoom: 1, canUndo: false, canRedo: false }));
|
|
1687
|
+
return () => {
|
|
1688
|
+
cancelled = true;
|
|
1689
|
+
editor.dispose();
|
|
1690
|
+
if (matteEditorRef.current === editor) matteEditorRef.current = null;
|
|
1691
|
+
};
|
|
1692
|
+
// Tolerance and mode are pushed by their own handlers below; re-running
|
|
1693
|
+
// this effect for them would throw away the selection.
|
|
1694
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1695
|
+
}, [matteSourceId, matteSelectionId]);
|
|
1214
1696
|
const [gizmoMode, setGizmoMode] = useState("move");
|
|
1215
1697
|
// Snap is a preference, not a law: with it on the gizmo blocks on the plan
|
|
1216
1698
|
// board's grid, and Ctrl/Cmd during a drag gives a free one. Off, it is the
|
|
@@ -1238,17 +1720,115 @@ globalThis.playMode = centerTab === "play";
|
|
|
1238
1720
|
setToast(isKo ? `${sceneObjectNameDisplayKo(object.name)} 추가됨 — W 이동, E 회전, R 크기` : `${object.name} added — W move, E rotate, R scale`);
|
|
1239
1721
|
}
|
|
1240
1722
|
|
|
1723
|
+
/** "Sofa 2.png" reads as a set piece; "sofa-2.png" does not. The extension
|
|
1724
|
+
* goes, the rest is the user's own name for the thing. */
|
|
1725
|
+
function cutoutNameFromFile(fileName) {
|
|
1726
|
+
const base = String(fileName ?? "").replace(/\.[^.]+$/, "").trim();
|
|
1727
|
+
return base || ko("Cutout", "컷아웃");
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Import one image and stand it up in the set. The card arrives at the
|
|
1732
|
+
* figure's own height, because a standee whose scale is a guess is worse
|
|
1733
|
+
* than useless in a tool where every camera level is a height in metres —
|
|
1734
|
+
* 1.8 m is at least an honest starting point to correct from.
|
|
1735
|
+
*/
|
|
1736
|
+
async function importCutout(file) {
|
|
1737
|
+
if (!file) return;
|
|
1738
|
+
try {
|
|
1739
|
+
const asset = await rememberAsset(await importImageFile(file));
|
|
1740
|
+
const camera = shotCamRef.current;
|
|
1741
|
+
const placement = camera
|
|
1742
|
+
? placementInFront({ x: camera.position.x, z: camera.position.z }, look.current.yaw)
|
|
1743
|
+
: {};
|
|
1744
|
+
const object = createCutoutObject(
|
|
1745
|
+
{ assetId: asset.id, aspect: assetAspect(asset) ?? 1, height: CUTOUT_DEFAULT_HEIGHT, name: cutoutNameFromFile(asset.name) },
|
|
1746
|
+
sceneObjects,
|
|
1747
|
+
placement,
|
|
1748
|
+
);
|
|
1749
|
+
if (!object) return;
|
|
1750
|
+
store.applyAtomic((objects) => [...objects, object]);
|
|
1751
|
+
setSelectedHierarchyId(`object:${object.id}`);
|
|
1752
|
+
setSidebarTab("inspector");
|
|
1753
|
+
setGizmoMode("move");
|
|
1754
|
+
setToast(
|
|
1755
|
+
isKo
|
|
1756
|
+
? `${object.name} 추가됨 — 실제 높이(m)를 입력하면 크기가 맞습니다`
|
|
1757
|
+
: `${object.name} added — type its real height in metres to set the scale`,
|
|
1758
|
+
);
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
setToast(isKo ? `이미지를 가져오지 못했어요 — ${error.message}` : `Could not import that image — ${error.message}`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** A drop can carry several pictures. They go in one at a time so each
|
|
1765
|
+
* lands in its own place and the last one is the one left selected. */
|
|
1766
|
+
async function importCutouts(files) {
|
|
1767
|
+
for (const file of files) await importCutout(file);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Apply what the background editor is showing.
|
|
1772
|
+
*
|
|
1773
|
+
* Nothing is destroyed. The card keeps three things: the photograph it was
|
|
1774
|
+
* imported from, the purple someone painted on it, and the cut picture the
|
|
1775
|
+
* set actually renders — so the next edit starts from the original with the
|
|
1776
|
+
* selection still on it, however many times it is re-cut.
|
|
1777
|
+
*
|
|
1778
|
+
* Trimming the dead margin changes how much of the frame the subject fills,
|
|
1779
|
+
* so the card's height is scaled with it. The scale is stored rather than
|
|
1780
|
+
* multiplied in, or a second cut would compound one trim onto the last.
|
|
1781
|
+
*/
|
|
1782
|
+
async function applyMatte(id = selectedSceneObjectId) {
|
|
1783
|
+
const object = sceneObjects.find((item) => item.id === id) ?? null;
|
|
1784
|
+
const options = matteEditorRef.current?.options();
|
|
1785
|
+
// Nothing purple means nothing was asked for. Removing "the background"
|
|
1786
|
+
// on a picture nobody has marked would be a guess applied to their set.
|
|
1787
|
+
if (!object || object.renderer !== CUTOUT_KIND || !options || matteBusy) return;
|
|
1788
|
+
setMatteBusy(true);
|
|
1789
|
+
try {
|
|
1790
|
+
const sourceId = object.sourceAssetId || object.assetId;
|
|
1791
|
+
const source = await assetRecord(sourceId);
|
|
1792
|
+
if (!source) throw new Error(ko("its picture is missing from the store", "저장소에 사진이 없습니다"));
|
|
1793
|
+
const [cut, matte] = await Promise.all([
|
|
1794
|
+
cutOutBackground(source, { mask: options.mask }),
|
|
1795
|
+
maskAsset(options.mask, { width: options.maskWidth, height: options.maskHeight, name: `${source.name || "cutout"} matte` }),
|
|
1796
|
+
]);
|
|
1797
|
+
await Promise.all([rememberAsset(cut.asset), rememberAsset(matte)]);
|
|
1798
|
+
const fullFrameHeight = object.height / (object.matteScale || 1);
|
|
1799
|
+
changeSceneObject(object.id, {
|
|
1800
|
+
assetId: cut.asset.id,
|
|
1801
|
+
sourceAssetId: source.id,
|
|
1802
|
+
matteAssetId: matte.id,
|
|
1803
|
+
matteScale: cut.heightScale,
|
|
1804
|
+
aspect: cut.asset.width / cut.asset.height,
|
|
1805
|
+
height: fullFrameHeight * cut.heightScale,
|
|
1806
|
+
});
|
|
1807
|
+
setToast(
|
|
1808
|
+
isKo
|
|
1809
|
+
? `${object.name} 배경 제거 — ${Math.round(cut.removed * 100)}% 지움. 원본과 칠한 영역은 그대로 남습니다`
|
|
1810
|
+
: `${object.name} — ${Math.round(cut.removed * 100)}% removed. The original and your selection are kept`,
|
|
1811
|
+
);
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
setToast(isKo ? `배경을 제거하지 못했어요 — ${error.message}` : `Could not remove the background — ${error.message}`);
|
|
1814
|
+
} finally {
|
|
1815
|
+
setMatteBusy(false);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1241
1819
|
function duplicateSelectedSceneObject(id = selectedSceneObjectId) {
|
|
1242
1820
|
// Defaults to the selection (Ctrl/Cmd+D); the hierarchy context menu
|
|
1243
1821
|
// passes a specific row's id. Same result either way: the copy is
|
|
1244
1822
|
// selected, offset one grid step, and toasted.
|
|
1245
1823
|
const object = sceneObjects.find((item) => item.id === id) ?? null;
|
|
1246
1824
|
if (!object) return;
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1825
|
+
const placement = { x: object.x, z: object.z, rot: object.rot };
|
|
1826
|
+
// A cutout cannot be minted from the catalogue — it needs the picture the
|
|
1827
|
+
// original is already wearing — so the copy is created through its own
|
|
1828
|
+
// door and shares the asset rather than importing it twice.
|
|
1829
|
+
const copy = object.renderer === CUTOUT_KIND
|
|
1830
|
+
? createCutoutObject({ assetId: object.assetId, aspect: object.aspect, height: object.height, name: object.name }, sceneObjects, placement)
|
|
1831
|
+
: createSceneObject(object.renderer, sceneObjects, placement);
|
|
1252
1832
|
if (!copy) return;
|
|
1253
1833
|
// Unity drops the duplicate exactly on top of the original; for blocking,
|
|
1254
1834
|
// one grid step to the side means you can see that it worked.
|
|
@@ -1290,13 +1870,53 @@ globalThis.playMode = centerTab === "play";
|
|
|
1290
1870
|
// Undo/redo (plan §6.5). The store settles any open drag first, so a
|
|
1291
1871
|
// mid-drag press commits that drag as one entry and then steps past it.
|
|
1292
1872
|
// After a step the selection can point at a deleted object — drop it to
|
|
1873
|
+
/* ---------------------- character undo stack ---------------------------
|
|
1874
|
+
* The scene history store owns scene OBJECTS; the cast lives outside it.
|
|
1875
|
+
* Character gestures (spawn, remove, show/hide, plan-board drags) push a
|
|
1876
|
+
* full-cast snapshot with the editing buffer folded in, and undo/redo
|
|
1877
|
+
* picks the newer of the two stacks so one Ctrl+Z history covers both. */
|
|
1878
|
+
const snapshotCast = () => ({
|
|
1879
|
+
characters: charactersRef.current.map((entry) => ({
|
|
1880
|
+
...entry,
|
|
1881
|
+
layer: entry.id === activeChar.id
|
|
1882
|
+
? { waypoints: bufferRef.current.waypoints, promptClips: bufferRef.current.promptClips }
|
|
1883
|
+
: entry.layer,
|
|
1884
|
+
})),
|
|
1885
|
+
bufferMotion: bufferRef.current.motion,
|
|
1886
|
+
bufferCharId: loadedLayerCharRef.current,
|
|
1887
|
+
});
|
|
1888
|
+
function recordCharacterUndo() {
|
|
1889
|
+
charHistoryRef.current.past.push({ tick: ++opClockRef.current, snapshot: snapshotCast() });
|
|
1890
|
+
charHistoryRef.current.future = [];
|
|
1891
|
+
}
|
|
1892
|
+
function restoreCast(snapshot) {
|
|
1893
|
+
setCharacters(snapshot.characters);
|
|
1894
|
+
const bufferChar = snapshot.characters.find((entry) => entry.id === snapshot.bufferCharId) ?? snapshot.characters[0];
|
|
1895
|
+
setWaypoints((bufferChar?.layer?.waypoints ?? []).map((waypoint) => ({ ...waypoint })));
|
|
1896
|
+
setPromptClips((bufferChar?.layer?.promptClips ?? []).map((clip) => ({ ...clip })));
|
|
1897
|
+
setMotion(snapshot.bufferMotion);
|
|
1898
|
+
loadedLayerCharRef.current = bufferChar?.id ?? null;
|
|
1899
|
+
setActiveCharacterId(bufferChar?.id ?? null);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1293
1902
|
// props so the inspector cannot show a ghost.
|
|
1294
1903
|
function undoScene() {
|
|
1904
|
+
const charTop = charHistoryRef.current.past[charHistoryRef.current.past.length - 1];
|
|
1905
|
+
if (charTop && charTop.tick > lastObjectOpRef.current) {
|
|
1906
|
+
charHistoryRef.current.future.push({ tick: charTop.tick, snapshot: snapshotCast() });
|
|
1907
|
+
charHistoryRef.current.past.pop();
|
|
1908
|
+
restoreCast(charTop.snapshot);
|
|
1909
|
+
setToast(ko("Undone", "실행 취소됨"));
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
suppressObjectClockRef.current = true;
|
|
1295
1913
|
const restored = store.undo();
|
|
1914
|
+
suppressObjectClockRef.current = false;
|
|
1296
1915
|
if (restored === null) {
|
|
1297
1916
|
setToast(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
|
|
1298
1917
|
return;
|
|
1299
1918
|
}
|
|
1919
|
+
lastObjectOpRef.current = ++opClockRef.current;
|
|
1300
1920
|
if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
|
|
1301
1921
|
setSelectedHierarchyId("props");
|
|
1302
1922
|
}
|
|
@@ -1304,11 +1924,22 @@ globalThis.playMode = centerTab === "play";
|
|
|
1304
1924
|
}
|
|
1305
1925
|
|
|
1306
1926
|
function redoScene() {
|
|
1927
|
+
const charTop = charHistoryRef.current.future[charHistoryRef.current.future.length - 1];
|
|
1928
|
+
if (charTop && charTop.tick > lastObjectOpRef.current) {
|
|
1929
|
+
charHistoryRef.current.past.push({ tick: charTop.tick, snapshot: snapshotCast() });
|
|
1930
|
+
charHistoryRef.current.future.pop();
|
|
1931
|
+
restoreCast(charTop.snapshot);
|
|
1932
|
+
setToast(ko("Redone", "다시 실행됨"));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
suppressObjectClockRef.current = true;
|
|
1307
1936
|
const restored = store.redo();
|
|
1937
|
+
suppressObjectClockRef.current = false;
|
|
1308
1938
|
if (restored === null) {
|
|
1309
1939
|
setToast(ko("Nothing to redo", "다시 실행할 작업이 없어요"));
|
|
1310
1940
|
return;
|
|
1311
1941
|
}
|
|
1942
|
+
lastObjectOpRef.current = ++opClockRef.current;
|
|
1312
1943
|
if (selectedSceneObjectId && !restored.some((object) => object.id === selectedSceneObjectId)) {
|
|
1313
1944
|
setSelectedHierarchyId("props");
|
|
1314
1945
|
}
|
|
@@ -1410,7 +2041,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
1410
2041
|
const startupShotState = nestedShotStartup.state ?? shotStartup.state;
|
|
1411
2042
|
// Each editorial strip owns its camera keys. The playhead chooses the
|
|
1412
2043
|
// 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 *
|
|
2044
|
+
const [shots, setShots] = useState(() => startupShotState?.shots ?? initialShots(startupShotState?.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS));
|
|
1414
2045
|
const [movePlaying, setMovePlaying] = useState(false);
|
|
1415
2046
|
// Follow slaves the move to the timeline playhead so camera and character
|
|
1416
2047
|
// motion share one time axis; off frees the camera while both stay set.
|
|
@@ -1418,13 +2049,14 @@ globalThis.playMode = centerTab === "play";
|
|
|
1418
2049
|
const [railDraw, setRailDraw] = useState(false);
|
|
1419
2050
|
const [hasCharSheet, setHasCharSheet] = useState(startupStage.hasCharSheet);
|
|
1420
2051
|
const [hasEnvSheet, setHasEnvSheet] = useState(false);
|
|
1421
|
-
const [subject, setSubject] = useState(startupStage.subject ?? DEFAULT_SUBJECT);
|
|
1422
|
-
const [subject2, setSubject2] = useState(startupStage.subject2 ?? "a man in a dark coat");
|
|
1423
2052
|
const [environment, setEnvironment] = useState(DEFAULT_ENVIRONMENT);
|
|
1424
2053
|
const [style, setStyle] = useState("moody cinematic lighting, 35mm film look");
|
|
1425
2054
|
|
|
1426
2055
|
const [cameraPos, setCameraPos] = useState(DEFAULT_CAMERA_POSITION);
|
|
1427
2056
|
const [subjectVisible, setSubjectVisible] = useState(true);
|
|
2057
|
+
const liveControlRef = useRef(null);
|
|
2058
|
+
const liveStateRef = useRef(null);
|
|
2059
|
+
const liveHandlersRef = useRef(null);
|
|
1428
2060
|
const [result, setResult] = useState(null);
|
|
1429
2061
|
const [resultOpen, setResultOpen] = useState(false);
|
|
1430
2062
|
const [copied, setCopied] = useState(false);
|
|
@@ -1432,10 +2064,11 @@ globalThis.playMode = centerTab === "play";
|
|
|
1432
2064
|
const [toast, setToast] = useState(startup.toast ?? "");
|
|
1433
2065
|
const [bridge, setBridge] = useState(null);
|
|
1434
2066
|
const [ardyPrompt, setArdyPrompt] = useState("");
|
|
1435
|
-
const [ardyDuration, setArdyDuration] = useState(
|
|
2067
|
+
const [ardyDuration, setArdyDuration] = useState(4); // matches the 4 s generation cap
|
|
1436
2068
|
// Optional native-ARDY seed: empty string = omit from the request (the
|
|
1437
|
-
// box picks
|
|
1438
|
-
|
|
2069
|
+
// box picks a fresh random one each run); otherwise a plain integer in
|
|
2070
|
+
// 0..2**31-1 to reproduce a result.
|
|
2071
|
+
const [ardySeed, setArdySeed] = useState("");
|
|
1439
2072
|
const [ardyRunning, setArdyRunning] = useState(false);
|
|
1440
2073
|
const [ardyStatus, setArdyStatus] = useState("");
|
|
1441
2074
|
const [consoleLines, setConsoleLines] = useState([]);
|
|
@@ -1449,8 +2082,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
1449
2082
|
const [ardyReport, setArdyReport] = useState(null);
|
|
1450
2083
|
const [ardyOutcome, setArdyOutcome] = useState(null);
|
|
1451
2084
|
const ardyAbortRef = useRef(null);
|
|
1452
|
-
//
|
|
1453
|
-
const maxDst = Math.max(0, Math.round(ardyDuration) *
|
|
2085
|
+
// Timeline frames for the configured duration: [0, duration*TIMELINE_FPS-1].
|
|
2086
|
+
const maxDst = Math.max(0, Math.round(ardyDuration) * TIMELINE_FPS - 1);
|
|
1454
2087
|
|
|
1455
2088
|
/* --------------------------- motion workspace --------------------------- */
|
|
1456
2089
|
// The timeline playhead and the root waypoints are App-owned so the scene
|
|
@@ -1458,8 +2091,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
1458
2091
|
const [tlFrame, setTlFrame] = useState(0);
|
|
1459
2092
|
|
|
1460
2093
|
const renderActive = useRenderActivity(tlPlaying || movePlaying);
|
|
1461
|
-
const [tlFrameCount, setTlFrameCount] = useState(startupShotState?.frameCount ?? DEFAULT_DURATION_S *
|
|
1462
|
-
const [tlFps, setTlFps] = useState(
|
|
2094
|
+
const [tlFrameCount, setTlFrameCount] = useState(startupShotState?.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS); // the clip length on the production clock
|
|
2095
|
+
const [tlFps, setTlFps] = useState(TIMELINE_FPS);
|
|
1463
2096
|
const activeShotIdx = shotIndexAtFrame(shots, tlFrame);
|
|
1464
2097
|
const activeShot = shots[activeShotIdx] ?? null;
|
|
1465
2098
|
const cameraKeys = activeShot?.cameraKeys ?? [];
|
|
@@ -1548,12 +2181,12 @@ globalThis.playMode = centerTab === "play";
|
|
|
1548
2181
|
return railFollow ? { ...shot, camera: updateCameraBlock(camera, { railFollow: { mode: "range", ...railFollow } }) } : shot;
|
|
1549
2182
|
}));
|
|
1550
2183
|
}
|
|
1551
|
-
const frameCountRef = useRef(DEFAULT_DURATION_S *
|
|
2184
|
+
const frameCountRef = useRef(DEFAULT_DURATION_S * TIMELINE_FPS);
|
|
1552
2185
|
frameCountRef.current = tlFrameCount;
|
|
1553
2186
|
// Root waypoints {frame, x, z, heading: null}, kept sorted by frame —
|
|
1554
2187
|
// the fixed bridge contract rejects out-of-order or duplicate frames.
|
|
1555
2188
|
const [waypointMode, setWaypointMode] = useState(false);
|
|
1556
|
-
const [waypoints, setWaypoints] = useState(startupShotState?.waypoints ?? []);
|
|
2189
|
+
const [waypoints, setWaypoints] = useState(startupStage.characters?.[0]?.layer?.waypoints ?? startupShotState?.waypoints ?? []);
|
|
1557
2190
|
const [activeWaypointFrame, setActiveWaypointFrame] = useState(null);
|
|
1558
2191
|
const [pendingWaypointFrame, setPendingWaypointFrame] = useState(null);
|
|
1559
2192
|
useEffect(() => {
|
|
@@ -1593,7 +2226,13 @@ globalThis.playMode = centerTab === "play";
|
|
|
1593
2226
|
scenesRef.current = scenes;
|
|
1594
2227
|
activeSceneIdRef.current = activeSceneId;
|
|
1595
2228
|
shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
|
|
1596
|
-
actorStageRef.current = {
|
|
2229
|
+
actorStageRef.current = {
|
|
2230
|
+
// sessionMotion is stripped: generated clips are session-only and far
|
|
2231
|
+
// too heavy for the stage envelope; paths and prompt blocks persist.
|
|
2232
|
+
characters: characters.map(({ sessionMotion, ...entry }) => entry),
|
|
2233
|
+
hasCharSheet,
|
|
2234
|
+
shotAspect: shotAspectKey,
|
|
2235
|
+
};
|
|
1597
2236
|
|
|
1598
2237
|
function snapshotActiveScene(sourceScenes = scenesRef.current) {
|
|
1599
2238
|
return sourceScenes.map((scene) => scene.id === activeSceneIdRef.current
|
|
@@ -1627,6 +2266,170 @@ globalThis.playMode = centerTab === "play";
|
|
|
1627
2266
|
}
|
|
1628
2267
|
}
|
|
1629
2268
|
|
|
2269
|
+
/* ============================ project files ============================
|
|
2270
|
+
* Game-engine workflow: the authoring state (scenes + cast + layers,
|
|
2271
|
+
* workspace layout, custom poses) round-trips through a real
|
|
2272
|
+
* `.cclayproject` file. localStorage stays as the always-on session
|
|
2273
|
+
* cache; the file is the portable, user-owned document. */
|
|
2274
|
+
const [projectName, setProjectName] = useState(null); // null = untitled session
|
|
2275
|
+
const [projectDirty, setProjectDirty] = useState(false);
|
|
2276
|
+
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
|
2277
|
+
const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
|
|
2278
|
+
const projectHandleRef = useRef(null);
|
|
2279
|
+
const projectSnapshotRef = useRef("");
|
|
2280
|
+
const projectStateRef = useRef(null);
|
|
2281
|
+
projectStateRef.current = { workspaceLayout, customPoses };
|
|
2282
|
+
|
|
2283
|
+
function collectProjectSerialized(name) {
|
|
2284
|
+
const scenesDocument = {
|
|
2285
|
+
version: SCENES_VERSION,
|
|
2286
|
+
activeSceneId: activeSceneIdRef.current,
|
|
2287
|
+
scenes: snapshotActiveScene(),
|
|
2288
|
+
};
|
|
2289
|
+
return JSON.stringify(createProjectDocument({
|
|
2290
|
+
scenesDocument,
|
|
2291
|
+
workspaceLayout: projectStateRef.current.workspaceLayout,
|
|
2292
|
+
customPoses: projectStateRef.current.customPoses,
|
|
2293
|
+
name,
|
|
2294
|
+
}), null, 2);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function markProjectClean(name, serialized) {
|
|
2298
|
+
projectSnapshotRef.current = serialized;
|
|
2299
|
+
setProjectDirty(false);
|
|
2300
|
+
setProjectName(name);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
async function saveProject(saveAs = false) {
|
|
2304
|
+
const name = projectName ?? "Untitled";
|
|
2305
|
+
const serialized = collectProjectSerialized(name);
|
|
2306
|
+
try {
|
|
2307
|
+
let handle = projectHandleRef.current;
|
|
2308
|
+
if (saveAs || !handle || !hasFileSystemAccess()) {
|
|
2309
|
+
if (hasFileSystemAccess()) {
|
|
2310
|
+
handle = await pickProjectFileForSave(name);
|
|
2311
|
+
projectHandleRef.current = handle;
|
|
2312
|
+
await writeProjectFile(handle, serialized);
|
|
2313
|
+
await rememberRecentProject(handle, name);
|
|
2314
|
+
} else {
|
|
2315
|
+
downloadProjectFallback(serialized, name);
|
|
2316
|
+
}
|
|
2317
|
+
} else {
|
|
2318
|
+
await writeProjectFile(handle, serialized);
|
|
2319
|
+
}
|
|
2320
|
+
markProjectClean(name, serialized);
|
|
2321
|
+
setToast(isKo ? `프로젝트 저장됨: ${name}${PROJECT_EXTENSION}` : `Project saved: ${name}${PROJECT_EXTENSION}`);
|
|
2322
|
+
} catch (err) {
|
|
2323
|
+
if (err?.name === "AbortError") return; // user closed the picker
|
|
2324
|
+
setToast(ko("Could not save the project", "프로젝트를 저장하지 못했어요"));
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
function applyProject(project, serialized) {
|
|
2329
|
+
const source = project.scenesDocument;
|
|
2330
|
+
// A project FILE carries its own scene document and never passes the
|
|
2331
|
+
// storage reader, so the 20 fps → 24 fps clock migration is applied here
|
|
2332
|
+
// too — otherwise an older .cozyclay would open a sixth too fast.
|
|
2333
|
+
const doc = Number.isInteger(source.version) && source.version < SCENES_VERSION
|
|
2334
|
+
? { ...source, version: SCENES_VERSION, scenes: source.scenes.map((scene) => ({ ...scene, stage: migrateStageFrames(scene.stage) })) }
|
|
2335
|
+
: source;
|
|
2336
|
+
setScenes(doc.scenes);
|
|
2337
|
+
setActiveSceneId(doc.activeSceneId);
|
|
2338
|
+
if (project.workspaceLayout) setWorkspaceLayout({ ...DEFAULT_WORKSPACE_LAYOUT, ...project.workspaceLayout });
|
|
2339
|
+
setCustomPoses(project.customPoses);
|
|
2340
|
+
saveCustomPoses(project.customPoses);
|
|
2341
|
+
persistScenes(doc.scenes, doc.activeSceneId);
|
|
2342
|
+
openScene(doc.scenes[activeSceneIndex(doc.scenes, doc.activeSceneId)], doc.scenes);
|
|
2343
|
+
projectSnapshotRef.current = serialized;
|
|
2344
|
+
setProjectDirty(false);
|
|
2345
|
+
setProjectName(project.name);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
async function openProject() {
|
|
2349
|
+
try {
|
|
2350
|
+
let file = null;
|
|
2351
|
+
let handle = null;
|
|
2352
|
+
if (hasFileSystemAccess()) {
|
|
2353
|
+
handle = await pickProjectFileForOpen();
|
|
2354
|
+
file = await readProjectFile(handle);
|
|
2355
|
+
} else {
|
|
2356
|
+
file = await openProjectFallback();
|
|
2357
|
+
}
|
|
2358
|
+
if (!file) return;
|
|
2359
|
+
const result = readProjectDocument(file.text);
|
|
2360
|
+
if (!result.ok) {
|
|
2361
|
+
setToast(isKo ? `프로젝트를 열 수 없어요: ${result.reason}` : `Cannot open project: ${result.reason}`);
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
projectHandleRef.current = handle;
|
|
2365
|
+
if (handle) await rememberRecentProject(handle, result.project.name);
|
|
2366
|
+
applyProject(result.project, file.text);
|
|
2367
|
+
setToast(isKo ? `프로젝트 열림: ${result.project.name}` : `Project opened: ${result.project.name}`);
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
if (err?.name === "AbortError") return;
|
|
2370
|
+
console.error("openProject failed", err);
|
|
2371
|
+
setToast(ko("Could not open the project", "프로젝트를 열지 못했어요"));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/** Open a project from the browser dialog: a stored handle from the
|
|
2376
|
+
* recents list or a file enumerated in the projects folder. */
|
|
2377
|
+
async function openProjectByHandle(handle) {
|
|
2378
|
+
try {
|
|
2379
|
+
const file = await readProjectFile(handle);
|
|
2380
|
+
const result = readProjectDocument(file.text);
|
|
2381
|
+
if (!result.ok) {
|
|
2382
|
+
setToast(isKo ? `프로젝트를 열 수 없어요: ${result.reason}` : `Cannot open project: ${result.reason}`);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
projectHandleRef.current = handle;
|
|
2386
|
+
await rememberRecentProject(handle, result.project.name);
|
|
2387
|
+
applyProject(result.project, file.text);
|
|
2388
|
+
setProjectBrowserOpen(false);
|
|
2389
|
+
setToast(isKo ? `프로젝트 열림: ${result.project.name}` : `Project opened: ${result.project.name}`);
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
console.error("openProjectByHandle failed", err);
|
|
2392
|
+
setToast(ko("Could not open the project", "프로젝트를 열지 못했어요"));
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function newProject() {
|
|
2397
|
+
if (projectDirty && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return;
|
|
2398
|
+
const fresh = createSceneDocument(ko("SCENE 01", "씬 01"));
|
|
2399
|
+
setScenes(fresh.scenes);
|
|
2400
|
+
setActiveSceneId(fresh.activeSceneId);
|
|
2401
|
+
persistScenes(fresh.scenes, fresh.activeSceneId);
|
|
2402
|
+
openScene(fresh.scenes[0], fresh.scenes);
|
|
2403
|
+
projectHandleRef.current = null;
|
|
2404
|
+
clearStoredProjectHandle();
|
|
2405
|
+
projectSnapshotRef.current = "";
|
|
2406
|
+
setProjectDirty(false);
|
|
2407
|
+
setProjectName(null);
|
|
2408
|
+
setToast(ko("New project", "새 프로젝트"));
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Re-open the last project on launch when the browser still grants access.
|
|
2412
|
+
const projectAutoOpenedRef = useRef(false);
|
|
2413
|
+
useEffect(() => {
|
|
2414
|
+
if (projectAutoOpenedRef.current) return;
|
|
2415
|
+
projectAutoOpenedRef.current = true;
|
|
2416
|
+
loadStoredProjectHandle().then(async (record) => {
|
|
2417
|
+
if (!record?.handle) return;
|
|
2418
|
+
if ((await queryHandlePermission(record.handle)) !== "granted") return;
|
|
2419
|
+
try {
|
|
2420
|
+
const file = await readProjectFile(record.handle);
|
|
2421
|
+
const result = readProjectDocument(file.text);
|
|
2422
|
+
if (!result.ok) return;
|
|
2423
|
+
projectHandleRef.current = record.handle;
|
|
2424
|
+
applyProject(result.project, file.text);
|
|
2425
|
+
setToast(isKo ? `프로젝트 복원됨: ${result.project.name}` : `Project restored: ${result.project.name}`);
|
|
2426
|
+
} catch {
|
|
2427
|
+
/* missing or unreadable file: fall back to the session cache */
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2431
|
+
}, []);
|
|
2432
|
+
|
|
1630
2433
|
function flushScenes() {
|
|
1631
2434
|
if (!dirtyRef.current) return;
|
|
1632
2435
|
persistScenes(snapshotActiveScene(), activeSceneIdRef.current);
|
|
@@ -1635,7 +2438,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
1635
2438
|
function restoredShotState(scene) {
|
|
1636
2439
|
const restored = readShotAuthoringDocument(scene?.shotDocument ?? undefined);
|
|
1637
2440
|
if (restored.state) return restored.state;
|
|
1638
|
-
const frameCount = DEFAULT_DURATION_S *
|
|
2441
|
+
const frameCount = DEFAULT_DURATION_S * TIMELINE_FPS;
|
|
1639
2442
|
return { shots: initialShots(frameCount), waypoints: [], frameCount };
|
|
1640
2443
|
}
|
|
1641
2444
|
|
|
@@ -1643,19 +2446,34 @@ globalThis.playMode = centerTab === "play";
|
|
|
1643
2446
|
const shotState = restoredShotState(scene);
|
|
1644
2447
|
const stage = createSceneStage(scene.stage);
|
|
1645
2448
|
const objects = Array.isArray(scene.objects) ? scene.objects : [];
|
|
1646
|
-
storeRef.current = createSceneHistoryStore(objects, {
|
|
2449
|
+
storeRef.current = createSceneHistoryStore(objects, {
|
|
2450
|
+
onObjects: (next) => {
|
|
2451
|
+
if (!suppressObjectClockRef.current) lastObjectOpRef.current = ++opClockRef.current;
|
|
2452
|
+
setSceneObjects(next);
|
|
2453
|
+
},
|
|
2454
|
+
});
|
|
1647
2455
|
setSceneObjects(objects);
|
|
1648
2456
|
setShots(shotState.shots);
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
setCharB(stage.charB);
|
|
1653
|
-
setShowB(stage.showB);
|
|
1654
|
-
setPoseA(stage.poseA ?? DEFAULT_POSE);
|
|
1655
|
-
setPoseB(stage.poseB ?? DEFAULT_POSE);
|
|
2457
|
+
setTlFrameCount(shotState.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS);
|
|
2458
|
+
setCharacters(stage.characters);
|
|
2459
|
+
setRigs({});
|
|
1656
2460
|
setHasCharSheet(stage.hasCharSheet);
|
|
1657
|
-
|
|
1658
|
-
|
|
2461
|
+
setShotAspectKey(stage.shotAspect);
|
|
2462
|
+
// The motion-layer buffer reloads from the scene's first character.
|
|
2463
|
+
const firstLayer = stage.characters[0]?.layer;
|
|
2464
|
+
setWaypoints(firstLayer?.waypoints ?? shotState.waypoints ?? []);
|
|
2465
|
+
setPromptClips(firstLayer?.promptClips?.map((clip) => ({ ...clip })) ?? []);
|
|
2466
|
+
setMotion(null);
|
|
2467
|
+
// Takes belong to the room being left; restoreMotionRefs re-fetches the
|
|
2468
|
+
// incoming scene's, and a stale full take must never survive the switch.
|
|
2469
|
+
motionFullRef.current.clear();
|
|
2470
|
+
setSelectedPromptId(null);
|
|
2471
|
+
ikStatesRef.current.clear();
|
|
2472
|
+
ikStateRef.current = createIkState();
|
|
2473
|
+
loadedLayerCharRef.current = stage.characters[0]?.id ?? null;
|
|
2474
|
+
setActiveCharacterId(stage.characters[0]?.id ?? null);
|
|
2475
|
+
charHistoryRef.current = { past: [], future: [] };
|
|
2476
|
+
restoreMotionRefs(stage.characters);
|
|
1659
2477
|
setTlFrame(0);
|
|
1660
2478
|
setMovePlaying(false);
|
|
1661
2479
|
manualCameraOverrideRef.current = false;
|
|
@@ -1722,13 +2540,290 @@ globalThis.playMode = centerTab === "play";
|
|
|
1722
2540
|
openScene(target, nextScenes);
|
|
1723
2541
|
}
|
|
1724
2542
|
|
|
2543
|
+
// Commands are a sequential transport boundary, while React commits on a
|
|
2544
|
+
// later turn. Keep its read model current synchronously so the next frame
|
|
2545
|
+
// observes the mutation that the previous frame just acknowledged.
|
|
2546
|
+
liveStateRef.current = {
|
|
2547
|
+
scenes,
|
|
2548
|
+
activeSceneId,
|
|
2549
|
+
camera: cameraPos,
|
|
2550
|
+
fovDeg,
|
|
2551
|
+
characters,
|
|
2552
|
+
objects: sceneObjects,
|
|
2553
|
+
commitManualCameraFraming,
|
|
2554
|
+
recordCharacterUndo,
|
|
2555
|
+
removeCharacter,
|
|
2556
|
+
persistScenes,
|
|
2557
|
+
openScene,
|
|
2558
|
+
loadMotion,
|
|
2559
|
+
};
|
|
2560
|
+
if (!liveHandlersRef.current) {
|
|
2561
|
+
const finitePatch = (args, fields) => {
|
|
2562
|
+
const patch = {};
|
|
2563
|
+
for (const field of fields) {
|
|
2564
|
+
if (args[field] === undefined) continue;
|
|
2565
|
+
if (!Number.isFinite(args[field])) throw new Error(`Invalid ${field}`);
|
|
2566
|
+
patch[field] = args[field];
|
|
2567
|
+
}
|
|
2568
|
+
return patch;
|
|
2569
|
+
};
|
|
2570
|
+
const characterForRef = (characters, ref) => {
|
|
2571
|
+
if (typeof ref === "number" && Number.isInteger(ref)) return characters[ref - 1] ?? null;
|
|
2572
|
+
if (typeof ref !== "string" || !ref) return null;
|
|
2573
|
+
if (/^\d+$/.test(ref)) return characters[Number(ref) - 1] ?? null;
|
|
2574
|
+
if (ref.toUpperCase() === "A") return characters[0] ?? null;
|
|
2575
|
+
if (ref.toUpperCase() === "B") return characters[1] ?? null;
|
|
2576
|
+
return characters.find((entry) => entry.id === ref) ?? null;
|
|
2577
|
+
};
|
|
2578
|
+
const describe = () => {
|
|
2579
|
+
const live = liveStateRef.current;
|
|
2580
|
+
return {
|
|
2581
|
+
sceneName: live.scenes.find((scene) => scene.id === live.activeSceneId)?.name ?? "",
|
|
2582
|
+
camera: { ...live.camera, focalMm: Math.round(fovToFocalMm((live.fovDeg * Math.PI) / 180) * 100) / 100 },
|
|
2583
|
+
characters: live.characters.map((entry) => ({ id: entry.id, subject: entry.subject, x: entry.x, z: entry.z, rot: entry.rot, hidden: entry.hidden })),
|
|
2584
|
+
// Scale and the library footprint travel with each object: the server
|
|
2585
|
+
// reports real sizes from them, and without them every prop reads as
|
|
2586
|
+
// 1x1x1 no matter how it was actually built.
|
|
2587
|
+
objects: live.objects.map((object) => ({
|
|
2588
|
+
id: object.id, name: object.name,
|
|
2589
|
+
x: object.x, y: object.y, z: object.z, rot: object.rot,
|
|
2590
|
+
scaleX: object.scaleX, scaleY: object.scaleY, scaleZ: object.scaleZ,
|
|
2591
|
+
parent: object.parent ?? null,
|
|
2592
|
+
footprint: object.footprint, height: object.height,
|
|
2593
|
+
})),
|
|
2594
|
+
};
|
|
2595
|
+
};
|
|
2596
|
+
const replaceCharacters = (next) => {
|
|
2597
|
+
charactersRef.current = next;
|
|
2598
|
+
liveStateRef.current.characters = next;
|
|
2599
|
+
setCharacters(next);
|
|
2600
|
+
};
|
|
2601
|
+
const syncObjects = () => {
|
|
2602
|
+
liveStateRef.current.objects = storeRef.current.objects;
|
|
2603
|
+
};
|
|
2604
|
+
liveHandlersRef.current = {
|
|
2605
|
+
ping: () => ({ pong: true }),
|
|
2606
|
+
describe,
|
|
2607
|
+
// Camera moves are not undoable in the UI. This is the free-camera and
|
|
2608
|
+
// Top-View path: drive the shot camera, lens state, then manual ownership.
|
|
2609
|
+
set_camera: (args) => {
|
|
2610
|
+
const live = liveStateRef.current;
|
|
2611
|
+
const patch = finitePatch(args, ["x", "y", "z"]);
|
|
2612
|
+
let nextFov = live.fovDeg;
|
|
2613
|
+
if (args.focalMm !== undefined) {
|
|
2614
|
+
if (!Number.isFinite(args.focalMm) || args.focalMm <= 0) throw new Error("Invalid focalMm");
|
|
2615
|
+
nextFov = (focalMmToFov(args.focalMm) * 180) / Math.PI;
|
|
2616
|
+
if (nextFov < 14 || nextFov > 90) throw new Error("focalMm is outside the editor lens range");
|
|
2617
|
+
}
|
|
2618
|
+
const next = { ...live.camera, ...patch };
|
|
2619
|
+
const camera = shotCamRef.current;
|
|
2620
|
+
if (camera) {
|
|
2621
|
+
camera.position.set(next.x, next.y, next.z);
|
|
2622
|
+
camera.fov = nextFov;
|
|
2623
|
+
camera.updateProjectionMatrix();
|
|
2624
|
+
}
|
|
2625
|
+
live.camera = next;
|
|
2626
|
+
live.fovDeg = nextFov;
|
|
2627
|
+
setCameraPos(next);
|
|
2628
|
+
setFovDeg(nextFov);
|
|
2629
|
+
live.commitManualCameraFraming();
|
|
2630
|
+
return { camera: { ...next, focalMm: Math.round(fovToFocalMm((nextFov * Math.PI) / 180) * 100) / 100 } };
|
|
2631
|
+
},
|
|
2632
|
+
add_character: (args) => {
|
|
2633
|
+
if (typeof args.subject !== "string") throw new Error("Invalid subject");
|
|
2634
|
+
const live = liveStateRef.current;
|
|
2635
|
+
const patch = finitePatch(args, ["x", "z", "rot"]);
|
|
2636
|
+
live.recordCharacterUndo();
|
|
2637
|
+
const id = nextCharacterId(live.characters);
|
|
2638
|
+
replaceCharacters([...live.characters, createCharacterEntry({ id, subject: args.subject, pose: DEFAULT_POSE, ...patch }, live.characters.length)]);
|
|
2639
|
+
return { id };
|
|
2640
|
+
},
|
|
2641
|
+
update_character: (args) => {
|
|
2642
|
+
const live = liveStateRef.current;
|
|
2643
|
+
const character = characterForRef(live.characters, args.ref);
|
|
2644
|
+
if (!character) throw new Error("Character not found");
|
|
2645
|
+
const patch = finitePatch(args, ["x", "z", "rot"]);
|
|
2646
|
+
if (args.subject !== undefined) {
|
|
2647
|
+
if (typeof args.subject !== "string") throw new Error("Invalid subject");
|
|
2648
|
+
patch.subject = args.subject;
|
|
2649
|
+
}
|
|
2650
|
+
if (args.hidden !== undefined) {
|
|
2651
|
+
if (typeof args.hidden !== "boolean") throw new Error("Invalid hidden");
|
|
2652
|
+
patch.hidden = args.hidden;
|
|
2653
|
+
}
|
|
2654
|
+
if (!Object.keys(patch).some((key) => patch[key] !== character[key])) return { id: character.id };
|
|
2655
|
+
live.recordCharacterUndo();
|
|
2656
|
+
replaceCharacters(live.characters.map((entry) => entry.id === character.id ? { ...entry, ...patch } : entry));
|
|
2657
|
+
return { id: character.id };
|
|
2658
|
+
},
|
|
2659
|
+
remove_character: (args) => {
|
|
2660
|
+
const live = liveStateRef.current;
|
|
2661
|
+
const character = characterForRef(live.characters, args.ref);
|
|
2662
|
+
if (!character) throw new Error("Character not found");
|
|
2663
|
+
if (live.characters.length <= 1) throw new Error("Cannot remove the final character");
|
|
2664
|
+
live.removeCharacter(character.id);
|
|
2665
|
+
live.characters = charactersRef.current;
|
|
2666
|
+
return { id: character.id };
|
|
2667
|
+
},
|
|
2668
|
+
place_object: (args) => {
|
|
2669
|
+
if (typeof args.kind !== "string") throw new Error("Invalid kind");
|
|
2670
|
+
const live = liveStateRef.current;
|
|
2671
|
+
const placement = finitePatch(args, ["x", "z", "rot"]);
|
|
2672
|
+
const object = createSceneObject(args.kind, live.objects, placement);
|
|
2673
|
+
if (!object) throw new Error(`Unknown object kind: ${args.kind}`);
|
|
2674
|
+
const patch = finitePatch(args, ["y"]);
|
|
2675
|
+
const placed = updateSceneObject([object], object.id, patch)[0];
|
|
2676
|
+
storeRef.current.applyAtomic((objects) => [...objects, placed]);
|
|
2677
|
+
syncObjects();
|
|
2678
|
+
return { id: placed.id };
|
|
2679
|
+
},
|
|
2680
|
+
update_object: (args) => {
|
|
2681
|
+
const live = liveStateRef.current;
|
|
2682
|
+
if (typeof args.id !== "string" || !live.objects.some((object) => object.id === args.id)) throw new Error("Object not found");
|
|
2683
|
+
const patch = finitePatch(args, ["x", "y", "z", "rot", "rotX", "rotZ"]);
|
|
2684
|
+
// A uniform `scale` is the common case; per-axis values are what a
|
|
2685
|
+
// squashed disc or a stretched column needs, exactly as the
|
|
2686
|
+
// inspector's three sliders provide. Per-axis wins when both come.
|
|
2687
|
+
if (args.scale !== undefined) {
|
|
2688
|
+
if (!Number.isFinite(args.scale)) throw new Error("Invalid scale");
|
|
2689
|
+
patch.scaleX = args.scale;
|
|
2690
|
+
patch.scaleY = args.scale;
|
|
2691
|
+
patch.scaleZ = args.scale;
|
|
2692
|
+
}
|
|
2693
|
+
Object.assign(patch, finitePatch(args, ["scaleX", "scaleY", "scaleZ"]));
|
|
2694
|
+
if (args.color !== undefined) {
|
|
2695
|
+
if (typeof args.color !== "string") throw new Error("Invalid color");
|
|
2696
|
+
patch.color = args.color;
|
|
2697
|
+
}
|
|
2698
|
+
storeRef.current.applyAtomic((objects) => updateSceneObject(objects, args.id, patch));
|
|
2699
|
+
syncObjects();
|
|
2700
|
+
return { id: args.id };
|
|
2701
|
+
},
|
|
2702
|
+
remove_object: (args) => {
|
|
2703
|
+
const live = liveStateRef.current;
|
|
2704
|
+
if (typeof args.id !== "string" || !live.objects.some((object) => object.id === args.id)) throw new Error("Object not found");
|
|
2705
|
+
storeRef.current.applyAtomic((objects) => removeSceneObject(objects, args.id));
|
|
2706
|
+
syncObjects();
|
|
2707
|
+
return { id: args.id };
|
|
2708
|
+
},
|
|
2709
|
+
// Replacing a document follows the existing project-open path and clears
|
|
2710
|
+
// its per-scene histories, so load_scenes is deliberately not undoable.
|
|
2711
|
+
load_scenes: (args) => {
|
|
2712
|
+
if (!args.document || typeof args.document !== "object" || Array.isArray(args.document)) throw new Error("Invalid scene document");
|
|
2713
|
+
const loaded = readSceneDocument(JSON.stringify(args.document));
|
|
2714
|
+
if (loaded.status !== "valid" && loaded.status !== "migrated") throw new Error("Invalid scene document");
|
|
2715
|
+
const document = loaded.document;
|
|
2716
|
+
const target = document.scenes[activeSceneIndex(document.scenes, document.activeSceneId)];
|
|
2717
|
+
const live = liveStateRef.current;
|
|
2718
|
+
live.persistScenes(document.scenes, document.activeSceneId);
|
|
2719
|
+
live.openScene(target, document.scenes);
|
|
2720
|
+
live.scenes = document.scenes;
|
|
2721
|
+
live.activeSceneId = document.activeSceneId;
|
|
2722
|
+
live.objects = storeRef.current.objects;
|
|
2723
|
+
live.characters = createSceneStage(target.stage).characters;
|
|
2724
|
+
charactersRef.current = live.characters;
|
|
2725
|
+
return { sceneName: target.name };
|
|
2726
|
+
},
|
|
2727
|
+
// Loads a bridge-generated take onto the active character — the same
|
|
2728
|
+
// path the demo seed and the Motion panel use. Replacing a take is not
|
|
2729
|
+
// undoable in the UI either, so this is deliberately not undoable.
|
|
2730
|
+
// Grouping is an editing convenience: the parent carries its children
|
|
2731
|
+
// when it moves, so a set piece built from primitives is dragged once.
|
|
2732
|
+
group_objects: (args) => {
|
|
2733
|
+
const live = liveStateRef.current;
|
|
2734
|
+
if (typeof args.parent !== "string" || !live.objects.some((o) => o.id === args.parent)) {
|
|
2735
|
+
throw new Error("Parent object not found");
|
|
2736
|
+
}
|
|
2737
|
+
if (!Array.isArray(args.children) || !args.children.length) throw new Error("No children given");
|
|
2738
|
+
for (const child of args.children) {
|
|
2739
|
+
if (!live.objects.some((o) => o.id === child)) throw new Error(`Object not found: ${child}`);
|
|
2740
|
+
}
|
|
2741
|
+
storeRef.current.applyAtomic((objects) =>
|
|
2742
|
+
args.children.reduce((acc, child) => setSceneObjectParent(acc, child, args.parent), objects),
|
|
2743
|
+
);
|
|
2744
|
+
syncObjects();
|
|
2745
|
+
return { parent: args.parent, children: args.children.length };
|
|
2746
|
+
},
|
|
2747
|
+
ungroup_objects: (args) => {
|
|
2748
|
+
const live = liveStateRef.current;
|
|
2749
|
+
if (!Array.isArray(args.children) || !args.children.length) throw new Error("No children given");
|
|
2750
|
+
for (const child of args.children) {
|
|
2751
|
+
if (!live.objects.some((o) => o.id === child)) throw new Error(`Object not found: ${child}`);
|
|
2752
|
+
}
|
|
2753
|
+
storeRef.current.applyAtomic((objects) =>
|
|
2754
|
+
args.children.reduce((acc, child) => setSceneObjectParent(acc, child, null), objects),
|
|
2755
|
+
);
|
|
2756
|
+
syncObjects();
|
|
2757
|
+
return { children: args.children.length };
|
|
2758
|
+
},
|
|
2759
|
+
// Authoring blocks is not generating: a director writes the beats and
|
|
2760
|
+
// their ranges first, then generates when the schedule reads right.
|
|
2761
|
+
// Frames arrive on the timeline's own 24 fps clock.
|
|
2762
|
+
set_prompt_blocks: (args) => {
|
|
2763
|
+
if (!Array.isArray(args.blocks)) throw new Error("Invalid blocks");
|
|
2764
|
+
const stamp = Date.now();
|
|
2765
|
+
const clips = args.blocks.map((block, i) => {
|
|
2766
|
+
const startFrame = Math.round(Number(block.startFrame));
|
|
2767
|
+
const endFrame = Math.round(Number(block.endFrame));
|
|
2768
|
+
if (!Number.isFinite(startFrame) || !Number.isFinite(endFrame) || endFrame <= startFrame) {
|
|
2769
|
+
throw new Error(`Invalid frame range on block ${i + 1}`);
|
|
2770
|
+
}
|
|
2771
|
+
if (typeof block.text !== "string" || !block.text.trim()) throw new Error(`Block ${i + 1} needs text`);
|
|
2772
|
+
return { id: `prompt-${stamp}-${i}`, startFrame, endFrame, text: block.text.trim() };
|
|
2773
|
+
});
|
|
2774
|
+
const live = liveStateRef.current;
|
|
2775
|
+
live.setPromptClips(clips);
|
|
2776
|
+
if (clips.length) {
|
|
2777
|
+
live.setTlFrameCount((count) => Math.max(count, clips[clips.length - 1].endFrame));
|
|
2778
|
+
}
|
|
2779
|
+
return { blocks: clips.length };
|
|
2780
|
+
},
|
|
2781
|
+
load_motion: async (args) => {
|
|
2782
|
+
if (typeof args.url !== "string" || !args.url.startsWith("/ardy/")) throw new Error("Invalid motion url");
|
|
2783
|
+
const prompt = typeof args.prompt === "string" ? args.prompt : "";
|
|
2784
|
+
await liveStateRef.current.loadMotion(args.url, prompt);
|
|
2785
|
+
// Optional per-phase blocks land on the Prompts lane the way hand-authored
|
|
2786
|
+
// ones do. They arrive on ARDY's 20 fps clock; the lane runs on the 24 fps
|
|
2787
|
+
// production clock, so each boundary is converted, not copied.
|
|
2788
|
+
if (Array.isArray(args.blocks) && args.blocks.length) {
|
|
2789
|
+
const toTimeline = (frame) => Math.round((frame * TIMELINE_FPS) / ARDY_FPS);
|
|
2790
|
+
const stamp = Date.now();
|
|
2791
|
+
const clips = args.blocks.map((block, i) => ({
|
|
2792
|
+
id: `prompt-${stamp}-${i}`,
|
|
2793
|
+
startFrame: toTimeline(block.startFrame),
|
|
2794
|
+
endFrame: toTimeline(block.endFrame),
|
|
2795
|
+
text: typeof block.prompt === "string" ? block.prompt : "",
|
|
2796
|
+
}));
|
|
2797
|
+
liveStateRef.current.setPromptClips(clips);
|
|
2798
|
+
liveStateRef.current.setTlFrameCount((count) => Math.max(count, clips[clips.length - 1].endFrame));
|
|
2799
|
+
}
|
|
2800
|
+
return { loaded: true, url: args.url, blocks: Array.isArray(args.blocks) ? args.blocks.length : 0 };
|
|
2801
|
+
},
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
useEffect(() => {
|
|
2806
|
+
const enabled = import.meta.env.DEV || window.__COZYCLAY_LIVE__ === true;
|
|
2807
|
+
if (!enabled) return undefined;
|
|
2808
|
+
// StrictMode replays effects in development. Delaying the open lets the
|
|
2809
|
+
// replay cleanup cancel its first pass, so one tab owns one socket.
|
|
2810
|
+
const timer = setTimeout(() => {
|
|
2811
|
+
if (!liveControlRef.current) liveControlRef.current = createLiveControl({ handlers: liveHandlersRef.current });
|
|
2812
|
+
}, 0);
|
|
2813
|
+
return () => {
|
|
2814
|
+
clearTimeout(timer);
|
|
2815
|
+
liveControlRef.current?.close();
|
|
2816
|
+
liveControlRef.current = null;
|
|
2817
|
+
};
|
|
2818
|
+
}, []);
|
|
2819
|
+
|
|
1725
2820
|
// One debounced Scene-document save owns both departments. Switching calls
|
|
1726
2821
|
// persistScenes directly, so no outgoing edit can be overtaken by a render.
|
|
1727
2822
|
useEffect(() => {
|
|
1728
2823
|
dirtyRef.current = true;
|
|
1729
2824
|
const timer = setTimeout(flushScenes, 400);
|
|
1730
2825
|
return () => clearTimeout(timer);
|
|
1731
|
-
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, subject, subject2, scenes, activeSceneId]);
|
|
2826
|
+
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, subject, subject2, shotAspectKey, scenes, activeSceneId]);
|
|
1732
2827
|
useEffect(() => {
|
|
1733
2828
|
const onPageHide = () => flushScenes();
|
|
1734
2829
|
const onVisibility = () => {
|
|
@@ -1742,12 +2837,145 @@ globalThis.playMode = centerTab === "play";
|
|
|
1742
2837
|
flushScenes();
|
|
1743
2838
|
};
|
|
1744
2839
|
}, []);
|
|
1745
|
-
const [promptClips, setPromptClips] = useState(() => DEFAULT_PROMPT_CLIPS.map((clip) => ({ ...clip })));
|
|
2840
|
+
const [promptClips, setPromptClips] = useState(() => (startupStage.characters?.[0]?.layer?.promptClips ?? DEFAULT_PROMPT_CLIPS).map((clip) => ({ ...clip })));
|
|
2841
|
+
// These hooks are declared after the liveStateRef assignment above runs, so
|
|
2842
|
+
// they join the live read model here — same render, no TDZ.
|
|
2843
|
+
Object.assign(liveStateRef.current, { setPromptClips, setTlFrameCount });
|
|
2844
|
+
|
|
2845
|
+
// Dirty tracking: any divergence from the last saved file lights the dot.
|
|
2846
|
+
useEffect(() => {
|
|
2847
|
+
if (projectName === null) return; // untitled sessions are never "dirty"
|
|
2848
|
+
const serialized = collectProjectSerialized(projectName);
|
|
2849
|
+
setProjectDirty(serialized !== projectSnapshotRef.current);
|
|
2850
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2851
|
+
}, [scenes, activeSceneId, workspaceLayout, customPoses, characters, shots, waypoints, promptClips, projectName]);
|
|
1746
2852
|
const [selectedPromptId, setSelectedPromptId] = useState(null);
|
|
1747
2853
|
// Loaded motion: decoded arrays plus the world anchor captured at load.
|
|
1748
2854
|
const [motion, setMotion] = useState(null);
|
|
2855
|
+
// The untrimmed take per CHARACTER. Trims are non-destructive views of the
|
|
2856
|
+
// full take, so re-trimming and "restore full" always cut from the
|
|
2857
|
+
// original — and because each cast member owns its own layer, one shared
|
|
2858
|
+
// ref would hand Subject 2's take to Subject 1 on the next switch.
|
|
2859
|
+
const motionFullRef = useRef(new Map()); // charId -> untrimmed take
|
|
1749
2860
|
const [motionBusy, setMotionBusy] = useState(false);
|
|
1750
2861
|
const [motionError, setMotionError] = useState("");
|
|
2862
|
+
/* ------------------------- video capture (ingest) ---------------------- */
|
|
2863
|
+
const [multiModelUrl, setMultiModelUrl] = useState("");
|
|
2864
|
+
const [multiModelSource, setMultiModelSource] = useState(null);
|
|
2865
|
+
const [multiModelStatus, setMultiModelStatus] = useState("idle");
|
|
2866
|
+
// The ingest is a real download + decode, so it owns real progress and a
|
|
2867
|
+
// real receipt: the footage numbers below come from the decoded media.
|
|
2868
|
+
const [multiModelStage, setMultiModelStage] = useState("idle");
|
|
2869
|
+
const [multiModelProgress, setMultiModelProgress] = useState(null);
|
|
2870
|
+
const [multiModelFootage, setMultiModelFootage] = useState(null);
|
|
2871
|
+
const [multiModelError, setMultiModelError] = useState("");
|
|
2872
|
+
const multiModelFileRef = useRef(null);
|
|
2873
|
+
const multiModelRunRef = useRef(0);
|
|
2874
|
+
const multiModelObjectUrlRef = useRef(null);
|
|
2875
|
+
const [multiModelTake, setMultiModelTake] = useState(null);
|
|
2876
|
+
const [multiModelExtract, setMultiModelExtract] = useState("idle"); // idle | running | done | error
|
|
2877
|
+
const [multiModelExtractProgress, setMultiModelExtractProgress] = useState(null);
|
|
2878
|
+
const [multiModelExtractError, setMultiModelExtractError] = useState("");
|
|
2879
|
+
const multiModelDetectorRef = useRef(null); // engine survives re-runs; the 15 MB download happens once
|
|
2880
|
+
const multiModelRestRef = useRef(null);
|
|
2881
|
+
|
|
2882
|
+
// Cast render props, memoized with Character itself (React.memo): during
|
|
2883
|
+
// playback the playhead ticks 24 times a second, and a character whose
|
|
2884
|
+
// props did not change must not re-render its subtree.
|
|
2885
|
+
const characterViews = useMemo(() => characters.flatMap((entry, index) => {
|
|
2886
|
+
if (entry.hidden) return [];
|
|
2887
|
+
// Each cast member is driven by ITS OWN clip: the active one reads
|
|
2888
|
+
// the editing buffer, the others their stored session motion.
|
|
2889
|
+
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
2890
|
+
return [{
|
|
2891
|
+
id: entry.id,
|
|
2892
|
+
url: characterModelUrl(entry.model),
|
|
2893
|
+
position: clip ? [clip.anchorX, entry.y ?? 0, clip.anchorZ] : [entry.x, entry.y ?? 0, entry.z],
|
|
2894
|
+
rot: clip ? clip.rotationDeg : entry.rot,
|
|
2895
|
+
tint: entry.tint ?? defaultCharacterTint(entry, index),
|
|
2896
|
+
pose: clip ? null : (entry.pose ?? DEFAULT_POSE),
|
|
2897
|
+
// The stature the entry's take was extracted at. It rides with the
|
|
2898
|
+
// clip, never separately — see Character for why.
|
|
2899
|
+
scale: entry.scale ?? 1,
|
|
2900
|
+
onRig: reportRig(entry.id),
|
|
2901
|
+
pickId: index === 0 ? "A" : index === 1 ? "B" : entry.id,
|
|
2902
|
+
}];
|
|
2903
|
+
}), [characters, activeChar.id, motion]);
|
|
2904
|
+
// Where the selection gizmo stands: same driving rules as the render,
|
|
2905
|
+
// for the active (selected) cast member only. Gated on the HIERARCHY
|
|
2906
|
+
// selection, not the sticky active layer — the layer stays on the last
|
|
2907
|
+
// character so the motion tab keeps working, but a move gizmo hanging in
|
|
2908
|
+
// the viewport while the camera or a prop is selected reads as a stray
|
|
2909
|
+
// widget.
|
|
2910
|
+
const gizmoView = useMemo(() => {
|
|
2911
|
+
if (!charIdFromHierarchyId(selectedHierarchyId)) return null;
|
|
2912
|
+
const entry = characters.find((item) => item.id === activeChar.id);
|
|
2913
|
+
if (!entry || entry.hidden) return null;
|
|
2914
|
+
const clip = motion;
|
|
2915
|
+
return { position: clip ? [clip.anchorX, entry.y ?? 0, clip.anchorZ] : [entry.x, entry.y ?? 0, entry.z] };
|
|
2916
|
+
}, [characters, activeChar.id, motion, selectedHierarchyId]);
|
|
2917
|
+
// The cast rides the SAME gizmo as scene objects — one movement grammar
|
|
2918
|
+
// for everything on stage. The proxy hands ObjectGizmo the object shape
|
|
2919
|
+
// it expects; `height` puts the pivot at the hips like a prop's centre.
|
|
2920
|
+
const characterGizmoObject = useMemo(() => {
|
|
2921
|
+
if (!gizmoView) return null;
|
|
2922
|
+
const entry = characters.find((item) => item.id === activeChar.id);
|
|
2923
|
+
return {
|
|
2924
|
+
id: "__character__",
|
|
2925
|
+
x: gizmoView.position[0],
|
|
2926
|
+
y: gizmoView.position[1],
|
|
2927
|
+
z: gizmoView.position[2],
|
|
2928
|
+
height: 1.15,
|
|
2929
|
+
footprint: { width: 0.6, depth: 0.6 },
|
|
2930
|
+
rotY: entry?.rot ?? 0,
|
|
2931
|
+
scaleX: entry?.scale ?? 1,
|
|
2932
|
+
scaleY: entry?.scale ?? 1,
|
|
2933
|
+
scaleZ: entry?.scale ?? 1,
|
|
2934
|
+
};
|
|
2935
|
+
}, [gizmoView, characters, activeChar.id]);
|
|
2936
|
+
|
|
2937
|
+
/* --------------------- per-character layer buffers ---------------------
|
|
2938
|
+
* waypoints / promptClips / motion above are the EDITING BUFFER of the
|
|
2939
|
+
* active character's animation layer. On a character switch the buffer is
|
|
2940
|
+
* committed back into the previous character's entry and the new one's
|
|
2941
|
+
* layer is loaded, so each cast member keeps its own schedule. The
|
|
2942
|
+
* generated clip is session-only; paths and prompt blocks persist in the
|
|
2943
|
+
* stage envelope via the characters array. */
|
|
2944
|
+
const bufferRef = useRef({ waypoints: [], promptClips: [], motion: null, ik: null });
|
|
2945
|
+
bufferRef.current = { waypoints, promptClips, motion, ik: ikStateRef.current };
|
|
2946
|
+
const loadedLayerCharRef = useRef(activeChar.id);
|
|
2947
|
+
const ikStatesRef = useRef(new Map()); // charId -> ikState, one per layer
|
|
2948
|
+
useEffect(() => {
|
|
2949
|
+
const previous = loadedLayerCharRef.current;
|
|
2950
|
+
if (previous === activeChar.id) return;
|
|
2951
|
+
// Leaving IK on switch: the handles are re-seated per rig, and a
|
|
2952
|
+
// half-dragged chain must never leak onto another character.
|
|
2953
|
+
if (ikMode) leaveIkMode();
|
|
2954
|
+
if (previous) {
|
|
2955
|
+
ikStatesRef.current.set(previous, bufferRef.current.ik);
|
|
2956
|
+
setCharacters((list) => list.map((entry) => entry.id === previous
|
|
2957
|
+
? { ...entry, layer: { waypoints: bufferRef.current.waypoints, promptClips: bufferRef.current.promptClips }, sessionMotion: bufferRef.current.motion }
|
|
2958
|
+
: entry));
|
|
2959
|
+
}
|
|
2960
|
+
const layer = activeChar.layer ?? createCharacterLayer();
|
|
2961
|
+
setWaypoints(layer.waypoints.map((waypoint) => ({ ...waypoint })));
|
|
2962
|
+
setPromptClips(layer.promptClips.map((clip) => ({ ...clip })));
|
|
2963
|
+
// A clip that arrived while this character was inactive (an extra
|
|
2964
|
+
// extraction take, a restored motionRef, a queued generation) becomes
|
|
2965
|
+
// trimmable the moment it enters the buffer. Seed only when absent: an
|
|
2966
|
+
// existing entry is the UNTRIMMED take, and the buffer may be holding a
|
|
2967
|
+
// cut view of it.
|
|
2968
|
+
if (activeChar.sessionMotion && !motionFullRef.current.has(activeChar.id)) {
|
|
2969
|
+
motionFullRef.current.set(activeChar.id, activeChar.sessionMotion);
|
|
2970
|
+
}
|
|
2971
|
+
setMotion(activeChar.sessionMotion ?? null);
|
|
2972
|
+
setSelectedPromptId(null);
|
|
2973
|
+
setWaypointMode(false);
|
|
2974
|
+
setActiveWaypointFrame(null);
|
|
2975
|
+
setPendingWaypointFrame(null);
|
|
2976
|
+
ikStateRef.current = ikStatesRef.current.get(activeChar.id) ?? createIkState();
|
|
2977
|
+
loadedLayerCharRef.current = activeChar.id;
|
|
2978
|
+
}, [activeChar.id]);
|
|
1751
2979
|
// Pre-playback bone snapshot; restoring it (after Character's pose effect
|
|
1752
2980
|
// has re-applied poseA) puts the rig back exactly where it was.
|
|
1753
2981
|
const restoreRef = useRef(null);
|
|
@@ -1831,11 +3059,14 @@ globalThis.playMode = centerTab === "play";
|
|
|
1831
3059
|
else if (!rec.armed) setToast(ko("Recording cancelled before any frame was captured", "프레임을 캡처하기 전에 녹화가 취소됐어요"));
|
|
1832
3060
|
}
|
|
1833
3061
|
|
|
1834
|
-
// Seedance's reference-video floor is 24 fps
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
//
|
|
1838
|
-
|
|
3062
|
+
// Seedance's reference-video floor is 24 fps — and so is the production
|
|
3063
|
+
// timeline now, so capture and timeline share one clock: the playhead
|
|
3064
|
+
// advances one timeline frame per captured frame, each stamped 41.67 ms
|
|
3065
|
+
// apart. The exported file is exactly 24 fps at true motion speed (the
|
|
3066
|
+
// old 20-fps timeline needed a 20/24 real-time warp for the same result),
|
|
3067
|
+
// valid as a camera/motion reference out of the box. This is the EXPORT
|
|
3068
|
+
// clock, never the ARDY wire clock — it does not go through toArdyFrame.
|
|
3069
|
+
const RECORD_FPS = TIMELINE_FPS;
|
|
1839
3070
|
|
|
1840
3071
|
function toggleShotRecording() {
|
|
1841
3072
|
if (recRef.current) {
|
|
@@ -1854,8 +3085,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
1854
3085
|
return;
|
|
1855
3086
|
}
|
|
1856
3087
|
const mirror = document.createElement("canvas");
|
|
1857
|
-
mirror.width =
|
|
1858
|
-
mirror.height =
|
|
3088
|
+
mirror.width = shotOutput.width;
|
|
3089
|
+
mirror.height = shotOutput.height;
|
|
1859
3090
|
const ctx = mirror.getContext("2d");
|
|
1860
3091
|
// Frames go in manually (and only when one was actually drawn): the
|
|
1861
3092
|
// track must be exactly RECORD_FPS, not whatever the display runs at.
|
|
@@ -1959,7 +3190,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
1959
3190
|
rec.timer = setTimeout(timeoutTick, 20);
|
|
1960
3191
|
return;
|
|
1961
3192
|
}
|
|
1962
|
-
const img = fitAspect(pane,
|
|
3193
|
+
const img = fitAspect(pane, shotOutput.aspect);
|
|
1963
3194
|
const scale = glCanvas.width / stageRect.width;
|
|
1964
3195
|
ctx.drawImage(
|
|
1965
3196
|
glCanvas,
|
|
@@ -2085,8 +3316,14 @@ globalThis.playMode = centerTab === "play";
|
|
|
2085
3316
|
}
|
|
2086
3317
|
|
|
2087
3318
|
const allPoses = useMemo(() => [...BUILT_IN_POSES, ...customPoses], [customPoses]);
|
|
2088
|
-
|
|
2089
|
-
|
|
3319
|
+
// The pose studio follows the character it was opened for: `posing` is a
|
|
3320
|
+
// charId, so every cast member gets the same studio, not just the first two.
|
|
3321
|
+
const posingIndex = characters.findIndex((entry) => entry.id === posing);
|
|
3322
|
+
const posingChar = posingIndex >= 0 ? characters[posingIndex] : null;
|
|
3323
|
+
const posedRig = () => rigs[posing] ?? null;
|
|
3324
|
+
const setPosed = (pose) => {
|
|
3325
|
+
if (posingIndex >= 0) updateCharacterAt(posingIndex, { pose: typeof pose === "function" ? pose(posingChar?.pose ?? DEFAULT_POSE) : pose });
|
|
3326
|
+
};
|
|
2090
3327
|
|
|
2091
3328
|
/* ------------------------- waypoint workspace --------------------------- */
|
|
2092
3329
|
|
|
@@ -2095,7 +3332,9 @@ globalThis.playMode = centerTab === "play";
|
|
|
2095
3332
|
const WALK_SPEED_MPS = 1.4;
|
|
2096
3333
|
const ROOT_ROOM_LIMIT = 11;
|
|
2097
3334
|
const clampRootPosition = (value) => Math.max(-ROOT_ROOM_LIMIT, Math.min(ROOT_ROOM_LIMIT, value));
|
|
2098
|
-
|
|
3335
|
+
// Frame 0 of a root path is the ACTIVE character's spot — each layer's
|
|
3336
|
+
// path starts from its own cast member.
|
|
3337
|
+
const rootStart = () => ({ frame: 0, x: activeChar.x, z: activeChar.z });
|
|
2099
3338
|
|
|
2100
3339
|
function validateWaypointAt(ordered, index, candidate) {
|
|
2101
3340
|
const previous = index > 0 ? ordered[index - 1] : rootStart();
|
|
@@ -2236,62 +3475,520 @@ globalThis.playMode = centerTab === "play";
|
|
|
2236
3475
|
|
|
2237
3476
|
/* --------------------------- motion playback ---------------------------- */
|
|
2238
3477
|
|
|
2239
|
-
|
|
2240
|
-
// Authored root destinations are generated by ARDY as sparse constraints,
|
|
2241
|
-
// so playback consumes the returned trajectory without coordinate warping.
|
|
2242
|
-
async function loadMotion(url, prompt, rotationDeg = charA.rot) {
|
|
2243
|
-
setMotionBusy(true);
|
|
2244
|
-
setMotionError("");
|
|
2245
|
-
// Loading a motion drops out of IK EDIT mode (playback is the new
|
|
2246
|
-
// context) — the IK KEYS stay and keep correcting the clip layer-style.
|
|
3478
|
+
function leaveIkMode() {
|
|
2247
3479
|
setIkMode(false);
|
|
3480
|
+
setIkFocus(null);
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/** Hand `rig` over to playback. Every path that starts a clip — a loaded
|
|
3484
|
+
* take, a browser-baked one — does the same two things: drop out of IK
|
|
3485
|
+
* EDIT mode (playback is the new context; the IK KEYS stay and keep
|
|
3486
|
+
* correcting the clip layer-style) and swap the pre-playback bone
|
|
3487
|
+
* baseline, so a re-load never snapshots mid-animation and clearing
|
|
3488
|
+
* always returns to the blocking pose. */
|
|
3489
|
+
function beginPlaybackOn(rig) {
|
|
3490
|
+
leaveIkMode();
|
|
3491
|
+
const previous = restoreRef.current;
|
|
3492
|
+
restoreRef.current = null;
|
|
3493
|
+
if (previous) restorePlaybackBones(previous.rig, previous.bones);
|
|
3494
|
+
restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
/* ---------------------------- video capture ----------------------------
|
|
3498
|
+
* Footage in, takes out. The ingest downloads and decodes a source so the
|
|
3499
|
+
* timeline is sized by what was actually read; extraction then turns it
|
|
3500
|
+
* into one take per tracked performer. Take 0 belongs to the ACTIVE
|
|
3501
|
+
* character's layer, the rest are landed on their own cast members. */
|
|
3502
|
+
|
|
3503
|
+
// A picked file is already local bytes: no download stage, straight to the
|
|
3504
|
+
// probe that produces the timeline numbers.
|
|
3505
|
+
function chooseMultiModelFile(event) {
|
|
3506
|
+
const file = event.target.files?.[0] ?? null;
|
|
3507
|
+
if (!file) return;
|
|
3508
|
+
setMultiModelUrl("");
|
|
3509
|
+
ingestFootage({ kind: "file", name: file.name, blob: file, bytes: file.size });
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
async function pasteMultiModelUrl() {
|
|
2248
3513
|
try {
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
3514
|
+
const text = await navigator.clipboard.readText();
|
|
3515
|
+
if (!text.trim()) return;
|
|
3516
|
+
setMultiModelUrl(text.trim());
|
|
3517
|
+
setMultiModelStatus("idle");
|
|
3518
|
+
} catch {
|
|
3519
|
+
setToast(isKo ? "클립보드를 읽지 못했어요 — 직접 붙여넣어 주세요" : "Clipboard is unavailable — paste into the field directly");
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function useMultiModelUrl() {
|
|
3524
|
+
const raw = multiModelUrl.trim();
|
|
3525
|
+
// A platform page (YouTube, Vimeo, …) is never browser-fetchable, but
|
|
3526
|
+
// the dev bridge can fetch it server-side. With the bridge up the
|
|
3527
|
+
// address goes there; without it the named refusal below stands.
|
|
3528
|
+
if (isPlatformPageUrl(raw) && bridge?.ok) {
|
|
3529
|
+
ingestPlatformFootage(raw);
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
const normalized = normalizeSourceUrl(raw);
|
|
3533
|
+
if (!normalized.ok) {
|
|
3534
|
+
setMultiModelStatus("error");
|
|
3535
|
+
setMultiModelStage("error");
|
|
3536
|
+
setMultiModelError(MULTIMODEL_REASONS[normalized.reason]?.[isKo ? 1 : 0] ?? normalized.reason);
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
ingestFootage({ kind: "url", name: sourceLabel(normalized.url), url: normalized.url });
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
/** Platform page → bridge download (yt-dlp + normalize) → the local
|
|
3543
|
+
* /ardy/footage/… address rides the ordinary ingest unchanged. */
|
|
3544
|
+
async function ingestPlatformFootage(pageUrl) {
|
|
3545
|
+
const run = multiModelRunRef.current + 1;
|
|
3546
|
+
multiModelRunRef.current = run;
|
|
3547
|
+
const live = () => multiModelRunRef.current === run;
|
|
3548
|
+
setMultiModelSource({ kind: "url", name: sourceLabel(pageUrl), url: pageUrl });
|
|
3549
|
+
setMultiModelFootage(null);
|
|
3550
|
+
setMultiModelError("");
|
|
3551
|
+
setMultiModelStatus("busy");
|
|
3552
|
+
setMultiModelStage("fetching");
|
|
3553
|
+
setMultiModelProgress(null);
|
|
3554
|
+
setMultiModelTake(null);
|
|
3555
|
+
setMultiModelExtract("idle");
|
|
3556
|
+
setMultiModelExtractProgress(null);
|
|
3557
|
+
setMultiModelExtractError("");
|
|
3558
|
+
try {
|
|
3559
|
+
const footage = await requestBridgeFootage(pageUrl, {
|
|
3560
|
+
onProgress: ({ ratio }) => {
|
|
3561
|
+
if (live()) setMultiModelProgress(Number.isFinite(ratio) ? ratio : null);
|
|
3562
|
+
},
|
|
2270
3563
|
});
|
|
2271
|
-
|
|
2272
|
-
|
|
3564
|
+
if (!live()) return;
|
|
3565
|
+
ingestFootage({ kind: "url", name: footage.title || sourceLabel(pageUrl), url: footage.url, fps: footage.fps, bridgeId: footage.footage ?? null });
|
|
3566
|
+
} catch (error) {
|
|
3567
|
+
if (!live()) return;
|
|
3568
|
+
const code = error?.message ?? String(error);
|
|
3569
|
+
setMultiModelStage("error");
|
|
3570
|
+
setMultiModelStatus("error");
|
|
3571
|
+
setMultiModelProgress(null);
|
|
3572
|
+
setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
/** Download (when remote), decode, measure, then size the timeline from what
|
|
3577
|
+
* was actually read. Each run carries a token so a slow first source can
|
|
3578
|
+
* never land its numbers after a second one replaced it. */
|
|
3579
|
+
async function ingestFootage(source) {
|
|
3580
|
+
const run = multiModelRunRef.current + 1;
|
|
3581
|
+
multiModelRunRef.current = run;
|
|
3582
|
+
const live = () => multiModelRunRef.current === run;
|
|
3583
|
+
setMultiModelSource(source);
|
|
3584
|
+
setMultiModelFootage(null);
|
|
3585
|
+
setMultiModelError("");
|
|
3586
|
+
setMultiModelStatus("busy");
|
|
3587
|
+
setMultiModelProgress(null);
|
|
3588
|
+
// A take baked from the PREVIOUS clip must not read as this one's
|
|
3589
|
+
// result, so the extraction state resets with the source.
|
|
3590
|
+
setMultiModelTake(null);
|
|
3591
|
+
setMultiModelExtract("idle");
|
|
3592
|
+
setMultiModelExtractProgress(null);
|
|
3593
|
+
setMultiModelExtractError("");
|
|
3594
|
+
try {
|
|
3595
|
+
let blob = source.blob ?? null;
|
|
3596
|
+
let bytes = source.bytes ?? 0;
|
|
3597
|
+
if (!blob) {
|
|
3598
|
+
setMultiModelStage("fetching");
|
|
3599
|
+
const downloaded = await fetchFootageBlob(source.url, {
|
|
3600
|
+
onProgress: ({ ratio }) => {
|
|
3601
|
+
if (live()) setMultiModelProgress(ratio);
|
|
3602
|
+
},
|
|
3603
|
+
});
|
|
3604
|
+
if (!live()) return;
|
|
3605
|
+
blob = downloaded.blob;
|
|
3606
|
+
bytes = downloaded.bytes;
|
|
3607
|
+
}
|
|
3608
|
+
setMultiModelStage("probing");
|
|
3609
|
+
if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
|
|
3610
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
3611
|
+
multiModelObjectUrlRef.current = objectUrl;
|
|
3612
|
+
const probed = await probeFootage(objectUrl, {
|
|
3613
|
+
createVideo: () => document.createElement("video"),
|
|
3614
|
+
// The bridge normalized the clip and DECLARED its rate; a probe
|
|
3615
|
+
// that re-guesses it would overrule a measurement with a guess.
|
|
3616
|
+
knownFps: Number.isFinite(source.fps) ? source.fps : null,
|
|
3617
|
+
});
|
|
3618
|
+
if (!live()) return;
|
|
3619
|
+
const footage = { ...probed, bytes, objectUrl, blob, bridgeId: source.bridgeId ?? null };
|
|
3620
|
+
setMultiModelFootage(footage);
|
|
3621
|
+
setMultiModelStage("ready");
|
|
3622
|
+
setMultiModelStatus("ready");
|
|
3623
|
+
setMultiModelProgress(1);
|
|
3624
|
+
// The timeline is the point: the playhead now spans the footage that
|
|
3625
|
+
// was actually decoded, at the rate that was actually measured.
|
|
3626
|
+
setTlFps(footage.fps);
|
|
3627
|
+
setTlFrameCount(footage.frames);
|
|
2273
3628
|
setTlFrame(0);
|
|
2274
3629
|
setTlPlaying(false);
|
|
2275
|
-
setToast(isKo
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
3630
|
+
setToast(isKo
|
|
3631
|
+
? `${source.name} 인제스트됨 — ${footage.frames}프레임 @ ${footage.fps} fps`
|
|
3632
|
+
: `Ingested ${source.name} — ${footage.frames} frames @ ${footage.fps} fps`);
|
|
3633
|
+
} catch (error) {
|
|
3634
|
+
if (!live()) return;
|
|
3635
|
+
const code = error?.message ?? String(error);
|
|
3636
|
+
setMultiModelStage("error");
|
|
3637
|
+
setMultiModelStatus("error");
|
|
3638
|
+
setMultiModelProgress(null);
|
|
3639
|
+
setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
2281
3640
|
}
|
|
2282
3641
|
}
|
|
2283
3642
|
|
|
2284
|
-
|
|
2285
|
-
|
|
3643
|
+
/** Extract motion from the ingested footage. With the bridge up this goes
|
|
3644
|
+
* to the GPU box (SAM-3D-Body: whole-clip temporal context, real 3D body
|
|
3645
|
+
* prior — previs-grade). Without it, the browser MediaPipe path below
|
|
3646
|
+
* still works offline as the rough-blocking fallback. */
|
|
3647
|
+
async function extractMultiModelMotion() {
|
|
3648
|
+
if (bridge?.ok) return extractMultiModelMotionGpu();
|
|
3649
|
+
return extractMultiModelMotionBrowser();
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
async function extractMultiModelMotionGpu() {
|
|
3653
|
+
const footage = multiModelFootage;
|
|
3654
|
+
if (!footage || multiModelExtract === "running") return;
|
|
3655
|
+
const run = multiModelRunRef.current;
|
|
3656
|
+
const live = () => multiModelRunRef.current === run;
|
|
3657
|
+
setMultiModelExtract("running");
|
|
3658
|
+
setMultiModelExtractProgress(null);
|
|
3659
|
+
setMultiModelExtractError("");
|
|
3660
|
+
setMultiModelTake(null);
|
|
3661
|
+
try {
|
|
3662
|
+
const done = await requestBridgeExtract(
|
|
3663
|
+
footage.bridgeId ? { footage: footage.bridgeId } : footage.blob,
|
|
3664
|
+
{
|
|
3665
|
+
onProgress: ({ ratio }) => {
|
|
3666
|
+
if (live()) setMultiModelExtractProgress(Number.isFinite(ratio) ? ratio : null);
|
|
3667
|
+
},
|
|
3668
|
+
}
|
|
3669
|
+
);
|
|
3670
|
+
if (!live()) return;
|
|
3671
|
+
// One take per tracked performer. An older bridge sends a single
|
|
3672
|
+
// motionUrl and no list; that is the same thing with one entry.
|
|
3673
|
+
const takes = Array.isArray(done.takes) && done.takes.length
|
|
3674
|
+
? done.takes
|
|
3675
|
+
: [{ motionUrl: done.motionUrl, personScale: done.personScale, offsetX: 0, offsetZ: 0 }];
|
|
3676
|
+
const label = multiModelSource?.name ?? "extracted take";
|
|
3677
|
+
// The cast can change while the GPU works, so the destination is read
|
|
3678
|
+
// now, once, and every take is placed against THIS character.
|
|
3679
|
+
const active = charactersRef.current.find((entry) => entry.id === activeChar.id) ?? activeChar;
|
|
3680
|
+
// Take 0 arrives as an ordinary motion npz; loadMotion decodes,
|
|
3681
|
+
// retimes to the 24 fps timeline, snapshots the rig baseline and
|
|
3682
|
+
// applies the stature stored in the take itself — the body matches
|
|
3683
|
+
// the FILMED person because the file carries the measurement, not
|
|
3684
|
+
// because this handler remembered to re-apply it afterwards.
|
|
3685
|
+
let personScale = await loadMotion(takes[0].motionUrl ?? done.motionUrl, label, active.rot);
|
|
3686
|
+
if (!live()) return;
|
|
3687
|
+
// loadMotion reports the stature it applied — 1 for a take that
|
|
3688
|
+
// stores none, nothing at all if the load failed. A failed load is
|
|
3689
|
+
// not a finished extraction: say so with the named reason instead
|
|
3690
|
+
// of a receipt for a take nobody can play.
|
|
3691
|
+
if (!Number.isFinite(personScale)) throw new Error("extract-convert-failed");
|
|
3692
|
+
// Only a take that stores NO stature gets the fallback for an npz
|
|
3693
|
+
// that predates person_scale (or an older bridge): the response
|
|
3694
|
+
// still carries the estimate, clamped the same way, because a bad
|
|
3695
|
+
// leg estimate must never produce a giant or a gnome.
|
|
3696
|
+
const declared = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
|
|
3697
|
+
if (personScale === 1 && Number.isFinite(declared)) {
|
|
3698
|
+
personScale = characterScaleFor(null, declared);
|
|
3699
|
+
}
|
|
3700
|
+
// The clip itself is session-only; this reference is what a save
|
|
3701
|
+
// keeps and restoreMotionRefs re-fetches on the next session.
|
|
3702
|
+
const leadRef = {
|
|
3703
|
+
url: takes[0].motionUrl ?? done.motionUrl,
|
|
3704
|
+
prompt: label,
|
|
3705
|
+
rotationDeg: active.rot,
|
|
3706
|
+
anchorX: active.x,
|
|
3707
|
+
anchorZ: active.z,
|
|
3708
|
+
};
|
|
3709
|
+
setCharacters((list) => list.map((entry) => entry.id === active.id
|
|
3710
|
+
? { ...entry, scale: personScale, motionRef: leadRef }
|
|
3711
|
+
: entry));
|
|
3712
|
+
const placed = await deliverExtraTakes(takes.slice(1), active, label);
|
|
3713
|
+
if (!live()) return;
|
|
3714
|
+
const persons = 1 + placed;
|
|
3715
|
+
setMultiModelTake({ frames: done.frames, fps: done.fps, gpu: true, personScale, persons });
|
|
3716
|
+
setMultiModelExtract("done");
|
|
3717
|
+
setToast(isKo
|
|
3718
|
+
? `GPU 모션 추출됨 — ${done.frames}프레임 @ ${done.fps} fps${persons > 1 ? ` · ${persons}명` : ""} · 인물 스케일 ×${personScale.toFixed(2)}`
|
|
3719
|
+
: `GPU motion extracted — ${done.frames} frames @ ${done.fps} fps${persons > 1 ? ` · ${persons} performers` : ""} · person scale ×${personScale.toFixed(2)}`);
|
|
3720
|
+
} catch (error) {
|
|
3721
|
+
if (!live()) return;
|
|
3722
|
+
const code = error?.message ?? String(error);
|
|
3723
|
+
setMultiModelExtract("error");
|
|
3724
|
+
setMultiModelExtractError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
/** Land takes 1..N-1 on the rest of the cast. Each extra take is its OWN
|
|
3729
|
+
* layer: it goes to that entry's sessionMotion, NOT through the editing
|
|
3730
|
+
* buffer, which holds the active character's clip alone. Returns how many
|
|
3731
|
+
* performers actually landed. */
|
|
3732
|
+
async function deliverExtraTakes(extras, active, label) {
|
|
3733
|
+
const decoded = await Promise.all(extras.map(async (take, index) => {
|
|
3734
|
+
if (typeof take?.motionUrl !== "string" || !take.motionUrl) return null;
|
|
3735
|
+
try {
|
|
3736
|
+
// Inbound boundary, exactly like every other clip: decode, then
|
|
3737
|
+
// retime onto the production clock before anything counts frames.
|
|
3738
|
+
const clip = retimeMotion(await loadMotionFromUrl(take.motionUrl), TIMELINE_FPS);
|
|
3739
|
+
const anchor = takeAnchor(active, take.offsetX, take.offsetZ);
|
|
3740
|
+
return {
|
|
3741
|
+
url: take.motionUrl,
|
|
3742
|
+
prompt: `${label} · ${index + 2}`,
|
|
3743
|
+
rotationDeg: active.rot,
|
|
3744
|
+
anchor,
|
|
3745
|
+
// A second performer is a DIFFERENT body: their take carries
|
|
3746
|
+
// their own stature, and the response estimate is only the
|
|
3747
|
+
// fallback for an npz that stores none.
|
|
3748
|
+
scale: characterScaleFor(clip, take.personScale),
|
|
3749
|
+
clip,
|
|
3750
|
+
};
|
|
3751
|
+
} catch {
|
|
3752
|
+
return null; // one unreadable take never voids the others
|
|
3753
|
+
}
|
|
3754
|
+
}));
|
|
3755
|
+
const usable = decoded.filter(Boolean);
|
|
3756
|
+
if (!usable.length) return 0;
|
|
3757
|
+
recordCharacterUndo();
|
|
3758
|
+
// Plan against the cast as it stands, so ids are decided once and the
|
|
3759
|
+
// full-take map can be seeded with them.
|
|
3760
|
+
const list = charactersRef.current;
|
|
3761
|
+
const taken = new Set([active.id]);
|
|
3762
|
+
let idPool = list;
|
|
3763
|
+
const assignments = usable.map((take) => {
|
|
3764
|
+
// Reuse a visible cast member with no clip of its own before adding
|
|
3765
|
+
// another body to the set.
|
|
3766
|
+
const reuse = list.find((entry) => !entry.hidden && !taken.has(entry.id) && !entry.sessionMotion && !entry.motionRef);
|
|
3767
|
+
const id = reuse ? reuse.id : nextCharacterId(idPool);
|
|
3768
|
+
if (!reuse) idPool = [...idPool, { id }];
|
|
3769
|
+
taken.add(id);
|
|
3770
|
+
return {
|
|
3771
|
+
id,
|
|
3772
|
+
spawn: !reuse,
|
|
3773
|
+
patch: {
|
|
3774
|
+
hidden: false,
|
|
3775
|
+
x: take.anchor.x,
|
|
3776
|
+
z: take.anchor.z,
|
|
3777
|
+
rot: take.rotationDeg,
|
|
3778
|
+
scale: take.scale,
|
|
3779
|
+
motionRef: {
|
|
3780
|
+
url: take.url,
|
|
3781
|
+
prompt: take.prompt,
|
|
3782
|
+
rotationDeg: take.rotationDeg,
|
|
3783
|
+
anchorX: take.anchor.x,
|
|
3784
|
+
anchorZ: take.anchor.z,
|
|
3785
|
+
},
|
|
3786
|
+
sessionMotion: {
|
|
3787
|
+
...take.clip,
|
|
3788
|
+
url: take.url,
|
|
3789
|
+
prompt: take.prompt,
|
|
3790
|
+
anchorX: take.anchor.x,
|
|
3791
|
+
anchorZ: take.anchor.z,
|
|
3792
|
+
anchorFrame: 0,
|
|
3793
|
+
rotationDeg: take.rotationDeg,
|
|
3794
|
+
},
|
|
3795
|
+
},
|
|
3796
|
+
};
|
|
3797
|
+
});
|
|
3798
|
+
setCharacters((current) => {
|
|
3799
|
+
let next = current;
|
|
3800
|
+
for (const { id, spawn, patch } of assignments) {
|
|
3801
|
+
next = spawn && !next.some((entry) => entry.id === id)
|
|
3802
|
+
? [...next, { ...createCharacterEntry({ id, model: active.model, pose: DEFAULT_POSE, subject: "a person" }, next.length), ...patch }]
|
|
3803
|
+
: next.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry));
|
|
3804
|
+
}
|
|
3805
|
+
return next;
|
|
3806
|
+
});
|
|
3807
|
+
// Their takes are trimmable the moment they become the active layer.
|
|
3808
|
+
for (const { id, patch } of assignments) motionFullRef.current.set(id, patch.sessionMotion);
|
|
3809
|
+
return assignments.length;
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
async function extractMultiModelMotionBrowser() {
|
|
3813
|
+
const footage = multiModelFootage;
|
|
3814
|
+
if (!footage || multiModelExtract === "running") return;
|
|
3815
|
+
const run = multiModelRunRef.current;
|
|
3816
|
+
const live = () => multiModelRunRef.current === run;
|
|
3817
|
+
setMultiModelExtract("running");
|
|
3818
|
+
setMultiModelExtractProgress(null); // indeterminate while the engine spins up
|
|
3819
|
+
setMultiModelExtractError("");
|
|
3820
|
+
setMultiModelTake(null);
|
|
3821
|
+
try {
|
|
3822
|
+
if (!multiModelRestRef.current) {
|
|
3823
|
+
const response = await fetch("/ardy/cskel27-rest.json").catch(() => null);
|
|
3824
|
+
if (!response?.ok) throw new Error("rest-unavailable");
|
|
3825
|
+
multiModelRestRef.current = await response.json().catch(() => {
|
|
3826
|
+
throw new Error("rest-unavailable");
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
if (!multiModelDetectorRef.current) {
|
|
3830
|
+
multiModelDetectorRef.current = await createPoseDetector();
|
|
3831
|
+
}
|
|
3832
|
+
const detector = multiModelDetectorRef.current;
|
|
3833
|
+
const total = sampleTimes(footage.durationS, MULTIMODEL_SAMPLE_FPS).length;
|
|
3834
|
+
const samples = await collectLandmarkTrack({
|
|
3835
|
+
frames: videoFrames(footage.objectUrl, {
|
|
3836
|
+
createVideo: () => document.createElement("video"),
|
|
3837
|
+
sampleFps: MULTIMODEL_SAMPLE_FPS,
|
|
3838
|
+
}),
|
|
3839
|
+
detect: detector.detect,
|
|
3840
|
+
onProgress: ({ processed }) => {
|
|
3841
|
+
if (live()) setMultiModelExtractProgress(total > 0 ? processed / total : null);
|
|
3842
|
+
},
|
|
3843
|
+
});
|
|
3844
|
+
if (!live()) return;
|
|
3845
|
+
if (samples.length === 0) throw new Error("no-person-found");
|
|
3846
|
+
const take = bakeExtractedTake({
|
|
3847
|
+
samples,
|
|
3848
|
+
rest: multiModelRestRef.current,
|
|
3849
|
+
fps: MULTIMODEL_SAMPLE_FPS,
|
|
3850
|
+
durationS: footage.durationS,
|
|
3851
|
+
createdMs: Date.now(),
|
|
3852
|
+
});
|
|
3853
|
+
if (!live()) return;
|
|
3854
|
+
const rig = activeRig;
|
|
3855
|
+
if (!rig) throw new Error("rig-not-loaded");
|
|
3856
|
+
beginPlaybackOn(rig);
|
|
3857
|
+
const loaded = {
|
|
3858
|
+
prompt: multiModelSource?.name ?? sourceLabel(footage.objectUrl),
|
|
3859
|
+
frames: take.frames,
|
|
3860
|
+
fps: take.fps,
|
|
3861
|
+
rotMats: take.rotMats,
|
|
3862
|
+
rootPos: take.rootPos,
|
|
3863
|
+
posedJoints: take.posedJoints,
|
|
3864
|
+
anchorX: activeChar.x,
|
|
3865
|
+
anchorZ: activeChar.z,
|
|
3866
|
+
anchorFrame: 0,
|
|
3867
|
+
rotationDeg: activeChar.rot,
|
|
3868
|
+
};
|
|
3869
|
+
// A baked take is trimmable like any other: without this the strip's
|
|
3870
|
+
// handles would drag against an empty map and cut nothing at all.
|
|
3871
|
+
motionFullRef.current.set(activeChar.id, loaded);
|
|
3872
|
+
// The browser fallback estimates no stature, and its root travel is
|
|
3873
|
+
// in canonical units — so it must not inherit the scale a previous
|
|
3874
|
+
// GPU take left on the character.
|
|
3875
|
+
setCharacters((list) => list.map((entry) => entry.id === activeChar.id
|
|
3876
|
+
? { ...entry, scale: characterScaleFor(take) }
|
|
3877
|
+
: entry));
|
|
3878
|
+
setMotion(loaded);
|
|
3879
|
+
setTlFrameCount(take.frames);
|
|
3880
|
+
setTlFps(take.fps);
|
|
3881
|
+
setTlFrame(0);
|
|
3882
|
+
setTlPlaying(false);
|
|
3883
|
+
setMultiModelTake({ frames: take.frames, fitted: take.fitted, held: take.held, sampled: total, accepted: samples.length });
|
|
3884
|
+
setMultiModelExtract("done");
|
|
3885
|
+
setToast(isKo
|
|
3886
|
+
? `모션 추출됨 — ${take.frames}프레임 테이크 @ ${take.fps} fps (실측 ${take.fitted}, 유지 ${take.held})`
|
|
3887
|
+
: `Motion extracted — a ${take.frames}-frame take @ ${take.fps} fps (${take.fitted} measured, ${take.held} held)`);
|
|
3888
|
+
} catch (error) {
|
|
3889
|
+
if (!live()) return;
|
|
3890
|
+
const code = error?.message ?? String(error);
|
|
3891
|
+
setMultiModelExtract("error");
|
|
3892
|
+
setMultiModelExtractError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
useEffect(() => () => {
|
|
3897
|
+
if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
|
|
3898
|
+
}, []);
|
|
3899
|
+
|
|
3900
|
+
// Decoded motion + the world anchor: frame 0 always starts at Subject 1.
|
|
3901
|
+
// Authored root destinations are generated by ARDY as sparse constraints,
|
|
3902
|
+
// so playback consumes the returned trajectory without coordinate warping.
|
|
3903
|
+
async function loadMotion(url, prompt, rotationDeg = charA.rot) {
|
|
3904
|
+
setMotionBusy(true);
|
|
3905
|
+
setMotionError("");
|
|
3906
|
+
try {
|
|
3907
|
+
// Inbound boundary: an ARDY take (20 fps) or a filmed one (30/60)
|
|
3908
|
+
// becomes a production-clock clip here, once, before anything on
|
|
3909
|
+
// the timeline counts its frames. Same-rate input rides through.
|
|
3910
|
+
const decoded = retimeMotion(await loadMotionFromUrl(url), TIMELINE_FPS);
|
|
3911
|
+
const rig = activeRig;
|
|
3912
|
+
if (!rig) throw new Error(ko("The active character's rig is not loaded", "활성 인물의 리그가 로드되지 않았어요"));
|
|
3913
|
+
beginPlaybackOn(rig);
|
|
3914
|
+
// THE INVARIANT: the take's travel assumes the character is scaled.
|
|
3915
|
+
// Extraction divided the root translation by the filmed person's
|
|
3916
|
+
// stature, so the clip and that stature have to be applied together
|
|
3917
|
+
// or every stride overshoots by the same factor and the feet skate.
|
|
3918
|
+
// The scale rides INSIDE the npz, so this one line covers every path
|
|
3919
|
+
// that loads a motion; an ARDY-generated take stores none and is
|
|
3920
|
+
// canonical, 1.
|
|
3921
|
+
const scale = characterScaleFor(decoded);
|
|
3922
|
+
setCharacters((list) => list.map((entry) => entry.id === activeChar.id ? { ...entry, scale } : entry));
|
|
3923
|
+
const loaded = {
|
|
3924
|
+
// Capture the exact prompt this motion was generated from; the
|
|
3925
|
+
// timeline keeps showing it even if the input field is edited
|
|
3926
|
+
// afterwards.
|
|
3927
|
+
prompt: typeof prompt === "string" ? prompt : "",
|
|
3928
|
+
...decoded,
|
|
3929
|
+
url,
|
|
3930
|
+
anchorX: activeChar.x,
|
|
3931
|
+
anchorZ: activeChar.z,
|
|
3932
|
+
anchorFrame: 0,
|
|
3933
|
+
rotationDeg,
|
|
3934
|
+
};
|
|
3935
|
+
// The take as loaded is what every future trim cuts from.
|
|
3936
|
+
motionFullRef.current.set(activeChar.id, loaded);
|
|
3937
|
+
setMotion(loaded);
|
|
3938
|
+
setTlFrameCount(decoded.frames);
|
|
3939
|
+
setTlFps(decoded.fps);
|
|
3940
|
+
setTlFrame(0);
|
|
3941
|
+
setTlPlaying(false);
|
|
3942
|
+
// IK keys correct SPECIFIC frames of the take they were authored on, so
|
|
3943
|
+
// a replacement take leaves them pointing at poses that no longer exist
|
|
3944
|
+
// — the same reason a trim clears them. The Full-Body lane would
|
|
3945
|
+
// otherwise keep showing corrections that belong to a discarded clip.
|
|
3946
|
+
const hadIkKeys = ikStateRef.current.keys.size > 0;
|
|
3947
|
+
if (hadIkKeys) {
|
|
3948
|
+
ikStateRef.current.keys.clear();
|
|
3949
|
+
ikStateRef.current.tracked.clear();
|
|
3950
|
+
ikStateRef.current.plants.clear();
|
|
3951
|
+
setIkTick((value) => value + 1);
|
|
3952
|
+
}
|
|
3953
|
+
setCommittedIkEdits([]);
|
|
3954
|
+
setToast(
|
|
3955
|
+
isKo
|
|
3956
|
+
? `모션 로드됨: ${decoded.frames}프레임 @ ${decoded.fps} fps${hadIkKeys ? " — 이전 테이크의 IK 키는 초기화됐어요" : ""}`
|
|
3957
|
+
: `Motion loaded: ${decoded.frames} frames @ ${decoded.fps} fps${hadIkKeys ? " — IK keys from the previous take were cleared" : ""}`,
|
|
3958
|
+
);
|
|
3959
|
+
// The applied stature, so a caller does not have to re-derive it
|
|
3960
|
+
// (and cannot derive a different one).
|
|
3961
|
+
return scale;
|
|
3962
|
+
} catch (err) {
|
|
3963
|
+
setMotion(null);
|
|
3964
|
+
setMotionError(err?.message || String(err));
|
|
3965
|
+
} finally {
|
|
3966
|
+
setMotionBusy(false);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// Hosted-demo seed. A build served as static files has no ARDY sidecar, so
|
|
3971
|
+
// a first-time visitor would otherwise land on a character standing still
|
|
2286
3972
|
// with no way to see generated motion. The clip below ships with the build
|
|
2287
3973
|
// and is loaded once, only when the bridge is absent and nothing has been
|
|
2288
3974
|
// loaded or generated yet. A local session with the bridge running is
|
|
2289
3975
|
// untouched.
|
|
3976
|
+
const motionRefsRestored = useRef(false);
|
|
3977
|
+
useEffect(() => {
|
|
3978
|
+
if (motionRefsRestored.current) return;
|
|
3979
|
+
motionRefsRestored.current = true;
|
|
3980
|
+
restoreMotionRefs(startupStage.characters);
|
|
3981
|
+
// Parse the thumbnail rigs during startup idle so the first pose-studio
|
|
3982
|
+
// open starts rendering immediately instead of paying a 100ms+ FBX parse.
|
|
3983
|
+
warmPoseThumbnails();
|
|
3984
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3985
|
+
}, []);
|
|
3986
|
+
|
|
2290
3987
|
const demoSeeded = useRef(false);
|
|
2291
3988
|
useEffect(() => {
|
|
2292
3989
|
if (demoSeeded.current) return;
|
|
2293
3990
|
if (!bridge || bridge.ok) return;
|
|
2294
|
-
if (!
|
|
3991
|
+
if (!activeRig || motion || motionBusy) return;
|
|
2295
3992
|
demoSeeded.current = true;
|
|
2296
3993
|
// Loaded, not played: the clip walks the subject out of the default
|
|
2297
3994
|
// framing, so autoplay would greet a first-time visitor with an empty
|
|
@@ -2299,39 +3996,108 @@ globalThis.playMode = centerTab === "play";
|
|
|
2299
3996
|
loadMotion(DEMO_MOTION_URL, DEMO_MOTION_PROMPT).catch(() => {
|
|
2300
3997
|
/* the seed is a nicety, never a failure the visitor must act on */
|
|
2301
3998
|
});
|
|
2302
|
-
}, [bridge,
|
|
3999
|
+
}, [bridge, activeRig, motion, motionBusy]);
|
|
2303
4000
|
|
|
2304
4001
|
function clearMotion() {
|
|
2305
4002
|
setMotion(null);
|
|
2306
4003
|
setMotionError("");
|
|
2307
|
-
|
|
4004
|
+
motionFullRef.current.delete(activeChar.id);
|
|
4005
|
+
// Corrections belong to the take. With the take gone they would sit on the
|
|
4006
|
+
// Full-Body lane describing frames of nothing.
|
|
4007
|
+
if (ikStateRef.current.keys.size > 0) {
|
|
4008
|
+
ikStateRef.current.keys.clear();
|
|
4009
|
+
ikStateRef.current.tracked.clear();
|
|
4010
|
+
ikStateRef.current.plants.clear();
|
|
4011
|
+
setIkTick((value) => value + 1);
|
|
4012
|
+
}
|
|
4013
|
+
setCommittedIkEdits([]);
|
|
4014
|
+
// A cleared clip leaves the body canonical: the stature belonged to the
|
|
4015
|
+
// take, not to the character.
|
|
4016
|
+
setCharacters((list) => list.map((entry) => entry.id === activeChar.id ? { ...entry, scale: 1 } : entry));
|
|
4017
|
+
// Back to the pre-generation timeline: the current duration on the production clock.
|
|
2308
4018
|
setTlFrameCount(maxDst + 1);
|
|
2309
|
-
setTlFps(
|
|
4019
|
+
setTlFps(TIMELINE_FPS);
|
|
2310
4020
|
setTlFrame((f) => Math.min(f, maxDst));
|
|
2311
4021
|
setTlPlaying(false);
|
|
2312
4022
|
}
|
|
2313
4023
|
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
4024
|
+
/** Cut the ACTIVE character's take to [start, end] of the CURRENT view.
|
|
4025
|
+
* Offsets compose, so a second cut still slices the original take; a cut
|
|
4026
|
+
* take drops its bridge url — its frames no longer match the source npz,
|
|
4027
|
+
* and IK-edit regeneration must not pretend they do. Per-character layers
|
|
4028
|
+
* mean the cut lands on this layer only; nobody else's clip moves. */
|
|
4029
|
+
function applyMotionTrim(start, end) {
|
|
4030
|
+
const full = motionFullRef.current.get(activeChar.id);
|
|
4031
|
+
if (!full || !motion) return;
|
|
4032
|
+
const offset = (motion.trimOffset ?? 0) + start;
|
|
4033
|
+
const last = (motion.trimOffset ?? 0) + end;
|
|
4034
|
+
if (last >= full.frames || offset > last) return;
|
|
4035
|
+
const sliced = sliceMotion(full, offset, last);
|
|
4036
|
+
const isFull = offset === 0 && sliced.frames === full.frames;
|
|
4037
|
+
setMotion({ ...sliced, url: isFull ? full.url : null, trimOffset: offset });
|
|
4038
|
+
setTlFrameCount(sliced.frames);
|
|
4039
|
+
setTlFrame((frame) => Math.min(frame, sliced.frames - 1));
|
|
4040
|
+
setTlPlaying(false);
|
|
4041
|
+
if (ikStateRef.current.keys.size > 0) {
|
|
4042
|
+
// IK keys were authored on the pre-cut frame numbers.
|
|
4043
|
+
ikStateRef.current.keys.clear();
|
|
4044
|
+
ikStateRef.current.tracked.clear();
|
|
4045
|
+
ikStateRef.current.plants.clear();
|
|
4046
|
+
setIkTick((value) => value + 1);
|
|
4047
|
+
setToast(isKo
|
|
4048
|
+
? `테이크 잘라냄 — ${sliced.frames}프레임 (원본 ${offset}–${last}). 기존 프레임의 IK 키는 초기화됐어요`
|
|
4049
|
+
: `Take cut to ${sliced.frames} frames (source ${offset}–${last}); IK keys keyed to the old frames were cleared`);
|
|
4050
|
+
} else {
|
|
4051
|
+
setToast(isKo
|
|
4052
|
+
? `테이크 잘라냄 — ${sliced.frames}프레임 (원본 ${offset}–${last})`
|
|
4053
|
+
: `Take cut to ${sliced.frames} frames (source ${offset}–${last})`);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
function resetMotionTrim() {
|
|
4058
|
+
const full = motionFullRef.current.get(activeChar.id);
|
|
4059
|
+
if (!full || !motion || (motion.trimOffset ?? 0) === 0 && motion.frames === full.frames) return;
|
|
4060
|
+
setMotion({ ...full });
|
|
4061
|
+
setTlFrameCount(full.frames);
|
|
4062
|
+
setTlFrame((frame) => Math.min(frame, full.frames - 1));
|
|
4063
|
+
setToast(ko("Full take restored", "테이크 전체 길이 복원"));
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// Drive every cast member from ITS OWN clip on the shared playhead. The
|
|
4067
|
+
// active character's buffer motion and the stored session motions of the
|
|
4068
|
+
// others all advance together; characters without a clip keep their pose.
|
|
2317
4069
|
useEffect(() => {
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
4070
|
+
for (const entry of characters) {
|
|
4071
|
+
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
4072
|
+
const rig = rigs[entry.id];
|
|
4073
|
+
if (clip && rig) applyMotionFrame(rig, clip, tlFrame);
|
|
4074
|
+
}
|
|
4075
|
+
}, [characters, activeChar.id, motion, rigs, tlFrame]);
|
|
4076
|
+
|
|
4077
|
+
// The shared timeline spans the LONGEST clip in the cast — a 300-frame
|
|
4078
|
+
// clip on Subject 2 must not clamp just because Subject 1's is 40.
|
|
4079
|
+
// Expansion only: shrinking stays owned by clearMotion and duration edits.
|
|
4080
|
+
useEffect(() => {
|
|
4081
|
+
const longest = characters.reduce((max, entry) => {
|
|
4082
|
+
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
4083
|
+
return Math.max(max, clip?.frames ?? 0);
|
|
4084
|
+
}, 0);
|
|
4085
|
+
if (longest > 0) setTlFrameCount((count) => Math.max(count, longest));
|
|
4086
|
+
}, [characters, activeChar.id, motion]);
|
|
2321
4087
|
|
|
2322
4088
|
/* ------------------------------ IK logic ------------------------------ */
|
|
2323
4089
|
|
|
2324
|
-
// Resolve the IK rig (chains + FK swing joints) whenever
|
|
2325
|
-
// (re)loads. A rig missing any bone resolves to null and
|
|
2326
|
-
// unavailable.
|
|
4090
|
+
// Resolve the IK rig (chains + FK swing joints) whenever the ACTIVE
|
|
4091
|
+
// character's rig (re)loads. A rig missing any bone resolves to null and
|
|
4092
|
+
// IK mode stays unavailable.
|
|
2327
4093
|
useEffect(() => {
|
|
2328
|
-
const resolved = resolveIkRig(
|
|
4094
|
+
const resolved = resolveIkRig(activeRig);
|
|
2329
4095
|
const chains = resolved ? resolved.chains : null;
|
|
2330
4096
|
setIkChains(chains);
|
|
2331
4097
|
setIkFkJoints(resolved ? resolved.fkJoints : null);
|
|
2332
4098
|
ikStateRef.current.chains = chains;
|
|
2333
|
-
if (!chains)
|
|
2334
|
-
}, [
|
|
4099
|
+
if (!chains) leaveIkMode();
|
|
4100
|
+
}, [activeRig]);
|
|
2335
4101
|
|
|
2336
4102
|
function toggleIkMode() {
|
|
2337
4103
|
const next = !ikMode;
|
|
@@ -2372,7 +4138,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2372
4138
|
// current frame's keyed rotations the moment ikMode flips, so nothing
|
|
2373
4139
|
// the user authored is lost by toggling. Untracked/unkeyed parts keep
|
|
2374
4140
|
// their current (FK) pose.
|
|
2375
|
-
|
|
4141
|
+
leaveIkMode();
|
|
2376
4142
|
setToast(ko("IK mode off — keyed poses keep playing", "IK 모드 꺼짐 — 키로 찍은 포즈는 계속 재생됩니다"));
|
|
2377
4143
|
}
|
|
2378
4144
|
|
|
@@ -2475,10 +4241,10 @@ globalThis.playMode = centerTab === "play";
|
|
|
2475
4241
|
// nothing to apply — pure FK posing / pure motion playback stays
|
|
2476
4242
|
// untouched.
|
|
2477
4243
|
useEffect(() => {
|
|
2478
|
-
if (!ikChains || !
|
|
4244
|
+
if (!ikChains || !activeRig || posing) return;
|
|
2479
4245
|
if (!ikMode && ikStateRef.current.keys.size === 0) return;
|
|
2480
4246
|
ikEvaluate(ikChains, ikStateRef.current, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
|
|
2481
|
-
}, [ikMode, ikChains,
|
|
4247
|
+
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints]);
|
|
2482
4248
|
|
|
2483
4249
|
// Re-seat the handles on the keyed pose when the FRAME changes with IK
|
|
2484
4250
|
// on — scrubbing to frame 39 shows that frame's interpolated pose AND
|
|
@@ -2490,16 +4256,16 @@ globalThis.playMode = centerTab === "play";
|
|
|
2490
4256
|
useEffect(() => {
|
|
2491
4257
|
const frameChanged = ikPrevFrameRef.current !== tlFrame;
|
|
2492
4258
|
ikPrevFrameRef.current = tlFrame;
|
|
2493
|
-
if (!ikMode || !ikChains || !
|
|
4259
|
+
if (!ikMode || !ikChains || !activeRig || posing) return;
|
|
2494
4260
|
if (!frameChanged) return; // pure toggle-on: evaluate already ran
|
|
2495
4261
|
ikSeedTargets(ikChains, ikStateRef.current);
|
|
2496
|
-
}, [ikMode, ikChains,
|
|
4262
|
+
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints]);
|
|
2497
4263
|
|
|
2498
4264
|
// QA hook: lets headless visual checks read the live rig/motion state
|
|
2499
4265
|
// (tools/ardy/visual-qa.mjs). Harmless in normal use.
|
|
2500
4266
|
useEffect(() => {
|
|
2501
4267
|
window.__cozyclay = {
|
|
2502
|
-
rigA, motion, tlFrame, ikMode, ikChains, ikFocus, ik: ikStateRef.current,
|
|
4268
|
+
rigA: activeRig, motion, tlFrame, ikMode, ikChains, ikFocus, ik: ikStateRef.current,
|
|
2503
4269
|
committedIkEdits, waypoints,
|
|
2504
4270
|
// the camera the main view renders through (poser in IK mode) — QA
|
|
2505
4271
|
// projections must use this one, not the frozen shot camera
|
|
@@ -2510,7 +4276,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2510
4276
|
charA,
|
|
2511
4277
|
insetPane: insetPaneRef.current,
|
|
2512
4278
|
};
|
|
2513
|
-
}, [
|
|
4279
|
+
}, [activeRig, motion, tlFrame, ikMode, ikChains, ikFocus, ikTick, charA, committedIkEdits, waypoints]);
|
|
2514
4280
|
// QA hook (plan §6.5): exposes history depth and the present === objects
|
|
2515
4281
|
// invariant so the browser suite can assert undo entry counts directly.
|
|
2516
4282
|
// Reads live store state at call time; re-registered after every render.
|
|
@@ -2599,7 +4365,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
2599
4365
|
const followCamActive =
|
|
2600
4366
|
activeCamera.mode !== "keys" && !!followTrack?.[tlFrame] && (centerTab === "play" || (!ikMode && !waypointMode && !posing));
|
|
2601
4367
|
|
|
2602
|
-
// Implied locomotion speed per authored segment
|
|
4368
|
+
// Implied locomotion speed per authored segment, on the timeline clock
|
|
4369
|
+
// (m/s is physical, so the judge always uses tlFps). Shown in the
|
|
2603
4370
|
// timeline hint so a path that forces a crawl or a sprint is visible
|
|
2604
4371
|
// before spending a generation on it.
|
|
2605
4372
|
const pathSpeed = useMemo(() => {
|
|
@@ -2618,7 +4385,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2618
4385
|
}
|
|
2619
4386
|
if (!Number.isFinite(min)) return null;
|
|
2620
4387
|
return { min, max, warn: min < 0.5 || max > 3 };
|
|
2621
|
-
}, [
|
|
4388
|
+
}, [activeChar.x, activeChar.z, tlFps, waypoints]);
|
|
2622
4389
|
|
|
2623
4390
|
const stateBadge = ardyRunning
|
|
2624
4391
|
? { label: ko("GENERATING", "생성 중"), kind: "generating" }
|
|
@@ -2628,10 +4395,13 @@ globalThis.playMode = centerTab === "play";
|
|
|
2628
4395
|
? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
|
|
2629
4396
|
: null;
|
|
2630
4397
|
|
|
2631
|
-
function openStudio(
|
|
2632
|
-
setPosing(
|
|
4398
|
+
function openStudio(charId) {
|
|
4399
|
+
setPosing(charId);
|
|
2633
4400
|
setPosingClosing(false);
|
|
2634
|
-
|
|
4401
|
+
const entry = characters.find((item) => item.id === charId);
|
|
4402
|
+
setStudioPick((entry?.pose ?? DEFAULT_POSE)?.id ?? null);
|
|
4403
|
+
// The panel docks under the Inspector — make sure that tab is showing.
|
|
4404
|
+
setSidebarTab("inspector");
|
|
2635
4405
|
}
|
|
2636
4406
|
|
|
2637
4407
|
function closeStudio() {
|
|
@@ -2674,21 +4444,24 @@ globalThis.playMode = centerTab === "play";
|
|
|
2674
4444
|
const p = PRESETS[key];
|
|
2675
4445
|
setPreset(key);
|
|
2676
4446
|
setFovDeg(p.fov);
|
|
2677
|
-
|
|
2678
|
-
|
|
4447
|
+
// Camera presets no longer touch the cast: with a free-form cast the
|
|
4448
|
+
// old two:false semantics would hide every extra character.
|
|
2679
4449
|
setNonce((n) => n + 1);
|
|
2680
4450
|
}
|
|
2681
4451
|
|
|
2682
4452
|
function bufferToPng(buffer) {
|
|
2683
4453
|
const canvas = document.createElement("canvas");
|
|
2684
|
-
canvas.width =
|
|
2685
|
-
canvas.height =
|
|
4454
|
+
canvas.width = shotOutput.width;
|
|
4455
|
+
canvas.height = shotOutput.height;
|
|
2686
4456
|
const ctx = canvas.getContext("2d");
|
|
2687
|
-
const image = ctx.createImageData(
|
|
4457
|
+
const image = ctx.createImageData(shotOutput.width, shotOutput.height);
|
|
2688
4458
|
// WebGL reads bottom-up; flip into canvas order.
|
|
2689
|
-
for (let row = 0; row <
|
|
2690
|
-
const from = (
|
|
2691
|
-
image.data.set(
|
|
4459
|
+
for (let row = 0; row < shotOutput.height; row += 1) {
|
|
4460
|
+
const from = (shotOutput.height - 1 - row) * shotOutput.width * 4;
|
|
4461
|
+
image.data.set(
|
|
4462
|
+
buffer.subarray(from, from + shotOutput.width * 4),
|
|
4463
|
+
row * shotOutput.width * 4
|
|
4464
|
+
);
|
|
2692
4465
|
}
|
|
2693
4466
|
ctx.putImageData(image, 0, 0);
|
|
2694
4467
|
return canvas.toDataURL("image/png");
|
|
@@ -2805,7 +4578,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2805
4578
|
railFollow: activeCamera.railFollow,
|
|
2806
4579
|
keys: cameraKeys,
|
|
2807
4580
|
} : null,
|
|
2808
|
-
subjects:
|
|
4581
|
+
subjects: characters.filter((entry) => !entry.hidden).map((entry) => entry.subject ?? "a person"),
|
|
2809
4582
|
};
|
|
2810
4583
|
setResult(nextResult);
|
|
2811
4584
|
setResultOpen(true);
|
|
@@ -2854,7 +4627,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
2854
4627
|
look,
|
|
2855
4628
|
fovDeg,
|
|
2856
4629
|
slate: slateLine(shot),
|
|
2857
|
-
rigName
|
|
4630
|
+
// rigName follows the posed character's actual model below
|
|
4631
|
+
rigName: posingChar?.model ?? charA.model,
|
|
2858
4632
|
root: captureArdyRoot(rig),
|
|
2859
4633
|
});
|
|
2860
4634
|
const blob = new Blob([JSON.stringify(pose, null, 2)], { type: "application/json" });
|
|
@@ -2888,7 +4662,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2888
4662
|
setPromptClips((prev) => [...prev, clip]);
|
|
2889
4663
|
setSelectedPromptId(clip.id);
|
|
2890
4664
|
setTlFrameCount((count) => Math.max(count, clip.endFrame));
|
|
2891
|
-
setArdyDuration(Math.max(ARDY_DURATION_MIN, clip.endFrame /
|
|
4665
|
+
setArdyDuration(Math.max(ARDY_DURATION_MIN, clip.endFrame / TIMELINE_FPS));
|
|
2892
4666
|
}
|
|
2893
4667
|
|
|
2894
4668
|
function changePromptClip(id, text) {
|
|
@@ -2896,18 +4670,23 @@ globalThis.playMode = centerTab === "play";
|
|
|
2896
4670
|
if (id === selectedPromptId) setArdyPrompt(text);
|
|
2897
4671
|
}
|
|
2898
4672
|
|
|
2899
|
-
|
|
4673
|
+
// Quality policy: one prompt block never spans more than 4 s. ARDY's
|
|
4674
|
+
// trained window is 10 s, but long single blocks drift — chained 4 s
|
|
4675
|
+
// blocks keep each call inside the model's sweet spot.
|
|
4676
|
+
const PROMPT_BLOCK_MAX_FRAMES = 4 * TIMELINE_FPS;
|
|
4677
|
+
|
|
4678
|
+
function resizePromptClip(id, edge, rawFrame) {
|
|
2900
4679
|
setPromptClips((prev) => {
|
|
2901
4680
|
const next = prev.map((clip) => {
|
|
2902
4681
|
if (clip.id !== id) return clip;
|
|
2903
4682
|
const snapped = Math.max(0, Math.round(rawFrame / ARDY_PROMPT_HORIZON_FRAMES) * ARDY_PROMPT_HORIZON_FRAMES);
|
|
2904
4683
|
return edge === "start"
|
|
2905
|
-
? { ...clip, startFrame: Math.min(snapped, clip.endFrame - ARDY_PROMPT_HORIZON_FRAMES) }
|
|
2906
|
-
: { ...clip, endFrame: Math.max(clip.startFrame + ARDY_PROMPT_HORIZON_FRAMES, snapped) };
|
|
4684
|
+
? { ...clip, startFrame: Math.min(Math.max(snapped, clip.endFrame - PROMPT_BLOCK_MAX_FRAMES), clip.endFrame - ARDY_PROMPT_HORIZON_FRAMES) }
|
|
4685
|
+
: { ...clip, endFrame: Math.min(Math.max(clip.startFrame + ARDY_PROMPT_HORIZON_FRAMES, snapped), clip.startFrame + PROMPT_BLOCK_MAX_FRAMES) };
|
|
2907
4686
|
});
|
|
2908
4687
|
const end = next.reduce((max, clip) => Math.max(max, clip.endFrame), ARDY_PROMPT_HORIZON_FRAMES);
|
|
2909
4688
|
setTlFrameCount((count) => Math.max(count, end));
|
|
2910
|
-
setArdyDuration(end /
|
|
4689
|
+
setArdyDuration(end / TIMELINE_FPS);
|
|
2911
4690
|
return next;
|
|
2912
4691
|
});
|
|
2913
4692
|
}
|
|
@@ -2918,7 +4697,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2918
4697
|
if (next === prev) return prev;
|
|
2919
4698
|
const end = next.reduce((max, clip) => Math.max(max, clip.endFrame), ARDY_PROMPT_HORIZON_FRAMES);
|
|
2920
4699
|
setTlFrameCount((count) => Math.max(count, end));
|
|
2921
|
-
setArdyDuration(end /
|
|
4700
|
+
setArdyDuration(end / TIMELINE_FPS);
|
|
2922
4701
|
return next;
|
|
2923
4702
|
});
|
|
2924
4703
|
}
|
|
@@ -2928,24 +4707,6 @@ globalThis.playMode = centerTab === "play";
|
|
|
2928
4707
|
if (selectedPromptId === id) setSelectedPromptId(null);
|
|
2929
4708
|
}
|
|
2930
4709
|
|
|
2931
|
-
function changeDuration(value) {
|
|
2932
|
-
const duration = Math.max(ARDY_DURATION_MIN, Math.min(Math.round(Number(value)) || ARDY_DURATION_MIN, ARDY_DURATION_MAX));
|
|
2933
|
-
setArdyDuration(duration);
|
|
2934
|
-
// Keep a stale destination frame in range when duration shrinks.
|
|
2935
|
-
// The generation clip is duration × 20 frames; root waypoints beyond
|
|
2936
|
-
// its end would be invalid for a new shorter generation, so prune
|
|
2937
|
-
// them even while a motion is loaded (the frame-0 start survives).
|
|
2938
|
-
const last = Math.max(0, duration * 20 - 1);
|
|
2939
|
-
setWaypoints((prev) => prev.filter((w) => w.frame <= last));
|
|
2940
|
-
// Without a loaded motion the timeline IS the generation clip: resize
|
|
2941
|
-
// it to duration × 20 and clamp the playhead. While a motion is loaded
|
|
2942
|
-
// the timeline keeps the clip's own frame count and playhead until clear.
|
|
2943
|
-
if (!motion) {
|
|
2944
|
-
setTlFrameCount(last + 1);
|
|
2945
|
-
setTlFrame((f) => Math.min(f, last));
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
4710
|
// Optional native-ARDY seed. Empty = request omits seed; the raw string
|
|
2950
4711
|
// is kept as typed (trimmed only). runArdy validates the bridge contract
|
|
2951
4712
|
// (integer in 0..2**31-1) right before the request and toasts on a
|
|
@@ -2963,7 +4724,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
2963
4724
|
return;
|
|
2964
4725
|
}
|
|
2965
4726
|
const totalFrames = Math.max(...clips.map((clip) => clip.endFrame));
|
|
2966
|
-
const duration = Math.max(ARDY_DURATION_MIN, Math.ceil(totalFrames /
|
|
4727
|
+
const duration = Math.max(ARDY_DURATION_MIN, Math.ceil(totalFrames / TIMELINE_FPS));
|
|
2967
4728
|
setArdyPrompt(clips[0].text);
|
|
2968
4729
|
setArdyDuration(duration);
|
|
2969
4730
|
runArdy({
|
|
@@ -2978,7 +4739,10 @@ globalThis.playMode = centerTab === "play";
|
|
|
2978
4739
|
durationOverride = ardyDuration,
|
|
2979
4740
|
promptClipsOverride = [],
|
|
2980
4741
|
} = {}) {
|
|
2981
|
-
|
|
4742
|
+
// Motion generation targets the ACTIVE character's layer; the pose
|
|
4743
|
+
// studio only lends its rig when it is actually open.
|
|
4744
|
+
const rig = posing ? posedRig() : activeRig;
|
|
4745
|
+
const rigModel = posing ? (posingChar?.model ?? activeChar.model) : activeChar.model;
|
|
2982
4746
|
if (!rig) {
|
|
2983
4747
|
setToast(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
|
|
2984
4748
|
return;
|
|
@@ -3015,7 +4779,11 @@ globalThis.playMode = centerTab === "play";
|
|
|
3015
4779
|
// prompt so the bridge always receives one contiguous 0..N sequence.
|
|
3016
4780
|
// Built BEFORE the root-path judge: whether the rollout is chained
|
|
3017
4781
|
// changes which window limit binds the path (per block, not per clip).
|
|
3018
|
-
|
|
4782
|
+
// `duration` is SECONDS — the one frame-rate-free number in the
|
|
4783
|
+
// request, and the only one the bridge reads directly. Everything the
|
|
4784
|
+
// app counts in frames from here on is on the timeline clock; the
|
|
4785
|
+
// bridge's own count is duration * ARDY_FPS, reached via toArdyFrame.
|
|
4786
|
+
const clipFrames = duration * TIMELINE_FPS;
|
|
3019
4787
|
const segments = [];
|
|
3020
4788
|
let cursor = 0;
|
|
3021
4789
|
const sourcePromptClips = promptClipsOverride
|
|
@@ -3033,7 +4801,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3033
4801
|
if (segments.length === 0) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
|
|
3034
4802
|
const hasPromptSchedule = segments.length > 1;
|
|
3035
4803
|
const rootPath = waypointMode
|
|
3036
|
-
? [{ frame: 0, x:
|
|
4804
|
+
? [{ frame: 0, x: activeChar.x, z: activeChar.z, heading: null }, ...waypoints]
|
|
3037
4805
|
: [];
|
|
3038
4806
|
if (waypointMode) {
|
|
3039
4807
|
if (waypoints.length < 1) {
|
|
@@ -3053,17 +4821,19 @@ globalThis.playMode = centerTab === "play";
|
|
|
3053
4821
|
// whole path is re-judged at the door. A prompt schedule chains
|
|
3054
4822
|
// the rollout block by block, so the trained window binds each
|
|
3055
4823
|
// block instead of the whole clip.
|
|
3056
|
-
|
|
4824
|
+
// Physical plausibility (m/s, deg/s) — judged against the clock the
|
|
4825
|
+
// pins were authored on, which is now the timeline's.
|
|
4826
|
+
const pathVerdict = judgeAuthoredPath(rootPath, TIMELINE_FPS, clipFrames, { chained: hasPromptSchedule });
|
|
3057
4827
|
if (pathVerdict.errors.length > 0) {
|
|
3058
4828
|
setToast(isKo ? `생성하지 못했어요 — ${pathVerdict.errors[0]}` : `Not generated — ${pathVerdict.errors[0]}`);
|
|
3059
4829
|
return;
|
|
3060
4830
|
}
|
|
3061
4831
|
if (hasPromptSchedule) {
|
|
3062
|
-
const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame >
|
|
4832
|
+
const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame > PROMPT_BLOCK_MAX_FRAMES);
|
|
3063
4833
|
if (longBlock) {
|
|
3064
4834
|
setToast(isKo
|
|
3065
|
-
? `생성하지 못했어요 —
|
|
3066
|
-
: `Not generated —
|
|
4835
|
+
? `생성하지 못했어요 — 프롬프트 블록은 ${PROMPT_BLOCK_MAX_FRAMES / TIMELINE_FPS}초 이내여야 해요. ${((longBlock.endFrame - longBlock.startFrame) / TIMELINE_FPS).toFixed(1)}초 블록을 나눠 주세요`
|
|
4836
|
+
: `Not generated — prompt blocks are capped at ${PROMPT_BLOCK_MAX_FRAMES / TIMELINE_FPS} s; split the ${((longBlock.endFrame - longBlock.startFrame) / TIMELINE_FPS).toFixed(1)} s block`);
|
|
3067
4837
|
return;
|
|
3068
4838
|
}
|
|
3069
4839
|
}
|
|
@@ -3074,19 +4844,43 @@ globalThis.playMode = centerTab === "play";
|
|
|
3074
4844
|
// is +Z (heading 0) and resampled with path-tangent headings — sparse
|
|
3075
4845
|
// heading-less pins that fight the forced facing corrupt the whole
|
|
3076
4846
|
// track (see the sign-convention notes in ardy/waypoints.js).
|
|
3077
|
-
|
|
3078
|
-
|
|
4847
|
+
// The 4 s block policy binds the schedule path too, not just
|
|
4848
|
+
// root-constrained runs: chained blocks are the whole point of the cap.
|
|
4849
|
+
if (!waypointMode && hasPromptSchedule) {
|
|
4850
|
+
const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame > PROMPT_BLOCK_MAX_FRAMES);
|
|
4851
|
+
if (longBlock) {
|
|
4852
|
+
setToast(isKo
|
|
4853
|
+
? `생성하지 못했어요 — 프롬프트 블록은 ${PROMPT_BLOCK_MAX_FRAMES / TIMELINE_FPS}초 이내여야 해요. ${((longBlock.endFrame - longBlock.startFrame) / TIMELINE_FPS).toFixed(1)}초 블록을 나눠 주세요`
|
|
4854
|
+
: `Not generated — prompt blocks are capped at ${PROMPT_BLOCK_MAX_FRAMES / TIMELINE_FPS} s; split the ${((longBlock.endFrame - longBlock.startFrame) / TIMELINE_FPS).toFixed(1)} s block`);
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
const alignedRoot = waypointMode ? alignArdyPath(rootPath, activeChar.rot, MAX_WAYPOINTS) : null;
|
|
4859
|
+
// Waypoints leave the app here, so their frames drop onto the bridge
|
|
4860
|
+
// clock here. Rounding can merge two near-adjacent samples; the first
|
|
4861
|
+
// wins — the bridge refuses non-ascending frame lists outright.
|
|
4862
|
+
const ardyWaypoints = toArdyFrameEntries(alignedRoot ? alignedRoot.waypoints : []);
|
|
3079
4863
|
|
|
3080
4864
|
// Capture every block boundary plus every authored IK key. Each sample
|
|
3081
4865
|
// is the composite base-motion + IK pose at that frame and carries the
|
|
3082
4866
|
// live ARDY root recovered from positional skinning.
|
|
3083
|
-
|
|
4867
|
+
// A block "edit" is a LOCAL correction of the loaded take, addressed to
|
|
4868
|
+
// that take's source npz. Without a bridge source there is nothing to edit
|
|
4869
|
+
// against, so such keys must not divert a fresh generation into the edit
|
|
4870
|
+
// path — that path sends no segments, and the whole schedule would collapse
|
|
4871
|
+
// to the first block's prompt.
|
|
4872
|
+
const editedSegments = motion?.url && hasPromptSchedule
|
|
3084
4873
|
? segments.filter((segment) =>
|
|
3085
4874
|
ikFrames.some((frame) => frame >= segment.startFrame && frame < segment.endFrame)
|
|
3086
4875
|
)
|
|
3087
4876
|
: [];
|
|
3088
4877
|
const hasBlockEdits = editedSegments.length > 0;
|
|
3089
|
-
|
|
4878
|
+
// Pin ONLY for authored IK edits. A loaded motion by itself must NOT
|
|
4879
|
+
// pin: pose-pinned calls run the box's pose mode, which builds on a
|
|
4880
|
+
// fixed implicit reference base — the new prompt then dresses up the
|
|
4881
|
+
// canned root path instead of driving its own motion ("I changed the
|
|
4882
|
+
// prompt but got the same walk").
|
|
4883
|
+
const shouldPin = hasBlockEdits || (!hasPromptSchedule && ikFrames.length > 0);
|
|
3090
4884
|
const constraintFrames = !shouldPin
|
|
3091
4885
|
? []
|
|
3092
4886
|
: waypointMode
|
|
@@ -3118,7 +4912,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3118
4912
|
look,
|
|
3119
4913
|
fovDeg,
|
|
3120
4914
|
slate: slateLine(shot),
|
|
3121
|
-
rigName:
|
|
4915
|
+
rigName: rigModel,
|
|
3122
4916
|
root: captureArdyRoot(rig),
|
|
3123
4917
|
}),
|
|
3124
4918
|
};
|
|
@@ -3132,95 +4926,243 @@ globalThis.playMode = centerTab === "play";
|
|
|
3132
4926
|
// always the origin; scene placement and the total scene->clip rotation
|
|
3133
4927
|
// (actor yaw plus the path-alignment fold) are restored only at
|
|
3134
4928
|
// playback, without constraining any later generated root frame.
|
|
3135
|
-
const rootRotationDeg = alignedRoot ? alignedRoot.rotationDeg :
|
|
4929
|
+
const rootRotationDeg = alignedRoot ? alignedRoot.rotationDeg : activeChar.rot;
|
|
4930
|
+
const body = { prompt, duration, posePin: shouldPin };
|
|
4931
|
+
// The bridge sees only wire frames; the timeline frames of the same
|
|
4932
|
+
// keys are kept beside the payload so the commit can mark the markers
|
|
4933
|
+
// the user actually authored.
|
|
4934
|
+
let committedEditKeys = [];
|
|
4935
|
+
if (shouldPin && !hasBlockEdits) body.poses = toArdyFrameEntries(poses);
|
|
4936
|
+
if (ardySeed !== "") body.seed = Number(ardySeed);
|
|
4937
|
+
if (waypointMode) {
|
|
4938
|
+
body.waypoints = ardyWaypoints;
|
|
4939
|
+
// A root path and a prompt schedule now travel TOGETHER: the
|
|
4940
|
+
// sequence generator threads the Root2D constraint set through
|
|
4941
|
+
// its chained calls (the interactive demo's pattern), so
|
|
4942
|
+
// neither authored surface is silently dropped any more.
|
|
4943
|
+
if (hasPromptSchedule && !hasBlockEdits) body.segments = toArdySegments(segments);
|
|
4944
|
+
// Looser pin grip than ARDY's 0.04 default: authored paths are
|
|
4945
|
+
// sparse and human-laid, so the postprocess gets 8 cm of room to
|
|
4946
|
+
// trade pin exactness for less foot skate.
|
|
4947
|
+
body.rootMargin = 0.08;
|
|
4948
|
+
// A path asks the model to CHANGE course at authored frames, so a
|
|
4949
|
+
// shorter 4 s history reacts faster to the pins than the default
|
|
4950
|
+
// full-window lookback (which favors continuing whatever came before).
|
|
4951
|
+
// A bridge-side frame count, so it is 4 s counted on the WIRE clock.
|
|
4952
|
+
body.historyFrames = 4 * ARDY_FPS;
|
|
4953
|
+
} else if (hasBlockEdits) {
|
|
4954
|
+
if (!motion?.url) {
|
|
4955
|
+
setToast(ko("The current motion has no bridge source; generate the prompt blocks once before regenerating IK edits", "현재 모션에 브리지 원본이 없어요. 프롬프트 블록을 한 번 생성한 뒤 IK 보정을 다시 생성하세요"));
|
|
4956
|
+
return;
|
|
4957
|
+
}
|
|
4958
|
+
const startFrame = Math.min(...editedSegments.map((segment) => segment.startFrame));
|
|
4959
|
+
const endFrame = Math.max(...editedSegments.map((segment) => segment.endFrame));
|
|
4960
|
+
const posesByFrame = new Map(poses.map((entry) => [entry.frame, entry.pose]));
|
|
4961
|
+
// Edits address the bridge-side source npz, so their frames drop
|
|
4962
|
+
// onto the bridge clock here; the timeline frame rides along only
|
|
4963
|
+
// for the app-side committed-keys bookkeeping (timeline markers).
|
|
4964
|
+
// contextBefore/contextAfter count frames of that source npz, so
|
|
4965
|
+
// they are already wire-clock numbers and do not convert.
|
|
4966
|
+
const editEntries = [];
|
|
4967
|
+
for (const timelineFrame of constraintFrames) {
|
|
4968
|
+
const frame = toArdyFrame(timelineFrame);
|
|
4969
|
+
if (editEntries.length && frame <= editEntries[editEntries.length - 1].frame) continue;
|
|
4970
|
+
editEntries.push({
|
|
4971
|
+
frame,
|
|
4972
|
+
timelineFrame,
|
|
4973
|
+
tracks: [...(ikStateRef.current.keys.get(timelineFrame)?.keys() || [])],
|
|
4974
|
+
pose: posesByFrame.get(timelineFrame),
|
|
4975
|
+
});
|
|
4976
|
+
}
|
|
4977
|
+
committedEditKeys = editEntries.map(({ timelineFrame, tracks }) => ({ frame: timelineFrame, tracks }));
|
|
4978
|
+
body.motionEdit = {
|
|
4979
|
+
sourceMotion: motion.url,
|
|
4980
|
+
startFrame: toArdyFrame(startFrame),
|
|
4981
|
+
endFrame: toArdyFrame(endFrame),
|
|
4982
|
+
contextBefore: 40,
|
|
4983
|
+
contextAfter: 20,
|
|
4984
|
+
edits: editEntries.map(({ frame, tracks, pose }) => ({ frame, tracks, pose })),
|
|
4985
|
+
};
|
|
4986
|
+
} else if (hasPromptSchedule) body.segments = toArdySegments(segments);
|
|
4987
|
+
// The request is fully packaged HERE, against the active character's
|
|
4988
|
+
// live layer — the queue only needs the frozen payload. Results are
|
|
4989
|
+
// delivered to THIS character even if the selection moves on while
|
|
4990
|
+
// the box is still working.
|
|
4991
|
+
enqueueMotionJob({
|
|
4992
|
+
charId: activeChar.id,
|
|
4993
|
+
charIndex: activeCharIndex,
|
|
4994
|
+
prompt,
|
|
4995
|
+
body,
|
|
4996
|
+
hasBlockEdits,
|
|
4997
|
+
committedEditKeys,
|
|
4998
|
+
rootRotationDeg,
|
|
4999
|
+
anchor: { x: activeChar.x, z: activeChar.z },
|
|
5000
|
+
ikState: hasBlockEdits ? ikStateRef.current : null,
|
|
5001
|
+
});
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
/* ------------------------- motion job queue ---------------------------
|
|
5005
|
+
* One box, one job at a time: Generate never blocks, it enqueues. The
|
|
5006
|
+
* payload is frozen at enqueue time; completion delivers the clip to the
|
|
5007
|
+
* REQUESTING character's layer, not whoever happens to be selected then. */
|
|
5008
|
+
const [genQueue, setGenQueue] = useState([]);
|
|
5009
|
+
const genRunningRef = useRef(false);
|
|
5010
|
+
const genJobSeq = useRef(0);
|
|
5011
|
+
function enqueueMotionJob(spec) {
|
|
5012
|
+
const id = `gen-${++genJobSeq.current}`;
|
|
5013
|
+
setGenQueue((queue) => [...queue, { id, status: "queued", ...spec }]);
|
|
5014
|
+
setToast(isKo ? `인물 ${spec.charIndex + 1} 모션 생성을 대기열에 넣었어요` : `Queued motion generation for Subject ${spec.charIndex + 1}`);
|
|
5015
|
+
}
|
|
5016
|
+
useEffect(() => {
|
|
5017
|
+
if (genRunningRef.current) return;
|
|
5018
|
+
const next = genQueue.find((job) => job.status === "queued");
|
|
5019
|
+
if (!next) return;
|
|
5020
|
+
genRunningRef.current = true;
|
|
5021
|
+
setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "running" } : job)));
|
|
5022
|
+
(async () => {
|
|
5023
|
+
try {
|
|
5024
|
+
await executeMotionJob(next);
|
|
5025
|
+
setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "done" } : job)));
|
|
5026
|
+
} catch (err) {
|
|
5027
|
+
const message = err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err);
|
|
5028
|
+
setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "error", error: message } : job)));
|
|
5029
|
+
} finally {
|
|
5030
|
+
genRunningRef.current = false;
|
|
5031
|
+
}
|
|
5032
|
+
})();
|
|
5033
|
+
}, [genQueue]);
|
|
5034
|
+
|
|
5035
|
+
async function executeMotionJob(job) {
|
|
3136
5036
|
const controller = new AbortController();
|
|
3137
5037
|
ardyAbortRef.current = controller;
|
|
3138
5038
|
setArdyRunning(true);
|
|
3139
5039
|
reportArdyStatus(ko("connecting…", "연결 중…"));
|
|
3140
5040
|
setArdyReport(null);
|
|
3141
5041
|
setArdyOutcome(null);
|
|
5042
|
+
let editCommitReport = null;
|
|
3142
5043
|
try {
|
|
3143
|
-
const body = { prompt, duration, posePin: shouldPin };
|
|
3144
|
-
if (shouldPin && !hasBlockEdits) body.poses = poses;
|
|
3145
|
-
if (ardySeed !== "") body.seed = Number(ardySeed);
|
|
3146
|
-
if (waypointMode) {
|
|
3147
|
-
body.waypoints = ardyWaypoints;
|
|
3148
|
-
// A root path and a prompt schedule now travel TOGETHER: the
|
|
3149
|
-
// sequence generator threads the Root2D constraint set through
|
|
3150
|
-
// its chained calls (the interactive demo's pattern), so
|
|
3151
|
-
// neither authored surface is silently dropped any more.
|
|
3152
|
-
if (hasPromptSchedule && !hasBlockEdits) body.segments = segments;
|
|
3153
|
-
} else if (hasBlockEdits) {
|
|
3154
|
-
if (!motion?.url) {
|
|
3155
|
-
throw new Error(ko("The current motion has no bridge source; generate the prompt blocks once before regenerating IK edits", "현재 모션에 브리지 원본이 없어요. 프롬프트 블록을 한 번 생성한 뒤 IK 보정을 다시 생성하세요"));
|
|
3156
|
-
}
|
|
3157
|
-
const startFrame = Math.min(...editedSegments.map((segment) => segment.startFrame));
|
|
3158
|
-
const endFrame = Math.max(...editedSegments.map((segment) => segment.endFrame));
|
|
3159
|
-
const posesByFrame = new Map(poses.map((entry) => [entry.frame, entry.pose]));
|
|
3160
|
-
body.motionEdit = {
|
|
3161
|
-
sourceMotion: motion.url,
|
|
3162
|
-
startFrame,
|
|
3163
|
-
endFrame,
|
|
3164
|
-
contextBefore: 40,
|
|
3165
|
-
contextAfter: 20,
|
|
3166
|
-
edits: constraintFrames.map((frame) => ({
|
|
3167
|
-
frame,
|
|
3168
|
-
tracks: [...(ikStateRef.current.keys.get(frame)?.keys() || [])],
|
|
3169
|
-
pose: posesByFrame.get(frame),
|
|
3170
|
-
})),
|
|
3171
|
-
};
|
|
3172
|
-
} else if (hasPromptSchedule) body.segments = segments;
|
|
3173
|
-
let editCommitReport = null;
|
|
3174
5044
|
const done = await ardyGenerate(
|
|
3175
|
-
body,
|
|
5045
|
+
job.body,
|
|
3176
5046
|
(event) => {
|
|
3177
5047
|
if (event.event === "status") reportArdyStatus(event.message);
|
|
3178
5048
|
else if (event.event === "report") {
|
|
3179
5049
|
setArdyReport(event.report);
|
|
3180
|
-
if (hasBlockEdits) editCommitReport = event.report;
|
|
5050
|
+
if (job.hasBlockEdits) editCommitReport = event.report;
|
|
3181
5051
|
}
|
|
3182
5052
|
},
|
|
3183
5053
|
{ signal: controller.signal },
|
|
3184
5054
|
);
|
|
3185
5055
|
if (
|
|
3186
|
-
hasBlockEdits &&
|
|
5056
|
+
job.hasBlockEdits &&
|
|
3187
5057
|
(
|
|
3188
5058
|
editCommitReport?.commit_verified !== true ||
|
|
3189
|
-
!body.motionEdit.edits.every((entry) =>
|
|
5059
|
+
!job.body.motionEdit.edits.every((entry) =>
|
|
3190
5060
|
editCommitReport.committed_keys?.includes(entry.frame)
|
|
3191
5061
|
)
|
|
3192
5062
|
)
|
|
3193
5063
|
) {
|
|
3194
5064
|
throw new Error(ko("ARDY returned motion without verified authored IK keys", "ARDY가 검증된 수동 IK 키 없이 모션을 반환했어요"));
|
|
3195
5065
|
}
|
|
3196
|
-
setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: rootRotationDeg });
|
|
5066
|
+
setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: job.rootRotationDeg });
|
|
3197
5067
|
// Fetch and decode the real npz right away; decode errors are shown
|
|
3198
|
-
// in the card, playback is never faked.
|
|
3199
|
-
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
]);
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
5068
|
+
// in the card, playback is never faked. The clip lands on the
|
|
5069
|
+
// REQUESTING character, not whoever is selected now.
|
|
5070
|
+
if (done.motionUrl) await deliverMotion(job, done.motionUrl);
|
|
5071
|
+
if (job.hasBlockEdits && job.ikState) {
|
|
5072
|
+
// Timeline frames, not the wire frames in body.motionEdit.edits:
|
|
5073
|
+
// these light up the IK markers on the production clock.
|
|
5074
|
+
setCommittedIkEdits((current) => [...current, ...job.committedEditKeys]);
|
|
5075
|
+
job.ikState.keys.clear();
|
|
5076
|
+
job.ikState.tracked.clear();
|
|
5077
|
+
job.ikState.plants.clear();
|
|
3208
5078
|
setIkTick((value) => value + 1);
|
|
3209
5079
|
}
|
|
3210
|
-
setToast(
|
|
5080
|
+
setToast(isKo ? `인물 ${job.charIndex + 1} ARDY 모션 생성됨` : `ARDY motion generated for Subject ${job.charIndex + 1}`);
|
|
3211
5081
|
} catch (err) {
|
|
3212
|
-
// Cancellation surfaces as an AbortError; everything else is the
|
|
3213
|
-
// bridge or the generator explaining itself.
|
|
3214
5082
|
setArdyOutcome({
|
|
3215
5083
|
ok: false,
|
|
3216
5084
|
message: err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err),
|
|
3217
5085
|
});
|
|
5086
|
+
throw err;
|
|
3218
5087
|
} finally {
|
|
3219
5088
|
setArdyRunning(false);
|
|
3220
5089
|
ardyAbortRef.current = null;
|
|
3221
5090
|
}
|
|
3222
5091
|
}
|
|
3223
5092
|
|
|
5093
|
+
/** Hand a finished clip to the layer that asked for it: the buffer when
|
|
5094
|
+
* the requester is still active, its stored session motion otherwise. A
|
|
5095
|
+
* lightweight motionRef is persisted with the entry either way, so the
|
|
5096
|
+
* clip can be re-fetched after a reload. */
|
|
5097
|
+
async function deliverMotion(job, motionUrl) {
|
|
5098
|
+
const motionRef = {
|
|
5099
|
+
url: motionUrl,
|
|
5100
|
+
prompt: job.prompt,
|
|
5101
|
+
rotationDeg: job.rootRotationDeg,
|
|
5102
|
+
anchorX: job.anchor.x,
|
|
5103
|
+
anchorZ: job.anchor.z,
|
|
5104
|
+
};
|
|
5105
|
+
setCharacters((list) => list.map((entry) => entry.id === job.charId ? { ...entry, motionRef } : entry));
|
|
5106
|
+
if (job.charId === loadedLayerCharRef.current) {
|
|
5107
|
+
await loadMotion(motionUrl, job.prompt, job.rootRotationDeg);
|
|
5108
|
+
return;
|
|
5109
|
+
}
|
|
5110
|
+
// Inbound boundary for a clip delivered to a non-active layer.
|
|
5111
|
+
const decoded = retimeMotion(await loadMotionFromUrl(motionUrl), TIMELINE_FPS);
|
|
5112
|
+
const clip = {
|
|
5113
|
+
...decoded,
|
|
5114
|
+
url: motionUrl,
|
|
5115
|
+
prompt: job.prompt,
|
|
5116
|
+
anchorX: job.anchor.x,
|
|
5117
|
+
anchorZ: job.anchor.z,
|
|
5118
|
+
anchorFrame: 0,
|
|
5119
|
+
rotationDeg: job.rootRotationDeg,
|
|
5120
|
+
};
|
|
5121
|
+
// Same stature rule as loadMotion, on the layer that asked for the clip.
|
|
5122
|
+
const scale = characterScaleFor(decoded);
|
|
5123
|
+
motionFullRef.current.set(job.charId, clip);
|
|
5124
|
+
setCharacters((list) => list.map((entry) => entry.id === job.charId
|
|
5125
|
+
? { ...entry, scale, sessionMotion: clip }
|
|
5126
|
+
: entry));
|
|
5127
|
+
}
|
|
5128
|
+
|
|
5129
|
+
/** After a scene (re)load, re-fetch every persisted clip reference and
|
|
5130
|
+
* rebuild the session motions. The bridge may be gone — failures just
|
|
5131
|
+
* leave the character posed, never an error the user must act on. */
|
|
5132
|
+
function restoreMotionRefs(list) {
|
|
5133
|
+
for (const entry of list) {
|
|
5134
|
+
if (!entry.motionRef?.url) continue;
|
|
5135
|
+
// Inbound boundary: a re-fetched clip is retimed exactly like a
|
|
5136
|
+
// freshly generated one, so a reload cannot resurrect 20 fps frames.
|
|
5137
|
+
loadMotionFromUrl(entry.motionRef.url).then((raw) => {
|
|
5138
|
+
const decoded = retimeMotion(raw, TIMELINE_FPS);
|
|
5139
|
+
const clip = {
|
|
5140
|
+
...decoded,
|
|
5141
|
+
url: entry.motionRef.url,
|
|
5142
|
+
prompt: entry.motionRef.prompt,
|
|
5143
|
+
anchorX: entry.motionRef.anchorX,
|
|
5144
|
+
anchorZ: entry.motionRef.anchorZ,
|
|
5145
|
+
anchorFrame: 0,
|
|
5146
|
+
rotationDeg: entry.motionRef.rotationDeg,
|
|
5147
|
+
};
|
|
5148
|
+
motionFullRef.current.set(entry.id, clip);
|
|
5149
|
+
setCharacters((current) => current.map((item) => item.id === entry.id
|
|
5150
|
+
// The stature rides inside the npz, so a restored take
|
|
5151
|
+
// re-applies it; the saved entry scale is only the fallback
|
|
5152
|
+
// for a take whose npz never stored one.
|
|
5153
|
+
? { ...item, scale: characterScaleFor(decoded, item.scale ?? 1), sessionMotion: clip }
|
|
5154
|
+
: item));
|
|
5155
|
+
// The buffer character's clip goes straight into the editing
|
|
5156
|
+
// buffer too, so its motion survives the reload seamlessly.
|
|
5157
|
+
if (entry.id === loadedLayerCharRef.current) {
|
|
5158
|
+
setMotion(clip);
|
|
5159
|
+
setTlFrameCount((count) => Math.max(count, decoded.frames));
|
|
5160
|
+
setTlFps(decoded.fps);
|
|
5161
|
+
}
|
|
5162
|
+
}).catch(() => {});
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
|
|
3224
5166
|
function cancelArdy() {
|
|
3225
5167
|
ardyAbortRef.current?.abort();
|
|
3226
5168
|
}
|
|
@@ -3240,6 +5182,26 @@ globalThis.playMode = centerTab === "play";
|
|
|
3240
5182
|
Cozy <span>Clay</span>
|
|
3241
5183
|
</span>
|
|
3242
5184
|
</div>
|
|
5185
|
+
<div className="project-menu-wrap">
|
|
5186
|
+
<button
|
|
5187
|
+
type="button"
|
|
5188
|
+
className="project-menu-trigger"
|
|
5189
|
+
aria-expanded={projectMenuOpen}
|
|
5190
|
+
onClick={() => setProjectMenuOpen((open) => !open)}
|
|
5191
|
+
>
|
|
5192
|
+
{projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
|
|
5193
|
+
{projectName ?? ko("Untitled Project", "제목 없는 프로젝트")}
|
|
5194
|
+
<span className="caret">▾</span>
|
|
5195
|
+
</button>
|
|
5196
|
+
{projectMenuOpen && (
|
|
5197
|
+
<div className="project-menu" role="menu" onClick={() => setProjectMenuOpen(false)}>
|
|
5198
|
+
<button type="button" role="menuitem" onClick={newProject}>{ko("New Project", "새 프로젝트")}</button>
|
|
5199
|
+
<button type="button" role="menuitem" onClick={() => setProjectBrowserOpen(true)}>{ko("Open Project…", "프로젝트 열기…")}</button>
|
|
5200
|
+
<button type="button" role="menuitem" onClick={() => saveProject(false)}>{ko("Save Project", "프로젝트 저장")}</button>
|
|
5201
|
+
<button type="button" role="menuitem" onClick={() => saveProject(true)}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
|
|
5202
|
+
</div>
|
|
5203
|
+
)}
|
|
5204
|
+
</div>
|
|
3243
5205
|
<div className="topbar-actions">
|
|
3244
5206
|
<LocaleToggle />
|
|
3245
5207
|
</div>
|
|
@@ -3248,9 +5210,18 @@ globalThis.playMode = centerTab === "play";
|
|
|
3248
5210
|
<div className="main" style={workspaceStyle}>
|
|
3249
5211
|
<div className="workspace">
|
|
3250
5212
|
<aside className="panel hierarchy-left" aria-label={ko("Hierarchy", "계층")}>
|
|
5213
|
+
{/* Project > Scene: the project is the document root, scenes live
|
|
5214
|
+
inside it — the picker sits at the top of the hierarchy column. */}
|
|
5215
|
+
<div className="hierarchy-project" data-dirty={projectDirty || undefined}>
|
|
5216
|
+
<span className="hierarchy-project-label">{ko("Project", "프로젝트")}</span>
|
|
5217
|
+
<strong>{projectName ?? ko("Untitled", "제목 없음")}</strong>
|
|
5218
|
+
{projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
|
|
5219
|
+
<button type="button" onClick={() => setProjectBrowserOpen(true)}>{ko("Projects…", "프로젝트…")}</button>
|
|
5220
|
+
</div>
|
|
3251
5221
|
<HierarchyPanel
|
|
3252
5222
|
selectedId={selectedHierarchyId}
|
|
3253
5223
|
onSelect={selectHierarchy}
|
|
5224
|
+
characters={characters}
|
|
3254
5225
|
showB={showB}
|
|
3255
5226
|
motionFrames={motion?.frames ?? 0}
|
|
3256
5227
|
ikFrames={ikFrames.length}
|
|
@@ -3269,6 +5240,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3269
5240
|
onDuplicateObject={duplicateSelectedSceneObject}
|
|
3270
5241
|
onDeleteObject={deleteSceneObject}
|
|
3271
5242
|
onFrameObject={frameSelection}
|
|
5243
|
+
propsDrop={propsDrop}
|
|
3272
5244
|
/>
|
|
3273
5245
|
</aside>
|
|
3274
5246
|
<div
|
|
@@ -3277,7 +5249,8 @@ globalThis.playMode = centerTab === "play";
|
|
|
3277
5249
|
aria-label={ko("Resize hierarchy panel", "계층 패널 크기 조절")}
|
|
3278
5250
|
onPointerDown={(event) => beginWorkspaceResize("hierarchy", event)}
|
|
3279
5251
|
/>
|
|
3280
|
-
<div className="viewport">
|
|
5252
|
+
<div className="viewport" data-drop={viewportDrop.over ? "over" : undefined} {...viewportDrop.handlers}>
|
|
5253
|
+
<div className="viewport-titlebar">
|
|
3281
5254
|
<div className="pane-tabs" role="tablist" aria-label={ko("Center view", "가운데 보기")}>
|
|
3282
5255
|
<button
|
|
3283
5256
|
type="button"
|
|
@@ -3298,7 +5271,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3298
5271
|
{ko("PlayView", "재생 보기")}
|
|
3299
5272
|
</button>
|
|
3300
5273
|
</div>
|
|
3301
|
-
{centerTab === "scene"
|
|
5274
|
+
{centerTab === "scene" ? (
|
|
3302
5275
|
<div className="editor-toolbar scene-tools" aria-label={ko("Scene tools", "장면 도구")}>
|
|
3303
5276
|
<div className="tool-switch" role="group" aria-label={ko("Gizmo tool", "기즈모 도구")}>
|
|
3304
5277
|
<button
|
|
@@ -3341,8 +5314,87 @@ globalThis.playMode = centerTab === "play";
|
|
|
3341
5314
|
>
|
|
3342
5315
|
{ko("Snap", "스냅")}
|
|
3343
5316
|
</button>
|
|
5317
|
+
<span className="viewport-toolbar-separator settings-separator" aria-hidden="true" />
|
|
5318
|
+
<label className="viewport-toolbar-field shot-field">
|
|
5319
|
+
<span>{ko("Shot", "샷")}</span>
|
|
5320
|
+
<select
|
|
5321
|
+
aria-label={ko("Shot preset", "샷 프리셋")}
|
|
5322
|
+
value={preset}
|
|
5323
|
+
onChange={(event) => applyPreset(event.target.value)}
|
|
5324
|
+
>
|
|
5325
|
+
{Object.entries(PRESETS).map(([key, value]) => (
|
|
5326
|
+
<option key={key} value={key}>{value.label}</option>
|
|
5327
|
+
))}
|
|
5328
|
+
</select>
|
|
5329
|
+
</label>
|
|
5330
|
+
<label className="viewport-toolbar-field ratio-field">
|
|
5331
|
+
<span>{ko("Ratio", "비율")}</span>
|
|
5332
|
+
<select
|
|
5333
|
+
aria-label={ko("Output aspect ratio", "출력 화면 비율")}
|
|
5334
|
+
value={shotAspectKey}
|
|
5335
|
+
onChange={(event) => setShotAspectKey(event.target.value)}
|
|
5336
|
+
>
|
|
5337
|
+
{Object.values(SHOT_ASPECT_PRESETS).map((value) => (
|
|
5338
|
+
<option key={value.label} value={value.label}>{value.label}</option>
|
|
5339
|
+
))}
|
|
5340
|
+
</select>
|
|
5341
|
+
</label>
|
|
5342
|
+
<label className="viewport-fov-control">
|
|
5343
|
+
<span>FOV</span>
|
|
5344
|
+
<input
|
|
5345
|
+
type="range"
|
|
5346
|
+
min="14"
|
|
5347
|
+
max="90"
|
|
5348
|
+
step="1"
|
|
5349
|
+
value={fovDeg}
|
|
5350
|
+
onChange={(event) => setFovDeg(Number(event.target.value))}
|
|
5351
|
+
/>
|
|
5352
|
+
<output>{Math.round(fovDeg)}°</output>
|
|
5353
|
+
<small>{shot.focalMm}mm</small>
|
|
5354
|
+
</label>
|
|
5355
|
+
<span className="viewport-toolbar-spacer" />
|
|
5356
|
+
<button
|
|
5357
|
+
type="button"
|
|
5358
|
+
title={ko("Recenter on subject", "피사체 다시 맞추기")}
|
|
5359
|
+
aria-label={ko("Recenter on subject", "피사체 다시 맞추기")}
|
|
5360
|
+
onClick={() => setNonce((n) => n + 1)}
|
|
5361
|
+
>
|
|
5362
|
+
◎
|
|
5363
|
+
</button>
|
|
5364
|
+
<button
|
|
5365
|
+
type="button"
|
|
5366
|
+
aria-pressed={!workspaceLayout.insetCollapsed}
|
|
5367
|
+
onClick={() => {
|
|
5368
|
+
if (workspaceLayout.insetCollapsed) expandInset();
|
|
5369
|
+
else setWorkspaceLayout((current) => ({ ...current, insetCollapsed: true }));
|
|
5370
|
+
}}
|
|
5371
|
+
>
|
|
5372
|
+
{ko("Top", "탑")} {workspaceLayout.insetCollapsed ? "▸" : "▾"}
|
|
5373
|
+
</button>
|
|
5374
|
+
</div>
|
|
5375
|
+
) : (
|
|
5376
|
+
<div className="editor-toolbar play-tools" aria-label={ko("PlayView tools", "재생 보기 도구")}>
|
|
5377
|
+
<span className="viewport-readout">{shotOutput.label}</span>
|
|
5378
|
+
<span className="viewport-readout">FOV {Math.round(fovDeg)}° · {shot.focalMm}mm</span>
|
|
5379
|
+
<span className="viewport-toolbar-spacer" />
|
|
5380
|
+
<button type="button" onClick={() => stepFrame(-1)} aria-label={ko("Previous frame", "이전 프레임")}>◀</button>
|
|
5381
|
+
<button type="button" onClick={() => setTlPlaying((value) => !value)}>
|
|
5382
|
+
{tlPlaying ? "Ⅱ" : "▶"}
|
|
5383
|
+
</button>
|
|
5384
|
+
<button type="button" onClick={() => stepFrame(1)} aria-label={ko("Next frame", "다음 프레임")}>▶│</button>
|
|
5385
|
+
<span className="viewport-readout">1.00×</span>
|
|
5386
|
+
<span className="viewport-toolbar-separator" aria-hidden="true" />
|
|
5387
|
+
<button
|
|
5388
|
+
type="button"
|
|
5389
|
+
className={recState === "recording" ? "recording" : ""}
|
|
5390
|
+
disabled={recState !== "recording" && !hasCameraKeys && !motion}
|
|
5391
|
+
onClick={toggleShotRecording}
|
|
5392
|
+
>
|
|
5393
|
+
{recState === "recording" ? ko("■ Stop", "■ 정지") : ko("● Record", "● 녹화")}
|
|
5394
|
+
</button>
|
|
3344
5395
|
</div>
|
|
3345
5396
|
)}
|
|
5397
|
+
</div>
|
|
3346
5398
|
|
|
3347
5399
|
<div className="stage" id="stage" ref={stageRef} data-render-loop={renderActive ? "always" : "demand"}>
|
|
3348
5400
|
<Canvas frameloop={renderActive ? "always" : "demand"} dpr={[1, 2]} gl={{ preserveDrawingBuffer: true, antialias: true }}>
|
|
@@ -3358,6 +5410,14 @@ globalThis.playMode = centerTab === "play";
|
|
|
3358
5410
|
planZoom={workspaceLayout.planZoom}
|
|
3359
5411
|
/>
|
|
3360
5412
|
<color attach="background" args={["#eef4f3"]} />
|
|
5413
|
+
{/* The open stage runs 500 m; without a falloff the whole deck
|
|
5414
|
+
reads at once and the horizon sits a kilometre away. Blender's
|
|
5415
|
+
viewport answer is a clip distance that lets the neutral void
|
|
5416
|
+
show through; the fog below is the seamless version of the
|
|
5417
|
+
same idea — it fades the floor INTO the background colour, so
|
|
5418
|
+
past ~120 m the deck simply ceases to exist with no horizon
|
|
5419
|
+
line, no clip edge and no tone break. */}
|
|
5420
|
+
<fog attach="fog" args={["#eef4f3", 18, 54]} />
|
|
3361
5421
|
<StageLights />
|
|
3362
5422
|
<Room />
|
|
3363
5423
|
<SetProps objects={sceneObjects} selectedId={selectedSceneObjectId} />
|
|
@@ -3390,24 +5450,47 @@ globalThis.playMode = centerTab === "play";
|
|
|
3390
5450
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
3391
5451
|
/>
|
|
3392
5452
|
|
|
3393
|
-
|
|
3394
|
-
url={CHARACTER_MODEL_URL}
|
|
3395
|
-
position={motion ? [motion.anchorX, 0, motion.anchorZ] : [charA.x, 0, charA.z]}
|
|
3396
|
-
rot={motion ? motion.rotationDeg : charA.rot}
|
|
3397
|
-
tint={CLAY}
|
|
3398
|
-
pose={motion ? null : poseA}
|
|
3399
|
-
onRig={setRigA}
|
|
3400
|
-
pickId="A"
|
|
3401
|
-
/>
|
|
3402
|
-
{showB && (
|
|
5453
|
+
{characterViews.map((view) => (
|
|
3403
5454
|
<Character
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
5455
|
+
key={view.id}
|
|
5456
|
+
url={view.url}
|
|
5457
|
+
position={view.position}
|
|
5458
|
+
rot={view.rot}
|
|
5459
|
+
tint={view.tint}
|
|
5460
|
+
pose={view.pose}
|
|
5461
|
+
scale={view.scale}
|
|
5462
|
+
onRig={view.onRig}
|
|
5463
|
+
pickId={view.pickId}
|
|
5464
|
+
/>
|
|
5465
|
+
))}
|
|
5466
|
+
|
|
5467
|
+
{/* Selection marker: XYZ tripod + ring on the picked cast
|
|
5468
|
+
member; the X/Z arrows drag it across the deck. */}
|
|
5469
|
+
{gizmoView && !playMode && !posing && !ikMode && (
|
|
5470
|
+
<ObjectGizmo
|
|
5471
|
+
pickOnly
|
|
5472
|
+
object={characterGizmoObject}
|
|
5473
|
+
objects={characterGizmoObject ? [characterGizmoObject] : []}
|
|
5474
|
+
mode={gizmoMode}
|
|
5475
|
+
snap={snapEnabled}
|
|
5476
|
+
enabled={!planIsMain && !posing && !ikMode && !playMode && !!characterGizmoObject}
|
|
5477
|
+
paneRef={mainPaneRef}
|
|
5478
|
+
camRef={shotCamRef}
|
|
5479
|
+
shotAspect={shotOutput.aspect}
|
|
5480
|
+
onChange={(id, patch) => moveCharacter(activeChar.id, () => {
|
|
5481
|
+
const next = {};
|
|
5482
|
+
if (patch.x !== undefined) next.x = THREE.MathUtils.clamp(patch.x, -4, 4);
|
|
5483
|
+
if (patch.y !== undefined) next.y = THREE.MathUtils.clamp(patch.y, 0, 4);
|
|
5484
|
+
if (patch.z !== undefined) next.z = THREE.MathUtils.clamp(patch.z, -4, 4);
|
|
5485
|
+
// a body only yaws — the X/Z rings and the screen ring's
|
|
5486
|
+
// other channels have nowhere to go on a character
|
|
5487
|
+
if (patch.rotY !== undefined) next.rot = patch.rotY;
|
|
5488
|
+
// one stature knob: any scale axis reads as uniform
|
|
5489
|
+
const s = patch.scaleX ?? patch.scaleY ?? patch.scaleZ;
|
|
5490
|
+
if (s !== undefined) next.scale = THREE.MathUtils.clamp(s, 0.2, 3);
|
|
5491
|
+
return next;
|
|
5492
|
+
})}
|
|
5493
|
+
onDragStart={recordCharacterUndo}
|
|
3411
5494
|
/>
|
|
3412
5495
|
)}
|
|
3413
5496
|
|
|
@@ -3483,7 +5566,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3483
5566
|
onCameraChange={commitManualCameraFraming}
|
|
3484
5567
|
/>
|
|
3485
5568
|
<PoseHandles
|
|
3486
|
-
root={
|
|
5569
|
+
root={posedRig()}
|
|
3487
5570
|
enabled={!!posing && !planIsMain && !playMode}
|
|
3488
5571
|
onChange={() => setPoseTick((n) => n + 1)}
|
|
3489
5572
|
/>
|
|
@@ -3503,11 +5586,10 @@ globalThis.playMode = centerTab === "play";
|
|
|
3503
5586
|
shotCamRef={shotCamRef}
|
|
3504
5587
|
look={look}
|
|
3505
5588
|
fovDeg={fovDeg}
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
showB={showB}
|
|
5589
|
+
characters={characters}
|
|
5590
|
+
onMoveCharacter={moveCharacter}
|
|
5591
|
+
onCharacterGestureStart={recordCharacterUndo}
|
|
5592
|
+
pathStart={activeChar}
|
|
3511
5593
|
waypoints={waypoints}
|
|
3512
5594
|
activeWaypointFrame={activeWaypointFrame}
|
|
3513
5595
|
onSelectWaypoint={(frame) => { setActiveWaypointFrame(frame); setTlFrame(Math.min(frame, tlFrameCount - 1)); setWaypointMode(true); }}
|
|
@@ -3518,7 +5600,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3518
5600
|
// issued by onObjectMoveStart cannot leak.
|
|
3519
5601
|
onSelectEntity={(id) => {
|
|
3520
5602
|
store.settle();
|
|
3521
|
-
setSelectedHierarchyId(id.startsWith("object:") ? id : id === "cam" ? "camera" : id
|
|
5603
|
+
setSelectedHierarchyId(id.startsWith("object:") ? id : id === "cam" ? "camera" : charKeyToHierarchyId(id));
|
|
3522
5604
|
}}
|
|
3523
5605
|
sceneObjects={sceneObjects}
|
|
3524
5606
|
selectedSceneObjectId={selectedSceneObjectId}
|
|
@@ -3549,12 +5631,13 @@ globalThis.playMode = centerTab === "play";
|
|
|
3549
5631
|
enabled={!planIsMain && !posing && !ikMode && !playMode}
|
|
3550
5632
|
paneRef={mainPaneRef}
|
|
3551
5633
|
camRef={shotCamRef}
|
|
5634
|
+
shotAspect={shotOutput.aspect}
|
|
3552
5635
|
onChange={changeSceneObject}
|
|
3553
5636
|
onDragStart={beginSceneTransaction}
|
|
3554
5637
|
onDragEnd={endSceneTransaction}
|
|
3555
5638
|
onSelect={(id) =>
|
|
3556
5639
|
selectHierarchy(
|
|
3557
|
-
id
|
|
5640
|
+
id?.startsWith("char:") ? charKeyToHierarchyId(id) : id ? `object:${id}` : "props",
|
|
3558
5641
|
)
|
|
3559
5642
|
}
|
|
3560
5643
|
onGroundClick={waypointMode && !planIsMain ? addFloorWaypoint : undefined}
|
|
@@ -3566,7 +5649,12 @@ globalThis.playMode = centerTab === "play";
|
|
|
3566
5649
|
{waypointMode && centerTab === "scene" && (
|
|
3567
5650
|
<ShotPathPreview waypoints={waypoints} start={charA} activeWaypointFrame={activeWaypointFrame} />
|
|
3568
5651
|
)}
|
|
3569
|
-
<CaptureRig
|
|
5652
|
+
<CaptureRig
|
|
5653
|
+
apiRef={captureRef}
|
|
5654
|
+
camRef={shotCamRef}
|
|
5655
|
+
width={shotOutput.width}
|
|
5656
|
+
height={shotOutput.height}
|
|
5657
|
+
/>
|
|
3570
5658
|
<DualRender
|
|
3571
5659
|
stageRef={stageRef}
|
|
3572
5660
|
mainRef={mainPaneRef}
|
|
@@ -3579,6 +5667,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3579
5667
|
playMode={playMode}
|
|
3580
5668
|
insetCollapsed={workspaceLayout.insetCollapsed}
|
|
3581
5669
|
planZoom={workspaceLayout.planZoom}
|
|
5670
|
+
shotAspect={shotOutput.aspect}
|
|
3582
5671
|
/>
|
|
3583
5672
|
</Canvas>
|
|
3584
5673
|
|
|
@@ -3587,7 +5676,10 @@ globalThis.playMode = centerTab === "play";
|
|
|
3587
5676
|
ref={insetPaneRef}
|
|
3588
5677
|
hidden={playMode}
|
|
3589
5678
|
className={"vp-pane vp-inset" + (planIsMain || ikMode ? " shot" : " plan") + (workspaceLayout.insetCollapsed ? " collapsed" : "")}
|
|
3590
|
-
style={
|
|
5679
|
+
style={{
|
|
5680
|
+
"--shot-aspect": shotOutput.aspect,
|
|
5681
|
+
...(insetPos ? { left: insetPos.x, top: insetPos.y, right: "auto" } : {}),
|
|
5682
|
+
}}
|
|
3591
5683
|
>
|
|
3592
5684
|
<span
|
|
3593
5685
|
className="vp-inset-tag"
|
|
@@ -3645,37 +5737,6 @@ globalThis.playMode = centerTab === "play";
|
|
|
3645
5737
|
</div>
|
|
3646
5738
|
)}
|
|
3647
5739
|
|
|
3648
|
-
{posing && (
|
|
3649
|
-
<PoseStudioPanel
|
|
3650
|
-
subject={posing === "B" ? 2 : 1}
|
|
3651
|
-
poses={allPoses}
|
|
3652
|
-
selectedId={studioPick}
|
|
3653
|
-
closing={posingClosing}
|
|
3654
|
-
motionActive={Boolean(motion)}
|
|
3655
|
-
onSelect={setStudioPick}
|
|
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
|
-
}
|
|
3667
|
-
}}
|
|
3668
|
-
onReset={() => {
|
|
3669
|
-
if (motion) clearMotion();
|
|
3670
|
-
setStudioPick(DEFAULT_POSE.id);
|
|
3671
|
-
setPosed(DEFAULT_POSE);
|
|
3672
|
-
setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
|
|
3673
|
-
}}
|
|
3674
|
-
onSave={savePose}
|
|
3675
|
-
onDelete={removePose}
|
|
3676
|
-
onClose={closeStudio}
|
|
3677
|
-
/>
|
|
3678
|
-
)}
|
|
3679
5740
|
</div>
|
|
3680
5741
|
</div>
|
|
3681
5742
|
|
|
@@ -3725,19 +5786,12 @@ globalThis.playMode = centerTab === "play";
|
|
|
3725
5786
|
</div>
|
|
3726
5787
|
)}
|
|
3727
5788
|
<div className="inspector-scroll">
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
{Object.entries(PRESETS).map(([key, p]) => (
|
|
3731
|
-
<button key={key} className={preset === key ? "active" : ""} onClick={() => applyPreset(key)}>
|
|
3732
|
-
{p.label}
|
|
3733
|
-
</button>
|
|
3734
|
-
))}
|
|
3735
|
-
</div>
|
|
3736
|
-
</Foldout>
|
|
5789
|
+
{/* Shot TYPE presets live in the viewport toolbar dropdown — not
|
|
5790
|
+
duplicated here. */}
|
|
3737
5791
|
|
|
3738
5792
|
{/* Camera animation is authored against the same playhead as motion,
|
|
3739
5793
|
so keep its controls beside the Motion tools as well as Shot setup. */}
|
|
3740
|
-
<Foldout hidden={!(sidebarTab === "shot" ||
|
|
5794
|
+
<Foldout hidden={!(sidebarTab === "shot" || (sidebarTab === "inspector" && selectedHierarchyId === "camera"))} title={ko("Camera", "카메라")}>
|
|
3741
5795
|
<Slider label={ko("Lens (FOV)", "렌즈 (FOV)")} min={14} max={90} step={1} value={fovDeg} unit="°" onChange={setFovDeg} />
|
|
3742
5796
|
<div className="readout">
|
|
3743
5797
|
<span title={ko("camera to subject", "카메라와 피사체 거리")}>{shot.distance.toFixed(2)} m</span>
|
|
@@ -3820,25 +5874,21 @@ globalThis.playMode = centerTab === "play";
|
|
|
3820
5874
|
</p>
|
|
3821
5875
|
</Foldout>
|
|
3822
5876
|
|
|
3823
|
-
<Foldout hidden={sidebarTab !== "inspector" || !
|
|
5877
|
+
<Foldout hidden={sidebarTab !== "inspector" || !(selectedHierarchyId === "characters" || selectedHierarchyId === "characterA" || selectedHierarchyId === "characterB" || selectedHierarchyId.startsWith("character:"))} title={showB ? ko("Subjects", "인물들") : ko("Subject", "인물")}>
|
|
3824
5878
|
<div className={"subjects-row" + (showB ? "" : " single")}>
|
|
3825
|
-
|
|
3826
|
-
label={ko("Subject 1", "인물 1")}
|
|
3827
|
-
value={charA}
|
|
3828
|
-
onChange={setCharA}
|
|
3829
|
-
onPose={() => openStudio("A")}
|
|
3830
|
-
posing={posing === "A"}
|
|
3831
|
-
/>
|
|
3832
|
-
{showB && (
|
|
5879
|
+
{characters.map((entry, index) => entry.hidden ? null : (
|
|
3833
5880
|
<SubjectBox
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
onPose={() => openStudio(
|
|
3839
|
-
posing={posing ===
|
|
5881
|
+
key={entry.id}
|
|
5882
|
+
label={ko(`Subject ${index + 1}`, `인물 ${index + 1}`)}
|
|
5883
|
+
value={entry}
|
|
5884
|
+
onChange={(next) => updateCharacterAt(index, next)}
|
|
5885
|
+
onPose={() => openStudio(entry.id)}
|
|
5886
|
+
posing={posing === entry.id}
|
|
5887
|
+
onRemove={index > 0 ? () => removeCharacter(entry.id) : undefined}
|
|
5888
|
+
color={entry.tint ?? defaultCharacterTint(entry, index)}
|
|
5889
|
+
onColorChange={(tint) => updateCharacterAt(index, { tint })}
|
|
3840
5890
|
/>
|
|
3841
|
-
)}
|
|
5891
|
+
))}
|
|
3842
5892
|
</div>
|
|
3843
5893
|
{!showB && (
|
|
3844
5894
|
<button type="button" className="add-subject" onClick={() => setShowB(true)}>
|
|
@@ -3848,7 +5898,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
3848
5898
|
)}
|
|
3849
5899
|
</Foldout>
|
|
3850
5900
|
|
|
3851
|
-
<Foldout hidden={sidebarTab !== "inspector" || !
|
|
5901
|
+
<Foldout hidden={sidebarTab !== "inspector" || !(selectedHierarchyId === "characters" || selectedHierarchyId === "characterA" || selectedHierarchyId === "characterB" || selectedHierarchyId.startsWith("character:"))} title={ko("Pose", "포즈")}>
|
|
3852
5902
|
<Field label={showB ? ko("Subject 1 pose", "인물 1 포즈") : ko("Pose", "포즈")}>
|
|
3853
5903
|
<Dropdown
|
|
3854
5904
|
ariaLabel={ko("Subject 1 pose", "인물 1 포즈")}
|
|
@@ -3898,16 +5948,15 @@ globalThis.playMode = centerTab === "play";
|
|
|
3898
5948
|
</label>
|
|
3899
5949
|
</div>
|
|
3900
5950
|
|
|
3901
|
-
{!hasCharSheet && (
|
|
3902
|
-
|
|
3903
|
-
<input
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
<input type="text" value={subject2} onChange={(e) => setSubject2(e.target.value)} />
|
|
5951
|
+
{!hasCharSheet && characters.map((entry, index) => entry.hidden ? null : (
|
|
5952
|
+
<Field key={entry.id} label={characters.filter((c) => !c.hidden).length > 1 ? ko(`Subject ${index + 1}`, `인물 ${index + 1}`) : ko("Subject", "인물")}>
|
|
5953
|
+
<input
|
|
5954
|
+
type="text"
|
|
5955
|
+
value={entry.subject ?? ""}
|
|
5956
|
+
onChange={(e) => updateCharacterAt(index, { subject: e.target.value })}
|
|
5957
|
+
/>
|
|
3909
5958
|
</Field>
|
|
3910
|
-
)}
|
|
5959
|
+
))}
|
|
3911
5960
|
{!hasEnvSheet && (
|
|
3912
5961
|
<Field label={ko("Environment", "환경")}>
|
|
3913
5962
|
<input type="text" value={environment} onChange={(e) => setEnvironment(e.target.value)} />
|
|
@@ -3947,69 +5996,217 @@ globalThis.playMode = centerTab === "play";
|
|
|
3947
5996
|
<input type="text" value={style} onChange={(e) => setStyle(e.target.value)} />
|
|
3948
5997
|
</Field>
|
|
3949
5998
|
|
|
3950
|
-
<button
|
|
3951
|
-
className="btn ghost full"
|
|
3952
|
-
title={ko("Export the posed character as an ARDY CozyClayPoseV1 JSON", "포즈가 잡힌 캐릭터를 ARDY CozyClayPoseV1 JSON으로 내보내기")}
|
|
3953
|
-
onClick={downloadArdyPose}
|
|
3954
|
-
>
|
|
3955
|
-
{ko("Export ARDY pose", "ARDY 포즈 내보내기")}
|
|
3956
|
-
</button>
|
|
3957
5999
|
<button className="btn primary full generate" onClick={generate}>
|
|
3958
|
-
{ko("Generate", "만들기")}
|
|
6000
|
+
{mode === "video" ? ko("Generate video", "영상 만들기") : ko("Generate image", "이미지 만들기")}
|
|
3959
6001
|
</button>
|
|
3960
6002
|
</Foldout>
|
|
6003
|
+
<Foldout hidden={sidebarTab !== "motion"} title={ko("Video capture", "영상 모캡")}>
|
|
6004
|
+
<div className="multimodel-card">
|
|
6005
|
+
<div className="multimodel-card-head">
|
|
6006
|
+
<div>
|
|
6007
|
+
<strong>{ko("Footage → motion", "영상 → 모션")}</strong>
|
|
6008
|
+
<span>{ko("Prepare a source inside the Motion workspace.", "모션 작업공간에서 입력 영상을 준비하세요.")}</span>
|
|
6009
|
+
</div>
|
|
6010
|
+
<span className={"multimodel-status " + multiModelStatus}>
|
|
6011
|
+
{multiModelStatus === "ready"
|
|
6012
|
+
? ko("READY", "준비됨")
|
|
6013
|
+
: multiModelStatus === "error"
|
|
6014
|
+
? ko("CHECK", "확인 필요")
|
|
6015
|
+
: multiModelStatus === "busy"
|
|
6016
|
+
? (multiModelStage === "fetching" ? ko("FETCHING", "받는 중") : ko("PROBING", "분석 중"))
|
|
6017
|
+
: ko("IDLE", "대기")}
|
|
6018
|
+
</span>
|
|
6019
|
+
</div>
|
|
6020
|
+
<div className="multimodel-input-block">
|
|
6021
|
+
<div className="multimodel-input-label">
|
|
6022
|
+
<strong>{ko("Local video file", "로컬 비디오 파일")}</strong>
|
|
6023
|
+
<span>{ko("Upload from this computer", "이 컴퓨터에서 업로드")}</span>
|
|
6024
|
+
</div>
|
|
6025
|
+
<div className="multimodel-source-row">
|
|
6026
|
+
<button type="button" className="btn ghost" onClick={() => multiModelFileRef.current?.click()}>
|
|
6027
|
+
{ko("Choose video", "영상 선택")}
|
|
6028
|
+
</button>
|
|
6029
|
+
<input ref={multiModelFileRef} className="multimodel-file-input" type="file" accept="video/*" onChange={chooseMultiModelFile} />
|
|
6030
|
+
<span className="multimodel-file-name">{multiModelSource?.kind === "file" ? multiModelSource.name : ko("No file selected", "파일이 선택되지 않음")}</span>
|
|
6031
|
+
</div>
|
|
6032
|
+
</div>
|
|
6033
|
+
<div className="multimodel-input-block">
|
|
6034
|
+
<div className="multimodel-input-label">
|
|
6035
|
+
<strong>{ko("Video URL", "비디오 URL")}</strong>
|
|
6036
|
+
<span>{ko("Use a hosted or local route", "호스팅 또는 로컬 경로 사용")}</span>
|
|
6037
|
+
</div>
|
|
6038
|
+
<input
|
|
6039
|
+
className="multimodel-url-input"
|
|
6040
|
+
type="text"
|
|
6041
|
+
value={multiModelUrl}
|
|
6042
|
+
onChange={(event) => setMultiModelUrl(event.target.value)}
|
|
6043
|
+
onKeyDown={(event) => { if (event.key === "Enter") useMultiModelUrl(); }}
|
|
6044
|
+
placeholder={ko("https://…/boxing.mp4", "https://…/boxing.mp4")}
|
|
6045
|
+
aria-label={ko("Multi-Model video URL", "멀티 모델 영상 URL")}
|
|
6046
|
+
spellCheck={false}
|
|
6047
|
+
/>
|
|
6048
|
+
<div className="multimodel-url-actions">
|
|
6049
|
+
<button type="button" className="btn ghost" onClick={pasteMultiModelUrl}>
|
|
6050
|
+
{ko("Paste", "붙여넣기")}
|
|
6051
|
+
</button>
|
|
6052
|
+
<button type="button" className="btn ghost" onClick={() => setMultiModelUrl("")} disabled={!multiModelUrl}>
|
|
6053
|
+
{ko("Clear", "지우기")}
|
|
6054
|
+
</button>
|
|
6055
|
+
<button type="button" className="btn primary" onClick={useMultiModelUrl} disabled={!multiModelUrl.trim()}>
|
|
6056
|
+
{ko("Use URL", "URL 사용")}
|
|
6057
|
+
</button>
|
|
6058
|
+
</div>
|
|
6059
|
+
</div>
|
|
6060
|
+
{multiModelStatus === "busy" && (
|
|
6061
|
+
<div className="multimodel-progress">
|
|
6062
|
+
<div className="multimodel-progress-track">
|
|
6063
|
+
<div
|
|
6064
|
+
className={"multimodel-progress-bar" + (multiModelProgress === null ? " indeterminate" : "")}
|
|
6065
|
+
style={multiModelProgress === null ? undefined : { width: `${Math.round(multiModelProgress * 100)}%` }}
|
|
6066
|
+
/>
|
|
6067
|
+
</div>
|
|
6068
|
+
<span>
|
|
6069
|
+
{multiModelStage === "fetching"
|
|
6070
|
+
? (multiModelProgress === null
|
|
6071
|
+
? ko("Downloading…", "다운로드 중…")
|
|
6072
|
+
: `${Math.round(multiModelProgress * 100)}%`)
|
|
6073
|
+
: ko("Decoding…", "디코딩 중…")}
|
|
6074
|
+
</span>
|
|
6075
|
+
</div>
|
|
6076
|
+
)}
|
|
6077
|
+
{multiModelError && <p className="multimodel-error">{multiModelError}</p>}
|
|
6078
|
+
{multiModelFootage && (
|
|
6079
|
+
<div className="multimodel-receipt">
|
|
6080
|
+
<video className="multimodel-preview" src={multiModelFootage.objectUrl} muted playsInline preload="metadata" />
|
|
6081
|
+
<div className="multimodel-receipt-body">
|
|
6082
|
+
<strong>{multiModelSource?.name}</strong>
|
|
6083
|
+
<span>{footageSummary(multiModelFootage)}</span>
|
|
6084
|
+
<span className="multimodel-receipt-timeline">
|
|
6085
|
+
{isKo
|
|
6086
|
+
? `타임라인 0–${multiModelFootage.frames - 1} 프레임으로 맞춤`
|
|
6087
|
+
: `Timeline sized to frames 0–${multiModelFootage.frames - 1}`}
|
|
6088
|
+
</span>
|
|
6089
|
+
</div>
|
|
6090
|
+
</div>
|
|
6091
|
+
)}
|
|
6092
|
+
{multiModelFootage && (
|
|
6093
|
+
<div className="multimodel-extract">
|
|
6094
|
+
<button
|
|
6095
|
+
type="button"
|
|
6096
|
+
className="btn primary"
|
|
6097
|
+
onClick={extractMultiModelMotion}
|
|
6098
|
+
disabled={multiModelExtract === "running"}
|
|
6099
|
+
>
|
|
6100
|
+
{multiModelExtract === "running"
|
|
6101
|
+
? ko("Extracting…", "추출 중…")
|
|
6102
|
+
: multiModelTake
|
|
6103
|
+
? ko("Extract again", "다시 추출")
|
|
6104
|
+
: ko("Extract motion", "모션 추출")}
|
|
6105
|
+
</button>
|
|
6106
|
+
{multiModelExtract === "running" && (
|
|
6107
|
+
<div className="multimodel-progress">
|
|
6108
|
+
<div className="multimodel-progress-track">
|
|
6109
|
+
<div
|
|
6110
|
+
className={"multimodel-progress-bar" + (multiModelExtractProgress === null ? " indeterminate" : "")}
|
|
6111
|
+
style={multiModelExtractProgress === null ? undefined : { width: `${Math.round(multiModelExtractProgress * 100)}%` }}
|
|
6112
|
+
/>
|
|
6113
|
+
</div>
|
|
6114
|
+
<span>
|
|
6115
|
+
{multiModelExtractProgress === null
|
|
6116
|
+
? ko("Engine…", "엔진 준비…")
|
|
6117
|
+
: `${Math.round(multiModelExtractProgress * 100)}%`}
|
|
6118
|
+
</span>
|
|
6119
|
+
</div>
|
|
6120
|
+
)}
|
|
6121
|
+
{multiModelExtract === "error" && <p className="multimodel-error">{multiModelExtractError}</p>}
|
|
6122
|
+
{multiModelTake && (
|
|
6123
|
+
<p className="multimodel-extract-receipt">
|
|
6124
|
+
{multiModelTake.gpu
|
|
6125
|
+
? (isKo
|
|
6126
|
+
? `GPU 테이크 ${multiModelTake.frames}프레임 추출됨 — 타임라인에서 재생하세요`
|
|
6127
|
+
: `GPU take extracted, ${multiModelTake.frames} frames — press play on the timeline`)
|
|
6128
|
+
: (isKo
|
|
6129
|
+
? `${multiModelTake.frames}프레임 테이크 구움 (실측 ${multiModelTake.fitted} · 유지 ${multiModelTake.held}) — 타임라인에서 재생하세요`
|
|
6130
|
+
: `Baked a ${multiModelTake.frames}-frame take (${multiModelTake.fitted} measured · ${multiModelTake.held} held) — press play on the timeline`)}
|
|
6131
|
+
</p>
|
|
6132
|
+
)}
|
|
6133
|
+
{multiModelTake?.persons > 1 && (
|
|
6134
|
+
<p className="multimodel-extract-receipt">
|
|
6135
|
+
{isKo
|
|
6136
|
+
? `${multiModelTake.persons}명의 테이크를 각 인물 레이어에 배치했어요`
|
|
6137
|
+
: `${multiModelTake.persons} performers landed on their own subject layers`}
|
|
6138
|
+
</p>
|
|
6139
|
+
)}
|
|
6140
|
+
{multiModelExtract === "idle" && !multiModelTake && (
|
|
6141
|
+
<p className="multimodel-note">
|
|
6142
|
+
{bridge === null
|
|
6143
|
+
? ko("Checking for the dev bridge…", "개발 브리지를 확인하는 중…")
|
|
6144
|
+
: bridge.ok
|
|
6145
|
+
? ko(
|
|
6146
|
+
"Extraction runs on the GPU box (about a minute per 15 s of footage).",
|
|
6147
|
+
"추출은 GPU 박스에서 돌아갑니다(영상 15초당 약 1분)."
|
|
6148
|
+
)
|
|
6149
|
+
: ko(
|
|
6150
|
+
"No bridge: extraction runs in this browser (rougher). First run downloads the pose engine (~15 MB).",
|
|
6151
|
+
"브리지 없음: 이 브라우저에서 추출합니다(품질 낮음). 첫 실행은 포즈 엔진(~15 MB)을 내려받습니다."
|
|
6152
|
+
)}
|
|
6153
|
+
</p>
|
|
6154
|
+
)}
|
|
6155
|
+
</div>
|
|
6156
|
+
)}
|
|
6157
|
+
<p className="multimodel-note">
|
|
6158
|
+
{ko("Locked-off footage with both performers in frame is best.", "두 사람이 함께 보이는 고정 카메라 영상이 가장 적합합니다.")}
|
|
6159
|
+
</p>
|
|
6160
|
+
</div>
|
|
6161
|
+
</Foldout>
|
|
3961
6162
|
<Foldout hidden={sidebarTab !== "motion"} title={ko("ARDY motion", "ARDY 모션")}>
|
|
6163
|
+
{/* One compact status line: which layer is being edited and on
|
|
6164
|
+
which box — the long hint texts lived here before. */}
|
|
6165
|
+
<p className="ardy-meta">
|
|
6166
|
+
{isKo ? `인물 ${activeCharIndex + 1} 레이어` : `Subject ${activeCharIndex + 1} layer`}
|
|
6167
|
+
{bridge?.ok ? ` · ${bridge.host ?? ko("box", "로컬")}` : ""}
|
|
6168
|
+
</p>
|
|
6169
|
+
{/* Per-character layer status: every cast member's clip and
|
|
6170
|
+
queue position at a glance. */}
|
|
6171
|
+
{characters.length > 0 && (
|
|
6172
|
+
<ul className="gen-layers">
|
|
6173
|
+
{characters.map((entry, index) => {
|
|
6174
|
+
const job = genQueue.find((item) => item.charId === entry.id && (item.status === "queued" || item.status === "running" || item.status === "error"));
|
|
6175
|
+
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
6176
|
+
const state = job?.status === "running"
|
|
6177
|
+
? ko("generating…", "생성 중…")
|
|
6178
|
+
: job?.status === "queued"
|
|
6179
|
+
? ko("queued", "대기 중")
|
|
6180
|
+
: job?.status === "error"
|
|
6181
|
+
? ko("failed", "실패")
|
|
6182
|
+
: clip
|
|
6183
|
+
? (isKo ? `${clip.frames}프레임 로드됨` : `${clip.frames} frames loaded`)
|
|
6184
|
+
: ko("no motion", "모션 없음");
|
|
6185
|
+
return (
|
|
6186
|
+
<li key={entry.id} className={entry.id === activeChar.id ? "active" : ""}>
|
|
6187
|
+
<span className="gen-layers-name">S{index + 1}</span>
|
|
6188
|
+
<span className={`gen-layers-state ${job?.status ?? (clip ? "loaded" : "")}`}>{state}</span>
|
|
6189
|
+
{job?.status === "queued" && (
|
|
6190
|
+
<button type="button" title={ko("Remove from queue", "대기열에서 제거")} onClick={() => setGenQueue((queue) => queue.filter((item) => item.id !== job.id))}>✕</button>
|
|
6191
|
+
)}
|
|
6192
|
+
</li>
|
|
6193
|
+
);
|
|
6194
|
+
})}
|
|
6195
|
+
</ul>
|
|
6196
|
+
)}
|
|
3962
6197
|
{bridge === null ? (
|
|
3963
6198
|
<p className="ardy-hint">{ko("Checking for the dev bridge…", "개발 브리지를 확인하는 중…")}</p>
|
|
3964
6199
|
) : bridge.ok ? (
|
|
3965
6200
|
<>
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
<input
|
|
3973
|
-
type="text"
|
|
3974
|
-
value={ardyPrompt}
|
|
3975
|
-
onChange={(e) => setArdyPrompt(e.target.value)}
|
|
3976
|
-
placeholder={ko("what the subject should do", "피사체가 할 동작을 설명하세요")}
|
|
3977
|
-
maxLength={ARDY_PROMPT_MAX}
|
|
3978
|
-
/>
|
|
3979
|
-
<span className="ardy-clamp">{isKo ? `최대 ${ARDY_PROMPT_MAX}자 · 필수` : `max ${ARDY_PROMPT_MAX} chars · required`}</span>
|
|
3980
|
-
</Field>
|
|
3981
|
-
<Field label={ko("Duration (s)", "길이 (초)")}>
|
|
3982
|
-
<input
|
|
3983
|
-
type="number"
|
|
3984
|
-
min={ARDY_DURATION_MIN}
|
|
3985
|
-
max={ARDY_DURATION_MAX}
|
|
3986
|
-
step={1}
|
|
3987
|
-
value={ardyDuration}
|
|
3988
|
-
onChange={(e) => changeDuration(e.target.value)}
|
|
3989
|
-
/>
|
|
3990
|
-
</Field>
|
|
3991
|
-
<Field label={ko("Seed (optional)", "Seed (선택)")}>
|
|
3992
|
-
<input
|
|
3993
|
-
type="text"
|
|
3994
|
-
inputMode="numeric"
|
|
3995
|
-
value={ardySeed}
|
|
3996
|
-
onChange={(e) => changeArdySeed(e.target.value)}
|
|
3997
|
-
placeholder={ko("empty = random", "비우면 랜덤")}
|
|
3998
|
-
/>
|
|
3999
|
-
<span className="ardy-clamp">{isKo ? `RT 기본값 2 · 비우면 랜덤 · 0..${ARDY_SEED_MAX}` : `RT default 2 · empty = random · 0..${ARDY_SEED_MAX}`}</span>
|
|
4000
|
-
</Field>
|
|
4001
|
-
{ardyRunning ? (
|
|
6201
|
+
{/* Generation is authored in Prompt Blocks below: a block owns
|
|
6202
|
+
both its wording and its frame range, so the range decides the
|
|
6203
|
+
duration and a separate prompt/duration pair here could only
|
|
6204
|
+
disagree with it. This box reports the run instead of starting
|
|
6205
|
+
one. */}
|
|
6206
|
+
{ardyRunning && (
|
|
4002
6207
|
<button type="button" className="btn ghost full" onClick={cancelArdy}>
|
|
4003
6208
|
{ko("Cancel run", "실행 취소")}
|
|
4004
6209
|
</button>
|
|
4005
|
-
) : (
|
|
4006
|
-
<button
|
|
4007
|
-
type="button"
|
|
4008
|
-
className="btn primary full generate"
|
|
4009
|
-
onClick={() => runArdy()}
|
|
4010
|
-
>
|
|
4011
|
-
{ko("Generate motion", "모션 생성")}
|
|
4012
|
-
</button>
|
|
4013
6210
|
)}
|
|
4014
6211
|
{ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
|
|
4015
6212
|
{ardyReport && (
|
|
@@ -4046,7 +6243,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
4046
6243
|
<p className="ardy-hint">{ko("Decoding motion…", "모션 디코딩 중…")}</p>
|
|
4047
6244
|
) : motion ? (
|
|
4048
6245
|
<p className="ardy-outcome done">
|
|
4049
|
-
{isKo ? `모션 로드됨 — ${motion.frames}프레임 @ ${motion.fps} fps, 인물 1에 재생 중` : `Motion loaded — ${motion.frames} frames @ ${motion.fps} fps, playing on Subject 1`}
|
|
6246
|
+
{isKo ? `모션 로드됨 — ${motion.frames}프레임 @ ${motion.fps} fps, 인물 ${activeCharIndex + 1}에 재생 중` : `Motion loaded — ${motion.frames} frames @ ${motion.fps} fps, playing on Subject ${activeCharIndex + 1}`}
|
|
4050
6247
|
</p>
|
|
4051
6248
|
) : motionError ? (
|
|
4052
6249
|
<>
|
|
@@ -4122,16 +6319,34 @@ globalThis.playMode = centerTab === "play";
|
|
|
4122
6319
|
/>
|
|
4123
6320
|
</Field>
|
|
4124
6321
|
)}
|
|
6322
|
+
{/* The seed belongs with the button that consumes it. Duration does
|
|
6323
|
+
not appear at all: the blocks' own frame ranges are the length. */}
|
|
6324
|
+
<Field label={ko("Seed", "시드")}>
|
|
6325
|
+
<input
|
|
6326
|
+
type="text"
|
|
6327
|
+
inputMode="numeric"
|
|
6328
|
+
value={ardySeed}
|
|
6329
|
+
onChange={(e) => changeArdySeed(e.target.value)}
|
|
6330
|
+
placeholder={ko("empty = random", "비우면 랜덤")}
|
|
6331
|
+
/>
|
|
6332
|
+
</Field>
|
|
4125
6333
|
<button
|
|
4126
6334
|
type="button"
|
|
4127
6335
|
className="btn primary full generate prompt-block-generate"
|
|
4128
|
-
disabled={!bridge?.ok || !promptClips.some((clip) => clip.text.trim())
|
|
6336
|
+
disabled={!bridge?.ok || !promptClips.some((clip) => clip.text.trim())}
|
|
4129
6337
|
onClick={runAllPromptBlocks}
|
|
4130
6338
|
>
|
|
4131
|
-
{ardyRunning
|
|
4132
|
-
? (
|
|
4133
|
-
:
|
|
6339
|
+
{ardyRunning || genQueue.some((job) => job.status === "queued")
|
|
6340
|
+
? ko("Queue block generation", "블록 생성 대기열에 추가")
|
|
6341
|
+
: isKo
|
|
6342
|
+
? `${promptClips.length}개 블록 모두 생성`
|
|
6343
|
+
: `Generate all ${promptClips.length} blocks`}
|
|
4134
6344
|
</button>
|
|
6345
|
+
{ardyRunning && (
|
|
6346
|
+
<button type="button" className="btn ghost full" onClick={cancelArdy}>
|
|
6347
|
+
{ko("Cancel run", "실행 취소")}
|
|
6348
|
+
</button>
|
|
6349
|
+
)}
|
|
4135
6350
|
{!bridge?.ok && <p className="ardy-hint">{ko("Start the ARDY bridge to enable generation.", "생성을 사용하려면 ARDY 브리지를 시작하세요.")}</p>}
|
|
4136
6351
|
{ardyStatus && <p className="ardy-status">{ardyStatus}</p>}
|
|
4137
6352
|
<button type="button" className="btn ghost full" onClick={() => addPromptClip(tlFrame)}>
|
|
@@ -4155,76 +6370,6 @@ globalThis.playMode = centerTab === "play";
|
|
|
4155
6370
|
</button>
|
|
4156
6371
|
</Foldout>
|
|
4157
6372
|
|
|
4158
|
-
<Foldout hidden={sidebarTab !== "motion"} title={ko("Root Path", "루트 경로")}>
|
|
4159
|
-
<div className="inspector-status-grid">
|
|
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>
|
|
4163
|
-
</div>
|
|
4164
|
-
<button type="button" className={"btn full" + (waypointMode ? " primary" : "")} onClick={toggleWaypointMode}>
|
|
4165
|
-
{waypointMode ? ko("Finish path editing", "경로 편집 끝내기") : ko("Edit root path", "루트 경로 편집")}
|
|
4166
|
-
</button>
|
|
4167
|
-
{waypointMode && (
|
|
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>
|
|
4169
|
-
)}
|
|
4170
|
-
<div className="inspector-list compact">
|
|
4171
|
-
{waypoints.map((waypoint) => (
|
|
4172
|
-
<button
|
|
4173
|
-
type="button"
|
|
4174
|
-
key={waypoint.frame}
|
|
4175
|
-
className={activeWaypointFrame === waypoint.frame ? "active" : ""}
|
|
4176
|
-
onClick={() => {
|
|
4177
|
-
setActiveWaypointFrame(waypoint.frame);
|
|
4178
|
-
setTlFrame(waypoint.frame);
|
|
4179
|
-
setWaypointMode(true);
|
|
4180
|
-
}}
|
|
4181
|
-
onContextMenu={(event) => {
|
|
4182
|
-
event.preventDefault();
|
|
4183
|
-
removeWaypoint(waypoint.frame);
|
|
4184
|
-
}}
|
|
4185
|
-
>
|
|
4186
|
-
<span>{isKo ? `프레임 ${waypoint.frame}` : `Frame ${waypoint.frame}`}</span>
|
|
4187
|
-
<small>{waypoint.x.toFixed(2)}, {waypoint.z.toFixed(2)}</small>
|
|
4188
|
-
</button>
|
|
4189
|
-
))}
|
|
4190
|
-
</div>
|
|
4191
|
-
</Foldout>
|
|
4192
|
-
|
|
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
6373
|
<Foldout hidden={sidebarTab !== "inspector" || selectedHierarchyId !== "environment"} title={ko("Environment", "환경")}>
|
|
4229
6374
|
<label className="check">
|
|
4230
6375
|
<input type="checkbox" checked={hasEnvSheet} onChange={(event) => setHasEnvSheet(event.target.checked)} />
|
|
@@ -4241,8 +6386,31 @@ globalThis.playMode = centerTab === "play";
|
|
|
4241
6386
|
</Foldout>
|
|
4242
6387
|
|
|
4243
6388
|
<Foldout hidden={sidebarTab !== "inspector" || selectedHierarchyId !== "props"} title={ko("Props", "소품")}>
|
|
4244
|
-
<
|
|
6389
|
+
<div className="props-drop" data-drop={inspectorDrop.over ? "over" : "target"} {...inspectorDrop.handlers}>
|
|
6390
|
+
<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. Drop a picture anywhere here — or on the shot view — to stand it up as a cutout.", "세트에 추가한 모든 소품이 여기에 모입니다. 편집하려면 하나를 고르거나 샷 뷰에서 클릭하세요. 사진을 이 영역이나 샷 뷰에 끌어다 놓으면 컷아웃으로 세워집니다.")}</p>
|
|
4245
6391
|
<AddObjectMenu onAdd={addSceneObject} label={ko("Add object to the set", "세트에 오브젝트 추가")} />
|
|
6392
|
+
<button
|
|
6393
|
+
type="button"
|
|
6394
|
+
className="btn ghost full"
|
|
6395
|
+
onClick={() => cutoutInputRef.current?.click()}
|
|
6396
|
+
title={ko("A photo of the real thing, standing in the set as a card", "실제 사진을 판때기로 세워 세트에 배치합니다")}
|
|
6397
|
+
>
|
|
6398
|
+
{ko("Import image as cutout", "이미지를 컷아웃으로 가져오기")}
|
|
6399
|
+
</button>
|
|
6400
|
+
<input
|
|
6401
|
+
ref={cutoutInputRef}
|
|
6402
|
+
type="file"
|
|
6403
|
+
hidden
|
|
6404
|
+
accept={ASSET_IMAGE_TYPES.join(",")}
|
|
6405
|
+
onChange={(event) => {
|
|
6406
|
+
const [file] = event.target.files ?? [];
|
|
6407
|
+
// Cleared before the await: picking the same file twice in a
|
|
6408
|
+
// row has to fire change twice, and it will not if the input
|
|
6409
|
+
// still holds it.
|
|
6410
|
+
event.target.value = "";
|
|
6411
|
+
importCutout(file);
|
|
6412
|
+
}}
|
|
6413
|
+
/>
|
|
4246
6414
|
<div className="inspector-list compact">
|
|
4247
6415
|
{sceneObjects.map((object) => (
|
|
4248
6416
|
<button
|
|
@@ -4255,6 +6423,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
4255
6423
|
</button>
|
|
4256
6424
|
))}
|
|
4257
6425
|
</div>
|
|
6426
|
+
</div>
|
|
4258
6427
|
</Foldout>
|
|
4259
6428
|
|
|
4260
6429
|
<Foldout hidden={sidebarTab !== "inspector" || !selectedSceneObject} title={ko("Object Transform", "오브젝트 변환")}>
|
|
@@ -4319,6 +6488,195 @@ globalThis.playMode = centerTab === "play";
|
|
|
4319
6488
|
{ axis: "Z", value: selectedSceneObject.scaleZ ?? 1, step: 0.05, precision: 2, onChange: (scaleZ) => changeSceneObject(selectedSceneObject.id, { scaleZ }), onScrubStart: beginSceneTransaction, onScrubEnd: endSceneTransaction },
|
|
4320
6489
|
]}
|
|
4321
6490
|
/>
|
|
6491
|
+
{selectedSceneObject.renderer === CUTOUT_KIND && (
|
|
6492
|
+
<>
|
|
6493
|
+
<Field label={ko("Card height (m)", "판 높이 (m)")}>
|
|
6494
|
+
<input
|
|
6495
|
+
type="number"
|
|
6496
|
+
data-field="cutout-height"
|
|
6497
|
+
min="0.05"
|
|
6498
|
+
max={CUTOUT_MAX_HEIGHT}
|
|
6499
|
+
step="0.05"
|
|
6500
|
+
value={selectedSceneObject.height ?? CUTOUT_DEFAULT_HEIGHT}
|
|
6501
|
+
onChange={(event) => changeSceneObject(selectedSceneObject.id, { height: Number(event.target.value) })}
|
|
6502
|
+
/>
|
|
6503
|
+
</Field>
|
|
6504
|
+
<p className="inspector-hint">
|
|
6505
|
+
{isKo
|
|
6506
|
+
? `가로 ${(selectedSceneObject.footprint?.width ?? 0).toFixed(2)} m — 사진 비율에 맞춰 자동 계산됩니다. 사진 속에서 높이를 알 수 있는 것(문 2 m, 사람 1.8 m)에 맞추세요.`
|
|
6507
|
+
: `${(selectedSceneObject.footprint?.width ?? 0).toFixed(2)} m wide, from the picture's own aspect. Measure against something you know: a door is 2 m, a person 1.8 m.`}
|
|
6508
|
+
</p>
|
|
6509
|
+
<div className="matte-editor">
|
|
6510
|
+
{!!selectedSceneObject.matteAssetId && (
|
|
6511
|
+
<p className="inspector-hint matte-state">
|
|
6512
|
+
{ko(
|
|
6513
|
+
"This card's background is removed. You are editing the original photograph — apply again to change what goes.",
|
|
6514
|
+
"이 카드는 배경이 지워진 상태입니다. 지금 보이는 것은 원본 사진이며, 다시 적용하면 지워지는 범위가 바뀝니다.",
|
|
6515
|
+
)}
|
|
6516
|
+
</p>
|
|
6517
|
+
)}
|
|
6518
|
+
<canvas
|
|
6519
|
+
ref={matteCanvasRef}
|
|
6520
|
+
className="matte-canvas"
|
|
6521
|
+
// Focusable for the space-drag pan, not for a shortcut: undo
|
|
6522
|
+
// belongs to the buttons here. Ctrl+Z is the scene's, and one
|
|
6523
|
+
// key meaning two different undos in two different panels is
|
|
6524
|
+
// worse than a key that means one thing everywhere.
|
|
6525
|
+
tabIndex={0}
|
|
6526
|
+
aria-label={ko("Background editor — drag over the background to cut it out", "배경 편집기 — 배경 위를 드래그하면 그 영역이 잘려 나갑니다")}
|
|
6527
|
+
/>
|
|
6528
|
+
<p className="inspector-hint">
|
|
6529
|
+
{matteStats.painted
|
|
6530
|
+
? isKo
|
|
6531
|
+
? `사진의 ${Math.round(matteStats.coverage * 100)}%가 선택됨 — 보라색이 지워집니다.`
|
|
6532
|
+
: `${Math.round(matteStats.coverage * 100)}% of the picture marked — purple is what goes.`
|
|
6533
|
+
: ko(
|
|
6534
|
+
"Drag over the background — the cut grows out from wherever the brush touches.",
|
|
6535
|
+
"배경 위를 드래그하세요 — 브러시가 닿은 곳에서 같은 배경으로 번져 나가며 잘립니다.",
|
|
6536
|
+
)}
|
|
6537
|
+
</p>
|
|
6538
|
+
</div>
|
|
6539
|
+
{/* Two hands: what the brush does on the left, what to do
|
|
6540
|
+
about what it did on the right. Clear sits under Undo and
|
|
6541
|
+
Redo because it is the same kind of act — taking work
|
|
6542
|
+
back — only all of it. */}
|
|
6543
|
+
<div className="matte-tools">
|
|
6544
|
+
<div className="presets matte-modes">
|
|
6545
|
+
<button
|
|
6546
|
+
type="button"
|
|
6547
|
+
className={matteMode === "paint" ? "active" : ""}
|
|
6548
|
+
onClick={() => {
|
|
6549
|
+
setMatteMode("paint");
|
|
6550
|
+
matteEditorRef.current?.setMode("paint");
|
|
6551
|
+
}}
|
|
6552
|
+
>
|
|
6553
|
+
{ko("Cut out", "누끼 따기")}
|
|
6554
|
+
</button>
|
|
6555
|
+
<button
|
|
6556
|
+
type="button"
|
|
6557
|
+
className={matteMode === "erase" ? "active" : ""}
|
|
6558
|
+
onClick={() => {
|
|
6559
|
+
setMatteMode("erase");
|
|
6560
|
+
matteEditorRef.current?.setMode("erase");
|
|
6561
|
+
}}
|
|
6562
|
+
>
|
|
6563
|
+
{ko("Bring back", "되살리기")}
|
|
6564
|
+
</button>
|
|
6565
|
+
</div>
|
|
6566
|
+
{/* Icons, not words: undo, redo and clear are the same three
|
|
6567
|
+
acts in every tool anyone has used, and spelling them out
|
|
6568
|
+
took more width than the two that actually name what this
|
|
6569
|
+
brush does. The label lives in the tooltip and in the
|
|
6570
|
+
accessible name. */}
|
|
6571
|
+
<div className="matte-history">
|
|
6572
|
+
<div className="presets matte-modes matte-icons">
|
|
6573
|
+
<button
|
|
6574
|
+
type="button"
|
|
6575
|
+
disabled={!matteStats.canUndo}
|
|
6576
|
+
title={ko("Undo", "실행 취소")}
|
|
6577
|
+
aria-label={ko("Undo", "실행 취소")}
|
|
6578
|
+
onClick={() => matteEditorRef.current?.undo()}
|
|
6579
|
+
>
|
|
6580
|
+
<span aria-hidden="true">↩️</span>
|
|
6581
|
+
</button>
|
|
6582
|
+
<button
|
|
6583
|
+
type="button"
|
|
6584
|
+
disabled={!matteStats.canRedo}
|
|
6585
|
+
title={ko("Redo", "다시 실행")}
|
|
6586
|
+
aria-label={ko("Redo", "다시 실행")}
|
|
6587
|
+
onClick={() => matteEditorRef.current?.redo()}
|
|
6588
|
+
>
|
|
6589
|
+
<span aria-hidden="true">↪️</span>
|
|
6590
|
+
</button>
|
|
6591
|
+
</div>
|
|
6592
|
+
<div className="presets matte-modes matte-icons matte-clear">
|
|
6593
|
+
<button
|
|
6594
|
+
type="button"
|
|
6595
|
+
title={ko("Clear the selection", "선택 모두 지우기")}
|
|
6596
|
+
aria-label={ko("Clear the selection", "선택 모두 지우기")}
|
|
6597
|
+
onClick={() => matteEditorRef.current?.clear()}
|
|
6598
|
+
>
|
|
6599
|
+
<span aria-hidden="true">🗑️</span>
|
|
6600
|
+
</button>
|
|
6601
|
+
</div>
|
|
6602
|
+
</div>
|
|
6603
|
+
</div>
|
|
6604
|
+
<div className="matte-slider">
|
|
6605
|
+
<label htmlFor="matte-tolerance">{ko("Tolerance", "허용치")}</label>
|
|
6606
|
+
<input
|
|
6607
|
+
id="matte-tolerance"
|
|
6608
|
+
type="range"
|
|
6609
|
+
min="0.02"
|
|
6610
|
+
max="0.6"
|
|
6611
|
+
step="0.01"
|
|
6612
|
+
value={matteTolerance}
|
|
6613
|
+
onChange={(event) => {
|
|
6614
|
+
const value = Number(event.target.value);
|
|
6615
|
+
setMatteTolerance(value);
|
|
6616
|
+
matteEditorRef.current?.setTolerance(value);
|
|
6617
|
+
}}
|
|
6618
|
+
/>
|
|
6619
|
+
<input
|
|
6620
|
+
type="number"
|
|
6621
|
+
data-field="matte-tolerance"
|
|
6622
|
+
min="0.02"
|
|
6623
|
+
max="0.6"
|
|
6624
|
+
step="0.01"
|
|
6625
|
+
value={matteTolerance}
|
|
6626
|
+
aria-label={ko("Tolerance", "허용치")}
|
|
6627
|
+
onChange={(event) => {
|
|
6628
|
+
const value = Number(event.target.value);
|
|
6629
|
+
if (!Number.isFinite(value)) return;
|
|
6630
|
+
setMatteTolerance(value);
|
|
6631
|
+
matteEditorRef.current?.setTolerance(value);
|
|
6632
|
+
}}
|
|
6633
|
+
/>
|
|
6634
|
+
</div>
|
|
6635
|
+
<p className="inspector-hint">
|
|
6636
|
+
{ko(
|
|
6637
|
+
"Tolerance is how far a drag spreads: low keeps to one flat colour, high walks across a shaded wall. It applies to the next drag and to Auto-detect, not to what is already purple.",
|
|
6638
|
+
"허용치는 드래그가 얼마나 번질지입니다. 낮으면 한 가지 색에 머무르고, 높으면 명암이 변하는 벽까지 따라갑니다. 이미 칠한 보라가 아니라 다음 드래그와 자동 인식에 적용됩니다.",
|
|
6639
|
+
)}
|
|
6640
|
+
</p>
|
|
6641
|
+
<button
|
|
6642
|
+
type="button"
|
|
6643
|
+
className="btn ghost full"
|
|
6644
|
+
onClick={() => {
|
|
6645
|
+
const added = matteEditorRef.current?.autoDetect(matteTolerance) ?? 0;
|
|
6646
|
+
if (!added) {
|
|
6647
|
+
setToast(
|
|
6648
|
+
isKo
|
|
6649
|
+
? "자동 인식이 더 칠할 곳을 찾지 못했어요 — 허용치를 높이거나 직접 칠하세요"
|
|
6650
|
+
: "Auto-detect found nothing new to paint — raise the tolerance, or paint it by hand",
|
|
6651
|
+
);
|
|
6652
|
+
}
|
|
6653
|
+
}}
|
|
6654
|
+
>
|
|
6655
|
+
{ko("Auto-detect background", "배경 자동 인식")}
|
|
6656
|
+
</button>
|
|
6657
|
+
<button
|
|
6658
|
+
type="button"
|
|
6659
|
+
className="btn primary full matte-apply"
|
|
6660
|
+
disabled={matteBusy || !matteStats.painted}
|
|
6661
|
+
onClick={() => applyMatte(selectedSceneObject.id)}
|
|
6662
|
+
>
|
|
6663
|
+
{matteBusy
|
|
6664
|
+
? ko("Removing…", "지우는 중…")
|
|
6665
|
+
: matteStats.painted
|
|
6666
|
+
? isKo
|
|
6667
|
+
? `보라색 부분 지우기 — 사진의 ${Math.round(matteStats.coverage * 100)}%`
|
|
6668
|
+
: `Remove what is purple — ${Math.round(matteStats.coverage * 100)}% of the picture`
|
|
6669
|
+
: ko("Nothing is marked yet", "아직 선택된 부분이 없습니다")}
|
|
6670
|
+
</button>
|
|
6671
|
+
<p className="inspector-hint">
|
|
6672
|
+
{ko(
|
|
6673
|
+
"Cut out grows the selection from wherever you drag; Bring back is the same growth fenced to what is already selected, so one drag returns a wrongly-cut region whole. Applying removes exactly what is purple and trims the empty margin — the card keeps the original photograph and this selection, so you can come back and change your mind.",
|
|
6674
|
+
"누끼 따기는 드래그한 자리에서 선택 영역을 키우고, 되살리기는 그 성장을 이미 선택된 범위 안으로 가둔 것이라 잘못 잘린 부분이 드래그 한 번에 통째로 돌아옵니다. 적용하면 보라색 부분만 지우고 여백을 잘라냅니다 — 원본 사진과 지금 선택한 영역은 카드에 남아 있어 언제든 다시 열어 고칠 수 있습니다.",
|
|
6675
|
+
)}
|
|
6676
|
+
</p>
|
|
6677
|
+
</>
|
|
6678
|
+
)}
|
|
6679
|
+
{selectedSceneObject.renderer !== CUTOUT_KIND && (
|
|
4322
6680
|
<div className="object-colors" role="group" aria-label={ko("Object colour", "오브젝트 색상")}>
|
|
4323
6681
|
{OBJECT_COLORS.map((color) => (
|
|
4324
6682
|
<button
|
|
@@ -4332,6 +6690,7 @@ globalThis.playMode = centerTab === "play";
|
|
|
4332
6690
|
/>
|
|
4333
6691
|
))}
|
|
4334
6692
|
</div>
|
|
6693
|
+
)}
|
|
4335
6694
|
</>
|
|
4336
6695
|
)}
|
|
4337
6696
|
</Foldout>
|
|
@@ -4349,6 +6708,41 @@ globalThis.playMode = centerTab === "play";
|
|
|
4349
6708
|
</div>
|
|
4350
6709
|
)}
|
|
4351
6710
|
</section>
|
|
6711
|
+
{/* Pose Studio docks under the inspector instead of floating over
|
|
6712
|
+
the shot: the viewport keeps the posed character unobstructed. */}
|
|
6713
|
+
{posing && (
|
|
6714
|
+
<PoseStudioPanel
|
|
6715
|
+
docked
|
|
6716
|
+
subject={posingIndex >= 0 ? posingIndex + 1 : 1}
|
|
6717
|
+
model={posingChar?.model ?? charA.model}
|
|
6718
|
+
poses={allPoses}
|
|
6719
|
+
selectedId={studioPick}
|
|
6720
|
+
closing={posingClosing}
|
|
6721
|
+
motionActive={Boolean(motion)}
|
|
6722
|
+
onSelect={setStudioPick}
|
|
6723
|
+
onApply={(selectedPoseId) => {
|
|
6724
|
+
const pose = allPoses.find((p) => p.id === selectedPoseId);
|
|
6725
|
+
if (pose) {
|
|
6726
|
+
const hadMotion = Boolean(motion);
|
|
6727
|
+
if (hadMotion) clearMotion();
|
|
6728
|
+
setPosed(pose);
|
|
6729
|
+
closeStudio();
|
|
6730
|
+
setToast(hadMotion ? ko("Cleared the current motion and applied the pose", "현재 모션을 지우고 포즈를 적용했어요") : ko("Pose applied", "포즈를 적용했어요"));
|
|
6731
|
+
} else {
|
|
6732
|
+
setToast(ko("Couldn't find the selected pose — pick again", "선택한 포즈를 찾지 못했어요. 다시 골라 주세요"));
|
|
6733
|
+
}
|
|
6734
|
+
}}
|
|
6735
|
+
onReset={() => {
|
|
6736
|
+
if (motion) clearMotion();
|
|
6737
|
+
setStudioPick(DEFAULT_POSE.id);
|
|
6738
|
+
setPosed(DEFAULT_POSE);
|
|
6739
|
+
setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
|
|
6740
|
+
}}
|
|
6741
|
+
onSave={savePose}
|
|
6742
|
+
onDelete={removePose}
|
|
6743
|
+
onClose={closeStudio}
|
|
6744
|
+
/>
|
|
6745
|
+
)}
|
|
4352
6746
|
</aside>
|
|
4353
6747
|
</div>
|
|
4354
6748
|
|
|
@@ -4376,7 +6770,18 @@ globalThis.playMode = centerTab === "play";
|
|
|
4376
6770
|
>
|
|
4377
6771
|
{ko("Console", "콘솔")}
|
|
4378
6772
|
</button>
|
|
6773
|
+
<button
|
|
6774
|
+
type="button"
|
|
6775
|
+
className={bottomTab === "assets" ? "active" : ""}
|
|
6776
|
+
aria-pressed={bottomTab === "assets"}
|
|
6777
|
+
onClick={() => setBottomTab("assets")}
|
|
6778
|
+
>
|
|
6779
|
+
{ko("Assets", "에셋")}
|
|
6780
|
+
</button>
|
|
4379
6781
|
</nav>
|
|
6782
|
+
<div className="assets-pane" hidden={bottomTab !== "assets"}>
|
|
6783
|
+
<AssetPane onAssetGrab={beginAssetDrag} />
|
|
6784
|
+
</div>
|
|
4380
6785
|
<div className="console-pane" hidden={bottomTab !== "console"}>
|
|
4381
6786
|
{consoleLines.length === 0 ? (
|
|
4382
6787
|
<p className="console-empty">{ko("No messages yet — ARDY status lines appear here.", "아직 메시지가 없어요 — ARDY 상태 줄이 여기에 표시됩니다.")}</p>
|
|
@@ -4395,6 +6800,9 @@ globalThis.playMode = centerTab === "play";
|
|
|
4395
6800
|
frameCount={tlFrameCount}
|
|
4396
6801
|
fps={tlFps}
|
|
4397
6802
|
playbackSpeed={DEFAULT_PLAYBACK_SPEED}
|
|
6803
|
+
trackOwner={characters.length > 1 ? `S${activeCharIndex + 1}` : null}
|
|
6804
|
+
ghostLayers={ghostLayers}
|
|
6805
|
+
pathSpeed={pathSpeed}
|
|
4398
6806
|
playing={tlPlaying}
|
|
4399
6807
|
waypointMode={waypointMode}
|
|
4400
6808
|
waypointFrames={waypoints.map((w) => w.frame)}
|
|
@@ -4405,6 +6813,9 @@ globalThis.playMode = centerTab === "play";
|
|
|
4405
6813
|
badge={stateBadge}
|
|
4406
6814
|
ikMode={ikMode}
|
|
4407
6815
|
ikDisabled={!ikChains}
|
|
6816
|
+
motion={motion ? { frames: motion.frames, label: motion.prompt || ko("Loaded take", "불러온 테이크") } : null}
|
|
6817
|
+
onMotionTrim={applyMotionTrim}
|
|
6818
|
+
onMotionTrimReset={resetMotionTrim}
|
|
4408
6819
|
ikFrames={ikFrames}
|
|
4409
6820
|
footSnap={footSnap}
|
|
4410
6821
|
shots={shots}
|
|
@@ -4525,7 +6936,28 @@ globalThis.playMode = centerTab === "play";
|
|
|
4525
6936
|
/>
|
|
4526
6937
|
)}
|
|
4527
6938
|
|
|
6939
|
+
{projectBrowserOpen && (
|
|
6940
|
+
<ProjectBrowser
|
|
6941
|
+
currentName={projectName}
|
|
6942
|
+
onOpen={(entry) => openProjectByHandle(entry.handle)}
|
|
6943
|
+
onOpenFile={() => {
|
|
6944
|
+
setProjectBrowserOpen(false);
|
|
6945
|
+
openProject();
|
|
6946
|
+
}}
|
|
6947
|
+
onNew={() => {
|
|
6948
|
+
setProjectBrowserOpen(false);
|
|
6949
|
+
newProject();
|
|
6950
|
+
}}
|
|
6951
|
+
onClose={() => setProjectBrowserOpen(false)}
|
|
6952
|
+
/>
|
|
6953
|
+
)}
|
|
4528
6954
|
<Toast message={toast} onDone={() => setToast("")} />
|
|
6955
|
+
{assetDrag && (
|
|
6956
|
+
<div className="asset-drag-ghost" style={{ left: assetDrag.x, top: assetDrag.y }} aria-hidden="true">
|
|
6957
|
+
<span className="asset-card-swatch" data-model={assetDrag.asset.id} />
|
|
6958
|
+
<span>{assetDrag.asset.label}</span>
|
|
6959
|
+
</div>
|
|
6960
|
+
)}
|
|
4529
6961
|
</div>
|
|
4530
6962
|
);
|
|
4531
6963
|
}
|
|
@@ -4553,13 +6985,23 @@ function Foldout({ title, hidden, children }) {
|
|
|
4553
6985
|
);
|
|
4554
6986
|
}
|
|
4555
6987
|
|
|
4556
|
-
function SubjectBox({ label, value, onChange, onRemove, onPose, posing }) {
|
|
6988
|
+
function SubjectBox({ label, value, onChange, onRemove, onPose, posing, color, onColorChange }) {
|
|
4557
6989
|
const set = (key) => (v) => onChange((prev) => ({ ...prev, [key]: v }));
|
|
4558
6990
|
return (
|
|
4559
6991
|
<div className="subject-box">
|
|
4560
6992
|
<div className="subject-box-head">
|
|
4561
6993
|
<span className="sb-name">{label}</span>
|
|
4562
6994
|
<div className="sb-actions">
|
|
6995
|
+
{onColorChange && (
|
|
6996
|
+
<input
|
|
6997
|
+
type="color"
|
|
6998
|
+
className="sb-color"
|
|
6999
|
+
title={ko("Character color", "인물 색상")}
|
|
7000
|
+
aria-label={ko("Character color", "인물 색상")}
|
|
7001
|
+
value={color}
|
|
7002
|
+
onChange={(e) => onColorChange(e.target.value)}
|
|
7003
|
+
/>
|
|
7004
|
+
)}
|
|
4563
7005
|
{onPose && (
|
|
4564
7006
|
<button
|
|
4565
7007
|
type="button"
|
|
@@ -4578,6 +7020,7 @@ function SubjectBox({ label, value, onChange, onRemove, onPose, posing }) {
|
|
|
4578
7020
|
</div>
|
|
4579
7021
|
</div>
|
|
4580
7022
|
<Slider compact label={ko("Left / right", "좌 / 우")} min={-4} max={4} step={0.1} value={value.x} onChange={set("x")} />
|
|
7023
|
+
<Slider compact label={ko("Height", "높이")} min={0} max={4} step={0.05} value={value.y ?? 0} onChange={set("y")} />
|
|
4581
7024
|
<Slider compact label={ko("Depth", "깊이")} min={-4} max={4} step={0.1} value={value.z} onChange={set("z")} />
|
|
4582
7025
|
<Slider compact label={ko("Rotate", "회전")} min={-180} max={180} step={1} value={value.rot} unit="°" onChange={set("rot")} />
|
|
4583
7026
|
</div>
|