@rightkit/video-ui 0.0.0-stage → 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 (148) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +47 -2
  5. package/dist/appName.d.ts +2 -0
  6. package/dist/appName.js +8 -0
  7. package/dist/components/DesktopWorkspace.d.ts +4 -0
  8. package/dist/components/DesktopWorkspace.js +527 -0
  9. package/dist/components/Player.d.ts +49 -0
  10. package/dist/components/Player.js +335 -0
  11. package/dist/components/RangePreview.d.ts +9 -0
  12. package/dist/components/RangePreview.js +22 -0
  13. package/dist/components/Workspace.css +15 -0
  14. package/dist/demo/DemoSettings.d.ts +1 -0
  15. package/dist/demo/DemoSettings.js +43 -0
  16. package/dist/demo/bindings.d.ts +121 -0
  17. package/dist/demo/bindings.js +57 -0
  18. package/dist/demo/demo.css +124 -0
  19. package/dist/demo/useDemo.d.ts +8 -0
  20. package/dist/demo/useDemo.js +37 -0
  21. package/dist/editor/CaptionInspector.d.ts +8 -0
  22. package/dist/editor/CaptionInspector.js +33 -0
  23. package/dist/editor/ChangesPanel.d.ts +13 -0
  24. package/dist/editor/ChangesPanel.js +87 -0
  25. package/dist/editor/ColourInspector.d.ts +5 -0
  26. package/dist/editor/ColourInspector.js +341 -0
  27. package/dist/editor/Inspector.d.ts +15 -0
  28. package/dist/editor/Inspector.js +202 -0
  29. package/dist/editor/Timeline.d.ts +39 -0
  30. package/dist/editor/Timeline.js +506 -0
  31. package/dist/editor/TimelineEditorView.d.ts +9 -0
  32. package/dist/editor/TimelineEditorView.js +237 -0
  33. package/dist/editor/TranscriptPanel.d.ts +20 -0
  34. package/dist/editor/TranscriptPanel.js +180 -0
  35. package/dist/editor/actions.d.ts +63 -0
  36. package/dist/editor/actions.js +124 -0
  37. package/dist/editor/adapter.d.ts +76 -0
  38. package/dist/editor/adapter.js +181 -0
  39. package/dist/editor/capabilities.d.ts +37 -0
  40. package/dist/editor/capabilities.js +33 -0
  41. package/dist/editor/gesture.d.ts +29 -0
  42. package/dist/editor/gesture.js +119 -0
  43. package/dist/editor/index.d.ts +10 -0
  44. package/dist/editor/index.js +7 -0
  45. package/dist/editor/keys.d.ts +15 -0
  46. package/dist/editor/keys.js +42 -0
  47. package/dist/editor/model.d.ts +88 -0
  48. package/dist/editor/model.js +87 -0
  49. package/dist/editor/transcript.d.ts +27 -0
  50. package/dist/editor/transcript.js +59 -0
  51. package/dist/editor/vendor/hyperframes/PlayheadIndicator.d.ts +9 -0
  52. package/dist/editor/vendor/hyperframes/PlayheadIndicator.js +38 -0
  53. package/dist/editor/vendor/hyperframes/audioWaveformPeaks.d.ts +7 -0
  54. package/dist/editor/vendor/hyperframes/audioWaveformPeaks.js +47 -0
  55. package/dist/editor/vendor/hyperframes/rulerGeometry.d.ts +13 -0
  56. package/dist/editor/vendor/hyperframes/rulerGeometry.js +133 -0
  57. package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.d.ts +33 -0
  58. package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.js +126 -0
  59. package/dist/env.d.ts +66 -0
  60. package/dist/hooks/useEngine.d.ts +10 -0
  61. package/dist/hooks/useEngine.js +68 -0
  62. package/dist/hooks/useHdrToSdr.d.ts +11 -0
  63. package/dist/hooks/useHdrToSdr.js +19 -0
  64. package/dist/hooks/useProgress.d.ts +10 -0
  65. package/dist/hooks/useProgress.js +32 -0
  66. package/dist/index.d.ts +55 -0
  67. package/dist/index.js +57 -0
  68. package/dist/legal/legalConfig.d.ts +38 -0
  69. package/dist/legal/legalConfig.js +77 -0
  70. package/dist/lib/assemble.d.ts +100 -0
  71. package/dist/lib/assemble.js +72 -0
  72. package/dist/lib/asset.d.ts +2 -0
  73. package/dist/lib/asset.js +3 -0
  74. package/dist/lib/dialog.d.ts +4 -0
  75. package/dist/lib/dialog.js +44 -0
  76. package/dist/lib/engine.d.ts +44 -0
  77. package/dist/lib/engine.js +51 -0
  78. package/dist/lib/genright.d.ts +90 -0
  79. package/dist/lib/genright.js +160 -0
  80. package/dist/lib/media.d.ts +1045 -0
  81. package/dist/lib/media.js +171 -0
  82. package/dist/lib/mediaAdapters.d.ts +18 -0
  83. package/dist/lib/mediaAdapters.js +46 -0
  84. package/dist/lib/mic.d.ts +38 -0
  85. package/dist/lib/mic.js +14 -0
  86. package/dist/lib/models.d.ts +53 -0
  87. package/dist/lib/models.js +72 -0
  88. package/dist/lib/modelsBackend.d.ts +3 -0
  89. package/dist/lib/modelsBackend.js +68 -0
  90. package/dist/lib/progress.d.ts +77 -0
  91. package/dist/lib/progress.js +192 -0
  92. package/dist/lib/project.d.ts +138 -0
  93. package/dist/lib/project.js +48 -0
  94. package/dist/lib/services.d.ts +24 -0
  95. package/dist/lib/services.js +38 -0
  96. package/dist/lib/settingsBackend.d.ts +2 -0
  97. package/dist/lib/settingsBackend.js +57 -0
  98. package/dist/lib/shell.d.ts +171 -0
  99. package/dist/lib/shell.js +32 -0
  100. package/dist/styles.css +16 -0
  101. package/dist/ui/icons.d.ts +7 -0
  102. package/dist/ui/icons.js +62 -0
  103. package/dist/ui/index.d.ts +45 -0
  104. package/dist/ui/index.js +43 -0
  105. package/dist/ui/ui.css +104 -0
  106. package/dist/views/Assets.css +99 -0
  107. package/dist/views/AssetsView.d.ts +6 -0
  108. package/dist/views/AssetsView.js +371 -0
  109. package/dist/views/AutoEditView.d.ts +16 -0
  110. package/dist/views/AutoEditView.js +452 -0
  111. package/dist/views/BatchDelivery.d.ts +3 -0
  112. package/dist/views/BatchDelivery.js +331 -0
  113. package/dist/views/BundleExport.d.ts +3 -0
  114. package/dist/views/BundleExport.js +81 -0
  115. package/dist/views/Exports.css +117 -0
  116. package/dist/views/ExportsView.d.ts +12 -0
  117. package/dist/views/ExportsView.js +354 -0
  118. package/dist/views/FinishedEdit.css +546 -0
  119. package/dist/views/Home.css +97 -0
  120. package/dist/views/Home.d.ts +14 -0
  121. package/dist/views/Home.js +192 -0
  122. package/dist/views/Media.css +65 -0
  123. package/dist/views/OnboardingGate.d.ts +10 -0
  124. package/dist/views/OnboardingGate.js +54 -0
  125. package/dist/views/Phrases.css +45 -0
  126. package/dist/views/PhrasesView.d.ts +4 -0
  127. package/dist/views/PhrasesView.js +104 -0
  128. package/dist/views/ProgressView.d.ts +19 -0
  129. package/dist/views/ProgressView.js +36 -0
  130. package/dist/views/Prompt.css +84 -0
  131. package/dist/views/PromptPanel.d.ts +8 -0
  132. package/dist/views/PromptPanel.js +169 -0
  133. package/dist/views/Recipe.css +84 -0
  134. package/dist/views/RecipeLibrary.d.ts +7 -0
  135. package/dist/views/RecipeLibrary.js +104 -0
  136. package/dist/views/Retention.css +49 -0
  137. package/dist/views/RetentionView.d.ts +7 -0
  138. package/dist/views/RetentionView.js +145 -0
  139. package/dist/views/SettingsView.d.ts +9 -0
  140. package/dist/views/SettingsView.js +235 -0
  141. package/dist/views/Shorts.css +112 -0
  142. package/dist/views/ShortsView.d.ts +4 -0
  143. package/dist/views/ShortsView.js +205 -0
  144. package/dist/views/SourcesView.d.ts +6 -0
  145. package/dist/views/SourcesView.js +417 -0
  146. package/dist/views/TimelineView.d.ts +20 -0
  147. package/dist/views/TimelineView.js +12 -0
  148. package/package.json +70 -3
