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) => (
  1. {i + 1 < step ? : i + 1}
  2. ))}
); } /** 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 (
{!inModal && }
); } 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. )}
{/* 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) => ( ))}
)} {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 ( ); })}
)}