Compare commits

..

No commits in common. "plaiground" and "main" have entirely different histories.

36 changed files with 898 additions and 3661 deletions

5
.gitignore vendored
View file

@ -21,13 +21,12 @@ frontend/.env.*
!frontend/.env.example
!frontend/.env.local
!frontend/.env.production
!frontend/.env.optical
backend/.env
backend/.env.*
!backend/.env.example
!backend/.env.local
!backend/.env.production
!backend/.env.optical
backend/config.php
!backend/config.example.php
# Generated content
@ -36,7 +35,6 @@ generated_videos/
backend/uploads/
uploads/
backend/video_operations.json
backend/runtime_config.json
*.save
# Logs
@ -80,5 +78,6 @@ frontend-server.log
stop.sh
# Local dev config
config.php
backend/php.ini
Prompt_Studio/

View file

@ -263,7 +263,7 @@ GEMINI_API_KEY=AIzaSyC...
FRONTEND_URL=http://localhost:3000
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
```
**backend/.env.production** - Production template (copied by deploy.sh):
@ -273,7 +273,7 @@ GEMINI_API_KEY=AIzaSyC...
FRONTEND_URL=https://ai-sandbox.oliver.solutions/lux-studio
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
```
**backend/.env** - Working config (gitignored, created by setup.sh or deploy.sh)
@ -289,7 +289,7 @@ VITE_API_URL=http://localhost:5015
VITE_GEMINI_API_KEY=AIzaSyC...
VITE_SSO_ENABLED=true
VITE_SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
VITE_SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
VITE_SSO_REDIRECT_URI=http://localhost:3000
NODE_ENV=development
```
@ -303,7 +303,7 @@ VITE_API_URL=https://ai-sandbox.oliver.solutions/lux-studio/api
VITE_GEMINI_API_KEY=AIzaSyC...
VITE_SSO_ENABLED=true
VITE_SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
VITE_SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
VITE_SSO_REDIRECT_URI=https://ai-sandbox.oliver.solutions/lux-studio/
NODE_ENV=production
```

View file

@ -19,20 +19,6 @@ BACKEND_PORT=5015
# Get your API key from: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=your-api-key-here
# ----------------------------------------------------------------------------
# Kling AI API Credentials (https://platform.klingai.com)
# ----------------------------------------------------------------------------
# Get credentials from: https://platform.klingai.com
KLING_ACCESS_KEY=your-kling-access-key-here
KLING_SECRET_KEY=your-kling-secret-key-here
# ----------------------------------------------------------------------------
# Admin Users (comma-separated email addresses / UPNs)
# ----------------------------------------------------------------------------
# Users who can rotate Kling credentials via the Admin Settings panel.
# For local dev, use dev@localhost (the mock user when SSO is disabled).
ADMIN_EMAILS=dev@localhost
# ----------------------------------------------------------------------------
# Frontend URL for CORS (REQUIRED)
# ----------------------------------------------------------------------------
@ -43,7 +29,7 @@ ADMIN_EMAILS=dev@localhost
FRONTEND_URL=http://localhost:3000
#
# PRODUCTION (comment out for local):
# FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
# FRONTEND_URL=https://ai-sandbox.oliver.solutions/lux-studio
# ----------------------------------------------------------------------------
# Azure AD / MSAL SSO Configuration
@ -52,4 +38,4 @@ FRONTEND_URL=http://localhost:3000
# The backend accepts all requests without token validation
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9

View file

@ -17,21 +17,6 @@ BACKEND_PORT=5015
# Get your API key from: https://aistudio.google.com/app/apikey
# GEMINI_API_KEY=AIzaSyCMKLSJJYEx4c6-TtBACnjdULrLzsr_fts
GEMINI_API_KEY=AIzaSyDs7EKdC9NLM5UqWlGUqeQO96TmSA-kos8
# ----------------------------------------------------------------------------
# Kling AI API Credentials (https://platform.klingai.com)
# ----------------------------------------------------------------------------
KLING_ACCESS_KEY=
KLING_SECRET_KEY=
# ----------------------------------------------------------------------------
# Admin Users (comma-separated email addresses / UPNs)
# ----------------------------------------------------------------------------
# Users in this list see the Admin Settings gear in Lux Studio and can rotate
# Kling credentials in real-time without touching the server.
# For local dev the mock user is dev@localhost — keep it here to enable admin UI.
ADMIN_EMAILS=dev@localhost
# ----------------------------------------------------------------------------
# Frontend URL for CORS (REQUIRED)
# ----------------------------------------------------------------------------
@ -45,4 +30,4 @@ FRONTEND_URL=http://localhost:3000
# Backend authentication is DISABLED - Frontend handles SSO
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9

View file

@ -1,21 +0,0 @@
# ============================================================================
# Lux Studio Backend - OPTICAL-DEV Environment Configuration
# ============================================================================
# Target: https://optical-prod.oliver.solutions/lux-studio/api/
# Deployed to: /var/www/html/lux-studio/api/.env (never overwritten by deploy-optical.sh)
# ============================================================================
GEMINI_API_KEY=AIzaSyDs7EKdC9NLM5UqWlGUqeQO96TmSA-kos8
KLING_ACCESS_KEY=
KLING_SECRET_KEY=
# Admin Users (comma-separated UPNs — users who can rotate Kling credentials)
ADMIN_EMAILS=VadymSamoilenko@oliver.agency,daveporter@oliver.agency
# IMPORTANT: No trailing slash!
FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6

View file

@ -13,21 +13,12 @@
GEMINI_API_KEY=AIzaSyDs7EKdC9NLM5UqWlGUqeQO96TmSA-kos8
# AIzaSyCMKLSJJYEx4c6-TtBACnjdULrLzsr_fts
# ----------------------------------------------------------------------------
# Kling AI API Credentials (https://platform.klingai.com)
# ----------------------------------------------------------------------------
KLING_ACCESS_KEY=
KLING_SECRET_KEY=
# Admin Users (comma-separated UPNs — users who can rotate Kling credentials)
ADMIN_EMAILS=VadymSamoilenko@oliver.agency,daveporter@oliver.agency
# ----------------------------------------------------------------------------
# Frontend URL for CORS (REQUIRED)
# ----------------------------------------------------------------------------
# IMPORTANT: No trailing slash!
# This allows the frontend to make API calls to the backend
FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
FRONTEND_URL=https://ai-sandbox.oliver.solutions/lux-studio
# ----------------------------------------------------------------------------
# Azure AD / MSAL SSO Configuration
@ -36,4 +27,4 @@ FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
# The backend accepts all requests without token validation
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef

View file

@ -1,202 +0,0 @@
<?php
/**
* Admin API Kling credential rotation and status
*
* Actions (all require admin):
* GET ?action=status masked credential info
* POST ?action=update_kling rotate access_key + secret_key
* POST ?action=test_kling verify credentials against Kling API
*
* Authentication:
* SSO disabled -> mock user accepted; must be in ADMIN_EMAILS
* SSO enabled -> Authorization: Bearer <idToken> required; validated via JWTValidator
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/runtime_config.php';
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function getRequestUser(): ?array {
// Always try Bearer token first — admin endpoint needs real identity even when
// SSO_ENABLED=false (backend SSO disabled only means regular APIs skip auth,
// not that admin panel should skip identity checks).
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (empty($authHeader) && function_exists('apache_request_headers')) {
$headers = apache_request_headers();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
if (preg_match('/^Bearer\s+(.+)$/i', trim($authHeader), $m)) {
require_once __DIR__ . '/JWTValidator.php';
$validator = new JWTValidator(SSO_TENANT_ID, SSO_CLIENT_ID);
$result = $validator->validateToken($m[1]);
if ($result['valid']) {
return $result['payload'];
}
}
// Fall back to mock user only for local dev (SSO fully disabled, no token sent)
if (!SSO_ENABLED) {
return ['preferred_username' => 'dev@localhost', 'name' => 'Local Developer'];
}
return null;
}
function isAdmin(?array $user): bool {
if (!$user) return false;
$list = array_filter(array_map('trim', explode(',', ADMIN_EMAILS)));
if (empty($list)) return false;
$email = strtolower(
$user['preferred_username'] ?? $user['upn'] ?? $user['email'] ?? ''
);
foreach ($list as $allowed) {
if (strtolower($allowed) === $email) return true;
}
return false;
}
function requireAdmin(): array {
$user = getRequestUser();
if (!isAdmin($user)) {
http_response_code(403);
echo json_encode(['error' => 'Admin access required']);
exit;
}
return $user;
}
// ---------------------------------------------------------------------------
// Kling test helper
// ---------------------------------------------------------------------------
function testKlingCredentials(string $accessKey, string $secretKey): array {
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
$now = time();
$payload = rtrim(strtr(base64_encode(json_encode([
'iss' => $accessKey,
'exp' => $now + 1800,
'nbf' => $now - 5,
])), '+/', '-_'), '=');
$sig = rtrim(strtr(base64_encode(hash_hmac('sha256', "$header.$payload", $secretKey, true)), '+/', '-_'), '=');
$jwt = "$header.$payload.$sig";
$ch = curl_init('https://api-singapore.klingai.com/v1/videos/text2video?pageNum=1&pageSize=1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $jwt,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
return ['ok' => true];
}
$body = json_decode($response, true);
$error = $body['message'] ?? ($body['error'] ?? "HTTP $httpCode");
return ['ok' => false, 'error' => $error];
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
function appendAuditLog(string $action, array $user): void {
$dir = __DIR__ . '/uploads';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$line = json_encode([
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
'user' => $user['preferred_username'] ?? $user['email'] ?? 'unknown',
'action' => $action,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
]) . "\n";
@file_put_contents($dir . '/admin_audit.log', $line, FILE_APPEND | LOCK_EX);
}
// ---------------------------------------------------------------------------
// Request handler
// ---------------------------------------------------------------------------
try {
$action = $_GET['action'] ?? $_POST['action'] ?? null;
if ($action === 'status') {
requireAdmin();
echo json_encode(['kling' => getKlingStatus()]);
} elseif ($action === 'test_kling') {
$user = requireAdmin();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$accessKey = trim($body['access_key'] ?? '');
$secretKey = trim($body['secret_key'] ?? '');
if ($accessKey === '' || $secretKey === '') {
$creds = getKlingCredentials();
$accessKey = $creds['access_key'];
$secretKey = $creds['secret_key'];
}
if ($accessKey === '' || $secretKey === '') {
echo json_encode(['ok' => false, 'error' => 'No credentials to test']);
} else {
echo json_encode(testKlingCredentials($accessKey, $secretKey));
}
} elseif ($action === 'update_kling') {
$user = requireAdmin();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$accessKey = trim($body['access_key'] ?? '');
$secretKey = trim($body['secret_key'] ?? '');
if (strlen($accessKey) < 8 || strlen($secretKey) < 8) {
http_response_code(400);
echo json_encode(['error' => 'Access key and secret key must each be at least 8 characters']);
exit;
}
$updatedBy = $user['preferred_username'] ?? $user['email'] ?? 'unknown';
if (!setKlingCredentials($accessKey, $secretKey, $updatedBy)) {
http_response_code(500);
echo json_encode(['error' => 'Failed to write runtime config — check file permissions on backend/']);
exit;
}
appendAuditLog('update_kling', $user);
echo json_encode(['kling' => getKlingStatus()]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
}
} catch (Throwable $e) {
error_log('admin_api.php error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}

View file

@ -57,20 +57,13 @@ try {
class NanoBananaProAPI {
private $apiKey;
private $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
private $model;
private $model = 'gemini-3-pro-image-preview';
// Available models
private static $models = [
'pro' => 'gemini-3-pro-image-preview',
'flash' => 'gemini-3.1-flash-image-preview'
];
public function __construct($apiKey, $modelType = 'pro') {
public function __construct($apiKey) {
$this->apiKey = $apiKey;
$this->model = self::$models[$modelType] ?? self::$models['pro'];
}
public function generateImage($prompt, $aspectRatio = '16:9', $imageSize = '2K', $inputImage = null, $referenceImages = [], $safetyLevel = 'default') {
public function generateImage($prompt, $aspectRatio = '16:9', $imageSize = '2K', $inputImage = null, $referenceImages = []) {
$parts = [];
// IMPORTANT: Input image (the one being edited) must come FIRST
@ -134,7 +127,7 @@ class NanoBananaProAPI {
['parts' => $parts]
],
'generationConfig' => [
'responseModalities' => ['TEXT', 'IMAGE'],
'responseModalities' => ['IMAGE'],
'imageConfig' => [
'aspectRatio' => $aspectRatio,
'imageSize' => $imageSize
@ -142,15 +135,6 @@ class NanoBananaProAPI {
]
];
if ($safetyLevel === 'relaxed') {
$payload['safetySettings'] = [
['category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT', 'threshold' => 'BLOCK_ONLY_HIGH'],
['category' => 'HARM_CATEGORY_HATE_SPEECH', 'threshold' => 'BLOCK_ONLY_HIGH'],
['category' => 'HARM_CATEGORY_HARASSMENT', 'threshold' => 'BLOCK_ONLY_HIGH'],
['category' => 'HARM_CATEGORY_DANGEROUS_CONTENT', 'threshold' => 'BLOCK_ONLY_HIGH'],
];
}
return $this->makeRequest($payload);
}
@ -312,15 +296,6 @@ try {
error_log("Received " . count($referenceImages) . " reference images from frontend");
}
// Read and validate model type
$modelType = $_POST['modelType'] ?? 'pro';
if (!in_array($modelType, ['pro', 'flash'])) {
$modelType = 'pro';
}
// Read safety level ('relaxed' uses BLOCK_ONLY_HIGH on all categories)
$safetyLevel = ($_POST['safetyLevel'] ?? 'default') === 'relaxed' ? 'relaxed' : 'default';
// Check if API key is configured
if (!defined('GEMINI_API_KEY') || empty(GEMINI_API_KEY)) {
throw new Exception('API key not configured. Please set GEMINI_API_KEY in config.php');
@ -332,7 +307,7 @@ try {
}
// Initialize API
$api = new NanoBananaProAPI(GEMINI_API_KEY, $modelType);
$api = new NanoBananaProAPI(GEMINI_API_KEY);
// Determine input image for editing:
// 1. Frontend sends uploadedImage when editing from library or a displayed image
@ -354,7 +329,7 @@ try {
}
// Generate or edit image (with optional reference images)
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $inputImage, $referenceImages, $safetyLevel);
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $inputImage, $referenceImages);
$imageData = $api->extractImageData($response);
// Save to disk
@ -372,7 +347,7 @@ try {
'aspectRatio' => $aspectRatio,
'imageSize' => $imageSize,
'actionType' => $inputImage ? 'edit' : 'generate',
'model' => $modelType === 'flash' ? 'Nano Banana 2 (Flash)' : 'Nano Banana Pro'
'model' => 'Gemini 3 Pro Image'
];
logImageGeneration($prompt, $imageData['base64'], $imageData['mime_type'], $webhookSettings, $userEmail, $inputImage ? 'edit' : 'generate');

View file

@ -1,35 +0,0 @@
<?php
/**
* Runtime configuration - reads all settings from the .env file.
* No secrets are stored here; keep this file committed to git.
*/
if (file_exists(__DIR__ . '/env_loader.php')) {
require_once __DIR__ . '/env_loader.php';
}
// Google Gemini
define('GEMINI_API_KEY', getenv('GEMINI_API_KEY') ?: '');
// Admin — comma-separated list of authorized admin email addresses (UPNs)
define('ADMIN_EMAILS', getenv('ADMIN_EMAILS') ?: '');
// Azure AD SSO
if (!defined('SSO_ENABLED')) {
define('SSO_ENABLED', getenv('SSO_ENABLED') === 'true');
}
if (!defined('SSO_TENANT_ID')) {
define('SSO_TENANT_ID', getenv('SSO_TENANT_ID') ?: '');
}
if (!defined('SSO_CLIENT_ID')) {
define('SSO_CLIENT_ID', getenv('SSO_CLIENT_ID') ?: '');
}
// Session lifetime
ini_set('session.gc_maxlifetime', 3600);
ini_set('session.cookie_lifetime', 3600);
// Error reporting
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);

View file

