Theloomvale commited on
Commit
ef904ea
·
verified ·
1 Parent(s): e6939d7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +308 -183
app.py CHANGED
@@ -1,232 +1,357 @@
1
- # app.py
2
- # Loomvale Image Lab – SDXL prompt runner (fits n8n + Google Sheet pipeline)
3
- #
4
- # Inputs match the order used in the Gradio /api/predict endpoint:
5
- # 0) model_key
6
- # 1) prompt
7
- # 2) negative
8
- # 3) width
9
- # 4) height
10
- # 5) steps
11
- # 6) guidance
12
- # 7) images_per_prompt
13
- # 8) seed (or None/-1 for random)
14
- # 9) use_lcm (bool)
15
-
16
  import os
17
- from functools import lru_cache
18
- from typing import List, Optional
 
 
 
 
19
 
20
  import gradio as gr
 
21
  from PIL import Image
22
- import torch
23
 
 
24
  from diffusers import (
25
  StableDiffusionXLPipeline,
26
- DPMSolverMultistepScheduler,
27
- EulerAncestralDiscreteScheduler,
28
  LCMScheduler,
29
  )
30
 
31
- SPACE_TITLE = "Loomvale Image Lab"
32
- DEFAULT_NEGATIVE = (
33
- "text, watermark, signature, logo, jpeg artifacts, lowres, blurry, oversharp, "
34
- "deformed, extra fingers, extra limbs, bad hands, bad anatomy, duplicate, worst quality"
35
- )
36
-
37
- # ---- Models ---------------------------------------------------------------
38
-
39
- MODEL_MAP = {
40
- # Default painterly / versatile (anime+semi-real)
41
- "SDXL Base 1.0 (stabilityai/stable-diffusion-xl-base-1.0)": {
42
- "repo": "stabilityai/stable-diffusion-xl-base-1.0",
43
- "variant": "fp16",
44
- "scheduler": "dpmpp",
45
- "hint": "Use steps ~24–36, guidance 5.5–7.5",
46
- },
47
- # Very fast drafts
48
- "SDXL Turbo (stabilityai/sdxl-turbo)": {
49
- "repo": "stabilityai/sdxl-turbo",
50
- "variant": "fp16",
51
- "scheduler": "euler_a",
52
- "hint": "Use steps 1–4, guidance 0.5–2.0",
53
- },
54
- # Photoreal leaning XL (popular community model)
55
- "Realistic Vision XL (photoreal)": {
56
- "repo": "SG161222/RealVisXL_V4.0",
57
- "variant": "fp16",
58
- "scheduler": "dpmpp",
59
- "hint": "Use steps ~25–40, guidance 4.5–7.0",
60
- },
61
- }
62
-
63
- def _device_dtype():
64
- if torch.cuda.is_available():
65
- return "cuda", torch.float16
66
- elif torch.backends.mps.is_available():
67
- return "mps", torch.float16
68
- return "cpu", torch.float32
69
-
70
- DEVICE, DTYPE = _device_dtype()
71
-
72
-
73
- @lru_cache(maxsize=3)
74
- def load_pipeline(model_key: str) -> StableDiffusionXLPipeline:
75
- spec = MODEL_MAP[model_key]
76
- repo_id = spec["repo"]
77
- variant = spec.get("variant", None)
78
-
79
- pipe = StableDiffusionXLPipeline.from_pretrained(
80
- repo_id,
81
- torch_dtype=DTYPE,
82
- use_safetensors=True,
83
- add_watermarker=False,
84
- variant=variant,
85
- )
86
-
87
- # default scheduler
88
- sched = spec.get("scheduler", "dpmpp")
89
- if sched == "dpmpp":
90
- pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
91
- elif sched == "euler_a":
92
- pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)
93
-
94
- pipe = pipe.to(DEVICE)
95
- return pipe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
 
 
 
 
 
 
97
 
98
- def apply_lcm(pipe: StableDiffusionXLPipeline) -> StableDiffusionXLPipeline:
99
- pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
100
  return pipe
101
 
 
 
 
 
 
102
 
