cinema-studio-pro/frontend/src/components/VideoGenTab.jsx
Vadym Samoilenko a2358ba01c fix: correct Kling API params per official docs
- camera_control: only kling-v1 and kling-v1-5 support it (not v3)
- For preset types (down_back etc.), config must be absent — only
  'simple' type uses config fields
- camera_control and image_tail are mutually exclusive in I2V
- cfg_scale not supported by kling-v2.x models — now skipped
- duration sent as string to match API examples
- v3/v3-omni removed from camera control UI list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:38:31 +01:00

2095 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react';
import { Video, Sparkles, Loader2, Download, RefreshCw, Plus, X, Volume2, VolumeX, Info, MessageSquare, FolderOpen, Image, AlertTriangle, ChevronDown, ChevronRight, Maximize2, Minimize2, Upload, Music, SlidersHorizontal } from 'lucide-react';
import VideoPlayer from './VideoPlayer';
import useProjects from '../hooks/useProjects';
const VideoGenTab = ({ activeProjectId, rerunData, onRerunLoaded, onBusyChange, isVisible }) => {
// API URL helper - uses Vite proxy in dev, direct URL in production
const getApiUrl = (endpoint) => {
// In development, use Vite proxy to avoid CORS
if (import.meta.env.DEV) {
return `/api/${endpoint}`;
}
// In production, use full API URL
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5015';
return `${apiUrl}/${endpoint}`;
};
// Video Generation Settings (keep these)
const [sceneDescription, setSceneDescription] = useState('');
const [modelType, setModelType] = useState('fast'); // 'standard' or 'fast'
const [duration, setDuration] = useState(4);
const [aspectRatio, setAspectRatio] = useState('16:9');
const [resolution, setResolution] = useState('720p');
const [generateAudio, setGenerateAudio] = useState(true);
const [referenceImages, setReferenceImages] = useState([]);
const [referenceMode, setReferenceMode] = useState('frame'); // 'frame' (first/last frame) or 'subject' (character reference)
// Engine selection
const [engine, setEngine] = useState('veo'); // 'veo' | 'kling'
// Kling workflow selection
const [klingWorkflow, setKlingWorkflow] = useState('generate'); // 'generate' | 'extend' | 'lipsync'
// Kling Generate settings
const [klingModel, setKlingModel] = useState('kling-v3');
const [klingMode, setKlingMode] = useState('std'); // std | pro
const [klingCfgScale, setKlingCfgScale] = useState(0.5); // 0-1
const [klingSound, setKlingSound] = useState(false);
const [klingCameraControl, setKlingCameraControl] = useState('none');
// Kling Extend settings
const [klingVideoId, setKlingVideoId] = useState(''); // task ID of video to extend
const [klingExtendPrompt, setKlingExtendPrompt] = useState('');
// Kling Lip Sync settings
const [klingLipSyncVideo, setKlingLipSyncVideo] = useState(null); // {videoId, videoUrl, label}
const [klingAudioFile, setKlingAudioFile] = useState(null); // {data, name, mime_type}
// Last generated Kling task ID (for auto-populating Extend workflow)
const [lastKlingTaskId, setLastKlingTaskId] = useState('');
// New simplified controls
const [dialogue, setDialogue] = useState('');
const [optimizedPrompt, setOptimizedPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [showNegativePrompt, setShowNegativePrompt] = useState(false);
// Load rerun data when provided
useEffect(() => {
if (rerunData) {
// Load prompt into optimized prompt field (it's the final prompt that was used)
setOptimizedPrompt(rerunData.prompt || '');
setSceneDescription(''); // Clear scene description since we're using the final prompt
// Load engine
const rerunEngine = rerunData.settings?.engine || 'veo';
setEngine(rerunEngine);
// Load settings
if (rerunData.settings) {
setModelType(rerunData.settings.modelType || 'fast');
setDuration(rerunData.settings.duration || (rerunEngine === 'kling' ? 5 : 4));
setAspectRatio(rerunData.settings.aspectRatio || '16:9');
setResolution(rerunData.settings.resolution || '720p');
setGenerateAudio(rerunData.settings.generateAudio ?? true);
setDialogue(rerunData.settings.dialogue || '');
setReferenceMode(rerunData.settings.referenceMode || 'frame');
if (rerunData.settings.negativePrompt) {
setNegativePrompt(rerunData.settings.negativePrompt);
setShowNegativePrompt(true);
}
// Load Kling-specific settings
if (rerunEngine === 'kling') {
setKlingWorkflow(rerunData.settings.klingWorkflow || 'generate');
setKlingModel(rerunData.settings.klingModel || 'kling-v3');
setKlingMode(rerunData.settings.klingMode || 'std');
setKlingCfgScale(rerunData.settings.klingCfgScale ?? 0.5);
setKlingSound(rerunData.settings.klingSound ?? false);
setKlingCameraControl(rerunData.settings.klingCameraControl || 'none');
}
}
// Load reference images
if (rerunData.referenceImages && rerunData.referenceImages.length > 0) {
const loadedImages = rerunData.referenceImages.map((img, index) => ({
data: img.data,
mime_type: img.mime_type,
name: `Reference ${index + 1}`
}));
setReferenceImages(loadedImages);
} else {
setReferenceImages([]);
}
// Clear the rerun data after loading
if (onRerunLoaded) {
onRerunLoaded();
}
}
}, [rerunData, onRerunLoaded]);
// Generation State
const [isGenerating, setIsGenerating] = useState(false);
const [isOptimizing, setIsOptimizing] = useState(false);
const [generatedVideo, setGeneratedVideo] = useState(null);
const [error, setError] = useState('');
const [generationProgress, setGenerationProgress] = useState(0);
const [videoPreviewExpanded, setVideoPreviewExpanded] = useState(false);
// Projects hook for auto-save and fetching project images
const { addItemToProject, getProjectWithItems, isReady: dbReady } = useProjects();
// Project images/videos state
const [projectImages, setProjectImages] = useState([]);
const [projectVideos, setProjectVideos] = useState([]);
const [showProjectPicker, setShowProjectPicker] = useState(false);
const [showVideoPicker, setShowVideoPicker] = useState(false);
const [refImageAspect, setRefImageAspect] = useState(null); // 'portrait', 'landscape', or 'square'
// Check first reference image aspect ratio
useEffect(() => {
if (referenceImages.length === 0) {
setRefImageAspect(null);
return;
}
const firstImg = referenceImages[0];
const imgElement = document.createElement('img');
imgElement.onload = () => {
const ratio = imgElement.width / imgElement.height;
if (ratio < 0.9) {
setRefImageAspect('portrait');
} else if (ratio > 1.1) {
setRefImageAspect('landscape');
} else {
setRefImageAspect('square');
}
};
imgElement.src = `data:${firstImg.mime_type};base64,${firstImg.data}`;
}, [referenceImages]);
// Check for aspect ratio mismatch
const hasAspectMismatch = refImageAspect && (
(aspectRatio === '16:9' && refImageAspect === 'portrait') ||
(aspectRatio === '9:16' && refImageAspect === 'landscape')
);
// Refresh project items list (images + videos) from IndexedDB
const refreshProjectItems = useCallback(async () => {
if (!activeProjectId || !dbReady) {
setProjectImages([]);
setProjectVideos([]);
return;
}
try {
const project = await getProjectWithItems(activeProjectId);
if (project && project.items) {
setProjectImages(project.items.filter(item => item.type === 'image'));
setProjectVideos(project.items.filter(item => item.type === 'video'));
}
} catch (err) {
console.error('Failed to load project items:', err);
}
}, [activeProjectId, dbReady, getProjectWithItems]);
// Fetch on mount and when project changes
useEffect(() => {
refreshProjectItems();
}, [refreshProjectItems]);
// Refresh project items when tab becomes visible (picks up images/videos added in other tabs)
useEffect(() => {
if (isVisible) {
refreshProjectItems();
}
}, [isVisible, refreshProjectItems]);
// Add image from project to reference images
const addProjectImageToReference = (item) => {
if (referenceImages.length >= 2) return;
// Check if already added
if (referenceImages.some(img => img.projectItemId === item.id)) return;
// Auto-set aspect ratio when adding first reference image
if (referenceImages.length === 0) {
setAspectRatio('16:9');
}
// Auto-set duration to 8s when adding second image (interpolation requires 8s)
if (referenceImages.length === 1) {
setDuration(8);
}
setReferenceImages(prev => [...prev, {
data: item.data,
mime_type: item.mimeType,
name: item.prompt?.substring(0, 20) || 'Project Image',
projectItemId: item.id
}]);
setShowProjectPicker(false);
};
// Normalize duration when switching engines
useEffect(() => {
if (engine === 'kling' && ![5, 10].includes(duration)) setDuration(5);
if (engine === 'veo' && ![4, 6, 8].includes(duration)) setDuration(6);
}, [engine]);
// Engine Options
const engineOptions = [
{ value: 'veo', label: 'Veo', description: 'Google Veo 3.1' },
{ value: 'kling', label: 'Kling', description: 'Kling AI' }
];
const klingWorkflowOptions = [
{ value: 'generate', label: 'Generate' },
{ value: 'extend', label: 'Extend' },
{ value: 'lipsync', label: 'Lip Sync' }
];
const klingModelOptions = [
{ value: 'kling-v3', label: 'V3', description: 'Latest' },
{ value: 'kling-v3-omni', label: 'V3 Omni', description: 'Multi-shot' },
{ value: 'kling-video-o1', label: 'Video O1', description: 'Fast' },
{ value: 'kling-v2-6', label: 'V2.6', description: 'V2 Series' },
{ value: 'kling-v2-5-turbo',label: 'V2.5 Turbo', description: 'Turbo' },
{ value: 'kling-v2-1-master',label: 'V2.1', description: 'Master' },
{ value: 'kling-v1-6', label: 'V1.6', description: 'Legacy' },
{ value: 'kling-v1', label: 'V1', description: 'Cam control' },
];
// camera_control presets only supported by kling-v1 and kling-v1-5 per official capability map
const klingCameraControlModels = ['kling-v1', 'kling-v1-5'];
const cameraPresets = [
{ value: 'none', label: 'None' },
{ value: 'down_back', label: 'Down & Back' },
{ value: 'forward_up', label: 'Forward & Up' },
{ value: 'right_turn_forward', label: 'Right Turn' },
{ value: 'left_turn_forward', label: 'Left Turn' }
];
// Model Options
const modelOptions = [
{ value: 'fast', label: 'Fast', description: 'Faster, lower cost' },
{ value: 'standard', label: 'Standard', description: 'Higher quality' }
];
// Duration Options (costs vary by model and resolution)
// Pricing: Fast $0.15/s (720p/1080p), $0.35/s (4K) | Standard $0.40/s (720p/1080p), $0.60/s (4K)
const getDurationCost = (seconds) => {
const is4K = resolution === '4K';
const costs = {
fast: {
'720p': { 4: '$0.60', 6: '$0.90', 8: '$1.20' },
'1080p': { 4: '$0.60', 6: '$0.90', 8: '$1.20' },
'4K': { 4: '$1.40', 6: '$2.10', 8: '$2.80' }
},
standard: {
'720p': { 4: '$1.60', 6: '$2.40', 8: '$3.20' },
'1080p': { 4: '$1.60', 6: '$2.40', 8: '$3.20' },
'4K': { 4: '$2.40', 6: '$3.60', 8: '$4.80' }
}
};
// Use effective resolution (high-res requires 8s, so show 720p cost for shorter durations)
const effectiveRes = (resolution === '1080p' || resolution === '4K') && seconds !== 8 ? '720p' : resolution;
return costs[modelType][effectiveRes][seconds];
};
const durationOptions = [
{ value: 4, label: '4s' },
{ value: 6, label: '6s' },
{ value: 8, label: '8s' }
];
// Handle Reference Image Upload
const handleReferenceUpload = (e) => {
const files = Array.from(e.target.files);
const remaining = 2 - referenceImages.length;
const filesToAdd = files.slice(0, remaining);
filesToAdd.forEach(file => {
if (!file.type.startsWith('image/')) {
setError(`${file.name} is not an image file`);
return;
}
if (file.size > 10 * 1024 * 1024) {
setError(`${file.name} exceeds 10MB limit`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setReferenceImages(prev => {
const newLength = prev.length + 1;
// Auto-set aspect ratio when adding first reference image
if (prev.length === 0) {
setAspectRatio('16:9');
}
// Auto-set duration to 8s when adding second image (interpolation requires 8s)
if (newLength === 2) {
setDuration(8);
}
return [...prev, {
data: reader.result.split(',')[1],
mime_type: file.type,
name: file.name
}];
});
};
reader.readAsDataURL(file);
});
e.target.value = '';
};
// Remove Reference Image
const removeReferenceImage = (index) => {
setReferenceImages(prev => prev.filter((_, i) => i !== index));
};
// Handle Kling Lip Sync audio upload
const handleAudioUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const validAudioTypes = ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/mp3'];
if (!validAudioTypes.some(t => file.type.includes(t.split('/')[1]))) {
setError('Please upload an audio file (mp3/wav/m4a/aac)');
return;
}
if (file.size > 5 * 1024 * 1024) {
setError('Audio file must be under 5MB');
return;
}
const reader = new FileReader();
reader.onload = () => {
setKlingAudioFile({
data: reader.result.split(',')[1],
mime_type: file.type,
name: file.name
});
};
reader.readAsDataURL(file);
e.target.value = '';
};
// Extract thumbnail from video URL
const extractVideoThumbnail = (videoUrl) => {
return new Promise((resolve) => {
if (!videoUrl) {
resolve(null);
return;
}
const video = document.createElement('video');
video.muted = true;
// Only set crossOrigin for non-data URIs
if (!videoUrl.startsWith('data:')) {
video.crossOrigin = 'anonymous';
}
video.onloadeddata = () => {
// Seek to 0.5 seconds for a better frame than the very first
video.currentTime = 0.5;
};
video.onseeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
// Return just the base64 part
resolve(dataUrl.split(',')[1]);
} catch (err) {
console.error('Failed to extract thumbnail:', err);
resolve(null);
}
};
video.onerror = (err) => {
console.error('Video load error for thumbnail:', err);
resolve(null);
};
// Timeout fallback
setTimeout(() => resolve(null), 5000);
// Convert URL if needed (same logic as VideoPlayer)
let finalUrl = videoUrl;
if (videoUrl.startsWith('/generated_videos/')) {
const filename = videoUrl.replace('/generated_videos/', '');
finalUrl = getApiUrl(`stream_video.php?file=${encodeURIComponent(filename)}`);
console.log(`Thumbnail extraction URL conversion: ${videoUrl}${finalUrl}`);
}
video.src = finalUrl;
video.load();
});
};
// AI Prompt Optimization using Gemini - Veo 3.1 Best Practices
const generateOptimizedVideoPrompt = async () => {
if (!sceneDescription.trim()) {
setError('Please enter a scene description');
return;
}
setIsOptimizing(true);
setError('');
try {
// Determine mode
const hasReference = referenceImages.length > 0;
const hasSecondFrame = referenceMode === 'frame' && referenceImages.length > 1;
const isFirstFrame = referenceMode === 'frame' && hasReference && !hasSecondFrame;
// Build the system prompt — forked by mode (T2V vs I2V) and engine
const isKling = engine === 'kling';
const isI2V = hasReference && !hasSecondFrame;
const isInterpolation = hasSecondFrame;
const isT2V = !hasReference;
// Shared: intent preservation rules (same for all modes and engines)
const intentRules = `1. PRESERVE USER INTENT (sacred, non-negotiable)
- Camera: If user says "static camera" → keep "static" or "locked-off". NEVER add dolly, pan, track, or any movement. If user specifies a move → keep that exact move.
- Action: If user describes what happens → keep it exactly. Do not add, remove, or embellish actions.
- Mood/style: If user sets a tone → keep it. Do not override with "cinematic" defaults.
- If the user's prompt is already clear and complete, return it nearly unchanged. Not every prompt needs heavy editing.`;
// Engine-specific quality anchors (T2V only — I2V gets none since image defines the visual)
const getQualityAnchors = () => {
if (!isT2V) return ''; // I2V and interpolation: image defines the visual, no anchors needed
if (isKling) {
return `\n3. QUALITY ANCHORS (T2V only — weave naturally, only where relevant)
- Person described? Add: "natural skin texture, consistent facial features"
- Hands mentioned/implied? Add: "five distinct fingers"
- Continuous action? Add: "continuous unbroken shot"
- No people? Skip ALL person anchors. A landscape needs zero of these.`;
}
return `\n3. QUALITY ANCHORS (T2V only — weave naturally, only where relevant)
- Person described? Add: "sharp defined features, natural proportions"
- Complex motion? Add: "fluid continuous motion, stable background"
- No people? Skip ALL person anchors. A landscape needs zero of these.`;
};
// Dialogue rules
const getDialogueRule = () => {
if (!dialogue) return '';
const nextNum = isT2V ? '4' : '3';
if (isKling) {
return `\n${nextNum}. DIALOGUE: Embed as [Character, tone]: "${dialogue}"`;
}
return `\n${nextNum}. DIALOGUE: Format as — Character says: "${dialogue}" (no subtitles)`;
};
// Audio rules
const getAudioRule = () => {
if (isKling && !klingSound) return '';
if (!isKling && !generateAudio) return '';
const nextNum = isT2V ? (dialogue ? '5' : '4') : (dialogue ? '4' : '3');
if (isKling) {
return `\n${nextNum}. AUDIO: Weave 1-2 specific sound cues naturally into the description`;
}
return `\n${nextNum}. AUDIO: End with "Audio:" followed by 2-3 specific sounds inferred from the scene`;
};
// Mode-specific job description and clarify rules
const getModeBlock = () => {
if (isInterpolation) {
return {
job: `You have two reference images: a starting frame and an ending frame. The user wants a video that transitions between them.
Your ONLY job: describe the motion/transition between these two frames. Do NOT describe what is in either image — the model already sees them.`,
clarify: `2. CLARIFY (motion and transition only)
- How does the camera move between frames? (arc, push, pull, static?)
- How does the subject change? (turns, walks, expression shifts?)
- If user already specified the transition clearly → change almost nothing.
- Do NOT describe subject appearance, clothing, environment, or lighting — the images handle all of that.`
};
}
if (isI2V) {
return {
job: `You have a reference image that defines the starting frame. The model already sees everything in it — subject, environment, lighting, color, composition.
Your ONLY job: describe what happens NEXT. Motion, camera, action. Nothing about what's already visible.`,
clarify: `2. CLARIFY (motion and action only — do NOT describe the image)
- What moves? The camera, the subject, or both?
- In which direction, at what speed?
- If user already specified the action clearly → change almost nothing. Maybe add timing or direction if missing.
- NEVER describe subject appearance, clothing, environment details, colors, or lighting — the image already defines all of this. Repeating it can cause conflicts.
- If the user wrote "she walks forward" → that's enough. Do not add "a woman with brown hair in a blue dress walks forward through the sunlit room."`
};
}
// T2V
return {
job: `No reference image. The prompt must describe everything the model needs to see: subject, environment, camera, and action.`,
clarify: `2. CLARIFY (fill in gaps the model needs answered)
- WHO: If subject is vague ("a man"), add 2-3 defining physical details.
- WHERE: If environment is vague, add 1-2 concrete details.
- HOW: If no shot type specified, infer the most natural one from context.${isKling ? '' : '\n - Veo weights early words heavily — front-load the camera/shot type.'}
- Do NOT pad with filler adjectives. Every added word must answer a question the model would otherwise guess at.`
};
};
const modeBlock = getModeBlock();
const platformName = isKling ? `Kling AI (${klingModel})` : 'Google Veo 3.1';
const systemPrompt = `You are a video prompt REFINER for ${platformName}.
YOUR JOB: Make the user's prompt clearer for the model — without changing what they asked for.
${modeBlock.job}
USER PROMPT: "${sceneDescription}"
${dialogue ? `DIALOGUE: "${dialogue}"` : ''}${isKling ? `\nSOUND: ${klingSound ? 'ON' : 'OFF'}` : `\nAUDIO: ${generateAudio ? 'ON' : 'OFF'}`}
RULES — strict priority order:
${intentRules}
${modeBlock.clarify}
${getQualityAnchors()}${getDialogueRule()}${getAudioRule()}
OUTPUT: 15-60 words. Natural prose. No labels, headers, or explanations. Just the refined prompt.`;
// Send optimization request to backend — keeps API key server-side
const formData = new FormData();
formData.append('action', 'optimize_prompt');
formData.append('systemPrompt', systemPrompt);
if (hasReference && referenceImages.length > 0) {
formData.append('imageCount', String(referenceImages.length));
referenceImages.forEach((img, index) => {
if (img?.data) {
const label = hasSecondFrame
? (index === 0 ? '[STARTING FRAME - video begins here]:' : '[ENDING FRAME - video ends here]:')
: '[FIRST FRAME - animate from this image]:';
formData.append(`imageLabel_${index}`, label);
formData.append(`imageData_${index}`, img.data.replace(/^data:[^;]+;base64,/, ''));
formData.append(`imageMime_${index}`, img.mime_type || 'image/jpeg');
}
});
}
const res = await fetch(getApiUrl('video_api.php'), { method: 'POST', body: formData });
const data = await res.json();
if (data.rateLimited) {
generateSimpleVideoPrompt();
return;
}
if (!data.success) throw new Error(data.error || 'Optimization failed');
const cleanedText = data.optimizedPrompt
.replace(/^["']|["']$/g, '')
.replace(/^\*\*|\*\*$/g, '')
.replace(/^#+\s*/gm, '')
.replace(/^(Enhanced prompt:|Output:|Prompt:)\s*/i, '')
.trim();
setOptimizedPrompt(cleanedText);
} catch (err) {
console.error('Optimization error:', err);
// 429 rate limit — silently fall back, don't surface as an error
if (!err.message?.includes('429')) {
setError(`Optimization failed: ${err.message}. Using simple prompt.`);
}
generateSimpleVideoPrompt();
} finally {
setIsOptimizing(false);
}
};
// Simple prompt generation (fallback)
const generateSimpleVideoPrompt = () => {
const parts = [];
parts.push(sceneDescription);
if (dialogue) {
if (engine === 'kling') {
// Kling uses character-tagged dialogue inline
parts.push(dialogue);
} else {
parts.push(`Character says: "${dialogue}" (no subtitles).`);
if (generateAudio) {
parts.push('No background music.');
}
}
}
if (engine !== 'kling' && generateAudio && !dialogue) {
parts.push('Audio: ambient sounds appropriate to scene.');
}
setOptimizedPrompt(parts.join(' '));
setIsOptimizing(false);
};
// Poll for video generation status
const pollForStatus = async (operationId) => {
const maxAttempts = 120;
let attempts = 0;
const poll = async () => {
if (attempts >= maxAttempts) {
throw new Error('Video generation timed out. Please try again.');
}
attempts++;
const formData = new FormData();
formData.append('action', 'check_status');
formData.append('operationId', operationId);
const response = await fetch(getApiUrl('video_api.php'), {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Status check failed');
}
if (result.status === 'complete') {
return result.video;
}
if (result.progress) {
setGenerationProgress(result.progress);
} else {
setGenerationProgress(Math.min(95, (attempts / maxAttempts) * 100));
}
await new Promise(resolve => setTimeout(resolve, 5000));
return poll();
};
return poll();
};
// Poll for Kling video generation status
const pollKlingStatus = async (taskId, taskType) => {
const estimatedTotal = klingMode === 'pro' ? 72 : 48;
const maxAttempts = 120;
let attempts = 0;
const poll = async () => {
if (attempts >= maxAttempts) {
throw new Error('Kling video generation timed out. Please try again.');
}
attempts++;
const formData = new FormData();
formData.append('action', 'check_status');
formData.append('taskId', taskId);
formData.append('taskType', taskType);
const response = await fetch(getApiUrl('kling_api.php'), {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Kling status check failed');
}
if (result.status === 'complete') {
return result;
}
// Estimate progress since Kling doesn't return percentage
setGenerationProgress(Math.min(95, (attempts / estimatedTotal) * 100));
await new Promise(resolve => setTimeout(resolve, 5000));
return poll();
};
return poll();
};
// Generate Video (routes to Veo or Kling based on engine)
const generateVideo = async (useSimple = false) => {
setIsGenerating(true);
onBusyChange?.(true);
setError('');
setGenerationProgress(0);
try {
// =====================================================================
// KLING ENGINE
// =====================================================================
if (engine === 'kling') {
let finalVideoUrl, finalFilename, savedTaskId, savedVideoId;
if (klingWorkflow === 'generate') {
// Kling Generate (T2V / I2V)
const prompt = useSimple ? sceneDescription : (optimizedPrompt || sceneDescription);
if (!prompt.trim() && referenceImages.length === 0) {
throw new Error('Please enter a scene description or add a reference image');
}
// Optimize prompt first if needed
if (!optimizedPrompt && !useSimple && sceneDescription.trim()) {
await generateOptimizedVideoPrompt();
}
const finalPrompt = useSimple ? sceneDescription : (optimizedPrompt || sceneDescription);
const formData = new FormData();
formData.append('action', 'generate');
formData.append('prompt', finalPrompt);
formData.append('modelName', klingModel);
formData.append('duration', duration.toString());
formData.append('aspectRatio', aspectRatio);
formData.append('mode', klingMode);
formData.append('cfgScale', klingCfgScale.toString());
formData.append('sound', klingSound ? 'on' : 'off');
if (negativePrompt.trim()) formData.append('negativePrompt', negativePrompt.trim());
formData.append('cameraControl', klingCameraControl);
// Reference images
referenceImages.forEach((img, index) => {
formData.append(`referenceImage_${index}`, img.data);
formData.append(`referenceImageType_${index}`, img.mime_type);
});
formData.append('referenceImageCount', referenceImages.length.toString());
const response = await fetch(getApiUrl('kling_api.php'), { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Kling generation failed');
setGenerationProgress(5);
const statusResult = await pollKlingStatus(result.taskId, result.taskType);
finalVideoUrl = statusResult.video.url;
finalFilename = statusResult.video.filename;
savedTaskId = statusResult.taskId || result.taskId;
savedVideoId = statusResult.video.videoId;
} else if (klingWorkflow === 'extend') {
// Kling Extend
if (!klingVideoId.trim()) {
throw new Error('Please enter a Kling Video ID to extend');
}
const formData = new FormData();
formData.append('action', 'extend');
formData.append('videoId', klingVideoId.trim());
if (klingExtendPrompt.trim()) formData.append('prompt', klingExtendPrompt.trim());
formData.append('cfgScale', klingCfgScale.toString());
if (negativePrompt.trim()) formData.append('negativePrompt', negativePrompt.trim());
const response = await fetch(getApiUrl('kling_api.php'), { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Kling extension failed');
setGenerationProgress(5);
const statusResult = await pollKlingStatus(result.taskId, result.taskType);
finalVideoUrl = statusResult.video.url;
finalFilename = statusResult.video.filename;
savedTaskId = statusResult.taskId || result.taskId;
savedVideoId = statusResult.video.videoId;
} else if (klingWorkflow === 'lipsync') {
// Kling Lip Sync
if (!klingLipSyncVideo) throw new Error('Please select a source video');
if (!klingAudioFile) throw new Error('Please upload an audio file');
const formData = new FormData();
formData.append('action', 'lipsync');
if (klingLipSyncVideo.videoId) {
formData.append('videoId', klingLipSyncVideo.videoId);
} else if (klingLipSyncVideo.videoUrl) {
formData.append('videoUrl', klingLipSyncVideo.videoUrl);
}
formData.append('audioFile', klingAudioFile.data);
const response = await fetch(getApiUrl('kling_api.php'), { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Kling lip sync failed');
setGenerationProgress(5);
const statusResult = await pollKlingStatus(result.taskId, result.taskType);
finalVideoUrl = statusResult.video.url;
finalFilename = statusResult.video.filename;
savedTaskId = statusResult.taskId || result.taskId;
savedVideoId = statusResult.video.videoId;
}
setGenerationProgress(100);
// Validate and display
if (!finalVideoUrl || (!finalVideoUrl.startsWith('/') && !finalVideoUrl.startsWith('data:') && !finalVideoUrl.startsWith('http'))) {
throw new Error('Kling did not return a valid video URL');
}
setGeneratedVideo({ url: finalVideoUrl, mime_type: 'video/mp4', filename: finalFilename });
// Save IDs for Extend/LipSync workflows
if (savedTaskId) {
setLastKlingTaskId(savedTaskId);
if (klingWorkflow === 'generate') {
// Use actual video ID (not task ID) for extend/lipsync APIs
setKlingVideoId(savedVideoId || savedTaskId);
}
}
// Auto-save to project
if (activeProjectId) {
try {
const thumbnail = await extractVideoThumbnail(finalVideoUrl);
const promptUsed = klingWorkflow === 'generate'
? (optimizedPrompt || sceneDescription)
: klingWorkflow === 'extend'
? klingExtendPrompt
: `Lip sync on ${klingLipSyncVideo?.label || 'video'}`;
await addItemToProject(activeProjectId, {
type: 'video',
prompt: promptUsed,
settings: {
engine: 'kling',
klingWorkflow, klingModel, klingMode, klingCfgScale, klingSound, klingCameraControl, dialogue,
duration, aspectRatio, negativePrompt,
klingTaskId: savedTaskId,
klingVideoId: savedVideoId
},
referenceImages: referenceImages.map(img => ({ data: img.data, mime_type: img.mime_type })),
thumbnail,
data: finalVideoUrl,
mimeType: 'video/mp4'
});
refreshProjectItems();
} catch (saveErr) {
console.error('Failed to save Kling video to project:', saveErr);
}
}
} else {
// ===================================================================
// VEO ENGINE (existing flow)
// ===================================================================
// If no optimized prompt yet, generate one first
if (!optimizedPrompt && !useSimple) {
await generateOptimizedVideoPrompt();
}
const prompt = useSimple ? sceneDescription : (optimizedPrompt || sceneDescription);
if (!prompt.trim()) {
throw new Error('Please enter a scene description');
}
const formData = new FormData();
formData.append('action', 'generate');
formData.append('prompt', prompt);
formData.append('modelType', modelType);
formData.append('duration', duration.toString());
formData.append('aspectRatio', aspectRatio);
formData.append('resolution', resolution);
formData.append('generateAudio', generateAudio.toString());
if (negativePrompt.trim()) {
formData.append('negativePrompt', negativePrompt.trim());
}
// Add reference images and mode
formData.append('referenceMode', referenceMode);
referenceImages.forEach((img, index) => {
formData.append(`referenceImage_${index}`, img.data);
formData.append(`referenceImageType_${index}`, img.mime_type);
});
formData.append('referenceImageCount', referenceImages.length.toString());
const response = await fetch(getApiUrl('video_api.php'), {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Video generation failed');
}
let videoResult;
if (result.status === 'pending' && result.operationId) {
setGenerationProgress(5);
videoResult = await pollForStatus(result.operationId);
} else if (result.status === 'complete') {
videoResult = result.video;
} else {
throw new Error('Unexpected response format');
}
setGenerationProgress(95);
// If the URL is from Google API, download through our proxy
let finalVideoUrl = videoResult.url;
let finalFilename = videoResult.filename;
if (videoResult.url && videoResult.url.includes('generativelanguage.googleapis.com')) {
const downloadFormData = new FormData();
downloadFormData.append('action', 'download_video');
downloadFormData.append('videoUrl', videoResult.url);
const downloadResponse = await fetch(getApiUrl('video_api.php'), {
method: 'POST',
body: downloadFormData
});
const downloadResult = await downloadResponse.json();
if (downloadResult.success && downloadResult.video) {
finalVideoUrl = downloadResult.video.url;
finalFilename = downloadResult.video.filename;
} else {
throw new Error(downloadResult.error || 'Failed to download video');
}
}
setGenerationProgress(100);
if (!finalVideoUrl) {
throw new Error('Backend did not return a valid video URL');
}
if (!finalVideoUrl.startsWith('/generated_videos/') && !finalVideoUrl.startsWith('data:') && !finalVideoUrl.startsWith('http')) {
throw new Error(`Invalid video URL format: ${finalVideoUrl}`);
}
setGeneratedVideo({
url: finalVideoUrl,
mime_type: videoResult.mime_type || 'video/mp4',
filename: finalFilename
});
// Auto-save to project if active
if (activeProjectId) {
try {
const thumbnail = await extractVideoThumbnail(finalVideoUrl);
await addItemToProject(activeProjectId, {
type: 'video',
prompt: prompt,
settings: { engine: 'veo', modelType, duration, aspectRatio, resolution, generateAudio, dialogue, referenceMode, negativePrompt },
referenceImages: referenceImages.map(img => ({ data: img.data, mime_type: img.mime_type })),
thumbnail,
data: finalVideoUrl,
mimeType: 'video/mp4'
});
refreshProjectItems();
} catch (saveErr) {
console.error('Failed to save to project:', saveErr);
}
}
}
} catch (err) {
setError(`Generation failed: ${err.message}`);
} finally {
setIsGenerating(false);
onBusyChange?.(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Settings & Inputs */}
<div className="lg:col-span-4 space-y-6">
{/* Video Settings */}
<div className="bg-slate-925 rounded p-6 space-y-6">
<h2 className="text-lg font-normal text-slate-200 flex items-center space-x-2">
<Video className="w-5 h-5 text-cinema-gold" />
<span>Video Settings</span>
</h2>
{/* Engine Selector */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Engine
</label>
<div className="flex gap-2">
{engineOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setEngine(opt.value)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
engine === opt.value
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
<div>{opt.label}</div>
<div className="text-xs opacity-70">{opt.description}</div>
</button>
))}
</div>
</div>
{/* ============================================================ */}
{/* VEO SETTINGS */}
{/* ============================================================ */}
{engine === 'veo' && (
<>
{/* Model */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Model
</label>
<div className="flex gap-2">
{modelOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setModelType(opt.value)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
modelType === opt.value
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
<div>{opt.label}</div>
<div className="text-xs opacity-70">{opt.description}</div>
</button>
))}
</div>
</div>
{/* Duration */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Duration
</label>
<div className="flex gap-2">
{durationOptions.map((opt) => {
const isDisabledByInterpolation = referenceImages.length === 2 && opt.value !== 8;
return (
<button
key={opt.value}
onClick={() => !isDisabledByInterpolation && setDuration(opt.value)}
disabled={isDisabledByInterpolation}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
duration === opt.value
? 'bg-cinema-gold text-slate-950'
: isDisabledByInterpolation
? 'bg-slate-800 text-slate-600 cursor-not-allowed'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
<div>{opt.label}</div>
<div className="text-xs font-mono opacity-70">{getDurationCost(opt.value)}</div>
</button>
);
})}
</div>
{referenceImages.length === 2 && (
<p className="text-xs text-slate-500">
First + Last frame interpolation requires 8-second duration
</p>
)}
</div>
{/* Aspect Ratio */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Aspect Ratio
</label>
<div className="flex gap-2">
{['16:9', '9:16'].map((ratio) => (
<button
key={ratio}
onClick={() => setAspectRatio(ratio)}
className={`flex-1 px-4 py-2 rounded font-normal transition-all ${
aspectRatio === ratio
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{ratio}
</button>
))}
</div>
</div>
{/* Resolution */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Resolution
</label>
<div className="flex gap-2">
{['720p', '1080p', '4K'].map((res) => {
const isHighResDisabled = (res === '1080p' || res === '4K') && duration !== 8;
return (
<button
key={res}
onClick={() => !isHighResDisabled && setResolution(res)}
disabled={isHighResDisabled}
className={`flex-1 px-4 py-2 rounded font-normal transition-all ${
resolution === res
? 'bg-cinema-gold text-slate-950'
: isHighResDisabled
? 'bg-slate-800 text-slate-600 cursor-not-allowed'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
title={isHighResDisabled ? `${res} requires 8s duration` : res === '4K' ? 'Native 4K - higher cost' : ''}
>
{res}
</button>
);
})}
</div>
{(resolution === '1080p' || resolution === '4K') && duration !== 8 && (
<p className="text-xs text-amber-500">{resolution} requires 8s duration - will fallback to 720p</p>
)}
{resolution === '4K' && duration === 8 && (
<p className="text-xs text-amber-500">4K has significantly higher API cost</p>
)}
</div>
</>
)}
{/* ============================================================ */}
{/* KLING SETTINGS */}
{/* ============================================================ */}
{engine === 'kling' && (
<>
{/* Kling Workflow Selector */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Workflow
</label>
<div className="flex gap-1.5">
{klingWorkflowOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setKlingWorkflow(opt.value)}
className={`flex-1 px-2 py-1.5 rounded font-normal transition-all text-sm ${
klingWorkflow === opt.value
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* -------------------------------------------------------- */}
{/* KLING GENERATE SETTINGS */}
{/* -------------------------------------------------------- */}
{klingWorkflow === 'generate' && (
<>
{/* Kling Model */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Model</label>
<div className="grid grid-cols-2 gap-2">
{klingModelOptions.map((opt) => (
<button
key={opt.value}
onClick={() => {
setKlingModel(opt.value);
// Turn off sound if switching to a model without native audio
if (!['kling-v3', 'kling-v3-omni', 'kling-v2-6'].includes(opt.value)) {
setKlingSound(false);
}
// Clear camera control if switching to a model that doesn't support it
if (!klingCameraControlModels.includes(opt.value)) {
setKlingCameraControl('none');
}
// V2.1 Master only supports pro mode
if (opt.value === 'kling-v2-1-master') {
setKlingMode('pro');
}
}}
className={`px-2 py-1.5 rounded font-normal transition-all text-sm ${
klingModel === opt.value
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
<div>{opt.label}</div>
<div className="text-xs opacity-70">{opt.description}</div>
</button>
))}
</div>
</div>
{/* Duration */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Duration</label>
<div className="flex gap-2">
{[5, 10].map((d) => (
<button
key={d}
onClick={() => setDuration(d)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
duration === d
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{d}s
</button>
))}
</div>
</div>
{/* Aspect Ratio */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Aspect Ratio</label>
<div className="flex gap-2">
{['16:9', '9:16', '1:1'].map((ratio) => (
<button
key={ratio}
onClick={() => setAspectRatio(ratio)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
aspectRatio === ratio
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{ratio}
</button>
))}
</div>
</div>
{/* Mode (Std/Pro) — controls resolution: std=720p, pro=1080p */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Resolution</label>
<div className="flex gap-2">
{[{ value: 'std', label: '720p', sub: 'Standard' }, { value: 'pro', label: '1080p', sub: 'Pro' }].map((opt) => {
// v2.1 Master only supports pro mode
const forceProOnly = klingModel === 'kling-v2-1-master';
const disabled = forceProOnly && opt.value === 'std';
return (
<button
key={opt.value}
onClick={() => !disabled && setKlingMode(opt.value)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
klingMode === opt.value
? 'bg-cinema-gold text-slate-950'
: disabled
? 'bg-slate-900 text-slate-600 cursor-not-allowed'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
title={disabled ? 'V2.1 Master only supports Pro mode' : `${opt.sub} mode (${opt.label})`}
>
{opt.label}
</button>
);
})}
</div>
</div>
{/* CFG Scale Slider */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>CFG Scale</span>
<span className="text-cinema-gold font-mono">{klingCfgScale.toFixed(1)}</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={klingCfgScale}
onChange={(e) => setKlingCfgScale(parseFloat(e.target.value))}
className="w-full h-2 bg-slate-800 rounded appearance-none cursor-pointer accent-cinema-gold"
/>
<div className="flex justify-between text-[10px] text-slate-600">
<span>Creative</span>
<span>Faithful</span>
</div>
</div>
{/* Camera Control — only available on v1, v1-5, v3, v3-omni */}
{klingCameraControlModels.includes(klingModel) && <div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Camera Control</label>
<div className="grid grid-cols-3 gap-1.5">
{cameraPresets.map((preset) => (
<button
key={preset.value}
onClick={() => setKlingCameraControl(preset.value)}
className={`px-2 py-1.5 rounded font-normal transition-all text-xs ${
klingCameraControl === preset.value
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{preset.label}
</button>
))}
</div>
</div>}
</>
)}
{/* -------------------------------------------------------- */}
{/* KLING EXTEND SETTINGS */}
{/* -------------------------------------------------------- */}
{klingWorkflow === 'extend' && (
<>
{/* Source Video */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Source Video
</label>
{klingVideoId ? (
<div className="flex items-center gap-3 p-3 bg-slate-800 rounded border border-cinema-gold">
<Video className="w-4 h-4 text-cinema-gold flex-shrink-0" />
<span className="text-sm text-slate-300 truncate flex-1 font-mono">{klingVideoId.substring(0, 20)}...</span>
<button
onClick={() => setKlingVideoId('')}
className="text-slate-500 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="space-y-2">
{(() => {
const klingVids = projectVideos.filter(v => v.settings?.engine === 'kling' && (v.settings?.klingVideoId || v.settings?.klingTaskId));
return klingVids.length > 0 ? (
<>
<button
onClick={() => setShowVideoPicker(!showVideoPicker)}
className={`w-full py-3 flex items-center justify-center gap-2 border-2 border-dashed rounded transition-colors ${
showVideoPicker
? 'border-cinema-gold bg-cinema-gold/10 text-cinema-gold'
: 'border-slate-700 hover:border-cinema-gold text-slate-400 hover:text-slate-300'
}`}
>
<FolderOpen className="w-4 h-4" />
<span className="text-sm">Choose from Library</span>
</button>
{showVideoPicker && (
<div className="bg-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-normal text-slate-400 uppercase tracking-wider">Kling Videos</span>
<button
onClick={() => setShowVideoPicker(false)}
className="text-slate-500 hover:text-slate-300"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{klingVids.map((item) => (
<button
key={item.id}
onClick={() => {
setKlingVideoId(item.settings.klingVideoId || item.settings.klingTaskId);
setShowVideoPicker(false);
}}
className="relative aspect-video rounded overflow-hidden border-2 border-transparent hover:border-cinema-gold transition-all"
>
{item.thumbnail ? (
<img
src={item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/jpeg;base64,${item.thumbnail}`}
alt={item.prompt || 'Kling video'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-slate-700 flex items-center justify-center">
<Video className="w-5 h-5 text-slate-500" />
</div>
)}
<span className="absolute bottom-0 left-0 right-0 bg-black/70 text-[9px] text-center text-slate-300 py-0.5 truncate px-1">
{item.prompt ? item.prompt.substring(0, 30) : (item.settings.klingVideoId || item.settings.klingTaskId).substring(0, 12)}
</span>
</button>
))}
</div>
</div>
)}
</>
) : (
<div className="w-full py-3 flex flex-col items-center justify-center border-2 border-dashed border-slate-700 rounded text-slate-500">
<Video className="w-5 h-5 mb-1" />
<span className="text-xs">Generate a Kling video first</span>
</div>
);
})()}
</div>
)}
</div>
{/* Extension Prompt */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Extension Prompt (Optional)
</label>
<textarea
value={klingExtendPrompt}
onChange={(e) => setKlingExtendPrompt(e.target.value)}
placeholder="Guide the extension direction..."
className="w-full h-20 px-4 py-3 bg-slate-950 border border-slate-800 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none resize-none text-sm"
/>
</div>
{/* CFG Scale */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center justify-between">
<span>CFG Scale</span>
<span className="text-cinema-gold font-mono">{klingCfgScale.toFixed(1)}</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={klingCfgScale}
onChange={(e) => setKlingCfgScale(parseFloat(e.target.value))}
className="w-full h-2 bg-slate-800 rounded appearance-none cursor-pointer accent-cinema-gold"
/>
</div>
<p className="text-xs text-slate-500">
Extends by 4-5s per call. Max total 3 minutes.
</p>
</>
)}
{/* -------------------------------------------------------- */}
{/* KLING LIP SYNC SETTINGS */}
{/* -------------------------------------------------------- */}
{klingWorkflow === 'lipsync' && (
<>
{/* Source Video */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Source Video
</label>
{/* Selected video display */}
{klingLipSyncVideo ? (
<div className="flex items-center gap-3 p-3 bg-slate-800 rounded border border-cinema-gold">
<Video className="w-4 h-4 text-cinema-gold flex-shrink-0" />
<span className="text-sm text-slate-300 truncate flex-1">{klingLipSyncVideo.label}</span>
<button
onClick={() => setKlingLipSyncVideo(null)}
className="text-slate-500 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="space-y-2">
{projectVideos.length > 0 ? (
<>
<button
onClick={() => setShowVideoPicker(!showVideoPicker)}
className={`w-full py-3 flex items-center justify-center gap-2 border-2 border-dashed rounded transition-colors ${
showVideoPicker
? 'border-cinema-gold bg-cinema-gold/10 text-cinema-gold'
: 'border-slate-700 hover:border-cinema-gold text-slate-400 hover:text-slate-300'
}`}
>
<FolderOpen className="w-4 h-4" />
<span className="text-sm">Choose from Library</span>
</button>
{showVideoPicker && (
<div className="bg-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-normal text-slate-400 uppercase tracking-wider">Project Videos</span>
<button
onClick={() => setShowVideoPicker(false)}
className="text-slate-500 hover:text-slate-300"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{projectVideos.map((item) => {
const isKling = item.settings?.engine === 'kling' && (item.settings?.klingVideoId || item.settings?.klingTaskId);
// In production, relative URLs like /generated_videos/... are publicly reachable
// In dev (localhost), only Kling video_id works since Kling can't reach localhost
const isDev = import.meta.env.DEV;
const hasUsableUrl = item.data && (item.data.startsWith('http') || item.data.startsWith('/generated_videos/'));
if (!isKling && (!hasUsableUrl || isDev)) return null;
return (
<button
key={item.id}
onClick={() => {
if (isKling) {
const vidId = item.settings.klingVideoId || item.settings.klingTaskId;
setKlingLipSyncVideo({
videoId: vidId,
label: item.prompt ? item.prompt.substring(0, 40) : `Kling ${vidId.substring(0, 12)}`
});
} else {
// Build full URL for non-Kling videos so Kling API can reach it
let fullUrl = item.data;
if (fullUrl.startsWith('/')) {
const apiBase = import.meta.env.VITE_API_URL || window.location.origin;
fullUrl = apiBase + fullUrl;
}
setKlingLipSyncVideo({
videoUrl: fullUrl,
label: item.prompt ? item.prompt.substring(0, 40) : 'Project video'
});
}
setShowVideoPicker(false);
}}
className="relative aspect-video rounded overflow-hidden border-2 border-transparent hover:border-cinema-gold transition-all"
>
{item.thumbnail ? (
<img
src={item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/jpeg;base64,${item.thumbnail}`}
alt={item.prompt || 'Video'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-slate-700 flex items-center justify-center">
<Video className="w-5 h-5 text-slate-500" />
</div>
)}
<span className="absolute bottom-0 left-0 right-0 bg-black/70 text-[9px] text-center text-slate-300 py-0.5 truncate px-1">
{item.prompt ? item.prompt.substring(0, 30) : (isKling ? 'Kling' : 'Veo')}
</span>
</button>
);
})}
</div>
</div>
)}
</>
) : (
<div className="w-full py-3 flex flex-col items-center justify-center border-2 border-dashed border-slate-700 rounded text-slate-500">
<Video className="w-5 h-5 mb-1" />
<span className="text-xs">Generate a video first</span>
</div>
)}
</div>
)}
<p className="text-[10px] text-slate-600">
210s video with a clear frontal face
</p>
</div>
{/* Audio File Upload */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Audio File
</label>
{klingAudioFile ? (
<div className="flex items-center gap-2 p-3 bg-slate-800 rounded border border-slate-700">
<Music className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="text-sm text-slate-300 truncate flex-1">{klingAudioFile.name}</span>
<button
onClick={() => setKlingAudioFile(null)}
className="text-slate-500 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<label className="w-full h-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-700 hover:border-cinema-gold rounded cursor-pointer transition-colors">
<Music className="w-5 h-5 text-slate-500 mb-1" />
<span className="text-xs text-slate-500">Upload audio (mp3/wav/m4a/aac, max 5MB)</span>
<input
type="file"
accept="audio/mpeg,audio/wav,audio/mp4,audio/x-m4a,audio/aac,audio/mp3"
onChange={handleAudioUpload}
className="hidden"
/>
</label>
)}
</div>
</>
)}
</>
)}
</div>
{/* Scene Description & Inputs — shown for Veo always, and Kling Generate */}
{(engine === 'veo' || (engine === 'kling' && klingWorkflow === 'generate')) && (
<div className="bg-slate-925 rounded px-6 pt-3 pb-6 space-y-4">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Scene Description {engine === 'veo' || referenceImages.length === 0 ? <span className="text-cinema-gold">*</span> : null}
</label>
<textarea
value={sceneDescription}
onChange={(e) => {
setSceneDescription(e.target.value);
setOptimizedPrompt('');
setNegativePrompt('');
}}
placeholder='Describe your scene...'
className="w-full h-24 px-4 py-3 bg-slate-950 border border-slate-800 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none resize-none text-sm"
/>
{/* Dialogue (optional) — Veo only here; Kling dialogue lives below Sound toggle */}
{engine === 'veo' && (
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center gap-2">
<MessageSquare className="w-3 h-3" />
Dialogue (optional)
</label>
<input
type="text"
value={dialogue}
onChange={(e) => {
setDialogue(e.target.value);
setOptimizedPrompt('');
setNegativePrompt('');
}}
placeholder='"Hold the line!"'
className="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
/>
</div>
)}
{/* Optimize Button */}
<div className="pt-2">
<button
onClick={generateOptimizedVideoPrompt}
disabled={isOptimizing || isGenerating || !sceneDescription.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 text-white font-normal rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isOptimizing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Optimizing...</span>
</>
) : (
<>
<Sparkles className="w-5 h-5" />
<span>Optimize</span>
</>
)}
</button>
</div>
{/* Kling Sound + Dialogue — below optimize, only for Kling generate with audio-capable models */}
{engine === 'kling' && klingWorkflow === 'generate' && ['kling-v3', 'kling-v3-omni', 'kling-v2-6'].includes(klingModel) && (
<div className="space-y-3 pt-2 border-t border-slate-800">
<button
onClick={() => {
const newSound = !klingSound;
setKlingSound(newSound);
// V2.6 requires pro mode when audio is enabled
if (newSound && klingModel === 'kling-v2-6') {
setKlingMode('pro');
}
}}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded font-normal transition-all text-sm ${
klingSound
? 'bg-green-900/50 text-green-400 border border-green-800'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}
>
<span className="flex items-center gap-2">
{klingSound ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
{klingSound ? 'Sound Enabled' : 'Sound Disabled'}
</span>
<span className="text-xs opacity-70">+~35% cost</span>
</button>
{klingSound && (
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center gap-2">
<MessageSquare className="w-3 h-3" />
Dialogue (optional)
</label>
<input
type="text"
value={dialogue}
onChange={(e) => {
setDialogue(e.target.value);
setOptimizedPrompt('');
setNegativePrompt('');
}}
placeholder='[Character, firmly]: "Hold the line!"'
className="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
/>
<p className="text-[10px] text-slate-600">
Use [Character, tone]: "line" format. Merged into prompt for Kling's native speech.
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Kling Extend / Lip Sync — Direct Generate Button (no scene description needed) */}
{engine === 'kling' && klingWorkflow !== 'generate' && (
<div className="bg-slate-925 rounded p-4">
<button
onClick={() => generateVideo(true)}
disabled={isGenerating || (klingWorkflow === 'extend' && !klingVideoId.trim()) || (klingWorkflow === 'lipsync' && (!klingLipSyncVideo || !klingAudioFile))}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-normal rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Generating... {Math.round(generationProgress)}%</span>
</>
) : (
<>
<Video className="w-5 h-5" />
<span>{klingWorkflow === 'extend' ? 'Extend Video' : 'Generate Lip Sync'}</span>
</>
)}
</button>
</div>
)}
</div>
{/* Right Column: Prompt Preview & Video */}
<div className="lg:col-span-8 space-y-6">
{/* Optimized Prompt Preview */}
<div className="relative bg-slate-925 rounded overflow-hidden">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Optimized Prompt
</h3>
{optimizedPrompt && (
<span className="text-[10px] text-slate-500">
Terminology corrected - edit as needed
</span>
)}
</div>
<textarea
value={optimizedPrompt}
onChange={(e) => setOptimizedPrompt(e.target.value)}
placeholder="Your optimized prompt will appear here. You can also type or edit directly."
className="w-full min-h-[80px] bg-transparent text-slate-300 text-sm font-mono leading-relaxed resize-none border-0 focus:outline-none focus:ring-0 placeholder:text-slate-500 placeholder:italic"
/>
{/* Negative Prompt (collapsible) */}
<div className="pt-3">
<button
onClick={() => setShowNegativePrompt(!showNegativePrompt)}
className="flex items-center gap-1.5 text-xs font-normal text-slate-400 hover:text-slate-300 transition-colors"
>
{showNegativePrompt ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Negative Prompt (optional)
</button>
{showNegativePrompt && (
<textarea
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
placeholder="Comma-separated terms to avoid: blurry, distorted, watermark..."
className="w-full mt-2 min-h-[50px] bg-slate-950/50 text-slate-400 text-xs font-mono leading-relaxed resize-none border border-slate-800 rounded px-3 py-2 focus:outline-none focus:border-cinema-gold placeholder:text-slate-600 placeholder:italic"
/>
)}
</div>
{/* Generate Video Button */}
{optimizedPrompt && (
<button
onClick={() => generateVideo(false)}
disabled={isGenerating}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-normal rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Generating... {Math.round(generationProgress)}%</span>
</>
) : (
<>
<Video className="w-5 h-5" />
<span>Generate Video</span>
</>
)}
</button>
)}
</div>
</div>
<div className="border-t border-slate-800" />
{/* Reference Frames Section — shown for Veo and Kling Generate */}
{(engine === 'veo' || (engine === 'kling' && klingWorkflow === 'generate')) && (
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="flex items-center justify-between">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Reference Frames (Optional)
</label>
<span className="text-xs text-slate-500">
{referenceImages.length}/2
</span>
</div>
{/* Buttons Row */}
<div className="flex gap-2">
{projectImages.length > 0 && (
<button
onClick={() => setShowProjectPicker(!showProjectPicker)}
className={`w-16 h-16 flex flex-col items-center justify-center border-2 border-dashed rounded transition-colors ${
showProjectPicker
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-cinema-gold'
}`}
title="Select from project"
>
<FolderOpen className="w-4 h-4 text-cinema-gold" />
<span className="text-[9px] text-slate-400 mt-0.5">Project</span>
</button>
)}
<label className={`w-16 h-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-700 hover:border-slate-600 rounded cursor-pointer transition-colors ${referenceImages.length >= 2 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<Plus className="w-4 h-4 text-slate-500" />
<span className="text-[9px] text-slate-500 mt-0.5">Upload</span>
<input
type="file"
accept="image/*"
multiple
onChange={handleReferenceUpload}
className="hidden"
disabled={referenceImages.length >= 2}
/>
</label>
</div>
{/* Project Image Picker */}
{showProjectPicker && projectImages.length > 0 && (
<div className="bg-slate-800 rounded p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-normal text-slate-400 uppercase tracking-wider">From Project</span>
<button
onClick={() => setShowProjectPicker(false)}
className="text-slate-500 hover:text-slate-300"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{projectImages.map((item) => (
<button
key={item.id}
onClick={() => addProjectImageToReference(item)}
disabled={referenceImages.some(img => img.projectItemId === item.id)}
className={`relative aspect-video rounded overflow-hidden border-2 transition-all ${
referenceImages.some(img => img.projectItemId === item.id)
? 'border-green-500 opacity-50'
: 'border-transparent hover:border-cinema-gold'
}`}
>
<img
src={`data:${item.mimeType};base64,${item.data}`}
alt={item.prompt || 'Project image'}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
)}
{/* Selected Reference Images - Larger Thumbnails */}
{referenceImages.length > 0 && (
<div className="flex gap-4">
{referenceImages.map((img, index) => (
<div key={index} className="relative group">
<img
src={`data:${img.mime_type};base64,${img.data}`}
alt={img.name}
className="w-48 h-28 object-cover rounded border border-slate-700"
/>
<button
onClick={() => removeReferenceImage(index)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 text-white" />
</button>
<span className="absolute bottom-0 left-0 right-0 bg-black/70 text-[10px] text-center text-slate-300 rounded-b-lg py-0.5">
{index === 0 ? 'Start Frame' : 'End Frame'}
</span>
</div>
))}
</div>
)}
{/* Help Text */}
<p className="text-xs text-slate-500">
Start from this frame. Add 2nd image for end frame (A→B interpolation).
</p>
{/* Aspect Ratio Mismatch Warning */}
{hasAspectMismatch && (
<div className="flex items-start gap-2 p-2 bg-amber-950/30 border border-amber-700/50 rounded">
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-400">
Reference is {refImageAspect}, output is {aspectRatio}. May cause cropping.
</p>
</div>
)}
</div>
)}
{/* Generated Video */}
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-normal text-slate-200 flex items-center gap-2">
<Video className="w-5 h-5 text-cinema-gold" />
Generated Video
</h3>
{generatedVideo && (
<button
onClick={() => setVideoPreviewExpanded(true)}
className="p-1.5 text-slate-400 hover:text-cinema-gold transition-colors"
title="Expand preview"
>
<Maximize2 className="w-4 h-4" />
</button>
)}
</div>
<div className={`${aspectRatio === '9:16' ? 'max-w-xs mx-auto' : aspectRatio === '1:1' ? 'max-w-lg mx-auto' : ''}`}>
<div className={`${aspectRatio === '9:16' ? 'aspect-[9/16]' : aspectRatio === '1:1' ? 'aspect-square' : 'aspect-video'} bg-slate-950 rounded border-2 ${generatedVideo ? 'border-cinema-gold' : 'border-dashed border-slate-700'} flex items-center justify-center overflow-hidden`}>
{isGenerating ? (
<div className="text-center p-8">
<Loader2 className="w-10 h-10 animate-spin text-cinema-gold mx-auto" />
<p className="text-slate-400 mt-3">Generating video...</p>
<div className="w-48 h-2 bg-slate-800 rounded-full mt-4 mx-auto overflow-hidden">
<div
className="h-full bg-cinema-gold transition-all duration-300"
style={{ width: `${generationProgress}%` }}
/>
</div>
<p className="text-slate-500 text-xs mt-2">This may take 1-6 minutes</p>
</div>
) : generatedVideo ? (
<VideoPlayer
src={generatedVideo.url}
onFrameExtract={(frame) => {
if (referenceImages.length < 3) {
setReferenceImages(prev => [...prev, {
data: frame.data,
mime_type: frame.mime_type,
name: `frame-${Math.round(frame.timestamp * 1000)}ms.png`
}]);
}
}}
onSaveToProject={activeProjectId ? async (frameData) => {
await addItemToProject(activeProjectId, {
type: 'image',
prompt: frameData.prompt,
data: frameData.data,
mimeType: frameData.mimeType,
thumbnail: frameData.data
});
refreshProjectItems();
} : null}
className="w-full"
/>
) : (
<div className="text-center p-8">
<Video className="w-12 h-12 text-slate-700 mx-auto mb-3" />
<p className="text-slate-500">
{engine === 'kling' && klingWorkflow === 'extend'
? 'Select a video to extend'
: engine === 'kling' && klingWorkflow === 'lipsync'
? 'Enter a video ID and upload an audio file'
: 'Enter a scene description and generate'}
</p>
<p className="text-slate-600 text-xs mt-2">
Powered by {engine === 'veo' ? 'Veo 3.1' : 'Kling AI'}
</p>
</div>
)}
</div>
</div>
{error && (
<div className="text-amber-400 text-sm bg-amber-950/20 border border-amber-900/50 rounded px-4 py-2">
{error}
</div>
)}
{/* Video Actions */}
{generatedVideo && (
<div className="flex gap-3">
<button
onClick={() => {
const link = document.createElement('a');
link.href = generatedVideo.url;
link.download = generatedVideo.filename || `lux-studio-video-${Date.now()}.mp4`;
link.click();
}}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-normal rounded transition-all"
>
<Download className="w-5 h-5" />
<span>Download</span>
</button>
<button
onClick={() => {
setGeneratedVideo(null);
setGenerationProgress(0);
setError('');
}}
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition-all"
title="Start New Video"
>
<RefreshCw className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
{/* Expanded Video Preview Overlay */}
{videoPreviewExpanded && generatedVideo && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setVideoPreviewExpanded(false)}
>
<div
className="relative max-h-[90vh] w-full mx-4 max-w-[95vw]"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute -top-12 right-0 flex items-center gap-2">
<button
onClick={() => setVideoPreviewExpanded(false)}
className="p-2 text-white hover:text-cinema-gold transition-colors"
title="Collapse"
>
<Minimize2 className="w-6 h-6" />
</button>
<button
onClick={() => setVideoPreviewExpanded(false)}
className="p-2 text-white hover:text-cinema-gold transition-colors"
>
<X className="w-8 h-8" />
</button>
</div>
<div className="bg-slate-950 rounded border-2 border-cinema-gold overflow-hidden">
<VideoPlayer
src={generatedVideo.url}
onFrameExtract={(frame) => {
if (referenceImages.length < 3) {
setReferenceImages(prev => [...prev, {
data: frame.data,
mime_type: frame.mime_type,
name: `frame-${Math.round(frame.timestamp * 1000)}ms.png`
}]);
}
}}
onSaveToProject={activeProjectId ? async (frameData) => {
await addItemToProject(activeProjectId, {
type: 'image',
prompt: frameData.prompt,
data: frameData.data,
mimeType: frameData.mimeType,
thumbnail: frameData.data
});
refreshProjectItems();
} : null}
className="w-full"
/>
</div>
</div>
</div>
)}
</div>
);
};
export default VideoGenTab;