import { useState, useRef, useEffect, useCallback, useMemo, type ReactNode } from "react"; import ReactDOM from "react-dom"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../hooks/useAuth"; import { isPaidPlan } from "../lib/plan"; import { useCraftedTemplates } from "../contexts/CraftedTemplatesContext"; import { useErrorModal } from "../contexts/ErrorModalContext"; import { BulkLinksSection } from "./BulkLinksSection"; import { classifyUrl, classifyUrlScrapability } from "../utils/urlScrapability"; import { getVoicePreviews, getMyVoices, getPrebuiltVoices, previewVoice, getBgmTracks, BACKEND_URL, type TemplateMeta, type CraftedTemplateItem, type VoicePreview, type BulkProjectItem, type CustomTemplateItem, type SavedVoiceFromAPI, type ElevenLabsVoice } from "../api/client"; import { primeBlogUrlFormStep2Prefetch, fetchBlogUrlFormBuiltinTemplatesDeduped, fetchBlogUrlFormAvailabilityDeduped, } from "../api/blogUrlFormStep2Prefetch"; import { VIDEO_STYLE_OPTIONS, normalizeVideoStyle, type VideoStyleId } from "../constants/videoStyles"; import { SUPPORTED_CONTENT_LANGUAGES, getLanguageOptionLabel } from "../constants/languages"; import UpgradePlanModal from "./UpgradePlanModal"; import { TEMPLATE_PREVIEWS, TEMPLATE_DESCRIPTIONS, NewTemplateBadge, PopularTemplateBadge, CustomTemplateBadge } from "./templatePreviewRegistry"; import CustomPreview from "./templatePreviews/CustomPreview"; import CustomPreviewLandscape from "./templatePreviews/CustomPreviewLandscape"; import CraftedTemplatePreviewSmart from "./templatePreviews/CraftedTemplatePreviewSmart"; import useIsMobileViewport from "../hooks/useIsMobileViewport"; import CraftYourTemplateCard from "./CraftYourTemplateCard"; import GetMoreTemplatesModal from "./GetMoreTemplatesModal"; import DesignerTemplateRequestModal from "./DesignerTemplateRequestModal"; import CraftYourVoiceCard from "./CraftYourVoiceCard"; import VoiceItem, { formatVoiceSubtitle, getMyVoiceDisplayName, subtitleForSavedVoice } from "./VoiceItem"; import AdvancedVoiceOptions from "./AdvancedVoiceOptions"; import { VOICE_STABILITY_DEFAULT, VOICE_STABILITY_MIN, VOICE_STABILITY_MAX, VOICE_SPEED_DEFAULT, VOICE_SPEED_MIN, VOICE_SPEED_MAX, VOICE_TUNING_STEP, VOICE_STYLE_DEFAULT, VOICE_STYLE_MIN, VOICE_STYLE_MAX, VOICE_EMOTIONS, parseVoiceTuning, serializeVoiceTuning, } from "./voiceTuning"; export const VIDEO_STYLES = VIDEO_STYLE_OPTIONS; const DEFAULT_VIDEO_STYLE: VideoStyleId = "auto"; const CRAFTED_TEMPLATE_MENU_THUMBNAIL_FRAME = 128; // ~85% of 5s * 30fps first scene /** Source-bucket sentinel values for the genre dropdown (not real template genres). */ const GENRE_CUSTOM = "__custom__"; export const GENRE_CRAFTED = "__crafted__"; const GENRE_NEW = "__new__"; const GENRE_POPULAR = "__popular__"; function genreTemplateListCaption(genreFilter: string): string { if (genreFilter === GENRE_CUSTOM) return "Custom templates"; if (genreFilter === GENRE_CRAFTED) return "Designer templates"; if (genreFilter === GENRE_NEW) return "New templates"; if (genreFilter === GENRE_POPULAR) return "Popular templates"; if (genreFilter) return `Templates for ${genreFilter}`; return "All templates"; } function templateBucketsForGenre( genreFilter: string, builtinTemplates: TemplateMeta[], readyCustomTemplates: CustomTemplateItem[], readyCraftedTemplates: CraftedTemplateItem[] ): { suggestedTemplates: TemplateMeta[]; customTemplatesForStyle: CustomTemplateItem[]; craftedTemplatesForStyle: CraftedTemplateItem[]; } { const sourceList = builtinTemplates; if (genreFilter === GENRE_CUSTOM) { return { suggestedTemplates: [], customTemplatesForStyle: readyCustomTemplates, craftedTemplatesForStyle: [], }; } if (genreFilter === GENRE_CRAFTED) { return { suggestedTemplates: [], customTemplatesForStyle: [], craftedTemplatesForStyle: readyCraftedTemplates, }; } if (genreFilter === GENRE_NEW) { return { suggestedTemplates: sourceList.filter((t) => t.new_template === true), customTemplatesForStyle: [], craftedTemplatesForStyle: [], }; } if (genreFilter === GENRE_POPULAR) { return { suggestedTemplates: sourceList.filter((t) => t.popular_template === true), customTemplatesForStyle: [], craftedTemplatesForStyle: [], }; } if (genreFilter) { const matchesGenre = (g?: string[]): boolean => (g ?? []).includes(genreFilter); return { suggestedTemplates: sourceList.filter((t) => matchesGenre(t.genres)), customTemplatesForStyle: [], craftedTemplatesForStyle: [], }; } return { suggestedTemplates: sourceList, customTemplatesForStyle: readyCustomTemplates, craftedTemplatesForStyle: readyCraftedTemplates, }; } /** First entry in template `genres` from meta.json; "" if missing. */ function defaultGenreForTemplate(meta: TemplateMeta | undefined | null): string { const raw = meta?.genres?.[0]; return typeof raw === "string" && raw.trim() !== "" ? raw : ""; } /** Genre aligned with a bulk row template id (built-in meta or custom genres). */ function defaultGenreForBulkTemplateId( templateId: string, builtinTemplates: TemplateMeta[], customTemplatesList: CustomTemplateItem[] ): string { if (templateId.startsWith("custom_")) { const cid = parseInt(templateId.replace("custom_", ""), 10); if (Number.isNaN(cid)) return ""; const ct = customTemplatesList.find((t) => t.id === cid); return ct?.genres?.[0] ?? ""; } return defaultGenreForTemplate(builtinTemplates.find((t) => t.id === templateId)); } /** * After the style/genre split: video_style no longer changes per template — it's an orthogonal * user choice (Auto by default). These helpers exist only as no-op shims so existing call sites * keep compiling; they always return the form-wide default style. */ function defaultVideoStyleForTemplate(_meta: TemplateMeta | undefined | null): VideoStyleId { return DEFAULT_VIDEO_STYLE; } function videoStyleForBulkTemplateId( _templateId: string, _builtinTemplates: TemplateMeta[], _customTemplatesList: CustomTemplateItem[] ): VideoStyleId { return DEFAULT_VIDEO_STYLE; } /** Read-only demo mode used by help videos: forces step + seeds state without firing API calls. */ export interface BlogUrlFormDemoMode { step: 1 | 2 | 3; url?: string; name?: string; videoLength?: "short" | "medium" | "detailed"; template?: string; videoStyle?: VideoStyleId; voiceGender?: "female" | "male" | "none"; voiceAccent?: string; customVoiceId?: string; templatesData?: TemplateMeta[]; customTemplatesData?: CustomTemplateItem[]; myVoicesData?: SavedVoiceFromAPI[]; voicePreviewsData?: Record; /** Replaces every built-in template preview with a static filler (used by help videos). */ templatePreviewOverride?: (opts: { templateId: string; selected: boolean; thumbnail: boolean }) => ReactNode; } interface Props { onSubmit: ( url: string, name?: string, voiceGender?: string, voiceAccent?: string, accentColor?: string, bgColor?: string, textColor?: string, animationInstructions?: string, logoFile?: File, logoPosition?: string, logoOpacity?: number, customVoiceId?: string, aspectRatio?: string, uploadFiles?: File[], template?: string, videoStyle?: VideoStyleId, videoLength?: "short" | "medium" | "detailed" | "more_detailed", contentLanguage?: string | null, voiceEmotion?: string, bgmTrackId?: string | null, bgmVolume?: number ) => Promise; /** Bulk create: one call with array of configs; per-project logo via logoIndices + logoFiles. */ onSubmitBulk?: (items: BulkProjectItem[], logoOptions: { logoIndices: number[]; logoFiles: File[] } | null) => Promise; /** * Extra creation options that don't fit `onSubmit`'s positional list (already * 22 args). Read by the caller when building the create payload; the form * pushes the current values here whenever they change. */ onExtraOptionsChange?: (opts: { stockFootageEnabled: boolean }) => void; loading?: boolean; asModal?: boolean; onClose?: () => void; /** Invoked before navigating to My Templates to craft a template (e.g. close the new-project modal). */ onDismissFlow?: () => void; /** When set, the form renders in read-only demo mode for help videos. */ demoMode?: BlogUrlFormDemoMode; /** Pre-select a genre filter when step 2 opens (e.g. GENRE_CRAFTED to show Designer Templates). */ initialGenre?: string; } const MAX_UPLOAD_FILES = 5; const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; const viteEnv = typeof import.meta !== "undefined" ? import.meta.env : undefined; const processEnv = typeof globalThis !== "undefined" && "process" in globalThis ? (globalThis as { process?: { env?: Record } }).process?.env : undefined; const MAX_BULK_LINKS = (() => { const raw = (viteEnv?.VITE_MAX_BULK_LINKS || processEnv?.VITE_MAX_BULK_LINKS) as | string | undefined; const parsed = raw ? parseInt(raw, 10) : NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : 10; })(); /** Estimated wall-clock range per tier (UI only; backend still uses short | medium | detailed). */ const VIDEO_LENGTH_DURATION_LABELS: Record<"short" | "medium" | "detailed" | "more_detailed", string> = { short: "Short ~ 30 sec – 1 min", medium: "Medium ~ 1 - 3 mins", detailed: "Detailed ~ 3 – 8 mins", more_detailed: "More Detailed ~ 8+ mins", }; /** * Minimum source-content word count each tier needs to actually reach that length. * Mirrors the backend thresholds in pipeline.py (_effective_video_length_for_content): * below these counts the video is automatically shortened to a smaller tier. */ const VIDEO_LENGTH_MIN_WORDS: Partial> = { detailed: 1500, more_detailed: 2000, }; /** * Long-form lengths reserved for paid plans; FREE users top out at "medium". * Mirrors _PAID_ONLY_VIDEO_LENGTHS in backend/app/routers/projects.py, which * rejects these with 403 `video_length_requires_paid` — this is the UI half of * the same rule, not the enforcement. */ const PAID_ONLY_VIDEO_LENGTHS = new Set(["detailed", "more_detailed"]); const ALLOWED_EXTENSIONS = [".pdf", ".docx", ".pptx", ".md", ".markdown", ".txt", ".vtt"]; const ALLOWED_TYPES = [ "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", "text/x-markdown", "text/vtt", ]; const VOICE_PREVIEW_KEYS = ["female_american", "female_british", "male_american", "male_british"]; const normalizeVoiceGender = (value?: string | null): "female" | "male" | null => { const v = (value ?? "").trim().toLowerCase(); if (v === "female" || v === "male") return v; return null; }; const normalizeVoiceAccent = (value?: string | null): string | null => { const v = (value ?? "").trim(); if (!v) return null; return v.toLowerCase(); }; // Step indicator — order: 1 Content, 2 Template, 3 Voice function StepIndicator({ current, total }: { current: number; total: number }) { const stepLabels = ["Project", "Template", "Voice"]; return (
{Array.from({ length: total }, (_, i) => i + 1).map((n) => (
{n < current ? ( ) : ( n )}
{n < total && (
)}
))}
Step {current} — {stepLabels[current - 1]}
); } // ─── Template video player lightbox interface VideoLightboxProps { templateId: string; onClose: () => void; onSelect: () => void; isSelected: boolean; customTemplate?: CustomTemplateItem | CraftedTemplateItem | null; } function TemplateVideoLightbox({ templateId, onClose, onSelect, isSelected, customTemplate }: VideoLightboxProps) { const { ensureCraftedTemplateDetail } = useCraftedTemplates(); const PreviewComp = TEMPLATE_PREVIEWS[templateId]; const desc = TEMPLATE_DESCRIPTIONS[templateId]; const title = customTemplate ? customTemplate.name : (desc?.title ?? templateId); const subtitle = customTemplate ? (templateId.startsWith("crafted_") ? "Designer template" : "Custom template") : desc?.subtitle; // Crafted templates ship summary-only by default; the full layout package // (frontend_files, frontend_entry_rel, public_asset_urls) is fetched here // on demand so the lightbox can render the real composition. The context // dedupes concurrent calls and caches the result. useEffect(() => { if (templateId.startsWith("crafted_")) { void ensureCraftedTemplateDetail(templateId); } }, [templateId, ensureCraftedTemplateDetail]); // Close on Escape useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]); return ReactDOM.createPortal(
{/* Backdrop */}
{/* Player container */}
{/* Screen bezel */}
{/* Top bar */}
{/* macOS-style dots */}
{title} — Preview
{/* Video content */}
{customTemplate && customTemplate.theme ? ( ) : PreviewComp ? ( ) : (
No preview available
)}
{/* Bottom controls bar */}
{subtitle}
, document.body ); } type BlogUrlFormDemoStep = 1 | 2 | 3; /** * @deprecated Use `` directly. Kept temporarily for backwards compatibility. */ export function BlogUrlFormDemoModal({ step, focus = "source", }: { step: BlogUrlFormDemoStep; focus?: "new" | "source" | "template" | "voice" | "generate"; }) { const selectedTemplateId = "spotlight"; const SelectedPreview = TEMPLATE_PREVIEWS[selectedTemplateId]; const selectedDesc = TEMPLATE_DESCRIPTIONS[selectedTemplateId]; const demoTemplates = ["spotlight", "nightfall", "gridcraft", "default"]; const inputClass = "w-full px-4 py-2.5 bg-white/80 border border-gray-200/60 rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all"; const step1 = (
{(["url", "upload", "bulk"] as const).map((mode) => ( ))}
); const step2 = (
{SelectedPreview ? : null}
{selectedDesc?.title ?? "Spotlight"}
{selectedDesc?.subtitle}
{VIDEO_STYLES.map((style) => ( ))}

Suggested templates for the selected video style

undefined} onKeyDown={() => undefined} /> {demoTemplates.map((templateId) => { const Preview = TEMPLATE_PREVIEWS[templateId]; const desc = TEMPLATE_DESCRIPTIONS[templateId]; const selected = templateId === selectedTemplateId; return (
{Preview ? : null} {selected && (
)}
{desc?.title ?? templateId}
); })}
); const voiceRows = [ { name: "Rachel", subtitle: "Female - American - clear product narration", selected: true }, { name: "Alice", subtitle: "Female - British - soft and polished", selected: false }, { name: "Bill", subtitle: "Male - American - friendly and articulate", selected: false }, ]; const step3 = (

Choose narration language. Keep Auto to detect from content.

Language of the video content

{voiceRows.map((voice) => ( undefined} isSelected={voice.selected} actions={ voice.selected ? (
) : null } /> ))}
); const content = step === 1 ? step1 : step === 2 ? step2 : step3; return (

New Project

{content}
); } function deriveNameFromUrl(url: string): string { try { const parsed = new URL(url); const segments = parsed.pathname.replace(/\/$/, "").split("/"); const path = segments[segments.length - 1] || parsed.hostname; return path.replace(/[-_]/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()).slice(0, 100) || "Untitled Project"; } catch { return "Untitled Project"; } } /** Returns true if the trimmed string has any whitespace in the middle (not just at start/end). */ function hasSpacesInMiddle(s: string): boolean { const t = s.trim(); return t.length > 0 && /\s/.test(t); } /** Treat as a link only if it contains a dot (e.g. example.com). Rejects single words or plain sentences. */ function containsDot(s: string): boolean { return s.trim().includes("."); } const FILE_EXTENSIONS = [".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".odt", ".odp", ".ods", ".txt", ".md", ".markdown", ".rtf", ".csv"]; /** Returns the matched file extension if the URL ends with a document extension, else null. */ function getFileExtension(s: string): string | null { const trimmed = s.trim().toLowerCase(); // Check the raw input first (covers "hello.pdf", "example.com/file.pdf", etc.) const directMatch = FILE_EXTENSIONS.find((ext) => trimmed.endsWith(ext)); if (directMatch) return directMatch; // Also check the pathname of a parsed URL (handles query strings, e.g. site.com/doc.pdf?v=1) try { const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); const path = url.pathname.toLowerCase(); return FILE_EXTENSIONS.find((ext) => path.endsWith(ext)) ?? null; } catch { return null; } } export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChange, loading, asModal, onClose, onDismissFlow, demoMode, initialGenre }: Props) { const { user } = useAuth(); const { showError } = useErrorModal(); const navigate = useNavigate(); const isPro = demoMode ? true : isPaidPlan(user?.plan); const isDemo = !!demoMode; // On mobile, crafted/custom grid tiles render a static themed placeholder // instead of a live Remotion Player; only the selected tile plays live. A grid // of live Players exhausts iOS Safari's per-tab memory and reloads the tab. const isMobile = useIsMobileViewport(); // Wizard step const [step, setStep] = useState<1 | 2 | 3>(demoMode?.step ?? 1); useEffect(() => { if (demoMode?.step) setStep(demoMode.step); }, [demoMode?.step]); // Step 1 — input const [mode, setMode] = useState<"url" | "upload" | "bulk">("url"); const [urls, setUrls] = useState([""]); const [name, setName] = useState(""); const [docFiles, setDocFiles] = useState([]); const [docError, setDocError] = useState(null); const docInputRef = useRef(null); /** URL validation error for single-link mode (shown only after blur). */ const [urlError, setUrlError] = useState(null); // Bulk: rows (url); per-row name, template, voice, format, logo const [bulkRows, setBulkRows] = useState<{ url: string }[]>([{ url: "" }]); const [bulkNames, setBulkNames] = useState([""]); const [bulkTemplates, setBulkTemplates] = useState(["default"]); const bulkTemplatesRef = useRef(["default"]); bulkTemplatesRef.current = bulkTemplates; const [bulkVoiceGender, setBulkVoiceGender] = useState<("female" | "male" | "none")[]>(["female"]); const [bulkVoiceAccent, setBulkVoiceAccent] = useState(["american"]); const [bulkVoiceStability, setBulkVoiceStability] = useState(() => [parseVoiceTuning(user?.preferred_voice_emotion)[0]]); const [bulkVoiceSpeed, setBulkVoiceSpeed] = useState(() => [parseVoiceTuning(user?.preferred_voice_emotion)[1]]); const [bulkVoiceEmotion, setBulkVoiceEmotion] = useState(() => [parseVoiceTuning(user?.preferred_voice_emotion)[2]]); const [bulkVoiceStyle, setBulkVoiceStyle] = useState(() => [parseVoiceTuning(user?.preferred_voice_emotion)[3]]); const [bulkCustomVoiceId, setBulkCustomVoiceId] = useState([]); const [bulkContentLanguage, setBulkContentLanguage] = useState(["auto"]); const [bulkVideoLength, setBulkVideoLength] = useState<("short" | "medium" | "detailed" | "more_detailed")[]>(["short"]); const [bulkAspectRatio, setBulkAspectRatio] = useState<("landscape" | "portrait")[]>(["landscape"]); const [bulkVideoStyles, setBulkVideoStyles] = useState([DEFAULT_VIDEO_STYLE]); const bulkStyleManuallySet = useRef([false]); // Empty string = "not yet set from template"; we derive from template.preview_colors on step 2. const [bulkAccentColors, setBulkAccentColors] = useState([""]); const [bulkBgColors, setBulkBgColors] = useState([""]); const [bulkTextColors, setBulkTextColors] = useState([""]); const [bulkActiveIndex, setBulkActiveIndex] = useState(0); const [bulkLogoFile, setBulkLogoFile] = useState<(File | null)[]>([null]); const [bulkLogoPosition, setBulkLogoPosition] = useState(["bottom_right"]); const [bulkLogoOpacity, setBulkLogoOpacity] = useState([0.9]); const [bulkLogoRowIndex, setBulkLogoRowIndex] = useState(null); const bulkLogoInputRef = useRef(null); const [bulkApplyLengthAll, setBulkApplyLengthAll] = useState(true); const [bulkLengthMasterIndex, setBulkLengthMasterIndex] = useState(0); const [bulkStockFootage, setBulkStockFootage] = useState([true]); const [bulkApplyStockAll, setBulkApplyStockAll] = useState(true); const [bulkStockMasterIndex, setBulkStockMasterIndex] = useState(0); const [bulkApplyTemplateAll, setBulkApplyTemplateAll] = useState(true); const [bulkTemplateMasterIndex, setBulkTemplateMasterIndex] = useState(0); const [bulkApplyVoiceAll, setBulkApplyVoiceAll] = useState(true); const [bulkVoiceMasterIndex, setBulkVoiceMasterIndex] = useState(0); // Step 2 — voice const [voiceGender, setVoiceGender] = useState<"female" | "male" | "none">("female"); const [voiceAccent, setVoiceAccent] = useState("american"); // Voice tuning sliders (paid). Initialized from the remembered per-user preference so a returning // user sees their last settings pre-selected. const [voiceStability, setVoiceStability] = useState(() => parseVoiceTuning(user?.preferred_voice_emotion)[0]); const [voiceSpeed, setVoiceSpeed] = useState(() => parseVoiceTuning(user?.preferred_voice_emotion)[1]); const [voiceEmotion, setVoiceEmotion] = useState(() => parseVoiceTuning(user?.preferred_voice_emotion)[2]); const [voiceStyle, setVoiceStyle] = useState(() => parseVoiceTuning(user?.preferred_voice_emotion)[3]); // Live voice-preview playback state (Advanced Options "Listen"). const [previewState, setPreviewState] = useState<"idle" | "loading" | "playing">("idle"); const previewAudioRef = useRef(null); const previewUrlRef = useRef(null); const stopVoicePreview = useCallback(() => { previewAudioRef.current?.pause(); previewAudioRef.current = null; if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = null; } setPreviewState("idle"); }, []); // Synthesize + play a short sample with the given voice + tuning. Toggling while playing stops it. const handleVoicePreview = useCallback( async (args: { gender: string; accent: string; customVoiceId: string; stability: number; speed: number; emotion: string; style: number }) => { if (previewState === "playing") { stopVoicePreview(); return; } if (previewState === "loading") return; setPreviewState("loading"); try { const url = await previewVoice({ voice_gender: args.gender, voice_accent: args.accent, custom_voice_id: args.customVoiceId || undefined, voice_emotion: serializeVoiceTuning(args.stability, args.speed, args.emotion, args.style, true), }); if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = url; const audio = new Audio(url); audio.addEventListener("ended", stopVoicePreview); previewAudioRef.current = audio; await audio.play(); setPreviewState("playing"); } catch (err: any) { setPreviewState("idle"); const status = err?.response?.status; if (status === 429) showError("Please wait a moment before previewing again."); else if (status === 403) showError("Voice preview requires a Pro or Standard plan."); else showError("Couldn't generate a voice preview. Please try again."); } }, [previewState, stopVoicePreview, showError] ); // Stop any preview playback when the component unmounts. useEffect(() => () => stopVoicePreview(), [stopVoicePreview]); // Whether the "Advanced Options" tab (voice tuning sliders) is expanded. Paid-only. const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); // Active tab in the Step 3 voice/music panel: voice | bgm | advanced (paid). const [voicePanelTab, setVoicePanelTab] = useState<"voice" | "bgm" | "advanced">("voice"); // Master switch for the expressive v3 path. Off → standard v2 voice. Only when ON is the voice // tuning sent (which is what routes the project through eleven_v3 + [excited] + emotive narration). // Defaults ON for users who previously enabled it (a saved preference exists) — it stays enabled // across sessions until they explicitly turn it off on a voiced video. const [expressiveEnabled, setExpressiveEnabled] = useState(() => parseVoiceTuning(user?.preferred_voice_emotion)[4]); const [contentLanguage, setContentLanguage] = useState("auto"); const [videoLength, setVideoLength] = useState<"short" | "medium" | "detailed" | "more_detailed">("short"); const [customVoiceId, setCustomVoiceId] = useState(""); const [voicePreviews, setVoicePreviews] = useState>({}); const [myVoicesList, setMyVoicesList] = useState([]); const [myVoicesLoading, setMyVoicesLoading] = useState(true); const [premiumTeaserVoices, setPremiumTeaserVoices] = useState([]); const audioRef = useRef(null); const preloadedAudioRef = useRef>({}); const [playingKey, setPlayingKey] = useState(null); const voiceGenderRef = useRef(voiceGender); const customVoiceIdRef = useRef(customVoiceId); voiceGenderRef.current = voiceGender; customVoiceIdRef.current = customVoiceId; // Background music const [bgmTracks, setBgmTracks] = useState([]); const [selectedBgmTrackId, setSelectedBgmTrackId] = useState(null); const [selectedBgmVolume, setSelectedBgmVolume] = useState(0.10); const [bgmPlayingId, setBgmPlayingId] = useState(null); const bgmAudioRef = useRef(null); // Step 2 — video style & template const [videoStyle, setVideoStyle] = useState(DEFAULT_VIDEO_STYLE); const styleManuallySet = useRef(false); /** Genre dropdown filter — populated dynamically from all templates' meta.genres. "" = show all. */ const [genre, setGenre] = useState(initialGenre ?? ""); const [genreDropdownOpen, setGenreDropdownOpen] = useState(false); const [template, setTemplate] = useState("default"); // Stock footage at generation time: available on every plan and every // template (builtin, custom, crafted). Free users get a clip on a single // scene (the backend caps it), paid users on all image-capable scenes. const [stockFootageEnabled, setStockFootageEnabled] = useState(true); const stockFootageAvailable = true; useEffect(() => { onExtraOptionsChange?.({ stockFootageEnabled: stockFootageAvailable && stockFootageEnabled }); }, [onExtraOptionsChange, stockFootageAvailable, stockFootageEnabled]); const [templates, setTemplates] = useState([]); const { craftedTemplates, loading: craftedTemplatesCacheLoading, initialized: craftedTemplatesInitialized, ensureCraftedTemplateDetail } = useCraftedTemplates(); // When a crafted template is selected, fetch its full bundle (frontend_files, // entry, public assets) on demand so its picker card can render the REAL // composition via the module graph (falls back to the marquee until ready). useEffect(() => { if (template.startsWith("crafted_")) { void ensureCraftedTemplateDetail(template); } }, [template, ensureCraftedTemplateDetail]); /** Built-in template list fetch — drives step 2 loading overlay (often warmed by Dashboard prefetch). */ const [builtinTemplatesLoading, setBuiltinTemplatesLoading] = useState(true); /** After built-ins load: session random pick (or skip) has finished — step 2 can interact. */ const [sessionBuiltinInitDone, setSessionBuiltinInitDone] = useState(false); /** Random built-in default for new rows / initial pick; stable until templates reload. */ const [pickerDefaultTemplateId, setPickerDefaultTemplateId] = useState("default"); const templateManuallySelectedRef = useRef(false); const [aspectRatio, setAspectRatio] = useState<"landscape" | "portrait">("landscape"); const [accentColor, setAccentColor] = useState("#7C3AED"); const [bgColor, setBgColor] = useState("#FFFFFF"); const [textColor, setTextColor] = useState("#000000"); const [logoFile, setLogoFile] = useState(null); const [logoPosition, setLogoPosition] = useState("bottom_right"); const [logoOpacity, setLogoOpacity] = useState(0.9); const logoInputRef = useRef(null); const [showUpgrade, setShowUpgrade] = useState(false); const [videoPreviewId, setVideoPreviewId] = useState(null); const submitButtonRef = useRef(null); /** When we navigated to step 3 (ms). Used to ignore submit from replayed click after "Go to step 3". */ const step3EnteredAtRef = useRef(null); // Load all templates once (filtering by style is done in UI) const [customTemplates, setCustomTemplates] = useState([]); const templatesRef = useRef(templates); const customTemplatesRef = useRef(customTemplates); const pickerDefaultTemplateIdRef = useRef(pickerDefaultTemplateId); templatesRef.current = templates; customTemplatesRef.current = customTemplates; pickerDefaultTemplateIdRef.current = pickerDefaultTemplateId; // Only show templates that have finished generating (intro_code exists) const readyCustomTemplates = customTemplates.filter((ct) => !!ct.intro_code); const readyCraftedTemplates = craftedTemplates.filter((ct) => !!ct.theme); const [showCustomTemplateUpgrade, setShowCustomTemplateUpgrade] = useState(false); const [showGetMoreTemplates, setShowGetMoreTemplates] = useState(false); const [showDesignerRequest, setShowDesignerRequest] = useState(false); const [customTemplatesLoading, setCustomTemplatesLoading] = useState(false); const [hasCraftedTemplatesEligible, setHasCraftedTemplatesEligible] = useState(false); // Show the crafted-template loader until the first R2 roundtrip resolves // (not just during the non-silent fetch window), so eligible users never // see the "no templates" state flash while we silently revalidate the // localStorage cache against R2. const craftedTemplatesLoading = hasCraftedTemplatesEligible && (craftedTemplatesCacheLoading || !craftedTemplatesInitialized); const [templateAvailabilityLoading, setTemplateAvailabilityLoading] = useState(true); const allTemplates = useMemo(() => { const byId = new Map(); for (const t of templates) byId.set(t.id, t); for (const ct of craftedTemplates) { if (!byId.has(ct.id)) byId.set(ct.id, ct as unknown as TemplateMeta); } return Array.from(byId.values()); }, [templates, craftedTemplates]); const renderLanguageDropdown = ( value: string, onSelect: (next: string) => void ) => (
{getLanguageOptionLabel(value)}
{/* ~12 visible rows; rest scrollable */}
{SUPPORTED_CONTENT_LANGUAGES.map((lang) => ( ))}
); const renderVideoLengthDropdown = ( value: "short" | "medium" | "detailed" | "more_detailed", onSelect: (next: "short" | "medium" | "detailed" | "more_detailed") => void ) => { const minWords = VIDEO_LENGTH_MIN_WORDS[value]; return ( <>
{VIDEO_LENGTH_DURATION_LABELS[value]}
{(["short", "medium", "detailed", "more_detailed"] as const).map((opt) => { // Long-form lengths require a paid plan — the backend rejects them // with 403 video_length_requires_paid, so gate them here too rather // than letting a free user pick an option that will fail on submit. const locked = !isPro && PAID_ONLY_VIDEO_LENGTHS.has(opt); return ( ); })}
{minWords && (

Make sure your content is at least ~{minWords.toLocaleString()} words for this length, otherwise the video will be automatically shortened.

)} ); }; // Load templates, voice previews, and user's saved voices once useEffect(() => { let mounted = true; if (isDemo) { if (demoMode?.templatesData) setTemplates(demoMode.templatesData); if (demoMode?.customTemplatesData) setCustomTemplates(demoMode.customTemplatesData); if (demoMode?.myVoicesData) setMyVoicesList(demoMode.myVoicesData); if (demoMode?.voicePreviewsData) setVoicePreviews(demoMode.voicePreviewsData); if (demoMode?.url) setUrls([demoMode.url]); if (demoMode?.name) setName(demoMode.name); if (demoMode?.videoLength) setVideoLength(demoMode.videoLength); if (demoMode?.template) { setTemplate(demoMode.template); templateManuallySelectedRef.current = true; } if (demoMode?.videoStyle) { setVideoStyle(demoMode.videoStyle); styleManuallySet.current = true; } if (demoMode?.voiceGender) setVoiceGender(demoMode.voiceGender); if (demoMode?.voiceAccent) setVoiceAccent(demoMode.voiceAccent); if (demoMode?.customVoiceId) setCustomVoiceId(demoMode.customVoiceId); setBuiltinTemplatesLoading(false); setSessionBuiltinInitDone(true); setMyVoicesLoading(false); setCustomTemplatesLoading(false); setHasCraftedTemplatesEligible(false); setTemplateAvailabilityLoading(false); sessionRandomAppliedRef.current = true; return () => { mounted = false; }; } primeBlogUrlFormStep2Prefetch(); fetchBlogUrlFormBuiltinTemplatesDeduped() .then((data) => { if (mounted) { setTemplates(data); setBuiltinTemplatesLoading(false); } }) .catch(() => { if (mounted) setBuiltinTemplatesLoading(false); }); fetchBlogUrlFormAvailabilityDeduped() .then(({ hasCraftedTemplatesEligible: eligible, customTemplates }) => { if (!mounted) return; setHasCraftedTemplatesEligible(eligible); setCustomTemplates(customTemplates); setCustomTemplatesLoading(false); setTemplateAvailabilityLoading(false); }) .catch(() => { if (!mounted) return; setHasCraftedTemplatesEligible(true); setCustomTemplates([]); setCustomTemplatesLoading(false); setTemplateAvailabilityLoading(false); }); getVoicePreviews() .then((r) => { if (mounted) setVoicePreviews(r.data); }) .catch(() => {}); getBgmTracks() .then((r) => { if (mounted) setBgmTracks(r.data); }) .catch(() => {}); setMyVoicesLoading(true); getMyVoices() .then((r) => { if (!mounted) return; const list = r.data ?? []; setMyVoicesList(list); if (list.length > 0) { const first = list[0]; const firstId = first.voice_id; // Single/link flow: default-select the first voice in Step 3 (read latest prefs via refs) if (!customVoiceIdRef.current && voiceGenderRef.current !== "none") { setCustomVoiceId(firstId); const g = normalizeVoiceGender(first.gender); const a = normalizeVoiceAccent(first.accent); if (g) setVoiceGender(g); if (a) setVoiceAccent(a); } } }) .catch(() => { if (mounted) setMyVoicesList([]); }) .finally(() => { if (mounted) setMyVoicesLoading(false); }); getPrebuiltVoices() .then((r: { data?: { voices?: ElevenLabsVoice[] } }) => { if (!mounted) return; const voices = r.data?.voices ?? []; const paid = voices.filter((v) => v.plan === "paid"); setPremiumTeaserVoices(paid.slice(0, 2)); }) .catch(() => {}); return () => { mounted = false; }; }, []); // Keep bulk rows in sync: whenever we have saved voices and bulk URLs, // ensure each populated row gets a default custom voice if it doesn't have one. useEffect(() => { if (!myVoicesList.length) return; const firstId = myVoicesList[0].voice_id; setBulkCustomVoiceId((prev) => { const next = [...prev]; let changed = false; bulkRows.forEach((row, idx) => { if (row.url.trim() && !next[idx]) { next[idx] = firstId; changed = true; } }); return changed ? next : prev; }); }, [myVoicesList, bulkRows]); // Sync colors to the selected template when templates load or selection changes. // Runs before session random pick on the same commit so random pick can override starter "default" colors. useEffect(() => { if (allTemplates.length === 0) return; // Check custom templates first if (template.startsWith("custom_")) { const customId = parseInt(template.replace("custom_", "")); const ct = customTemplates.find((t) => t.id === customId); if (ct) { setAccentColor(ct.preview_colors.accent); setBgColor(ct.preview_colors.bg); setTextColor(ct.preview_colors.text); } return; } const meta = allTemplates.find((t) => t.id === template); if (meta?.preview_colors) { setAccentColor(meta.preview_colors.accent); setBgColor(meta.preview_colors.bg); setTextColor(meta.preview_colors.text); } }, [allTemplates, customTemplates, template]); // Built-ins loaded but empty (error / no data): unblock step 2 without random pick. useEffect(() => { if (builtinTemplatesLoading) return; if (templates.length === 0) { setSessionBuiltinInitDone(true); } }, [builtinTemplatesLoading, templates.length]); // Once per form mount: pick a random built-in template for this session (single + all bulk rows). const sessionRandomAppliedRef = useRef(false); useEffect(() => { if (templates.length === 0) return; if (sessionRandomAppliedRef.current) { setSessionBuiltinInitDone(true); return; } const idx = Math.floor(Math.random() * templates.length); const picked = templates[idx]; if (!picked?.id) { setSessionBuiltinInitDone(true); return; } sessionRandomAppliedRef.current = true; setPickerDefaultTemplateId(picked.id); if (templateManuallySelectedRef.current) { setSessionBuiltinInitDone(true); return; } // Genre filter stays at "All genres" by default; users narrow the list explicitly. if (picked.preview_colors) { setAccentColor(picked.preview_colors.accent); setBgColor(picked.preview_colors.bg); setTextColor(picked.preview_colors.text); } setTemplate((prev) => prev === "default" ? picked.id : prev ); setBulkTemplates((prev) => prev.length > 0 ? prev.map((tpl) => (tpl === "default" ? picked.id : tpl)) : [picked.id] ); setSessionBuiltinInitDone(true); }, [templates]); // Preload voice preview audio on mount so it's ready by step 3 useEffect(() => { if (isDemo) return; const base = BACKEND_URL ? `${BACKEND_URL}/api` : "/api"; for (const key of VOICE_PREVIEW_KEYS) { if (preloadedAudioRef.current[key]) continue; const url = `${base}/voices/preview-audio?key=${encodeURIComponent(key)}`; const a = new Audio(); a.preload = "auto"; a.src = url; preloadedAudioRef.current[key] = a; } }, [isDemo]); // Cleanup audio only on unmount; do not clear preloadedAudioRef so step 3 can use it useEffect(() => { return () => { audioRef.current?.pause(); for (const key of VOICE_PREVIEW_KEYS) { const a = preloadedAudioRef.current[key]; if (a) { a.pause(); a.removeAttribute("src"); a.load(); } } preloadedAudioRef.current = {}; }; }, []); // ─── Audio preview ─────────────────────────────────────────── const playVoice = (key: string, url: string | null) => { if (!url) return; if (playingKey === key) { audioRef.current?.pause(); setPlayingKey(null); return; } audioRef.current?.pause(); // Always use preloaded instance when present so we don't refetch at step 3 let audio = preloadedAudioRef.current[key]; if (!audio) { audio = new Audio(url); audio.preload = "auto"; preloadedAudioRef.current[key] = audio; } audio.currentTime = 0; audio.onended = () => setPlayingKey(null); audio.onerror = () => setPlayingKey(null); audio.play().catch(() => setPlayingKey(null)); audioRef.current = audio; setPlayingKey(key); }; const playMyVoice = (saved: { voice_id: string; preview_url?: string | null }) => { const key = `my_${saved.voice_id}`; if (playingKey === key) { audioRef.current?.pause(); setPlayingKey(null); return; } const src = saved.preview_url; if (!src) return; audioRef.current?.pause(); const audio = new Audio(src); audio.onended = () => setPlayingKey(null); audio.onerror = () => setPlayingKey(null); audio.play().catch(() => setPlayingKey(null)); audioRef.current = audio; setPlayingKey(key); }; const playPremiumTeaser = (voice: ElevenLabsVoice) => { const key = `premium_${voice.voice_id}`; if (playingKey === key) { audioRef.current?.pause(); setPlayingKey(null); return; } if (!voice.preview_url) return; audioRef.current?.pause(); const audio = new Audio(voice.preview_url); audio.onended = () => setPlayingKey(null); audio.onerror = () => setPlayingKey(null); audio.play().catch(() => setPlayingKey(null)); audioRef.current = audio; setPlayingKey(key); }; // ─── File helpers ──────────────────────────────────────────── const isAllowedFile = (file: File) => { // 1) Extension check first — file.type can be empty / unreliable for .vtt // on many browsers (Chrome reports "" or "application/octet-stream"). const lowerName = (file.name || "").toLowerCase(); const dotIdx = lowerName.lastIndexOf("."); if (dotIdx !== -1) { const ext = lowerName.slice(dotIdx); // includes the leading "." if (ALLOWED_EXTENSIONS.includes(ext)) return true; } // 2) Fallback to MIME type. return ALLOWED_TYPES.includes(file.type); }; /** Add one or more files using functional state so paste / drop handlers never see stale `docFiles`. */ const addDocFileArray = useCallback((incoming: File[]) => { if (incoming.length === 0) return; setDocError(null); for (const f of incoming) { if (!isAllowedFile(f)) { setDocError(`"${f.name}" is not supported. Use PDF, DOCX, PPTX, Markdown, TXT, or VTT.`); return; } if (f.size > MAX_UPLOAD_SIZE) { setDocError(`"${f.name}" exceeds the 5 MB size limit.`); return; } } setDocFiles((prev) => { if (prev.length + incoming.length > MAX_UPLOAD_FILES) { setTimeout(() => setDocError(`Maximum ${MAX_UPLOAD_FILES} files allowed.`), 0); return prev; } return [...prev, ...incoming]; }); }, []); const addDocFiles = useCallback((newFiles: FileList | null) => { if (!newFiles || newFiles.length === 0) return; addDocFileArray(Array.from(newFiles)); }, [addDocFileArray]); /** Plain text from clipboard → single .txt document (same limits as uploads). */ const addPastedTextAsTxtFile = useCallback((text: string) => { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const file = new File([blob], "pasted-content.txt", { type: "text/plain" }); addDocFileArray([file]); }, [addDocFileArray]); // Step 1 + Upload: Ctrl+V pastes plain text as a .txt doc, or pasted files as uploads. // Skips when focus is in an input/textarea so project name & other fields work normally. useEffect(() => { if (step !== 1 || mode !== "upload") return; const onPaste = (e: ClipboardEvent) => { const el = e.target as HTMLElement | null; if (el?.closest?.("input, textarea, select, [contenteditable='true']")) return; const cd = e.clipboardData; if (!cd) return; if (cd.files && cd.files.length > 0) { e.preventDefault(); addDocFiles(cd.files); return; } const text = cd.getData("text/plain"); const trimmed = text?.trim() ?? ""; if (!trimmed) return; e.preventDefault(); addPastedTextAsTxtFile(trimmed); }; window.addEventListener("paste", onPaste); return () => window.removeEventListener("paste", onPaste); }, [step, mode, addDocFiles, addPastedTextAsTxtFile]); const removeDocFile = (index: number) => { setDocFiles((prev) => prev.filter((_, i) => i !== index)); setDocError(null); }; const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; // ─── File-extension URL detection ──────────────────────────── const urlFileExt = mode === "url" ? getFileExtension(urls[0] ?? "") : null; const bulkFileExtRows = mode === "bulk" ? bulkRows.map((r) => getFileExtension(r.url)) : []; const hasBulkFileExt = bulkFileExtRows.some(Boolean); // ─── Non-scrapable link detection ──────────────────────────── const urlClassification = mode === "url" ? classifyUrl(urls[0] ?? "") : { kind: "ok" as const }; const urlScrape = urlClassification.kind; const bulkScrapeRows = mode === "bulk" ? bulkRows.map((r) => classifyUrlScrapability(r.url)) : []; const hasBulkBlocked = bulkScrapeRows.some((s) => s === "blocked"); // ─── Navigation ────────────────────────────────────────────── // Step order: 1 = Project (URL/Upload/Bulk), 2 = Template, 3 = Voice const canGoNext1 = mode === "url" ? !!urls[0]?.trim() && !hasSpacesInMiddle(urls[0]) && containsDot(urls[0]) && !urlFileExt && urlScrape !== "blocked" : mode === "upload" ? docFiles.length > 0 : bulkRows.some((r) => r.url.trim()) && bulkRows.every( (r) => !r.url.trim() || (!hasSpacesInMiddle(r.url) && containsDot(r.url)) ) && !hasBulkFileExt && !hasBulkBlocked; const goNext = () => { if (step === 1 && canGoNext1) { if (mode === "bulk") { const n = bulkRows.length; setBulkNames((prev) => resizeTo(prev, n, "")); setBulkTemplates((prev) => resizeTo(prev, n, pickerDefaultTemplateId)); setBulkVoiceGender((prev) => resizeTo(prev, n, "female")); setBulkVoiceAccent((prev) => resizeTo(prev, n, "american")); setBulkCustomVoiceId((prev) => resizeTo(prev, n, "")); setBulkContentLanguage((prev) => resizeTo(prev, n, "auto")); setBulkVideoLength((prev) => resizeTo(prev, n, "short")); setBulkStockFootage((prev) => resizeTo(prev, n, true)); setBulkAspectRatio((prev) => resizeTo(prev, n, "landscape")); setBulkVideoStyles((prev) => resizeTo( prev, n, videoStyleForBulkTemplateId( pickerDefaultTemplateIdRef.current, templatesRef.current, customTemplatesRef.current ) ) ); setBulkAccentColors((prev) => resizeTo(prev, n, "")); setBulkBgColors((prev) => resizeTo(prev, n, "")); setBulkTextColors((prev) => resizeTo(prev, n, "")); setBulkLogoFile((prev) => resizeTo(prev, n, null)); setBulkLogoPosition((prev) => resizeTo(prev, n, "bottom_right")); setBulkLogoOpacity((prev) => resizeTo(prev, n, 0.9)); setBulkActiveIndex(0); } setStep(2); } else if (step === 2) { step3EnteredAtRef.current = Date.now(); setBulkActiveIndex(0); setStep(3); } }; function resizeTo(arr: T[], len: number, fill: T): T[] { if (arr.length >= len) return arr.slice(0, len); return [...arr, ...Array(len - arr.length).fill(fill)]; } const addBulkRow = () => { if (bulkRows.length >= MAX_BULK_LINKS) return; setBulkRows((prev) => [...prev, { url: "" }]); setBulkNames((prev) => [...prev, ""]); setBulkTemplates((prev) => [...prev, pickerDefaultTemplateId]); setBulkVoiceGender((prev) => [...prev, "female"]); setBulkVoiceAccent((prev) => [...prev, "american"]); setBulkCustomVoiceId((prev) => [...prev, ""]); setBulkContentLanguage((prev) => [...prev, "auto"]); setBulkVideoLength((prev) => [...prev, "short"]); setBulkStockFootage((prev) => { const next = [...prev, bulkApplyStockAll ? (prev[bulkStockMasterIndex] ?? true) : true]; return next; }); setBulkAspectRatio((prev) => [...prev, "landscape"]); setBulkVideoStyles((prev) => [ ...prev, videoStyleForBulkTemplateId( pickerDefaultTemplateIdRef.current, templatesRef.current, customTemplatesRef.current ), ]); setBulkAccentColors((prev) => [...prev, ""]); setBulkBgColors((prev) => [...prev, ""]); setBulkTextColors((prev) => [...prev, ""]); setBulkLogoFile((prev) => [...prev, null]); setBulkLogoPosition((prev) => [...prev, "bottom_right"]); setBulkLogoOpacity((prev) => [...prev, 0.9]); bulkStyleManuallySet.current = [...bulkStyleManuallySet.current, false]; }; const removeBulkRow = (index: number) => { if (bulkRows.length <= 1) return; bulkStyleManuallySet.current = bulkStyleManuallySet.current.filter((_, i) => i !== index); setBulkRows((prev) => prev.filter((_, i) => i !== index)); setBulkNames((prev) => prev.filter((_, i) => i !== index)); setBulkTemplates((prev) => prev.filter((_, i) => i !== index)); setBulkVoiceGender((prev) => prev.filter((_, i) => i !== index)); setBulkVoiceAccent((prev) => prev.filter((_, i) => i !== index)); setBulkCustomVoiceId((prev) => prev.filter((_, i) => i !== index)); setBulkContentLanguage((prev) => prev.filter((_, i) => i !== index)); setBulkVideoLength((prev) => prev.filter((_, i) => i !== index)); setBulkStockFootage((prev) => prev.filter((_, i) => i !== index)); setBulkAspectRatio((prev) => prev.filter((_, i) => i !== index)); setBulkVideoStyles((prev) => prev.filter((_, i) => i !== index)); setBulkAccentColors((prev) => prev.filter((_, i) => i !== index)); setBulkBgColors((prev) => prev.filter((_, i) => i !== index)); setBulkTextColors((prev) => prev.filter((_, i) => i !== index)); setBulkLogoFile((prev) => prev.filter((_, i) => i !== index)); setBulkLogoPosition((prev) => prev.filter((_, i) => i !== index)); setBulkLogoOpacity((prev) => prev.filter((_, i) => i !== index)); setBulkActiveIndex((prev) => { if (prev >= bulkRows.length - 1) return Math.max(0, prev - 1); return prev; }); }; const goBack = () => { if (step === 2) { setStep(1); setBulkActiveIndex(0); } else if (step === 3) { setStep(2); setBulkActiveIndex(0); } }; // ─── Submit ────────────────────────────────────────────────── const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (isDemo) return; if (step !== 3) return; const enteredAt = step3EnteredAtRef.current; if (enteredAt != null && Date.now() - enteredAt < 400) return; step3EnteredAtRef.current = null; audioRef.current?.pause(); bgmAudioRef.current?.pause(); if (mode === "bulk" && onSubmitBulk) { const valid = bulkRows .map((r, i) => ({ url: r.url, name: bulkNames[i] ?? "", i })) .filter((r) => r.url.trim()); if (valid.length === 0) return; // Detect duplicate URLs and auto-suffix names const urlCounts: Record = {}; const urlSeenSoFar: Record = {}; for (const { url } of valid) { const normalized = url.trim(); urlCounts[normalized] = (urlCounts[normalized] ?? 0) + 1; } const firstSavedVoiceId = myVoicesList[0]?.voice_id; const items: BulkProjectItem[] = valid.map(({ url, name: n, i }) => { const normalized = url.trim(); urlSeenSoFar[normalized] = (urlSeenSoFar[normalized] ?? 0) + 1; const isDuplicate = urlCounts[normalized] > 1; let resolvedName = n.trim() || undefined; if (!resolvedName && isDuplicate) { const occurrence = urlSeenSoFar[normalized]; const derived = deriveNameFromUrl(normalized); resolvedName = occurrence === 1 ? derived : `${derived} (${occurrence})`; } const rowSelectedVoiceId = bulkCustomVoiceId[i]?.trim(); const selectedVoice = myVoicesList.find((v) => v.voice_id === rowSelectedVoiceId); const rowGender = bulkVoiceGender[i] ?? "female"; const inferredGender = rowGender === "none" ? "none" : (normalizeVoiceGender(selectedVoice?.gender) ?? rowGender); const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? bulkVoiceAccent[i]; const effectiveCustomVoiceId = rowSelectedVoiceId || firstSavedVoiceId; return { blog_url: normalized, name: resolvedName, template: bulkTemplates[i] !== "default" ? bulkTemplates[i] : undefined, video_style: bulkVideoStyles[i] ?? videoStyleForBulkTemplateId( bulkTemplates[i] ?? "default", templatesRef.current, customTemplatesRef.current ), video_length: bulkVideoLength[i] ?? "short", voice_gender: inferredGender, voice_accent: inferredAccent, voice_emotion: (() => { const s = bulkVoiceStability[i] ?? VOICE_STABILITY_DEFAULT; const sp = bulkVoiceSpeed[i] ?? VOICE_SPEED_DEFAULT; const em = bulkVoiceEmotion[i] ?? ""; const sty = bulkVoiceStyle[i] ?? VOICE_STYLE_DEFAULT; // Always send for Pro+voiced so the enabled/disabled flag + last values are remembered; // the backend only applies tuning to the project when the flag is on. return isPro && inferredGender !== "none" ? serializeVoiceTuning(s, sp, em, sty, expressiveEnabled) : undefined; })(), accent_color: bulkAccentColors[i] && bulkAccentColors[i].trim() ? bulkAccentColors[i] : accentColor, bg_color: bulkBgColors[i] && bulkBgColors[i].trim() ? bulkBgColors[i] : bgColor, text_color: bulkTextColors[i] && bulkTextColors[i].trim() ? bulkTextColors[i] : textColor, logo_position: bulkLogoPosition[i] ?? "bottom_right", logo_opacity: bulkLogoOpacity[i] ?? 0.9, custom_voice_id: inferredGender === "none" ? undefined : (effectiveCustomVoiceId || undefined), aspect_ratio: bulkAspectRatio[i] ?? "landscape", content_language: (bulkContentLanguage[i] ?? "auto") === "auto" ? null : (bulkContentLanguage[i] ?? "auto"), // Per-row now (with "apply to all" in step 1); the backend still decides // per project whether its template can render a clip and clears otherwise. stock_footage_enabled: bulkStockFootage[i] ?? false, }; }); const logoIndices: number[] = []; const logoFiles: File[] = []; valid.forEach((v, j) => { const f = bulkLogoFile[v.i]; if (f) { logoIndices.push(j); logoFiles.push(f); } }); await onSubmitBulk(items, logoIndices.length > 0 ? { logoIndices, logoFiles } : null); setBulkRows([{ url: "" }]); setBulkNames([""]); setBulkTemplates([pickerDefaultTemplateId]); setBulkVoiceGender(["female"]); setBulkVoiceAccent(["american"]); setBulkVoiceStability([VOICE_STABILITY_DEFAULT]); setBulkVoiceSpeed([VOICE_SPEED_DEFAULT]); setBulkVoiceEmotion([""]); setBulkVoiceStyle([VOICE_STYLE_DEFAULT]); setBulkCustomVoiceId([]); setBulkContentLanguage(["auto"]); setBulkVideoLength(["short"]); setBulkStockFootage([true]); setBulkAspectRatio(["landscape"]); setBulkVideoStyles([DEFAULT_VIDEO_STYLE]); setBulkAccentColors([""]); setBulkBgColors([""]); setBulkTextColors([""]); setBulkLogoFile([null]); setBulkLogoPosition(["bottom_right"]); setBulkLogoOpacity([0.9]); setBulkActiveIndex(0); setBulkApplyLengthAll(true); setBulkLengthMasterIndex(0); setBulkApplyTemplateAll(true); setBulkTemplateMasterIndex(0); setBulkApplyVoiceAll(true); setBulkVoiceMasterIndex(0); setVideoLength("short"); setContentLanguage("auto"); return; } if (mode === "upload") { if (docFiles.length === 0) return; const selectedVoice = myVoicesList.find((v) => v.voice_id === customVoiceId.trim()); const inferredGender = voiceGender === "none" ? "none" : (normalizeVoiceGender(selectedVoice?.gender) ?? voiceGender); const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? voiceAccent; const effectiveCustomVoiceId = customVoiceId.trim() || myVoicesList[0]?.voice_id || ""; await onSubmit( "", name.trim() || undefined, inferredGender, inferredAccent, accentColor, bgColor, textColor, undefined, logoFile || undefined, logoPosition, logoOpacity, inferredGender === "none" ? undefined : (effectiveCustomVoiceId || undefined), aspectRatio, docFiles, template !== "default" ? template : undefined, videoStyle, videoLength, contentLanguage === "auto" ? null : contentLanguage, isPro && inferredGender !== "none" ? serializeVoiceTuning(voiceStability, voiceSpeed, voiceEmotion, voiceStyle, expressiveEnabled) : undefined, selectedBgmTrackId, selectedBgmVolume ); setDocFiles([]); setName(""); } else { const validUrls = urls.filter((u) => u.trim()); if (validUrls.length === 0) return; const selectedVoice = myVoicesList.find((v) => v.voice_id === customVoiceId.trim()); const inferredGender = voiceGender === "none" ? "none" : (normalizeVoiceGender(selectedVoice?.gender) ?? voiceGender); const inferredAccent = normalizeVoiceAccent(selectedVoice?.accent) ?? voiceAccent; const effectiveCustomVoiceId = customVoiceId.trim() || myVoicesList[0]?.voice_id || ""; for (const url of validUrls) { await onSubmit( url.trim(), name.trim() || undefined, inferredGender, inferredAccent, accentColor, bgColor, textColor, undefined, logoFile || undefined, logoPosition, logoOpacity, inferredGender === "none" ? undefined : (effectiveCustomVoiceId || undefined), aspectRatio, undefined, template !== "default" ? template : undefined, videoStyle, videoLength, contentLanguage === "auto" ? null : contentLanguage, isPro && inferredGender !== "none" ? serializeVoiceTuning(voiceStability, voiceSpeed, voiceEmotion, voiceStyle, expressiveEnabled) : undefined, selectedBgmTrackId, selectedBgmVolume ); } setUrls([""]); setName(""); } }; // ─── Template apply colors ─────────────────────────────────── const applyTemplate = (id: string) => { templateManuallySelectedRef.current = true; setTemplate(id); // Custom template if (id.startsWith("custom_")) { const customId = parseInt(id.replace("custom_", "")); const ct = customTemplates.find((t) => t.id === customId); if (ct) { setAccentColor(ct.preview_colors.accent); setBgColor(ct.preview_colors.bg); setTextColor(ct.preview_colors.text); } return; } const meta = allTemplates.find((t) => t.id === id); if (meta?.preview_colors) { setAccentColor(meta.preview_colors.accent); setBgColor(meta.preview_colors.bg); setTextColor(meta.preview_colors.text); } }; const openStep2CustomTemplateCreator = (style: VideoStyleId, _bulkRow: number | null) => { // Creation is open to all plans; the dashboard creator enforces the per-plan // template-creation cap (1 free + purchased slots) via can_create_custom_template. onDismissFlow?.(); const params = new URLSearchParams(); params.set("tab", "templates"); params.set("openCustomCreator", "1"); params.set("videoStyle", style); navigate(`/dashboard?${params.toString()}`); }; const openStep3CustomVoiceCreator = () => { if (!isPro) { setShowCustomTemplateUpgrade(true); return; } onDismissFlow?.(); const params = new URLSearchParams(); params.set("tab", "voices"); params.set("openCustomVoiceCreator", "1"); navigate(`/dashboard?${params.toString()}`); }; // ─── Step 1: Project (URL or Upload) ───────────────────────── const bulkStep1ActiveIndex = Math.min(bulkActiveIndex, Math.max(0, bulkRows.length - 1)); const bulkStep1MasterIndex = Math.min(bulkLengthMasterIndex, Math.max(0, bulkRows.length - 1)); const bulkStep1RowVideoLength = bulkVideoLength[bulkStep1ActiveIndex] ?? "short"; const applyStep1LengthToAll = () => { setBulkVideoLength((prev) => { const next = resizeTo(prev, bulkRows.length, "short"); const value = next[bulkStep1ActiveIndex] ?? "short"; return next.map(() => value); }); }; const bulkStep1RowStockFootage = bulkStockFootage[bulkStep1ActiveIndex] ?? false; const applyStep1StockToAll = () => { setBulkStockFootage((prev) => { const next = resizeTo(prev, bulkRows.length, false); const value = next[bulkStep1ActiveIndex] ?? false; return next.map(() => value); }); }; const setBulkStockFootageAt = (value: boolean) => { // Mirrors the length dropdown: editing a non-master row while "apply to all" // is on breaks the sync; editing the master (or with sync off) sets one/all. if (bulkApplyStockAll && bulkStep1ActiveIndex !== bulkStockMasterIndex) { setBulkApplyStockAll(false); setBulkStockFootage((prev) => { const next = resizeTo(prev, bulkRows.length, false); next[bulkStep1ActiveIndex] = value; return next; }); return; } if (bulkApplyStockAll && bulkStep1ActiveIndex === bulkStockMasterIndex) { setBulkStockFootage((prev) => resizeTo(prev, bulkRows.length, false).map(() => value)); return; } setBulkStockFootage((prev) => { const next = resizeTo(prev, bulkRows.length, false); next[bulkStep1ActiveIndex] = value; return next; }); }; const step1 = (
{/* Mode tabs — selected tab purple */}
{(["url", "upload", ...(onSubmitBulk ? (["bulk"] as const) : [])] as const).map((m) => ( ))}
{/* Bulk: multiple links (url + name per row) */} {mode === "bulk" && (<> setBulkRows((prev) => prev.map((r, i) => (i === index ? { ...r, url: value } : r))) } onChangeAspectRatio={(index, value) => setBulkAspectRatio((prev) => { const next = [...prev]; next[index] = value; return next; }) } onAddRow={addBulkRow} onRemoveRow={removeBulkRow} /> {(() => { const seen = new Set(); const hasDupes = bulkRows.some((r) => { const t = r.url.trim(); if (!t) return false; if (seen.has(t)) return true; seen.add(t); return false; }); return hasDupes ? (

Duplicate URLs detected — suffixes will be added to project names to differentiate them.

) : null; })()} {hasBulkFileExt && (

{bulkFileExtRows.map((ext, i) => ext ? ( Row {i + 1} has a {ext.toUpperCase()} file link. ) : null)} Please use the Upload tab to upload files directly instead of linking to them.

)}
{bulkRows.map((_, i) => ( ))}
{/* One "apply to all" toggle drives both duration and footage sync. */}
{/* Estimated duration + stock footage side by side. */}
{renderVideoLengthDropdown(bulkStep1RowVideoLength, (value) => { if (bulkApplyLengthAll && bulkStep1ActiveIndex !== bulkStep1MasterIndex) { setBulkApplyLengthAll(false); setBulkVideoLength((prev) => { const next = resizeTo(prev, bulkRows.length, "short"); next[bulkStep1ActiveIndex] = value; return next; }); return; } if (bulkApplyLengthAll && bulkStep1ActiveIndex === bulkStep1MasterIndex) { setBulkVideoLength((prev) => resizeTo(prev, bulkRows.length, "short").map(() => value)); return; } setBulkVideoLength((prev) => { const next = resizeTo(prev, bulkRows.length, "short"); next[bulkStep1ActiveIndex] = value; return next; }); })}
{stockFootageAvailable && (
)}

Actual length may vary depending on content size and video style. If the scraped or uploaded content is very short, video might get shorten automatically.

)} {/* URL input */} {mode === "url" && (
{urls.map((url, i) => ( { const next = e.target.value; setUrls((prev) => prev.map((u, idx) => (idx === i ? next : u))); if (i === 0) { setUrlError(null); } }} onBlur={(e) => { if (i !== 0) return; const value = e.target.value; const trimmed = value.trim(); if (!trimmed) { setUrlError(null); return; } if (hasSpacesInMiddle(value)) { setUrlError("Enter a valid link (e.g. example.com, https://example.com)."); } else if (!containsDot(value)) { setUrlError("Enter a valid link (e.g. example.com, https://example.com)."); } else { setUrlError(null); } }} placeholder={ i === 0 ? "https://yourblog.com/your-article..." : `URL ${i + 1} (optional)` } className={`w-full px-4 py-2.5 bg-white/80 border rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all mb-2 ${ i === 0 && urls[0]?.trim() && (urlError || urlScrape === "blocked") ? "border-red-400" : "border-gray-200/60" }`} autoFocus={i === 0} /> ))} {urlError && urls[0]?.trim() && (

{urlError}

)} {urlFileExt && urls[0]?.trim() && (

{urlFileExt.toUpperCase()} files can't be processed as a URL. Please use the{" "} Upload tab above to upload this file directly.

)} {urlScrape === "blocked" && urls[0]?.trim() && (

This site can't be scraped, please use a different link.

)} {urlScrape === "warn" && urls[0]?.trim() && (

{urlClassification.message}

)}

Use a paywall-free link for best results.{" "}

)} {/* Document upload */} {mode === "upload" && (
docInputRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); docInputRef.current?.click(); } }} onPaste={(e) => { e.stopPropagation(); const cd = e.clipboardData; if (cd.files && cd.files.length > 0) { e.preventDefault(); addDocFiles(cd.files); return; } const text = cd.getData("text/plain"); const trimmed = text?.trim() ?? ""; if (trimmed) { e.preventDefault(); addPastedTextAsTxtFile(trimmed); } }} onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("border-purple-400/60", "bg-purple-50/30"); }} onDragLeave={(e) => { e.preventDefault(); e.currentTarget.classList.remove("border-purple-400/60", "bg-purple-50/30"); }} onDrop={(e) => { e.preventDefault(); e.currentTarget.classList.remove("border-purple-400/60", "bg-purple-50/30"); addDocFiles(e.dataTransfer.files); }} >

