@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.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. 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
+ }