import { Play, Upload, X } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import type React from 'react' import { api } from '../api/client' import Checkbox from '../components/Checkbox' import { useToast } from '../store/toast' import { useRuns } from '../store/runs' import type { GenerateSettings, OutputFormat } from '../api/types' const ACCEPTED_MIME = /^image\/.+$/ const ACCEPTED_EXT = /\.(png|jpe?g|webp|bmp)$/i const CLASS_OPTIONS = ['Class 8', 'Class 9', 'Class 10', 'Class 11', 'Class 12'] const SUBJECT_OPTIONS = ['Nepali', 'English', 'Science', 'Math', 'Social', 'Model Question'] const RESOLUTION_OPTIONS: Array> = ['720p', '1080p', '1440p', '4k'] const DEFAULT_SETTINGS: GenerateSettings = { output_format: 'video', class_name: 'Class 10', subject: 'Nepali', resolution: '1080p', fps: 30, video_quality: 85, auto_timing_screenshot_slides: true, fixed_seconds_per_screenshot_slide: 5, close_powerpoint_before_start: true, intro_thumbnail_enabled: false, intro_thumbnail_duration_sec: 5, outro_thumbnail_enabled: false, outro_thumbnail_duration_sec: 5, concurrent_pipeline_runs: false, } interface ScreenshotEntry { file: File url: string key: string } export default function ScreenshotsToVideo() { const [entries, setEntries] = useState([]) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [dragActive, setDragActive] = useState(false) const [submitting, setSubmitting] = useState(false) const [reorderDragKey, setReorderDragKey] = useState(null) const [reorderOverKey, setReorderOverKey] = useState(null) const [previewKey, setPreviewKey] = useState(null) const toast = useToast() const runs = useRuns() const nav = useNavigate() useEffect(() => { if (!previewKey) return const onKey = (ev: KeyboardEvent) => { if (ev.key === 'Escape') setPreviewKey(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [previewKey]) const totalBytes = useMemo(() => entries.reduce((s, e) => s + e.file.size, 0), [entries]) const acceptFiles = (incoming: FileList | File[] | null) => { if (!incoming) return const list = Array.from(incoming) const filtered = list.filter( (f) => ACCEPTED_MIME.test(f.type) || ACCEPTED_EXT.test(f.name), ) if (filtered.length !== list.length) { toast.push({ variant: 'error', title: 'Some files were skipped', message: 'Only PNG, JPG, WEBP, or BMP screenshots are supported.', }) } setEntries((prev) => [ ...prev, ...filtered.map((file, idx) => ({ file, url: URL.createObjectURL(file), key: `${Date.now()}-${prev.length + idx}-${file.name}`, })), ]) } const moveEntry = (key: string, direction: -1 | 1) => { setEntries((prev) => { const idx = prev.findIndex((e) => e.key === key) if (idx === -1) return prev const target = idx + direction if (target < 0 || target >= prev.length) return prev const next = prev.slice() const [item] = next.splice(idx, 1) next.splice(target, 0, item) return next }) } const reorderTo = (sourceKey: string, targetKey: string) => { if (sourceKey === targetKey) return setEntries((prev) => { const from = prev.findIndex((e) => e.key === sourceKey) const to = prev.findIndex((e) => e.key === targetKey) if (from === -1 || to === -1) return prev const next = prev.slice() const [item] = next.splice(from, 1) next.splice(to, 0, item) return next }) } const removeEntry = (key: string) => { setEntries((prev) => { const found = prev.find((e) => e.key === key) if (found) URL.revokeObjectURL(found.url) return prev.filter((e) => e.key !== key) }) } const onDragOver = (e: React.DragEvent) => { if (submitting) return e.preventDefault() setDragActive(true) } const onDragLeave = (e: React.DragEvent) => { e.preventDefault() setDragActive(false) } const onDrop = (e: React.DragEvent) => { if (submitting) return e.preventDefault() setDragActive(false) acceptFiles(e.dataTransfer?.files ?? null) } const set = (key: K, value: GenerateSettings[K]) => setSettings((prev) => ({ ...prev, [key]: value })) const onSubmit = async (e: React.FormEvent) => { e.preventDefault() if (entries.length === 0) { toast.push({ variant: 'error', message: 'Add at least one screenshot.' }) return } if (!settings.title?.trim()) { toast.push({ variant: 'error', title: 'Chapter title required', message: 'Provide the chapter title so the canonical filename is correct.', }) return } setSubmitting(true) try { const files = entries.map((e) => e.file) const response = await api.startScreenshotsToVideoRun(files, settings) // Mirror the text-to-video flow: drop a "running" row into the // local Runs store so the user sees the new process show up // immediately on the Processes tab without waiting for the next // /runs poll. runs.start({ tool: 'screenshots-to-video', inputPreview: `${entries.length} screenshots`, inputText: `[${entries.length} screenshots]`, settings: { class_name: settings.class_name, subject: settings.subject, title: settings.title, output_format: settings.output_format, resolution: settings.resolution, fps: settings.fps, video_quality: settings.video_quality, }, operationId: response.operation_id, inputFiles: entries.map((e) => e.file.name), }) toast.push({ variant: 'success', message: `Run queued (#${response.queue_position ?? 1}). Watch progress on the Processes tab.`, }) nav(`/processes?run=${encodeURIComponent(response.run_id)}`) } catch (err) { toast.push({ variant: 'error', title: 'Could not start run', message: err instanceof Error ? err.message : String(err), }) } finally { setSubmitting(false) } } return (
Tool · Screenshots → Video

Screenshots → Video

Upload PNG / JPG screenshots you already have, fill in the project info, and run the same MP4 / PPTX export pipeline used by Text → Video. Outputs use the canonical{' '} class_X_subject_chapter_Y_exercise_<year> {' '} filename so Process, Publish, and Library all line up.

{entries.length > 0 && ( <>
Drag tiles to reorder. Double-click any tile to preview at full size.
    {entries.map((entry, index) => (
  1. { if (submitting) return setReorderDragKey(entry.key) e.dataTransfer.effectAllowed = 'move' try { e.dataTransfer.setData('text/plain', entry.key) } catch { /* some browsers throw on certain drag types */ } }} onDragOver={(e) => { if (!reorderDragKey) return e.preventDefault() e.dataTransfer.dropEffect = 'move' if (reorderOverKey !== entry.key) setReorderOverKey(entry.key) }} onDragLeave={() => { if (reorderOverKey === entry.key) setReorderOverKey(null) }} onDrop={(e) => { if (!reorderDragKey) return e.preventDefault() e.stopPropagation() reorderTo(reorderDragKey, entry.key) setReorderDragKey(null) setReorderOverKey(null) }} onDragEnd={() => { setReorderDragKey(null) setReorderOverKey(null) }} className={ 'group relative overflow-hidden rounded-md border bg-white shadow-sm transition dark:bg-white/5 ' + (reorderOverKey === entry.key && reorderDragKey && reorderDragKey !== entry.key ? 'border-brand-500 ring-2 ring-brand-400/50 ' : 'border-slate-200 dark:border-white/10 ') + (reorderDragKey === entry.key ? 'opacity-50 ' : '') + (submitting ? '' : 'cursor-grab active:cursor-grabbing') } > {entry.file.name} setPreviewKey(entry.key)} title="Double-click to preview" />
    {index + 1}. {entry.file.name}
    {(entry.file.size / 1024).toFixed(0)} KB
  2. ))}
)}
set('intro_thumbnail_enabled', v)} onFilenameChange={(v) => set('intro_thumbnail_filename', v)} onDurationChange={(v) => set('intro_thumbnail_duration_sec', v)} /> set('outro_thumbnail_enabled', v)} onFilenameChange={(v) => set('outro_thumbnail_filename', v)} onDurationChange={(v) => set('outro_thumbnail_duration_sec', v)} />
set('title', e.target.value)} />
Used to compute the canonical chapter_N segment of the output filename.
set('fps', Number(e.target.value))} disabled={settings.output_format !== 'video'} />
set('video_quality', Number(e.target.value))} disabled={settings.output_format !== 'video'} />
set('fixed_seconds_per_screenshot_slide', Number(e.target.value)) } disabled={Boolean(settings.auto_timing_screenshot_slides)} />
set('auto_timing_screenshot_slides', v)} label="Auto-pace slides for ≥500s total" /> set('close_powerpoint_before_start', v)} label="Close existing PowerPoint instances first" /> set('concurrent_pipeline_runs', v)} label="Allow this run to overlap with other exports" />
Runs go through the same queue as Text → Video — track progress in the Processes tab.
{previewKey && (() => { const entry = entries.find((e) => e.key === previewKey) if (!entry) return null return (
setPreviewKey(null)} >
e.stopPropagation()} > {entry.file.name}
{entry.file.name}
) })()}
) } function ThumbnailSlot({ kind, title, position, enabled, filename, durationSec, onEnabledChange, onFilenameChange, onDurationChange, }: { kind: 'intro' | 'outro' title: string position: string enabled: boolean filename: string durationSec: number | undefined onEnabledChange: (v: boolean) => void onFilenameChange: (v: string) => void onDurationChange: (v: number) => void }) { const [uploading, setUploading] = useState(false) const [uploadErr, setUploadErr] = useState(null) const trimmed = filename.trim() const onPickFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return setUploading(true) setUploadErr(null) try { const { filename: stored } = await api.uploadThumbnail(file) onFilenameChange(stored) } catch (err) { setUploadErr(err instanceof Error ? err.message : String(err)) } finally { setUploading(false) e.target.value = '' } } return (
{title}
{position}
{enabled && (
{trimmed && (
{`${title}
{trimmed}
Stored on backend
)} {uploadErr && (
{uploadErr}
)}
{ const v = Number(e.target.value) if (!Number.isNaN(v)) onDurationChange(v) }} />
How long this {kind === 'intro' ? 'intro' : 'outro'} thumbnail stays on screen.
)}
) }