import React, { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowLeft, ArrowRight, Check, FileText, Play, StopCircle, AlertCircle, AlertTriangle, Upload, Wand2, Trash2, Copy as CopyIcon, Eye, EyeOff, Gauge, Sparkles, Sliders, Layers, Lock, Unlock, RotateCcw, ArrowLeftRight, Zap, } from 'lucide-react' import PreflightModal from '../components/PreflightModal' import BackendRejectedBanner from '../components/BackendRejectedBanner' import Banner from '../components/Banner' import Toggle from '../components/Toggle' import { useTrackedGenerate } from '../hooks/useTrackedGenerate' import { useBackendCapabilities } from '../hooks/useBackendPlatform' import { api } from '../api/client' import { useSettings, DEFAULT_CLASS_OPTIONS, DEFAULT_SUBJECT_OPTIONS, } from '../store/settings' import type { GenerateSettings, OutputFormat, SavedThumbnailTemplate } from '../api/types' import { consumeProcessEditHandoff } from '../lib/processEditHandoff' import type { ReplacementTargets } from '../lib/processEditHandoff' import { buildAutoThumbnailFile, buildAutoThumbnailTemplate, renderTemplateToDataUrl, duplicateElement, nextElementId, detectChapterMeta, incrementChapterNum, type ThumbnailElement, type ThumbnailShapeType, } from '../lib/thumbnailBuilder' // ─── Defaults ────────────────────────────────────────────────────────────── const DEFAULT_SETTINGS: GenerateSettings = { output_format: 'images', class_name: 'Class 10', subject: 'Nepali', model_choice: 'default', zoom: 2.1, overlap: 15, viewport_width: 1920, viewport_height: 1080, max_screenshots: 50, use_cache: true, beautify_html: false, close_powerpoint_before_start: true, auto_timing_screenshot_slides: true, fixed_seconds_per_screenshot_slide: 5, resolution: '1080p', video_quality: 85, fps: 30, slide_duration_sec: 5, intro_thumbnail_enabled: false, intro_thumbnail_duration_sec: 5, outro_thumbnail_enabled: false, outro_thumbnail_duration_sec: 5, } // New storage key (v2) captures the full wizard state so "Reuse previous // run" restores everything: text, all GenerateSettings (model choice, // system prompt, screenshot settings, video settings, thumbnails, …). // The old v1 key only stored {class_name, subject, title, output_format} // — we still read it as a fallback so users who ran at least once under // v1 don't lose the little that was saved. const LAST_RUN_STORAGE_KEY = 'textbro:text-to-video:last-run:v2' const HTML_LAST_RUN_STORAGE_KEY = 'textbro:html-to-video:last-run:v1' const LEGACY_PROJECT_DETAILS_STORAGE_KEY = 'textbro:text-to-video:project-details:v1' // C3: keys for the always-on draft autosave (every 5s while editing). const TEXT_DRAFT_STORAGE_KEY = 'textbro:text-to-video:draft:v1' const HTML_DRAFT_STORAGE_KEY = 'textbro:html-to-video:draft:v1' const DRAFT_AUTOSAVE_MS = 5_000 type SourceMode = 'text' | 'html' type LegacyProjectDetails = Pick /** Full snapshot of the wizard — everything needed to restore a prior run. */ interface LastRunSnapshot { text: string settings: GenerateSettings } function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null } function readLastRunSnapshot(mode: SourceMode = 'text'): LastRunSnapshot | null { if (typeof window === 'undefined') return null try { const raw = window.localStorage.getItem( mode === 'html' ? HTML_LAST_RUN_STORAGE_KEY : LAST_RUN_STORAGE_KEY, ) if (raw) { const parsed = JSON.parse(raw) as unknown if (isRecord(parsed) && isRecord(parsed.settings)) { return { text: typeof parsed.text === 'string' ? parsed.text : '', settings: parsed.settings as GenerateSettings, } } } // Fallback — legacy key only had the 4 project-info fields. const legacyRaw = mode === 'text' ? window.localStorage.getItem(LEGACY_PROJECT_DETAILS_STORAGE_KEY) : null if (legacyRaw) { const legacy = JSON.parse(legacyRaw) as LegacyProjectDetails if (isRecord(legacy)) { return { text: '', settings: { class_name: typeof legacy.class_name === 'string' ? legacy.class_name : undefined, subject: typeof legacy.subject === 'string' ? legacy.subject : undefined, title: typeof legacy.title === 'string' ? legacy.title : undefined, output_format: legacy.output_format, }, } } } return null } catch { return null } } function saveLastRunSnapshot( text: string, settings: GenerateSettings, mode: SourceMode = 'text', ): LastRunSnapshot | null { if (typeof window === 'undefined') return null const snapshot: LastRunSnapshot = { text, settings } try { window.localStorage.setItem( mode === 'html' ? HTML_LAST_RUN_STORAGE_KEY : LAST_RUN_STORAGE_KEY, JSON.stringify(snapshot), ) // Clear the legacy key so the reuse button doesn't surface stale // project-info that we've already superseded. if (mode === 'text') window.localStorage.removeItem(LEGACY_PROJECT_DETAILS_STORAGE_KEY) } catch { /* ignore storage failures */ } return snapshot } // C3: every-5s draft autosave keys are independent from the // "Reuse previous run" snapshot — we always write the in-progress state // here so a refresh / accidental tab close doesn't lose work. function readDraftSnapshot(mode: SourceMode = 'text'): LastRunSnapshot | null { if (typeof window === 'undefined') return null try { const raw = window.localStorage.getItem( mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, ) if (!raw) return null const parsed = JSON.parse(raw) as unknown if (isRecord(parsed) && isRecord(parsed.settings)) { return { text: typeof parsed.text === 'string' ? parsed.text : '', settings: parsed.settings as GenerateSettings, } } return null } catch { return null } } function saveDraftSnapshot(text: string, settings: GenerateSettings, mode: SourceMode = 'text') { if (typeof window === 'undefined') return try { window.localStorage.setItem( mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, JSON.stringify({ text, settings, savedAt: Date.now() }), ) } catch { /* quota errors etc — ignore, user can still submit */ } } function clearDraftSnapshot(mode: SourceMode = 'text') { if (typeof window === 'undefined') return try { window.localStorage.removeItem( mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, ) } catch { /* ignore */ } } function captureThumbnailTemplateSettings(settings: GenerateSettings): Partial { const sideImageUrl = settings.auto_thumbnail_side_image_url return { auto_thumbnail_chapter_num: settings.auto_thumbnail_chapter_num, auto_thumbnail_year: settings.auto_thumbnail_year, auto_thumbnail_chapter_prefix: settings.auto_thumbnail_chapter_prefix, auto_thumbnail_side_image_url: sideImageUrl?.startsWith('blob:') ? undefined : sideImageUrl, auto_thumbnail_image_offset_x: settings.auto_thumbnail_image_offset_x, auto_thumbnail_image_offset_y: settings.auto_thumbnail_image_offset_y, auto_thumbnail_image_zoom: settings.auto_thumbnail_image_zoom, auto_thumbnail_outro_title: settings.auto_thumbnail_outro_title, auto_thumbnail_outro_side_image_url: settings.auto_thumbnail_outro_side_image_url?.startsWith('blob:') ? undefined : settings.auto_thumbnail_outro_side_image_url, auto_thumbnail_canvas_background: settings.auto_thumbnail_canvas_background, auto_thumbnail_overrides: settings.auto_thumbnail_overrides, auto_thumbnail_added_elements: settings.auto_thumbnail_added_elements, auto_thumbnail_hidden_elements: settings.auto_thumbnail_hidden_elements, } } type StepId = 'project' | 'content' | 'screenshot' | 'video' | 'thumbnail' | 'advanced' interface StepDef { id: StepId label: string shortLabel: string /** Output formats for which this step is irrelevant and should be hidden. */ hiddenFor?: OutputFormat[] } const STEP_DEFS: StepDef[] = [ { id: 'project', label: 'Project info', shortLabel: 'Project' }, { id: 'content', label: 'AI & text', shortLabel: 'Content' }, // Screenshots only matter once HTML has to be rendered to images. { id: 'screenshot', label: 'Screenshot settings', shortLabel: 'Screenshots', hiddenFor: ['html'] }, // Video + thumbnail only matter for PowerPoint/MP4 export. { id: 'video', label: 'Video settings', shortLabel: 'Video', hiddenFor: ['html', 'images'] }, { id: 'thumbnail', label: 'Thumbnail', shortLabel: 'Thumbnail', hiddenFor: ['html', 'images'] }, { id: 'advanced', label: 'Advanced & start', shortLabel: 'Advanced' }, ] const OUTPUT_OPTIONS: { value: OutputFormat; label: string; desc: string }[] = [ { value: 'html', label: 'HTML file', desc: 'Raw AI-generated HTML only' }, { value: 'images', label: 'Screenshots', desc: 'HTML rendered to PNG images (default)' }, { value: 'pptx', label: 'PowerPoint', desc: 'Images packed into a .pptx (Windows only)' }, { value: 'video', label: 'MP4 video', desc: 'Rendered to MP4 via PowerPoint (Windows) or MoviePy (Linux/macOS).' }, ] // ─── Validation ──────────────────────────────────────────────────────────── type FieldErrors = Record /** Returns a map of { fieldId -> errorMessage } for a given step. Empty = valid. */ function validateStep( id: StepId, settings: GenerateSettings, text: string, mode: SourceMode = 'text', autoThumbnailBuilder = false, ): FieldErrors { const errs: FieldErrors = {} const num = (v: unknown): number | null => { if (v === undefined || v === null || v === '') return null const n = Number(v) return Number.isFinite(n) ? n : null } switch (id) { case 'project': { if (!(settings.class_name ?? '').trim()) errs.class_name = 'Pick a class.' if (!(settings.subject ?? '').trim()) errs.subject = 'Pick a subject.' if (!(settings.title ?? '').trim()) errs.title = 'Enter a chapter title — this is used in the video and thumbnail.' if (!settings.output_format) errs.output_format = 'Pick an output format.' return errs } case 'content': { if (!text.trim()) errs.text = mode === 'html' ? 'Paste your HTML here' : 'Paste your source text here' return errs } case 'screenshot': { const zoom = num(settings.zoom) if (zoom === null || zoom <= 0 || zoom > 10) errs.zoom = 'Zoom must be between 0.1 and 10' const overlap = num(settings.overlap) if (overlap === null || overlap < 0) errs.overlap = 'Overlap must be 0 or more' const vw = num(settings.viewport_width) if (vw === null || vw < 320) errs.viewport_width = 'Width must be at least 320px' const vh = num(settings.viewport_height) if (vh === null || vh < 240) errs.viewport_height = 'Height must be at least 240px' if (overlap !== null && vh !== null && overlap >= vh) { errs.overlap = 'Overlap must be less than viewport height' } const mx = num(settings.max_screenshots) if (mx === null || mx < 1) errs.max_screenshots = 'At least 1' return errs } case 'video': { if (!settings.resolution) errs.resolution = 'Pick a resolution' const q = num(settings.video_quality) if (q === null || q < 1 || q > 100) errs.video_quality = 'Between 1 and 100' const fps = num(settings.fps) if (fps === null || fps < 1 || fps > 120) errs.fps = 'Between 1 and 120' const sd = num(settings.slide_duration_sec) if (sd === null || sd <= 0) errs.slide_duration_sec = 'Must be greater than 0' return errs } case 'thumbnail': { if (settings.intro_thumbnail_enabled) { if (!autoThumbnailBuilder && !(settings.intro_thumbnail_filename ?? '').trim()) { errs.intro_thumbnail_filename = 'Upload an image first' } const d = num(settings.intro_thumbnail_duration_sec) if (d === null || d <= 0) { errs.intro_thumbnail_duration_sec = 'Duration must be greater than 0' } } if (settings.outro_thumbnail_enabled) { if (!autoThumbnailBuilder && !(settings.outro_thumbnail_filename ?? '').trim()) { errs.outro_thumbnail_filename = 'Upload an image first' } const d = num(settings.outro_thumbnail_duration_sec) if (d === null || d <= 0) { errs.outro_thumbnail_duration_sec = 'Duration must be greater than 0' } } return errs } case 'advanced': { if (!settings.auto_timing_screenshot_slides) { const f = num(settings.fixed_seconds_per_screenshot_slide) if (f === null || f <= 0) { errs.fixed_seconds_per_screenshot_slide = 'Seconds must be greater than 0' } } return errs } } } /** Scroll the first error field on a step into view and focus it. */ function focusFirstError(stepId: StepId, errs: FieldErrors) { const first = Object.keys(errs)[0] if (!first) return // Defer so the inline error nodes have rendered. setTimeout(() => { const el = document.getElementById(fieldId(stepId, first)) if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { el.focus({ preventScroll: true }) } } }, 0) } const fieldId = (step: StepId, name: string) => `field-${step}-${name}` // ─── Page ────────────────────────────────────────────────────────────────── export default function TextToVideo({ sourceMode = 'text' }: { sourceMode?: SourceMode }) { const nav = useNavigate() const tracked = useTrackedGenerate(sourceMode === 'html' ? 'html-to-video' : 'text-to-video') const { state, cancel } = tracked const generateSource = sourceMode === 'html' ? tracked.generateFromHtml : tracked.generate const running = false const [text, setText] = useState('') const { settings: appSettings } = useSettings() const [settings, setSettings] = useState(() => ({ ...DEFAULT_SETTINGS, output_format: sourceMode === 'html' ? 'video' : appSettings.defaultOutputFormat, // When the global Auto-thumbnail-builder preference is on, default // both per-run slots to enabled so the wizard mounts with the same // intent the user expressed in Settings. They can still toggle each // slot off independently before submitting. intro_thumbnail_enabled: appSettings.autoThumbnailBuilder ? true : DEFAULT_SETTINGS.intro_thumbnail_enabled, outro_thumbnail_enabled: appSettings.autoThumbnailBuilder ? true : DEFAULT_SETTINGS.outro_thumbnail_enabled, })) const [lastRunSnapshot, setLastRunSnapshot] = useState(() => readLastRunSnapshot(sourceMode), ) const [replaceTargets, setReplaceTargets] = useState(null) const [stepId, setStepId] = useState('project') const [showPreflight, setShowPreflight] = useState(false) const [autoThumbnailPreviewUrl, setAutoThumbnailPreviewUrl] = useState(null) const [autoThumbnailError, setAutoThumbnailError] = useState(null) const [autoThumbnailSaving, setAutoThumbnailSaving] = useState(false) const [autoThumbnailEditOpen, setAutoThumbnailEditOpen] = useState(false) const preflightProceedingRef = useRef(false) /** Step ids whose inline errors should be visible (only populated after the * user clicks Next on an invalid step). Silent until then. */ const [erroredSteps, setErroredSteps] = useState>(new Set()) /** C1: live-validation toggle. Once any step has been touched / visited * past the first one, we set this and per-step error icons appear in * the tab strip even before the user clicks Next. */ const [liveValidate, setLiveValidate] = useState(false) /** C3: surface a "you have unsaved progress" prompt whenever a draft was * found at mount time and not yet consumed. */ const [draftSnapshot, setDraftSnapshot] = useState(() => readDraftSnapshot(sourceMode), ) useEffect(() => { const draft = consumeProcessEditHandoff(sourceMode === 'html' ? 'html-to-video' : 'text-to-video') if (!draft) return setText(draft.text) setSettings((prev) => ({ ...prev, ...draft.settings })) setReplaceTargets(draft.replaceTargets) setStepId('project') setErroredSteps(new Set()) setDraftSnapshot(null) }, []) const set = (key: K, v: GenerateSettings[K]) => { setSettings((prev) => ({ ...prev, [key]: v })) // C1: as soon as the user starts editing, flip on live validation so // the per-step error icons update without waiting for a Next click. if (!liveValidate) setLiveValidate(true) } /** * C3: every 5s while the user is making progress, snapshot the current * state into localStorage. We skip the save when the page is idle (no * text and no overrides past defaults) so we don't write empty drafts. */ useEffect(() => { const id = window.setInterval(() => { const hasContent = text.trim().length > 0 || (settings.title ?? '').trim().length > 0 || (settings.auto_thumbnail_chapter_num ?? '').trim().length > 0 || (settings.intro_thumbnail_filename ?? '').trim().length > 0 || (settings.outro_thumbnail_filename ?? '').trim().length > 0 if (!hasContent) return saveDraftSnapshot(text, settings, sourceMode) }, DRAFT_AUTOSAVE_MS) return () => window.clearInterval(id) }, [text, settings, sourceMode]) // Beforeunload safety: write a final snapshot synchronously when the user // closes the tab. The 5s interval may have just fired; this is a belt for // the case where they edit the very last 4s before quitting. useEffect(() => { const handler = () => saveDraftSnapshot(text, settings, sourceMode) window.addEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler) }, [text, settings, sourceMode]) const restoreDraft = () => { if (!draftSnapshot) return setSettings((prev) => ({ ...prev, ...draftSnapshot.settings })) if (typeof draftSnapshot.text === 'string') setText(draftSnapshot.text) setStepId('project') setErroredSteps(new Set()) setDraftSnapshot(null) } const dismissDraft = () => { clearDraftSnapshot(sourceMode) setDraftSnapshot(null) } const shouldAutoBuildThumbnail = sourceMode === 'text' && appSettings.autoThumbnailBuilder && (settings.output_format === 'video' || settings.output_format === 'pptx') useEffect(() => { // Live preview is only used as a placeholder for the intro tile when no // file has been saved yet. Once the user clicks "Use as intro thumbnail" // the saved server file takes over, so we stop computing it. (The // editor's own mini-preview is independent and always reflects the // current template.) if (!shouldAutoBuildThumbnail || (settings.intro_thumbnail_filename ?? '').trim()) { setAutoThumbnailPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev) return null }) return } let cancelled = false let objectUrl: string | null = null buildAutoThumbnailFile(settings, text) .then((file) => { if (cancelled) return objectUrl = URL.createObjectURL(file) setAutoThumbnailPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev) return objectUrl }) }) .catch((err) => { if (!cancelled) setAutoThumbnailError(err instanceof Error ? err.message : String(err)) }) return () => { cancelled = true if (objectUrl) URL.revokeObjectURL(objectUrl) } }, [ shouldAutoBuildThumbnail, settings.class_name, settings.subject, settings.title, settings.auto_thumbnail_side_image_url, settings.auto_thumbnail_chapter_num, settings.auto_thumbnail_year, settings.auto_thumbnail_chapter_prefix, settings.auto_thumbnail_image_offset_x, settings.auto_thumbnail_image_offset_y, settings.auto_thumbnail_image_zoom, settings.auto_thumbnail_canvas_background, settings.auto_thumbnail_overrides, settings.auto_thumbnail_added_elements, settings.auto_thumbnail_hidden_elements, settings.intro_thumbnail_filename, settings, text, ]) useEffect(() => { return () => { const url = settings.auto_thumbnail_side_image_url if (url?.startsWith('blob:')) URL.revokeObjectURL(url) const outroUrl = settings.auto_thumbnail_outro_side_image_url if (outroUrl?.startsWith('blob:')) URL.revokeObjectURL(outroUrl) } }, [settings.auto_thumbnail_side_image_url, settings.auto_thumbnail_outro_side_image_url]) // Compute the chapter / unit number that *would* be used by the // auto-thumbnail renderer right now. Manual override beats auto-detection // beats the "Chapter 1" fallback. Used to derive the outro number, which // is one greater than whatever the intro renders with. const currentChapterNum = (): string => { const manual = (settings.auto_thumbnail_chapter_num ?? '').trim() if (manual) return manual const detected = detectChapterMeta(text, settings) if (detected) return detected.num return '1' } const useAutoThumbnailNow = async (slot: 'intro' | 'outro' | 'both' = 'intro') => { if (autoThumbnailSaving) return setAutoThumbnailSaving(true) setAutoThumbnailError(null) try { // 2× pixel ratio gives a 3840×2160 master file for sharper YouTube // uploads; the default 1.5× already exceeds the on-screen 1920×1080. const pixelRatio = settings.auto_thumbnail_export_2x ? 2 : 1.5 // Build the intro file from the current settings as-is. // Build the outro file from a clone with `auto_thumbnail_chapter_num` // bumped by one — so unit 2's outro reads "Unit 3", chapter 5's outro // reads "Chapter 6", पाठ ४'s outro reads पाठ ५, etc. The on-screen // settings stay at their current value so re-saving the intro after // doesn't accidentally bump it. (Use for both = intro at N + outro at // N+1 in one click.) let introFile: File | null = null if (slot === 'intro' || slot === 'both') { introFile = await buildAutoThumbnailFile(settings, text, pixelRatio) } let outroFile: File | null = null if (slot === 'outro' || slot === 'both') { const outroSettings: GenerateSettings = { ...settings, title: settings.auto_thumbnail_outro_title?.trim() || settings.title, auto_thumbnail_side_image_url: settings.auto_thumbnail_outro_side_image_url || settings.auto_thumbnail_side_image_url, auto_thumbnail_chapter_num: incrementChapterNum(currentChapterNum()), } outroFile = await buildAutoThumbnailFile(outroSettings, text, pixelRatio) } // Each slot uploads its own file so updating one never disturbs the // other. ('both' uploads twice; tiny cost vs. the surprise of one // shared file overwriting both slots when only one was edited.) if (introFile) { const { filename } = await api.uploadThumbnail(introFile) setSettings((prev) => ({ ...prev, intro_thumbnail_enabled: true, intro_thumbnail_filename: filename, auto_thumbnail_generated: true, })) } if (outroFile) { const { filename } = await api.uploadThumbnail(outroFile) setSettings((prev) => ({ ...prev, outro_thumbnail_enabled: true, outro_thumbnail_filename: filename, auto_thumbnail_outro_generated: true, })) } } catch (err) { setAutoThumbnailError(err instanceof Error ? err.message : String(err)) } finally { setAutoThumbnailSaving(false) } } const setAutoThumbnailSideImage = (file: File | null) => { setSettings((prev) => { const previousUrl = prev.auto_thumbnail_side_image_url if (previousUrl?.startsWith('blob:')) URL.revokeObjectURL(previousUrl) return { ...prev, auto_thumbnail_side_image_url: file ? URL.createObjectURL(file) : undefined, } }) } const setAutoThumbnailOutroSideImage = (file: File | null) => { setSettings((prev) => { const previousUrl = prev.auto_thumbnail_outro_side_image_url if (previousUrl?.startsWith('blob:')) URL.revokeObjectURL(previousUrl) return { ...prev, auto_thumbnail_outro_side_image_url: file ? URL.createObjectURL(file) : undefined, } }) } // Swap intro ↔ outro thumbnail file references. Useful when the end-card // saved as outro of one unit should become the intro of the next ("Reuse // previous run" → swap → ready). Files on disk are untouched; only the // pointers and the auto-generated flags are exchanged. const swapIntroOutroThumbnails = () => { setSettings((prev) => ({ ...prev, intro_thumbnail_filename: prev.outro_thumbnail_filename, outro_thumbnail_filename: prev.intro_thumbnail_filename, intro_thumbnail_enabled: prev.outro_thumbnail_filename?.trim() ? true : prev.intro_thumbnail_enabled, outro_thumbnail_enabled: prev.intro_thumbnail_filename?.trim() ? true : prev.outro_thumbnail_enabled, auto_thumbnail_generated: prev.auto_thumbnail_outro_generated, auto_thumbnail_outro_generated: prev.auto_thumbnail_generated, })) } const perStepErrors: Record = useMemo(() => ({ project: validateStep('project', settings, text, sourceMode, shouldAutoBuildThumbnail), content: validateStep('content', settings, text, sourceMode, shouldAutoBuildThumbnail), screenshot: validateStep('screenshot', settings, text, sourceMode, shouldAutoBuildThumbnail), video: validateStep('video', settings, text, sourceMode, shouldAutoBuildThumbnail), thumbnail: validateStep('thumbnail', settings, text, sourceMode, shouldAutoBuildThumbnail), advanced: validateStep('advanced', settings, text, sourceMode, shouldAutoBuildThumbnail), }), [settings, text, sourceMode, shouldAutoBuildThumbnail]) const stepValid = (id: StepId) => Object.keys(perStepErrors[id]).length === 0 // Visible steps depend on the chosen output format — irrelevant steps // (e.g. Video / Thumbnail for html / images output) are hidden so the user // doesn't fill fields that will never be used. const outputFormat: OutputFormat = settings.output_format ?? 'images' const visibleSteps = useMemo( () => STEP_DEFS.filter((s) => !s.hiddenFor?.includes(outputFormat)), [outputFormat], ) // If the user toggled output_format and hid the currently-selected step, // fall back to the project step *derived* (no effect, no cascading render). const activeStepId: StepId = visibleSteps.some((s) => s.id === stepId) ? stepId : 'project' const stepIndex = visibleSteps.findIndex((s) => s.id === activeStepId) const currentErrors = perStepErrors[activeStepId] // Errors are shown after the user first attempted to leave an invalid step. // Once shown, they stay live — the Field border flips red/green as the user // types — which is the normal "touched-then-validate-on-change" pattern. const showCurrentErrors = erroredSteps.has(activeStepId) && Object.keys(currentErrors).length > 0 /** A step (in the visible list) is reachable if every earlier visible step is valid. */ const canNavigateTo = (target: StepId): boolean => { const targetIdx = visibleSteps.findIndex((s) => s.id === target) if (targetIdx <= stepIndex) return true for (let i = 0; i < targetIdx; i++) { if (!stepValid(visibleSteps[i].id)) return false } return true } // Only the *visible* steps participate in the final validation. const allValid = visibleSteps.every((s) => stepValid(s.id)) const onStart = () => { // Surface every outstanding error at once and jump to the first broken step. if (!allValid) { const broken = visibleSteps.find((s) => !stepValid(s.id))! setErroredSteps(new Set(visibleSteps.filter((s) => !stepValid(s.id)).map((s) => s.id))) setStepId(broken.id) focusFirstError(broken.id, perStepErrors[broken.id]) return } preflightProceedingRef.current = false setShowPreflight(true) } const reuseLastRun = () => { if (!lastRunSnapshot) return // Full restore: text + every field the previous run set. Start from // the current baseline so anything *not* captured in the snapshot // (new settings added after the snapshot was saved) keeps its // current value instead of becoming `undefined`. setSettings((prev) => ({ ...prev, ...lastRunSnapshot.settings })) if (typeof lastRunSnapshot.text === 'string') setText(lastRunSnapshot.text) // After a restore, land the user back on the first step so they // can quickly confirm the restored values before running. setStepId('project') setErroredSteps(new Set()) } const onPreflightProceed = async () => { if (preflightProceedingRef.current) return preflightProceedingRef.current = true setShowPreflight(false) const payload: GenerateSettings = { ...settings } payload.class_name = (payload.class_name ?? '').trim() || undefined payload.subject = (payload.subject ?? '').trim() || undefined payload.title = (payload.title ?? '').trim() || undefined payload.concurrent_pipeline_runs = appSettings.concurrentPipelineRuns setAutoThumbnailError(null) // Only auto-build the intro / outro slots when the user has the // per-run toggle on. The global Auto-thumbnail-builder preference // means "if the user wants a thumbnail, generate it for them" — it // does NOT force-enable the slot. Letting the user toggle each slot // off skips the corresponding auto-build step. if (shouldAutoBuildThumbnail && payload.intro_thumbnail_enabled) { const existingIntroThumbnail = (payload.intro_thumbnail_filename ?? '').trim() if (!existingIntroThumbnail) { try { const pixelRatio = payload.auto_thumbnail_export_2x ? 2 : 1.5 const file = await buildAutoThumbnailFile(payload, text, pixelRatio) const { filename } = await api.uploadThumbnail(file) payload.intro_thumbnail_filename = filename payload.auto_thumbnail_generated = true setSettings((prev) => ({ ...prev, intro_thumbnail_filename: filename, auto_thumbnail_generated: true, })) } catch (err) { preflightProceedingRef.current = false setAutoThumbnailError(err instanceof Error ? err.message : String(err)) setStepId('thumbnail') return } } } if (shouldAutoBuildThumbnail && payload.outro_thumbnail_enabled) { try { const pixelRatio = payload.auto_thumbnail_export_2x ? 2 : 1.5 const existingOutroThumbnail = (payload.outro_thumbnail_filename ?? '').trim() if (!existingOutroThumbnail) { const outroPayload: GenerateSettings = { ...payload, title: payload.auto_thumbnail_outro_title?.trim() || payload.title, auto_thumbnail_side_image_url: payload.auto_thumbnail_outro_side_image_url || payload.auto_thumbnail_side_image_url, auto_thumbnail_chapter_num: incrementChapterNum(currentChapterNum()), } const file = await buildAutoThumbnailFile(outroPayload, text, pixelRatio) const { filename } = await api.uploadThumbnail(file) payload.outro_thumbnail_filename = filename payload.auto_thumbnail_outro_generated = true setSettings((prev) => ({ ...prev, outro_thumbnail_filename: filename, auto_thumbnail_outro_generated: true, })) } } catch (err) { preflightProceedingRef.current = false setAutoThumbnailError(err instanceof Error ? err.message : String(err)) setStepId('thumbnail') return } } // Snapshot the full wizard state (text + settings) so the next // session can restore everything via "Reuse previous run". setLastRunSnapshot(saveLastRunSnapshot(text, payload, sourceMode)) // C3: the run was actually started, so the in-progress draft is no // longer relevant — clear it. Reuse-previous-run still works through // the LAST_RUN snapshot we just saved. clearDraftSnapshot(sourceMode) // Enqueues and (if idle) kicks off immediately. Navigate right away so // the user sees either the running run or the queue entry without // staying on the wizard. const targets = replaceTargets const { queueId } = generateSource(text, payload, targets ? { replaceTargets: targets } : undefined) setReplaceTargets(null) nav(`/processes?queue=${encodeURIComponent(queueId)}`) } const goNext = () => { if (!stepValid(activeStepId)) { setErroredSteps((prev) => new Set(prev).add(activeStepId)) focusFirstError(activeStepId, currentErrors) return } if (stepIndex >= 0 && stepIndex < visibleSteps.length - 1) { setStepId(visibleSteps[stepIndex + 1].id) } } const goPrev = () => { if (stepIndex > 0) setStepId(visibleSteps[stepIndex - 1].id) } const onPickTab = (target: StepId) => { const targetIdx = visibleSteps.findIndex((s) => s.id === target) if (targetIdx <= stepIndex) { // Going backward — always allowed. setStepId(target) return } // Going forward — every visible step up to (and including) the current // step must be valid. Surface the first broken step's errors. for (let i = 0; i < targetIdx; i++) { const s = visibleSteps[i] if (!stepValid(s.id)) { setErroredSteps((prev) => new Set(prev).add(s.id)) setStepId(s.id) focusFirstError(s.id, perStepErrors[s.id]) return } } setStepId(target) } return (
{sourceMode === 'html' ? 'Tool · HTML → Video' : 'Tool · Text → Video'}

