Compare commits
No commits in common. "plaiground" and "main" have entirely different histories.
plaiground
...
main
36 changed files with 898 additions and 3661 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
@ -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/
|
||||
|
|
@ -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:
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue