@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +47 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/DesktopWorkspace.d.ts +4 -0
- package/dist/components/DesktopWorkspace.js +527 -0
- package/dist/components/Player.d.ts +49 -0
- package/dist/components/Player.js +335 -0
- package/dist/components/RangePreview.d.ts +9 -0
- package/dist/components/RangePreview.js +22 -0
- package/dist/components/Workspace.css +15 -0
- package/dist/demo/DemoSettings.d.ts +1 -0
- package/dist/demo/DemoSettings.js +43 -0
- package/dist/demo/bindings.d.ts +121 -0
- package/dist/demo/bindings.js +57 -0
- package/dist/demo/demo.css +124 -0
- package/dist/demo/useDemo.d.ts +8 -0
- package/dist/demo/useDemo.js +37 -0
- package/dist/editor/CaptionInspector.d.ts +8 -0
- package/dist/editor/CaptionInspector.js +33 -0
- package/dist/editor/ChangesPanel.d.ts +13 -0
- package/dist/editor/ChangesPanel.js +87 -0
- package/dist/editor/ColourInspector.d.ts +5 -0
- package/dist/editor/ColourInspector.js +341 -0
- package/dist/editor/Inspector.d.ts +15 -0
- package/dist/editor/Inspector.js +202 -0
- package/dist/editor/Timeline.d.ts +39 -0
- package/dist/editor/Timeline.js +506 -0
- package/dist/editor/TimelineEditorView.d.ts +9 -0
- package/dist/editor/TimelineEditorView.js +237 -0
- package/dist/editor/TranscriptPanel.d.ts +20 -0
- package/dist/editor/TranscriptPanel.js +180 -0
- package/dist/editor/actions.d.ts +63 -0
- package/dist/editor/actions.js +124 -0
- package/dist/editor/adapter.d.ts +76 -0
- package/dist/editor/adapter.js +181 -0
- package/dist/editor/capabilities.d.ts +37 -0
- package/dist/editor/capabilities.js +33 -0
- package/dist/editor/gesture.d.ts +29 -0
- package/dist/editor/gesture.js +119 -0
- package/dist/editor/index.d.ts +10 -0
- package/dist/editor/index.js +7 -0
- package/dist/editor/keys.d.ts +15 -0
- package/dist/editor/keys.js +42 -0
- package/dist/editor/model.d.ts +88 -0
- package/dist/editor/model.js +87 -0
- package/dist/editor/transcript.d.ts +27 -0
- package/dist/editor/transcript.js +59 -0
- package/dist/editor/vendor/hyperframes/PlayheadIndicator.d.ts +9 -0
- package/dist/editor/vendor/hyperframes/PlayheadIndicator.js +38 -0
- package/dist/editor/vendor/hyperframes/audioWaveformPeaks.d.ts +7 -0
- package/dist/editor/vendor/hyperframes/audioWaveformPeaks.js +47 -0
- package/dist/editor/vendor/hyperframes/rulerGeometry.d.ts +13 -0
- package/dist/editor/vendor/hyperframes/rulerGeometry.js +133 -0
- package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.d.ts +33 -0
- package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.js +126 -0
- package/dist/env.d.ts +66 -0
- package/dist/hooks/useEngine.d.ts +10 -0
- package/dist/hooks/useEngine.js +68 -0
- package/dist/hooks/useHdrToSdr.d.ts +11 -0
- package/dist/hooks/useHdrToSdr.js +19 -0
- package/dist/hooks/useProgress.d.ts +10 -0
- package/dist/hooks/useProgress.js +32 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +57 -0
- package/dist/legal/legalConfig.d.ts +38 -0
- package/dist/legal/legalConfig.js +77 -0
- package/dist/lib/assemble.d.ts +100 -0
- package/dist/lib/assemble.js +72 -0
- package/dist/lib/asset.d.ts +2 -0
- package/dist/lib/asset.js +3 -0
- package/dist/lib/dialog.d.ts +4 -0
- package/dist/lib/dialog.js +44 -0
- package/dist/lib/engine.d.ts +44 -0
- package/dist/lib/engine.js +51 -0
- package/dist/lib/genright.d.ts +90 -0
- package/dist/lib/genright.js +160 -0
- package/dist/lib/media.d.ts +1045 -0
- package/dist/lib/media.js +171 -0
- package/dist/lib/mediaAdapters.d.ts +18 -0
- package/dist/lib/mediaAdapters.js +46 -0
- package/dist/lib/mic.d.ts +38 -0
- package/dist/lib/mic.js +14 -0
- package/dist/lib/models.d.ts +53 -0
- package/dist/lib/models.js +72 -0
- package/dist/lib/modelsBackend.d.ts +3 -0
- package/dist/lib/modelsBackend.js +68 -0
- package/dist/lib/progress.d.ts +77 -0
- package/dist/lib/progress.js +192 -0
- package/dist/lib/project.d.ts +138 -0
- package/dist/lib/project.js +48 -0
- package/dist/lib/services.d.ts +27 -0
- package/dist/lib/services.js +38 -0
- package/dist/lib/settingsBackend.d.ts +2 -0
- package/dist/lib/settingsBackend.js +57 -0
- package/dist/lib/shell.d.ts +172 -0
- package/dist/lib/shell.js +32 -0
- package/dist/styles.css +24 -0
- package/dist/ui/icons.d.ts +7 -0
- package/dist/ui/icons.js +62 -0
- package/dist/ui/index.d.ts +45 -0
- package/dist/ui/index.js +43 -0
- package/dist/ui/ui.css +104 -0
- package/dist/views/Assets.css +99 -0
- package/dist/views/AssetsView.d.ts +6 -0
- package/dist/views/AssetsView.js +371 -0
- package/dist/views/AutoEditView.d.ts +16 -0
- package/dist/views/AutoEditView.js +452 -0
- package/dist/views/BatchDelivery.d.ts +3 -0
- package/dist/views/BatchDelivery.js +331 -0
- package/dist/views/BundleExport.d.ts +3 -0
- package/dist/views/BundleExport.js +81 -0
- package/dist/views/Exports.css +117 -0
- package/dist/views/ExportsView.d.ts +12 -0
- package/dist/views/ExportsView.js +354 -0
- package/dist/views/FinishedEdit.css +546 -0
- package/dist/views/Home.css +97 -0
- package/dist/views/Home.d.ts +14 -0
- package/dist/views/Home.js +192 -0
- package/dist/views/Media.css +65 -0
- package/dist/views/OnboardingGate.d.ts +10 -0
- package/dist/views/OnboardingGate.js +54 -0
- package/dist/views/Phrases.css +45 -0
- package/dist/views/PhrasesView.d.ts +4 -0
- package/dist/views/PhrasesView.js +104 -0
- package/dist/views/ProgressView.d.ts +19 -0
- package/dist/views/ProgressView.js +36 -0
- package/dist/views/Prompt.css +84 -0
- package/dist/views/PromptPanel.d.ts +8 -0
- package/dist/views/PromptPanel.js +169 -0
- package/dist/views/Recipe.css +84 -0
- package/dist/views/RecipeLibrary.d.ts +7 -0
- package/dist/views/RecipeLibrary.js +104 -0
- package/dist/views/Retention.css +49 -0
- package/dist/views/RetentionView.d.ts +7 -0
- package/dist/views/RetentionView.js +145 -0
- package/dist/views/SettingsView.d.ts +9 -0
- package/dist/views/SettingsView.js +235 -0
- package/dist/views/Shorts.css +112 -0
- package/dist/views/ShortsView.d.ts +4 -0
- package/dist/views/ShortsView.js +205 -0
- package/dist/views/SourcesView.d.ts +6 -0
- package/dist/views/SourcesView.js +417 -0
- package/dist/views/TimelineView.d.ts +20 -0
- package/dist/views/TimelineView.js +12 -0
- package/package.json +68 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge, Button, Card, Field, Input, PageShell, SegmentedControl, Select, Toggle } from "@rightkit/react-shell/react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { engineCall, errorMessage } from "../lib/engine.js";
|
|
5
|
+
import { genRightCallFailed, interpretHost } from "../lib/genright.js";
|
|
6
|
+
import { durationControl, findModel, formatUsd, ofKind, parseCatalog, pickModel, providersOf, spendTotal, } from "../lib/models.js";
|
|
7
|
+
import { ErrorPanel, ProgressRow, Icon } from "../ui/index.js";
|
|
8
|
+
import { hostAppName } from "../appName.js";
|
|
9
|
+
// Preferred starting picks; used only when the installed the host app lists them (else the cheapest of the kind).
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
video: { provider: "fal", model: "fal-ai/veo3.1/fast" },
|
|
12
|
+
image: { provider: "fal", model: "fal-ai/flux-2/klein/9b" },
|
|
13
|
+
voice: { provider: "elevenlabs", model: "eleven_v4_turbo" },
|
|
14
|
+
};
|
|
15
|
+
const TEMPLATES = [
|
|
16
|
+
{ id: "lower_third", label: "Lower third" },
|
|
17
|
+
{ id: "centered", label: "Centered" },
|
|
18
|
+
{ id: "caption", label: "Caption" },
|
|
19
|
+
];
|
|
20
|
+
/** A shot of `kind` on the preferred (or cheapest) listed model, its default duration preselected. */
|
|
21
|
+
function newShot(kind, catalog) {
|
|
22
|
+
const m = catalog ? pickModel(catalog, kind, DEFAULTS[kind]) : undefined;
|
|
23
|
+
return {
|
|
24
|
+
prompt: "",
|
|
25
|
+
kind,
|
|
26
|
+
duration_s: kind === "video" ? durationControl(m).initial : 4,
|
|
27
|
+
provider: m?.provider ?? DEFAULTS[kind].provider,
|
|
28
|
+
model: m?.model ?? DEFAULTS[kind].model,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Keeps a shot's model valid for its kind and its duration within the model's allowed values. */
|
|
32
|
+
function fitShot(s, catalog) {
|
|
33
|
+
const m = findModel(catalog, s.provider, s.model);
|
|
34
|
+
if (!m || m.kind !== s.kind)
|
|
35
|
+
return { ...newShot(s.kind, catalog), prompt: s.prompt };
|
|
36
|
+
return s.kind === "video" ? { ...s, duration_s: fitDuration(durationControl(m), s.duration_s) } : s;
|
|
37
|
+
}
|
|
38
|
+
function fitDuration(c, d) {
|
|
39
|
+
if (c.type === "set")
|
|
40
|
+
return c.options.includes(d) ? d : c.initial;
|
|
41
|
+
if (c.type === "range")
|
|
42
|
+
return d >= c.min && d <= c.max ? d : c.initial;
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
function modelOptionLabel(m) {
|
|
46
|
+
return `${m.label}${m.estUsd !== null ? ` (${formatUsd(m.estUsd)})` : ""}`;
|
|
47
|
+
}
|
|
48
|
+
function ModelPicker({ catalog, kind, provider, model, onChange, providerLabel = "Provider", modelLabel = "Model", }) {
|
|
49
|
+
const providers = providersOf(catalog, kind);
|
|
50
|
+
const models = ofKind(catalog, kind).filter((m) => m.provider === provider);
|
|
51
|
+
const pick = (p, mdl) => {
|
|
52
|
+
const row = ofKind(catalog, kind).find((m) => m.provider === p && (mdl === undefined || m.model === mdl));
|
|
53
|
+
if (row)
|
|
54
|
+
onChange(row);
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs(_Fragment, { children: [_jsx(Field, { label: providerLabel, children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: provider, onChange: (e) => pick(e.target.value), children: [!providers.includes(provider) && _jsx("option", { value: provider, children: provider }), providers.map((p) => (_jsx("option", { value: p, children: p }, p)))] })) }), _jsx(Field, { label: modelLabel, children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: model, onChange: (e) => pick(provider, e.target.value), children: [!models.some((m) => m.model === model) && _jsx("option", { value: model, children: model }), models.map((m) => (_jsx("option", { value: m.model, children: modelOptionLabel(m) }, m.model)))] })) })] }));
|
|
57
|
+
}
|
|
58
|
+
export function PromptPanel({ busy, progress, onSubmit, onOpenFinished, }) {
|
|
59
|
+
const [gr, setGr] = useState({ state: "checking" });
|
|
60
|
+
const [prompt, setPrompt] = useState("");
|
|
61
|
+
const [catalog, setCatalog] = useState(null);
|
|
62
|
+
const [catalogError, setCatalogError] = useState(null);
|
|
63
|
+
const [shots, setShots] = useState([newShot("video", null), newShot("video", null)]);
|
|
64
|
+
const [narrate, setNarrate] = useState(true);
|
|
65
|
+
const [narration, setNarration] = useState("");
|
|
66
|
+
const [voice, setVoice] = useState({ ...DEFAULTS.voice, voice: "" });
|
|
67
|
+
const [titles, setTitles] = useState([]);
|
|
68
|
+
const [preset, setPreset] = useState("youtube");
|
|
69
|
+
const [render, setRender] = useState(true);
|
|
70
|
+
const [confirming, setConfirming] = useState(false);
|
|
71
|
+
const check = useCallback(async () => {
|
|
72
|
+
setGr({ state: "checking" });
|
|
73
|
+
try {
|
|
74
|
+
setGr(interpretHost(await engineCall("genright.status", {})));
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
setGr(genRightCallFailed(errorMessage(e)));
|
|
78
|
+
}
|
|
79
|
+
}, []);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
void check();
|
|
82
|
+
}, [check]);
|
|
83
|
+
// The model catalog (pickers, durations, estimates) comes from the installed the host app.
|
|
84
|
+
const ready0 = gr.state === "ready";
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!ready0)
|
|
87
|
+
return;
|
|
88
|
+
let live = true;
|
|
89
|
+
setCatalogError(null);
|
|
90
|
+
engineCall("genright.models", {})
|
|
91
|
+
.then((r) => {
|
|
92
|
+
if (!live)
|
|
93
|
+
return;
|
|
94
|
+
const c = parseCatalog(r);
|
|
95
|
+
setCatalog(c);
|
|
96
|
+
setShots((s) => s.map((x) => fitShot(x, c)));
|
|
97
|
+
setVoice((v) => {
|
|
98
|
+
const m = findModel(c, v.provider, v.model) ?? pickModel(c, "voice", DEFAULTS.voice);
|
|
99
|
+
return m ? { ...v, provider: m.provider, model: m.model } : v;
|
|
100
|
+
});
|
|
101
|
+
})
|
|
102
|
+
.catch((e) => {
|
|
103
|
+
if (live) {
|
|
104
|
+
setCatalog(null);
|
|
105
|
+
setCatalogError(errorMessage(e));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return () => {
|
|
109
|
+
live = false;
|
|
110
|
+
};
|
|
111
|
+
}, [ready0]);
|
|
112
|
+
const setCount = (n) => {
|
|
113
|
+
const count = Math.max(1, Math.min(6, n || 1));
|
|
114
|
+
setShots((s) => (count <= s.length ? s.slice(0, count) : [...s, ...Array.from({ length: count - s.length }, () => newShot("video", catalog))]));
|
|
115
|
+
};
|
|
116
|
+
const patchShot = (i, p) => setShots((s) => s.map((x, j) => (j === i ? { ...x, ...p } : x)));
|
|
117
|
+
const setKind = (i, kind) => {
|
|
118
|
+
const { provider, model, duration_s } = newShot(kind, catalog);
|
|
119
|
+
patchShot(i, { kind, provider, model, duration_s });
|
|
120
|
+
};
|
|
121
|
+
const setShotModel = (i, m) => setShots((all) => all.map((x, j) => j === i ? { ...x, provider: m.provider, model: m.model, duration_s: x.kind === "video" ? fitDuration(durationControl(m), x.duration_s) : x.duration_s } : x));
|
|
122
|
+
const patchTitle = (i, p) => setTitles((t) => t.map((x, j) => (j === i ? { ...x, ...p } : x)));
|
|
123
|
+
const form = useMemo(() => ({
|
|
124
|
+
prompt,
|
|
125
|
+
shots,
|
|
126
|
+
narration: narrate ? { text: narration, ...voice } : null,
|
|
127
|
+
titles,
|
|
128
|
+
preset,
|
|
129
|
+
render,
|
|
130
|
+
}), [prompt, shots, narrate, narration, voice, titles, preset, render]);
|
|
131
|
+
const narrates = narrate && narration.trim().length > 0;
|
|
132
|
+
const spend = useMemo(() => {
|
|
133
|
+
const lines = shots.map((s, i) => ({ label: `Shot ${i + 1}`, estUsd: findModel(catalog, s.provider, s.model)?.estUsd ?? null }));
|
|
134
|
+
if (narrates)
|
|
135
|
+
lines.push({ label: "Narration", estUsd: findModel(catalog, voice.provider, voice.model)?.estUsd ?? null });
|
|
136
|
+
return spendTotal(lines);
|
|
137
|
+
}, [catalog, shots, voice, narrates]);
|
|
138
|
+
const titlesOk = titles.every((t) => t.text.trim().length > 0 && t.text.length <= 160 && t.duration_s > 0 && t.start_s >= 0);
|
|
139
|
+
const valid = prompt.trim().length > 0 && shots.every((s) => s.provider && s.model && s.duration_s > 0) && titlesOk;
|
|
140
|
+
const ready = gr.state === "ready";
|
|
141
|
+
const disabled = !ready || busy || !valid;
|
|
142
|
+
return (_jsx(PageShell, { title: "From prompt", maxWidth: 0, subtitle: `Generate a video from a text prompt with ${hostAppName()}.`, children: _jsxs("div", { className: "Prompt", children: [_jsxs(Card, { children: [_jsxs("div", { className: "Prompt-grRow", "aria-live": "polite", children: [_jsx("span", { className: "Prompt-label", children: hostAppName() }), _jsx("span", { title: ready
|
|
143
|
+
? `${hostAppName()} ${gr.appVersion ?? ""} · contract ${gr.found ?? "?"}`
|
|
144
|
+
: (gr.detail ?? undefined), children: _jsx(Badge, { tone: ready ? "ok" : gr.state === "checking" ? "neutral" : "warn", children: ready ? `${hostAppName()} ready` : gr.state === "checking" ? "Checking…" : gr.state }) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void check(), children: "Re-check" })] }), !ready && gr.state !== "checking" && (_jsx(ErrorPanel, { title: gr.state === "outdated"
|
|
145
|
+
? `Update ${hostAppName()}`
|
|
146
|
+
: gr.state === "missing"
|
|
147
|
+
? `Install ${hostAppName()}`
|
|
148
|
+
: `${hostAppName()} could not be started`, detail: (gr.state === "outdated" && gr.found !== undefined && gr.required !== undefined
|
|
149
|
+
? `The installed ${hostAppName()} speaks engine contract ${gr.found}; ${hostAppName()} needs ${gr.required}. Update ${hostAppName()}, then re-check.`
|
|
150
|
+
: gr.state === "outdated"
|
|
151
|
+
? `The installed ${hostAppName()} is too old for ${hostAppName()}. Update ${hostAppName()}, then re-check.`
|
|
152
|
+
: gr.state === "missing"
|
|
153
|
+
? `${hostAppName()} generates media through the ${hostAppName()} app. Install ${hostAppName()}, then re-check.`
|
|
154
|
+
: `${hostAppName()} is installed but its engine did not run. Reinstall or update ${hostAppName()}, then re-check.`) +
|
|
155
|
+
(gr.detail ? ` (${gr.detail})` : "") +
|
|
156
|
+
(gr.enginePath ? ` Engine: ${gr.enginePath}` : "") })), catalogError && (_jsxs("p", { role: "status", className: "Prompt-error", children: ["Could not load ", hostAppName(), "'s model list (", catalogError, "); enter provider and model by hand."] })), _jsx(Field, { label: "Prompt", children: (ids) => (_jsx("textarea", { ...ids, className: "rk-input cr-textarea", value: prompt, onChange: (e) => setPrompt(e.target.value), placeholder: "What is the video about?" })) })] }), _jsxs(Card, { children: [_jsxs("div", { className: "Prompt-cardHead", children: [_jsx("h2", { children: "Shots" }), _jsx(SegmentedControl, { label: "Number of shots", value: String(Math.min(shots.length, 6)), options: ["1", "2", "3", "4", "5", "6"].map((v) => ({ value: v, label: v })), onChange: (v) => setCount(Number(v)) })] }), shots.map((s, i) => (_jsxs("div", { className: "Prompt-shotRow", children: [_jsxs("div", { className: "Prompt-shotGrid", children: [_jsx(Field, { label: `Shot ${i + 1} · kind`, children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", "aria-label": `Shot ${i + 1} kind`, value: s.kind, onChange: (e) => setKind(i, e.target.value), children: [_jsx("option", { value: "video", children: "Video" }), _jsx("option", { value: "image", children: "Image" })] })) }), catalog ? (_jsx(ModelPicker, { catalog: catalog, kind: s.kind, provider: s.provider, model: s.model, providerLabel: "Provider", modelLabel: "Model", onChange: (m) => setShotModel(i, m) })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Provider", children: (ids) => (_jsx(Input, { ...ids, value: s.provider, onChange: (e) => patchShot(i, { provider: e.target.value }) })) }), _jsx(Field, { label: "Model", children: (ids) => (_jsx(Input, { ...ids, value: s.model, onChange: (e) => patchShot(i, { model: e.target.value }) })) })] })), _jsx(Field, { label: s.kind === "image" ? "Still (s)" : "Duration (s)", children: () => (_jsx(DurationInput, { control: s.kind === "video" ? durationControl(findModel(catalog, s.provider, s.model)) : { type: "free", initial: 4 }, value: s.duration_s, onChange: (d) => patchShot(i, { duration_s: d }) })) })] }), _jsx(Field, { label: "Shot prompt", note: "Optional — defaults to the main prompt.", children: (ids) => (_jsx(Input, { ...ids, value: s.prompt, onChange: (e) => patchShot(i, { prompt: e.target.value }) })) })] }, i)))] }), _jsxs(Card, { children: [_jsxs("div", { className: "Prompt-cardHead", children: [_jsx("h2", { children: "Narration" }), _jsx(Toggle, { label: "Generate a voiceover", checked: narrate, onChange: setNarrate })] }), narrate && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Narration script", children: (ids) => (_jsx("textarea", { ...ids, className: "rk-input cr-textarea", value: narration, onChange: (e) => setNarration(e.target.value) })) }), _jsxs("div", { className: "Prompt-shotGrid Prompt-shotGrid--auto", children: [catalog ? (_jsx(ModelPicker, { catalog: catalog, kind: "voice", provider: voice.provider, model: voice.model, providerLabel: "Voice provider", modelLabel: "Voice model", onChange: (m) => setVoice({ ...voice, provider: m.provider, model: m.model }) })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Voice provider", children: (ids) => (_jsx(Input, { ...ids, value: voice.provider, onChange: (e) => setVoice({ ...voice, provider: e.target.value }) })) }), _jsx(Field, { label: "Voice model", children: (ids) => (_jsx(Input, { ...ids, value: voice.model, onChange: (e) => setVoice({ ...voice, model: e.target.value }) })) })] })), _jsx(Field, { label: "Voice", note: "Optional", children: (ids) => (_jsx(Input, { ...ids, value: voice.voice, onChange: (e) => setVoice({ ...voice, voice: e.target.value }) })) })] })] }))] }), _jsxs(Card, { children: [_jsxs("div", { className: "Prompt-cardHead", children: [_jsx("h2", { children: "Titles" }), _jsxs(Button, { size: "sm", variant: "secondary", onClick: () => setTitles((x) => [...x, { template: "lower_third", text: "", subtitle: "", start_s: 0, duration_s: 3 }]), children: [_jsx(Icon, { name: "plus" }), "Add title"] })] }), titles.map((t, i) => (_jsxs("div", { className: "Prompt-titleRow", children: [_jsx(Field, { label: "Template", children: (ids) => (_jsx("select", { ...ids, className: "rk-input cr-select", value: t.template, onChange: (e) => patchTitle(i, { template: e.target.value }), children: TEMPLATES.map((x) => (_jsx("option", { value: x.id, children: x.label }, x.id))) })) }), _jsx(Field, { label: t.template === "lower_third" ? "Title" : "Text", children: (ids) => (_jsx(Input, { ...ids, maxLength: 160, value: t.text, onChange: (e) => patchTitle(i, { text: e.target.value }) })) }), t.template === "lower_third" && (_jsx(Field, { label: "Subtitle", note: "Optional", children: (ids) => (_jsx(Input, { ...ids, maxLength: 160, value: t.subtitle, onChange: (e) => patchTitle(i, { subtitle: e.target.value }) })) })), _jsx(Field, { label: "Start (s)", children: (ids) => (_jsx(Input, { ...ids, type: "number", min: 0, step: 0.5, value: t.start_s, onChange: (e) => patchTitle(i, { start_s: Number(e.target.value) }) })) }), _jsx(Field, { label: "Length (s)", children: (ids) => (_jsx(Input, { ...ids, type: "number", min: 0.5, step: 0.5, value: t.duration_s, onChange: (e) => patchTitle(i, { duration_s: Number(e.target.value) }) })) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setTitles((x) => x.filter((_, j) => j !== i)), children: "Remove" })] }, i)))] }), _jsxs(Card, { children: [_jsx("div", { className: "Prompt-cardHead", children: _jsx("h2", { children: "Output" }) }), _jsxs("div", { className: "Prompt-shotGrid Prompt-shotGrid--auto", children: [_jsx(Field, { label: "Aspect", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: preset, onChange: (e) => setPreset(e.target.value), children: [_jsx("option", { value: "youtube", children: "16:9 (YouTube)" }), _jsx("option", { value: "reels", children: "9:16 (vertical)" })] })) }), _jsx(Field, { label: "Render", note: "Render the finished edit at the end.", children: () => (_jsx(Toggle, { label: "Render final", checked: render, onChange: setRender })) })] })] }), busy && progress && progress.stages.length > 0 && (_jsx(Card, { children: progress.stages.map((s, i) => (_jsx(ProgressRow, { label: s, status: i === progress.stages.length - 1 ? "Running" : "Done", tone: i === progress.stages.length - 1 ? "info" : "ok" }, s))) })), progress?.done && onOpenFinished && (_jsx(Card, { children: _jsxs("div", { className: "Prompt-grRow", children: [_jsx(Badge, { tone: "ok", children: "Finished edit ready" }), _jsx(Button, { variant: "secondary", onClick: onOpenFinished, children: "Open finished edit" })] }) })), !confirming ? (_jsx("div", { className: "Prompt-actions", children: _jsx(Button, { variant: "primary", disabled: disabled, onClick: () => setConfirming(true), children: "Assemble" }) })) : (_jsx(Card, { className: "Prompt-confirm", children: _jsxs("div", { role: "group", "aria-label": "Confirm paid generation", className: "Prompt-confirmInner", children: [_jsxs("p", { className: "m-0", children: ["This submits paid ", hostAppName(), " jobs (", shots.length, " shot", shots.length === 1 ? "" : "s", narrates ? " plus narration" : "", ")."] }), _jsxs("p", { className: "m-0", "data-testid": "spend-estimate", children: [_jsxs("strong", { children: ["Estimate: ", catalog ? `about ${formatUsd(spend.total)}` : "unavailable"] }), catalog && spend.unpriced > 0
|
|
157
|
+
? ` plus ${spend.unpriced} job${spend.unpriced === 1 ? "" : "s"} without a listed price`
|
|
158
|
+
: "", ". This is ", hostAppName(), "'s own estimate; the final bill may differ."] }), _jsxs("div", { className: "Prompt-actions", children: [_jsx(Button, { variant: "primary", disabled: disabled, onClick: () => {
|
|
159
|
+
setConfirming(false);
|
|
160
|
+
onSubmit(form);
|
|
161
|
+
}, children: "Confirm and generate" }), _jsx(Button, { variant: "secondary", onClick: () => setConfirming(false), children: "Back" })] })] }) }))] }) }));
|
|
162
|
+
}
|
|
163
|
+
function DurationInput({ control, value, onChange }) {
|
|
164
|
+
if (control.type === "set") {
|
|
165
|
+
return (_jsx(Select, { label: "Duration (s)", value: String(value), options: control.options.map((d) => ({ value: String(d), label: String(d) })), onChange: (next) => onChange(Number(next)) }));
|
|
166
|
+
}
|
|
167
|
+
const [min, max] = control.type === "range" ? [control.min, control.max] : [1, 30];
|
|
168
|
+
return (_jsx(Input, { type: "number", min: min, max: max, step: control.type === "range" ? 1 : 0.5, value: value, onChange: (e) => onChange(Number(e.target.value)) }));
|
|
169
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Edit recipe screen — docs/design/app-ui.md §3.3. Tokens only. */
|
|
2
|
+
|
|
3
|
+
.recipe { display: grid; grid-template-columns: 420px 1fr; gap: var(--s6); align-items: start; }
|
|
4
|
+
.recipe__form { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
|
|
5
|
+
.recipe__form h2 { margin: 0; font: 600 15px/1.3 var(--rk-font-body); }
|
|
6
|
+
.recipe__desc { margin: 0; font-size: 12px; color: var(--rk-ink-2); }
|
|
7
|
+
.recipe__chips { display: flex; flex-wrap: nowrap; gap: var(--s1); }
|
|
8
|
+
.recipe__run { min-width: 0; display: flex; flex-direction: column; gap: var(--s4); }
|
|
9
|
+
/* The run bar stays in view over the form, so it needs its own ground: without one its
|
|
10
|
+
text is drawn over whatever scrolls beneath it. */
|
|
11
|
+
.recipe__cta {
|
|
12
|
+
position: sticky;
|
|
13
|
+
bottom: 0;
|
|
14
|
+
z-index: 1;
|
|
15
|
+
padding: var(--s2) 0;
|
|
16
|
+
background: var(--rk-plane);
|
|
17
|
+
}
|
|
18
|
+
/* Leave room so the last control of the form can scroll clear of the bar. */
|
|
19
|
+
.recipe__form { padding-bottom: var(--s6); }
|
|
20
|
+
.recipe__blocked { margin: 0; font-size: 12px; color: var(--rk-warn); }
|
|
21
|
+
|
|
22
|
+
.recipe-frame-row {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: var(--s3);
|
|
26
|
+
}
|
|
27
|
+
.recipe-frame-row .cr-thumb { width: 96px; flex: none; }
|
|
28
|
+
.recipe-frame-row__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
|
29
|
+
|
|
30
|
+
.recipe-source-row {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: var(--s2);
|
|
34
|
+
font-size: 13px;
|
|
35
|
+
}
|
|
36
|
+
.recipe-source-row input { accent-color: var(--rk-accent); }
|
|
37
|
+
.recipe-source-row .cr-thumb { width: 56px; flex: none; }
|
|
38
|
+
.recipe-source-row__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
39
|
+
|
|
40
|
+
/* R3: the swatch is an engine-rendered PNG (`captions.preview`), not a CSS approximation. */
|
|
41
|
+
.recipe-swatch {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
width: 100%;
|
|
46
|
+
border-radius: var(--r-sm);
|
|
47
|
+
background: var(--cr-stage);
|
|
48
|
+
}
|
|
49
|
+
.recipe-swatch-img {
|
|
50
|
+
display: block;
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 100%;
|
|
53
|
+
object-fit: contain;
|
|
54
|
+
border-radius: var(--r-sm);
|
|
55
|
+
background: var(--cr-stage);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.recipe-lut-row {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: var(--s2);
|
|
62
|
+
min-width: 0;
|
|
63
|
+
}
|
|
64
|
+
.recipe-lut-row .recipe__desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
65
|
+
|
|
66
|
+
.recipe-anchors { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s3); }
|
|
67
|
+
.recipe-anchor { position: relative; border-radius: var(--r-sm); overflow: hidden; }
|
|
68
|
+
.recipe-anchor img { display: block; width: 100%; }
|
|
69
|
+
.recipe-anchor__crop {
|
|
70
|
+
position: absolute;
|
|
71
|
+
border: 2px solid var(--rk-accent);
|
|
72
|
+
border-radius: 2px;
|
|
73
|
+
box-shadow: 0 0 0 999px rgb(0 0 0 / 0.35);
|
|
74
|
+
}
|
|
75
|
+
.recipe-anchor__t {
|
|
76
|
+
position: absolute;
|
|
77
|
+
left: 4px;
|
|
78
|
+
bottom: 4px;
|
|
79
|
+
font: 10px/1 var(--rk-font-mono);
|
|
80
|
+
color: rgb(255 255 255);
|
|
81
|
+
background: rgb(0 0 0 / 0.6);
|
|
82
|
+
padding: 2px 4px;
|
|
83
|
+
border-radius: 4px;
|
|
84
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Panel on the Edit recipe screen: apply a saved recipe here, or save this project's recipe to the library. */
|
|
2
|
+
export declare function RecipeLibrary({ dir, onApplied, onBeforeSave, }: {
|
|
3
|
+
dir: string;
|
|
4
|
+
onApplied?: () => void;
|
|
5
|
+
/** Writes what the Edit recipe screen shows into the project, so that is what gets saved. */
|
|
6
|
+
onBeforeSave?: () => Promise<void>;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Channel recipe — the app-wide recipe library applied to one project (recipes.* engine methods).
|
|
3
|
+
import { Button, Field, Input, useToast } from "@rightkit/react-shell/react";
|
|
4
|
+
import { Icon } from "../ui/index.js";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { errorMessage } from "../lib/engine.js";
|
|
7
|
+
import { RECIPE_ID_RULE, recipesApply, recipesDelete, recipesList, recipesSaveFromProject, recipesStatus, } from "../lib/media.js";
|
|
8
|
+
const ID_RULE_TEXT = "Use 1–64 lowercase letters, digits or hyphens.";
|
|
9
|
+
/** Panel on the Edit recipe screen: apply a saved recipe here, or save this project's recipe to the library. */
|
|
10
|
+
export function RecipeLibrary({ dir, onApplied, onBeforeSave, }) {
|
|
11
|
+
const toast = useToast();
|
|
12
|
+
const [library, setLibrary] = useState([]);
|
|
13
|
+
const [status, setStatus] = useState(null);
|
|
14
|
+
const [selected, setSelected] = useState("");
|
|
15
|
+
const [saveId, setSaveId] = useState("");
|
|
16
|
+
const [saveName, setSaveName] = useState("");
|
|
17
|
+
const [armed, setArmed] = useState(false);
|
|
18
|
+
const [busy, setBusy] = useState(null);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
const refresh = useCallback(async () => {
|
|
21
|
+
const [list, st] = await Promise.all([recipesList(), recipesStatus(dir)]);
|
|
22
|
+
setLibrary(list.recipes);
|
|
23
|
+
setStatus(st);
|
|
24
|
+
setSelected((cur) => (list.recipes.some((r) => r.id === cur) ? cur : ""));
|
|
25
|
+
}, [dir]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
refresh().catch((e) => setError(errorMessage(e)));
|
|
28
|
+
}, [refresh]);
|
|
29
|
+
// A pending delete never survives a change of selection.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setArmed(false);
|
|
32
|
+
}, [selected]);
|
|
33
|
+
/** Runs one write, then the caller's reload; failures stay visible inline. */
|
|
34
|
+
const act = async (tag, fn) => {
|
|
35
|
+
setBusy(tag);
|
|
36
|
+
setError(null);
|
|
37
|
+
try {
|
|
38
|
+
await fn();
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
setError(errorMessage(e));
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
setBusy(null);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const applied = status?.ref ?? null;
|
|
48
|
+
const appliedName = applied ? (library.find((r) => r.id === applied.id)?.name ?? applied.id) : "";
|
|
49
|
+
const selectedRow = library.find((r) => r.id === selected);
|
|
50
|
+
const idError = saveId !== "" && !RECIPE_ID_RULE.test(saveId) ? ID_RULE_TEXT : null;
|
|
51
|
+
const existing = idError === null ? library.find((r) => r.id === saveId) : undefined;
|
|
52
|
+
const saveLabel = existing ? `Update ${existing.id} to v${existing.version + 1}` : "Save recipe";
|
|
53
|
+
const canSave = busy === null && saveId !== "" && idError === null && saveName.trim() !== "";
|
|
54
|
+
const statusText = !status
|
|
55
|
+
? "Checking the applied recipe…"
|
|
56
|
+
: !applied
|
|
57
|
+
? "No saved recipe applied"
|
|
58
|
+
: [
|
|
59
|
+
`Using ${appliedName} v${applied.version}`,
|
|
60
|
+
status.overridden ? "edited in this project" : null,
|
|
61
|
+
status.update_available
|
|
62
|
+
? status.library_version != null
|
|
63
|
+
? `update available (v${status.library_version})`
|
|
64
|
+
: "update available"
|
|
65
|
+
: null,
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join(" · ");
|
|
69
|
+
const apply = () => void act("apply", async () => {
|
|
70
|
+
await recipesApply(dir, selected);
|
|
71
|
+
toast(`Applied ${selectedRow?.name ?? selected} to this project`, { kind: "success" });
|
|
72
|
+
await refresh();
|
|
73
|
+
onApplied?.();
|
|
74
|
+
});
|
|
75
|
+
const save = () => void act("save", async () => {
|
|
76
|
+
const name = saveName.trim();
|
|
77
|
+
await onBeforeSave?.();
|
|
78
|
+
await recipesSaveFromProject({ dir, id: saveId, name });
|
|
79
|
+
toast(`Saved ${name} as ${saveId}`, { kind: "success" });
|
|
80
|
+
setSelected(saveId);
|
|
81
|
+
setSaveId("");
|
|
82
|
+
setSaveName("");
|
|
83
|
+
await refresh();
|
|
84
|
+
});
|
|
85
|
+
const remove = () => {
|
|
86
|
+
if (!selectedRow)
|
|
87
|
+
return;
|
|
88
|
+
if (!armed) {
|
|
89
|
+
setArmed(true);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setArmed(false);
|
|
93
|
+
void act("delete", async () => {
|
|
94
|
+
await recipesDelete(selectedRow.id);
|
|
95
|
+
toast(`Deleted ${selectedRow.name}`, { kind: "success" });
|
|
96
|
+
setSelected("");
|
|
97
|
+
await refresh();
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
return (_jsxs("section", { className: "recipe-library", "data-testid": "recipe-library", "aria-label": "Channel recipe", children: [_jsx("h2", { className: "panel-title", children: "Channel recipe" }), _jsxs("p", { className: "panel-note recipe-status", "data-testid": "recipe-status", children: [statusText, status?.overridden && (_jsxs(_Fragment, { children: [_jsx("br", {}), "Applying replaces this project's recipe settings."] }))] }), _jsxs("div", { className: "panel-stack", children: [_jsx(Field, { label: "Library recipe", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", "data-testid": "recipe-select", value: selected, disabled: library.length === 0, onChange: (e) => setSelected(e.target.value), children: [_jsx("option", { value: "", children: library.length === 0 ? "No saved recipes yet" : "Choose a recipe…" }), library.map((r) => (_jsx("option", { value: r.id, children: `${r.name} (v${r.version})` }, r.id)))] })) }), _jsxs("div", { className: "panel-row", children: [_jsxs(Button, { variant: "primary", size: "sm", "data-testid": "recipe-apply", onClick: apply, disabled: (!selectedRow || busy !== null) || (busy === "apply"), "aria-busy": (busy === "apply") || undefined, children: [!(busy === "apply") && _jsx(Icon, { name: "check" }), busy === "apply" ? "Applying…" : "Apply to this project"] }), _jsxs(Button, { variant: armed ? "danger" : "ghost", size: "sm", "data-testid": "recipe-delete", onClick: remove, onKeyDown: (e) => {
|
|
101
|
+
if (e.key === "Escape")
|
|
102
|
+
setArmed(false);
|
|
103
|
+
}, onBlur: () => setArmed(false), disabled: (!selectedRow || (busy !== null && busy !== "delete")) || (busy === "delete"), "aria-busy": (busy === "delete") || undefined, children: [!(busy === "delete") && _jsx(Icon, { name: "trash-2" }), busy === "delete" ? "Deleting…" : armed ? "Confirm delete" : "Delete"] })] })] }), _jsxs("div", { className: "panel-stack", children: [_jsx("span", { className: "cr-overline", children: "Save this project's recipe as\u2026" }), _jsx(Field, { label: "Recipe id", children: (ids) => (_jsx(Input, { ...ids, "data-testid": "recipe-save-id", value: saveId, placeholder: "talking-head", "aria-invalid": idError ? true : undefined, onChange: (e) => setSaveId(e.target.value) })) }), idError && (_jsx("p", { role: "alert", className: "panel-error", "data-testid": "recipe-save-id-error", children: idError })), _jsx(Field, { label: "Name", children: (ids) => (_jsx(Input, { ...ids, "data-testid": "recipe-save-name", value: saveName, placeholder: "Talking head, 16:9 and 9:16", onChange: (e) => setSaveName(e.target.value) })) }), _jsx("div", { className: "panel-row", children: _jsx(Button, { variant: "secondary", size: "sm", "data-testid": "recipe-save", onClick: save, disabled: (!canSave) || (busy === "save"), "aria-busy": (busy === "save") || undefined, children: busy === "save" ? "Saving…" : saveLabel }) })] }), error && (_jsx("p", { role: "alert", className: "panel-error", "data-testid": "recipe-error", children: error }))] }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Retention — docs/design/app-ui.md §3.6 */
|
|
2
|
+
|
|
3
|
+
.retention-body { display: flex; flex-direction: column; gap: var(--s4); max-width: 860px; }
|
|
4
|
+
|
|
5
|
+
.retention-controls { display: flex; align-items: flex-end; gap: var(--s3); flex-wrap: wrap; }
|
|
6
|
+
.retention-controls .rk-field { width: 240px; }
|
|
7
|
+
.retention-controls__or { font-size: 12px; padding-bottom: 8px; }
|
|
8
|
+
|
|
9
|
+
.retention-imports { display: flex; flex-wrap: wrap; gap: var(--s2); }
|
|
10
|
+
|
|
11
|
+
.retention-chart { position: relative; }
|
|
12
|
+
.retention-chart svg { width: 100%; height: 160px; display: block; }
|
|
13
|
+
.retention-chart circle { cursor: default; }
|
|
14
|
+
.retention-chart__axis {
|
|
15
|
+
display: flex;
|
|
16
|
+
justify-content: space-between;
|
|
17
|
+
font-size: 10px;
|
|
18
|
+
margin-top: var(--s1);
|
|
19
|
+
padding-left: 34px;
|
|
20
|
+
}
|
|
21
|
+
.retention-tip {
|
|
22
|
+
position: absolute;
|
|
23
|
+
transform: translateY(-100%);
|
|
24
|
+
background: var(--rk-elevated);
|
|
25
|
+
border: 1px solid var(--rk-line);
|
|
26
|
+
border-radius: var(--r-sm);
|
|
27
|
+
box-shadow: var(--rk-pop);
|
|
28
|
+
padding: 4px var(--s2);
|
|
29
|
+
font-size: 10px;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
pointer-events: none;
|
|
32
|
+
z-index: 5;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.retention-drops { display: flex; flex-direction: column; gap: var(--s2); }
|
|
36
|
+
.retention-drop {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: var(--s3);
|
|
40
|
+
padding: var(--s2) var(--s3);
|
|
41
|
+
background: var(--rk-elevated);
|
|
42
|
+
border: 1px solid var(--rk-line);
|
|
43
|
+
border-radius: var(--r-md);
|
|
44
|
+
font-size: 13px;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
}
|
|
47
|
+
.retention-drop input { accent-color: var(--rk-accent); }
|
|
48
|
+
.retention-drop__map { font-size: 12px; margin-left: auto; flex: none; }
|
|
49
|
+
.retention-none { margin: 0; font-size: 13px; }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function RetentionView({ dir, onChanged, onExportRevised, preselectRender, }: {
|
|
2
|
+
dir: string;
|
|
3
|
+
onChanged: () => Promise<void>;
|
|
4
|
+
onExportRevised?: () => void;
|
|
5
|
+
/** R5: Exports "Import retention…" routes here with this render preselected. */
|
|
6
|
+
preselectRender?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Retention — docs/design/app-ui.md §3.6. Audience-retention CSV bound to one
|
|
3
|
+
// exact render; drop-offs become cut proposals applied as one revision.
|
|
4
|
+
import { Badge, Button, Card, Checkbox, EmptyState, Field, PageShell, useToast } from "@rightkit/react-shell/react";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { errorMessage } from "../lib/engine.js";
|
|
7
|
+
import { mediaScopeAllow, rendersList, retentionApply, retentionImport, retentionList, retentionShow, } from "../lib/media.js";
|
|
8
|
+
import { assetUrl } from "../lib/asset.js";
|
|
9
|
+
import { pickFile } from "../lib/dialog.js";
|
|
10
|
+
import { ErrorPanel, timecode, Icon } from "../ui/index.js";
|
|
11
|
+
/** Retention % line chart, inline SVG on tokens (spec §3.6). 160px tall. */
|
|
12
|
+
function RetentionChart({ points, drops }) {
|
|
13
|
+
const [tip, setTip] = useState(null);
|
|
14
|
+
const W = 640;
|
|
15
|
+
const H = 160;
|
|
16
|
+
const PAD = { l: 34, r: 8, t: 8, b: 18 };
|
|
17
|
+
const iw = W - PAD.l - PAD.r;
|
|
18
|
+
const ih = H - PAD.t - PAD.b;
|
|
19
|
+
const maxMs = Math.max(...points.map((p) => p.out_ms), 1);
|
|
20
|
+
const x = (ms) => PAD.l + (ms / maxMs) * iw;
|
|
21
|
+
const y = (v) => PAD.t + (1 - Math.max(0, Math.min(100, v)) / 100) * ih;
|
|
22
|
+
const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.out_ms).toFixed(1)},${y(p.retention).toFixed(1)}`).join(" ");
|
|
23
|
+
return (_jsxs("div", { className: "retention-chart", children: [_jsxs("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", role: "img", "aria-label": "Retention over time", children: [[0, 25, 50, 75, 100].map((g) => (_jsxs("g", { children: [_jsx("line", { x1: PAD.l, x2: W - PAD.r, y1: y(g), y2: y(g), stroke: "var(--rk-line)", strokeWidth: 1 }), _jsx("text", { x: 4, y: y(g) + 3, fill: "var(--rk-ink-2)", fontSize: 9, fontFamily: "var(--rk-font-mono)", children: g })] }, g))), _jsx("path", { d: path, fill: "none", stroke: "var(--rk-accent)", strokeWidth: 1.6 }), drops.map((d) => {
|
|
24
|
+
const cx = x(d.out_start_ms);
|
|
25
|
+
const near = points.reduce((best, p) => (Math.abs(p.out_ms - d.out_start_ms) < Math.abs(best.out_ms - d.out_start_ms) ? p : best), points[0] ?? { out_ms: 0, retention: 50 });
|
|
26
|
+
return (_jsx("circle", { cx: cx, cy: y(near.retention), r: 4, fill: "var(--rk-bad)", onMouseEnter: () => setTip({ x: cx, y: y(near.retention), text: `${timecode(d.out_start_ms)} · −${d.drop_points.toFixed(1)} pts` }), onMouseLeave: () => setTip(null) }, d.id));
|
|
27
|
+
})] }), tip && (_jsx("div", { className: "retention-tip cr-mono", style: { left: `min(${(tip.x / W) * 100}%, 80%)`, top: tip.y - 8 }, children: tip.text })), _jsxs("div", { className: "retention-chart__axis cr-muted", children: [_jsx("span", { children: "0:00" }), _jsx("span", { className: "cr-mono", children: timecode(maxMs) })] })] }));
|
|
28
|
+
}
|
|
29
|
+
export function RetentionView({ dir, onChanged, onExportRevised, preselectRender, }) {
|
|
30
|
+
const toast = useToast();
|
|
31
|
+
const [renders, setRenders] = useState([]);
|
|
32
|
+
const [imports, setImports] = useState([]);
|
|
33
|
+
const [active, setActive] = useState(null);
|
|
34
|
+
const [csv, setCsv] = useState("");
|
|
35
|
+
const [render, setRender] = useState("");
|
|
36
|
+
const [checked, setChecked] = useState(new Set());
|
|
37
|
+
const [busy, setBusy] = useState(null);
|
|
38
|
+
const [error, setError] = useState(null);
|
|
39
|
+
const dirty = useRef(false);
|
|
40
|
+
const refresh = useCallback(async () => {
|
|
41
|
+
const [r, ret] = await Promise.all([rendersList(dir), retentionList(dir)]);
|
|
42
|
+
const mp4s = r.renders.filter((f) => f.name.endsWith(".mp4"));
|
|
43
|
+
setRenders(mp4s);
|
|
44
|
+
setImports(ret.retention);
|
|
45
|
+
setRender((cur) => cur || mp4s[0]?.name || "");
|
|
46
|
+
}, [dir]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
refresh().catch((e) => setError(errorMessage(e)));
|
|
49
|
+
}, [refresh]);
|
|
50
|
+
// Route in from Exports with a specific render preselected.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (preselectRender)
|
|
53
|
+
setRender(preselectRender);
|
|
54
|
+
}, [preselectRender]);
|
|
55
|
+
const open = useCallback(async (name) => {
|
|
56
|
+
setBusy(`show-${name}`);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
const s = await retentionShow(dir, name);
|
|
60
|
+
setActive(s);
|
|
61
|
+
setChecked(new Set((s.drop_offs ?? []).map((d) => d.id)));
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
setError(errorMessage(e));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
setBusy(null);
|
|
68
|
+
}
|
|
69
|
+
}, [dir]);
|
|
70
|
+
// Auto-open the newest import (but don't clobber the user's selection).
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!active && !dirty.current && imports.length > 0) {
|
|
73
|
+
void open(imports[imports.length - 1].name);
|
|
74
|
+
}
|
|
75
|
+
}, [imports, active, open]);
|
|
76
|
+
const doImport = async (text) => {
|
|
77
|
+
if (!text.trim() || !render)
|
|
78
|
+
return;
|
|
79
|
+
setBusy("import");
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const r = await retentionImport(dir, text, render);
|
|
83
|
+
toast(`Imported ${r.points} retention points — ${r.drop_offs} drop-off proposals`, { kind: "success" });
|
|
84
|
+
setCsv("");
|
|
85
|
+
await refresh();
|
|
86
|
+
await open(r.name);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
setError(errorMessage(e));
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
setBusy(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const importFile = async () => {
|
|
96
|
+
const file = await pickFile("Import retention CSV", ["csv", "txt"]);
|
|
97
|
+
if (!file)
|
|
98
|
+
return;
|
|
99
|
+
setBusy("import");
|
|
100
|
+
try {
|
|
101
|
+
await mediaScopeAllow([file]);
|
|
102
|
+
const text = await fetch(assetUrl(file)).then((r) => {
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
throw new Error(`could not read ${file}`);
|
|
105
|
+
return r.text();
|
|
106
|
+
});
|
|
107
|
+
await doImport(text);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
setError(errorMessage(e));
|
|
111
|
+
setBusy(null);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const apply = async () => {
|
|
115
|
+
if (!active)
|
|
116
|
+
return;
|
|
117
|
+
setBusy("apply");
|
|
118
|
+
setError(null);
|
|
119
|
+
try {
|
|
120
|
+
const res = await retentionApply(dir, active.name, [...checked]);
|
|
121
|
+
toast(`Applied ${res.cuts} cut${res.cuts === 1 ? "" : "s"} — re-export to deliver`, { kind: "success", action: onExportRevised ? { label: "Export revised", run: onExportRevised } : undefined });
|
|
122
|
+
dirty.current = true;
|
|
123
|
+
setActive(null);
|
|
124
|
+
await refresh();
|
|
125
|
+
await onChanged();
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
setError(errorMessage(e));
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
setBusy(null);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const drops = active?.drop_offs ?? [];
|
|
135
|
+
return (_jsx(PageShell, { title: "Retention", maxWidth: 0, subtitle: "Audience drop-offs mapped onto your exports", children: _jsxs("div", { className: "retention-body", children: [_jsxs("div", { className: "retention-controls", children: [_jsx(Field, { label: "Export", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: render, onChange: (e) => setRender(e.target.value), children: [renders.length === 0 && _jsx("option", { value: "", children: "No exports yet" }), renders.map((r) => (_jsx("option", { value: r.name, children: r.name }, r.path)))] })) }), _jsxs(Button, { variant: "secondary", disabled: !render || busy !== null, onClick: () => void importFile(), children: [_jsx(Icon, { name: "upload" }), "Import CSV\u2026"] }), _jsxs("span", { className: "cr-muted retention-controls__or", children: ["or paste ", _jsx("span", { className: "cr-mono", children: "time,retention" }), " rows:"] })] }), _jsx("textarea", { className: "rk-input cr-textarea", placeholder: "time,retention\n0,100\n1,97\n5,72", value: csv, onChange: (e) => setCsv(e.target.value) }), _jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => void doImport(csv), disabled: (!csv.trim() || !render) || (busy === "import"), "aria-busy": (busy === "import") || undefined, children: busy === "import" ? "Importing…" : "Import" }) }), error && _jsx(ErrorPanel, { title: "Retention failed", detail: error }), imports.length > 0 && (_jsx("div", { className: "retention-imports", children: imports.map((r) => (_jsx(Button, { variant: "ghost", size: "sm", className: "cr-chip-btn", onClick: () => void open(r.name), title: `${r.points} points${r.drop_offs != null ? ` · ${r.drop_offs} drop-offs` : ""}`, children: _jsx(Badge, { tone: active?.name === r.name ? "accent" : "neutral", children: r.name }) }, r.name))) })), active?.stale && (_jsx(ErrorPanel, { title: "This analysis is stale", detail: active.render_exists
|
|
136
|
+
? `The export ${active.render} changed since this CSV was imported. Re-import the retention data against the current file.`
|
|
137
|
+
: `The export ${active.render} no longer exists. Re-render, then import the retention data again.` })), active && active.points.length > 0 && (_jsx(Card, { children: _jsx(RetentionChart, { points: active.points, drops: drops.filter((d) => checked.has(d.id)) }) })), active && drops.length > 0 && (_jsxs("div", { className: "retention-drops", children: [_jsx("span", { className: "cr-overline", children: "Drop-off cuts" }), drops.map((d) => (_jsx(Checkbox, { className: "retention-drop", checked: checked.has(d.id), onChange: (checked) => setChecked((s) => {
|
|
138
|
+
const n = new Set(s);
|
|
139
|
+
if (checked)
|
|
140
|
+
n.add(d.id);
|
|
141
|
+
else
|
|
142
|
+
n.delete(d.id);
|
|
143
|
+
return n;
|
|
144
|
+
}), label: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: "var(--s3)" }, children: [_jsxs("span", { className: "cr-mono cr-muted", children: [timecode(d.out_start_ms), "\u2013", timecode(d.out_end_ms)] }), _jsxs(Badge, { tone: "bad", children: ["\u2212", d.drop_points.toFixed(1), " pts"] }), d.suggested && _jsx("span", { className: "cr-muted cr-clamp-1", children: d.suggested }), _jsxs("span", { className: "cr-muted retention-drop__map", children: ["maps to ", d.source_mappings.length, " source range", d.source_mappings.length === 1 ? "" : "s"] })] }) }, d.id))), _jsx("div", { children: _jsx(Button, { variant: "primary", onClick: () => void apply(), disabled: (checked.size === 0 || active.stale) || (busy === "apply"), "aria-busy": (busy === "apply") || undefined, children: busy === "apply" ? "Applying…" : _jsxs(_Fragment, { children: ["Apply ", checked.size, " cut", checked.size === 1 ? "" : "s"] }) }) })] })), active && drops.length === 0 && !active.stale && (_jsx("p", { className: "cr-muted retention-none", children: "No drop-off cuts proposed for this import." })), imports.length === 0 && !error && (_jsx(EmptyState, { title: "No retention data yet", icon: _jsx(Icon, { name: "chart-line", width: 28, height: 28 }), children: "Export a render, then import the retention CSV your platform gives you." }))] }) }));
|
|
145
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DoctorReport, EngineStatus } from "../lib/engine.js";
|
|
2
|
+
export declare function SettingsView({ dir, doctor, error, section, }: {
|
|
3
|
+
dir: string | null;
|
|
4
|
+
status: EngineStatus | null;
|
|
5
|
+
doctor: DoctorReport | null;
|
|
6
|
+
error: string | null;
|
|
7
|
+
/** Land on this section (engine status → "engine"). */
|
|
8
|
+
section?: string;
|
|
9
|
+
}): import("react").JSX.Element;
|