Drop files here or paste text (Ctrl+V)

PDF, Word, PowerPoint, Markdown, Text, VTT — or paste plain text as a .txt file

{ addDocFiles(e.target.files); e.target.value = ""; }} />
{docError &&

{docError}

} {docFiles.length > 0 && (
{docFiles.map((file, i) => (
{file.name} {formatFileSize(file.size)}
))}
)}
)} {/* Project name (single-link / upload only; bulk has per-row name) */} {mode !== "bulk" && (
setName(e.target.value)} placeholder={mode === "url" ? "Auto-generated from URL" : "Auto-generated from file name"} className="w-full px-4 py-2.5 bg-white/80 border border-gray-200/60 rounded-xl text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500/40 focus:border-transparent transition-all" />
)} {/* Format, duration + Logo (single-link / upload only; bulk has per-row in step 3) */} {mode !== "bulk" && ( <>
{([ { value: "landscape", label: "Landscape", sub: "YouTube" }, { value: "portrait", label: "Portrait", sub: "TikTok / Reels" }, ] as const).map((opt) => ( ))}
{/* Estimated duration + stock footage side by side in one row. */} {/* items-stretch + h-full on each column: the duration label can wrap to two lines while the checkbox stays one, so sizing each column to its own content leaves the two pills visibly different heights. */}
{renderVideoLengthDropdown(videoLength, setVideoLength)}
{stockFootageAvailable && (
)}

Actual length may vary depending on content size and video style. If the scraped or uploaded content is very short, we may shorten the video.

{logoFile && ( )}
{ const f = e.target.files?.[0] || null; if (f && f.size > 2 * 1024 * 1024) { showError("Logo must be under 2 MB."); e.target.value = ""; return; } setLogoFile(f); }} />
{logoFile && (
{([ { value: "top_left", label: "Top Left" }, { value: "top_right", label: "Top Right" }, { value: "bottom_left", label: "Bottom Left" }, { value: "bottom_right", label: "Bottom Right" }, ] as const).map((pos) => ( ))}
setLogoOpacity(parseInt(e.target.value, 10) / 100)} className="w-full h-1 bg-gray-300 rounded-full appearance-none cursor-pointer accent-purple-600" />
)}
)}
{/* Next — always visible; gray when disabled (invalid/empty URL) */}
); // ─── Step 2: Genre/source filter + Template list ────────────────────────── // Genre dropdown has two source-bucket options at the top — "Custom Templates" // and "Designer Templates" — encoded with sentinel values. Below them are // the actual genres, compiled from built-in templates' meta.json. const allGenres = useMemo(() => { const set = new Set(); for (const t of templates) (t.genres ?? []).forEach((g) => g && set.add(g)); return Array.from(set).sort(); }, [templates]); const { suggestedTemplates, customTemplatesForStyle, craftedTemplatesForStyle, } = templateBucketsForGenre(genre, templates, readyCustomTemplates, readyCraftedTemplates); const sortedSuggestedTemplates = [...suggestedTemplates].sort((a, b) => { const rank = (t: TemplateMeta) => (t.new_template ? 0 : t.popular_template ? 1 : 2); return rank(a) - rank(b); }); const styleTemplateItems: Array< | { type: "builtin"; id: string; data: TemplateMeta } | { type: "custom"; id: string; data: CustomTemplateItem } | { type: "crafted"; id: string; data: CraftedTemplateItem } > = [ ...sortedSuggestedTemplates.map((t) => ({ type: "builtin" as const, id: t.id, data: t })), ...customTemplatesForStyle.map((ct) => ({ type: "custom" as const, id: `custom_${ct.id}`, data: ct })), ...craftedTemplatesForStyle.map((ct) => ({ type: "crafted" as const, id: ct.id, data: ct })), ]; const SelectedPreviewComp = TEMPLATE_PREVIEWS[template]; const selectedDesc = TEMPLATE_DESCRIPTIONS[template]; const selectedCustom = template.startsWith("custom_") ? customTemplates.find((ct) => ct.id === parseInt(template.replace("custom_", ""))) : null; const selectedCrafted = template.startsWith("crafted_") ? craftedTemplates.find((ct) => ct.id === template) : null; const selectedBuiltinNew = !template.startsWith("custom_") && allTemplates.some((t) => t.id === template && t.new_template === true); const step2Template = (
{/* Selected template — full-width preview */}
{demoMode?.templatePreviewOverride && !selectedCustom && !selectedCrafted ? ( demoMode.templatePreviewOverride({ templateId: template, selected: true, thumbnail: false }) ) : selectedCustom ? ( ) : selectedCrafted && ((selectedCrafted as any).frontend_files || selectedCrafted.preview_file || selectedCrafted.preview_image_url || selectedCrafted.theme) ? ( ) : template.startsWith("crafted_") && craftedTemplatesLoading ? (
) : SelectedPreviewComp ? ( ) : (
{selectedDesc?.title ?? template}
)}
{selectedCustom ? selectedCustom.name : selectedCrafted ? selectedCrafted.name : (selectedDesc?.title ?? template)} {selectedBuiltinNew && } {selectedCustom && ( Custom )} {selectedCrafted && ( Designer )}
{selectedCustom ? (
Custom template
) : selectedCrafted ? (
Designer template
) : selectedDesc?.subtitle ? (
{selectedDesc.subtitle}
) : null}
{/* Genre dropdown + Templates list (filtered by genre) */}
{genreDropdownOpen && ( <>
setGenreDropdownOpen(false)} />
{/* Source filters — independent of genre */}
{allGenres.length > 0 &&
} {allGenres.map((g) => ( ))}
)}

