Compare commits

...

3 Commits

Author SHA1 Message Date
daniel 13dbe86f19 Split repo into apiserver/ and webserver/, add auth error handling and nginx proxy timeouts 2026-09-28 23:05:00 -05:00
daniel 32149a88e1 Inline CSS into index.html and add nginx config 2026-09-27 16:40:33 -05:00
daniel 9afe6bce7b Refactor CSS into separate file, add size selector and prompt clear button
- Extract styles.css from inline <style> in flux-index.html
- Add image size selector with 9 preset aspect ratios (standard + XL)
- Add clear (✕) button in prompt input field
- Pass width/height to Flux pipeline API endpoint
2026-09-27 15:55:21 -05:00
12 changed files with 1012 additions and 412 deletions
+1
View File
@@ -0,0 +1 @@
apiserver/users.json
-57
View File
@@ -1,57 +0,0 @@
import os
import base64
from io import BytesIO
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import torch
from diffusers import FluxPipeline
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
print("Initializing Flux-Schnell on RTX 5060 Ti (sm_120)...")
hf_token = os.environ.get("HF_TOKEN")
pipe = FluxPipeline.from_pretrained(
"black-forest-labs/FLUX.1-schnell",
torch_dtype=torch.bfloat16,
token=hf_token
)
# Offload execution stages sequentially to maintain 16GB headroom
pipe.enable_sequential_cpu_offload()
pipe.vae.enable_tiling()
class ImageRequest(BaseModel):
prompt: str
model: str = "flux-schnell"
@app.post("/v1/images/generations")
async def generate(request: ImageRequest):
try:
result = pipe(
request.prompt,
guidance_scale=0.0,
num_inference_steps=4,
max_sequence_length=256,
).images[0]
buffered = BytesIO()
result.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return {"data": [{"b64_json": img_str}]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5005)
-59
View File
@@ -1,59 +0,0 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import torch
from diffusers import FluxPipeline
import base64
from io import BytesIO
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Load FLUX.1-schnell (Best for 16GB VRAM + Fast Generation)
print("Initializing Flux-Schnell on RTX 5060 Ti (sm_120)...")
try:
pipe = FluxPipeline.from_pretrained(
"black-forest-labs/FLUX.1-schnell",
torch_dtype=torch.bfloat16
)
# Optimized for 16GB: Moves parts of the model to CPU when not in use
# pipe.enable_model_cpu_offload()
pipe.enable_sequential_cpu_offload()
pipe.vae.enable_tiling()
except Exception as e:
print(f"Error loading model: {e}")
class ImageRequest(BaseModel):
prompt: str
model: str = "flux-schnell" # Matches the error fix from earlier
@app.post("/v1/images/generations")
async def generate(request: ImageRequest):
try:
# Flux-Schnell generates high quality in just 4 steps
result = pipe(
request.prompt,
guidance_scale=0.0,
num_inference_steps=4,
max_sequence_length=256,
).images[0]
# Convert to Base64 for the API response
buffered = BytesIO()
result.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return {"data": [{"b64_json": img_str}]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5005)
+2 -1
View File
@@ -12,7 +12,8 @@ RUN pip3 install --no-cache-dir \
diffusers==0.35.2 \
accelerate==0.30.1 \
sentencepiece \
protobuf
protobuf \
bcrypt
# Set up the application
WORKDIR /app
+191
View File
@@ -0,0 +1,191 @@
import os
import json
import base64
import time
import hmac
import hashlib
import secrets
from io import BytesIO
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, model_validator
from typing import Any
import torch
from diffusers import FluxPipeline
import bcrypt
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
print("Initializing Flux-Schnell on RTX 5060 Ti (sm_120)...")
hf_token = os.environ.get("HF_TOKEN")
pipe = FluxPipeline.from_pretrained(
"black-forest-labs/FLUX.1-schnell",
torch_dtype=torch.bfloat16,
token=hf_token
)
# Offload execution stages sequentially to maintain 16GB headroom
pipe.enable_sequential_cpu_offload()
pipe.vae.enable_tiling()
# Auth config
USERS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'users.json')
SESSION_SECRET = os.environ.get('SESSION_SECRET', secrets.token_hex(32))
SESSION_DURATION = 86400 # 24 hours
def load_users():
with open(USERS_FILE) as f:
return json.load(f)
def create_session(username: str) -> str:
payload = f"{username}:{int(time.time())}"
signature = hmac.new(
SESSION_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return f"{payload}.{signature}"
def verify_session(session: str) -> str | None:
try:
payload, sig = session.rsplit('.', 1)
expected_sig = hmac.new(
SESSION_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
username, timestamp = payload.split(':', 1)
if time.time() - int(timestamp) > SESSION_DURATION:
return None
users = load_users()
if username not in users:
return None
return username
except Exception:
return None
class ImageRequest(BaseModel):
prompt: str
model: str = "flux-schnell"
width: int = 1024
height: int = 1024
@model_validator(mode="before")
@classmethod
def validate_dimensions(cls, data: Any) -> Any:
if isinstance(data, dict):
width = data.get("width", 1024)
height = data.get("height", 1024)
if width % 32 != 0 or height % 32 != 0:
raise ValueError("Width and height must be multiples of 32")
if width > 2048 or height > 2048:
raise ValueError("Maximum width and height is 2048")
return data
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
# Allow auth endpoints and static files through
if request.url.path.startswith(("/auth/login", "/auth/logout", "/auth/check")):
return await call_next(request)
# Check session cookie for API requests
session = request.cookies.get("session")
username = verify_session(session) if session else None
if not username:
return JSONResponse(
status_code=401,
content={"detail": "Not authenticated"}
)
request.state.username = username
return await call_next(request)
@app.post("/auth/login")
async def login(request: Request):
data = await request.json()
username = data.get('username', '')
password = data.get('password', '')
users = load_users()
if username not in users:
raise HTTPException(401, "Invalid credentials")
if not bcrypt.checkpw(password.encode(), users[username].encode()):
raise HTTPException(401, "Invalid credentials")
session = create_session(username)
response = JSONResponse(content={"username": username})
response.set_cookie(
key="session",
value=session,
httponly=True,
samesite="strict",
max_age=SESSION_DURATION,
path="/",
)
return response
@app.post("/auth/logout")
async def logout():
response = JSONResponse(content={"message": "Logged out"})
response.set_cookie("session", "", expires=0, path="/")
return response
@app.get("/auth/check")
async def check(request: Request, session: str | None = None):
username = verify_session(session) if session else None
if not username:
raise HTTPException(401, "Not authenticated")
return {"username": username}
@app.post("/v1/images/generations")
async def generate(request: Request):
try:
body = await request.json()
req = ImageRequest(**body)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
try:
result = pipe(
req.prompt,
guidance_scale=0.0,
num_inference_steps=4,
max_sequence_length=256,
width=req.width,
height=req.height,
).images[0]
buffered = BytesIO()
result.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return {"data": [{"b64_json": img_str}]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5005)
+27
View File
@@ -0,0 +1,27 @@
services:
flux-api:
image: flux-image:1.0
container_name: flux-api
ports:
- "5005:5005"
ipc: host
ulimits:
memlock: -1
stack: 67108864
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- HF_TOKEN=${HF_TOKEN}
- HF_HOME=/root/.cache/huggingface
- SESSION_SECRET=${SESSION_SECRET}
volumes:
- /opt/huggingface_cache:/root/.cache/huggingface
- /home/sysuser/Development/flux-api/users.json:/app/users.json
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

-295
View File
@@ -1,295 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flux Image Generator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f13;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 0.3rem;
background: linear-gradient(135deg, #a78bfa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: #666;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.input-area {
width: 100%;
max-width: 640px;
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
#prompt {
flex: 1;
padding: 0.85rem 1rem;
font-size: 1rem;
border: 1px solid #2a2a35;
border-radius: 10px;
background: #1a1a24;
color: #e0e0e0;
outline: none;
transition: border-color 0.2s;
}
#prompt:focus {
border-color: #a78bfa;
}
#prompt::placeholder {
color: #555;
}
#generate-btn {
padding: 0.85rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
white-space: nowrap;
}
#generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
#generate-btn:active:not(:disabled) {
transform: scale(0.97);
}
#generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#image-container {
display: none;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
#image-container.visible {
display: flex;
}
#result-image {
max-width: 100%;
border-radius: 12px;
border: 1px solid #2a2a35;
background: #1a1a24;
}
#download-btn {
padding: 0.7rem 1.4rem;
font-size: 0.9rem;
font-weight: 600;
border: 1px solid #2a2a35;
border-radius: 8px;
background: #1a1a24;
color: #a78bfa;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
}
#download-btn:hover {
background: #22222e;
}
.status {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.4em;
}
.status.error {
color: #f87171;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5em;
vertical-align: -0.15em;
border: 2px solid #a78bfa;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.config {
width: 100%;
max-width: 640px;
margin-bottom: 1.5rem;
padding: 0.8rem 1rem;
background: #1a1a24;
border: 1px solid #2a2a35;
border-radius: 10px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.config label {
white-space: nowrap;
}
.config input {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
border: 1px solid #2a2a35;
border-radius: 6px;
background: #0f0f13;
color: #e0e0e0;
outline: none;
}
.config input:focus {
border-color: #a78bfa;
}
</style>
</head>
<body>
<h1>Flux Image Generator</h1>
<p class="subtitle">Powered by flux-schnell</p>
<div class="config">
<label for="api-url">API URL:</label>
<input id="api-url" value="http://192.168.2.154:5005/v1/images/generations" />
</div>
<div class="input-area">
<input id="prompt" type="text" placeholder="Describe the image you want to create..." autofocus />
<button id="generate-btn">Generate</button>
</div>
<p class="status" id="status"></p>
<div class="result-area">
<div id="image-container">
<img id="result-image" alt="Generated image" />
<a id="download-btn" download="flux-image.png">⬇ Download PNG</a>
</div>
</div>
<script>
const promptInput = document.getElementById('prompt');
const generateBtn = document.getElementById('generate-btn');
const apiInput = document.getElementById('api-url');
const statusEl = document.getElementById('status');
const imageContainer = document.getElementById('image-container');
const resultImage = document.getElementById('result-image');
const downloadBtn = document.getElementById('download-btn');
function setStatus(msg, isError = false) {
statusEl.innerHTML = msg;
statusEl.className = 'status' + (isError ? ' error' : '');
}
async function generate() {
const prompt = promptInput.value.trim();
if (!prompt) {
setStatus('Please enter a prompt.', true);
return;
}
const apiUrl = apiInput.value.trim();
if (!apiUrl) {
setStatus('Please enter an API URL.', true);
return;
}
generateBtn.disabled = true;
generateBtn.textContent = 'Generating...';
setStatus('<span class="spinner"></span> Generating image...');
imageContainer.classList.remove('visible');
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, model: 'flux-schnell' })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const base64 = data.data?.[0]?.b64_json;
if (!base64 || base64 === 'null') {
throw new Error(data.detail || 'No image data returned.');
}
const blob = await fetch(`data:image/png;base64,${base64}`).then(r => r.blob());
const url = URL.createObjectURL(blob);
resultImage.src = url;
downloadBtn.href = url;
const ts = new Date().toISOString().replace(/[:.]/g, '-');
downloadBtn.download = `flux-image-${ts}.png`;
imageContainer.classList.add('visible');
setStatus('');
} catch (err) {
setStatus(`Error: ${err.message}`, true);
} finally {
generateBtn.disabled = false;
generateBtn.textContent = 'Generate';
}
}
generateBtn.addEventListener('click', generate);
promptInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') generate();
});
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
services:
webserver:
image: nginx:alpine
volumes:
- /home/sysuser/html:/usr/share/nginx/html:ro
- /home/sysuser/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
ports:
- "8080:80"
+532
View File
@@ -0,0 +1,532 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flux Image Generator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f13;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 0.3rem;
background: linear-gradient(135deg, #a78bfa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: #666;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.input-area {
width: 100%;
max-width: 640px;
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
#prompt {
flex: 1;
padding: 0.85rem 1rem;
font-size: 1rem;
border: 1px solid #2a2a35;
border-radius: 10px;
background: #1a1a24;
color: #e0e0e0;
outline: none;
transition: border-color 0.2s;
}
#prompt:focus {
border-color: #a78bfa;
}
#prompt::placeholder {
color: #555;
}
#generate-btn {
padding: 0.85rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
white-space: nowrap;
}
#generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
#generate-btn:active:not(:disabled) {
transform: scale(0.97);
}
#generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#image-container {
display: none;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
#image-container.visible {
display: flex;
}
#result-image {
max-width: 100%;
border-radius: 12px;
border: 1px solid #2a2a35;
background: #1a1a24;
}
#download-btn {
padding: 0.7rem 1.4rem;
font-size: 0.9rem;
font-weight: 600;
border: 1px solid #2a2a35;
border-radius: 8px;
background: #1a1a24;
color: #a78bfa;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
}
#download-btn:hover {
background: #22222e;
}
.status {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.4em;
}
.status.error {
color: #f87171;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5em;
vertical-align: -0.15em;
border: 2px solid #a78bfa;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.config {
width: 100%;
max-width: 640px;
margin-bottom: 1.5rem;
padding: 0.8rem 1rem;
background: #1a1a24;
border: 1px solid #2a2a35;
border-radius: 10px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.config label {
white-space: nowrap;
}
.config input {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
border: 1px solid #2a2a35;
border-radius: 6px;
background: #0f0f13;
color: #e0e0e0;
outline: none;
}
.config input:focus,
.config select:focus {
border-color: #a78bfa;
}
.config select {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
border: 1px solid #2a2a35;
border-radius: 6px;
background: #0f0f13;
color: #e0e0e0;
outline: none;
cursor: pointer;
}
.prompt-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.prompt-wrapper #prompt {
padding-right: 2.5rem;
}
.clear-btn {
position: absolute;
right: 0.6rem;
background: none;
border: none;
color: #555;
font-size: 1rem;
cursor: pointer;
padding: 0.3rem;
line-height: 1;
transition: color 0.2s;
z-index: 1;
}
.clear-btn:hover {
color: #a78bfa;
}
#login-overlay {
position: fixed;
inset: 0;
background: #0f0f13;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
#login-overlay.hidden { display: none; }
.login-box {
width: 100%;
max-width: 360px;
padding: 2rem;
text-align: center;
}
.login-box h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #a78bfa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-subtitle {
color: #666;
font-size: 0.85rem;
margin-bottom: 1.5rem;
}
.login-box input[type="text"],
.login-box input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
font-size: 1rem;
border: 1px solid #2a2a35;
border-radius: 8px;
background: #1a1a24;
color: #e0e0e0;
outline: none;
}
.login-box input:focus { border-color: #a78bfa; }
#login-btn {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
cursor: pointer;
}
#login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.login-error {
color: #f87171;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
#logout-btn {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 999;
background: #1a1a24;
color: #a78bfa;
border: 1px solid #2a2a35;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
}
#logout-btn:hover { background: #22222e; }
</style>
</head>
<body>
<div id="login-overlay">
<div class="login-box">
<h2>Flux Image Generator</h2>
<p class="login-subtitle">Sign in to continue</p>
<form id="login-form">
<input type="text" id="login-username" placeholder="Username" required autocomplete="username" />
<input type="password" id="login-password" placeholder="Password" required autocomplete="current-password" />
<p id="login-error" class="login-error" style="display:none"></p>
<button type="submit" id="login-btn">Sign In</button>
</form>
</div>
</div>
<button id="logout-btn" style="display:none">Sign Out</button>
<h1>Flux Image Generator</h1>
<p class="subtitle">Powered by flux-schnell</p>
<div class="config">
<label for="api-url">API URL:</label>
<input id="api-url" value="/v1/images/generations" />
</div>
<div class="config">
<label for="size-select">Size:</label>
<select id="size-select">
<option value="1024x1024">1024x1024 (1:1)</option>
<option value="1920x1088">1920x1088 (16:9)</option>
<option value="1088x1920">1088x1920 (9:16)</option>
<option value="1376x1024">1376x1024 (4:3)</option>
<option value="1024x1376">1024x1376 (3:4)</option>
<option value="1536x1024">1536x1024 (3:2)</option>
<option value="1024x1536">1024x1536 (2:3)</option>
<option value="2048x2048">2048x2048 (1:1 XL)</option>
<option value="2048x1152">2048x1152 (16:9 XL)</option>
<option value="1152x2048">1152x2048 (9:16 XL)</option>
</select>
</div>
<div class="input-area">
<div class="prompt-wrapper">
<input id="prompt" type="text" placeholder="Describe the image you want to create..." autofocus />
<button id="clear-btn" class="clear-btn" title="Clear">✕</button>
</div>
<button id="generate-btn">Generate</button>
</div>
<p class="status" id="status"></p>
<div class="result-area">
<div id="image-container">
<img id="result-image" alt="Generated image" />
<a id="download-btn" download="flux-image.png">⬇ Download PNG</a>
</div>
</div>
<script>
const loginOverlay = document.getElementById('login-overlay');
const loginForm = document.getElementById('login-form');
const loginBtn = document.getElementById('login-btn');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const promptInput = document.getElementById('prompt');
const clearBtn = document.getElementById('clear-btn');
const sizeSelect = document.getElementById('size-select');
const generateBtn = document.getElementById('generate-btn');
const apiInput = document.getElementById('api-url');
const statusEl = document.getElementById('status');
const imageContainer = document.getElementById('image-container');
const resultImage = document.getElementById('result-image');
const downloadBtn = document.getElementById('download-btn');
async function checkAuth() {
try {
const resp = await fetch('/auth/check');
if (resp.ok) {
loginOverlay.classList.add('hidden');
logoutBtn.style.display = 'block';
return true;
}
} catch (e) {
// Not authenticated
}
return false;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginBtn.disabled = true;
loginError.style.display = 'none';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (resp.ok) {
loginOverlay.classList.add('hidden');
logoutBtn.style.display = 'block';
} else {
let detail;
try {
const data = await resp.json();
detail = data.detail || (resp.status >= 500 ? `Server error (${resp.status})` : 'Invalid credentials');
} catch (_) {
detail = resp.status >= 500 ? `Server error (${resp.status})` : 'Invalid credentials';
}
loginError.textContent = detail;
loginError.style.display = 'block';
}
} catch (err) {
loginError.textContent = 'Connection error';
loginError.style.display = 'block';
} finally {
loginBtn.disabled = false;
}
});
logoutBtn.addEventListener('click', async () => {
await fetch('/auth/logout', { method: 'POST' });
loginOverlay.classList.remove('hidden');
logoutBtn.style.display = 'none';
});
function setStatus(msg, isError = false) {
statusEl.innerHTML = msg;
statusEl.className = 'status' + (isError ? ' error' : '');
}
async function generate() {
const prompt = promptInput.value.trim();
if (!prompt) {
setStatus('Please enter a prompt.', true);
return;
}
const apiUrl = apiInput.value.trim();
if (!apiUrl) {
setStatus('Please enter an API URL.', true);
return;
}
generateBtn.disabled = true;
generateBtn.textContent = 'Generating...';
setStatus('<span class="spinner"></span> Generating image...');
imageContainer.classList.remove('visible');
try {
const [width, height] = sizeSelect.value.split('x').map(Number);
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, model: 'flux-schnell', width, height })
});
if (response.status === 401) {
loginOverlay.classList.remove('hidden');
logoutBtn.style.display = 'none';
throw new Error('Session expired');
}
if (!response.ok) {
let detail = `HTTP ${response.status}: ${response.statusText}`;
try {
const errData = await response.json();
if (errData.detail) detail = errData.detail;
} catch (_) { /* non-JSON error body */ }
throw new Error(detail);
}
const data = await response.json();
const base64 = data.data?.[0]?.b64_json;
if (!base64 || base64 === 'null') {
throw new Error(data.detail || 'No image data returned.');
}
const blob = await fetch(`data:image/png;base64,${base64}`).then(r => r.blob());
const url = URL.createObjectURL(blob);
resultImage.src = url;
downloadBtn.href = url;
const ts = new Date().toISOString().replace(/[:.]/g, '-');
downloadBtn.download = `flux-image-${ts}.png`;
imageContainer.classList.add('visible');
setStatus('');
} catch (err) {
setStatus(`Error: ${err.message}`, true);
} finally {
generateBtn.disabled = false;
generateBtn.textContent = 'Generate';
}
}
generateBtn.addEventListener('click', generate);
promptInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') generate();
});
clearBtn.addEventListener('click', () => {
promptInput.value = '';
promptInput.focus();
});
promptInput.addEventListener('focus', () => {
promptInput.select();
});
// Check session on page load
checkAuth().then(authenticated => {
if (!authenticated) {
loginOverlay.classList.remove('hidden');
}
});
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /auth/ {
proxy_pass http://192.168.2.154:5005;
}
location /v1/ {
proxy_pass http://192.168.2.154:5005;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
try_files $uri $uri/ =404;
}
}
+229
View File
@@ -0,0 +1,229 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f13;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 0.3rem;
background: linear-gradient(135deg, #a78bfa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: #666;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.input-area {
width: 100%;
max-width: 640px;
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
#prompt {
flex: 1;
padding: 0.85rem 1rem;
font-size: 1rem;
border: 1px solid #2a2a35;
border-radius: 10px;
background: #1a1a24;
color: #e0e0e0;
outline: none;
transition: border-color 0.2s;
}
#prompt:focus {
border-color: #a78bfa;
}
#prompt::placeholder {
color: #555;
}
#generate-btn {
padding: 0.85rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
white-space: nowrap;
}
#generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
#generate-btn:active:not(:disabled) {
transform: scale(0.97);
}
#generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-area {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#image-container {
display: none;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
#image-container.visible {
display: flex;
}
#result-image {
max-width: 100%;
border-radius: 12px;
border: 1px solid #2a2a35;
background: #1a1a24;
}
#download-btn {
padding: 0.7rem 1.4rem;
font-size: 0.9rem;
font-weight: 600;
border: 1px solid #2a2a35;
border-radius: 8px;
background: #1a1a24;
color: #a78bfa;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
}
#download-btn:hover {
background: #22222e;
}
.status {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.4em;
}
.status.error {
color: #f87171;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5em;
vertical-align: -0.15em;
border: 2px solid #a78bfa;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.config {
width: 100%;
max-width: 640px;
margin-bottom: 1.5rem;
padding: 0.8rem 1rem;
background: #1a1a24;
border: 1px solid #2a2a35;
border-radius: 10px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.config label {
white-space: nowrap;
}
.config input {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
border: 1px solid #2a2a35;
border-radius: 6px;
background: #0f0f13;
color: #e0e0e0;
outline: none;
}
.config input:focus,
.config select:focus {
border-color: #a78bfa;
}
.config select {
flex: 1;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
border: 1px solid #2a2a35;
border-radius: 6px;
background: #0f0f13;
color: #e0e0e0;
outline: none;
cursor: pointer;
}
.prompt-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.prompt-wrapper #prompt {
padding-right: 2.5rem;
}
.clear-btn {
position: absolute;
right: 0.6rem;
background: none;
border: none;
color: #555;
font-size: 1rem;
cursor: pointer;
padding: 0.3rem;
line-height: 1;
transition: color 0.2s;
z-index: 1;
}
.clear-btn:hover {
color: #a78bfa;
}