@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,452 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Button, Card, Checkbox, EmptyState, Field, PageShell, SegmentedControl, Select, Slider, Toggle, useToast } from "@rightkit/react-shell/react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { engineCall, onEngineRaw, errorCode, errorMessage } from "../lib/engine.js";
5
+ import { assetUrl } from "../lib/asset.js";
6
+ import { interpretAssetLibrary } from "../lib/genright.js";
7
+ import { pickFile } from "../lib/dialog.js";
8
+ import { baseName, projectShow } from "../lib/project.js";
9
+ import { CAPTION_STYLES, DEFAULT_CAPTION_STYLE, captionsPreview, modelsStatus, recipeCancel, recipeRun, recipeStatus, reframeAnalyze, reframeApprove, reframeShow, framingApproval, framingLabel, settingsGet, settingsSet, sourceList, thumbnails, } from "../lib/media.js";
10
+ import { Drawer, ErrorPanel, ProgressRow, Thumbnail, timecode, Icon } from "../ui/index.js";
11
+ import { RecipeLibrary } from "./RecipeLibrary.js";
12
+ import { HDR_COLOR_PRESETS, HDR_UNAVAILABLE_NOTICE, useHdrToSdr } from "../hooks/useHdrToSdr.js";
13
+ const STYLE_DESC = {
14
+ natural: "Keeps the room to breathe — removes only mistakes and dead air.",
15
+ tight: "Cuts pauses, fillers and retakes for a brisk, conversational pace.",
16
+ punchy: "Aggressive trims and fast beat changes for short-form energy.",
17
+ };
18
+ const FORMATS = ["16:9", "9:16", "1:1"];
19
+ /**
20
+ * The app's default music bed level (dB) for a recipe that has none saved. The engine's
21
+ * fallback for a recipe with no `music_level` is also -24 dB (recipe_cmd.rs, `unwrap_or(-24.0)`),
22
+ * so the app and engine defaults match (#75).
23
+ */
24
+ const DEFAULT_MUSIC_LEVEL_DB = -24;
25
+ const COLOR_OPTS = [
26
+ ["", "None"],
27
+ ["neutral", "Neutral Rec.709"],
28
+ ["hdr-natural", "HDR natural"],
29
+ ["hdr-soft", "HDR soft"],
30
+ ["apple-log-natural", "Apple Log natural"],
31
+ ["talking-head-auto", "Talking head (auto)"],
32
+ ["warm", "Warm"],
33
+ ["cool", "Cool"],
34
+ ["lift", "Lift"],
35
+ ["custom", "Custom LUT (.cube)"],
36
+ ];
37
+ /** Colour options; with HDR to SDR unavailable the HDR presets stay selectable but say so. */
38
+ const colourOpts = (hdr) => COLOR_OPTS.map(([v, l]) => hdr === false && HDR_COLOR_PRESETS.includes(v) ? [v, `${l} — not available on this computer`] : [v, l]);
39
+ const AUDIO_OPTS = [
40
+ ["", "None"],
41
+ ["talking-head", "Talking head"],
42
+ ["podcast", "Podcast"],
43
+ ["noisy-room", "Noisy room"],
44
+ ];
45
+ const STAGE_LABELS = [
46
+ ["transcribe", "Transcribe speech"],
47
+ ["analyse", "Analyse visuals"],
48
+ ["takes", "Choose best takes"],
49
+ ["cut", "Cut"],
50
+ ["captions", "Captions"],
51
+ ["framing", "Framing"],
52
+ ["colour", "Colour"],
53
+ ["audio", "Audio"],
54
+ ["music", "Music"],
55
+ ];
56
+ function anchorViews(plan) {
57
+ const p = (plan.plan ?? {});
58
+ const anchors = p.anchors ?? [];
59
+ const keyframes = p.keyframes ?? [];
60
+ const [sw, sh] = [p.source_width ?? 0, p.source_height ?? 0];
61
+ const [cw, ch] = plan.crop ?? [0, 0];
62
+ const out = [];
63
+ const step = Math.max(1, Math.floor(anchors.length / 6) || 1);
64
+ for (let i = 0; i < anchors.length && out.length < 6; i += step) {
65
+ const a = anchors[i];
66
+ const mid = Math.round(((a.start_ms ?? 0) + (a.end_ms ?? 0)) / 2);
67
+ let k = keyframes[0];
68
+ for (const cand of keyframes) {
69
+ if (Math.abs((cand.t_ms ?? 0) - mid) < Math.abs((k?.t_ms ?? 0) - mid))
70
+ k = cand;
71
+ }
72
+ out.push({
73
+ mid_ms: mid,
74
+ approved: a.approved === true,
75
+ crop: {
76
+ x: sw ? (k?.x ?? 0) / sw : 0,
77
+ y: sh ? (k?.y ?? 0) / sh : 0,
78
+ w: sw ? cw / sw : 0,
79
+ h: sh ? ch / sh : 0,
80
+ },
81
+ });
82
+ }
83
+ return out;
84
+ }
85
+ /** §3.3 Edit recipe: persisted form + recipe.run stage panel + framing review drawer. */
86
+ const SWATCH_ASPECT = { youtube: "16 / 9", reels: "9 / 16", square: "1 / 1" };
87
+ export function AutoEditView({ dir, onChanged, onNavigate, }) {
88
+ const toast = useToast();
89
+ const hdr = useHdrToSdr();
90
+ const [sources, setSources] = useState([]);
91
+ const [show, setShow] = useState(null);
92
+ const [recipe, setRecipe] = useState(null);
93
+ const [thumbs, setThumbs] = useState({});
94
+ const [plans, setPlans] = useState({});
95
+ const [autoFraming, setAutoFraming] = useState(false);
96
+ const [framingFor, setFramingFor] = useState(null);
97
+ const [music, setMusic] = useState([]);
98
+ const [speechReady, setSpeechReady] = useState(null);
99
+ const [swatch, setSwatch] = useState(null);
100
+ const [stages, setStages] = useState({});
101
+ const [running, setRunning] = useState(null); // job id
102
+ const [runError, setRunError] = useState(null);
103
+ const [lastRun, setLastRun] = useState(null);
104
+ const [error, setError] = useState(null);
105
+ const runTag = useRef(null);
106
+ const persistTimer = useRef(null);
107
+ // The recipe object produced by the user's last edit on this screen (see `patch`). Only that object is saved.
108
+ const userRecipe = useRef(null);
109
+ // The project the last user edit was made in: an edit is never written into another project.
110
+ const userRecipeDir = useRef(null);
111
+ // `reloadRecipe` re-reads the recipe from project settings (after a library apply) instead of keeping local edits.
112
+ const refresh = useCallback(async (reloadRecipe = false) => {
113
+ const [list, proj, settings, models, status] = await Promise.all([
114
+ sourceList(dir),
115
+ projectShow(dir).catch(() => null),
116
+ settingsGet(dir).catch(() => null),
117
+ modelsStatus().catch(() => null),
118
+ recipeStatus(dir).catch(() => null),
119
+ ]);
120
+ setAutoFraming(settings ? framingApproval(settings) === "auto" : false);
121
+ setPlans({});
122
+ setSources(list.sources);
123
+ setShow(proj);
124
+ setSpeechReady(models ? (models.speech.ready ?? null) : null);
125
+ setLastRun(status);
126
+ const saved = (settings?.project?.recipe ?? {});
127
+ setRecipe((cur) => {
128
+ if (cur && !reloadRecipe)
129
+ return cur;
130
+ const all = list.sources.map((s) => s.id);
131
+ return {
132
+ style: saved.style ?? settings?.project?.cut_preset ?? "natural",
133
+ formats: saved.formats?.length ? saved.formats : ["16:9"],
134
+ captions: {
135
+ on: saved.captions?.on ?? (settings?.project?.burn_captions !== false),
136
+ style: saved.captions?.style ?? DEFAULT_CAPTION_STYLE,
137
+ },
138
+ finish: {
139
+ color: saved.finish?.color ?? settings?.project?.color_preset ?? "neutral",
140
+ audio: saved.finish?.audio ?? settings?.project?.audio_preset ?? "talking-head",
141
+ music: saved.finish?.music ?? null,
142
+ music_level: saved.finish?.music_level ?? DEFAULT_MUSIC_LEVEL_DB,
143
+ lut_path: saved.finish?.lut_path ?? null,
144
+ lut_intensity: saved.finish?.lut_intensity ?? 100,
145
+ match_shots: saved.finish?.match_shots ?? true,
146
+ },
147
+ sources: saved.sources?.length ? saved.sources : all,
148
+ };
149
+ });
150
+ }, [dir]);
151
+ useEffect(() => {
152
+ refresh().catch((e) => setError(errorMessage(e)));
153
+ }, [refresh]);
154
+ // Called after a library recipe is applied: the form adopts the project's new recipe settings.
155
+ const reloadSettings = useCallback(() => {
156
+ refresh(true).catch((e) => setError(errorMessage(e)));
157
+ }, [refresh]);
158
+ // Persist the recipe into project settings.json under "recipe". Loads (first load, refresh, library
159
+ // reload) replace `recipe` with an object that is not `userRecipe`, so they are not written back.
160
+ useEffect(() => {
161
+ if (!recipe || recipe !== userRecipe.current || userRecipeDir.current !== dir)
162
+ return;
163
+ if (persistTimer.current)
164
+ clearTimeout(persistTimer.current);
165
+ persistTimer.current = setTimeout(() => {
166
+ settingsSet({ recipe }, dir).catch((e) => setError(errorMessage(e)));
167
+ }, 400);
168
+ return () => {
169
+ if (persistTimer.current)
170
+ clearTimeout(persistTimer.current);
171
+ };
172
+ }, [recipe, dir]);
173
+ // Card thumbnails for framing/source rows.
174
+ useEffect(() => {
175
+ for (const s of sources) {
176
+ if (thumbs[s.id] !== undefined)
177
+ continue;
178
+ void thumbnails(dir, { source: s.id }, { width: 240 })
179
+ .then((t) => {
180
+ const f = t.frames[0];
181
+ if (f)
182
+ setThumbs((m) => ({ ...m, [s.id]: assetUrl(f.path) }));
183
+ })
184
+ .catch(() => setThumbs((m) => ({ ...m, [s.id]: "" })));
185
+ }
186
+ }, [sources, dir, thumbs]);
187
+ // Music library assets for the Finish card.
188
+ useEffect(() => {
189
+ engineCall("assets.list", { dir })
190
+ .then((r) => setMusic(interpretAssetLibrary(r).assets.filter((a) => a.kind === "music" && a.exists)))
191
+ .catch(() => setMusic([]));
192
+ }, [dir]);
193
+ // R3: real caption-style swatch — the engine renders a styled PNG on a real frame.
194
+ const swatchPreset = recipe?.formats.includes("9:16") || recipe?.formats.includes("1:1")
195
+ ? recipe.formats.includes("1:1") && !recipe.formats.includes("9:16")
196
+ ? "square"
197
+ : "reels"
198
+ : "youtube";
199
+ useEffect(() => {
200
+ if (!recipe?.captions.on || sources.length === 0) {
201
+ setSwatch(null);
202
+ return;
203
+ }
204
+ let stale = false;
205
+ setSwatch("loading");
206
+ captionsPreview(dir, {
207
+ style: recipe.captions.style,
208
+ preset: swatchPreset,
209
+ source: sources.find((s) => recipe.sources.includes(s.id))?.id,
210
+ })
211
+ .then((r) => {
212
+ if (!stale)
213
+ setSwatch(r);
214
+ })
215
+ .catch(() => {
216
+ if (!stale)
217
+ setSwatch("error");
218
+ });
219
+ return () => {
220
+ stale = true;
221
+ };
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [dir, recipe?.captions.on, recipe?.captions.style, swatchPreset, sources.length]);
224
+ // recipe.stage events for this run's tag.
225
+ useEffect(() => {
226
+ let off;
227
+ let cancelled = false;
228
+ void onEngineRaw((payload) => {
229
+ const frame = payload;
230
+ if (frame.event?.event !== "recipe.stage")
231
+ return;
232
+ if (runTag.current && frame.tag !== runTag.current)
233
+ return;
234
+ const ev = frame.event;
235
+ setStages((m) => ({
236
+ ...m,
237
+ [ev.stage]: { state: ev.state, fraction: ev.fraction, error: ev.error },
238
+ }));
239
+ }).then((f) => {
240
+ if (cancelled)
241
+ f();
242
+ else
243
+ off = f;
244
+ });
245
+ return () => {
246
+ cancelled = true;
247
+ off?.();
248
+ };
249
+ }, []);
250
+ const selectedSources = useMemo(() => sources.filter((s) => recipe?.sources.includes(s.id)), [sources, recipe]);
251
+ // R2: every (source × format) pair gets its own plan — plans keyed "id|aspect".
252
+ const reframeAspects = useMemo(() => (recipe?.formats ?? []).filter((f) => f !== "16:9"), [recipe]);
253
+ const needsReframe = useCallback((s, aspect) => {
254
+ const v = show?.timeline.sources.find((t) => t.id === s.id)?.video;
255
+ if (!v)
256
+ return false;
257
+ const [aw, ah] = aspect.split(":").map(Number);
258
+ const target = aw / ah;
259
+ return Math.abs(v.width / v.height / target - 1) >= 0.05;
260
+ }, [show]);
261
+ const planKey = (id, aspect) => `${id}|${aspect}`;
262
+ // Reframe approval state for each (source × aspect) that needs it.
263
+ useEffect(() => {
264
+ for (const s of sources) {
265
+ for (const aspect of reframeAspects) {
266
+ const k = planKey(s.id, aspect);
267
+ if (!needsReframe(s, aspect) || plans[k] !== undefined)
268
+ continue;
269
+ setPlans((m) => ({ ...m, [k]: "none" }));
270
+ reframeShow(dir, s.id, aspect)
271
+ .then((r) => setPlans((m) => ({ ...m, [k]: r })))
272
+ .catch(() => setPlans((m) => ({ ...m, [k]: "none" })));
273
+ }
274
+ }
275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
276
+ }, [sources, plans, needsReframe, reframeAspects, dir]);
277
+ const framingPairs = useMemo(() => selectedSources.flatMap((s) => reframeAspects.filter((a) => needsReframe(s, a)).map((a) => ({ s, aspect: a }))), [selectedSources, reframeAspects, needsReframe]);
278
+ const unapproved = framingPairs.filter(({ s, aspect }) => {
279
+ const p = plans[planKey(s.id, aspect)];
280
+ return !(p && p !== "none" && p !== "error" && p.approved);
281
+ });
282
+ const run = useCallback(async () => {
283
+ if (!recipe)
284
+ return;
285
+ setStages({});
286
+ setRunError(null);
287
+ const job = `recipe-ui-${Date.now().toString(36)}`;
288
+ const tag = `recipe-${job}`;
289
+ runTag.current = tag;
290
+ setRunning(job);
291
+ try {
292
+ // Flush the recipe to settings.json so engine reads current values (R3/R4).
293
+ await settingsSet({ recipe }, dir).catch(() => undefined);
294
+ await recipeRun(dir, {
295
+ style: recipe.style,
296
+ formats: recipe.formats,
297
+ sources: recipe.sources,
298
+ captions: recipe.captions.on,
299
+ music: recipe.finish.music ?? undefined,
300
+ music_level: recipe.finish.music_level,
301
+ job,
302
+ }, tag);
303
+ toast("Finished edit ready", { kind: "success" });
304
+ onNavigate?.("finished");
305
+ await onChanged();
306
+ }
307
+ catch (e) {
308
+ if (errorCode(e) === "cancelled" || errorCode(e) === "render_cancelled") {
309
+ setRunError("Cancelled — run again to resume where it stopped.");
310
+ }
311
+ else {
312
+ setRunError(errorMessage(e));
313
+ }
314
+ }
315
+ finally {
316
+ setRunning(null);
317
+ runTag.current = null;
318
+ void recipeStatus(dir).then(setLastRun).catch(() => undefined);
319
+ void refresh();
320
+ }
321
+ }, [recipe, dir, toast, onNavigate, onChanged, refresh]);
322
+ const stageList = useMemo(() => {
323
+ const rows = [...STAGE_LABELS];
324
+ for (const f of recipe?.formats ?? [])
325
+ rows.push([`render:${f}`, `Render ${f}`]);
326
+ return rows;
327
+ }, [recipe]);
328
+ if (!recipe) {
329
+ return (_jsx(PageShell, { title: "Edit recipe", maxWidth: 0, children: _jsx(ProgressRow, { label: "Loading recipe", status: "working", fraction: undefined }) }));
330
+ }
331
+ // Every user edit on this screen goes through here, so it is the only path that marks a recipe for saving.
332
+ const patch = (p) => {
333
+ const next = { ...recipe, ...p };
334
+ userRecipe.current = next;
335
+ userRecipeDir.current = dir;
336
+ setRecipe(next);
337
+ };
338
+ return (_jsxs(PageShell, { title: "Edit recipe", maxWidth: 0, subtitle: "One pass: transcribe, cut, frame and render.", children: [_jsxs("div", { className: "recipe", children: [_jsxs("div", { className: "recipe__form", children: [_jsx(RecipeLibrary, { dir: dir, onApplied: reloadSettings, onBeforeSave: async () => {
339
+ if (recipe)
340
+ await settingsSet({ recipe }, dir);
341
+ } }), _jsxs(Card, { children: [_jsx("h2", { children: "Style" }), _jsx(SegmentedControl, { label: "Edit style", value: recipe.style, options: [
342
+ { value: "natural", label: "Natural" },
343
+ { value: "tight", label: "Tight" },
344
+ { value: "punchy", label: "Punchy" },
345
+ ], onChange: (style) => patch({ style }) }), _jsx("p", { className: "recipe__desc", children: STYLE_DESC[recipe.style] })] }), _jsxs(Card, { children: [_jsx("h2", { children: "Format" }), _jsx("div", { className: "recipe__chips", role: "group", "aria-label": "Output formats", children: FORMATS.map((f) => {
346
+ const on = recipe.formats.includes(f);
347
+ return (_jsx(Button, { variant: "ghost", size: "sm", className: "cr-chip-btn", onClick: () => {
348
+ const next = on
349
+ ? recipe.formats.filter((x) => x !== f)
350
+ : [...recipe.formats, f];
351
+ if (next.length)
352
+ patch({ formats: next });
353
+ }, children: _jsx(Badge, { "aria-pressed": on, tone: on ? "ok" : "neutral", children: f }) }, f));
354
+ }) })] }), _jsxs(Card, { children: [_jsx("h2", { children: "Captions" }), _jsx(Toggle, { label: "Burn captions into the finished edit", checked: recipe.captions.on, onChange: (on) => patch({ captions: { ...recipe.captions, on } }) }), recipe.captions.on && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Caption style", children: () => (_jsx(Select, { label: "Caption style", value: recipe.captions.style, onChange: (style) => patch({ captions: { ...recipe.captions, style } }), options: CAPTION_STYLES.map(([value, label]) => ({ value, label })) })) }), _jsx("div", { className: "recipe-swatch", style: { aspectRatio: SWATCH_ASPECT[swatchPreset] }, children: swatch === null ? null : swatch === "loading" ? (_jsx(ProgressRow, { label: "Rendering style preview", status: "working" })) : swatch === "error" ? (_jsx("p", { className: "recipe__desc", children: "Preview unavailable for this project." })) : (_jsx("img", { className: "recipe-swatch-img", src: assetUrl(swatch.path) + `?m=${swatch.mtime_ms ?? 0}`, alt: `${recipe.captions.style} caption style preview` })) })] }))] }), reframeAspects.length > 0 && (_jsxs(Card, { children: [_jsx("h2", { children: "Framing" }), framingPairs.length === 0 ? (_jsxs("p", { className: "recipe__desc", children: ["Every included source already fits ", reframeAspects.join(" and "), "."] })) : (framingPairs.map(({ s, aspect }) => {
355
+ const p = plans[planKey(s.id, aspect)];
356
+ const approved = p !== undefined && p !== "none" && p !== "error" && p.approved;
357
+ return (_jsxs("div", { className: "recipe-frame-row", children: [_jsx(Thumbnail, { src: thumbs[s.id] || undefined }), _jsx("span", { className: "recipe-frame-row__name", children: s.name || s.id.slice(0, 8) }), _jsx(Badge, { tone: "neutral", children: aspect }), _jsx(Badge, { tone: approved || autoFraming ? "ok" : "warn", children: framingLabel(p && p !== "none" && p !== "error" ? p : undefined, autoFraming) }), _jsx(Button, { size: "sm", variant: "secondary", onClick: () => setFramingFor({ s, aspect }), children: "Review framing" })] }, planKey(s.id, aspect)));
358
+ }))] })), _jsxs(Card, { children: [_jsx("h2", { children: "Finish" }), _jsx(Field, { label: "Colour", children: () => (_jsx(Select, { label: "Colour", value: recipe.finish.color, onChange: (color) => {
359
+ // The LUT applies only under the Custom preset (engine, commit 9866899): leaving Custom clears it.
360
+ patch({ finish: { ...recipe.finish, color, ...(color === "custom" ? {} : { lut_path: null }) } });
361
+ settingsSet({ color_preset: color }, dir).catch(() => undefined);
362
+ }, options: colourOpts(hdr).map(([value, label]) => ({ value, label })) })) }), hdr === false && (_jsx("p", { className: "recipe__desc", role: HDR_COLOR_PRESETS.includes(recipe.finish.color) ? "alert" : undefined, children: HDR_UNAVAILABLE_NOTICE })), _jsx(Field, { label: "Audio", children: () => (_jsx(Select, { label: "Audio", value: recipe.finish.audio, onChange: (audio) => {
363
+ patch({ finish: { ...recipe.finish, audio } });
364
+ settingsSet({ audio_preset: audio }, dir).catch(() => undefined);
365
+ }, options: AUDIO_OPTS.map(([value, label]) => ({ value, label })) })) }), _jsx(Field, { label: "Music", note: music.length === 0 ? "No music in the library yet" : undefined, children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: recipe.finish.music ?? "", onChange: (e) => patch({ finish: { ...recipe.finish, music: e.target.value || null } }), children: [_jsx("option", { value: "", children: "None" }), music.map((a) => (_jsx("option", { value: a.sourceId ?? a.assetId, children: a.prompt ?? baseName(a.path) }, a.assetId)))] })) }), recipe.finish.music && (_jsx(Field, { label: `Music level (${recipe.finish.music_level} dB)`, children: () => (_jsx(Slider, { label: `Music level (${recipe.finish.music_level} dB)`, min: -40, max: -6, step: 1, value: recipe.finish.music_level, onChange: (music_level) => patch({ finish: { ...recipe.finish, music_level } }) })) })), recipe.finish.color === "custom" && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Custom LUT", note: recipe.finish.lut_path ? undefined : "Pick a .cube file", children: (ids) => (_jsxs("div", { ...ids, className: "recipe-lut-row", children: [_jsxs(Button, { size: "sm", variant: "secondary", onClick: () => void pickFile("Choose a .cube LUT", ["cube"]).then((p) => {
366
+ if (p)
367
+ patch({ finish: { ...recipe.finish, lut_path: p } });
368
+ }), children: [_jsx(Icon, { name: "folder-open" }), recipe.finish.lut_path ? "Change" : "Choose file"] }), recipe.finish.lut_path && (_jsx("span", { className: "recipe__desc", children: baseName(recipe.finish.lut_path) }))] })) }), _jsx(Field, { label: `LUT intensity (${recipe.finish.lut_intensity}%)`, children: () => (_jsx(Slider, { label: `LUT intensity (${recipe.finish.lut_intensity}%)`, min: 0, max: 100, step: 5, value: recipe.finish.lut_intensity, onChange: (lut_intensity) => patch({ finish: { ...recipe.finish, lut_intensity } }) })) })] })), _jsx(Toggle, { label: "Match shots \u2014 even out colour differences between sources", checked: recipe.finish.match_shots, onChange: (match_shots) => patch({ finish: { ...recipe.finish, match_shots } }) })] }), _jsxs(Card, { children: [_jsx("h2", { children: "Sources included" }), sources.map((s) => (_jsx(Checkbox, { className: "recipe-source-row", checked: recipe.sources.includes(s.id), onChange: (checked) => patch({
369
+ sources: checked
370
+ ? [...recipe.sources, s.id]
371
+ : recipe.sources.filter((x) => x !== s.id),
372
+ }), label: _jsxs("span", { className: "recipe-source-row", children: [_jsx(Thumbnail, { src: thumbs[s.id] || undefined }), _jsx("span", { className: "recipe-source-row__name", children: s.name || s.id.slice(0, 8) })] }) }, s.id))), sources.length === 0 && (_jsx("p", { className: "recipe__desc", children: "Import footage on the Media screen first." }))] }), _jsxs("div", { className: "recipe__cta", children: [_jsxs(Button, { variant: "primary", onClick: () => void run(), disabled: (recipe.sources.length === 0 || recipe.formats.length === 0) || (running !== null), "aria-busy": (running !== null) || undefined, className: "cr-btn--block", children: [!(running !== null) && _jsx(Icon, { name: "wand-sparkles" }), running !== null ? "Making…" : "Make finished edit"] }), !autoFraming && reframeAspects.length > 0 && unapproved.length > 0 && (_jsxs("p", { className: "recipe__blocked", children: ["Framing for", " ", unapproved
373
+ .map(({ s, aspect }) => `${s.name || s.id.slice(0, 8)} (${aspect})`)
374
+ .join(", "), " ", "needs approval before the run."] })), speechReady === false && (_jsx("p", { className: "recipe__blocked", children: "The speech model is unavailable \u2014 untranscribed sources get a visual edit." })), error && (_jsx("p", { role: "alert", className: "recipe__blocked", style: { color: "var(--rk-bad)" }, children: error }))] })] }), _jsx("div", { className: "recipe__run", children: running || runError || Object.keys(stages).length > 0 ? (_jsxs(Card, { children: [_jsx("h2", { children: "This run" }), stageList.map(([id, label]) => {
375
+ const st = stages[id];
376
+ return (_jsx(ProgressRow, { label: label, status: st?.state === "done"
377
+ ? "done"
378
+ : st?.state === "skipped"
379
+ ? "skipped"
380
+ : st?.state === "failed"
381
+ ? "failed"
382
+ : st?.state === "running"
383
+ ? "running"
384
+ : "waiting", tone: st?.state === "done"
385
+ ? "ok"
386
+ : st?.state === "failed"
387
+ ? "danger"
388
+ : st?.state === "skipped"
389
+ ? "neutral"
390
+ : "info", fraction: st?.state === "running" ? st.fraction : null, error: st?.state === "failed" ? st.error : undefined }, id));
391
+ }), running && (_jsx(Button, { variant: "secondary", onClick: () => void recipeCancel(running), children: "Cancel" })), runError && !running && (_jsx(ErrorPanel, { title: "The recipe stopped", message: runError, actions: _jsxs(Button, { size: "sm", variant: "secondary", onClick: () => void run(), children: [_jsx(Icon, { name: "rotate-ccw" }), "Retry"] }) }))] })) : lastRun?.ran ? (_jsxs(Card, { children: [_jsx("h2", { children: "Last run" }), _jsxs("p", { className: "recipe__desc", children: ["Finished ", lastRun.finished_at ? new Date(lastRun.finished_at).toLocaleString() : "recently", " · ", lastRun.signature?.formats?.join(", ") ?? "—", " · style ", String(lastRun.signature?.style ?? "—")] }), _jsx("div", { className: "recipe__chips", children: Object.entries(lastRun.done).map(([k]) => (_jsxs(Badge, { tone: "ok", children: [_jsx(Icon, { name: "check", width: 12, height: 12 }), k.replace("render:", "Render ")] }, k))) })] })) : (_jsx(EmptyState, { title: "Your recipe runs here", icon: _jsx(Icon, { name: "wand-sparkles", width: 28, height: 28 }), children: "Set the recipe on the left, then run it \u2014 each stage reports here." })) })] }), framingFor && (_jsx(FramingDrawer, { dir: dir, source: framingFor.s, aspect: framingFor.aspect, plan: plans[planKey(framingFor.s.id, framingFor.aspect)], autoFraming: autoFraming, onClose: () => setFramingFor(null), onChanged: (p) => setPlans((m) => ({ ...m, [planKey(framingFor.s.id, framingFor.aspect)]: p })) }))] }));
392
+ }
393
+ /** Framing review for one (source, aspect) plan; shared by Edit recipe and Shorts. */
394
+ export function FramingDrawer({ dir, source, aspect, plan, autoFraming = false, onClose, onChanged, }) {
395
+ const [busy, setBusy] = useState(null);
396
+ const [err, setErr] = useState(null);
397
+ const [shots, setShots] = useState([]);
398
+ const loadShots = useCallback(async (p) => {
399
+ const views = anchorViews({ ...p, plan: p.plan ?? p.plan });
400
+ const times = views.map((v) => v.mid_ms);
401
+ const t = await thumbnails(dir, { source: source.id }, { times_ms: times, width: 640 });
402
+ setShots(views.map((v, i) => ({
403
+ mid_ms: v.mid_ms,
404
+ src: t.frames[i] ? assetUrl(t.frames[i].path) : undefined,
405
+ crop: v.crop,
406
+ approved: v.approved,
407
+ })));
408
+ }, [dir, source.id]);
409
+ useEffect(() => {
410
+ if (plan && plan !== "none" && plan !== "error") {
411
+ loadShots(plan).catch((e) => setErr(errorMessage(e)));
412
+ }
413
+ else {
414
+ setShots([]);
415
+ }
416
+ }, [plan, loadShots]);
417
+ const analyze = async () => {
418
+ setBusy("analyse");
419
+ setErr(null);
420
+ try {
421
+ const r = await reframeAnalyze(dir, source.id, aspect, `frame-${source.id.slice(0, 8)}`);
422
+ onChanged(r);
423
+ }
424
+ catch (e) {
425
+ setErr(errorMessage(e));
426
+ }
427
+ finally {
428
+ setBusy(null);
429
+ }
430
+ };
431
+ const approve = async () => {
432
+ setBusy("approve");
433
+ setErr(null);
434
+ try {
435
+ const r = await reframeApprove(dir, source.id, { all: true, approver: "ui", aspect });
436
+ onChanged(r);
437
+ }
438
+ catch (e) {
439
+ setErr(errorMessage(e));
440
+ }
441
+ finally {
442
+ setBusy(null);
443
+ }
444
+ };
445
+ const ready = plan && plan !== "none" && plan !== "error";
446
+ return (_jsxs(Drawer, { title: `Framing — ${source.name || source.id.slice(0, 8)}`, open: true, onClose: onClose, footer: ready ? (plan.approved ? (_jsxs(Badge, { tone: "ok", children: [_jsx(Icon, { name: "check", width: 12, height: 12 }), framingLabel(plan, false)] })) : (_jsxs(Button, { variant: "primary", onClick: () => void approve(), disabled: (busy === "approve"), "aria-busy": (busy === "approve") || undefined, children: [!(busy === "approve") && _jsx(Icon, { name: "check" }), busy === "approve" ? "Approving…" : "Approve framing"] }))) : (_jsx(Button, { variant: "primary", onClick: () => void analyze(), disabled: (busy === "analyse"), "aria-busy": (busy === "analyse") || undefined, children: busy === "analyse" ? "Analysing…" : _jsxs(_Fragment, { children: ["Analyse framing for ", aspect] }) })), children: [_jsxs("p", { className: "recipe__desc", style: { margin: 0 }, children: ["The ", aspect, " crop over sampled moments of this source. ", autoFraming ? "Saved policy approves tracked framing during delivery; review is optional." : "Approve it to unblock vertical output."] }), err && (_jsx("p", { role: "alert", style: { margin: 0, color: "var(--rk-bad)", fontSize: 13 }, children: err })), ready && (_jsx("div", { className: "recipe-anchors", children: shots.map((s, i) => (_jsxs("div", { className: "recipe-anchor", children: [s.src ? _jsx("img", { src: s.src, alt: "" }) : _jsx(Thumbnail, {}), s.src && (_jsx("span", { className: "recipe-anchor__crop", style: {
447
+ left: `${s.crop.x * 100}%`,
448
+ top: `${s.crop.y * 100}%`,
449
+ width: `${s.crop.w * 100}%`,
450
+ height: `${s.crop.h * 100}%`,
451
+ } })), _jsx("span", { className: "recipe-anchor__t", children: timecode(s.mid_ms) })] }, i))) })), busy === "analyse" && _jsx(ProgressRow, { label: "Tracking the face", status: "working", fraction: undefined })] }));
452
+ }
@@ -0,0 +1,3 @@
1
+ export declare function BatchDelivery({ dir }: {
2
+ dir: string;
3
+ }): import("react").JSX.Element;