@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,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
+ }