Spaces:
Running
Running
| # streamlit_app.py — Fit Studio AI (Fashion & Apparel) v0.7.0 UI | |
| # Dark theme; fixed-size tiles; native downloads + on-the-fly resized downloads from NATIVE (no padding). | |
| # Backend v0.7.0 uyumlu: category-aware video prompt backend’de; frontend category/prompt/duration/aspect gönderir. | |
| # In-memory; yerel dosya yazmaz. HF’de klasör/themes yok. | |
| import os, io, zipfile, base64, requests, streamlit as st, uuid, tempfile, re | |
| from pathlib import Path | |
| from PIL import Image | |
| # ================= Theme (no extra files) ================= | |
| try: | |
| st._config.set_option("theme.base", "dark") | |
| st._config.set_option("theme.primaryColor", "#ff7a1a") | |
| st._config.set_option("theme.backgroundColor", "#0e0f12") | |
| st._config.set_option("theme.secondaryBackgroundColor", "#171a1f") | |
| st._config.set_option("theme.textColor", "#e6e8ea") | |
| except Exception: | |
| pass | |
| # ================= Env & API ================= | |
| def _env(k): return (os.getenv(k) or "").strip().strip("'\"") | |
| API_BASE = ( | |
| _env("FISUITE_API") | |
| or _env("AI_LIGHTBOX_API") | |
| or _env("LUXFIT_API") | |
| ).rstrip("/") if ( | |
| _env("FISUITE_API") or _env("AI_LIGHTBOX_API") or _env("LUXFIT_API") | |
| ) else "" | |
| HF_TOKEN = _env("FISUITE_TOKEN") or _env("AI_LIGHTBOX_TOKEN") or _env("LUXFIT_TOKEN") | |
| HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} | |
| # === Tile geometry (ALL preview cards same size) === | |
| TILE_H = int(os.getenv("TILE_H", "640")) # change via env if needed | |
| TILE_W = max(200, int(round(TILE_H * 9 / 16))) # 16:9 default; keeps uniform layout | |
| PREVIEW_BG = (18, 20, 24) # dark-friendly bg | |
| GIF_MAX_W = int(os.getenv("GIF_MAX_W", "512")) | |
| GIF_FPS = int(os.getenv("GIF_FPS", "12")) | |
| GIF_MAX_FRAMES = GIF_FPS * 12 | |
| # ================= Session State ================= | |
| defaults = { | |
| "results": None, | |
| "job_counter": 0, | |
| "duration": "6", # default duration seconds (string for API) | |
| "rand": uuid.uuid4().hex[:8], | |
| "exports_last": "", | |
| } | |
| for k, v in defaults.items(): | |
| if k not in st.session_state: | |
| st.session_state[k] = v | |
| # ================= Helpers ================= | |
| def _needs_auth(url: str) -> bool: | |
| return (API_BASE and (url.startswith(API_BASE) or url.startswith("outputs/"))) | |
| def fetch_bytes(url_or_path: str | None): | |
| if not url_or_path: | |
| return None | |
| try: | |
| p = Path(url_or_path) | |
| if p.is_file(): | |
| return p.read_bytes() | |
| url = f"{API_BASE}/{url_or_path}" if url_or_path.startswith("outputs/") else url_or_path | |
| headers = (HEADERS if (_needs_auth(url) and HF_TOKEN) else None) | |
| r = requests.get(url, headers=headers, timeout=180) | |
| r.raise_for_status() | |
| return r.content | |
| except Exception: | |
| return None | |
| def image_to_canvas(img_bytes: bytes, w: int = TILE_W, h: int = TILE_H, bg_rgb=PREVIEW_BG) -> bytes: | |
| im = Image.open(io.BytesIO(img_bytes)).convert("RGBA") | |
| iw, ih = im.size | |
| scale = min(w / iw, h / ih) | |
| nw, nh = max(1, int(iw*scale)), max(1, int(ih*scale)) | |
| im_resized = im.resize((nw, nh), Image.LANCZOS) | |
| canvas = Image.new("RGBA", (w, h), (*bg_rgb, 255)) | |
| off = ((w - nw)//2, (h - nh)//2) | |
| canvas.paste(im_resized, off, im_resized) | |
| out = io.BytesIO(); canvas.save(out, format="PNG"); return out.getvalue() | |
| def image_size_from_bytes(b: bytes): | |
| try: | |
| im = Image.open(io.BytesIO(b)); return im.size | |
| except Exception: | |
| return None | |
| def infer_ext(data: bytes) -> str: | |
| try: | |
| im = Image.open(io.BytesIO(data)) | |
| fmt = (im.format or "JPEG").lower() | |
| return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt, "jpg") | |
| except Exception: | |
| return ".jpg" | |
| def unique_key(prefix: str) -> str: | |
| return f"{prefix}_{st.session_state['job_counter']}_{st.session_state['rand']}_{uuid.uuid4().hex[:6]}" | |
| def _b64(data: bytes, mime: str) -> str: | |
| return f"data:{mime};base64," + base64.b64encode(data).decode("ascii") | |
| # ---------- Preset parsing (accepts lowercase/uppercase x, supports A:B@L) ---------- | |
| _SIZE_RE = re.compile(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$") | |
| _ASPECT_RE = re.compile(r"^\s*(\d+)\s*:\s*(\d+)(?:\s*@\s*(\d+))?\s*$") | |
| def _size_from_aspect(ar_w: int, ar_h: int, long_edge: int) -> tuple[int,int]: | |
| aw, ah, L = ar_w, ar_h, long_edge | |
| if ah >= aw: | |
| h = max(64, int(L)); w = max(64, int(round(h * aw / ah))) | |
| else: | |
| w = max(64, int(L)); h = max(64, int(round(w * ah / aw))) | |
| return w, h | |
| def parse_presets_for_resizing(presets_str: str) -> list[tuple[int,int,str]]: | |
| """ | |
| Returns list of (w, h, name). | |
| Supports: | |
| - '800x1200' or '800X1200' | |
| - '9:16@1536' → computed (w,h) | |
| """ | |
| outs = [] | |
| if not presets_str or not isinstance(presets_str, str): | |
| return outs | |
| for tok in [t.strip() for t in presets_str.split(",") if t.strip()]: | |
| m = _SIZE_RE.match(tok) | |
| if m: | |
| w, h = int(m.group(1)), int(m.group(2)) | |
| if w >= 64 and h >= 64: | |
| outs.append((w, h, f"{w}x{h}")) | |
| continue | |
| m = _ASPECT_RE.match(tok) | |
| if m: | |
| aw, ah = int(m.group(1)), int(m.group(2)) | |
| L = int(m.group(3)) if m.group(3) else 1536 | |
| w, h = _size_from_aspect(aw, ah, L) | |
| outs.append((w, h, f"{aw}:{ah}@{L}")) | |
| continue | |
| # dedupe by name | |
| seen = set(); uniq = [] | |
| for w,h,name in outs: | |
| if name not in seen: | |
| seen.add(name); uniq.append((w,h,name)) | |
| return uniq | |
| # ---------- Resize (no padding) = cover + center-crop to EXACT WxH ---------- | |
| def resize_cover_exact(img_bytes: bytes, target_w: int, target_h: int, out_format: str = "JPEG") -> bytes: | |
| im = Image.open(io.BytesIO(img_bytes)).convert("RGB") | |
| iw, ih = im.size | |
| scale = max(target_w / iw, target_h / ih) # cover | |
| nw, nh = max(1, int(round(iw * scale))), max(1, int(round(ih * scale))) | |
| im_resized = im.resize((nw, nh), Image.LANCZOS) | |
| # center crop | |
| left = max(0, (nw - target_w) // 2) | |
| top = max(0, (nh - target_h) // 2) | |
| right, bottom = left + target_w, top + target_h | |
| cropped = im_resized.crop((left, top, right, bottom)) | |
| out = io.BytesIO() | |
| if out_format.upper() == "PNG": | |
| cropped.save(out, format="PNG") | |
| elif out_format.upper() == "WEBP": | |
| cropped.save(out, format="WEBP", quality=95, method=6) | |
| else: | |
| cropped.save(out, format="JPEG", quality=95, subsampling=1) | |
| return out.getvalue() | |
| # -------- Fixed-size IMAGE tile (uniform with video tile) -------- | |
| def show_image_tile(col, title: str, src: str|bytes|None, filename_stub="image", key_prefix=""): | |
| col.subheader(title) | |
| if not src: | |
| from PIL import Image as _PIL | |
| placeholder = _PIL.new("RGBA", (TILE_W, TILE_H), (*PREVIEW_BG, 255)) | |
| buf = io.BytesIO(); placeholder.save(buf, "PNG") | |
| col.image(buf.getvalue(), width=TILE_W) | |
| col.caption("—") | |
| return None, None | |
| b = src if isinstance(src, (bytes, bytearray)) else fetch_bytes(src) | |
| if not b: | |
| col.warning("Image could not be loaded") | |
| return None, None | |
| tile_png = image_to_canvas(b, w=TILE_W, h=TILE_H, bg_rgb=PREVIEW_BG) | |
| col.image(tile_png, width=TILE_W) | |
| sz = image_size_from_bytes(b) | |
| if sz: | |
| col.caption(f"Source: {sz[0]}×{sz[1]} px") | |
| ext = infer_ext(b) | |
| mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp") | |
| col.download_button( | |
| "Download (full quality) — native", | |
| data=b, # native/original bytes | |
| file_name=f"{filename_stub}{ext}", | |
| mime=mime, | |
| key=unique_key(f"{key_prefix}_{filename_stub}_native_dl") | |
| ) | |
| return src, b | |
| # -------- Fixed-size VIDEO tile (uniform with image tile) -------- | |
| def _video_html_src(data_or_url, is_bytes: bool): | |
| src = _b64(data_or_url, "video/mp4") if is_bytes else data_or_url | |
| return f""" | |
| <div style="width:{TILE_W}px; height:{TILE_H}px; display:flex; align-items:center; justify-content:center;"> | |
| <video src="{src}" autoplay muted loop playsinline controls | |
| style="width:100%; height:100%; object-fit:contain; background:#0e0f12;"></video> | |
| </div> | |
| """ | |
| def render_video_placeholder(slot, video_url: str|None, key_prefix=""): | |
| with slot.container(): | |
| st.subheader("Video (loop)") | |
| if not video_url: | |
| st.markdown(f""" | |
| <div style="width:{TILE_W}px; height:{TILE_H}px; display:flex; align-items:center; justify-content:center; background:#0e0f12; color:#777; border-radius:4px;"> | |
| Not generated yet. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return None | |
| vb = fetch_bytes(video_url) | |
| if vb: | |
| st.markdown(_video_html_src(vb, is_bytes=True), unsafe_allow_html=True) | |
| st.download_button( | |
| "Download video (MP4)", | |
| data=vb, | |
| file_name="fitstudio_video.mp4", | |
| mime="video/mp4", | |
| key=unique_key(f"{key_prefix}_video_dl") | |
| ) | |
| return vb | |
| else: | |
| st.markdown(_video_html_src(video_url, is_bytes=False), unsafe_allow_html=True) | |
| return None | |
| def mp4_to_gif_bytes(mp4_bytes: bytes, target_w: int = GIF_MAX_W, fps: int = GIF_FPS, max_frames: int = GIF_MAX_FRAMES) -> bytes: | |
| try: | |
| import imageio.v3 as iio | |
| from PIL import Image as _PILImage | |
| except Exception as e: | |
| raise RuntimeError("imageio.v3 + PIL required for GIF conversion") from e | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as f: | |
| f.write(mp4_bytes); mp4_path = f.name | |
| try: | |
| import imageio.v3 as iio2 | |
| meta = {} | |
| try: meta = iio2.immeta(mp4_path) | |
| except Exception: meta = {} | |
| src_fps = meta.get("fps", fps) | |
| step = max(1, int(round(src_fps / fps))) if isinstance(src_fps, (int, float)) and src_fps > 0 else 1 | |
| except Exception: | |
| step = 1 | |
| frames = [] | |
| try: | |
| for idx, frame in enumerate(iio.imiter(mp4_path)): | |
| if idx % step != 0: continue | |
| im = _PILImage.fromarray(frame) | |
| if im.width > target_w: | |
| new_h = max(1, int(im.height * target_w / im.width)) | |
| im = im.resize((target_w, new_h), Image.LANCZOS) | |
| frames.append(im.convert("P", palette=Image.ADAPTIVE)) | |
| if len(frames) >= max_frames: break | |
| finally: | |
| try: Path(mp4_path).unlink(missing_ok=True) | |
| except Exception: pass | |
| if not frames: raise RuntimeError("No frames decoded for GIF.") | |
| out = io.BytesIO() | |
| frames[0].save(out, format="GIF", save_all=True, append_images=frames[1:], | |
| loop=0, duration=max(10, int(1000 / fps)), disposal=2) | |
| return out.getvalue() | |
| def make_zip(named_bytes): | |
| buf = io.BytesIO() | |
| with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: | |
| for fname, b in named_bytes: | |
| if b: z.writestr(fname, b) | |
| buf.seek(0); return buf.read() | |
| def backend_ok() -> bool: | |
| if not API_BASE: return False | |
| try: | |
| r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10) | |
| r.raise_for_status(); return True | |
| except Exception: | |
| return False | |
| # --------- API calls --------- | |
| def post_image_edit(data: dict, files_payload): | |
| r = requests.post(f"{API_BASE}/v1/image/edit", data=data, files=files_payload or None, | |
| headers=HEADERS if HF_TOKEN else None, timeout=600) | |
| r.raise_for_status(); return r.json() | |
| def post_chain(data: dict, files_payload): | |
| payload = {**data, "to_video": "false"} # chain image step only (no video) | |
| r = requests.post(f"{API_BASE}/v1/tryon/chain", data=payload, files=files_payload or None, | |
| headers=HEADERS if HF_TOKEN else None, timeout=600) | |
| r.raise_for_status(); return r.json() | |
| def post_video(image_url: str, gender="auto", age_group="auto", frame_aspect="9:16", | |
| duration_sec: str | None = None, category: str = "general", prompt: str | None = None): | |
| payload = { | |
| "image_url": image_url, | |
| "gender": gender, | |
| "age_group": age_group, | |
| "frame_aspect": frame_aspect, | |
| "category": category, | |
| } | |
| if duration_sec: payload["duration"] = str(duration_sec) | |
| if prompt: payload["prompt"] = prompt | |
| r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, | |
| headers=HEADERS if HF_TOKEN else None, timeout=600) | |
| r.raise_for_status(); return r.json() | |
| def _video_prompt_override_txt(category: str) -> str: | |
| # Backend zaten category-aware. Buraya manuel override gerekiyorsa ekle. | |
| return "" | |
| # ================= Page ================= | |
| st.set_page_config(page_title="Fit Suite AI — by Vahit FERYAD", layout="wide", page_icon="🧵") | |
| left, right = st.columns([0.7, 0.3], vertical_alignment="center") | |
| with left: | |
| st.title("🧵 Fit Suite AI — Fashion & Apparel") | |
| st.caption("Built by Vahit FERYAD — Demo") | |
| with right: | |
| badge = "🟢 Online" if backend_ok() else "🔴 Offline" | |
| st.metric("Backend", badge) | |
| st.write("Fast, consistent, automated fashion try-on. Virtual looks, real consistency → optional runway-style video.") | |
| if not API_BASE: | |
| st.error("API_BASE not set. Define env FITSTUDIO_API (or AI_LIGHTBOX_API / LUXFIT_API).") | |
| st.stop() | |
| # ================= Controls ================= | |
| c0, c_gender, c_age, c_aspect, c5 = st.columns([0.9, 1.0, 1.1, 1.0, 2.0]) | |
| with c0: category = st.selectbox("Category", ["general","underwear","shoes"], index=0, key="category") | |
| with c_gender: gender = st.selectbox("Gender", ["auto","female","male","unisex"], index=0, key="gender") | |
| with c_age: age_group= st.selectbox("Age group", ["auto","teen","young_adult","adult","mature"], index=0, key="age_group") | |
| with c_aspect: frame_aspect = st.selectbox("Primary aspect", ["9:16","3:4","1:1","4:5","16:9"], index=0, key="frame_aspect") | |
| with c5: | |
| st.write("Export presets (optional)") | |
| presets_quick = st.multiselect( | |
| "Quick add", | |
| [ | |
| "800x1200", "1200x800", "1500x1500", | |
| "9:16@1536", "3:4@1536", "1:1@1500", "4:5@1600", "16:9@1536" | |
| ], | |
| default=[], key="presets_quick" | |
| ) | |
| presets_free = st.text_input( | |
| "Custom (comma-separated, e.g. 800X1200, 1024x768, 4:5@1600)", value=st.session_state.get("exports_last",""), key="presets_free" | |
| ) | |
| def _merge_presets(): | |
| toks = [t.strip() for t in (presets_free or "").split(",") if t.strip()] | |
| toks = toks + [p for p in presets_quick if p not in toks] | |
| s = ",".join(toks) | |
| st.session_state["exports_last"] = s | |
| return s | |
| export_presets_val = _merge_presets() | |
| c2, c3, c4 = st.columns([1.8, 1.4, 1.4]) | |
| with c2: file_list= st.file_uploader("Garment image(s)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl") | |
| with c3: image_url= st.text_input("or Image URL", key="image_url") | |
| with c4: | |
| num_images = st.selectbox("Number of looks", [1,2,3,4], index=0, key="num_images") | |
| custom_prompt = st.text_area("Custom prompt (blank → default)", value="", height=90, key="custom_prompt") | |
| bA, bB = st.columns([1.0, 1.0]) | |
| with bA: st.info(f"API: {API_BASE}", icon="🔌") | |
| with bB: | |
| run_image = st.button("Run (Image only)", type="primary", use_container_width=True, key="run_img_btn") | |
| run_chain = st.button("Chain (Image → prep Video)", use_container_width=True, key="run_chain_btn") | |
| # ================= Demo preview ================= | |
| dp_in, dp_sp, dp_v = st.columns([1,1,1]) | |
| show_image_tile(dp_in, "Input Sample", fetch_bytes("input.jpg"), filename_stub="input", key_prefix="demo_in") | |
| show_image_tile(dp_sp, "Sample Output Preview", fetch_bytes("tryon.jpg"), filename_stub="tryon", key_prefix="demo_out") | |
| demo_vbytes = fetch_bytes("fitstudio_video.mp4") | |
| if demo_vbytes: | |
| dp_v.subheader("AI Generated Video Preview") | |
| dp_v.markdown(_video_html_src(demo_vbytes, is_bytes=True), unsafe_allow_html=True) | |
| # ================= Input preview ================= | |
| st.divider() | |
| g1, _, _, _ = st.columns([1,1,1,1]) | |
| input_preview = None | |
| if file_list: | |
| try: input_preview = file_list[0].getvalue() | |
| except Exception: input_preview = None | |
| elif image_url: | |
| input_preview = fetch_bytes(image_url) | |
| show_image_tile(g1, "Input (preview)", input_preview, filename_stub="preview", key_prefix="preview") | |
| # ================ Run helpers ================ | |
| def _build_files_payload(): | |
| files_payload = [] | |
| if file_list: | |
| for f in file_list: | |
| files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg"))) | |
| return files_payload | |
| def _common_form_data(): | |
| data = { | |
| "category": st.session_state["category"], | |
| "gender": st.session_state["gender"], | |
| "age_group": st.session_state["age_group"], | |
| "frame_aspect": st.session_state["frame_aspect"], | |
| "num_images": str(st.session_state["num_images"]), | |
| } | |
| if st.session_state.get("custom_prompt","").strip(): | |
| data["prompt"] = st.session_state["custom_prompt"].strip() | |
| if image_url: | |
| data["image_urls"] = image_url.strip() | |
| if export_presets_val: | |
| data["export_presets"] = export_presets_val | |
| return data | |
| if run_image or run_chain: | |
| if not backend_ok(): | |
| st.error("Backend not reachable. Check API_BASE / token.") | |
| elif not (file_list or image_url): | |
| st.error("Provide at least one garment image (upload or URL).") | |
| else: | |
| try: | |
| files_payload = _build_files_payload() | |
| form = _common_form_data() | |
| with st.spinner("Running…"): | |
| if run_image: | |
| out = post_image_edit(form, files_payload) | |
| image_json = out | |
| else: | |
| out = post_chain(form, files_payload) # to_video=false | |
| image_json = out.get("image_step") or out | |
| res_block = (image_json.get("result") or {}) | |
| imgs_primary = res_block.get("images_primary") or [] | |
| imgs_norm916 = res_block.get("images_9_16") or [] | |
| imgs_raw = res_block.get("images") or [] | |
| exports_dict = res_block.get("exports") or {} | |
| def _collect_urls(lst): | |
| outu = [] | |
| for it in lst: | |
| u = it.get("url") | |
| if u: outu.append(u) | |
| return outu | |
| urls_primary = _collect_urls(imgs_primary) | |
| urls_norm = _collect_urls(imgs_norm916) | |
| urls_raw = _collect_urls(imgs_raw) | |
| st.session_state["job_counter"] += 1 | |
| st.session_state["results"] = { | |
| "job_id": (image_json.get("job_id") or out.get("job_id")), | |
| "schema_version": (image_json.get("schema_version") or out.get("schema_version")), | |
| "image_urls_primary": urls_primary, | |
| "image_urls_9_16": urls_norm, | |
| "image_urls_raw": urls_raw, # NATIVE images (prefer for video & resizing) | |
| "exports": exports_dict, # name -> list[{url,w,h}] | |
| "video_url": None, | |
| "frame_aspect": st.session_state["frame_aspect"], | |
| } | |
| except requests.HTTPError as e: | |
| st.error(f"HTTP {e.response.status_code}: {(e.response.text or '')[:200]}") | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # ===== Helper: dual tile (primary/9:16/native) ===== | |
| def show_dual_image_tile(col, title: str, | |
| primary_src: str|bytes|None, | |
| norm916_src: str|bytes|None, | |
| native_src: str|bytes|None, | |
| filename_stub="image", key_prefix=""): | |
| preview_src = primary_src or norm916_src or native_src | |
| _, preview_bytes = show_image_tile(col, title, preview_src, filename_stub=filename_stub, key_prefix=key_prefix) | |
| def _load(src): | |
| if isinstance(src, (bytes, bytearray)): return bytes(src) | |
| if isinstance(src, str): return fetch_bytes(src) | |
| return None | |
| b_native = _load(native_src) or _load(primary_src) or _load(norm916_src) # fallbacks for preview; downloads say "native" if actually native_src | |
| # Only keep the single "Download native" button in the base tile (already rendered inside show_image_tile for preview src). | |
| # Now add RESIZED download buttons derived from NATIVE bytes if available. | |
| if b_native: | |
| # Determine output base format by native ext (prefer jpeg for photos) | |
| ext = infer_ext(b_native).lower() | |
| out_fmt = "JPEG" if ext in (".jpg",".jpeg",".webp",".png") else "JPEG" | |
| # Parse presets and show buttons | |
| presets = parse_presets_for_resizing(st.session_state.get("exports_last","")) | |
| if presets: | |
| st_html = "<div style='font-size:12px; opacity:0.9; margin-top:4px;'>Resized downloads (no padding):</div>" | |
| col.markdown(st_html, unsafe_allow_html=True) | |
| # Show up to 4 per row | |
| for i, (tw, th, name) in enumerate(presets): | |
| btn_label = f"Download {name}" | |
| try: | |
| rb = resize_cover_exact(b_native, tw, th, out_format=out_fmt) | |
| mime = "image/jpeg" if out_fmt.upper()=="JPEG" else ("image/png" if out_fmt.upper()=="PNG" else "image/webp") | |
| col.download_button( | |
| btn_label, | |
| data=rb, | |
| file_name=f"{filename_stub}_{name}.{ 'jpg' if out_fmt.upper()=='JPEG' else out_fmt.lower()}", | |
| mime=mime, | |
| key=unique_key(f"{key_prefix}_{filename_stub}_{name}_resized_dl") | |
| ) | |
| except Exception as e: | |
| col.caption(f"{name}: resize failed ({e})") | |
| return preview_bytes, None, None, b_native | |
| # --------- Source selection for VIDEO (prefer NATIVE) --------- | |
| def _pick_source_for_video(look_idx: int, urls_primary, urls_norm, urls_raw): | |
| if look_idx < len(urls_raw) and urls_raw[look_idx]: | |
| return urls_raw[look_idx], "native" | |
| if look_idx < len(urls_primary) and urls_primary[look_idx]: | |
| return urls_primary[look_idx], "primary" | |
| if look_idx < len(urls_norm) and urls_norm[look_idx]: | |
| return urls_norm[look_idx], "9:16" | |
| return None, "" | |
| # ================ Results ================ | |
| res = st.session_state.get("results") or {} | |
| if res: | |
| key_prefix = f"job{st.session_state['job_counter']}" | |
| urls_primary = res.get("image_urls_primary") or [] | |
| urls_norm916 = res.get("image_urls_9_16") or [] | |
| urls_raw = res.get("image_urls_raw") or [] | |
| exports_dict = res.get("exports") or {} | |
| video_url = res.get("video_url") | |
| fa = res.get("frame_aspect","9:16") | |
| st.subheader("Results") | |
| c1, c2, c3, c4 = st.columns([1,1,1,1]) | |
| named = [] | |
| def _triple_at(i): | |
| primary = urls_primary[i] if i < len(urls_primary) else None | |
| norm916 = urls_norm916[i] if i < len(urls_norm916) else None | |
| raw = urls_raw[i] if i < len(urls_raw) else None | |
| return primary, norm916, raw | |
| for idx, col in enumerate([c1, c2, c3]): | |
| p_u, n_u, r_u = _triple_at(idx) | |
| if p_u or n_u or r_u: | |
| title = "Look" if idx == 0 else f"Look #{idx+1}" | |
| _, _, _, b_r = show_dual_image_tile( | |
| col, title, p_u, n_u, r_u, filename_stub=f"look_{idx+1}", key_prefix=f"{key_prefix}_{idx}" | |
| ) | |
| if b_r: | |
| named.append((f"look_{idx+1}_native{infer_ext(b_r)}", b_r)) | |
| else: | |
| show_image_tile(col, f"Look #{idx+1}", None, key_prefix=f"{key_prefix}_{idx}") | |
| # --- Single video area with fixed size + controls --- | |
| video_slot = c4.empty() | |
| vbytes = render_video_placeholder(video_slot, video_url, key_prefix=key_prefix) | |
| st.divider() | |
| colv0, colv1, colv2, colv3 = st.columns([1.0, 1.0, 1.0, 1.0]) | |
| with colv0: | |
| gen_video = st.checkbox("Generate video from first look", value=False, key="video_toggle") | |
| with colv1: | |
| keep_aspect = st.selectbox("Video aspect", ["9:16","3:4","1:1","4:5","16:9"], | |
| index=["9:16","3:4","1:1","4:5","16:9"].index(fa)) | |
| with colv2: | |
| duration_sec = st.number_input("Video duration (sec)", min_value=1, max_value=60, | |
| value=int(st.session_state.get("duration","6") or 6), step=1, key="duration_num") | |
| with colv3: | |
| go_video = st.button("Create Video", use_container_width=True, key="video_btn") | |
| if gen_video and go_video: | |
| src_for_video, reason = _pick_source_for_video(0, urls_primary, urls_norm916, urls_raw) | |
| if not src_for_video: | |
| st.warning("No image to create video. Run image/chain first.") | |
| else: | |
| st.session_state["duration"] = str(int(duration_sec)) | |
| prompt_override = _video_prompt_override_txt(st.session_state.get("category","")) | |
| with st.spinner(f"Generating video from {reason} image…"): | |
| v = post_video( | |
| src_for_video, | |
| gender=st.session_state.get("gender","auto"), | |
| age_group=st.session_state.get("age_group","auto"), | |
| frame_aspect=keep_aspect, | |
| duration_sec=st.session_state["duration"], | |
| category=st.session_state.get("category","general"), | |
| prompt=(prompt_override or None), | |
| ) | |
| vurl = (v.get("result") or {}).get("video", {}).get("url") | |
| if vurl: | |
| st.session_state["results"]["video_url"] = vurl | |
| vbytes = render_video_placeholder(video_slot, vurl, key_prefix=key_prefix) | |
| st.info(f"Video source: {reason} • category sent: {st.session_state.get('category','general')}") | |
| else: | |
| st.info("Video URL not returned.") | |
| # --- Export variants preview (from backend) — kept as-is --- | |
| if exports_dict: | |
| st.divider() | |
| st.subheader("Exports") | |
| for vname in sorted(exports_dict.keys()): | |
| items = exports_dict.get(vname) or [] | |
| st.text(f"{vname} · {len(items)} item(s)") | |
| cols = st.columns(4) | |
| for i, it in enumerate(items[:8]): # show up to 8 previews, all fixed-size | |
| u = it.get("url") | |
| if not u: continue | |
| _, b = show_image_tile(cols[i % 4], f"{vname} • #{i+1}", u, | |
| filename_stub=f"{vname.replace(':','x').replace('@','_')}_{i+1}", | |
| key_prefix=f"{key_prefix}_exp_{vname}_{i}") | |
| if b: | |
| named.append((f"{vname.replace(':','-').replace('@','_')}_{i+1}{infer_ext(b)}", b)) | |
| # ZIP + GIF | |
| if named or vbytes: | |
| if vbytes: | |
| named.append(("video.mp4", vbytes)) | |
| zip_bytes = make_zip(named) | |
| st.download_button( | |
| "Download All (ZIP)", | |
| data=zip_bytes, | |
| file_name="fitstudio_outputs.zip", | |
| mime="application/zip", | |
| key=unique_key(f"{key_prefix}_zip_dl") | |
| ) | |
| if vbytes: | |
| try: | |
| with st.spinner("Preparing GIF…"): | |
| gif_bytes = mp4_to_gif_bytes(vbytes, target_w=GIF_MAX_W, fps=GIF_FPS, max_frames=GIF_MAX_FRAMES) | |
| st.download_button( | |
| f"Download GIF ({GIF_MAX_W}px, {GIF_FPS}fps)", | |
| data=gif_bytes, | |
| file_name="fitstudio_video.gif", | |
| mime="image/gif", | |
| key=unique_key(f"{key_prefix}_gif_dl") | |
| ) | |
| except Exception as e: | |
| st.info(f"GIF conversion not available: {e}. Try installing ffmpeg / imageio-ffmpeg.") | |
| # ================= Sidebar ================= | |
| with st.sidebar: | |
| st.caption(f"Resolved API_BASE: {API_BASE}") | |
| st.caption("Auth header: " + ("ON" if HF_TOKEN else "OFF")) | |
| st.divider() | |
| if st.button("Ping /health", key=unique_key("ping_health")): | |
| try: | |
| r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10) | |
| st.write(r.status_code) | |
| try: st.json(r.json()) | |
| except Exception: st.code((r.text or "")[:1000]) | |
| except Exception as e: | |
| st.error(f"Health error: {e}") | |
| if st.button("Clear cache", key=unique_key("clear_cache")): | |
| st.cache_data.clear(); st.success("Cache cleared.") | |
| if st.button("Clear results", key=unique_key("clear_results")): | |
| st.session_state["results"] = None; st.success("Results cleared.") | |