{genreTemplateListCaption(genre)}

<>
{ setShowGetMoreTemplates(true); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowGetMoreTemplates(true); } }} /> {styleTemplateItems.map((item) => { if (item.type === "custom" || item.type === "crafted") { const ct = item.data; const customId = item.id; const isSelected = template === customId; // Mobile: every grid tile renders a static themed placeholder // and never mounts a Player — the selected template plays only // in the full-width "Selected Template" preview above. A grid of // live Players exhausts iOS Safari's memory and reloads the tab. const staticThumb = isMobile; return (
applyTemplate(customId)} className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-all group ${ isSelected ? "border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]" : "border-gray-200/60 hover:border-purple-300/60" }`} >
{item.type === "crafted" ? ( ) : ( )}
{item.type === "custom" ? : ( Designer )}
{isSelected && (
)}
{ct.name}
); } const t = item.data; const PreviewComp = TEMPLATE_PREVIEWS[t.id]; const desc = TEMPLATE_DESCRIPTIONS[t.id]; const isSelected = template === t.id; const isNewTemplate = t.new_template === true; const isPopularTemplate = t.popular_template === true; return (
applyTemplate(t.id)} className={`relative rounded-lg overflow-hidden cursor-pointer transition-all group ${ isSelected ? "border-2 border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]" : isNewTemplate ? "border border-purple-500 shadow-[0_0_0_2px_rgba(124,58,237,0.2)] hover:border-purple-600" : isPopularTemplate ? "border border-amber-400/60 shadow-[0_0_0_2px_rgba(245,158,11,0.15)] hover:border-amber-500" : "border-2 border-gray-200/60 hover:border-purple-300/60" }`} >
{demoMode?.templatePreviewOverride ? ( demoMode.templatePreviewOverride({ templateId: t.id, selected: isSelected, thumbnail: true }) ) : PreviewComp ? ( ) : (
{desc?.title ?? t.name}
)} {isSelected && (
)} {isNewTemplate && (
)} {!isNewTemplate && isPopularTemplate && (
)}
{desc?.title ?? t.name}
); })} {customTemplatesLoading && (

Loading custom templates, please wait.

)} {craftedTemplatesLoading && (

Loading designer templates, please wait.

)}
{styleTemplateItems.length === 0 && (

No templates for this genre. Pick another genre above or add a custom template.

)}
{/* Video Style picker — orthogonal to genre. "Auto" lets the AI choose after scraping. */}
{VIDEO_STYLES.map((s) => { const isSelected = videoStyle === s.id; return ( ); })}
{/* Video colors */}
{[ { label: "Accent", value: accentColor, setter: setAccentColor }, { label: "Background", value: bgColor, setter: setBgColor }, { label: "Text", value: textColor, setter: setTextColor }, ].map(({ label, value, setter }) => ( ))}
); // ─── Step 2 bulk: template per project (tabbed; same UI as single) ───────── const step2BulkTemplate = (() => { const indexed = bulkRows .map((row, i) => ({ row, i })) .filter(({ row }) => row.url.trim()); if (indexed.length === 0) { return (

Please go back to step 1 to continue again.

); } const active = Math.min(bulkActiveIndex, indexed.length - 1); const activeIndex = indexed[active].i; const activeRow = indexed[active].row; const masterIndex = indexed.find(({ i }) => i === bulkTemplateMasterIndex)?.i ?? indexed[0].i; const tpl = bulkTemplates[activeIndex] ?? "default"; const templateMeta = allTemplates.find((t) => t.id === tpl); const selectedCustomBulk = tpl.startsWith("custom_") ? customTemplates.find((ct) => ct.id === parseInt(tpl.replace("custom_", ""))) : null; const selectedCraftedBulk = tpl.startsWith("crafted_") ? craftedTemplates.find((ct) => ct.id === tpl) : null; const selectedBuiltinNewBulk = !tpl.startsWith("custom_") && templateMeta?.new_template === true; const defaultAccent = selectedCustomBulk?.preview_colors?.accent ?? selectedCraftedBulk?.preview_colors?.accent ?? templateMeta?.preview_colors?.accent ?? accentColor; const defaultBg = selectedCustomBulk?.preview_colors?.bg ?? selectedCraftedBulk?.preview_colors?.bg ?? templateMeta?.preview_colors?.bg ?? bgColor; const defaultText = selectedCustomBulk?.preview_colors?.text ?? selectedCraftedBulk?.preview_colors?.text ?? templateMeta?.preview_colors?.text ?? textColor; const accent = bulkAccentColors[activeIndex] && bulkAccentColors[activeIndex].trim() ? bulkAccentColors[activeIndex] : defaultAccent; const bg = bulkBgColors[activeIndex] && bulkBgColors[activeIndex].trim() ? bulkBgColors[activeIndex] : defaultBg; const text = bulkTextColors[activeIndex] && bulkTextColors[activeIndex].trim() ? bulkTextColors[activeIndex] : defaultText; const activeVideoStyle = bulkVideoStyles[activeIndex] ?? DEFAULT_VIDEO_STYLE; const rowVideoLength = bulkVideoLength[activeIndex] ?? "short"; const applyTemplateToAll = () => { const targetIndices = indexed.map(({ i }) => i); // Template + video style + colors setBulkTemplates((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = tpl; }); return next; }); setBulkVideoStyles((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = activeVideoStyle || DEFAULT_VIDEO_STYLE; }); return next; }); setBulkAccentColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = accent; }); return next; }); setBulkBgColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = bg; }); return next; }); setBulkTextColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = text; }); return next; }); setBulkVideoLength((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVideoLength; }); return next; }); // Logo (file, position, opacity) setBulkLogoFile((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = bulkLogoFile[activeIndex] ?? null; }); return next; }); setBulkLogoPosition((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = bulkLogoPosition[activeIndex] ?? "bottom_right"; }); return next; }); setBulkLogoOpacity((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = bulkLogoOpacity[activeIndex] ?? 0.9; }); return next; }); }; const applyBulkTemplate = (id: string) => { templateManuallySelectedRef.current = true; const colors = id.startsWith("custom_") ? customTemplates.find((t) => t.id === parseInt(id.replace("custom_", "")))?.preview_colors : id.startsWith("crafted_") ? craftedTemplates.find((t) => t.id === id)?.preview_colors : allTemplates.find((t) => t.id === id)?.preview_colors; const targetIndices = indexed.map(({ i }) => i); if (bulkApplyTemplateAll && activeIndex !== masterIndex) { setBulkApplyTemplateAll(false); setBulkTemplates((prev) => { const next = [...prev]; next[activeIndex] = id; return next; }); if (colors) { setBulkAccentColors((prev) => { const next = [...prev]; next[activeIndex] = colors.accent; return next; }); setBulkBgColors((prev) => { const next = [...prev]; next[activeIndex] = colors.bg; return next; }); setBulkTextColors((prev) => { const next = [...prev]; next[activeIndex] = colors.text; return next; }); } return; } if (bulkApplyTemplateAll && activeIndex === masterIndex) { setBulkTemplates((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = id; }); return next; }); if (colors) { setBulkAccentColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = colors.accent; }); return next; }); setBulkBgColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = colors.bg; }); return next; }); setBulkTextColors((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = colors.text; }); return next; }); } return; } setBulkTemplates((prev) => { const next = [...prev]; next[activeIndex] = id; return next; }); if (colors) { setBulkAccentColors((prev) => { const next = [...prev]; next[activeIndex] = colors.accent; return next; }); setBulkBgColors((prev) => { const next = [...prev]; next[activeIndex] = colors.bg; return next; }); setBulkTextColors((prev) => { const next = [...prev]; next[activeIndex] = colors.text; return next; }); } }; const targetIndicesForTemplate = indexed.map(({ i }) => i); const setBulkColor = (kind: "accent" | "bg" | "text", v: string) => { if (bulkApplyTemplateAll && activeIndex !== masterIndex) { setBulkApplyTemplateAll(false); if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); return; } if (bulkApplyTemplateAll && activeIndex === masterIndex) { if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; }); if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; }); if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; targetIndicesForTemplate.forEach((idx) => { n[idx] = v; }); return n; }); return; } if (kind === "accent") setBulkAccentColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); if (kind === "bg") setBulkBgColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); if (kind === "text") setBulkTextColors((prev) => { const n = [...prev]; n[activeIndex] = v; return n; }); }; const SelectedPreviewComp = TEMPLATE_PREVIEWS[tpl]; const selectedDesc = TEMPLATE_DESCRIPTIONS[tpl]; const { suggestedTemplates, customTemplatesForStyle, craftedTemplatesForStyle, } = templateBucketsForGenre(genre, templates, readyCustomTemplates, readyCraftedTemplates); const sortedBulkSuggestedTemplates = [...suggestedTemplates].sort((a, b) => { const rank = (t: TemplateMeta) => (t.new_template ? 0 : t.popular_template ? 1 : 2); return rank(a) - rank(b); }); const styleTemplateItems: Array< | { type: "builtin"; id: string; data: TemplateMeta } | { type: "custom"; id: string; data: CustomTemplateItem } | { type: "crafted"; id: string; data: CraftedTemplateItem } > = [ ...sortedBulkSuggestedTemplates.map((t) => ({ type: "builtin" as const, id: t.id, data: t })), ...customTemplatesForStyle.map((ct) => ({ type: "custom" as const, id: `custom_${ct.id}`, data: ct })), ...craftedTemplatesForStyle.map((ct) => ({ type: "crafted" as const, id: ct.id, data: ct })), ]; return (
{/* Bulk logo picker (step 2) */} { const f = e.target.files?.[0] || null; e.target.value = ""; if (bulkLogoRowIndex === null) return; if (f && f.size > 2 * 1024 * 1024) { showError("Logo must be under 2 MB."); return; } const targetIndices = indexed.map(({ i }) => i); if (bulkApplyTemplateAll && bulkLogoRowIndex !== masterIndex) { setBulkApplyTemplateAll(false); setBulkLogoFile((prev) => { const n = [...prev]; n[bulkLogoRowIndex] = f; return n; }); setBulkLogoRowIndex(null); return; } if (bulkApplyTemplateAll && bulkLogoRowIndex === masterIndex) { setBulkLogoFile((prev) => { const n = [...prev]; targetIndices.forEach((idx) => { n[idx] = f; }); return n; }); setBulkLogoRowIndex(null); return; } setBulkLogoFile((prev) => { const n = [...prev]; n[bulkLogoRowIndex] = f; return n; }); setBulkLogoRowIndex(null); }} /> {/* Tabs for each bulk project — match Link/Upload/Multi Link tabs */}
{indexed.map(({ row, i }, tabIdx) => ( ))}
{/* Apply template, colors & logo to all */}
{/* Selected template — full-width preview (same UI as single) */}
{selectedCustomBulk ? ( ) : selectedCraftedBulk && ((selectedCraftedBulk as any).frontend_files || selectedCraftedBulk.preview_file || selectedCraftedBulk.preview_image_url || selectedCraftedBulk.theme) ? ( ) : tpl.startsWith("crafted_") && craftedTemplatesLoading ? (
) : SelectedPreviewComp ? ( ) : (
{selectedDesc?.title ?? tpl}
)}
{selectedCustomBulk ? selectedCustomBulk.name : selectedCraftedBulk ? selectedCraftedBulk.name : (selectedDesc?.title ?? tpl)}
{selectedBuiltinNewBulk && } {selectedCustomBulk && ( Custom )} {selectedCraftedBulk && ( Designer )}
{selectedCustomBulk ? (
Custom template
) : selectedCraftedBulk ? (
Designer template
) : selectedDesc?.subtitle ? (
{selectedDesc.subtitle}
) : null}
{/* Genre filter — same `genre` state as single-link step 2 */}
{genreDropdownOpen && ( <>
setGenreDropdownOpen(false)} />
{allGenres.length > 0 &&
} {allGenres.map((g) => ( ))}
)}

{genreTemplateListCaption(genre)}

{/* Template thumbnails — filtered by genre; video style below is orthogonal. Height matches the single-link grid: 2 columns make the cards taller, so mobile needs the extra room. */}
<> {/* Same responsive columns as the single-link template grid. */}
{ setShowGetMoreTemplates(true); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowGetMoreTemplates(true); } }} /> {styleTemplateItems.map((item) => { if (item.type === "custom" || item.type === "crafted") { const ct = item.data; const customId = item.id; const isSelected = tpl === customId; // Mobile: every grid tile renders a static themed placeholder // and never mounts a Player — the selected template plays only // in the full-width "Selected Template" preview above. A grid of // live Players exhausts iOS Safari's memory and reloads the tab. const staticThumb = isMobile; return (
applyBulkTemplate(customId)} className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-all group ${ isSelected ? "border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]" : "border-gray-200/60 hover:border-purple-300/60" }`} >
{item.type === "crafted" ? ( ) : ( )}
{item.type === "custom" ? : ( Designer )}
{isSelected && (
)}
{ct.name}
); } const t = item.data; const PreviewComp = TEMPLATE_PREVIEWS[t.id]; const desc = TEMPLATE_DESCRIPTIONS[t.id]; const isSelected = tpl === t.id; const isNewTemplate = t.new_template === true; const isPopularTemplate = t.popular_template === true; return (
applyBulkTemplate(t.id)} className={`relative rounded-lg overflow-hidden cursor-pointer transition-all group ${ isSelected ? "border-2 border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.1)]" : isNewTemplate ? "border border-purple-500 shadow-[0_0_0_2px_rgba(124,58,237,0.2)] hover:border-purple-600" : isPopularTemplate ? "border border-amber-400/60 shadow-[0_0_0_2px_rgba(245,158,11,0.15)] hover:border-amber-500" : "border-2 border-gray-200/60 hover:border-purple-300/60" }`} >
{PreviewComp ? ( ) : (
{desc?.title ?? t.name}
)} {isSelected && (
)} {isNewTemplate && (
)} {!isNewTemplate && isPopularTemplate && (
)}
{desc?.title ?? t.name}
); })} {customTemplatesLoading && (

Loading the custom template, please wait.

)} {craftedTemplatesLoading && (

Loading designer templates, please wait.

)}
{styleTemplateItems.length === 0 && (

No templates for this genre. Pick another genre above or add a custom template.

)}
{/* Video Style — orthogonal to genre (same behavior as single-link step 2) */}
{/* Same wrapping/alignment as the single-link Video Style row. */}
{VIDEO_STYLES.map((s) => { const isSelected = activeVideoStyle === s.id; return ( ); })}
{/* Video colors (same UI as single) + Logo (bulk-only extra, placed to the right) */}
{/* Same label + swatch alignment as the single-link Video Colors row. */}
{[ { label: "Accent", value: accent, setter: (v: string) => setBulkColor("accent", v) }, { label: "Background", value: bg, setter: (v: string) => setBulkColor("bg", v) }, { label: "Text", value: text, setter: (v: string) => setBulkColor("text", v) }, ].map(({ label, value, setter }) => ( ))}
{bulkLogoFile[activeIndex] && ( )}
{bulkLogoFile[activeIndex] && (
{([ { value: "top_left", label: "Top Left" }, { value: "top_right", label: "Top Right" }, { value: "bottom_left", label: "Bottom Left" }, { value: "bottom_right", label: "Bottom Right" }, ] as const).map((pos) => ( ))}
{ const val = parseInt(e.target.value, 10) / 100; const targetIndices = indexed.map(({ i }) => i); if (bulkApplyTemplateAll && activeIndex !== masterIndex) { setBulkApplyTemplateAll(false); setBulkLogoOpacity((prev) => { const n = [...prev]; n[activeIndex] = val; return n; }); return; } if (bulkApplyTemplateAll && activeIndex === masterIndex) { setBulkLogoOpacity((prev) => { const n = [...prev]; targetIndices.forEach((idx) => { n[idx] = val; }); return n; }); return; } setBulkLogoOpacity((prev) => { const n = [...prev]; n[activeIndex] = val; return n; }); }} className="w-full h-1 bg-gray-300 rounded-full appearance-none cursor-pointer accent-purple-600" />
)}
); })(); // ─── Step 3: Voice (last step) — audio playlist style ───────── const voiceOptions = [ { gender: "female" as const, accent: "american" as const, key: "female_american" }, { gender: "female" as const, accent: "british" as const, key: "female_british" }, { gender: "male" as const, accent: "american" as const, key: "male_american" }, { gender: "male" as const, accent: "british" as const, key: "male_british" }, ]; const getPlaybackUrl = (key: string): string | null => { if (!VOICE_PREVIEW_KEYS.includes(key)) return null; const base = BACKEND_URL ? `${BACKEND_URL}/api` : "/api"; return `${base}/voices/preview-audio?key=${encodeURIComponent(key)}`; }; const FALLBACK_VOICE_NAMES: Record = { female_american: "Rachel", female_british: "Alice", male_american: "Bill", male_british: "Daniel", }; const FALLBACK_VOICE_DESCS: Record = { female_american: "Warm & confident, clear narration", female_british: "Soft & polished, refined tone", male_american: "Friendly & articulate, conversational", male_british: "Calm & authoritative, smooth delivery", }; const step3Voice = (