103
- def _seed_to_generator(seed: Optional[int]) -> Optional[torch.Generator]:
104
- if seed is None or seed == -1:
105
- return None
106
- g = torch.Generator(device=DEVICE)
107
- g.manual_seed(int(seed))
108
- return g
109
-
110
 
111
- def run_infer(
112
- model_key: str,
113
- prompt: str,
114
- negative: str,
 
 
115
  width: int,
116
  height: int,
117
  steps: int,
118
- guidance: float,
119
- images_per_prompt: int,
120
- seed: Optional[int],
121
- use_lcm: bool,
122
  ) -> List[Image.Image]:
123
-
124
- assert width % 64 == 0 and height % 64 == 0, "Width/Height must be divisible by 64"
125
-
126
- pipe = load_pipeline(model_key)
127
- pipe.set_progress_bar_config(disable=True)
128
-
129
- # LCM toggle
130
- pipe_to_use = apply_lcm(pipe) if use_lcm else pipe
131
-
132
- # Turbo best practice: very low guidance, very few steps
133
- if "sdxl-turbo" in MODEL_MAP[model_key]["repo"]:
134
- guidance = max(0.0, min(guidance, 2.0))
135
- steps = max(1, min(steps, 6))
136
-
137
- generator = _seed_to_generator(seed)
138
-
139
- # Do the inference
140
- with torch.inference_mode():
141
- out = pipe_to_use(
142
- prompt=prompt,
143
- negative_prompt=negative or DEFAULT_NEGATIVE,
 
 
 
 
 
 
 
144
  width=width,
145
  height=height,
146
  num_inference_steps=steps,
147
- guidance_scale=guidance,
148
- generator=generator,
149
- num_images_per_prompt=images_per_prompt,
150
  )
151
-
152
- images: List[Image.Image] = out.images
 
153
  return images
154
 
 
 
 
 
 
 
155
 
156
- # ---- Gradio UI ------------------------------------------------------------
157
-
158
- def ui_predict(
159
- model_key: str,
160
- prompt: str,
161
- negative: str,
162
- width: int,
163
- height: int,
164
- steps: int,
165
- guidance: float,
166
- images_per_prompt: int,
167
- seed: int,
168
- use_lcm: bool,
169
- ):
170
- seed_val = None if seed in (-1, None) else seed
171
- imgs = run_infer(
172
- model_key=model_key,
173
- prompt=prompt.strip(),
174
- negative=negative.strip(),
175
- width=width,
176
- height=height,
177
- steps=steps,
178
- guidance=guidance,
179
- images_per_prompt=images_per_prompt,
180
- seed=seed_val,
181
- use_lcm=use_lcm,
182
- )
183
- return imgs
184
-
185
-
186
- with gr.Blocks(title=SPACE_TITLE, fill_height=True) as demo:
187
- gr.Markdown(f"## {SPACE_TITLE} — SDXL cinematic generator\n"
188
- "Paste the prompt built from your Google Sheet "
189
- "(**ImagePrompt_Ambience + ImagePrompt_Scenes**) then hit **Run**. "
190
- "The API is available at `/api/predict/` for n8n.")
191
-
192
  with gr.Row():
193
- model_key = gr.Dropdown(
194
- list(MODEL_MAP.keys()),
195
- value="SDXL Base 1.0 (stabilityai/stable-diffusion-xl-base-1.0)",
196
- label="Model",
197
- )
198
  use_lcm = gr.Checkbox(value=False, label="Use LCM Scheduler (faster)")
199
 
 
 
 
200
  prompt = gr.Textbox(
 
201
  label="Prompt (Ambience + 5 Scenes; literal dialogue allowed)",
202
- placeholder="e.g., Color theme: Mizu blue… stylized dialogue bubbles (blank)…",
203
- lines=10,
204
  )
205
  negative = gr.Textbox(
206
- label="Negative prompt",
207
  value=DEFAULT_NEGATIVE,
208
- lines=2,
209
  )
210
 
211
  with gr.Row():
212
- width = gr.Slider(640, 1536, value=1024, step=64, label="Width")
213
- height = gr.Slider(768, 1664, value=1344, step=64, label="Height")
214
 
215
  with gr.Row():
