import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { videoEditApi, videoVoicesApi } from "../../api/endpoints"; import type { VideoCatalog, VideoSavedVoice, VideoTemplate } from "../../api/types"; import { Dropdown } from "../Dropdown"; import { CheckIcon } from "../icons/Icons"; import { Modal } from "../ui"; import type { PanelProps } from "./EditorPanels"; import { FONTS } from "./fonts"; import { PlayButton, TemplateThumb, useAudio } from "./parts"; const SPEEDS = [0.5, 1, 1.5, 2, 2.5]; const NO_VOICE = "__none"; const DEFAULT_FONT = "__default"; /** The thin slider's blue fill up to the handle (see .range-thin). */ const fill = (value: number, min: number, max: number) => ({ "--fill": `${((value - min) / (max - min)) * 100}%` }) as CSSProperties; const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : ""); type SaveKey = "colors" | "font" | "captions" | "speed" | "template" | "voice" | "language"; /** A card's save button: disabled until something changed, a spinner while it saves. */ function SaveButton({ dirty, saving, off, onClick, children, busyLabel = "Saving..." }: { dirty: boolean; saving: boolean; off: boolean; onClick: () => void; children: ReactNode; /** What the button says while it works ("Changing..." for the template, voice and language switches). */ busyLabel?: string; }) { return ( ); } /** The built-in templates as a grid; the picked one replaces the video's template (a background job). */ function TemplatePickerModal({ templates, current, portrait, onPick, onClose }: { templates: VideoTemplate[]; current: string; portrait: boolean; onPick: (id: string) => Promise; onClose: () => void; }) { const [picked, setPicked] = useState(null); const [busy, setBusy] = useState(false); async function go() { if (!picked) return; setBusy(true); await onPick(picked); setBusy(false); onClose(); } return ( undefined : onClose}>

Pick a new look. Scenes, narration and voiceovers stay; the layouts are redone for the new template.

{templates.map((t) => { const isCurrent = t.id === current; const on = picked === t.id; return ( ); })}
Uses 1 video
); } /** Warning before removing every scene's voiceover. Stays open (loading) until blog2video accepts the job; its * progress then shows on the editor's progress screen. */ function DeleteVoiceoverModal({ onConfirm, onClose }: { onConfirm: () => Promise; onClose: () => void }) { const [busy, setBusy] = useState(false); async function go() { setBusy(true); await onConfirm(); setBusy(false); onClose(); } return ( undefined : onClose}>

Every scene's narration is removed and the video becomes silent. This can't be undone; you'd need to pick a voice again to get narration back. Render again afterwards for a silent MP4.

); } function Card({ title, note, children }: { title: string; note: string; children: ReactNode }) { return (

{title}

{note}

{children}
); } /** Settings, laid out like blog2video's: each card saves on its own. Colours, fonts, speed and captions are a * project update; template, voice and language are background jobs. */ export function LookPanel(p: PanelProps & { catalog: VideoCatalog | null }) { const pr = p.project; const off = p.disabled; // Drafts, reset whenever the project reloads after a save. const [colors, setColors] = useState({ accent_color: pr.accent_color, text_color: pr.text_color, bg_color: pr.bg_color }); const [font, setFont] = useState(pr.font_family || DEFAULT_FONT); const [speed, setSpeed] = useState(pr.playback_speed ?? 1); const [captions, setCaptions] = useState({ captions_enabled: !!pr.captions_enabled, caption_offset: Number(pr.caption_offset ?? 0), caption_font_size: Number(pr.caption_font_size) || 36, caption_font_family: pr.caption_font_family || "inter", }); const [picking, setPicking] = useState(false); // the template picker is open const [deletingVoice, setDeletingVoice] = useState(false); // the delete-voiceover warning is open const [voiceId, setVoiceId] = useState(pr.voice_gender === "none" ? NO_VOICE : pr.custom_voice_id ?? ""); const [language, setLanguage] = useState(""); const [voices, setVoices] = useState([]); const { playing, play } = useAudio(); // The card being saved. Project updates clear it when act returns (the reload has reset the drafts, so the button // is disabled again); template, voice and language are jobs, cleared when the editor unlocks after the job. const [saving, setSaving] = useState(null); const [jobSaving, setJobSaving] = useState(false); useEffect(() => { if (jobSaving && !off) { setJobSaving(false); setSaving(null); } }, [jobSaving, off]); async function save(key: SaveKey, fn: () => Promise) { setSaving(key); await p.act(fn); setSaving(null); } /** A background job: the button keeps loading until the job ends (the editor is locked meanwhile). */ async function job(key: SaveKey, run: () => Promise) { setSaving(key); await run(); setJobSaving(true); } useEffect(() => { setColors({ accent_color: pr.accent_color, text_color: pr.text_color, bg_color: pr.bg_color }); setFont(pr.font_family || DEFAULT_FONT); setSpeed(pr.playback_speed ?? 1); setCaptions({ captions_enabled: !!pr.captions_enabled, caption_offset: Number(pr.caption_offset ?? 0), caption_font_size: Number(pr.caption_font_size) || 36, caption_font_family: pr.caption_font_family || "inter", }); setVoiceId(pr.voice_gender === "none" ? NO_VOICE : pr.custom_voice_id ?? ""); }, [pr]); useEffect(() => { // A video speaks in the free built-in voices; a Notestack voice (made on the Voice page) is for audio overviews. videoVoicesApi.list().then((v) => setVoices(v.saved.filter((x) => x.source !== "notestack"))).catch(() => undefined); }, []); const templates = [...(p.catalog?.my_templates ?? []), ...(p.catalog?.templates ?? [])]; const current = templates.find((t) => t.id === pr.template); // Only blog2video's built-in templates can be switched to (no custom ones). const builtIn = (p.catalog?.templates ?? []).filter((t) => !t.custom); const languages = p.catalog?.languages ?? []; const currentLanguage = languages.find((l) => l.code === pr.content_language)?.name ?? "Auto-detected"; const currentVoice = voices.find((v) => v.voice_id === pr.custom_voice_id); const chosenVoice = voices.find((v) => v.voice_id === voiceId); const voiceChanged = voiceId !== (pr.voice_gender === "none" ? NO_VOICE : pr.custom_voice_id ?? ""); // What is saved now, to tell whether each card has changes. const same = (a?: string | null, b?: string | null) => (a ?? "").toLowerCase() === (b ?? "").toLowerCase(); const colorsChanged = !same(colors.accent_color, pr.accent_color) || !same(colors.text_color, pr.text_color) || !same(colors.bg_color, pr.bg_color); const fontChanged = font !== (pr.font_family || DEFAULT_FONT); const captionsChanged = captions.captions_enabled !== !!pr.captions_enabled || captions.caption_offset !== Number(pr.caption_offset ?? 0) || captions.caption_font_size !== (Number(pr.caption_font_size) || 36) || captions.caption_font_family !== (pr.caption_font_family || "inter"); const speedChanged = Math.abs(speed - (pr.playback_speed ?? 1)) > 0.001; function changeVoice() { const body = voiceId === NO_VOICE ? { voice_gender: "none" } : { voice_gender: chosenVoice?.gender === "male" ? "male" : "female", voice_accent: chosenVoice?.accent === "british" ? "british" : "american", custom_voice_id: voiceId, }; job("voice", () => p.startJob("Changing the voice", () => videoEditApi.changeVoice(p.id, body), () => videoEditApi.voiceStatus(p.id))); } function changeTemplate(template: string) { return job("template", () => p.startJob("Switching template", () => videoEditApi.changeTemplate(p.id, template), () => videoEditApi.templateStatus(p.id))); } const fontOptions = FONTS.map((f) => ({ value: f.id, label: f.name })); return (
{([["accent_color", "Accent", "Buttons & highlights"], ["text_color", "Text", "On-screen text"], ["bg_color", "Background", "Scene background"]] as const).map(([k, label, hint]) => ( ))}
save("colors", () => videoEditApi.updateProject(p.id, colors))}> Save colors

