@dailies-studio/shot-editor 0.1.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/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- package/src/tools.ts +181 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// The timeline dock: ONE CONTINUOUS MASTER STRIP.
|
|
2
|
+
//
|
|
3
|
+
// ── WHAT DIED HERE, AND WHY ─────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// This used to be two levels. A scene strip on top, and underneath it the block
|
|
6
|
+
// tracks of WHICHEVER SCENE THE PLAYHEAD WAS IN — one bit, `state.sceneId`,
|
|
7
|
+
// deciding what half the dock showed. It is a coherent model and it is the wrong
|
|
8
|
+
// one for a video: the author's question is almost never "what is in scene 2",
|
|
9
|
+
// it is "what happens between the logo landing and the price appearing", and
|
|
10
|
+
// those two things are in different scenes. A dock that can only show one scene
|
|
11
|
+
// answers that question by making the author flip between two pictures and hold
|
|
12
|
+
// the join in their head.
|
|
13
|
+
//
|
|
14
|
+
// So every scene's rows are on screen at once, at their master positions,
|
|
15
|
+
// grouped in master order, with the scene boundaries drawn through the tracks.
|
|
16
|
+
// `state.sceneId` survives as the INSPECTOR'S SUBJECT and the URL's, and nothing
|
|
17
|
+
// about this file's layout reads it any more. The scene strip survives too, slim
|
|
18
|
+
// and secondary: it is the only place scenes are created, reordered and trimmed,
|
|
19
|
+
// which is exactly the set of verbs that are about scenes rather than about
|
|
20
|
+
// time.
|
|
21
|
+
//
|
|
22
|
+
// ── WHERE THE SCENE COMES FROM NOW ──────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// Every row carries its own `sceneId` and every gesture passes it to the store.
|
|
25
|
+
// That is not tidiness: `state.sceneId` follows the playhead, so a drag on a row
|
|
26
|
+
// three scenes away would have written to whichever scene the playhead happened
|
|
27
|
+
// to be sitting in.
|
|
28
|
+
//
|
|
29
|
+
// Everything geometric is @dailies-studio/timeline's, unchanged: `Ruler`,
|
|
30
|
+
// `PlayheadLine`, `frameToPx`, `pxToFrame` and `useAnchoredZoom` are about
|
|
31
|
+
// frames and pixels, which the keyframe lane and the block lane agree about.
|
|
32
|
+
// What is NOT reused is `TimelineModel` — see model.ts for why.
|
|
33
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
34
|
+
import { PlayheadLine, Ruler, frameToPx, useAnchoredZoom } from "@dailies-studio/timeline";
|
|
35
|
+
import type { Rational } from "@dailies-studio/engine";
|
|
36
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
37
|
+
import { ResizeHandle } from "../ResizeHandle.js";
|
|
38
|
+
import { DOCK_HEIGHT } from "../../state/panelSize.js";
|
|
39
|
+
import { AudioLane } from "./AudioLane.js";
|
|
40
|
+
import { BlockRow } from "./BlockRow.js";
|
|
41
|
+
import { ChannelRows } from "./ChannelRow.js";
|
|
42
|
+
import { LifespanRow } from "./LifespanRow.js";
|
|
43
|
+
import { MarkerLane } from "./MarkerLane.js";
|
|
44
|
+
import { SceneStrip } from "./SceneStrip.js";
|
|
45
|
+
import { CAMERA_ROW_ID, masterContentFrames, masterDuration, masterRows, sceneBars, timecode } from "./model.js";
|
|
46
|
+
import { collectSnapTargets, snapFrame, snapThresholdFrames } from "./snap.js";
|
|
47
|
+
import type { SnapPress, SnapScope } from "./snap.js";
|
|
48
|
+
|
|
49
|
+
/** Where the track labels end and the time area begins, in px. */
|
|
50
|
+
export const LABEL_WIDTH = 150;
|
|
51
|
+
|
|
52
|
+
// ── THE FRAME STAYS OUT OF THIS COMPONENT ─────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// During playback the frame changes ~30 times a second, and this component
|
|
55
|
+
// renders the ENTIRE dock — every scene group, camera bar, track row and block
|
|
56
|
+
// bar. When it subscribed to `frame`, all of that reconciled per playback
|
|
57
|
+
// frame. The three things that actually show the frame are leaves below
|
|
58
|
+
// (DockTime, DockReadout, DockPlayhead), each subscribing to `frame` alone, so
|
|
59
|
+
// a playhead tick re-renders three spans and nothing else. If a future reader
|
|
60
|
+
// adds `useShotEditor((s) => s.frame)` back here, playback perf regresses to
|
|
61
|
+
// where it was — don't.
|
|
62
|
+
|
|
63
|
+
/** The current-time readout, isolated so its per-frame tick stays its own. */
|
|
64
|
+
function DockTime({ fps }: { fps: Rational }) {
|
|
65
|
+
const frame = useShotEditor((s) => s.frame);
|
|
66
|
+
return (
|
|
67
|
+
<span className="dock-time" data-testid="dock-time">
|
|
68
|
+
{timecode(frame, fps)}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The frame-counter readout, same isolation. */
|
|
74
|
+
function DockReadout({ total }: { total: number }) {
|
|
75
|
+
const frame = useShotEditor((s) => s.frame);
|
|
76
|
+
return (
|
|
77
|
+
<span className="dock-readout" data-testid="dock-readout">
|
|
78
|
+
{frame} / {Math.max(0, total - 1)}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The playhead, subscribing to the frame itself so its two mount sites ride
|
|
84
|
+
* a per-frame tick without dragging the dock along. */
|
|
85
|
+
function DockPlayhead({ ppf }: { ppf: number }) {
|
|
86
|
+
const frame = useShotEditor((s) => s.frame);
|
|
87
|
+
return <PlayheadLine frame={frame} ppf={ppf} offset={0} className="dock-playhead" />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function TimelineDock() {
|
|
91
|
+
const store = useStore();
|
|
92
|
+
const doc = useShotEditor((s) => s.doc);
|
|
93
|
+
const isolation = useShotEditor((s) => s.isolation);
|
|
94
|
+
const playing = useShotEditor((s) => s.playing);
|
|
95
|
+
const collapsed = useShotEditor((s) => s.dockCollapsed);
|
|
96
|
+
const ppf = useShotEditor((s) => s.dockPpf);
|
|
97
|
+
const previewMuted = useShotEditor((s) => s.previewMuted);
|
|
98
|
+
const previewVolume = useShotEditor((s) => s.previewVolume);
|
|
99
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
100
|
+
/**
|
|
101
|
+
* Which layers show their block lanes. LOCAL, not store state: it is about
|
|
102
|
+
* this panel's shape and nothing else reads it, and the body is hidden rather
|
|
103
|
+
* than unmounted on collapse (see below) so it survives the one event that
|
|
104
|
+
* would otherwise lose it.
|
|
105
|
+
*/
|
|
106
|
+
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set<string>());
|
|
107
|
+
/** The frame a live drag has snapped onto, for the guide. Null between drags. */
|
|
108
|
+
const [guide, setGuide] = useState<number | null>(null);
|
|
109
|
+
|
|
110
|
+
// Imperative reads, so the wheel listener never closes over a stale zoom.
|
|
111
|
+
const read = useCallback(() => store.getState().dockPpf, [store]);
|
|
112
|
+
const setPxPerFrame = useCallback((next: number) => store.setDockPpf(next), [store]);
|
|
113
|
+
useAnchoredZoom({
|
|
114
|
+
scrollRef,
|
|
115
|
+
labelWidth: LABEL_WIDTH,
|
|
116
|
+
ppf,
|
|
117
|
+
min: 0.2,
|
|
118
|
+
max: 60,
|
|
119
|
+
read,
|
|
120
|
+
setPxPerFrame,
|
|
121
|
+
enabled: !collapsed && doc !== null,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// SHIFT+WHEEL PANS. Native and non-passive for `useAnchoredZoom`'s reason: a
|
|
125
|
+
// React wheel handler is registered passive and cannot preventDefault, so the
|
|
126
|
+
// page would scroll underneath the pan.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const el = scrollRef.current;
|
|
129
|
+
if (el === null) return;
|
|
130
|
+
const onWheel = (e: WheelEvent): void => {
|
|
131
|
+
if (!e.shiftKey || e.ctrlKey || e.metaKey) return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
// A trackpad sends its own horizontal axis; a wheel with one axis sends
|
|
134
|
+
// deltaY and means the same thing with shift held.
|
|
135
|
+
el.scrollLeft += e.deltaX !== 0 ? e.deltaX : e.deltaY;
|
|
136
|
+
};
|
|
137
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
138
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
139
|
+
// Keyed on whether there IS a document, not on which one: the container
|
|
140
|
+
// does not exist until one opens, and re-binding a native listener on every
|
|
141
|
+
// keystroke of an edit would be work for nothing.
|
|
142
|
+
}, [collapsed, doc === null]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A scroll position that cannot be written until the NEXT layout.
|
|
146
|
+
*
|
|
147
|
+
* The lane's scrollable width is React-rendered from `ppf` (`timeW` below
|
|
148
|
+
* feeds every `minWidth`), and `setDockPpf` does not flush a commit — so a
|
|
149
|
+
* `scrollLeft` written in the same handler is clamped by the browser against
|
|
150
|
+
* the width the lane still HAS, not the one it is about to have. Zooming
|
|
151
|
+
* into a late shot from a fitted document would clamp to ~0 and show the
|
|
152
|
+
* first shot at the last shot's zoom.
|
|
153
|
+
*
|
|
154
|
+
* `useAnchoredZoom` solved this for the wheel and its comment names the
|
|
155
|
+
* reason; this is the same pending-ref + layout-effect pair for the one
|
|
156
|
+
* scroll target the wheel never produces.
|
|
157
|
+
*/
|
|
158
|
+
const pendingScroll = useRef<number | null>(null);
|
|
159
|
+
useLayoutEffect(() => {
|
|
160
|
+
const el = scrollRef.current;
|
|
161
|
+
if (el === null || pendingScroll.current === null) return;
|
|
162
|
+
el.scrollLeft = pendingScroll.current;
|
|
163
|
+
pendingScroll.current = null;
|
|
164
|
+
}, [ppf]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fit the whole document across the lane, or ONE SHOT if given its id.
|
|
168
|
+
*
|
|
169
|
+
* Registered with the store rather than bound to a key here, because the
|
|
170
|
+
* keymap is the chrome's ONE key-binding site and only this component knows
|
|
171
|
+
* how wide its lane is — the same division of labour `fitView` uses against
|
|
172
|
+
* the stage.
|
|
173
|
+
*/
|
|
174
|
+
const fit = useCallback(
|
|
175
|
+
(sceneId?: string) => {
|
|
176
|
+
const el = scrollRef.current;
|
|
177
|
+
const current = store.getState().doc;
|
|
178
|
+
if (el === null || current === null) return;
|
|
179
|
+
const width = el.clientWidth - LABEL_WIDTH;
|
|
180
|
+
if (width <= 0) return;
|
|
181
|
+
|
|
182
|
+
// Whole document: the target is 0, which is in range at every zoom, so
|
|
183
|
+
// it needs none of the deferral below.
|
|
184
|
+
if (sceneId === undefined) {
|
|
185
|
+
const total = masterDuration(current);
|
|
186
|
+
if (total <= 0) return;
|
|
187
|
+
store.setDockPpf(width / total);
|
|
188
|
+
el.scrollLeft = 0;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bar = sceneBars(current).find((b) => b.id === sceneId);
|
|
193
|
+
if (bar === undefined) return;
|
|
194
|
+
const span = bar.end - bar.start;
|
|
195
|
+
if (span <= 0) return;
|
|
196
|
+
const before = store.getState().dockPpf;
|
|
197
|
+
store.setDockPpf(width / span);
|
|
198
|
+
// READ THE CLAMP BACK: setDockPpf pins ppf to [0.2, 60], so a very short
|
|
199
|
+
// shot asks for more zoom than it gets and the scroll must be computed
|
|
200
|
+
// against what actually landed.
|
|
201
|
+
const actual = store.getState().dockPpf;
|
|
202
|
+
const target = frameToPx(bar.start, actual);
|
|
203
|
+
// An unchanged ppf renders nothing, so the layout effect above would
|
|
204
|
+
// never fire and the scroll would strand. Layout is already correct
|
|
205
|
+
// there, so write it now.
|
|
206
|
+
if (actual === before) el.scrollLeft = target;
|
|
207
|
+
else pendingScroll.current = target;
|
|
208
|
+
},
|
|
209
|
+
[store],
|
|
210
|
+
);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
store.attachDockFit(fit);
|
|
213
|
+
return () => store.attachDockFit(null);
|
|
214
|
+
}, [store, fit]);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Freeze the snap targets at the press and hand a row back the one function a
|
|
218
|
+
* drag needs.
|
|
219
|
+
*
|
|
220
|
+
* Frozen, for the reason every other value in this lane is read at press: the
|
|
221
|
+
* document changes under a live drag, and a list re-derived per move would
|
|
222
|
+
* start including the thing being dragged.
|
|
223
|
+
*/
|
|
224
|
+
const beginSnap = useCallback<SnapPress>(
|
|
225
|
+
(exclude: SnapScope["exclude"], suppressed: boolean) => {
|
|
226
|
+
const s = store.getState();
|
|
227
|
+
const targets =
|
|
228
|
+
suppressed || s.doc === null
|
|
229
|
+
? []
|
|
230
|
+
: collectSnapTargets(s.doc, exclude === undefined ? { playhead: s.frame } : { playhead: s.frame, exclude });
|
|
231
|
+
const threshold = snapThresholdFrames(s.dockPpf);
|
|
232
|
+
return (candidate: number): number => {
|
|
233
|
+
const hit = snapFrame(candidate, targets, threshold);
|
|
234
|
+
setGuide(hit.target === null ? null : hit.frame);
|
|
235
|
+
return hit.frame;
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
[store],
|
|
239
|
+
);
|
|
240
|
+
// The guide is cleared by the END of a drag, and a drag has three ends:
|
|
241
|
+
// release, cancel, and the Escape that `startDrag` turns into a discard. Held
|
|
242
|
+
// here rather than threaded back through every row, because "is a drag
|
|
243
|
+
// running" is a property of the dock and not of any one bar.
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const clear = (): void => setGuide(null);
|
|
246
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
247
|
+
if (e.key === "Escape") clear();
|
|
248
|
+
};
|
|
249
|
+
window.addEventListener("pointerup", clear);
|
|
250
|
+
window.addEventListener("pointercancel", clear);
|
|
251
|
+
window.addEventListener("keydown", onKey);
|
|
252
|
+
return () => {
|
|
253
|
+
window.removeEventListener("pointerup", clear);
|
|
254
|
+
window.removeEventListener("pointercancel", clear);
|
|
255
|
+
window.removeEventListener("keydown", onKey);
|
|
256
|
+
};
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
// Memoised on the document identity: the rows change exactly when the doc
|
|
260
|
+
// does, and this component still re-renders for ppf/guide/collapse changes
|
|
261
|
+
// that should not pay a full row rebuild. Above the null return — hooks.
|
|
262
|
+
// Keyed on the ISOLATION too: going inside a card narrows the outline and the
|
|
263
|
+
// stage, and a dock still listing every layer in the scene is the one panel
|
|
264
|
+
// an author uses to find a block showing rows they cannot click.
|
|
265
|
+
const groups = useMemo(() => (doc === null ? [] : masterRows(doc, isolation)), [doc, isolation]);
|
|
266
|
+
const contentF = useMemo(
|
|
267
|
+
// The RULER ends at the document; the CONTENT may run past it, because a
|
|
268
|
+
// bar dragged off the end has to stay reachable.
|
|
269
|
+
() => (doc === null ? 0 : masterContentFrames(doc, groups)),
|
|
270
|
+
[doc, groups],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (doc === null) return null;
|
|
274
|
+
const total = masterDuration(doc);
|
|
275
|
+
const timeW = frameToPx(contentF, ppf);
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<section
|
|
279
|
+
className={`timeline-dock${collapsed ? " is-collapsed" : ""}`}
|
|
280
|
+
data-testid="timeline-dock"
|
|
281
|
+
aria-label="Timeline"
|
|
282
|
+
>
|
|
283
|
+
{/* A child, not a sibling: the dock's height is content-driven up to its
|
|
284
|
+
max, so only the dock itself knows where its top edge actually is.
|
|
285
|
+
Absolute inside a `position: fixed` box, above the head it overlays. */}
|
|
286
|
+
{collapsed ? null : (
|
|
287
|
+
<ResizeHandle
|
|
288
|
+
axis="y"
|
|
289
|
+
getValue={() => store.getState().dockHeight}
|
|
290
|
+
// Dragging the TOP edge upward is a DECREASING clientY and a TALLER
|
|
291
|
+
// dock, so the pointer's travel is negated.
|
|
292
|
+
sign={-1}
|
|
293
|
+
onChange={(h) => store.setDockHeight(h)}
|
|
294
|
+
onReset={() => store.setDockHeight(DOCK_HEIGHT.default)}
|
|
295
|
+
className="dock-resize"
|
|
296
|
+
label="Resize the timeline"
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
<header className="dock-head">
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
className="dock-collapse"
|
|
303
|
+
aria-expanded={!collapsed}
|
|
304
|
+
aria-label={collapsed ? "Expand timeline" : "Collapse timeline"}
|
|
305
|
+
onClick={() => store.setDockCollapsed(!collapsed)}
|
|
306
|
+
>
|
|
307
|
+
{collapsed ? "▴" : "▾"}
|
|
308
|
+
</button>
|
|
309
|
+
{/* The transport, on the strip it drives. The header has one too and
|
|
310
|
+
both call the same action — there is one `playing` bit, so they
|
|
311
|
+
cannot disagree, and a timeline you have to look away from to start
|
|
312
|
+
is a timeline you scrub with the keyboard instead. */}
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
className="dock-play"
|
|
316
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
317
|
+
title={playing ? "Pause (Space)" : "Play (Space)"}
|
|
318
|
+
onClick={() => store.setPlaying(!playing)}
|
|
319
|
+
>
|
|
320
|
+
{playing ? "❚❚" : "▶"}
|
|
321
|
+
</button>
|
|
322
|
+
<DockTime fps={doc.fps} />
|
|
323
|
+
<span className="dock-span" data-testid="dock-span">
|
|
324
|
+
Start {timecode(0, doc.fps)} / End {timecode(total, doc.fps)}
|
|
325
|
+
</span>
|
|
326
|
+
{/* Wrapped, not passed by reference: `fit` now takes an optional
|
|
327
|
+
sceneId, and a bare handler would hand it the MouseEvent. */}
|
|
328
|
+
<button type="button" className="dock-fit" title="Fit the timeline (\)" onClick={() => fit()}>
|
|
329
|
+
Fit
|
|
330
|
+
</button>
|
|
331
|
+
{/* MONITORING, not the mix. Neither control touches the document, so
|
|
332
|
+
neither can change what an export sounds like — see
|
|
333
|
+
ShotEditorState.previewMuted. */}
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
className="dock-mute"
|
|
337
|
+
aria-label={previewMuted ? "Unmute preview" : "Mute preview"}
|
|
338
|
+
aria-pressed={previewMuted}
|
|
339
|
+
title={previewMuted ? "Preview muted" : "Mute the preview"}
|
|
340
|
+
onClick={() => store.setPreviewMuted(!previewMuted)}
|
|
341
|
+
>
|
|
342
|
+
{previewMuted ? "🔇" : "🔊"}
|
|
343
|
+
</button>
|
|
344
|
+
<input
|
|
345
|
+
className="dock-volume"
|
|
346
|
+
type="range"
|
|
347
|
+
min={0}
|
|
348
|
+
max={1}
|
|
349
|
+
step={0.01}
|
|
350
|
+
value={previewVolume}
|
|
351
|
+
aria-label="Preview volume"
|
|
352
|
+
onChange={(e) => store.setPreviewVolume(Number(e.currentTarget.value))}
|
|
353
|
+
/>
|
|
354
|
+
<DockReadout total={total} />
|
|
355
|
+
</header>
|
|
356
|
+
|
|
357
|
+
{/* The body is HIDDEN, not unmounted, when collapsed. Unmounting would
|
|
358
|
+
throw away the scroll position and every row's state, and the author
|
|
359
|
+
would come back to a dock that had forgotten where they were —
|
|
360
|
+
dockCollapse.test.tsx is the gate. */}
|
|
361
|
+
<div className="dock-body" hidden={collapsed} data-testid="dock-body">
|
|
362
|
+
<div className="dock-scroll" ref={scrollRef} data-testid="dock-scroll">
|
|
363
|
+
{/* INSIDE the scroller, with the same content width as the tracks.
|
|
364
|
+
It used to sit outside, and that was correct while it was a macro
|
|
365
|
+
lane over a scene-local micro one — the two never shared an x. They
|
|
366
|
+
share one now, so a strip that did not scroll with the tracks would
|
|
367
|
+
draw every scene bar somewhere its own boundary line is not. */}
|
|
368
|
+
<div style={{ minWidth: LABEL_WIDTH + timeW }}>
|
|
369
|
+
<SceneStrip ppf={ppf} labelWidth={LABEL_WIDTH} />
|
|
370
|
+
</div>
|
|
371
|
+
<div className="dock-ruler" style={{ marginLeft: LABEL_WIDTH, minWidth: timeW }}>
|
|
372
|
+
{/* MASTER frames end to end. The seek is the identity now — the two
|
|
373
|
+
clocks used to meet here, and that conversion was the one place a
|
|
374
|
+
sign error put the playhead in another scene. */}
|
|
375
|
+
<Ruler
|
|
376
|
+
durationFrames={total}
|
|
377
|
+
contentF={contentF}
|
|
378
|
+
ppf={ppf}
|
|
379
|
+
timeW={timeW}
|
|
380
|
+
formatTick={(f) => timecode(f, doc.fps)}
|
|
381
|
+
onSeek={(f) => store.seek(f)}
|
|
382
|
+
>
|
|
383
|
+
<DockPlayhead ppf={ppf} />
|
|
384
|
+
</Ruler>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<div className="dock-tracks" data-testid="dock-tracks" style={{ minWidth: LABEL_WIDTH + timeW }}>
|
|
388
|
+
{/* The scene boundaries, drawn THROUGH the tracks. Derived from the
|
|
389
|
+
same prefix sum the strip above uses, so the line and the bar it
|
|
390
|
+
divides can never be a frame apart. */}
|
|
391
|
+
<div className="dock-overlay" style={{ left: LABEL_WIDTH }} aria-hidden="true">
|
|
392
|
+
{groups.map((group) =>
|
|
393
|
+
group.start === 0 ? null : (
|
|
394
|
+
<span
|
|
395
|
+
key={group.id}
|
|
396
|
+
className="dock-boundary"
|
|
397
|
+
data-boundary={group.id}
|
|
398
|
+
style={{ left: frameToPx(group.start, ppf) }}
|
|
399
|
+
/>
|
|
400
|
+
),
|
|
401
|
+
)}
|
|
402
|
+
{guide === null ? null : (
|
|
403
|
+
<span className="dock-snap-guide" data-testid="snap-guide" style={{ left: frameToPx(guide, ppf) }} />
|
|
404
|
+
)}
|
|
405
|
+
<DockPlayhead ppf={ppf} />
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{groups.map((group) => (
|
|
409
|
+
<div className="dock-scene-group" key={group.id} data-scene-group={group.id}>
|
|
410
|
+
{/* The camera is a ROW like any other, one per scene at its own
|
|
411
|
+
master position, so a camera move trims and retimes with the
|
|
412
|
+
same gestures a block does. */}
|
|
413
|
+
<BlockRow
|
|
414
|
+
label="Camera"
|
|
415
|
+
sceneId={group.id}
|
|
416
|
+
layerId={CAMERA_ROW_ID}
|
|
417
|
+
depth={0}
|
|
418
|
+
bars={group.camera}
|
|
419
|
+
ppf={ppf}
|
|
420
|
+
labelWidth={LABEL_WIDTH}
|
|
421
|
+
offsetFrames={group.start}
|
|
422
|
+
beginSnap={beginSnap}
|
|
423
|
+
// The same action ⇧C runs. A camera move has no preset to
|
|
424
|
+
// pick, so unlike a layer's blocks it can be created from the
|
|
425
|
+
// row; the scene is passed so a row that is not under the
|
|
426
|
+
// playhead is refused by name rather than writing somewhere else.
|
|
427
|
+
onAdd={() => store.addShotAtPlayhead(group.id)}
|
|
428
|
+
addLabel="+ move"
|
|
429
|
+
/>
|
|
430
|
+
{group.rows.map((row) => (
|
|
431
|
+
<Fragment key={row.layerId}>
|
|
432
|
+
<LifespanRow
|
|
433
|
+
row={row}
|
|
434
|
+
sceneId={group.id}
|
|
435
|
+
sceneStart={group.start}
|
|
436
|
+
ppf={ppf}
|
|
437
|
+
labelWidth={LABEL_WIDTH}
|
|
438
|
+
expanded={open.has(row.layerId)}
|
|
439
|
+
onToggle={() =>
|
|
440
|
+
setOpen((was) => {
|
|
441
|
+
const next = new Set(was);
|
|
442
|
+
if (!next.delete(row.layerId)) next.add(row.layerId);
|
|
443
|
+
return next;
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
beginSnap={beginSnap}
|
|
447
|
+
/>
|
|
448
|
+
{/* The Figma/Lottie read: one derived row per channel the
|
|
449
|
+
blocks write, diamonds at each block's ends. The
|
|
450
|
+
preset-named block lane is gone from the expansion —
|
|
451
|
+
the preset lives in the span tooltip and the Animate
|
|
452
|
+
tab. See ChannelRow.tsx. */}
|
|
453
|
+
{open.has(row.layerId) &&
|
|
454
|
+
(() => {
|
|
455
|
+
const layer = doc.scenesById[group.id]?.layersById[row.layerId];
|
|
456
|
+
return layer === undefined ? null : (
|
|
457
|
+
<ChannelRows
|
|
458
|
+
layer={layer}
|
|
459
|
+
sceneId={group.id}
|
|
460
|
+
depth={row.depth + 1}
|
|
461
|
+
ppf={ppf}
|
|
462
|
+
labelWidth={LABEL_WIDTH}
|
|
463
|
+
offsetFrames={group.start}
|
|
464
|
+
fps={doc.fps}
|
|
465
|
+
lifespan={row.lifespan}
|
|
466
|
+
beginSnap={beginSnap}
|
|
467
|
+
/>
|
|
468
|
+
);
|
|
469
|
+
})()}
|
|
470
|
+
</Fragment>
|
|
471
|
+
))}
|
|
472
|
+
</div>
|
|
473
|
+
))}
|
|
474
|
+
|
|
475
|
+
{/* UNDER the video and OVER the audio, which is where every NLE
|
|
476
|
+
puts the two. Both are outside the scene groups, and
|
|
477
|
+
deliberately: `doc.audio` and `doc.markers` are MASTER-timed and
|
|
478
|
+
belong to no scene, so a lane nested in one would be drawing a
|
|
479
|
+
lie. The marks sit directly over the waveform because they are
|
|
480
|
+
almost always beats in it. */}
|
|
481
|
+
<MarkerLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
|
|
482
|
+
<AudioLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</section>
|
|
487
|
+
);
|
|
488
|
+
}
|