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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. 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, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
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
- if (focus === "hips") return "rig.hips";
317
- if (["spine", "chest", "neck", "head"].includes(focus)) return "rig.spine";
318
- if (focus.startsWith("left") && ["Hand", "Elbow", "Shoulder"].some((part) => focus.endsWith(part))) return "rig.leftArm";
319
- if (focus.startsWith("right") && ["Hand", "Elbow", "Shoulder"].some((part) => focus.endsWith(part))) return "rig.rightArm";
320
- if (focus === "leftFoot" || focus === "leftKnee") return "rig.leftLeg";
321
- if (focus === "rightFoot" || focus === "rightKnee") return "rig.rightLeg";
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
- const CHARACTER_MODEL_URL = "/models/y-bot-tpose.fbx";
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 = "a young woman in a tan coat";
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
- const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × 20 frames
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 = 40; // core model horizon: 2 seconds at 20 fps
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
- function Character({ url, position, rot, tint, pose, onRig, pickId }) {
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
- clone.scale.setScalar(0.01); // Mixamo exports centimetres
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
- onRig?.(model);
391
- }, [model, onRig]);
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
- /** Applies a preset to the free-flying camera, then reports live metrics. */
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 60 at 20 fps puts the camera exactly 3 s into its move. */
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: 1 m cells on the set floor, editor chrome only.
703
- It rides the gizmo layer so exports and the plan never pick it up, sits a
704
- hair above the deck to dodge z-fighting, and never swallows a raycast —
705
- floor clicks pass straight through to the ground plane. Warm greys tuned
706
- to the clay floor (#e7e1d7). */
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 grid = useMemo(() => {
709
- const helper = new THREE.GridHelper(24, 24, 0x8f8474, 0xa99f8e);
710
- helper.material.transparent = true;
711
- helper.material.opacity = 0.62;
712
- helper.material.depthWrite = false;
713
- helper.position.y = 0.002;
714
- helper.layers.set(GIZMO_LAYER);
715
- helper.raycast = () => null;
716
- return helper;
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 <primitive object={grid} />;
918
+ return (
919
+ <>
920
+ <primitive object={grids[0]} />
921
+ <primitive object={grids[1]} />
922
+ </>
923
+ );
719
924
  }
720
925
 
721
- /** Offscreen 1920x1080 read-back, always from the shot camera. */
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(CAPTURE_W, CAPTURE_H, {
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 = CAPTURE_W / CAPTURE_H;
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(CAPTURE_W * CAPTURE_H * 4);
746
- gl.readRenderTargetPixels(target, 0, 0, CAPTURE_W, CAPTURE_H, buffer);
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 1920x1080
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
- const [startup] = useState(loadSceneStartup);
1060
- const startupScene = startup.document.scenes[activeSceneIndex(startup.document.scenes, startup.document.activeSceneId)];
1061
- const startupStage = createSceneStage(startupScene.stage);
1062
- const [showB, setShowB] = useState(startupStage.showB);
1063
- const [charA, setCharA] = useState(startupStage.charA);
1064
- const [charB, setCharB] = useState(startupStage.charB);
1065
- const [poseA, setPoseA] = useState(startupStage.poseA ?? DEFAULT_POSE);
1066
- const [poseB, setPoseB] = useState(startupStage.poseB ?? DEFAULT_POSE);
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 [rigA, setRigA] = useState(null);
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, { onObjects: setSceneObjects });
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 @ 20 fps = 0.3 s — long enough to
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
- // The root SHOT row is the one non-object row kept as a bridge to the
1146
- // shot-global settings; everything else is a scene entity.
1147
- setSidebarTab(id === "shot" ? "shot" : "inspector");
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 copy = createSceneObject(object.renderer, sceneObjects, {
1248
- x: object.x,
1249
- z: object.z,
1250
- rot: object.rot,
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 * 20));
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(DEFAULT_DURATION_S);
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 its own); otherwise a plain integer in 0..2**31-1.
1438
- const [ardySeed, setArdySeed] = useState("2"); // RT demo parity: its GUI seed defaults to 2, so generation is reproducible out of the box; clear the field for the box's random seed
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
- // ARDY Core runs at 20 fps: the destination frame lives in [0, duration*20-1].
1453
- const maxDst = Math.max(0, Math.round(ardyDuration) * 20 - 1);
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 * 20); // the generation clip length @ 20 fps
1462
- const [tlFps, setTlFps] = useState(20);
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 * 20);
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 = { charA, charB, showB, poseA, poseB, hasCharSheet, subject, subject2 };
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 * 20;
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, { onObjects: setSceneObjects });
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
- setWaypoints(shotState.waypoints);
1650
- setTlFrameCount(shotState.frameCount ?? DEFAULT_DURATION_S * 20);
1651
- setCharA(stage.charA);
1652
- setCharB(stage.charB);
1653
- setShowB(stage.showB);
1654
- setPoseA(stage.poseA ?? DEFAULT_POSE);
1655
- setPoseB(stage.poseB ?? DEFAULT_POSE);
2457
+ setTlFrameCount(shotState.frameCount ?? DEFAULT_DURATION_S * TIMELINE_FPS);
2458
+ setCharacters(stage.characters);
2459
+ setRigs({});
1656
2460
  setHasCharSheet(stage.hasCharSheet);
