Compare commits

..

58 commits

Author SHA1 Message Date
Vadym Samoilenko
bc3c5e0756 fix: replace unsupported safetyFilterLevel with personGeneration for Veo 3.1
safetyFilterLevel is not supported by veo-3.1-generate-preview.
Use personGeneration=allow_all instead, which is the correct Veo API
parameter for relaxed safety mode. Verified via live API test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 18:57:58 +01:00
Vadym Samoilenko
0df2924c65 feat: add relaxed safety filter toggle to video generation (Veo)
Mirrors the image gen safety toggle — BLOCK_FEWEST on all Veo safety
categories when enabled, default filters otherwise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:16:46 +01:00
Vadym Samoilenko
5453591b27 feat: add relaxed safety filter toggle to image generation
Adds a per-request BLOCK_ONLY_HIGH safety level option so users can
generate creative content that the default filters would otherwise block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:50:01 +01:00
Vadym Samoilenko
a399c3164f fix: logout exits app only; fix admin email format
- logoutRedirect with onRedirectNavigate:false clears MSAL local session
  without touching the Microsoft/Azure AD session — re-login is instant SSO
- Fix ADMIN_EMAILS: VadymSamoilenko@oliver.agency (no dot, matches real UPN)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:01:38 +01:00
Vadym Samoilenko
c24965d0e4 fix: logout properly signs out from Microsoft account
Switch from logoutPopup() to logoutRedirect() to match the loginRedirect()
flow and actually terminate the Azure AD session. Add postLogoutRedirectUri
so Azure AD redirects back to the app after sign-out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:56:47 +01:00
Vadym Samoilenko
ef55b30820 fix: admin auth checks Bearer token regardless of SSO_ENABLED
When backend SSO_ENABLED=false, regular API endpoints skip auth, but the
admin panel still needs to identify the caller. Now Bearer token is always
validated first; mock dev@localhost fallback only kicks in when no token is
present AND SSO is disabled (local dev).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:56:09 +01:00
Vadym Samoilenko
bd4e600ec7 config: add admin emails for Vadym and Dave Porter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:51:37 +01:00
Vadym Samoilenko
22a5ce83af feat: admin users can rotate Kling credentials in real-time via UI
- Add runtime_config.php: credential store backed by runtime_config.json
  (gitignored). Falls back to .env values so existing envs need no migration.
- Add admin_api.php: status / test_kling / update_kling endpoints gated
  behind ADMIN_EMAILS allowlist. Accepts Bearer idToken when SSO enabled;
  uses mock dev@localhost when SSO disabled.
- config.php: replace KLING_ACCESS_KEY/SECRET_KEY defines with ADMIN_EMAILS
- kling_api.php: read credentials via getKlingCredentials() on every request
  so rotations take effect immediately without a server restart
- All .env templates: add ADMIN_EMAILS= (dev@localhost populated in .env.local)
- AdminSettings.jsx: modal with masked status, Test Connection, Save Credentials
- AppContent.jsx: admin status check on mount; Settings gear shown to admins
- Fix production URL in .env.production/.env.example (optical-prod.oliver.solutions)
- .gitignore: exclude backend/runtime_config.json

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:45:27 +01:00
Vadym Samoilenko
a2358ba01c fix: correct Kling API params per official docs
- camera_control: only kling-v1 and kling-v1-5 support it (not v3)
- For preset types (down_back etc.), config must be absent — only
  'simple' type uses config fields
- camera_control and image_tail are mutually exclusive in I2V
- cfg_scale not supported by kling-v2.x models — now skipped
- duration sent as string to match API examples
- v3/v3-omni removed from camera control UI list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:38:31 +01:00
Vadym Samoilenko
746cd42f6c fix: restrict camera control to supported models; update model list
Camera/motion control is only supported by kling-v1, kling-v1-5,
kling-v3, kling-v3-omni. Backend silently drops the camera_control
field for all other models. Frontend hides the camera control UI
and auto-clears the selection when switching to an unsupported model.

