cozyclay 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -24
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/cozyclay.mjs +105 -11
- package/dist/CNAME +1 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-Cgpk2hwX.js +4803 -0
- package/dist/assets/app-DgZvaAE1.css +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +406 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +9 -0
- package/dist/sw.js +127 -0
- package/package.json +15 -3
- package/src/App.jsx +1393 -648
- package/src/ardy/client.js +5 -4
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/timeline-coordinates.js +11 -0
- package/src/ardy/timeline.jsx +528 -67
- package/src/ardy/waypoints.js +14 -9
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +9 -17
- package/src/hierarchy-panel.jsx +170 -23
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/object-catalog.jsx +32 -3
- package/src/planview.jsx +174 -66
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +53 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +75 -13
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/scenes.js +223 -0
- package/src/shot-authoring.js +204 -77
- package/src/shot.js +8 -4
- package/src/styles.css +839 -38
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +70 -4
- package/tools/ardy/README.md +55 -0
- package/tools/ardy/bridge.mjs +89 -389
- package/tools/ardy/cclay_constrained_generate.py +449 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +68 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +246 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
package/src/ardy/timeline.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { frameFromClientX, promptMoveStartFrame } from "./timeline-coordinates.js";
|
|
2
|
+
import { frameFromClientX, promptMoveStartFrame, shotBlockGeometry } from "./timeline-coordinates.js";
|
|
3
3
|
import { promptResizeFrame } from "./timeline-resize.js";
|
|
4
|
+
import { ko, isKo } from "../locale.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ARDY Viser-style animation timeline — the live motion workspace.
|
|
@@ -11,8 +12,8 @@ import { promptResizeFrame } from "./timeline-resize.js";
|
|
|
11
12
|
* The 2D Root lane authors temporal keyframes directly: left-clicking an empty
|
|
12
13
|
* track cell creates/selects a numbered waypoint, then Top-View owns its
|
|
13
14
|
* spatial position through direct marker dragging.
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
15
|
+
* The unified Shot lane authors shot-camera keyframings directly: left-clicking
|
|
16
|
+
* a block's lower strip keys the CURRENT camera framing there, dots jump
|
|
16
17
|
* the playhead on click, drag to re-time, right-click to remove. Playback
|
|
17
18
|
* and PlayView ride the keys segment by segment.
|
|
18
19
|
*/
|
|
@@ -36,13 +37,19 @@ const TRACKS = [
|
|
|
36
37
|
"Prompts",
|
|
37
38
|
"Full-Body",
|
|
38
39
|
"2D Root",
|
|
39
|
-
"
|
|
40
|
+
"Shots",
|
|
40
41
|
];
|
|
42
|
+
const TRACK_LABELS_KO = {
|
|
43
|
+
Prompts: ko("Prompts", "프롬프트"),
|
|
44
|
+
"Full-Body": ko("Full-Body", "전신"),
|
|
45
|
+
"2D Root": ko("2D Root", "2D 루트"),
|
|
46
|
+
Shots: ko("Shots", "샷"),
|
|
47
|
+
};
|
|
41
48
|
|
|
42
49
|
/** IK keys live on the Full-Body lane: one marker per keyed frame, holding
|
|
43
50
|
* a sparse set of the limbs the user has moved (never every joint). */
|
|
44
51
|
const IK_LANE = "Full-Body";
|
|
45
|
-
const
|
|
52
|
+
const SHOTS_LANE = "Shots";
|
|
46
53
|
// Ruler labels and lane gridlines share one 10-frame cadence, so authored
|
|
47
54
|
// elements (40-frame prompt blocks, camera key dots) always land on visible
|
|
48
55
|
// lines. Label density adapts to zoom in 10-based steps.
|
|
@@ -52,6 +59,109 @@ const MAX_LABELS = 30;
|
|
|
52
59
|
|
|
53
60
|
const framePct = (f, count) => (count > 1 ? f / (count - 1) : 0);
|
|
54
61
|
|
|
62
|
+
const CAMERA_BLOCK_DEFAULTS = {
|
|
63
|
+
distance: 3,
|
|
64
|
+
height: 1.6,
|
|
65
|
+
response: 0.7,
|
|
66
|
+
lead: 0.25,
|
|
67
|
+
railStartMode: "head",
|
|
68
|
+
maxDollySpeed: 4,
|
|
69
|
+
pitchOffsetDeg: 0,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function cameraBlockMode(shot) {
|
|
73
|
+
const mode = shot?.camera?.mode;
|
|
74
|
+
if (mode === "keys" || mode === "follow" || mode === "rail") return mode;
|
|
75
|
+
if (shot?.camera?.cameraRail) return "rail";
|
|
76
|
+
if (shot?.camera?.followCam?.enabled) return "follow";
|
|
77
|
+
return "keys";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cameraBlockFollow(shot) {
|
|
81
|
+
return { ...CAMERA_BLOCK_DEFAULTS, ...(shot?.camera?.followCam ?? {}) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signedDegrees(value) {
|
|
85
|
+
const rounded = Math.round(Number(value) || 0);
|
|
86
|
+
return `${rounded >= 0 ? "+" : ""}${rounded}\u00b0`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function signedValue(value) {
|
|
90
|
+
const rounded = Math.round((Number(value) || 0) * 10) / 10;
|
|
91
|
+
return `${rounded >= 0 ? "+" : ""}${rounded}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function CameraBlockEditor({ shot, blocked, previewing, railDraw, railLength, onChange, onPreview, onRailDrawToggle }) {
|
|
95
|
+
if (!shot) return null;
|
|
96
|
+
const mode = cameraBlockMode(shot);
|
|
97
|
+
const follow = cameraBlockFollow(shot);
|
|
98
|
+
const patchFollow = (patch) => onChange?.({ followCam: { ...follow, ...patch } });
|
|
99
|
+
const numberValue = (event) => Number(event.currentTarget.value);
|
|
100
|
+
const metric = (value, places = 1) => Number(value).toFixed(places);
|
|
101
|
+
return (
|
|
102
|
+
<section className="tl-camera-editor" aria-label={ko(`Camera controls for ${shot.name}`, `${shot.name} 카메라 컨트롤`)}>
|
|
103
|
+
<strong className="tl-camera-editor-title">{shot.name}</strong>
|
|
104
|
+
{blocked ? (
|
|
105
|
+
<span className="tl-camera-blocked">{ko("Turn Waypoint off to edit or preview this camera.", "카메라를 편집하거나 미리 보려면 Waypoint를 꺼주세요.")}</span>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<button type="button" className={"tl-camera-tool" + (previewing ? " active" : "")} onClick={() => onPreview?.()}>
|
|
109
|
+
{previewing ? ko("Stop", "정지") : ko("Preview", "미리보기")}
|
|
110
|
+
</button>
|
|
111
|
+
<button type="button" className={"tl-camera-tool" + (railDraw ? " active" : "")} onClick={() => onRailDrawToggle?.()}>
|
|
112
|
+
{railDraw ? ko("Drawing…", "그리는 중…") : ko("Draw rail", "레일 그리기")}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className={"tl-camera-head" + (follow.railStartMode === "head" ? " active" : "")}
|
|
117
|
+
aria-pressed={follow.railStartMode === "head"}
|
|
118
|
+
title={ko("Choose whether the dolly starts at the rail head or the nearest useful point", "돌리가 레일 시작점 또는 가까운 지점에서 출발하도록 정합니다")}
|
|
119
|
+
onClick={() => patchFollow({ railStartMode: follow.railStartMode === "head" ? "nearest" : "head" })}
|
|
120
|
+
>
|
|
121
|
+
{follow.railStartMode === "head" ? ko("Head start", "시작점 출발") : ko("Nearest", "가까운 지점")}
|
|
122
|
+
</button>
|
|
123
|
+
<label title={ko("Read automatically from the camera position", "현재 카메라 위치에서 자동으로 읽습니다")}>
|
|
124
|
+
<span>{ko("Distance", "거리")}</span>
|
|
125
|
+
<output className="tl-camera-metric">{metric(follow.distance, 2)}</output>
|
|
126
|
+
<small>m</small>
|
|
127
|
+
</label>
|
|
128
|
+
<label title={ko("Cap dolly travel speed", "돌리의 최고 이동 속도를 제한합니다")}>
|
|
129
|
+
<span>{ko("Speed", "속도")}</span>
|
|
130
|
+
<input type="number" min="0.2" max="8" step="0.1" value={follow.maxDollySpeed} onChange={(event) => patchFollow({ maxDollySpeed: numberValue(event) })} />
|
|
131
|
+
<small>m/s</small>
|
|
132
|
+
</label>
|
|
133
|
+
<label title={ko("Read automatically from the camera position", "현재 카메라 위치에서 자동으로 읽습니다")}>
|
|
134
|
+
<span>{ko("Height", "높이")}</span>
|
|
135
|
+
<output className="tl-camera-metric">{metric(follow.height, 2)}</output>
|
|
136
|
+
<small>m</small>
|
|
137
|
+
</label>
|
|
138
|
+
<label title={ko("Read automatically from the camera tilt", "현재 카메라 틸트에서 자동으로 읽습니다")}>
|
|
139
|
+
<span>{ko("Pitch", "피치")}</span>
|
|
140
|
+
<output className="tl-camera-metric">{signedValue(follow.pitchOffsetDeg)}</output>
|
|
141
|
+
<small>°</small>
|
|
142
|
+
</label>
|
|
143
|
+
<details className="tl-camera-advanced">
|
|
144
|
+
<summary>{ko("Advanced", "고급")}</summary>
|
|
145
|
+
<label title={ko("Set how softly the rig catches up", "카메라가 얼마나 부드럽게 따라붙는지 정합니다")}>
|
|
146
|
+
<span>{ko("Damping", "댐핑")}</span>
|
|
147
|
+
<input type="number" min="0.1" max="3" step="0.05" value={follow.response} onChange={(event) => patchFollow({ response: numberValue(event) })} />
|
|
148
|
+
<small>s</small>
|
|
149
|
+
</label>
|
|
150
|
+
<label title={ko("Aim ahead of subject travel", "피사체 진행 방향을 미리 조준합니다")}>
|
|
151
|
+
<span>{ko("Look-ahead", "조준 선행")}</span>
|
|
152
|
+
<input type="number" min="0" max="1" step="0.05" value={follow.lead} onChange={(event) => patchFollow({ lead: numberValue(event) })} />
|
|
153
|
+
<small>s</small>
|
|
154
|
+
</label>
|
|
155
|
+
</details>
|
|
156
|
+
<span className="tl-camera-slate">
|
|
157
|
+
{mode === "rail" ? `${ko("Dolly on rail", "레일 돌리")}${railLength == null ? "" : ` · ${railLength.toFixed(1)} m`}` : ko("Camera preview", "카메라 미리보기")}
|
|
158
|
+
</span>
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
161
|
+
</section>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
55
165
|
export default function Timeline({
|
|
56
166
|
frame,
|
|
57
167
|
frameCount = DEFAULT_FRAME_COUNT,
|
|
@@ -69,7 +179,10 @@ export default function Timeline({
|
|
|
69
179
|
ikDisabled = false, // a loaded motion owns the rig
|
|
70
180
|
ikFrames = [], // sorted full-body key frames
|
|
71
181
|
footSnap = true, // feet stay planted while the body moves
|
|
72
|
-
|
|
182
|
+
shots = [],
|
|
183
|
+
activeShotIdx = 0,
|
|
184
|
+
selectedCameraBlockIdx,
|
|
185
|
+
shotCutDisabled = false,
|
|
73
186
|
onScrub,
|
|
74
187
|
onAdvance,
|
|
75
188
|
onStep,
|
|
@@ -93,10 +206,31 @@ export default function Timeline({
|
|
|
93
206
|
onCameraKeyframeAdd,
|
|
94
207
|
onCameraKeyframeMove,
|
|
95
208
|
onCameraKeyframeRemove,
|
|
209
|
+
onCameraBlockSelect,
|
|
210
|
+
onCameraBlockChange,
|
|
211
|
+
onCameraPreview,
|
|
212
|
+
railDraw = false,
|
|
213
|
+
cameraRailLength = null,
|
|
214
|
+
onCameraRailDrawToggle,
|
|
215
|
+
onRailSelect,
|
|
216
|
+
onRailMove,
|
|
217
|
+
onRailRangeChange,
|
|
218
|
+
onRailRemove,
|
|
219
|
+
onShotSelect,
|
|
220
|
+
onShotBoundaryMove,
|
|
221
|
+
onShotRename,
|
|
222
|
+
onShotRemove,
|
|
223
|
+
onShotDuplicate,
|
|
224
|
+
onShotCut,
|
|
225
|
+
onShotSplit,
|
|
226
|
+
onShotMove,
|
|
96
227
|
}) {
|
|
97
228
|
const [expanded, setExpanded] = useState(true);
|
|
98
229
|
const [zoom, setZoom] = useState(ZOOM_DEFAULT);
|
|
99
230
|
const [movingPromptId, setMovingPromptId] = useState(null);
|
|
231
|
+
const [renamingShotId, setRenamingShotId] = useState(null);
|
|
232
|
+
const [movingShotId, setMovingShotId] = useState(null);
|
|
233
|
+
const [localCameraBlockIdx, setLocalCameraBlockIdx] = useState(null);
|
|
100
234
|
const rulerRef = useRef(null);
|
|
101
235
|
const bodyRef = useRef(null);
|
|
102
236
|
const scrubbing = useRef(false);
|
|
@@ -110,7 +244,7 @@ export default function Timeline({
|
|
|
110
244
|
// The window key/interval handlers register once; the latest callbacks
|
|
111
245
|
// are read through a ref so they never go stale mid-playback.
|
|
112
246
|
const handlers = useRef({});
|
|
113
|
-
handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove };
|
|
247
|
+
handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove, onCameraBlockSelect, onCameraBlockChange, onCameraPreview, onCameraRailDrawToggle, onRailSelect, onRailMove, onRailRangeChange, onRailRemove, onShotSelect, onShotBoundaryMove, onShotRename, onShotRemove, onShotDuplicate, onShotCut, onShotSplit, onShotMove };
|
|
114
248
|
|
|
115
249
|
// Trackpad/wheel zoom over the FRAME ruler lane only. React registers
|
|
116
250
|
// onWheel as passive, so a synthetic onWheel could never preventDefault —
|
|
@@ -254,6 +388,12 @@ export default function Timeline({
|
|
|
254
388
|
const resizeRef = useRef(null);
|
|
255
389
|
const camDragRef = useRef(null);
|
|
256
390
|
const camSuppressClickRef = useRef(false);
|
|
391
|
+
const shotBoundaryRef = useRef(null);
|
|
392
|
+
const shotMoveRef = useRef(null);
|
|
393
|
+
const shotSuppressClickRef = useRef(false);
|
|
394
|
+
const railDragRef = useRef(null);
|
|
395
|
+
const railResizeRef = useRef(null);
|
|
396
|
+
const railSuppressClickRef = useRef(false);
|
|
257
397
|
|
|
258
398
|
function beginPromptMove(e, clip) {
|
|
259
399
|
if (e.button !== 0 || e.target.closest(".tl-chip-handle")) return;
|
|
@@ -347,9 +487,10 @@ export default function Timeline({
|
|
|
347
487
|
// Camera key dots re-time by dragging, like prompt clips move: the frame
|
|
348
488
|
// delta comes from the pointer-down geometry so a growing timeline cannot
|
|
349
489
|
// feed back into the next pointermove.
|
|
350
|
-
function beginCameraKeyDrag(e, keyFrame) {
|
|
490
|
+
function beginCameraKeyDrag(e, keyFrame, shotIndex) {
|
|
351
491
|
if (e.button !== 0) return;
|
|
352
492
|
e.stopPropagation();
|
|
493
|
+
selectUnifiedShotBlock(shotIndex);
|
|
353
494
|
// A 10px dot loses the pointer almost immediately without capture —
|
|
354
495
|
// grab it on pointerdown so the drag survives past the marker's edge.
|
|
355
496
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
@@ -359,6 +500,7 @@ export default function Timeline({
|
|
|
359
500
|
pointerId: e.pointerId,
|
|
360
501
|
startClientX: e.clientX,
|
|
361
502
|
fromFrame: keyFrame,
|
|
503
|
+
shotIndex,
|
|
362
504
|
currentFrame: keyFrame,
|
|
363
505
|
laneWidth: rect?.width ?? 1,
|
|
364
506
|
displayFrameCount,
|
|
@@ -381,7 +523,7 @@ export default function Timeline({
|
|
|
381
523
|
if (next === active.currentFrame) return;
|
|
382
524
|
const from = active.currentFrame;
|
|
383
525
|
active.currentFrame = next;
|
|
384
|
-
handlers.current.onCameraKeyframeMove?.(from, next);
|
|
526
|
+
handlers.current.onCameraKeyframeMove?.(from, next, active.shotIndex);
|
|
385
527
|
}
|
|
386
528
|
|
|
387
529
|
function endCameraKeyDrag(e) {
|
|
@@ -397,17 +539,183 @@ export default function Timeline({
|
|
|
397
539
|
camDragRef.current = null;
|
|
398
540
|
}
|
|
399
541
|
|
|
400
|
-
function onCameraKeyClick(e, keyFrame) {
|
|
542
|
+
function onCameraKeyClick(e, keyFrame, shotIndex) {
|
|
401
543
|
if (camSuppressClickRef.current) {
|
|
402
544
|
e.preventDefault();
|
|
403
545
|
e.stopPropagation();
|
|
404
546
|
return;
|
|
405
547
|
}
|
|
406
548
|
e.stopPropagation();
|
|
549
|
+
selectUnifiedShotBlock(shotIndex);
|
|
407
550
|
handlers.current.onScrub?.(keyFrame);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function beginRailMove(e, shotIndex, range, duration) {
|
|
554
|
+
if (e.button !== 0) return;
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
handlers.current.onRailSelect?.(shotIndex);
|
|
557
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
558
|
+
const rect = lane?.getBoundingClientRect();
|
|
559
|
+
railDragRef.current = { shotIndex, pointerId: e.pointerId, startClientX: e.clientX, startFrame: range.start, length: range.end - range.start + 1, duration, laneWidth: rect?.width ?? 1, moved: false };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function moveRail(e) {
|
|
563
|
+
const active = railDragRef.current;
|
|
564
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
565
|
+
if (!active.moved && Math.abs(e.clientX - active.startClientX) < 4) return;
|
|
566
|
+
active.moved = true;
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
e.stopPropagation();
|
|
569
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
570
|
+
const delta = Math.round((e.clientX - active.startClientX) * Math.max(0, displayFrameCount - 1) / Math.max(1, active.laneWidth));
|
|
571
|
+
const next = Math.max(0, Math.min(active.duration - active.length, active.startFrame + delta));
|
|
572
|
+
handlers.current.onRailMove?.(active.shotIndex, next);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function endRailMove(e) {
|
|
576
|
+
const active = railDragRef.current;
|
|
577
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
578
|
+
if (active.moved) {
|
|
579
|
+
e.preventDefault();
|
|
580
|
+
e.stopPropagation();
|
|
581
|
+
railSuppressClickRef.current = true;
|
|
582
|
+
queueMicrotask(() => { railSuppressClickRef.current = false; });
|
|
583
|
+
}
|
|
584
|
+
railDragRef.current = null;
|
|
585
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function beginRailResize(e, shotIndex, edge, range, duration) {
|
|
589
|
+
if (e.button !== 0) return;
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
e.stopPropagation();
|
|
592
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
593
|
+
handlers.current.onRailSelect?.(shotIndex);
|
|
594
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
595
|
+
const rect = lane?.getBoundingClientRect();
|
|
596
|
+
railResizeRef.current = { shotIndex, edge, pointerId: e.pointerId, startClientX: e.clientX, startFrame: edge === "start" ? range.start : range.end, duration, laneWidth: rect?.width ?? 1 };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function moveRailResize(e) {
|
|
600
|
+
const active = railResizeRef.current;
|
|
601
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
e.stopPropagation();
|
|
604
|
+
const delta = Math.round((e.clientX - active.startClientX) * Math.max(0, displayFrameCount - 1) / Math.max(1, active.laneWidth));
|
|
605
|
+
const next = Math.max(0, Math.min(active.duration - 1, active.startFrame + delta));
|
|
606
|
+
handlers.current.onRailRangeChange?.(active.shotIndex, active.edge, next);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function endRailResize(e) {
|
|
610
|
+
if (!railResizeRef.current || e.pointerId !== railResizeRef.current.pointerId) return;
|
|
611
|
+
railResizeRef.current = null;
|
|
612
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function onRailKeyDown(e, shotIndex, range, duration) {
|
|
616
|
+
const edge = e.currentTarget.dataset.railEdge;
|
|
617
|
+
if (e.key === "Delete" || e.key === "Backspace") {
|
|
618
|
+
e.preventDefault();
|
|
619
|
+
e.stopPropagation();
|
|
620
|
+
handlers.current.onRailRemove?.(shotIndex);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
e.stopPropagation();
|
|
626
|
+
const delta = (e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? 10 : 1);
|
|
627
|
+
if (edge === "body") handlers.current.onRailMove?.(shotIndex, Math.max(0, Math.min(duration - (range.end - range.start + 1), range.start + delta)));
|
|
628
|
+
else handlers.current.onRailRangeChange?.(shotIndex, edge, Math.max(0, Math.min(duration - 1, (edge === "start" ? range.start : range.end) + delta)));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function beginShotBoundaryDrag(e, shotIndex, edge) {
|
|
632
|
+
if (e.button !== 0) return;
|
|
633
|
+
e.preventDefault();
|
|
634
|
+
e.stopPropagation();
|
|
635
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
636
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
637
|
+
const rect = lane?.getBoundingClientRect();
|
|
638
|
+
shotBoundaryRef.current = { shotIndex, edge, pointerId: e.pointerId, left: rect?.left ?? 0, width: rect?.width ?? 1, displayFrameCount };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function moveShotBoundary(e) {
|
|
642
|
+
const active = shotBoundaryRef.current;
|
|
643
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
e.stopPropagation();
|
|
646
|
+
const ratio = (e.clientX - active.left) / Math.max(1, active.width);
|
|
647
|
+
const rawFrame = Math.round(ratio * Math.max(0, active.displayFrameCount - 1));
|
|
648
|
+
handlers.current.onShotBoundaryMove?.(active.shotIndex, active.edge, Math.max(0, Math.min(frameCount - 1, rawFrame)));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function endShotBoundaryDrag(e) {
|
|
652
|
+
const active = shotBoundaryRef.current;
|
|
653
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
654
|
+
shotBoundaryRef.current = null;
|
|
655
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function beginShotMove(e, shot, index) {
|
|
659
|
+
if (e.button !== 0 || e.target.closest("button, input")) return;
|
|
660
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
661
|
+
const rect = lane?.getBoundingClientRect();
|
|
662
|
+
shotMoveRef.current = { id: shot.id, index, pointerId: e.pointerId, startClientX: e.clientX, left: rect?.left ?? 0, width: rect?.width ?? 1, targetFrame: shot.startFrame, moved: false };
|
|
663
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function moveShot(e) {
|
|
667
|
+
const active = shotMoveRef.current;
|
|
668
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
669
|
+
if (!active.moved && Math.abs(e.clientX - active.startClientX) < 4) return;
|
|
670
|
+
active.moved = true;
|
|
671
|
+
active.targetFrame = frameFromClientX(e.clientX, active.left, active.width, displayFrameCount, frameCount);
|
|
672
|
+
setMovingShotId(active.id);
|
|
673
|
+
e.preventDefault();
|
|
674
|
+
e.stopPropagation();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function endShotMove(e) {
|
|
678
|
+
const active = shotMoveRef.current;
|
|
679
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
680
|
+
if (active.moved) {
|
|
681
|
+
e.preventDefault();
|
|
682
|
+
e.stopPropagation();
|
|
683
|
+
shotSuppressClickRef.current = true;
|
|
684
|
+
handlers.current.onShotMove?.(active.index, active.targetFrame);
|
|
685
|
+
queueMicrotask(() => { shotSuppressClickRef.current = false; });
|
|
686
|
+
}
|
|
687
|
+
shotMoveRef.current = null;
|
|
688
|
+
setMovingShotId(null);
|
|
689
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function selectUnifiedShotBlock(index) {
|
|
693
|
+
if (shotSuppressClickRef.current) return;
|
|
694
|
+
setLocalCameraBlockIdx(index);
|
|
695
|
+
handlers.current.onCameraBlockSelect?.(index);
|
|
696
|
+
handlers.current.onShotSelect?.(index);
|
|
697
|
+
if (!handlers.current.onShotSelect) handlers.current.onScrub?.(shots[index]?.startFrame ?? 0);
|
|
408
698
|
handlers.current.onCameraMoveSelect?.();
|
|
409
699
|
}
|
|
410
700
|
|
|
701
|
+
function addCameraKeyFromBlock(e, index) {
|
|
702
|
+
if (e.button !== 0) return;
|
|
703
|
+
e.preventDefault();
|
|
704
|
+
e.stopPropagation();
|
|
705
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
706
|
+
const rect = lane?.getBoundingClientRect();
|
|
707
|
+
if (!rect) return;
|
|
708
|
+
const target = frameFromClientX(e.clientX, rect.left, rect.width, displayFrameCount, frameCount);
|
|
709
|
+
selectUnifiedShotBlock(index);
|
|
710
|
+
handlers.current.onCameraKeyframeAdd?.(target, index);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function finishShotRename(shot, index, value) {
|
|
714
|
+
const name = value.trim();
|
|
715
|
+
if (name && name !== shot.name) handlers.current.onShotRename?.(index, name);
|
|
716
|
+
setRenamingShotId(null);
|
|
717
|
+
}
|
|
718
|
+
|
|
411
719
|
function frameFromEvent(e) {
|
|
412
720
|
const el = rulerRef.current;
|
|
413
721
|
if (!el) return frame;
|
|
@@ -438,17 +746,20 @@ export default function Timeline({
|
|
|
438
746
|
rulerRef.current?.releasePointerCapture?.(e.pointerId);
|
|
439
747
|
}
|
|
440
748
|
|
|
749
|
+
const cameraBlockIdx = selectedCameraBlockIdx === undefined ? localCameraBlockIdx : selectedCameraBlockIdx;
|
|
750
|
+
const selectedCameraShot = cameraBlockIdx == null ? null : shots[cameraBlockIdx] ?? null;
|
|
751
|
+
|
|
441
752
|
return (
|
|
442
|
-
<section className={"timeline" + (expanded ? "" : " collapsed")} aria-label="Animation timeline">
|
|
753
|
+
<section className={"timeline" + (expanded ? "" : " collapsed")} aria-label={ko("Animation timeline", "애니메이션 타임라인")}>
|
|
443
754
|
{expanded ? (
|
|
444
755
|
<>
|
|
445
756
|
<div className="tl-head">
|
|
446
|
-
<div className="tl-transport" aria-label="Playback transport">
|
|
757
|
+
<div className="tl-transport" aria-label={ko("Playback transport", "재생 컨트롤")}>
|
|
447
758
|
<button
|
|
448
759
|
type="button"
|
|
449
760
|
className="tl-btn"
|
|
450
|
-
aria-label="Previous frame"
|
|
451
|
-
title="Previous frame (k)"
|
|
761
|
+
aria-label={ko("Previous frame", "이전 프레임")}
|
|
762
|
+
title={ko("Previous frame (k)", "이전 프레임 (k)")}
|
|
452
763
|
onClick={() => handlers.current.onStep?.(-1)}
|
|
453
764
|
>
|
|
454
765
|
‹
|
|
@@ -456,8 +767,8 @@ export default function Timeline({
|
|
|
456
767
|
<button
|
|
457
768
|
type="button"
|
|
458
769
|
className={"tl-btn play" + (playing ? " on" : "")}
|
|
459
|
-
aria-label={playing ? "Pause playback" : "Play playback"}
|
|
460
|
-
title="Play / pause (Space)"
|
|
770
|
+
aria-label={playing ? ko("Pause playback", "재생 일시중지") : ko("Play playback", "재생 시작")}
|
|
771
|
+
title={ko("Play / pause (Space)", "재생/일시중지 (Space)")}
|
|
461
772
|
onClick={() => handlers.current.onPlayToggle?.()}
|
|
462
773
|
>
|
|
463
774
|
{playing ? "❚❚" : "▶"}
|
|
@@ -465,8 +776,8 @@ export default function Timeline({
|
|
|
465
776
|
<button
|
|
466
777
|
type="button"
|
|
467
778
|
className="tl-btn"
|
|
468
|
-
aria-label="Next frame"
|
|
469
|
-
title="Next frame (j)"
|
|
779
|
+
aria-label={ko("Next frame", "다음 프레임")}
|
|
780
|
+
title={ko("Next frame (j)", "다음 프레임 (j)")}
|
|
470
781
|
onClick={() => handlers.current.onStep?.(1)}
|
|
471
782
|
>
|
|
472
783
|
›
|
|
@@ -478,7 +789,7 @@ export default function Timeline({
|
|
|
478
789
|
<button
|
|
479
790
|
type="button"
|
|
480
791
|
className={"tl-btn zoom" + (zoom !== ZOOM_DEFAULT ? " on" : "")}
|
|
481
|
-
title="Two-finger up/down over FRAME ruler to zoom — click to reset to 1×"
|
|
792
|
+
title={ko("Two-finger up/down over FRAME ruler to zoom — click to reset to 1×", "프레임 눈금 위에서 두 손가락으로 위아래 스크롤해 확대/축소 · 클릭하면 1×로 초기화")}
|
|
482
793
|
onClick={resetZoom}
|
|
483
794
|
>
|
|
484
795
|
{zoom.toFixed(2)}×
|
|
@@ -487,39 +798,45 @@ export default function Timeline({
|
|
|
487
798
|
type="button"
|
|
488
799
|
className={"tl-btn wp" + (waypointMode ? " on" : "")}
|
|
489
800
|
aria-pressed={waypointMode}
|
|
490
|
-
title="Enable or disable 2D Root path constraints (P)"
|
|
801
|
+
title={ko("Enable or disable 2D Root path constraints (P)", "2D 루트 경로 제약 켜기/끄기 (P)")}
|
|
491
802
|
onClick={() => handlers.current.onWaypointToggle?.()}
|
|
492
803
|
>
|
|
493
|
-
Waypoint {waypointMode ? "on" : "off"}
|
|
804
|
+
{isKo ? `웨이포인트 ${waypointMode ? "켜짐" : "꺼짐"}` : `Waypoint ${waypointMode ? "on" : "off"}`}
|
|
494
805
|
</button>
|
|
495
806
|
<button
|
|
496
807
|
type="button"
|
|
497
808
|
className={"tl-btn ik" + (ikMode ? " on" : "")}
|
|
498
809
|
aria-pressed={ikMode}
|
|
499
810
|
disabled={ikDisabled && !ikMode}
|
|
500
|
-
title={ikDisabled && !ikMode ? "IK needs Subject 1's rig loaded" : "IK mode — drag a wrist / ankle handle; keys land on the Full-Body lane. With a motion loaded, keys correct it layer-style"}
|
|
811
|
+
title={ikDisabled && !ikMode ? ko("IK needs Subject 1's rig loaded", "IK를 사용하려면 인물 1의 리그를 먼저 불러와야 해요") : ko("IK mode — drag a wrist / ankle handle; keys land on the Full-Body lane. With a motion loaded, keys correct it layer-style", "IK 모드 — 손목이나 발목 핸들을 드래그하세요. 키는 전신 레인에 찍히며, 모션을 불러온 뒤에는 레이어 방식으로 보정합니다")}
|
|
501
812
|
onClick={() => handlers.current.onIkToggle?.()}
|
|
502
813
|
>
|
|
503
|
-
IK {ikMode ? "on" : "off"}
|
|
814
|
+
{isKo ? `IK ${ikMode ? "켜짐" : "꺼짐"}` : `IK ${ikMode ? "on" : "off"}`}
|
|
504
815
|
</button>
|
|
505
816
|
<button
|
|
506
817
|
type="button"
|
|
507
818
|
className={"tl-btn ik snap" + (footSnap ? " on" : "")}
|
|
508
819
|
aria-pressed={footSnap}
|
|
509
|
-
title="Foot snap — keep the feet planted while you move the body (hips); the knees bend instead of the feet sinking through the floor"
|
|
820
|
+
title={ko("Foot snap — keep the feet planted while you move the body (hips); the knees bend instead of the feet sinking through the floor", "발 스냅 — 몸(엉덩이)을 움직여도 발을 바닥에 고정합니다. 발이 바닥으로 가라앉는 대신 무릎이 구부러집니다")}
|
|
510
821
|
onClick={() => handlers.current.onFootSnapToggle?.()}
|
|
511
822
|
>
|
|
512
|
-
Snap {footSnap ? "on" : "off"}
|
|
823
|
+
{isKo ? `스냅 ${footSnap ? "켜짐" : "꺼짐"}` : `Snap ${footSnap ? "on" : "off"}`}
|
|
513
824
|
</button>
|
|
514
825
|
{waypointMode && (
|
|
515
826
|
<span className={"tl-wp-hint" + (waypointFrames.length < 2 || pathSpeed?.warn ? " warn" : "")}>
|
|
516
827
|
{waypointFrames.length < 2
|
|
517
|
-
? "Click the set floor in the Shot view to drop waypoints"
|
|
518
|
-
:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
828
|
+
? ko("Click the set floor in the Shot view to drop waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 놓으세요")
|
|
829
|
+
: isKo
|
|
830
|
+
? `루트 웨이포인트 ${waypointFrames.length}개` +
|
|
831
|
+
(pathSpeed
|
|
832
|
+
? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — 자연스러운 이동 속도 0.5–3 m/s 범위를 벗어남" : ""}`
|
|
833
|
+
: "") +
|
|
834
|
+
" · 세트 바닥을 클릭해 더 추가"
|
|
835
|
+
: `${waypointFrames.length} root waypoints` +
|
|
836
|
+
(pathSpeed
|
|
837
|
+
? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — outside the natural 0.5–3 m/s locomotion band" : ""}`
|
|
838
|
+
: "") +
|
|
839
|
+
" · click the set floor to add more"}
|
|
523
840
|
</span>
|
|
524
841
|
)}
|
|
525
842
|
{badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
|
|
@@ -527,33 +844,45 @@ export default function Timeline({
|
|
|
527
844
|
<button
|
|
528
845
|
type="button"
|
|
529
846
|
className="tl-btn clear"
|
|
530
|
-
title="Clear motion and restore the blocking pose"
|
|
847
|
+
title={ko("Clear motion and restore the blocking pose", "모션을 지우고 블로킹 포즈로 되돌리기")}
|
|
531
848
|
onClick={onClearMotion}
|
|
532
849
|
>
|
|
533
|
-
✕ Motion
|
|
850
|
+
✕ {ko("Motion", "모션")}
|
|
534
851
|
</button>
|
|
535
852
|
)}
|
|
536
853
|
<button
|
|
537
854
|
type="button"
|
|
538
855
|
className="tl-toggle"
|
|
539
856
|
aria-expanded="true"
|
|
540
|
-
aria-label="Collapse timeline"
|
|
541
|
-
title="Collapse timeline"
|
|
857
|
+
aria-label={ko("Collapse timeline", "타임라인 접기")}
|
|
858
|
+
title={ko("Collapse timeline", "타임라인 접기")}
|
|
542
859
|
onClick={() => setExpanded(false)}
|
|
543
860
|
>
|
|
544
861
|
▾
|
|
545
862
|
</button>
|
|
546
863
|
</div>
|
|
864
|
+
{selectedCameraShot && (
|
|
865
|
+
<CameraBlockEditor
|
|
866
|
+
shot={selectedCameraShot}
|
|
867
|
+
blocked={waypointMode}
|
|
868
|
+
previewing={playing}
|
|
869
|
+
railDraw={railDraw}
|
|
870
|
+
railLength={cameraRailLength}
|
|
871
|
+
onChange={(patch) => handlers.current.onCameraBlockChange?.(patch)}
|
|
872
|
+
onPreview={() => handlers.current.onCameraPreview?.(cameraBlockIdx)}
|
|
873
|
+
onRailDrawToggle={() => handlers.current.onCameraRailDrawToggle?.()}
|
|
874
|
+
/>
|
|
875
|
+
)}
|
|
547
876
|
|
|
548
877
|
<div className="tl-body" ref={bodyRef}>
|
|
549
878
|
<div className="tl-surface" style={{ "--tl-zoom": surfaceZoom }}>
|
|
550
879
|
<div className="tl-ruler">
|
|
551
|
-
<span className="tl-ruler-label">Frame</span>
|
|
880
|
+
<span className="tl-ruler-label">{ko("Frame", "프레임")}</span>
|
|
552
881
|
<div
|
|
553
882
|
className="tl-ruler-lane"
|
|
554
883
|
ref={rulerRef}
|
|
555
884
|
role="slider"
|
|
556
|
-
aria-label="Scrub timeline"
|
|
885
|
+
aria-label={ko("Scrub timeline", "타임라인 탐색")}
|
|
557
886
|
aria-valuemin={0}
|
|
558
887
|
aria-valuemax={frameCount - 1}
|
|
559
888
|
aria-valuenow={frame}
|
|
@@ -585,15 +914,26 @@ export default function Timeline({
|
|
|
585
914
|
</div>
|
|
586
915
|
|
|
587
916
|
{TRACKS.map((name) => (
|
|
588
|
-
<div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "")} key={name}>
|
|
917
|
+
<div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "") + (name === SHOTS_LANE ? " shots" : "")} key={name}>
|
|
589
918
|
<span className="tl-track-label">
|
|
590
|
-
{name}
|
|
591
|
-
{name === "Prompts" && <button className="tl-track-add" type="button" title="Add 2 second prompt clip" onClick={() => handlers.current.onPromptAdd?.(frame)}>+</button>}
|
|
919
|
+
{TRACK_LABELS_KO[name]}
|
|
920
|
+
{name === "Prompts" && <button className="tl-track-add" type="button" title={ko("Add 2 second prompt clip", "2초 프롬프트 클립 추가")} onClick={() => handlers.current.onPromptAdd?.(frame)}>+</button>}
|
|
921
|
+
{name === SHOTS_LANE && (
|
|
922
|
+
<button
|
|
923
|
+
type="button"
|
|
924
|
+
className="tl-track-add cut"
|
|
925
|
+
disabled={shotCutDisabled}
|
|
926
|
+
title={ko("Add a 2 second shot without changing existing shots", "기존 샷을 바꾸지 않고 2초 샷 추가")}
|
|
927
|
+
onClick={() => handlers.current.onShotCut?.()}
|
|
928
|
+
>
|
|
929
|
+
{ko("+ Add shot", "+ 샷 추가")}
|
|
930
|
+
</button>
|
|
931
|
+
)}
|
|
592
932
|
{name === IK_LANE && ikMode && (
|
|
593
933
|
<button
|
|
594
934
|
className="tl-track-add ik"
|
|
595
935
|
type="button"
|
|
596
|
-
title={`Key the current pose at frame ${frame}`}
|
|
936
|
+
title={isKo ? `현재 포즈를 ${frame}프레임에 키로 저장` : `Key the current pose at frame ${frame}`}
|
|
597
937
|
onClick={() => handlers.current.onIkKeyframeAdd?.()}
|
|
598
938
|
>
|
|
599
939
|
+
|
|
@@ -601,32 +941,151 @@ export default function Timeline({
|
|
|
601
941
|
)}
|
|
602
942
|
</span>
|
|
603
943
|
<div
|
|
604
|
-
className={"tl-lane" + (name === "2D Root" ? " root" : "") + (name ===
|
|
944
|
+
className={"tl-lane" + (name === "2D Root" ? " root" : "") + (name === SHOTS_LANE ? " shots" : "")}
|
|
605
945
|
onPointerDown={
|
|
606
946
|
name === "2D Root"
|
|
607
947
|
? (e) => {
|
|
608
948
|
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
|
609
949
|
handlers.current.onRootKeyframeAdd?.(rootFrameFromEvent(e));
|
|
610
950
|
}
|
|
611
|
-
:
|
|
612
|
-
? (e) => {
|
|
613
|
-
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
|
614
|
-
handlers.current.onCameraKeyframeAdd?.(rootFrameFromEvent(e));
|
|
615
|
-
}
|
|
616
|
-
: undefined
|
|
951
|
+
: undefined
|
|
617
952
|
}
|
|
618
|
-
title={name === CAMERA_LANE ? "Click to key the current camera framing at this frame" : undefined}
|
|
619
953
|
>
|
|
620
954
|
{gridFrames.map((f) => (
|
|
621
955
|
<i key={f} className="tl-grid" style={{ "--tl-f": framePct(f, displayFrameCount) }} aria-hidden="true" />
|
|
622
956
|
))}
|
|
957
|
+
{name === SHOTS_LANE && shots.length === 0 && (
|
|
958
|
+
<p className="tl-shot-empty">{ko("No shots — free camera owns the timeline. Add one at the playhead.", "샷 없음 — 자유 카메라 구간입니다. 재생 헤드에 샷을 추가하세요.")}</p>
|
|
959
|
+
)}
|
|
960
|
+
{name === SHOTS_LANE && shots.map((shot, index) => {
|
|
961
|
+
const geometry = shotBlockGeometry(shots, index, frameCount, displayFrameCount);
|
|
962
|
+
if (!geometry) return null;
|
|
963
|
+
const mode = cameraBlockMode(shot);
|
|
964
|
+
const follow = cameraBlockFollow(shot);
|
|
965
|
+
const keyCount = shot.cameraKeys?.length ?? 0;
|
|
966
|
+
const lastFrame = shot.endFrame;
|
|
967
|
+
const durationS = (lastFrame - shot.startFrame + 1) / Math.max(1, fps);
|
|
968
|
+
const stateLabel = mode === "keys" && keyCount === 0 ? "FREE" : "LOCKED";
|
|
969
|
+
const modeLabel = mode === "rail" ? "RAIL" : mode === "follow" ? "FOLLOW" : `KEYS ${keyCount}`;
|
|
970
|
+
const detailLabel = mode === "rail"
|
|
971
|
+
? `${follow.railStartMode === "head" ? "HEAD" : "NEAREST"} · ${Number(follow.maxDollySpeed).toFixed(1)} m/s · PITCH ${signedDegrees(follow.pitchOffsetDeg)}`
|
|
972
|
+
: mode === "follow"
|
|
973
|
+
? `${Number(follow.distance).toFixed(1)} m · PITCH ${signedDegrees(follow.pitchOffsetDeg)}`
|
|
974
|
+
: null;
|
|
975
|
+
const durationFrames = lastFrame - shot.startFrame + 1;
|
|
976
|
+
const railAvailable = Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2 && durationFrames >= 10;
|
|
977
|
+
const storedRail = shot.camera?.railFollow;
|
|
978
|
+
const railRange = railAvailable
|
|
979
|
+
? storedRail?.mode === "range"
|
|
980
|
+
? { start: Math.max(0, Math.min(durationFrames - 1, storedRail.startFrame)), end: Math.max(0, Math.min(durationFrames - 1, storedRail.endFrame)) }
|
|
981
|
+
: { start: 0, end: durationFrames - 1 }
|
|
982
|
+
: null;
|
|
983
|
+
const railOff = storedRail?.mode === "off" || mode !== "rail";
|
|
984
|
+
const localProgress = frame - shot.startFrame;
|
|
985
|
+
const railProgress = !railOff && railRange && localProgress >= railRange.start && localProgress <= railRange.end
|
|
986
|
+
? (localProgress - railRange.start) / Math.max(1, railRange.end - railRange.start)
|
|
987
|
+
: null;
|
|
988
|
+
return (
|
|
989
|
+
<div
|
|
990
|
+
key={shot.id}
|
|
991
|
+
className={"tl-shot-block" + (index === cameraBlockIdx ? " selected" : "") + (index === activeShotIdx ? " active" : "") + (movingShotId === shot.id ? " moving" : "")}
|
|
992
|
+
style={{ "--tl-f-start": geometry.startPct, "--tl-f-end": geometry.endPct }}
|
|
993
|
+
title={isKo ? `${shot.name} · ${shot.startFrame}–${lastFrame}프레임 · 드래그해 순서 이동, 양끝으로 컷 조절, 아래 빈 줄을 클릭해 카메라 키 추가` : `${shot.name} · frames ${shot.startFrame}–${lastFrame} · drag to reorder, trim cuts at either edge, click the empty lower strip to add a camera key`}
|
|
994
|
+
onPointerDown={(e) => beginShotMove(e, shot, index)}
|
|
995
|
+
onPointerMove={moveShot}
|
|
996
|
+
onPointerUp={endShotMove}
|
|
997
|
+
onPointerCancel={endShotMove}
|
|
998
|
+
onClick={() => selectUnifiedShotBlock(index)}
|
|
999
|
+
onDoubleClick={(e) => { e.stopPropagation(); setRenamingShotId(shot.id); }}
|
|
1000
|
+
>
|
|
1001
|
+
<button
|
|
1002
|
+
type="button"
|
|
1003
|
+
className="tl-shot-edge start"
|
|
1004
|
+
aria-label={ko(`Resize start of ${shot.name}`, `${shot.name} 시작점 조절`)}
|
|
1005
|
+
onPointerDown={(e) => beginShotBoundaryDrag(e, index, "start")}
|
|
1006
|
+
onPointerMove={moveShotBoundary}
|
|
1007
|
+
onPointerUp={endShotBoundaryDrag}
|
|
1008
|
+
onPointerCancel={endShotBoundaryDrag}
|
|
1009
|
+
>
|
|
1010
|
+
⋮
|
|
1011
|
+
</button>
|
|
1012
|
+
<button
|
|
1013
|
+
type="button"
|
|
1014
|
+
className="tl-shot-edge end"
|
|
1015
|
+
aria-label={ko(`Resize end of ${shot.name}`, `${shot.name} 끝 길이 조절`)}
|
|
1016
|
+
onPointerDown={(e) => beginShotBoundaryDrag(e, index, "end")}
|
|
1017
|
+
onPointerMove={moveShotBoundary}
|
|
1018
|
+
onPointerUp={endShotBoundaryDrag}
|
|
1019
|
+
onPointerCancel={endShotBoundaryDrag}
|
|
1020
|
+
>
|
|
1021
|
+
⋮
|
|
1022
|
+
</button>
|
|
1023
|
+
{renamingShotId === shot.id ? (
|
|
1024
|
+
<input
|
|
1025
|
+
className="tl-shot-name-input"
|
|
1026
|
+
defaultValue={shot.name}
|
|
1027
|
+
autoFocus
|
|
1028
|
+
onClick={(e) => e.stopPropagation()}
|
|
1029
|
+
onBlur={(e) => finishShotRename(shot, index, e.target.value)}
|
|
1030
|
+
onKeyDown={(e) => {
|
|
1031
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
1032
|
+
if (e.key === "Escape") setRenamingShotId(null);
|
|
1033
|
+
}}
|
|
1034
|
+
/>
|
|
1035
|
+
) : (
|
|
1036
|
+
<span className="tl-shot-label">
|
|
1037
|
+
<b>{shot.name}</b>
|
|
1038
|
+
<small>{shot.startFrame}–{lastFrame} · {durationS.toFixed(1)}{ko("s", "초")}</small>
|
|
1039
|
+
</span>
|
|
1040
|
+
)}
|
|
1041
|
+
<span className="tl-shot-actions">
|
|
1042
|
+
<button type="button" title={ko("Split at the playhead", "재생 헤드에서 분할")} disabled={frame <= shot.startFrame || frame > shot.endFrame} onClick={(e) => { e.stopPropagation(); handlers.current.onShotSplit?.(index); }}>{ko("Split", "분할")}</button>
|
|
1043
|
+
<button type="button" title={ko("Duplicate shot", "샷 복제")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotDuplicate?.(index); }}>{ko("Duplicate", "복제")}</button>
|
|
1044
|
+
<button type="button" title={ko("Delete shot and leave free-camera time", "샷을 지우고 자유 카메라 구간으로 비우기")} onClick={(e) => { e.stopPropagation(); handlers.current.onShotRemove?.(index); }}>{ko("Delete", "삭제")}</button>
|
|
1045
|
+
</span>
|
|
1046
|
+
<span className="tl-shot-camera-summary">
|
|
1047
|
+
<span className="tl-camera-block-state">{stateLabel}</span>
|
|
1048
|
+
<b>{modeLabel}</b>
|
|
1049
|
+
{detailLabel && <small>{detailLabel}</small>}
|
|
1050
|
+
</span>
|
|
1051
|
+
{railRange && (
|
|
1052
|
+
<div
|
|
1053
|
+
role="group"
|
|
1054
|
+
aria-label={ko(`Rail follow range for ${shot.name}`, `${shot.name} 레일 팔로우 구간`)}
|
|
1055
|
+
className={"tl-rail" + (index === cameraBlockIdx ? " selected" : "") + (railOff ? " off" : "")}
|
|
1056
|
+
style={{ "--tl-f-start": railRange.start / Math.max(1, durationFrames - 1), "--tl-f-end": railRange.end / Math.max(1, durationFrames - 1) }}
|
|
1057
|
+
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handlers.current.onRailRemove?.(index); }}
|
|
1058
|
+
>
|
|
1059
|
+
<button type="button" tabIndex={0} data-rail-edge="start" className="tl-rail-handle start" aria-label={ko("Resize rail follow start", "레일 팔로우 시작점 조절")} onKeyDown={(e) => onRailKeyDown(e, index, railRange, durationFrames)} onPointerDown={(e) => beginRailResize(e, index, "start", railRange, durationFrames)} onPointerMove={moveRailResize} onPointerUp={endRailResize} onPointerCancel={endRailResize} />
|
|
1060
|
+
<button type="button" tabIndex={0} data-rail-edge="body" className="tl-rail-body" aria-label={ko("Rail follow — drag to move, right-click to remove", "레일 팔로우 — 드래그로 이동, 오른쪽 클릭으로 삭제")} onKeyDown={(e) => onRailKeyDown(e, index, railRange, durationFrames)} onPointerDown={(e) => beginRailMove(e, index, railRange, durationFrames)} onPointerMove={moveRail} onPointerUp={endRailMove} onPointerCancel={endRailMove} onClick={(e) => { e.stopPropagation(); if (!railSuppressClickRef.current) handlers.current.onRailSelect?.(index); }}>
|
|
1061
|
+
<span className="tl-rail-label">{ko("Rail Follow", "레일 팔로우")}</span>
|
|
1062
|
+
{railOff && <span className="tl-rail-off">{ko("OFF", "꺼짐")}</span>}
|
|
1063
|
+
</button>
|
|
1064
|
+
<button type="button" tabIndex={0} data-rail-edge="end" className="tl-rail-handle end" aria-label={ko("Resize rail follow end", "레일 팔로우 끝점 조절")} onKeyDown={(e) => onRailKeyDown(e, index, railRange, durationFrames)} onPointerDown={(e) => beginRailResize(e, index, "end", railRange, durationFrames)} onPointerMove={moveRailResize} onPointerUp={endRailResize} onPointerCancel={endRailResize} />
|
|
1065
|
+
{railProgress != null && <i className="tl-rail-progress" style={{ "--tl-rail-p": railProgress }} aria-hidden="true" />}
|
|
1066
|
+
</div>
|
|
1067
|
+
)}
|
|
1068
|
+
{/* The card body owns selection/reorder; this narrow empty strip
|
|
1069
|
+
owns keying. Keeping it a button excludes it from beginShotMove,
|
|
1070
|
+
so one gesture cannot both reorder a shot and author a key. */}
|
|
1071
|
+
<button
|
|
1072
|
+
type="button"
|
|
1073
|
+
className="tl-shot-key-surface"
|
|
1074
|
+
aria-label={ko(`Add camera key in ${shot.name}`, `${shot.name}에 카메라 키 추가`)}
|
|
1075
|
+
title={ko("Click at a frame to store the current camera framing", "프레임 위치를 클릭해 현재 카메라 프레이밍을 저장합니다")}
|
|
1076
|
+
onClick={(event) => addCameraKeyFromBlock(event, index)}
|
|
1077
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
1078
|
+
/>
|
|
1079
|
+
</div>
|
|
1080
|
+
);
|
|
1081
|
+
})}
|
|
623
1082
|
{name === "Prompts" && promptClips.map((clip) => {
|
|
624
1083
|
const duration = ((clip.endFrame - clip.startFrame) / Math.max(1, fps)).toFixed(1);
|
|
625
1084
|
return (
|
|
626
|
-
<div key={clip.id} className={"tl-chip" + (selectedPromptId === clip.id ? " selected" : "") + (movingPromptId === clip.id ? " moving" : "")} style={{ "--tl-f-start": clipPct(clip.startFrame), "--tl-f-end": clipPct(clip.endFrame) }} title="Drag to move · edge handles resize · right-click removes" onPointerDown={(e) => beginPromptMove(e, clip)} onPointerMove={movePrompt} onPointerUp={endPromptMove} onPointerCancel={endPromptMove} onClick={blockPromptClick} onContextMenu={(e) => { e.preventDefault(); handlers.current.onPromptRemove?.(clip.id); }}>
|
|
627
|
-
<button className="tl-chip-handle start" type="button" aria-label="Resize prompt start" onPointerDown={(e) => beginPromptResize(e, clip, "start")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
|
|
628
|
-
<input className="tl-chip-input" value={clip.text} placeholder={`${duration}s · motion prompt`} maxLength={500} onChange={(e) => handlers.current.onPromptChange?.(clip.id, e.target.value)} />
|
|
629
|
-
<button className="tl-chip-handle end" type="button" aria-label="Resize prompt end" onPointerDown={(e) => beginPromptResize(e, clip, "end")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
|
|
1085
|
+
<div key={clip.id} className={"tl-chip" + (selectedPromptId === clip.id ? " selected" : "") + (movingPromptId === clip.id ? " moving" : "")} style={{ "--tl-f-start": clipPct(clip.startFrame), "--tl-f-end": clipPct(clip.endFrame) }} title={ko("Drag to move · edge handles resize · right-click removes", "드래그로 이동 · 가장자리 핸들로 길이 조절 · 오른쪽 클릭으로 삭제")} onPointerDown={(e) => beginPromptMove(e, clip)} onPointerMove={movePrompt} onPointerUp={endPromptMove} onPointerCancel={endPromptMove} onClick={blockPromptClick} onContextMenu={(e) => { e.preventDefault(); handlers.current.onPromptRemove?.(clip.id); }}>
|
|
1086
|
+
<button className="tl-chip-handle start" type="button" aria-label={ko("Resize prompt start", "프롬프트 시작점 조절")} onPointerDown={(e) => beginPromptResize(e, clip, "start")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
|
|
1087
|
+
<input className="tl-chip-input" value={clip.text} placeholder={isKo ? `${duration}초 · 모션 프롬프트` : `${duration}s · motion prompt`} maxLength={500} onChange={(e) => handlers.current.onPromptChange?.(clip.id, e.target.value)} />
|
|
1088
|
+
<button className="tl-chip-handle end" type="button" aria-label={ko("Resize prompt end", "프롬프트 끝점 조절")} onPointerDown={(e) => beginPromptResize(e, clip, "end")} onPointerMove={movePromptResize} onPointerUp={endPromptResize} onPointerCancel={endPromptResize} />
|
|
630
1089
|
</div>
|
|
631
1090
|
);
|
|
632
1091
|
})}
|
|
@@ -636,7 +1095,7 @@ export default function Timeline({
|
|
|
636
1095
|
key={f}
|
|
637
1096
|
className="tl-marker ik"
|
|
638
1097
|
style={{ "--tl-f": framePct(f, displayFrameCount) }}
|
|
639
|
-
title={`Full-body IK key at frame ${f} — click to jump, right-click to remove`}
|
|
1098
|
+
title={isKo ? `${f}프레임의 전신 IK 키 — 클릭해 이동, 오른쪽 클릭으로 삭제` : `Full-body IK key at frame ${f} — click to jump, right-click to remove`}
|
|
640
1099
|
onPointerDown={(e) => {
|
|
641
1100
|
if (e.button !== 0) return;
|
|
642
1101
|
e.stopPropagation();
|
|
@@ -655,7 +1114,7 @@ export default function Timeline({
|
|
|
655
1114
|
key={f}
|
|
656
1115
|
className={"tl-marker wp" + (f === pendingWaypointFrame ? " pending" : "")}
|
|
657
1116
|
style={{ "--tl-f": framePct(f, displayFrameCount) }}
|
|
658
|
-
title={`Root waypoint at frame ${f} — click to select, right-click to remove; drag marker ${waypointFrames.indexOf(f) + 1} in Top-View to move`}
|
|
1117
|
+
title={isKo ? `${f}프레임의 루트 웨이포인트 — 클릭해 선택, 오른쪽 클릭으로 삭제. 탑뷰에서 ${waypointFrames.indexOf(f) + 1}번 마커를 드래그해 이동` : `Root waypoint at frame ${f} — click to select, right-click to remove; drag marker ${waypointFrames.indexOf(f) + 1} in Top-View to move`}
|
|
659
1118
|
onPointerDown={(e) => {
|
|
660
1119
|
// Select only on the primary button — a right click
|
|
661
1120
|
// must reach contextmenu so it removes without scrubbing.
|
|
@@ -670,24 +1129,24 @@ export default function Timeline({
|
|
|
670
1129
|
}}
|
|
671
1130
|
/>
|
|
672
1131
|
))}
|
|
673
|
-
{name ===
|
|
1132
|
+
{name === SHOTS_LANE && shots.flatMap((shot, index) => (shot.cameraKeys ?? []).map((key) => (
|
|
674
1133
|
<span
|
|
675
|
-
key={
|
|
1134
|
+
key={`${shot.id}:${key.frame}`}
|
|
676
1135
|
className="tl-marker cam"
|
|
677
|
-
style={{ "--tl-f": framePct(
|
|
678
|
-
title={`Camera key at frame ${
|
|
679
|
-
onPointerDown={(e) => beginCameraKeyDrag(e,
|
|
1136
|
+
style={{ "--tl-f": framePct(key.frame, displayFrameCount) }}
|
|
1137
|
+
title={isKo ? `${key.frame}프레임의 카메라 키 — 클릭해 이동, 드래그로 시간 변경, 오른쪽 클릭으로 삭제` : `Camera key at frame ${key.frame} — click to jump, drag to re-time, right-click to remove`}
|
|
1138
|
+
onPointerDown={(e) => beginCameraKeyDrag(e, key.frame, index)}
|
|
680
1139
|
onPointerMove={moveCameraKeyDrag}
|
|
681
1140
|
onPointerUp={endCameraKeyDrag}
|
|
682
1141
|
onPointerCancel={endCameraKeyDrag}
|
|
683
|
-
onClick={(e) => onCameraKeyClick(e,
|
|
1142
|
+
onClick={(e) => onCameraKeyClick(e, key.frame, index)}
|
|
684
1143
|
onContextMenu={(e) => {
|
|
685
1144
|
e.preventDefault();
|
|
686
1145
|
e.stopPropagation();
|
|
687
|
-
handlers.current.onCameraKeyframeRemove?.(
|
|
1146
|
+
handlers.current.onCameraKeyframeRemove?.(key.frame, index);
|
|
688
1147
|
}}
|
|
689
1148
|
/>
|
|
690
|
-
))}
|
|
1149
|
+
)))}
|
|
691
1150
|
</div>
|
|
692
1151
|
</div>
|
|
693
1152
|
))}
|
|
@@ -702,16 +1161,18 @@ export default function Timeline({
|
|
|
702
1161
|
{waypointMode && (
|
|
703
1162
|
<span className={"tl-wp-hint" + (waypointFrames.length < 2 ? " warn" : "")}>
|
|
704
1163
|
{waypointFrames.length < 2
|
|
705
|
-
? "Click the set floor in the Shot view to add waypoints"
|
|
706
|
-
:
|
|
1164
|
+
? ko("Click the set floor in the Shot view to add waypoints", "샷 뷰의 세트 바닥을 클릭해 웨이포인트를 추가하세요")
|
|
1165
|
+
: isKo
|
|
1166
|
+
? `루트 웨이포인트 ${waypointFrames.length}개 · 세트 바닥을 클릭해 더 추가`
|
|
1167
|
+
: `${waypointFrames.length} root waypoints · click the set floor to add more`}
|
|
707
1168
|
</span>
|
|
708
1169
|
)}
|
|
709
1170
|
<button
|
|
710
1171
|
type="button"
|
|
711
1172
|
className="tl-toggle"
|
|
712
1173
|
aria-expanded="false"
|
|
713
|
-
aria-label="Expand timeline"
|
|
714
|
-
title="Expand timeline"
|
|
1174
|
+
aria-label={ko("Expand timeline", "타임라인 펼치기")}
|
|
1175
|
+
title={ko("Expand timeline", "타임라인 펼치기")}
|
|
715
1176
|
onClick={() => setExpanded(true)}
|
|
716
1177
|
>
|
|
717
1178
|
▸
|