cozyclay 1.0.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/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -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/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { frameFromClientX, promptMoveStartFrame } from "./timeline-coordinates.js";
|
|
3
|
+
import { promptResizeFrame } from "./timeline-resize.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ARDY Viser-style animation timeline — the live motion workspace.
|
|
7
|
+
*
|
|
8
|
+
* Frame, playback, waypoints and the motion badge are owned by App: this
|
|
9
|
+
* component is controlled and reports every interaction through callbacks,
|
|
10
|
+
* so the scene (character rig, root path) can react to playhead moves.
|
|
11
|
+
* The 2D Root lane authors temporal keyframes directly: left-clicking an empty
|
|
12
|
+
* track cell creates/selects a numbered waypoint, then Top-View owns its
|
|
13
|
+
* spatial position through direct marker dragging.
|
|
14
|
+
* The Camera lane authors shot-camera keyframings directly: left-clicking an
|
|
15
|
+
* empty track cell keys the CURRENT camera framing at that frame, dots jump
|
|
16
|
+
* the playhead on click, drag to re-time, right-click to remove. Playback
|
|
17
|
+
* and PlayView ride the keys segment by segment.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_FRAME_COUNT = 300; // 15 s @ 20 fps, the ARDY Core cadence
|
|
21
|
+
const DEFAULT_FPS = 20;
|
|
22
|
+
// Trackpad/wheel zoom over the FRAME ruler: the gesture changes only the
|
|
23
|
+
// horizontal visual scale of the time surface — never frameCount, fps,
|
|
24
|
+
// duration, waypoints or the generation request.
|
|
25
|
+
const ZOOM_MIN = 1 / 3; // show up to 3× more frames than the 1× viewport
|
|
26
|
+
const ZOOM_DEFAULT = 1;
|
|
27
|
+
const ZOOM_MAX = 8;
|
|
28
|
+
const ZOOM_STEP = 0.25; // one wheel notch = one zoom step
|
|
29
|
+
// Wheel deltas are accumulated in pixels; LINE/PAGE deltas are normalized
|
|
30
|
+
// (a line-mode notch is ~3 lines). A step fires every 50 px, capped at 3
|
|
31
|
+
// steps per event so a violent flick cannot jump the whole range.
|
|
32
|
+
const WHEEL_STEP_PX = 50;
|
|
33
|
+
const MAX_WHEEL_STEPS = 3;
|
|
34
|
+
|
|
35
|
+
const TRACKS = [
|
|
36
|
+
"Prompts",
|
|
37
|
+
"Full-Body",
|
|
38
|
+
"2D Root",
|
|
39
|
+
"Camera",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** IK keys live on the Full-Body lane: one marker per keyed frame, holding
|
|
43
|
+
* a sparse set of the limbs the user has moved (never every joint). */
|
|
44
|
+
const IK_LANE = "Full-Body";
|
|
45
|
+
const CAMERA_LANE = "Camera";
|
|
46
|
+
// Ruler labels and lane gridlines share one 10-frame cadence, so authored
|
|
47
|
+
// elements (40-frame prompt blocks, camera key dots) always land on visible
|
|
48
|
+
// lines. Label density adapts to zoom in 10-based steps.
|
|
49
|
+
const GRID_STEP_FRAMES = 10;
|
|
50
|
+
const LABEL_STEPS = [10, 20, 50, 100, 200, 500, 1000];
|
|
51
|
+
const MAX_LABELS = 30;
|
|
52
|
+
|
|
53
|
+
const framePct = (f, count) => (count > 1 ? f / (count - 1) : 0);
|
|
54
|
+
|
|
55
|
+
export default function Timeline({
|
|
56
|
+
frame,
|
|
57
|
+
frameCount = DEFAULT_FRAME_COUNT,
|
|
58
|
+
fps = DEFAULT_FPS,
|
|
59
|
+
playbackSpeed = 1,
|
|
60
|
+
playing,
|
|
61
|
+
waypointMode,
|
|
62
|
+
waypointFrames = [],
|
|
63
|
+
pathSpeed = null,
|
|
64
|
+
badge,
|
|
65
|
+
promptClips = [],
|
|
66
|
+
selectedPromptId,
|
|
67
|
+
pendingWaypointFrame = null,
|
|
68
|
+
ikMode = false,
|
|
69
|
+
ikDisabled = false, // a loaded motion owns the rig
|
|
70
|
+
ikFrames = [], // sorted full-body key frames
|
|
71
|
+
footSnap = true, // feet stay planted while the body moves
|
|
72
|
+
cameraKeyFrames = [], // sorted camera key frames — dots on the Camera lane
|
|
73
|
+
onScrub,
|
|
74
|
+
onAdvance,
|
|
75
|
+
onStep,
|
|
76
|
+
onPlayToggle,
|
|
77
|
+
onWaypointToggle,
|
|
78
|
+
onMarkerSelect,
|
|
79
|
+
onMarkerRemove,
|
|
80
|
+
onRootKeyframeAdd,
|
|
81
|
+
onPromptAdd,
|
|
82
|
+
onPromptSelect,
|
|
83
|
+
onPromptChange,
|
|
84
|
+
onPromptResize,
|
|
85
|
+
onPromptMove,
|
|
86
|
+
onPromptRemove,
|
|
87
|
+
onClearMotion,
|
|
88
|
+
onIkToggle,
|
|
89
|
+
onIkKeyframeAdd,
|
|
90
|
+
onIkKeyframeRemove,
|
|
91
|
+
onFootSnapToggle,
|
|
92
|
+
onCameraMoveSelect,
|
|
93
|
+
onCameraKeyframeAdd,
|
|
94
|
+
onCameraKeyframeMove,
|
|
95
|
+
onCameraKeyframeRemove,
|
|
96
|
+
}) {
|
|
97
|
+
const [expanded, setExpanded] = useState(true);
|
|
98
|
+
const [zoom, setZoom] = useState(ZOOM_DEFAULT);
|
|
99
|
+
const [movingPromptId, setMovingPromptId] = useState(null);
|
|
100
|
+
const rulerRef = useRef(null);
|
|
101
|
+
const bodyRef = useRef(null);
|
|
102
|
+
const scrubbing = useRef(false);
|
|
103
|
+
// Wheel zoom is driven by the pointer's live gesture: zoomRef tracks the
|
|
104
|
+
// intended zoom synchronously between renders, renderedRef the zoom that
|
|
105
|
+
// is actually in the DOM, and pendingScrollRef the scrollLeft to apply
|
|
106
|
+
// right after the next render so the frame under the pointer stays put.
|
|
107
|
+
const zoomRef = useRef(ZOOM_DEFAULT);
|
|
108
|
+
const renderedRef = useRef(ZOOM_DEFAULT);
|
|
109
|
+
const pendingScrollRef = useRef(null);
|
|
110
|
+
// The window key/interval handlers register once; the latest callbacks
|
|
111
|
+
// are read through a ref so they never go stale mid-playback.
|
|
112
|
+
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 };
|
|
114
|
+
|
|
115
|
+
// Trackpad/wheel zoom over the FRAME ruler lane only. React registers
|
|
116
|
+
// onWheel as passive, so a synthetic onWheel could never preventDefault —
|
|
117
|
+
// attach a real non-passive listener instead, so a vertical gesture over
|
|
118
|
+
// the ruler zooms the timeline instead of scrolling the page. Under Mac
|
|
119
|
+
// natural scrolling the signed delta follows the physical gesture: two
|
|
120
|
+
// fingers up (deltaY > 0) zooms IN, two fingers down (deltaY < 0) zooms
|
|
121
|
+
// OUT. Horizontal swipes (deltaY === 0) are left alone so they scroll the
|
|
122
|
+
// zoomed surface natively within .tl-body.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const el = rulerRef.current;
|
|
125
|
+
if (!el) return;
|
|
126
|
+
let acc = 0;
|
|
127
|
+
const onWheel = (e) => {
|
|
128
|
+
if (e.deltaY === 0) return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
const dy =
|
|
131
|
+
e.deltaMode === 1 ? (e.deltaY * WHEEL_STEP_PX) / 3 : e.deltaMode === 2 ? e.deltaY * WHEEL_STEP_PX * 8 : e.deltaY;
|
|
132
|
+
acc += dy;
|
|
133
|
+
let steps = Math.trunc(acc / WHEEL_STEP_PX);
|
|
134
|
+
steps = Math.max(-MAX_WHEEL_STEPS, Math.min(MAX_WHEEL_STEPS, steps));
|
|
135
|
+
acc -= steps * WHEEL_STEP_PX;
|
|
136
|
+
if (steps === 0) return;
|
|
137
|
+
const zR = renderedRef.current;
|
|
138
|
+
const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomRef.current + steps * ZOOM_STEP));
|
|
139
|
+
if (z1 === zR) return;
|
|
140
|
+
zoomRef.current = z1;
|
|
141
|
+
setZoom(z1);
|
|
142
|
+
// Keep the frame under the pointer anchored. `dx` already includes
|
|
143
|
+
// the sticky label offset and the body's current horizontal scroll,
|
|
144
|
+
// so only add the extra scaled distance.
|
|
145
|
+
const body = bodyRef.current;
|
|
146
|
+
const lane = rulerRef.current;
|
|
147
|
+
if (!body || !lane) return;
|
|
148
|
+
const rect = lane.getBoundingClientRect();
|
|
149
|
+
if (rect.width <= 0) return;
|
|
150
|
+
const dx = e.clientX - rect.left;
|
|
151
|
+
const labelW = parseFloat(getComputedStyle(body).getPropertyValue("--tl-label-w")) || 148;
|
|
152
|
+
const surfaceR = Math.max(ZOOM_DEFAULT, zR);
|
|
153
|
+
const surface1 = Math.max(ZOOM_DEFAULT, z1);
|
|
154
|
+
const maxScroll = Math.max(0, labelW + (rect.width / surfaceR) * surface1 - body.clientWidth);
|
|
155
|
+
const target = body.scrollLeft + dx * (surface1 / surfaceR - 1);
|
|
156
|
+
pendingScrollRef.current = Math.max(0, Math.min(target, maxScroll));
|
|
157
|
+
};
|
|
158
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
159
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
160
|
+
// The ruler lane unmounts when the timeline collapses, so re-attach
|
|
161
|
+
// whenever it comes back; zoom state itself lives on in the component.
|
|
162
|
+
}, [expanded]);
|
|
163
|
+
|
|
164
|
+
// Commit the gesture's scroll anchor right after the zoomed layout lands,
|
|
165
|
+
// before paint — otherwise the surface would snap back to frame 0.
|
|
166
|
+
useLayoutEffect(() => {
|
|
167
|
+
renderedRef.current = zoom;
|
|
168
|
+
if (pendingScrollRef.current == null) return;
|
|
169
|
+
const body = bodyRef.current;
|
|
170
|
+
if (body) {
|
|
171
|
+
const max = Math.max(0, body.scrollWidth - body.clientWidth);
|
|
172
|
+
body.scrollLeft = Math.max(0, Math.min(pendingScrollRef.current, max));
|
|
173
|
+
}
|
|
174
|
+
pendingScrollRef.current = null;
|
|
175
|
+
}, [zoom]);
|
|
176
|
+
|
|
177
|
+
function resetZoom() {
|
|
178
|
+
if (renderedRef.current === ZOOM_DEFAULT && zoomRef.current === ZOOM_DEFAULT) return;
|
|
179
|
+
zoomRef.current = ZOOM_DEFAULT;
|
|
180
|
+
setZoom(ZOOM_DEFAULT);
|
|
181
|
+
pendingScrollRef.current = 0; // at 1× the surface fits exactly: no scroll
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Playhead advance at the loaded clip's fps multiplied by preview speed;
|
|
185
|
+
// wraps at the end without resampling the underlying motion.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!playing) return;
|
|
188
|
+
const id = window.setInterval(
|
|
189
|
+
() => handlers.current.onAdvance?.(),
|
|
190
|
+
1000 / Math.max(1, fps * playbackSpeed),
|
|
191
|
+
);
|
|
192
|
+
return () => window.clearInterval(id);
|
|
193
|
+
}, [playing, fps, playbackSpeed]);
|
|
194
|
+
|
|
195
|
+
// Space toggles playback, j/k step the playhead, p toggles waypoint mode —
|
|
196
|
+
// but only when focus is outside interactive native controls: a focused
|
|
197
|
+
// input/select owns its keys, and Space on a focused button would trigger
|
|
198
|
+
// the button's native activation on top of the global toggle. Focus on
|
|
199
|
+
// the body or canvas keeps the shortcuts live.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
function onKey(e) {
|
|
202
|
+
const el = document.activeElement;
|
|
203
|
+
const interactive =
|
|
204
|
+
el &&
|
|
205
|
+
(el.tagName === "INPUT" ||
|
|
206
|
+
el.tagName === "TEXTAREA" ||
|
|
207
|
+
el.tagName === "SELECT" ||
|
|
208
|
+
el.tagName === "BUTTON" ||
|
|
209
|
+
el.isContentEditable);
|
|
210
|
+
if (interactive) return;
|
|
211
|
+
// Physical key codes, not characters — Hangul IME turns j/k/p into
|
|
212
|
+
// ㅓ/ㅔ/ㅔ and the shortcuts would go dead (same fix as FlyControls).
|
|
213
|
+
// Space and P are toggles — ignore OS key-repeat so a held key
|
|
214
|
+
// cannot flap playback or waypoint mode; j/k keep stepping.
|
|
215
|
+
if (e.repeat && (e.code === "Space" || e.code === "KeyP")) return;
|
|
216
|
+
const h = handlers.current;
|
|
217
|
+
if (e.code === "Space") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
h.onPlayToggle?.();
|
|
220
|
+
} else if (e.code === "KeyJ") {
|
|
221
|
+
h.onStep?.(1);
|
|
222
|
+
} else if (e.code === "KeyK") {
|
|
223
|
+
h.onStep?.(-1);
|
|
224
|
+
} else if (e.code === "KeyP") {
|
|
225
|
+
h.onWaypointToggle?.();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
window.addEventListener("keydown", onKey);
|
|
229
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
// Below 1× the surface still fills the viewport; the ruler exposes a wider
|
|
233
|
+
// virtual frame range instead of physically shrinking into the left edge.
|
|
234
|
+
const displayFrameCount = zoom < ZOOM_DEFAULT ? Math.ceil(frameCount / zoom) : frameCount;
|
|
235
|
+
const surfaceZoom = Math.max(ZOOM_DEFAULT, zoom);
|
|
236
|
+
const labelStep = LABEL_STEPS.find((s) => (displayFrameCount - 1) / s <= MAX_LABELS) ?? LABEL_STEPS[LABEL_STEPS.length - 1];
|
|
237
|
+
const labels = useMemo(() => {
|
|
238
|
+
const out = [];
|
|
239
|
+
for (let f = 0; f < displayFrameCount; f += labelStep) out.push(f);
|
|
240
|
+
return out;
|
|
241
|
+
}, [displayFrameCount, labelStep]);
|
|
242
|
+
// Lane gridlines ride the same 10-frame cadence and the same framePct as
|
|
243
|
+
// the ruler ticks, so lines and labels can never drift apart at any zoom.
|
|
244
|
+
const gridFrames = useMemo(() => {
|
|
245
|
+
const out = [];
|
|
246
|
+
for (let f = 0; f < displayFrameCount; f += GRID_STEP_FRAMES) out.push(f);
|
|
247
|
+
return out;
|
|
248
|
+
}, [displayFrameCount]);
|
|
249
|
+
// Chips clamp into the surface; markers use framePct directly. One scale
|
|
250
|
+
// for everything — the old /count chip scale drifted off the gridlines.
|
|
251
|
+
const clipPct = (value) => Math.max(0, Math.min(1, framePct(value, displayFrameCount)));
|
|
252
|
+
const moveRef = useRef(null);
|
|
253
|
+
const suppressPromptClickRef = useRef(false);
|
|
254
|
+
const resizeRef = useRef(null);
|
|
255
|
+
const camDragRef = useRef(null);
|
|
256
|
+
const camSuppressClickRef = useRef(false);
|
|
257
|
+
|
|
258
|
+
function beginPromptMove(e, clip) {
|
|
259
|
+
if (e.button !== 0 || e.target.closest(".tl-chip-handle")) return;
|
|
260
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
261
|
+
if (!lane) return;
|
|
262
|
+
const rect = lane.getBoundingClientRect();
|
|
263
|
+
moveRef.current = {
|
|
264
|
+
id: clip.id,
|
|
265
|
+
pointerId: e.pointerId,
|
|
266
|
+
startClientX: e.clientX,
|
|
267
|
+
startFrame: clip.startFrame,
|
|
268
|
+
laneWidth: rect.width,
|
|
269
|
+
displayFrameCount,
|
|
270
|
+
moving: false,
|
|
271
|
+
};
|
|
272
|
+
handlers.current.onPromptSelect?.(clip.id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function movePrompt(e) {
|
|
276
|
+
const active = moveRef.current;
|
|
277
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
278
|
+
if (!active.moving) {
|
|
279
|
+
if (Math.abs(e.clientX - active.startClientX) < 4) return;
|
|
280
|
+
active.moving = true;
|
|
281
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
282
|
+
setMovingPromptId(active.id);
|
|
283
|
+
document.activeElement?.blur();
|
|
284
|
+
}
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
e.stopPropagation();
|
|
287
|
+
const rawStart = promptMoveStartFrame(
|
|
288
|
+
active.startFrame,
|
|
289
|
+
active.startClientX,
|
|
290
|
+
e.clientX,
|
|
291
|
+
active.laneWidth,
|
|
292
|
+
active.displayFrameCount
|
|
293
|
+
);
|
|
294
|
+
handlers.current.onPromptMove?.(active.id, rawStart);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function endPromptMove(e) {
|
|
298
|
+
const active = moveRef.current;
|
|
299
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
300
|
+
if (active.moving) {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
e.stopPropagation();
|
|
303
|
+
suppressPromptClickRef.current = true;
|
|
304
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
305
|
+
queueMicrotask(() => { suppressPromptClickRef.current = false; });
|
|
306
|
+
}
|
|
307
|
+
moveRef.current = null;
|
|
308
|
+
setMovingPromptId(null);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function blockPromptClick(e) {
|
|
312
|
+
if (!suppressPromptClickRef.current) return;
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function beginPromptResize(e, clip, edge) {
|
|
318
|
+
if (e.button !== 0) return;
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
e.stopPropagation();
|
|
321
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
322
|
+
resizeRef.current = {
|
|
323
|
+
id: clip.id,
|
|
324
|
+
edge,
|
|
325
|
+
startClientX: e.clientX,
|
|
326
|
+
startFrame: edge === "start" ? clip.startFrame : clip.endFrame,
|
|
327
|
+
lastFrame: edge === "start" ? clip.startFrame : clip.endFrame,
|
|
328
|
+
};
|
|
329
|
+
handlers.current.onPromptSelect?.(clip.id);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function movePromptResize(e) {
|
|
333
|
+
const active = resizeRef.current;
|
|
334
|
+
if (!active) return;
|
|
335
|
+
const nextFrame = promptResizeFrame(active.startFrame, active.startClientX, e.clientX);
|
|
336
|
+
if (nextFrame === active.lastFrame) return;
|
|
337
|
+
active.lastFrame = nextFrame;
|
|
338
|
+
handlers.current.onPromptResize?.(active.id, active.edge, nextFrame);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function endPromptResize(e) {
|
|
342
|
+
if (!resizeRef.current) return;
|
|
343
|
+
resizeRef.current = null;
|
|
344
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Camera key dots re-time by dragging, like prompt clips move: the frame
|
|
348
|
+
// delta comes from the pointer-down geometry so a growing timeline cannot
|
|
349
|
+
// feed back into the next pointermove.
|
|
350
|
+
function beginCameraKeyDrag(e, keyFrame) {
|
|
351
|
+
if (e.button !== 0) return;
|
|
352
|
+
e.stopPropagation();
|
|
353
|
+
// A 10px dot loses the pointer almost immediately without capture —
|
|
354
|
+
// grab it on pointerdown so the drag survives past the marker's edge.
|
|
355
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
356
|
+
const lane = e.currentTarget.closest(".tl-lane");
|
|
357
|
+
const rect = lane?.getBoundingClientRect();
|
|
358
|
+
camDragRef.current = {
|
|
359
|
+
pointerId: e.pointerId,
|
|
360
|
+
startClientX: e.clientX,
|
|
361
|
+
fromFrame: keyFrame,
|
|
362
|
+
currentFrame: keyFrame,
|
|
363
|
+
laneWidth: rect?.width ?? 1,
|
|
364
|
+
displayFrameCount,
|
|
365
|
+
moved: false,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function moveCameraKeyDrag(e) {
|
|
370
|
+
const active = camDragRef.current;
|
|
371
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
372
|
+
if (!active.moved) {
|
|
373
|
+
if (Math.abs(e.clientX - active.startClientX) < 4) return;
|
|
374
|
+
active.moved = true;
|
|
375
|
+
}
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
e.stopPropagation();
|
|
378
|
+
const framesPerPixel = Math.max(0, active.displayFrameCount - 1) / Math.max(1, active.laneWidth);
|
|
379
|
+
const raw = active.fromFrame + (e.clientX - active.startClientX) * framesPerPixel;
|
|
380
|
+
const next = Math.max(0, Math.min(frameCount - 1, Math.round(raw)));
|
|
381
|
+
if (next === active.currentFrame) return;
|
|
382
|
+
const from = active.currentFrame;
|
|
383
|
+
active.currentFrame = next;
|
|
384
|
+
handlers.current.onCameraKeyframeMove?.(from, next);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function endCameraKeyDrag(e) {
|
|
388
|
+
const active = camDragRef.current;
|
|
389
|
+
if (!active || e.pointerId !== active.pointerId) return;
|
|
390
|
+
if (active.moved) {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
e.stopPropagation();
|
|
393
|
+
camSuppressClickRef.current = true;
|
|
394
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
395
|
+
queueMicrotask(() => { camSuppressClickRef.current = false; });
|
|
396
|
+
}
|
|
397
|
+
camDragRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function onCameraKeyClick(e, keyFrame) {
|
|
401
|
+
if (camSuppressClickRef.current) {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
e.stopPropagation();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
e.stopPropagation();
|
|
407
|
+
handlers.current.onScrub?.(keyFrame);
|
|
408
|
+
handlers.current.onCameraMoveSelect?.();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function frameFromEvent(e) {
|
|
412
|
+
const el = rulerRef.current;
|
|
413
|
+
if (!el) return frame;
|
|
414
|
+
const rect = el.getBoundingClientRect();
|
|
415
|
+
return frameFromClientX(e.clientX, rect.left, rect.width, displayFrameCount, frameCount);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function rootFrameFromEvent(e) {
|
|
419
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
420
|
+
return frameFromClientX(e.clientX, rect.left, rect.width, displayFrameCount, frameCount);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function onRulerDown(e) {
|
|
424
|
+
if (e.button !== 0) return;
|
|
425
|
+
scrubbing.current = true;
|
|
426
|
+
rulerRef.current?.setPointerCapture?.(e.pointerId);
|
|
427
|
+
handlers.current.onScrub?.(frameFromEvent(e));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function onRulerMove(e) {
|
|
431
|
+
if (!scrubbing.current) return;
|
|
432
|
+
handlers.current.onScrub?.(frameFromEvent(e));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function onRulerUp(e) {
|
|
436
|
+
if (!scrubbing.current) return;
|
|
437
|
+
scrubbing.current = false;
|
|
438
|
+
rulerRef.current?.releasePointerCapture?.(e.pointerId);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<section className={"timeline" + (expanded ? "" : " collapsed")} aria-label="Animation timeline">
|
|
443
|
+
{expanded ? (
|
|
444
|
+
<>
|
|
445
|
+
<div className="tl-head">
|
|
446
|
+
<div className="tl-transport" aria-label="Playback transport">
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
className="tl-btn"
|
|
450
|
+
aria-label="Previous frame"
|
|
451
|
+
title="Previous frame (k)"
|
|
452
|
+
onClick={() => handlers.current.onStep?.(-1)}
|
|
453
|
+
>
|
|
454
|
+
‹
|
|
455
|
+
</button>
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
className={"tl-btn play" + (playing ? " on" : "")}
|
|
459
|
+
aria-label={playing ? "Pause playback" : "Play playback"}
|
|
460
|
+
title="Play / pause (Space)"
|
|
461
|
+
onClick={() => handlers.current.onPlayToggle?.()}
|
|
462
|
+
>
|
|
463
|
+
{playing ? "❚❚" : "▶"}
|
|
464
|
+
</button>
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
className="tl-btn"
|
|
468
|
+
aria-label="Next frame"
|
|
469
|
+
title="Next frame (j)"
|
|
470
|
+
onClick={() => handlers.current.onStep?.(1)}
|
|
471
|
+
>
|
|
472
|
+
›
|
|
473
|
+
</button>
|
|
474
|
+
<span className="tl-readout" aria-live="polite">
|
|
475
|
+
<b>{frame}</b> / {frameCount - 1} · {fps} fps · {playbackSpeed.toFixed(2)}×
|
|
476
|
+
</span>
|
|
477
|
+
</div>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
className={"tl-btn zoom" + (zoom !== ZOOM_DEFAULT ? " on" : "")}
|
|
481
|
+
title="Two-finger up/down over FRAME ruler to zoom — click to reset to 1×"
|
|
482
|
+
onClick={resetZoom}
|
|
483
|
+
>
|
|
484
|
+
{zoom.toFixed(2)}×
|
|
485
|
+
</button>
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
className={"tl-btn wp" + (waypointMode ? " on" : "")}
|
|
489
|
+
aria-pressed={waypointMode}
|
|
490
|
+
title="Enable or disable 2D Root path constraints (P)"
|
|
491
|
+
onClick={() => handlers.current.onWaypointToggle?.()}
|
|
492
|
+
>
|
|
493
|
+
Waypoint {waypointMode ? "on" : "off"}
|
|
494
|
+
</button>
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
className={"tl-btn ik" + (ikMode ? " on" : "")}
|
|
498
|
+
aria-pressed={ikMode}
|
|
499
|
+
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"}
|
|
501
|
+
onClick={() => handlers.current.onIkToggle?.()}
|
|
502
|
+
>
|
|
503
|
+
IK {ikMode ? "on" : "off"}
|
|
504
|
+
</button>
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
className={"tl-btn ik snap" + (footSnap ? " on" : "")}
|
|
508
|
+
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"
|
|
510
|
+
onClick={() => handlers.current.onFootSnapToggle?.()}
|
|
511
|
+
>
|
|
512
|
+
Snap {footSnap ? "on" : "off"}
|
|
513
|
+
</button>
|
|
514
|
+
{waypointMode && (
|
|
515
|
+
<span className={"tl-wp-hint" + (waypointFrames.length < 2 || pathSpeed?.warn ? " warn" : "")}>
|
|
516
|
+
{waypointFrames.length < 2
|
|
517
|
+
? "Click the set floor in the Shot view to drop waypoints"
|
|
518
|
+
: `${waypointFrames.length} root waypoints` +
|
|
519
|
+
(pathSpeed
|
|
520
|
+
? ` · ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s${pathSpeed.warn ? " — outside the natural 0.5–3 m/s locomotion band" : ""}`
|
|
521
|
+
: "") +
|
|
522
|
+
" · click the set floor to add more"}
|
|
523
|
+
</span>
|
|
524
|
+
)}
|
|
525
|
+
{badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
|
|
526
|
+
{onClearMotion && (
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
className="tl-btn clear"
|
|
530
|
+
title="Clear motion and restore the blocking pose"
|
|
531
|
+
onClick={onClearMotion}
|
|
532
|
+
>
|
|
533
|
+
✕ Motion
|
|
534
|
+
</button>
|
|
535
|
+
)}
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
className="tl-toggle"
|
|
539
|
+
aria-expanded="true"
|
|
540
|
+
aria-label="Collapse timeline"
|
|
541
|
+
title="Collapse timeline"
|
|
542
|
+
onClick={() => setExpanded(false)}
|
|
543
|
+
>
|
|
544
|
+
▾
|
|
545
|
+
</button>
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
<div className="tl-body" ref={bodyRef}>
|
|
549
|
+
<div className="tl-surface" style={{ "--tl-zoom": surfaceZoom }}>
|
|
550
|
+
<div className="tl-ruler">
|
|
551
|
+
<span className="tl-ruler-label">Frame</span>
|
|
552
|
+
<div
|
|
553
|
+
className="tl-ruler-lane"
|
|
554
|
+
ref={rulerRef}
|
|
555
|
+
role="slider"
|
|
556
|
+
aria-label="Scrub timeline"
|
|
557
|
+
aria-valuemin={0}
|
|
558
|
+
aria-valuemax={frameCount - 1}
|
|
559
|
+
aria-valuenow={frame}
|
|
560
|
+
tabIndex={0}
|
|
561
|
+
onPointerDown={onRulerDown}
|
|
562
|
+
onPointerMove={onRulerMove}
|
|
563
|
+
onPointerUp={onRulerUp}
|
|
564
|
+
onPointerCancel={onRulerUp}
|
|
565
|
+
onKeyDown={(e) => {
|
|
566
|
+
if (e.key === "ArrowRight") {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
handlers.current.onStep?.(1);
|
|
569
|
+
} else if (e.key === "ArrowLeft") {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
handlers.current.onStep?.(-1);
|
|
572
|
+
}
|
|
573
|
+
}}
|
|
574
|
+
>
|
|
575
|
+
<span className="tl-frame-box" style={{ "--tl-f": framePct(frame, displayFrameCount) }} aria-hidden="true">
|
|
576
|
+
{frame}
|
|
577
|
+
</span>
|
|
578
|
+
{labels.map((f) => (
|
|
579
|
+
<span key={f} className="tl-tick" style={{ "--tl-f": framePct(f, displayFrameCount) }}>
|
|
580
|
+
<i />
|
|
581
|
+
{f}
|
|
582
|
+
</span>
|
|
583
|
+
))}
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
{TRACKS.map((name) => (
|
|
588
|
+
<div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "")} key={name}>
|
|
589
|
+
<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>}
|
|
592
|
+
{name === IK_LANE && ikMode && (
|
|
593
|
+
<button
|
|
594
|
+
className="tl-track-add ik"
|
|
595
|
+
type="button"
|
|
596
|
+
title={`Key the current pose at frame ${frame}`}
|
|
597
|
+
onClick={() => handlers.current.onIkKeyframeAdd?.()}
|
|
598
|
+
>
|
|
599
|
+
+
|
|
600
|
+
</button>
|
|
601
|
+
)}
|
|
602
|
+
</span>
|
|
603
|
+
<div
|
|
604
|
+
className={"tl-lane" + (name === "2D Root" ? " root" : "") + (name === CAMERA_LANE ? " cam" : "")}
|
|
605
|
+
onPointerDown={
|
|
606
|
+
name === "2D Root"
|
|
607
|
+
? (e) => {
|
|
608
|
+
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
|
609
|
+
handlers.current.onRootKeyframeAdd?.(rootFrameFromEvent(e));
|
|
610
|
+
}
|
|
611
|
+
: name === CAMERA_LANE
|
|
612
|
+
? (e) => {
|
|
613
|
+
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
|
614
|
+
handlers.current.onCameraKeyframeAdd?.(rootFrameFromEvent(e));
|
|
615
|
+
}
|
|
616
|
+
: undefined
|
|
617
|
+
}
|
|
618
|
+
title={name === CAMERA_LANE ? "Click to key the current camera framing at this frame" : undefined}
|
|
619
|
+
>
|
|
620
|
+
{gridFrames.map((f) => (
|
|
621
|
+
<i key={f} className="tl-grid" style={{ "--tl-f": framePct(f, displayFrameCount) }} aria-hidden="true" />
|
|
622
|
+
))}
|
|
623
|
+
{name === "Prompts" && promptClips.map((clip) => {
|
|
624
|
+
const duration = ((clip.endFrame - clip.startFrame) / Math.max(1, fps)).toFixed(1);
|
|
625
|
+
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} />
|
|
630
|
+
</div>
|
|
631
|
+
);
|
|
632
|
+
})}
|
|
633
|
+
{name === IK_LANE &&
|
|
634
|
+
ikFrames.map((f) => (
|
|
635
|
+
<span
|
|
636
|
+
key={f}
|
|
637
|
+
className="tl-marker ik"
|
|
638
|
+
style={{ "--tl-f": framePct(f, displayFrameCount) }}
|
|
639
|
+
title={`Full-body IK key at frame ${f} — click to jump, right-click to remove`}
|
|
640
|
+
onPointerDown={(e) => {
|
|
641
|
+
if (e.button !== 0) return;
|
|
642
|
+
e.stopPropagation();
|
|
643
|
+
handlers.current.onScrub?.(f);
|
|
644
|
+
}}
|
|
645
|
+
onContextMenu={(e) => {
|
|
646
|
+
e.preventDefault();
|
|
647
|
+
e.stopPropagation();
|
|
648
|
+
handlers.current.onIkKeyframeRemove?.(f);
|
|
649
|
+
}}
|
|
650
|
+
/>
|
|
651
|
+
))}
|
|
652
|
+
{name === "2D Root" &&
|
|
653
|
+
[...waypointFrames, ...(pendingWaypointFrame == null || waypointFrames.includes(pendingWaypointFrame) ? [] : [pendingWaypointFrame])].map((f) => (
|
|
654
|
+
<span
|
|
655
|
+
key={f}
|
|
656
|
+
className={"tl-marker wp" + (f === pendingWaypointFrame ? " pending" : "")}
|
|
657
|
+
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`}
|
|
659
|
+
onPointerDown={(e) => {
|
|
660
|
+
// Select only on the primary button — a right click
|
|
661
|
+
// must reach contextmenu so it removes without scrubbing.
|
|
662
|
+
if (e.button !== 0) return;
|
|
663
|
+
e.stopPropagation();
|
|
664
|
+
handlers.current.onMarkerSelect?.(f);
|
|
665
|
+
}}
|
|
666
|
+
onContextMenu={(e) => {
|
|
667
|
+
e.preventDefault();
|
|
668
|
+
e.stopPropagation();
|
|
669
|
+
handlers.current.onMarkerRemove?.(f);
|
|
670
|
+
}}
|
|
671
|
+
/>
|
|
672
|
+
))}
|
|
673
|
+
{name === CAMERA_LANE && cameraKeyFrames.map((f) => (
|
|
674
|
+
<span
|
|
675
|
+
key={f}
|
|
676
|
+
className="tl-marker cam"
|
|
677
|
+
style={{ "--tl-f": framePct(f, displayFrameCount) }}
|
|
678
|
+
title={`Camera key at frame ${f} — click to jump, drag to re-time, right-click to remove`}
|
|
679
|
+
onPointerDown={(e) => beginCameraKeyDrag(e, f)}
|
|
680
|
+
onPointerMove={moveCameraKeyDrag}
|
|
681
|
+
onPointerUp={endCameraKeyDrag}
|
|
682
|
+
onPointerCancel={endCameraKeyDrag}
|
|
683
|
+
onClick={(e) => onCameraKeyClick(e, f)}
|
|
684
|
+
onContextMenu={(e) => {
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
e.stopPropagation();
|
|
687
|
+
handlers.current.onCameraKeyframeRemove?.(f);
|
|
688
|
+
}}
|
|
689
|
+
/>
|
|
690
|
+
))}
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
))}
|
|
694
|
+
|
|
695
|
+
<div className="tl-playhead" style={{ "--tl-f": framePct(frame, displayFrameCount) }} aria-hidden="true" />
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
</>
|
|
699
|
+
) : (
|
|
700
|
+
<div className="tl-collapsed">
|
|
701
|
+
{badge && <span className={"tl-badge " + badge.kind}>{badge.label}</span>}
|
|
702
|
+
{waypointMode && (
|
|
703
|
+
<span className={"tl-wp-hint" + (waypointFrames.length < 2 ? " warn" : "")}>
|
|
704
|
+
{waypointFrames.length < 2
|
|
705
|
+
? "Click the set floor in the Shot view to add waypoints"
|
|
706
|
+
: `${waypointFrames.length} root waypoints · click the set floor to add more`}
|
|
707
|
+
</span>
|
|
708
|
+
)}
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
className="tl-toggle"
|
|
712
|
+
aria-expanded="false"
|
|
713
|
+
aria-label="Expand timeline"
|
|
714
|
+
title="Expand timeline"
|
|
715
|
+
onClick={() => setExpanded(true)}
|
|
716
|
+
>
|
|
717
|
+
▸
|
|
718
|
+
</button>
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
</section>
|
|
722
|
+
);
|
|
723
|
+
}
|