Font family
save("font", () => videoEditApi.updateProject(p.id, { font_family: font === DEFAULT_FONT ? null : font }))}> Save font
{current && (
)}
Current template {current?.name ?? pr.template} {current?.description && {current.description}}
0} saving={saving === "template"} off={off} busyLabel="Changing..." onClick={() => setPicking(true)}> Change template
{deletingVoice && ( setDeletingVoice(false)} onConfirm={() => job("voice", () => p.startJob("Deleting the voiceover", () => videoEditApi.deleteVoiceover(p.id), () => videoEditApi.voiceStatus(p.id)))} /> )} {picking && ( setPicking(false)} /> )}
Captions

Shown on the preview and the final rendered video.

Font setCaptions({ ...captions, caption_font_family: v })} options={fontOptions} />
save("captions", () => videoEditApi.updateProject(p.id, captions))}> Save captions
Playback speed

Applies to the preview and the final video, voiceover included.

{SPEEDS.map((v) => ( ))}
save("speed", () => videoEditApi.updateProject(p.id, { playback_speed: speed }))}> Save speed
Voice

The narration voice for this project.

currentVoice && play(currentVoice.voice_id, currentVoice.preview_url)} /> {pr.voice_gender === "none" ? "No voiceover" : currentVoice?.name ?? "Current voice"} {currentVoice && {[cap(currentVoice.gender), cap(currentVoice.accent)].filter(Boolean).join(" • ")}}
({ value: v.voice_id, label: v.name, hint: [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ") || undefined }))]} />
Change voice
Changing the voice uses one video credit.
Language

Translate the video: text, narration and voiceover.

Current language
{currentLanguage}
Translate to l.code !== pr.content_language).map((l) => ({ value: l.code, label: l.name }))]} />
job("language", () => p.startJob("Translating", () => videoEditApi.changeLanguage(p.id, language), () => videoEditApi.languageStatus(p.id)).then(() => setLanguage("")))}> Change language
Counts as a new video (1 credit).
); }