Spaces:
Running
Running
| import { useState, useCallback, useRef } from "react"; | |
| import { useMutation, useQuery } from "@tanstack/react-query"; | |
| import { FileAudio, Sparkles, Shield, Zap, ArrowRight, Upload as UploadIcon } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent } from "@/components/ui/card"; | |
| import { ThemeToggle } from "@/components/ThemeToggle"; | |
| import { FileUpload } from "@/components/FileUpload"; | |
| import { TranscriptionProgress } from "@/components/TranscriptionProgress"; | |
| import { SrtViewer } from "@/components/SrtViewer"; | |
| import { AnomalyReview } from "@/components/AnomalyReview"; | |
| import { ExportPanel } from "@/components/ExportPanel"; | |
| import { MediaPlayer } from "@/components/MediaPlayer"; | |
| import { useToast } from "@/hooks/use-toast"; | |
| import { apiRequest, queryClient } from "@/lib/queryClient"; | |
| import type { TranscriptionJob, ApiResponse } from "@shared/schema"; | |
| type AppView = "landing" | "upload" | "processing" | "review"; | |
| export default function Home() { | |
| const [view, setView] = useState<AppView>("landing"); | |
| const [currentJobId, setCurrentJobId] = useState<string | null>(null); | |
| const [uploadProgress, setUploadProgress] = useState(0); | |
| const [currentAnomalyIndex, setCurrentAnomalyIndex] = useState(0); | |
| const [highlightedSegmentId, setHighlightedSegmentId] = useState<number | undefined>(); | |
| const [currentTime, setCurrentTime] = useState(0); | |
| const mediaPlayerRef = useRef<HTMLMediaElement>(null); | |
| const { toast } = useToast(); | |
| const { data: response, refetch: refetchJob } = useQuery<ApiResponse<TranscriptionJob>>({ | |
| queryKey: ["/api/jobs", currentJobId], | |
| enabled: !!currentJobId && view === "processing", | |
| refetchInterval: (query) => { | |
| const resp = query.state.data as ApiResponse<TranscriptionJob> | undefined; | |
| const data = resp?.data; | |
| if (data?.status === "completed" || data?.status === "failed") { | |
| return false; | |
| } | |
| return 2000; | |
| }, | |
| }); | |
| const job = response?.data; | |
| const uploadMutation = useMutation({ | |
| mutationFn: async ({ file, context }: { file: File; context: string }) => { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| if (context) { | |
| formData.append("context", context); | |
| } | |
| const response = await fetch("/api/upload", { | |
| method: "POST", | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.message || "Upload failed"); | |
| } | |
| return response.json() as Promise<ApiResponse<TranscriptionJob>>; | |
| }, | |
| onSuccess: (response) => { | |
| if (response.success && response.data) { | |
| setCurrentJobId(response.data.id); | |
| setView("processing"); | |
| toast({ | |
| title: "Upload successful", | |
| description: "Your file is being processed. This may take a few minutes.", | |
| }); | |
| } | |
| }, | |
| onError: (error: Error) => { | |
| toast({ | |
| title: "Upload failed", | |
| description: error.message, | |
| variant: "destructive", | |
| }); | |
| setUploadProgress(0); | |
| }, | |
| }); | |
| const correctionMutation = useMutation({ | |
| mutationFn: async ({ | |
| anomalyId, | |
| correction, | |
| applyToSimilar, | |
| }: { | |
| anomalyId: string; | |
| correction: string; | |
| applyToSimilar: boolean; | |
| }) => { | |
| return apiRequest("POST", "/api/corrections", { | |
| jobId: currentJobId, | |
| anomalyId, | |
| correction, | |
| applyToSimilar, | |
| }); | |
| }, | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ["/api/jobs", currentJobId] }); | |
| refetchJob(); | |
| }, | |
| onError: (error: Error) => { | |
| toast({ | |
| title: "Correction failed", | |
| description: error.message, | |
| variant: "destructive", | |
| }); | |
| }, | |
| }); | |
| const segmentUpdateMutation = useMutation({ | |
| mutationFn: async ({ segmentId, text }: { segmentId: number; text: string }) => { | |
| return apiRequest("PATCH", `/api/jobs/${currentJobId}/segments/${segmentId}`, { text }); | |
| }, | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ["/api/jobs", currentJobId] }); | |
| refetchJob(); | |
| toast({ | |
| title: "Success", | |
| description: "Subtitle updated successfully", | |
| }); | |
| }, | |
| onError: (error: Error) => { | |
| toast({ | |
| title: "Update failed", | |
| description: error.message, | |
| variant: "destructive", | |
| }); | |
| }, | |
| }); | |
| const handleUpdateSegment = useCallback((segmentId: number, text: string) => { | |
| segmentUpdateMutation.mutate({ segmentId, text }); | |
| }, [segmentUpdateMutation]); | |
| const handleUpload = useCallback((file: File, context: string) => { | |
| setUploadProgress(10); | |
| const interval = setInterval(() => { | |
| setUploadProgress((prev) => { | |
| if (prev >= 90) { | |
| clearInterval(interval); | |
| return 90; | |
| } | |
| return prev + 10; | |
| }); | |
| }, 200); | |
| uploadMutation.mutate({ file, context }); | |
| }, [uploadMutation]); | |
| const handleApplyCorrection = useCallback( | |
| (anomalyId: string, correction: string, applyToSimilar: boolean) => { | |
| correctionMutation.mutate({ anomalyId, correction, applyToSimilar }); | |
| }, | |
| [correctionMutation] | |
| ); | |
| const handleSkipAnomaly = useCallback(() => { | |
| if (job?.anomalies) { | |
| const unresolvedCount = job.anomalies.filter(a => !a.resolved).length; | |
| if (currentAnomalyIndex < unresolvedCount - 1) { | |
| setCurrentAnomalyIndex(currentAnomalyIndex + 1); | |
| } | |
| } | |
| }, [job, currentAnomalyIndex]); | |
| const handleExport = useCallback(async () => { | |
| if (!job?.segments) return; | |
| const srtContent = job.segments.map(segment => { | |
| return `${segment.id}\n${segment.startTime} --> ${segment.endTime}\n${segment.text}\n`; | |
| }).join("\n"); | |
| const blob = new Blob([srtContent], { type: "text/srt" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = job.fileName.replace(/\.(mp3|mp4)$/i, ".srt"); | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| toast({ | |
| title: "Export complete", | |
| description: "Your SRT file has been downloaded.", | |
| }); | |
| }, [job, toast]); | |
| const handleSegmentClick = useCallback((segmentId: number) => { | |
| setHighlightedSegmentId(segmentId); | |
| if (job?.anomalies) { | |
| const anomalyIndex = job.anomalies | |
| .filter(a => !a.resolved) | |
| .findIndex(a => a.segmentId === segmentId); | |
| if (anomalyIndex !== -1) { | |
| setCurrentAnomalyIndex(anomalyIndex); | |
| } | |
| } | |
| }, [job]); | |
| const handleTimeJump = useCallback((time: number) => { | |
| if (mediaPlayerRef.current) { | |
| mediaPlayerRef.current.currentTime = time; | |
| } | |
| }, []); | |
| if (job?.status === "completed" && view === "processing") { | |
| setView("review"); | |
| } | |
| const renderLanding = () => ( | |
| <div className="min-h-screen flex flex-col"> | |
| <header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50"> | |
| <div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-8 h-8 rounded-md bg-primary flex items-center justify-center"> | |
| <FileAudio className="h-4 w-4 text-primary-foreground" /> | |
| </div> | |
| <span className="font-semibold text-lg">MV Subtitle Generator</span> | |
| </div> | |
| <ThemeToggle /> | |
| </div> | |
| </header> | |
| <main className="flex-1"> | |
| <section className="py-20 md:py-32"> | |
| <div className="container mx-auto px-4 text-center"> | |
| <h1 className="text-4xl md:text-6xl font-bold tracking-tight mb-6"> | |
| Generate & Tidy-up <span className="text-primary">SRT Subtitles</span> | |
| </h1> | |
| <p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10"> | |
| Generate and tidy-up subtitle (SRT) files for MP3 and MP4 files of upto 25 MB. | |
| Our AI detects and helps you fix subtitle errors, misheard words, | |
| and out-of-context phrases. | |
| </p> | |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-4"> | |
| <Button size="lg" onClick={() => setView("upload")} data-testid="button-get-started"> | |
| Get Started | |
| <ArrowRight className="h-4 w-4 ml-2" /> | |
| </Button> | |
| <Button size="lg" variant="outline" data-testid="button-learn-more"> | |
| Learn More | |
| </Button> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="py-20 bg-muted/30"> | |
| <div className="container mx-auto px-4"> | |
| <h2 className="text-2xl md:text-3xl font-bold text-center mb-12"> | |
| How It Works | |
| </h2> | |
| <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto"> | |
| <Card className="text-center"> | |
| <CardContent className="pt-8 pb-6"> | |
| <div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4"> | |
| <UploadIcon className="h-7 w-7 text-primary" /> | |
| </div> | |
| <h3 className="font-semibold text-lg mb-2">1. Upload</h3> | |
| <p className="text-muted-foreground text-sm"> | |
| Upload your MP3 or MP4 file (up to 25MB) and optionally provide context about the content. | |
| </p> | |
| </CardContent> | |
| </Card> | |
| <Card className="text-center"> | |
| <CardContent className="pt-8 pb-6"> | |
| <div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4"> | |
| <Sparkles className="h-7 w-7 text-primary" /> | |
| </div> | |
| <h3 className="font-semibold text-lg mb-2">2. Transcribe</h3> | |
| <p className="text-muted-foreground text-sm"> | |
| Our AI (powered by OpenAI Whisper) transcribes your audio and detects potential errors. | |
| </p> | |
| </CardContent> | |
| </Card> | |
| <Card className="text-center"> | |
| <CardContent className="pt-8 pb-6"> | |
| <div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4"> | |
| <Shield className="h-7 w-7 text-primary" /> | |
| </div> | |
| <h3 className="font-semibold text-lg mb-2">3. Review & Export</h3> | |
| <p className="text-muted-foreground text-sm"> | |
| Review suggested corrections, apply fixes, and download your polished SRT file. | |
| </p> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="py-20"> | |
| <div className="container mx-auto px-4"> | |
| <h2 className="text-2xl md:text-3xl font-bold text-center mb-12"> | |
| Key Features | |
| </h2> | |
| <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl mx-auto"> | |
| {[ | |
| { icon: Sparkles, title: "AI Subtitle Generation", desc: "Powered by OpenAI Whisper for accurate speech-to-subtitle" }, | |
| { icon: Shield, title: "Anomaly Detection", desc: "Identifies misheard words and unusual phrases" }, | |
| { icon: Zap, title: "Batch Corrections", desc: "Apply the same fix to multiple similar errors at once" }, | |
| { icon: FileAudio, title: "SRT Export", desc: "Download properly formatted subtitle files" }, | |
| ].map((feature, i) => ( | |
| <div key={i} className="flex items-start gap-4 p-4"> | |
| <div className="w-10 h-10 rounded-md bg-accent flex items-center justify-center flex-shrink-0"> | |
| <feature.icon className="h-5 w-5 text-accent-foreground" /> | |
| </div> | |
| <div> | |
| <h4 className="font-medium mb-1">{feature.title}</h4> | |
| <p className="text-sm text-muted-foreground">{feature.desc}</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <footer className="border-t py-8"> | |
| <div className="container mx-auto px-4 text-center text-sm text-muted-foreground"> | |
| <p>Powered by Hugging Face open-source models. Deployable on Hugging Face Spaces.</p> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| const renderUpload = () => ( | |
| <div className="min-h-screen flex flex-col"> | |
| <header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50"> | |
| <div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="ghost" onClick={() => setView("landing")} data-testid="button-back-home"> | |
| <div className="w-8 h-8 rounded-md bg-primary flex items-center justify-center"> | |
| <FileAudio className="h-4 w-4 text-primary-foreground" /> | |
| </div> | |
| <span className="font-semibold text-lg ml-2">MV Subtitle Generator</span> | |
| </Button> | |
| </div> | |
| <ThemeToggle /> | |
| </div> | |
| </header> | |
| <main className="flex-1 py-12"> | |
| <div className="container mx-auto px-4 max-w-xl"> | |
| <div className="text-center mb-8"> | |
| <h1 className="text-2xl md:text-3xl font-bold mb-2">Upload Your File</h1> | |
| <p className="text-muted-foreground"> | |
| Upload an audio (MP3) or video (MP4) file of upto 25MB to generate subtitles in SRT format. | |
| </p> | |
| </div> | |
| <FileUpload | |
| onUpload={handleUpload} | |
| isUploading={uploadMutation.isPending} | |
| uploadProgress={uploadProgress} | |
| /> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| const renderProcessing = () => ( | |
| <div className="min-h-screen flex flex-col"> | |
| <header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50"> | |
| <div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-8 h-8 rounded-md bg-primary flex items-center justify-center"> | |
| <FileAudio className="h-4 w-4 text-primary-foreground" /> | |
| </div> | |
| <span className="font-semibold text-lg">MV Subtitle Generator</span> | |
| </div> | |
| <ThemeToggle /> | |
| </div> | |
| </header> | |
| <main className="flex-1 py-12"> | |
| <div className="container mx-auto px-4 max-w-xl"> | |
| <div className="text-center mb-8"> | |
| <h1 className="text-2xl md:text-3xl font-bold mb-2">Processing Your File</h1> | |
| <p className="text-muted-foreground"> | |
| Please wait while we transcribe and analyze your content | |
| </p> | |
| </div> | |
| {job && <TranscriptionProgress job={job} />} | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| const renderReview = () => ( | |
| <div className="min-h-screen flex flex-col"> | |
| <header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50"> | |
| <div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="ghost" onClick={() => setView("landing")} data-testid="button-back-home-review"> | |
| <div className="w-8 h-8 rounded-md bg-primary flex items-center justify-center"> | |
| <FileAudio className="h-4 w-4 text-primary-foreground" /> | |
| </div> | |
| <span className="font-semibold text-lg ml-2">MV Subtitle Generator</span> | |
| </Button> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button variant="outline" onClick={() => setView("upload")} data-testid="button-new-file"> | |
| New File | |
| </Button> | |
| <ThemeToggle /> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="flex-1 py-8"> | |
| <div className="container mx-auto px-4"> | |
| <div className="grid lg:grid-cols-2 gap-8"> | |
| <div className="space-y-6"> | |
| <div> | |
| <h1 className="text-2xl font-bold mb-1">Review Subtitles</h1> | |
| <p className="text-muted-foreground text-sm"> | |
| Review detected issues and make corrections | |
| </p> | |
| </div> | |
| {job?.anomalies && job.segments && ( | |
| <AnomalyReview | |
| anomalies={job.anomalies} | |
| segments={job.segments} | |
| onApplyCorrection={handleApplyCorrection} | |
| onSkip={handleSkipAnomaly} | |
| currentIndex={currentAnomalyIndex} | |
| onIndexChange={setCurrentAnomalyIndex} | |
| onTimeJump={handleTimeJump} | |
| /> | |
| )} | |
| {job?.segments && job.anomalies && ( | |
| <ExportPanel | |
| segments={job.segments} | |
| anomalies={job.anomalies} | |
| fileName={job.fileName} | |
| onExport={handleExport} | |
| /> | |
| )} | |
| </div> | |
| <div className="space-y-6"> | |
| {job && job.mediaUrl && ( | |
| <MediaPlayer | |
| url={job.mediaUrl} | |
| fileType={job.fileType} | |
| onTimeUpdate={setCurrentTime} | |
| onPause={() => mediaPlayerRef.current?.pause()} | |
| onPlay={() => mediaPlayerRef.current?.play()} | |
| mediaRef={mediaPlayerRef as React.RefObject<HTMLMediaElement>} | |
| /> | |
| )} | |
| {job?.segments && job.anomalies && ( | |
| <SrtViewer | |
| segments={job.segments} | |
| anomalies={job.anomalies} | |
| onSegmentClick={handleSegmentClick} | |
| onSegmentUpdate={handleUpdateSegment} | |
| onTimeJump={handleTimeJump} | |
| onPause={() => mediaPlayerRef.current?.pause()} | |
| highlightedSegmentId={highlightedSegmentId} | |
| currentTime={currentTime} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| switch (view) { | |
| case "upload": | |
| return renderUpload(); | |
| case "processing": | |
| return renderProcessing(); | |
| case "review": | |
| return renderReview(); | |
| default: | |
| return renderLanding(); | |
| } | |
| } | |