diff --git a/apps/web/app/api/subtitle/[id]/route.ts b/apps/web/app/api/subtitle/[id]/route.ts new file mode 100644 index 0000000..304f016 --- /dev/null +++ b/apps/web/app/api/subtitle/[id]/route.ts @@ -0,0 +1,134 @@ +import {NextRequest, NextResponse} from 'next/server'; +import {createClient} from '@/lib/supabase/server'; +import {convertJsonToSrt} from "@/utils/convertJsonToSrt"; + +export async function GET(req: NextRequest, context: { params: Promise<{ id: string }> }) { + const supabase = await createClient(); + + const params = await context.params; + const subtitleId = params.id; + try{ + const{data: {user}} = await supabase.auth.getUser(); + if(!user){ + return NextResponse.json({error: 'Unauthorized'}, {status:401}); + } + + + + const {data: subtitleData, error: subtitleError} = await supabase + .from('subtitle_jobs') + .select("*") + .eq("id", subtitleId) + .eq("user_id", user.id) + .single(); + + + if (subtitleError) { + return NextResponse.json({ message: 'Error fetching subtitle' }, { status: 500 }); + } + // console.log(subtitleData); + + return NextResponse.json(subtitleData); + + + }catch(error: unknown) { + console.error('Error fetching subtitles:', error); + return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); + } + +} + + + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> } +) { + const supabase = await createClient(); + const params = await context.params; + const subtitleId = params.id; + console.log(subtitleId); + + try { + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { data: subtitle, error: subtitleError } = await supabase + .from('subtitle_jobs') + .delete() + .eq('id', subtitleId) + .eq('user_id', user.id) + .select('video_url') + .single(); + + console.log(subtitle) + if (subtitleError && subtitleError.code !== 'PGRST116' || !subtitle) { + + console.error('Subtitle lookup error:', subtitleError); + NextResponse.json({ message: 'Subtitle lookup error' }, { status: 404 }); + } + + if (subtitle?.video_url) { + const bucketName = 'video_subtitles'; + const filePath = subtitle.video_url.substring( + subtitle.video_url.indexOf(bucketName) + bucketName.length + 1 + ); + if (filePath) { + await supabase.storage.from(bucketName).remove([filePath]); + } + } + + return NextResponse.json({ message: 'Script deleted successfully' }); + } catch (error) { + console.error('Error deleting script:', error); + return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + const supabase = await createClient(); + const params = await context.params; + const subtitle_id = params.id; + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { subtitle_json} = body; + console.log(subtitle_id); + console.log(subtitle_json.type); + if (!Array.isArray(subtitle_json)) { + return NextResponse.json({ error: 'Invalid subtitle format' }, { status: 400 }); + } + + const srtContent = convertJsonToSrt(subtitle_json); + + const { error: updateError } = await supabase + .from("subtitle_jobs") + .update({ + subtitles_json:JSON.stringify(subtitle_json) + }) + .eq("id", subtitle_id) + .eq("user_id", user.id) + .single(); + + if (updateError) { + console.error(updateError); + return NextResponse.json({ error: 'Failed to update subtitles' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Subtitles updated successfully', + subtitles: subtitle_json, + srt: srtContent + }); + } catch (error) { + console.error("Error in PATCH:", error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/subtitle/burnSubtitle/route.ts b/apps/web/app/api/subtitle/burnSubtitle/route.ts new file mode 100644 index 0000000..dad5b9a --- /dev/null +++ b/apps/web/app/api/subtitle/burnSubtitle/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { configureFFmpeg } from "@/utils/ffmpegConfig"; +import { fetchVideoAsBuffer } from "@/utils/videoUrlTools"; +import { convertJsonToSrt } from "@/utils/convertJsonToSrt"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const { videoUrl, subtitles } = await request.json(); + + if (!videoUrl || !subtitles || !Array.isArray(subtitles)) { + return NextResponse.json( + { error: "Missing videoUrl or subtitles" }, + { status: 400 } + ); + } + + console.log("Starting burnSubtitle process..."); + console.log(" Video URL:", videoUrl); + console.log(" Subtitles received:", subtitles.length); + + const videoBuffer = await fetchVideoAsBuffer(videoUrl); + + const tmpDir = path.join(os.tmpdir(), "video_processing"); + await fs.mkdir(tmpDir, { recursive: true }); + + const videoPath = path.join(tmpDir, `input_${Date.now()}.mp4`); + const srtPath = path.join(tmpDir, `subs_${Date.now()}.srt`); + const outputPath = path.join(tmpDir, `output_${Date.now()}.mp4`); + + await fs.writeFile(videoPath, videoBuffer); + console.log("💾 Saved video to:", videoPath); + + const srtContent = convertJsonToSrt(subtitles); + await fs.writeFile(srtPath, srtContent, "utf-8"); + + const ffmpeg = configureFFmpeg(); + + const safeSubtitlePath = srtPath + .replace(/\\/g, "/") + .replace(/:/g, "\\:"); + + + await new Promise((resolve, reject) => { + ffmpeg(videoPath) + .outputOptions(["-c:v", "libx264", "-c:a", "copy"]) + .videoFilter(`subtitles='${safeSubtitlePath}'`) + .on("start", (cmd) => console.log(" FFmpeg command:", cmd)) + .on("progress", (p) => console.log(`⏳ FFmpeg progress: ${p.percent?.toFixed(2)}%`)) + .on("end", () => { + console.log(" FFmpeg completed successfully"); + resolve(); + }) + .on("error", (err) => { + console.error("Burn subtitle error:", err); + reject(err); + }) + .save(outputPath); + }); + + const outputBuffer = await fs.readFile(outputPath); + console.log(" Output video size:", outputBuffer.length); + + await Promise.allSettled([ + fs.unlink(videoPath), + fs.unlink(srtPath), + fs.unlink(outputPath), + ]); + + return new NextResponse(outputBuffer, { + status: 200, + headers: { + "Content-Type": "video/mp4", + "Content-Disposition": "attachment; filename=video_with_subtitles.mp4", + }, + }); + + } catch (error) { + console.error("BurnSubtitle route error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/subtitle/route.ts b/apps/web/app/api/subtitle/route.ts new file mode 100644 index 0000000..bb7aed8 --- /dev/null +++ b/apps/web/app/api/subtitle/route.ts @@ -0,0 +1,371 @@ +import {NextResponse} from 'next/server'; +import {createClient} from '@/lib/supabase/server'; +import {extractAudioFromBuffer} from "@/utils/ExtractAudio"; +import path from "path"; +import os from "os"; +import fs from "fs/promises"; +import { GoogleGenAI } from '@google/genai'; +import {convertJsonToSrt} from '@/utils/convertJsonToSrt'; +import {fetchVideoAsBuffer, getFileNameFromUrl, getMimeTypeFromUrl} from "@/utils/videoUrlTools"; + +async function waitForFileActive(ai: any, fileName: string, maxWaitTime = 120000) { + const startTime = Date.now(); + const pollInterval = 3000; // Check every 3 seconds + + console.log(`Polling for file status: ${fileName}`); + + while (Date.now() - startTime < maxWaitTime) { + try { + const file = await ai.files.get({ name: fileName }); + + console.log(`File status: ${file.state} (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`); + + if (file.state === 'ACTIVE') { + return file; + } + if (file.state === 'FAILED') { + throw new Error(`File processing failed: ${file.stateDescription}`); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + + } catch (error) { + console.error('Error checking file status:', error); + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + throw new Error(`File processing timeout for ${fileName} after ${maxWaitTime / 1000}s`); +} + + +export async function POST(request: Request) { + const supabase = await createClient(); + let tempFilePath: string | null = null; + + async function logErrorToDB(message: string, subtitleId: string) { + if (!subtitleId) return; + try { + await supabase + .from("subtitle_jobs") + .update({ + status: "error", + error_message: message.slice(0, 5000) + }) + .eq("id", subtitleId); + } catch (dbError) { + console.error(" Failed to record error in DB:", dbError); + } + } + + try{ + const{data: {user}} = await supabase.auth.getUser(); + + if(!user){ + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const body = await request.json(); + const { subtitleId, language, targetLanguage, duration } = body; + + const { data: profileData, error: profileError } = await supabase + .from('profiles') + .select('credits, ai_trained, youtube_connected') + .eq('user_id', user.id) + .single(); + + if (profileError || !profileData) { + await logErrorToDB('Profile not found or profile fetch failed', subtitleId); + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + if (!profileData.ai_trained && !profileData.youtube_connected) { + await logErrorToDB('AI training and YouTube connection are required', subtitleId); + return NextResponse.json({ message: 'AI training and YouTube connection are required' }, { status: 403 }); + } + + const typedProfileData = profileData + + if (typedProfileData.credits < 1) { + await logErrorToDB('Insufficient credits', subtitleId); + return NextResponse.json({ + error: 'Insufficient credits. Please upgrade your plan or earn more credits.' + }, { status: 403 }); + } + + const { data: subtitle, error: subtitleError } = await supabase + .from('subtitle_jobs') + .select('video_url') + .eq('user_id', user.id) + .eq('id', subtitleId) + .single(); + + if (subtitleError && subtitleError.code !== 'PGRST116' || !subtitle?.video_url) { + await logErrorToDB('Subtitle lookup failed or video_url missing', subtitleId); + console.error('Subtitle lookup error:', subtitleError); + NextResponse.json({ message: 'Subtitle lookup error' }, { status: 404 }); + } + const video_url = subtitle ? subtitle.video_url: null; + + const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!apiKey) { + console.error('Google Generative AI API key is missing'); + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + + const ai = new GoogleGenAI({ apiKey }); + + // Determine if auto-detect or specific language + const isAutoDetect = !language || language.toLowerCase() === 'auto detect' || language.toLowerCase() === 'auto'; + const languageInstruction = isAutoDetect + ? "Automatically detect the language being spoken and transcribe in that language." + : `Transcribe the audio in ${language}.`; + + // Determine target language instruction + const hasTargetLanguage = targetLanguage && targetLanguage.toLowerCase() !== 'none' && targetLanguage.toLowerCase() !== 'same'; + const targetLanguageInstruction = hasTargetLanguage + ? `After transcription, translate all subtitle text to ${targetLanguage}. Maintain the same timestamps but provide the translated text.` + : "Provide subtitles in the original/detected language without translation."; + + const prompt = ` +You are an expert, highly-accurate subtitle transcription service. +Your task is to transcribe the provided audio file and generate precise, time-stamped subtitles. + +**LANGUAGE INSTRUCTION:** ${languageInstruction} + +**TARGET LANGUAGE INSTRUCTION:** ${targetLanguageInstruction} + +**CRITICAL RULES:** +1. **Format:** Your entire response MUST be a valid JSON object with two keys: "detected_language" (string) and "subtitles" (array). Do NOT include any text, headers, or markdown formatting (like \`\`\`json) before or after the object. +2. **Language Detection:** The "detected_language" field MUST contain the full name of the language detected in the audio (e.g., "English", "Spanish", "Hindi", "French", etc.). +3. **Translation:** ${hasTargetLanguage ? `Translate all subtitle text to ${targetLanguage} while keeping timestamps accurate. The subtitle text should be in ${targetLanguage}, not the original language.` : 'Provide subtitles in the detected/original language.'} +4. **Timestamps:** Timestamps MUST be in the exact \`HH:MM:SS.mmm\` format (hours:minutes:seconds.milliseconds). +5. **Punctuation:** Include correct punctuation (commas, periods, question marks) for readability. +6. **Silence:** Do NOT generate subtitle entries for periods of silence. +7. **Non-Speech:** (Optional) Transcribe significant non-speech sounds in brackets, e.g., [MUSIC], [LAUGHTER], [APPLAUSE]. +8. **Accuracy:** Transcribe the audio verbatim. Do not paraphrase or correct the speaker's grammar. ${hasTargetLanguage ? `When translating, maintain the meaning and tone of the original speech.` : ''} +9. **SUBTITLE LENGTH:** Keep each subtitle entry SHORT and concise. Each subtitle should contain a maximum of 1-2 short sentences or 5-10 words. Break longer sentences into multiple subtitle entries with appropriate timestamps. This ensures subtitles are readable and don't cover the entire screen. Think of comfortable reading speed - viewers should be able to read the subtitle in 2-3 seconds. + +**Output Example:** +{ + "detected_language": "English", + "subtitles": [ + { "start": "00:00:01.200", "end": "00:00:04.100", "text": "${hasTargetLanguage ? `[Translated text in ${targetLanguage}]` : 'Hello everyone, and welcome.'}" }, + { "start": "00:00:04.350", "end": "00:00:07.000", "text": "${hasTargetLanguage ? `[Translated text in ${targetLanguage}]` : 'Today we\'re going to discuss...'}" }, + { "start": "00:00:07.100", "end": "00:00:08.000", "text": "[MUSIC]" } + ] +} +`; + + console.log('Converting file to buffer...'); + const audioBuffer = await fetchVideoAsBuffer(video_url) + const fileName = getFileNameFromUrl(video_url); + console.log('Buffer length:', audioBuffer.length); + + tempFilePath = path.join(os.tmpdir(), `${Date.now()}_${fileName}`); + console.log('Writing to temp file:', tempFilePath); + await fs.writeFile(tempFilePath, audioBuffer); + console.log("audio length", audioBuffer.length) + + console.log('Uploading to Google AI...'); + const newFileName = `${user.id}/${Date.now()}_${fileName}`; + const fileType = getMimeTypeFromUrl(video_url); + const [ myFile] = await Promise.all([ + ai.files.upload({ + file: tempFilePath, + config: { mimeType: fileType }, + }), + ]); + + console.log('File uploaded:', myFile); + + await waitForFileActive(ai, myFile.name!); + const parts = [ + { text: prompt }, + { + fileData: { + fileUri: myFile.uri, + mimeType: myFile.mimeType + } + } + ]; + + console.log('Generating content...'); + let result: any; + + try { + result = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: [{ role: "user", parts }], + }); + console.log('Generation complete'); + } catch (geminiError) { + console.error('Gemini API error:', geminiError); + await logErrorToDB(`Gemini API error: ${geminiError}`, subtitleId); + return NextResponse.json({ error: 'Failed to generate subtitles from Gemini API' }, { status: 500 }); + } + + // Clean up temp file + if (tempFilePath) { + await fs.unlink(tempFilePath).catch(err => + console.error('Failed to delete temp file:', err) + ); + } + + console.log(result.text); + + let subtitlesData; + let cleanedText; + try { + const rawText = result.text; + cleanedText = rawText.replace(/```json/g, '').replace(/```/g, '').trim(); + subtitlesData = JSON.parse(cleanedText); + + // Validate the response structure + if (!subtitlesData.detected_language || !subtitlesData.subtitles) { + throw new Error('Invalid response structure from Gemini'); + } + } catch (parseError) { + await logErrorToDB(`Failed to parse Gemini JSON: ${String(parseError)}`, subtitleId); + + console.error('Failed to parse JSON from Gemini:', parseError, "Raw text:", result.text); + return NextResponse.json({ error: 'Failed to parse subtitle data' }, { status: 500 }); + } + + const subtitlesJson = subtitlesData.subtitles; + const detectedLanguage = subtitlesData.detected_language; + + console.log('Detected language:', detectedLanguage); + + const srtContent = convertJsonToSrt(subtitlesJson); + const {data, error: subtitleInsertError} = await supabase + .from("subtitle_jobs") + .update({ + subtitles_json: JSON.stringify(subtitlesJson), + status: "done", + language: language, + detected_language: detectedLanguage, + target_language: targetLanguage, + duration: duration + }) + .eq("id", subtitleId) + .eq("user_id", user.id) + .select() + .single(); + + if(subtitleInsertError){ + await logErrorToDB(`Failed to update subtitles: ${subtitleInsertError.message}`, subtitleId); + console.log(subtitleInsertError); + return NextResponse.json({error: 'Failed to update subtitles'}, {status: 500}); + } + + const { error: updateError } = await supabase + .from('profiles') + .update({ credits: typedProfileData.credits - 1 }) + .eq('user_id', user.id); + + if (updateError) { + console.error('Error updating credits:', updateError); + return NextResponse.json({ error: 'Failed to update credits' }, { status: 500 }); + } + + console.log(data); + return NextResponse.json({ + success: true, + detected_language: detectedLanguage, + target_language: hasTargetLanguage ? targetLanguage : detectedLanguage + }); + } catch (error) { + console.error('Error in subtitle generation:', error); + + // Clean up temp file on error + if (tempFilePath) { + fs.unlink(tempFilePath).catch(err => + console.error('Failed to delete temp file:', err) + ); + } + + return NextResponse.json({ error: "Upload failed", status: 400 }); + } +} + +export async function GET(req: Request, res: Response) { + const supabase = await createClient(); + try{ + const{data: {user}} = await supabase.auth.getUser(); + if(!user){ + return NextResponse.json({error: 'Unauthorized'}, {status:401}); + } + + const {data: subtitleData, error: subtitleError} = await supabase + .from('subtitle_jobs') + .select("*") + .eq("user_id", user.id) + .order('created_at', { ascending: false }) + .limit(10); + + + if (subtitleError) { + return NextResponse.json({ message: 'Error fetching subtitle' }, { status: 500 }); + } + // console.log(subtitleData); + + return NextResponse.json(subtitleData); + + + }catch(error: unknown) { + console.error('Error fetching subtitles:', error); + return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); + } + + + +} + + +export async function PATCH(request: Request) { + const supabase = await createClient(); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { subtitles, subtitle_id } = body; // Edited subtitle JSON + console.log(subtitle_id); + if (!Array.isArray(subtitles)) { + return NextResponse.json({ error: 'Invalid subtitle format' }, { status: 400 }); + } + + // Convert to SRT for consistency + const srtContent = convertJsonToSrt(subtitles); + + // Store edited subtitles in the same way as POST + const { error: updateError } = await supabase + .from("subtitle_jobs") + .update({ + subtitles_json: subtitles + }) + .eq("id", subtitle_id) + .eq("user_id", user.id) + .single(); + + if (updateError) { + console.error(updateError); + return NextResponse.json({ error: 'Failed to update subtitles' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Subtitles updated successfully', + subtitles: subtitles, + srt: srtContent + }); + } catch (error) { + console.error("Error in PATCH:", error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + + diff --git a/apps/web/app/api/subtitle/upload/route.ts b/apps/web/app/api/subtitle/upload/route.ts new file mode 100644 index 0000000..f0391fd --- /dev/null +++ b/apps/web/app/api/subtitle/upload/route.ts @@ -0,0 +1,88 @@ +import {NextResponse} from "next/server"; +import {createClient} from '@/lib/supabase/server'; + + +async function UploadVideo(file: File, newFileName: string): Promise { + const supabase = await createClient(); + + const { error: uploadError } = await supabase.storage + .from("video_subtitles") + .upload(newFileName, file); + + if (uploadError) { + throw uploadError; + } + + const { data: { publicUrl } } = supabase.storage + .from("video_subtitles") + .getPublicUrl(newFileName); + + return publicUrl; +} + + +export async function POST(request: Request) { + const supabase = await createClient(); + + try { + const {data: {user}} = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({error: 'Unauthorized'}, {status: 401}); + } + const formData = await request.formData(); + const file = formData.get('video') as File; + const durationStr = formData.get('duration') as string | null; + if (!file) { + return NextResponse.json({ error: 'No file provided.' }, { status: 400 }); + } + + if (!durationStr) { + return NextResponse.json({ error: 'No duration provided.' }, { status: 400 }); + } + + const duration = parseFloat(durationStr); + if (isNaN(duration)) { + return NextResponse.json({ error: 'Invalid duration format.' }, { status: 400 }); + } + + const maxSize = 200 * 1024 * 1024; + if (file.size > maxSize) { + return NextResponse.json({ error: 'File size must be less than 200MB' }, { status: 413 }); + } + + const maxDuration = 10 * 60; + if (duration > maxDuration) { + return NextResponse.json({ error: 'Video duration must be 10 minutes or less' }, { status: 400 }); + } + const newFileName = `${user.id}/${Date.now()}_${file.name}`; + const video_url = await UploadVideo(file, newFileName); + const {data, error: subtitleInsertError} = await supabase + .from("subtitle_jobs") + .insert({ + user_id: user.id, + video_path: video_url, + video_url: video_url, + duration: duration, + }) + .select() + .single() + + if(subtitleInsertError){ + console.log(subtitleInsertError); + return NextResponse.json({error: 'Failed to update subtitles'}, {status: 500}); + } + + console.log(data) + + return NextResponse.json({ + success: true, + subtitleId: data.id, + }); + + }catch(error) { + console.log(error); + return NextResponse.json({error: "Upload failed", status: 400}) + } + + +} diff --git a/apps/web/app/dashboard/subtitles/[id]/page.tsx b/apps/web/app/dashboard/subtitles/[id]/page.tsx new file mode 100644 index 0000000..9187fe3 --- /dev/null +++ b/apps/web/app/dashboard/subtitles/[id]/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import React, { useRef, useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { motion } from 'motion/react'; // Using framer-motion +import { Loader2, X, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; + +import { SubtitleHeader } from '@/components/dashboard/subtitles/SubtitleHeader'; +import { VideoPlayer } from '@/components/dashboard/subtitles/VideoPlayer'; +import { SubtitleEditorPanel } from '@/components/dashboard/subtitles/SubtitleEditorPanel'; + +import { useSubtitleData } from '@/hooks/useSubtitleData'; +import { useVideoPlayer } from '@/hooks/useVideoPlayer'; +import { useSubtitleEditor } from '@/hooks/useSubtitleEditor'; +import { useSubtitleFileOperations } from '@/hooks/useSubtitleFileOperations'; +import { useSubtitleGeneration } from '@/hooks/useSubtitleGeneration'; +import {convertJsonToVTT} from "@/utils/vttHelper"; +import {useDebounce} from "@/hooks/useDebounce"; +import {SubtitleEditorSkeleton} from "@/components/dashboard/subtitles/skeleton/SubtitleEditorSkeleton"; + +const containerVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + } +}; + +const gridVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1, delayChildren: 0.2 } + } +}; + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.3 } + } +}; + + + +export default function SubtitleEditorPage() { + const videoRef = useRef(null); + const { id } = useParams(); + + const { subtitleData, subtitles, setSubtitles, isLoading, error, refetch } = useSubtitleData(id as string); + const { currentTime, videoDuration, currentSubtitle, seekTo } = useVideoPlayer( + videoRef, + subtitleData?.video_url, + subtitles + ); + const { editingIndex, setEditingIndex, updateSubtitle, handleAddLine, handleDeleteLine } = useSubtitleEditor( + subtitles, + setSubtitles, + currentTime + ); + const { isSaving, handleSave, handleDownload, handleFileUpload, handleDownloadVideo,downloadVideoLoading } = useSubtitleFileOperations( + id as string, + subtitles, + setSubtitles, + subtitleData?.video_path, + subtitleData?.video_url + ); + const { isGenerating, handleGenerate } = useSubtitleGeneration(id as string, videoDuration, refetch); + const [subtitleUrl, setSubtitleUrl] = useState(null); + const debouncedSubtitles = useDebounce(subtitles, 500); + + useEffect(() => { + if (!debouncedSubtitles || debouncedSubtitles.length === 0) { + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + setSubtitleUrl(null); + } + return; + } + + const vttContent = convertJsonToVTT(debouncedSubtitles); + const blob = new Blob([vttContent], { type: 'text/vtt' }); + const newSubtitleUrl = URL.createObjectURL(blob); + setSubtitleUrl(newSubtitleUrl); + + return () => { + URL.revokeObjectURL(newSubtitleUrl); + }; + }, [debouncedSubtitles]); + + if (isLoading) { + return ( + + ); + } + + if (error) { + return ( +
+ + + +

Error Loading Job

+

{error}

+ +
+
+
+ ); + } + + return ( + +
+
+ 0} + onSave={handleSave} + onDownload={handleDownload} + /> +
+ + + + + + + + +
+ + + + + ); +} \ No newline at end of file diff --git a/apps/web/app/dashboard/subtitles/page.tsx b/apps/web/app/dashboard/subtitles/page.tsx index d3962b3..3480146 100644 --- a/apps/web/app/dashboard/subtitles/page.tsx +++ b/apps/web/app/dashboard/subtitles/page.tsx @@ -1,377 +1,85 @@ -"use client" - -import type React from "react" -import { useState } from "react" -import { useRouter } from "next/navigation" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { useToast } from "@/components/ui/use-toast" -import { Loader2, Upload, Download, Clock } from "lucide-react" -import { Textarea } from "@/components/ui/textarea" - -export default function SubtitleGenerator() { - const { toast } = useToast() - const router = useRouter() - const [videoUrl, setVideoUrl] = useState("") - const [videoFile, setVideoFile] = useState(null) - const [language, setLanguage] = useState("english") - const [loading, setLoading] = useState(false) - const [subtitles, setSubtitles] = useState("") - const [uploadProgress, setUploadProgress] = useState(0) - const [activeTab, setActiveTab] = useState("url") - const [isComingSoon] = useState(true) // Toggle this to false when feature is released - - const handleFileChange = (e: React.ChangeEvent) => { - if (isComingSoon) return // Prevent interaction when coming soon - if (e.target.files && e.target.files[0]) { - setVideoFile(e.target.files[0]) - } - } - - const handleGenerateSubtitles = async () => { - if (isComingSoon) return // Prevent interaction when coming soon - if (activeTab === "url" && !videoUrl) { - toast({ - title: "Video URL required", - description: "Please enter a YouTube video URL to generate subtitles.", - variant: "destructive", - }) - return - } - - if (activeTab === "upload" && !videoFile) { - toast({ - title: "Video file required", - description: "Please upload a video file to generate subtitles.", - variant: "destructive", - }) - return - } - - setLoading(true) - setUploadProgress(0) - - try { - // Simulate file upload progress - if (activeTab === "upload") { - const interval = setInterval(() => { - setUploadProgress((prev) => { - if (prev >= 100) { - clearInterval(interval) - return 100 +"use client"; + +import { SubtitleUploader } from "@/components/dashboard/subtitles/subtitleUploader"; +import { SubtitleHistory } from "@/components/dashboard/subtitles/subtitleHistory"; +import { SubtitleResponse } from "@repo/validation/src/types/SubtitleTypes"; +import { useState, useEffect, useCallback } from "react"; +import { toast } from "sonner"; +import { motion } from "motion/react"; + +export default function SubtitlesPage() { + const [subtitles, setSubtitles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshSubtitles = useCallback(async () => { + try { + setError(null); + + const res = await fetch("/api/subtitle"); + if (!res.ok) { + throw new Error("Failed to fetch subtitle history"); } - return prev + 10 - }) - }, 500) - } - - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 5000)) - - // Sample subtitles for demonstration - const sampleSubtitles = `1 -00:00:00,000 --> 00:00:03,000 -Welcome to this video about content creation. - -2 -00:00:03,500 --> 00:00:07,000 -Today we're going to discuss how AI can help YouTubers. - -3 -00:00:07,500 --> 00:00:12,000 -Script AI makes it easy to generate personalized scripts for your videos. - -4 -00:00:12,500 --> 00:00:16,000 -Let's start by looking at the key features of this platform. - -5 -00:00:16,500 --> 00:00:20,000 -First, you can train the AI on your existing content. - -6 -00:00:20,500 --> 00:00:25,000 -This ensures that the scripts match your unique style and tone.` - - setSubtitles(sampleSubtitles) - - toast({ - title: "Subtitles generated!", - description: "Your subtitles have been generated successfully.", - }) - } catch (error: any) { - toast({ - title: "Error generating subtitles", - description: error.message || "An unexpected error occurred", - variant: "destructive", - }) - } finally { - setLoading(false) - setUploadProgress(0) - } - } - - const handleDownloadSubtitles = () => { - if (isComingSoon || !subtitles) return - - const blob = new Blob([subtitles], { type: "text/plain" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = "subtitles.srt" - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - } - - return ( -
-
-

Subtitle Generator

-

Generate accurate subtitles for your YouTube videos

-
- - - - YouTube URL - Upload Video - - - - - - Generate from YouTube URL - Enter a YouTube video URL to generate subtitles - - -
- - setVideoUrl(e.target.value)} - disabled={loading || isComingSoon} - /> -

- Enter the full URL of the YouTube video you want to generate subtitles for -

-
- -
- - -

Select the language of the video content

-
-
- - - - - {isComingSoon && ( -
-
- -

- Coming Soon -

-

- Stay tuned to unlock this exciting feature to generate accurate subtitles for your YouTube videos! -

- -
-
- )} -
-
- - - - - Upload Video - Upload a video file to generate subtitles - - -
- -
- -
- {videoFile && ( -

Selected file: {videoFile.name}

- )} -
- - {uploadProgress > 0 && uploadProgress < 100 && ( -
-
-

Uploading: {uploadProgress}%

-
- )} - -
- - -

Select the language of the video content

-
-
- - - - - {isComingSoon && ( -
-
- -

- Coming Soon -

-

- Stay tuned to unlock this exciting feature to generate accurate subtitles for your YouTube videos! -

- -
-
- )} -
-
-
- - {subtitles && ( - - - Generated Subtitles - Review and download your subtitles - - -