216
  steps = gr.Slider(1, 60, value=28, step=1, label="Steps")
217
- guidance = gr.Slider(0.0, 12.0, value=6.5, step=0.1, label="Guidance (CFG)")
218
- images_per_prompt = gr.Slider(1, 5, value=3, step=1, label="Images per prompt")
219
  seed = gr.Number(value=-1, precision=0, label="Seed (-1=random)")
220
 
221
  run_btn = gr.Button("Run", variant="primary")
222
- gallery = gr.Gallery(label="Output", columns=5, height=480)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
  run_btn.click(
225
- ui_predict,
226
- inputs=[model_key, prompt, negative, width, height, steps, guidance, images_per_prompt, seed, use_lcm],
227
  outputs=[gallery],
228
- api_name="predict", # enables /api/predict
229
  )
230
 
231
- if __name__ == "__main__":
232
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import io
3
+ import re
4
+ import json
5
+ import base64
6
+ import random
7
+ from typing import List, Tuple, Optional
8
 
9
  import gradio as gr
10
+ import numpy as np
11
  from PIL import Image
 
12
 
13
+ import torch
14
  from diffusers import (
15
  StableDiffusionXLPipeline,
16
+ AutoPipelineForText2Image,
 
17
  LCMScheduler,
18
  )
19
 
