File size: 10,981 Bytes
c2f0237
 
 
 
 
fc6433a
c2f0237
1b86c52
2393a98
c2f0237
e73d18e
 
2393a98
 
c2f0237
 
 
e73d18e
 
 
 
c2f0237
1b86c52
c2f0237
e73d18e
 
 
 
1b86c52
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
1b86c52
c2f0237
 
 
2393a98
c2f0237
 
e73d18e
c2f0237
 
 
e73d18e
0ce7f80
2393a98
e73d18e
ef904ea
e73d18e
 
 
1b86c52
c2f0237
 
 
 
 
2393a98
c2f0237
 
2393a98
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e73d18e
c2f0237
 
 
e73d18e
 
 
1b86c52
 
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b86c52
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e73d18e
c2f0237
 
 
 
 
 
 
 
e73d18e
 
c2f0237
 
 
 
 
 
 
 
 
 
e73d18e
c2f0237
 
 
 
 
 
 
 
 
1b86c52
c2f0237
 
 
e73d18e
 
c2f0237
 
 
e73d18e
c2f0237
 
e73d18e
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e73d18e
c2f0237
 
 
 
 
 
 
e73d18e
1b86c52
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b86c52
c2f0237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e73d18e
c2f0237
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# 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)