{sourceMode === 'html' ? 'HTML to Video' : 'Text to Video'}

Step through the wizard to configure the run. Nothing starts until you hit{' '} Start Process on the last step.

{draftSnapshot && ( } title="Unsaved draft from your last visit" actions={ <> } > {draftSnapshot.text.trim().length > 0 ? `${draftSnapshot.text.length.toLocaleString()} characters of text plus settings.` : 'Project info and settings preserved.'} {' '}Resume where you left off, or discard. )} liveValidate || erroredSteps.has(id)} />
{activeStepId === 'project' && ( 0 ? appSettings.customClassOptions : DEFAULT_CLASS_OPTIONS } subjectOptions={ appSettings.customSubjectOptions.length > 0 ? appSettings.customSubjectOptions : DEFAULT_SUBJECT_OPTIONS } /> )} {activeStepId === 'content' && ( )} {activeStepId === 'screenshot' && ( )} {activeStepId === 'video' && ( )} {activeStepId === 'thumbnail' && ( setAutoThumbnailEditOpen((v) => !v)} onAutoThumbnailSideImage={setAutoThumbnailSideImage} onAutoThumbnailOutroSideImage={setAutoThumbnailOutroSideImage} onSwapIntroOutroThumbnails={swapIntroOutroThumbnails} /> )} {activeStepId === 'advanced' && ( )} {showCurrentErrors && Object.keys(currentErrors).length > 0 && (
Fix {Object.keys(currentErrors).length} issue {Object.keys(currentErrors).length === 1 ? '' : 's'} on this step before continuing.
)}
{/* Back / Next + C14 Start-on-every-step — sticky bottom action bar so the primary CTA is always reachable on long steps. */}
Step {Math.max(stepIndex + 1, 1)} of {visibleSteps.length}
{/* C14: as soon as every visible step validates, expose Start Process here too — no need to tab all the way to Advanced. */} {allValid && activeStepId !== 'advanced' && ( )} {activeStepId !== 'advanced' && ( )}
{showPreflight && ( setShowPreflight(false)} onProceed={onPreflightProceed} /> )}
) } // ─── Tabs ────────────────────────────────────────────────────────────────── function Tabs({ steps, currentId, onPick, canNavigateTo, stepValid, showErrors, }: { steps: StepDef[] currentId: StepId onPick: (id: StepId) => void canNavigateTo: (id: StepId) => boolean stepValid: (id: StepId) => boolean showErrors: (id: StepId) => boolean }) { const currentIndex = steps.findIndex((s) => s.id === currentId) const tabRefs = useRef>([]) // Roving-tabindex + arrow-key navigation per WAI-ARIA Authoring Practices // tablist pattern. Only the active tab is in the tab order; arrows walk // siblings, Home/End jump to the ends, Enter/Space activates. const focusTab = (idx: number) => { const n = steps.length const target = ((idx % n) + n) % n tabRefs.current[target]?.focus() } const onKey = (e: React.KeyboardEvent, idx: number) => { switch (e.key) { case 'ArrowRight': e.preventDefault() focusTab(idx + 1) break case 'ArrowLeft': e.preventDefault() focusTab(idx - 1) break case 'Home': e.preventDefault() focusTab(0) break case 'End': e.preventDefault() focusTab(steps.length - 1) break // Enter / Space fall through to the native button click — no special // handling needed, onClick fires either way. } } return (
    {steps.map((s, i) => { const active = s.id === currentId const valid = stepValid(s.id) const isDone = i < currentIndex && valid const reachable = canNavigateTo(s.id) // C2: per-step error badge — only when validation has been // surfaced (after Next on an invalid step, or once the user has // started typing) AND the step is actually invalid AND it's not // the one currently being edited. const hasError = showErrors(s.id) && !valid && !active return (
  1. ) })}
) } // ─── Step bodies ─────────────────────────────────────────────────────────── type Setter = (k: K, v: GenerateSettings[K]) => void function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { return (