20
+ # ---------- Google Sheets helpers ----------
21
+ SHEET_ID = os.getenv("SHEET_ID", "").strip()
22
+ SHEET_NAME = os.getenv("SHEET_NAME", "Pipeline").strip()
23
+ AMBIENCE_COL = os.getenv("AMBIENCE_COL", "ImagePrompt_Ambience")
24
+ SCENES_COL = os.getenv("SCENES_COL", "ImagePrompt_Scenes")
25
+
26
+ def _get_ws():
27
+ """
28
+ Return a gspread worksheet using service-account JSON from GOOGLE_CREDENTIALS_JSON
29
+ (secret pasted as full JSON).
30
+ """
31
+ if not SHEET_ID:
32
+ raise RuntimeError("Missing SHEET_ID secret.")
33
+ raw = os.getenv("GOOGLE_CREDENTIALS_JSON", "")
34
+ if not raw:
35
+ raise RuntimeError("Missing GOOGLE_CREDENTIALS_JSON secret.")
36
+ try:
37
+ import gspread
38
+ from google.oauth2.service_account import Credentials
39
+ except Exception as e:
40
+ raise RuntimeError("Google dependencies missing: " + str(e))
41
+
42
+ # Accept either raw JSON or base64
43
+ if not raw.strip().startswith("{"):
44
+ raw = base64.b64decode(raw).decode("utf-8")
45
+ info = json.loads(raw)
46
+ scopes = [
47
+ "https://www.googleapis.com/auth/spreadsheets",
48
+ "https://www.googleapis.com/auth/drive",
49
+ ]
50
+ creds = Credentials.from_service_account_info(info, scopes=scopes)
51
+ gc = gspread.authorize(creds)
52
+ sh = gc.open_by_key(SHEET_ID)
53
+ return sh.worksheet(SHEET_NAME)
54
+
55
+ def _header_map(ws) -> dict:
56
+ headers = [h.strip() for h in ws.row_values(1)]
57
+ return {h: i + 1 for i, h in enumerate(headers)}
58
+
59
+ def pull_row_from_sheet(row_number: int) -> str:
60
+ """
61
+ Read a single row (1-based: header is row 1) and build the prompt:
62
+ Ambience + Scenes. Returns a single text blob.
63
+ """
64
+ ws = _get_ws()
65
+ hdr = _header_map(ws)
66
+ if AMBIENCE_COL not in hdr or SCENES_COL not in hdr:
67
+ raise RuntimeError(
68
+ f"Sheet is missing required columns: '{AMBIENCE_COL}' and/or '{SCENES_COL}'."
69
+ )
70
+ values = ws.row_values(row_number)
71
+ def _get(col):
72
+ idx = hdr[col] - 1
73
+ return values[idx] if idx < len(values) else ""
74
+ ambience = (_get(AMBIENCE_COL) or "").strip()
75
+ scenes = (_get(SCENES_COL) or "").strip()
76
+ if not ambience and not scenes:
77
+ raise RuntimeError("Row has empty ambience and scenes.")
78
+ if ambience and scenes:
79
+ return ambience.rstrip() + "\n\n" + scenes.lstrip()
80
+ return ambience or scenes
81
+
82
+ # ---------- Prompt parsing ----------
83
+ SCENE_SPLIT_RE = re.compile(r"(?:^|\n)\s*Scene\s*[1-5]\s*(?:–|-|—)?\s*", re.IGNORECASE)
84
+
85
+ def split_into_scenes(full_text: str) -> List[str]:
86
+ """
87
+ Split a long prompt into up to 5 scene blocks by 'Scene 1 ... Scene 5' headings.
88
+ If not found, treat entire text as a single 'scene'.
89
+ """
90
+ # Keep headings by splitting, then re-attaching labels for clarity
91
+ # First find positions
92
+ matches = list(SCENE_SPLIT_RE.finditer(full_text))
93
+ if not matches:
94
+ return [full_text.strip()] if full_text.strip() else []
95
+
96
+ # Collect segments
97
+ segments = []
98
+ for i, m in enumerate(matches):
99
+ start = m.end()
100
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(full_text)
101
+ chunk = full_text[start:end].strip()
102
+ if chunk:
103
+ segments.append(chunk)
104
+ # Limit to first 5 segments
105
+ return segments[:5]
106
+
107
+ def attach_ambience(ambience: str, scene_texts: List[str]) -> List[str]:
108
+ """
109
+ Prefix each scene with ambience instructions so the style is consistent.
110
+ """
111
+ out = []
112
+ for s in scene_texts:
113
+ if ambience.strip():
114
+ out.append(ambience.strip() + "\n\n" + s.strip())
115
+ else:
116
+ out.append(s.strip())
117
+ return out
118
+
119
+ def parse_manual_prompt(long_text: str) -> Tuple[str, List[str]]:
120
+ """
121
+ Try to separate 'ambience' lines above Scene 1..5.
122
+ If no scene headers, we produce a single scene.
123
+ Return (ambience, scenes_list).
124
+ """
125
+ # Try to split by first "Scene 1"
126
+ m = re.search(r"(?:^|\n)\s*Scene\s*1\b", long_text, flags=re.IGNORECASE)
127
+ if not m:
128
+ return ("", [long_text.strip()] if long_text.strip() else [])
129
+
130
+ ambience = long_text[:m.start()].strip()
131
+ scenes_blob = long_text[m.start():]
132
+ scenes = split_into_scenes(scenes_blob)
133
+ return (ambience, scenes)
134
+
135
+ # ---------- Diffusers model loading ----------
136
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
137
+ DTYPE = torch.float16 if torch.cuda.is_available() else torch.float32
138
+
139
+ DEFAULT_MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
140
+ REAL_XL = "SG161222/Realistic_Vision_V6.0_B1_noVAE"
141
+ TURBO = "stabilityai/sdxl-turbo"
142
+
143
+ PIPE_CACHE = {}
144
+
145
+ def load_pipeline(model_id: str, use_lcm: bool):
146
+ """
147
+ Load and cache a text2img pipeline. Falls back gracefully if a model
148
+ requires a different loader.
149
+ """
150
+ key = (model_id, use_lcm)
151
+ if key in PIPE_CACHE:
152
+ return PIPE_CACHE[key]
153
+
154
+ try:
155
+ # Auto pipeline handles SDXL Base / Turbo / RealisticVision XL
156
+ pipe = AutoPipelineForText2Image.from_pretrained(
157
+ model_id, torch_dtype=DTYPE
158
+ )
159
+ except Exception:
160
+ # fallback to SDXL base
161
+ pipe = StableDiffusionXLPipeline.from_pretrained(
162
+ model_id, torch_dtype=DTYPE
163
+ )
164
 
165
+ if use_lcm:
166
+ try:
167
+ pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
168
+ except Exception:
169
+ # If LCM not compatible, keep default
170
+ pass
171
 
