#!/usr/bin/env python3 """ LoRA Studio — FastAPI Backend Seedream 4.0 Text-to-Image with LoRA + Style Reference 4K images in 2.39:1 cinematic ratio via Volcengine API. """ import os import json import time import uuid import requests import io import base64 from pathlib import Path from typing import Optional from fastapi import FastAPI, UploadFile, File, Form, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from dotenv import load_dotenv from loguru import logger from PIL import Image load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"), override=True) load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env"), override=False) from volcenginesdkcore.signv4 import SignerV4 app = FastAPI(title="LoRA Studio") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # Serve generated images OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs") os.makedirs(OUTPUT_DIR, exist_ok=True) app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs") # ============================================================ # VOLCENGINE CONFIG # ============================================================ HOST = "visual.volcengineapi.com" ENDPOINT = "https://visual.volcengineapi.com" REGION = "cn-north-1" SERVICE = "cv" API_VERSION = "2025-06-01" TEMPLATE_ID = "1762940890894000" MG_MV_ID = "592942110437597155" BASE_MODEL_ID = "592942110404108259" BASE_MODEL_VER_ID = "592942110437597155" LORA_PRESETS = { "krstyle": { "name": "KRSTYLE — Krishna Mystical Forest", "trigger": "KRSTYLE", "card_id": "614342735335368774", "ver_id": "614367323301661726", "description": "Deep blue-teal mystical forests, smooth digital painting, matte quality", }, "lkstyle": { "name": "LKSTYLE — Arcane / Fortiche", "trigger": "LKSTYLE", "card_id": "614122746506477325", "ver_id": "614247929653245457", "description": "Arcane animated series style, cinematic moody lighting", }, "hantex": { "name": "Hantex — Cinematic Concept Art", "trigger": "", "card_id": "614078666753801530", "ver_id": "614103179759504414", "description": "Cinematic matte painting, concept art quality", }, "none": { "name": "None — Base Seedream 4.0", "trigger": "", "card_id": "", "ver_id": "", "description": "No LoRA, pure Seedream 4.0 base model", }, } # ============================================================ # API HELPERS # ============================================================ def _signed_post(action, body): ak = os.environ.get("VOLC_ACCESS_KEY", "") sk = os.environ.get("VOLC_SECRET_KEY", "") req_body = json.dumps(body, ensure_ascii=False) query = {"Action": action, "Version": API_VERSION} headers = {"Content-Type": "application/json", "Host": HOST} SignerV4.sign(path="/", method="POST", headers=headers, body=req_body, post_params=None, query=query, ak=ak, sk=sk, region=REGION, service=SERVICE) url = f"{ENDPOINT}?Action={action}&Version={API_VERSION}" resp = requests.post(url, headers=headers, data=req_body.encode("utf-8"), timeout=120) if not resp.text: return {"code": -1, "message": f"Empty response, HTTP {resp.status_code}"} return resp.json() def _signed_get(action, extra=None): ak = os.environ.get("VOLC_ACCESS_KEY", "") sk = os.environ.get("VOLC_SECRET_KEY", "") query = {"Action": action, "Version": API_VERSION} if extra: query.update(extra) headers = {"Content-Type": "application/json", "Host": HOST} SignerV4.sign(path="/", method="GET", headers=headers, body="", post_params=None, query=query, ak=ak, sk=sk, region=REGION, service=SERVICE) from urllib.parse import urlencode url = f"{ENDPOINT}?{urlencode(query)}" resp = requests.get(url, headers=headers) return resp.json() def _poll(task_id, timeout=300, interval=8): start = time.time() while time.time() - start < timeout: result = _signed_get("GetLumiInferenceTask", {"id": str(task_id)}) if not result or result.get("code") != 0: time.sleep(interval) continue status = result.get("data", {}).get("status", "") if status == "complete": return result elif status in ("failed", "cancel", "risk", "timeout", "invalid_param"): return result time.sleep(interval) return None def _upload_to_s3(filepath): fpath = Path(filepath) with open(fpath, 'rb') as f: data = f.read() ext = fpath.suffix.lower() mime = 'image/png' if ext == '.png' else 'image/jpeg' files = {"file": (fpath.name, data, mime)} headers = {"x-api-key": "juniordevKey@9911"} resp = requests.post( "https://tinify-backend-dev-868570596092.asia-south1.run.app/api/upload-file", files=files, headers=headers, timeout=120 ) if resp.status_code == 200: result = resp.json() return result.get("url") or result.get("fileUrl") or result.get("s3_url") return None # ============================================================ # ENDPOINTS # ============================================================ @app.get("/api/presets") def get_presets(): return [{"id": k, **v} for k, v in LORA_PRESETS.items()] @app.post("/api/generate") def generate( prompt: str = Form(...), lora_id: str = Form("none"), lora_weight: float = Form(0.8), denoise: float = Form(0.5), guidance: float = Form(3.0), steps: int = Form(20), use_4k: bool = Form(True), ref_image: Optional[UploadFile] = File(None), ): preset = LORA_PRESETS.get(lora_id, LORA_PRESETS["none"]) trigger = preset.get("trigger", "") card_id = preset.get("card_id", "") ver_id = preset.get("ver_id", "") full_prompt = f"{trigger} {prompt}".strip() if trigger else prompt if len(full_prompt) > 1000: full_prompt = full_prompt[:997] + "..." w, h = (3840, 1646) if use_4k else (2688, 1152) # Upload ref image if provided ref_url = None if ref_image: tmp_path = os.path.join(OUTPUT_DIR, f"ref_{uuid.uuid4().hex[:8]}{Path(ref_image.filename).suffix}") with open(tmp_path, "wb") as f: f.write(ref_image.file.read()) ref_url = _upload_to_s3(tmp_path) os.remove(tmp_path) inference_type = "i2i" if ref_url else "t2i" body = { "task_name": f"studio_{int(time.time())}", "template_id": TEMPLATE_ID, "mg_mv_id": MG_MV_ID, "inference_config": { "inference_pipeline": "ba_worker", "inference_id": BASE_MODEL_ID, "inference_ver_id": BASE_MODEL_VER_ID, }, "request_source": 2, "count": 1, "inference_type": inference_type, "inputs": [ {"name": "seed", "internal_name": "seed", "format": "input_seed", "value": "-1"}, {"name": "denoise_strength", "internal_name": "denoise_strength", "format": "input_number", "value": str(denoise)}, {"name": "guidance_scale", "internal_name": "guidance_scale", "format": "input_number", "value": str(guidance)}, {"name": "steps", "internal_name": "steps", "format": "input_number", "value": str(steps)}, {"name": "prompt", "internal_name": "prompt", "format": "text_area", "value": full_prompt}, {"name": "width", "internal_name": "width", "format": "input_number", "value": str(w)}, {"name": "height", "internal_name": "height", "format": "input_number", "value": str(h)}, {"name": "process_type", "internal_name": "process_type", "format": "select", "value": "基础生成"}, ], } if card_id and ver_id: lora_json = json.dumps([{ "model_id": card_id, "model_version_id": ver_id, "configs": [{"name": "weight", "format": "input_number", "value": lora_weight}] }]) body["inputs"].append( {"name": "lora", "internal_name": "lora", "format": "model_upload", "value": lora_json} ) if ref_url: body["inputs"].append( {"name": "img0", "internal_name": "img0", "format": "image_upload", "value": ref_url} ) result = _signed_post("CreateLumiInferenceTask", body) if not result or result.get("code") != 0: raise HTTPException(500, f"API error: {result}") task_id = (result.get("data", {}).get("parent_task_id") or result.get("data", {}).get("task_id")) if not task_id: raise HTTPException(500, f"No task_id: {result}") poll_result = _poll(task_id, timeout=300, interval=8) if not poll_result: raise HTTPException(504, "Timeout — task did not complete") status = poll_result.get("data", {}).get("status", "") if status != "complete": raise HTTPException(500, f"Task failed: {status}") children = poll_result.get("data", {}).get("children", []) if not children: raise HTTPException(500, "No output") img_url = children[0].get("output", {}).get("value", "") if not img_url or not img_url.startswith("http"): raise HTTPException(500, "No image URL in output") # Download and save resp = requests.get(img_url, verify=False, timeout=60) if resp.status_code != 200: raise HTTPException(500, f"Download failed: {resp.status_code}") img = Image.open(io.BytesIO(resp.content)) filename = f"{uuid.uuid4().hex[:12]}.png" filepath = os.path.join(OUTPUT_DIR, filename) img.save(filepath, quality=100) return { "image_url": f"/outputs/{filename}", "width": img.size[0], "height": img.size[1], "task_id": task_id, "lora": preset["name"], "prompt": full_prompt, } # Serve React frontend FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend") if os.path.exists(FRONTEND_DIR): app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", 7860)) uvicorn.run(app, host="0.0.0.0", port=port)