Choose narration language. Keep Auto to detect from content.

{renderLanguageDropdown(contentLanguage, setContentLanguage)}

Language of the video content

{/* No voiceover: label above the pill, description + checkbox inside it (mirrors the stock-footage pill in step 1). Stock footage moved to step 1 so the visual choice sits with format/length. */}
{/* Voices from user's saved list + premium teasers for free users */}
{voicePanelTab === "voice" && (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openStep3CustomVoiceCreator(); } }} /> {myVoicesLoading ? (

Loading your voices…

) : ( <> {myVoicesList.map((saved) => { const isSelected = customVoiceId === saved.voice_id; const canSelect = isPro || (saved.plan !== "paid" && !saved.custom_voice_id); const hasPreview = !!saved.preview_url; const myKey = `my_${saved.voice_id}`; const isPlaying = playingKey === myKey; const { displayName } = getMyVoiceDisplayName(saved.name); const isCustom = !!saved.custom_voice_id; return ( playMyVoice(saved)} disabled={false} isSelected={isSelected} onClick={() => { if (!canSelect) { setShowUpgrade(true); return; } const nextId = isSelected ? "" : saved.voice_id; setCustomVoiceId(nextId); if (!isSelected) { const g = normalizeVoiceGender(saved.gender); const a = normalizeVoiceAccent(saved.accent); if (g) setVoiceGender(g); if (a) setVoiceAccent(a); } }} badge={ isCustom ? ( Custom ) : saved.plan === "paid" ? ( Premium ) : null } actions={ isSelected ? (
) : null } /> ); })} {!isPro && premiumTeaserVoices.map((voice) => { const key = `premium_${voice.voice_id}`; const isPlaying = playingKey === key; const labels = voice.labels ?? {}; const subtitle = formatVoiceSubtitle(labels.gender, labels.accent, voice.description ?? "Premium voice"); return ( playPremiumTeaser(voice)} disabled={false} isSelected={false} onClick={() => setShowUpgrade(true)} badge={ Premium } /> ); })} )}
)} {voicePanelTab === "voice" && !myVoicesLoading && myVoicesList.length === 0 && (

No voices saved. Add voices in the Voices tab to use them here.{" "} {!isPro && ( )}

)} {/* Advanced Options tab content — voice tuning sliders (paid) */} {voicePanelTab === "advanced" && isPro && voiceGender !== "none" && ( { setExpressiveEnabled(t.enabled); setVoiceStability(t.stability); setVoiceSpeed(t.speed); setVoiceEmotion(t.emotion); setVoiceStyle(t.style); }} voiceGender={voiceGender} voiceAccent={voiceAccent} customVoiceId={customVoiceId} /> )}
{/* ─── Music tab content (Premium; optional) ─────── */} {voicePanelTab === "bgm" && bgmTracks.length > 0 && (

Ambient music behind your narration — optional.

{/* Volume — shown once a track is picked; applies to the whole project */} {selectedBgmTrackId && (
Music volume {Math.round(selectedBgmVolume * 100)}%
{ const v = Number(e.target.value) / 100; setSelectedBgmVolume(v); if (bgmAudioRef.current) bgmAudioRef.current.volume = Math.max(0, Math.min(1, v)); }} className="w-full accent-purple-600 cursor-pointer" />

Sets the volume for the whole video. After it's generated, you can fine-tune the music volume per scene in the Audio tab.

)}
{/* None option */} {bgmTracks.map((track) => { const isSelected = selectedBgmTrackId === track.track_id; const isPlaying = bgmPlayingId === track.track_id; return (
setSelectedBgmTrackId(isSelected ? null : track.track_id)} className={`flex items-center gap-3 p-3 rounded-xl border transition-all cursor-pointer ${ isSelected ? "border-purple-400 bg-purple-50/60" : "border-gray-200/60 bg-gray-50/40 hover:border-gray-300/60" }`} > {/* Play/pause button */}
{track.display_name}

{track.mood}

{isSelected && (
)}
); })}
)}
); // ─── Step 3 bulk: voice per project (tabbed) ─────────────────── const step3BulkVoice = (() => { const indexed = bulkRows .map((row, i) => ({ row, i })) .filter(({ row }) => row.url.trim()); if (indexed.length === 0) { return (