172
+ pipe.to(DEVICE)
173
+ PIPE_CACHE[key] = pipe
174
  return pipe
175
 
176
+ # ---------- Generation ----------
177
+ DEFAULT_NEGATIVE = (
178
+ "text, watermark, signature, logo, jpeg artifacts, lowres, blurry, oversharp, "
179
+ "deformed, extra fingers, extra limbs, bad hands, bad anatomy, duplicate, worst quality"
180
+ )
181
 
182
+ def to_multiple_of_64(x: int) -> int:
183
+ return max(64, int(round(x / 64)) * 64)
 
 
 
 
 
184
 
185
+ @torch.inference_mode()
186
+ def generate_images_for_scenes(
187
+ model_id: str,
188
+ use_lcm: bool,
189
+ ambience_and_scenes_text: str,
190
+ negative_prompt: str,
191
  width: int,
192
  height: int,
193
  steps: int,
194
+ cfg: float,
195
+ seed: int,
 
 
196
  ) -> List[Image.Image]:
197
+ """
198
+ Parse the combined text, produce 1 image per scene (up to 5), total 5 max.
199
+ """
200
+ # Parse manual text into ambience + scenes if it has Scene headers
201
+ ambience, scenes = parse_manual_prompt(ambience_and_scenes_text)
202
+ if not scenes:
203
+ # treat entire text as one scene
204
+ scenes = [ambience_and_scenes_text.strip()]
205
+ ambience = ""
206
+
207
+ scenes = scenes[:5]
208
+ prompts = attach_ambience(ambience, scenes)
209
+
210
+ width = to_multiple_of_64(width)
211
+ height = to_multiple_of_64(height)
212
+ gen = torch.Generator(device=DEVICE)
213
+ if seed is None or seed < 0:
214
+ seed = random.randint(0, 2**31 - 1)
215
+ gen = gen.manual_seed(seed)
216
+
217
+ pipe = load_pipeline(model_id, use_lcm)
218
+
219
+ images = []
220
+ for i, ptxt in enumerate(prompts, start=1):
221
+ # SDXL Turbo prefers low steps; we still honor the UI value
222
+ out = pipe(
223
+ prompt=ptxt,
224
+ negative_prompt=negative_prompt or DEFAULT_NEGATIVE,
225
  width=width,
226
  height=height,
227
  num_inference_steps=steps,
228
+ guidance_scale=cfg,
229
+ generator=gen,
 
230
  )
231
+ img = out.images[0]
232
+ # add tiny label in metadata for scene index
233
+ images.append(img)
234
  return images
235
 
236
+ # ---------- Gradio UI + API ----------
237
+ MODEL_CHOICES = [
238
+ DEFAULT_MODEL,
239
+ TURBO,
240
+ REAL_XL,
241
+ ]
242
 
243
+ with gr.Blocks(title="Loomvale Image Lab") as demo:
244
+ gr.Markdown("## Loomvale Image Lab — SDXL cinematic generator")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  with gr.Row():
246
+ model = gr.Dropdown(MODEL_CHOICES, value=DEFAULT_MODEL, label="Model")
 
 
 
 
247
  use_lcm = gr.Checkbox(value=False, label="Use LCM Scheduler (faster)")
248
 
249
+ with gr.Row():
250
+ sheet_row = gr.Number(value=2, precision=0, label="Sheet row (1-based)")
251
+ pull_btn = gr.Button("Pull from Google Sheet")
252
  prompt = gr.Textbox(
253
+ lines=12,
254
  label="Prompt (Ambience + 5 Scenes; literal dialogue allowed)",
255
+ placeholder='e.g., Color theme: Mizu blue… stylized dialogue bubbles (blank)…',
 
256
  )
257
  negative = gr.Textbox(
 
258
  value=DEFAULT_NEGATIVE,
259
+ label="Negative prompt",
260
  )
261
 
262
  with gr.Row():
263
+ width = gr.Slider(640, 1536, value=1024, step=1, label="Width")
264
+ height = gr.Slider(768, 1664, value=1344, step=1, label="Height")
265
 
266
  with gr.Row():
267
  steps = gr.Slider(1, 60, value=28, step=1, label="Steps")
