Spaces:
Starting
on
Zero
Starting
on
Zero
| # 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] | |
| 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) | |