Spaces:
Sleeping
Sleeping
Download frontend/src/components/GenerationForm.tsx from userIdc2024/Video_AdGenesis_App: direct link, hf CLI and curl.
- Browser
- Download file 81.7 kB
-
https://huggingface.co/spaces/userIdc2024/Video_AdGenesis_App/resolve/main/frontend/src/components/GenerationForm.tsx
- Command line
-
hf download hf://spaces/userIdc2024/Video_AdGenesis_App/frontend/src/components/GenerationForm.tsx
-
curl -L -o GenerationForm.tsx https://huggingface.co/spaces/userIdc2024/Video_AdGenesis_App/resolve/main/frontend/src/components/GenerationForm.tsx
81.7 kB
| import React, { useState, useCallback, useEffect, useRef } from 'react'; | |
| import { motion } from 'framer-motion'; | |
| import { useGeneration } from '@/context/GenerationContext'; | |
| import type { GenerationInputs, VideoProvider, GeneratedVideo, VeoSegment, KieVideoFlow } from '@/types'; | |
| import { | |
| SparklesIcon, | |
| ArrowLeftIcon, | |
| ImageIcon | |
| } from './Icons'; | |
| import { | |
| generatePromptsStreaming, | |
| type StreamEvent, | |
| type StreamSegmentEvent, | |
| uploadImage, | |
| klingGenerate, | |
| klingExtend, | |
| waitForKlingVideo, | |
| generateVideoWithRetry, | |
| handleFlowRetry, | |
| type FlowRetryOutcome, | |
| downloadVideo, | |
| getVideoDuration, | |
| generateThumbnails, | |
| replicateGenerate, | |
| waitForReplicateVideo, | |
| whisperAnalyzeAndExtract, | |
| fixUnsafePrompt, | |
| isUnsafeSegmentError, | |
| validateContent, | |
| AUTO_FIX_MAX_ATTEMPTS, | |
| MAX_VIDEO_ATTEMPTS, | |
| type ContentValidationResponse | |
| } from '@/utils/api'; | |
| interface GenerationFormProps { | |
| provider: VideoProvider; | |
| onBack: () => void; | |
| } | |
| const voiceTypes = ['Deep', 'Warm', 'Crisp']; | |
| const energyLevels = ['Low', 'Medium', 'High']; | |
| const cameraStyles = ['Standard', 'Handheld', 'Steadicam', 'FPV Drone']; | |
| const narrativeStyles = ['Standard', 'Documentary', 'Action', 'Introspective']; | |
| const aspectRatios = ['9:16', '16:9', '1:1']; | |
| // Generation modes | |
| type GenerationMode = 'extend' | 'frame-continuity'; | |
| export const GenerationForm: React.FC<GenerationFormProps> = ({ provider, onBack }) => { | |
| const { startGeneration, updateProgress, addVideo, setStep, setError, updateSegments, addTaskId, removeTaskId, setPartialCompletionError, registerPromptAbortController, state } = useGeneration(); | |
| const { generatedVideos, segments, isCancelling } = state; | |
| // Check if we're in "reuse mode" (segments pre-loaded from cache) | |
| const isReuseMode = segments.length > 0; | |
| // Draft storage key | |
| const draftKey = `video-gen-draft-${provider}`; | |
| // Load draft on mount - initialize state from localStorage | |
| const loadDraft = useCallback(() => { | |
| try { | |
| const savedDraft = localStorage.getItem(draftKey); | |
| if (savedDraft) { | |
| const draft = JSON.parse(savedDraft); | |
| return draft; | |
| } | |
| } catch (error) { | |
| console.warn('Failed to load draft:', error); | |
| } | |
| return null; | |
| }, [draftKey]); | |
| const draft = loadDraft(); | |
| const [draftRestored, setDraftRestored] = useState(!!draft); | |
| const [formState, setFormState] = useState<GenerationInputs>( | |
| draft?.formState || { | |
| script: '', | |
| style: '', | |
| voiceType: 'Deep', | |
| energyLevel: 'Medium', | |
| cameraStyle: 'Standard', | |
| narrativeStyle: 'Standard', | |
| seedValue: 12005, | |
| aspectRatio: '9:16', | |
| model: provider === 'kling' ? 'veo3_fast' : 'google/veo-3-fast', | |
| } | |
| ); | |
| const [imageFile, setImageFile] = useState<File | null>(null); | |
| const [imagePreview, setImagePreview] = useState<string | null>(draft?.imagePreview || null); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [contentWarnings, setContentWarnings] = useState<ContentValidationResponse | null>(null); | |
| const [showWarnings, setShowWarnings] = useState(false); | |
| // Generation mode selection | |
| const [generationMode, setGenerationMode] = useState<GenerationMode>(draft?.generationMode || 'frame-continuity'); | |
| /** KIE: prompt style β creator talking-head vs product showcase UGC */ | |
| const [kieVideoFlow, setKieVideoFlow] = useState<KieVideoFlow>(draft?.kieVideoFlow || 'creator_ugc'); | |
| // Segment mode: fixed duration (4/6/8s) or AI decides per segment | |
| const [segmentMode, setSegmentMode] = useState<'fixed' | 'ai_driven'>(draft?.segmentMode ?? 'fixed'); | |
| const [secondsPerSegment, setSecondsPerSegment] = useState<4 | 6 | 8>(draft?.secondsPerSegment ?? 8); | |
| // Show notification if draft was restored | |
| useEffect(() => { | |
| if (draftRestored) { | |
| console.log('π Draft restored from localStorage'); | |
| // Auto-hide notification after 5 seconds | |
| const timer = setTimeout(() => setDraftRestored(false), 5000); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [draftRestored]); | |
| // Save draft whenever formState, imagePreview, or generationMode changes | |
| // Skip saving on initial mount to avoid overwriting with default values | |
| const isInitialMount = useRef(true); | |
| useEffect(() => { | |
| if (isInitialMount.current) { | |
| isInitialMount.current = false; | |
| return; | |
| } | |
| try { | |
| const draft = { | |
| formState, | |
| generationMode, | |
| kieVideoFlow, | |
| segmentMode, | |
| secondsPerSegment, | |
| savedAt: new Date().toISOString(), | |
| }; | |
| localStorage.setItem(draftKey, JSON.stringify(draft)); | |
| } catch (error) { | |
| if (error instanceof DOMException && error.name === 'QuotaExceededError') { | |
| try { | |
| localStorage.removeItem(draftKey); | |
| const fallback = { | |
| formState: { ...formState, script: formState.script.slice(0, 5000) }, | |
| generationMode, | |
| kieVideoFlow, | |
| segmentMode, | |
| secondsPerSegment, | |
| savedAt: new Date().toISOString(), | |
| }; | |
| localStorage.setItem(draftKey, JSON.stringify(fallback)); | |
| } catch { | |
| console.warn('Failed to save draft: quota exceeded'); | |
| } | |
| } else { | |
| console.warn('Failed to save draft:', error); | |
| } | |
| } | |
| }, [formState, imagePreview, generationMode, kieVideoFlow, segmentMode, secondsPerSegment, draftKey]); | |
| // Clear draft function | |
| const clearDraft = useCallback(() => { | |
| try { | |
| localStorage.removeItem(draftKey); | |
| setDraftRestored(false); | |
| console.log('ποΈ Draft cleared'); | |
| } catch (error) { | |
| console.warn('Failed to clear draft:', error); | |
| } | |
| }, [draftKey]); | |
| // Accumulate streamed segments so we can show them live and pass to context | |
| const streamedSegmentsRef = useRef<VeoSegment[]>([]); | |
| // Calculate estimated segments | |
| const wordCount = formState.script.trim().split(/\s+/).filter(w => w).length; | |
| const estimatedSegments = wordCount > 0 ? Math.max(1, Math.min(Math.ceil(wordCount / 17), 10)) : 0; | |
| // Validate content when script changes (debounced) | |
| const validateScriptContent = useCallback(async (script: string) => { | |
| if (!script.trim() || isReuseMode) { | |
| setContentWarnings(null); | |
| return; | |
| } | |
| try { | |
| const validation = await validateContent(script); | |
| if (!validation.is_valid) { | |
| setContentWarnings(validation); | |
| setShowWarnings(true); | |
| } else { | |
| setContentWarnings(null); | |
| setShowWarnings(false); | |
| } | |
| } catch (error) { | |
| console.error('Content validation error:', error); | |
| // Silently fail - don't block user | |
| } | |
| }, [isReuseMode]); | |
| // Debounce content validation | |
| useEffect(() => { | |
| const timer = setTimeout(() => { | |
| validateScriptContent(formState.script); | |
| }, 1000); // Validate 1 second after user stops typing | |
| return () => clearTimeout(timer); | |
| }, [formState.script, validateScriptContent]); | |
| // Handle input changes | |
| const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { | |
| const { name, value } = e.target; | |
| setFormState(prev => ({ ...prev, [name]: value })); | |
| }; | |
| // Handle image upload | |
| const handleImageUpload = useCallback((file: File) => { | |
| if (file.type.startsWith('image/')) { | |
| setImageFile(file); | |
| const reader = new FileReader(); | |
| reader.onloadend = () => setImagePreview(reader.result as string); | |
| reader.readAsDataURL(file); | |
| } | |
| }, []); | |
| // Drag and drop handlers | |
| const handleDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(true); | |
| }; | |
| const handleDragLeave = () => setIsDragging(false); | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| const file = e.dataTransfer.files[0]; | |
| if (file) handleImageUpload(file); | |
| }; | |
| // Extract last frame from video blob | |
| const extractLastFrame = async (videoBlob: Blob): Promise<File> => { | |
| return new Promise((resolve, reject) => { | |
| const video = document.createElement('video'); | |
| video.preload = 'metadata'; | |
| video.muted = true; | |
| video.src = URL.createObjectURL(videoBlob); | |
| video.onloadedmetadata = async () => { | |
| // Seek to near the end of the video | |
| const targetTime = Math.max(0, video.duration - 0.1); | |
| video.currentTime = targetTime; | |
| }; | |
| video.onseeked = () => { | |
| // Create canvas and draw current frame | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) { | |
| URL.revokeObjectURL(video.src); | |
| reject(new Error('Could not get canvas context')); | |
| return; | |
| } | |
| ctx.drawImage(video, 0, 0); | |
| // Convert to blob then to file | |
| canvas.toBlob((blob) => { | |
| URL.revokeObjectURL(video.src); | |
| if (!blob) { | |
| reject(new Error('Could not extract frame')); | |
| return; | |
| } | |
| const file = new File([blob], `frame-${Date.now()}.jpg`, { type: 'image/jpeg' }); | |
| resolve(file); | |
| }, 'image/jpeg', 0.95); | |
| }; | |
| video.onerror = () => { | |
| URL.revokeObjectURL(video.src); | |
| reject(new Error('Failed to load video for frame extraction')); | |
| }; | |
| }); | |
| }; | |
| // ============================================ | |
| // KIE GENERATION - FRAME CONTINUITY FLOW | |
| // ============================================ | |
| // This mirrors the Replicate flow from standalone_video_creator.py: | |
| // 1. Generate first video with original reference image | |
| // 2. Extract last frame using the whisper analysis from generated video | |
| // 3. Use that frame as reference for next segment | |
| // 4. Repeat for all segments | |
| const handleKlingFrameContinuityFlow = async (attemptCount: number = 0) => { | |
| if (!imageFile) return; | |
| if (!isReuseMode && !formState.script.trim()) return; | |
| setIsGenerating(true); | |
| setError(null); | |
| let segmentsToUse: VeoSegment[] = []; | |
| let currentImageFile: File = imageFile; // Track reference image across segments and catch block | |
| let completedVideoCount = 0; // Track locally to avoid stale React state closure | |
| try { | |
| // Step 1: Get segments - either from cache (reuse mode), existing segments (retry mode), or generate new ones | |
| if ((isReuseMode || attemptCount > 0) && segments.length > 0) { | |
| // REUSE MODE or RETRY MODE: Skip prompt generation, use existing segments | |
| const mode = isReuseMode ? 'REUSE' : 'RETRY'; | |
| console.log(`β»οΈ ${mode} MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`); | |
| segmentsToUse = segments; | |
| setStep('generating_video'); | |
| updateProgress(`Starting video generation with ${segments.length} ${mode === 'RETRY' ? 'cached' : 'pre-loaded'} segments...`, 0, segments.length); | |
| } else { | |
| // NORMAL MODE: Generate prompts using GPT-5.2 with STREAMING | |
| setStep('generating_prompts'); | |
| updateProgress('π Streaming prompt generation with GPT-5.2...', 0, estimatedSegments); | |
| const formData = new FormData(); | |
| formData.append('script', formState.script); | |
| formData.append('style', formState.style || 'clean, lifestyle UGC'); | |
| formData.append('jsonFormat', 'standard'); | |
| formData.append('continuationMode', 'true'); | |
| formData.append('voiceType', formState.voiceType || ''); | |
| formData.append('energyLevel', formState.energyLevel || ''); | |
| formData.append('settingMode', 'single'); | |
| formData.append('cameraStyle', formState.cameraStyle || ''); | |
| formData.append('narrativeStyle', formState.narrativeStyle || ''); | |
| formData.append( | |
| 'videoFlow', | |
| provider === 'kling' ? kieVideoFlow : 'creator_ugc' | |
| ); | |
| formData.append('image', imageFile); | |
| formData.append('segment_mode', segmentMode); | |
| formData.append('seconds_per_segment', String(secondsPerSegment)); | |
| if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8'); | |
| streamedSegmentsRef.current = []; | |
| const promptAbort = new AbortController(); | |
| registerPromptAbortController(promptAbort); | |
| try { | |
| const payload = await generatePromptsStreaming( | |
| formData, | |
| (event: StreamEvent) => { | |
| switch (event.event) { | |
| case 'start': | |
| streamedSegmentsRef.current = []; | |
| updateSegments([]); | |
| console.log(`π Starting streaming generation of ${event.total_segments} segments`); | |
| updateProgress(`Streaming ${event.total_segments} segments from GPT-5.2...`, 0, event.total_segments); | |
| break; | |
| case 'segment': { | |
| const seg = (event as StreamSegmentEvent).segment; | |
| if (seg) { | |
| streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg]; | |
| updateSegments(streamedSegmentsRef.current); | |
| } | |
| console.log(`β Segment ${event.index + 1} complete (${event.progress.toFixed(1)}%)`); | |
| updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`); | |
| break; | |
| } | |
| case 'complete': | |
| console.log(`π All ${event.payload.segments.length} segments generated!`); | |
| updateProgress('All segments generated! Starting video generation...'); | |
| break; | |
| case 'error': | |
| console.error(`β Streaming error: ${event.message}`); | |
| throw new Error(event.message); | |
| } | |
| }, | |
| { signal: promptAbort.signal } | |
| ); | |
| if (!payload?.segments?.length) { | |
| throw new Error('No segments generated from script'); | |
| } | |
| segmentsToUse = payload.segments; | |
| updateProgress(`Generated ${segmentsToUse.length} segments. Starting video generation...`); | |
| startGeneration(segmentsToUse); | |
| } finally { | |
| registerPromptAbortController(null); | |
| } | |
| } | |
| // Reset reference image to original for this run | |
| currentImageFile = imageFile; | |
| // RESUME SUPPORT: Start from where we left off if retrying | |
| const startIndex = attemptCount > 0 ? generatedVideos.length : 0; | |
| // If resuming, extract last frame from previous video for continuity | |
| if (startIndex > 0 && generatedVideos[startIndex - 1]?.blobUrl) { | |
| updateProgress(`Resuming from segment ${startIndex + 1}...`); | |
| console.log(`π Resuming generation from segment ${startIndex + 1} (${generatedVideos.length} videos already generated)`); | |
| try { | |
| const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob()); | |
| const lastFrameFile = await extractLastFrame(lastVideoBlob); | |
| currentImageFile = lastFrameFile; | |
| console.log(`β Using frame from segment ${startIndex} for continuity`); | |
| } catch (frameError) { | |
| console.warn('β οΈ Failed to extract frame for resume, using original image:', frameError); | |
| } | |
| } | |
| // Step 2: Generate videos segment by segment with frame continuity | |
| for (let i = startIndex; i < segmentsToUse.length; i++) { | |
| const isLastSegment = i === segmentsToUse.length - 1; | |
| // Per-segment safety retry loop with hard cap (prevents infinite retries) | |
| for (let safetyAttempt = 0; safetyAttempt <= AUTO_FIX_MAX_ATTEMPTS; safetyAttempt++) { | |
| try { | |
| const segment = segmentsToUse[i]; | |
| updateProgress( | |
| `Generating video ${i + 1} of ${segmentsToUse.length}...${i > 0 ? ' (using last frame from previous)' : ''}${safetyAttempt > 0 ? ` (safety retry ${safetyAttempt}/${AUTO_FIX_MAX_ATTEMPTS})` : ''}`, | |
| i, | |
| segmentsToUse.length | |
| ); | |
| // Upload current reference image | |
| updateProgress(`Uploading reference image for segment ${i + 1}...`); | |
| const uploadResult = await uploadImage(currentImageFile, { reference: true }); | |
| const hostedImageUrl = uploadResult.url; | |
| console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`); | |
| // Generate video with current reference image | |
| updateProgress(`Submitting segment ${i + 1} to KIE Veo 3.1...`); | |
| const generateResult = await klingGenerate({ | |
| prompt: segment, | |
| imageUrls: [hostedImageUrl], | |
| model: 'veo3_fast', | |
| aspectRatio: formState.aspectRatio, | |
| generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO', | |
| seeds: formState.seedValue, | |
| voiceType: formState.voiceType, | |
| }); | |
| // Wait for completion | |
| updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`); | |
| const videoUrl = await waitForKlingVideo(generateResult.taskId); | |
| // Download video | |
| updateProgress(`Downloading video ${i + 1}...`); | |
| const videoBlob = await downloadVideo(videoUrl); | |
| const blobUrl = URL.createObjectURL(videoBlob); | |
| // Get video duration | |
| const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); | |
| const duration = await getVideoDuration(videoFile); | |
| const thumbnails = await generateThumbnails(videoFile); | |
| // Use Whisper to find optimal trim point, extract frame, and get transcription | |
| let trimPoint = duration; // Default to full duration | |
| let transcribedText = ''; // What Whisper actually heard | |
| if (!isLastSegment) { | |
| updateProgress(`Analyzing video ${i + 1} with Whisper for optimal continuity...`); | |
| try { | |
| // Get dialogue from segment for Whisper analysis | |
| const dialogue = segment.action_timeline?.dialogue || ''; | |
| const whisperResult = await whisperAnalyzeAndExtract({ | |
| video_url: videoUrl, | |
| dialogue: dialogue, | |
| buffer_time: 0.3, | |
| model_size: 'base' | |
| }); | |
| if (whisperResult.success && whisperResult.frame_base64) { | |
| // Convert base64 frame to File for next segment (backend may return PNG or JPEG) | |
| const dataUrl = whisperResult.frame_base64; | |
| const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg'; | |
| const ext = mime === 'image/png' ? 'png' : 'jpg'; | |
| const base64Data = dataUrl.split(',')[1] || dataUrl; | |
| const byteCharacters = atob(base64Data); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let j = 0; j < byteCharacters.length; j++) { | |
| byteNumbers[j] = byteCharacters.charCodeAt(j); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| const frameBlob = new Blob([byteArray], { type: mime }); | |
| currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.${ext}`, { type: mime }); | |
| // Store trim point for later merge | |
| if (whisperResult.trim_point) { | |
| trimPoint = whisperResult.trim_point; | |
| } | |
| // Store transcribed text for prompt refinement | |
| if (whisperResult.transcribed_text) { | |
| transcribedText = whisperResult.transcribed_text; | |
| console.log(`π Whisper transcription: "${transcribedText.substring(0, 100)}..."`); | |
| } | |
| console.log(`β Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`); | |
| // REFINE NEXT SEGMENT PROMPT with frame + transcription | |
| const nextSegment = segmentsToUse[i + 1]; | |
| if (nextSegment && currentImageFile) { | |
| updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`); | |
| try { | |
| const { refinePromptWithContext } = await import('@/utils/api'); | |
| const refined = await refinePromptWithContext( | |
| nextSegment, | |
| currentImageFile, | |
| transcribedText, | |
| dialogue | |
| ); | |
| // Update the next segment with refined prompt | |
| segmentsToUse[i + 1] = refined.refined_prompt as typeof nextSegment; | |
| console.log(`β Refined segment ${i + 2} prompt for consistency`); | |
| } catch (refineError) { | |
| console.warn(`β οΈ Prompt refinement failed, using original:`, refineError); | |
| } | |
| } | |
| } else { | |
| // Fallback to simple last frame extraction | |
| console.log(`β οΈ Whisper failed (${whisperResult.error}), falling back to last frame extraction`); | |
| const lastFrameFile = await extractLastFrame(videoBlob); | |
| currentImageFile = lastFrameFile; | |
| } | |
| } catch (frameError) { | |
| console.error(`β οΈ Whisper analysis failed, using fallback:`, frameError); | |
| try { | |
| const lastFrameFile = await extractLastFrame(videoBlob); | |
| currentImageFile = lastFrameFile; | |
| } catch { | |
| // Continue with current image if all extraction fails | |
| } | |
| } | |
| } | |
| // Add to generated videos with trim metadata | |
| const generatedVideo: GeneratedVideo = { | |
| id: `video-${Date.now()}-${i}`, | |
| url: videoUrl, | |
| blobUrl, | |
| segment, | |
| duration, | |
| thumbnails, | |
| trimPoint, // Store trim point for merge | |
| }; | |
| addVideo(generatedVideo); | |
| completedVideoCount++; | |
| updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length); | |
| break; // Segment succeeded - exit safety retry loop, move to next segment | |
| } catch (segErr) { | |
| const segErrMsg = segErr instanceof Error ? segErr.message : String(segErr); | |
| // Safety error with retries remaining: auto-fix the prompt and retry this segment | |
| if (isUnsafeSegmentError(segErrMsg) && safetyAttempt < AUTO_FIX_MAX_ATTEMPTS) { | |
| console.log(`π‘οΈ Safety error on segment ${i + 1}, auto-fix attempt ${safetyAttempt + 1}/${AUTO_FIX_MAX_ATTEMPTS}...`); | |
| updateProgress(`Safety issue in segment ${i + 1}, auto-fixing (attempt ${safetyAttempt + 1}/${AUTO_FIX_MAX_ATTEMPTS})...`); | |
| try { | |
| const fixResult = await fixUnsafePrompt({ | |
| segment: segmentsToUse[i], | |
| error_message: segErrMsg, | |
| attempt_count: safetyAttempt + 1 | |
| }); | |
| if (fixResult.success && fixResult.fixed_segment) { | |
| console.log(`β Auto-fix successful: ${fixResult.changes_made}`); | |
| updateProgress(`Auto-fix successful: ${fixResult.changes_made}`); | |
| segmentsToUse[i] = fixResult.fixed_segment; | |
| updateSegments([...segmentsToUse]); | |
| await new Promise(resolve => setTimeout(resolve, 1000)); | |
| continue; // Retry this segment with the fixed prompt | |
| } else { | |
| console.warn(`β οΈ Auto-fix returned no usable fix: ${fixResult.error}`); | |
| } | |
| } catch (fixErr) { | |
| console.error('β Auto-fix API error:', fixErr); | |
| } | |
| } | |
| // Non-safety error, fix failed, or max safety retries exhausted β propagate to outer handler | |
| throw segErr; | |
| } | |
| } // end per-segment safety retry loop | |
| } | |
| // All done! | |
| clearDraft(); // Clear draft on successful generation | |
| setStep('completed'); | |
| updateProgress('All videos generated successfully!'); | |
| } catch (err) { | |
| console.error('Generation error:', err); | |
| const errorMessage = err instanceof Error ? err.message : 'Generation failed'; | |
| const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling; | |
| // If user cancelled, context already set error with segment/video count; don't overwrite | |
| if (isUserCancel) { | |
| setStep('error'); | |
| setIsGenerating(false); | |
| return; | |
| } | |
| // If some videos were generated, show partial completion immediately | |
| // (don't retry from scratch -- that would waste the already-generated videos) | |
| if (completedVideoCount > 0) { | |
| console.log(`β οΈ Partial completion: ${completedVideoCount}/${segmentsToUse.length} segments generated`); | |
| setPartialCompletionError( | |
| `Generation stopped at segment ${completedVideoCount + 1} of ${segmentsToUse.length}. ` + | |
| `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` + | |
| `Reason: ${errorMessage}` | |
| ); | |
| setStep('completed'); | |
| } else { | |
| // No videos generated at all -- use normal retry logic | |
| const outcome: FlowRetryOutcome = await handleFlowRetry({ | |
| attemptCount, | |
| errorMessage, | |
| isCancelled: false, | |
| generatedCount: 0, | |
| totalCount: segmentsToUse.length, | |
| setError, | |
| setStep, | |
| setPartialCompletionError, | |
| }); | |
| if (outcome === 'retry') { | |
| console.log('π No videos generated, auto-retrying...'); | |
| updateProgress('Generation failed, automatically retrying...'); | |
| return handleKlingFrameContinuityFlow(1); | |
| } | |
| } | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| // ============================================ | |
| // KIE GENERATION - EXTEND API FLOW | |
| // ============================================ | |
| // Original flow using KIE's extend API | |
| const handleKlingExtendFlow = async (attemptCount: number = 0) => { | |
| if (!imageFile || !formState.script.trim()) return; | |
| setIsGenerating(true); | |
| setError(null); | |
| let payload: { segments: VeoSegment[] } = { segments: [] }; // Declare at function scope | |
| let completedVideoCount = 0; // Track locally to avoid stale React state closure | |
| try { | |
| // Step 1: Get segments - skip prompt generation if segments already exist (retry mode) | |
| if (attemptCount > 0 && segments.length > 0) { | |
| // RETRY MODE: Use existing segments (they may have been edited) | |
| console.log(`β»οΈ RETRY MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`); | |
| payload = { segments }; | |
| setStep('generating_video'); | |
| updateProgress(`Retrying video generation with ${segments.length} cached segments...`, 0, segments.length); | |
| } else { | |
| // NORMAL MODE: Generate prompts using streaming (GPT-5.2) | |
| setStep('generating_prompts'); | |
| updateProgress('Streaming prompt generation with GPT-5.2...', 0, estimatedSegments); | |
| const formData = new FormData(); | |
| formData.append('script', formState.script); | |
| formData.append('style', formState.style || 'clean, lifestyle UGC'); | |
| formData.append('jsonFormat', 'standard'); | |
| formData.append('continuationMode', 'true'); | |
| formData.append('voiceType', formState.voiceType || ''); | |
| formData.append('energyLevel', formState.energyLevel || ''); | |
| formData.append('settingMode', 'single'); | |
| formData.append('cameraStyle', formState.cameraStyle || ''); | |
| formData.append('narrativeStyle', formState.narrativeStyle || ''); | |
| formData.append( | |
| 'videoFlow', | |
| provider === 'kling' ? kieVideoFlow : 'creator_ugc' | |
| ); | |
| formData.append('image', imageFile); | |
| formData.append('segment_mode', segmentMode); | |
| formData.append('seconds_per_segment', String(secondsPerSegment)); | |
| if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8'); | |
| streamedSegmentsRef.current = []; | |
| const promptAbortExtend = new AbortController(); | |
| registerPromptAbortController(promptAbortExtend); | |
| try { | |
| payload = await generatePromptsStreaming( | |
| formData, | |
| (event: StreamEvent) => { | |
| switch (event.event) { | |
| case 'start': | |
| streamedSegmentsRef.current = []; | |
| updateSegments([]); | |
| updateProgress(`Streaming ${event.total_segments} segments from GPT-5.2...`, 0, event.total_segments); | |
| break; | |
| case 'segment': { | |
| const seg = (event as StreamSegmentEvent).segment; | |
| if (seg) { | |
| streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg]; | |
| updateSegments(streamedSegmentsRef.current); | |
| } | |
| updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`); | |
| break; | |
| } | |
| case 'complete': | |
| updateProgress('All segments generated! Starting video generation...'); | |
| break; | |
| case 'error': | |
| throw new Error(event.message); | |
| } | |
| }, | |
| { signal: promptAbortExtend.signal } | |
| ); | |
| if (!payload?.segments?.length) { | |
| throw new Error('No segments generated from script'); | |
| } | |
| updateProgress(`Generated ${payload.segments.length} segments. Starting video generation...`); | |
| startGeneration(payload.segments); | |
| } finally { | |
| registerPromptAbortController(null); | |
| } | |
| } | |
| // Step 2: Upload reference image once | |
| updateProgress('Uploading reference image...'); | |
| const uploadResult = await uploadImage(imageFile); | |
| const hostedImageUrl = uploadResult.url; | |
| // Step 3: Generate videos (resume from where we left off if retrying) | |
| const startIndex = generatedVideos.length; | |
| let currentTaskId: string | null = null; | |
| let currentImageUrl = hostedImageUrl; // Start with original image | |
| // If resuming, extract last frame from previous video for continuity | |
| if (startIndex > 0 && generatedVideos[startIndex - 1]?.blobUrl) { | |
| updateProgress(`Extracting last frame from segment ${startIndex} for continuity...`); | |
| try { | |
| const lastVideoBlob = await fetch(generatedVideos[startIndex - 1].blobUrl!).then(r => r.blob()); | |
| const lastFrameFile = await extractLastFrame(lastVideoBlob); | |
| const frameUploadResult = await uploadImage(lastFrameFile, { reference: true }); | |
| currentImageUrl = frameUploadResult.url; | |
| updateProgress(`Using frame from segment ${startIndex} for segment ${startIndex + 1}...`); | |
| } catch (frameError) { | |
| console.warn('Failed to extract frame, using original image:', frameError); | |
| // Continue with original image | |
| } | |
| } | |
| for (let i = startIndex; i < payload.segments.length; i++) { | |
| const segment = payload.segments[i]; | |
| updateProgress(`Generating video ${i + 1} of ${payload.segments.length}...`, i, payload.segments.length); | |
| // Generate video with automatic retry (retries once on failure) | |
| updateProgress(`Processing video ${i + 1}... (this may take 1-2 minutes)`); | |
| // Check if cancelled | |
| if (isCancelling) { | |
| throw new Error('Generation cancelled by user'); | |
| } | |
| const videoUrl = await generateVideoWithRetry(async () => { | |
| if (i === 0 || (i === startIndex && startIndex > 0)) { | |
| // First segment OR resuming after failure: use generate API with current image | |
| const generateResult = await klingGenerate({ | |
| prompt: segment, | |
| imageUrls: [currentImageUrl], | |
| model: 'veo3_fast', | |
| aspectRatio: formState.aspectRatio, | |
| generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO', | |
| seeds: formState.seedValue, | |
| voiceType: formState.voiceType, | |
| }); | |
| currentTaskId = generateResult.taskId; | |
| addTaskId(currentTaskId); | |
| return generateResult; | |
| } else { | |
| // Subsequent segments: use extend API | |
| const extendResult = await klingExtend( | |
| currentTaskId!, | |
| segment, | |
| formState.seedValue, | |
| formState.voiceType | |
| ); | |
| currentTaskId = extendResult.taskId; | |
| addTaskId(currentTaskId); | |
| return extendResult; | |
| } | |
| }, 300000, (attempt) => { | |
| updateProgress(`Retrying video ${i + 1}... (attempt ${attempt}/${MAX_VIDEO_ATTEMPTS})`); | |
| }); | |
| // Download and save | |
| updateProgress(`Downloading video ${i + 1}...`); | |
| const videoBlob = await downloadVideo(videoUrl); | |
| const blobUrl = URL.createObjectURL(videoBlob); | |
| const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); | |
| const duration = await getVideoDuration(videoFile); | |
| const thumbnails = await generateThumbnails(videoFile); | |
| addVideo({ | |
| id: `video-${Date.now()}-${i}`, | |
| url: videoUrl, | |
| blobUrl, | |
| segment, | |
| duration, | |
| thumbnails, | |
| }); | |
| completedVideoCount++; | |
| updateProgress(`Completed video ${i + 1} of ${payload.segments.length}`, i + 1, payload.segments.length); | |
| // Remove task ID after completion | |
| if (currentTaskId) { | |
| removeTaskId(currentTaskId); | |
| } | |
| } | |
| clearDraft(); // Clear draft on successful generation | |
| setStep('completed'); | |
| updateProgress('All videos generated successfully!'); | |
| } catch (err) { | |
| console.error('Generation error:', err); | |
| const errorMessage = err instanceof Error ? err.message : 'Generation failed'; | |
| const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling; | |
| if (isUserCancel) { | |
| setStep('error'); | |
| setIsGenerating(false); | |
| return; | |
| } | |
| // If some videos were generated, show partial completion immediately | |
| if (completedVideoCount > 0) { | |
| console.log(`β οΈ Partial completion: ${completedVideoCount}/${payload.segments.length} segments generated`); | |
| setPartialCompletionError( | |
| `Generation stopped at segment ${completedVideoCount + 1} of ${payload.segments.length}. ` + | |
| `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` + | |
| `Reason: ${errorMessage}` | |
| ); | |
| setStep('completed'); | |
| } else { | |
| const outcome = await handleFlowRetry({ | |
| attemptCount, | |
| errorMessage, | |
| isCancelled: false, | |
| generatedCount: 0, | |
| totalCount: payload.segments.length, | |
| setError, | |
| setStep, | |
| setPartialCompletionError, | |
| }); | |
| if (outcome === 'retry') { | |
| console.log('π No videos generated, auto-retrying...'); | |
| updateProgress('Generation failed, automatically retrying...'); | |
| return handleKlingExtendFlow(1); | |
| } | |
| } | |
| } finally { | |
| setIsGenerating(false); | |
| // Clean up any remaining task IDs | |
| state.activeTaskIds.forEach(taskId => removeTaskId(taskId)); | |
| } | |
| }; | |
| // ============================================ | |
| // REPLICATE GENERATION - FRAME CONTINUITY FLOW | |
| // ============================================ | |
| // This mirrors the approach from standalone_video_creator.py: | |
| // 1. Generate prompts using streaming (GPT-5.2) | |
| // 2. For each segment, generate video with current reference image | |
| // 3. Extract last frame from generated video | |
| // 4. Use that frame as reference for next segment | |
| // 5. Result: Perfect visual continuity across all segments | |
| const handleReplicateGeneration = async (attemptCount: number = 0) => { | |
| if (!formState.script.trim()) return; | |
| setIsGenerating(true); | |
| setError(null); | |
| let segmentsToUse: VeoSegment[] = []; // Declare at function scope | |
| let completedVideoCount = 0; // Track locally to avoid stale React state closure | |
| try { | |
| // Step 1: Get segments - skip prompt generation if segments already exist (retry mode) | |
| if (attemptCount > 0 && segments.length > 0) { | |
| // RETRY MODE: Use existing segments | |
| console.log(`β»οΈ RETRY MODE: Using ${segments.length} existing segments (attempt ${attemptCount})`); | |
| segmentsToUse = segments; | |
| setStep('generating_video'); | |
| updateProgress(`Retrying video generation with ${segments.length} cached segments...`, 0, segments.length); | |
| } else { | |
| // NORMAL MODE: Generate prompts using streaming (GPT-5.2) | |
| setStep('generating_prompts'); | |
| updateProgress('Streaming prompt generation with GPT-5.2...', 0, estimatedSegments); | |
| const formData = new FormData(); | |
| formData.append('script', formState.script); | |
| formData.append('style', formState.style || 'clean, lifestyle UGC'); | |
| formData.append('jsonFormat', 'standard'); | |
| formData.append('continuationMode', 'true'); | |
| formData.append('voiceType', formState.voiceType || ''); | |
| formData.append('energyLevel', formState.energyLevel || ''); | |
| formData.append('settingMode', 'single'); | |
| formData.append('cameraStyle', formState.cameraStyle || ''); | |
| formData.append('narrativeStyle', formState.narrativeStyle || ''); | |
| formData.append( | |
| 'videoFlow', | |
| provider === 'kling' ? kieVideoFlow : 'creator_ugc' | |
| ); | |
| // If image provided, include it for GPT analysis | |
| if (imageFile) { | |
| formData.append('image', imageFile); | |
| } else { | |
| const placeholderBlob = new Blob(['placeholder'], { type: 'image/jpeg' }); | |
| formData.append('image', placeholderBlob, 'placeholder.jpg'); | |
| } | |
| formData.append('segment_mode', segmentMode); | |
| formData.append('seconds_per_segment', String(secondsPerSegment)); | |
| if (segmentMode === 'ai_driven') formData.append('allowed_durations', '4,6,8'); | |
| streamedSegmentsRef.current = []; | |
| const promptAbortReplicate = new AbortController(); | |
| registerPromptAbortController(promptAbortReplicate); | |
| try { | |
| const payload = await generatePromptsStreaming( | |
| formData, | |
| (event: StreamEvent) => { | |
| switch (event.event) { | |
| case 'start': | |
| streamedSegmentsRef.current = []; | |
| updateSegments([]); | |
| updateProgress(`Streaming ${event.total_segments} segments...`, 0, event.total_segments); | |
| break; | |
| case 'segment': { | |
| const seg = (event as StreamSegmentEvent).segment; | |
| if (seg) { | |
| streamedSegmentsRef.current = [...streamedSegmentsRef.current, seg]; | |
| updateSegments(streamedSegmentsRef.current); | |
| } | |
| updateProgress(`Generated segment ${event.index + 1}... (${event.progress.toFixed(1)}%)`); | |
| break; | |
| } | |
| case 'complete': | |
| updateProgress('All segments generated! Starting Replicate generation...'); | |
| break; | |
| case 'error': | |
| throw new Error(event.message); | |
| } | |
| }, | |
| { signal: promptAbortReplicate.signal } | |
| ); | |
| if (!payload?.segments?.length) { | |
| throw new Error('No segments generated from script'); | |
| } | |
| segmentsToUse = payload.segments; | |
| updateProgress(`Generated ${segmentsToUse.length} segments. Starting Replicate generation...`); | |
| startGeneration(segmentsToUse); | |
| } finally { | |
| registerPromptAbortController(null); | |
| } | |
| } | |
| // Track current reference image (starts with original if provided) | |
| let currentImageFile = imageFile; | |
| // Step 2: Generate videos segment by segment with frame continuity | |
| for (let i = 0; i < segmentsToUse.length; i++) { | |
| const segment = segmentsToUse[i]; | |
| const isLastSegment = i === segmentsToUse.length - 1; | |
| updateProgress( | |
| `Generating video ${i + 1} of ${segmentsToUse.length} with Replicate...${i > 0 ? ' (using last frame)' : ''}`, | |
| i, | |
| segmentsToUse.length | |
| ); | |
| // Convert structured segment to text prompt for Replicate | |
| // Replicate models typically expect text prompts | |
| const textPrompt = convertSegmentToTextPrompt(segment); | |
| console.log(`π¬ Segment ${i + 1} prompt:`, textPrompt.substring(0, 100) + '...'); | |
| // Upload current reference image if available | |
| let imageUrl: string | undefined; | |
| if (currentImageFile) { | |
| updateProgress(`Uploading reference image for segment ${i + 1}...`); | |
| const uploadResult = await uploadImage(currentImageFile, { reference: true }); | |
| imageUrl = uploadResult.url; | |
| console.log(`πΌοΈ Segment ${i + 1} using image: ${i === 0 ? 'original' : 'last frame from previous'}`); | |
| } | |
| // Generate video with Replicate | |
| updateProgress(`Submitting segment ${i + 1} to Replicate...`); | |
| const generateResult = await replicateGenerate({ | |
| prompt: textPrompt, | |
| imageUrl: imageUrl, | |
| model: formState.model || 'google/veo-3-fast', | |
| aspectRatio: formState.aspectRatio, | |
| }); | |
| // Wait for completion (polling) | |
| updateProgress(`Processing video ${i + 1}... (this may take 2-5 minutes)`); | |
| const videoUrl = await waitForReplicateVideo(generateResult.id); | |
| // Download video | |
| updateProgress(`Downloading video ${i + 1}...`); | |
| const videoBlob = await downloadVideo(videoUrl); | |
| const blobUrl = URL.createObjectURL(videoBlob); | |
| // Get video duration and thumbnails | |
| const videoFile = new File([videoBlob], `segment-${i + 1}.mp4`, { type: 'video/mp4' }); | |
| const duration = await getVideoDuration(videoFile); | |
| const thumbnails = await generateThumbnails(videoFile); | |
| // Use Whisper to find optimal trim point, extract frame, and get transcription | |
| // This is more accurate than extracting the very last frame | |
| let trimPoint = duration; // Default to full duration | |
| let transcribedText = ''; // What Whisper actually heard | |
| if (!isLastSegment) { | |
| updateProgress(`Analyzing video ${i + 1} with Whisper for optimal continuity...`); | |
| try { | |
| // Get dialogue from segment for Whisper analysis | |
| const dialogue = segment.action_timeline?.dialogue || textPrompt; | |
| const whisperResult = await whisperAnalyzeAndExtract({ | |
| video_url: videoUrl, | |
| dialogue: dialogue, | |
| buffer_time: 0.3, | |
| model_size: 'base' | |
| }); | |
| if (whisperResult.success && whisperResult.frame_base64) { | |
| // Convert base64 frame to File for next segment (backend may return PNG or JPEG) | |
| const dataUrl = whisperResult.frame_base64; | |
| const mime = dataUrl.startsWith('data:image/png') ? 'image/png' : 'image/jpeg'; | |
| const ext = mime === 'image/png' ? 'png' : 'jpg'; | |
| const base64Data = dataUrl.split(',')[1] || dataUrl; | |
| const byteCharacters = atob(base64Data); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let j = 0; j < byteCharacters.length; j++) { | |
| byteNumbers[j] = byteCharacters.charCodeAt(j); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| const frameBlob = new Blob([byteArray], { type: mime }); | |
| currentImageFile = new File([frameBlob], `whisper-frame-${i + 1}.${ext}`, { type: mime }); | |
| // Store trim point for later merge | |
| if (whisperResult.trim_point) { | |
| trimPoint = whisperResult.trim_point; | |
| } | |
| // Store transcribed text for prompt refinement | |
| if (whisperResult.transcribed_text) { | |
| transcribedText = whisperResult.transcribed_text; | |
| console.log(`π Whisper transcription: "${transcribedText.substring(0, 100)}..."`); | |
| } | |
| console.log(`β Whisper: Last word at ${whisperResult.last_word_timestamp?.toFixed(2)}s, frame at ${whisperResult.frame_timestamp?.toFixed(2)}s, trim at ${trimPoint.toFixed(2)}s`); | |
| // REFINE NEXT SEGMENT PROMPT with frame + transcription | |
| const nextSegment = segmentsToUse[i + 1]; | |
| if (nextSegment && currentImageFile) { | |
| updateProgress(`Refining segment ${i + 2} prompt with visual and audio context...`); | |
| try { | |
| const { refinePromptWithContext } = await import('@/utils/api'); | |
| const refined = await refinePromptWithContext( | |
| nextSegment, | |
| currentImageFile, | |
| transcribedText, | |
| dialogue | |
| ); | |
| // Update the next segment with refined prompt | |
| segmentsToUse[i + 1] = refined.refined_prompt as typeof nextSegment; | |
| console.log(`β Refined segment ${i + 2} prompt for consistency`); | |
| } catch (refineError) { | |
| console.warn(`β οΈ Prompt refinement failed, using original:`, refineError); | |
| } | |
| } | |
| } else { | |
| // Fallback to simple last frame extraction | |
| console.log(`β οΈ Whisper failed (${whisperResult.error}), falling back to last frame extraction`); | |
| const lastFrameFile = await extractLastFrame(videoBlob); | |
| currentImageFile = lastFrameFile; | |
| } | |
| } catch (frameError) { | |
| console.error(`β οΈ Whisper analysis failed, using fallback:`, frameError); | |
| try { | |
| const lastFrameFile = await extractLastFrame(videoBlob); | |
| currentImageFile = lastFrameFile; | |
| } catch { | |
| // Continue with current image if all extraction fails | |
| } | |
| } | |
| } | |
| // Add to generated videos with trim metadata | |
| const generatedVideo: GeneratedVideo = { | |
| id: `video-${Date.now()}-${i}`, | |
| url: videoUrl, | |
| blobUrl, | |
| segment, | |
| duration, | |
| thumbnails, | |
| trimPoint, // Store trim point for merge | |
| }; | |
| addVideo(generatedVideo); | |
| completedVideoCount++; | |
| updateProgress(`Completed video ${i + 1} of ${segmentsToUse.length}`, i + 1, segmentsToUse.length); | |
| } | |
| // All done! | |
| setStep('completed'); | |
| updateProgress('All videos generated successfully with Replicate!'); | |
| } catch (err) { | |
| console.error('Replicate generation error:', err); | |
| const errorMessage = err instanceof Error ? err.message : 'Replicate generation failed'; | |
| const isUserCancel = (err as Error & { name?: string })?.name === 'AbortError' || errorMessage.includes('cancelled') || isCancelling; | |
| if (isUserCancel) { | |
| setStep('error'); | |
| setIsGenerating(false); | |
| return; | |
| } | |
| // If some videos were generated, show partial completion immediately | |
| if (completedVideoCount > 0) { | |
| console.log(`β οΈ Partial completion: ${completedVideoCount}/${segmentsToUse.length} segments generated`); | |
| setPartialCompletionError( | |
| `Generation stopped at segment ${completedVideoCount + 1} of ${segmentsToUse.length}. ` + | |
| `${completedVideoCount} video${completedVideoCount > 1 ? 's' : ''} generated successfully. ` + | |
| `Reason: ${errorMessage}` | |
| ); | |
| setStep('completed'); | |
| } else { | |
| const outcome = await handleFlowRetry({ | |
| attemptCount, | |
| errorMessage, | |
| isCancelled: false, | |
| generatedCount: 0, | |
| totalCount: segmentsToUse.length, | |
| setError, | |
| setStep, | |
| setPartialCompletionError, | |
| }); | |
| if (outcome === 'retry') { | |
| console.log('π No videos generated, auto-retrying...'); | |
| updateProgress('Generation failed, automatically retrying...'); | |
| return handleReplicateGeneration(1); | |
| } | |
| } | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| // Helper: Convert structured segment JSON to text prompt for Replicate | |
| // Replicate models typically expect plain text, not structured JSON | |
| const convertSegmentToTextPrompt = (segment: VeoSegment): string => { | |
| const parts: string[] = []; | |
| // Extract dialogue | |
| const dialogue = segment.action_timeline?.dialogue; | |
| if (dialogue) { | |
| parts.push(`"${dialogue}"`); | |
| } | |
| // Extract character description | |
| const character = segment.character_description; | |
| if (character?.current_state) { | |
| parts.push(`Character: ${character.current_state}`); | |
| } | |
| // Extract scene description | |
| const scene = segment.scene_continuity; | |
| if (scene?.environment) { | |
| parts.push(`Scene: ${scene.environment}`); | |
| } | |
| if (scene?.lighting_state) { | |
| parts.push(`Lighting: ${scene.lighting_state}`); | |
| } | |
| if (scene?.camera_position) { | |
| parts.push(`Camera: ${scene.camera_position}`); | |
| } | |
| if (scene?.camera_movement) { | |
| parts.push(`Movement: ${scene.camera_movement}`); | |
| } | |
| // Extract synchronized actions | |
| const syncedActions = segment.action_timeline?.synchronized_actions; | |
| if (syncedActions) { | |
| const actionsList = Object.entries(syncedActions) | |
| .filter(([, value]) => value) | |
| .map(([key, value]) => `${key}: ${value}`) | |
| .join('; '); | |
| if (actionsList) { | |
| parts.push(`Actions: ${actionsList}`); | |
| } | |
| } | |
| // Add instruction to not include captions/subtitles | |
| parts.push('Do not include any captions, subtitles, or text overlays in the video'); | |
| return parts.join('. '); | |
| }; | |
| // Main submit handler | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (provider === 'kling') { | |
| if (generationMode === 'frame-continuity') { | |
| handleKlingFrameContinuityFlow(); | |
| } else { | |
| handleKlingExtendFlow(); | |
| } | |
| } else { | |
| handleReplicateGeneration(); | |
| } | |
| }; | |
| // Handler for reuse mode - skip prompt generation, go directly to video generation | |
| const handleReuseGeneration = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!imageFile) { | |
| alert('Please upload a character image to start video generation'); | |
| return; | |
| } | |
| console.log(`π Starting video generation with ${segments.length} pre-loaded segments (REUSE MODE)`); | |
| // Skip prompt generation entirely - go directly to video generation | |
| if (provider === 'kling') { | |
| if (generationMode === 'frame-continuity') { | |
| // Start from the video generation part of the flow | |
| setIsGenerating(true); | |
| setError(null); | |
| startGeneration(segments); | |
| handleKlingFrameContinuityFlow(0); | |
| } else { | |
| setIsGenerating(true); | |
| setError(null); | |
| startGeneration(segments); | |
| handleKlingExtendFlow(0); | |
| } | |
| } else { | |
| setIsGenerating(true); | |
| setError(null); | |
| startGeneration(segments); | |
| handleReplicateGeneration(0); | |
| } | |
| }; | |
| const isValid = provider === 'kling' | |
| ? !!imageFile && (isReuseMode || formState.script.trim().length > 0) | |
| : (isReuseMode || formState.script.trim().length > 0); | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, x: 20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: -20 }} | |
| className="max-w-6xl mx-auto p-8" | |
| > | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-8"> | |
| <div> | |
| <button | |
| onClick={onBack} | |
| className="flex items-center gap-2 text-void-400 hover:text-void-200 transition-colors mb-4" | |
| > | |
| <ArrowLeftIcon size={20} /> | |
| <span>Back to providers</span> | |
| </button> | |
| <h1 className="text-3xl font-display font-bold"> | |
| <span className={provider === 'kling' ? 'gradient-text' : 'gradient-text-electric'}> | |
| {provider === 'kling' ? 'KIE API' : 'Replicate'} | |
| </span> | |
| <span className="text-void-200"> Video Generation</span> | |
| </h1> | |
| <p className="text-void-400 mt-2"> | |
| {isReuseMode ? ( | |
| <span className="flex items-center gap-2"> | |
| <span>β»οΈ Reusing {segments.length} cached segments - Upload image and click Generate to start</span> | |
| </span> | |
| ) : ( | |
| provider === 'kling' | |
| ? ( | |
| kieVideoFlow === 'product_ugc' | |
| ? 'Product-focused UGC on KIE β prompts emphasize demos, product-in-hand, and authentic selfie-style shots' | |
| : 'Generate professional UGC videos with AI-powered segmentation' | |
| ) | |
| : 'Create unique videos with open-source models' | |
| )} | |
| </p> | |
| </div> | |
| </div> | |
| {/* Content Warnings */} | |
| {contentWarnings && showWarnings && !isReuseMode && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-6 p-4 bg-gradient-to-r from-amber-500/10 to-red-500/10 border-2 border-amber-500/30 rounded-xl" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="text-2xl">β οΈ</span> | |
| <div className="flex-1"> | |
| <h3 className="font-bold text-amber-400 mb-2">Content Policy Warnings</h3> | |
| <div className="space-y-2 mb-3"> | |
| {contentWarnings.warnings.map((warning, idx) => ( | |
| <p key={idx} className="text-sm text-void-300">{warning}</p> | |
| ))} | |
| </div> | |
| {contentWarnings.guidance && ( | |
| <details className="mt-3"> | |
| <summary className="text-sm text-amber-300 cursor-pointer hover:text-amber-200"> | |
| View Content Guidelines | |
| </summary> | |
| <div className="mt-2 space-y-2 text-xs text-void-400"> | |
| <div> | |
| <strong className="text-void-300">Avoid Public Figures:</strong> | |
| <ul className="list-disc list-inside ml-2"> | |
| {contentWarnings.guidance.avoid_public_figures.map((item, idx) => ( | |
| <li key={idx}>{item}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| <div> | |
| <strong className="text-void-300">Avoid Copyrighted Content:</strong> | |
| <ul className="list-disc list-inside ml-2"> | |
| {contentWarnings.guidance.avoid_copyrighted.map((item, idx) => ( | |
| <li key={idx}>{item}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| <div> | |
| <strong className="text-void-300">Best Practices:</strong> | |
| <ul className="list-disc list-inside ml-2"> | |
| {contentWarnings.guidance.best_practices.map((item, idx) => ( | |
| <li key={idx}>{item}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| </div> | |
| </details> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => setShowWarnings(false)} | |
| className="text-void-400 hover:text-void-200 transition-colors" | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Reuse Mode Banner */} | |
| {isReuseMode && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-6 p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10 border-2 border-green-500/30 rounded-xl" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <span className="text-2xl">β»οΈ</span> | |
| <div className="flex-1"> | |
| <h3 className="font-bold text-green-400 mb-1">Reusing Cached Prompts</h3> | |
| <p className="text-sm text-void-300 mb-2"> | |
| {segments.length} segments are pre-loaded from your saved prompt. | |
| <strong className="text-green-400"> Prompt generation will be skipped.</strong> | |
| </p> | |
| <p className="text-xs text-void-400"> | |
| Simply upload your character image and click "Generate Video" to start creating videos immediately! | |
| </p> | |
| </div> | |
| <button | |
| onClick={() => { | |
| updateSegments([]); | |
| alert('Cleared cached segments. You can now enter a new script.'); | |
| }} | |
| className="px-3 py-1 text-xs bg-void-800 hover:bg-void-700 text-void-300 rounded-lg transition-colors" | |
| > | |
| Clear & Start Fresh | |
| </button> | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Draft Restored Notification */} | |
| {draftRestored && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -20 }} | |
| className="mb-6 p-4 bg-void-800/80 border border-void-600 rounded-xl flex items-center justify-between" | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> | |
| <div> | |
| <p className="text-sm font-medium text-void-200">Draft restored</p> | |
| <p className="text-xs text-void-400">Your previous inputs have been loaded</p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setDraftRestored(false)} | |
| className="text-void-400 hover:text-void-200 transition-colors" | |
| > | |
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </motion.div> | |
| )} | |
| <form onSubmit={isReuseMode ? handleReuseGeneration : handleSubmit}> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> | |
| {/* Left Column - Script & Style */} | |
| <div className="space-y-6"> | |
| {/* Script Input - Hidden in reuse mode */} | |
| {!isReuseMode && ( | |
| <div className="card"> | |
| <label className="block text-sm font-semibold text-void-200 mb-3"> | |
| Script <span className="text-coral-400">*</span> | |
| </label> | |
| <textarea | |
| name="script" | |
| value={formState.script} | |
| onChange={handleChange} | |
| rows={10} | |
| className="textarea-field font-mono text-sm" | |
| placeholder="Enter your complete video script here... | |
| The AI will automatically analyze and segment your script into optimal video chunks, typically 8 seconds each." | |
| required={!isReuseMode} | |
| /> | |
| <div className="flex items-center justify-between mt-3"> | |
| <p className="text-xs text-void-500"> | |
| AI will automatically segment your script | |
| </p> | |
| {wordCount > 0 && ( | |
| <div className="flex items-center gap-4 text-xs"> | |
| <span className="text-void-400">{wordCount} words</span> | |
| <span className={`font-semibold ${provider === 'kling' ? 'text-coral-400' : 'text-electric-400'}`}> | |
| ~{estimatedSegments} segments | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Reuse Mode: Show segment count + same-image reminder */} | |
| {isReuseMode && ( | |
| <div className="card border-2 border-green-500/30 bg-green-500/5"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h4 className="text-sm font-semibold text-green-400"> | |
| β Segments Loaded from Cache | |
| </h4> | |
| <span className="px-3 py-1 bg-green-500/20 text-green-300 text-xs rounded-full font-bold"> | |
| {segments.length} segments ready | |
| </span> | |
| </div> | |
| <p className="text-sm text-void-300 mb-2"> | |
| Your cached prompts are loaded and ready for video generation. | |
| </p> | |
| <p className="text-xs text-amber-200/90 bg-amber-500/20 border border-amber-500/40 rounded-lg px-3 py-2 mb-2"> | |
| β οΈ Use the <strong>same reference image</strong> you used when creating this prompt for best visual continuity. The segment prompts describe that specific person and scene. | |
| </p> | |
| <p className="text-xs text-void-400"> | |
| Estimated duration: ~{segments.length * 8} seconds | |
| </p> | |
| </div> | |
| )} | |
| {/* Style Input - Hidden in reuse mode */} | |
| {!isReuseMode && ( | |
| <div className="card"> | |
| <label className="block text-sm font-semibold text-void-200 mb-3"> | |
| Visual Style | |
| </label> | |
| <textarea | |
| name="style" | |
| value={formState.style} | |
| onChange={handleChange} | |
| rows={3} | |
| className="textarea-field" | |
| placeholder="e.g., Cinematic, hyper-realistic, natural lighting, modern aesthetic, warm tones..." | |
| /> | |
| </div> | |
| )} | |
| {/* Segment duration: fixed 4/6/8s or AI decides */} | |
| {!isReuseMode && ( | |
| <div className="card border-2 border-void-500/30"> | |
| <label className="block text-sm font-semibold text-void-200 mb-3"> | |
| Segment duration | |
| </label> | |
| <div className="space-y-3"> | |
| <label className="flex items-center gap-3 cursor-pointer"> | |
| <input | |
| type="radio" | |
| name="segmentMode" | |
| value="fixed" | |
| checked={segmentMode === 'fixed'} | |
| onChange={() => setSegmentMode('fixed')} | |
| className="w-4 h-4 accent-coral-500" | |
| /> | |
| <span className="text-sm text-void-200">Fixed</span> | |
| <select | |
| value={secondsPerSegment} | |
| onChange={(e) => setSecondsPerSegment(Number(e.target.value) as 4 | 6 | 8)} | |
| disabled={segmentMode !== 'fixed'} | |
| className="ml-2 px-2 py-1 rounded bg-void-800 border border-void-600 text-void-200 text-sm" | |
| > | |
| <option value={4}>4 sec</option> | |
| <option value={6}>6 sec</option> | |
| <option value={8}>8 sec</option> | |
| </select> | |
| <span className="text-xs text-void-400">per segment</span> | |
| </label> | |
| <label className="flex items-start gap-3 cursor-pointer"> | |
| <input | |
| type="radio" | |
| name="segmentMode" | |
| value="ai_driven" | |
| checked={segmentMode === 'ai_driven'} | |
| onChange={() => setSegmentMode('ai_driven')} | |
| className="mt-0.5 w-4 h-4 accent-coral-500" | |
| /> | |
| <div> | |
| <span className="text-sm font-medium text-void-100">AI decides</span> | |
| <p className="text-xs text-void-400 mt-0.5"> | |
| AI splits the script and chooses 4, 6, or 8 sec per segment for natural pacing. | |
| </p> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {/* KIE: creator vs product UGC prompt format */} | |
| {provider === 'kling' && !isReuseMode && ( | |
| <div className="card border-2 border-amber-500/25 bg-amber-500/5"> | |
| <h4 className="text-sm font-semibold text-void-200 mb-3"> | |
| UGC video format | |
| </h4> | |
| <p className="text-xs text-void-400 mb-3"> | |
| Chooses how GPT structures scenes: classic creator / talking-head vs product-centric UGC (holds, demos, packaging). Same KIE generation modes below. | |
| </p> | |
| <div className="space-y-3"> | |
| <label className="flex items-start gap-3 cursor-pointer group"> | |
| <input | |
| type="radio" | |
| name="kieVideoFlow" | |
| value="creator_ugc" | |
| checked={kieVideoFlow === 'creator_ugc'} | |
| onChange={() => setKieVideoFlow('creator_ugc')} | |
| className="mt-1 w-4 h-4 accent-amber-500" | |
| /> | |
| <div> | |
| <span className="text-sm font-medium text-void-100 group-hover:text-amber-400 transition-colors"> | |
| Creator / talking-head UGC | |
| </span> | |
| <p className="text-xs text-void-400 mt-0.5"> | |
| Emphasis on face, delivery, and lifestyle settingβgeneral-purpose ads and explainers. | |
| </p> | |
| </div> | |
| </label> | |
| <label className="flex items-start gap-3 cursor-pointer group"> | |
| <input | |
| type="radio" | |
| name="kieVideoFlow" | |
| value="product_ugc" | |
| checked={kieVideoFlow === 'product_ugc'} | |
| onChange={() => setKieVideoFlow('product_ugc')} | |
| className="mt-1 w-4 h-4 accent-amber-500" | |
| /> | |
| <div> | |
| <span className="text-sm font-medium text-void-100 group-hover:text-amber-400 transition-colors"> | |
| Product showcase UGC | |
| </span> | |
| <p className="text-xs text-void-400 mt-0.5"> | |
| Prompts force hands-on product beats, selfie framing with the item readable, and props that hero the product (generic labels only). | |
| </p> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {/* Generation Mode Selection (Kling only) */} | |
| {provider === 'kling' && ( | |
| <div className="card border-2 border-coral-500/30 bg-coral-500/5"> | |
| <h4 className="text-sm font-semibold text-void-200 mb-4"> | |
| Generation Mode | |
| </h4> | |
| <div className="space-y-3"> | |
| <label className="flex items-start gap-3 cursor-pointer group"> | |
| <input | |
| type="radio" | |
| name="generationMode" | |
| value="frame-continuity" | |
| checked={generationMode === 'frame-continuity'} | |
| onChange={() => setGenerationMode('frame-continuity')} | |
| className="mt-1 w-4 h-4 accent-coral-500" | |
| /> | |
| <div> | |
| <span className="text-sm font-medium text-void-100 group-hover:text-coral-400 transition-colors"> | |
| π― Frame Continuity (Recommended) | |
| </span> | |
| <p className="text-xs text-void-400 mt-0.5"> | |
| Extract last frame from each video β Use as reference for next segment. | |
| <br /> | |
| <span className="text-coral-400">Best for visual consistency across segments.</span> | |
| </p> | |
| </div> | |
| </label> | |
| <label className="flex items-start gap-3 cursor-pointer group"> | |
| <input | |
| type="radio" | |
| name="generationMode" | |
| value="extend" | |
| checked={generationMode === 'extend'} | |
| onChange={() => setGenerationMode('extend')} | |
| className="mt-1 w-4 h-4 accent-coral-500" | |
| /> | |
| <div> | |
| <span className="text-sm font-medium text-void-100 group-hover:text-coral-400 transition-colors"> | |
| β Extend API | |
| </span> | |
| <p className="text-xs text-void-400 mt-0.5"> | |
| Use KIE's native extend API for video continuation. | |
| <br /> | |
| <span className="text-void-500">Faster but may have less visual consistency.</span> | |
| </p> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| )} | |
| {/* Generation Preview - Only show in normal mode */} | |
| {!isReuseMode && estimatedSegments > 0 && ( | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| className={`card border-2 ${provider === 'kling' ? 'border-coral-500/30 bg-coral-500/5' : 'border-electric-500/30 bg-electric-500/5'}`} | |
| > | |
| <h4 className={`font-bold text-sm mb-3 ${provider === 'kling' ? 'text-coral-400' : 'text-electric-400'}`}> | |
| AI Analysis Preview | |
| </h4> | |
| <div className="grid grid-cols-2 gap-3 text-sm"> | |
| <div className="flex justify-between"> | |
| <span className="text-void-400">Words:</span> | |
| <span className="text-void-200 font-medium">{wordCount}</span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-void-400">Segments:</span> | |
| <span className="text-void-200 font-medium">~{estimatedSegments}</span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-void-400">Duration:</span> | |
| <span className="text-void-200 font-medium">~{estimatedSegments * 8}s</span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-void-400">UGC format:</span> | |
| <span className="text-void-200 font-medium"> | |
| {provider === 'kling' | |
| ? (kieVideoFlow === 'product_ugc' ? 'Product' : 'Creator') | |
| : 'β'} | |
| </span> | |
| </div> | |
| <div className="flex justify-between"> | |
| <span className="text-void-400">KIE mode:</span> | |
| <span className="text-void-200 font-medium"> | |
| {provider === 'kling' | |
| ? (generationMode === 'frame-continuity' ? 'Frame' : 'Extend') | |
| : 'β'} | |
| </span> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </div> | |
| {/* Right Column - Image & Settings */} | |
| <div className="space-y-6"> | |
| {/* Image Upload - Required for Kling, Optional for Replicate */} | |
| <div className="card"> | |
| <label className="block text-sm font-semibold text-void-200 mb-3"> | |
| Character Image {provider === 'kling' && <span className="text-coral-400">*</span>} | |
| {provider === 'replicate' && <span className="text-void-500 text-xs ml-2">(optional, for visual continuity)</span>} | |
| </label> | |
| {provider === 'kling' && kieVideoFlow === 'product_ugc' && ( | |
| <p className="text-xs text-amber-200/90 mb-3"> | |
| For best results, use a reference where you (or the talent) clearly hold or show the product; the model will align prompts to that framing. | |
| </p> | |
| )} | |
| <div | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| className={`dropzone flex flex-col items-center justify-center text-center min-h-[200px] ${isDragging ? 'active' : ''}`} | |
| > | |
| {imagePreview ? ( | |
| <div className="relative"> | |
| <img | |
| src={imagePreview} | |
| alt="Preview" | |
| className="max-h-40 rounded-lg shadow-lg" | |
| /> | |
| <button | |
| type="button" | |
| onClick={() => { setImageFile(null); setImagePreview(null); }} | |
| className={`absolute -top-2 -right-2 w-6 h-6 rounded-full flex items-center justify-center text-white text-xs transition-colors ${ | |
| provider === 'kling' ? 'bg-coral-500 hover:bg-coral-600' : 'bg-electric-500 hover:bg-electric-600' | |
| }`} | |
| > | |
| Γ | |
| </button> | |
| </div> | |
| ) : ( | |
| <> | |
| <ImageIcon className="text-void-500 mb-4" size={48} /> | |
| <p className="text-void-300 mb-2">Drag and drop your image here</p> | |
| <p className="text-void-500 text-sm mb-4">or</p> | |
| <label className={`cursor-pointer ${provider === 'kling' ? 'btn-secondary' : 'btn-secondary-electric'}`}> | |
| <span>Browse Files</span> | |
| <input | |
| type="file" | |
| accept="image/*" | |
| className="hidden" | |
| onChange={(e) => e.target.files?.[0] && handleImageUpload(e.target.files[0])} | |
| /> | |
| </label> | |
| </> | |
| )} | |
| </div> | |
| <p className="text-xs text-void-500 mt-2"> | |
| {provider === 'kling' | |
| ? 'PNG, JPG up to 10MB. This image will be used as your character reference.' | |
| : 'PNG, JPG up to 10MB. Optional: Provides visual continuity across segments.' | |
| } | |
| </p> | |
| </div> | |
| {/* Settings Grid */} | |
| <div className="card"> | |
| <h4 className="text-sm font-semibold text-void-200 mb-4">Generation Settings</h4> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Voice Type</label> | |
| <select | |
| name="voiceType" | |
| value={formState.voiceType} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| {voiceTypes.map(v => <option key={v} value={v}>{v}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Energy Level</label> | |
| <select | |
| name="energyLevel" | |
| value={formState.energyLevel} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| {energyLevels.map(e => <option key={e} value={e}>{e}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Camera Style</label> | |
| <select | |
| name="cameraStyle" | |
| value={formState.cameraStyle} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| {cameraStyles.map(c => <option key={c} value={c}>{c}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Narrative</label> | |
| <select | |
| name="narrativeStyle" | |
| value={formState.narrativeStyle} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| {narrativeStyles.map(n => <option key={n} value={n}>{n}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Aspect Ratio</label> | |
| <select | |
| name="aspectRatio" | |
| value={formState.aspectRatio} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| {aspectRatios.map(a => <option key={a} value={a}>{a}</option>)} | |
| </select> | |
| </div> | |
| {provider === 'kling' ? ( | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Seed Value</label> | |
| <input | |
| type="number" | |
| name="seedValue" | |
| value={formState.seedValue} | |
| onChange={handleChange} | |
| className="input-field" | |
| placeholder="12005" | |
| /> | |
| </div> | |
| ) : ( | |
| <div> | |
| <label className="block text-xs text-void-400 mb-1.5">Model</label> | |
| <select | |
| name="model" | |
| value={formState.model} | |
| onChange={handleChange} | |
| className="select-field" | |
| > | |
| <option value="google/veo-3-fast">Google Veo 3 Fast</option> | |
| </select> | |
| </div> | |
| )} | |
| </div> | |
| <p className="text-xs text-void-500 mt-3"> | |
| {provider === 'kling' | |
| ? 'Seed 12005 = Warm, flattering lighting. Use same seed for consistency.' | |
| : 'Google Veo 3 Fast recommended for best balance of speed and cost.' | |
| } | |
| </p> | |
| </div> | |
| {/* Submit Button */} | |
| <button | |
| type="submit" | |
| disabled={!isValid || isGenerating} | |
| className={` | |
| w-full py-4 font-semibold rounded-xl transition-all duration-300 | |
| flex items-center justify-center gap-3 | |
| ${isReuseMode | |
| ? 'bg-gradient-to-r from-green-500 to-blue-500 hover:from-green-400 hover:to-blue-400 text-white shadow-lg shadow-green-500/30' | |
| : provider === 'kling' ? 'btn-primary' : 'btn-electric' | |
| } | |
| disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100 | |
| `} | |
| > | |
| {isGenerating ? ( | |
| <> | |
| <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> | |
| <span>Generating...</span> | |
| </> | |
| ) : isReuseMode ? ( | |
| <> | |
| <span className="text-xl">π</span> | |
| <span> | |
| Start Video Generation ({segments.length} segments) | |
| </span> | |
| </> | |
| ) : ( | |
| <> | |
| <SparklesIcon size={20} /> | |
| <span> | |
| Generate Video | |
| {provider === 'kling' && generationMode === 'frame-continuity' && ' (Frame Continuity)'} | |
| {provider === 'kling' && generationMode === 'extend' && ' (Extend API)'} | |
| </span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </form> | |
| </motion.div> | |
| ); | |
| }; | |