@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,76 @@
1
+ /**
2
+ * Revision-bound edit session against the canonical Rust timeline.
3
+ *
4
+ * The frontend owns only transient gesture state. On gesture release the
5
+ * session submits one transaction (one action, or a grouped list) stamped with
6
+ * the revision cursor the UI last saw. Undo/redo always pass `expect_cursor`
7
+ * (supported by the engine today). Edits pass `expect_cursor` only when the
8
+ * host advertises `edit.expect_cursor`; otherwise the session verifies the
9
+ * returned cursor advanced exactly as expected and reports a stale
10
+ * acknowledgement when it did not. Stale outcomes surface through `onStale`
11
+ * so the host refreshes `project.show` and rebuilds — never silently
12
+ * re-applied.
13
+ */
14
+ import type { CapabilityReport } from "./capabilities.js";
15
+ import type { EditAction } from "./actions.js";
16
+ export type RevisionInfo = {
17
+ cursor: number;
18
+ total: number;
19
+ can_undo: boolean;
20
+ can_redo: boolean;
21
+ };
22
+ export type EditResponse = {
23
+ status?: string;
24
+ seq?: number | null;
25
+ revision?: RevisionInfo;
26
+ /** undo/redo: false when expect_cursor showed the step already happened. */
27
+ changed?: boolean;
28
+ };
29
+ export type EngineCall = (method: string, params: Record<string, unknown>) => Promise<unknown>;
30
+ export type EditError = {
31
+ code?: string;
32
+ message?: string;
33
+ };
34
+ export declare class StaleEditError extends Error {
35
+ readonly expected: number;
36
+ readonly actual: number | null;
37
+ constructor(expected: number, actual: number | null);
38
+ }
39
+ export declare class EngineEditError extends Error {
40
+ readonly code: string;
41
+ constructor(code: string, message: string);
42
+ }
43
+ export declare function errorCode(e: unknown): string | undefined;
44
+ export declare function errorText(e: unknown): string;
45
+ export type ApplyResult = {
46
+ /** Actions recorded as new revisions (retries/no-ops excluded). */
47
+ applied: number;
48
+ cursor: number;
49
+ /** True when at least one action was already applied (idempotent retry). */
50
+ retried: boolean;
51
+ };
52
+ export declare class EditSession {
53
+ private readonly dir;
54
+ private readonly caps;
55
+ private readonly call;
56
+ private readonly onStale?;
57
+ private cursor;
58
+ private inFlight;
59
+ constructor(dir: string, revisionCursor: number, caps: CapabilityReport | null, call: EngineCall, onStale?: ((expected: number, actual: number | null) => void) | undefined);
60
+ get revisionCursor(): number;
61
+ get busy(): boolean;
62
+ /** Re-seat the session after the host refetches `project.show`. */
63
+ resync(revisionCursor: number): void;
64
+ private stale;
65
+ private checkAck;
66
+ /**
67
+ * Submit one action (or a gesture's grouped actions) as a single logical
68
+ * transaction. Without `edit.batch`, grouped actions are applied sequentially
69
+ * — each lands as its own engine revision; `applied` reports how many
70
+ * recorded. A failure aborts the rest and reports `applied` for the caller
71
+ * to display honestly.
72
+ */
73
+ apply(actions: EditAction | EditAction[], label?: string): Promise<ApplyResult>;
74
+ undo(): Promise<boolean>;
75
+ redo(): Promise<boolean>;
76
+ }
@@ -0,0 +1,181 @@
1
+ import { supports } from "./capabilities.js";
2
+ export class StaleEditError extends Error {
3
+ expected;
4
+ actual;
5
+ constructor(expected, actual) {
6
+ super(`timeline changed underneath the edit (expected revision ${expected}${actual == null ? "" : `, engine is at ${actual}`})`);
7
+ this.name = "StaleEditError";
8
+ this.expected = expected;
9
+ this.actual = actual;
10
+ }
11
+ }
12
+ export class EngineEditError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = "EngineEditError";
17
+ this.code = code;
18
+ }
19
+ }
20
+ export function errorCode(e) {
21
+ if (e && typeof e === "object" && "code" in e) {
22
+ return String(e.code);
23
+ }
24
+ return undefined;
25
+ }
26
+ export function errorText(e) {
27
+ if (e instanceof Error)
28
+ return e.message;
29
+ if (e && typeof e === "object" && "message" in e) {
30
+ return String(e.message);
31
+ }
32
+ return String(e);
33
+ }
34
+ function asEditResponse(v) {
35
+ return v && typeof v === "object" ? v : {};
36
+ }
37
+ export class EditSession {
38
+ dir;
39
+ caps;
40
+ call;
41
+ onStale;
42
+ cursor;
43
+ inFlight = 0;
44
+ constructor(dir, revisionCursor, caps, call, onStale) {
45
+ this.dir = dir;
46
+ this.caps = caps;
47
+ this.call = call;
48
+ this.onStale = onStale;
49
+ this.cursor = revisionCursor;
50
+ }
51
+ get revisionCursor() {
52
+ return this.cursor;
53
+ }
54
+ get busy() {
55
+ return this.inFlight > 0;
56
+ }
57
+ /** Re-seat the session after the host refetches `project.show`. */
58
+ resync(revisionCursor) {
59
+ this.cursor = revisionCursor;
60
+ }
61
+ stale(expected, actual) {
62
+ this.onStale?.(expected, actual);
63
+ throw new StaleEditError(expected, actual);
64
+ }
65
+ checkAck(expectedBefore, res) {
66
+ const next = res.revision?.cursor;
67
+ if (next == null)
68
+ return; // engine did not report a cursor; nothing to verify
69
+ if (res.status === "already_applied" || res.status === "no_change") {
70
+ if (next !== expectedBefore)
71
+ this.stale(expectedBefore, next);
72
+ return;
73
+ }
74
+ if (next !== expectedBefore + 1)
75
+ this.stale(expectedBefore + 1, next);
76
+ }
77
+ /**
78
+ * Submit one action (or a gesture's grouped actions) as a single logical
79
+ * transaction. Without `edit.batch`, grouped actions are applied sequentially
80
+ * — each lands as its own engine revision; `applied` reports how many
81
+ * recorded. A failure aborts the rest and reports `applied` for the caller
82
+ * to display honestly.
83
+ */
84
+ async apply(actions, label) {
85
+ const list = Array.isArray(actions) ? actions : [actions];
86
+ if (list.length === 0)
87
+ return { applied: 0, cursor: this.cursor, retried: false };
88
+ this.inFlight += 1;
89
+ try {
90
+ if (list.length > 1 && supports("edit.batch", this.caps)) {
91
+ const params = {
92
+ dir: this.dir,
93
+ actions: list,
94
+ label: label ?? null,
95
+ };
96
+ if (supports("edit.expect_cursor", this.caps))
97
+ params.expect_cursor = this.cursor;
98
+ const res = asEditResponse(await this.call("edit", params));
99
+ // A batch records at most one revision: cursor advances by 0 or 1.
100
+ const next = res.revision?.cursor;
101
+ if (next != null && next !== this.cursor && next !== this.cursor + 1) {
102
+ this.stale(this.cursor + 1, next);
103
+ }
104
+ const recorded = next === this.cursor + 1;
105
+ if (next != null)
106
+ this.cursor = next;
107
+ return { applied: recorded ? list.length : 0, cursor: this.cursor, retried: !recorded };
108
+ }
109
+ let applied = 0;
110
+ let retried = false;
111
+ for (const action of list) {
112
+ const before = this.cursor;
113
+ const params = { dir: this.dir, action };
114
+ if (supports("edit.expect_cursor", this.caps))
115
+ params.expect_cursor = before;
116
+ let res;
117
+ try {
118
+ res = asEditResponse(await this.call("edit", params));
119
+ }
120
+ catch (e) {
121
+ if (errorCode(e) === "cursor_mismatch") {
122
+ const actual = e && typeof e === "object" && "actual" in e
123
+ ? Number(e.actual)
124
+ : null;
125
+ this.stale(before, actual);
126
+ }
127
+ throw e;
128
+ }
129
+ this.checkAck(before, res);
130
+ if (res.status === "already_applied" || res.status === "no_change") {
131
+ retried = true;
132
+ }
133
+ else {
134
+ applied += 1;
135
+ if (res.revision)
136
+ this.cursor = res.revision.cursor;
137
+ }
138
+ }
139
+ return { applied, cursor: this.cursor, retried };
140
+ }
141
+ finally {
142
+ this.inFlight -= 1;
143
+ }
144
+ }
145
+ async undo() {
146
+ this.inFlight += 1;
147
+ try {
148
+ const res = asEditResponse(await this.call("undo", { dir: this.dir, expect_cursor: this.cursor }));
149
+ const next = res.revision?.cursor;
150
+ if (next != null)
151
+ this.cursor = next;
152
+ return res.status !== "unchanged" && res.status !== "noop" && res.changed !== false;
153
+ }
154
+ catch (e) {
155
+ if (errorCode(e) === "cursor_mismatch")
156
+ this.stale(this.cursor, null);
157
+ throw e;
158
+ }
159
+ finally {
160
+ this.inFlight -= 1;
161
+ }
162
+ }
163
+ async redo() {
164
+ this.inFlight += 1;
165
+ try {
166
+ const res = asEditResponse(await this.call("redo", { dir: this.dir, expect_cursor: this.cursor }));
167
+ const next = res.revision?.cursor;
168
+ if (next != null)
169
+ this.cursor = next;
170
+ return res.status !== "unchanged" && res.status !== "noop" && res.changed !== false;
171
+ }
172
+ catch (e) {
173
+ if (errorCode(e) === "cursor_mismatch")
174
+ this.stale(this.cursor, null);
175
+ throw e;
176
+ }
177
+ finally {
178
+ this.inFlight -= 1;
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Explicit capability gating between the editor gestures and what the live
3
+ * engine actually accepts. Every gesture resolves through `supports()`;
4
+ * unsupported controls render inert with a reason, never toast-only success.
5
+ *
6
+ * `extensions` is the optional list of negotiated engine extensions that the
7
+ * engine advertises (rightkit-video-project, project.rs). A capability
8
+ * opens when its extension id is advertised, with no frontend change.
9
+ */
10
+ export type EditorCapability =
11
+ /** Single-action `edit` with an `expect_cursor` optimistic guard. Needs engine hunk `edit-expect-cursor`. */
12
+ "edit.expect_cursor"
13
+ /** One revision for a multi-action gesture. Needs engine hunk `edit.batch`. */
14
+ | "edit.batch"
15
+ /** `split_clip` action on video/audio clips. Needs timeline hunk `action-split`. */
16
+ | "edit.split"
17
+ /** Slip (move source range, keep output span). Needs timeline hunk `action-slip`. */
18
+ | "edit.slip"
19
+ /** `ripple_delete_range` excises an output range across video tracks. */
20
+ | "edit.ripple_delete"
21
+ /** Per-source decoded peak data for waveforms. Needs engine hunk `media.waveform`. */
22
+ | "media.waveform"
23
+ /** Caption-track editing on the timeline. Needs caption contract work. */
24
+ | "edit.captions"
25
+ /** Revision-bound rendered/native preview frames. Host-provided callback. */
26
+ | "preview.frames";
27
+ export type CapabilityReport = {
28
+ /** Extensions the engine advertised (e.g. via `project.show` extension). */
29
+ extensions?: readonly string[];
30
+ };
31
+ export declare function supports(cap: EditorCapability, report?: CapabilityReport | null): boolean;
32
+ export type UnsupportedReason = {
33
+ capability: EditorCapability;
34
+ reason: string;
35
+ };
36
+ export declare function unsupported(capability: EditorCapability): UnsupportedReason;
37
+ export declare function reportCapabilities(extensions?: readonly string[]): CapabilityReport;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Explicit capability gating between the editor gestures and what the live
3
+ * engine actually accepts. Every gesture resolves through `supports()`;
4
+ * unsupported controls render inert with a reason, never toast-only success.
5
+ *
6
+ * `extensions` is the optional list of negotiated engine extensions that the
7
+ * engine advertises (rightkit-video-project, project.rs). A capability
8
+ * opens when its extension id is advertised, with no frontend change.
9
+ */
10
+ const ALWAYS_SUPPORTED = new Set([]);
11
+ export function supports(cap, report) {
12
+ if (ALWAYS_SUPPORTED.has(cap))
13
+ return true;
14
+ return report?.extensions?.includes(cap) ?? false;
15
+ }
16
+ const REASONS = {
17
+ "edit.expect_cursor": "Engine does not yet guard edits by revision cursor",
18
+ "edit.batch": "Engine applies one action per revision; grouped undo needs an engine update",
19
+ "edit.split": "Clip split is not yet a timeline action",
20
+ "edit.slip": "Slip editing is not yet a timeline action",
21
+ "edit.ripple_delete": "Range ripple-delete is not yet a timeline action",
22
+ "media.waveform": "No waveform data endpoint on the engine yet",
23
+ "edit.captions": "Caption-track editing is not yet on the timeline contract",
24
+ "preview.frames": "No revision-bound preview host is connected",
25
+ };
26
+ export function unsupported(capability) {
27
+ return { capability, reason: REASONS[capability] };
28
+ }
29
+ // Compatibility shim for the in-flight editor refactor: builds a report from
30
+ // the engine-advertised extension ids.
31
+ export function reportCapabilities(extensions) {
32
+ return { extensions };
33
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure gesture → action planning. Pointer handlers report transient deltas
3
+ * here; on release the planned actions go through `EditSession.apply` as one
4
+ * transaction. Nothing here mutates the canonical timeline — the returned
5
+ * `EditAction`s are proposals the engine validates.
6
+ */
7
+ import type { EditAction } from "./actions.js";
8
+ import type { EditorClip, EditorModel } from "./model.js";
9
+ export type Edge = "in" | "out";
10
+ /** Snap `t` to the nearest grid step, or to a clip edge within `threshold` s. */
11
+ export declare function snapTime(t: number, gridStep: number, edges: readonly number[], threshold: number): number;
12
+ export declare function clipEdges(model: EditorModel, exclude: ReadonlySet<string>): number[];
13
+ /**
14
+ * Video tracks are gapless: a horizontal drag is a reorder, never a free
15
+ * position. Returns the index the dragged clip occupies once removed, from the
16
+ * lane position of `dropTime`.
17
+ */
18
+ export declare function videoDropIndex(lane: {
19
+ clips: EditorClip[];
20
+ }, clipId: string, dropTime: number): number;
21
+ /** Plan a completed move gesture for one clip. */
22
+ export declare function planMove(model: EditorModel, clip: EditorClip, target: {
23
+ time?: number;
24
+ index?: number;
25
+ }): EditAction[];
26
+ /** Plan a completed multi-clip move: one action per clip (grouped transaction). */
27
+ export declare function planMoveMany(model: EditorModel, clips: readonly EditorClip[], deltaS: number): EditAction[];
28
+ /** Plan a completed edge trim. Returns [] when the trim is a no-op. */
29
+ export declare function planTrim(model: EditorModel, clip: EditorClip, edge: Edge, deltaS: number): EditAction[];
@@ -0,0 +1,119 @@
1
+ import { moveAudioClip, moveClip, moveTitle, trimAudioClip, trimClip, trimTitle, } from "./actions.js";
2
+ /** Snap `t` to the nearest grid step, or to a clip edge within `threshold` s. */
3
+ export function snapTime(t, gridStep, edges, threshold) {
4
+ let best = gridStep > 0 ? Math.round(t / gridStep) * gridStep : t;
5
+ let bestDist = Math.abs(best - t);
6
+ for (const e of edges) {
7
+ const d = Math.abs(e - t);
8
+ if (d <= threshold && d < bestDist) {
9
+ best = e;
10
+ bestDist = d;
11
+ }
12
+ }
13
+ return Math.max(0, best);
14
+ }
15
+ export function clipEdges(model, exclude) {
16
+ const out = [0];
17
+ for (const lane of model.lanes) {
18
+ for (const c of lane.clips) {
19
+ if (exclude.has(c.id))
20
+ continue;
21
+ out.push(c.start, c.start + c.duration);
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+ /**
27
+ * Video tracks are gapless: a horizontal drag is a reorder, never a free
28
+ * position. Returns the index the dragged clip occupies once removed, from the
29
+ * lane position of `dropTime`.
30
+ */
31
+ export function videoDropIndex(lane, clipId, dropTime) {
32
+ const clips = lane.clips.filter((c) => c.id !== clipId);
33
+ const idx = clips.findIndex((c) => dropTime < c.start + c.duration / 2);
34
+ return idx < 0 ? clips.length : idx;
35
+ }
36
+ /** Plan a completed move gesture for one clip. */
37
+ export function planMove(model, clip, target) {
38
+ const lane = model.lanes.find((l) => l.id === clip.ref.trackId);
39
+ switch (clip.ref.kind) {
40
+ case "video": {
41
+ const index = target.index ??
42
+ (lane && target.time != null ? videoDropIndex(lane, clip.id, target.time) : clip.ref.index);
43
+ if (index === clip.ref.index)
44
+ return [];
45
+ return [moveClip(clip.id, index)];
46
+ }
47
+ case "audio": {
48
+ if (target.time == null)
49
+ return [];
50
+ return [moveAudioClip(clip.id, target.time)];
51
+ }
52
+ case "title": {
53
+ if (target.time == null)
54
+ return [];
55
+ return [moveTitle(clip.id, target.time * model.fps)];
56
+ }
57
+ }
58
+ }
59
+ /** Plan a completed multi-clip move: one action per clip (grouped transaction). */
60
+ export function planMoveMany(model, clips, deltaS) {
61
+ const out = [];
62
+ for (const c of clips) {
63
+ if (c.ref.kind === "audio") {
64
+ out.push(...planMove(model, c, { time: Math.max(0, c.start + deltaS) }));
65
+ }
66
+ else if (c.ref.kind === "title") {
67
+ out.push(...planMove(model, c, { time: Math.max(0, c.start + deltaS) }));
68
+ }
69
+ // Video clips ignore pure time deltas — gapless track; reorder via drop index.
70
+ }
71
+ return out;
72
+ }
73
+ /** Plan a completed edge trim. Returns [] when the trim is a no-op. */
74
+ export function planTrim(model, clip, edge, deltaS) {
75
+ if (deltaS === 0 || clip.duration <= 0)
76
+ return [];
77
+ switch (clip.ref.kind) {
78
+ case "video": {
79
+ const inS = clip.ref.clip.source_in / TICKS;
80
+ const outS = clip.ref.clip.source_out / TICKS;
81
+ const sourceDur = sourceDuration(model, clip.ref.clip.source);
82
+ const [lo, hi] = edge === "in"
83
+ ? [Math.max(0, Math.min(inS + deltaS, outS - MIN_CLIP_S)), outS]
84
+ : [inS, Math.min(sourceDur ?? Infinity, Math.max(outS + deltaS, inS + MIN_CLIP_S))];
85
+ if (lo === inS && hi === outS)
86
+ return [];
87
+ return [trimClip(clip.id, lo, hi)];
88
+ }
89
+ case "audio": {
90
+ const inS = clip.ref.clip.trim_in / TICKS;
91
+ const outS = clip.ref.clip.trim_out / TICKS;
92
+ const sourceDur = sourceDuration(model, clip.ref.clip.source);
93
+ const [lo, hi] = edge === "in"
94
+ ? [Math.max(0, Math.min(inS + deltaS, outS - MIN_CLIP_S)), outS]
95
+ : [inS, Math.min(sourceDur ?? Infinity, Math.max(outS + deltaS, inS + MIN_CLIP_S))];
96
+ if (lo === inS && hi === outS)
97
+ return [];
98
+ return [trimAudioClip(clip.id, lo, hi)];
99
+ }
100
+ case "title": {
101
+ const startF = clip.ref.clip.start_frame;
102
+ const frames = clip.ref.clip.frames;
103
+ const d = Math.round(deltaS * model.fps);
104
+ const [sf, fr] = edge === "in"
105
+ ? [startF + d, frames - d]
106
+ : [startF, frames + d];
107
+ if (fr < 1)
108
+ return [];
109
+ if (sf === startF && fr === frames)
110
+ return [];
111
+ return [trimTitle(clip.id, sf, fr)];
112
+ }
113
+ }
114
+ }
115
+ const TICKS = 705_600_000;
116
+ const MIN_CLIP_S = 1 / 60;
117
+ function sourceDuration(model, sourceId) {
118
+ return model.sourceDurationS.get(sourceId) ?? null;
119
+ }
@@ -0,0 +1,10 @@
1
+ export { TimelineEditorView, type PreviewHost } from "./TimelineEditorView.js";
2
+ export { Timeline } from "./Timeline.js";
3
+ export { Inspector } from "./Inspector.js";
4
+ export { EditSession, StaleEditError, EngineEditError } from "./adapter.js";
5
+ export type { EditAction } from "./actions.js";
6
+ export * as actions from "./actions.js";
7
+ export { buildEditorModel, findClip } from "./model.js";
8
+ export type { EditorModel, EditorClip, EditorLane } from "./model.js";
9
+ export { supports, unsupported } from "./capabilities.js";
10
+ export type { EditorCapability, CapabilityReport } from "./capabilities.js";
@@ -0,0 +1,7 @@
1
+ export { TimelineEditorView } from "./TimelineEditorView.js";
2
+ export { Timeline } from "./Timeline.js";
3
+ export { Inspector } from "./Inspector.js";
4
+ export { EditSession, StaleEditError, EngineEditError } from "./adapter.js";
5
+ export * as actions from "./actions.js";
6
+ export { buildEditorModel, findClip } from "./model.js";
7
+ export { supports, unsupported } from "./capabilities.js";
@@ -0,0 +1,15 @@
1
+ import { type RefObject } from "react";
2
+ import type { ShortcutDef } from "@rightkit/react-shell";
3
+ /**
4
+ * Editor key bindings. The chords and labels are authored once here and registered on the
5
+ * shell's shortcut registry (by DesktopWorkspace), so Settings and the shortcut help list the
6
+ * real bindings. The timeline that is on screen supplies the actions through
7
+ * `useEditorKeys`; with none mounted, or focus outside it, a press is left to the page.
8
+ */
9
+ export type EditorKeyId = "undo" | "redo" | "redoY" | "stepBack" | "stepForward" | "shuttleBack" | "pause" | "shuttleForward" | "remove" | "split";
10
+ /** An action returns `false` when it did not handle the key, so the page default still runs. */
11
+ export type EditorKeyHandlers = Partial<Record<EditorKeyId, (event: KeyboardEvent) => void | boolean>>;
12
+ /** The registry definitions for the editor keys (register once, at the workspace). */
13
+ export declare function editorShortcuts(): ShortcutDef[];
14
+ /** Makes this editor the one the keys act on while focus is inside `scope`. */
15
+ export declare function useEditorKeys(scope: RefObject<HTMLElement | null>, handlers: EditorKeyHandlers): void;
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from "react";
2
+ const BINDINGS = [
3
+ { id: "undo", chord: "mod+z", label: "Undo" },
4
+ { id: "redo", chord: "shift+mod+z", label: "Redo" },
5
+ { id: "redoY", chord: "mod+y", label: "Redo", hidden: true },
6
+ { id: "stepBack", chord: "left", label: "Step back one frame" },
7
+ { id: "stepForward", chord: "right", label: "Step forward one frame" },
8
+ { id: "shuttleBack", chord: "j", label: "Shuttle back" },
9
+ { id: "pause", chord: "k", label: "Stop shuttle" },
10
+ { id: "shuttleForward", chord: "l", label: "Shuttle forward" },
11
+ { id: "remove", chord: ["backspace", "delete"], label: "Ripple-delete selection" },
12
+ { id: "split", chord: "s", label: "Split at playhead" },
13
+ ];
14
+ let active = null;
15
+ /** The registry definitions for the editor keys (register once, at the workspace). */
16
+ export function editorShortcuts() {
17
+ return BINDINGS.map(({ id, chord, label, hidden }) => ({
18
+ id: `cr.editor.${id}`,
19
+ chord,
20
+ label,
21
+ group: "Editor",
22
+ hidden,
23
+ when: () => active !== null && active.inScope() && active.handlers()[id] !== undefined,
24
+ run: (event) => active?.handlers()[id]?.(event),
25
+ }));
26
+ }
27
+ /** Makes this editor the one the keys act on while focus is inside `scope`. */
28
+ export function useEditorKeys(scope, handlers) {
29
+ const latest = useRef(handlers);
30
+ latest.current = handlers;
31
+ useEffect(() => {
32
+ const entry = {
33
+ handlers: () => latest.current,
34
+ inScope: () => scope.current?.contains(document.activeElement) ?? false,
35
+ };
36
+ active = entry;
37
+ return () => {
38
+ if (active === entry)
39
+ active = null;
40
+ };
41
+ }, [scope]);
42
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Read model: folds `project.show` (`ProjectShow`) into flat editor lanes with
3
+ * second-based geometry. Rust owns canonical ticks/frames — this model only
4
+ * translates for display and gesture maths, and converts back on commit.
5
+ */
6
+ import { type AudioClip, type OverlayClip, type ProjectShow, type VideoClip } from "../lib/project.js";
7
+ export type LaneKind = "video" | "audio" | "overlay";
8
+ export type EditorClipRef = {
9
+ kind: "video";
10
+ clip: VideoClip;
11
+ trackId: string;
12
+ index: number;
13
+ } | {
14
+ kind: "audio";
15
+ clip: AudioClip;
16
+ trackId: string;
17
+ role: string;
18
+ } | {
19
+ kind: "title";
20
+ clip: OverlayClip;
21
+ trackId: string;
22
+ };
23
+ export type EditorClip = {
24
+ id: string;
25
+ kind: LaneKind;
26
+ /** Timeline start in seconds. */
27
+ start: number;
28
+ /** Timeline duration in seconds. */
29
+ duration: number;
30
+ label: string;
31
+ ref: EditorClipRef;
32
+ /** Audio clip linked narration target (video clip id). */
33
+ link?: string | null;
34
+ };
35
+ export type EditorLane = {
36
+ id: string;
37
+ kind: LaneKind;
38
+ /** e.g. "V1", "Narr", "Music", "Titles". */
39
+ label: string;
40
+ clips: EditorClip[];
41
+ };
42
+ export type TextPosition = {
43
+ x: number;
44
+ y: number;
45
+ };
46
+ export type CaptionCorrection = {
47
+ text: string;
48
+ start_frame: number;
49
+ frames: number;
50
+ position: TextPosition;
51
+ };
52
+ export type EditorCaption = CaptionCorrection & {
53
+ id: string;
54
+ original_text: string;
55
+ corrected?: boolean;
56
+ };
57
+ export type EditorCorrections = {
58
+ captions: Record<string, CaptionCorrection>;
59
+ positions: Record<string, TextPosition>;
60
+ audio: Record<string, {
61
+ gain: number;
62
+ muted: boolean;
63
+ }>;
64
+ };
65
+ export type CorrectionShow = ProjectShow & {
66
+ editor_captions?: EditorCaption[];
67
+ timeline: ProjectShow["timeline"] & {
68
+ corrections?: EditorCorrections;
69
+ };
70
+ };
71
+ export type EditorModel = {
72
+ corrections: EditorCorrections;
73
+ captions: EditorCaption[];
74
+ sources: ProjectShow["sources"];
75
+ fps: number;
76
+ durationS: number;
77
+ revisionCursor: number;
78
+ revisionTotal: number;
79
+ canUndo: boolean;
80
+ canRedo: boolean;
81
+ lanes: EditorLane[];
82
+ clipCount: number;
83
+ /** Source id → duration in seconds (from registered source info ticks). */
84
+ sourceDurationS: ReadonlyMap<string, number>;
85
+ };
86
+ export declare function buildEditorModel(show: ProjectShow): EditorModel;
87
+ export declare function findClip(model: EditorModel, id: string): EditorClip | null;
88
+ export declare const buildModel: typeof buildEditorModel;