@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,641 @@
|
|
|
1
|
+
// The dock's geometry and its rows, derived from the document.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY `TimelineModel` IS NOT USED HERE ────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// @dailies-studio/timeline's `TimelineModel` is KEYFRAME-SHAPED throughout:
|
|
6
|
+
// `rows[].keyframes`, `toggleKeyframe(rowId, frame)`, `setEase(refs:
|
|
7
|
+
// KeyframeAddr[])`, `focusedCurve`. Every member is about INSTANTS.
|
|
8
|
+
//
|
|
9
|
+
// A block lane has INTERVALS, and its verbs are trim-in, trim-out, split,
|
|
10
|
+
// reorder and retime. Forcing one through the other gives either a
|
|
11
|
+
// discriminated union half the shared components must switch on, or a
|
|
12
|
+
// `TimelineModel` whose `keyframes` array is permanently empty — and the second
|
|
13
|
+
// is worse, because it looks like it works.
|
|
14
|
+
//
|
|
15
|
+
// What IS reused, unchanged, is everything geometric and presentational:
|
|
16
|
+
// `Ruler`, `PlayheadLine`, `frameToPx`, `useAnchoredZoom`, `startDrag` and
|
|
17
|
+
// `CurveGraph`. Those are about frames and pixels, which both lanes agree about.
|
|
18
|
+
// The one geometry helper this lane does NOT reuse is `pxToFrame` — see
|
|
19
|
+
// `frameDelta` below for why a signed delta must not go through it.
|
|
20
|
+
//
|
|
21
|
+
// ponytail: BlockRow and SceneStrip are local. Promote them into
|
|
22
|
+
// @dailies-studio/timeline when a SECOND chrome needs a block lane — the motion
|
|
23
|
+
// editor already has overlapping `Transition`s, so it plausibly will.
|
|
24
|
+
import {
|
|
25
|
+
deltaFor,
|
|
26
|
+
docDurationFrames,
|
|
27
|
+
isSpringBlock,
|
|
28
|
+
layerChildren,
|
|
29
|
+
motionPresets,
|
|
30
|
+
resolveLayerAt,
|
|
31
|
+
sceneWindows,
|
|
32
|
+
} from "@dailies-studio/shot/doc";
|
|
33
|
+
import type { AnimBlock, AudioClipRef, Doc3D, Layer3D, Scene3D } from "@dailies-studio/shot/doc";
|
|
34
|
+
import type { Rational } from "@dailies-studio/engine";
|
|
35
|
+
import { CHANNELS, CHANNEL_BY_KEY } from "../../channels.js";
|
|
36
|
+
import type { Channel } from "../../channels.js";
|
|
37
|
+
|
|
38
|
+
/** One scene's bar on the SCENE STRIP. */
|
|
39
|
+
export interface SceneBar {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
/** MASTER frames, half-open [start, end). */
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One block's bar on a MICRO track. */
|
|
48
|
+
export interface BlockBar {
|
|
49
|
+
id: string;
|
|
50
|
+
layerId: string;
|
|
51
|
+
slot: AnimBlock["slot"];
|
|
52
|
+
preset: string;
|
|
53
|
+
/** SCENE-LOCAL frames, half-open. */
|
|
54
|
+
frame: number;
|
|
55
|
+
durationFrames: number;
|
|
56
|
+
/** The OUT handle means something different for a spring — see timelineOps.
|
|
57
|
+
* The dock disables it rather than letting the author discover that. */
|
|
58
|
+
spring: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One layer's row. */
|
|
62
|
+
export interface TrackRowModel {
|
|
63
|
+
layerId: string;
|
|
64
|
+
label: string;
|
|
65
|
+
depth: number;
|
|
66
|
+
blocks: BlockBar[];
|
|
67
|
+
/** A layer with no blocks still gets a row: it is where the Animate tool
|
|
68
|
+
* will put the first one, and an empty lane the author cannot see is a
|
|
69
|
+
* feature they cannot find. */
|
|
70
|
+
empty: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sceneBars(doc: Doc3D): SceneBar[] {
|
|
74
|
+
return sceneWindows(doc).map((w) => ({
|
|
75
|
+
id: w.id,
|
|
76
|
+
name: doc.scenesById[w.id]?.name ?? w.id,
|
|
77
|
+
start: w.start,
|
|
78
|
+
end: w.end,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function masterDuration(doc: Doc3D): number {
|
|
83
|
+
return docDurationFrames(doc);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One scene's rows, in OUTLINE order and nested.
|
|
88
|
+
*
|
|
89
|
+
* Outline order and not paint order, because the dock sits under a layers panel
|
|
90
|
+
* that is itself the outline — two lists of the same layers in different orders
|
|
91
|
+
* is a UI nobody can follow.
|
|
92
|
+
*/
|
|
93
|
+
export function trackRows(scene: Scene3D, isolation: string | null = null): TrackRowModel[] {
|
|
94
|
+
const out: TrackRowModel[] = [];
|
|
95
|
+
const walk = (ids: readonly string[], depth: number): void => {
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const layer = scene.layersById[id];
|
|
98
|
+
if (layer === undefined) continue;
|
|
99
|
+
out.push({
|
|
100
|
+
layerId: id,
|
|
101
|
+
label: layer.label,
|
|
102
|
+
depth,
|
|
103
|
+
blocks: layer.blocks.map((b) => barOf(b, id)),
|
|
104
|
+
empty: layer.blocks.length === 0,
|
|
105
|
+
});
|
|
106
|
+
walk(layerChildren(layer), depth + 1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// ── THE DOCK IS SCOPED WHEN THE OUTLINE AND THE STAGE ARE ─────────────
|
|
110
|
+
//
|
|
111
|
+
// Going inside a card dims the rest of the scene, makes it unpickable and
|
|
112
|
+
// narrows the outline to the subtree — and the dock kept listing every layer
|
|
113
|
+
// in the scene, so the one panel an author uses to find a block still showed
|
|
114
|
+
// dozens of rows for layers they could not click.
|
|
115
|
+
//
|
|
116
|
+
// The subtree's ROOT is included and its ancestors are not, so the card's own
|
|
117
|
+
// blocks stay reachable: entering a card to retime it and then not finding it
|
|
118
|
+
// is the reason to enter at all.
|
|
119
|
+
const root = isolation === null ? null : scene.layersById[isolation];
|
|
120
|
+
if (root !== undefined && root !== null) walk([root.id], 0);
|
|
121
|
+
else walk(scene.layers, 0);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function barOf(block: AnimBlock, layerId: string): BlockBar {
|
|
126
|
+
return {
|
|
127
|
+
id: block.id,
|
|
128
|
+
layerId,
|
|
129
|
+
slot: block.slot,
|
|
130
|
+
preset: block.preset,
|
|
131
|
+
frame: block.frame,
|
|
132
|
+
durationFrames: Math.max(1, Math.trunc(block.durationFrames)),
|
|
133
|
+
spring: isSpringBlock(block),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── CHANNEL ROWS: THE LOTTIE READ OF A BLOCK LANE ───────────────────────────
|
|
138
|
+
//
|
|
139
|
+
// Lottielab's timeline expands a layer into one row per ANIMATED PROPERTY —
|
|
140
|
+
// Position, Scale, Opacity — with keyframe diamonds on each. This lane
|
|
141
|
+
// animates with blocks, not stored keyframe tracks, and that does not change
|
|
142
|
+
// here: a channel row is a DERIVED VIEW over the blocks. `deltaFor` already
|
|
143
|
+
// says which channels a block writes, so the rows come straight out of the
|
|
144
|
+
// document with nothing new stored in it.
|
|
145
|
+
//
|
|
146
|
+
// The span's endpoint values come from `resolveLayerAt` — the same fold the
|
|
147
|
+
// stage draws and the renderer samples — at the block's two edge frames. A
|
|
148
|
+
// second fold here would be a second answer to a question with exactly one.
|
|
149
|
+
//
|
|
150
|
+
// Dragging anything on a channel row drags the BLOCK. The block is still the
|
|
151
|
+
// atom: "move the opacity keyframes" moves the Fade everywhere it writes,
|
|
152
|
+
// which is what keeps a preset one legible thing instead of six anonymous
|
|
153
|
+
// tracks.
|
|
154
|
+
|
|
155
|
+
/** One block's footprint on one channel row. Frames are SCENE-LOCAL, like
|
|
156
|
+
* every bar in this file. */
|
|
157
|
+
export interface ChannelSpan {
|
|
158
|
+
blockId: string;
|
|
159
|
+
slot: AnimBlock["slot"];
|
|
160
|
+
/** The preset's display label, for the span's tooltip. */
|
|
161
|
+
preset: string;
|
|
162
|
+
frame: number;
|
|
163
|
+
durationFrames: number;
|
|
164
|
+
spring: boolean;
|
|
165
|
+
/** The channel's resolved value at the span's two edges, formatted. */
|
|
166
|
+
from: string;
|
|
167
|
+
to: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ChannelRowModel {
|
|
171
|
+
key: Channel;
|
|
172
|
+
label: string;
|
|
173
|
+
spans: ChannelSpan[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The parts of a bar that fall outside the layer's lifespan, in SCENE frames.
|
|
177
|
+
* Both ends, because a lifespan can be trimmed from either side and a short
|
|
178
|
+
* enough one clips a block at both. Shared by the camera's block bars and the
|
|
179
|
+
* channel spans, so "drawn dead" means one thing. */
|
|
180
|
+
export function deadSpans(
|
|
181
|
+
bar: { frame: number; durationFrames: number },
|
|
182
|
+
lifespan: Lifespan | undefined,
|
|
183
|
+
): { from: number; to: number }[] {
|
|
184
|
+
if (lifespan === undefined) return [];
|
|
185
|
+
const end = bar.frame + bar.durationFrames;
|
|
186
|
+
const out: { from: number; to: number }[] = [];
|
|
187
|
+
if (bar.frame < lifespan.in) out.push({ from: bar.frame, to: Math.min(end, lifespan.in) });
|
|
188
|
+
if (end > lifespan.out) out.push({ from: Math.max(bar.frame, lifespan.out), to: end });
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One row per channel that at least one block on this layer writes, in
|
|
194
|
+
* CHANNELS order (which is resolve.ts's), each carrying the spans of the
|
|
195
|
+
* blocks that write it. A channel nothing writes gets no row — Lottie lists
|
|
196
|
+
* animated properties, not every property.
|
|
197
|
+
*
|
|
198
|
+
* Which channels a block writes is DECLARED, not sampled: `deltaFor`'s keys at
|
|
199
|
+
* any progress. Probed at 0.5 rather than an endpoint only because a fade's
|
|
200
|
+
* delta at p=0 is still a declaration and the keys do not depend on p.
|
|
201
|
+
*/
|
|
202
|
+
export function channelRows(layer: Layer3D, fps: Rational): ChannelRowModel[] {
|
|
203
|
+
const spansByKey = new Map<Channel, ChannelSpan[]>();
|
|
204
|
+
for (const block of layer.blocks) {
|
|
205
|
+
const keys = Object.keys(deltaFor(block.preset, block.slot, block.params, 0.5)) as Channel[];
|
|
206
|
+
if (keys.length === 0) continue;
|
|
207
|
+
const frame = Math.trunc(block.frame);
|
|
208
|
+
const duration = Math.max(1, Math.trunc(block.durationFrames));
|
|
209
|
+
const a = resolveLayerAt(layer, frame, fps);
|
|
210
|
+
const b = resolveLayerAt(layer, frame + duration, fps);
|
|
211
|
+
for (const key of keys) {
|
|
212
|
+
const spec = CHANNEL_BY_KEY.get(key);
|
|
213
|
+
if (spec === undefined) continue;
|
|
214
|
+
const span: ChannelSpan = {
|
|
215
|
+
blockId: block.id,
|
|
216
|
+
slot: block.slot,
|
|
217
|
+
preset: motionPresets[block.preset]?.label ?? block.preset,
|
|
218
|
+
frame,
|
|
219
|
+
durationFrames: duration,
|
|
220
|
+
spring: isSpringBlock(block),
|
|
221
|
+
from: spec.read(a),
|
|
222
|
+
to: spec.read(b),
|
|
223
|
+
};
|
|
224
|
+
const at = spansByKey.get(key);
|
|
225
|
+
if (at === undefined) spansByKey.set(key, [span]);
|
|
226
|
+
else at.push(span);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return CHANNELS.flatMap((c) => {
|
|
230
|
+
const spans = spansByKey.get(c.key);
|
|
231
|
+
return spans === undefined ? [] : [{ key: c.key, label: c.label, spans }];
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The camera's own track: one bar per shot. It is a row like any other so the
|
|
236
|
+
* camera can be trimmed and retimed with the same gestures, rather than
|
|
237
|
+
* through a panel that would be a second timeline. */
|
|
238
|
+
export function cameraBars(scene: Scene3D): BlockBar[] {
|
|
239
|
+
return scene.shots.map((shot) => ({
|
|
240
|
+
id: shot.id,
|
|
241
|
+
layerId: CAMERA_ROW_ID,
|
|
242
|
+
slot: "during" as const,
|
|
243
|
+
preset: shot.kind,
|
|
244
|
+
frame: shot.frame,
|
|
245
|
+
durationFrames: Math.max(1, Math.trunc(shot.durationFrames)),
|
|
246
|
+
spring: shot.ease?.type === "spring",
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A DELTA in px as a delta in frames.
|
|
252
|
+
*
|
|
253
|
+
* NOT `pxToFrame`, and the difference is a real bug rather than a style
|
|
254
|
+
* preference: that helper clamps at zero, because its job is turning an
|
|
255
|
+
* absolute x POSITION into a frame and there is no frame before the first one.
|
|
256
|
+
* A drag delta is signed, so running it through the clamp silently drops every
|
|
257
|
+
* leftward drag — a left-trim that never moves, which looks like a dead handle.
|
|
258
|
+
*/
|
|
259
|
+
export function frameDelta(px: number, ppf: number): number {
|
|
260
|
+
return Math.round(px / Math.max(1e-6, ppf));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The camera row's stand-in layer id. Not a real layer, and deliberately not a
|
|
264
|
+
* valid `l3_` id, so a code path that treated it as one fails loudly. */
|
|
265
|
+
export const CAMERA_ROW_ID = "camera";
|
|
266
|
+
|
|
267
|
+
// ── THE MASTER STRIP ────────────────────────────────────────────────────────
|
|
268
|
+
//
|
|
269
|
+
// The dock used to be two levels: a scene strip on top and the tracks of
|
|
270
|
+
// WHICHEVER SCENE THE PLAYHEAD WAS IN underneath. One bit, `state.sceneId`,
|
|
271
|
+
// decided what the lower half showed.
|
|
272
|
+
//
|
|
273
|
+
// That bit is gone from the geometry. Every scene's rows are on screen at once,
|
|
274
|
+
// at their MASTER positions, grouped in master order — one continuous strip, the
|
|
275
|
+
// way the reference editor and every NLE draw a sequence. `state.sceneId` is
|
|
276
|
+
// still the inspector's subject and the URL's, but nothing about the timeline's
|
|
277
|
+
// layout reads it any more, which is what makes "drag a layer in scene 3 while
|
|
278
|
+
// the playhead sits in scene 1" a gesture rather than a mode change.
|
|
279
|
+
//
|
|
280
|
+
// BARS STAY SCENE-LOCAL and the GROUP carries the offset. The alternative —
|
|
281
|
+
// baking `start` into every bar's `frame` — turns each bar into a master frame
|
|
282
|
+
// that every gesture has to convert back before it can call a document op, and
|
|
283
|
+
// one missed conversion moves a block a whole scene. A drag DELTA is
|
|
284
|
+
// offset-invariant, so keeping the offset out of the bar makes the conversion a
|
|
285
|
+
// rendering detail and never a document one.
|
|
286
|
+
|
|
287
|
+
/** A layer's half-open SCENE-LOCAL lifespan, with the default filled in. */
|
|
288
|
+
export interface Lifespan {
|
|
289
|
+
in: number;
|
|
290
|
+
out: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The layer's bar, with an ABSENT `lifetime` resolved to the whole scene.
|
|
295
|
+
*
|
|
296
|
+
* The document's field is optional and absent means "always" (`layerAliveAt`).
|
|
297
|
+
* A bar needs two ends, so the default is materialised HERE, in one place,
|
|
298
|
+
* rather than at each of the gestures and readers that would otherwise pick
|
|
299
|
+
* their own. Nothing is written by reading it: the first edge drag is what puts
|
|
300
|
+
* a `lifetime` in the document.
|
|
301
|
+
*
|
|
302
|
+
* A stored lifespan is returned AS IT IS, even when it runs past the end of the
|
|
303
|
+
* scene. Clamping it to the scene here would silently rewrite a document the
|
|
304
|
+
* first time the dock drew it, and the part past the end is exactly the thing
|
|
305
|
+
* the author needs to see in order to pull it back.
|
|
306
|
+
*/
|
|
307
|
+
export function lifespanOf(layer: Layer3D, scene: Scene3D): Lifespan {
|
|
308
|
+
const life = layer.lifetime;
|
|
309
|
+
if (life === undefined) return { in: 0, out: Math.max(1, Math.trunc(scene.durationFrames)) };
|
|
310
|
+
return { in: Math.trunc(life.in), out: Math.trunc(life.out) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface MasterRow extends TrackRowModel {
|
|
314
|
+
lifespan: Lifespan;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One scene's whole block of rows, placed on the master timeline. */
|
|
318
|
+
export interface MasterSceneGroup {
|
|
319
|
+
id: string;
|
|
320
|
+
name: string;
|
|
321
|
+
/** MASTER frames, half-open — the prefix sum, never a stored offset. */
|
|
322
|
+
start: number;
|
|
323
|
+
end: number;
|
|
324
|
+
/** The camera's shots. SCENE-LOCAL, like every other bar here. */
|
|
325
|
+
camera: BlockBar[];
|
|
326
|
+
rows: MasterRow[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Every scene's rows in MASTER order, each group carrying the offset its bars
|
|
330
|
+
* are drawn at. */
|
|
331
|
+
export function masterRows(doc: Doc3D, isolation: string | null = null): MasterSceneGroup[] {
|
|
332
|
+
return sceneWindows(doc).flatMap((w) => {
|
|
333
|
+
const scene = doc.scenesById[w.id];
|
|
334
|
+
if (scene === undefined) return [];
|
|
335
|
+
return [
|
|
336
|
+
{
|
|
337
|
+
id: w.id,
|
|
338
|
+
name: scene.name,
|
|
339
|
+
start: w.start,
|
|
340
|
+
end: w.end,
|
|
341
|
+
camera: cameraBars(scene),
|
|
342
|
+
// The isolation only narrows the scene it LIVES IN. A master timeline
|
|
343
|
+
// shows every scene, and the subtree exists in exactly one of them —
|
|
344
|
+
// scoping the others by an id they do not contain would empty them.
|
|
345
|
+
rows: trackRows(scene, scene.layersById[isolation ?? ""] === undefined ? null : isolation).map((row) => ({
|
|
346
|
+
...row,
|
|
347
|
+
lifespan: lifespanOf(scene.layersById[row.layerId]!, scene),
|
|
348
|
+
})),
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* How wide the time area has to be, in MASTER frames.
|
|
356
|
+
*
|
|
357
|
+
* Not `docDurationFrames`: a lifespan or a block may sit past the end of its
|
|
358
|
+
* scene (nothing forbids it, and the runtime simply never reaches it), and a
|
|
359
|
+
* bar the author cannot scroll to is a bar they cannot drag back.
|
|
360
|
+
*/
|
|
361
|
+
export function masterContentFrames(doc: Doc3D, groups: readonly MasterSceneGroup[]): number {
|
|
362
|
+
let end = docDurationFrames(doc);
|
|
363
|
+
for (const group of groups) {
|
|
364
|
+
for (const row of group.rows) {
|
|
365
|
+
end = Math.max(end, group.start + row.lifespan.out);
|
|
366
|
+
for (const bar of row.blocks) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
|
|
367
|
+
}
|
|
368
|
+
for (const bar of group.camera) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
|
|
369
|
+
}
|
|
370
|
+
return end;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The dock's one time string.
|
|
375
|
+
*
|
|
376
|
+
* A WHOLE second reads as a clock — "0:00", "1:07" — and anything between two
|
|
377
|
+
* seconds reads as seconds and hundredths — "12.84". The hundredths appear only
|
|
378
|
+
* when they carry information: at 30fps the clock form rounds three neighbouring
|
|
379
|
+
* frames to the same string, and a readout that sits still while the playhead
|
|
380
|
+
* moves is worse than no readout. Past a minute the clock prefix stays on
|
|
381
|
+
* ("1:07.50") so the two forms never disagree about which minute it is.
|
|
382
|
+
*
|
|
383
|
+
* Whole-second detection is INTEGER arithmetic on the rational fps, not
|
|
384
|
+
* `Number.isInteger` on a division: 1001/30000 documents have exact second
|
|
385
|
+
* boundaries that a float compare misses.
|
|
386
|
+
*/
|
|
387
|
+
export function timecode(frame: number, fps: Rational): string {
|
|
388
|
+
const seconds = (frame * fps.den) / fps.num;
|
|
389
|
+
const minutes = Math.floor(seconds / 60);
|
|
390
|
+
const rest = seconds - minutes * 60;
|
|
391
|
+
if ((frame * fps.den) % fps.num === 0) {
|
|
392
|
+
return `${minutes}:${String(Math.round(rest)).padStart(2, "0")}`;
|
|
393
|
+
}
|
|
394
|
+
const fixed = rest.toFixed(2);
|
|
395
|
+
return minutes === 0 ? fixed : `${minutes}:${fixed.padStart(5, "0")}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── THE LIFESPAN'S THREE GESTURES ───────────────────────────────────────────
|
|
399
|
+
//
|
|
400
|
+
// BODY moves the bar AND every block on the layer by the same delta.
|
|
401
|
+
// SLIP moves every block and leaves the bar where it is.
|
|
402
|
+
// IN / OUT move one end and touch no block at all.
|
|
403
|
+
//
|
|
404
|
+
// Body-versus-edge is After Effects verbatim, and it is not configurable in any
|
|
405
|
+
// tool that ships it: dragging a layer's duration bar carries its keyframes,
|
|
406
|
+
// moving one end does not. SLIP is the third gesture — AE's hollow slip bar,
|
|
407
|
+
// Premiere's slip tool, Cavalry's offset command — and a timeline that ships
|
|
408
|
+
// only the first two gets filed against for it.
|
|
409
|
+
//
|
|
410
|
+
// EVERY MODE CLAMPS ON THE LEFT ONLY. `block.frame` is validated non-negative
|
|
411
|
+
// and a lifespan starting before its scene is meaningless, so zero is a real
|
|
412
|
+
// floor; the right has no floor, because a bar hanging past the end of its scene
|
|
413
|
+
// is a legible state the author can drag back and a clamp there would freeze the
|
|
414
|
+
// default lifespan (which is exactly the whole scene) in place.
|
|
415
|
+
|
|
416
|
+
export type LifespanDrag = "body" | "in" | "out" | "slip";
|
|
417
|
+
|
|
418
|
+
/** The shortest lifespan an edge drag may leave behind. `validate.ts` demands
|
|
419
|
+
* `out > in`; this is that rule with the integer grid applied. */
|
|
420
|
+
export const MIN_LIFESPAN_FRAMES = 1;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Apply one lifespan gesture. PURE, and the delta is measured from the
|
|
424
|
+
* document handed in — which is the scrub's BASE, so a coalesced pointermove or
|
|
425
|
+
* a snap that bites and un-bites cannot make a drag drift.
|
|
426
|
+
*/
|
|
427
|
+
export function writeLifespan(
|
|
428
|
+
doc: Doc3D,
|
|
429
|
+
sceneId: string,
|
|
430
|
+
layerId: string,
|
|
431
|
+
mode: LifespanDrag,
|
|
432
|
+
delta: number,
|
|
433
|
+
): Doc3D {
|
|
434
|
+
const scene = doc.scenesById[sceneId];
|
|
435
|
+
const layer = scene?.layersById[layerId];
|
|
436
|
+
if (scene === undefined || layer === undefined) return doc;
|
|
437
|
+
const life = lifespanOf(layer, scene);
|
|
438
|
+
const d = Math.round(delta);
|
|
439
|
+
let next: Layer3D;
|
|
440
|
+
|
|
441
|
+
switch (mode) {
|
|
442
|
+
case "body": {
|
|
443
|
+
// ONE clamped delta for the bar and the blocks together. Clamping them
|
|
444
|
+
// apart is the bug this shape exists to prevent: the bar would stop at
|
|
445
|
+
// zero while its blocks kept sliding, and the author would have retimed
|
|
446
|
+
// the animation by dragging the layer.
|
|
447
|
+
const moved = Math.max(d, -Math.min(life.in, earliestBlock(layer)));
|
|
448
|
+
if (moved === 0) return doc;
|
|
449
|
+
next = {
|
|
450
|
+
...layer,
|
|
451
|
+
lifetime: { in: life.in + moved, out: life.out + moved },
|
|
452
|
+
blocks: shiftBlocks(layer.blocks, moved),
|
|
453
|
+
};
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "slip": {
|
|
457
|
+
const earliest = earliestBlock(layer);
|
|
458
|
+
if (!Number.isFinite(earliest)) return doc;
|
|
459
|
+
const moved = Math.max(d, -earliest);
|
|
460
|
+
if (moved === 0) return doc;
|
|
461
|
+
next = { ...layer, blocks: shiftBlocks(layer.blocks, moved) };
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
// An edge that lands where it already was returns the document BY
|
|
465
|
+
// REFERENCE, absent `lifetime` included. So the field is materialised by
|
|
466
|
+
// the first drag that actually moves an edge and not by the first one that
|
|
467
|
+
// twitches — a no-op edit still dirties the document and still autosaves.
|
|
468
|
+
case "in": {
|
|
469
|
+
const at = Math.max(0, Math.min(life.out - MIN_LIFESPAN_FRAMES, life.in + d));
|
|
470
|
+
if (at === life.in) return doc;
|
|
471
|
+
next = { ...layer, lifetime: { in: at, out: life.out } };
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case "out": {
|
|
475
|
+
const at = Math.max(life.in + MIN_LIFESPAN_FRAMES, life.out + d);
|
|
476
|
+
if (at === life.out) return doc;
|
|
477
|
+
next = { ...layer, lifetime: { in: life.in, out: at } };
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
...doc,
|
|
484
|
+
scenesById: {
|
|
485
|
+
...doc.scenesById,
|
|
486
|
+
[sceneId]: { ...scene, layersById: { ...scene.layersById, [layerId]: next } },
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** `Infinity` for a layer with no blocks, so the caller's `Math.min` against it
|
|
492
|
+
* is a no-op rather than a special case. */
|
|
493
|
+
function earliestBlock(layer: Layer3D): number {
|
|
494
|
+
return layer.blocks.reduce((min, b) => Math.min(min, b.frame), Infinity);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── THE AUDIO CLIP'S GESTURES ───────────────────────────────────────────────
|
|
498
|
+
//
|
|
499
|
+
// The same shape as the lifespan's — a PURE writer measured from the scrub's
|
|
500
|
+
// BASE, so a coalesced pointermove or a snap that bites and un-bites cannot
|
|
501
|
+
// make a drag drift — over `AudioClipRef`, whose frames are MASTER frames.
|
|
502
|
+
//
|
|
503
|
+
// ── WHY A BODY DRAG CARRIES THE MARKERS ─────────────────────────────────
|
|
504
|
+
//
|
|
505
|
+
// A beat marker belongs to the music, not to the timecode. This is the single
|
|
506
|
+
// most-filed complaint against Premiere's timeline markers (they are sequence
|
|
507
|
+
// markers, so sliding the track leaves every marked beat pointing at silence)
|
|
508
|
+
// and it is the thing Final Cut made a fiat by giving clips their own markers.
|
|
509
|
+
//
|
|
510
|
+
// We keep `MarkerRef.frame` a MASTER frame and derive the ownership instead: a
|
|
511
|
+
// body drag moves every marker that CURRENTLY SITS INSIDE the clip's window, by
|
|
512
|
+
// the same clamped delta, in the same capture — so it is one undo entry and
|
|
513
|
+
// Escape restores clip and marks together.
|
|
514
|
+
//
|
|
515
|
+
// Derived rather than stored, and that is the load-bearing choice. A
|
|
516
|
+
// `marker.clipId` would need an answer for "the clip was deleted", "the clip
|
|
517
|
+
// was trimmed past the marker", "two clips overlap this frame" and "the marker
|
|
518
|
+
// was dragged out of its clip", and every one of those answers is a rule the
|
|
519
|
+
// author cannot see on screen. Containment at the moment of the gesture is a
|
|
520
|
+
// rule they can see: the marks under the bar move with the bar.
|
|
521
|
+
//
|
|
522
|
+
// TRIM AND SLIP DO NOT MOVE MARKERS. A trim changes what you hear; it does not
|
|
523
|
+
// change when the beat you marked happens. Slip is the same statement about the
|
|
524
|
+
// source. Only moving the clip moves the sound in time, so only moving the clip
|
|
525
|
+
// moves the marks.
|
|
526
|
+
|
|
527
|
+
export type AudioDrag = "body" | "in" | "out" | "slip" | "fade-in" | "fade-out";
|
|
528
|
+
|
|
529
|
+
/** Fields with their documented defaults filled in, for a lane that has to
|
|
530
|
+
* compute with them. Reading one never writes one. */
|
|
531
|
+
export interface AudioClipView {
|
|
532
|
+
frame: number;
|
|
533
|
+
durationFrames: number;
|
|
534
|
+
offsetFrames: number;
|
|
535
|
+
gain: number;
|
|
536
|
+
fadeInFrames: number;
|
|
537
|
+
fadeOutFrames: number;
|
|
538
|
+
muted: boolean;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function audioClipView(clip: AudioClipRef): AudioClipView {
|
|
542
|
+
return {
|
|
543
|
+
frame: Math.trunc(clip.frame),
|
|
544
|
+
durationFrames: Math.max(1, Math.trunc(clip.durationFrames)),
|
|
545
|
+
offsetFrames: Math.max(0, Math.trunc(clip.offsetFrames ?? 0)),
|
|
546
|
+
gain: clip.gain ?? 1,
|
|
547
|
+
fadeInFrames: Math.max(0, Math.trunc(clip.fadeInFrames ?? 0)),
|
|
548
|
+
fadeOutFrames: Math.max(0, Math.trunc(clip.fadeOutFrames ?? 0)),
|
|
549
|
+
muted: clip.muted ?? false,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Apply one audio gesture. Every clamp here restates a `validate.ts` bound, so
|
|
555
|
+
* a live drag can never build a document the save would refuse: `frame` and
|
|
556
|
+
* `offsetFrames` stay non-negative integers, `durationFrames` stays positive,
|
|
557
|
+
* and `fadeIn + fadeOut` never exceeds the duration.
|
|
558
|
+
*/
|
|
559
|
+
export function writeAudioDrag(doc: Doc3D, clipId: string, mode: AudioDrag, delta: number): Doc3D {
|
|
560
|
+
const clip = doc.audio[clipId];
|
|
561
|
+
if (clip === undefined) return doc;
|
|
562
|
+
const c = audioClipView(clip);
|
|
563
|
+
const d = Math.round(delta);
|
|
564
|
+
/** The shortest a trim may leave the clip: one frame, and never shorter than
|
|
565
|
+
* the fades already on it. */
|
|
566
|
+
const floor = Math.max(1, c.fadeInFrames + c.fadeOutFrames);
|
|
567
|
+
let patch: Partial<AudioClipRef>;
|
|
568
|
+
let markerDelta = 0;
|
|
569
|
+
|
|
570
|
+
switch (mode) {
|
|
571
|
+
case "body": {
|
|
572
|
+
const moved = Math.max(d, -c.frame);
|
|
573
|
+
if (moved === 0) return doc;
|
|
574
|
+
patch = { frame: c.frame + moved };
|
|
575
|
+
markerDelta = moved;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
// A head trim moves the clip and the source trim TOGETHER: the audio under
|
|
579
|
+
// the remaining bar must not slide, which is what a trim means in every NLE
|
|
580
|
+
// and is why this is not two independent edits.
|
|
581
|
+
case "in": {
|
|
582
|
+
const moved = Math.max(-c.frame, Math.max(-c.offsetFrames, Math.min(c.durationFrames - floor, d)));
|
|
583
|
+
if (moved === 0) return doc;
|
|
584
|
+
patch = {
|
|
585
|
+
frame: c.frame + moved,
|
|
586
|
+
offsetFrames: c.offsetFrames + moved,
|
|
587
|
+
durationFrames: c.durationFrames - moved,
|
|
588
|
+
};
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case "out": {
|
|
592
|
+
const next = Math.max(floor, c.durationFrames + d);
|
|
593
|
+
if (next === c.durationFrames) return doc;
|
|
594
|
+
patch = { durationFrames: next };
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
// SLIP: the source moves under a bar that stays exactly where it is. There
|
|
598
|
+
// is no right-hand clamp, because nothing here knows the source length —
|
|
599
|
+
// the mixer emits silence past the end, which is a legible state the author
|
|
600
|
+
// can drag back out of.
|
|
601
|
+
case "slip": {
|
|
602
|
+
const next = Math.max(0, c.offsetFrames + d);
|
|
603
|
+
if (next === c.offsetFrames) return doc;
|
|
604
|
+
patch = { offsetFrames: next };
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
case "fade-in": {
|
|
608
|
+
const next = Math.max(0, Math.min(c.durationFrames - c.fadeOutFrames, c.fadeInFrames + d));
|
|
609
|
+
if (next === c.fadeInFrames) return doc;
|
|
610
|
+
patch = { fadeInFrames: next };
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
// The handle sits at `frame + duration - fadeOut`, so dragging it LEFT
|
|
614
|
+
// (negative delta) lengthens the fade. Hence the minus.
|
|
615
|
+
case "fade-out": {
|
|
616
|
+
const next = Math.max(0, Math.min(c.durationFrames - c.fadeInFrames, c.fadeOutFrames - d));
|
|
617
|
+
if (next === c.fadeOutFrames) return doc;
|
|
618
|
+
patch = { fadeOutFrames: next };
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const next: Doc3D = { ...doc, audio: { ...doc.audio, [clipId]: { ...clip, ...patch } } };
|
|
624
|
+
if (markerDelta === 0) return next;
|
|
625
|
+
const end = c.frame + c.durationFrames;
|
|
626
|
+
const markers = (doc.markers ?? []).map((m) =>
|
|
627
|
+
m.frame >= c.frame && m.frame < end ? { ...m, frame: Math.max(0, m.frame + markerDelta) } : m,
|
|
628
|
+
);
|
|
629
|
+
// Re-sorted because a moved subset can cross a marker that did not move, and
|
|
630
|
+
// `validate.ts` enforces the sort the strip draws in.
|
|
631
|
+
return { ...next, markers: [...markers].sort((a, b) => a.frame - b.frame) };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function shiftBlocks(blocks: readonly AnimBlock[], delta: number): AnimBlock[] {
|
|
635
|
+
// No per-block clamp, deliberately: the caller has already clamped the DELTA
|
|
636
|
+
// against the earliest block, so none of these can go negative. A floor here
|
|
637
|
+
// would break the one thing a body drag and a slip promise — that every block
|
|
638
|
+
// moved by the same amount — in exactly the case it was meant to protect.
|
|
639
|
+
// No re-sort either: a uniform shift preserves whatever order they were in.
|
|
640
|
+
return blocks.map((b) => ({ ...b, frame: b.frame + delta }));
|
|
641
|
+
}
|