import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { videosApi, videoVoicesApi, voiceApi } from "../api/endpoints";
import type {
VideoCatalog,
VideoConfig,
VideoCreateBody,
VideoFocusSource,
VideoFocusTopic,
VideoOptions,
VideoQuota,
VideoSavedVoice,
VideoStyleItem,
VideoTemplate,
VideoVoicesResponse,
} from "../api/types";
import { Dropdown } from "../components/Dropdown";
import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
import { ErrorText, errorMessage, Loading, PageHeader } from "../components/ui";
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
import { useUpgrade } from "../hooks/useUpgrade";
const FALLBACK_COLORS = { accent_color: "#7c3aed", bg_color: "#ffffff", text_color: "#000000" };
/** A template and the colours it was designed with. */
const templateFields = (t: VideoTemplate) => ({
template: t.id,
accent_color: t.preview_colors?.accent ?? FALLBACK_COLORS.accent_color,
bg_color: t.preview_colors?.bg ?? FALLBACK_COLORS.bg_color,
text_color: t.preview_colors?.text ?? FALLBACK_COLORS.text_color,
});
const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
function Steps({ step }: { step: number }) {
const names = ["Project", "Template", "Voice"];
return (
{names.map((name, i) => (
{i + 1 < step ? : i + 1}
))}
);
}
/** The New video page (/app/videos/new?notebook_id=… | document_id=…). */
export default function VideoCreate() {
const [params] = useSearchParams();
return ;
}
/** The three-step video wizard: as a page, or inside a modal (inModal: no page header, no step-1 Back, no outer card;
* the modal's own close button leaves it). */
export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal = false }: {
startNotebook?: string | null; startDoc?: string | null; inModal?: boolean;
}) {
const navigate = useNavigate();
const { openUpgrade } = useUpgrade();
const [config, setConfig] = useState(null);
const [catalog, setCatalog] = useState(null);
const [voices, setVoices] = useState(null);
const [quota, setQuota] = useState(null);
const [loadError, setLoadError] = useState(null);
const [step, setStep] = useState(1);
const [source, setSource] = useState(
startNotebook ? { kind: "notebook", id: startNotebook } : startDoc ? { kind: "post", id: startDoc }
: sourceCache.archiveId ? { kind: "notebook", id: sourceCache.archiveId } : null,
);
const [postIds, setPostIds] = useState([]);
// Step 1's "focus on": the AI's three suggestions for the source chosen (sourceKey), and the one picked (none =
// the whole material).
const [topics, setTopics] = useState(null);
const topicsFor = useRef(""); // the sourceKey of the suggestions shown or on their way
const [focus, setFocus] = useState(null);
// Or the user's own topic: one or the other (or neither, for a general video).
const [custom, setCustom] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const { playing, play, stop } = useAudio();
/** Notestack voices have no stored sample: the Voice page speaks a line in them. */
function playVoice(v: VideoSavedVoice) {
if (v.source !== "notestack") return play(v.voice_id, v.preview_url);
if (playing === v.voice_id) return stop();
voiceApi.preview(v.voice_id).then((r) => play(v.voice_id, r.url)).catch((e) => setError(errorMessage(e)));
}
const [form, setForm] = useState({
stock_footage_enabled: true,
aspect_ratio: "landscape",
video_length: "medium", // no length picker: the server sets it from a house style
logo_position: "bottom_right",
logo_opacity: 0.9,
template: "default",
video_style: "auto",
...FALLBACK_COLORS,
content_language: null,
voice_gender: "female",
voice_accent: "american",
bgm_volume: 0.1,
});
const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch }));
const gridBox = useRef(null);
const loadVoices = useCallback(
() => videoVoicesApi.list().then((v) => {
setVoices(v);
return v;
}),
[],
);
useEffect(() => {
videosApi.config().then(setConfig).catch((e) => setLoadError(errorMessage(e)));
videosApi.quota().then(setQuota).catch(() => undefined);
// Nothing picked on the way in: start from every post, in the "All posts" notebook.
if (!startNotebook && !startDoc && !sourceCache.archiveId) {
loadArchiveId().then((id) => setSource((s) => s ?? { kind: "notebook", id })).catch(() => undefined);
}
}, [startNotebook, startDoc]);
useEffect(() => {
if (!config?.configured) return;
videosApi.catalog().then((c) => {
setCatalog(c);
// Start on a random built-in template, unless the form already names a real one.
const first = c.templates[Math.floor(Math.random() * c.templates.length)];
const known = new Set([...c.my_templates, ...c.templates].map((t) => t.id));
if (first) setForm((f) => (known.has(f.template) ? f : { ...f, ...templateFields(first) }));
// Start on the first house style (Overview) unless the form already names one on offer.
const house = c.video_styles.filter((s) => s.kind === "house");
if (house.length) setForm((f) => (house.some((s) => s.id === f.video_style) ? f : { ...f, video_style: house[0].id }));
}).catch((e) => setLoadError(errorMessage(e)));
loadVoices()
.then((v) => v.saved[0] && pickVoice(v.saved[0]))
.catch(() => setVoices({ saved: [], library: [], notestack: [] }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config?.configured]);
// Keep the chosen template visible inside the template grid's own scroll box (never scrolls the page).
useEffect(() => {
const box = gridBox.current;
const card = box?.querySelector(`[data-id="${CSS.escape(form.template)}"]`);
if (!box || !card) return;
const top = card.offsetTop; // the box is position: relative, so this is measured from its top
if (top < box.scrollTop || top + card.offsetHeight > box.scrollTop + box.clientHeight) {
box.scrollTop = top - 8;
}
}, [form.template, step, catalog]);
const templates = catalog?.templates ?? [];
const shown: VideoTemplate[] = templates;
// The house styles alone when the account has them; otherwise "Auto" and the rest, as before.
const listed = catalog?.video_styles ?? [];
const houseStyles = listed.some((s) => s.kind === "house");
const styles: VideoStyleItem[] = houseStyles ? listed : [{ id: "auto", name: "Auto", kind: "builtin" }, ...listed];
const picked = styles.find((s) => s.id === form.video_style);
// A video speaks in a free built-in voice; a Notestack voice (made on the Voice page) is for audio overviews.
const saved = (voices?.saved ?? []).filter((v) => v.source !== "notestack");
const left = quota ? Math.max(quota.limit - quota.used, 0) : null;
function pickTemplate(t: VideoTemplate) {
set(templateFields(t));
}
function pickVoice(v: VideoSavedVoice) {
setForm((f) => ({
...f,
voice_gender: v.gender === "male" ? "male" : "female",
voice_accent: v.accent === "british" ? "british" : "american",
custom_voice_id: v.voice_id,
}));
}
const sourceReady = source?.kind === "post" || (source?.kind === "notebook" && postIds.length > 0);
/** Step 1's choice as API fields: the same for the focus suggestions and the create. */
const sourceFields = (): VideoFocusSource | null => !source ? null
: source.kind === "post" ? { document_id: source.id }
: { document_ids: postIds, notebook_id: source.id };
// The focus suggestions belong to one source choice. They are asked for as soon as the choice is complete and show
// below it on step 1; a new source or other ticks asks again. The short wait lets a run of ticks settle into one
// request.
const sourceKey = !source ? ""
: `${source.kind}:${source.id}:${source.kind === "notebook" ? [...postIds].sort().join(",") : ""}`;
useEffect(() => {
if (!sourceReady || sourceKey === topicsFor.current) return;
const fields = sourceFields();
if (!fields) return;
const timer = setTimeout(() => {
topicsFor.current = sourceKey;
setTopics(null);
setFocus(null);
// An answer for a choice that has since changed is dropped (topicsFor moved on). A failure counts as no
// topics: the block hides, and this choice is not asked again.
videosApi.focusTopics(fields)
.then((r) => topicsFor.current === sourceKey && setTopics(r.topics))
.catch(() => topicsFor.current === sourceKey && setTopics([]));
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey, sourceReady]);
// Suggestions made for an earlier choice are never shown for this one: the cards wait for the new ones (and so
// does Go to step 2). null = still loading; [] = none to show.
const shownTopics = topicsFor.current === sourceKey ? topics : null;
/** The focus for the create: a suggested topic, the user's own, or none (a general video). */
function focusFields(): Pick {
if (focus) return { focus: focus.title, focus_detail: focus.description };
return custom.trim() ? { focus_prompt: custom.trim() } : {};
}
function options(): VideoOptions {
const o: VideoOptions = { ...form };
if (o.voice_gender === "none") delete o.custom_voice_id;
return o;
}
async function submit() {
setError(null);
setBusy(true);
try {
const from = sourceFields();
if (!from) return;
const a = await videosApi.create({ ...options(), ...from, ...focusFields() });
navigate(`/app/videos/${a.id}`);
} catch (e) {
setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts)
setBusy(false);
}
}
if (loadError) {
return (
);
}
if (!config) return ;
if (!config.configured) {
return (
{!inModal &&
}
Video is not set up on this server yet.
);
}
const currentVoice = saved.find((v) => v.voice_id === form.custom_voice_id);
return (
{inModal ? (
left !== null &&
{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left
) : (
{left !== null && (
{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left
)}
)}
{step === 1 && !inModal && (
// Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
(window.history.length > 1 ? navigate(-1) : navigate("/app/archive?tab=videos"))}>
Back
)}
{/* Stays mounted on steps 2 and 3, so going Back keeps the loaded posts and the ticks */}
{step === 1 && (
<>
{/* Landscape with stock footage, always: the form's defaults, no picker */}
Video style
{catalog === null ?
: (
{styles.map((s) => (
set({ video_style: s.id })}>
{s.name}{s.kind === "custom" ? " (yours)" : ""}
))}
)}
{houseStyles && picked?.description && (
{picked.description}
)}
{/* The suggestions are asked for as soon as the source is ready (hidden if there are none); the custom
topic is always there */}
{sourceReady && (
What should the video focus on?
{shownTopics?.length !== 0 && (
{shownTopics === null
? [0, 1, 2].map((i) => (
))
: shownTopics.map((t) => {
const on = focus?.title === t.title;
return (
{
setFocus(on ? null : t);
setCustom("");
}}>
{t.title}
{t.description && {t.description} }
{on && }
);
})}
)}
Custom topic
)}
{error && }
{/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
setStep(2)}>
Go to step 2
>
)}
{step === 2 && (
<>
{catalog === null ? (
) : shown.length === 0 ? (
No templates available.
) : (
{shown.map((t) => (
pickTemplate(t)}>
{t.custom ? Yours
: (t.badge || t.popular_template) && {t.badge ?? "Popular"} }
{t.name}
{form.template === t.id && }
))}
)}
setStep(1)}>Back
setStep(3)}>
Go to step 3
>
)}
{step === 3 && (
<>
Language
{/* "" is Auto: the language is detected from the content */}
({ value: l.code, label: l.name })),
]}
onChange={(v) => set({ content_language: v || null })}
/>
Language of the video content
{
if (e.target.checked) return set({ voice_gender: "none" });
const v = currentVoice ?? saved[0];
if (v) pickVoice(v);
else set({ voice_gender: "female" });
}} />
No voiceover
Voice: play to preview
{voices === null ? (
) : saved.length === 0 ? (
No voices yet. Add some in Manage voices.
) : (
{saved.map((v) => {
const on = form.custom_voice_id === v.voice_id && form.voice_gender !== "none";
return (
playVoice(v)} />
pickVoice(v)}>
{v.name}
{[cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}
{on && }
);
})}
)}
{error && }
{left === 0 && (
You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
openUpgrade()}>See plans
)}
setStep(2)} disabled={busy}>Back
{busy ? "Launching..." : "Generate Video"}
>
)}
);
}