New models added: kling-v3-omni, kling-video-o1, kling-v1 (cam ctrl),
kling-v2-master. Duration validation loosened for v3/v3-omni (3-15s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:35:34 +01:00
Vadym Samoilenko
fa7c67af6a refactor: move video prompt optimization to backend
Client-side Gemini calls were hitting the frontend API key's rate limits
and exposing the key in the browser. The optimizer now POSTs to
video_api.php (action=optimize_prompt) which calls Gemini server-side
using the backend key.

Rate-limited responses (429) silently fall back to the simple prompt
generator without showing an error.

Removes the @google/generative-ai import from VideoGenTab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:32:30 +01:00
Vadym Samoilenko
5a17565731 fix: Kling I2V uses image/image_tail fields with clean base64
Kling image2video endpoint expects:
  image: "<base64>" (no data URI prefix)
  image_tail: "<base64>" (optional, for interpolation)

Not image_url/image_tail_url with data URIs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:27:58 +01:00
Vadym Samoilenko
68690b819a fix: durationSeconds must be integer not string in Veo API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:24:47 +01:00
Vadym Samoilenko
dbe5ef3f11 fix: revert Veo I2V image format to bytesBase64Encoded
The Veo predictLongRunning endpoint is a predict-style API (not
generateContent) and expects image data as:
  { "bytesBase64Encoded": "...", "mimeType": "image/jpeg" }

The previous session switched it to inlineData (generateContent format),
causing the API to reject it with:
  "inlineData isn't supported by this model"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:23:32 +01:00
Vadym Samoilenko
866ff126d7 fix: nginx client_max_body_size 100M + silent 429 fallback
nginx inside the Docker container defaults to 1MB body limit —
base64 image payloads exceed this and return a 413 HTML page,
causing the "Unexpected token '<'" JSON parse error on video_api.php.

Gemini 429 rate limit errors on prompt optimization now fall back
silently instead of surfacing an error to the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:21:36 +01:00
Vadym Samoilenko
59e71b0bcc fix: safer deploy — build first, up only on success, --no-cache
Old: `up -d --build` stops old container before building → if build fails, service is down
New:
  1. `build --no-cache` — full rebuild, fails without touching running container
  2. `up -d --force-recreate` — only runs if build succeeded
  3. health check after start to catch silent failures

--no-cache ensures PHP/JS file changes are always picked up (no stale layer cache)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:16:33 +01:00
Vadym Samoilenko
c030b47fcf fix: upgrade prompt optimizer to gemini-2.0-flash
gemini-2.0-flash-lite has lower RPM limits even on paid plans.
gemini-2.0-flash has higher quotas and better quality optimization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:14:24 +01:00
Vadym Samoilenko
53c4e9f8a3 fix: GD safety check + zlib-dev for robust Alpine build
- video_api.php: check function_exists('imagecreatefromstring') before calling GD
  — undefined function causes PHP fatal error even with @ suppressor, kills php-fpm
- Dockerfile: add zlib-dev (required for libpng on some Alpine versions)
- Dockerfile: verify GD loaded after install (build log confirmation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:09:54 +01:00
Vadym Samoilenko
e43432e08c fix: correct Kling and Veo API parameter formats
Kling:
- camera_control.type: use preset name directly (down_back etc.), not 'predefined'
- camera_control.config: all 6 integer fields required even for preset types
- duration: cast to integer (API rejects strings)
- I2V images: use image_url/image_tail_url with data URI prefix (not plain base64 in image/image_tail)

Veo:
- image/lastFrame: use inlineData format (Gemini API), not bytesBase64Encoded (Vertex AI)
- durationSeconds: send as string "4"/"6"/"8", not integer

Docker:
- Add uploads.ini: post_max_size=100M, upload_max_filesize=100M, memory_limit=512M
  (ini_set cannot override these at runtime in php-fpm)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:59:56 +01:00
Vadym Samoilenko
07aa52438c fix: accept any relative URL for Kling video validation
After changing the video URL prefix to /lux-studio/api/generated_videos/,
the old /generated_videos/ check caused a false-positive validation error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:42:25 +01:00
Vadym Samoilenko
3f10b53f9c fix: add GD extension, fix Kling video URLs and undefined variable
- Dockerfile: install GD (libpng + libjpeg) for Veo I2V image resizing
- Dockerfile: create generated_videos/ dir with write permissions
- kling_api.php: fix video URL /generated_videos/ → /lux-studio/api/generated_videos/ (nginx couldn't find files at old path)
- kling_api.php: fix undefined \$path → \$endpoint in error log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:27:12 +01:00
Vadym Samoilenko
f221c33773 fix: add config.php and unignore it so Docker builds include it
All backend PHP files require config.php as their first include.
It was gitignored and missing, causing PHP fatal errors that produced
empty responses — breaking JSON parsing in the frontend.

The new config.php has no hardcoded secrets; it reads all values
(GEMINI_API_KEY, KLING_ACCESS_KEY, KLING_SECRET_KEY, SSO_*) from
the .env file via env_loader.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:12:38 +01:00
Vadym Samoilenko
8c91232f30 fix: overwrite .env.production with .env.optical in Docker build
Vite loads .env.production with higher priority than .env in production
mode — optical URLs were being overridden by ai-sandbox values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:30:26 +01:00
Vadym Samoilenko
498b667903 fix: copy PHP files before composer install (classmap needs them)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:20:41 +01:00
Vadym Samoilenko
234b13ee31 fix: use --no-security-blocking for older composer version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:20:02 +01:00
Vadym Samoilenko
5591d46397 fix: skip composer audit for firebase/php-jwt advisory (SSO disabled)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:19:12 +01:00
Vadym Samoilenko
db4322bcd7 fix: rewrite deploy-optical.sh for Docker Compose pattern
git pull → docker compose up --build → Apache include (idempotent) → reload.
No longer assumes PHP/Node on host; runs without sudo except Apache steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:17:56 +01:00
Vadym Samoilenko
03671abcaa feat: add Docker setup for optical-prod deployment
Multi-stage Dockerfile (node:20 builder + php:8.2-fpm-alpine runtime),
nginx serving frontend SPA + PHP-FPM backend at /lux-studio/, supervisord
managing both processes. docker-compose.prod.yml on port 8085, .env.optical
mounted read-only, uploads in a named volume.
Apache include at deploy/apache-lux-studio.conf proxies /lux-studio/ → :8085.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:13:52 +01:00
Vadym Samoilenko
18cc2aa869 config: update Azure AD Client ID and add optical-dev deployment
Replace old SSO Client IDs with new IT-provisioned ID (a321d54f) across
all env templates and CLAUDE.md. Add frontend/.env.optical, backend/.env.optical,
and deploy-optical.sh targeting optical-prod.oliver.solutions/lux-studio/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:06:24 +01:00
Simeon.Schecter
95e6946807 feat: Kling integration, prompt optimizer rework, and stability fixes
Kling video generation:
- Full T2V, I2V, extend, and lip sync workflows via Kling API
- V3, V2.6, V2.5 Turbo, V2.1 Master, V1.6 model support
- Resolution selector (720p std / 1080p pro) with model constraints
- Native audio toggle with dialogue input for Kling
- Video ID tracking for extend and lip sync chains
- Camera control presets (pan, tilt, arc)

Prompt optimizer rework:
- Intent-preserving refinement (camera, action, mood are sacred)
- Mode-aware: T2V adds subject/environment detail, I2V describes only motion
- Reference images analyzed for content, not re-described
- Platform-specific quality anchors woven into positive prompt
- Negative prompts removed from optimizer (positive-only approach)
- 15-60 word target for concise, effective prompts

Backend fixes:
- Gemini responseModalities: ['TEXT', 'IMAGE'] for Flash model compatibility
- Veo first-frame resize to exact target dimensions (prevents letterboxing)
- Session directory re-creation in saveImage (auto-cleanup race condition)
- Kling API error logging with HTTP codes and payload details
- Lip sync endpoint updated to /v1/videos/lip-sync with video_id

Frontend stability:
- Tab persistence via CSS hidden (generation survives tab switches)
- Project switch protection (confirm dialog when generation in progress)
- Retina thumbnails (480px/q0.8) for library grid — prevents OOM crashes
- Thumbnail backfill migration for existing project items
- Project items refresh on tab visibility and after save
- 1:1 aspect ratio container for Kling videos
- Expanded video view matches library modal behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:51:03 -04:00
Simeon.Schecter
696d8a985c style: tabs flush left with generous logo spacing, bump to 14px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:46:10 -04:00
Simeon.Schecter
0fdb43fecf style: simplify storyboard exports — let the images speak
PNG: Remove card backgrounds, borders, header bars, and empty
annotation text. Just images in a grid with a small frame number.

PDF: Reduce margins and label reservation to maximize image size.
Frame number and annotation condensed to one small line below.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:44:46 -04:00
Simeon.Schecter
b94c5c6b2a style: separate tabs from logo, spread across header
Tabs now float between logo and user info via justify-between,
giving the logo more breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:40:15 -04:00
Simeon.Schecter
a7d409b7fc style: remove icons from tab navigation, text only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:39:05 -04:00
Simeon.Schecter
390450d873 style: try IBM Plex Sans + IBM Plex Mono as unified family
Matched sans+mono from same family. More Univers-like, technical.
Previous commit (6c47a79) has Space Grotesk for comparison.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:37:43 -04:00
Simeon.Schecter
6c47a7967f style: switch primary typeface from DM Sans to Space Grotesk
Geometric grotesque with equipment personality. Keeps JetBrains Mono
for data text. Base size stays 12px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:35:29 -04:00
Simeon.Schecter
5ad83aca48 style: reduce base font size from 13px to 12px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:29:52 -04:00
Simeon.Schecter
02dd46e9d7 style: modals use bg-slate-800 to pop above dark overlay
Slate-925 panels are nearly invisible against black/60 overlays.
Modals need to be clearly visible, so they use slate-800.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:26:57 -04:00
Simeon.Schecter
b522f94ca9 style: minimize panel contrast — slate-925 panels on slate-950 ground
Custom slate-925 (#080d1b) sits just one shade above the 950 ground,
making panels barely visible. The content is the interface, not the
chrome. Reverts the inverted-depth experiment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:23:44 -04:00
Simeon.Schecter
43833d7474 style: invert depth — lighter surround, darker content wells
Page ground from bg-slate-950 to bg-slate-800. Content panels stay
bg-slate-900, creating inset wells rather than floating cards.
Like a Braun control panel with recessed dark areas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:21:20 -04:00
Simeon.Schecter
2a52106ba0 style: restore uppercase tracking on section labels in Image/Video Gen
Zone markers (Preset, Camera Body, Lens Kit, Duration, etc.) get
uppercase + wide tracking like Braun hi-fi category labels. Modal
form labels and interactive toggles stay sentence case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:19:30 -04:00
Simeon.Schecter
efe5757edf style: add JetBrains Mono for data/spec text
Second typographic voice for timestamps, dimensions, file sizes,
session IDs, costs, word counts, resolution specs, and version number.
Like lens barrel markings — the data speaks in mono, the UI in sans.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:15:05 -04:00
Simeon.Schecter
addce12347 style: reduce base font size from 14px to 13px
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:08:39 -04:00
Simeon.Schecter
a096cd8d88 style: standardize backgrounds to strict 5-tone scale (950/900/800/700/600)
Remove all opacity variants (bg-slate-900/50, bg-slate-800/50, etc.) in
favor of opaque tones. Inputs standardized to bg-slate-800 (Elevated).
hover:bg-slate-800 promoted to hover:bg-slate-700 (Interactive tier).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:06:34 -04:00
Simeon.Schecter
fdfb8ceb2e style: restore purple Optimize button in Image Gen and Video Gen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:00:28 -04:00
Simeon.Schecter
7051d260fe style: remove tab underline indicator, let type color speak
Active tab is already distinguished by gold text color. The
underline was redundant decoration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:56:55 -04:00
Simeon.Schecter
af3f8f0536 style: revert toggle buttons back to gold
Off-white toggles did not improve things -- gold selected state
was already working well. Reverts 4c4c857.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:55:28 -04:00
Simeon.Schecter
4c4c8575e5 style: change selected toggle buttons from gold to off-white
Replace bg-cinema-gold with bg-slate-200 on all 21 toggle/selector
buttons. Gold now reserved exclusively for primary CTA buttons
(Generate Image, Generate Video). Creates quieter UI that lets
generated images and video stand out.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:52:57 -04:00
Simeon.Schecter
036884bf91 style: remove decorative borders, keep only functional ones
Strip panel/card wrapper borders, section separator borders, tooltip
borders, and modal borders. Remaining borders are all functional:
input fields, focus states, status indicators (green/red/amber),
dashed upload zones, active selection states, and type badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:50:20 -04:00
Simeon.Schecter
c5002b8c6b style: drop all type weight from medium (500) to normal (400)
DM Sans at weight 400 is clean and readable — hierarchy now comes
entirely from size, not weight. Lighter overall feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:44:32 -04:00
Simeon.Schecter
0093b6dc92 style: Rams refinement on ProjectsTab
Thin font weights, tighten border radii, remove dropdown shadows,
strip uppercase from section headers, flatten video placeholder
gradient, replace indigo/purple action buttons with neutral slate,
consolidate move/select toggles to cinema-gold. Keep type badges
(IMG/VID/KLING) as functional color indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:33:48 -04:00
Simeon.Schecter
c5dcc68056 style: Rams refinement on VideoGenTab
Strip uppercase+tracking from all labels, thin font weights to medium,
tighten all border radii, replace indigo-to-purple gradient with neutral
slate button, flatten container gradient, consolidate indigo workflow
selector to cinema-gold, remove all indigo color references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:31:53 -04:00
Simeon.Schecter
f1315a1662 style: Rams refinement on CinePromptStudio
Strip uppercase+tracking from all labels, thin font-bold to font-medium,
tighten all border radii, replace indigo-to-purple gradient with neutral
slate button, remove tooltip shadow, consolidate purple focus mode badge
to slate, remove decorative hover:scale on copy button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:30:14 -04:00
Simeon.Schecter
d8c6f8db29 style: Rams refinement on VideoPlayer and StoryboardEditor
Tighten border radius (rounded-xl/lg→rounded), remove decorative
shadows, replace indigo Generate Video button with neutral slate,
thin heading font weights to medium.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:28:44 -04:00
Simeon.Schecter
6ba469c7b9 style: Rams refinement on LoginPage and AppContent
Thin font weights (bold→medium, semibold→medium), tighten border
radius (rounded-lg→rounded), reduce shadow (xl→sm) on login card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:27:31 -04:00
Simeon.Schecter
c7ec58ff7f style: add DM Sans font and 14px base sizing
Switch from system font stack to DM Sans (Google Fonts, variable weight)
and reduce base font-size to 14px for a more compact, professional feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:27:02 -04:00
Simeon.Schecter
35d19e3a25 Add Kling 3.0 models (V3 and V3 Omni) to video generation
- Add kling-v3 and kling-v3-omni to model selector and backend validation
- Set V3 as the new default model (was V2.6)
- V3 Omni includes built-in audio generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:30:00 -04:00
Simeon.Schecter
b9f35de5ef Add Kling AI video generation engine alongside Veo 3.1
Integrates Kling AI as a second video engine in the Video Gen tab with
three cinema-relevant workflows: Generate (T2V/I2V with camera control),
Extend (video extension up to 3 min), and Lip Sync (image + audio).

Backend: New kling_api.php with pure PHP JWT auth, all workflows, async
status polling, and CDN video download. Env files updated with Kling
credential placeholders.

Frontend: Engine selector toggle, workflow-specific settings panels,
Kling polling, engine badges in ProjectsTab, rerun support.

Also includes image model toggle changes (Gemini Pro/Flash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:22:39 -04:00
36 changed files with 3659 additions and 896 deletions

5
.gitignore vendored
View file

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

View file

@ -263,7 +263,7 @@ GEMINI_API_KEY=AIzaSyC...
FRONTEND_URL=http://localhost:3000
SSO_ENABLED=false
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
```
**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=9079054c-9620-4757-a256-23413042f1ef
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
```
**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=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
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=9079054c-9620-4757-a256-23413042f1ef
VITE_SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6
VITE_SSO_REDIRECT_URI=https://ai-sandbox.oliver.solutions/lux-studio/
NODE_ENV=production
```

View file

@ -19,6 +19,20 @@ 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)
# ----------------------------------------------------------------------------
@ -29,7 +43,7 @@ GEMINI_API_KEY=your-api-key-here
FRONTEND_URL=http://localhost:3000
#
# PRODUCTION (comment out for local):
# FRONTEND_URL=https://ai-sandbox.oliver.solutions/lux-studio
# FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
# ----------------------------------------------------------------------------
# Azure AD / MSAL SSO Configuration
@ -38,4 +52,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=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6

View file

@ -17,6 +17,21 @@ 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)
# ----------------------------------------------------------------------------
@ -30,4 +45,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=15c0c4e2-bac0-4564-a3a6-c2717f00a6d9
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6

21
backend/.env.optical Normal file
View file

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

View file

@ -13,12 +13,21 @@
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://ai-sandbox.oliver.solutions/lux-studio
FRONTEND_URL=https://optical-prod.oliver.solutions/lux-studio
# ----------------------------------------------------------------------------
# Azure AD / MSAL SSO Configuration
@ -27,4 +36,4 @@ FRONTEND_URL=https://ai-sandbox.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=9079054c-9620-4757-a256-23413042f1ef
SSO_CLIENT_ID=a321d54f-4f94-4be4-ae91-026c4d9839e6

202
backend/admin_api.php Normal file
View file

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

View file

@ -57,13 +57,20 @@ try {
class NanoBananaProAPI {
private $apiKey;
private $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
private $model = 'gemini-3-pro-image-preview';
private $model;
public function __construct($apiKey) {
// Available models
private static $models = [
'pro' => 'gemini-3-pro-image-preview',
'flash' => 'gemini-3.1-flash-image-preview'
];
public function __construct($apiKey, $modelType = 'pro') {
$this->apiKey = $apiKey;
$this->model = self::$models[$modelType] ?? self::$models['pro'];
}
public function generateImage($prompt, $aspectRatio = '16:9', $imageSize = '2K', $inputImage = null, $referenceImages = []) {
public function generateImage($prompt, $aspectRatio = '16:9', $imageSize = '2K', $inputImage = null, $referenceImages = [], $safetyLevel = 'default') {
$parts = [];
// IMPORTANT: Input image (the one being edited) must come FIRST
@ -127,7 +134,7 @@ class NanoBananaProAPI {
['parts' => $parts]
],
'generationConfig' => [
'responseModalities' => ['IMAGE'],
'responseModalities' => ['TEXT', 'IMAGE'],
'imageConfig' => [
'aspectRatio' => $aspectRatio,
'imageSize' => $imageSize
@ -135,6 +142,15 @@ 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);
}
@ -296,6 +312,15 @@ 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');
@ -307,7 +332,7 @@ try {
}
// Initialize API
$api = new NanoBananaProAPI(GEMINI_API_KEY);
$api = new NanoBananaProAPI(GEMINI_API_KEY, $modelType);
// Determine input image for editing:
// 1. Frontend sends uploadedImage when editing from library or a displayed image
@ -329,7 +354,7 @@ try {
}
// Generate or edit image (with optional reference images)
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $inputImage, $referenceImages);
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $inputImage, $referenceImages, $safetyLevel);
$imageData = $api->extractImageData($response);
// Save to disk
@ -347,7 +372,7 @@ try {
'aspectRatio' => $aspectRatio,
'imageSize' => $imageSize,
'actionType' => $inputImage ? 'edit' : 'generate',
'model' => 'Gemini 3 Pro Image'
'model' => $modelType === 'flash' ? 'Nano Banana 2 (Flash)' : 'Nano Banana Pro'
];
logImageGeneration($prompt, $imageData['base64'], $imageData['mime_type'], $webhookSettings, $userEmail, $inputImage ? 'edit' : 'generate');

35
backend/config.php Normal file
View file

@ -0,0 +1,35 @@
<?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);

685
backend/kling_api.php Normal file
View file

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

View file

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

View file

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

View file

@ -10,6 +10,12 @@ 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');
@ -44,11 +50,97 @@ 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 = '') {
public function generateVideo($prompt, $duration = 4, $aspectRatio = '16:9', $resolution = '720p', $generateAudio = true, $referenceImages = [], $referenceMode = 'frame', $negativePrompt = '', $safetyLevel = 'default') {
// Build the instance object
$instance = [
'prompt' => $prompt
@ -60,19 +152,29 @@ 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)) {
// Use bytesBase64Encoded format (original working format)
$mimeType = $refImg['mime_type'] ?? 'image/jpeg';
$data = $this->resizeImageForVeo($data, $mimeType, $aspectRatio);
$instance['image'] = [
'bytesBase64Encoded' => $data,
'mimeType' => $refImg['mime_type'] ?? 'image/jpeg'
'mimeType' => $mimeType
];
error_log("Added first frame for I2V generation");
error_log("Added first frame for I2V generation (resized to match $aspectRatio)");
}
}
@ -84,10 +186,11 @@ class VeoVideoAPI {
$lastData = preg_replace('/\s+/', '', $lastImg['data']);
if (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $lastData)) {
// Use bytesBase64Encoded format, placed in instance (same level as image)
$lastMimeType = $lastImg['mime_type'] ?? 'image/jpeg';
$lastData = $this->resizeImageForVeo($lastData, $lastMimeType, $aspectRatio);
$instance['lastFrame'] = [
'bytesBase64Encoded' => $lastData,
'mimeType' => $lastImg['mime_type'] ?? 'image/jpeg'
'mimeType' => $lastMimeType
];
error_log("Added lastFrame to instance for video interpolation (8s duration)");
}
@ -96,12 +199,9 @@ class VeoVideoAPI {
}
}
// 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;
}
// 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;
// Resolution: 720p (default), 1080p (8s only), 4k (8s only)
// Per API docs: higher resolutions only available for 8-second videos
@ -126,6 +226,11 @@ 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
@ -543,6 +648,63 @@ 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
@ -554,6 +716,7 @@ 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'])) {
@ -589,7 +752,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);
$response = $api->generateVideo($prompt, $duration, $aspectRatio, $resolution, $generateAudio, $referenceImages, $referenceMode, $negativePrompt, $safetyLevel);
$videoData = $api->extractVideoData($response);
// Handle different response types

96
deploy-optical.sh Executable file
View file

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

View file

@ -0,0 +1,7 @@
# 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/

14
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,14 @@
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:

45
docker/Dockerfile Normal file
View file

@ -0,0 +1,45 @@
# ── 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"]

45
docker/nginx.conf Normal file
View file

@ -0,0 +1,45 @@
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;
}
}
}

23
docker/supervisord.conf Normal file
View file

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

View file

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

20
frontend/.env.optical Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,13 @@
import { useState } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { LogOut } from 'lucide-react';
import { LogOut, Settings } 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
@ -22,6 +23,45 @@ 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) {
@ -35,8 +75,12 @@ function AppContent() {
// Logout handler
const handleLogout = () => {
if (instance) {
instance.logoutPopup();
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,
});
}
};
@ -64,6 +108,11 @@ 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);
};
@ -82,7 +131,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-6">
<div className="flex items-center gap-10">
<img
src={`${import.meta.env.BASE_URL}LUX_STUDIO_LOGO.svg`}
alt="Lux Studio"
@ -98,10 +147,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-medium">{activeProjectName}</span>
Working in: <span className="text-cinema-gold font-normal">{activeProjectName}</span>
</div>
)}
<div className="text-xs text-slate-500">
<div className="text-xs font-mono text-slate-500">
Version 1.0
</div>
</div>
@ -110,10 +159,19 @@ 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-800 rounded-lg transition-colors"
className="p-2 hover:bg-slate-700 rounded transition-colors"
title="Logout"
>
<LogOut className="w-5 h-5 text-slate-400" />
@ -125,29 +183,41 @@ function AppContent() {
</div>
</div>
{/* Main Content - Tab Panels */}
{showAdminSettings && (
<AdminSettings onClose={() => setShowAdminSettings(false)} />
)}
{/* Main Content - Tab Panels (always mounted so async generation persists across tab switches) */}
<main className="max-w-7xl mx-auto px-6 pb-8">
{activeTab === 'projects' && (
<div className={activeTab === 'projects' ? '' : 'hidden'}>
<ProjectsTab
onProjectSelect={handleProjectSelect}
activeProjectId={activeProjectId}
onRerunVideo={handleRerunVideo}
onEditInImageGen={handleEditInImageGen}
/>
)}
{activeTab === 'image' && (
<CinePromptStudio
activeProjectId={activeProjectId}
editData={imageEditData}
onEditLoaded={handleEditLoaded}
/>
)}
{activeTab === 'video' && (
<VideoGenTab
activeProjectId={activeProjectId}
rerunData={videoRerunData}
onRerunLoaded={handleRerunLoaded}
/>
</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>
</>
)}
</main>
</div>

View file

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } 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 }) => {
const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded, onBusyChange, isVisible }) => {
// API URL helper - uses Vite proxy in dev, direct URL in production
const getApiUrl = (endpoint) => {
// In development, use Vite proxy to avoid CORS
@ -725,12 +725,20 @@ const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded }) => {
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);
@ -807,29 +815,34 @@ const CinePromptStudio = ({ activeProjectId, editData, onEditLoaded }) => {
}
}, [editData, onEditLoaded]);
// Fetch project images when activeProjectId changes
useEffect(() => {
const loadProjectImages = async () => {
if (!activeProjectId || !dbReady) {
setProjectImages([]);
return;
// 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'));
}
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();
} catch (err) {
console.error('Failed to load project images:', err);
}
}, [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;
@ -1145,6 +1158,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
}
setIsGeneratingImage(true);
onBusyChange?.(true);
setImageError('');
try {
@ -1158,6 +1172,7 @@ 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
@ -1172,6 +1187,7 @@ 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',
@ -1197,11 +1213,12 @@ 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 },
settings: { camera, lens, application, aspectRatio, imageResolution, imageModelType },
thumbnail: null,
data: imageData.data,
mimeType: imageData.mime_type
});
refreshProjectImages();
} catch (saveErr) {
console.error('Failed to save to project:', saveErr);
}
@ -1216,6 +1233,7 @@ 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);
}
};
@ -1391,13 +1409,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 border border-slate-700 text-slate-300 rounded-lg shadow-2xl max-w-xs pointer-events-none
className={`absolute z-50 p-3 bg-slate-900 text-slate-300 rounded 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 border-l border-t border-slate-700 transform rotate-45
<div className={`absolute w-2 h-2 bg-slate-900 transform rotate-45
${position === 'top' ? 'bottom-[-5px]' : 'top-[-5px] rotate-[225deg]'} left-6`}></div>
</div>
</div>
@ -1410,15 +1428,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-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">
<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">
<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-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<label className="text-xs font-normal 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 && (
@ -1441,7 +1459,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-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"
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"
>
{customPresets.length > 0 && (
<optgroup label="My Custom Presets">
@ -1530,13 +1548,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-bold text-slate-400 uppercase tracking-wider">
<label className="text-xs font-normal 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-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"
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"
>
{cameraData.map((cam) => (
<option key={cam.value} value={cam.value}>{cam.display}</option>
@ -1549,13 +1567,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-bold text-slate-400 uppercase tracking-wider">
<label className="text-xs font-normal 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-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"
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"
>
{compatibleLenses.map((l) => (
<option key={l.value} value={l.value}>{l.display}</option>
@ -1568,7 +1586,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-bold text-slate-400 uppercase tracking-wider">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider">
Aspect Ratio
</label>
<div className="flex flex-wrap gap-2">
@ -1576,7 +1594,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-lg font-medium transition-all ${
className={`px-4 py-2 rounded font-normal transition-all ${
aspectRatio === ratio
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
@ -1589,7 +1607,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{/* Info Box */}
<div className="bg-slate-800/50 rounded-lg p-4 text-xs text-slate-400">
<div className="bg-slate-800 rounded 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>
@ -1599,9 +1617,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 border-t border-slate-800">
<div className="space-y-3 pt-4">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<label className="text-xs font-normal text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Sliders className="w-4 h-4" />
Creative Freedom
</label>
@ -1615,7 +1633,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-lg appearance-none cursor-pointer slider"
className="w-full h-2 bg-slate-700 rounded appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #f59e0b 0%, #f59e0b ${creativeFreedom * 100}%, #475569 ${creativeFreedom * 100}%, #475569 100%)`
}}
@ -1628,14 +1646,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-bold text-slate-400 uppercase tracking-wider">
<label className="text-xs font-normal 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-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"
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"
/>
{/* Action Buttons */}
@ -1643,7 +1661,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-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"
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"
>
{isGenerating ? (
<>
@ -1660,14 +1678,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-medium rounded-lg transition-all"
className="w-full px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-normal rounded transition-all"
>
Simple Generate
</button>
</div>
{error && (
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded-lg px-4 py-2">
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded px-4 py-2">
{error}
</div>
)}
@ -1678,9 +1696,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-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 rounded-xl overflow-hidden">
<div className="relative bg-slate-925 rounded overflow-hidden">
<div className="p-6 space-y-4">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
<h3 className="text-xs font-normal 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">
@ -1698,10 +1716,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 border-t border-slate-800">
<div className="flex items-center justify-between pt-4">
<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-medium rounded-lg transition-all transform hover:scale-105"
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"
>
{copied ? (
<>
@ -1716,7 +1734,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
)}
</button>
<div className="text-xs text-slate-500">
<div className="text-xs font-mono text-slate-500">
{generatedPrompt.split(' ').length} words
</div>
</div>
@ -1724,10 +1742,12 @@ 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-900/50 border border-slate-800 rounded-xl p-6 space-y-4">
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-slate-200 flex items-center gap-2">
<h3 className="text-lg font-normal text-slate-200 flex items-center gap-2">
<Image className="w-5 h-5 text-cinema-gold" />
Generated Image
</h3>
@ -1738,7 +1758,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-lg 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 transition-all flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
Start Fresh
@ -1750,7 +1770,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-bold text-slate-400 uppercase tracking-wider">Reference Images (Optional)</label>
<label className="text-xs font-normal 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">
@ -1759,11 +1779,14 @@ 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-12 h-12 object-cover rounded-lg border border-slate-700"
className="w-24 h-24 object-cover rounded border-2 border-cinema-gold"
/>
<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 -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"
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"
>
<X className="w-3 h-3 text-white" />
</button>
@ -1774,7 +1797,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-12 h-12 flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-colors ${
className={`w-16 h-16 flex flex-col items-center justify-center border-2 border-dashed rounded transition-colors ${
showProjectPicker
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-cinema-gold'
@ -1782,12 +1805,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-[8px] text-slate-400 mt-0.5">Project</span>
<span className="text-[9px] text-slate-400 mt-0.5">Project</span>
</button>
)}
<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">
<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">
<Plus className="w-4 h-4 text-slate-500" />
<span className="text-[8px] text-slate-500 mt-0.5">Upload</span>
<span className="text-[9px] text-slate-500 mt-0.5">Upload</span>
<input
type="file"
accept="image/*"
@ -1802,9 +1825,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 border border-slate-700 rounded-lg p-3">
<div className="bg-slate-800 rounded p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-slate-400">From Project</span>
<span className="text-xs font-normal text-slate-400 uppercase tracking-wider">From Project</span>
<button
onClick={() => setShowProjectPicker(false)}
className="text-slate-500 hover:text-slate-300"
@ -1812,7 +1835,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-32 overflow-y-auto">
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{projectImages.map((item) => (
<button
key={item.id}
@ -1840,15 +1863,36 @@ 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-bold text-slate-400 uppercase tracking-wider">Output Resolution</label>
<label className="text-xs font-normal 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-lg text-sm font-medium transition-all ${
className={`px-4 py-2 rounded text-sm font-normal transition-all ${
imageResolution === res
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
@ -1858,13 +1902,29 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</button>
))}
</div>
<p className="text-xs text-slate-500">
<p className="text-xs font-mono 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 && (
@ -1884,7 +1944,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-lg border-2 ${generatedImage ? 'border-cinema-gold' : 'border-dashed border-slate-700'} flex items-center justify-center overflow-hidden`}>
} bg-slate-950 rounded 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" />
@ -1909,7 +1969,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-lg px-4 py-2">
<div className="text-red-400 text-sm bg-red-950/20 border border-red-900/50 rounded px-4 py-2">
{imageError}
</div>
)}
@ -1919,7 +1979,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-medium rounded-lg 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-normal rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGeneratingImage ? (
<>
@ -1938,14 +1998,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-lg transition-all"
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded 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-lg transition-all"
className="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded transition-all"
title="Start New Image"
>
<RefreshCw className="w-5 h-5" />
@ -1956,7 +2016,7 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</div>
{/* Current Settings Display */}
<div className="bg-slate-900/30 rounded-lg p-4 space-y-3">
<div className="bg-slate-925 rounded p-4 space-y-3">
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-slate-500">Application:</span>
@ -1976,13 +2036,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 border-t border-slate-800">
<div className="pt-2">
<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-purple-900/50 text-purple-400 border border-purple-800'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}>
{allApplicationData.find(app => app.value === application)?.focusType === 'realism'
? '🔬 Realism (Deep Focus)'
@ -2001,9 +2061,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-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<div className="bg-slate-800 rounded w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-slate-200">
<h3 className="text-lg font-normal text-slate-200">
{editingPreset ? 'Edit Preset' : 'Create New Preset'}
</h3>
<button onClick={() => setShowPresetModal(false)} className="text-slate-500 hover:text-slate-300">
@ -2013,34 +2073,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-bold text-slate-400 uppercase tracking-wider">Preset Name *</label>
<label className="text-xs font-normal text-slate-400">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-lg 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 text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
/>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Lighting Description *</label>
<label className="text-xs font-normal text-slate-400">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-lg 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 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-bold text-slate-400 uppercase tracking-wider">Default Camera</label>
<label className="text-xs font-normal text-slate-400">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-lg 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 text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="">None (user picks)</option>
{cameraData.map((cam) => (
@ -2049,11 +2109,11 @@ Return ONLY the final prompt text. No markdown, no preamble, no "Here is your pr
</select>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Default Lens</label>
<label className="text-xs font-normal text-slate-400">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-lg 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 text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="">None (user picks)</option>
{lensData.map((l) => (
@ -2065,11 +2125,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-bold text-slate-400 uppercase tracking-wider">Focus Mode</label>
<label className="text-xs font-normal text-slate-400">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-lg 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 text-slate-200 focus:border-cinema-gold focus:outline-none text-sm"
>
<option value="stylistic">Stylistic</option>
<option value="realism">Realism</option>
@ -2090,14 +2150,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-lg text-sm transition-all"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded 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-medium rounded-lg 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-normal rounded text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingPreset ? 'Save Changes' : 'Create Preset'}
</button>
@ -2109,9 +2169,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-900 border border-slate-700 rounded-xl w-full max-w-lg p-6 space-y-4">
<div className="bg-slate-800 rounded w-full max-w-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-slate-200">Manage Custom Presets</h3>
<h3 className="text-lg font-normal 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>
@ -2122,9 +2182,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-lg px-4 py-3">
<div key={p.id} className="flex items-center justify-between bg-slate-800 rounded px-4 py-3">
<div>
<div className="text-sm text-slate-200 font-medium">{p.name}</div>
<div className="text-sm text-slate-200 font-normal">{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">
@ -2154,10 +2214,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 border-t border-slate-800">
<div className="flex items-center gap-2 pt-2">
<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-medium rounded-lg 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-normal rounded text-sm transition-all"
>
<Plus className="w-4 h-4" />
New Preset
@ -2165,13 +2225,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-lg 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 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-lg 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 text-sm transition-all cursor-pointer">
<Upload className="w-4 h-4" />
Import
<input
@ -2207,7 +2267,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-xl border-2 border-cinema-gold"
className="max-w-[90vw] max-h-[85vh] object-contain rounded border-2 border-cinema-gold"
/>
</div>
</div>

View file

@ -22,7 +22,7 @@ const LoginPage = () => {
alt="Lux Studio"
className="h-16 w-auto mx-auto mb-6"
/>
<h1 className="text-3xl font-bold text-slate-100 mb-2">
<h1 className="text-3xl font-normal 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-900 rounded-lg p-8 shadow-xl border border-slate-800">
<div className="bg-slate-925 rounded p-8 shadow-sm">
<button
onClick={handleLogin}
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"
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"
>
<svg className="w-5 h-5" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h10v10H0V0z" fill="#f25022"/>

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { FolderOpen, Plus, Image, Video, Trash2, Edit2, Check, X, Download, Clock, Search, Grid, List, Loader2, AlertCircle, Play, RefreshCw, Layers, CheckSquare, Square, Upload, Wand2, Database, ArrowRightLeft, Maximize2, Minimize2 } 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, Copy } from 'lucide-react';
import useProjects from '../hooks/useProjects';
import useCustomPresets from '../hooks/useCustomPresets';
import VideoPlayer from './VideoPlayer';
@ -133,6 +133,7 @@ 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([]);
@ -575,15 +576,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-900/50 border border-slate-800 rounded-xl p-6 space-y-4">
<div className="bg-slate-925 rounded p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-200 flex items-center space-x-2">
<h2 className="text-lg font-normal 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-lg transition-all"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded transition-all"
>
<Plus className="w-4 h-4" />
</button>
@ -591,7 +592,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-lg text-red-400 text-sm">
<div className="flex items-center gap-2 p-3 bg-red-950/30 border border-red-900/50 rounded text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error || dbError}</span>
</div>
@ -605,7 +606,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-lg 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 text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
/>
</div>
@ -617,19 +618,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-lg 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 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-lg"
className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"
>
<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-lg"
className="p-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded"
>
<X className="w-4 h-4" />
</button>
@ -651,10 +652,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={project.id}
onClick={() => handleSelectProject(project)}
className={`group p-4 rounded-lg cursor-pointer transition-all ${
className={`group p-4 rounded cursor-pointer transition-all ${
selectedProject?.id === project.id
? 'bg-cinema-gold/20 border border-cinema-gold'
: 'bg-slate-800/50 border border-slate-700 hover:border-slate-600'
: 'bg-slate-800 hover:bg-slate-700'
}`}
>
{editingId === project.id ? (
@ -663,7 +664,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-900 border border-cinema-gold rounded text-slate-200 text-sm"
className="flex-1 px-2 py-1 bg-slate-800 border border-cinema-gold rounded text-slate-200 text-sm"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleRenameProject(project.id)}
/>
@ -683,7 +684,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
) : (
<>
<div className="flex items-center justify-between">
<h3 className="font-medium text-slate-200 truncate">{project.name}</h3>
<h3 className="font-normal 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) => {
@ -706,7 +707,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</button>
</div>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500">
<div className="flex items-center gap-3 mt-2 text-xs font-mono text-slate-500">
<span className="flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" />
{formatDate(project.updatedAt)}
@ -723,21 +724,21 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Right Panel: Project Contents */}
<div className="lg:col-span-8">
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-6 min-h-[70vh]">
<div className="bg-slate-925 rounded p-6 min-h-[70vh]">
{selectedProject ? (
<div className="space-y-4">
{/* Project Header */}
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div className="flex items-center justify-between pb-4">
<div>
<h2 className="text-xl font-bold text-slate-200">{selectedProject.name}</h2>
<p className="text-xs text-slate-500 mt-1">
<h2 className="text-xl font-normal text-slate-200">{selectedProject.name}</h2>
<p className="text-xs font-mono 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-lg transition-colors"
className="group relative p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded 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">
@ -748,10 +749,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Sub-tabs: Library | Storyboards */}
<div className="flex items-center gap-4 border-b border-slate-800 pb-2">
<div className="flex items-center gap-4 pb-2">
<button
onClick={() => { setActiveSubTab('library'); setIsSelecting(false); setSelectedImageIds([]); }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
activeSubTab === 'library'
? 'bg-cinema-gold/20 text-cinema-gold'
: 'text-slate-400 hover:text-slate-200'
@ -762,7 +763,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-lg transition-all ${
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
activeSubTab === 'storyboards'
? 'bg-cinema-gold/20 text-cinema-gold'
: 'text-slate-400 hover:text-slate-200'
@ -786,9 +787,9 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setIsSelecting(!isSelecting);
if (isSelecting) setSelectedImageIds([]);
}}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-all ${
isSelecting
? 'bg-indigo-600 text-white'
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -802,7 +803,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-lg text-sm font-medium transition-all"
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"
>
<Check className="w-4 h-4" />
Update Frames
@ -813,7 +814,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-lg 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 text-sm transition-all"
>
<X className="w-4 h-4" />
Cancel
@ -822,7 +823,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-lg text-sm font-medium transition-all"
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"
>
<Layers className="w-4 h-4" />
Create Storyboard
@ -837,7 +838,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-lg 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 text-sm transition-all disabled:opacity-50"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -859,7 +860,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-lg 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 text-sm transition-all disabled:opacity-50"
>
{importing ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -874,7 +875,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-all ${
className={`p-2 rounded transition-all ${
viewMode === 'grid' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -882,7 +883,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-all ${
className={`p-2 rounded transition-all ${
viewMode === 'list' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
@ -894,28 +895,28 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Create Storyboard Modal */}
{isCreatingStoryboard && (
<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="bg-slate-800 rounded p-4">
<h3 className="text-sm font-normal 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-900 border border-slate-600 rounded-lg text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
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"
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-lg text-sm font-medium 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 text-sm font-normal 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-lg text-sm"
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm"
>
Cancel
</button>
@ -929,12 +930,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-900 border border-slate-700 rounded-xl p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto">
<div className="bg-slate-800 rounded 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-bold text-slate-200">Import from Backend</h2>
<h2 className="text-xl font-normal text-slate-200">Import from Backend</h2>
<button
onClick={() => setShowImportModal(false)}
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
className="p-2 hover:bg-slate-700 rounded transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
@ -961,10 +962,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/50 border border-slate-700 rounded-lg p-4">
<div key={session.session_id} className="bg-slate-800 rounded p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-300">
Session: {session.session_id.substring(0, 8)}...
<h3 className="text-sm font-normal text-slate-300">
Session: <span className="font-mono">{session.session_id.substring(0, 8)}...</span>
</h3>
<span className="text-xs text-slate-500">
{session.images.length} image(s)
@ -974,7 +975,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
{/* Images */}
{session.images.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-slate-400 uppercase">Images</h4>
<h4 className="text-xs font-normal text-slate-400">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}`;
@ -985,7 +986,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={img.filename}
onClick={() => toggleFileSelection(session.session_id, 'image', img.filename)}
className={`relative p-2 rounded-lg border-2 cursor-pointer transition-all ${
className={`relative p-2 rounded border-2 cursor-pointer transition-all ${
isSelected
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-slate-600'
@ -995,7 +996,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 text-slate-500">
<p className="text-xs font-mono text-slate-500">
{img.size_kb} KB {expiresIn}h left
</p>
{isSelected && (
@ -1013,7 +1014,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-medium text-slate-400 uppercase">Videos (Import Not Supported)</h4>
<h4 className="text-xs font-normal text-slate-400">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);
@ -1021,7 +1022,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
return (
<div
key={vid.filename}
className="relative p-2 rounded-lg border-2 border-slate-700 opacity-50 cursor-not-allowed"
className="relative p-2 rounded 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" />
@ -1041,21 +1042,21 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Action buttons */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-700">
<div className="flex items-center justify-between mt-6 pt-4">
<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-lg text-sm transition-all"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded 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-lg text-sm font-medium 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 text-sm font-normal transition-all"
>
{importing ? (
<>
@ -1103,7 +1104,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setPreviewItem(item);
}
}}
className={`group relative aspect-square bg-slate-800 rounded-lg overflow-hidden border transition-all cursor-pointer ${
className={`group relative aspect-square bg-slate-800 rounded 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'
@ -1122,51 +1123,33 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</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>
);
})()}
{/* 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>
)}
{/* Type Badge */}
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-medium ${
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-normal ${
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' : 'VID'}
{item.type === 'image' ? 'IMG' : item.settings?.engine === 'kling' ? 'KLING' : 'VEO'}
</div>
{/* Hover Overlay (hide when selecting) */}
@ -1177,7 +1160,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-lg"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded"
>
<Download className="w-4 h-4" />
</button>
@ -1186,7 +1169,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-lg"
className="p-2 bg-red-600 hover:bg-red-500 text-white rounded"
>
<Trash2 className="w-4 h-4" />
</button>
@ -1203,7 +1186,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
setPreviewItem(item);
}
}}
className={`flex items-center gap-4 p-3 bg-slate-800/50 rounded-lg border transition-all cursor-pointer ${
className={`flex items-center gap-4 p-3 bg-slate-800 rounded border transition-all cursor-pointer ${
isSelecting && selectedImageIds.includes(item.id)
? 'border-cinema-gold bg-cinema-gold/10'
: 'border-slate-700 hover:border-slate-600'
@ -1220,18 +1203,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-purple-900/50'
item.type === 'image' ? 'bg-blue-900/50' : 'bg-slate-800'
}`}>
{item.type === 'image' ? (
<Image className="w-5 h-5 text-blue-400" />
) : (
<Video className="w-5 h-5 text-purple-400" />
<Video className="w-5 h-5 text-slate-500" />
)}
</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>{formatDate(item.createdAt)}</span>
<span className="font-mono">{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)}
@ -1246,17 +1229,17 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
setMovingItemId(movingItemId === item.id ? null : item.id);
}}
className={`p-2 rounded-lg ${movingItemId === item.id ? 'bg-indigo-900/50' : 'hover:bg-slate-700'}`}
className={`p-2 rounded ${movingItemId === item.id ? 'bg-cinema-gold/20' : 'hover:bg-slate-700'}`}
title="Move to another project"
>
<ArrowRightLeft className={`w-4 h-4 ${movingItemId === item.id ? 'text-indigo-400' : 'text-slate-400'}`} />
<ArrowRightLeft className={`w-4 h-4 ${movingItemId === item.id ? 'text-cinema-gold' : 'text-slate-400'}`} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
downloadItem(item);
}}
className="p-2 hover:bg-slate-700 rounded-lg"
className="p-2 hover:bg-slate-700 rounded"
>
<Download className="w-4 h-4 text-slate-400" />
</button>
@ -1265,15 +1248,15 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="p-2 hover:bg-red-900/50 rounded-lg"
className="p-2 hover:bg-red-900/50 rounded"
>
<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-lg shadow-xl py-1 min-w-[180px]"
<div className="absolute right-0 top-full mt-1 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-1.5 text-xs font-bold text-slate-500 uppercase tracking-wider">Move to...</div>
<div className="px-3 py-1.5 text-xs font-normal text-slate-500 uppercase tracking-wider">Move to...</div>
{projects
.filter(p => p.id !== selectedProject?.id)
.map(p => (
@ -1337,7 +1320,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-lg text-sm font-medium transition-all"
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"
>
<Plus className="w-4 h-4" />
New Storyboard
@ -1355,7 +1338,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-lg text-sm font-medium transition-all"
className="mt-4 px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
>
Select Images
</button>
@ -1366,10 +1349,10 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
<div
key={board.id}
onClick={() => handleOpenStoryboard(board.id)}
className="group p-4 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-cinema-gold transition-all cursor-pointer"
className="group p-4 bg-slate-800 rounded hover:bg-slate-700 transition-all cursor-pointer"
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-slate-200">{board.name}</h3>
<h3 className="font-normal text-slate-200">{board.name}</h3>
<span className="text-xs text-slate-500">{board.frames?.length || 0} frames</span>
</div>
{/* Frame thumbnails preview */}
@ -1400,7 +1383,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
)}
</div>
<p className="text-xs text-slate-500">
<p className="text-xs font-mono text-slate-500">
Updated {formatDate(board.updatedAt)}
</p>
</div>
@ -1450,7 +1433,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
</div>
{/* Content */}
<div className="bg-slate-900 rounded-xl overflow-hidden border border-slate-700">
<div className="bg-slate-800 rounded overflow-hidden">
{previewItem.type === 'image' ? (
<img
src={
@ -1483,8 +1466,33 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
)}
{/* Info bar */}
<div className="p-4 border-t border-slate-700">
<p className="text-sm text-slate-300 line-clamp-2">{previewItem.prompt}</p>
<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>
{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">
@ -1492,8 +1500,26 @@ 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 text-slate-500">{formatDate(previewItem.createdAt)}</span>
<span className="text-xs font-mono text-slate-500">{formatDate(previewItem.createdAt)}</span>
<div className="flex gap-2">
{/* Edit in Image Gen button for images */}
{previewItem.type === 'image' && onEditInImageGen && (
@ -1508,7 +1534,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
});
setPreviewItem(null);
}}
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"
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"
>
<Wand2 className="w-4 h-4" />
Edit in Image Gen
@ -1528,7 +1554,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-lg text-sm font-medium transition-colors"
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"
>
<Video className="w-4 h-4" />
Generate Video
@ -1545,7 +1571,7 @@ const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInI
});
setPreviewItem(null);
}}
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"
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"
>
<RefreshCw className="w-4 h-4" />
Re-run
@ -1556,9 +1582,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-lg text-sm font-medium transition-colors ${
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-normal transition-colors ${
movingItemId === previewItem.id
? 'bg-indigo-600 text-white'
? 'bg-cinema-gold text-slate-950'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
>
@ -1566,7 +1592,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-lg shadow-xl py-1 min-w-[180px]">
<div className="absolute bottom-full mb-1 right-0 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]">
{projects
.filter(p => p.id !== selectedProject?.id)
.map(p => (
@ -1595,7 +1621,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-lg text-sm font-medium transition-colors"
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"
>
<Download className="w-4 h-4" />
Download

View file

@ -62,12 +62,12 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
<div
ref={setNodeRef}
style={style}
className={`bg-slate-800 rounded-xl border border-slate-700 overflow-hidden ${
isDragging ? 'shadow-2xl ring-2 ring-cinema-gold' : ''
className={`bg-slate-800 rounded overflow-hidden ${
isDragging ? 'ring-2 ring-cinema-gold' : ''
}`}
>
{/* Frame number and drag handle */}
<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 justify-between px-3 py-2 bg-slate-925">
<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-medium text-slate-400">Frame {index + 1}</span>
<span className="text-xs font-normal 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-900 relative">
<div className="aspect-video bg-slate-925 relative">
{frameItem ? (
<img
src={
@ -108,14 +108,14 @@ const SortableFrame = ({ frame, frameItem, index, onAnnotationChange, onDelete,
</div>
{/* Annotation */}
<div className="p-3 border-t border-slate-700">
<div className="p-3">
{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-900 border border-slate-600 rounded-lg 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-800 border border-slate-600 rounded 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-900/50 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors"
className="min-h-[60px] p-2 bg-slate-925 rounded cursor-pointer hover:bg-slate-800 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-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
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"
>
<Video className="w-4 h-4" />
Generate Video
@ -288,26 +288,22 @@ 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 = 20;
const margin = 15;
const contentWidth = pageWidth - margin * 2;
const colWidth = (contentWidth - 15) / 2; // 2 columns with 15mm gap
const colWidth = (contentWidth - 10) / 2; // 2 columns with 10mm gap
// Title page with better styling
pdf.setFontSize(32);
// Title page minimal
pdf.setFontSize(24);
pdf.setTextColor(30, 30, 30);
pdf.text(name, pageWidth / 2, pageHeight / 2 - 20, { align: 'center' });
pdf.text(name, pageWidth / 2, pageHeight / 2 - 10, { align: 'center' });
pdf.setFontSize(14);
pdf.setTextColor(100, 100, 100);
pdf.text(`${frames.length} frames`, pageWidth / 2, pageHeight / 2, { align: 'center' });
pdf.setFontSize(11);
pdf.setFontSize(10);
pdf.setTextColor(140, 140, 140);
pdf.text(`Exported ${new Date().toLocaleDateString('en-US', {
pdf.text(`${frames.length} frames · ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}`, pageWidth / 2, pageHeight / 2 + 12, { align: 'center' });
})}`, pageWidth / 2, pageHeight / 2 + 4, { align: 'center' });
// Process frames - 4 per page (2x2 grid)
let frameIndex = 0;
@ -319,9 +315,9 @@ const StoryboardEditor = ({
const frame = frames[frameIndex];
const frameItem = projectItems.find(item => item.id === frame.imageId);
const x = margin + col * (colWidth + 15);
const rowHeight = (pageHeight - margin * 2 - 10) / 2;
const y = margin + row * (rowHeight + 10);
const x = margin + col * (colWidth + 10);
const rowHeight = (pageHeight - margin * 2 - 6) / 2;
const y = margin + row * (rowHeight + 6);
// Image with preserved aspect ratio
if (frameItem) {
@ -330,64 +326,41 @@ const StoryboardEditor = ({
const dimensions = await getImageDimensions(imgSrc);
const imgAspect = dimensions.width / dimensions.height;
// Max image area
// Max image area minimal reservation for label
const maxImgWidth = colWidth;
const maxImgHeight = rowHeight - 30; // Leave room for label and annotation
const maxImgHeight = rowHeight - 14;
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 + 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');
const imgY = y;
pdf.addImage(imgSrc, 'JPEG', imgX, imgY, imgWidth, imgHeight);
// 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);
}
// 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);
} catch (e) {
console.error('Failed to add image to PDF:', e);
// 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' });
pdf.setFontSize(8);
pdf.setTextColor(160, 160, 160);
pdf.text(`${frameIndex + 1}`, x, y + 4);
}
} else {
// 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');
pdf.setFontSize(8);
pdf.setTextColor(160, 160, 160);
pdf.text(`${frameIndex + 1}`, x, y + 4);
}
frameIndex++;
@ -414,10 +387,10 @@ const StoryboardEditor = ({
position: absolute;
left: -9999px;
top: 0;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
background: #020617;
padding: 48px;
width: 1400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
`;
// Title section
@ -425,12 +398,10 @@ 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: 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', {
<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', {
year: 'numeric',
month: 'long',
day: 'numeric'
@ -443,7 +414,7 @@ const StoryboardEditor = ({
framesGrid.style.cssText = `
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
gap: 16px;
`;
for (let i = 0; i < frames.length; i++) {
@ -452,24 +423,9 @@ 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);
@ -477,10 +433,8 @@ 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;
@ -493,22 +447,17 @@ const StoryboardEditor = ({
frameCard.appendChild(imgWrapper);
}
// Annotation
const annotationDiv = document.createElement('div');
annotationDiv.style.cssText = `
padding: 16px;
min-height: 60px;
// Caption: frame number + annotation (only if annotation exists)
const captionDiv = document.createElement('div');
captionDiv.style.cssText = `
padding: 8px 0;
`;
const captionParts = [`<span style="color: #64748b; font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase;">${i + 1}</span>`];
if (frame.annotation) {
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>
`;
captionParts.push(`<p style="color: #94a3b8; font-size: 12px; line-height: 1.4; margin: 4px 0 0 0;">${frame.annotation}</p>`);
}
frameCard.appendChild(annotationDiv);
captionDiv.innerHTML = captionParts.join('');
frameCard.appendChild(captionDiv);
framesGrid.appendChild(frameCard);
}
@ -562,7 +511,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-lg transition-colors"
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 rounded transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
@ -573,7 +522,7 @@ const StoryboardEditor = ({
type="text"
value={editNameValue}
onChange={(e) => setEditNameValue(e.target.value)}
className="px-3 py-1.5 bg-slate-800 border border-cinema-gold rounded-lg text-slate-200 focus:outline-none"
className="px-3 py-1.5 bg-slate-800 border border-slate-600 rounded text-slate-200 focus:border-cinema-gold focus:outline-none"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleNameSave()}
/>
@ -586,7 +535,7 @@ const StoryboardEditor = ({
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-slate-200">{name}</h2>
<h2 className="text-xl font-normal text-slate-200">{name}</h2>
<button
onClick={() => setIsEditingName(true)}
className="p-1 hover:bg-slate-700 rounded"
@ -609,7 +558,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-lg text-sm font-medium transition-colors"
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"
>
<Plus className="w-4 h-4" />
Edit Frames
@ -617,7 +566,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-lg 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 text-sm transition-colors disabled:opacity-50"
>
<FileDown className="w-4 h-4" />
PDF
@ -625,14 +574,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-lg 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 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-lg 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 text-sm transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete
@ -641,7 +590,7 @@ const StoryboardEditor = ({
</div>
{/* Frames Grid */}
<div ref={storyboardRef} className="p-4 bg-slate-900/30 rounded-xl">
<div ref={storyboardRef} className="p-4 bg-slate-925 rounded">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}

View file

@ -1,18 +1,15 @@
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', icon: FolderOpen, requiresProject: false },
{ id: 'image', label: 'Image Gen', icon: Image, requiresProject: true },
{ id: 'video', label: 'Video Gen', icon: Video, requiresProject: true }
{ id: 'projects', label: 'Projects', requiresProject: false },
{ id: 'image', label: 'Image Gen', requiresProject: true },
{ id: 'video', label: 'Video Gen', 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;
@ -22,7 +19,7 @@ const TabNavigation = ({ activeTab, onTabChange, activeProjectId }) => {
onClick={() => !isDisabled && onTabChange(tab.id)}
disabled={isDisabled}
title={isDisabled ? 'Select a project first' : tab.label}
className={`relative flex items-center gap-2 px-1 py-2 font-medium transition-all ${
className={`px-1 py-2 text-[14px] font-normal transition-all ${
isDisabled
? 'text-slate-600 cursor-not-allowed'
: isActive
@ -30,14 +27,7 @@ const TabNavigation = ({ activeTab, onTabChange, activeProjectId }) => {
: 'text-slate-400 hover:text-slate-200'
}`}
>
<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'
}`}
/>
{tab.label}
</button>
);
})}

File diff suppressed because it is too large Load diff

View file

@ -304,7 +304,7 @@ const VideoPlayer = ({ src, onFrameExtract, onSaveToProject, className = '', aut
<video
ref={videoRef}
src={videoSrc}
className="w-full rounded-lg"
className="w-full rounded"
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-lg">
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4 rounded-b">
{/* 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 shadow-lg 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 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-lg transition-colors"
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded 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-lg text-sm transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 rounded 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-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">
<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">
<Image className="w-5 h-5 text-cinema-gold" />
Extracted Frame
</h3>
<div className="bg-slate-950 rounded-lg p-2 mb-4">
<div className="bg-slate-950 rounded 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 text-slate-400 mb-4">
<div className="flex items-center justify-between text-xs font-mono 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-medium rounded-lg 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-normal rounded 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-medium rounded-lg transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 font-normal rounded 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-medium rounded-lg 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-normal rounded transition-colors"
>
Close
</button>

View file

@ -8,6 +8,28 @@ 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
@ -149,6 +171,12 @@ 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,
@ -156,7 +184,7 @@ const useProjects = () => {
prompt: item.prompt,
settings: item.settings || {},
referenceImages: item.referenceImages || [], // For video re-run feature
thumbnail: item.thumbnail || null,
thumbnail,
data: item.data, // base64 for images, URL for videos
mimeType: item.mimeType || 'image/png',
createdAt: Date.now()
@ -262,6 +290,23 @@ const useProjects = () => {
return item;
});
// Migration: Backfill or upgrade thumbnails for Retina (480px/q0.8)
// Old thumbnails (300px/q0.7) are typically under 27,000 base64 chars
for (const item of items) {
if (item.type === 'image' && item.data && !item.data.startsWith('http')) {
const needsThumb = !item.thumbnail || item.thumbnail.length < 27000;
if (needsThumb) {
try {
const thumb = await generateThumbnail(item.data, item.mimeType);
if (thumb) {
item.thumbnail = thumb;
put('items', item).catch(() => {});
}
} catch { /* skip */ }
}
}
}
// Sort by createdAt descending (newest first)
return items.sort((a, b) => b.createdAt - a.createdAt);
} catch (err) {

View file

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

View file

@ -6,8 +6,13 @@ 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',