@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,242 @@
|
|
|
1
|
+
// One track: a layer's blocks, or the camera's shots, as INTERVALS.
|
|
2
|
+
//
|
|
3
|
+
// ── THE THREE DRAGS, AND WHY THEY WRITE DIFFERENT FIELDS ────────────────
|
|
4
|
+
//
|
|
5
|
+
// BODY writes `frame`.
|
|
6
|
+
// IN writes `frame` AND `durationFrames`, so the out edge stays put.
|
|
7
|
+
// OUT writes `durationFrames`.
|
|
8
|
+
//
|
|
9
|
+
// One field for both edges is the classic version of this bug, and it makes a
|
|
10
|
+
// left-trim slide the whole block. The document math is in
|
|
11
|
+
// @dailies-studio/shot's timelineOps; this file only turns pixels into frames.
|
|
12
|
+
//
|
|
13
|
+
// ── SCENE-LOCAL BARS ON A MASTER STRIP ──────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// `offsetFrames` is the scene's master start. It is added for DRAWING and
|
|
16
|
+
// subtracted nowhere, because a drag never leaves scene-local space: what a
|
|
17
|
+
// pointer produces is a signed DELTA, and a delta is the same number in both
|
|
18
|
+
// clocks. Only SNAPPING needs the master frame — its targets come from other
|
|
19
|
+
// scenes — so that is the one place the offset crosses, and it crosses in one
|
|
20
|
+
// expression that converts straight back.
|
|
21
|
+
//
|
|
22
|
+
// ── THE SPRING'S OUT HANDLE IS DISABLED, AND SAYS SO ────────────────────
|
|
23
|
+
//
|
|
24
|
+
// `clipProgress01`'s spring arm measures elapsed SECONDS, because a spring has
|
|
25
|
+
// an intrinsic settle time. So dragging that edge changes where the window
|
|
26
|
+
// CLIPS, not how fast the spring settles. That is correct physics and it is
|
|
27
|
+
// still surprising, so the handle is disabled and the tail is drawn dashed —
|
|
28
|
+
// the author sees the block continuing past its window rather than discovering
|
|
29
|
+
// the rule by dragging.
|
|
30
|
+
import { frameToPx, startDrag } from "@dailies-studio/timeline";
|
|
31
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
32
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
33
|
+
import type { BlockBar, Lifespan } from "./model.js";
|
|
34
|
+
import { CAMERA_ROW_ID, deadSpans, frameDelta } from "./model.js";
|
|
35
|
+
import type { SnapPress } from "./snap.js";
|
|
36
|
+
import { isSnapSuppressed } from "./snap.js";
|
|
37
|
+
|
|
38
|
+
export function BlockRow({
|
|
39
|
+
label,
|
|
40
|
+
sceneId,
|
|
41
|
+
layerId,
|
|
42
|
+
depth,
|
|
43
|
+
bars,
|
|
44
|
+
ppf,
|
|
45
|
+
labelWidth,
|
|
46
|
+
offsetFrames = 0,
|
|
47
|
+
lifespan,
|
|
48
|
+
beginSnap,
|
|
49
|
+
onAdd,
|
|
50
|
+
addLabel,
|
|
51
|
+
}: {
|
|
52
|
+
label: string;
|
|
53
|
+
/** WHICH SCENE this row's bars live in. Read from the row and never from
|
|
54
|
+
* `state.sceneId`: the dock shows every scene at once, so the row under the
|
|
55
|
+
* pointer is routinely not the one the playhead is in. */
|
|
56
|
+
sceneId: string;
|
|
57
|
+
layerId: string;
|
|
58
|
+
depth: number;
|
|
59
|
+
bars: readonly BlockBar[];
|
|
60
|
+
ppf: number;
|
|
61
|
+
labelWidth: number;
|
|
62
|
+
/** The scene's master start. Drawing only — see the header. */
|
|
63
|
+
offsetFrames?: number;
|
|
64
|
+
/** The owning layer's lifespan, when it has one. A block outside it is kept
|
|
65
|
+
* and drawn dead rather than deleted. Absent on the camera row, which has no
|
|
66
|
+
* layer and therefore no lifespan. */
|
|
67
|
+
lifespan?: Lifespan;
|
|
68
|
+
beginSnap?: SnapPress;
|
|
69
|
+
/** Give the row a create button in its label cell. Only the camera row has
|
|
70
|
+
* one: a layer's blocks are added from the Animate palette, which is where
|
|
71
|
+
* the author picks WHICH preset, and a bare "+" there would have to guess. */
|
|
72
|
+
onAdd?: () => void;
|
|
73
|
+
addLabel?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const store = useStore();
|
|
76
|
+
const selectedLayer = useShotEditor((s) => s.selection.includes(layerId));
|
|
77
|
+
const selectedBlock = useShotEditor((s) => s.selectedBlock);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className={`block-row${selectedLayer ? " is-selected" : ""}`}
|
|
82
|
+
data-track={layerId}
|
|
83
|
+
data-row-scene={sceneId}
|
|
84
|
+
>
|
|
85
|
+
{/* A CELL rather than a wide button, because the add control has to sit
|
|
86
|
+
beside the name and a button inside a button is not markup a browser
|
|
87
|
+
will keep. */}
|
|
88
|
+
<div className="block-row-label-cell" style={{ width: labelWidth }}>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className="block-row-label"
|
|
92
|
+
style={{ paddingLeft: 8 + depth * 12 }}
|
|
93
|
+
onClick={() => {
|
|
94
|
+
// NAVIGATE FIRST when the row belongs to another scene: the stage
|
|
95
|
+
// shows one scene, and `select` orders within the current one — so
|
|
96
|
+
// without this the selection resolves to empty and nothing happens.
|
|
97
|
+
store.focusScene(sceneId);
|
|
98
|
+
if (layerId !== CAMERA_ROW_ID) store.select([layerId]);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{label}
|
|
102
|
+
</button>
|
|
103
|
+
{onAdd !== undefined && (
|
|
104
|
+
<button type="button" className="block-row-add" onClick={onAdd}>
|
|
105
|
+
{addLabel ?? "+"}
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="block-row-lane">
|
|
110
|
+
{bars.map((bar) => (
|
|
111
|
+
<BlockBarView
|
|
112
|
+
key={bar.id}
|
|
113
|
+
bar={bar}
|
|
114
|
+
ppf={ppf}
|
|
115
|
+
offsetFrames={offsetFrames}
|
|
116
|
+
lifespan={lifespan}
|
|
117
|
+
beginSnap={beginSnap}
|
|
118
|
+
sceneId={sceneId}
|
|
119
|
+
selected={selectedBlock?.blockId === bar.id}
|
|
120
|
+
onSelect={() => {
|
|
121
|
+
store.focusScene(sceneId);
|
|
122
|
+
store.selectBlock({ sceneId, layerId: bar.layerId, blockId: bar.id });
|
|
123
|
+
}}
|
|
124
|
+
onDrag={(edge, toFrame) => store.dragBlock(sceneId, bar.layerId, bar.id, edge, toFrame)}
|
|
125
|
+
onDragEnd={(commit) => store.endBlockDrag(commit)}
|
|
126
|
+
onDragStart={() => store.beginBlockDrag()}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function BlockBarView({
|
|
135
|
+
bar,
|
|
136
|
+
ppf,
|
|
137
|
+
offsetFrames,
|
|
138
|
+
lifespan,
|
|
139
|
+
beginSnap,
|
|
140
|
+
sceneId,
|
|
141
|
+
selected,
|
|
142
|
+
onSelect,
|
|
143
|
+
onDragStart,
|
|
144
|
+
onDrag,
|
|
145
|
+
onDragEnd,
|
|
146
|
+
}: {
|
|
147
|
+
bar: BlockBar;
|
|
148
|
+
ppf: number;
|
|
149
|
+
offsetFrames: number;
|
|
150
|
+
lifespan: Lifespan | undefined;
|
|
151
|
+
beginSnap: SnapPress | undefined;
|
|
152
|
+
sceneId: string;
|
|
153
|
+
selected: boolean;
|
|
154
|
+
onSelect(): void;
|
|
155
|
+
onDragStart(): void;
|
|
156
|
+
onDrag(edge: "body" | "in" | "out", toFrame: number): void;
|
|
157
|
+
onDragEnd(commit: boolean): void;
|
|
158
|
+
}) {
|
|
159
|
+
const left = frameToPx(bar.frame + offsetFrames, ppf);
|
|
160
|
+
const width = Math.max(4, frameToPx(bar.durationFrames, ppf));
|
|
161
|
+
const dead = deadSpans(bar, lifespan);
|
|
162
|
+
|
|
163
|
+
const drag =
|
|
164
|
+
(edge: "body" | "in" | "out") =>
|
|
165
|
+
(e: ReactPointerEvent<HTMLElement>): void => {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
onSelect();
|
|
169
|
+
const startX = e.clientX;
|
|
170
|
+
// The values AT PRESS, so every move is an absolute offset from them —
|
|
171
|
+
// never cumulative, which drifts the moment a move is coalesced. The
|
|
172
|
+
// modifier is read at press too: a gesture that changed meaning halfway
|
|
173
|
+
// through would be unusable.
|
|
174
|
+
const startFrame = bar.frame;
|
|
175
|
+
const startEnd = bar.frame + bar.durationFrames;
|
|
176
|
+
// THIS BAR, not this layer: only the two edges under the pointer are
|
|
177
|
+
// frozen, so the block can still land on its own siblings and on its
|
|
178
|
+
// layer's lifespan. LifespanRow passes no blockId, because there the whole
|
|
179
|
+
// layer is what moves.
|
|
180
|
+
const snap = beginSnap?.({ sceneId, layerId: bar.layerId, blockId: bar.id }, isSnapSuppressed(e));
|
|
181
|
+
const place = (anchor: number, delta: number): number =>
|
|
182
|
+
snap === undefined ? anchor + delta : snap(anchor + delta + offsetFrames) - offsetFrames;
|
|
183
|
+
onDragStart();
|
|
184
|
+
startDrag(e, {
|
|
185
|
+
onMove(ev) {
|
|
186
|
+
// A signed DELTA, so it must not go through `pxToFrame` — see
|
|
187
|
+
// frameDelta. A clamped delta drops every leftward drag.
|
|
188
|
+
const delta = frameDelta(ev.clientX - startX, ppf);
|
|
189
|
+
if (edge === "out") onDrag("out", place(startEnd, delta));
|
|
190
|
+
else onDrag(edge, place(startFrame, delta));
|
|
191
|
+
},
|
|
192
|
+
onEnd: (commit) => onDragEnd(commit),
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className={`block-bar slot-${bar.slot}${selected ? " is-selected" : ""}${dead.length > 0 ? " is-clipped" : ""}`}
|
|
199
|
+
style={{ left, width }}
|
|
200
|
+
data-block={bar.id}
|
|
201
|
+
data-clipped={dead.length > 0 ? "true" : undefined}
|
|
202
|
+
onPointerDown={drag("body")}
|
|
203
|
+
title={
|
|
204
|
+
`${bar.preset} · ${bar.slot} · ${bar.frame}–${bar.frame + bar.durationFrames}` +
|
|
205
|
+
(dead.length > 0 ? " · outside the layer's lifespan, so this part never plays" : "")
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
{/* KEPT, NOT DELETED. Shortening a lifespan past a block is a trim, not a
|
|
209
|
+
request to throw the block away — refusal over corruption applies to
|
|
210
|
+
the editor too. The part that can never play is greyed in place, so the
|
|
211
|
+
author can see exactly how far to pull the edge back. */}
|
|
212
|
+
{dead.map((span) => (
|
|
213
|
+
<span
|
|
214
|
+
key={span.from}
|
|
215
|
+
className="block-dead"
|
|
216
|
+
aria-hidden="true"
|
|
217
|
+
style={{ left: frameToPx(span.from - bar.frame, ppf), width: frameToPx(span.to - span.from, ppf) }}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
<span
|
|
221
|
+
className="block-handle block-handle-in"
|
|
222
|
+
data-handle="in"
|
|
223
|
+
onPointerDown={drag("in")}
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
/>
|
|
226
|
+
<span className="block-label">{bar.preset}</span>
|
|
227
|
+
{bar.spring ? (
|
|
228
|
+
// The settle tail: the block keeps moving past its own window, which is
|
|
229
|
+
// what a spring does and what the disabled handle is about.
|
|
230
|
+
<span className="block-spring-tail" aria-hidden="true" />
|
|
231
|
+
) : (
|
|
232
|
+
<span
|
|
233
|
+
className="block-handle block-handle-out"
|
|
234
|
+
data-handle="out"
|
|
235
|
+
onPointerDown={drag("out")}
|
|
236
|
+
aria-hidden="true"
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The channel rows under an expanded layer: one row per animated property,
|
|
2
|
+
// diamonds at the ends — the Figma/Lottie read, and since the preset-named
|
|
3
|
+
// block lane left the expansion, the ONLY expanded read.
|
|
4
|
+
//
|
|
5
|
+
// ── A VIEW OVER BLOCKS, NEVER A SECOND MODEL ────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// Every span here is a block's footprint on one channel (model.ts's
|
|
8
|
+
// `channelRows`), and every gesture routes through the SAME store ops the
|
|
9
|
+
// block lane uses — `dragBlock`, `selectBlock`. Dragging the "opacity
|
|
10
|
+
// keyframes" therefore moves the Fade block on every channel it writes, in one
|
|
11
|
+
// undo entry. The preset's name lives in the span's tooltip and the Animate
|
|
12
|
+
// tab. There is nothing a channel row can do that a block cannot, which is
|
|
13
|
+
// what keeps the document blocks-not-tracks.
|
|
14
|
+
//
|
|
15
|
+
// ── THE LIVE VALUE IS A LEAF ────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// The label cell shows the channel's resolved value AT THE PLAYHEAD. That
|
|
18
|
+
// number changes ~30 times a second during playback, so it is its own
|
|
19
|
+
// component with its own `frame` subscription — the dock's DockTime rule. The
|
|
20
|
+
// rows themselves never see the frame.
|
|
21
|
+
import { frameToPx, startDrag } from "@dailies-studio/timeline";
|
|
22
|
+
import { resolveLayerAt } from "@dailies-studio/shot/doc";
|
|
23
|
+
import type { Layer3D } from "@dailies-studio/shot/doc";
|
|
24
|
+
import type { Rational } from "@dailies-studio/engine";
|
|
25
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
26
|
+
import { useMemo } from "react";
|
|
27
|
+
import { CHANNEL_BY_KEY } from "../../channels.js";
|
|
28
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
29
|
+
import { useCoalescedFrame } from "../inspector/useDocWrite.js";
|
|
30
|
+
import { channelRows, deadSpans, frameDelta } from "./model.js";
|
|
31
|
+
import type { ChannelRowModel, ChannelSpan, Lifespan } from "./model.js";
|
|
32
|
+
import type { SnapPress } from "./snap.js";
|
|
33
|
+
import { isSnapSuppressed } from "./snap.js";
|
|
34
|
+
|
|
35
|
+
export function ChannelRows({
|
|
36
|
+
layer,
|
|
37
|
+
sceneId,
|
|
38
|
+
depth,
|
|
39
|
+
ppf,
|
|
40
|
+
labelWidth,
|
|
41
|
+
offsetFrames,
|
|
42
|
+
fps,
|
|
43
|
+
lifespan,
|
|
44
|
+
beginSnap,
|
|
45
|
+
}: {
|
|
46
|
+
layer: Layer3D;
|
|
47
|
+
sceneId: string;
|
|
48
|
+
depth: number;
|
|
49
|
+
ppf: number;
|
|
50
|
+
labelWidth: number;
|
|
51
|
+
/** The scene's master start. Drawing only, like every bar in this lane. */
|
|
52
|
+
offsetFrames: number;
|
|
53
|
+
fps: Rational;
|
|
54
|
+
/** The owning layer's lifespan. A span outside it is kept and drawn dead,
|
|
55
|
+
* the block lane's own rule. */
|
|
56
|
+
lifespan?: Lifespan | undefined;
|
|
57
|
+
beginSnap?: SnapPress | undefined;
|
|
58
|
+
}) {
|
|
59
|
+
// Derived per layer identity: the rows change exactly when the layer's
|
|
60
|
+
// blocks or transform do, and the dock re-renders for ppf/guide changes
|
|
61
|
+
// that should not pay a re-derive.
|
|
62
|
+
const rows = useMemo(() => channelRows(layer, fps), [layer, fps]);
|
|
63
|
+
// A layer with no animation still answers the disclosure: the hint is where
|
|
64
|
+
// the first block comes from, and an empty expansion reads as broken.
|
|
65
|
+
if (rows.length === 0) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="channel-row channel-empty" data-testid="channel-empty">
|
|
68
|
+
<div className="channel-label-cell" style={{ width: labelWidth, paddingLeft: 8 + depth * 12 }}>
|
|
69
|
+
<span className="channel-name">No animation</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="channel-lane channel-empty-hint">
|
|
72
|
+
Toggle ⇧A and edit — the change becomes keyframes ending at the playhead.
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
{rows.map((row) => (
|
|
80
|
+
<ChannelRow
|
|
81
|
+
key={row.key}
|
|
82
|
+
row={row}
|
|
83
|
+
layer={layer}
|
|
84
|
+
sceneId={sceneId}
|
|
85
|
+
depth={depth}
|
|
86
|
+
ppf={ppf}
|
|
87
|
+
labelWidth={labelWidth}
|
|
88
|
+
offsetFrames={offsetFrames}
|
|
89
|
+
fps={fps}
|
|
90
|
+
lifespan={lifespan}
|
|
91
|
+
beginSnap={beginSnap}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ChannelRow({
|
|
99
|
+
row,
|
|
100
|
+
layer,
|
|
101
|
+
sceneId,
|
|
102
|
+
depth,
|
|
103
|
+
ppf,
|
|
104
|
+
labelWidth,
|
|
105
|
+
offsetFrames,
|
|
106
|
+
fps,
|
|
107
|
+
lifespan,
|
|
108
|
+
beginSnap,
|
|
109
|
+
}: {
|
|
110
|
+
row: ChannelRowModel;
|
|
111
|
+
layer: Layer3D;
|
|
112
|
+
sceneId: string;
|
|
113
|
+
depth: number;
|
|
114
|
+
ppf: number;
|
|
115
|
+
labelWidth: number;
|
|
116
|
+
offsetFrames: number;
|
|
117
|
+
fps: Rational;
|
|
118
|
+
lifespan?: Lifespan | undefined;
|
|
119
|
+
beginSnap?: SnapPress | undefined;
|
|
120
|
+
}) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="channel-row" data-channel-row={row.key} data-row-scene={sceneId}>
|
|
123
|
+
<div className="channel-label-cell" style={{ width: labelWidth, paddingLeft: 8 + depth * 12 }}>
|
|
124
|
+
<span className="channel-name">{row.label}</span>
|
|
125
|
+
<ChannelValue layer={layer} channel={row.key} offsetFrames={offsetFrames} fps={fps} />
|
|
126
|
+
</div>
|
|
127
|
+
<div className="channel-lane">
|
|
128
|
+
{row.spans.map((span) => (
|
|
129
|
+
<ChannelSpanView
|
|
130
|
+
key={span.blockId}
|
|
131
|
+
span={span}
|
|
132
|
+
channelLabel={row.label}
|
|
133
|
+
layerId={layer.id}
|
|
134
|
+
sceneId={sceneId}
|
|
135
|
+
ppf={ppf}
|
|
136
|
+
offsetFrames={offsetFrames}
|
|
137
|
+
lifespan={lifespan}
|
|
138
|
+
beginSnap={beginSnap}
|
|
139
|
+
/>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The resolved value at the playhead — the same fold the stage draws, through
|
|
147
|
+
* the shared channel table's formatter. Total at every frame, so a playhead
|
|
148
|
+
* outside this scene simply shows the layer's held value. */
|
|
149
|
+
function ChannelValue({
|
|
150
|
+
layer,
|
|
151
|
+
channel,
|
|
152
|
+
offsetFrames,
|
|
153
|
+
fps,
|
|
154
|
+
}: {
|
|
155
|
+
layer: Layer3D;
|
|
156
|
+
channel: ChannelRowModel["key"];
|
|
157
|
+
offsetFrames: number;
|
|
158
|
+
fps: Rational;
|
|
159
|
+
}) {
|
|
160
|
+
// The COALESCED frame, not the raw one — the inspector's posture, applied
|
|
161
|
+
// here for the inspector's reason: this runs a full `resolveLayerAt` block
|
|
162
|
+
// fold in render, one instance per open channel row, and the raw frame ran
|
|
163
|
+
// every one of them per playback tick (~750 folds/s with five layers
|
|
164
|
+
// expanded). Held while playing, ~10 Hz while scrubbing, leading edge on a
|
|
165
|
+
// lone seek — a readout, not an animation.
|
|
166
|
+
const frame = useCoalescedFrame();
|
|
167
|
+
const spec = CHANNEL_BY_KEY.get(channel);
|
|
168
|
+
if (spec === undefined) return null;
|
|
169
|
+
return (
|
|
170
|
+
<span className="channel-value" data-testid={`channel-value-${channel}`}>
|
|
171
|
+
{spec.read(resolveLayerAt(layer, frame - offsetFrames, fps))}
|
|
172
|
+
</span>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ChannelSpanView({
|
|
177
|
+
span,
|
|
178
|
+
channelLabel,
|
|
179
|
+
layerId,
|
|
180
|
+
sceneId,
|
|
181
|
+
ppf,
|
|
182
|
+
offsetFrames,
|
|
183
|
+
lifespan,
|
|
184
|
+
beginSnap,
|
|
185
|
+
}: {
|
|
186
|
+
span: ChannelSpan;
|
|
187
|
+
channelLabel: string;
|
|
188
|
+
layerId: string;
|
|
189
|
+
sceneId: string;
|
|
190
|
+
ppf: number;
|
|
191
|
+
offsetFrames: number;
|
|
192
|
+
lifespan?: Lifespan | undefined;
|
|
193
|
+
beginSnap?: SnapPress | undefined;
|
|
194
|
+
}) {
|
|
195
|
+
const store = useStore();
|
|
196
|
+
const selected = useShotEditor((s) => s.selectedBlock?.blockId === span.blockId);
|
|
197
|
+
const left = frameToPx(span.frame + offsetFrames, ppf);
|
|
198
|
+
const width = Math.max(4, frameToPx(span.durationFrames, ppf));
|
|
199
|
+
const dead = deadSpans(span, lifespan);
|
|
200
|
+
|
|
201
|
+
// BlockBarView's drag, verbatim in shape: values read at press, a signed
|
|
202
|
+
// delta never through pxToFrame, the snap frozen with THIS block excluded.
|
|
203
|
+
const drag =
|
|
204
|
+
(edge: "body" | "in" | "out") =>
|
|
205
|
+
(e: ReactPointerEvent<HTMLElement>): void => {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
e.stopPropagation();
|
|
208
|
+
store.focusScene(sceneId);
|
|
209
|
+
store.selectBlock({ sceneId, layerId, blockId: span.blockId });
|
|
210
|
+
const startX = e.clientX;
|
|
211
|
+
const startFrame = span.frame;
|
|
212
|
+
const startEnd = span.frame + span.durationFrames;
|
|
213
|
+
const snap = beginSnap?.({ sceneId, layerId, blockId: span.blockId }, isSnapSuppressed(e));
|
|
214
|
+
const place = (anchor: number, delta: number): number =>
|
|
215
|
+
snap === undefined ? anchor + delta : snap(anchor + delta + offsetFrames) - offsetFrames;
|
|
216
|
+
store.beginBlockDrag();
|
|
217
|
+
startDrag(e, {
|
|
218
|
+
onMove(ev) {
|
|
219
|
+
const delta = frameDelta(ev.clientX - startX, ppf);
|
|
220
|
+
if (edge === "out") store.dragBlock(sceneId, layerId, span.blockId, "out", place(startEnd, delta));
|
|
221
|
+
else store.dragBlock(sceneId, layerId, span.blockId, edge, place(startFrame, delta));
|
|
222
|
+
},
|
|
223
|
+
onEnd: (commit) => store.endBlockDrag(commit),
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div
|
|
229
|
+
className={`channel-span slot-${span.slot}${selected ? " is-selected" : ""}${dead.length > 0 ? " is-clipped" : ""}`}
|
|
230
|
+
style={{ left, width }}
|
|
231
|
+
data-channel-span={span.blockId}
|
|
232
|
+
data-clipped={dead.length > 0 ? "true" : undefined}
|
|
233
|
+
onPointerDown={drag("body")}
|
|
234
|
+
title={
|
|
235
|
+
`${span.preset} · ${channelLabel}: ${span.from} → ${span.to}` +
|
|
236
|
+
(dead.length > 0 ? " · outside the layer's lifespan, so this part never plays" : "")
|
|
237
|
+
}
|
|
238
|
+
>
|
|
239
|
+
<span className="channel-connector" aria-hidden="true" />
|
|
240
|
+
{/* KEPT, NOT DELETED — the block lane's rule, restated per channel:
|
|
241
|
+
shortening a lifespan is a trim, and the part that can never play is
|
|
242
|
+
dashed in place so the author can see how far to pull the edge back. */}
|
|
243
|
+
{dead.map((d) => (
|
|
244
|
+
<span
|
|
245
|
+
key={d.from}
|
|
246
|
+
className="channel-dead"
|
|
247
|
+
aria-hidden="true"
|
|
248
|
+
style={{ left: frameToPx(d.from - span.frame, ppf), width: frameToPx(d.to - d.from, ppf) }}
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
251
|
+
<span className="channel-key channel-key-in" data-key="in" onPointerDown={drag("in")} aria-hidden="true" />
|
|
252
|
+
{span.spring ? (
|
|
253
|
+
// The settle tail, same statement the block bar makes: a spring keeps
|
|
254
|
+
// moving past its window, and its end is physics rather than a handle.
|
|
255
|
+
<span className="channel-spring-tail" aria-hidden="true" />
|
|
256
|
+
) : (
|
|
257
|
+
<span
|
|
258
|
+
className="channel-key channel-key-out"
|
|
259
|
+
data-key="out"
|
|
260
|
+
onPointerDown={drag("out")}
|
|
261
|
+
aria-hidden="true"
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A layer's LIFESPAN bar: one rounded bar per layer, at its master position.
|
|
2
|
+
//
|
|
3
|
+
// ── THE BLOCK IS THE ATOM, AND THE LIFESPAN IS THE LAYER ────────────────
|
|
4
|
+
//
|
|
5
|
+
// The disclosure triangle expands this row into one DERIVED row per channel
|
|
6
|
+
// the layer's blocks write (ChannelRow.tsx) — property rows with keyframe
|
|
7
|
+
// diamonds, the Figma/Lottie read. The channel rows are views, never storage:
|
|
8
|
+
// every gesture on one drags the whole BLOCK, in one undo entry, and the
|
|
9
|
+
// preset's name lives in the span's tooltip and the Animate tab. The block is
|
|
10
|
+
// still where this timeline's DOCUMENT stops.
|
|
11
|
+
//
|
|
12
|
+
// ── THREE GESTURES, AND WHY THE THIRD IS NOT OPTIONAL ───────────────────
|
|
13
|
+
//
|
|
14
|
+
// BODY drags the bar AND every block on the layer with it.
|
|
15
|
+
// IN / OUT move one end and touch no block.
|
|
16
|
+
// SLIP (⌥ held) moves every block and leaves the bar exactly where it is.
|
|
17
|
+
//
|
|
18
|
+
// Body-versus-edge is After Effects verbatim, and no tool makes it a preference
|
|
19
|
+
// — so there is no setting here either. SLIP is the one every two-gesture
|
|
20
|
+
// timeline gets filed against for: AE draws it as the hollow slip bar, Premiere
|
|
21
|
+
// gives it its own tool, Cavalry splits it into offset and crop. Without it,
|
|
22
|
+
// "this animation should start a beat later, but the layer must stay where it
|
|
23
|
+
// is" has no gesture at all.
|
|
24
|
+
//
|
|
25
|
+
// All three go through `history.scrub()`, so each is ONE undo entry and Escape
|
|
26
|
+
// restores the document by reference — every capture re-derives from the base,
|
|
27
|
+
// which is also why the delta below is measured from the press and never
|
|
28
|
+
// accumulated.
|
|
29
|
+
import { frameToPx, startDrag } from "@dailies-studio/timeline";
|
|
30
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
31
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
32
|
+
import type { LifespanDrag, MasterRow } from "./model.js";
|
|
33
|
+
import { frameDelta } from "./model.js";
|
|
34
|
+
import type { SnapPress } from "./snap.js";
|
|
35
|
+
import { isSnapSuppressed } from "./snap.js";
|
|
36
|
+
|
|
37
|
+
/** ⌥ turns a body drag into a slip. Alt is the modifier both AE and Premiere
|
|
38
|
+
* put on the timeline's third gesture, and it does not collide with the snap
|
|
39
|
+
* suppressor (⌘/ctrl) — so a slip can be snapped or not, like every other
|
|
40
|
+
* drag. */
|
|
41
|
+
function isSlip(event: { altKey: boolean }): boolean {
|
|
42
|
+
return event.altKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function LifespanRow({
|
|
46
|
+
row,
|
|
47
|
+
sceneId,
|
|
48
|
+
sceneStart,
|
|
49
|
+
ppf,
|
|
50
|
+
labelWidth,
|
|
51
|
+
expanded,
|
|
52
|
+
onToggle,
|
|
53
|
+
beginSnap,
|
|
54
|
+
}: {
|
|
55
|
+
row: MasterRow;
|
|
56
|
+
sceneId: string;
|
|
57
|
+
/** The scene's master start. Drawing only: the store takes a scene-local
|
|
58
|
+
* DELTA, which is the same number in both clocks. */
|
|
59
|
+
sceneStart: number;
|
|
60
|
+
ppf: number;
|
|
61
|
+
labelWidth: number;
|
|
62
|
+
expanded: boolean;
|
|
63
|
+
onToggle(): void;
|
|
64
|
+
beginSnap?: SnapPress;
|
|
65
|
+
}) {
|
|
66
|
+
const store = useStore();
|
|
67
|
+
const selected = useShotEditor((s) => s.selection.includes(row.layerId));
|
|
68
|
+
const { lifespan } = row;
|
|
69
|
+
|
|
70
|
+
/** Selecting a row in another scene NAVIGATES first: the stage shows one
|
|
71
|
+
* scene, so handles on a layer three scenes away would be handles on
|
|
72
|
+
* nothing — and `select` orders within the current scene, so the selection
|
|
73
|
+
* would resolve to empty. */
|
|
74
|
+
const pick = (): void => {
|
|
75
|
+
store.focusScene(sceneId);
|
|
76
|
+
store.select([row.layerId]);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const drag =
|
|
80
|
+
(mode: LifespanDrag) =>
|
|
81
|
+
(e: ReactPointerEvent<HTMLElement>): void => {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
pick();
|
|
85
|
+
const startX = e.clientX;
|
|
86
|
+
// Read at the PRESS: the pose the drag is measured from, and the two
|
|
87
|
+
// modifiers. A gesture whose meaning changed under a held key mid-drag
|
|
88
|
+
// would retime an animation the author was only moving.
|
|
89
|
+
const slipping = mode === "body" && isSlip(e);
|
|
90
|
+
const effective: LifespanDrag = slipping ? "slip" : mode;
|
|
91
|
+
const snap = beginSnap?.({ sceneId, layerId: row.layerId }, isSnapSuppressed(e));
|
|
92
|
+
// WHAT THE POINTER IS CARRYING is what snaps: the out edge for an out
|
|
93
|
+
// drag, the earliest block for a slip (the bar is not moving), the in
|
|
94
|
+
// edge for everything else.
|
|
95
|
+
const anchor =
|
|
96
|
+
effective === "out"
|
|
97
|
+
? lifespan.out
|
|
98
|
+
: effective === "slip"
|
|
99
|
+
? row.blocks.reduce((min, b) => Math.min(min, b.frame), Infinity)
|
|
100
|
+
: lifespan.in;
|
|
101
|
+
store.beginBlockDrag();
|
|
102
|
+
startDrag(e, {
|
|
103
|
+
onMove(ev) {
|
|
104
|
+
const raw = frameDelta(ev.clientX - startX, ppf);
|
|
105
|
+
// A signed delta into master space and straight back, so the store
|
|
106
|
+
// still only ever sees a scene-local number.
|
|
107
|
+
const delta =
|
|
108
|
+
snap === undefined || !Number.isFinite(anchor)
|
|
109
|
+
? raw
|
|
110
|
+
: snap(anchor + raw + sceneStart) - sceneStart - anchor;
|
|
111
|
+
store.dragLifespan(sceneId, row.layerId, effective, delta);
|
|
112
|
+
},
|
|
113
|
+
onEnd: (commit) => store.endBlockDrag(commit),
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const left = frameToPx(sceneStart + lifespan.in, ppf);
|
|
118
|
+
const width = Math.max(8, frameToPx(lifespan.out - lifespan.in, ppf));
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={`lifespan-row${selected ? " is-selected" : ""}`}
|
|
123
|
+
data-lifespan={row.layerId}
|
|
124
|
+
data-row-scene={sceneId}
|
|
125
|
+
>
|
|
126
|
+
<div className="lifespan-label-cell" style={{ width: labelWidth, paddingLeft: 4 + row.depth * 12 }}>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="lifespan-disclosure"
|
|
130
|
+
aria-expanded={expanded}
|
|
131
|
+
aria-label={expanded ? `Hide ${row.label}'s blocks` : `Show ${row.label}'s blocks`}
|
|
132
|
+
onClick={onToggle}
|
|
133
|
+
>
|
|
134
|
+
{expanded ? "▾" : "▸"}
|
|
135
|
+
</button>
|
|
136
|
+
<button type="button" className="lifespan-name" onClick={pick}>
|
|
137
|
+
{row.label}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="lifespan-lane">
|
|
141
|
+
<div
|
|
142
|
+
className={`lifespan-bar${selected ? " is-selected" : ""}`}
|
|
143
|
+
style={{ left, width }}
|
|
144
|
+
data-bar={row.layerId}
|
|
145
|
+
onPointerDown={drag("body")}
|
|
146
|
+
title={`${row.label} · ${lifespan.in}–${lifespan.out} · drag to move it and its blocks, ⌥drag to slip the blocks alone, ⌘drag to ignore snapping`}
|
|
147
|
+
>
|
|
148
|
+
<span
|
|
149
|
+
className="lifespan-handle lifespan-handle-in"
|
|
150
|
+
data-handle="lifespan-in"
|
|
151
|
+
onPointerDown={drag("in")}
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
/>
|
|
154
|
+
<span className="lifespan-bar-name">{row.label}</span>
|
|
155
|
+
<span
|
|
156
|
+
className="lifespan-handle lifespan-handle-out"
|
|
157
|
+
data-handle="lifespan-out"
|
|
158
|
+
onPointerDown={drag("out")}
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|