MikaelAthar commited on
Commit
e71a09c
·
verified ·
1 Parent(s): 613215b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +518 -306
app.py CHANGED
@@ -1,335 +1,547 @@
1
- # app.py — Atharium Batch Renderer (CPU, HF Spaces compatible)
2
- # UI & workflow: 2 tab (Prepare Batch, Monitor & Outputs)
3
-
4
- import os, io, json, time, threading, zipfile, glob, uuid
5
- from typing import Dict, Tuple, List, Any, Optional
 
 
 
 
 
 
6
 
7
  import gradio as gr
8
  import pandas as pd
9
- from PIL import Image
10
- import numpy as np
11
-
12
- from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
13
 
14
- # -------------------- Config --------------------
15
  OUTPUT_ROOT = "outputs"
 
16
  os.makedirs(OUTPUT_ROOT, exist_ok=True)
17
-
18
- MODEL_CHOICES = {
19
- "DreamShaper 8 (SD1.5)": "Lykon/DreamShaper-8",
20
- # Tambahin model lain kalau mau, tapi default tetap DS8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
- # “Booster” agar single character + idle + faceless + bg polos (chroma green)
24
- POSITIVE_FORCE = (
25
- "full body solo character, 1 person, single subject, centered, "
26
- "idle stance, neutral pose, arms relaxed by sides, front view, symmetrical, "
27
- "studio shot, high detail outfit and gear, "
28
- "solid chroma key green background, evenly lit, no shadows on background, "
29
- "featureless face, masked face or visor or helmet, minimal facial details"
30
- )
31
-
32
- NEGATIVE_FORCE = (
33
- "multiple people, group, crowd, extra arms, extra legs, extra heads, "
34
- "deformed, mutated, distorted anatomy, blurry, lowres, low quality, "
35
- "busy background, scenery, environment, props, "
36
- "profile view, dynamic action pose, crouching, jumping, running, "
37
- "text, watermark, logo, frame, border"
38
- )
39
-
40
- # -------------------- Globals --------------------
41
- JOBS: Dict[str, Dict[str, Any]] = {}
42
- PIPE_CACHE: Dict[str, StableDiffusionPipeline] = {}
43
- LOCK = threading.Lock()
44
-
45
- # -------------------- Utils --------------------
46
- def _namedfile_to_path(x) -> Optional[str]:
47
- if x is None:
48
- return None
49
- # Gradio dapat mengirim str path / UploadFile-like / NamedString
50
- if isinstance(x, str):
51
- return x
52
- # gradio.types.NamedString / TemporaryFileWrapper: punya .name
53
- name = getattr(x, "name", None)
54
- if isinstance(name, str):
55
- return name
56
- return None
57
-
58
- def _ensure_pipe(model_id: str) -> StableDiffusionPipeline:
59
- if model_id in PIPE_CACHE:
60
- return PIPE_CACHE[model_id]
61
- # CPU pipeline
62
- pipe = StableDiffusionPipeline.from_pretrained(
63
- model_id,
64
- safety_checker=None, # kita enforce via prompt saja; jangan expose konten publik eksplisit
65
- torch_dtype=None # CPU → float32
66
- )
67
- pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
68
- pipe.enable_attention_slicing()
69
- if hasattr(pipe, "enable_vae_slicing"):
70
- pipe.enable_vae_slicing()
71
- PIPE_CACHE[model_id] = pipe
72
- return pipe
73
-
74
- def _chroma_green_to_alpha(img: Image.Image) -> Image.Image:
75
- """Ganti background hijau-chroma menjadi transparan (alpha)."""
76
- img = img.convert("RGBA")
77
- a = np.array(img)
78
- r, g, b = a[:, :, 0].astype(np.int32), a[:, :, 1].astype(np.int32), a[:, :, 2].astype(np.int32)
79
- # deteksi 'hijau kuat' dengan toleransi
80
- green_mask = (g > 120) & (g > r + 30) & (g > b + 30)
81
- # perluas sedikit agar tepi bersih
82
- from scipy.ndimage import binary_dilation # tersedia di HF cpu image
83
- green_mask = binary_dilation(green_mask, iterations=1)
84
- a[:, :, 3] = np.where(green_mask, 0, 255).astype(np.uint8)
85
- return Image.fromarray(a, mode="RGBA")
86
-
87
- def _save_json(path: str, obj: Any):
88
- with open(path, "w", encoding="utf-8") as f:
89
- json.dump(obj, f, ensure_ascii=False, indent=2)
90
-
91
- def _zip_folder(folder: str, zip_path: str):
92
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
93
- for p in sorted(glob.glob(os.path.join(folder, "*"))):
94
- z.write(p, os.path.basename(p))
95
-
96
- def _read_table(path: str, sheet: Optional[str]) -> pd.DataFrame:
97
- if path.lower().endswith(".csv"):
98
- df = pd.read_csv(path)
99
- else:
100
- df = pd.read_excel(path, sheet_name=sheet if sheet else 0)
101
- # normalisasi kolom
102
- cols = {c.strip(): c for c in df.columns}
103
- need = "FinalPrompt"
104
- if need not in cols and need not in df.columns:
105
- # fallback: gabung kolom-kolom umum (Name, Tier, Faction, Region, Role, Lore, Prompt, Style)
106
- parts = []
107
- for c in ["Name","Tier","Faction","Region","Role","Lore","Prompt","Style"]:
108
- if c in df.columns:
109
- parts.append(df[c].astype(str))
110
- if parts:
111
- df["FinalPrompt"] = ((" | ").join([f"{{{c}}}" for c in ["Name","Tier","Faction","Region","Role","Lore","Prompt","Style"] if c in df.columns]))
112
- df["FinalPrompt"] = pd.Series([" | ".join([str(r.get(c,"")) for c in ["Name","Tier","Faction","Region","Role","Lore","Prompt","Style"] if c in df.index]) for _, r in df.iterrows()])
113
- else:
114
- raise ValueError("Tidak menemukan kolom 'FinalPrompt' dan kolom-kolom sumber.")
115
- return df
116
 
117
- def _mk_job(status="queued") -> str:
118
- job_id = f"job-{int(time.time())}-{uuid.uuid4().hex[:8]}"
119
- job_dir = os.path.join(OUTPUT_ROOT, job_id)
120
- os.makedirs(job_dir, exist_ok=True)
121
- with LOCK:
122
- JOBS[job_id] = dict(
123
- id=job_id, dir=job_dir, status=status,
124
- total=0, done=0, last="", error="",
125
- started=time.time(), model="", zip_path=""
126
- )
127
- return job_id
128
-
129
- def job_status(job_id: str) -> Tuple[Tuple[str, str, str, str], List[str]]:
130
- with LOCK:
131
- info = JOBS.get(job_id)
132
- if not info:
133
- return (("unknown","0/0","","Job not found"), [] )
134
- # thumbnails
135
- pngs = sorted(glob.glob(os.path.join(info["dir"], "*_img.png")))
136
- thumbs = pngs[-18:] # ambil terakhir untuk preview
137
- stat = (
138
- info["status"],
139
- f"{info['done']}/{info['total']}",
140
- info["last"],
141
- info.get("error",""),
142
- )
143
- return stat, thumbs
144
 
145
- # -------------------- Worker --------------------
146
- def _render_job(job_id: str, model_id: str, steps: int, guidance: float, w: int, h: int,
147
- seed_base: int, df: pd.DataFrame):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  try:
149
- pipe = _ensure_pipe(model_id)
150
- with LOCK:
151
- JOBS[job_id]["model"] = model_id
152
- JOBS[job_id]["status"] = "running"
153
- JOBS[job_id]["total"] = len(df)
154
-
155
- rng = np.random.RandomState(seed_base if seed_base else None)
156
- for idx, row in df.iterrows():
157
- with LOCK:
158
- if JOBS[job_id].get("status") == "canceled":
159
- break
160
-
161
- base_prompt = str(row.get("FinalPrompt","")).strip()
162
- user_neg = str(row.get("Negative","")).strip()
163
- prompt = f"{base_prompt}, {POSITIVE_FORCE}"
164
- negative = f"{NEGATIVE_FORCE}"
165
- if user_neg:
166
- negative = f"{negative}, {user_neg}"
167
-
168
- # seed per baris
169
- seed = int(seed_base + idx) if seed_base else int(rng.randint(0, 2**31-1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  img = pipe(
172
- prompt=prompt,
173
- negative_prompt=negative,
 
174
  num_inference_steps=int(steps),
175
  guidance_scale=float(guidance),
176
- height=int(h),
177
- width=int(w),
178
- generator=None,
179
  ).images[0]
180
-
181
- # chroma key → alpha
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  try:
183
- img = _chroma_green_to_alpha(img)
184
- except Exception:
185
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- out_dir = JOBS[job_id]["dir"]
188
- out_name = os.path.join(out_dir, f"{idx+1:05d}_img.png")
189
- img.save(out_name)
190
 
191
- with LOCK:
192
- JOBS[job_id]["done"] += 1
193
- JOBS[job_id]["last"] = os.path.basename(out_name)
 
194
 
195
- with LOCK:
196
- if JOBS[job_id]["status"] != "canceled":
197
- JOBS[job_id]["status"] = "done"
 
198
 
199
- # dump job meta
200
- _save_json(os.path.join(JOBS[job_id]["dir"], "job.json"), JOBS[job_id])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
- except Exception as e:
203
- with LOCK:
204
- JOBS[job_id]["status"] = "error"
205
- JOBS[job_id]["error"] = str(e)
206
-
207
- # -------------------- Gradio Callbacks --------------------
208
- def ui_start_batch(xlsx, sheet_name, model_key, steps, guidance, w, h, seed_base):
209
- path = _namedfile_to_path(xlsx)
210
- if not path or not os.path.exists(path):
211
- return "File Excel/CSV tidak ditemukan.", ""
 
 
 
 
 
 
 
 
 
212
  try:
213
- df = _read_table(path, sheet_name)
214
- except Exception as e:
215
- return f"Read table error: {e}", ""
216
-
217
- model_id = MODEL_CHOICES.get(model_key, list(MODEL_CHOICES.values())[0])
218
- jid = _mk_job("queued")
219
- th = threading.Thread(
220
- target=_render_job,
221
- args=(jid, model_id, steps, guidance, w, h, int(seed_base) if str(seed_base).strip() else 0, df),
222
- daemon=True,
223
- )
224
- th.start()
225
- return f"Job started: {jid}", jid
226
-
227
- def ui_cancel_batch(jid):
228
- if not jid:
229
- return "Isi JobID dulu."
230
- with LOCK:
231
- if jid in JOBS:
232
- JOBS[jid]["status"] = "canceled"
233
- return f"Job {jid} ditandai canceled."
234
- return "Job tidak ditemukan."
235
-
236
- def ui_status(jid):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  stat, thumbs = job_status(jid)
238
- s, dt, lastf, err = stat
239
- return s, dt, lastf, err, thumbs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- def ui_zip(jid):
242
- if not jid:
243
- return None
244
- with LOCK:
245
- info = JOBS.get(jid)
246
- if not info:
247
- return None
248
- folder = info["dir"]
249
- zip_path = os.path.join(folder, f"{jid}.zip")
250
- _zip_folder(folder, zip_path)
251
- with LOCK:
252
- JOBS[jid]["zip_path"] = zip_path
253
- return zip_path
254
-
255
- def ui_browse_job(jid):
256
- if not jid:
257
- return []
258
- with LOCK:
259
- info = JOBS.get(jid)
260
- if not info:
261
- return []
262
- return sorted(glob.glob(os.path.join(info["dir"], "*_img.png")))
263
-
264
- # -------------------- UI --------------------
265
- with gr.Blocks(title="Atharium Batch Renderer") as demo:
266
- gr.Markdown("## 🧪 Atharium Batch Renderer (CPU)")
267
-
268
- with gr.Tab("1) Prepare Batch"):
269
- with gr.Group():
270
- xlsx = gr.File(label="Excel/CSV (wajib kolom: FinalPrompt; opsional: Negative)")
271
- sheet_name = gr.Textbox(label="Sheet name (kosongkan untuk sheet pertama)")
272
  with gr.Row():
273
- model = gr.Dropdown(choices=list(MODEL_CHOICES.keys()), value="DreamShaper 8 (SD1.5)", label="Model")
274
- steps = gr.Slider(5, 30, value=18, step=1, label="Steps (CPU optimal)")
275
- guidance = gr.Slider(3, 12, value=6.5, step=0.1, label="Guidance")
 
 
 
 
276
  with gr.Row():
277
- w = gr.Slider(512, 768, value=640, step=64, label="Width")
278
- h = gr.Slider(512, 768, value=640, step=64, label="Height")
279
- seed_base = gr.Number(value=0, precision=0, label="Seed base (0=random)")
280
-
281
- start_btn = gr.Button("Start Batch", variant="primary")
282
- start_msg = gr.Markdown()
283
- job_id_tb = gr.Textbox(label="Current Job ID", interactive=False)
284
-
285
- def _start(*args):
286
- msg, jid = ui_start_batch(*args)
287
- return msg, jid
288
- start_btn.click(
289
- fn=_start,
290
- inputs=[xlsx, sheet_name, model, steps, guidance, w, h, seed_base],
291
- outputs=[start_msg, job_id_tb],
292
- show_progress=False
293
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
- with gr.Tab("2) Monitor & Outputs"):
296
- with gr.Row():
297
- sel_job = gr.Textbox(label="JobID")
298
- cancel_btn = gr.Button("Cancel Batch", variant="stop")
299
- zip_btn = gr.Button("Create ZIP")
300
-
301
- with gr.Row():
302
- stat_status = gr.Textbox(label="Status")
303
- stat_done = gr.Textbox(label="Done/Total")
304
- stat_last = gr.Textbox(label="Last file")
305
- stat_err = gr.Textbox(label="Error (jika ada)")
306
-
307
- preview = gr.Gallery(label="Browse Outputs")
308
- refresh_btn = gr.Button("Refresh Preview")
309
-
310
- zip_file = gr.File(label="ZIP file (download)")
311
-
312
- # events
313
- def _cancel(jid):
314
- return ui_cancel_batch(jid)
315
- cancel_btn.click(fn=_cancel, inputs=[sel_job], outputs=[stat_err], show_progress=False)
316
-
317
- def _status_once(jid):
318
- s, dt, lf, err, thumbs = ui_status(jid)
319
- return s, dt, lf, err, thumbs
320
- refresh_btn.click(fn=_status_once, inputs=[sel_job],
321
- outputs=[stat_status, stat_done, stat_last, stat_err, preview],
322
- show_progress=False)
323
-
324
- def _make_zip(jid):
325
- return ui_zip(jid)
326
- zip_btn.click(fn=_make_zip, inputs=[sel_job], outputs=[zip_file], show_progress=False)
327
-
328
- # juga update otomatis saat JobID diisi
329
- sel_job.change(fn=_status_once, inputs=[sel_job],
330
- outputs=[stat_status, stat_done, stat_last, stat_err, preview],
331
- show_progress=False)
332
-
333
- # Peluncuran (queue tanpa argumen aneh supaya kompatibel Gradio lama)
334
  if __name__ == "__main__":
335
- demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
 
 
1
+ # app.py (patched 2025-08-21)
2
+ # Ultimate Batch Rendering stable UI + resilient worker
3
+ # - Forced: SOLO + IDLE POSE + NO BACKGROUND (transparan)
4
+ # - Optional: Faceless
5
+ # - Solid UI/Workflow: Prepare Batch + Monitor & Outputs
6
+ # - Background worker (resume-ish), Cancel, ZIP, Preview
7
+ # - Gradio 4.x compatible (tanpa .style/.scale chaining)
8
+
9
+ import os, io, json, time, uuid, glob, zipfile, threading, queue
10
+ from datetime import datetime
11
+ from typing import List, Tuple
12
 
13
  import gradio as gr
14
  import pandas as pd
15
+ from PIL import Image, ImageDraw, ImageFont
 
 
 
16
 
17
+ APP_TITLE = "Ultimate Batch Rendering"
18
  OUTPUT_ROOT = "outputs"
19
+ ASSET_IDLE = "assets/idle_pose.png"
20
  os.makedirs(OUTPUT_ROOT, exist_ok=True)
21
+ os.makedirs("assets", exist_ok=True)
22
+
23
+ # ---------------- Model catalog ----------------
24
+ # Beberapa model komunitas TIDAK selalu tersedia dalam format diffusers.
25
+ # Kita siapkan kandidat repo (prioritas kiri ke kanan). Yang tidak bisa diload akan otomatis di-skip.
26
+ MODEL_CANDIDATES = {
27
+ "Stable Diffusion 1.5": ["runwayml/stable-diffusion-v1-5"],
28
+ "DreamShaper 8": ["Lykon/DreamShaper-8"],
29
+ "Counterfeit": ["gsdf/Counterfeit-V3.0", "gsdf/Counterfeit-V2.5"],
30
+ "PastelMix": [
31
+ # Kandidat umum — jika tidak tersedia diffusers, akan auto-skip
32
+ "andite/pastel-mix", "stablediffusionapi/pastel-mix"
33
+ ],
34
+ "ReVAnimated": [
35
+ "stablediffusionapi/rev-animated", # sering ckpt-only; jika gagal akan skip
36
+ "xyn-ai/RevAnimated" # placeholder kandidat; auto-skip jika tak ada
37
+ ],
38
+ "EimisAnime": [
39
+ "eimiss/EimisAnimeDiffusion_1", # jika tidak ada, auto-skip
40
+ "eimiss/EimisAnimeDiffusion" # kandidat alternatif
41
+ ],
42
+ # Tetap sediakan Anything v4.5 (anime aman)
43
+ "Anything v4.5": ["andite/anything-v4.5"],
44
  }
45
 
46
+ # UI dropdown default
47
+ MODEL_DEFAULT = "DreamShaper 8"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ # ---------------- utils ----------------
50
+ def _safe_int(x, dv=0):
51
+ try: return int(x)
52
+ except: return dv
53
+
54
+ def _safe_float(x, dv=7.5):
55
+ try: return float(x)
56
+ except: return dv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ def _save_json(fp, data):
59
+ tmp = fp + ".tmp"
60
+ with open(tmp, "w", encoding="utf-8") as f:
61
+ json.dump(data, f, ensure_ascii=False, indent=2)
62
+ os.replace(tmp, fp)
63
+
64
+ def _read_json(fp, dv=None):
65
+ try:
66
+ with open(fp, "r", encoding="utf-8") as f:
67
+ return json.load(f)
68
+ except:
69
+ return dv
70
+
71
+ def _read_excel(fileobj, sheet_name):
72
+ try:
73
+ if hasattr(fileobj, "read"):
74
+ data = fileobj.read()
75
+ else:
76
+ path = getattr(fileobj, "name", None) or str(fileobj)
77
+ with open(path, "rb") as f:
78
+ data = f.read()
79
+ return pd.read_excel(io.BytesIO(data), sheet_name=sheet_name if sheet_name != "" else 0)
80
+ except Exception as e:
81
+ raise gr.Error(f"Gagal membaca Excel: {e}")
82
+
83
+ def _wrap_text(s, draw, font, maxw):
84
+ words = s.split()
85
+ line, out = "", []
86
+ for w in words:
87
+ test = (line+" "+w).strip()
88
+ if draw.textlength(test, font=font) > maxw and line:
89
+ out.append(line); line = w
90
+ else:
91
+ line = test
92
+ if line: out.append(line)
93
+ return out
94
+
95
+ def _dummy_image(text, size=(768,1024), transparent=False):
96
+ mode = "RGBA" if transparent else "RGB"
97
+ bg = (0,0,0,0) if transparent else (242,242,242)
98
+ img = Image.new(mode, size, bg)
99
+ d = ImageDraw.Draw(img)
100
  try:
101
+ font = ImageFont.truetype("DejaVuSans.ttf", 22)
102
+ except:
103
+ font = ImageFont.load_default()
104
+ margin, y = 20, 20
105
+ for line in _wrap_text(text, d, font, size[0]-2*margin):
106
+ d.text((margin,y), line, fill=(30,30,30,255), font=font)
107
+ y += 26
108
+ if y > size[1]-40: break
109
+ footer = "[SOLO • IDLE • TRANSPARENT BG]"
110
+ d.text((margin, size[1]-30), footer, fill=(90,90,90,255), font=font)
111
+ return img
112
+
113
+ # ---------------- diffusers (optional) ----------------
114
+ _PIPE_CACHE = {}
115
+ _PIPE_LOCK = threading.Lock()
116
+
117
+ def _resolve_repo(model_key: str) -> str:
118
+ """Ambil repo pertama yang berhasil di-load; jika semua gagal, fallback ke SD1.5."""
119
+ candidates = MODEL_CANDIDATES.get(model_key, []) or MODEL_CANDIDATES["Stable Diffusion 1.5"]
120
+ # Cek cache yang pernah sukses
121
+ for rid in candidates:
122
+ if ("ok_repo", rid) in _PIPE_CACHE:
123
+ return rid
124
+ # Kalau belum ada, coba satu-satu secara ringan via from_pretrained (ditangani di _get_pipe)
125
+ return candidates[0]
126
+
127
+ def _get_pipe(model_key: str, device="cpu"):
128
+ # pilih repo kandidat
129
+ repo_id = _resolve_repo(model_key)
130
+ with _PIPE_LOCK:
131
+ key = (repo_id, device)
132
+ if key in _PIPE_CACHE:
133
+ return _PIPE_CACHE[key]
134
+
135
+ # coba load diffusers; jika gagal, fallback try kandidat lain; terakhir fallback SD1.5
136
+ try:
137
+ from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
138
+ import torch
139
+ except Exception:
140
+ # diffusers tidak tersedia → pakai dummy
141
+ return None
142
+
143
+ candidates = MODEL_CANDIDATES.get(model_key, []) or MODEL_CANDIDATES["Stable Diffusion 1.5"]
144
+ tried = []
145
+ for rid in candidates + MODEL_CANDIDATES["Stable Diffusion 1.5"]:
146
+ if rid in tried: # hindari duplikat
147
+ continue
148
+ tried.append(rid)
149
+ try:
150
+ pipe = StableDiffusionPipeline.from_pretrained(
151
+ rid,
152
+ torch_dtype=torch.float16 if device.startswith("cuda") else torch.float32,
153
+ safety_checker=None,
154
+ )
155
+ # scheduler yang cepat & stabil
156
+ try:
157
+ pipe.scheduler = DPMSolverMultistepScheduler.from_config(
158
+ pipe.scheduler.config, use_karras_sigmas=True
159
+ )
160
+ except Exception:
161
+ pass
162
+ pipe.enable_attention_slicing()
163
+ pipe = pipe.to(device)
164
+ _PIPE_CACHE[key] = pipe
165
+ _PIPE_CACHE[("ok_repo", rid)] = True # tandai repo sukses
166
+ return pipe
167
+ except Exception:
168
+ continue
169
+
170
+ # semua gagal
171
+ return None
172
 
173
+ def _to_transparent_best_effort(img: Image.Image) -> Image.Image:
174
+ """
175
+ Usaha terbaik bikin BG transparan.
176
+ 1) Jika rembg tersedia, pakai rembg.
177
+ 2) Jika tidak, chroma-key corner (warna dominan di (0,0)).
178
+ """
179
+ try:
180
+ from rembg import remove as rembg_remove
181
+ arr = rembg_remove(img.convert("RGBA"))
182
+ if isinstance(arr, bytes):
183
+ from io import BytesIO
184
+ return Image.open(BytesIO(arr))
185
+ return Image.open(arr.fp)
186
+ except Exception:
187
+ pass
188
+
189
+ # Fallback: chroma key corner
190
+ img = img.convert("RGBA")
191
+ corner = img.getpixel((0,0))[:3]
192
+ px = img.getdata()
193
+ new = []
194
+ thr = 6
195
+ for r,g,b,a in px:
196
+ if abs(r-corner[0])<thr and abs(g-corner[1])<thr and abs(b-corner[2])<thr:
197
+ new.append((0,0,0,0))
198
+ else:
199
+ new.append((r,g,b,a))
200
+ img.putdata(new)
201
+ return img
202
+
203
+ def _render_image(prompt, negative, w, h, steps, guidance, seed, model_key, faceless, device="cpu"):
204
+ """
205
+ Enforce:
206
+ - SOLO
207
+ - IDLE POSE
208
+ - TRANSPARENT BACKGROUND (selalu)
209
+ """
210
+ # Hints dipaksakan
211
+ pose_hint = "solo, full body, idle stance, neutral standing, arms relaxed by sides, front view, symmetrical"
212
+ face_hint = "featureless face, masked face, visor, minimal facial details" if faceless else ""
213
+ bg_hint = "simple studio background"
214
+
215
+ # Final positive (dibatasi agar tidak over panjang token)
216
+ base_pieces = [prompt, pose_hint, bg_hint, face_hint]
217
+ final_p = ", ".join([p for p in base_pieces if p]).strip(", ")[:300]
218
+
219
+ # Negative — keras untuk cegah multi-person & cacat
220
+ base_neg = (
221
+ "(multiple people:1.4), two persons, group, crowd, duplicate, clone, "
222
+ "extra head, extra body, extra arms, extra legs, siamese, mutated, deformed, bad anatomy, bad hands, "
223
+ "text, watermark, logo, caption, frame, lowres, blurry"
224
+ )
225
+ if faceless:
226
+ base_neg += ", detailed face, realistic face, expressive face, eyes, nose, mouth, teeth, smile"
227
+ if negative:
228
+ base_neg += ", " + negative
229
+ neg_final = base_neg[:300]
230
+
231
+ # Coba diffusers
232
+ pipe = _get_pipe(model_key, device)
233
+ if pipe is not None:
234
+ try:
235
+ import torch
236
+ gen = None
237
+ if seed is not None and seed != -1:
238
+ gen = torch.Generator(device=device).manual_seed(int(seed))
239
  img = pipe(
240
+ prompt=final_p,
241
+ negative_prompt=neg_final,
242
+ width=int(w), height=int(h),
243
  num_inference_steps=int(steps),
244
  guidance_scale=float(guidance),
245
+ generator=gen
 
 
246
  ).images[0]
247
+ # Wajib transparan
248
+ img = _to_transparent_best_effort(img)
249
+ return img
250
+ except Exception as e:
251
+ # jatuh ke dummy
252
+ pass
253
+
254
+ # Dummy fallback jika pipeline gagal
255
+ return _dummy_image(final_p, size=(int(w), int(h)), transparent=True)
256
+
257
+ # ---------------- background worker ----------------
258
+ class Worker(threading.Thread):
259
+ def __init__(self):
260
+ super().__init__(daemon=True)
261
+ self.q = queue.Queue()
262
+ self._stop = threading.Event()
263
+
264
+ def submit(self, job_id): self.q.put(job_id)
265
+
266
+ def run(self):
267
+ while not self._stop.is_set():
268
  try:
269
+ jid = self.q.get(timeout=0.5)
270
+ except queue.Empty:
271
+ # auto-resume ringan
272
+ for d in os.listdir(OUTPUT_ROOT):
273
+ meta = _read_json(os.path.join(OUTPUT_ROOT,d,"job_meta.json"))
274
+ if meta and meta.get("status")=="running" and not os.path.exists(os.path.join(OUTPUT_ROOT,d,"_busy.lock")):
275
+ self.q.put(d)
276
+ continue
277
+ try:
278
+ self._run_job(jid)
279
+ except Exception as e:
280
+ _write_error(jid, f"Worker crash: {e}")
281
+ finally:
282
+ self.q.task_done()
283
+
284
+ def _run_job(self, job_id):
285
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
286
+ meta_fp = os.path.join(job_dir, "job_meta.json")
287
+ rows_fp = os.path.join(job_dir, "rows.json")
288
+ params_fp = os.path.join(job_dir, "params.json")
289
+ cancel_fl = os.path.join(job_dir, "cancel.flag")
290
+ busy_fl = os.path.join(job_dir, "_busy.lock")
291
+ open(busy_fl, "w").close()
292
+
293
+ meta = _read_json(meta_fp, {})
294
+ params = _read_json(params_fp, {})
295
+ rows = _read_json(rows_fp, [])
296
+
297
+ meta["status"] = "running"
298
+ meta["total"] = len(rows)
299
+ _save_json(meta_fp, meta)
300
+
301
+ done_prefix = set(os.path.splitext(os.path.basename(p))[0].split("_")[0]
302
+ for p in glob.glob(os.path.join(job_dir,"*.png")))
303
+
304
+ for i, r in enumerate(rows):
305
+ if os.path.exists(cancel_fl):
306
+ meta["status"] = "cancelled"; _save_json(meta_fp, meta); break
307
+
308
+ rid = int(r.get("_rid", i+1))
309
+ if str(rid) in done_prefix or f"{rid:0>5}" in done_prefix:
310
+ continue
311
+
312
+ # Ambil prompt/negative dari kolom
313
+ prompt_col = params.get("prompt_col","FinalPrompt")
314
+ prompt = str(r.get(prompt_col, "")).strip()
315
+ neg = str(r.get(params.get("neg_col",""), "")).strip() if params.get("neg_col") else params.get("negative_prompt","")
316
+
317
+ img = _render_image(
318
+ prompt=prompt, negative=neg,
319
+ w=params.get("width",768), h=params.get("height",1024),
320
+ steps=params.get("steps",18), guidance=params.get("guidance",7.0),
321
+ seed=params.get("seed",-1),
322
+ model_key=params.get("model_key", MODEL_DEFAULT),
323
+ faceless=bool(params.get("faceless", True)),
324
+ device=params.get("device","cpu")
325
+ )
326
 
327
+ fn = f"{rid:0>5}_img.png"; fp = os.path.join(job_dir, fn)
328
+ img.save(fp)
 
329
 
330
+ meta["done"] = len(glob.glob(os.path.join(job_dir,"*.png")))
331
+ meta["last_file"] = fn
332
+ meta["status"] = "running"
333
+ _save_json(meta_fp, meta)
334
 
335
+ if meta.get("status") == "running":
336
+ meta["status"] = "done"; _save_json(meta_fp, meta)
337
+ try: os.remove(busy_fl)
338
+ except: pass
339
 
340
+ def _write_error(job_id, msg):
341
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
342
+ os.makedirs(job_dir, exist_ok=True)
343
+ with open(os.path.join(job_dir,"error.txt"),"a",encoding="utf-8") as f:
344
+ f.write(f"[{datetime.now().isoformat()}] {msg}\n")
345
+ meta_fp = os.path.join(job_dir,"job_meta.json")
346
+ meta = _read_json(meta_fp, {}) or {}
347
+ meta["status"]="error"; _save_json(meta_fp, meta)
348
+
349
+ RUNNER = Worker(); RUNNER.start()
350
+
351
+ # ---------------- job api ----------------
352
+ def new_job_from_excel(xlsx, sheet_name, prompt_col, neg_col,
353
+ model_key, steps, guidance, width, height,
354
+ seed, negative_base, faceless, device):
355
+ if not xlsx: raise gr.Error("Upload Excel dulu.")
356
+ df = _read_excel(xlsx, sheet_name).copy()
357
+ if "_rid" not in df.columns:
358
+ df["_rid"] = list(range(1, len(df)+1))
359
+ rows = df.to_dict(orient="records")
360
+
361
+ job_id = f"job-{int(time.time())}-{uuid.uuid4().hex[:8]}"
362
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
363
+ os.makedirs(job_dir, exist_ok=True)
364
 
365
+ _save_json(os.path.join(job_dir,"rows.json"), rows)
366
+ _save_json(os.path.join(job_dir,"job_meta.json"), {
367
+ "job_id": job_id, "status":"queued", "created_at": datetime.now().isoformat(),
368
+ "total": len(rows), "done": 0, "last_file":""
369
+ })
370
+ _save_json(os.path.join(job_dir,"params.json"), {
371
+ "prompt_col": prompt_col or "FinalPrompt",
372
+ "neg_col": neg_col or "",
373
+ "model_key": model_key or MODEL_DEFAULT,
374
+ "steps": _safe_int(steps, 18), "guidance": _safe_float(guidance, 7.0),
375
+ "width": _safe_int(width, 768), "height": _safe_int(height, 1024),
376
+ "seed": _safe_int(seed, -1),
377
+ "negative_prompt": (negative_base or "").strip(),
378
+ # Forced no background → parameter transparent tidak dipakai (tetap transparan)
379
+ "faceless": bool(faceless),
380
+ "device": device or "cpu",
381
+ })
382
+
383
+ # simpan excel mentah (opsional)
384
  try:
385
+ if hasattr(xlsx, "read"): raw = xlsx.read()
386
+ else:
387
+ path = getattr(xlsx, "name", None) or str(xlsx)
388
+ with open(path,"rb") as f: raw = f.read()
389
+ with open(os.path.join(job_dir,"source.xlsx"),"wb") as f: f.write(raw)
390
+ except: pass
391
+
392
+ RUNNER.submit(job_id)
393
+ return job_id, f"Job dibuat: {job_id}"
394
+
395
+ def cancel_job(job_id):
396
+ if not job_id: return "Pilih Job dulu."
397
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
398
+ if not os.path.isdir(job_dir): return "Job tidak ditemukan."
399
+ open(os.path.join(job_dir,"cancel.flag"),"w").close()
400
+ return f"Cancel dikirim ke {job_id}"
401
+
402
+ def make_zip(job_id):
403
+ if not job_id: raise gr.Error("Pilih Job dulu.")
404
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
405
+ if not os.path.isdir(job_dir): raise gr.Error("Job tidak ditemukan.")
406
+ zip_fp = os.path.join(job_dir, f"{job_id}.zip")
407
+ with zipfile.ZipFile(zip_fp, "w", compression=zipfile.ZIP_DEFLATED) as zf:
408
+ for p in sorted(glob.glob(os.path.join(job_dir,"*.png"))):
409
+ zf.write(p, arcname=os.path.basename(p))
410
+ return zip_fp
411
+
412
+ def list_jobs() -> List[str]:
413
+ res=[]
414
+ for d in sorted(os.listdir(OUTPUT_ROOT)):
415
+ jp = os.path.join(OUTPUT_ROOT, d, "job_meta.json")
416
+ if os.path.exists(jp): res.append(d)
417
+ return res
418
+
419
+ def job_status(job_id):
420
+ """Return (stat, thumbs). stat=(status, 'done/total', last_file, err)"""
421
+ if not job_id:
422
+ return ("unknown", "0/0", "", ""), []
423
+ job_dir = os.path.join(OUTPUT_ROOT, job_id)
424
+ if not os.path.isdir(job_dir):
425
+ return ("unknown", "0/0", "", "job folder not found"), []
426
+ meta = _read_json(os.path.join(job_dir,"job_meta.json"), {}) or {}
427
+ status = meta.get("status","unknown")
428
+ total = int(meta.get("total", 0))
429
+ done = int(meta.get("done", 0))
430
+ lastf = meta.get("last_file","")
431
+ err = ""
432
+ err_fp = os.path.join(job_dir,"error.txt")
433
+ if os.path.exists(err_fp):
434
+ try:
435
+ err = open(err_fp,"r",encoding="utf-8").read().strip()[-900:]
436
+ if err and status not in ("done","cancelled"): status="error"
437
+ except: pass
438
+
439
+ pngs = sorted(glob.glob(os.path.join(job_dir,"*.png")))
440
+ if pngs:
441
+ done = len(pngs)
442
+ lastf = os.path.basename(pngs[-1])
443
+
444
+ thumbs = [(p, os.path.basename(p)) for p in pngs[-120:]]
445
+ return (status, f"{done}/{total}", lastf, err), thumbs
446
+
447
+ # ---------------- UI helpers ----------------
448
+ def _ui_status(jid: str):
449
  stat, thumbs = job_status(jid)
450
+ try:
451
+ s, d, l, e = stat
452
+ except Exception:
453
+ s, d, l, e = "unknown", "0/0", "", "invalid stat"
454
+ return s, d, l, e, thumbs
455
+
456
+ def _refresh_job_list():
457
+ jobs = list_jobs()
458
+ val = jobs[-1] if jobs else None
459
+ return gr.Dropdown(choices=jobs, value=val)
460
+
461
+ def _browse(jid: str):
462
+ if not jid: return []
463
+ return job_status(jid)[1]
464
+
465
+ # ---------------- UI ----------------
466
+ with gr.Blocks(title=APP_TITLE) as demo:
467
+ gr.Markdown(f"# {APP_TITLE}")
468
+
469
+ with gr.Tabs():
470
+ with gr.Tab("1) Prepare Batch"):
471
+ with gr.Row():
472
+ xlsx_in = gr.File(label="Excel (wajib kolom FinalPrompt)", file_count="single", file_types=[".xlsx",".xls"])
473
+ sheet_tb = gr.Textbox(label="Sheet name (kosongkan=sheet pertama)", value="")
474
+ with gr.Row():
475
+ prompt_col_tb = gr.Textbox(label="Kolom Prompt (default=FinalPrompt)", value="FinalPrompt")
476
+ neg_col_tb = gr.Textbox(label="Kolom Negative (opsional)", value="")
477
+ with gr.Row():
478
+ model_dd = gr.Dropdown(label="Model", choices=list(MODEL_CANDIDATES.keys()), value=MODEL_DEFAULT)
479
+ device_dd = gr.Dropdown(label="Device", choices=["cpu","cuda"], value="cpu")
480
+ steps_sl = gr.Slider(10, 50, value=18, step=1, label="Steps")
481
+ guid_sl = gr.Slider(1.0, 15.0, value=7.0, step=0.5, label="Guidance")
482
+ with gr.Row():
483
+ w_nb = gr.Number(label="Width", value=768, precision=0)
484
+ h_nb = gr.Number(label="Height", value=1024, precision=0)
485
+ seed_nb = gr.Number(label="Seed (-1=random per row)", value=-1, precision=0)
486
+ with gr.Row():
487
+ # Checkbox transparan ditampilkan agar UI tetap sama, tapi akan diabaikan (forced transparan)
488
+ transp_cb = gr.Checkbox(label="Transparent background (forced ON)", value=True, interactive=False)
489
+ faceless_cb= gr.Checkbox(label="Faceless (disarankan)", value=True)
490
+ neg_tb = gr.Textbox(label="Negative Prompt dasar (bila kolom Negative kosong)",
491
+ value="lowres, blurry, deformed, bad anatomy, extra limbs, watermark, logo, text")
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  with gr.Row():
494
+ start_btn = gr.Button("Start Batch", variant="primary")
495
+ job_created_md = gr.Markdown()
496
+
497
+ gr.Markdown("**Idle Pose Guide (optional)** — letakkan file `assets/idle_pose.png`.")
498
+ gr.Image(value=ASSET_IDLE if os.path.exists(ASSET_IDLE) else None, label="Idle reference", interactive=False)
499
+
500
+ with gr.Tab("2) Monitor & Outputs"):
501
  with gr.Row():
502
+ job_id_dd = gr.Dropdown(label="Pilih Job", choices=list_jobs(), value=None, allow_custom_value=False)
503
+ refresh_jobs_btn = gr.Button("Refresh daftar Job")
504
+ cancel_btn = gr.Button("Cancel Batch", variant="stop")
505
+ zip_btn = gr.Button("Create ZIP")
506
+ with gr.Row():
507
+ st_out = gr.Textbox(label="Status", interactive=False)
508
+ dn_out = gr.Textbox(label="Done/Total", interactive=False)
509
+ last_out= gr.Textbox(label="Last file", interactive=False)
510
+ err_out = gr.Textbox(label="Error (jika ada)", interactive=False)
511
+ gallery = gr.Gallery(label="Browse Outputs")
512
+ browse_btn = gr.Button("Refresh Preview")
513
+ zip_file = gr.File(label="ZIP (download)")
514
+
515
+ # ---- events
516
+ def _start(xlsx, sheet, pcol, ncol, model_key, device, steps, guide, w, h, seed, neg, _transp_forced, faceless):
517
+ # transparan selalu dipaksa ON; parameter _transp_forced hanya untuk kompat UI
518
+ jid, msg = new_job_from_excel(
519
+ xlsx, sheet, pcol, ncol,
520
+ model_key, steps, guide, int(w), int(h),
521
+ int(seed), neg, bool(faceless), device
522
+ )
523
+ return msg, gr.Dropdown(choices=list_jobs(), value=jid)
524
+
525
+ start_btn.click(
526
+ _start,
527
+ inputs=[xlsx_in, sheet_tb, prompt_col_tb, neg_col_tb, model_dd, device_dd, steps_sl, guid_sl,
528
+ w_nb, h_nb, seed_nb, neg_tb, transp_cb, faceless_cb],
529
+ outputs=[job_created_md, job_id_dd]
530
+ )
531
+
532
+ refresh_jobs_btn.click(_refresh_job_list, outputs=job_id_dd)
533
+ cancel_btn.click(cancel_job, inputs=job_id_dd, outputs=err_out)
534
+ zip_btn.click(make_zip, inputs=job_id_dd, outputs=zip_file)
535
+
536
+ def _status_once(jid):
537
+ if not jid:
538
+ return "idle", "0/0", "", "", []
539
+ return _ui_status(jid)
540
+
541
+ browse_btn.click(lambda jid: _browse(jid) if jid else [], inputs=job_id_dd, outputs=gallery)
542
+ browse_btn.click(_status_once, inputs=job_id_dd, outputs=[st_out, dn_out, last_out, err_out, gallery])
543
+ job_id_dd.change(_status_once, inputs=job_id_dd, outputs=[st_out, dn_out, last_out, err_out, gallery])
544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  if __name__ == "__main__":
546
+ # ssr_mode=False untuk menghindari glitch di beberapa versi Gradio
547
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860, show_error=True, ssr_mode=False)