Please go back to step 1 to continue again.

); } const active = Math.min(bulkActiveIndex, indexed.length - 1); const activeIndex = indexed[active].i; const masterIndex = indexed.find(({ i }) => i === bulkVoiceMasterIndex)?.i ?? indexed[0].i; const rowVoiceGender = bulkVoiceGender[activeIndex] ?? "female"; const rowVoiceAccent = bulkVoiceAccent[activeIndex] ?? "american"; const rowVoiceStability = bulkVoiceStability[activeIndex] ?? VOICE_STABILITY_DEFAULT; const rowVoiceSpeed = bulkVoiceSpeed[activeIndex] ?? VOICE_SPEED_DEFAULT; const rowVoiceEmotion = bulkVoiceEmotion[activeIndex] ?? ""; const rowVoiceStyle = bulkVoiceStyle[activeIndex] ?? VOICE_STYLE_DEFAULT; const rowCustomVoiceId = bulkCustomVoiceId[activeIndex] ?? ""; const rowContentLanguage = bulkContentLanguage[activeIndex] ?? "auto"; const rowVideoLength = bulkVideoLength[activeIndex] ?? "short"; const applyVoiceToAll = () => { const targetIndices = indexed.map(({ i }) => i); setBulkVoiceGender((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceGender; }); return next; }); setBulkVoiceAccent((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceAccent; }); return next; }); setBulkVoiceStability((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceStability; }); return next; }); setBulkVoiceSpeed((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceSpeed; }); return next; }); setBulkVoiceEmotion((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceEmotion; }); return next; }); setBulkVoiceStyle((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVoiceStyle; }); return next; }); setBulkCustomVoiceId((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowCustomVoiceId; }); return next; }); setBulkContentLanguage((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowContentLanguage; }); return next; }); setBulkVideoLength((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = rowVideoLength; }); return next; }); }; return (
{/* Tabs for each bulk project — match Link/Upload/Multi Link tabs */}
{indexed.map(({ row, i }, tabIdx) => ( ))}
{/* Apply voice selection to all */}
{renderLanguageDropdown(rowContentLanguage, (value) => { const targetIndices = indexed.map(({ i }) => i); if (bulkApplyVoiceAll && activeIndex === masterIndex) { setBulkContentLanguage((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = value; }); return next; }); } else { setBulkApplyVoiceAll(false); setBulkContentLanguage((prev) => { const next = [...prev]; next[activeIndex] = value; return next; }); } })}