268
+ cfg = gr.Slider(0.0, 12.0, value=6.5, step=0.1, label="Guidance (CFG)")
 
269
  seed = gr.Number(value=-1, precision=0, label="Seed (-1=random)")
270
 
271
  run_btn = gr.Button("Run", variant="primary")
272
+ gallery = gr.Gallery(label="Output", columns=5, rows=1, height=420)
273
+
274
+ # Pull handler
275
+ def on_pull(rownum: float):
276
+ try:
277
+ r = int(rownum)
278
+ txt = pull_row_from_sheet(r)
279
+ return gr.update(value=txt), gr.Info(f"Loaded row {r} from '{SHEET_NAME}'.")
280
+ except Exception as e:
281
+ return gr.update(), gr.Error(str(e))
282
+
283
+ pull_btn.click(on_pull, inputs=[sheet_row], outputs=[prompt])
284
+
285
+ # Run handler
286
+ def on_run(model, use_lcm, prompt_text, negative_text, width_v, height_v, steps_v, cfg_v, seed_v):
287
+ try:
288
+ imgs = generate_images_for_scenes(
289
+ model_id=model,
290
+ use_lcm=bool(use_lcm),
291
+ ambience_and_scenes_text=prompt_text or "",
292
+ negative_prompt=negative_text or DEFAULT_NEGATIVE,
293
+ width=int(width_v),
294
+ height=int(height_v),
295
+ steps=int(steps_v),
296
+ cfg=float(cfg_v),
297
+ seed=int(seed_v),
298
+ )
299
+ # Convert to displayable
300
+ return imgs
301
+ except Exception as e:
302
+ gr.Error(str(e))
303
+ return []
304
 
305
  run_btn.click(
306
+ on_run,
307
+ inputs=[model, use_lcm, prompt, negative, width, height, steps, cfg, seed],
308
  outputs=[gallery],
 
309
  )
310
 
311
+ # Lightweight REST API for n8n: POST /api/predict
312
+ from fastapi import FastAPI
313
+ from fastapi.responses import JSONResponse
314
+
315
+ app = gr.mount_gradio_app(FastAPI(), demo, path="/")
316
+
317
+ @app.post("/api/predict")
318
+ async def api_predict(payload: dict):
319
+ try:
320
+ model_id = payload.get("model", DEFAULT_MODEL)
321
+ use_lcm = bool(payload.get("use_lcm", False))
322
+ neg = payload.get("negative_prompt", DEFAULT_NEGATIVE)
323
+ w = int(payload.get("width", 1024))
324
+ h = int(payload.get("height", 1344))
325
+ steps_v = int(payload.get("steps", 28))
326
+ cfg_v = float(payload.get("cfg", 6.5))
327
+ seed_v = int(payload.get("seed", -1))
328
+
329
+ # Either prompt text or a sheet row
330
+ text = payload.get("prompt", "")
331
+ sheet_row_req = payload.get("sheet_row")
332
+ if (not text) and sheet_row_req:
333
+ text = pull_row_from_sheet(int(sheet_row_req))
334
+
335
+ imgs = generate_images_for_scenes(
336
+ model_id=model_id,
337
+ use_lcm=use_lcm,
338
+ ambience_and_scenes_text=text,
339
+ negative_prompt=neg,
340
+ width=w,
341
+ height=h,
342
+ steps=steps_v,
343
+ cfg=cfg_v,
344
+ seed=seed_v,
345
+ )
346
+ # Return as temporary URLs (Gradio serves in-session)
347
+ bufs = []
348
+ for im in imgs:
349
+ bio = io.BytesIO()
350
+ im.save(bio, format="PNG")
351
+ bufs.append(bio.getvalue())
352
+ # Gradio's JSONResponse expects base64 or we can just do one-off data URIs
353
+ # We'll return arrays of base64 PNGs for n8n convenience
354
+ b64s = [base64.b64encode(b).decode("utf-8") for b in bufs]
355
+ return JSONResponse({"images_b64_png": b64s, "count": len(b64s)})
356
+ except Exception as e:
357
+ return JSONResponse({"error": str(e)}, status_code=400)