Spaces:
Sleeping
Sleeping
| import os, time | |
| import gradio as gr | |
| from runninghub_backend import ( | |
| start_runs, poll_many, call_outputs, download_urls_as_files | |
| ) | |
| # ====== Brand & Access Config (ENV) ====== | |
| APP_PASSCODE = os.getenv("APP_PASSCODE", "").strip() # required to unlock app | |
| BRAND_TITLE = os.getenv("BRAND_TITLE", "Tâm hồn vô tri").strip() # title text | |
| AVATAR_URL = os.getenv("AVATAR_URL", "").strip() # avatar image url | |
| # Up to 3 clickable banners (URL + HREF) | |
| BANNER1_URL = os.getenv("BANNER1_URL", "").strip() | |
| BANNER1_HREF = os.getenv("BANNER1_HREF", "").strip() | |
| BANNER2_URL = os.getenv("BANNER2_URL", "").strip() | |
| BANNER2_HREF = os.getenv("BANNER2_HREF", "").strip() | |
| BANNER3_URL = os.getenv("BANNER3_URL", "").strip() | |
| BANNER3_HREF = os.getenv("BANNER3_HREF", "").strip() | |
| # ====== Theme colors (TCI-ish) ====== | |
| PRIMARY_BG = "#0a0b0e" # dark background | |
| CARD_BG = "#0f1115" # card background | |
| BORDER = "#1a1f2b" # borders | |
| TEXT = "#e5e7eb" # text | |
| MUTED = "#9ca3af" # muted text — greenish | |
| ACCENT = "#34d399" # emerald accent | |
| ACCENT_2 = "#22d3ee" # cyan accent | |
| CSS = f""" | |
| :root {{ --bg:{PRIMARY_BG}; --card:{CARD_BG}; --border:{BORDER}; --text:{TEXT}; --muted:{MUTED}; --accent:{ACCENT}; --accent2:{ACCENT_2}; }} | |
| * {{ box-sizing: border-box; }} | |
| html, body {{ background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }} | |
| body {{ font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }} | |
| .container {{ max-width: 1120px; margin: 0 auto; padding: 16px; }} | |
| .row {{ display:flex; gap:16px; align-items:stretch; }} | |
| .col {{ display:flex; flex-direction:column; gap:16px; }} | |
| .card {{ background: var(--card); border:1px solid var(--border); border-radius:16px; padding:16px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }} | |
| .card h3 {{ margin:0 0 8px; font-weight:800; letter-spacing:.2px; }} | |
| .card p {{ margin:0; color: var(--muted); }} | |
| .header {{ display:flex; align-items:center; gap:14px; padding:8px 4px 0; }} | |
| .brand {{ display:flex; flex-direction:column; gap:2px; }} | |
| .brand .title {{ font-size:20px; font-weight:900; letter-spacing:.3px; }} | |
| .brand .subtitle {{ font-size:13px; color:var(--muted); }} | |
| .avatar {{ width:56px; height:56px; border-radius:999px; overflow:hidden; background:rgba(52,211,153,.2); display:flex; align-items:center; justify-content:center; }} | |
| .avatar img {{ width:100%; height:100%; object-fit:cover; display:block; }} | |
| .banners {{ display:grid; grid-template-columns: 1fr; gap:16px; }} | |
| @media (min-width: 900px) {{ .banners {{ grid-template-columns: repeat(3, 1fr); }} }} | |
| .banner {{ position:relative; width:100%; aspect-ratio: 21/9; border-radius:16px; overflow:hidden; border:1px solid var(--border); | |
| background:linear-gradient(135deg, rgba(52,211,153,.08), rgba(34,211,238,.08)); display:block; }} | |
| .banner img {{ width:100%; height:100%; object-fit:cover; display:block; filter:saturate(1.05) contrast(1.02); }} | |
| label, .gr-textbox label, .gr-slider label, .gr-image label, .gr-files label {{ color: var(--text) !important; font-weight:600 !important; }} | |
| .gr-textbox textarea, .gr-textbox input, .gr-number input {{ background:#0b0d12 !important; border:1px solid var(--border) !important; border-radius:12px !important; }} | |
| .gr-image {{ background:#0b0d12 !important; border:1px solid var(--border) !important; border-radius:12px !important; }} | |
| .gr-button.primary {{ background: linear-gradient(135deg, var(--accent), var(--accent2)); border:none !important; color:#00221e !important; font-weight:800; border-radius:12px !important; box-shadow:0 8px 30px rgba(34,211,238,.25); }} | |
| .gr-button.primary:hover {{ filter:brightness(1.03); transform: translateY(-1px); }} | |
| #gallery {{ min-height: 560px; }} | |
| #links {{ min-height: 120px; }} | |
| #files {{ min-height: 120px; }} | |
| #gallery .grid-wrap {{ width:100%; }} | |
| .help {{ color:var(--muted); font-size:14px; line-height:1.5; }} | |
| """ | |
| def _banners_html(items): | |
| out = [] | |
| for url, href in items: | |
| if not url: | |
| continue | |
| href = href or "#" | |
| out.append(f'<a class="banner" href="{href}" target="_blank" rel="noopener"><img src="{url}" alt="banner"/></a>') | |
| return f'<div class="banners">{"".join(out)}</div>' if out else "" | |
| def _is_image_url(u: str) -> bool: | |
| return isinstance(u, str) and any(u.lower().endswith(ext) for ext in (".png",".jpg",".jpeg",".webp",".gif")) | |
| def _stream_run(start_image_path, end_image_path, qty, text_input): | |
| if not APP_PASSCODE: | |
| yield [], "**LỖI cấu hình**: Thiếu APP_PASSCODE trong Variables của Space.", "", None | |
| return | |
| if not start_image_path: | |
| yield [], "Vui lòng chọn **Ảnh đầu**.", "", None; return | |
| if not end_image_path: | |
| yield [], "Vui lòng chọn **Ảnh cuối**.", "", None; return | |
| text_input = (text_input or "").strip() | |
| if not text_input: | |
| yield [], "Vui lòng nhập **Ô chữ** (prompt).", "", None; return | |
| try: | |
| qty = int(qty or 1) | |
| if qty < 1: qty = 1 | |
| if qty > 10: qty = 10 | |
| except Exception: | |
| qty = 1 | |
| try: | |
| task_ids = start_runs(start_image_path, end_image_path, qty, text_input) | |
| except Exception as e: | |
| yield [], f"❌ Không khởi chạy được: {e}", "", None | |
| return | |
| pending = set(task_ids) | |
| succeeded, failed = set(), set() | |
| gallery_items, all_links = [], [] | |
| files_saved = None | |
| last_files_emit_at = 0.0; started_at = time.time() | |
| while pending: | |
| done, running, fail = poll_many(list(pending)) | |
| for tid in done: succeeded.add(tid); pending.discard(tid) | |
| for tid in fail: failed.add(tid); pending.discard(tid) | |
| new_urls = [] | |
| for tid in done: | |
| try: | |
| urls = call_outputs(tid) | |
| new_urls.extend(urls) | |
| except Exception: | |
| pass | |
| if new_urls: | |
| for u in new_urls: | |
| if u not in all_links: all_links.append(u) | |
| if _is_image_url(u): gallery_items.append(u) | |
| files_md = None | |
| now = time.time() | |
| if all_links and now - last_files_emit_at > 3: | |
| try: | |
| files_saved = download_urls_as_files(all_links, base_name=text_input) | |
| last_files_emit_at = now | |
| except Exception: | |
| pass | |
| files_md = files_saved if files_saved else None | |
| status = ( | |
| f"Đang xử lý… ✅ xong {len(succeeded)}/{len(task_ids)}" | |
| + (f" · ❌ lỗi {len(failed)}" if failed else "") | |
| + (f" · ⏳ còn {len(pending)}" if pending else "") | |
| ) | |
| links_md = "\n".join(f"- [{u}]({u})" for u in all_links) if all_links else "_Chưa có file_" | |
| files_md = files_md if files_md else None | |
| yield gallery_items, status, links_md, files_md | |
| time.sleep(1.0) | |
| if time.time() - started_at > 3600: | |
| yield gallery_items, "Quá thời gian tối đa 1 giờ, dừng lại.", "\n".join(f"- [{u}]({u})" for u in all_links), (files_saved or None) | |
| return | |
| if all_links and (not files_saved): | |
| try: files_saved = download_urls_as_files(all_links, base_name=text_input) | |
| except Exception: pass | |
| final_status = f"Hoàn tất ✅ {len(succeeded)}/{len(task_ids)}" + (f" · ❌ lỗi {len(failed)}" if failed else "") | |
| final_links = "\n".join(f"- [{u}]({u})" for u in all_links) if all_links else "_Không có file trả về_" | |
| yield gallery_items, final_status, final_links, (files_saved or None) | |
| def _check_pass(pw): | |
| ok = bool(APP_PASSCODE) and (pw.strip() == APP_PASSCODE) | |
| msg = "Đăng nhập thành công ✅" if ok else ("Sai passcode ❌" if APP_PASSCODE else "Thiếu APP_PASSCODE trong Variables của Space ❗") | |
| return msg, gr.update(visible=not ok), gr.update(visible=ok) | |
| with gr.Blocks(css=CSS, title="2 Ảnh + Prompt — Tâm hồn vô tri") as demo: | |
| # ===== LOCK SCREEN ===== | |
| lock_group = gr.Group(visible=True) | |
| with lock_group: | |
| with gr.Column(elem_classes=["container", "card" ]): | |
| gr.Markdown("### 🔒 Nhập passcode để sử dụng ứng dụng") | |
| passbox = gr.Textbox(label="Passcode", type="password", placeholder="Nhập APP_PASSCODE", autofocus=True) | |
| login_btn = gr.Button("Đăng nhập") | |
| login_msg = gr.Markdown("") | |
| # ===== APP UI ===== | |
| app_group = gr.Group(visible=False) | |
| with app_group: | |
| with gr.Column(elem_classes=["container"]): | |
| # Header | |
| with gr.Row(elem_classes=["header"]): | |
| avatar_html = f'<div class="avatar"><img src="{AVATAR_URL}" /></div>' if AVATAR_URL else '<div class="avatar">👤</div>' | |
| gr.HTML(avatar_html) | |
| gr.HTML(f'<div class="brand"><div class="title">{BRAND_TITLE}</div><div class="subtitle">2 ảnh (đầu/cuối) + 1 prompt → RunningHub</div></div>') | |
| # Banners | |
| items = [(BANNER1_URL, BANNER1_HREF), (BANNER2_URL, BANNER2_HREF), (BANNER3_URL, BANNER3_HREF)] | |
| if any(u for u,_ in items): | |
| with gr.Column(elem_classes=["card"]): | |
| gr.HTML(_banners_html(items)) | |
| # IO + Results | |
| with gr.Row(elem_classes=["row"]): | |
| # Left Card: Inputs | |
| with gr.Column(scale=1, min_width=360, elem_classes=["col", "card"]): | |
| img_start = gr.Image(label="Ảnh đầu (bắt buộc)", type="filepath", height=240, image_mode="RGB") | |
| img_end = gr.Image(label="Ảnh cuối (bắt buộc)", type="filepath", height=240, image_mode="RGB") | |
| name_text = gr.Textbox(label="Ô chữ Promt", value="", placeholder="ví dụ: POV go to school") | |
| qty = gr.Slider(label="Số lượng job chạy song song", minimum=1, maximum=10, step=1, value=3) | |
| run_btn = gr.Button("▶️ Chạy song song", elem_classes=["primary"]) # styled by CSS | |
| # Right Card: Results | |
| with gr.Column(scale=2, min_width=600, elem_classes=["col", "card"]): | |
| gallery = gr.Gallery(label="Ảnh trả về (nếu có)", columns=[3], height=560, object_fit="contain", elem_id="gallery") | |
| status = gr.Markdown("", elem_classes=["help"]) | |
| links = gr.Markdown("", elem_id="links") | |
| files = gr.Files(label="File đã tải về (đặt tên theo ô chữ)", elem_id="files") | |
| run_btn.click( | |
| fn=_stream_run, | |
| inputs=[img_start, img_end, qty, name_text], | |
| outputs=[gallery, status, links, files], | |
| api_name=False | |
| ) | |
| # Login wiring | |
| login_btn.click(_check_pass, inputs=[passbox], outputs=[login_msg, lock_group, app_group]) | |
| # Queue | |
| demo.queue(default_concurrency_limit=1, max_size=32, status_update_rate=0.5) | |
| if __name__ == "__main__": | |
| demo.launch(show_api=False, quiet=True) | |