@rightkit/video-ui 0.0.0-stage → 0.1.1

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 +10 -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 +27 -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 +172 -0
  99. package/dist/lib/shell.js +32 -0
  100. package/dist/styles.css +24 -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 +68 -3
@@ -0,0 +1,237 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Finished edit workspace (docs/design/app-ui.md §3.4).
4
+ *
5
+ * Layout: app-sidebar · [ headerbar | stage col (stage + transport) + side
6
+ * panel (Transcript / Changes / Inspect tabs) | timeline ].
7
+ *
8
+ * Stage shows real compositor frames (StagePlayer over the Rust playback
9
+ * session), falling back to the nearest `media.thumbnail` while loading or if
10
+ * playback fails — never a black box when media exists.
11
+ *
12
+ * `project.show` is the single source of truth: EditSession mutations carry
13
+ * `expect_cursor` and the host refreshes after every commit; the preview
14
+ * session reopens on revision change.
15
+ */
16
+ import { IconButton, SegmentedControl } from "@rightkit/react-shell/react";
17
+ import { useSegmentedControl } from "@rightkit/react-shell/headless";
18
+ import { Icon } from "../ui/index.js";
19
+ import { useCallback, useEffect, useId, useMemo, useState } from "react";
20
+ import { engineCall, errorMessage } from "../lib/engine.js";
21
+ import { assetUrl } from "../lib/asset.js";
22
+ import { editplanList, thumbnails, } from "../lib/media.js";
23
+ import { baseName, formatSeconds } from "../lib/project.js";
24
+ import { StagePlayer } from "../components/Player.js";
25
+ import { EditSession, errorText } from "./adapter.js";
26
+ import { ChangesPanel } from "./ChangesPanel.js";
27
+ import { Inspector } from "./Inspector.js";
28
+ import { CaptionInspector } from "./CaptionInspector.js";
29
+ import { buildEditorModel } from "./model.js";
30
+ import { clipRanges } from "./transcript.js";
31
+ import { Timeline } from "./Timeline.js";
32
+ import { TranscriptPanel } from "./TranscriptPanel.js";
33
+ export function TimelineEditorView({ show, previewHost, onRefresh, busy, }) {
34
+ const revision = show.revision.cursor;
35
+ const dir = show.project.dir;
36
+ // project.show advertises engine extensions; each gates a gesture family.
37
+ const caps = useMemo(() => ({ extensions: show.extensions }), [show.extensions]);
38
+ const [tab, setTab] = useState("transcript");
39
+ const [mode, setMode] = useState("review");
40
+ const [cues, setCues] = useState([]);
41
+ const [captionsOn, setCaptionsOn] = useState(true);
42
+ const [plans, setPlans] = useState([]);
43
+ const [plansError, setPlansError] = useState(null);
44
+ // Side panel tabs: the shell's headless segmented-control behaviour (roving focus, Arrow/Home/End
45
+ // keys that move and select) with the tablist/tab roles applied on top of its radio props.
46
+ const tabsId = useId();
47
+ const sideTabs = useSegmentedControl({
48
+ value: tab,
49
+ options: [{ value: "transcript" }, { value: "changes" }, { value: "inspect" }, { value: "captions" }],
50
+ onChange: setTab,
51
+ label: "Side panel tabs",
52
+ });
53
+ const [panelError, setPanelError] = useState(null);
54
+ const [playheadS, setPlayheadS] = useState(0);
55
+ const [selected, setSelected] = useState(new Set());
56
+ const [fallback, setFallback] = useState(null);
57
+ const [tlH, setTlH] = useState(192);
58
+ const [tlFull, setTlFull] = useState(false);
59
+ const [sideHidden, setSideHidden] = useState(false);
60
+ const [refreshing, setRefreshing] = useState(false);
61
+ const [seekState, setSeekState] = useState(null);
62
+ const model = useMemo(() => {
63
+ try {
64
+ return buildEditorModel(show);
65
+ }
66
+ catch (e) {
67
+ setPanelError(errorMessage(e));
68
+ return null;
69
+ }
70
+ }, [show]);
71
+ // Revision-bound cues include saved corrections from project.show.
72
+ const refreshCues = useCallback(async () => {
73
+ if (!model) {
74
+ setCues([]);
75
+ return;
76
+ }
77
+ setCues(model.captions.map((cue) => ({
78
+ start_ms: cue.start_frame * 1000 / model.fps,
79
+ end_ms: (cue.start_frame + cue.frames) * 1000 / model.fps,
80
+ text: cue.text,
81
+ lines: [cue.text],
82
+ })).sort((a, b) => a.start_ms - b.start_ms));
83
+ }, [model]);
84
+ useEffect(() => {
85
+ void refreshCues();
86
+ }, [refreshCues, revision]);
87
+ // A failed read is retried, then shown: an empty Changes tab must mean "no plan",
88
+ // never "the plan could not be read".
89
+ useEffect(() => {
90
+ let cancelled = false;
91
+ setPlansError(null);
92
+ void (async () => {
93
+ for (let attempt = 0;; attempt += 1) {
94
+ try {
95
+ const r = await editplanList(dir);
96
+ if (!cancelled)
97
+ setPlans(r.plans);
98
+ return;
99
+ }
100
+ catch (e) {
101
+ if (cancelled)
102
+ return;
103
+ if (attempt === 2) {
104
+ setPlans([]);
105
+ setPlansError(errorText(e));
106
+ return;
107
+ }
108
+ await new Promise((done) => setTimeout(done, 600));
109
+ }
110
+ }
111
+ })();
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [dir, revision]);
116
+ // Stage fallback frame: nearest real thumbnail at the playhead, mapped
117
+ // back through the clip ranges. Keeps the stage truthful while the
118
+ // playback session opens or if it fails.
119
+ const ranges = useMemo(() => clipRanges(show), [show]);
120
+ const firstSource = model?.lanes
121
+ .find((l) => l.kind === "video")
122
+ ?.clips[0]?.ref;
123
+ const firstSourceId = firstSource && firstSource.kind === "video" ? firstSource.clip.source : null;
124
+ useEffect(() => {
125
+ if (!firstSourceId)
126
+ return;
127
+ const rs = ranges.get(firstSourceId) ?? [];
128
+ const t = playheadS;
129
+ const r = rs.find((x) => t >= x.outOffS && t < x.outOffS + (x.outS - x.inS)) ??
130
+ rs.reduce((best, x) => (!best || Math.abs(x.outOffS - t) < Math.abs(best.outOffS - t) ? x : best), null);
131
+ if (!r)
132
+ return;
133
+ const at = r.inS + Math.max(0, t - r.outOffS);
134
+ const timer = setTimeout(() => {
135
+ void thumbnails(dir, { source: firstSourceId }, { at_ms: Math.round(at * 1000), width: 1280 })
136
+ .then((res) => {
137
+ const p = res.frames[0]?.path;
138
+ if (p)
139
+ setFallback(assetUrl(p));
140
+ })
141
+ .catch(() => undefined);
142
+ }, 200);
143
+ return () => clearTimeout(timer);
144
+ }, [dir, firstSourceId, playheadS, ranges]);
145
+ const refresh = useCallback(async () => {
146
+ setRefreshing(true);
147
+ try {
148
+ await onRefresh();
149
+ await refreshCues();
150
+ }
151
+ finally {
152
+ setRefreshing(false);
153
+ }
154
+ }, [onRefresh, refreshCues]);
155
+ const committed = useCallback(() => {
156
+ void refresh();
157
+ }, [refresh]);
158
+ const session = useMemo(() => new EditSession(dir, revision, caps, engineCall, () => {
159
+ /* stale cursor: the host refreshes, which re-seats via resync */
160
+ void refresh();
161
+ }),
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ [dir]);
164
+ // Re-seat the session's cursor whenever the show's revision changes.
165
+ useEffect(() => {
166
+ session.resync(revision);
167
+ }, [session, revision]);
168
+ const onSeekPreview = useCallback((_cursor, t) => {
169
+ setSeekState((prev) => ({ ms: Math.round(t * 1000), n: (prev?.n ?? 0) + 1 }));
170
+ previewHost?.seek(revision, t);
171
+ }, [previewHost]);
172
+ const currentCue = cues.find((c) => playheadS * 1000 >= c.start_ms && playheadS * 1000 < c.end_ms);
173
+ // ---- header data ---------------------------------------------------------
174
+ const end = model?.durationS ?? 0;
175
+ const sources = show.sources;
176
+ const captionsLabel = show.transcripts?.length
177
+ ? `${show.transcripts.length} source${show.transcripts.length === 1 ? "" : "s"}`
178
+ : null;
179
+ // ---- render ---------------------------------------------------------------
180
+ if (!model || model.lanes.every((l) => l.clips.length === 0)) {
181
+ return (_jsx("div", { className: "fe", style: { display: "flex", alignItems: "center", justifyContent: "center" }, children: _jsxs("div", { style: { textAlign: "center" }, children: [_jsx("h2", { style: { fontSize: 15, fontWeight: 600, margin: 0 }, children: "No finished edit yet" }), _jsx("p", { className: "cr-muted", style: { fontSize: 13, marginTop: 4 }, children: "Run an auto-edit to build the first cut. Its clip plan, captions, and changes will land here." })] }) }));
182
+ }
183
+ const aspect = model.lanes.length > 0 ? aspectLabel(show) : null;
184
+ const dragTl = (e) => {
185
+ const startY = e.clientY;
186
+ const start = tlH;
187
+ const move = (ev) => {
188
+ const next = Math.max(120, Math.min(480, start + (startY - ev.clientY)));
189
+ setTlH(next);
190
+ setTlFull(false);
191
+ };
192
+ const up = () => {
193
+ window.removeEventListener("pointermove", move);
194
+ window.removeEventListener("pointerup", up);
195
+ };
196
+ window.addEventListener("pointermove", move);
197
+ window.addEventListener("pointerup", up);
198
+ };
199
+ return (_jsxs("div", { className: "fe", children: [_jsxs("header", { className: "fe-header", children: [_jsxs("div", { className: "fe-header__left", children: [_jsx(IconButton, { label: "Panel", pressed: !sideHidden, onClick: () => setSideHidden((v) => !v), children: _jsx(Icon, { name: "layout-list" }) }), _jsx("strong", { className: "fe-header__name", children: "Finished edit" })] }), _jsx("div", { className: "fe-header__mid", children: _jsx(SegmentedControl, { label: "Mode", value: mode, onChange: (v) => setMode(v), options: [
200
+ { value: "review", label: "Review" },
201
+ { value: "edit", label: "Edit" },
202
+ ] }) }), _jsxs("div", { className: "fe-header__right", children: [_jsx("span", { className: "rk-badge", children: baseName(dir) || "Project" }), _jsx("span", { className: "rk-badge", children: timecodeish(end) }), _jsxs("span", { className: "rk-badge", children: [sources.length, " source", sources.length === 1 ? "" : "s"] }), captionsLabel && _jsxs("span", { className: "rk-badge", children: ["Captions ", captionsLabel] }), _jsxs("span", { className: "rk-badge", children: ["Revision ", revision, show.revision.total > revision + 1 ? ` / ${show.revision.total}` : ""] }), (session.busy || busy || refreshing) && (_jsx("span", { role: "status", className: "cr-muted", children: "Applying changes\u2026" }))] })] }), _jsxs("div", { className: "fe-main", children: [_jsx("div", { className: "fe-stage-col", children: _jsx(StagePlayer, { dir: dir, revision: revision, seekRequest: seekState, captionsOn: captionsOn, caption: currentCue?.text ?? null, aspect: aspect, fallback: fallback, onToggleCaptions: setCaptionsOn, onPosition: (ms) => setPlayheadS(ms / 1000) }) }), !sideHidden && (_jsxs("aside", { className: "fe-side", "aria-label": "Details", children: [_jsx("div", { className: "fe-side__tabs", ...sideTabs.groupProps, role: "tablist", children: [
203
+ ["transcript", "Transcript"],
204
+ ["changes", `Changes${plans.length ? ` ${plans.flatMap((p) => p.items ?? []).length}` : ""}`],
205
+ ["inspect", "Inspect"],
206
+ ["captions", "Captions"],
207
+ ].map(([id, label]) => (_jsx("button", { type: "button", ...sideTabs.getOptionProps(id), role: "tab", id: `${tabsId}-tab-${id}`, "aria-controls": `${tabsId}-panel`, "aria-selected": tab === id, "aria-checked": undefined, className: `fe-side__tab${tab === id ? " fe-side__tab--active" : ""}`, children: label }, id))) }), _jsxs("div", { className: "fe-side__body", role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${tab}`, children: [tab === "transcript" && (_jsx(TranscriptPanel, { show: show, session: session, caps: caps, mode: mode, cues: cues, playheadS: playheadS, onSeek: (t) => {
208
+ setPlayheadS(t);
209
+ onSeekPreview(revision, t);
210
+ }, onCommitted: committed, onError: setPanelError })), tab === "changes" && (_jsx(ChangesPanel, { dir: dir, plans: plans, loadError: plansError, session: session, onCommitted: committed, onSeek: (t) => {
211
+ setPlayheadS(t);
212
+ onSeekPreview(revision, t);
213
+ } })), tab === "inspect" && (_jsx(Inspector, { model: model, selection: selected, session: session, caps: caps, dir: dir, playheadS: playheadS, onCommitted: committed, onError: (m) => setPanelError(m) })), tab === "captions" && _jsx(CaptionInspector, { model: model, session: session, onCommitted: committed, onError: setPanelError })] })] }))] }), panelError && (_jsx("p", { role: "alert", className: "cr-muted", style: { margin: 0, padding: "0 var(--s4)", fontSize: 12 }, children: panelError })), _jsxs("div", { className: "fe-tl", style: { height: tlFull ? "55%" : tlH }, children: [_jsx("div", { className: "fe-tl__resize", role: "separator", "aria-orientation": "horizontal", "aria-label": "Resize timeline", onPointerDown: dragTl }), _jsx("div", { className: "fe-tl__resizebtn", children: _jsx(IconButton, { label: tlFull ? "Shrink timeline" : "Expand timeline", onClick: () => setTlFull((v) => !v), children: _jsx(Icon, { name: tlFull ? "chevron-down" : "chevron-up" }) }) }), _jsx(Timeline, { model: model, session: session, caps: caps, busy: busy || refreshing, mode: mode, cues: cues, dir: dir, playheadS: playheadS, onPlayhead: setPlayheadS, onCommitted: committed, onSeek: onSeekPreview, onSelect: setSelected })] })] }));
214
+ }
215
+ function timecodeish(s) {
216
+ return formatSeconds(s);
217
+ }
218
+ function aspectLabel(show) {
219
+ const v = show.timeline.video_tracks.flatMap((t) => t.clips)[0];
220
+ if (!v)
221
+ return null;
222
+ const src = show.sources.find((s) => s.info.id === v.source);
223
+ const w = src?.info.video?.width;
224
+ const h = src?.info.video?.height;
225
+ if (!w || !h)
226
+ return null;
227
+ const r = w / h;
228
+ if (Math.abs(r - 16 / 9) < 0.02)
229
+ return "16:9";
230
+ if (Math.abs(r - 9 / 16) < 0.02)
231
+ return "9:16";
232
+ if (Math.abs(r - 1) < 0.02)
233
+ return "1:1";
234
+ if (Math.abs(r - 4 / 3) < 0.02)
235
+ return "4:3";
236
+ return `${w}×${h}`;
237
+ }
@@ -0,0 +1,20 @@
1
+ import { type CaptionCue } from "../lib/media.js";
2
+ import { type ProjectShow } from "../lib/project.js";
3
+ import type { EditSession } from "./adapter.js";
4
+ import { type CapabilityReport } from "./capabilities.js";
5
+ import { type ClipRange } from "./transcript.js";
6
+ export declare function TranscriptPanel({ show, session, caps, mode, cues, playheadS, onSeek, onCommitted, onError, }: {
7
+ show: ProjectShow;
8
+ session: EditSession;
9
+ caps: CapabilityReport | null;
10
+ mode: "review" | "edit";
11
+ /** Timeline-mapped caption cues (Review mode rows). */
12
+ cues: CaptionCue[];
13
+ /** Current playhead in seconds — drives the current-line highlight. */
14
+ playheadS: number;
15
+ /** Seek the timeline playhead (seconds). */
16
+ onSeek: (timeS: number) => void;
17
+ onCommitted: () => void;
18
+ onError?: (message: string | null) => void;
19
+ }): import("react").JSX.Element;
20
+ export type { ClipRange };
@@ -0,0 +1,180 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Transcript tab of the Finished edit side panel (docs/design/app-ui.md §3.4).
4
+ *
5
+ * Review mode: lines are the timeline-mapped caption cues (`captions.show`) —
6
+ * stamp + text, current line highlighted, click seeks.
7
+ *
8
+ * Edit mode: word-level surface. Words come from the engine's validated
9
+ * `transcript.show` (independent word-edge evidence — the locked invariant for
10
+ * destructive word cuts); each word maps through the live clip ranges, so
11
+ * removed words show struck-through. Selecting kept words + Delete
12
+ * ripple-deletes them through the EditSession; selecting struck words +
13
+ * Restore re-inserts the source span as a clip at the boundary where it was
14
+ * cut. Both land as engine revisions — undoable, never local state.
15
+ */
16
+ import { Button } from "@rightkit/react-shell/react";
17
+ import { useEffect, useMemo, useState } from "react";
18
+ import { errorMessage } from "../lib/engine.js";
19
+ import { transcriptShow } from "../lib/media.js";
20
+ import { baseName, formatSeconds, newClipId, secondsToTicks } from "../lib/project.js";
21
+ import { insertClip, rippleDeleteRange } from "./actions.js";
22
+ import { supports, unsupported } from "./capabilities.js";
23
+ import { clipRanges, coveredOutputRanges, sourceToOutput } from "./transcript.js";
24
+ export function TranscriptPanel({ show, session, caps, mode, cues, playheadS, onSeek, onCommitted, onError, }) {
25
+ if (mode === "review") {
26
+ return _jsx(CueList, { cues: cues, playheadS: playheadS, onSeek: onSeek });
27
+ }
28
+ return (_jsx(WordEditor, { show: show, session: session, caps: caps, onSeek: onSeek, onCommitted: onCommitted, onError: onError }));
29
+ }
30
+ /** Review-mode transcript: one row per caption cue on the current timeline. */
31
+ function CueList({ cues, playheadS, onSeek, }) {
32
+ const current = cues.findIndex((c) => playheadS * 1000 >= c.start_ms && playheadS * 1000 < c.end_ms);
33
+ if (cues.length === 0) {
34
+ return _jsx("p", { className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: "No captions on this edit yet." });
35
+ }
36
+ return (_jsx(_Fragment, { children: cues.map((c, i) => (_jsxs("button", { type: "button", className: `fe-cue${i === current ? " fe-cue--current" : ""}`, "aria-current": i === current || undefined, onClick: () => onSeek(c.start_ms / 1000), children: [_jsx("span", { className: "fe-cue__stamp", children: formatSeconds(c.start_ms / 1000) }), _jsx("span", { children: c.text })] }, i))) }));
37
+ }
38
+ function WordEditor({ show, session, caps, onSeek, onCommitted, onError, }) {
39
+ const dir = show.project.dir;
40
+ const transcripted = useMemo(() => new Set((show.transcripts ?? []).map((t) => t.source)), [show.transcripts]);
41
+ const ranges = useMemo(() => clipRanges(show), [show]);
42
+ const [transcripts, setTranscripts] = useState({});
43
+ const [loadError, setLoadError] = useState(null);
44
+ const [sel, setSel] = useState(null);
45
+ const [busy, setBusy] = useState(false);
46
+ useEffect(() => {
47
+ if (transcripted.size === 0)
48
+ return;
49
+ let cancelled = false;
50
+ (async () => {
51
+ try {
52
+ const ts = await Promise.all([...transcripted].map((id) => transcriptShow(dir, id)));
53
+ if (cancelled)
54
+ return;
55
+ const next = {};
56
+ [...transcripted].forEach((id, i) => {
57
+ next[id] = ts[i].transcript;
58
+ });
59
+ setTranscripts(next);
60
+ setLoadError(null);
61
+ }
62
+ catch (e) {
63
+ if (!cancelled)
64
+ setLoadError(errorMessage(e));
65
+ }
66
+ })();
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [dir, transcripted]);
71
+ const selInfo = (() => {
72
+ if (!sel)
73
+ return null;
74
+ const t = transcripts[sel.source];
75
+ if (!t)
76
+ return null;
77
+ const lo = Math.min(sel.anchor, sel.focus);
78
+ const hi = Math.max(sel.anchor, sel.focus);
79
+ const words = t.words.slice(lo, hi + 1);
80
+ if (words.length === 0)
81
+ return null;
82
+ const startMs = words[0].start_ms;
83
+ const endMs = words[words.length - 1].end_ms;
84
+ const rs = ranges.get(sel.source) ?? [];
85
+ const spans = coveredOutputRanges(rs, startMs, endMs);
86
+ // A struck selection is one whose words are all unplaced.
87
+ const allCut = words.every((w) => sourceToOutput(rs, w.start_ms) == null);
88
+ return { lo, hi, words, startMs, endMs, spans, allCut, source: sel.source };
89
+ })();
90
+ const canDelete = supports("edit.ripple_delete", caps);
91
+ const clickWord = (source, index, shift) => {
92
+ setSel((prev) => shift && prev && prev.source === source
93
+ ? { source, anchor: prev.anchor, focus: index }
94
+ : { source, anchor: index, focus: index });
95
+ const w = (transcripts[source]?.words ?? [])[index];
96
+ if (!w)
97
+ return;
98
+ const out = sourceToOutput(ranges.get(source) ?? [], w.start_ms);
99
+ if (out != null)
100
+ onSeek(out);
101
+ };
102
+ const deleteSelection = async () => {
103
+ if (!selInfo || selInfo.spans.length === 0 || !canDelete)
104
+ return;
105
+ setBusy(true);
106
+ try {
107
+ await session.apply(selInfo.spans.map((s) => rippleDeleteRange(s.startS, s.endS)), `Delete ${selInfo.words.length} word${selInfo.words.length === 1 ? "" : "s"}`);
108
+ setSel(null);
109
+ onCommitted();
110
+ }
111
+ catch (e) {
112
+ onError?.(errorMessage(e));
113
+ onCommitted();
114
+ }
115
+ finally {
116
+ setBusy(false);
117
+ }
118
+ };
119
+ /**
120
+ * Restore: re-insert the struck span's source range into the footage track
121
+ * at the boundary where it was cut. The insert index is the position of the
122
+ * first clip (of the same source) starting at/after the span, else after the
123
+ * last clip ending at/before it — the point the ripple-delete collapsed.
124
+ */
125
+ const restoreSelection = async () => {
126
+ if (!selInfo || !selInfo.allCut)
127
+ return;
128
+ const track = show.timeline.video_tracks[0];
129
+ if (!track)
130
+ return;
131
+ const { source, startMs, endMs } = selInfo;
132
+ const inT = secondsToTicks(startMs / 1000);
133
+ const outT = secondsToTicks(endMs / 1000);
134
+ const sameSource = track.clips.filter((c) => c.source === source);
135
+ let index = track.clips.length;
136
+ const after = sameSource.find((c) => c.source_in >= outT);
137
+ if (after) {
138
+ index = track.clips.indexOf(after);
139
+ }
140
+ else {
141
+ const before = [...sameSource].reverse().find((c) => c.source_out <= inT);
142
+ if (before)
143
+ index = track.clips.indexOf(before) + 1;
144
+ }
145
+ setBusy(true);
146
+ try {
147
+ const action = insertClip(track.id, index, {
148
+ id: newClipId(),
149
+ source,
150
+ source_in: inT,
151
+ source_out: outT,
152
+ });
153
+ await session.apply([action], `Restore ${selInfo.words.length} word${selInfo.words.length === 1 ? "" : "s"}`);
154
+ setSel(null);
155
+ onCommitted();
156
+ }
157
+ catch (e) {
158
+ onError?.(errorMessage(e));
159
+ onCommitted();
160
+ }
161
+ finally {
162
+ setBusy(false);
163
+ }
164
+ };
165
+ const entries = Object.entries(transcripts);
166
+ const placed = (id) => (ranges.get(id)?.length ?? 0) > 0;
167
+ return (_jsxs(_Fragment, { children: [loadError && (_jsx("p", { role: "alert", className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: loadError })), transcripted.size === 0 && (_jsx("p", { className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: "No transcripts yet \u2014 transcribe a source to edit from its words." })), entries.length === 0 && transcripted.size > 0 && !loadError && (_jsx("p", { className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: "Loading transcripts\u2026" })), entries.map(([source, t]) => {
168
+ const name = show.sources.find((s) => s.info.id === source)?.name ?? baseName(source);
169
+ const rs = ranges.get(source) ?? [];
170
+ const isSel = (i) => sel != null &&
171
+ sel.source === source &&
172
+ i >= Math.min(sel.anchor, sel.focus) &&
173
+ i <= Math.max(sel.anchor, sel.focus);
174
+ return (_jsxs("div", { children: [_jsxs("div", { className: "cr-muted", style: { fontSize: 11, marginBottom: 4 }, title: source, children: [name, !placed(source) && " (not on the timeline)"] }), _jsx("p", { className: "fe-words", style: { margin: 0 }, children: t.words.map((w, i) => {
175
+ const out = sourceToOutput(rs, w.start_ms);
176
+ const approx = w.end_source && w.end_source !== "model";
177
+ return (_jsx("button", { type: "button", className: `fe-word${out == null ? " fe-word--cut" : ""}${isSel(i) ? " fe-word--sel" : ""}`, title: `${formatSeconds(w.start_ms / 1000)}–${formatSeconds(w.end_ms / 1000)} source${out != null ? ` · ${formatSeconds(out)} timeline` : " · cut"}${approx ? " · word end approximate" : ""}`, onClick: (e) => clickWord(source, i, e.shiftKey), children: w.text }, i));
178
+ }) })] }, source));
179
+ }), selInfo && (_jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: "var(--s2)", alignItems: "center" }, children: [selInfo.allCut ? (_jsxs(Button, { size: "sm", disabled: busy, onClick: () => void restoreSelection(), children: ["Restore ", selInfo.words.length, " word", selInfo.words.length === 1 ? "" : "s"] })) : (_jsxs(Button, { size: "sm", disabled: busy || !canDelete || selInfo.spans.length === 0, title: !canDelete ? unsupported("edit.ripple_delete").reason : undefined, onClick: () => void deleteSelection(), children: ["Delete ", selInfo.words.length, " word", selInfo.words.length === 1 ? "" : "s"] })), selInfo.spans.length === 0 && !selInfo.allCut && (_jsx("span", { className: "cr-muted", style: { fontSize: 11 }, children: "Selected words are not on the timeline." }))] }))] }));
180
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Wire builders for `cutright_timeline::Action` — the serde shape the engine's
3
+ * `edit` method consumes (`{ "type": <snake_case>, ... }`). Field names and
4
+ * units match rightkit-timeline src/action.rs exactly:
5
+ * times are `Ticks` (705_600_000 per second), gains are `MilliDb`
6
+ * (thousandths of a dB), title spans are integer frames.
7
+ */
8
+ import { type TitleTemplate } from "../lib/project.js";
9
+ export type EditAction = Record<string, unknown> & {
10
+ type: string;
11
+ };
12
+ export declare const insertClip: (trackId: string, index: number, clip: {
13
+ id: string;
14
+ source: string;
15
+ source_in: number;
16
+ source_out: number;
17
+ }) => EditAction;
18
+ export declare const removeClip: (clipId: string) => EditAction;
19
+ export declare const trimClip: (clipId: string, sourceInS: number, sourceOutS: number) => EditAction;
20
+ export declare const moveClip: (clipId: string, toIndex: number) => EditAction;
21
+ export declare const moveAudioClip: (clipId: string, startS: number) => EditAction;
22
+ export declare const trimAudioClip: (clipId: string, trimInS: number, trimOutS: number) => EditAction;
23
+ export declare const removeAudioClip: (clipId: string) => EditAction;
24
+ /** `gainDb` is linear-reading decibels; the model stores thousandths (MilliDb). */
25
+ export declare const setGain: (clipId: string, gainDb: number) => EditAction;
26
+ export declare const linkNarration: (audioId: string, videoId: string) => EditAction;
27
+ export declare const unlinkNarration: (audioId: string) => EditAction;
28
+ export type UnderMode = {
29
+ mode: "keep";
30
+ } | {
31
+ mode: "duck";
32
+ amount: number;
33
+ } | {
34
+ mode: "mute";
35
+ };
36
+ export declare const setUnder: (audioId: string, under: UnderMode) => EditAction;
37
+ export declare const addTitle: (trackId: string, clip: {
38
+ id: string;
39
+ start_frame: number;
40
+ frames: number;
41
+ template: TitleTemplate;
42
+ fade_in_frames: number;
43
+ fade_out_frames: number;
44
+ }) => EditAction;
45
+ export declare const removeTitle: (clipId: string) => EditAction;
46
+ export declare const moveTitle: (clipId: string, startFrame: number) => EditAction;
47
+ export declare const trimTitle: (clipId: string, startFrame: number, frames: number) => EditAction;
48
+ export declare const editTitle: (clipId: string, template: TitleTemplate, fadeInFrames: number, fadeOutFrames: number) => EditAction;
49
+ /** `atS` is seconds INTO the clip's own source range (not output time). */
50
+ export declare const splitClip: (clipId: string, atS: number, newClipId: string) => EditAction;
51
+ /** `atS` is seconds into the audio clip's trimmed duration. */
52
+ export declare const splitAudioClip: (clipId: string, atS: number, newClipId: string) => EditAction;
53
+ /** Shifts a video clip's source window by `deltaS`; output span unchanged. */
54
+ export declare const slipClip: (clipId: string, deltaS: number) => EditAction;
55
+ /** Shifts an audio clip's trim window by `deltaS`; start/duration unchanged. */
56
+ export declare const slipAudioClip: (clipId: string, deltaS: number) => EditAction;
57
+ /** Excises the output range [startS, endS) on every video track it covers. */
58
+ export declare const rippleDeleteRange: (startS: number, endS: number) => EditAction;
59
+ export declare const setMute: (clipId: string, muted: boolean) => EditAction;
60
+ export declare const positionTitle: (clipId: string, x: number, y: number) => EditAction;
61
+ export declare const swapTake: (clipId: string, source: string, inS: number, outS: number) => EditAction;
62
+ export declare const editCaption: (cueId: string, correction: import("./model.js").CaptionCorrection) => EditAction;
63
+ export declare const removeCaption: (cueId: string) => EditAction;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Wire builders for `cutright_timeline::Action` — the serde shape the engine's
3
+ * `edit` method consumes (`{ "type": <snake_case>, ... }`). Field names and
4
+ * units match rightkit-timeline src/action.rs exactly:
5
+ * times are `Ticks` (705_600_000 per second), gains are `MilliDb`
6
+ * (thousandths of a dB), title spans are integer frames.
7
+ */
8
+ import { secondsToTicks } from "../lib/project.js";
9
+ export const insertClip = (trackId, index, clip) => ({ type: "insert_clip", track_id: trackId, index, clip });
10
+ export const removeClip = (clipId) => ({
11
+ type: "remove_clip",
12
+ clip_id: clipId,
13
+ });
14
+ export const trimClip = (clipId, sourceInS, sourceOutS) => ({
15
+ type: "trim_clip",
16
+ clip_id: clipId,
17
+ source_in: secondsToTicks(sourceInS),
18
+ source_out: secondsToTicks(sourceOutS),
19
+ });
20
+ export const moveClip = (clipId, toIndex) => ({
21
+ type: "move_clip",
22
+ clip_id: clipId,
23
+ to_index: toIndex,
24
+ });
25
+ export const moveAudioClip = (clipId, startS) => ({
26
+ type: "move_audio_clip",
27
+ clip_id: clipId,
28
+ start: secondsToTicks(startS),
29
+ });
30
+ export const trimAudioClip = (clipId, trimInS, trimOutS) => ({
31
+ type: "trim_audio_clip",
32
+ clip_id: clipId,
33
+ trim_in: secondsToTicks(trimInS),
34
+ trim_out: secondsToTicks(trimOutS),
35
+ });
36
+ export const removeAudioClip = (clipId) => ({
37
+ type: "remove_audio_clip",
38
+ clip_id: clipId,
39
+ });
40
+ /** `gainDb` is linear-reading decibels; the model stores thousandths (MilliDb). */
41
+ export const setGain = (clipId, gainDb) => ({
42
+ type: "set_gain",
43
+ clip_id: clipId,
44
+ gain: Math.round(gainDb * 1000),
45
+ });
46
+ export const linkNarration = (audioId, videoId) => ({
47
+ type: "link_narration",
48
+ audio_id: audioId,
49
+ video_id: videoId,
50
+ });
51
+ export const unlinkNarration = (audioId) => ({
52
+ type: "unlink_narration",
53
+ audio_id: audioId,
54
+ });
55
+ export const setUnder = (audioId, under) => ({
56
+ type: "set_under",
57
+ audio_id: audioId,
58
+ under,
59
+ });
60
+ export const addTitle = (trackId, clip) => ({ type: "add_title", track_id: trackId, clip });
61
+ export const removeTitle = (clipId) => ({ type: "remove_title", clip_id: clipId });
62
+ export const moveTitle = (clipId, startFrame) => ({
63
+ type: "move_title",
64
+ clip_id: clipId,
65
+ start_frame: Math.round(startFrame),
66
+ });
67
+ export const trimTitle = (clipId, startFrame, frames) => ({
68
+ type: "trim_title",
69
+ clip_id: clipId,
70
+ start_frame: Math.round(startFrame),
71
+ frames: Math.round(frames),
72
+ });
73
+ export const editTitle = (clipId, template, fadeInFrames, fadeOutFrames) => ({
74
+ type: "edit_title",
75
+ clip_id: clipId,
76
+ template,
77
+ fade_in_frames: Math.round(fadeInFrames),
78
+ fade_out_frames: Math.round(fadeOutFrames),
79
+ });
80
+ /** `atS` is seconds INTO the clip's own source range (not output time). */
81
+ export const splitClip = (clipId, atS, newClipId) => ({
82
+ type: "split_clip",
83
+ clip_id: clipId,
84
+ at: secondsToTicks(atS),
85
+ new_clip_id: newClipId,
86
+ });
87
+ /** `atS` is seconds into the audio clip's trimmed duration. */
88
+ export const splitAudioClip = (clipId, atS, newClipId) => ({
89
+ type: "split_audio_clip",
90
+ clip_id: clipId,
91
+ at: secondsToTicks(atS),
92
+ new_clip_id: newClipId,
93
+ });
94
+ /** Shifts a video clip's source window by `deltaS`; output span unchanged. */
95
+ export const slipClip = (clipId, deltaS) => ({
96
+ type: "slip_clip",
97
+ clip_id: clipId,
98
+ delta: secondsToTicks(deltaS),
99
+ });
100
+ /** Shifts an audio clip's trim window by `deltaS`; start/duration unchanged. */
101
+ export const slipAudioClip = (clipId, deltaS) => ({
102
+ type: "slip_audio_clip",
103
+ clip_id: clipId,
104
+ delta: secondsToTicks(deltaS),
105
+ });
106
+ /** Excises the output range [startS, endS) on every video track it covers. */
107
+ export const rippleDeleteRange = (startS, endS) => ({
108
+ type: "ripple_delete_range",
109
+ start: secondsToTicks(startS),
110
+ end: secondsToTicks(endS),
111
+ });
112
+ export const setMute = (clipId, muted) => ({ type: "set_mute", clip_id: clipId, muted });
113
+ export const positionTitle = (clipId, x, y) => ({
114
+ type: "position_title", clip_id: clipId, position: { x: Math.round(x), y: Math.round(y) },
115
+ });
116
+ export const swapTake = (clipId, source, inS, outS) => ({
117
+ type: "swap_take", clip_id: clipId, source, source_in: secondsToTicks(inS), source_out: secondsToTicks(outS),
118
+ });
119
+ export const editCaption = (cueId, correction) => ({
120
+ type: "edit_caption", cue_id: cueId, correction: {
121
+ text: correction.text, start_frame: correction.start_frame, frames: correction.frames, position: correction.position,
122
+ },
123
+ });
124
+ export const removeCaption = (cueId) => ({ type: "remove_caption", cue_id: cueId });