1657
- setSubject(stage.subject ?? DEFAULT_SUBJECT);
1658
- setSubject2(stage.subject2 ?? "a man in a dark coat");
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 while ARDY clips run at 20.
1835
- // Recording plays the clip at 20/24 real time and stamps each captured
1836
- // frame 41.67 ms apart, so the exported file is exactly 24 fps at true
1837
- // motion speed — valid as a camera/motion reference out of the box.
1838
- const RECORD_FPS = 24;
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 = 1600;
1858
- mirror.height = 900;
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, SHOT_ASPECT);
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
- const posedRig = () => (posing === "B" ? rigB : rigA);
2089
- const setPosed = posing === "B" ? setPoseB : setPoseA;
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
- const rootStart = () => ({ frame: 0, x: charA.x, z: charA.z });
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
- // Decoded motion + the world anchor: frame 0 always starts at Subject 1.
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
- // Re-loading while a motion is active: restore the previous
2250
- // pre-playback baseline first, so the new snapshot is not taken
2251
- // mid-animation and clearing always returns to the blocking pose.
2252
- const previous = restoreRef.current;
2253
- restoreRef.current = null;
2254
- if (previous) restorePlaybackBones(previous.rig, previous.bones);
2255
- const decoded = await loadMotionFromUrl(url);
2256
- const rig = rigA;
2257
- if (!rig) throw new Error(ko("Subject 1 rig is not loaded", "인물 1 리그가 로드되지 않았어요"));
2258
- restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
2259
- setMotion({
2260
- // Capture the exact prompt this motion was generated from; the
2261
- // timeline keeps showing it even if the input field is edited
2262
- // afterwards.
2263
- prompt: typeof prompt === "string" ? prompt : "",
2264
- ...decoded,
2265
- url,
2266
- anchorX: charA.x,
2267
- anchorZ: charA.z,
2268
- anchorFrame: 0,
2269
- rotationDeg,
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
- setTlFrameCount(decoded.frames);
2272
- setTlFps(decoded.fps);
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 ? `모션 로드됨: ${decoded.frames}프레임 @ ${decoded.fps} fps` : `Motion loaded: ${decoded.frames} frames @ ${decoded.fps} fps`);
2276
- } catch (err) {
2277
- setMotion(null);
2278
- setMotionError(err?.message || String(err));
2279
- } finally {
2280
- setMotionBusy(false);
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
- // Hosted-demo seed. A build served as static files has no ARDY sidecar, so
2285
- // a first-time visitor would otherwise land on a character standing still
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 (!rigA || motion || motionBusy) return;
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, rigA, motion, motionBusy]);
3999
+ }, [bridge, activeRig, motion, motionBusy]);
2303
4000
 
