truegleai Qwen-Coder commited on
Commit
b7a8a4b
·
0 Parent(s):

Initial commit: Pete Studio with animated Three.js UI

Browse files

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Environment variables for local testing
2
+ # These will be set as Secrets in Hugging Face Space
3
+
4
+ # For local testing only - in production, use HF Secrets
5
+ REPLICATE_API_TOKEN=your_replicate_token_here
6
+ HF_API_TOKEN=your_huggingface_token_here
.gitignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+
14
+ # Environment
15
+ .env
16
+ *.env
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Node
29
+ node_modules/
30
+
31
+ # API Keys (never commit these)
32
+ api_keys/
33
+ *.key
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Space
2
+ FROM python:3.11-slim
3
+
4
+ # Hugging Face Spaces requirements
5
+ RUN useradd -m -u 1000 user
6
+ WORKDIR /app
7
+
8
+ # Set environment variables for Hugging Face compatibility
9
+ ENV HF_HOME=/tmp/huggingface \
10
+ TRANSFORMERS_CACHE=/tmp/huggingface/transformers \
11
+ HF_DATASETS_CACHE=/tmp/huggingface/datasets \
12
+ HUGGINGFACE_HUB_CACHE=/tmp/huggingface/hub \
13
+ XDG_CACHE_HOME=/tmp/huggingface \
14
+ PYTHONUNBUFFERED=1
15
+
16
+ # Copy requirements and install dependencies
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy application code
22
+ COPY --chown=user . .
23
+
24
+ # Switch to the non-root user (required by HF Spaces)
25
+ USER user
26
+
27
+ # Expose the port that HF Spaces expects
28
+ EXPOSE 7860
29
+
30
+ # Run the FastAPI application with uvicorn
31
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Pete Studio
3
+ emoji: 🎨
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Pete Studio
13
+
14
+ Generate high-quality images from text descriptions with automatic API key rotation across multiple providers.
15
+
16
+ ## Features
17
+ - Text-to-image generation with multiple AI providers
18
+ - Automatic API key rotation and rate limit handling
19
+ - Support for Replicate and Hugging Face inference APIs
20
+ - Beautiful animated 3D interface with Three.js
21
+ - PWA for mobile access
22
+ - Local image saving
23
+
24
+ ## API Keys
25
+ Configure your API keys in the app's settings panel:
26
+ - **Replicate**: Get keys from [replicate.com](https://replicate.com)
27
+ - **Hugging Face**: Get keys from [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
28
+
29
+ Add multiple comma-separated keys for automatic rotation when rate limits are hit.
30
+
31
+ ## Keyboard Shortcuts
32
+ - `Ctrl+Enter`: Generate image
33
+ - `Ctrl+S`: Save image
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # App module
app/image_generator.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import httpx
3
+ import base64
4
+ from typing import Optional
5
+ from .key_rotator import key_rotator
6
+
7
+ class ImageGenerator:
8
+ """
9
+ Handles image generation requests with automatic provider selection and key rotation.
10
+ Supports multiple providers: Replicate, Hugging Face, and fallback options.
11
+ """
12
+
13
+ PROVIDER_CONFIGS = {
14
+ 'replicate': {
15
+ 'url': 'https://api.replicate.com/v1/predictions',
16
+ 'model': 'stability-ai/stable-diffusion:ac732df83cea7fff18b8472768c88ad041fa750ff7682a21affe81863cbe77e4',
17
+ 'auth_header': 'Authorization',
18
+ 'auth_prefix': 'Token '
19
+ },
20
+ 'huggingface': {
21
+ 'url': 'https://api-inference.huggingface.co/models/black-forest-labs/FLUX.1-dev',
22
+ 'auth_header': 'Authorization',
23
+ 'auth_prefix': 'Bearer '
24
+ }
25
+ }
26
+
27
+ async def generate(self, prompt: str, provider: str = 'replicate',
28
+ width: int = 768, height: int = 768) -> Optional[str]:
29
+ """
30
+ Generate an image from text prompt.
31
+ Returns URL of generated image or None if failed.
32
+ """
33
+ # Get an available API key for the provider
34
+ api_key = key_rotator.get_next_key(provider)
35
+ if not api_key:
36
+ # Try fallback provider
37
+ fallback = 'huggingface' if provider != 'huggingface' else 'replicate'
38
+ return await self.generate(prompt, fallback, width, height)
39
+
40
+ config = self.PROVIDER_CONFIGS.get(provider)
41
+ if not config:
42
+ return None
43
+
44
+ try:
45
+ async with httpx.AsyncClient(timeout=120.0) as client:
46
+ headers = {
47
+ config['auth_header']: f"{config['auth_prefix']}{api_key}",
48
+ 'Content-Type': 'application/json'
49
+ }
50
+
51
+ if provider == 'replicate':
52
+ payload = {
53
+ 'version': config['model'].split(':')[-1],
54
+ 'input': {
55
+ 'prompt': prompt,
56
+ 'width': width,
57
+ 'height': height,
58
+ 'num_outputs': 1
59
+ }
60
+ }
61
+
62
+ # Create prediction
63
+ response = await client.post(config['url'], headers=headers, json=payload)
64
+
65
+ if response.status_code == 201:
66
+ prediction = response.json()
67
+ # Poll for completion
68
+ get_url = prediction['urls']['get']
69
+ for _ in range(30): # Max 30 attempts
70
+ await asyncio.sleep(2)
71
+ status_response = await client.get(get_url, headers=headers)
72
+ status_data = status_response.json()
73
+
74
+ if status_data['status'] == 'succeeded':
75
+ key_rotator.mark_success(api_key)
76
+ return status_data['output'][0]
77
+ elif status_data['status'] == 'failed':
78
+ break
79
+
80
+ elif response.status_code == 429:
81
+ # Rate limit - check if it's daily or minute
82
+ is_daily = 'daily' in str(response.text).lower()
83
+ key_rotator.mark_rate_limit(api_key, is_daily_limit=is_daily)
84
+
85
+ elif response.status_code in (401, 403):
86
+ key_rotator.mark_failure(api_key, response.status_code)
87
+
88
+ elif provider == 'huggingface':
89
+ payload = {
90
+ 'inputs': prompt,
91
+ 'parameters': {
92
+ 'width': width,
93
+ 'height': height,
94
+ 'guidance_scale': 7.5
95
+ }
96
+ }
97
+
98
+ response = await client.post(config['url'], headers=headers, json=payload)
99
+
100
+ if response.status_code == 200:
101
+ key_rotator.mark_success(api_key)
102
+ # Return base64 encoded image
103
+ image_b64 = base64.b64encode(response.content).decode('utf-8')
104
+ return f"data:image/png;base64,{image_b64}"
105
+
106
+ elif response.status_code == 429:
107
+ key_rotator.mark_rate_limit(api_key)
108
+
109
+ elif response.status_code in (401, 403):
110
+ key_rotator.mark_failure(api_key, response.status_code)
111
+
112
+ except Exception as e:
113
+ print(f"Generation error with {provider}: {e}")
114
+
115
+ # If we got here, try fallback provider
116
+ fallback = 'huggingface' if provider == 'replicate' else 'replicate'
117
+ if fallback != provider:
118
+ return await self.generate(prompt, fallback, width, height)
119
+
120
+ return None
121
+
122
+ image_generator = ImageGenerator()
app/key_rotator.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Dict, List, Optional
3
+
4
+
5
+ class KeyRotator:
6
+ """
7
+ Smart API key rotator with rate limit tracking and cooldown management.
8
+ Supports multiple providers and automatic failover.
9
+ """
10
+
11
+ def __init__(self):
12
+ # Key pool: provider -> list of keys
13
+ self.key_pools: Dict[str, List[str]] = {}
14
+ # Key status: key -> {'status': 'active'/'cooling', 'cooldown_until': timestamp, 'model_limits': dict}
15
+ self.key_status: Dict[str, Dict] = {}
16
+ # Track which key was last used for round-robin
17
+ self.last_used_index: Dict[str, int] = {}
18
+
19
+ def add_key(self, provider: str, api_key: str):
20
+ """Add an API key to the pool for a specific provider"""
21
+ if provider not in self.key_pools:
22
+ self.key_pools[provider] = []
23
+ self.last_used_index[provider] = -1
24
+ self.key_pools[provider].append(api_key)
25
+ self.key_status[api_key] = {
26
+ 'status': 'active',
27
+ 'cooldown_until': 0,
28
+ 'rate_limit_count': 0,
29
+ 'last_error': None
30
+ }
31
+
32
+ def add_multiple_keys(self, provider: str, keys_string: str):
33
+ """Add multiple comma-separated keys"""
34
+ keys = [k.strip() for k in keys_string.split(',') if k.strip()]
35
+ for key in keys:
36
+ self.add_key(provider, key)
37
+
38
+ def get_next_key(self, provider: str) -> Optional[str]:
39
+ """Get the next available active key using round-robin"""
40
+ if provider not in self.key_pools:
41
+ return None
42
+
43
+ pool = self.key_pools[provider]
44
+ if not pool:
45
+ return None
46
+
47
+ # Try up to len(pool) times to find an active key
48
+ for _ in range(len(pool)):
49
+ self.last_used_index[provider] = (self.last_used_index[provider] + 1) % len(pool)
50
+ candidate_key = pool[self.last_used_index[provider]]
51
+
52
+ status = self.key_status.get(candidate_key, {})
53
+ if status.get('status') == 'active':
54
+ # Check if cooldown has expired
55
+ if status.get('cooldown_until', 0) <= time.time():
56
+ return candidate_key
57
+ else:
58
+ # Still cooling down, skip
59
+ continue
60
+
61
+ # No active keys found, return the first one anyway (will likely fail)
62
+ return pool[0] if pool else None
63
+
64
+ def mark_rate_limit(self, api_key: str, model: str = None, is_daily_limit: bool = False):
65
+ """
66
+ Mark a key as rate-limited.
67
+ For daily limits: 24 hour cooldown
68
+ For minute limits: 60 second cooldown
69
+ """
70
+ if api_key not in self.key_status:
71
+ return
72
+
73
+ cooldown_seconds = 24 * 3600 if is_daily_limit else 60
74
+ self.key_status[api_key]['status'] = 'cooling'
75
+ self.key_status[api_key]['cooldown_until'] = time.time() + cooldown_seconds
76
+ self.key_status[api_key]['rate_limit_count'] += 1
77
+ self.key_status[api_key]['last_error'] = f'rate_limit_{"daily" if is_daily_limit else "minute"}'
78
+
79
+ def mark_failure(self, api_key: str, error_code: int):
80
+ """Mark a key as failed (403, 401) - permanently block it"""
81
+ if api_key not in self.key_status:
82
+ return
83
+
84
+ if error_code in (401, 403):
85
+ self.key_status[api_key]['status'] = 'blocked'
86
+ self.key_status[api_key]['last_error'] = f'error_{error_code}'
87
+
88
+ def mark_success(self, api_key: str):
89
+ """Reset any temporary cooldown on success"""
90
+ if api_key not in self.key_status:
91
+ return
92
+ self.key_status[api_key]['status'] = 'active'
93
+ self.key_status[api_key]['rate_limit_count'] = 0
94
+
95
+ def get_status_summary(self) -> dict:
96
+ """Get summary of all keys for monitoring"""
97
+ summary = {}
98
+ for provider, keys in self.key_pools.items():
99
+ provider_summary = {
100
+ 'total': len(keys),
101
+ 'active': 0,
102
+ 'cooling': 0,
103
+ 'blocked': 0
104
+ }
105
+ for key in keys:
106
+ status = self.key_status.get(key, {}).get('status', 'active')
107
+ if status == 'active':
108
+ provider_summary['active'] += 1
109
+ elif status == 'cooling':
110
+ provider_summary['cooling'] += 1
111
+ else:
112
+ provider_summary['blocked'] += 1
113
+ summary[provider] = provider_summary
114
+ return summary
115
+
116
+ # Global instance
117
+ key_rotator = KeyRotator()
app/main.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import FileResponse
4
+ from pydantic import BaseModel
5
+ from typing import Optional
6
+
7
+ from .image_generator import image_generator
8
+ from .key_rotator import key_rotator
9
+
10
+ app = FastAPI(title="Pete Studio", description="Generate images with AI")
11
+
12
+ # Mount static files
13
+ app.mount("/static", StaticFiles(directory="static"), name="static")
14
+
15
+ # Request/Response models
16
+ class GenerateRequest(BaseModel):
17
+ prompt: str
18
+ width: int = 768
19
+ height: int = 768
20
+ provider: str = 'replicate'
21
+
22
+ class GenerateResponse(BaseModel):
23
+ success: bool
24
+ image_url: Optional[str] = None
25
+ error: Optional[str] = None
26
+ provider_used: Optional[str] = None
27
+
28
+ class KeyConfigRequest(BaseModel):
29
+ provider: str
30
+ keys: str
31
+
32
+ # API Endpoints
33
+ @app.get("/")
34
+ async def root():
35
+ """Serve the frontend"""
36
+ return FileResponse("static/index.html")
37
+
38
+ @app.get("/health")
39
+ async def health_check():
40
+ """Health check endpoint"""
41
+ return {
42
+ "status": "healthy",
43
+ "key_status": key_rotator.get_status_summary()
44
+ }
45
+
46
+ @app.post("/api/generate", response_model=GenerateResponse)
47
+ async def generate_image(request: GenerateRequest):
48
+ """Generate an image from text prompt"""
49
+ try:
50
+ image_url = await image_generator.generate(
51
+ prompt=request.prompt,
52
+ provider=request.provider,
53
+ width=request.width,
54
+ height=request.height
55
+ )
56
+
57
+ if image_url:
58
+ return GenerateResponse(
59
+ success=True,
60
+ image_url=image_url,
61
+ provider_used=request.provider
62
+ )
63
+ else:
64
+ return GenerateResponse(
65
+ success=False,
66
+ error="All providers failed or no API keys available"
67
+ )
68
+ except Exception as e:
69
+ return GenerateResponse(success=False, error=str(e))
70
+
71
+ @app.post("/api/keys/configure")
72
+ async def configure_keys(request: KeyConfigRequest):
73
+ """Configure API keys for a provider"""
74
+ key_rotator.add_multiple_keys(request.provider, request.keys)
75
+ return {"status": "success", "provider": request.provider, "keys_added": len(request.keys.split(','))}
76
+
77
+ @app.get("/api/keys/status")
78
+ async def get_key_status():
79
+ """Get current status of all API keys"""
80
+ return key_rotator.get_status_summary()
81
+
82
+ if __name__ == "__main__":
83
+ import uvicorn
84
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ python-multipart==0.0.12
4
+ httpx==0.27.0
5
+ pydantic==2.9.0
6
+ python-dotenv==1.0.0
7
+ aiofiles==24.1.0
8
+ Pillow==10.4.0
static/index.html ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <title>Pete Studio - AI Image Generator</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
8
+ <link rel="manifest" href="/static/manifest.json">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
18
+ overflow-x: hidden;
19
+ background: #0a0a0a;
20
+ color: white;
21
+ }
22
+
23
+ /* 3D Canvas Background */
24
+ #canvas-container {
25
+ position: fixed;
26
+ top: 0;
27
+ left: 0;
28
+ width: 100%;
29
+ height: 100%;
30
+ z-index: -1;
31
+ background: radial-gradient(circle at center, #1a1a2e 0%, #0a0a0a 100%);
32
+ }
33
+
34
+ /* Navigation */
35
+ nav {
36
+ position: fixed;
37
+ top: 0;
38
+ width: 100%;
39
+ padding: 1.5rem 5%;
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: center;
43
+ z-index: 1000;
44
+ backdrop-filter: blur(10px);
45
+ background: rgba(255, 255, 255, 0.05);
46
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
47
+ }
48
+
49
+ .logo {
50
+ font-size: 1.5rem;
51
+ font-weight: 700;
52
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
53
+ -webkit-background-clip: text;
54
+ -webkit-text-fill-color: transparent;
55
+ letter-spacing: -0.5px;
56
+ }
57
+
58
+ .nav-buttons {
59
+ display: flex;
60
+ gap: 1rem;
61
+ align-items: center;
62
+ }
63
+
64
+ .nav-btn {
65
+ background: rgba(255, 255, 255, 0.1);
66
+ border: 1px solid rgba(255, 255, 255, 0.2);
67
+ color: white;
68
+ padding: 0.6rem 1.2rem;
69
+ border-radius: 25px;
70
+ cursor: pointer;
71
+ font-size: 0.9rem;
72
+ transition: all 0.3s;
73
+ backdrop-filter: blur(10px);
74
+ }
75
+
76
+ .nav-btn:hover {
77
+ background: rgba(255, 255, 255, 0.15);
78
+ border-color: rgba(102, 126, 234, 0.5);
79
+ }
80
+
81
+ /* Main Layout */
82
+ .app-container {
83
+ min-height: 100vh;
84
+ padding-top: 80px;
85
+ display: flex;
86
+ position: relative;
87
+ }
88
+
89
+ /* Sidebar */
90
+ .sidebar {
91
+ width: 380px;
92
+ padding: 2rem;
93
+ background: rgba(255, 255, 255, 0.03);
94
+ backdrop-filter: blur(20px);
95
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 1.5rem;
99
+ overflow-y: auto;
100
+ position: relative;
101
+ z-index: 10;
102
+ }
103
+
104
+ .input-group label {
105
+ display: block;
106
+ font-size: 0.85rem;
107
+ font-weight: 500;
108
+ margin-bottom: 0.5rem;
109
+ color: rgba(255, 255, 255, 0.8);
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.5px;
112
+ }
113
+
114
+ .prompt-input {
115
+ width: 100%;
116
+ height: 150px;
117
+ background: rgba(255, 255, 255, 0.05);
118
+ border: 1px solid rgba(255, 255, 255, 0.15);
119
+ border-radius: 12px;
120
+ padding: 1rem;
121
+ color: white;
122
+ font-family: inherit;
123
+ font-size: 1rem;
124
+ resize: vertical;
125
+ transition: all 0.3s;
126
+ }
127
+
128
+ .prompt-input:focus {
129
+ outline: none;
130
+ border-color: #667eea;
131
+ box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
132
+ }
133
+
134
+ .prompt-input::placeholder {
135
+ color: rgba(255, 255, 255, 0.4);
136
+ }
137
+
138
+ select {
139
+ width: 100%;
140
+ background: rgba(255, 255, 255, 0.05);
141
+ border: 1px solid rgba(255, 255, 255, 0.15);
142
+ border-radius: 12px;
143
+ padding: 0.8rem;
144
+ color: white;
145
+ font-size: 0.95rem;
146
+ cursor: pointer;
147
+ transition: all 0.3s;
148
+ }
149
+
150
+ select:focus {
151
+ outline: none;
152
+ border-color: #667eea;
153
+ }
154
+
155
+ select option {
156
+ background: #1a1a2e;
157
+ }
158
+
159
+ .generate-btn {
160
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
161
+ border: none;
162
+ color: white;
163
+ padding: 1.2rem;
164
+ border-radius: 12px;
165
+ font-size: 1.1rem;
166
+ font-weight: 600;
167
+ cursor: pointer;
168
+ transition: all 0.3s;
169
+ box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
170
+ text-transform: uppercase;
171
+ letter-spacing: 1px;
172
+ }
173
+
174
+ .generate-btn:hover:not(:disabled) {
175
+ transform: translateY(-3px);
176
+ box-shadow: 0 15px 40px rgba(102, 126, 234, 0.5);
177
+ }
178
+
179
+ .generate-btn:disabled {
180
+ opacity: 0.5;
181
+ cursor: not-allowed;
182
+ }
183
+
184
+ /* API Keys Section */
185
+ .api-keys-section {
186
+ background: rgba(255, 255, 255, 0.03);
187
+ border: 1px solid rgba(255, 255, 255, 0.1);
188
+ border-radius: 12px;
189
+ padding: 1rem;
190
+ }
191
+
192
+ .api-keys-section summary {
193
+ cursor: pointer;
194
+ color: rgba(255, 255, 255, 0.8);
195
+ font-weight: 500;
196
+ font-size: 0.9rem;
197
+ user-select: none;
198
+ }
199
+
200
+ .api-keys-section summary:hover {
201
+ color: #667eea;
202
+ }
203
+
204
+ .api-input-group {
205
+ margin-top: 1rem;
206
+ }
207
+
208
+ .api-input-group label {
209
+ display: block;
210
+ font-size: 0.8rem;
211
+ color: rgba(255, 255, 255, 0.6);
212
+ margin-bottom: 0.4rem;
213
+ }
214
+
215
+ .api-input-group input {
216
+ width: 100%;
217
+ background: rgba(0, 0, 0, 0.3);
218
+ border: 1px solid rgba(255, 255, 255, 0.1);
219
+ border-radius: 8px;
220
+ padding: 0.6rem;
221
+ color: white;
222
+ font-size: 0.85rem;
223
+ margin-bottom: 0.5rem;
224
+ font-family: monospace;
225
+ }
226
+
227
+ .api-input-group input:focus {
228
+ outline: none;
229
+ border-color: #667eea;
230
+ }
231
+
232
+ .api-input-group button {
233
+ background: rgba(102, 126, 234, 0.3);
234
+ border: 1px solid rgba(102, 126, 234, 0.5);
235
+ color: white;
236
+ padding: 0.5rem 1rem;
237
+ border-radius: 8px;
238
+ cursor: pointer;
239
+ font-size: 0.8rem;
240
+ transition: all 0.3s;
241
+ }
242
+
243
+ .api-input-group button:hover {
244
+ background: rgba(102, 126, 234, 0.5);
245
+ }
246
+
247
+ .key-status {
248
+ margin-top: 1rem;
249
+ font-size: 0.75rem;
250
+ color: rgba(255, 255, 255, 0.6);
251
+ padding: 0.5rem;
252
+ background: rgba(0, 0, 0, 0.2);
253
+ border-radius: 8px;
254
+ }
255
+
256
+ /* Canvas Area */
257
+ .canvas-area {
258
+ flex: 1;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ padding: 2rem;
263
+ position: relative;
264
+ }
265
+
266
+ #imageCanvas {
267
+ max-width: 100%;
268
+ max-height: calc(100vh - 120px);
269
+ border-radius: 20px;
270
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
271
+ display: none;
272
+ }
273
+
274
+ .image-placeholder {
275
+ text-align: center;
276
+ color: rgba(255, 255, 255, 0.3);
277
+ animation: fadeInUp 1s ease-out 0.4s both;
278
+ }
279
+
280
+ .image-placeholder svg {
281
+ margin-bottom: 1.5rem;
282
+ stroke: rgba(255, 255, 255, 0.3);
283
+ width: 80px;
284
+ height: 80px;
285
+ }
286
+
287
+ .image-placeholder p {
288
+ font-size: 1.1rem;
289
+ }
290
+
291
+ /* Loading Indicator */
292
+ .loading {
293
+ text-align: center;
294
+ padding: 1rem;
295
+ background: rgba(102, 126, 234, 0.1);
296
+ border: 1px solid rgba(102, 126, 234, 0.3);
297
+ border-radius: 12px;
298
+ color: #667eea;
299
+ font-size: 0.9rem;
300
+ display: none;
301
+ }
302
+
303
+ .loading.active {
304
+ display: block;
305
+ animation: pulse 1.5s ease-in-out infinite;
306
+ }
307
+
308
+ @keyframes pulse {
309
+ 0%, 100% { opacity: 1; }
310
+ 50% { opacity: 0.6; }
311
+ }
312
+
313
+ /* Animations */
314
+ @keyframes fadeInUp {
315
+ from {
316
+ opacity: 0;
317
+ transform: translateY(30px);
318
+ }
319
+ to {
320
+ opacity: 1;
321
+ transform: translateY(0);
322
+ }
323
+ }
324
+
325
+ /* Feature Cards (for stats/info) */
326
+ .stats-bar {
327
+ position: fixed;
328
+ bottom: 0;
329
+ left: 0;
330
+ right: 0;
331
+ padding: 1rem 5%;
332
+ display: flex;
333
+ justify-content: space-around;
334
+ flex-wrap: wrap;
335
+ gap: 2rem;
336
+ background: rgba(255, 255, 255, 0.03);
337
+ backdrop-filter: blur(20px);
338
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
339
+ z-index: 100;
340
+ }
341
+
342
+ .stat-item {
343
+ text-align: center;
344
+ }
345
+
346
+ .stat-item h3 {
347
+ font-size: 1.5rem;
348
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
349
+ -webkit-background-clip: text;
350
+ -webkit-text-fill-color: transparent;
351
+ }
352
+
353
+ .stat-item p {
354
+ color: rgba(255, 255, 255, 0.6);
355
+ font-size: 0.8rem;
356
+ text-transform: uppercase;
357
+ letter-spacing: 0.5px;
358
+ }
359
+
360
+ /* Mobile Responsive */
361
+ @media (max-width: 1024px) {
362
+ .app-container {
363
+ flex-direction: column;
364
+ }
365
+
366
+ .sidebar {
367
+ width: 100%;
368
+ border-right: none;
369
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
370
+ max-height: 50vh;
371
+ }
372
+
373
+ .canvas-area {
374
+ min-height: 50vh;
375
+ }
376
+ }
377
+
378
+ @media (max-width: 768px) {
379
+ .sidebar {
380
+ padding: 1rem;
381
+ }
382
+
383
+ .nav-buttons {
384
+ gap: 0.5rem;
385
+ }
386
+
387
+ .nav-btn {
388
+ padding: 0.5rem 0.8rem;
389
+ font-size: 0.8rem;
390
+ }
391
+ }
392
+
393
+ /* Toast notifications */
394
+ .toast {
395
+ position: fixed;
396
+ bottom: 100px;
397
+ right: 2rem;
398
+ background: rgba(255, 255, 255, 0.1);
399
+ backdrop-filter: blur(20px);
400
+ border: 1px solid rgba(255, 255, 255, 0.2);
401
+ padding: 1rem 1.5rem;
402
+ border-radius: 12px;
403
+ color: white;
404
+ z-index: 2000;
405
+ animation: slideIn 0.3s ease-out;
406
+ max-width: 350px;
407
+ }
408
+
409
+ @keyframes slideIn {
410
+ from {
411
+ transform: translateX(400px);
412
+ opacity: 0;
413
+ }
414
+ to {
415
+ transform: translateX(0);
416
+ opacity: 1;
417
+ }
418
+ }
419
+
420
+ .toast.error {
421
+ border-color: rgba(255, 100, 100, 0.5);
422
+ background: rgba(255, 100, 100, 0.1);
423
+ }
424
+
425
+ .toast.success {
426
+ border-color: rgba(100, 255, 100, 0.5);
427
+ background: rgba(100, 255, 100, 0.1);
428
+ }
429
+ </style>
430
+ </head>
431
+ <body>
432
+
433
+ <!-- 3D Background -->
434
+ <div id="canvas-container"></div>
435
+
436
+ <!-- Navigation -->
437
+ <nav>
438
+ <div class="logo">PETE STUDIO</div>
439
+ <div class="nav-buttons">
440
+ <button id="saveBtn" class="nav-btn">💾 Save</button>
441
+ <button id="fullscreenBtn" class="nav-btn">⛶ Fullscreen</button>
442
+ </div>
443
+ </nav>
444
+
445
+ <!-- Main App -->
446
+ <div class="app-container">
447
+ <!-- Sidebar Controls -->
448
+ <aside class="sidebar">
449
+ <div class="input-group">
450
+ <label>Describe your image</label>
451
+ <textarea id="promptInput" class="prompt-input" placeholder="A serene mountain lake at sunset, digital art, highly detailed, cinematic lighting..."></textarea>
452
+ </div>
453
+
454
+ <div class="input-group">
455
+ <label>Dimensions</label>
456
+ <select id="sizeSelect">
457
+ <option value="512x512">Square (512×512)</option>
458
+ <option value="768x768" selected>Square (768×768)</option>
459
+ <option value="1024x1024">Square (1024×1024)</option>
460
+ <option value="1024x768">Landscape (1024×768)</option>
461
+ <option value="768x1024">Portrait (768×1024)</option>
462
+ </select>
463
+ </div>
464
+
465
+ <div class="input-group">
466
+ <label>Provider</label>
467
+ <select id="providerSelect">
468
+ <option value="replicate">Replicate (Stable Diffusion)</option>
469
+ <option value="huggingface">Hugging Face (FLUX)</option>
470
+ </select>
471
+ </div>
472
+
473
+ <button id="generateBtn" class="generate-btn">✨ Generate Image</button>
474
+ <div id="loadingIndicator" class="loading">Generating... Please wait ⏳</div>
475
+
476
+ <!-- API Keys Configuration -->
477
+ <details class="api-keys-section">
478
+ <summary>🔑 Configure API Keys</summary>
479
+ <div class="api-input-group">
480
+ <label>Replicate Keys (comma-separated):</label>
481
+ <input type="text" id="replicateKeys" placeholder="r8_xxx, r8_yyy, r8_zzz">
482
+ <button id="saveReplicateKeys">Save Replicate Keys</button>
483
+ </div>
484
+ <div class="api-input-group">
485
+ <label>Hugging Face Keys (comma-separated):</label>
486
+ <input type="text" id="hfKeys" placeholder="hf_xxx, hf_yyy, hf_zzz">
487
+ <button id="saveHfKeys">Save HF Keys</button>
488
+ </div>
489
+ <div id="keyStatus" class="key-status"></div>
490
+ </details>
491
+ </aside>
492
+
493
+ <!-- Canvas Display Area -->
494
+ <main class="canvas-area">
495
+ <canvas id="imageCanvas"></canvas>
496
+ <div id="imagePlaceholder" class="image-placeholder">
497
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
498
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
499
+ <circle cx="8.5" cy="8.5" r="2.5"></circle>
500
+ <polyline points="21 15 16 10 5 21"></polyline>
501
+ </svg>
502
+ <p>Your generated image will appear here</p>
503
+ </div>
504
+ </main>
505
+ </div>
506
+
507
+ <!-- Stats Bar -->
508
+ <div class="stats-bar">
509
+ <div class="stat-item">
510
+ <h3 id="statGenerations">0</h3>
511
+ <p>Generations</p>
512
+ </div>
513
+ <div class="stat-item">
514
+ <h3 id="statActiveKeys">0</h3>
515
+ <p>Active Keys</p>
516
+ </div>
517
+ <div class="stat-item">
518
+ <h3 id="statProvider">-</h3>
519
+ <p>Last Provider</p>
520
+ </div>
521
+ </div>
522
+
523
+ <script>
524
+ // Three.js 3D Background Animation
525
+ const scene = new THREE.Scene();
526
+ const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
527
+ const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
528
+
529
+ renderer.setSize(window.innerWidth, window.innerHeight);
530
+ renderer.setPixelRatio(window.devicePixelRatio);
531
+ document.getElementById('canvas-container').appendChild(renderer.domElement);
532
+
533
+ // Create particles
534
+ const particlesGeometry = new THREE.BufferGeometry();
535
+ const particlesCount = 1500;
536
+ const posArray = new Float32Array(particlesCount * 3);
537
+ const colorsArray = new Float32Array(particlesCount * 3);
538
+
539
+ for(let i = 0; i < particlesCount * 3; i += 3) {
540
+ posArray[i] = (Math.random() - 0.5) * 50;
541
+ posArray[i+1] = (Math.random() - 0.5) * 50;
542
+ posArray[i+2] = (Math.random() - 0.5) * 50;
543
+
544
+ colorsArray[i] = 0.4 + Math.random() * 0.4;
545
+ colorsArray[i+1] = 0.2 + Math.random() * 0.3;
546
+ colorsArray[i+2] = 0.8 + Math.random() * 0.2;
547
+ }
548
+
549
+ particlesGeometry.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
550
+ particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colorsArray, 3));
551
+
552
+ const particlesMaterial = new THREE.PointsMaterial({
553
+ size: 0.05,
554
+ vertexColors: true,
555
+ transparent: true,
556
+ opacity: 0.8,
557
+ blending: THREE.AdditiveBlending
558
+ });
559
+
560
+ const particlesMesh = new THREE.Points(particlesGeometry, particlesMaterial);
561
+ scene.add(particlesMesh);
562
+
563
+ // Add floating geometric shapes
564
+ const geometries = [
565
+ new THREE.IcosahedronGeometry(1, 0),
566
+ new THREE.OctahedronGeometry(1, 0),
567
+ new THREE.TetrahedronGeometry(1, 0)
568
+ ];
569
+
570
+ const shapes = [];
571
+ const shapeCount = 15;
572
+
573
+ for(let i = 0; i < shapeCount; i++) {
574
+ const geometry = geometries[Math.floor(Math.random() * geometries.length)];
575
+ const material = new THREE.MeshBasicMaterial({
576
+ color: new THREE.Color().setHSL(0.6 + Math.random() * 0.2, 0.7, 0.5),
577
+ wireframe: true,
578
+ transparent: true,
579
+ opacity: 0.3
580
+ });
581
+
582
+ const mesh = new THREE.Mesh(geometry, material);
583
+ mesh.position.set(
584
+ (Math.random() - 0.5) * 40,
585
+ (Math.random() - 0.5) * 40,
586
+ (Math.random() - 0.5) * 40
587
+ );
588
+ mesh.rotation.set(
589
+ Math.random() * Math.PI,
590
+ Math.random() * Math.PI,
591
+ 0
592
+ );
593
+
594
+ mesh.userData = {
595
+ rotationSpeed: {
596
+ x: (Math.random() - 0.5) * 0.01,
597
+ y: (Math.random() - 0.5) * 0.01,
598
+ z: (Math.random() - 0.5) * 0.01
599
+ },
600
+ floatSpeed: Math.random() * 0.02 + 0.01,
601
+ floatOffset: Math.random() * Math.PI * 2
602
+ };
603
+
604
+ shapes.push(mesh);
605
+ scene.add(mesh);
606
+ }
607
+
608
+ camera.position.z = 30;
609
+
610
+ // Mouse interaction
611
+ let mouseX = 0;
612
+ let mouseY = 0;
613
+ let targetX = 0;
614
+ let targetY = 0;
615
+
616
+ const windowHalfX = window.innerWidth / 2;
617
+ const windowHalfY = window.innerHeight / 2;
618
+
619
+ document.addEventListener('mousemove', (event) => {
620
+ mouseX = (event.clientX - windowHalfX) / 100;
621
+ mouseY = (event.clientY - windowHalfY) / 100;
622
+ });
623
+
624
+ // Animation loop
625
+ const clock = new THREE.Clock();
626
+
627
+ function animate() {
628
+ requestAnimationFrame(animate);
629
+
630
+ const time = clock.getElapsedTime();
631
+
632
+ targetX = mouseX * 2;
633
+ targetY = mouseY * 2;
634
+
635
+ camera.position.x += (targetX - camera.position.x) * 0.05;
636
+ camera.position.y += (-targetY - camera.position.y) * 0.05;
637
+ camera.lookAt(scene.position);
638
+
639
+ particlesMesh.rotation.y = time * 0.05;
640
+ particlesMesh.rotation.x = time * 0.02;
641
+
642
+ shapes.forEach((shape) => {
643
+ shape.rotation.x += shape.userData.rotationSpeed.x;
644
+ shape.rotation.y += shape.userData.rotationSpeed.y;
645
+ shape.rotation.z += shape.userData.rotationSpeed.z;
646
+ shape.position.y += Math.sin(time * shape.userData.floatSpeed + shape.userData.floatOffset) * 0.02;
647
+ });
648
+
649
+ const positions = particlesGeometry.attributes.position.array;
650
+ for(let i = 0; i < particlesCount * 3; i += 3) {
651
+ positions[i+1] += Math.sin(time * 0.5 + positions[i] * 0.1) * 0.02;
652
+ }
653
+ particlesGeometry.attributes.position.needsUpdate = true;
654
+
655
+ renderer.render(scene, camera);
656
+ }
657
+
658
+ animate();
659
+
660
+ window.addEventListener('resize', () => {
661
+ camera.aspect = window.innerWidth / window.innerHeight;
662
+ camera.updateProjectionMatrix();
663
+ renderer.setSize(window.innerWidth, window.innerHeight);
664
+ });
665
+
666
+ // ==================== APP LOGIC ====================
667
+ const API_BASE = '';
668
+ let currentImageUrl = null;
669
+ let generationCount = 0;
670
+
671
+ // DOM Elements
672
+ const promptInput = document.getElementById('promptInput');
673
+ const generateBtn = document.getElementById('generateBtn');
674
+ const loadingIndicator = document.getElementById('loadingIndicator');
675
+ const imageCanvas = document.getElementById('imageCanvas');
676
+ const imagePlaceholder = document.getElementById('imagePlaceholder');
677
+ const fullscreenBtn = document.getElementById('fullscreenBtn');
678
+ const saveBtn = document.getElementById('saveBtn');
679
+ const sizeSelect = document.getElementById('sizeSelect');
680
+ const providerSelect = document.getElementById('providerSelect');
681
+ const saveReplicateKeys = document.getElementById('saveReplicateKeys');
682
+ const saveHfKeys = document.getElementById('saveHfKeys');
683
+ const replicateKeysInput = document.getElementById('replicateKeys');
684
+ const hfKeysInput = document.getElementById('hfKeys');
685
+ const keyStatusDiv = document.getElementById('keyStatus');
686
+ const ctx = imageCanvas.getContext('2d');
687
+
688
+ // Toast notification
689
+ function showToast(message, type = 'info') {
690
+ const toast = document.createElement('div');
691
+ toast.className = `toast ${type}`;
692
+ toast.textContent = message;
693
+ document.body.appendChild(toast);
694
+ setTimeout(() => toast.remove(), 3000);
695
+ }
696
+
697
+ // Load saved API keys from localStorage
698
+ function loadSavedKeys() {
699
+ const savedReplicate = localStorage.getItem('replicate_keys');
700
+ const savedHf = localStorage.getItem('hf_keys');
701
+
702
+ if (savedReplicate) {
703
+ replicateKeysInput.value = savedReplicate;
704
+ configureKeys('replicate', savedReplicate);
705
+ }
706
+ if (savedHf) {
707
+ hfKeysInput.value = savedHf;
708
+ configureKeys('huggingface', savedHf);
709
+ }
710
+ }
711
+
712
+ // Configure API keys for a provider
713
+ async function configureKeys(provider, keysString) {
714
+ try {
715
+ const response = await fetch(`${API_BASE}/api/keys/configure`, {
716
+ method: 'POST',
717
+ headers: { 'Content-Type': 'application/json' },
718
+ body: JSON.stringify({ provider, keys: keysString })
719
+ });
720
+
721
+ if (response.ok) {
722
+ console.log(`Configured ${provider} keys`);
723
+ await updateKeyStatus();
724
+ }
725
+ } catch (error) {
726
+ console.error(`Failed to configure ${provider} keys:`, error);
727
+ }
728
+ }
729
+
730
+ // Update key status display
731
+ async function updateKeyStatus() {
732
+ try {
733
+ const response = await fetch(`${API_BASE}/api/keys/status`);
734
+ const status = await response.json();
735
+
736
+ let html = '';
737
+ let totalActive = 0;
738
+ for (const [provider, data] of Object.entries(status)) {
739
+ html += `<div><strong>${provider}:</strong> ${data.active}/${data.total} active (${data.cooling} cooling, ${data.blocked} blocked)</div>`;
740
+ totalActive += data.active;
741
+ }
742
+ keyStatusDiv.innerHTML = html || '<div>No keys configured</div>';
743
+ document.getElementById('statActiveKeys').textContent = totalActive;
744
+ } catch (error) {
745
+ console.error('Failed to get key status:', error);
746
+ }
747
+ }
748
+
749
+ // Generate image
750
+ async function generateImage() {
751
+ const prompt = promptInput.value.trim();
752
+ if (!prompt) {
753
+ showToast('Please enter a prompt', 'error');
754
+ return;
755
+ }
756
+
757
+ const size = sizeSelect.value;
758
+ const [width, height] = size.split('x').map(Number);
759
+ const provider = providerSelect.value;
760
+
761
+ generateBtn.disabled = true;
762
+ loadingIndicator.classList.add('active');
763
+
764
+ try {
765
+ const response = await fetch(`${API_BASE}/api/generate`, {
766
+ method: 'POST',
767
+ headers: { 'Content-Type': 'application/json' },
768
+ body: JSON.stringify({ prompt, width, height, provider })
769
+ });
770
+
771
+ const result = await response.json();
772
+
773
+ if (result.success && result.image_url) {
774
+ currentImageUrl = result.image_url;
775
+ const img = new Image();
776
+ img.onload = () => {
777
+ imageCanvas.width = img.width;
778
+ imageCanvas.height = img.height;
779
+ ctx.drawImage(img, 0, 0);
780
+ imageCanvas.style.display = 'block';
781
+ imagePlaceholder.style.display = 'none';
782
+ generationCount++;
783
+ document.getElementById('statGenerations').textContent = generationCount;
784
+ document.getElementById('statProvider').textContent = result.provider_used || '-';
785
+ };
786
+ img.onerror = () => {
787
+ showToast('Failed to load image', 'error');
788
+ };
789
+ img.src = result.image_url;
790
+ showToast('Image generated successfully!', 'success');
791
+ } else {
792
+ showToast(`Generation failed: ${result.error || 'Unknown error'}`, 'error');
793
+ }
794
+ } catch (error) {
795
+ showToast(`Error: ${error.message}`, 'error');
796
+ } finally {
797
+ generateBtn.disabled = false;
798
+ loadingIndicator.classList.remove('active');
799
+ }
800
+ }
801
+
802
+ // Save current image locally
803
+ function saveImage() {
804
+ if (!currentImageUrl) {
805
+ showToast('No image to save', 'error');
806
+ return;
807
+ }
808
+
809
+ const link = document.createElement('a');
810
+ link.download = `pete-studio-${Date.now()}.png`;
811
+ link.href = currentImageUrl;
812
+ link.click();
813
+ showToast('Image saved!', 'success');
814
+ }
815
+
816
+ // Fullscreen mode
817
+ function toggleFullscreen() {
818
+ if (!document.fullscreenElement) {
819
+ document.documentElement.requestFullscreen();
820
+ fullscreenBtn.textContent = '✕ Exit';
821
+ } else {
822
+ document.exitFullscreen();
823
+ fullscreenBtn.textContent = '⛶ Fullscreen';
824
+ }
825
+ }
826
+
827
+ document.addEventListener('fullscreenchange', () => {
828
+ if (!document.fullscreenElement) {
829
+ fullscreenBtn.textContent = '⛶ Fullscreen';
830
+ }
831
+ });
832
+
833
+ // Event listeners
834
+ generateBtn.addEventListener('click', generateImage);
835
+ fullscreenBtn.addEventListener('click', toggleFullscreen);
836
+ saveBtn.addEventListener('click', saveImage);
837
+
838
+ saveReplicateKeys.addEventListener('click', () => {
839
+ const keys = replicateKeysInput.value.trim();
840
+ if (keys) {
841
+ localStorage.setItem('replicate_keys', keys);
842
+ configureKeys('replicate', keys);
843
+ showToast('Replicate keys saved', 'success');
844
+ }
845
+ });
846
+
847
+ saveHfKeys.addEventListener('click', () => {
848
+ const keys = hfKeysInput.value.trim();
849
+ if (keys) {
850
+ localStorage.setItem('hf_keys', keys);
851
+ configureKeys('huggingface', keys);
852
+ showToast('Hugging Face keys saved', 'success');
853
+ }
854
+ });
855
+
856
+ // Initialize
857
+ loadSavedKeys();
858
+ updateKeyStatus();
859
+ setInterval(updateKeyStatus, 30000);
860
+
861
+ // Keyboard shortcuts
862
+ document.addEventListener('keydown', (e) => {
863
+ if (e.ctrlKey && e.key === 'Enter') {
864
+ e.preventDefault();
865
+ generateImage();
866
+ }
867
+ if (e.ctrlKey && e.key === 's') {
868
+ e.preventDefault();
869
+ saveImage();
870
+ }
871
+ });
872
+ </script>
873
+ </body>
874
+ </html>
static/manifest.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Pete Studio",
3
+ "short_name": "Pete Studio",
4
+ "description": "Generate high-quality images from text with AI",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "theme_color": "#0a0a0a",
8
+ "background_color": "#0a0a0a",
9
+ "icons": [
10
+ {
11
+ "src": "/static/icon-192.png",
12
+ "sizes": "192x192",
13
+ "type": "image/png"
14
+ },
15
+ {
16
+ "src": "/static/icon-512.png",
17
+ "sizes": "512x512",
18
+ "type": "image/png"
19
+ }
20
+ ]
21
+ }