{title}

{subtitle && (

{subtitle}

)}
) } function ProjectStep({ settings, onChange, lastRunSnapshot, onReuseLast, running, errors, sourceMode, classOptions, subjectOptions, }: { settings: GenerateSettings onChange: Setter lastRunSnapshot: LastRunSnapshot | null onReuseLast: () => void running: boolean errors: FieldErrors sourceMode: SourceMode classOptions: readonly string[] subjectOptions: readonly string[] }) { // "Reuse previous run" is offered whenever we have any captured // signal from last time — project info, typed text, or non-default // advanced settings. This is broader than the previous // project-only gate. const canReuse = Boolean( lastRunSnapshot && (lastRunSnapshot.text?.trim() || lastRunSnapshot.settings?.class_name || lastRunSnapshot.settings?.subject || lastRunSnapshot.settings?.title || lastRunSnapshot.settings?.output_format || lastRunSnapshot.settings?.model_choice), ) return ( <>
{canReuse && ( )}
onChange('title', e.target.value)} disabled={running} />
onChange('output_format', v)} running={running} error={errors.output_format} sourceMode={sourceMode} /> ) } /** Output-format selector with Windows-only options disabled upstream * when the backend platform isn't Windows. We still show a heads-up * for Windows hosts so users know the preflight will verify PPT. */ function OutputFormatPicker({ value, onChange, running, error, sourceMode, }: { value: OutputFormat onChange: (v: OutputFormat) => void running: boolean error?: string sourceMode: SourceMode }) { const { platform, videoEngineReady, pptxReady } = useBackendCapabilities() const engineOnly = (v: OutputFormat) => v === 'pptx' || v === 'video' return (
Output format
{OUTPUT_OPTIONS.filter((o) => sourceMode === 'text' || o.value !== 'html').map((o) => { const active = value === o.value // Gate engine-dependent outputs on the new preflight // ``video_engine`` capability so the MoviePy branch unlocks // MP4 export on Linux. PPTX still needs PowerPoint COM. const pptxBlocked = o.value === 'pptx' && platform !== 'unknown' && !pptxReady const videoBlocked = o.value === 'video' && platform !== 'unknown' && !videoEngineReady const disabledByPlatform = pptxBlocked || videoBlocked const disabled = running || disabledByPlatform const tooltip = pptxBlocked ? 'PowerPoint deck export requires a Windows host with PowerPoint installed — this backend reports a non-Windows OS.' : videoBlocked ? 'No video engine available — install MoviePy (pip install moviepy) or run on Windows with PowerPoint.' : undefined return ( ) })}
{error && }
) } /** * C5: tiny visual mini-illustration of each output format. Pure SVG so it * scales cleanly in dark/light themes without bringing in a sprite asset. * Sits in the 16×12 (h-12 w-16) frame next to each format card. */ function OutputFormatThumb({ format, active }: { format: OutputFormat; active: boolean }) { const stroke = active ? 'currentColor' : 'rgb(148 163 184)' // brand-active vs slate-400 const fill = active ? 'rgba(99,102,241,0.12)' : 'rgba(148,163,184,0.12)' const accent = active ? 'rgb(99 102 241)' : 'rgb(100 116 139)' // brand-500 vs slate-500 const svgClass = active ? 'text-brand-600 dark:text-brand-300' : 'text-slate-500 dark:text-slate-300' switch (format) { case 'html': return ( {''} ) case 'images': return ( ) case 'pptx': return ( ) case 'video': return ( ) } } function WindowsOnlyWarning({ outputFormat }: { outputFormat: OutputFormat }) { const { platform, videoEngineReady, pptxReady } = useBackendCapabilities() const needsEngine = outputFormat === 'pptx' || outputFormat === 'video' if (!needsEngine) return null if (outputFormat === 'pptx' && !pptxReady && platform !== 'unknown') { return (

PowerPoint deck export won't work on this backend — it requires a Windows host with PowerPoint installed. Pick MP4 video, Screenshots, or{' '} HTML file to avoid surprise output.

) } if (outputFormat === 'video' && !videoEngineReady && platform !== 'unknown') { return (

MP4 video export won't work — no video engine is available on this backend. Install MoviePy (pip install moviepy) or run on a Windows host with PowerPoint installed.

) } if (outputFormat === 'video' && platform !== 'windows') { return (

MP4 export will use the MoviePy engine (libx264 ultrafast, 4K @ 30 fps).

) } return null } /** * C7: card-based AI-model chooser. Each card shows the model's tradeoff * dimensions (speed / quality / context) at a glance instead of a raw * dropdown. Same canonical `model_choice` values as before so backend * routing is unchanged. */ type ModelTier = 'fast' | 'medium' | 'good' | 'best' const MODEL_OPTIONS: Array<{ value: string label: string vendor: string speed: ModelTier quality: ModelTier context: string blurb: string icon: typeof Zap }> = [ { value: 'default', label: 'Default', vendor: 'Qwen 3.5 122B', speed: 'good', quality: 'good', context: 'Standard', blurb: 'Balanced default for textbook chapters.', icon: Sparkles }, { value: 'fast', label: 'Fast (1M ctx)', vendor: 'DeepSeek V4 Flash', speed: 'best', quality: 'medium', context: '1M tokens', blurb: 'Whole books in one go, fastest turnaround.', icon: Zap }, { value: 'short', label: 'Shortest & fastest', vendor: 'Llama 3.1 8B', speed: 'best', quality: 'fast', context: 'Standard', blurb: 'Tiny chapters / quick drafts.', icon: Gauge }, { value: 'balanced', label: 'Balanced', vendor: 'GLM 4.7', speed: 'good', quality: 'good', context: 'Standard', blurb: 'Higher fidelity for the same time budget.', icon: Sliders }, { value: 'quality', label: 'Highest quality', vendor: 'DeepSeek V3.2', speed: 'medium', quality: 'best', context: 'Standard', blurb: 'Slow but the best at structured exercises.', icon: Wand2 }, { value: 'long', label: 'Long context', vendor: 'DeepSeek V4 Pro', speed: 'medium', quality: 'good', context: '1M tokens', blurb: 'Very long inputs, careful tone.', icon: Layers }, ] function tierBars(tier: ModelTier): number { switch (tier) { case 'fast': return 1 case 'medium': return 2 case 'good': return 3 case 'best': return 4 } } function ModelTierBar({ tier, label }: { tier: ModelTier; label: string }) { const filled = tierBars(tier) return (
{label}
{[1, 2, 3, 4].map((i) => ( ))}
) } function ModelChooser({ value, onChange, disabled, }: { value: string onChange: (v: string) => void disabled?: boolean }) { return (
{MODEL_OPTIONS.map((m) => { const active = value === m.value const Icon = m.icon return ( ) })}
) } /** * C12: tabbed Default / Custom system prompt editor with a small set of * presets for the most common tones. Plain textarea inside (no CodeMirror * dependency added) but mono font + larger height. */ const SYSTEM_PROMPT_PRESETS: { label: string; value: string }[] = [ { label: 'Tabular', value: 'Render the chapter as a clean, dense table-of-contents style HTML page. Use for any list structures. Keep the visual rhythm consistent.', }, { label: 'Simple narrative', value: 'Use short paragraphs, simple sentence structure, and friendly tone. Aim for clarity over comprehensiveness. Avoid bullet lists.', }, { label: 'Bilingual (Nepali ↔ English)', value: 'Render every heading in both Nepali (Devanagari) and English. Body paragraphs may stay monolingual, but the structure must be presented in both languages.', }, { label: 'Exam-prep flashcards', value: 'Format the output as a flashcard deck: each concept becomes a
with a question on top and the answer below in a contrasting block. No paragraphs longer than 3 sentences.', }, ] function SystemPromptEditor({ value, onChange, disabled, }: { value: string onChange: (v: string) => void disabled?: boolean }) { const [tab, setTab] = useState<'default' | 'custom'>(value.trim().length > 0 ? 'custom' : 'default') const onPickPreset = (preset: string) => { setTab('custom') onChange(preset) } return (
{tab === 'custom' && (
Presets {SYSTEM_PROMPT_PRESETS.map((p) => ( ))}
)}
{tab === 'default' ? (

Using the default backend system prompt — tuned for textbook chapters. Switch to Custom to override or pick a preset.

) : (