@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,354 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Exports — docs/design/app-ui.md §3.5.
3
+ import { Badge, Button, Checkbox, ContextMenu, EmptyState, Field, IconButton, Input, PageShell, Popover, Select, useToast } from "@rightkit/react-shell/react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { engineCall, errorMessage, onEngineEvent, revealPath } from "../lib/engine.js";
6
+ import { exportFcpxml, exportOtio, qaList, qaRun, framingApproval, framingLabel, packageBuild, reframeShow, rendersDelete, rendersList, settingsGet, sourceList, thumbnails, } from "../lib/media.js";
7
+ import { assetUrl } from "../lib/asset.js";
8
+ import { projectShow } from "../lib/project.js";
9
+ import { MediaPlayer } from "@rightkit/media-ui";
10
+ import { isPlayable, mediaKind, useMediaPlaybackId } from "../lib/mediaAdapters.js";
11
+ import { Drawer, ErrorPanel, Icon, ProgressRow, Thumbnail, fileSize, timecode } from "../ui/index.js";
12
+ import { BatchDelivery } from "./BatchDelivery.js";
13
+ import { BundleExport } from "./BundleExport.js";
14
+ /** Recipe aspect chips (Edit recipe → Format) → export presets. */
15
+ const RECIPE_PRESET = { "16:9": "youtube", "9:16": "reels", "1:1": "square" };
16
+ const FORMATS = [
17
+ { preset: "youtube", title: "YouTube 16:9", sub: "1080p", aspect: "16 / 9", vertical: false, supported: true },
18
+ {
19
+ preset: "reels",
20
+ title: "Shorts / Reels / TikTok",
21
+ sub: "9:16",
22
+ aspect: "9 / 16",
23
+ vertical: true,
24
+ supported: true,
25
+ },
26
+ { preset: "square", title: "Square", sub: "1:1", aspect: "1 / 1", vertical: true, supported: true },
27
+ ];
28
+ const PRESET_ASPECT = { youtube: "16 / 9", reels: "9 / 16", tiktok: "9 / 16", square: "1 / 1" };
29
+ /** Cached JPEG from `media.thumbnail` for a render (or a source range). */
30
+ function useThumb(dir, target, atMs) {
31
+ const [src, setSrc] = useState();
32
+ const key = "render" in target ? `r:${target.render}` : `s:${target.source}:${atMs ?? ""}`;
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ setSrc(undefined);
36
+ thumbnails(dir, target, atMs != null ? { at_ms: atMs, width: 480 } : { width: 480 })
37
+ .then((r) => !cancelled && setSrc(r.frames[0] ? assetUrl(r.frames[0].path) : undefined))
38
+ .catch(() => undefined);
39
+ return () => {
40
+ cancelled = true;
41
+ };
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- key captures target
43
+ }, [dir, key]);
44
+ return src;
45
+ }
46
+ function RenderThumb({ dir, render, aspect }) {
47
+ const src = useThumb(dir, { render });
48
+ return _jsx(Thumbnail, { src: src, aspect: aspect });
49
+ }
50
+ /** ⋯ menu per export card. */
51
+ function CardMenu({ onCopy, onDelete, onImportRetention, }) {
52
+ const [open, setOpen] = useState(false);
53
+ const ref = useRef(null);
54
+ return (_jsxs("div", { className: "exports-menu", children: [_jsx(IconButton, { ref: ref, label: "More actions", onClick: () => setOpen((v) => !v), children: _jsx(Icon, { name: "more-horizontal" }) }), open && ref.current && (_jsx(ContextMenu, { anchor: ref.current, placement: "top-end", label: "Export actions", width: 160, className: "exports-menu__pop", itemAs: "button", keyboard: "escape-only", autoFocus: false, onClose: () => setOpen(false), items: [
55
+ { id: "copy", label: "Copy path", icon: _jsx(Icon, { name: "copy" }), run: () => { setOpen(false); onCopy(); } },
56
+ { id: "retention", label: "Import retention…", icon: _jsx(Icon, { name: "chart-line" }), disabled: !onImportRetention, run: () => { setOpen(false); onImportRetention?.(); } },
57
+ { id: "delete", label: "Delete export", icon: _jsx(Icon, { name: "trash-2" }), danger: true, run: () => { setOpen(false); onDelete(); } },
58
+ ], renderItem: (item) => (_jsxs("span", { title: item.id === "retention" && item.disabled ? "Retention screen routing pending" : undefined, children: [item.icon, " ", item.label] })) }))] }));
59
+ }
60
+ /** Key for a render's QA result in `qaByHash`: its output hash when recorded, else its file name.
61
+ * Used by both the store (`runQa`) and the lookup (the export card). */
62
+ const qaKey = (r) => r.record?.output_blake3 ?? r.name;
63
+ /** QA chip + issues popover (§3.5). */
64
+ function QaChip({ report, running, onRun }) {
65
+ const [open, setOpen] = useState(false);
66
+ const ref = useRef(null);
67
+ if (!report) {
68
+ return (_jsx(Button, { variant: "ghost", size: "sm", className: "cr-chip-btn", onClick: onRun, title: "Run QA checks on this export", children: _jsx(Badge, { tone: "neutral", children: running ? "Checking…" : "Run QA" }) }));
69
+ }
70
+ const ok = report.status === "pass" && report.issues === 0;
71
+ const failed = (report.checks ?? []).filter((c) => c.status !== "pass");
72
+ return (_jsxs("div", { className: "exports-menu", children: [_jsx(Button, { variant: "ghost", size: "sm", ref: ref, className: "cr-chip-btn", onClick: () => setOpen((v) => !v), children: _jsx(Badge, { tone: ok ? "ok" : "warn", children: ok ? "QA passed" : `${report.issues} issues` }) }), open && (_jsxs(Popover, { anchor: ref.current, placement: "top-start", width: 280, label: "Export QA checks", className: "exports-qa__pop", onClose: () => setOpen(false), children: [failed.length === 0 ? (_jsx("p", { className: "cr-muted", children: "All checks passed." })) : (_jsx("ul", { children: failed.map((c, i) => (_jsxs("li", { children: [_jsx("span", { className: "cr-mono cr-muted", children: c.id }), c.detail ? ` — ${c.detail}` : ""] }, c.id ?? i))) })), _jsx(Button, { size: "sm", variant: "ghost", onClick: onRun, disabled: (running), "aria-busy": (running) || undefined, children: running ? "Running QA…" : "Re-run QA" })] }))] }));
73
+ }
74
+ const PACKAGE_PLATFORMS = [
75
+ { value: "youtube", label: "YouTube" },
76
+ { value: "shorts", label: "YouTube Shorts" },
77
+ { value: "reels", label: "Reels" },
78
+ { value: "tiktok", label: "TikTok" },
79
+ ];
80
+ /** Comma-separated tag input → the array `package.build` takes. */
81
+ const splitTags = (text) => text
82
+ .split(",")
83
+ .map((t) => t.trim())
84
+ .filter(Boolean);
85
+ /** Inline `package.build` form for one export: platform, title and optional copy. */
86
+ function PackageForm({ dir, render, onClose }) {
87
+ const [platform, setPlatform] = useState("youtube");
88
+ const [title, setTitle] = useState("");
89
+ const [description, setDescription] = useState("");
90
+ const [tags, setTags] = useState("");
91
+ const [busy, setBusy] = useState(false);
92
+ const [error, setError] = useState(null);
93
+ const [folder, setFolder] = useState(null);
94
+ const submit = (e) => {
95
+ e.preventDefault();
96
+ if (!title.trim())
97
+ return;
98
+ setBusy(true);
99
+ setError(null);
100
+ setFolder(null);
101
+ void packageBuild(dir, {
102
+ platform,
103
+ render,
104
+ title: title.trim(),
105
+ description: description.trim(),
106
+ tags: splitTags(tags),
107
+ })
108
+ // The reply names the manifest inside the package folder; the folder is its parent.
109
+ .then((res) => setFolder(res.manifest.replace(/[\\/][^\\/]+$/, "")))
110
+ .catch((e) => {
111
+ const msg = errorMessage(e);
112
+ // Drop only the engine's `qa_failed:` / `qa_missing:` code and the whitespace after it.
113
+ const detail = msg.replace(/^qa_(?:failed|missing):\s*/, "");
114
+ setError(msg.startsWith("qa_failed:")
115
+ ? `This render has not passed QA — ${detail}`
116
+ : msg.startsWith("qa_missing:")
117
+ ? `This render has not been checked yet; run QA first — ${detail}`
118
+ : msg);
119
+ })
120
+ .finally(() => setBusy(false));
121
+ };
122
+ return (_jsxs("form", { className: "exports-package", onSubmit: submit, children: [_jsxs("div", { className: "exports-package__head", children: [_jsx("span", { className: "cr-overline", children: "Package" }), _jsx(IconButton, { label: "Close package form", onClick: onClose, children: _jsx(Icon, { name: "x" }) })] }), _jsx(Field, { label: "Platform", children: () => (_jsx(Select, { label: "Platform", value: platform, onChange: setPlatform, options: PACKAGE_PLATFORMS })) }), _jsx(Field, { label: "Title", children: (ids) => (_jsx(Input, { ...ids, value: title, onChange: (e) => setTitle(e.target.value), required: true })) }), _jsx(Field, { label: "Description", children: (ids) => (_jsx(Input, { ...ids, value: description, onChange: (e) => setDescription(e.target.value) })) }), _jsx(Field, { label: "Tags", note: "Separate with commas", children: (ids) => (_jsx(Input, { ...ids, value: tags, onChange: (e) => setTags(e.target.value) })) }), _jsxs("div", { className: "exports-package__actions", children: [_jsx(Button, { type: "submit", variant: "primary", size: "sm", disabled: (!title.trim()) || (busy), "aria-busy": (busy) || undefined, children: busy ? "Packaging…" : "Build package" }), folder && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => void revealPath(folder), children: "Reveal package" }))] }), folder && (_jsxs("p", { role: "status", className: "exports-package__result cr-muted", children: ["Packaged to ", _jsx("span", { className: "cr-mono", children: folder })] })), error && (_jsx("p", { role: "alert", className: "exports-package__error", children: error }))] }));
123
+ }
124
+ export function ExportsView({ dir, progress, rendering, onRender, onChanged, onImportRetention, }) {
125
+ const toast = useToast();
126
+ const [renders, setRenders] = useState(null);
127
+ const [qaByHash, setQaByHash] = useState({});
128
+ // QA results this session obtained through "Run QA"; the engine's reports win on lookup.
129
+ const [qaRan, setQaRan] = useState({});
130
+ const [sources, setSources] = useState([]);
131
+ const [error, setError] = useState(null);
132
+ const [preview, setPreview] = useState(null);
133
+ const previewPlaybackId = useMediaPlaybackId(preview);
134
+ const [drawerOpen, setDrawerOpen] = useState(false);
135
+ const [picked, setPicked] = useState(new Set(["youtube"]));
136
+ const [interchange, setInterchange] = useState(false);
137
+ const [framing, setFraming] = useState(new Map());
138
+ const [autoFraming, setAutoFraming] = useState(false);
139
+ const [show, setShow] = useState(null);
140
+ const [queue, setQueue] = useState([]);
141
+ const [busy, setBusy] = useState(null);
142
+ // Name of the export whose package form is open.
143
+ const [packaging, setPackaging] = useState(null);
144
+ const seq = useRef(0);
145
+ const tags = useRef(new Set());
146
+ const refresh = useCallback(async () => {
147
+ const [r, s, q, settings, project] = await Promise.all([
148
+ rendersList(dir),
149
+ sourceList(dir),
150
+ qaList(dir).catch(() => ({ reports: [] })),
151
+ settingsGet(dir),
152
+ projectShow(dir),
153
+ ]);
154
+ setShow(project);
155
+ setAutoFraming(framingApproval(settings) === "auto");
156
+ setRenders(r.renders);
157
+ setSources(s.sources);
158
+ setQaByHash(Object.fromEntries(q.reports.map((x) => [x.render_hash, x])));
159
+ // Keep session results only for renders still in the list (a deleted render's result goes).
160
+ const live = new Set(r.renders.flatMap((x) => [x.name, x.record?.output_blake3].filter((k) => !!k)));
161
+ setQaRan((m) => Object.fromEntries(Object.entries(m).filter(([k]) => live.has(k))));
162
+ }, [dir]);
163
+ useEffect(() => {
164
+ refresh().catch((e) => setError(errorMessage(e)));
165
+ }, [refresh]);
166
+ useEffect(() => {
167
+ if (progress?.done)
168
+ void refresh().catch(() => undefined);
169
+ }, [progress?.done, refresh]);
170
+ // Track render progress events for this view's own render calls.
171
+ useEffect(() => {
172
+ let off;
173
+ let dead = false;
174
+ void onEngineEvent((frame) => {
175
+ if (!frame.tag || !tags.current.has(frame.tag))
176
+ return;
177
+ const ev = frame.event;
178
+ if (ev.event === "progress" && typeof ev.job === "string") {
179
+ const frac = typeof ev.out_time_ms === "number" && typeof ev.total_ms === "number" && ev.total_ms > 0
180
+ ? ev.out_time_ms / ev.total_ms
181
+ : undefined;
182
+ setQueue((qs) => qs.map((j) => (j.job === ev.job ? { ...j, fraction: frac } : j)));
183
+ }
184
+ }).then((fn) => {
185
+ if (dead)
186
+ fn();
187
+ else
188
+ off = fn;
189
+ });
190
+ return () => {
191
+ dead = true;
192
+ off?.();
193
+ };
194
+ }, []);
195
+ // Framing status per source, loaded when the drawer opens (vertical/square).
196
+ useEffect(() => {
197
+ if (!drawerOpen)
198
+ return;
199
+ let cancelled = false;
200
+ void Promise.all(sources.flatMap((s) => ["9:16", "1:1"].map(async (aspect) => {
201
+ const key = `${s.id}:${aspect}`;
202
+ try {
203
+ return [key, await reframeShow(dir, s.id, aspect)];
204
+ }
205
+ catch {
206
+ return [key, undefined];
207
+ }
208
+ }))).then((rows) => !cancelled && setFraming(new Map(rows)));
209
+ return () => {
210
+ cancelled = true;
211
+ };
212
+ }, [drawerOpen, dir, sources]);
213
+ const mp4s = (renders ?? []).filter((r) => r.name.endsWith(".mp4"));
214
+ const docs = (renders ?? []).filter((r) => /\.(srt|vtt|fcpxml|otio|json)$/.test(r.name));
215
+ const runJob = async (j) => {
216
+ const tag = `exports-${j.job}`;
217
+ tags.current.add(tag);
218
+ setQueue((qs) => qs.map((x) => (x.job === j.job ? { ...x, status: "running" } : x)));
219
+ try {
220
+ await engineCall("render", { dir, preset: j.preset, job: j.job }, tag);
221
+ setQueue((qs) => qs.map((x) => (x.job === j.job ? { ...x, status: "done", fraction: 1 } : x)));
222
+ }
223
+ catch (e) {
224
+ const code = e?.code;
225
+ const cancelled = code === "cancelled" || code === "render_cancelled";
226
+ setQueue((qs) => qs.map((x) => x.job === j.job
227
+ ? { ...x, status: cancelled ? "cancelled" : "failed", error: cancelled ? undefined : errorMessage(e) }
228
+ : x));
229
+ }
230
+ finally {
231
+ tags.current.delete(tag);
232
+ }
233
+ };
234
+ // The drawer opens on the formats the recipe asks for; the user can still change them.
235
+ const openDrawer = () => {
236
+ setDrawerOpen(true);
237
+ settingsGet(dir)
238
+ .then((st) => {
239
+ setAutoFraming(framingApproval(st) === "auto");
240
+ const formats = st?.project?.recipe?.formats ?? [];
241
+ const presets = formats.map((f) => RECIPE_PRESET[f]).filter(Boolean);
242
+ if (presets.length)
243
+ setPicked(new Set(presets));
244
+ })
245
+ .catch(() => undefined);
246
+ };
247
+ const startExport = async () => {
248
+ const formats = FORMATS.filter((f) => f.supported && picked.has(f.preset));
249
+ if (formats.length === 0 && !interchange)
250
+ return;
251
+ setDrawerOpen(false);
252
+ const jobs = formats.map((f) => {
253
+ seq.current += 1;
254
+ return { job: `ui-x${seq.current}`, preset: f.preset, label: f.title, status: "queued" };
255
+ });
256
+ setQueue((qs) => [...qs.filter((j) => j.status === "queued" || j.status === "running"), ...jobs]);
257
+ for (const j of jobs) {
258
+ // Sequential: renders share the timeline and media workers.
259
+ // eslint-disable-next-line no-await-in-loop
260
+ await runJob(j);
261
+ }
262
+ if (interchange) {
263
+ try {
264
+ await exportFcpxml(dir);
265
+ await exportOtio(dir);
266
+ }
267
+ catch (e) {
268
+ toast(`Interchange export failed: ${errorMessage(e)}`, { kind: "error", ttl: 0 });
269
+ }
270
+ }
271
+ await refresh();
272
+ await onChanged();
273
+ };
274
+ const cancelJob = (job) => {
275
+ void engineCall("render.cancel", { job }).catch((e) => toast(errorMessage(e), { kind: "error", ttl: 0 }));
276
+ setQueue((qs) => qs.map((x) => (x.job === job && x.status === "queued" ? { ...x, status: "cancelled" } : x)));
277
+ };
278
+ const runQa = (r) => {
279
+ const tag = `qa-${r.name}`;
280
+ setBusy(tag);
281
+ void qaRun(dir, r.path)
282
+ .then((res) => {
283
+ const checks = res.report?.checks ?? [];
284
+ const issues = checks.filter((c) => c.status !== "pass").length;
285
+ const key = qaKey(r);
286
+ const row = { render_hash: key, status: res.status, issues, checks };
287
+ // The run's receipt carries the output hash the engine keys its report by (absent on a dry run).
288
+ const hash = res.receipt?.render_blake3;
289
+ setQaRan((m) => ({ ...m, [key]: row, ...(hash ? { [hash]: { ...row, render_hash: hash } } : {}) }));
290
+ })
291
+ .catch((e) => toast(errorMessage(e), { kind: "error", ttl: 0 }))
292
+ .finally(() => setBusy(null));
293
+ };
294
+ const del = (r) => {
295
+ setBusy(`del-${r.name}`);
296
+ void rendersDelete(dir, r.name)
297
+ .then(async () => {
298
+ toast(`Deleted ${r.name}`, { kind: "success" });
299
+ await refresh();
300
+ })
301
+ .catch((e) => toast(errorMessage(e), { kind: "error", ttl: 0 }))
302
+ .finally(() => setBusy(null));
303
+ };
304
+ const activeQueue = queue.filter((j) => j.status === "queued" || j.status === "running");
305
+ const finishedQueue = queue.filter((j) => j.status === "failed" || j.status === "cancelled");
306
+ void onRender; // shell-owned render path kept for signature compatibility
307
+ return (_jsxs(PageShell, { title: "Exports", actions: _jsxs(Button, { variant: "primary", onClick: openDrawer, disabled: sources.length === 0, children: [_jsx(Icon, { name: "download" }), "Export\u2026"] }), maxWidth: 0, subtitle: renders
308
+ ? `${mp4s.length} export${mp4s.length === 1 ? "" : "s"}`
309
+ : undefined, children: [_jsxs("div", { className: "exports-body", children: [error && _jsx(ErrorPanel, { title: "Exports failed to load", detail: error }), (activeQueue.length > 0 || finishedQueue.length > 0 || rendering) && (_jsxs("div", { className: "exports-queue", children: [_jsx("span", { className: "cr-overline", children: "Queue" }), queue.map((j) => (_jsxs("div", { className: "exports-queue__row", children: [_jsx(ProgressRow, { label: `Render ${j.label}`, status: j.status === "running"
310
+ ? "Rendering"
311
+ : j.status === "queued"
312
+ ? "Queued"
313
+ : j.status === "done"
314
+ ? "Done"
315
+ : j.status === "cancelled"
316
+ ? "Cancelled"
317
+ : "Failed", tone: j.status === "failed" ? "danger" : j.status === "done" ? "ok" : "info", fraction: j.status === "done" ? 1 : j.status === "failed" || j.status === "cancelled" ? null : j.fraction, error: j.error }), (j.status === "queued" || j.status === "running") && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => cancelJob(j.job), children: "Cancel" })), (j.status === "failed" || j.status === "cancelled" || j.status === "done") && (_jsx(IconButton, { label: "Dismiss", onClick: () => setQueue((qs) => qs.filter((x) => x.job !== j.job)), children: _jsx(Icon, { name: "x" }) }))] }, j.job)))] })), preview && (_jsxs("div", { className: "exports-preview", children: [_jsxs("div", { className: "exports-preview__head", children: [_jsx("span", { className: "cr-overline", children: "Playing" }), _jsx(IconButton, { label: "Close preview", onClick: () => setPreview(null), children: _jsx(Icon, { name: "x" }) })] }), _jsx(MediaPlayer, { src: preview, playbackId: previewPlaybackId, kind: mediaKind(preview), title: preview.split(/[\\/]/).pop(), autoPlay: true }, previewPlaybackId ?? "pending")] })), renders === null ? (_jsx(ProgressRow, { label: "Loading exports", status: "Loading", fraction: undefined })) : mp4s.length === 0 && docs.length === 0 ? (_jsx(EmptyState, { title: "No exports yet", action: _jsx(Button, { variant: "primary", onClick: openDrawer, children: "Export\u2026" }), icon: _jsx(Icon, { name: "download", width: 28, height: 28 }), children: "Render your finished edit to share it." })) : (_jsx("div", { className: "exports-grid", children: mp4s.map((r) => {
318
+ const rec = r.record;
319
+ const aspect = PRESET_ASPECT[rec?.preset ?? ""] ?? "16 / 9";
320
+ const qa = qaByHash[qaKey(r)] ?? qaRan[qaKey(r)];
321
+ const lufs = rec?.loudness?.after?.input_i ?? rec?.loudness?.measured?.input_i;
322
+ const meta = [
323
+ rec?.duration_ms != null ? timecode(rec.duration_ms) : null,
324
+ rec?.preset === "reels" || rec?.preset === "tiktok" ? "9:16" : "16:9",
325
+ fileSize(r.bytes),
326
+ rec?.origin?.timeline_cursor != null ? `rev ${rec.origin.timeline_cursor}` : null,
327
+ ]
328
+ .filter(Boolean)
329
+ .join(" · ");
330
+ return (_jsxs("div", { className: "exports-card", children: [_jsx("button", { type: "button", className: "exports-card__thumb", "aria-label": `Play ${r.name}`, onClick: () => isPlayable(r.path) && setPreview(r.path), children: _jsx(RenderThumb, { dir: dir, render: r.name, aspect: aspect }) }), _jsx("div", { className: "exports-card__name cr-clamp-1", title: r.name, children: r.name }), _jsx("div", { className: "exports-card__meta cr-muted", children: meta }), _jsxs("div", { className: "exports-card__row", children: [_jsx(QaChip, { report: qa, running: busy === `qa-${r.name}`, onRun: () => runQa(r) }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Package ${r.name}`, "aria-expanded": packaging === r.name, onClick: () => setPackaging((n) => (n === r.name ? null : r.name)), children: "Package" }), lufs != null && Number.isFinite(lufs) && (_jsxs("span", { className: "exports-lufs", children: [lufs.toFixed(1), " LUFS"] })), _jsx("span", { className: "exports-card__spacer" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void revealPath(r.path), children: "Reveal" }), _jsx(CardMenu, { onCopy: () => void navigator.clipboard.writeText(r.path).then(() => toast("Path copied", { kind: "success" })), onDelete: () => del(r), onImportRetention: onImportRetention ? () => onImportRetention(r.name) : undefined })] }), packaging === r.name && (_jsx(PackageForm, { dir: dir, render: r.name, onClose: () => setPackaging(null) }))] }, r.path));
331
+ }) })), docs.length > 0 && (_jsxs("div", { className: "exports-docs", children: [_jsx("span", { className: "cr-overline", children: "Files" }), docs.map((d) => (_jsxs("div", { className: "exports-doc", children: [_jsx(Icon, { name: "captions" }), _jsx("span", { className: "cr-clamp-1", title: d.path, children: d.name }), _jsx("span", { className: "cr-mono cr-muted", children: fileSize(d.bytes) }), _jsx(IconButton, { label: "Reveal", onClick: () => void revealPath(d.path), children: _jsx(Icon, { name: "folder-open" }) })] }, d.path)))] })), _jsx(BatchDelivery, { dir: dir }), _jsx(BundleExport, { dir: dir })] }), _jsxs(Drawer, { title: "Export", open: drawerOpen, onClose: () => setDrawerOpen(false), footer: _jsxs(Button, { variant: "primary", disabled: picked.size === 0 && !interchange, onClick: () => void startExport(), children: ["Export ", picked.size > 0 ? picked.size : ""] }), children: [_jsx("span", { className: "cr-overline", children: "Formats" }), FORMATS.map((f) => {
332
+ const on = picked.has(f.preset);
333
+ const aspect = f.preset === "square" ? "1:1" : "9:16";
334
+ const [aw, ah] = aspect.split(":").map(Number);
335
+ const placed = new Set(show?.timeline.video_tracks.flatMap((t) => t.clips.map((c) => c.source)));
336
+ const plans = (show?.timeline.sources ?? [])
337
+ .filter((s) => placed.has(s.id) && s.video && Math.abs(s.video.width / s.video.height / (aw / ah) - 1) >= 0.05)
338
+ .map((s) => framing.get(`${s.id}:${aspect}`));
339
+ const approved = show !== null && plans.every((p) => p?.approved);
340
+ const automatic = approved && plans.some((p) => p?.approval?.approver === "policy:auto");
341
+ return (_jsx(Checkbox, { className: `exports-format ${f.supported ? "" : "exports-format--off"}`, checked: on && f.supported, disabled: !f.supported, onChange: (checked) => setPicked((s) => {
342
+ const n = new Set(s);
343
+ if (checked)
344
+ n.add(f.preset);
345
+ else
346
+ n.delete(f.preset);
347
+ return n;
348
+ }), label: _jsxs("span", { className: "exports-format__body", children: [_jsxs("span", { className: "exports-format__title", children: [f.title, _jsxs("span", { className: "cr-muted", children: [" ", f.sub] })] }), !f.supported && (_jsx("span", { title: "Square output is not supported by the render engine yet", children: _jsx(Badge, { tone: "warn", children: "Not available" }) })), f.supported && f.vertical && (_jsx("span", { title: autoFraming && !approved
349
+ ? "Saved policy approves analysed framing during export"
350
+ : !approved
351
+ ? "Vertical exports need an approved reframe plan per source"
352
+ : "Framing approved for all sources", children: _jsx(Badge, { tone: approved || autoFraming ? "ok" : "warn", children: automatic ? "Approved automatically" : approved ? "Framing approved" : framingLabel(undefined, autoFraming) }) }))] }) }, f.preset));
353
+ }), _jsx(Checkbox, { className: "exports-format", checked: interchange, onChange: setInterchange, label: _jsxs("span", { className: "exports-format__body", children: [_jsx("span", { className: "exports-format__title", children: "Also export for Resolve / Premiere" }), _jsx("span", { className: "cr-muted", children: "FCPXML, OTIO and captions next to the renders" })] }) })] })] }));
354
+ }