@ -1,685 +0,0 @@
<?php
/**
* Kling AI Video Generation API for Lux Studio
* Handles Kling video generation: Text-to-Video, Image-to-Video, Video Extension, Lip Sync
*/
// Suppress HTML error output to prevent breaking JSON responses
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
set_time_limit(600);
ini_set('memory_limit', '512M');
ini_set('post_max_size', '100M');
ini_set('upload_max_filesize', '100M');
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Load configuration
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/runtime_config.php';
class KlingAPI {
private $accessKey;
private $secretKey;
private $baseUrl = 'https://api-singapore.klingai.com';
public function __construct($accessKey, $secretKey) {
$this->accessKey = $accessKey;
$this->secretKey = $secretKey;
}
/**
* Generate HS256 JWT token for Kling API authentication
* Pure PHP implementation - no external libraries required
*/
private function generateJWT() {
$header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']);
$now = time();
$payload = json_encode([
'iss' => $this->accessKey,
'exp' => $now + 1800, // 30 minute expiry
'nbf' => $now - 5 // Valid from 5 seconds ago (clock skew tolerance)
]);
$b64Header = $this->base64UrlEncode($header);
$b64Payload = $this->base64UrlEncode($payload);
$signature = hash_hmac('sha256', "$b64Header.$b64Payload", $this->secretKey, true);
$b64Signature = $this->base64UrlEncode($signature);
return "$b64Header.$b64Payload.$b64Signature";
}
/**
* URL-safe Base64 encoding
*/
private function base64UrlEncode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Make an authenticated request to the Kling API
*/
private function makeRequest($method, $endpoint, $payload = null) {
$url = $this->baseUrl . $endpoint;
$jwt = $this->generateJWT();
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $jwt
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => $headers
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($payload !== null) {
$jsonPayload = json_encode($payload);
if ($jsonPayload === false) {
throw new Exception('Failed to encode request payload as JSON: ' . json_last_error_msg());
}
error_log("Kling API request to $endpoint: payload size=" . strlen($jsonPayload) . " bytes");
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
}
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Exception("Kling API connection error: $curlError");
}
$decoded = json_decode($response, true);
if ($httpCode === 401 || (isset($decoded['code']) && $decoded['code'] >= 1000 && $decoded['code'] <= 1004)) {
throw new Exception('Kling API authentication failed. Check your KLING_ACCESS_KEY and KLING_SECRET_KEY in .env');
}
if ($httpCode === 429 || (isset($decoded['code']) && $decoded['code'] >= 1302 && $decoded['code'] <= 1304)) {
throw new Exception('Kling API rate limit or concurrent task limit exceeded. Please wait and try again.');
}
if (isset($decoded['code']) && $decoded['code'] >= 1200 && $decoded['code'] <= 1201) {
$detail = $decoded['message'] ?? 'Unknown parameter error';
throw new Exception("Kling API invalid parameters: $detail");
}
if ($httpCode >= 500) {
throw new Exception('Kling server error. Please try again.');
}
if ($httpCode !== 200 && $httpCode !== 201) {
$msg = $decoded['message'] ?? $decoded['msg'] ?? null;
if (!$msg && isset($decoded['data']['message'])) {
$msg = $decoded['data']['message'];
}
if (!$msg) {
$msg = "HTTP $httpCode";
}
error_log("Kling API error (HTTP $httpCode) on $method $endpoint: " . substr($response, 0, 2000));
throw new Exception("Kling API error (HTTP $httpCode): $msg");
}
return $decoded;
}
/**
* Text-to-Video generation
*/
public function textToVideo($prompt, $opts = []) {
$modelName = $opts['model_name'] ?? 'kling-v3';
$payload = [
'prompt' => $prompt,
'model_name' => $modelName,
'mode' => $opts['mode'] ?? 'std',
'duration' => strval(intval($opts['duration'] ?? 5)),
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
];
// cfg_scale not supported by v2.x models
if (!preg_match('/^kling-v2/', $modelName)) {
$payload['cfg_scale'] = floatval($opts['cfg_scale'] ?? 0.5);
}
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (!empty($opts['sound']) && $opts['sound'] === 'on') {
$payload['sound'] = 'on';
}
// camera_control only supported by kling-v1 and kling-v1-5 per capability map
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none'
&& in_array($modelName, ['kling-v1', 'kling-v1-5'])) {
$cameraType = $opts['camera_control'];
// For preset types, config must be null/absent; only 'simple' type uses config
if ($cameraType === 'simple') {
$payload['camera_control'] = ['type' => 'simple', 'config' => ['horizontal' => 0, 'vertical' => 0, 'pan' => 0, 'tilt' => 0, 'roll' => 0, 'zoom' => 0]];
} else {
$payload['camera_control'] = ['type' => $cameraType];
}
}
error_log("Kling T2V request: model={$payload['model_name']}, mode={$payload['mode']}, duration={$payload['duration']}");
$response = $this->makeRequest('POST', '/v1/videos/text2video', $payload);
return $this->extractTaskInfo($response, 'text2video');
}
/**
* Image-to-Video generation
*/
public function imageToVideo($images, $prompt, $opts = []) {
$modelName = $opts['model_name'] ?? 'kling-v3';
$payload = [
'model_name' => $modelName,
'mode' => $opts['mode'] ?? 'std',
'duration' => strval(intval($opts['duration'] ?? 5)),
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
];
// cfg_scale not supported by v2.x models
if (!preg_match('/^kling-v2/', $modelName)) {
$payload['cfg_scale'] = floatval($opts['cfg_scale'] ?? 0.5);
}
if (!empty($prompt)) {
$payload['prompt'] = $prompt;
}
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (!empty($opts['sound']) && $opts['sound'] === 'on') {
$payload['sound'] = 'on';
}
// camera_control only for kling-v1/v1-5; also mutually exclusive with image_tail
$hasTailFrame = isset($images[1]);
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none'
&& in_array($modelName, ['kling-v1', 'kling-v1-5'])
&& !$hasTailFrame) {
$cameraType = $opts['camera_control'];
if ($cameraType === 'simple') {
$payload['camera_control'] = ['type' => 'simple', 'config' => ['horizontal' => 0, 'vertical' => 0, 'pan' => 0, 'tilt' => 0, 'roll' => 0, 'zoom' => 0]];
} else {
$payload['camera_control'] = ['type' => $cameraType];
}
}
// Start frame — field name is `image`, value is clean base64 (no data URI prefix)
if (isset($images[0])) {
$payload['image'] = $this->cleanBase64($images[0]['data']);
}
// Optional end frame for A→B interpolation — field name is `image_tail`
if (isset($images[1])) {
$payload['image_tail'] = $this->cleanBase64($images[1]['data']);
}
error_log("Kling I2V request: model={$payload['model_name']}, images=" . count($images));
$response = $this->makeRequest('POST', '/v1/videos/image2video', $payload);
return $this->extractTaskInfo($response, 'image2video');
}
/**
* Extend an existing Kling video
*/
public function extendVideo($videoId, $opts = []) {
$payload = [
'video_id' => $videoId
];
if (!empty($opts['prompt'])) {
$payload['prompt'] = $opts['prompt'];
}
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (isset($opts['cfg_scale'])) {
$payload['cfg_scale'] = floatval($opts['cfg_scale']);
}
error_log("Kling Extend request: videoId=$videoId");
$response = $this->makeRequest('POST', '/v1/videos/video-extend', $payload);
return $this->extractTaskInfo($response, 'video-extend');
}
/**
* Lip Sync generation (video + audio lip-synced video)
* Uses the official /v1/videos/lip-sync endpoint
*/
public function lipSync($audioData, $opts = []) {
$input = [
'mode' => 'audio2video',
'audio_type' => 'file',
'audio_file' => $this->cleanBase64($audioData)
];
// Video source: either video_id (from a previous Kling generation) or video_url
if (!empty($opts['video_id'])) {
$input['video_id'] = $opts['video_id'];
} elseif (!empty($opts['video_url'])) {
$input['video_url'] = $opts['video_url'];
}
error_log("Kling Lip Sync request: video_id=" . ($opts['video_id'] ?? 'none') . ", video_url=" . (!empty($opts['video_url']) ? 'provided' : 'none'));
error_log("Kling Lip Sync audio_file length: " . strlen($input['audio_file']));
$response = $this->makeRequest('POST', '/v1/videos/lip-sync', ['input' => $input]);
return $this->extractTaskInfo($response, 'lip-sync');
}
/**
* Check the status of a Kling task
*/
public function checkStatus($taskId, $taskType) {
// Map task types to their status endpoints
$endpointMap = [
'text2video' => '/v1/videos/text2video/',
'image2video' => '/v1/videos/image2video/',
'video-extend' => '/v1/videos/video-extend/',
'lip-sync' => '/v1/videos/lip-sync/',
'avatar' => '/v1/videos/lip-sync/' // Legacy compat
];
$endpoint = ($endpointMap[$taskType] ?? '/v1/videos/text2video/') . $taskId;
$response = $this->makeRequest('GET', $endpoint);
return $response;
}
/**
* Download a video from Kling CDN and save locally
*/
public function downloadAndSaveVideo($url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 120
]);
$videoData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode !== 200 || !$videoData) {
throw new Exception("Failed to download video from Kling CDN: HTTP $httpCode");
}
$filename = 'video_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.mp4';
$videoDir = __DIR__ . '/generated_videos';
if (!is_dir($videoDir)) {
mkdir($videoDir, 0755, true);
}
$filepath = $videoDir . '/' . $filename;
file_put_contents($filepath, $videoData);
return $filename;
}
/**
* Extract task ID and type from Kling API response
*/
private function extractTaskInfo($response, $taskType) {
// Kling API returns: { "code": 0, "message": "...", "request_id": "...", "data": { "task_id": "...", "task_status": "submitted" } }
if (isset($response['data']['task_id'])) {
return [
'taskId' => $response['data']['task_id'],
'taskType' => $taskType,
'status' => $response['data']['task_status'] ?? 'submitted'
];
}
// Also check for task_id at top level
if (isset($response['task_id'])) {
return [
'taskId' => $response['task_id'],
'taskType' => $taskType,
'status' => $response['task_status'] ?? 'submitted'
];
}
throw new Exception('Kling API returned unexpected response: ' . json_encode($response));
}
/**
* Clean base64 data - remove data URI prefix and whitespace
*/
private function cleanBase64($data) {
$data = preg_replace('/^data:[^;]+;base64,/', '', $data);
$data = preg_replace('/\s+/', '', $data);
// Ensure proper base64 padding
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
return $data;
}
}
// =============================================================================
// Request Handler
// =============================================================================
try {
$action = $_POST['action'] ?? $_GET['action'] ?? null;
if (!$action) {
throw new Exception('No action specified');
}
// Load credentials — runtime JSON first, falls back to .env
$klingCreds = getKlingCredentials();
if (empty($klingCreds['access_key']) || empty($klingCreds['secret_key'])) {
throw new Exception('Kling API credentials not configured. Set KLING_ACCESS_KEY and KLING_SECRET_KEY in .env or rotate them via the admin panel.');
}
$api = new KlingAPI($klingCreds['access_key'], $klingCreds['secret_key']);
// =========================================================================
// Action: generate (Text-to-Video or Image-to-Video)
// =========================================================================
if ($action === 'generate') {
$prompt = $_POST['prompt'] ?? '';
$modelName = $_POST['modelName'] ?? 'kling-v2-6';
$duration = $_POST['duration'] ?? '5';
$aspectRatio = $_POST['aspectRatio'] ?? '16:9';
$mode = $_POST['mode'] ?? 'std';
$cfgScale = $_POST['cfgScale'] ?? '0.5';
$sound = $_POST['sound'] ?? 'off';
$negativePrompt = $_POST['negativePrompt'] ?? '';
$cameraControl = $_POST['cameraControl'] ?? 'none';
// Validate model
$validModels = [
'kling-v3', 'kling-v3-omni', // Latest — support motion control
'kling-video-o1', // Fast
'kling-v2-6', 'kling-v2-5-turbo', // V2 series
'kling-v2-1-master', 'kling-v2-master',
'kling-v1-6', 'kling-v1-5', 'kling-v1' // V1 series — v1 has camera control
];
if (!in_array($modelName, $validModels)) {
$modelName = 'kling-v3';
}
// Duration: v3/v3-omni support 3-15s; most others only 5s/10s
$durationInt = intval($duration);
$flexibleDurationModels = ['kling-v3', 'kling-v3-omni'];
if (in_array($modelName, $flexibleDurationModels)) {
if ($durationInt < 3 || $durationInt > 15) { $duration = '5'; }
} else {
if (!in_array($duration, ['5', '10'])) { $duration = '5'; }
}
// Validate aspect ratio
if (!in_array($aspectRatio, ['16:9', '9:16', '1:1'])) {
$aspectRatio = '16:9';
}
// Validate mode
if (!in_array($mode, ['std', 'pro'])) {
$mode = 'std';
}
$opts = [
'model_name' => $modelName,
'mode' => $mode,
'duration' => $duration,
'aspect_ratio' => $aspectRatio,
'cfg_scale' => $cfgScale,
'sound' => $sound,
'negative_prompt' => trim($negativePrompt),
'camera_control' => $cameraControl
];
// Collect reference images
$referenceImages = [];
$refCount = intval($_POST['referenceImageCount'] ?? 0);
for ($i = 0; $i < min($refCount, 2); $i++) {
if (isset($_POST["referenceImage_$i"])) {
$referenceImages[] = [
'data' => $_POST["referenceImage_$i"],
'mime_type' => $_POST["referenceImageType_$i"] ?? 'image/jpeg'
];
}
}
// Branch: T2V or I2V based on reference images
if (!empty($referenceImages)) {
if (empty($prompt)) {
$prompt = ''; // Prompt is optional for I2V
}
$result = $api->imageToVideo($referenceImages, $prompt, $opts);
} else {
if (empty($prompt)) {
throw new Exception('Prompt is required for text-to-video generation');
}
$result = $api->textToVideo($prompt, $opts);
}
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => $result['taskType'],
'message' => 'Kling video generation started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: extend (Video Extension)
// =========================================================================
if ($action === 'extend') {
$videoId = $_POST['videoId'] ?? null;
$prompt = $_POST['prompt'] ?? '';
$cfgScale = $_POST['cfgScale'] ?? '0.5';
$negativePrompt = $_POST['negativePrompt'] ?? '';
if (!$videoId) {
throw new Exception('Video ID is required for video extension');
}
$opts = [
'prompt' => trim($prompt),
'negative_prompt' => trim($negativePrompt),
'cfg_scale' => $cfgScale
];
$result = $api->extendVideo($videoId, $opts);
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => 'video-extend',
'message' => 'Video extension started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: lipsync (Lip Sync Generation)
// =========================================================================
if ($action === 'lipsync') {
$videoId = $_POST['videoId'] ?? null;
$videoUrl = $_POST['videoUrl'] ?? null;
$audioData = $_POST['audioFile'] ?? null;
if (!$videoId && !$videoUrl) {
throw new Exception('A Kling video ID or video URL is required for lip sync');
}
if (!$audioData) {
throw new Exception('Audio file is required for lip sync');
}
error_log("Lip sync: videoId=$videoId, videoUrl=" . ($videoUrl ? 'yes' : 'no') . ", audioData length=" . strlen($audioData));
// Validate the base64 audio data
$cleanAudio = preg_replace('/^data:[^;]+;base64,/', '', $audioData);
$cleanAudio = preg_replace('/\s+/', '', $cleanAudio);
$decoded = base64_decode($cleanAudio, true);
if ($decoded === false) {
throw new Exception('Audio file base64 data is invalid or corrupted');
}
error_log("Lip sync: decoded audio size=" . strlen($decoded) . " bytes");
$opts = [
'video_id' => $videoId,
'video_url' => $videoUrl
];
$result = $api->lipSync($audioData, $opts);
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => 'lip-sync',
'message' => 'Lip sync generation started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: check_status (Poll task status for any workflow)
// =========================================================================
if ($action === 'check_status') {
$taskId = $_POST['taskId'] ?? null;
$taskType = $_POST['taskType'] ?? 'text2video';
if (!$taskId) {
throw new Exception('Task ID is required');
}
$response = $api->checkStatus($taskId, $taskType);
// Extract status from response
$taskStatus = $response['data']['task_status'] ?? 'processing';
$taskStatusMsg = $response['data']['task_status_msg'] ?? '';
if ($taskStatus === 'succeed') {
// Task completed - extract video URL
$videos = $response['data']['task_result']['videos'] ?? [];
if (empty($videos)) {
throw new Exception('Kling generation completed but no video was returned');
}
$videoUrl = $videos[0]['url'] ?? null;
$videoDuration = $videos[0]['duration'] ?? null;
$videoId = $videos[0]['id'] ?? null;
if (!$videoUrl) {
throw new Exception('Kling generation completed but video URL is missing');
}
// Download from Kling CDN and save locally (CDN URLs are temporary)
$filename = $api->downloadAndSaveVideo($videoUrl);
echo json_encode([
'success' => true,
'status' => 'complete',
'video' => [
'url' => '/lux-studio/api/generated_videos/' . $filename,
'filename' => $filename,
'mime_type' => 'video/mp4',
'duration' => $videoDuration,
'videoId' => $videoId
],
'taskId' => $taskId
]);
} elseif ($taskStatus === 'failed') {
throw new Exception('Kling video generation failed: ' . ($taskStatusMsg ?: 'Unknown error'));
} else {
// Still processing (submitted, processing)
echo json_encode([
'success' => true,
'status' => 'pending',
'taskStatus' => $taskStatus,
'message' => 'Kling video generation in progress...'
]);
}
exit;
}
// =========================================================================
// Action: download_video (Proxy download from Kling CDN)
// =========================================================================
if ($action === 'download_video') {
$videoUrl = $_POST['videoUrl'] ?? null;
if (!$videoUrl) {
throw new Exception('Video URL is required');
}
$filename = $api->downloadAndSaveVideo($videoUrl);
echo json_encode([
'success' => true,
'video' => [
'url' => '/lux-studio/api/generated_videos/' . $filename,
'filename' => $filename,
'mime_type' => 'video/mp4'
]
]);
exit;
}
throw new Exception('Invalid action: ' . $action);
} catch (Exception $e) {
http_response_code(500);
error_log("Exception in kling_api.php: " . $e->getMessage());
error_log("Stack trace: " . $e->getTraceAsString());
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
'debug' => [
'file' => basename($e->getFile()),
'line' => $e->getLine(),
'timestamp' => date('Y-m-d H:i:s')
]
]);
exit;
}

View file

@ -1,83 +0,0 @@
<?php
/**
* Runtime credential store Kling credential rotation
*
* Reads backend/runtime_config.json (written via admin panel) and falls back
* to .env values so existing environments keep working without any migration.
*/
define('RUNTIME_CONFIG_PATH', __DIR__ . '/runtime_config.json');
/**
* Returns current Kling credentials. Checks runtime JSON first, then .env.
*/
function getKlingCredentials(): array {
if (file_exists(RUNTIME_CONFIG_PATH)) {
$data = json_decode(file_get_contents(RUNTIME_CONFIG_PATH), true);
if (!empty($data['kling']['access_key']) && !empty($data['kling']['secret_key'])) {
return [
'access_key' => $data['kling']['access_key'],
'secret_key' => $data['kling']['secret_key'],
'source' => 'runtime',
'updated_at' => $data['kling']['updated_at'] ?? null,
'updated_by' => $data['kling']['updated_by'] ?? null,
];
}
}
return [
'access_key' => getenv('KLING_ACCESS_KEY') ?: '',
'secret_key' => getenv('KLING_SECRET_KEY') ?: '',
'source' => 'env',
'updated_at' => null,
'updated_by' => null,
];
}
/**
* Writes new Kling credentials atomically (tempfile + rename).
*/
function setKlingCredentials(string $accessKey, string $secretKey, string $updatedBy): bool {
$data = [];
if (file_exists(RUNTIME_CONFIG_PATH)) {
$existing = json_decode(file_get_contents(RUNTIME_CONFIG_PATH), true);
if (is_array($existing)) {
$data = $existing;
}
}
$data['kling'] = [
'access_key' => $accessKey,
'secret_key' => $secretKey,
'updated_at' => gmdate('Y-m-d\TH:i:s\Z'),
'updated_by' => $updatedBy,
];
$tmpPath = RUNTIME_CONFIG_PATH . '.tmp.' . getmypid();
if (file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT)) === false) {
return false;
}
return rename($tmpPath, RUNTIME_CONFIG_PATH);
}
/**
* Returns masked status suitable for the admin UI (never exposes raw secrets).
*/
function getKlingStatus(): array {
$creds = getKlingCredentials();
$key = $creds['access_key'];
if (strlen($key) > 8) {
$masked = substr($key, 0, 4) . '…' . substr($key, -4);
} elseif ($key !== '') {
$masked = str_repeat('*', strlen($key));
} else {
$masked = null;
}
return [
'access_key_masked' => $masked,
'secret_set' => $creds['secret_key'] !== '',
'source' => $creds['source'],
'updated_at' => $creds['updated_at'],
'updated_by' => $creds['updated_by'],
];
}