2304
4001
  function clearMotion() {
2305
4002
  setMotion(null);
2306
4003
  setMotionError("");
2307
- // Back to the pre-generation timeline: the current duration at 20 fps.
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(20);
4019
+ setTlFps(TIMELINE_FPS);
2310
4020
  setTlFrame((f) => Math.min(f, maxDst));
2311
4021
  setTlPlaying(false);
2312
4022
  }
2313
4023
 
2314
- // Drive Subject 1's rig from the loaded clip whenever the playhead moves.
2315
- // During playback the pose prop is null so Character's effect never
2316
- // overwrites the animation on unrelated re-renders.
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
- if (!motion || !rigA) return;
2319
- applyMotionFrame(rigA, motion, tlFrame);
2320
- }, [motion, rigA, tlFrame]);
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 Subject 1's rig
2325
- // (re)loads. A rig missing any bone resolves to null and IK mode stays
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(rigA);
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) setIkMode(false);
2334
- }, [rigA]);
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
- setIkMode(false);
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 || !rigA || posing) return;
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, rigA, motion, posing, tlFrame, ikTick, ikFkJoints]);
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 || !rigA || posing) return;
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, rigA, motion, posing, tlFrame, ikTick, ikFkJoints]);
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
- }, [rigA, motion, tlFrame, ikMode, ikChains, ikFocus, ikTick, charA, committedIkEdits, waypoints]);
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 (@ 20 fps). Shown in the
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
- }, [charA.x, charA.z, tlFps, waypoints]);
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(which) {
2632
- setPosing(which);
4398
+ function openStudio(charId) {
4399
+ setPosing(charId);
2633
4400
  setPosingClosing(false);
2634
- setStudioPick((which === "B" ? poseB : poseA)?.id ?? null);
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
- setShowB(p.two);
2678
- if (p.charB) setCharB(p.charB);
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 = CAPTURE_W;
2685
- canvas.height = CAPTURE_H;
4454
+ canvas.width = shotOutput.width;
4455
+ canvas.height = shotOutput.height;
2686
4456
  const ctx = canvas.getContext("2d");
2687
- const image = ctx.createImageData(CAPTURE_W, CAPTURE_H);
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 < CAPTURE_H; row += 1) {
2690
- const from = (CAPTURE_H - 1 - row) * CAPTURE_W * 4;
2691
- image.data.set(buffer.subarray(from, from + CAPTURE_W * 4), row * CAPTURE_W * 4);
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: [subject, ...(showB ? [subject2] : [])],
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: posing === "B" ? "y-bot-tpose" : "x-bot-tpose",
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 / 20));
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
- function resizePromptClip(id, edge, rawFrame) {
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 / 20);
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 / 20);
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 / 20));
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
- const rig = posedRig();
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
- const clipFrames = duration * 20;
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: charA.x, z: charA.z, heading: null }, ...waypoints]
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
- const pathVerdict = judgeAuthoredPath(rootPath, 20, clipFrames, { chained: hasPromptSchedule });
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 > PATH_LIMITS.clipMaxS * 20);
4832
+ const longBlock = segments.find((segment) => segment.endFrame - segment.startFrame > PROMPT_BLOCK_MAX_FRAMES);
3063
4833
  if (longBlock) {
3064
4834
  setToast(isKo
3065
- ? `생성하지 못했어요 — 루트 경로가 있을 때 각 프롬프트 블록은 ${PATH_LIMITS.clipMaxS}초 이내여야 해요(ARDY 연쇄 호출 학습 범위). ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)}초 블록을 나눠 주세요`
3066
- : `Not generated — with a root path every prompt block must stay within ${PATH_LIMITS.clipMaxS} s (ARDY's trained window per chained call); split the ${((longBlock.endFrame - longBlock.startFrame) / 20).toFixed(1)} s block`);
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
- const alignedRoot = waypointMode ? alignArdyPath(rootPath, charA.rot, MAX_WAYPOINTS) : null;
3078
- const ardyWaypoints = alignedRoot ? alignedRoot.waypoints : [];
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
- const editedSegments = motion && hasPromptSchedule
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
- const shouldPin = hasBlockEdits || (!hasPromptSchedule && Boolean(motion || ikFrames.length > 0));
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: posing === "B" ? "y-bot-tpose" : "x-bot-tpose",
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 : charA.rot;
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
- if (done.motionUrl) await loadMotion(done.motionUrl, prompt, rootRotationDeg);
3200
- if (hasBlockEdits) {
3201
- setCommittedIkEdits((current) => [
3202
- ...current,
3203
- ...body.motionEdit.edits.map(({ frame, tracks }) => ({ frame, tracks })),
3204
- ]);
3205
- ikStateRef.current.keys.clear();
3206
- ikStateRef.current.tracked.clear();
3207
- ikStateRef.current.plants.clear();
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(ko("ARDY motion generated", "ARDY 모션 생성됨"));
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
- <Character
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
- url={CHARACTER_MODEL_URL}
3405
- position={[charB.x, 0, charB.z]}
3406
- rot={charB.rot}
3407
- tint={CLAY_B}
3408
- pose={poseB}
3409
- onRig={setRigB}
3410
- pickId="B"
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={posing === "B" ? rigB : rigA}
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
- charA={charA}
3507
- setCharA={setCharA}
3508
- charB={charB}
3509
- setCharB={setCharB}
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 === "b" ? "characterB" : "characterA");
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 === "char:A" ? "characterA" : id === "char:B" ? "characterB" : id ? `object:${id}` : "props",
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 apiRef={captureRef} camRef={shotCamRef} />
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={insetPos ? { left: insetPos.x, top: insetPos.y, right: "auto" } : undefined}
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
- <Foldout hidden={sidebarTab !== "shot"} title={ko("Shot type", "샷 종류")}>
3729
- <div className="presets">
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" || sidebarTab === "motion" || (sidebarTab === "inspector" && selectedHierarchyId === "camera"))} title={ko("Camera", "카메라")}>
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" || !["characters", "characterA", "characterB"].includes(selectedHierarchyId)} title={showB ? ko("Subjects", "인물들") : ko("Subject", "인물")}>
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
- <SubjectBox
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
- label={ko("Subject 2", "인물 2")}
3835
- value={charB}
3836
- onChange={setCharB}
3837
- onRemove={() => setShowB(false)}
3838
- onPose={() => openStudio("B")}
3839
- posing={posing === "B"}
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" || !["characters", "characterA", "characterB"].includes(selectedHierarchyId)} title={ko("Pose", "포즈")}>
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
- <Field label={showB ? ko("Subject 1", "인물 1") : ko("Subject", "인물")}>
3903
- <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} />
3904
- </Field>
3905
- )}
3906
- {!hasCharSheet && showB && (
3907
- <Field label={ko("Subject 2", "인물 2")}>
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
- <p className="ardy-meta">
3967
- {bridge.host ?? ko("box", "로컬")} · {isKo ? `장치 ${bridge.device ?? "알 수 없음"}` : bridge.device ?? "device?"} · {ko("encoder", "인코더")}{" "}
3968
- {bridge.encoder ?? ko("?", "알 수 없음")}
3969
- </p>
3970
- <p className="ardy-hint">{ko("Neutral skeleton · block boundaries and IK keys are pinned automatically.", "중립 스켈레톤 · 블록 경계와 IK 키는 자동으로 고정됩니다.")}</p>
3971
- <Field label={ko("Motion prompt", "모션 프롬프트")}>
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()) || ardyRunning}
6336
+ disabled={!bridge?.ok || !promptClips.some((clip) => clip.text.trim())}
4129
6337
  onClick={runAllPromptBlocks}
4130
6338
  >
4131
- {ardyRunning
4132
- ? (isKo ? `${promptClips.length}개 블록 생성 중…` : `Generating ${promptClips.length} blocks…`)
4133
- : (isKo ? `${promptClips.length}개 블록 모두 생성` : `Generate all ${promptClips.length} blocks`)}
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
- <p className="inspector-hint">{ko("Everything you add to the set lives here. Pick one to edit it, or click it in the shot view.", "세트에 추가한 모든 소품이 여기에 모입니다. 편집하려면 하나를 고르거나 샷 뷰에서 클릭하세요.")}</p>
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>