YT-AI-Automation / frontend /src /pages /ScreenshotsToVideo.tsx
github-actions
Sync Docker Space
5f3e9f5
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<NonNullable<GenerateSettings['resolution']>> = ['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<ScreenshotEntry[]>([])
const [settings, setSettings] = useState<GenerateSettings>(DEFAULT_SETTINGS)
const [dragActive, setDragActive] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [reorderDragKey, setReorderDragKey] = useState<string | null>(null)
const [reorderOverKey, setReorderOverKey] = useState<string | null>(null)
const [previewKey, setPreviewKey] = useState<string | null>(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<HTMLLabelElement>) => {
if (submitting) return
e.preventDefault()
setDragActive(true)
}
const onDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault()
setDragActive(false)
}
const onDrop = (e: React.DragEvent<HTMLLabelElement>) => {
if (submitting) return
e.preventDefault()
setDragActive(false)
acceptFiles(e.dataTransfer?.files ?? null)
}
const set = <K extends keyof GenerateSettings>(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 (
<div className="container-form space-y-6">
<div>
<div className="eyebrow">
<span className="h-1 w-1 rounded-full bg-brand-500" />
Tool · Screenshots → Video
</div>
<h1 className="h-page mt-2">Screenshots → Video</h1>
<p className="mt-2 text-sm text-muted">
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{' '}
<code className="mx-1 rounded bg-[rgb(var(--bg-muted))] px-1 text-[12px]">
class_X_subject_chapter_Y_exercise_&lt;year&gt;
</code>{' '}
filename so Process, Publish, and Library all line up.
</p>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<div className="card">
<label className="label" htmlFor="screenshots-input">
Screenshots ({entries.length})
{entries.length > 0 && (
<span className="ml-2 text-xs text-slate-500">
{(totalBytes / 1024 / 1024).toFixed(1)} MB total
</span>
)}
</label>
<label
htmlFor="screenshots-input"
onDragOver={onDragOver}
onDragEnter={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 text-center transition-colors ' +
(dragActive
? 'border-brand-500 bg-brand-50 dark:border-brand-400 dark:bg-brand-900/30'
: 'border-slate-200 bg-slate-50 hover:border-brand-400 hover:bg-brand-50 dark:border-white/10 dark:bg-white/[0.03] dark:hover:border-brand-500/60 dark:hover:bg-brand-900/20')
}
>
<Upload size={28} className={dragActive ? 'text-brand-500' : 'text-slate-400'} />
<div className="mt-2 text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium">Click to upload</span> or drag-and-drop multiple
PNG / JPG / WEBP screenshots
</div>
<input
id="screenshots-input"
type="file"
multiple
className="hidden"
accept="image/*"
onChange={(e) => {
acceptFiles(e.target.files)
e.target.value = ''
}}
disabled={submitting}
/>
</label>
{entries.length > 0 && (
<>
<div className="mt-3 text-[11px] text-muted">
Drag tiles to reorder. Double-click any tile to preview at full size.
</div>
<ol className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{entries.map((entry, index) => (
<li
key={entry.key}
draggable={!submitting}
onDragStart={(e) => {
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')
}
>
<img
src={entry.url}
alt={entry.file.name}
className="h-24 w-full cursor-zoom-in object-cover"
onDoubleClick={() => setPreviewKey(entry.key)}
title="Double-click to preview"
/>
<div className="px-2 pb-1 pt-1 text-xs">
<div className="truncate font-medium text-slate-700 dark:text-slate-200">
{index + 1}. {entry.file.name}
</div>
<div className="text-[11px] text-slate-500">
{(entry.file.size / 1024).toFixed(0)} KB
</div>
<div className="mt-1 flex items-center justify-between gap-1">
<button
type="button"
className="btn-secondary !px-2 !py-0.5 text-[11px]"
onClick={() => moveEntry(entry.key, -1)}
disabled={index === 0 || submitting}
>
</button>
<button
type="button"
className="btn-secondary !px-2 !py-0.5 text-[11px]"
onClick={() => moveEntry(entry.key, 1)}
disabled={index === entries.length - 1 || submitting}
>
</button>
<button
type="button"
className="btn-secondary !px-2 !py-0.5 text-[11px]"
onClick={() => removeEntry(entry.key)}
disabled={submitting}
aria-label={`Remove ${entry.file.name}`}
>
<X size={12} />
</button>
</div>
</div>
</li>
))}
</ol>
</>
)}
</div>
<ThumbnailSlot
kind="intro"
title="Intro thumbnail"
position="Inserted on slide 2"
enabled={settings.intro_thumbnail_enabled ?? false}
filename={settings.intro_thumbnail_filename ?? ''}
durationSec={settings.intro_thumbnail_duration_sec}
onEnabledChange={(v) => set('intro_thumbnail_enabled', v)}
onFilenameChange={(v) => set('intro_thumbnail_filename', v)}
onDurationChange={(v) => set('intro_thumbnail_duration_sec', v)}
/>
<ThumbnailSlot
kind="outro"
title="Outro thumbnail"
position="Inserted on the 2nd-to-last slide"
enabled={settings.outro_thumbnail_enabled ?? false}
filename={settings.outro_thumbnail_filename ?? ''}
durationSec={settings.outro_thumbnail_duration_sec}
onEnabledChange={(v) => set('outro_thumbnail_enabled', v)}
onFilenameChange={(v) => set('outro_thumbnail_filename', v)}
onDurationChange={(v) => set('outro_thumbnail_duration_sec', v)}
/>
<div className="card grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="label" htmlFor="s2v-class">Class</label>
<select
id="s2v-class"
className="input"
value={settings.class_name ?? ''}
onChange={(e) => set('class_name', e.target.value)}
>
{CLASS_OPTIONS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="s2v-subject">Subject</label>
<select
id="s2v-subject"
className="input"
value={settings.subject ?? ''}
onChange={(e) => set('subject', e.target.value)}
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="s2v-title">Chapter title</label>
<input
id="s2v-title"
className="input"
placeholder="e.g. Chapter 1 — सनीदेव वा सानी रानी"
value={settings.title ?? ''}
onChange={(e) => set('title', e.target.value)}
/>
<div className="mt-1 text-[11px] text-muted">
Used to compute the canonical
<code className="mx-1 rounded bg-slate-100 px-1 dark:bg-white/10">chapter_N</code>
segment of the output filename.
</div>
</div>
</div>
<div className="card grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="label" htmlFor="s2v-output-format">Output</label>
<select
id="s2v-output-format"
className="input"
value={(settings.output_format as OutputFormat) ?? 'video'}
onChange={(e) => set('output_format', e.target.value as OutputFormat)}
>
<option value="video">MP4 video</option>
<option value="pptx">PPTX deck only</option>
</select>
</div>
<div>
<label className="label" htmlFor="s2v-resolution">Resolution</label>
<select
id="s2v-resolution"
className="input"
value={settings.resolution ?? '1080p'}
onChange={(e) =>
set('resolution', e.target.value as GenerateSettings['resolution'])
}
disabled={settings.output_format !== 'video'}
>
{RESOLUTION_OPTIONS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="s2v-fps">Frame rate</label>
<input
id="s2v-fps"
type="number"
className="input"
min={1}
max={120}
value={settings.fps ?? 30}
onChange={(e) => set('fps', Number(e.target.value))}
disabled={settings.output_format !== 'video'}
/>
</div>
</div>
<div className="card grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="label" htmlFor="s2v-quality">Video quality (1–100)</label>
<input
id="s2v-quality"
type="number"
className="input"
min={1}
max={100}
value={settings.video_quality ?? 85}
onChange={(e) => set('video_quality', Number(e.target.value))}
disabled={settings.output_format !== 'video'}
/>
</div>
<div>
<label className="label" htmlFor="s2v-fixed-seconds">Seconds per slide</label>
<input
id="s2v-fixed-seconds"
type="number"
className="input"
min={1}
step={0.5}
value={settings.fixed_seconds_per_screenshot_slide ?? 5}
onChange={(e) =>
set('fixed_seconds_per_screenshot_slide', Number(e.target.value))
}
disabled={Boolean(settings.auto_timing_screenshot_slides)}
/>
</div>
<div className="flex flex-col justify-end gap-2.5 text-sm">
<Checkbox
checked={Boolean(settings.auto_timing_screenshot_slides)}
onChange={(v) => set('auto_timing_screenshot_slides', v)}
label="Auto-pace slides for ≥500s total"
/>
<Checkbox
checked={Boolean(settings.close_powerpoint_before_start)}
onChange={(v) => set('close_powerpoint_before_start', v)}
label="Close existing PowerPoint instances first"
/>
<Checkbox
checked={Boolean(settings.concurrent_pipeline_runs)}
onChange={(v) => set('concurrent_pipeline_runs', v)}
label="Allow this run to overlap with other exports"
/>
</div>
</div>
<div className="sticky-action-bar">
<button
type="submit"
className="btn-primary"
disabled={entries.length === 0 || submitting}
>
<Play size={16} />
{submitting ? 'Submitting…' : 'Start export'}
</button>
<span className="text-xs text-muted">
Runs go through the same queue as Text → Video — track progress in the Processes tab.
</span>
</div>
</form>
{previewKey && (() => {
const entry = entries.find((e) => e.key === previewKey)
if (!entry) return null
return (
<div
role="dialog"
aria-modal="true"
aria-label={`Preview ${entry.file.name}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setPreviewKey(null)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setPreviewKey(null)
}}
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
aria-label="Close preview"
>
<X size={20} />
</button>
<div
className="flex max-h-full max-w-full flex-col items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<img
src={entry.url}
alt={entry.file.name}
className="max-h-[85vh] max-w-[90vw] rounded-md object-contain shadow-2xl"
/>
<div className="truncate text-sm text-white/90">{entry.file.name}</div>
</div>
</div>
)
})()}
</div>
)
}
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<string | null>(null)
const trimmed = filename.trim()
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="card">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-slate-50">{title}</div>
<div className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{position}</div>
</div>
<Checkbox
checked={enabled}
onChange={onEnabledChange}
label="Enabled"
/>
</div>
{enabled && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="label">Image</label>
<label
className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-slate-300 bg-slate-50 px-3 py-5 text-sm text-slate-600 transition-colors hover:bg-slate-100 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300"
>
<Upload size={16} />
{uploading ? 'Uploading…' : trimmed ? 'Replace image' : 'Upload image'}
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/bmp"
className="hidden"
onChange={onPickFile}
disabled={uploading}
/>
</label>
{trimmed && (
<div className="mt-2 flex items-center gap-3 rounded-md border border-slate-200 bg-white p-2 dark:border-white/10 dark:bg-white/[0.03]">
<img
src={api.thumbnailUrl(trimmed)}
alt={`${title} preview`}
className="h-14 w-20 shrink-0 rounded object-cover"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-slate-800 dark:text-slate-200">
{trimmed}
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
Stored on backend
</div>
</div>
<button
type="button"
className="btn-secondary !px-2 !py-1 text-xs"
onClick={() => onFilenameChange('')}
>
Remove
</button>
</div>
)}
{uploadErr && (
<div className="mt-1 text-xs text-rose-600 dark:text-rose-400">{uploadErr}</div>
)}
</div>
<div>
<label className="label" htmlFor={`s2v-${kind}-duration`}>
Duration (seconds)
</label>
<input
id={`s2v-${kind}-duration`}
type="number"
step={0.5}
min={0.5}
className="input"
value={durationSec ?? ''}
onChange={(e) => {
const v = Number(e.target.value)
if (!Number.isNaN(v)) onDurationChange(v)
}}
/>
<div className="mt-1 text-[11px] text-muted">
How long this {kind === 'intro' ? 'intro' : 'outro'} thumbnail stays on screen.
</div>
</div>
</div>
)}
</div>
)
}