@@ -0,0 +1,506 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Finished-edit timeline (docs/design/app-ui.md §3.4): ruler, playhead,
4
+ * selectable clips with trim/move/slip gestures in Edit mode, navigation-only
5
+ * in Review mode. All pointer geometry is transient — the canonical timeline
6
+ * updates only when `session.apply` commits a planned action and the host
7
+ * refreshes `project.show`.
8
+ *
9
+ * Lanes follow the spec order: Footage, Titles, Captions (timeline-mapped
10
+ * cues), Voice (waveform), Music, B-roll when a second video track exists.
11
+ * Clip thumbnails come from `media.thumbnail`, waveforms from
12
+ * `media.waveform` — both behind real engine data, never placeholders.
13
+ *
14
+ * Visual structure adapted from hyperframes' separable TimelineRuler pattern,
15
+ * retargeted at the design tokens. The clip menu is the shell's ContextMenu.
16
+ */
17
+ import { Button, ContextMenu, IconButton, Slider, cx } from "@rightkit/react-shell/react";
18
+ import { Icon } from "../ui/index.js";
19
+ import { errorText } from "./adapter.js";
20
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
21
+ import { formatSeconds, newClipId } from "../lib/project.js";
22
+ import { assetUrl } from "../lib/asset.js";
23
+ import { PLAYBACK_REFRESHED, thumbnails } from "../lib/media.js";
24
+ import { engineCall } from "../lib/engine.js";
25
+ import { slipAudioClip, slipClip, splitAudioClip, splitClip } from "./actions.js";
26
+ import { formatTimelineTickLabel, generateTicks, getTimelineGridStep, } from "./vendor/hyperframes/rulerGeometry.js";
27
+ import { clipEdges, planMove, planMoveMany, planTrim, snapTime, videoDropIndex } from "./gesture.js";
28
+ import { supports, unsupported } from "./capabilities.js";
29
+ import { useEditorKeys } from "./keys.js";
30
+ const LABEL_W = 72;
31
+ const RULER_H = 24;
32
+ const MIN_PPS = 4;
33
+ const MAX_PPS = 640;
34
+ export function Timeline({ model, session, caps, busy, mode, cues, dir, playheadS, onPlayhead, onCommitted, onSeek, onSelect, onMenuAction, }) {
35
+ const viewportRef = useRef(null);
36
+ const [pps, setPps] = useState(48);
37
+ const [snapping, setSnapping] = useState(true);
38
+ const [selected, setSelected] = useState(new Set());
39
+ const [drag, setDrag] = useState(null);
40
+ const [menu, setMenu] = useState(null);
41
+ const [gestureError, setGestureError] = useState(null);
42
+ const shuttle = useRef({ dir: 0, mag: 1 });
43
+ const editing = mode === "edit";
44
+ const duration = Math.max(model.durationS, 1);
45
+ const contentW = Math.max(duration * pps, 240);
46
+ // Ruler gains :ff frame labels once a frame is wider than ~3px
47
+ // (4 frames / 12px past that zoom, per §1 Typography).
48
+ const showFrames = pps >= model.fps * 3;
49
+ const { major, minor } = useMemo(() => generateTicks(duration, pps, showFrames ? model.fps : undefined), [duration, pps, model.fps, showFrames]);
50
+ const majorInterval = major.length > 1 ? major[1] - major[0] : 1;
51
+ const gridStep = useMemo(() => getTimelineGridStep(duration, pps, model.fps), [duration, pps, model.fps]);
52
+ const lanes = useMemo(() => {
53
+ const out = [];
54
+ let videoSeen = 0;
55
+ for (const l of model.lanes.filter((l) => l.kind === "video")) {
56
+ videoSeen += 1;
57
+ out.push({ id: l.id, kind: "video", label: videoSeen === 1 ? "Footage" : `B-roll`, clips: l.clips });
58
+ }
59
+ for (const l of model.lanes.filter((l) => l.kind === "overlay")) {
60
+ out.push({ id: l.id, kind: "overlay", label: "Titles", clips: l.clips });
61
+ }
62
+ if (cues.length > 0)
63
+ out.push({ id: "captions", kind: "captions", label: "Captions", cues });
64
+ for (const l of model.lanes.filter((l) => l.kind === "audio")) {
65
+ const role = l.label.toLowerCase();
66
+ const label = role === "narr" || role === "narration" || role === "dialogue" || role === "voice"
67
+ ? "Voice"
68
+ : role === "music"
69
+ ? "Music"
70
+ : l.label;
71
+ out.push({ id: l.id, kind: "audio", label, clips: l.clips });
72
+ }
73
+ return out;
74
+ }, [model, cues]);
75
+ // Drop selection that no longer exists after a refresh (agent edits, undo).
76
+ useEffect(() => {
77
+ setSelected((prev) => {
78
+ const live = new Set(model.lanes.flatMap((l) => l.clips.map((c) => c.id)));
79
+ const next = new Set([...prev].filter((id) => live.has(id)));
80
+ return next.size === prev.size && [...next].every((id) => prev.has(id)) ? prev : next;
81
+ });
82
+ }, [model]);
83
+ useEffect(() => onSelect(selected), [selected, onSelect]);
84
+ const seekTo = useCallback((t) => {
85
+ const clamped = Math.max(0, Math.min(duration, t));
86
+ onPlayhead(clamped);
87
+ onSeek(model.revisionCursor, clamped);
88
+ }, [duration, model.revisionCursor, onPlayhead, onSeek]);
89
+ const timeAtClientX = useCallback((clientX) => {
90
+ const el = viewportRef.current;
91
+ if (!el)
92
+ return 0;
93
+ const rect = el.getBoundingClientRect();
94
+ return (clientX - rect.left + el.scrollLeft - LABEL_W) / pps;
95
+ }, [pps]);
96
+ const commit = useCallback(async (actions, label) => {
97
+ if (actions.length === 0)
98
+ return;
99
+ setGestureError(null);
100
+ try {
101
+ await session.apply(actions, label);
102
+ onCommitted();
103
+ }
104
+ catch (e) {
105
+ setGestureError(errorText(e));
106
+ onCommitted(); // still refresh: engine is the source of truth
107
+ }
108
+ }, [session, onCommitted]);
109
+ // ---- pointer handling -------------------------------------------------
110
+ const startScrub = (e) => {
111
+ if (e.button !== 0)
112
+ return;
113
+ e.currentTarget.setPointerCapture(e.pointerId);
114
+ setDrag({ kind: "scrub", pointerId: e.pointerId });
115
+ seekTo(timeAtClientX(e.clientX));
116
+ };
117
+ const startClipGesture = (e, clip, edge) => {
118
+ if (e.button !== 0 || busy)
119
+ return;
120
+ e.stopPropagation();
121
+ if (!editing) {
122
+ // Review mode: the timeline navigates; a clip click seeks to it.
123
+ seekTo(clip.start);
124
+ return;
125
+ }
126
+ e.currentTarget.setPointerCapture(e.pointerId);
127
+ const nextSelected = e.shiftKey
128
+ ? new Set(selected).add(clip.id)
129
+ : selected.has(clip.id)
130
+ ? selected
131
+ : new Set([clip.id]);
132
+ setSelected(nextSelected);
133
+ if (edge) {
134
+ setDrag({ kind: "trim", pointerId: e.pointerId, clip, edge, startX: e.clientX, deltaS: 0 });
135
+ }
136
+ else if (e.altKey && clip.ref.kind !== "title") {
137
+ if (!supports("edit.slip", caps)) {
138
+ setGestureError(unsupported("edit.slip").reason);
139
+ return;
140
+ }
141
+ setDrag({ kind: "slip", pointerId: e.pointerId, clip, startX: e.clientX, deltaS: 0 });
142
+ }
143
+ else {
144
+ setDrag({
145
+ kind: "move",
146
+ pointerId: e.pointerId,
147
+ clipIds: [...nextSelected],
148
+ startX: e.clientX,
149
+ deltaS: 0,
150
+ dropIndex: null,
151
+ });
152
+ }
153
+ };
154
+ const moveGesture = (e) => {
155
+ if (!drag || e.pointerId !== drag.pointerId)
156
+ return;
157
+ const t = timeAtClientX(e.clientX);
158
+ if (drag.kind === "scrub") {
159
+ seekTo(t);
160
+ return;
161
+ }
162
+ if (drag.kind === "trim" || drag.kind === "slip") {
163
+ setDrag({ ...drag, deltaS: (e.clientX - drag.startX) / pps });
164
+ return;
165
+ }
166
+ const first = model.lanes.flatMap((l) => l.clips).find((c) => c.id === drag.clipIds[0]);
167
+ const raw = (e.clientX - drag.startX) / pps;
168
+ const exclude = new Set(drag.clipIds);
169
+ const targetStart = Math.max(0, (first?.start ?? 0) + raw);
170
+ const snapped = snapping
171
+ ? snapTime(targetStart, gridStep, clipEdges(model, exclude), 0.08)
172
+ : targetStart;
173
+ const deltaS = Math.abs(snapped - targetStart) <= 0.08
174
+ ? snapped - (first?.start ?? 0)
175
+ : raw;
176
+ const dropIndex = first?.ref.kind === "video"
177
+ ? videoDropIndex(model.lanes.find((l) => l.id === first.ref.trackId), first.id, t)
178
+ : null;
179
+ setDrag({ ...drag, deltaS, dropIndex });
180
+ };
181
+ const endGesture = (e) => {
182
+ if (!drag || e.pointerId !== drag.pointerId)
183
+ return;
184
+ const done = drag;
185
+ setDrag(null);
186
+ if (done.kind === "scrub") {
187
+ seekTo(timeAtClientX(e.clientX));
188
+ return;
189
+ }
190
+ if (done.kind === "trim") {
191
+ if (Math.abs(done.deltaS) < 1 / model.fps / 2)
192
+ return;
193
+ void commit(planTrim(model, done.clip, done.edge, done.deltaS), `Trim ${done.clip.label}`);
194
+ return;
195
+ }
196
+ if (done.kind === "slip") {
197
+ const step = gridStep > 0 ? gridStep : 1 / model.fps;
198
+ const d = Math.abs(done.deltaS) < step / 2 ? 0 : Math.round(done.deltaS / step) * step;
199
+ if (d === 0)
200
+ return;
201
+ void commit([done.clip.ref.kind === "audio" ? slipAudioClip(done.clip.id, d) : slipClip(done.clip.id, d)], `Slip ${done.clip.label}`);
202
+ return;
203
+ }
204
+ const clips = done.clipIds
205
+ .map((id) => model.lanes.flatMap((l) => l.clips).find((c) => c.id === id))
206
+ .filter((c) => Boolean(c));
207
+ const first = clips[0];
208
+ if (!first)
209
+ return;
210
+ if (first.ref.kind === "video" && done.dropIndex != null) {
211
+ void commit(planMove(model, first, { index: done.dropIndex }), `Move ${first.label}`);
212
+ return;
213
+ }
214
+ if (Math.abs(done.deltaS) < gridStep / 4 && Math.abs(done.deltaS) < 0.02)
215
+ return;
216
+ void commit(planMoveMany(model, clips, done.deltaS), "Move clips");
217
+ };
218
+ // ---- keyboard ----------------------------------------------------------
219
+ // The chords live in ./keys (registered on the shell's shortcut registry); these are the actions,
220
+ // active while focus is inside the timeline.
221
+ const rootRef = useRef(null);
222
+ const reportKeyError = (err) => setGestureError(err instanceof Error ? err.message : String(err));
223
+ const step = (dir) => {
224
+ shuttle.current = { dir: 0, mag: 1 };
225
+ seekTo(playheadS + dir / model.fps);
226
+ };
227
+ const shuttleBy = (d) => {
228
+ const mag = shuttle.current.dir === d ? Math.min(shuttle.current.mag * 4, 16) : 1;
229
+ shuttle.current = { dir: d, mag };
230
+ seekTo(playheadS + (d * mag) / model.fps);
231
+ };
232
+ useEditorKeys(rootRef, {
233
+ undo: () => void session.undo().then(onCommitted).catch(reportKeyError),
234
+ redo: () => void session.redo().then(onCommitted).catch(reportKeyError),
235
+ redoY: () => void session.redo().then(onCommitted).catch(() => undefined),
236
+ stepBack: () => step(-1),
237
+ stepForward: () => step(1),
238
+ shuttleBack: () => shuttleBy(-1),
239
+ pause: () => {
240
+ shuttle.current = { dir: 0, mag: 1 };
241
+ },
242
+ shuttleForward: () => shuttleBy(1),
243
+ ...(editing
244
+ ? {
245
+ remove: () => (selected.size > 0 ? void removeSelected() : false),
246
+ split: () => splitAtPlayhead(),
247
+ }
248
+ : {}),
249
+ });
250
+ /** Splits `clip` at the playhead. `at` is seconds into the clip's own span. */
251
+ const splitClipAt = (clip) => {
252
+ // The playhead can sit between frames (a seek the player has not answered yet);
253
+ // a cut must land on a frame, so it goes to the nearest one.
254
+ const at = Math.round((playheadS - clip.start) * model.fps) / model.fps;
255
+ if (at <= 0 || at >= clip.duration)
256
+ return [];
257
+ const id = newClipId();
258
+ if (clip.ref.kind === "video")
259
+ return [splitClip(clip.id, at, id)];
260
+ if (clip.ref.kind === "audio")
261
+ return [splitAudioClip(clip.id, at, id)];
262
+ return [];
263
+ };
264
+ /** Split the selected clips — or, with no selection, whatever the playhead crosses. */
265
+ const splitAtPlayhead = () => {
266
+ if (!supports("edit.split", caps)) {
267
+ setGestureError(unsupported("edit.split").reason);
268
+ return;
269
+ }
270
+ const targets = selected.size > 0
271
+ ? [...selected]
272
+ : model.lanes
273
+ .flatMap((l) => l.clips)
274
+ .filter((c) => playheadS > c.start && playheadS < c.start + c.duration)
275
+ .map((c) => c.id);
276
+ const actions = targets.flatMap((id) => {
277
+ const clip = model.lanes.flatMap((l) => l.clips).find((c) => c.id === id);
278
+ return clip ? splitClipAt(clip) : [];
279
+ });
280
+ if (actions.length === 0) {
281
+ setGestureError("Playhead is not inside a clip");
282
+ return;
283
+ }
284
+ void commit(actions, "Split at playhead");
285
+ };
286
+ // The shell menu restarts its highlight when `items` changes identity, so the list is built once per open.
287
+ // Row actions read the current render's closures through a ref.
288
+ const menuOps = useRef({
289
+ split: (_clip) => { },
290
+ remove: (_clip) => { },
291
+ });
292
+ menuOps.current = {
293
+ split: (clip) => {
294
+ setSelected(new Set([clip.id]));
295
+ const actions = splitClipAt(clip);
296
+ if (actions.length === 0) {
297
+ setGestureError("Playhead is not inside the clip");
298
+ return;
299
+ }
300
+ void commit(actions, `Split ${clip.label}`);
301
+ },
302
+ remove: (clip) => {
303
+ setSelected(new Set([clip.id]));
304
+ void removeClipNow(clip);
305
+ },
306
+ };
307
+ const menuItems = useMemo(() => menu
308
+ ? clipMenuItems(menu.clip, model, caps, {
309
+ split: () => menuOps.current.split(menu.clip),
310
+ remove: () => menuOps.current.remove(menu.clip),
311
+ extras: onMenuAction?.(menu.clip) ?? [],
312
+ })
313
+ : [], [menu, model, caps, onMenuAction]);
314
+ const removeSelected = async () => {
315
+ const ids = [...selected];
316
+ const actions = ids.flatMap((id) => {
317
+ const clip = model.lanes.flatMap((l) => l.clips).find((c) => c.id === id);
318
+ if (!clip)
319
+ return [];
320
+ if (clip.ref.kind === "video")
321
+ return [{ type: "remove_clip", clip_id: id }];
322
+ if (clip.ref.kind === "audio")
323
+ return [{ type: "remove_audio_clip", clip_id: id }];
324
+ return [{ type: "remove_title", clip_id: id }];
325
+ });
326
+ setSelected(new Set());
327
+ await commit(actions, "Remove clips");
328
+ };
329
+ const zoom = (next, anchorClientX) => {
330
+ const el = viewportRef.current;
331
+ const clamped = Math.max(MIN_PPS, Math.min(MAX_PPS, next));
332
+ if (el && anchorClientX != null) {
333
+ const rect = el.getBoundingClientRect();
334
+ const t = timeAtClientX(anchorClientX);
335
+ el.scrollLeft = t * clamped + LABEL_W - (anchorClientX - rect.left);
336
+ }
337
+ setPps(clamped);
338
+ };
339
+ const fit = () => {
340
+ const el = viewportRef.current;
341
+ if (!el)
342
+ return;
343
+ const w = el.clientWidth - LABEL_W - 8;
344
+ setPps(Math.max(MIN_PPS, Math.min(MAX_PPS, w / duration)));
345
+ };
346
+ const undo = () => session
347
+ .undo()
348
+ .then(onCommitted)
349
+ .catch((err) => setGestureError(err instanceof Error ? err.message : String(err)));
350
+ const redo = () => session
351
+ .redo()
352
+ .then(onCommitted)
353
+ .catch((err) => setGestureError(err instanceof Error ? err.message : String(err)));
354
+ // ---- render ------------------------------------------------------------
355
+ const playheadX = LABEL_W + playheadS * pps;
356
+ return (_jsxs("div", { ref: rootRef, style: { display: "flex", flexDirection: "column", minHeight: 0, flex: 1 }, children: [_jsxs("div", { className: "fe-tl__bar", children: [_jsx(IconButton, { label: "Select", pressed: editing, children: _jsx(Icon, { name: "mouse-pointer" }) }), _jsx(IconButton, { label: "Split at playhead", disabled: !editing || !supports("edit.split", caps), onClick: splitAtPlayhead, children: _jsx(Icon, { name: "scissors" }) }), _jsx(IconButton, { label: "Undo", disabled: !model.canUndo || session.busy, onClick: () => void undo(), children: _jsx(Icon, { name: "undo" }) }), _jsx(IconButton, { label: "Redo", disabled: !model.canRedo || session.busy, onClick: () => void redo(), children: _jsx(Icon, { name: "redo" }) }), _jsx(IconButton, { label: snapping ? "Snapping on" : "Snapping off", pressed: snapping, onClick: () => setSnapping((s) => !s), children: _jsx(Icon, { name: "magnet" }) }), _jsxs("div", { className: "fe-tl__zoom", children: [_jsx(Slider, { className: "w-28!", label: "Timeline zoom", min: MIN_PPS, max: MAX_PPS, value: pps, onChange: (value) => zoom(value) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: fit, children: "Fit" })] })] }), gestureError && (_jsx("p", { role: "alert", className: "cr-muted", style: { margin: 0, padding: "0 var(--s4)", fontSize: 12 }, children: gestureError })), _jsx("div", { ref: viewportRef, tabIndex: 0, role: "region", "aria-label": "Timeline", className: "fe-tl__viewport", onWheel: (e) => {
357
+ if (e.ctrlKey || e.metaKey) {
358
+ e.preventDefault();
359
+ zoom(pps * (e.deltaY > 0 ? 0.85 : 1.18), e.clientX);
360
+ }
361
+ }, children: _jsxs("div", { style: { width: contentW + LABEL_W, position: "relative", minHeight: "100%" }, children: [_jsxs("div", { className: "fe-tl__ruler", onPointerDown: startScrub, onPointerMove: moveGesture, onPointerUp: endGesture, role: "slider", "aria-label": "Playhead", "aria-valuemin": 0, "aria-valuemax": Math.round(duration * model.fps), "aria-valuenow": Math.round(playheadS * model.fps), "aria-valuetext": formatSeconds(playheadS), children: [_jsx("div", { style: {
362
+ position: "sticky",
363
+ left: 0,
364
+ zIndex: 8,
365
+ width: LABEL_W,
366
+ flex: "none",
367
+ background: "var(--rk-plane)",
368
+ } }), _jsxs("div", { style: { position: "relative", width: contentW, height: RULER_H }, children: [minor.map((t) => (_jsx("div", { className: "fe-tl__ruler-tick", style: { left: t * pps - 0.5 } }, `m${t}`))), major.map((t) => (_jsxs("div", { className: "fe-tl__ruler-major", style: { left: t * pps - 0.5 }, children: [_jsx("span", { children: showFrames
369
+ ? `${formatTimelineTickLabel(t, duration, majorInterval)}:${String(Math.round((t * model.fps) % model.fps)).padStart(2, "0")}`
370
+ : formatTimelineTickLabel(t, duration, majorInterval) }), _jsx("i", {})] }, `M${t}`)))] })] }), lanes.map((lane) => (_jsxs("div", { className: "fe-lane", role: "row", "aria-label": `${lane.label} lane`, children: [_jsx("div", { className: "fe-lane__label", children: _jsx("span", { children: lane.label }) }), _jsxs("div", { className: "fe-lane__track", style: { width: contentW }, onPointerDown: mode === "review" ? startScrub : undefined, onPointerMove: mode === "review" ? moveGesture : undefined, onPointerUp: mode === "review" ? endGesture : undefined, children: [lane.kind === "captions"
371
+ ? lane.cues.map((c, i) => (_jsx("button", { type: "button", className: "fe-clip fe-clip--caption", style: { left: (c.start_ms / 1000) * pps, width: ((c.end_ms - c.start_ms) / 1000) * pps }, title: c.text, onClick: () => seekTo(c.start_ms / 1000), children: _jsx("span", { className: "fe-clip__label", children: c.text }) }, i)))
372
+ : lane.clips.map((clip) => {
373
+ const isSel = selected.has(clip.id);
374
+ const moveDelta = drag?.kind === "move" && drag.clipIds.includes(clip.id) ? drag.deltaS : 0;
375
+ const trimDelta = drag?.kind === "trim" && drag.clip.id === clip.id ? drag.deltaS : 0;
376
+ const left = clip.start * pps + (clip.ref.kind === "video" ? 0 : moveDelta * pps);
377
+ const trimLeft = drag?.kind === "trim" && drag.clip.id === clip.id && drag.edge === "in"
378
+ ? trimDelta
379
+ : 0;
380
+ const trimRight = drag?.kind === "trim" && drag.clip.id === clip.id && drag.edge === "out"
381
+ ? trimDelta
382
+ : 0;
383
+ const w = Math.max((clip.duration + trimRight - trimLeft) * pps, 6);
384
+ return (_jsxs("div", { role: "button", tabIndex: -1, "aria-label": `${clip.label} clip`, "aria-selected": isSel, title: clip.label, onPointerDown: (e) => startClipGesture(e, clip), onPointerMove: moveGesture, onPointerUp: endGesture, onContextMenu: (e) => {
385
+ e.preventDefault();
386
+ if (!editing)
387
+ return;
388
+ setSelected(new Set([clip.id]));
389
+ setMenu({ x: e.clientX, y: e.clientY, clip });
390
+ }, className: cx("fe-clip", isSel && "fe-clip--sel", session.busy && "fe-clip--pending"), style: { left: left + trimLeft * pps, width: w }, children: [clip.ref.kind === "video" && (_jsx(ClipThumbs, { dir: dir, source: clip.ref.clip.source, inS: clip.ref.clip.source_in / 705_600_000, outS: clip.ref.clip.source_out / 705_600_000 })), clip.ref.kind === "audio" && supports("media.waveform", caps) && (_jsx(ClipWave, { dir: dir, source: clip.ref.clip.source, inS: clip.ref.clip.trim_in / 705_600_000, outS: clip.ref.clip.trim_out / 705_600_000, durS: model.sourceDurationS.get(clip.ref.clip.source) ?? 0 })), _jsx("span", { className: "fe-clip__label", children: clip.ref.kind === "audio" && lane.label === "Music"
391
+ ? `${clip.label} ${Math.round(clip.ref.clip.gain / 1000)} dB`
392
+ : clip.label }), editing && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": `Trim ${clip.label} in`, className: "fe-clip__handle fe-clip__handle--in", onPointerDown: (e) => startClipGesture(e, clip, "in"), onPointerMove: moveGesture, onPointerUp: endGesture }), _jsx("button", { type: "button", "aria-label": `Trim ${clip.label} out`, className: "fe-clip__handle fe-clip__handle--out", onPointerDown: (e) => startClipGesture(e, clip, "out"), onPointerMove: moveGesture, onPointerUp: endGesture })] }))] }, clip.id));
393
+ }), drag?.kind === "move" && drag.dropIndex != null && lane.kind === "video" && (_jsx(DropIndicator, { lane: lane, index: drag.dropIndex, pps: pps }))] })] }, `${lane.kind}:${lane.id}`))), _jsx("div", { className: "fe-tl__playhead", style: { left: playheadX }, "aria-hidden": "true" })] }) }), menu && (_jsx(ContextMenu, { x: menu.x, y: menu.y, width: 300, label: "Clip actions", itemAs: "button", items: menuItems, onClose: () => setMenu(null) }, `${menu.x}:${menu.y}:${menu.clip.id}`))] }));
394
+ async function removeClipNow(clip) {
395
+ const action = clip.ref.kind === "video"
396
+ ? { type: "remove_clip", clip_id: clip.id }
397
+ : clip.ref.kind === "audio"
398
+ ? { type: "remove_audio_clip", clip_id: clip.id }
399
+ : { type: "remove_title", clip_id: clip.id };
400
+ await commit([action], `Remove ${clip.label}`);
401
+ }
402
+ }
403
+ /** Thumbnail strip inside a footage clip (`media.thumbnail`, cached JPEGs). */
404
+ function ClipThumbs({ dir, source, inS, outS, }) {
405
+ const [urls, setUrls] = useState([]);
406
+ // The preview rebuild after a grade change also rebuilds the chain the frames are drawn through.
407
+ const [graded, setGraded] = useState(0);
408
+ useEffect(() => {
409
+ const bump = () => setGraded((n) => n + 1);
410
+ window.addEventListener(PLAYBACK_REFRESHED, bump);
411
+ return () => window.removeEventListener(PLAYBACK_REFRESHED, bump);
412
+ }, []);
413
+ useEffect(() => {
414
+ let cancelled = false;
415
+ if (!(outS > inS))
416
+ return;
417
+ const times = [0.1, 0.4, 0.7].map((f) => Math.round((inS + (outS - inS) * f) * 1000));
418
+ thumbnails(dir, { source }, { times_ms: times, width: 96 })
419
+ .then((r) => {
420
+ if (!cancelled)
421
+ setUrls(r.frames.map((f) => assetUrl(f.path)));
422
+ })
423
+ .catch(() => undefined);
424
+ return () => {
425
+ cancelled = true;
426
+ };
427
+ }, [dir, source, inS, outS, graded]);
428
+ if (urls.length === 0)
429
+ return null;
430
+ return (_jsx("div", { className: "fe-clip__thumbs", "aria-hidden": "true", children: urls.map((u) => (_jsx("img", { src: u, alt: "", draggable: false }, u))) }));
431
+ }
432
+ /** Waveform bars inside an audio clip (`media.waveform` peaks of the source). */
433
+ function ClipWave({ dir, source, inS, outS, durS, }) {
434
+ const [peaks, setPeaks] = useState(null);
435
+ useEffect(() => {
436
+ let cancelled = false;
437
+ engineCall("media.waveform", { dir, source, bars: 600 })
438
+ .then((r) => !cancelled && setPeaks(r.peaks ?? []))
439
+ .catch(() => !cancelled && setPeaks([]));
440
+ return () => {
441
+ cancelled = true;
442
+ };
443
+ }, [dir, source]);
444
+ const bars = useMemo(() => {
445
+ if (!peaks || peaks.length === 0 || !(durS > 0))
446
+ return [];
447
+ const lo = Math.floor((inS / durS) * peaks.length);
448
+ const hi = Math.max(lo + 1, Math.ceil((outS / durS) * peaks.length));
449
+ const slice = peaks.slice(lo, hi);
450
+ const n = 48;
451
+ const out = [];
452
+ for (let i = 0; i < n; i++) {
453
+ const s = Math.floor((i / n) * slice.length);
454
+ const e = Math.max(s + 1, Math.ceil(((i + 1) / n) * slice.length));
455
+ out.push(Math.max(...slice.slice(s, e)));
456
+ }
457
+ return out;
458
+ }, [peaks, inS, outS, durS]);
459
+ if (bars.length === 0)
460
+ return null;
461
+ return (_jsx("div", { className: "fe-clip__wave", "aria-hidden": "true", children: bars.map((p, i) => (_jsx("i", { style: { height: `${Math.max(12, Math.min(100, p * 140))}%` } }, i))) }));
462
+ }
463
+ function DropIndicator({ lane, index, pps, }) {
464
+ const before = lane.clips[index - 1];
465
+ const x = (before ? before.start + before.duration : 0) * pps;
466
+ return (_jsx("div", { className: "pointer-events-none absolute top-0 bottom-0 w-0.5", style: { left: x, background: "var(--rk-accent)" }, "aria-hidden": "true" }));
467
+ }
468
+ /** A disabled row says why: the honest capability reason sits beside the label. */
469
+ function reasoned(label, reason) {
470
+ if (!reason)
471
+ return label;
472
+ return (_jsxs(_Fragment, { children: [label, _jsx("span", { className: "fe-menu__reason", title: reason, children: reason })] }));
473
+ }
474
+ function clipMenuItems(clip, model, caps, ops) {
475
+ const edits = [];
476
+ if (clip.ref.kind === "video" || clip.ref.kind === "audio") {
477
+ const canSplit = supports("edit.split", caps);
478
+ const canSlip = supports("edit.slip", caps);
479
+ edits.push({
480
+ id: "split",
481
+ label: reasoned("Split at playhead", canSplit ? undefined : unsupported("edit.split").reason),
482
+ shortcut: "S",
483
+ disabled: !canSplit,
484
+ run: ops.split,
485
+ }, {
486
+ id: "slip",
487
+ label: reasoned("Slip (Alt+drag clip)", canSlip ? undefined : unsupported("edit.slip").reason),
488
+ disabled: !canSlip,
489
+ });
490
+ }
491
+ if (clip.ref.kind === "audio") {
492
+ const videoClips = model.lanes.filter((l) => l.kind === "video").flatMap((l) => l.clips);
493
+ if (videoClips.length > 0 && !clip.link) {
494
+ edits.push({
495
+ id: "link",
496
+ label: reasoned(`Link to ${videoClips[0].label}`, "Use the Inspect tab's Link control"),
497
+ disabled: true,
498
+ });
499
+ }
500
+ }
501
+ // Groups are divided by a separator; an empty group adds none.
502
+ const groups = [edits, ops.extras, [{ id: "remove", label: "Remove", danger: true, run: ops.remove }]];
503
+ return groups
504
+ .filter((group) => group.length > 0)
505
+ .flatMap((group, index) => group.map((item, i) => (index > 0 && i === 0 ? { ...item, separatorBefore: true } : item)));
506
+ }
@@ -0,0 +1,9 @@
1
+ import { type ProjectShow } from "../lib/project.js";
2
+ import { useTimelinePreview } from "../components/Player.js";
3
+ export type PreviewHost = ReturnType<typeof useTimelinePreview>;
4
+ export declare function TimelineEditorView({ show, previewHost, onRefresh, busy, }: {
5
+ show: ProjectShow;
6
+ previewHost: PreviewHost | null;
7
+ onRefresh: () => Promise<unknown>;
8
+ busy: boolean;
9
+ }): import("react").JSX.Element;