- 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>
2095 lines
92 KiB
JavaScript
2095 lines
92 KiB
JavaScript
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">
|
||
2–10s 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;
|