View file

@ -85,14 +85,7 @@ class SessionManager {
$extension = $this->getExtensionFromMime($mimeType);
$timestamp = time();
$filename = 'image_' . $timestamp . '_' . uniqid() . '.' . $extension;
// Re-ensure directory exists (auto-cleanup may have removed it)
$imagesDir = $this->getImagesDir();
if (!is_dir($imagesDir)) {
mkdir($imagesDir, 0755, true);
}
$filepath = $imagesDir . '/' . $filename;
$filepath = $this->getImagesDir() . '/' . $filename;
// Decode base64 if needed
if (base64_decode($imageData, true) !== false) {

View file

@ -10,12 +10,6 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1);
set_time_limit(300); // Veo 3.1 operations poll for up to 5 minutes
// Increase execution time and memory/POST limits for video generation with large images
set_time_limit(600);
ini_set('memory_limit', '512M');
ini_set('post_max_size', '100M');
ini_set('upload_max_filesize', '100M');
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
@ -50,97 +44,11 @@ class VeoVideoAPI {
$this->operationsFile = __DIR__ . '/video_operations.json';
}
/**
* Resize a base64-encoded image to exact Veo target dimensions.
* Prevents letterboxing caused by Gemini outputting non-standard aspect ratios (e.g., 1408x768 instead of true 16:9).
*/
private function resizeImageForVeo($base64Data, $mimeType, $aspectRatio) {
// Target dimensions by aspect ratio and resolution tier
$targets = [
'16:9' => [1920, 1080],
'9:16' => [1080, 1920],
'1:1' => [1080, 1080],
];
if (!isset($targets[$aspectRatio])) {
return $base64Data; // Unknown ratio, pass through
}
[$targetW, $targetH] = $targets[$aspectRatio];
$targetRatio = $targetW / $targetH;
// GD may not be installed — fatal errors from undefined functions can't be suppressed
if (!function_exists('imagecreatefromstring')) {
error_log("resizeImageForVeo: GD extension not available, skipping resize");
return $base64Data;
}
$decoded = base64_decode($base64Data);
if ($decoded === false) return $base64Data;
$src = @imagecreatefromstring($decoded);
if (!$src) {
error_log("resizeImageForVeo: could not create image from data, passing through");
return $base64Data;
}
$srcW = imagesx($src);
$srcH = imagesy($src);
$srcRatio = $srcW / $srcH;
// If already exact match (within 1px), skip
if ($srcW === $targetW && $srcH === $targetH) {
imagedestroy($src);
return $base64Data;
}
// If aspect ratio matches closely (within 0.5%), just resize
// Otherwise, center-crop to target ratio first, then resize
$dst = imagecreatetruecolor($targetW, $targetH);
if (abs($srcRatio - $targetRatio) < 0.005) {
// Close enough — straight resize
imagecopyresampled($dst, $src, 0, 0, 0, 0, $targetW, $targetH, $srcW, $srcH);
error_log("resizeImageForVeo: resized {$srcW}x{$srcH} -> {$targetW}x{$targetH}");
} else {
// Crop to target ratio, then resize
if ($srcRatio > $targetRatio) {
// Source is wider — crop sides
$cropH = $srcH;
$cropW = (int)round($srcH * $targetRatio);
$cropX = (int)round(($srcW - $cropW) / 2);
$cropY = 0;
} else {
// Source is taller — crop top/bottom
$cropW = $srcW;
$cropH = (int)round($srcW / $targetRatio);
$cropX = 0;
$cropY = (int)round(($srcH - $cropH) / 2);
}
imagecopyresampled($dst, $src, 0, 0, $cropX, $cropY, $targetW, $targetH, $cropW, $cropH);
error_log("resizeImageForVeo: crop+resize {$srcW}x{$srcH} -> {$targetW}x{$targetH} (cropped from {$cropX},{$cropY} {$cropW}x{$cropH})");
}
// Encode back to base64
ob_start();
if ($mimeType === 'image/png') {
imagepng($dst);
} else {
imagejpeg($dst, null, 95);
}
$output = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return base64_encode($output);
}
/**
* Generate a video using Veo 3.1
* Returns an operation ID for async polling
*/
public function generateVideo($prompt, $duration = 4, $aspectRatio = '16:9', $resolution = '720p', $generateAudio = true, $referenceImages = [], $referenceMode = 'frame', $negativePrompt = '', $safetyLevel = 'default') {
public function generateVideo($prompt, $duration = 4, $aspectRatio = '16:9', $resolution = '720p', $generateAudio = true, $referenceImages = [], $referenceMode = 'frame', $negativePrompt = '') {
// Build the instance object
$instance = [
'prompt' => $prompt
@ -152,29 +60,19 @@ class VeoVideoAPI {
'sampleCount' => 1
];
// Person generation: T2V supports 'allow_all', but I2V only supports 'allow_adult'
// Note: 'allow_adult' means children will still be blocked in I2V mode (Google restriction)
if (!empty($referenceImages)) {
$parameters['personGeneration'] = 'allow_adult';
} else {
$parameters['personGeneration'] = 'allow_all';
}
// Frame mode: first image is starting frame, optional second image is last frame for interpolation
// Images are resized to exact Veo dimensions to prevent letterboxing from non-standard Gemini output sizes
if (!empty($referenceImages)) {
if (isset($referenceImages[0])) {
$refImg = $referenceImages[0];
$data = preg_replace('/\s+/', '', $refImg['data']);
if (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $data)) {
$mimeType = $refImg['mime_type'] ?? 'image/jpeg';
$data = $this->resizeImageForVeo($data, $mimeType, $aspectRatio);
// Use bytesBase64Encoded format (original working format)
$instance['image'] = [
'bytesBase64Encoded' => $data,
'mimeType' => $mimeType
'mimeType' => $refImg['mime_type'] ?? 'image/jpeg'
];
error_log("Added first frame for I2V generation (resized to match $aspectRatio)");
error_log("Added first frame for I2V generation");
}
}
@ -186,11 +84,10 @@ class VeoVideoAPI {
$lastData = preg_replace('/\s+/', '', $lastImg['data']);
if (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $lastData)) {
$lastMimeType = $lastImg['mime_type'] ?? 'image/jpeg';
$lastData = $this->resizeImageForVeo($lastData, $lastMimeType, $aspectRatio);
// Use bytesBase64Encoded format, placed in instance (same level as image)
$instance['lastFrame'] = [
'bytesBase64Encoded' => $lastData,
'mimeType' => $lastMimeType
'mimeType' => $lastImg['mime_type'] ?? 'image/jpeg'
];
error_log("Added lastFrame to instance for video interpolation (8s duration)");
}
@ -199,9 +96,12 @@ class VeoVideoAPI {
}
}
// Duration: Veo 3.1 supports 4, 6, or 8 seconds (API requires string values)
$validDuration = in_array(intval($duration), [4, 6, 8]) ? intval($duration) : 4;
$parameters['durationSeconds'] = $validDuration;
// Duration: Veo 3.1 supports 4, 6, or 8 seconds
if (in_array(intval($duration), [4, 6, 8])) {
$parameters['durationSeconds'] = intval($duration);
} else {
$parameters['durationSeconds'] = 4;
}
// Resolution: 720p (default), 1080p (8s only), 4k (8s only)
// Per API docs: higher resolutions only available for 8-second videos
@ -226,11 +126,6 @@ class VeoVideoAPI {
error_log("Negative prompt: " . substr($negativePrompt, 0, 200));
}
// personGeneration: "allow_all" = least restrictive (T2V only; I2V is limited to "allow_adult")
if ($safetyLevel === 'relaxed') {
$parameters['personGeneration'] = 'allow_all';
}
// Note: generateAudio is handled automatically by Veo 3.1
// The model generates audio natively based on the scene
// No need to explicitly pass this parameter
@ -648,63 +543,6 @@ try {
$modelType = 'standard';
}
// Prompt optimization — runs server-side so the API key stays on the server
if ($action === 'optimize_prompt') {
$systemPrompt = $_POST['systemPrompt'] ?? '';
if (empty($systemPrompt)) {
throw new Exception('systemPrompt is required');
}
$parts = [];
$imageCount = intval($_POST['imageCount'] ?? 0);
for ($i = 0; $i < min($imageCount, 2); $i++) {
$label = $_POST["imageLabel_$i"] ?? '';
$imageData = $_POST["imageData_$i"] ?? '';
$imageMime = $_POST["imageMime_$i"] ?? 'image/jpeg';
if (!empty($label)) { $parts[] = ['text' => $label]; }
if (!empty($imageData)) {
$imageData = preg_replace('/^data:[^;]+;base64,/', '', $imageData);
$imageData = preg_replace('/\s+/', '', $imageData);
$parts[] = ['inlineData' => ['mimeType' => $imageMime, 'data' => $imageData]];
}
}
$parts[] = ['text' => $systemPrompt];
$geminiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' . GEMINI_API_KEY;
$geminiPayload = json_encode([
'contents' => [['parts' => $parts]],
'generationConfig' => ['temperature' => 0.7, 'maxOutputTokens' => 1024]
]);
$ch = curl_init($geminiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $geminiPayload,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 30
]);
$geminiResp = curl_exec($ch);
$geminiCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($geminiCode === 429) {
echo json_encode(['success' => false, 'rateLimited' => true]);
exit;
}
if ($geminiCode !== 200) {
throw new Exception("Gemini optimization error: HTTP $geminiCode");
}
$geminiResult = json_decode($geminiResp, true);
$optimizedText = $geminiResult['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$optimizedText) {
throw new Exception('Empty response from Gemini optimization');
}
echo json_encode(['success' => true, 'optimizedPrompt' => trim($optimizedText)]);
exit;
}
$api = new VeoVideoAPI(GEMINI_API_KEY, $modelType);
// Handle generate action
@ -716,7 +554,6 @@ try {
$generateAudio = ($_POST['generateAudio'] ?? 'true') === 'true';
$referenceMode = $_POST['referenceMode'] ?? 'frame';
$negativePrompt = $_POST['negativePrompt'] ?? '';
$safetyLevel = ($_POST['safetyLevel'] ?? 'default') === 'relaxed' ? 'relaxed' : 'default';
// Validate reference mode
if (!in_array($referenceMode, ['frame', 'subject'])) {
@ -752,7 +589,7 @@ try {
error_log("Starting video generation: duration=$duration, aspect=$aspectRatio, audio=$generateAudio, refMode=$referenceMode, refCount=" . count($referenceImages));
// Generate video
$response = $api->generateVideo($prompt, $duration, $aspectRatio, $resolution, $generateAudio, $referenceImages, $referenceMode, $negativePrompt, $safetyLevel);
$response = $api->generateVideo($prompt, $duration, $aspectRatio, $resolution, $generateAudio, $referenceImages, $referenceMode, $negativePrompt);
$videoData = $api->extractVideoData($response);
// Handle different response types

View file

@ -1,96 +0,0 @@
#!/bin/bash
################################################################################
# Lux Studio — Optical-Prod Deployment Script
#
# Usage:
# cd /opt/cinema-studio-pro
# ./deploy-optical.sh
#
# Target: https://optical-prod.oliver.solutions/lux-studio/
#
# What it does:
# 1. git pull (plaiground branch)
# 2. docker compose build + up
# 3. Adds Apache include once (idempotent on re-deploy)
# 4. Reloads Apache
################################################################################
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APACHE_VHOST="/etc/apache2/sites-enabled/optical-prod.oliver.solutions.conf"
APACHE_INCLUDE="Include /opt/cinema-studio-pro/deploy/apache-lux-studio.conf"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
step() { echo ""; echo -e "${YELLOW}[$1] $2...${NC}"; }
ok() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
fail() { echo -e "${RED}$1${NC}"; exit 1; }
echo ""
echo -e "${BLUE}================================================"
echo -e " Lux Studio — optical-prod deploy"
echo -e "================================================${NC}"
echo " Dir: $SCRIPT_DIR"
echo " URL: https://optical-prod.oliver.solutions/lux-studio/"
echo ""
# ── 1. git pull ───────────────────────────────────────────────────────────────
step "1/4" "git pull"
cd "$SCRIPT_DIR"
git pull origin plaiground
ok "Code up to date ($(git rev-parse --short HEAD))"
# ── 2. Docker build ───────────────────────────────────────────────────────────
step "2/4" "Building image (no-cache)"
[ -f "backend/.env.optical" ] || fail "backend/.env.optical not found"
# Build first — if build fails the running container stays up (service never goes down)
docker compose -f docker-compose.prod.yml build --no-cache
ok "Image built"
# Only replace running container after successful build
docker compose -f docker-compose.prod.yml up -d --force-recreate
ok "Container up"
# Brief health check
sleep 3
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
ok "Container is running"
else
fail "Container failed to start — check: docker compose -f docker-compose.prod.yml logs"
fi
# ── 3. Apache include (idempotent) ────────────────────────────────────────────
step "3/4" "Apache include"
if grep -qF "$APACHE_INCLUDE" "$APACHE_VHOST" 2>/dev/null; then
ok "Include already present — skipping"
else
if [ -f "$APACHE_VHOST" ]; then
# Insert include before closing </VirtualHost>
sudo sed -i "s|</VirtualHost>| $APACHE_INCLUDE\n</VirtualHost>|" "$APACHE_VHOST"
ok "Include added to $APACHE_VHOST"
else
warn "$APACHE_VHOST not found — add manually:"
warn " $APACHE_INCLUDE"
fi
fi
# ── 4. Reload Apache ──────────────────────────────────────────────────────────
step "4/4" "Reloading Apache"
sudo apache2ctl configtest 2>&1 | grep -v "^$" || true
sudo systemctl reload apache2
ok "Apache reloaded"
echo ""
echo -e "${GREEN} Deploy complete!${NC}"
echo -e " → https://optical-prod.oliver.solutions/lux-studio/"
echo ""
echo -e "${BLUE} Useful commands:${NC}"
echo " Logs: docker compose -f docker-compose.prod.yml logs -f"
echo " Restart: docker compose -f docker-compose.prod.yml restart"
echo " Re-deploy: cd $SCRIPT_DIR && ./deploy-optical.sh"
echo ""

View file

@ -1,7 +0,0 @@
# Lux Studio — optical-prod Apache routing
# Include this in /etc/apache2/sites-enabled/optical-prod.oliver.solutions.conf:
# Include /opt/cinema-studio-pro/deploy/apache-lux-studio.conf
ProxyPreserveHost On
ProxyPass /lux-studio/ http://127.0.0.1:8085/lux-studio/
ProxyPassReverse /lux-studio/ http://127.0.0.1:8085/lux-studio/

View file

@ -1,14 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- ./backend/.env.optical:/var/www/html/lux-studio/api/.env:ro
- uploads:/var/www/html/lux-studio/api/uploads
ports:
- "127.0.0.1:8085:80"
restart: unless-stopped
volumes:
uploads:

View file

@ -1,45 +0,0 @@
# ── Stage 1: build frontend ───────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /build
COPY frontend/package*.json ./
RUN npm ci --quiet
COPY frontend/ ./
COPY frontend/.env.optical .env.production
RUN npm run build
# ── Stage 2: runtime (nginx + php-fpm) ────────────────────────────────────────
FROM php:8.2-fpm-alpine
RUN apk add --no-cache nginx supervisor libpng-dev libjpeg-turbo-dev zlib-dev \
&& docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& php -m | grep -i gd && echo "GD installed OK" || echo "GD missing — resize disabled"
# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Backend PHP files + deps
WORKDIR /var/www/html/lux-studio/api
COPY backend/*.php ./
COPY backend/composer.json ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-security-blocking
# Built frontend
COPY --from=builder /build/dist /var/www/html/lux-studio
# nginx + supervisord config
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisord.conf
# uploads dir (overridden by named volume at runtime)
RUN mkdir -p /var/www/html/lux-studio/api/uploads/sessions \
/var/www/html/lux-studio/api/generated_videos \
&& chown -R www-data:www-data /var/www/html/lux-studio \
&& chmod -R 777 /var/www/html/lux-studio/api/uploads \
&& chmod -R 777 /var/www/html/lux-studio/api/generated_videos
# PHP limits for large image uploads (ini_set can't override these at runtime)
RUN printf "post_max_size = 100M\nupload_max_filesize = 100M\nmemory_limit = 512M\n" \
> /usr/local/etc/php/conf.d/uploads.ini
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

View file

@ -1,45 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
client_max_body_size 100M;
server {
listen 80;
root /var/www/html;
# Block sensitive backend files
location ~ ^/lux-studio/api/(\.env|composer\.|vendor/|\.htaccess|\.git) {
return 404;
}
# Block uploads dir served only via stream_video.php
location ^~ /lux-studio/api/uploads/ {
return 403;
}
# Block internal-only PHP classes
location ~ ^/lux-studio/api/(AuthMiddleware|JWTValidator|session_manager|env_loader|config)\.php$ {
return 403;
}
# PHP API pass to php-fpm
location ~ ^/lux-studio/api/(.+\.php)$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/lux-studio/api/$1;
fastcgi_param DOCUMENT_ROOT /var/www/html/lux-studio/api;
fastcgi_read_timeout 300;
}
# Frontend SPA fallback to index.html for React Router
location /lux-studio/ {
try_files $uri $uri/ /lux-studio/index.html;
}
}
}

View file

@ -1,23 +0,0 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View file

@ -37,4 +37,4 @@ FRONTEND_URL=http://localhost:3000
# Backend authentication is DISABLED - Frontend handles SSO
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9

View file

@ -1,20 +0,0 @@
# ============================================================================
# Lux Studio Frontend - OPTICAL-DEV Environment Configuration
# ============================================================================
# Target: https://optical-prod.oliver.solutions/lux-studio/
# Usage: sudo ./deploy-optical.sh
# ============================================================================
VITE_BASE_PATH=/lux-studio/
VITE_API_URL=https://optical-prod.oliver.solutions/lux-studio/api
VITE_GEMINI_API_KEY=AIzaSyDs7EKdC9NLM5UqWlGUqeQO96TmSA-kos8
VITE_SSO_ENABLED=true
VITE_SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
VITE_SSO_REDIRECT_URI=https://optical-prod.oliver.solutions/lux-studio/
NODE_ENV=production

View file

@ -41,7 +41,7 @@ VITE_GEMINI_API_KEY=AIzaSyDs7EKdC9NLM5UqWlGUqeQO96TmSA-kos8
VITE_SSO_ENABLED=true
# Production credentials
VITE_SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
VITE_SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
# ----------------------------------------------------------------------------
# SSO Redirect URI (REQUIRED)

View file

@ -2,9 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/LUX_STUDIO_LOGO.svg" />
<link rel="icon" type="image/png" href="/LUX_STUDIO_LOGO.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View file

@ -8,7 +8,6 @@ export const msalConfig = {
clientId: import.meta.env.VITE_SSO_CLIENT_ID || '',
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_SSO_TENANT_ID || 'common'}`,
redirectUri: import.meta.env.VITE_SSO_REDIRECT_URI || window.location.origin,
postLogoutRedirectUri: import.meta.env.VITE_SSO_REDIRECT_URI || window.location.origin,
navigateToLoginRequestUrl: false,
},
cache: {

View file

@ -1,258 +0,0 @@
import { useState, useEffect } from 'react';
import { useMsal } from '@azure/msal-react';
import { X, Eye, EyeOff, CheckCircle, AlertCircle, Settings } from 'lucide-react';
import { isSSOEnabled } from '../authConfig';
function AdminSettings({ onClose }) {
const { instance, accounts } = useMsal();
const ssoEnabled = isSSOEnabled();
const [status, setStatus] = useState(null);
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [fetchError, setFetchError] = useState(null);
const [saveError, setSaveError] = useState(null);
const getApiUrl = (endpoint) => {
if (import.meta.env.DEV) return `/api/${endpoint}`;
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5015';
return `${apiUrl}/${endpoint}`;
};
const getAuthHeaders = async () => {
const headers = { 'Content-Type': 'application/json' };
if (!ssoEnabled || accounts.length === 0) return headers;
try {
const result = await instance.acquireTokenSilent({
scopes: ['User.Read'],
account: accounts[0],
});
if (result.idToken) headers['Authorization'] = `Bearer ${result.idToken}`;
} catch (e) {
// acquireTokenSilent failed proceed without token (backend will 403)
}
return headers;
};
useEffect(() => {
(async () => {
try {
const headers = await getAuthHeaders();
const res = await fetch(getApiUrl('admin_api.php?action=status'), { headers });
if (!res.ok) throw new Error('Not authorized');
const data = await res.json();
setStatus(data.kling);
} catch (e) {
setFetchError(e.message);
}
})();
}, []);
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const headers = await getAuthHeaders();
const res = await fetch(getApiUrl('admin_api.php?action=test_kling'), {
method: 'POST',
headers,
body: JSON.stringify({ access_key: accessKey, secret_key: secretKey }),
});
const data = await res.json();
setTestResult(data);
} catch (e) {
setTestResult({ ok: false, error: e.message });
} finally {
setTesting(false);
}
};
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const headers = await getAuthHeaders();
const res = await fetch(getApiUrl('admin_api.php?action=update_kling'), {
method: 'POST',
headers,
body: JSON.stringify({ access_key: accessKey, secret_key: secretKey }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Save failed');
setStatus(data.kling);
setAccessKey('');
setSecretKey('');
setTestResult(null);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (e) {
setSaveError(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded p-6 max-w-xl w-full">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-cinema-gold" />
<h2 className="text-xl font-normal text-slate-200">Admin Settings</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-slate-700 rounded transition-colors"
title="Close"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{fetchError ? (
<div className="flex items-center gap-2 text-sm px-3 py-2 rounded bg-red-900/40 text-red-300">
<AlertCircle className="w-4 h-4 flex-shrink-0" /> {fetchError}
</div>
) : (
<>
{/* Current status */}
<div className="mb-6">
<h3 className="text-xs font-normal text-slate-400 uppercase tracking-wider mb-3">
Kling AI Credentials
</h3>
{status ? (
<div className="bg-slate-900 rounded p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">Access Key</span>
<span className="font-mono text-slate-300">
{status.access_key_masked ?? '— not set —'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Secret Key</span>
<span className="font-mono text-slate-300">
{status.secret_set ? '••• set •••' : '— not set —'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Source</span>
<span className={`font-mono text-sm ${status.source === 'runtime' ? 'text-cinema-gold' : 'text-slate-300'}`}>
{status.source}
</span>
</div>
{status.updated_at && (
<div className="flex justify-between">
<span className="text-slate-400">Last updated</span>
<span className="text-slate-300 text-xs">
{status.updated_at} {status.updated_by}
</span>
</div>
)}
</div>
) : (
<div className="text-slate-500 text-sm">Loading</div>
)}
</div>
{/* Rotate credentials */}
<div className="space-y-4">
<h3 className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Rotate Credentials
</h3>
<div>
<label className="block text-sm text-slate-400 mb-1">New Access Key</label>
<input
type="text"
value={accessKey}
onChange={e => { setAccessKey(e.target.value); setTestResult(null); }}
placeholder="Access key from platform.klingai.com"
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm font-mono"
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">New Secret Key</label>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
value={secretKey}
onChange={e => { setSecretKey(e.target.value); setTestResult(null); }}
placeholder="Secret key from platform.klingai.com"
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm font-mono pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowSecret(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-200"
>
{showSecret
? <EyeOff className="w-4 h-4" />
: <Eye className="w-4 h-4" />
}
</button>
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded ${
testResult.ok
? 'bg-emerald-900/40 text-emerald-300'
: 'bg-red-900/40 text-red-300'
}`}>
{testResult.ok
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> Connection successful</>
: <><AlertCircle className="w-4 h-4 flex-shrink-0" /> {testResult.error}</>
}
</div>
)}
{/* Save error */}
{saveError && (
<div className="flex items-center gap-2 text-sm px-3 py-2 rounded bg-red-900/40 text-red-300">
<AlertCircle className="w-4 h-4 flex-shrink-0" /> {saveError}
</div>
)}
{/* Save success */}
{saveSuccess && (
<div className="flex items-center gap-2 text-sm px-3 py-2 rounded bg-cinema-gold/20 text-cinema-gold">
<CheckCircle className="w-4 h-4 flex-shrink-0" /> Credentials updated successfully
</div>
)}
<div className="flex gap-3 pt-2">
<button
onClick={handleTest}
disabled={!accessKey || !secretKey || testing}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-600 text-slate-300 rounded text-sm transition-all"
>
{testing ? 'Testing…' : 'Test Connection'}
</button>
<button
onClick={handleSave}
disabled={!accessKey || !secretKey || saving}
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded text-sm font-normal transition-all"
>
{saving ? 'Saving…' : 'Save Credentials'}
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}
export default AdminSettings;

View file

@ -1,13 +1,12 @@
import { useState, useCallback, useEffect } from 'react';
import { useState } from 'react';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { LogOut, Settings } from 'lucide-react';
import { LogOut } from 'lucide-react';
import { isSSOEnabled } from '../authConfig';
import TabNavigation from './TabNavigation';
import CinePromptStudio from './CinePromptStudio';
import VideoGenTab from './VideoGenTab';
import ProjectsTab from './ProjectsTab';
import LoginPage from './LoginPage';
import AdminSettings from './AdminSettings';
function AppContent() {
// Check if SSO is enabled
@ -23,45 +22,6 @@ function AppContent() {
const [activeProjectName, setActiveProjectName] = useState(null);
const [videoRerunData, setVideoRerunData] = useState(null);
const [imageEditData, setImageEditData] = useState(null);
const [imageGenBusy, setImageGenBusy] = useState(false);
const [videoGenBusy, setVideoGenBusy] = useState(false);
const isAnyGenerating = imageGenBusy || videoGenBusy;
const [isAdmin, setIsAdmin] = useState(false);
const [showAdminSettings, setShowAdminSettings] = useState(false);
useEffect(() => {
const checkAdmin = async () => {
const headers = {};
if (ssoEnabled && accounts.length > 0) {
try {
const result = await instance.acquireTokenSilent({
scopes: ['User.Read'],
account: accounts[0],
});
if (result.idToken) headers['Authorization'] = `Bearer ${result.idToken}`;
} catch (e) {
return;
}
} else if (ssoEnabled) {
return;
}
try {
const apiUrl = import.meta.env.DEV
? '/api/admin_api.php?action=status'
: `${import.meta.env.VITE_API_URL || 'http://localhost:5015'}/admin_api.php?action=status`;
const res = await fetch(apiUrl, { headers });
if (res.ok) setIsAdmin(true);
} catch (e) {
// Network error or 403 not admin
}
};
if (!ssoEnabled || isAuthenticated) {
checkAdmin();
}
}, [isAuthenticated, ssoEnabled]);
// Show login page if SSO is enabled and user is not authenticated
if (ssoEnabled && !isAuthenticated) {
@ -75,12 +35,8 @@ function AppContent() {
// Logout handler
const handleLogout = () => {
if (instance && accounts.length > 0) {
// Sign out of the app only keeps Microsoft session alive for seamless re-login
instance.logoutRedirect({
account: accounts[0],
onRedirectNavigate: () => false,
});
if (instance) {
instance.logoutPopup();
}
};
@ -108,11 +64,6 @@ function AppContent() {
// Handler for project selection from ProjectsTab
const handleProjectSelect = (id, name) => {
if (isAnyGenerating && id !== activeProjectId) {
if (!window.confirm('A generation is still in progress. Switching projects will cancel it. Continue?')) {
return;
}
}
setActiveProjectId(id);
setActiveProjectName(name);
};
@ -131,7 +82,7 @@ function AppContent() {
{/* Header */}
<div className="max-w-7xl mx-auto px-6 pt-10 pb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-10">
<div className="flex items-center gap-6">
<img
src={`${import.meta.env.BASE_URL}LUX_STUDIO_LOGO.svg`}
alt="Lux Studio"
@ -147,10 +98,10 @@ function AppContent() {
<div className="flex flex-col items-end gap-1">
{activeProjectName && (
<div className="text-sm text-slate-400">
Working in: <span className="text-cinema-gold font-normal">{activeProjectName}</span>
Working in: <span className="text-cinema-gold font-medium">{activeProjectName}</span>
</div>
)}
<div className="text-xs font-mono text-slate-500">
<div className="text-xs text-slate-500">
Version 1.0
</div>
</div>
@ -159,19 +110,10 @@ function AppContent() {
<div className="text-right">
<div className="text-sm text-slate-300">{userName}</div>
</div>
{isAdmin && (
<button
onClick={() => setShowAdminSettings(true)}
className="p-2 hover:bg-slate-700 rounded transition-colors"
title="Admin Settings"
>
<Settings className="w-5 h-5 text-slate-400" />
</button>
)}
{ssoEnabled && (
<button
onClick={handleLogout}
className="p-2 hover:bg-slate-700 rounded transition-colors"
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
title="Logout"
>
<LogOut className="w-5 h-5 text-slate-400" />
@ -183,41 +125,29 @@ function AppContent() {
</div>
</div>
{showAdminSettings && (
<AdminSettings onClose={() => setShowAdminSettings(false)} />
)}
{/* Main Content - Tab Panels (always mounted so async generation persists across tab switches) */}
{/* Main Content - Tab Panels */}
<main className="max-w-7xl mx-auto px-6 pb-8">
<div className={activeTab === 'projects' ? '' : 'hidden'}>
{activeTab === 'projects' && (
<ProjectsTab
onProjectSelect={handleProjectSelect}
activeProjectId={activeProjectId}
onRerunVideo={handleRerunVideo}
onEditInImageGen={handleEditInImageGen}
/>
</div>
{activeProjectId && (
<>
<div className={activeTab === 'image' ? '' : 'hidden'}>
<CinePromptStudio
activeProjectId={activeProjectId}
editData={imageEditData}
onEditLoaded={handleEditLoaded}
onBusyChange={setImageGenBusy}
isVisible={activeTab === 'image'}
/>
</div>
<div className={activeTab === 'video' ? '' : 'hidden'}>
<VideoGenTab
activeProjectId={activeProjectId}
rerunData={videoRerunData}
onRerunLoaded={handleRerunLoaded}
onBusyChange={setVideoGenBusy}
isVisible={activeTab === 'video'}
/>
</div>
</>
)}
{activeTab === 'image' && (
<CinePromptStudio
activeProjectId={activeProjectId}
editData={imageEditData}
onEditLoaded={handleEditLoaded}
/>
)}
{activeTab === 'video' && (
<VideoGenTab
activeProjectId={activeProjectId}
rerunData={videoRerunData}
onRerunLoaded={handleRerunLoaded}
/>
)}
</main>
</div>

View file

@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import { Sparkles, Copy, Film, Camera, Sun, Check, Loader2, HelpCircle, Info, Sliders, X, Image, Download, RefreshCw, Upload, Plus, FolderOpen, Settings, Trash2, Pencil, Shield, Maximize2, Minimize2 } from 'lucide-react';
import { GoogleGenerativeAI } from '@google/generative-ai';
import useProjects from '../hooks/useProjects';
import useCustomPresets from '../hooks/useCustomPresets';
const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded, onBusyChange, isVisible }) => {
const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded }) => {
// API URL helper - uses Vite proxy in dev, direct URL in production
const getApiUrl = (endpoint) => {
// In development, use Vite proxy to avoid CORS
@ -725,20 +725,12 @@ const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded, onBusyChang
const [imageError, setImageError] = useState('');
const [referenceImages, setReferenceImages] = useState([]);
const [imageResolution, setImageResolution] = useState('2K');
const [imageModelType, setImageModelType] = useState('pro');
const [relaxedSafety, setRelaxedSafety] = useState(false);
const [imagePreviewExpanded, setImagePreviewExpanded] = useState(false);
// Project images state (for "Add from Library" feature)
const [projectImages, setProjectImages] = useState([]);
const [showProjectPicker, setShowProjectPicker] = useState(false);
// Image Model Options
const imageModelOptions = [
{ value: 'pro', label: 'Pro', description: 'Higher quality' },
{ value: 'flash', label: 'Flash', description: 'Faster, lower cost' }
];
// Effect: Auto-select camera and lens based on application
useEffect(() => {
const selectedApp = allApplicationData.find(app => app.value === application);
@ -815,34 +807,29 @@ const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded, onBusyChang
}
}, [editData, onEditLoaded]);
// Refresh project images from IndexedDB
const refreshProjectImages = useCallback(async () => {
if (!activeProjectId || !dbReady) {
setProjectImages([]);
return;
}
try {
const project = await getProjectWithItems(activeProjectId);
if (project && project.items) {
setProjectImages(project.items.filter(item => item.type === 'image'));
// Fetch project images when activeProjectId changes
useEffect(() => {
const loadProjectImages = async () => {
if (!activeProjectId || !dbReady) {
setProjectImages([]);
return;
}
} catch (err) {
console.error('Failed to load project images:', err);
}
try {
const project = await getProjectWithItems(activeProjectId);
if (project && project.items) {
// Filter for image items only
const images = project.items.filter(item => item.type === 'image');
setProjectImages(images);
}
} catch (err) {
console.error('Failed to load project images:', err);
}
};
loadProjectImages();
}, [activeProjectId, dbReady, getProjectWithItems]);
// Fetch on mount and when project changes
useEffect(() => {
refreshProjectImages();
}, [refreshProjectImages]);
// Refresh when tab becomes visible (picks up images added in other tabs)
useEffect(() => {
if (isVisible) {
refreshProjectImages();
}
}, [isVisible, refreshProjectImages]);
// Add image from project to reference images
const addProjectImageToReference = (item) => {
if (referenceImages.length >= 14) return;
@ -1158,7 +1145,6 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
}
setIsGeneratingImage(true);
onBusyChange?.(true);
setImageError('');
try {
@ -1172,7 +1158,6 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
formData.append('prompt', finalPrompt);
formData.append('aspectRatio', aspectRatio);
formData.append('imageSize', imageResolution);
formData.append('modelType', imageModelType);
// If we have a generatedImage (from library edit or previous generation),
// send it to the backend so it uses the correct image for editing
@ -1187,7 +1172,6 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
formData.append(`referenceImageType_${index}`, img.mime_type);
});
formData.append('referenceImageCount', referenceImages.length.toString());
formData.append('safetyLevel', relaxedSafety ? 'relaxed' : 'default');
const response = await fetch(getApiUrl('api.php'), {
method: 'POST',
@ -1213,12 +1197,11 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
await addItemToProject(activeProjectId, {
type: 'image',
prompt: generatedPrompt,
settings: { camera, lens, application, aspectRatio, imageResolution, imageModelType },
settings: { camera, lens, application, aspectRatio, imageResolution },
thumbnail: null,
data: imageData.data,
mimeType: imageData.mime_type
});
refreshProjectImages();
} catch (saveErr) {
console.error('Failed to save to project:', saveErr);
}
@ -1233,7 +1216,6 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
setImageError(`Network error: ${err.message}. Make sure backend service is running.`);
} finally {
setIsGeneratingImage(false);
onBusyChange?.(false);
}
};
@ -1409,13 +1391,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
return (
<div
className={`absolute z-50 p-3 bg-slate-900 text-slate-300 rounded max-w-xs pointer-events-none
className={`absolute z-50 p-3 bg-slate-900 border border-slate-700 text-slate-300 rounded-lg shadow-2xl max-w-xs pointer-events-none
${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'} left-0 right-0`}
style={{ textTransform: 'none' }}
>
<div className="relative text-xs leading-relaxed font-normal tracking-normal">
<span style={{ textTransform: 'none', fontWeight: 'normal' }}>{text}</span>
<div className={`absolute w-2 h-2 bg-slate-900 transform rotate-45
<div className={`absolute w-2 h-2 bg-slate-900 border-l border-t border-slate-700 transform rotate-45
${position === 'top' ? 'bottom-[-5px]' : 'top-[-5px] rotate-[225deg]'} left-6`}></div>
</div>
</div>
@ -1428,15 +1410,15 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Left Column: Controls */}
<div className="lg:col-span-4">
<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">
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-6 space-y-6">
<h2 className="text-lg font-bold text-slate-200 flex items-center space-x-2">
<Camera className="w-5 h-5 text-cinema-gold" />
<span>Technical Specs</span>
</h2>
{/* Application/Lighting Preset */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center gap-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
Preset
<span className="text-xs font-normal normal-case text-slate-500">Lighting / application presets + Styles</span>
{customPresets.length > 0 && (
@ -1459,7 +1441,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
setApplication(val);
}
}}
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
>
{customPresets.length > 0 && (
<optgroup label="My Custom Presets">
@ -1548,13 +1530,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Camera Body */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Camera Body
</label>
<select
value={camera}
onChange={(e) => setCamera(e.target.value)}
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
>
{cameraData.map((cam) => (
<option key={cam.value} value={cam.value}>{cam.display}</option>
@ -1567,13 +1549,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Lens Kit */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Lens Kit
</label>
<select
value={lens}
onChange={(e) => setLens(e.target.value)}
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all appearance-none cursor-pointer"
>
{compatibleLenses.map((l) => (
<option key={l.value} value={l.value}>{l.display}</option>
@ -1586,7 +1568,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Aspect Ratio */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Aspect Ratio
</label>
<div className="flex flex-wrap gap-2">
@ -1594,7 +1576,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<button
key={ratio}
onClick={() => setAspectRatio(ratio)}
className={`px-4 py-2 rounded font-normal transition-all ${
className={`px-4 py-2 rounded-lg font-medium transition-all ${
aspectRatio === ratio
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
@ -1607,7 +1589,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{/* Info Box */}
<div className="bg-slate-800 rounded p-4 text-xs text-slate-400">
<div className="bg-slate-800/50 rounded-lg p-4 text-xs text-slate-400">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
@ -1617,9 +1599,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{/* Creative Freedom Slider - Moved to left column */}
<div className="space-y-3 pt-4">
<div className="space-y-3 pt-4 border-t border-slate-800">
<div className="flex items-center justify-between">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center gap-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Sliders className="w-4 h-4" />
Creative Freedom
</label>
@ -1633,7 +1615,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
max="100"
value={creativeFreedom * 100}
onChange={(e) => setCreativeFreedom(e.target.value / 100)}
className="w-full h-2 bg-slate-700 rounded appearance-none cursor-pointer slider"
className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #f59e0b 0%, #f59e0b ${creativeFreedom * 100}%, #475569 ${creativeFreedom * 100}%, #475569 100%)`
}}
@ -1646,14 +1628,14 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Scene Description - Moved to left column */}
<div className="space-y-3">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Scene Description
</label>
<textarea
value={sceneDescription}
onChange={(e) => setSceneDescription(e.target.value)}
placeholder="Describe your scene..."
className="w-full h-28 px-4 py-3 bg-slate-800 border border-slate-700 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all resize-none text-sm"
className="w-full h-28 px-4 py-3 bg-slate-900/50 border border-slate-800 rounded-xl text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none focus:ring-2 focus:ring-cinema-gold/20 transition-all resize-none text-sm"
/>
{/* Action Buttons */}
@ -1661,7 +1643,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<button
onClick={generateOptimizedPrompt}
disabled={isGenerating}
className="flex items-center justify-center space-x-2 w-full 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"
className="flex items-center justify-center space-x-2 w-full px-4 py-3 bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
@ -1678,14 +1660,14 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<button
onClick={generateSimplePrompt}
className="w-full px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-normal rounded transition-all"
className="w-full px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium rounded-lg transition-all"
>
Simple Generate
</button>
</div>
{error && (
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded px-4 py-2">
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded-lg px-4 py-2">
{error}
</div>
)}
@ -1696,9 +1678,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Right Column: Output & Image Generation */}
<div className="lg:col-span-8 space-y-6">
{/* Generated Prompt Output */}
<div className="relative bg-slate-925 rounded overflow-hidden">
<div className="relative bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 rounded-xl overflow-hidden">
<div className="p-6 space-y-4">
<h3 className="text-xs font-normal text-slate-400 uppercase tracking-wider">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Optimized Prompt
</h3>
<div className="flex items-center gap-1.5 text-xs text-emerald-400/70 mt-1">
@ -1716,10 +1698,10 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{generatedPrompt && (
<div className="flex items-center justify-between pt-4">
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<button
onClick={copyToClipboard}
className="flex items-center space-x-2 px-5 py-2.5 bg-white hover:bg-slate-100 text-slate-950 font-normal rounded transition-all"
className="flex items-center space-x-2 px-5 py-2.5 bg-white hover:bg-slate-100 text-slate-950 font-medium rounded-lg transition-all transform hover:scale-105"
>
{copied ? (
<>
@ -1734,7 +1716,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
)}
</button>
<div className="text-xs font-mono text-slate-500">
<div className="text-xs text-slate-500">
{generatedPrompt.split(' ').length} words
</div>
</div>
@ -1742,12 +1724,10 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
</div>
<div className="border-t border-slate-800" />
{/* Image Generation Panel */}
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="bg-slate-900/50 border border-slate-800 rounded-xl 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">
<h3 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<Image className="w-5 h-5 text-cinema-gold" />
Generated Image
</h3>
@ -1758,7 +1738,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</span>
<button
onClick={resetImage}
className="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-all flex items-center gap-1"
className="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg transition-all flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
Start Fresh
@ -1770,7 +1750,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Reference Images Upload */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Reference Images (Optional)</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Reference Images (Optional)</label>
<span className="text-xs text-slate-500">{referenceImages.length}/14</span>
</div>
<div className="flex flex-wrap gap-2">
@ -1779,14 +1759,11 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<img
src={`data:${img.mime_type};base64,${img.data}`}
alt={img.name}
className="w-24 h-24 object-cover rounded border-2 border-cinema-gold"
className="w-12 h-12 object-cover rounded-lg border border-slate-700"
/>
<span className="absolute bottom-0 left-0 right-0 bg-black/70 text-[9px] text-center text-slate-300 py-0.5">
Ref {index + 1}
</span>
<button
onClick={() => removeReferenceImage(index)}
className="absolute -top-1.5 -right-1.5 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"
className="absolute -top-1 -right-1 w-4 h-4 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>
@ -1797,7 +1774,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{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 ${
className={`w-12 h-12 flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-colors ${
showProjectPicker
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-cinema-gold'
@ -1805,12 +1782,12 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
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>
<span className="text-[8px] 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">
<label className="w-12 h-12 flex flex-col items-center justify-center border-2 border-dashed border-slate-700 hover:border-slate-600 rounded-lg cursor-pointer transition-colors">
<Plus className="w-4 h-4 text-slate-500" />
<span className="text-[9px] text-slate-500 mt-0.5">Upload</span>
<span className="text-[8px] text-slate-500 mt-0.5">Upload</span>
<input
type="file"
accept="image/*"
@ -1825,9 +1802,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Project Image Picker Dropdown */}
{showProjectPicker && projectImages.length > 0 && (
<div className="bg-slate-800 rounded p-3">
<div className="bg-slate-800 border border-slate-700 rounded-lg 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>
<span className="text-xs font-bold text-slate-400">From Project</span>
<button
onClick={() => setShowProjectPicker(false)}
className="text-slate-500 hover:text-slate-300"
@ -1835,7 +1812,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
<div className="grid grid-cols-4 gap-2 max-h-32 overflow-y-auto">
{projectImages.map((item) => (
<button
key={item.id}
@ -1863,36 +1840,15 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</p>
</div>
{/* Model Selector */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Model</label>
<div className="flex gap-2">
{imageModelOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setImageModelType(opt.value)}
className={`flex-1 px-3 py-2 rounded font-normal transition-all text-sm ${
imageModelType === 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>
{/* Resolution Selector */}
<div className="space-y-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">Output Resolution</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Output Resolution</label>
<div className="flex gap-2">
{['1K', '2K', '4K'].map((res) => (
<button
key={res}
onClick={() => setImageResolution(res)}
className={`px-4 py-2 rounded text-sm font-normal transition-all ${
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
imageResolution === res
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
@ -1902,29 +1858,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</button>
))}
</div>
<p className="text-xs font-mono text-slate-500">
<p className="text-xs text-slate-500">
{imageResolution === '1K' && '~1024px - Fastest, web/social'}
{imageResolution === '2K' && '~2048px - Recommended for most uses'}
{imageResolution === '4K' && '~4096px - Print quality, slower'}
</p>
</div>
{/* Safety Filter Toggle */}
<div className="flex items-center justify-between py-2 border-t border-slate-800">
<div>
<span className="text-xs font-normal text-slate-400 uppercase tracking-wider">Relaxed Safety</span>
<p className="text-xs font-mono text-slate-500 mt-0.5">
{relaxedSafety ? 'BLOCK_ONLY_HIGH — fewer blocks on creative content' : 'Default — standard content filters'}
</p>
</div>
<button
onClick={() => setRelaxedSafety(v => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${relaxedSafety ? 'bg-cinema-gold' : 'bg-slate-700'}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${relaxedSafety ? 'translate-x-4' : 'translate-x-1'}`} />
</button>
</div>
{/* Image Display Area */}
<div className={`${aspectRatio === '9:16' || aspectRatio === '3:4' ? 'max-w-xs mx-auto' : ''}`}>
{generatedImage && (
@ -1944,7 +1884,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
aspectRatio === '1:1' ? 'aspect-square' :
aspectRatio === '4:3' ? 'aspect-[4/3]' :
'aspect-video'
} bg-slate-950 rounded border-2 ${generatedImage ? 'border-cinema-gold' : 'border-dashed border-slate-700'} flex items-center justify-center overflow-hidden`}>
} bg-slate-950 rounded-lg border-2 ${generatedImage ? 'border-cinema-gold' : 'border-dashed border-slate-700'} flex items-center justify-center overflow-hidden`}>
{isGeneratingImage ? (
<div className="text-center p-8">
<Loader2 className="w-10 h-10 animate-spin text-cinema-gold mx-auto" />
@ -1969,7 +1909,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Image Error */}
{imageError && (
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded px-4 py-2">
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded-lg px-4 py-2">
{imageError}
</div>
)}
@ -1979,7 +1919,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<button
onClick={generateImage}
disabled={!generatedPrompt || isGeneratingImage}
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 disabled:opacity-50 disabled:cursor-not-allowed"
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-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGeneratingImage ? (
<>
@ -1998,14 +1938,14 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<>
<button
onClick={downloadImage}
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition-all"
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-all"
title="Download Image"
>
<Download className="w-5 h-5" />
</button>
<button
onClick={resetImage}
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition-all"
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-all"
title="Start New Image"
>
<RefreshCw className="w-5 h-5" />
@ -2016,7 +1956,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{/* Current Settings Display */}
<div className="bg-slate-925 rounded p-4 space-y-3">
<div className="bg-slate-900/30 rounded-lg p-4 space-y-3">
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-slate-500">Application:</span>
@ -2036,13 +1976,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
</div>
{/* Focus Mode Indicator */}
<div className="pt-2">
<div className="pt-2 border-t border-slate-800">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Focus Mode:</span>
<span className={`text-xs px-2 py-1 rounded-full ${
allApplicationData.find(app => app.value === application)?.focusType === 'realism'
? 'bg-green-900/50 text-green-400 border border-green-800'
: 'bg-slate-800 text-slate-400 border border-slate-700'
: 'bg-purple-900/50 text-purple-400 border border-purple-800'
}`}>
{allApplicationData.find(app => app.value === application)?.focusType === 'realism'
? '🔬 Realism (Deep Focus)'
@ -2061,9 +2001,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Create/Edit Preset Modal */}
{showPresetModal && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded w-full max-w-md p-6 space-y-4">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-normal text-slate-200">
<h3 className="text-lg font-bold text-slate-200">
{editingPreset ? 'Edit Preset' : 'Create New Preset'}
</h3>
<button onClick={() => setShowPresetModal(false)} className="text-slate-500 hover:text-slate-300">
@ -2073,34 +2013,34 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<div className="space-y-3">
<div>
<label className="text-xs font-normal text-slate-400">Preset Name *</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Preset Name *</label>
<input
type="text"
value={presetForm.name}
onChange={(e) => setPresetForm({ ...presetForm, name: e.target.value })}
placeholder="e.g. My Moody Portrait"
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
/>
</div>
<div>
<label className="text-xs font-normal text-slate-400">Lighting Description *</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Lighting Description *</label>
<textarea
value={presetForm.lighting}
onChange={(e) => setPresetForm({ ...presetForm, lighting: e.target.value })}
placeholder="e.g. Soft Rembrandt lighting, warm color temp, subtle shadows..."
rows={3}
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none text-sm resize-none"
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-normal text-slate-400">Default Camera</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Default Camera</label>
<select
value={presetForm.defaultCamera}
onChange={(e) => setPresetForm({ ...presetForm, defaultCamera: e.target.value })}
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="">None (user picks)</option>
{cameraData.map((cam) => (
@ -2109,11 +2049,11 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</select>
</div>
<div>
<label className="text-xs font-normal text-slate-400">Default Lens</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Default Lens</label>
<select
value={presetForm.defaultLens}
onChange={(e) => setPresetForm({ ...presetForm, defaultLens: e.target.value })}
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="">None (user picks)</option>
{lensData.map((l) => (
@ -2125,11 +2065,11 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs font-normal text-slate-400">Focus Mode</label>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Focus Mode</label>
<select
value={presetForm.focusType}
onChange={(e) => setPresetForm({ ...presetForm, focusType: e.target.value })}
className="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
className="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="stylistic">Stylistic</option>
<option value="realism">Realism</option>
@ -2150,14 +2090,14 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowPresetModal(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-all"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-all"
>
Cancel
</button>
<button
onClick={handleSavePreset}
disabled={!presetForm.name.trim() || !presetForm.lighting.trim()}
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-normal rounded text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-medium rounded-lg text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingPreset ? 'Save Changes' : 'Create Preset'}
</button>
@ -2169,9 +2109,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{/* Manage Presets Modal */}
{showManagePresetsModal && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded w-full max-w-lg p-6 space-y-4">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-normal text-slate-200">Manage Custom Presets</h3>
<h3 className="text-lg font-bold text-slate-200">Manage Custom Presets</h3>
<button onClick={() => setShowManagePresetsModal(false)} className="text-slate-500 hover:text-slate-300">
<X className="w-5 h-5" />
</button>
@ -2182,9 +2122,9 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{customPresets.map((p) => (
<div key={p.id} className="flex items-center justify-between bg-slate-800 rounded px-4 py-3">
<div key={p.id} className="flex items-center justify-between bg-slate-800 rounded-lg px-4 py-3">
<div>
<div className="text-sm text-slate-200 font-normal">{p.name}</div>
<div className="text-sm text-slate-200 font-medium">{p.name}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{p.lighting}</div>
</div>
<div className="flex items-center gap-2">
@ -2214,10 +2154,10 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
)}
<div className="flex items-center gap-2 pt-2">
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={openCreatePresetModal}
className="flex items-center gap-1.5 px-3 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-normal rounded text-sm transition-all"
className="flex items-center gap-1.5 px-3 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 font-medium rounded-lg text-sm transition-all"
>
<Plus className="w-4 h-4" />
New Preset
@ -2225,13 +2165,13 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
{customPresets.length > 0 && (
<button
onClick={handleExportPresets}
className="flex items-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-all"
className="flex items-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-all"
>
<Download className="w-4 h-4" />
Export All
</button>
)}
<label className="flex items-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-all cursor-pointer">
<label className="flex items-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-all cursor-pointer">
<Upload className="w-4 h-4" />
Import
<input
@ -2267,7 +2207,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
<img
src={`data:${generatedImage.mime_type};base64,${generatedImage.data}`}
alt="Generated"
className="max-w-[90vw] max-h-[85vh] object-contain rounded border-2 border-cinema-gold"
className="max-w-[90vw] max-h-[85vh] object-contain rounded-xl border-2 border-cinema-gold"
/>
</div>
</div>

View file

@ -22,7 +22,7 @@ const LoginPage = () => {
alt="Lux Studio"
className="h-16 w-auto mx-auto mb-6"
/>
<h1 className="text-3xl font-normal text-slate-100 mb-2">
<h1 className="text-3xl font-bold text-slate-100 mb-2">
Welcome to Lux Studio
</h1>
<p className="text-slate-400">
@ -30,10 +30,10 @@ const LoginPage = () => {
</p>
</div>
<div className="bg-slate-925 rounded p-8 shadow-sm">
<div className="bg-slate-900 rounded-lg p-8 shadow-xl border border-slate-800">
<button
onClick={handleLogin}
className="w-full bg-cinema-gold hover:bg-yellow-500 text-slate-950 font-normal py-3 px-6 rounded transition-colors flex items-center justify-center gap-3"
className="w-full bg-cinema-gold hover:bg-yellow-500 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors flex items-center justify-center gap-3"
>
<svg className="w-5 h-5" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h10v10H0V0z" fill="#f25022"/>

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { FolderOpen, Plus, Image, Video, Trash2, Edit2, Check, X, Download, Clock, Search, Grid, List, Loader2, AlertCircle, Play, RefreshCw, Layers, CheckSquare, Square, Upload, Wand2, Database, ArrowRightLeft, Maximize2, Minimize2, Copy } from 'lucide-react';
import { FolderOpen, Plus, Image, Video, Trash2, Edit2, Check, X, Download, Clock, Search, Grid, List, Loader2, AlertCircle, Play, RefreshCw, Layers, CheckSquare, Square, Upload, Wand2, Database, ArrowRightLeft, Maximize2, Minimize2 } from 'lucide-react';
import useProjects from '../hooks/useProjects';
import useCustomPresets from '../hooks/useCustomPresets';
import VideoPlayer from './VideoPlayer';
@ -133,7 +133,6 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
const [isUploading, setIsUploading] = useState(false);
// Import from backend state
const [copiedPrompt, setCopiedPrompt] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [availableSessions, setAvailableSessions] = useState([]);
const [selectedFiles, setSelectedFiles] = useState([]);
@ -576,15 +575,15 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar: Project List */}
<div className="lg:col-span-4">
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-normal text-slate-200 flex items-center space-x-2">
<h2 className="text-lg font-bold text-slate-200 flex items-center space-x-2">
<FolderOpen className="w-5 h-5 text-cinema-gold" />
<span>Projects</span>
</h2>
<button
onClick={() => setIsCreating(true)}
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded transition-all"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg transition-all"
>
<Plus className="w-4 h-4" />
</button>
@ -592,7 +591,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Error Display */}
{(error || dbError) && (
<div className="flex items-center gap-2 p-3 bg-red-950/30 border border-red-900/50 rounded text-red-400 text-sm">
<div className="flex items-center gap-2 p-3 bg-red-950/30 border border-red-900/50 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error || dbError}</span>
</div>
@ -606,7 +605,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search projects..."
className="w-full pl-10 pr-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"
className="w-full pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
/>
</div>
@ -618,19 +617,19 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name..."
className="flex-1 px-3 py-2 bg-slate-800 border border-cinema-gold rounded text-slate-200 placeholder-slate-500 focus:outline-none text-sm"
className="flex-1 px-3 py-2 bg-slate-800 border border-cinema-gold rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none text-sm"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateProject()}
/>
<button
onClick={handleCreateProject}
className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"
className="p-2 bg-green-600 hover:bg-green-500 text-white rounded-lg"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => { setIsCreating(false); setNewProjectName(''); }}
className="p-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded"
className="p-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
>
<X className="w-4 h-4" />
</button>
@ -652,10 +651,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={project.id}
onClick={() => handleSelectProject(project)}
className={`group p-4 rounded cursor-pointer transition-all ${
className={`group p-4 rounded-lg cursor-pointer transition-all ${
selectedProject?.id === project.id
? 'bg-cinema-gold/20 border border-cinema-gold'
: 'bg-slate-800 hover:bg-slate-700'
: 'bg-slate-800/50 border border-slate-700 hover:border-slate-600'
}`}
>
{editingId === project.id ? (
@ -664,7 +663,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1 px-2 py-1 bg-slate-800 border border-cinema-gold rounded text-slate-200 text-sm"
className="flex-1 px-2 py-1 bg-slate-900 border border-cinema-gold rounded text-slate-200 text-sm"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleRenameProject(project.id)}
/>
@ -684,7 +683,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
) : (
<>
<div className="flex items-center justify-between">
<h3 className="font-normal text-slate-200 truncate">{project.name}</h3>
<h3 className="font-medium text-slate-200 truncate">{project.name}</h3>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
@ -707,7 +706,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</button>
</div>
</div>
<div className="flex items-center gap-3 mt-2 text-xs font-mono text-slate-500">
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" />
{formatDate(project.updatedAt)}
@ -724,21 +723,21 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Right Panel: Project Contents */}
<div className="lg:col-span-8">
<div className="bg-slate-925 rounded p-6 min-h-[70vh]">
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-6 min-h-[70vh]">
{selectedProject ? (
<div className="space-y-4">
{/* Project Header */}
<div className="flex items-center justify-between pb-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div>
<h2 className="text-xl font-normal text-slate-200">{selectedProject.name}</h2>
<p className="text-xs font-mono text-slate-500 mt-1">
<h2 className="text-xl font-bold text-slate-200">{selectedProject.name}</h2>
<p className="text-xs text-slate-500 mt-1">
Created {formatDate(selectedProject.createdAt)} · {selectedProjectItems.length} items
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleExportProject(selectedProject.id)}
className="group relative p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded transition-colors"
className="group relative p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
<span className="absolute bottom-full right-0 mb-2 px-2 py-1 text-xs text-slate-200 bg-slate-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
@ -749,10 +748,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Sub-tabs: Library | Storyboards */}
<div className="flex items-center gap-4 pb-2">
<div className="flex items-center gap-4 border-b border-slate-800 pb-2">
<button
onClick={() => { setActiveSubTab('library'); setIsSelecting(false); setSelectedImageIds([]); }}
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeSubTab === 'library'
? 'bg-cinema-gold/20 text-cinema-gold'
: 'text-slate-400 hover:text-slate-200'
@ -763,7 +762,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</button>
<button
onClick={() => { setActiveSubTab('storyboards'); setIsSelecting(false); setSelectedImageIds([]); }}
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeSubTab === 'storyboards'
? 'bg-cinema-gold/20 text-cinema-gold'
: 'text-slate-400 hover:text-slate-200'
@ -787,9 +786,9 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setIsSelecting(!isSelecting);
if (isSelecting) setSelectedImageIds([]);
}}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-all ${
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${
isSelecting
? 'bg-cinema-gold text-slate-950'
? 'bg-indigo-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -803,7 +802,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div className="flex items-center gap-2">
<button
onClick={handleUpdateStoryboardFrames}
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
<Check className="w-4 h-4" />
Update Frames
@ -814,7 +813,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setSelectedImageIds([]);
setIsSelecting(false);
}}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm transition-all"
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm transition-all"
>
<X className="w-4 h-4" />
Cancel
@ -823,7 +822,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
) : (
<button
onClick={() => setIsCreatingStoryboard(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
<Layers className="w-4 h-4" />
Create Storyboard
@ -838,7 +837,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded text-sm transition-all disabled:opacity-50"
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded-lg text-sm transition-all disabled:opacity-50"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -860,7 +859,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<button
onClick={handleOpenImportModal}
disabled={importing}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded text-sm transition-all disabled:opacity-50"
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded-lg text-sm transition-all disabled:opacity-50"
>
{importing ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -875,7 +874,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded transition-all ${
className={`p-2 rounded-lg transition-all ${
viewMode === 'grid' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -883,7 +882,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded transition-all ${
className={`p-2 rounded-lg transition-all ${
viewMode === 'list' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -895,28 +894,28 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Create Storyboard Modal */}
{isCreatingStoryboard && (
<div className="bg-slate-800 rounded p-4">
<h3 className="text-sm font-normal text-slate-200 mb-2">Name your storyboard</h3>
<div className="bg-slate-800/80 border border-slate-700 rounded-lg p-4">
<h3 className="text-sm font-medium text-slate-200 mb-2">Name your storyboard</h3>
<div className="flex gap-2">
<input
type="text"
value={newStoryboardName}
onChange={(e) => setNewStoryboardName(e.target.value)}
placeholder="Storyboard name..."
className="flex-1 px-3 py-2 bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateStoryboard()}
/>
<button
onClick={handleCreateStoryboard}
disabled={!newStoryboardName.trim()}
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded text-sm font-normal transition-all"
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
Create
</button>
<button
onClick={() => { setIsCreatingStoryboard(false); setNewStoryboardName(''); }}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm"
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm"
>
Cancel
</button>
@ -930,12 +929,12 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Import from Backend Modal */}
{showImportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto">
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-normal text-slate-200">Import from Backend</h2>
<h2 className="text-xl font-bold text-slate-200">Import from Backend</h2>
<button
onClick={() => setShowImportModal(false)}
className="p-2 hover:bg-slate-700 rounded transition-colors"
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
@ -962,10 +961,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Session list */}
<div className="space-y-4">
{availableSessions.map((session) => (
<div key={session.session_id} className="bg-slate-800 rounded p-4">
<div key={session.session_id} className="bg-slate-800/50 border border-slate-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-normal text-slate-300">
Session: <span className="font-mono">{session.session_id.substring(0, 8)}...</span>
<h3 className="text-sm font-medium text-slate-300">
Session: {session.session_id.substring(0, 8)}...
</h3>
<span className="text-xs text-slate-500">
{session.images.length} image(s)
@ -975,7 +974,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Images */}
{session.images.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-normal text-slate-400">Images</h4>
<h4 className="text-xs font-medium text-slate-400 uppercase">Images</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{session.images.map((img) => {
const fileKey = `${session.session_id}:image:${img.filename}`;
@ -986,7 +985,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={img.filename}
onClick={() => toggleFileSelection(session.session_id, 'image', img.filename)}
className={`relative p-2 rounded border-2 cursor-pointer transition-all ${
className={`relative p-2 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-slate-600'
@ -996,7 +995,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<Image className="w-8 h-8 text-slate-500" />
</div>
<p className="text-xs text-slate-400 truncate">{img.filename}</p>
<p className="text-xs font-mono text-slate-500">
<p className="text-xs text-slate-500">
{img.size_kb} KB {expiresIn}h left
</p>
{isSelected && (
@ -1014,7 +1013,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Videos - Hidden for now since import not supported */}
{false && session.videos.length > 0 && (
<div className="space-y-2 mt-4">
<h4 className="text-xs font-normal text-slate-400">Videos (Import Not Supported)</h4>
<h4 className="text-xs font-medium text-slate-400 uppercase">Videos (Import Not Supported)</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{session.videos.map((vid) => {
const expiresIn = Math.floor(vid.time_remaining / 3600);
@ -1022,7 +1021,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
return (
<div
key={vid.filename}
className="relative p-2 rounded border-2 border-slate-700 opacity-50 cursor-not-allowed"
className="relative p-2 rounded-lg border-2 border-slate-700 opacity-50 cursor-not-allowed"
>
<div className="aspect-square bg-slate-700/50 rounded flex items-center justify-center mb-2">
<Video className="w-8 h-8 text-slate-500" />
@ -1042,21 +1041,21 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Action buttons */}
<div className="flex items-center justify-between mt-6 pt-4">
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-700">
<p className="text-sm text-slate-400">
{selectedFiles.length} file(s) selected
</p>
<div className="flex gap-2">
<button
onClick={() => setShowImportModal(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-all"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-all"
>
Cancel
</button>
<button
onClick={handleImportFiles}
disabled={selectedFiles.length === 0 || importing}
className="flex items-center gap-2 px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded text-sm font-normal transition-all"
className="flex items-center gap-2 px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
{importing ? (
<>
@ -1104,7 +1103,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setPreviewItem(item);
}
}}
className={`group relative aspect-square bg-slate-800 rounded overflow-hidden border transition-all cursor-pointer ${
className={`group relative aspect-square bg-slate-800 rounded-lg overflow-hidden border transition-all cursor-pointer ${
isSelecting && selectedImageIds.includes(item.id)
? 'border-cinema-gold ring-2 ring-cinema-gold'
: 'border-slate-700 hover:border-cinema-gold'
@ -1123,33 +1122,51 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
)}
{/* Thumbnail or placeholder — NEVER use full item.data here (causes OOM with many large images) */}
{item.thumbnail ? (
<img
src={item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/jpeg;base64,${item.thumbnail}`}
alt={item.prompt}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-slate-800">
{item.type === 'image' ? (
<Image className="w-8 h-8 text-slate-600" />
) : (
<Video className="w-12 h-12 text-slate-500" />
)}
</div>
)}
{/* Thumbnail or placeholder */}
{(() => {
// For videos without thumbnails, show placeholder
if (item.type === 'video' && !item.thumbnail) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-purple-900/50 to-slate-800">
<Video className="w-12 h-12 text-purple-400" />
</div>
);
}
// For items with thumbnail or displayable data
if (item.thumbnail || (item.data && !item.data.startsWith('/api'))) {
return (
<img
src={
item.thumbnail
? (item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/jpeg;base64,${item.thumbnail}`)
: (item.data.startsWith('data:') || item.data.startsWith('http'))
? item.data
: `data:${item.mimeType};base64,${item.data}`
}
alt={item.prompt}
className="w-full h-full object-cover"
/>
);
}
// Fallback placeholder
return (
<div className="absolute inset-0 flex items-center justify-center">
{item.type === 'image' ? (
<Image className="w-8 h-8 text-slate-600" />
) : (
<Video className="w-8 h-8 text-slate-600" />
)}
</div>
);
})()}
{/* Type Badge */}
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-normal ${
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-medium ${
item.type === 'image'
? 'bg-blue-900/80 text-blue-300'
: item.settings?.engine === 'kling'
? 'bg-indigo-900/80 text-indigo-300'
: 'bg-purple-900/80 text-purple-300'
}`}>
{item.type === 'image' ? 'IMG' : item.settings?.engine === 'kling' ? 'KLING' : 'VEO'}
{item.type === 'image' ? 'IMG' : 'VID'}
</div>
{/* Hover Overlay (hide when selecting) */}
@ -1160,7 +1177,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
downloadItem(item);
}}
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg"
>
<Download className="w-4 h-4" />
</button>
@ -1169,7 +1186,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="p-2 bg-red-600 hover:bg-red-500 text-white rounded"
className="p-2 bg-red-600 hover:bg-red-500 text-white rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
@ -1186,7 +1203,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setPreviewItem(item);
}
}}
className={`flex items-center gap-4 p-3 bg-slate-800 rounded border transition-all cursor-pointer ${
className={`flex items-center gap-4 p-3 bg-slate-800/50 rounded-lg border transition-all cursor-pointer ${
isSelecting && selectedImageIds.includes(item.id)
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-slate-600'
@ -1203,18 +1220,18 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
)}
<div className={`w-10 h-10 rounded flex items-center justify-center flex-shrink-0 ${
item.type === 'image' ? 'bg-blue-900/50' : 'bg-slate-800'
item.type === 'image' ? 'bg-blue-900/50' : 'bg-purple-900/50'
}`}>
{item.type === 'image' ? (
<Image className="w-5 h-5 text-blue-400" />
) : (
<Video className="w-5 h-5 text-slate-500" />
<Video className="w-5 h-5 text-purple-400" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-200 truncate">{item.prompt}</p>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{formatDate(item.createdAt)}</span>
<span>{formatDate(item.createdAt)}</span>
{item.settings?.application && (
<span className="px-1.5 py-0.5 bg-slate-800 rounded text-slate-400 truncate max-w-[150px]">
{getPresetDisplayName(item.settings.application)}
@ -1229,17 +1246,17 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
setMovingItemId(movingItemId === item.id ? null : item.id);
}}
className={`p-2 rounded ${movingItemId === item.id ? 'bg-cinema-gold/20' : 'hover:bg-slate-700'}`}
className={`p-2 rounded-lg ${movingItemId === item.id ? 'bg-indigo-900/50' : 'hover:bg-slate-700'}`}
title="Move to another project"
>
<ArrowRightLeft className={`w-4 h-4 ${movingItemId === item.id ? 'text-cinema-gold' : 'text-slate-400'}`} />
<ArrowRightLeft className={`w-4 h-4 ${movingItemId === item.id ? 'text-indigo-400' : 'text-slate-400'}`} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
downloadItem(item);
}}
className="p-2 hover:bg-slate-700 rounded"
className="p-2 hover:bg-slate-700 rounded-lg"
>
<Download className="w-4 h-4 text-slate-400" />
</button>
@ -1248,15 +1265,15 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="p-2 hover:bg-red-900/50 rounded"
className="p-2 hover:bg-red-900/50 rounded-lg"
>
<Trash2 className="w-4 h-4 text-red-400" />
</button>
{movingItemId === item.id && (
<div className="absolute right-0 top-full mt-1 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]"
<div className="absolute right-0 top-full mt-1 z-20 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-1.5 text-xs font-normal text-slate-500 uppercase tracking-wider">Move to...</div>
<div className="px-3 py-1.5 text-xs font-bold text-slate-500 uppercase tracking-wider">Move to...</div>
{projects
.filter(p => p.id !== selectedProject?.id)
.map(p => (
@ -1320,7 +1337,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setActiveSubTab('library');
setIsSelecting(true);
}}
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
<Plus className="w-4 h-4" />
New Storyboard
@ -1338,7 +1355,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setActiveSubTab('library');
setIsSelecting(true);
}}
className="mt-4 px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
className="mt-4 px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-all"
>
Select Images
</button>
@ -1349,10 +1366,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={board.id}
onClick={() => handleOpenStoryboard(board.id)}
className="group p-4 bg-slate-800 rounded hover:bg-slate-700 transition-all cursor-pointer"
className="group p-4 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-cinema-gold transition-all cursor-pointer"
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-normal text-slate-200">{board.name}</h3>
<h3 className="font-medium text-slate-200">{board.name}</h3>
<span className="text-xs text-slate-500">{board.frames?.length || 0} frames</span>
</div>
{/* Frame thumbnails preview */}
@ -1383,7 +1400,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
)}
</div>
<p className="text-xs font-mono text-slate-500">
<p className="text-xs text-slate-500">
Updated {formatDate(board.updatedAt)}
</p>
</div>
@ -1433,7 +1450,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Content */}
<div className="bg-slate-800 rounded overflow-hidden">
<div className="bg-slate-900 rounded-xl overflow-hidden border border-slate-700">
{previewItem.type === 'image' ? (
<img
src={
@ -1466,33 +1483,8 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
)}
{/* Info bar */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-slate-300 line-clamp-2 flex-1">{previewItem.prompt}</p>
{previewItem.prompt && (
<button
onClick={() => {
navigator.clipboard.writeText(previewItem.prompt);
setCopiedPrompt(true);
setTimeout(() => setCopiedPrompt(false), 2000);
}}
className="flex items-center gap-1.5 px-2.5 py-1 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-normal transition-colors shrink-0"
title="Copy prompt to clipboard"
>
{copiedPrompt ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>Copy Prompt</span>
</>
)}
</button>
)}
</div>
<div className="p-4 border-t border-slate-700">
<p className="text-sm text-slate-300 line-clamp-2">{previewItem.prompt}</p>
{previewItem.settings?.application && (
<div className="mt-2">
<span className="text-xs px-2 py-1 bg-slate-800 rounded-full text-slate-400 border border-slate-700">
@ -1500,26 +1492,8 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</span>
</div>
)}
{previewItem.type === 'video' && (
<div className="mt-2 flex gap-1.5">
<span className={`text-xs px-2 py-0.5 rounded-full font-normal ${
previewItem.settings?.engine === 'kling'
? 'bg-indigo-900/50 text-indigo-300 border border-indigo-700'
: 'bg-emerald-900/50 text-emerald-300 border border-emerald-700'
}`}>
{previewItem.settings?.engine === 'kling'
? `Kling ${previewItem.settings?.klingModel?.replace('kling-', '').toUpperCase() || 'AI'}`
: `Veo ${previewItem.settings?.modelType === 'fast' ? 'Fast' : 'Std'}`}
</span>
{previewItem.settings?.engine === 'kling' && previewItem.settings?.klingTaskId && (
<span className="text-xs px-2 py-0.5 bg-slate-800 rounded-full text-slate-500 border border-slate-700 font-mono">
ID: {previewItem.settings.klingTaskId.substring(0, 8)}...
</span>
)}
</div>
)}
<div className="flex items-center justify-between mt-3">
<span className="text-xs font-mono text-slate-500">{formatDate(previewItem.createdAt)}</span>
<span className="text-xs text-slate-500">{formatDate(previewItem.createdAt)}</span>
<div className="flex gap-2">
{/* Edit in Image Gen button for images */}
{previewItem.type === 'image' && onEditInImageGen && (
@ -1534,7 +1508,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
});
setPreviewItem(null);
}}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-200 hover:bg-slate-300 text-slate-900 rounded text-sm font-normal transition-colors"
className="flex items-center gap-2 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm font-medium transition-colors"
>
<Wand2 className="w-4 h-4" />
Edit in Image Gen
@ -1554,7 +1528,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
});
setPreviewItem(null);
}}
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-colors"
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-colors"
>
<Video className="w-4 h-4" />
Generate Video
@ -1571,7 +1545,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
});
setPreviewItem(null);
}}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-200 hover:bg-slate-300 text-slate-900 rounded text-sm font-normal transition-colors"
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Re-run
@ -1582,9 +1556,9 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div className="relative">
<button
onClick={() => setMovingItemId(movingItemId === previewItem.id ? null : previewItem.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-normal transition-colors ${
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
movingItemId === previewItem.id
? 'bg-cinema-gold text-slate-950'
? 'bg-indigo-600 text-white'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
>
@ -1592,7 +1566,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
Move to
</button>
{movingItemId === previewItem.id && (
<div className="absolute bottom-full mb-1 right-0 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]">
<div className="absolute bottom-full mb-1 right-0 z-20 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]">
{projects
.filter(p => p.id !== selectedProject?.id)
.map(p => (
@ -1621,7 +1595,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
)}
<button
onClick={() => downloadItem(previewItem)}
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-colors"
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-colors"
>
<Download className="w-4 h-4" />
Download

View file

@ -62,12 +62,12 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
<div
ref={setNodeRef}
style={style}
className={`bg-slate-800 rounded overflow-hidden ${
isDragging ? 'ring-2 ring-cinema-gold' : ''
className={`bg-slate-800 rounded-xl border border-slate-700 overflow-hidden ${
isDragging ? 'shadow-2xl ring-2 ring-cinema-gold' : ''
}`}
>
{/* Frame number and drag handle */}
<div className="flex items-center justify-between px-3 py-2 bg-slate-925">
<div className="flex items-center justify-between px-3 py-2 bg-slate-900/50 border-b border-slate-700">
<div className="flex items-center gap-2">
<button
{...attributes}
@ -76,7 +76,7 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
>
<GripVertical className="w-4 h-4 text-slate-500" />
</button>
<span className="text-xs font-normal text-slate-400">Frame {index + 1}</span>
<span className="text-xs font-medium text-slate-400">Frame {index + 1}</span>
</div>
<button
onClick={() => onDelete(frame.imageId)}
@ -87,7 +87,7 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
</div>
{/* Image */}
<div className="aspect-video bg-slate-925 relative">
<div className="aspect-video bg-slate-900 relative">
{frameItem ? (
<img
src={
@ -108,14 +108,14 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
</div>
{/* Annotation */}
<div className="p-3">
<div className="p-3 border-t border-slate-700">
{isEditing ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
placeholder="Add scene notes..."
className="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded text-sm text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none resize-none"
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none resize-none"
rows={3}
autoFocus
/>
@ -137,7 +137,7 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
) : (
<div
onClick={() => setIsEditing(true)}
className="min-h-[60px] p-2 bg-slate-925 rounded cursor-pointer hover:bg-slate-800 transition-colors"
className="min-h-[60px] p-2 bg-slate-900/50 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors"
>
{frame.annotation ? (
<p className="text-sm text-slate-300">{frame.annotation}</p>
@ -153,7 +153,7 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
<div className="px-3 pb-3">
<button
onClick={() => onGenerateVideo(frame, frameItem)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-slate-200 hover:bg-slate-300 text-slate-900 rounded text-sm font-normal transition-colors"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
>
<Video className="w-4 h-4" />
Generate Video
@ -288,22 +288,26 @@ const StoryboardEditor = ({
const pdf = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 15;
const margin = 20;
const contentWidth = pageWidth - margin * 2;
const colWidth = (contentWidth - 10) / 2; // 2 columns with 10mm gap
const colWidth = (contentWidth - 15) / 2; // 2 columns with 15mm gap
// Title page minimal
pdf.setFontSize(24);
// Title page with better styling
pdf.setFontSize(32);
pdf.setTextColor(30, 30, 30);
pdf.text(name, pageWidth / 2, pageHeight / 2 - 10, { align: 'center' });
pdf.text(name, pageWidth / 2, pageHeight / 2 - 20, { align: 'center' });
pdf.setFontSize(10);
pdf.setFontSize(14);
pdf.setTextColor(100, 100, 100);
pdf.text(`${frames.length} frames`, pageWidth / 2, pageHeight / 2, { align: 'center' });
pdf.setFontSize(11);
pdf.setTextColor(140, 140, 140);
pdf.text(`${frames.length} frames · ${new Date().toLocaleDateString('en-US', {
pdf.text(`Exported ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}`, pageWidth / 2, pageHeight / 2 + 4, { align: 'center' });
})}`, pageWidth / 2, pageHeight / 2 + 12, { align: 'center' });
// Process frames - 4 per page (2x2 grid)
let frameIndex = 0;
@ -315,9 +319,9 @@ const StoryboardEditor = ({
const frame = frames[frameIndex];
const frameItem = projectItems.find(item => item.id === frame.imageId);
const x = margin + col * (colWidth + 10);
const rowHeight = (pageHeight - margin * 2 - 6) / 2;
const y = margin + row * (rowHeight + 6);
const x = margin + col * (colWidth + 15);
const rowHeight = (pageHeight - margin * 2 - 10) / 2;
const y = margin + row * (rowHeight + 10);
// Image with preserved aspect ratio
if (frameItem) {
@ -326,41 +330,64 @@ const StoryboardEditor = ({
const dimensions = await getImageDimensions(imgSrc);
const imgAspect = dimensions.width / dimensions.height;
// Max image area minimal reservation for label
// Max image area
const maxImgWidth = colWidth;
const maxImgHeight = rowHeight - 14;
const maxImgHeight = rowHeight - 30; // Leave room for label and annotation
let imgWidth, imgHeight;
if (imgAspect > maxImgWidth / maxImgHeight) {
// Width constrained
imgWidth = maxImgWidth;
imgHeight = maxImgWidth / imgAspect;
} else {
// Height constrained
imgHeight = maxImgHeight;
imgWidth = maxImgHeight * imgAspect;
}
// Center the image horizontally in the column
const imgX = x + (colWidth - imgWidth) / 2;
const imgY = y;
const imgY = y + 12;
// Frame label - bold text aligned to image
pdf.setFontSize(11);
pdf.setFont(undefined, 'bold');
pdf.setTextColor(50, 50, 50);
pdf.text(`Frame ${frameIndex + 1}`, imgX, y + 6);
pdf.setFont(undefined, 'normal');
pdf.addImage(imgSrc, 'JPEG', imgX, imgY, imgWidth, imgHeight);
// Frame number + annotation below image
pdf.setFontSize(8);
pdf.setFont(undefined, 'normal');
pdf.setTextColor(160, 160, 160);
const label = frame.annotation ? `${frameIndex + 1} ${frame.annotation}` : `${frameIndex + 1}`;
const lines = pdf.splitTextToSize(label, imgWidth);
pdf.text(lines.slice(0, 1), imgX, imgY + imgHeight + 4);
// Annotation below image - aligned to image
if (frame.annotation) {
pdf.setFontSize(10);
pdf.setTextColor(80, 80, 80);
const lines = pdf.splitTextToSize(frame.annotation, imgWidth);
pdf.text(lines.slice(0, 2), imgX, imgY + imgHeight + 6);
}
} catch (e) {
console.error('Failed to add image to PDF:', e);
pdf.setFontSize(8);
pdf.setTextColor(160, 160, 160);
pdf.text(`${frameIndex + 1}`, x, y + 4);
// Frame label for error state
pdf.setFontSize(11);
pdf.setFont(undefined, 'bold');
pdf.setTextColor(50, 50, 50);
pdf.text(`Frame ${frameIndex + 1}`, x, y + 6);
pdf.setFont(undefined, 'normal');
pdf.setDrawColor(200);
pdf.setFillColor(245, 245, 245);
pdf.roundedRect(x, y + 12, colWidth, 60, 3, 3, 'FD');
pdf.setFontSize(10);
pdf.setTextColor(150);
pdf.text('Image unavailable', x + colWidth / 2, y + 47, { align: 'center' });
}
} else {
pdf.setFontSize(8);
pdf.setTextColor(160, 160, 160);
pdf.text(`${frameIndex + 1}`, x, y + 4);
// No frame item - show label anyway
pdf.setFontSize(11);
pdf.setFont(undefined, 'bold');
pdf.setTextColor(50, 50, 50);
pdf.text(`Frame ${frameIndex + 1}`, x, y + 6);
pdf.setFont(undefined, 'normal');
}
frameIndex++;
@ -387,10 +414,10 @@ const StoryboardEditor = ({
position: absolute;
left: -9999px;
top: 0;
background: #020617;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
padding: 48px;
width: 1400px;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Title section
@ -398,10 +425,12 @@ const StoryboardEditor = ({
titleSection.style.cssText = `
text-align: center;
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
`;
titleSection.innerHTML = `
<h1 style="color: #f1f5f9; font-size: 28px; font-weight: 400; margin: 0 0 6px 0;">${name}</h1>
<p style="color: #64748b; font-size: 11px; margin: 0; letter-spacing: 0.05em;">${frames.length} frames · ${new Date().toLocaleDateString('en-US', {
<h1 style="color: #f1f5f9; font-size: 36px; font-weight: 700; margin: 0 0 8px 0; letter-spacing: -0.5px;">${name}</h1>
<p style="color: #94a3b8; font-size: 14px; margin: 0;">${frames.length} frames Exported ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
@ -414,7 +443,7 @@ const StoryboardEditor = ({
framesGrid.style.cssText = `
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
gap: 24px;
`;
for (let i = 0; i < frames.length; i++) {
@ -423,9 +452,24 @@ const StoryboardEditor = ({
const frameCard = document.createElement('div');
frameCard.style.cssText = `
background: rgba(30, 41, 59, 0.8);
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(71, 85, 105, 0.5);
`;
// Frame header
const frameHeader = document.createElement('div');
frameHeader.style.cssText = `
padding: 12px 16px;
background: rgba(15, 23, 42, 0.6);
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
`;
frameHeader.innerHTML = `
<span style="color: #cbd5e1; font-size: 13px; font-weight: 600;">Frame ${i + 1}</span>
`;
frameCard.appendChild(frameHeader);
// Image
if (frameItem) {
const imgSrc = getImageSrc(frameItem);
@ -433,8 +477,10 @@ const StoryboardEditor = ({
imgWrapper.style.cssText = `
aspect-ratio: 16/9;
background: #0f172a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 2px;
`;
const img = document.createElement('img');
img.src = imgSrc;
@ -447,17 +493,22 @@ const StoryboardEditor = ({
frameCard.appendChild(imgWrapper);
}
// Caption: frame number + annotation (only if annotation exists)
const captionDiv = document.createElement('div');
captionDiv.style.cssText = `
padding: 8px 0;
// Annotation
const annotationDiv = document.createElement('div');
annotationDiv.style.cssText = `
padding: 16px;
min-height: 60px;
`;
const captionParts = [`<span style="color: #64748b; font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase;">${i + 1}</span>`];
if (frame.annotation) {
captionParts.push(`<p style="color: #94a3b8; font-size: 12px; line-height: 1.4; margin: 4px 0 0 0;">${frame.annotation}</p>`);
annotationDiv.innerHTML = `
<p style="color: #e2e8f0; font-size: 14px; line-height: 1.5; margin: 0;">${frame.annotation}</p>
`;
} else {
annotationDiv.innerHTML = `
<p style="color: #64748b; font-size: 14px; font-style: italic; margin: 0;">No annotation</p>
`;
}
captionDiv.innerHTML = captionParts.join('');
frameCard.appendChild(captionDiv);
frameCard.appendChild(annotationDiv);
framesGrid.appendChild(frameCard);
}
@ -511,7 +562,7 @@ const StoryboardEditor = ({
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 rounded transition-colors"
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
@ -522,7 +573,7 @@ const StoryboardEditor = ({
type="text"
value={editNameValue}
onChange={(e) => setEditNameValue(e.target.value)}
className="px-3 py-1.5 bg-slate-800 border border-slate-600 rounded text-slate-200 focus:border-cinema-gold focus:outline-none"
className="px-3 py-1.5 bg-slate-800 border border-cinema-gold rounded-lg text-slate-200 focus:outline-none"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleNameSave()}
/>
@ -535,7 +586,7 @@ const StoryboardEditor = ({
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-xl font-normal text-slate-200">{name}</h2>
<h2 className="text-xl font-bold text-slate-200">{name}</h2>
<button
onClick={() => setIsEditingName(true)}
className="p-1 hover:bg-slate-700 rounded"
@ -558,7 +609,7 @@ const StoryboardEditor = ({
<div className="flex items-center gap-2">
<button
onClick={() => onEditFrames(storyboard.id, frames.map(f => f.imageId))}
className="flex items-center gap-2 px-3 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-colors"
className="flex items-center gap-2 px-3 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
Edit Frames
@ -566,7 +617,7 @@ const StoryboardEditor = ({
<button
onClick={handleExportPDF}
disabled={isExporting}
className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-colors disabled:opacity-50"
>
<FileDown className="w-4 h-4" />
PDF
@ -574,14 +625,14 @@ const StoryboardEditor = ({
<button
onClick={handleExportPNG}
disabled={isExporting}
className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm transition-colors disabled:opacity-50"
>
<ImageDown className="w-4 h-4" />
PNG
</button>
<button
onClick={handleDeleteStoryboard}
className="flex items-center gap-2 px-3 py-2 bg-red-900/50 hover:bg-red-900 text-red-400 rounded text-sm transition-colors"
className="flex items-center gap-2 px-3 py-2 bg-red-900/50 hover:bg-red-900 text-red-400 rounded-lg text-sm transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete
@ -590,7 +641,7 @@ const StoryboardEditor = ({
</div>
{/* Frames Grid */}
<div ref={storyboardRef} className="p-4 bg-slate-925 rounded">
<div ref={storyboardRef} className="p-4 bg-slate-900/30 rounded-xl">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}

View file

@ -1,15 +1,18 @@
import React from 'react';
import { Image, Video, FolderOpen } from 'lucide-react';
const TabNavigation = ({ activeTab, onTabChange, activeProjectId }) => {
// Projects first - project-first workflow
const tabs = [
{ id: 'projects', label: 'Projects', requiresProject: false },
{ id: 'image', label: 'Image Gen', requiresProject: true },
{ id: 'video', label: 'Video Gen', requiresProject: true }
{ id: 'projects', label: 'Projects', icon: FolderOpen, requiresProject: false },
{ id: 'image', label: 'Image Gen', icon: Image, requiresProject: true },
{ id: 'video', label: 'Video Gen', icon: Video, requiresProject: true }
];
return (
<div className="flex items-center gap-6">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
const isDisabled = tab.requiresProject && !activeProjectId;
@ -19,7 +22,7 @@ const TabNavigation = ({ activeTab, onTabChange, activeProjectId }) => {
onClick={() => !isDisabled && onTabChange(tab.id)}
disabled={isDisabled}
title={isDisabled ? 'Select a project first' : tab.label}
className={`px-1 py-2 text-[14px] font-normal transition-all ${
className={`relative flex items-center gap-2 px-1 py-2 font-medium transition-all ${
isDisabled
? 'text-slate-600 cursor-not-allowed'
: isActive
@ -27,7 +30,14 @@ const TabNavigation = ({ activeTab, onTabChange, activeProjectId }) => {
: 'text-slate-400 hover:text-slate-200'
}`}
>
{tab.label}
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
{/* Underline indicator */}
<span
className={`absolute bottom-0 left-0 right-0 h-0.5 bg-cinema-gold transition-all ${
isActive ? 'opacity-100' : 'opacity-0'
}`}
/>
</button>
);
})}

File diff suppressed because it is too large Load diff

View file

@ -304,7 +304,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
<video
ref={videoRef}
src={videoSrc}
className="w-full rounded"
className="w-full rounded-lg"
onClick={togglePlay}
preload="auto"
playsInline
@ -313,7 +313,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
/>
{/* Controls overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4 rounded-b">
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4 rounded-b-lg">
{/* Progress bar */}
<div
ref={progressRef}
@ -324,7 +324,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
className="h-full bg-cinema-gold rounded-full relative"
style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
>
<div className={`absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-cinema-gold rounded-full transition-opacity ${isScrubbing ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100'}`} />
<div className={`absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-cinema-gold rounded-full shadow-lg transition-opacity ${isScrubbing ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100'}`} />
</div>
</div>
@ -351,7 +351,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
<button
onClick={togglePlay}
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded transition-colors"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded-lg transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
@ -402,7 +402,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
{/* Extract frame button */}
<button
onClick={extractFrame}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded text-sm transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition-colors"
title="Extract current frame"
>
<Scissors className="w-4 h-4" />
@ -415,13 +415,13 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
{/* Frame preview modal */}
{showFramePreview && extractedFrame && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8" onClick={() => setShowFramePreview(false)}>
<div className="bg-slate-800 rounded p-6 max-w-2xl w-full" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-normal text-slate-200 mb-4 flex items-center gap-2">
<div className="bg-slate-900 rounded-xl p-6 max-w-2xl w-full" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-200 mb-4 flex items-center gap-2">
<Image className="w-5 h-5 text-cinema-gold" />
Extracted Frame
</h3>
<div className="bg-slate-950 rounded p-2 mb-4">
<div className="bg-slate-950 rounded-lg p-2 mb-4">
<img
src={extractedFrame.dataUrl}
alt="Extracted frame"
@ -429,7 +429,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
/>
</div>
<div className="flex items-center justify-between text-xs font-mono text-slate-400 mb-4">
<div className="flex items-center justify-between text-xs text-slate-400 mb-4">
<span>Time: {formatTime(extractedFrame.timestamp)}</span>
<span>{extractedFrame.width} x {extractedFrame.height}</span>
</div>
@ -437,7 +437,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
<div className="flex gap-3">
<button
onClick={downloadFrame}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 font-normal rounded transition-colors"
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 font-medium rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Download
@ -455,7 +455,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
});
setSavedToProject(true);
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 font-normal rounded transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 font-medium rounded-lg transition-colors ${
savedToProject
? 'bg-green-600 text-white cursor-default'
: 'bg-cinema-gold hover:bg-amber-400 text-slate-950'
@ -476,7 +476,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
) : (
<button
onClick={() => setShowFramePreview(false)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 font-normal rounded transition-colors"
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 font-medium rounded-lg transition-colors"
>
Close
</button>

View file

@ -8,28 +8,6 @@ const getCurrentUserId = () => {
return 'local';
};
// Generate a Retina-ready thumbnail from a base64 image to prevent OOM in library grid
// 480px @ q0.8 = ~20-25KB per thumb, sharp on 2x displays up to 240px CSS width
const generateThumbnail = (base64Data, mimeType, maxSize = 480) => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ratio = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * ratio);
canvas.height = Math.round(img.height * ratio);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Return raw base64 without data: prefix
const thumbData = canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
resolve(thumbData);
};
img.onerror = () => resolve(null); // Fail silently — grid will use placeholder
const src = base64Data.startsWith('data:') ? base64Data : `data:${mimeType || 'image/png'};base64,${base64Data}`;
img.src = src;
});
};
/**
* Custom hook for project management
* Provides CRUD operations for projects and their items
@ -171,12 +149,6 @@ const useProjects = () => {
// Add an item to a project
const addItemToProject = useCallback(async (projectId, item) => {
// Auto-generate thumbnail for images without one to prevent OOM in library grid
let thumbnail = item.thumbnail || null;
if (item.type === 'image' && !thumbnail && item.data && !item.data.startsWith('http')) {
thumbnail = await generateThumbnail(item.data, item.mimeType);
}
const newItem = {
id: generateId(),
projectId,
@ -184,7 +156,7 @@ const useProjects = () => {
prompt: item.prompt,
settings: item.settings || {},
referenceImages: item.referenceImages || [], // For video re-run feature
thumbnail,
thumbnail: item.thumbnail || null,
data: item.data, // base64 for images, URL for videos
mimeType: item.mimeType || 'image/png',
createdAt: Date.now()
@ -290,23 +262,6 @@ const useProjects = () => {
return item;
});
// Migration: Backfill or upgrade thumbnails for Retina (480px/q0.8)
// Old thumbnails (300px/q0.7) are typically under 27,000 base64 chars
for (const item of items) {
if (item.type === 'image' && item.data && !item.data.startsWith('http')) {
const needsThumb = !item.thumbnail || item.thumbnail.length < 27000;
if (needsThumb) {
try {
const thumb = await generateThumbnail(item.data, item.mimeType);
if (thumb) {
item.thumbnail = thumb;
put('items', item).catch(() => {});
}
} catch { /* skip */ }
}
}
}
// Sort by createdAt descending (newest first)
return items.sort((a, b) => b.createdAt - a.createdAt);
} catch (err) {

View file

@ -4,7 +4,6 @@
@layer base {
html {
font-family: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
}

View file

@ -6,13 +6,8 @@ export default {
],
theme: {
extend: {
fontFamily: {
sans: ['"IBM Plex Sans"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
colors: {
'cinema-gold': '#f59e0b',
'slate-925': '#080d1b',
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',