Theloomvale's picture
Update app.py
c2f0237 verified
# Loomvale Image Lab β€” Anime Cinematic (5 scenes -> 5 images)
import os, io, re, json, asyncio, tempfile
from typing import List, Tuple
import gradio as gr
import spaces # IMPORTANT: import before torch on ZeroGPU
import torch
from PIL import Image
from diffusers import DiffusionPipeline
import gspread
from google.oauth2.service_account import Credentials
from huggingface_hub import HfApi
# =========================
# Config / Secrets
# =========================
SHEET_ID = os.getenv("SHEET_ID")
SHEET_NAME = os.getenv("SHEET_NAME", "Pipeline")
GOOGLE_CREDENTIALS_JSON = os.getenv("GOOGLE_CREDENTIALS_JSON")
HF_TOKEN = os.getenv("HF_TOKEN")
SPACE_ID = "Theloomvale/loomvale-image-lab" # public repo id of this Space
# Sheet columns (exact headers)
COL_AMBIENCE = "ImagePrompt_Ambience"
COL_SCENES = "ImagePrompt_Scenes"
COL_LINKS_OUT = "AI generated images"
COL_ASSISTANT = "Assistant"
# Default model choices (anime model first)
DEFAULT_MODEL = "Linaqruf/animagine-xl-3.1"
MODEL_CHOICES = [
"Linaqruf/animagine-xl-3.1",
"stabilityai/stable-diffusion-xl-base-1.0",
"SG161222/Realistic_Vision_V5.1_noVAE",
]
NEGATIVE_DEFAULT = (
"text watermark, signature, logo, jpeg artifacts, lowres, blurry, oversharp, noisy, "
"deformed, mutated, extra fingers, extra limbs, bad hands, bad anatomy, "
"crooked lines, harsh outlines, messy typography"
)
# =========================
# Google Sheets helpers
# =========================
def _gc():
if not SHEET_ID or not GOOGLE_CREDENTIALS_JSON:
raise RuntimeError("SHEET_ID and GOOGLE_CREDENTIALS_JSON must be set in Space secrets.")
info = json.loads(GOOGLE_CREDENTIALS_JSON)
creds = Credentials.from_service_account_info(
info, scopes=["https://www.googleapis.com/auth/spreadsheets"]
)
return gspread.authorize(creds)
def _ws():
sh = _gc().open_by_key(SHEET_ID)
try:
return sh.worksheet(SHEET_NAME)
except Exception:
return sh.sheet1
def _header_index(ws, header: str) -> int:
headers = ws.row_values(1)
if header not in headers:
raise RuntimeError(f"Missing column header in sheet: {header}")
return headers.index(header) + 1 # 1-based
def pull_prompt_from_row(row_number: int) -> Tuple[str, str]:
"""Returns (ambience, scenes_block) from the sheet row (1-based)."""
ws = _ws()
records = ws.get_all_records()
if row_number < 1 or row_number > len(records):
raise ValueError(f"Row {row_number} is out of range (1–{len(records)}).")
row = records[row_number - 1]
amb = (row.get(COL_AMBIENCE) or "").strip()
scn = (row.get(COL_SCENES) or "").strip()
return amb, scn
def write_links_to_row(row_number: int, links: List[str], mark_done: bool = True):
ws = _ws()
col_links = _header_index(ws, COL_LINKS_OUT)
ws.update_cell(row_number, col_links, ", ".join(links))
if mark_done:
col_asst = _header_index(ws, COL_ASSISTANT)
ws.update_cell(row_number, col_asst, "Done")
# =========================
# Prompt parsing / building
# =========================
COLOR_RE = re.compile(r"\(Color Theme:\s*([^)]+)\)", re.IGNORECASE)
SCENE_SPLIT_RE = re.compile(r"(?=^Scene\s*\d+\s*[-–])", re.IGNORECASE | re.MULTILINE)
def parse_color(ambience: str) -> str:
m = COLOR_RE.search(ambience or "")
return (m.group(1).strip() if m else "").strip()
def split_scenes(scenes_block: str) -> List[str]:
"""Splits your multi-scene block into a list of per-scene text chunks."""
if not scenes_block.strip():
return []
chunks = SCENE_SPLIT_RE.split(scenes_block.strip())
chunks = [c.strip() for c in chunks if c.strip()]
return chunks[:5] # we only need 5
def anime_style_preset(ambience: str, scene_chunk: str) -> str:
"""
Builds a strong anime/manga cinematic prompt for a single scene.
Uses color theme if present and merges ambience + scene content.
"""
color = parse_color(ambience)
palette_hint = f"limited palette, monochrome tint ({color})" if color else "limited palette, monochrome tint"
base_style = (
"anime cinematic still, manga panel composition, soft watercolor shading, fine grain, "
"clean lineart, gentle halftone texture, subtle film look, cozy lo-fi tone, "
"speech bubbles integrated, panel captions integrated, text shapes (NOT readable words), "
"East Asian character design, cinematic intimacy, shallow depth of field, "
f"{palette_hint}"
)
# Keep ambience body (style/tone paragraph) but remove explicit (Color Theme: ...)
ambience_body = COLOR_RE.sub("", ambience).strip()
return (
f"{base_style}\n\n"
f"Global ambience: {ambience_body}\n\n"
f"Scene details: {scene_chunk}\n"
"Render as one cohesive illustration."
)
# =========================
# Pipeline / ZeroGPU
# =========================
_pipe_cache = {}
_hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
def _get_pipe(model_name: str) -> DiffusionPipeline:
device = "cuda" if torch.cuda.is_available() else "cpu"
key = (model_name, device)
if key not in _pipe_cache:
dtype = torch.float16 if device == "cuda" else torch.float32
pipe = DiffusionPipeline.from_pretrained(model_name, torch_dtype=dtype)
pipe.to(device)
pipe.enable_attention_slicing()
_pipe_cache[key] = pipe
return _pipe_cache[key]
@spaces.GPU
async def generate_one(
model_name: str,
prompt: str,
negative: str,
width: int,
height: int,
steps: int,
cfg: float,
seed: int,
) -> Image.Image:
pipe = _get_pipe(model_name)
if seed == -1:
seed = int(torch.seed())
generator = torch.Generator(device=pipe.device).manual_seed(int(seed))
# run in a thread to avoid blocking the event loop
result = await asyncio.to_thread(
pipe,
prompt=prompt,
negative_prompt=negative,
num_inference_steps=int(steps),
guidance_scale=float(cfg),
width=int(width),
height=int(height),
generator=generator,
)
return result.images[0]
async def generate_five(
model_name: str,
ambience: str,
scenes_block: str,
negative: str,
width: int,
height: int,
steps: int,
cfg: float,
seed: int,
) -> List[Image.Image]:
scenes = split_scenes(scenes_block)
if not scenes:
# fallback to 5 copies of ambience if scenes are missing
scenes = [f"Scene {i+1}: (no scene provided) intimate vignette." for i in range(5)]
prompts = [anime_style_preset(ambience, sc) for sc in scenes]
# Make 5 tasks (one per scene). Use fixed seeds if user provided; else unique.
seeds = [(seed if seed != -1 else int(torch.seed())) + i for i in range(5)]
tasks = [
generate_one(model_name, prompts[i], negative, width, height, steps, cfg, seeds[i])
for i in range(5)
]
return await asyncio.gather(*tasks)
# =========================
# Persist results to Space
# =========================
def persist_images(images: List[Image.Image], row_number: int) -> List[str]:
"""Uploads images into the Space repo and returns URL list."""
if not _hf_api:
return []
urls = []
for i, img in enumerate(images, start=1):
path = f"outputs/row-{row_number}/img-{i}.png"
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
img.save(tmp.name, format="PNG")
_hf_api.upload_file(
path_or_fileobj=tmp.name,
path_in_repo=path,
repo_id=SPACE_ID,
repo_type="space",
)
urls.append(f"https://huggingface.co/spaces/{SPACE_ID}/resolve/main/{path}")
return urls
# =========================
# Gradio UI
# =========================
def ui_pull(row):
try:
amb, scn = pull_prompt_from_row(int(row))
combined = (amb + "\n\n" + scn).strip()
return gr.update(value=amb, lines=min(10, amb.count("\n")+4)), gr.update(value=scn, lines=min(16, scn.count("\n")+6))
except Exception as e:
err = f"❌ {e}"
return gr.update(value=err), gr.update(value="")
async def ui_run(
model_name, ambience, scenes_block, negative,
width, height, steps, cfg, seed, row_number, save_to_sheet
):
imgs = await generate_five(
model_name=model_name,
ambience=ambience,
scenes_block=scenes_block,
negative=negative,
width=width,
height=height,
steps=steps,
cfg=cfg,
seed=int(seed),
)
msg = ""
if save_to_sheet and int(row_number) > 0:
links = persist_images(imgs, int(row_number))
if links:
write_links_to_row(int(row_number), links, mark_done=True)
msg = f"βœ… Saved & wrote {len(links)} URLs to '{COL_LINKS_OUT}' (row {int(row_number)})"
else:
msg = "⚠️ Generated images but did not save (missing HF_TOKEN?)"
return imgs, msg
with gr.Blocks(title="Loomvale Image Lab β€” SDXL cinematic (anime)") as demo:
gr.Markdown("## 🎨 Loomvale Image Lab β€” SDXL cinematic generator (anime, 5 scenes β†’ 5 images)")
with gr.Row():
model_name = gr.Dropdown(choices=MODEL_CHOICES, value=DEFAULT_MODEL, label="Model")
row_number = gr.Number(label="Google Sheet row (1-based)", value=2, precision=0)
pull_btn = gr.Button("πŸ“„ Pull from Google Sheet")
ambience = gr.Textbox(
label="ImagePrompt_Ambience",
placeholder="Paste your ambience (starts with `(Color Theme: …)` + overall style text)",
lines=8,
)
scenes_block = gr.Textbox(
label="ImagePrompt_Scenes (multi-scene block)",
placeholder="Scene 1 – β€œβ€¦β€\nVisual: …\nMood: …\nText: …\n\nScene 2 – …\n…",
lines=16,
)
negative = gr.Textbox(label="Negative prompt", value=NEGATIVE_DEFAULT)
with gr.Row():
width = gr.Slider(640, 1536, 1024, step=8, label="Width")
height = gr.Slider(768, 1664, 1344, step=8, label="Height")
with gr.Row():
steps = gr.Slider(1, 60, 28, step=1, label="Steps")
cfg = gr.Slider(0, 12, 6.5, step=0.1, label="Guidance (CFG)")
seed = gr.Number(value=-1, label="Seed (-1=random)", precision=0)
save_to_sheet = gr.Checkbox(label=f"Write links to Sheet column β€œ{COL_LINKS_OUT}” and mark Assistant=Done", value=True)
run_btn = gr.Button("🎬 Generate 5 images (one per scene)")
gallery = gr.Gallery(label="Output", columns=5, height=720)
status = gr.Markdown()
pull_btn.click(fn=ui_pull, inputs=row_number, outputs=[ambience, scenes_block])
run_btn.click(
fn=ui_run,
inputs=[model_name, ambience, scenes_block, negative, width, height, steps, cfg, seed, row_number, save_to_sheet],
outputs=[gallery, status],
)
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), max_threads=20)