Language of the video content

{/* No voiceover: label above the pill, description + checkbox inside it (mirrors step 1). Edits this row, or all rows when the apply-to-all voice sync is on. */}
{!(showAdvancedOptions && isPro) && (
{myVoicesLoading ? (

Loading your voices…

) : ( <> {myVoicesList.map((saved) => { const isSelectedBulk = rowCustomVoiceId === saved.voice_id; const canSelectBulk = isPro || (saved.plan !== "paid" && !saved.custom_voice_id); const hasPreview = !!saved.preview_url; const myKey = `my_${saved.voice_id}`; const isPlaying = playingKey === myKey; const { displayName } = getMyVoiceDisplayName(saved.name); const isCustom = !!saved.custom_voice_id; return ( playMyVoice(saved)} disabled={false} isSelected={isSelectedBulk} onClick={() => { if (!canSelectBulk) { setShowUpgrade(true); return; } const value = isSelectedBulk ? "" : saved.voice_id; const g = normalizeVoiceGender(saved.gender); const a = normalizeVoiceAccent(saved.accent); const targetIndices = indexed.map(({ i }) => i); if (bulkApplyVoiceAll && activeIndex === masterIndex) { if (g) { setBulkVoiceGender((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = g; }); return next; }); } if (a) { setBulkVoiceAccent((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = a; }); return next; }); } setBulkCustomVoiceId((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = value; }); return next; }); } else { setBulkApplyVoiceAll(false); if (g) { setBulkVoiceGender((prev) => { const next = [...prev]; next[activeIndex] = g; return next; }); } if (a) { setBulkVoiceAccent((prev) => { const next = [...prev]; next[activeIndex] = a; return next; }); } setBulkCustomVoiceId((prev) => { const next = [...prev]; next[activeIndex] = value; return next; }); } }} badge={ isCustom ? ( Custom ) : saved.plan === "paid" ? ( Premium ) : null } actions={ isSelectedBulk ? (
) : null } /> ); })} {!isPro && premiumTeaserVoices.map((voice) => { const key = `premium_${voice.voice_id}`; const isPlaying = playingKey === key; const labels = voice.labels ?? {}; const subtitle = formatVoiceSubtitle(labels.gender, labels.accent, voice.description ?? "Premium voice"); return ( playPremiumTeaser(voice)} disabled={false} isSelected={false} onClick={() => setShowUpgrade(true)} badge={ Premium } /> ); })} )}
)} {!(showAdvancedOptions && isPro) && !myVoicesLoading && myVoicesList.length === 0 && (

No voices saved. Add voices in the Voices tab to use them here.{" "} {!isPro && ( )}

)}
{/* Voice tuning — Stability + Speed sliders (paid); apply-to-all sync mirrors other voice controls */} {(() => { const applyBulkTuning = ( setter: React.Dispatch>, value: T, ) => { const targetIndices = indexed.map(({ i }) => i); if (bulkApplyVoiceAll && activeIndex === masterIndex) { setter((prev) => { const next = [...prev]; targetIndices.forEach((idx) => { next[idx] = value; }); return next; }); } else { setBulkApplyVoiceAll(false); setter((prev) => { const next = [...prev]; next[activeIndex] = value; return next; }); } }; return (
{showAdvancedOptions && isPro && (

Enable

Emotion (optional)
{VOICE_EMOTIONS.map((em) => { const selected = rowVoiceEmotion === em.value; return ( ); })}
Expressiveness {rowVoiceStability.toFixed(2)}
applyBulkTuning(setBulkVoiceStability, parseFloat(e.target.value))} className="w-full accent-purple-600 cursor-pointer" />
Steady Expressive
Character {rowVoiceStyle.toFixed(2)}
applyBulkTuning(setBulkVoiceStyle, parseFloat(e.target.value))} className="w-full accent-purple-600 cursor-pointer" />
Natural Dramatic
{rowVoiceStyle >= 0.4 && rowVoiceStability >= 0.7 && (

High Character + high Expressiveness can sound distorted.

)}
Speed {rowVoiceSpeed.toFixed(2)}x
applyBulkTuning(setBulkVoiceSpeed, parseFloat(e.target.value))} className="w-full accent-purple-600 cursor-pointer" />
)}
); })()}
); })(); // ─── Render ────────────────────────────────────────────────── // Step order: 1 Project, 2 Template, 3 Voice const stepContent = step === 1 ? step1 : step === 2 ? mode === "bulk" ? step2BulkTemplate : step2Template : mode === "bulk" ? step3BulkVoice : step3Voice; const modalWidth = "max-w-xl"; const isStep2TemplatesPending = step === 2 && (builtinTemplatesLoading || templateAvailabilityLoading || !sessionBuiltinInitDone); // Constant form size: min-height so layout doesn’t jump between steps const stepContentWrapper = (
{stepContent}
{isStep2TemplatesPending && (
)}
); const formContent = ( <>
{ // Prevent Enter from submitting unless the submit button is focused (avoids auto-submit when landing on step 3) if (e.key === "Enter" && document.activeElement !== submitButtonRef.current) { e.preventDefault(); } }} >
{stepContentWrapper}
setShowUpgrade(false)} title="Upgrade to unlock" subtitle="Unlock premium voices and more. Choose a plan below." /> setShowCustomTemplateUpgrade(false)} subscriptionsOnly title="Upgrade to use custom template" subtitle="Custom templates are a Standard & Pro feature. Upgrade your plan to use this template in your videos." /> setShowGetMoreTemplates(false)} onChooseLink={() => { setShowGetMoreTemplates(false); openStep2CustomTemplateCreator(videoStyle, null); }} onChooseDesigner={() => { setShowGetMoreTemplates(false); setShowDesignerRequest(true); }} /> setShowDesignerRequest(false)} /> ); if (!asModal) { return (
{ if (e.key === "Enter" && document.activeElement !== submitButtonRef.current) { e.preventDefault(); } }} > {stepContentWrapper}
setShowUpgrade(false)} title="Upgrade to unlock" subtitle="Unlock premium voices and more. Choose a plan below." /> setShowCustomTemplateUpgrade(false)} subscriptionsOnly title="Upgrade to use custom template" subtitle="Custom templates are a Standard & Pro feature. Upgrade your plan to use this template in your videos." /> setShowGetMoreTemplates(false)} onChooseLink={() => { setShowGetMoreTemplates(false); openStep2CustomTemplateCreator(videoStyle, null); }} onChooseDesigner={() => { setShowGetMoreTemplates(false); setShowDesignerRequest(true); }} /> setShowDesignerRequest(false)} /> {videoPreviewId && ( setVideoPreviewId(null)} onSelect={() => applyTemplate(videoPreviewId)} isSelected={template === videoPreviewId} customTemplate={ videoPreviewId.startsWith("custom_") ? customTemplates.find((ct) => ct.id === parseInt(videoPreviewId.replace("custom_", ""))) : videoPreviewId.startsWith("crafted_") ? craftedTemplates.find((ct) => ct.id === videoPreviewId) : null } /> )}
); } return ReactDOM.createPortal(
{/* Header */}

New Project

{formContent}
{videoPreviewId && ( setVideoPreviewId(null)} onSelect={() => applyTemplate(videoPreviewId)} isSelected={template === videoPreviewId} /> )}
, document.body ); }