Video_AdGenesis_App / frontend /src /components /GenerationForm.tsx
sushilideaclan01's picture
added product related changes
fc3f649
Raw History Blame Contribute Delete
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>
);
};