renderfy commited on
Commit
82b599a
·
verified ·
1 Parent(s): e2d8bbe

Upload 2 files

Browse files
Files changed (2) hide show
  1. requirements.txt +2 -0
  2. streamlit_app.py +273 -171
requirements.txt CHANGED
@@ -10,3 +10,5 @@ python-multipart
10
  aiofiles
11
  websockets
12
  websocket-client
 
 
 
10
  aiofiles
11
  websockets
12
  websocket-client
13
+ imageio>=2.34
14
+ imageio-ffmpeg>=0.4.9
streamlit_app.py CHANGED
@@ -1,8 +1,7 @@
1
- # streamlit_app.py — Fit Studio AI (Fashion & Apparel) v0.5 UI
2
- # 9:16 uniform tiles, gender & age controls, looping sample/result videos
3
- # Demo: put these next to this file → input.jpg, tryon.jpg, fitstudio_video.mp4
4
 
5
- import os, io, zipfile, base64, requests, streamlit as st
6
  from pathlib import Path
7
  from PIL import Image
8
 
@@ -18,31 +17,48 @@ API_BASE = (
18
  ) else ""
19
 
20
  HF_TOKEN = _env("FITSTUDIO_TOKEN") or _env("AI_LIGHTBOX_TOKEN") or _env("LUXFIT_TOKEN")
21
-
22
- if not API_BASE:
23
- st.error("API_BASE bulunamadı. Settings > Variables: FITSTUDIO_API (veya AI_LIGHTBOX_API / LUXFIT_API) tanımlayın.")
24
- st.stop()
25
-
26
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
27
 
28
  # === Tile geometry (strict 9:16) ===
29
- TILE_H = int(os.getenv("TILE_H", "640")) # all tiles same height
30
- TILE_W = max(180, int(round(TILE_H * 9 / 16))) # width derived from 9:16
31
  PREVIEW_BG = (255, 255, 255)
32
 
 
 
 
 
33
  # ================= Session State =================
34
- defaults = {"results": None, "job_counter": 0, "duration": "6"}
35
  for k, v in defaults.items():
36
  if k not in st.session_state:
37
  st.session_state[k] = v
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # ================= Helpers =================
40
  def _needs_auth(url: str) -> bool:
41
- return url.startswith(API_BASE) or url.startswith("outputs/")
42
 
43
  @st.cache_data(ttl=300, show_spinner=False)
44
  def fetch_bytes(url_or_path: str | None):
45
- """Reads local file (same folder) or fetches URL."""
46
  if not url_or_path:
47
  return None
48
  try:
@@ -59,7 +75,6 @@ def fetch_bytes(url_or_path: str | None):
59
 
60
  @st.cache_data(ttl=300, show_spinner=False)
61
  def image_to_9_16_canvas(img_bytes: bytes, w: int = TILE_W, h: int = TILE_H, bg_rgb=PREVIEW_BG) -> bytes:
62
- """Letterbox/pillarbox to exact 9:16 tile size, no crop."""
63
  im = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
64
  iw, ih = im.size
65
  scale = min(w / iw, h / ih)
@@ -85,53 +100,113 @@ def infer_ext(data: bytes) -> str:
85
  except Exception:
86
  return ".jpg"
87
 
 
 
 
 
 
 
88
  def show_image_tile(col, title: str, src: str|bytes|None, filename_stub="image", key_prefix=""):
89
- col.markdown(f"#### {title}")
90
  if not src:
91
  placeholder = Image.new("RGBA", (TILE_W, TILE_H), (240,240,240,255))
92
  buf = io.BytesIO(); placeholder.save(buf, "PNG")
93
- col.image(buf.getvalue(), width=TILE_W)
94
- col.caption("—"); return None, None
 
 
 
95
  b = src if isinstance(src, (bytes, bytearray)) else fetch_bytes(src)
96
  if not b:
97
- col.warning("Görsel yüklenemedi"); return None, None
98
- tile = image_to_9_16_canvas(b, w=TILE_W, h=TILE_H, bg_rgb=PREVIEW_BG)
99
- col.image(tile, width=TILE_W)
 
 
 
100
  sz = image_size_from_bytes(b)
101
- if sz: col.caption(f"Kaynak: {sz[0]}×{sz[1]} px")
 
 
102
  ext = infer_ext(b)
103
  mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp")
104
- col.download_button("İndir (tam kalite)", data=b, file_name=f"{filename_stub}{ext}", mime=mime,
105
- key=f"{key_prefix}_{filename_stub}_dl")
 
 
 
 
 
106
  return src, b
107
 
108
- def _video_html(src: str, w: int = TILE_W, h: int = TILE_H) -> str:
109
- return f"""
110
- <video src="{src}" width="{w}" height="{h}" autoplay muted loop playsinline controls
111
- style="display:block; background:#fff; object-fit:contain; border-radius:12px;
112
- box-shadow:0 1px 6px rgba(0,0,0,.08);"></video>
113
- """
114
-
115
- def show_video_tile(col, title: str, src_or_bytes: str|bytes|None, key_prefix=""):
116
- col.markdown(f"#### {title}")
117
- if not src_or_bytes:
118
- col.caption("—"); return None
119
- if isinstance(src_or_bytes, (bytes, bytearray)):
120
- b64 = base64.b64encode(src_or_bytes).decode("ascii")
121
- html = _video_html(f"data:video/mp4;base64,{b64}", w=TILE_W, h=TILE_H)
122
- col.markdown(html, unsafe_allow_html=True)
123
- vb = bytes(src_or_bytes)
124
- else:
125
- # remote/local path → try to fetch for download button, but loop via URL
126
- vb = fetch_bytes(src_or_bytes)
127
- html = _video_html(src_or_bytes, w=TILE_W, h=TILE_H)
128
- col.markdown(html, unsafe_allow_html=True)
129
- if vb:
130
- col.download_button("Video indir (MP4)", data=vb, file_name="fitstudio_video.mp4", mime="video/mp4",
131
- key=f"{key_prefix}_video_dl")
132
- return vb
 
 
 
 
 
 
 
 
 
133
 
134
- def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  buf = io.BytesIO()
136
  with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:
137
  for fname, b in named_bytes:
@@ -139,6 +214,7 @@ def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
139
  buf.seek(0); return buf.read()
140
 
141
  def backend_ok() -> bool:
 
142
  try:
143
  r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10)
144
  r.raise_for_status(); return True
@@ -151,84 +227,74 @@ def post_image_edit(data: dict, files_payload):
151
  r.raise_for_status(); return r.json()
152
 
153
  def post_chain(data: dict, files_payload):
154
- payload = {**data, "to_video": "false"} # video ayrı
155
  r = requests.post(f"{API_BASE}/v1/tryon/chain", data=payload, files=files_payload or None,
156
  headers=HEADERS if HF_TOKEN else None, timeout=600)
157
  r.raise_for_status(); return r.json()
158
 
159
- def post_video(image_url: str, duration="6", resolution="768P", prompt_optimizer=True, gender="auto", age_group="auto"):
160
- payload = {
161
- "image_url": image_url, "duration": duration, "resolution": resolution,
162
- "prompt_optimizer": "true" if prompt_optimizer else "false",
163
- "gender": gender, "age_group": age_group, "frame_aspect": "9:16"
164
- }
165
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload,
166
  headers=HEADERS if HF_TOKEN else None, timeout=600)
167
  r.raise_for_status(); return r.json()
168
 
169
  # ================= Page =================
170
- st.set_page_config(page_title="Fit Studio AI", layout="wide", page_icon="🧵")
171
- st.title("🧵 Fit Studio AI")
172
- st.caption("Fashion & Apparel — 9:16 stills (VTO) → optional runway video (looping)")
173
-
174
- # ================= Controls =================
175
- ok = backend_ok()
176
- c0, c_gender, c_age, c2, c3, c4 = st.columns([1.0, 1.0, 1.2, 1.8, 1.4, 1.6])
177
-
178
- with c0:
179
- category = st.selectbox("Kategori", ["general","underwear"], index=0, key="category")
180
 
181
- with c_gender:
182
- gender = st.selectbox("Cinsiyet", ["auto","female","male","unisex"], index=0, key="gender")
 
 
 
 
 
183
 
184
- with c_age:
185
- age_group = st.selectbox("Yaş grubu", ["auto","teen","young_adult","adult","mature"], index=0, key="age_group")
186
 
187
- with c2:
188
- file_list = st.file_uploader("Garment image (upload)", type=["jpg","jpeg","png","webp"],
189
- accept_multiple_files=True, key="file_upl")
190
-
191
- with c3:
192
- image_url = st.text_input("veya Garment URL", key="image_url")
193
 
 
 
 
 
 
 
 
194
  with c4:
195
- num_images = st.selectbox("Çıktı adedi", [1,2,3,4], index=0, key="num_images")
196
- custom_prompt = st.text_area("Özel prompt (boş → varsayılan)", value="", height=90, key="custom_prompt")
197
 
198
  bA, bB = st.columns([1.0, 1.0])
199
- with bA: st.info(f"Backend: {'Online' if ok else 'Offline'}", icon="🔌")
200
  with bB:
201
- run_image = st.button("Çalıştır (Sadece Foto)", type="primary", use_container_width=True, key="run_img_btn")
202
- run_chain = st.button("Zincir (Foto → Video hazırlık)", use_container_width=True, key="run_chain_btn")
203
-
204
- # ================= Demo preview (uniform 9:16 tiles) =================
205
- st.markdown("---")
206
- st.subheader("Demo preview")
207
 
 
 
208
  dp_in, dp_sp, dp_v = st.columns([1,1,1])
209
-
210
- in_bytes = fetch_bytes("input.jpg")
211
- show_image_tile(dp_in, "Input", in_bytes, filename_stub="input", key_prefix="demo_in")
212
-
213
- try_bytes = fetch_bytes("tryon.jpg")
214
- show_image_tile(dp_sp, "Sample Output", try_bytes, filename_stub="tryon", key_prefix="demo_out")
215
-
216
  demo_vbytes = fetch_bytes("fitstudio_video.mp4")
217
- show_video_tile(dp_v, "Sample Video (loop)", demo_vbytes, key_prefix="demo_vid")
 
 
218
 
219
- # ================= Girdi önizleme (uniform 9:16) =================
220
  st.markdown("---")
221
- g1, _, _, _ = st.columns([1,1,1,1]) # sadece 1 tile gösteriyoruz
222
  input_preview = None
223
  if file_list:
224
  try: input_preview = file_list[0].getvalue()
225
  except Exception: input_preview = None
226
  elif image_url:
227
  input_preview = fetch_bytes(image_url)
228
-
229
  show_image_tile(g1, "Input (preview)", input_preview, filename_stub="preview", key_prefix="preview")
230
 
231
- # ================ Çalıştır ================
232
  def _build_files_payload():
233
  files_payload = []
234
  if file_list:
@@ -251,15 +317,15 @@ def _common_form_data():
251
  return data
252
 
253
  if run_image or run_chain:
254
- if not ok:
255
- st.error("Backend erişilemiyor. API_BASE / TOKEN kontrol edin.")
256
  elif not (file_list or image_url):
257
- st.error("En az bir garment görseli girin (upload veya URL).")
258
  else:
259
  try:
260
  files_payload = _build_files_payload()
261
  form = _common_form_data()
262
- with st.spinner("Çalışıyor…"):
263
  if run_image:
264
  out = post_image_edit(form, files_payload)
265
  image_json = out
@@ -267,120 +333,157 @@ if run_image or run_chain:
267
  out = post_chain(form, files_payload) # to_video=false
268
  image_json = out.get("image_step") or out
269
 
270
- # Prefer normalized images_9_16
271
- res_block = image_json.get("result") or {}
272
  imgs_norm = res_block.get("images_9_16") or []
273
  imgs_raw = res_block.get("images") or []
274
- urls = []
275
- if imgs_norm:
276
- for it in imgs_norm:
277
- u = it.get("url");
278
- if u: urls.append(u)
279
- elif imgs_raw:
280
- for it in imgs_raw:
281
- u = it.get("url")
282
- if u: urls.append(u)
283
 
284
  st.session_state["job_counter"] += 1
285
  st.session_state["results"] = {
286
  "job_id": (image_json.get("job_id") or out.get("job_id")),
287
  "schema_version": (image_json.get("schema_version") or out.get("schema_version")),
288
- "image_urls": urls,
 
289
  "video_url": None,
290
  }
291
 
292
  except requests.HTTPError as e:
293
  st.error(f"HTTP {e.response.status_code}")
294
  except Exception as e:
295
- st.error(f"Hata: {e}")
296
-
297
- # ================ Sonuçları Yan Yana (aynı kadraj) ================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  res = st.session_state.get("results") or {}
299
  if res:
300
  key_prefix = f"job{st.session_state['job_counter']}"
301
- img_urls = res.get("image_urls") or []
 
302
  video_url = res.get("video_url")
303
 
304
- st.markdown("### Sonuçlar")
305
  c1, c2, c3, c4 = st.columns([1,1,1,1])
306
  named = []
307
 
308
- # İlk 3 görsel
 
 
 
 
309
  for idx, col in enumerate([c1, c2, c3]):
310
- if idx < len(img_urls):
311
- u = img_urls[idx]
312
  title = "Look" if idx == 0 else f"Look #{idx+1}"
313
- _, b = show_image_tile(col, title, u, filename_stub=f"look_{idx+1}", key_prefix=f"{key_prefix}_{idx}")
314
- if b: named.append((f"look_{idx+1}{infer_ext(b)}", b))
 
 
 
315
  else:
316
  show_image_tile(col, f"Look #{idx+1}", None, key_prefix=f"{key_prefix}_{idx}")
317
 
318
- # Video sütunu (loop)
319
- vbytes = None
320
- if video_url:
321
- vbytes = show_video_tile(c4, "Video (loop)", video_url, key_prefix=key_prefix)
322
- else:
323
- c4.markdown("#### Video (loop)")
324
- c4.caption("Henüz üretilmedi.")
325
 
326
- # Video üretim kontrolleri
327
  st.markdown("---")
328
- colv1, colv2, colv3 = st.columns([1.1, 1.0, 1.2])
329
  with colv1:
330
- gen_video = st.checkbox("Bu sonuçtan video üret", value=False, key="video_toggle")
331
  with colv2:
332
- st.selectbox("Video süresi", ["6","10"], index=(0 if st.session_state["duration"]=="6" else 1), key="duration_select")
333
- with colv3:
334
- go_video = st.button("Video Oluştur", use_container_width=True, key="video_btn")
335
 
336
  if gen_video and go_video:
337
- src_for_video = img_urls[0] if img_urls else None
338
  if not src_for_video:
339
- st.warning("Video için kaynak görsel yok.")
340
  else:
341
- with st.spinner("Video üretiliyor…"):
342
- v = post_video(
343
- src_for_video,
344
- duration=st.session_state.get("duration_select","6"),
345
- resolution="768P",
346
- prompt_optimizer=True,
347
- gender=st.session_state.get("gender","auto"),
348
- age_group=st.session_state.get("age_group","auto")
349
- )
350
  vurl = (v.get("result") or {}).get("video", {}).get("url")
351
  if vurl:
352
  st.session_state["results"]["video_url"] = vurl
353
- video_url = vurl
354
- vbytes = show_video_tile(c4, "Video (loop)", video_url, key_prefix=key_prefix)
355
  else:
356
- st.info("Video URL gelmedi.")
357
 
358
- # ZIP indir
359
  if named or vbytes:
360
  if vbytes:
361
  named.append(("video.mp4", vbytes))
362
  zip_bytes = make_zip(named)
363
- st.download_button("Tümünü indir (ZIP)", data=zip_bytes, file_name="fitstudio_outputs.zip",
364
- mime="application/zip", key=f"{key_prefix}_zip_dl")
365
-
366
- # Debug
367
- with st.expander("Debug"):
368
- st.json({
369
- "job_id": res.get("job_id"),
370
- "schema_version": res.get("schema_version"),
371
- "api_base": API_BASE,
372
- "has_auth_header": bool(HF_TOKEN),
373
- "image_urls": img_urls,
374
- "video_url": res.get("video_url"),
375
- "tile": f"{TILE_W}x{TILE_H} (9:16)"
376
- })
 
 
 
 
 
 
377
 
378
  # ================= Sidebar =================
379
  with st.sidebar:
380
  st.caption(f"Resolved API_BASE: {API_BASE}")
381
  st.caption("Auth header: " + ("ON" if HF_TOKEN else "OFF"))
382
-
383
- if st.button("Ping /health"):
 
 
384
  try:
385
  r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10)
386
  st.write(r.status_code)
@@ -388,8 +491,7 @@ with st.sidebar:
388
  except Exception: st.code((r.text or "")[:1000])
389
  except Exception as e:
390
  st.error(f"Health error: {e}")
391
-
392
- if st.button("Clear cache"):
393
  st.cache_data.clear(); st.success("Cache cleared.")
394
- if st.button("Clear results"):
395
  st.session_state["results"] = None; st.success("Results cleared.")
 
1
+ # streamlit_app.py — Fit Studio AI (Fashion & Apparel) v0.6.5 UI
2
+ # Fix: "Video (loop)" duplicate — single placeholder (st.empty) updated in-place.
 
3
 
4
+ import os, io, zipfile, base64, requests, streamlit as st, uuid, tempfile
5
  from pathlib import Path
6
  from PIL import Image
7
 
 
17
  ) else ""
18
 
19
  HF_TOKEN = _env("FITSTUDIO_TOKEN") or _env("AI_LIGHTBOX_TOKEN") or _env("LUXFIT_TOKEN")
 
 
 
 
 
20
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
21
 
22
  # === Tile geometry (strict 9:16) ===
23
+ TILE_H = int(os.getenv("TILE_H", "640"))
24
+ TILE_W = max(200, int(round(TILE_H * 9 / 16)))
25
  PREVIEW_BG = (255, 255, 255)
26
 
27
+ GIF_MAX_W = int(os.getenv("GIF_MAX_W", "512"))
28
+ GIF_FPS = int(os.getenv("GIF_FPS", "12"))
29
+ GIF_MAX_FRAMES = GIF_FPS * 12
30
+
31
  # ================= Session State =================
32
+ defaults = {"results": None, "job_counter": 0, "duration": "6", "rand": uuid.uuid4().hex[:8]}
33
  for k, v in defaults.items():
34
  if k not in st.session_state:
35
  st.session_state[k] = v
36
 
37
+ # ================= CSS =================
38
+ st.markdown(f"""
39
+ <style>
40
+ h4.tile-title {{ margin: 0 0 8px 0; font-weight: 600; }}
41
+ div.tile-box {{
42
+ width: {TILE_W}px; height: {TILE_H}px;
43
+ display: flex; align-items: center; justify-content: center;
44
+ background: #ffffff; border-radius: 12px; overflow: hidden;
45
+ box-shadow: 0 1px 6px rgba(0,0,0,.08);
46
+ margin: 0 0 6px 0;
47
+ }}
48
+ img.tile-media, video.tile-media {{
49
+ width: 100%; height: 100%; object-fit: contain; background: #ffffff;
50
+ display: block;
51
+ }}
52
+ .small-cap {{ font-size: 12px; color: #6b7280; margin: 4px 0 8px 2px; }}
53
+ </style>
54
+ """, unsafe_allow_html=True)
55
+
56
  # ================= Helpers =================
57
  def _needs_auth(url: str) -> bool:
58
+ return (API_BASE and (url.startswith(API_BASE) or url.startswith("outputs/")))
59
 
60
  @st.cache_data(ttl=300, show_spinner=False)
61
  def fetch_bytes(url_or_path: str | None):
 
62
  if not url_or_path:
63
  return None
64
  try:
 
75
 
76
  @st.cache_data(ttl=300, show_spinner=False)
77
  def image_to_9_16_canvas(img_bytes: bytes, w: int = TILE_W, h: int = TILE_H, bg_rgb=PREVIEW_BG) -> bytes:
 
78
  im = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
79
  iw, ih = im.size
80
  scale = min(w / iw, h / ih)
 
100
  except Exception:
101
  return ".jpg"
102
 
103
+ def unique_key(prefix: str) -> str:
104
+ return f"{prefix}_{st.session_state['job_counter']}_{st.session_state['rand']}_{uuid.uuid4().hex[:6]}"
105
+
106
+ def _b64(data: bytes, mime: str) -> str:
107
+ return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
108
+
109
  def show_image_tile(col, title: str, src: str|bytes|None, filename_stub="image", key_prefix=""):
110
+ col.markdown(f"<h4 class='tile-title'>{title}</h4>", unsafe_allow_html=True)
111
  if not src:
112
  placeholder = Image.new("RGBA", (TILE_W, TILE_H), (240,240,240,255))
113
  buf = io.BytesIO(); placeholder.save(buf, "PNG")
114
+ html = f"<div class='tile-box'><img class='tile-media' src='{_b64(buf.getvalue(),'image/png')}'/></div>"
115
+ col.markdown(html, unsafe_allow_html=True)
116
+ col.markdown("<div class='small-cap'>—</div>", unsafe_allow_html=True)
117
+ return None, None
118
+
119
  b = src if isinstance(src, (bytes, bytearray)) else fetch_bytes(src)
120
  if not b:
121
+ col.warning("Image could not be loaded"); return None, None
122
+
123
+ tile_png = image_to_9_16_canvas(b, w=TILE_W, h=TILE_H, bg_rgb=PREVIEW_BG)
124
+ html = f"<div class='tile-box'><img class='tile-media' src='{_b64(tile_png,'image/png')}'/></div>"
125
+ col.markdown(html, unsafe_allow_html=True)
126
+
127
  sz = image_size_from_bytes(b)
128
+ if sz:
129
+ col.markdown(f"<div class='small-cap'>Source: {sz[0]}×{sz[1]} px</div>", unsafe_allow_html=True)
130
+
131
  ext = infer_ext(b)
132
  mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp")
133
+ col.download_button(
134
+ "Download (full quality)",
135
+ data=b,
136
+ file_name=f"{filename_stub}{ext}",
137
+ mime=mime,
138
+ key=unique_key(f"{key_prefix}_{filename_stub}_dl")
139
+ )
140
  return src, b
141
 
142
+ def _video_html_src(src: str) -> str:
143
+ return f"<div class='tile-box'><video class='tile-media' src='{src}' autoplay muted loop playsinline controls></video></div>"
144
+
145
+ # --- Video rendering in a single placeholder (prevents duplicate heading) ---
146
+ def render_video_placeholder(slot, video_url: str|None, key_prefix=""):
147
+ with slot.container():
148
+ st.markdown("<h4 class='tile-title'>Video (loop)</h4>", unsafe_allow_html=True)
149
+ if not video_url:
150
+ st.markdown("<div class='small-cap'>Not generated yet.</div>", unsafe_allow_html=True)
151
+ return None
152
+ vb = fetch_bytes(video_url)
153
+ vsrc = _b64(vb, "video/mp4") if vb else video_url
154
+ st.markdown(_video_html_src(vsrc), unsafe_allow_html=True)
155
+ if vb:
156
+ st.download_button(
157
+ "Download video (MP4)",
158
+ data=vb,
159
+ file_name="fitstudio_video.mp4",
160
+ mime="video/mp4",
161
+ key=unique_key(f"{key_prefix}_video_dl")
162
+ )
163
+ return vb
164
+
165
+ # ---- MP4 → GIF ----
166
+ @st.cache_data(show_spinner=False)
167
+ 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:
168
+ try:
169
+ import imageio.v3 as iio
170
+ from PIL import Image
171
+ except Exception as e:
172
+ raise RuntimeError("imageio.v3 + PIL required for GIF conversion") from e
173
+
174
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as f:
175
+ f.write(mp4_bytes); mp4_path = f.name
176
 
177
+ try:
178
+ import imageio.v3 as iio2 # same pkg; just to be explicit
179
+ meta = {}
180
+ try: meta = iio2.immeta(mp4_path)
181
+ except Exception: meta = {}
182
+ src_fps = meta.get("fps", fps)
183
+ step = max(1, int(round(src_fps / fps))) if isinstance(src_fps, (int, float)) and src_fps > 0 else 1
184
+ except Exception:
185
+ step = 1
186
+
187
+ frames = []
188
+ try:
189
+ for idx, frame in enumerate(iio.imiter(mp4_path)):
190
+ if idx % step != 0: continue
191
+ im = Image.fromarray(frame)
192
+ if im.width > target_w:
193
+ new_h = max(1, int(im.height * target_w / im.width))
194
+ im = im.resize((target_w, new_h), Image.LANCZOS)
195
+ frames.append(im.convert("P", palette=Image.ADAPTIVE))
196
+ if len(frames) >= max_frames: break
197
+ except Exception as e:
198
+ raise RuntimeError("Failed to decode frames for GIF. ffmpeg/pyav may be missing.") from e
199
+ finally:
200
+ try: Path(mp4_path).unlink(missing_ok=True)
201
+ except Exception: pass
202
+
203
+ if not frames: raise RuntimeError("No frames decoded for GIF.")
204
+ out = io.BytesIO()
205
+ frames[0].save(out, format="GIF", save_all=True, append_images=frames[1:],
206
+ loop=0, duration=max(10, int(1000 / fps)), disposal=2)
207
+ return out.getvalue()
208
+
209
+ def make_zip(named_bytes):
210
  buf = io.BytesIO()
211
  with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:
212
  for fname, b in named_bytes:
 
214
  buf.seek(0); return buf.read()
215
 
216
  def backend_ok() -> bool:
217
+ if not API_BASE: return False
218
  try:
219
  r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10)
220
  r.raise_for_status(); return True
 
227
  r.raise_for_status(); return r.json()
228
 
229
  def post_chain(data: dict, files_payload):
230
+ payload = {**data, "to_video": "false"}
231
  r = requests.post(f"{API_BASE}/v1/tryon/chain", data=payload, files=files_payload or None,
232
  headers=HEADERS if HF_TOKEN else None, timeout=600)
233
  r.raise_for_status(); return r.json()
234
 
235
+ def post_video(image_url: str, gender="auto", age_group="auto"):
236
+ payload = {"image_url": image_url, "gender": gender, "age_group": age_group, "frame_aspect": "9:16"}
 
 
 
 
237
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload,
238
  headers=HEADERS if HF_TOKEN else None, timeout=600)
239
  r.raise_for_status(); return r.json()
240
 
241
  # ================= Page =================
242
+ st.set_page_config(page_title="Fit Studio AI — by Vahit Feryad", layout="wide", page_icon="🧵")
 
 
 
 
 
 
 
 
 
243
 
244
+ left, right = st.columns([0.7, 0.3], vertical_alignment="center")
245
+ with left:
246
+ st.title("🧵 Fit Studio AI — Fashion & Apparel")
247
+ st.caption("Built by **Vahit Feryad** — Demo for Groove Jones")
248
+ with right:
249
+ badge = "🟢 Online" if backend_ok() else "🔴 Offline"
250
+ st.metric("Backend", badge)
251
 
252
+ st.markdown("**9:16** commercial stills (VTO) → optional runway-style video (looping). \nNo persistence: outputs are remote URLs only. Gender & Age controls steer prompts server-side.")
 
253
 
254
+ if not API_BASE:
255
+ st.error("API_BASE not set. Define env var FITSTUDIO_API (or AI_LIGHTBOX_API / LUXFIT_API).")
256
+ st.stop()
 
 
 
257
 
258
+ # ================= Controls =================
259
+ c0, c_gender, c_age, c2, c3, c4 = st.columns([1.0, 1.0, 1.2, 1.8, 1.4, 1.6])
260
+ with c0: category = st.selectbox("Category", ["general","underwear"], index=0, key="category")
261
+ with c_gender: gender = st.selectbox("Gender", ["auto","female","male","unisex"], index=0, key="gender")
262
+ with c_age: age_group= st.selectbox("Age group", ["auto","teen","young_adult","adult","mature"], index=0, key="age_group")
263
+ with c2: file_list= st.file_uploader("Garment image (upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")
264
+ with c3: image_url= st.text_input("or Garment URL", key="image_url")
265
  with c4:
266
+ num_images = st.selectbox("Number of looks", [1,2,3,4], index=0, key="num_images")
267
+ custom_prompt = st.text_area("Custom prompt (blank → default)", value="", height=90, key="custom_prompt")
268
 
269
  bA, bB = st.columns([1.0, 1.0])
270
+ with bA: st.info(f"API: {API_BASE}", icon="🔌")
271
  with bB:
272
+ run_image = st.button("Run (Image only)", type="primary", use_container_width=True, key="run_img_btn")
273
+ run_chain = st.button("Chain (Image → prep Video)", use_container_width=True, key="run_chain_btn")
 
 
 
 
274
 
275
+ # ================= Demo preview =================
276
+ st.markdown("---"); st.subheader("Demo preview")
277
  dp_in, dp_sp, dp_v = st.columns([1,1,1])
278
+ show_image_tile(dp_in, "Input", fetch_bytes("input.jpg"), filename_stub="input", key_prefix="demo_in")
279
+ show_image_tile(dp_sp, "Sample Output", fetch_bytes("tryon.jpg"), filename_stub="tryon", key_prefix="demo_out")
280
+ # keep separate label to avoid confusion with "Video (loop)"
 
 
 
 
281
  demo_vbytes = fetch_bytes("fitstudio_video.mp4")
282
+ if demo_vbytes:
283
+ dp_v.markdown("<h4 class='tile-title'>Sample Video (loop)</h4>", unsafe_allow_html=True)
284
+ dp_v.markdown(f"<div class='tile-box'><video class='tile-media' src='{_b64(demo_vbytes,'video/mp4')}' autoplay muted loop playsinline controls></video></div>", unsafe_allow_html=True)
285
 
286
+ # ================= Input preview =================
287
  st.markdown("---")
288
+ g1, _, _, _ = st.columns([1,1,1,1])
289
  input_preview = None
290
  if file_list:
291
  try: input_preview = file_list[0].getvalue()
292
  except Exception: input_preview = None
293
  elif image_url:
294
  input_preview = fetch_bytes(image_url)
 
295
  show_image_tile(g1, "Input (preview)", input_preview, filename_stub="preview", key_prefix="preview")
296
 
297
+ # ================ Run helpers ================
298
  def _build_files_payload():
299
  files_payload = []
300
  if file_list:
 
317
  return data
318
 
319
  if run_image or run_chain:
320
+ if not backend_ok():
321
+ st.error("Backend not reachable. Check API_BASE / token.")
322
  elif not (file_list or image_url):
323
+ st.error("Provide at least one garment image (upload or URL).")
324
  else:
325
  try:
326
  files_payload = _build_files_payload()
327
  form = _common_form_data()
328
+ with st.spinner("Running…"):
329
  if run_image:
330
  out = post_image_edit(form, files_payload)
331
  image_json = out
 
333
  out = post_chain(form, files_payload) # to_video=false
334
  image_json = out.get("image_step") or out
335
 
336
+ res_block = (image_json.get("result") or {})
 
337
  imgs_norm = res_block.get("images_9_16") or []
338
  imgs_raw = res_block.get("images") or []
339
+
340
+ urls_norm, urls_raw = [], []
341
+ for it in imgs_norm:
342
+ u = it.get("url");
343
+ if u: urls_norm.append(u)
344
+ for it in imgs_raw:
345
+ u = it.get("url");
346
+ if u: urls_raw.append(u)
 
347
 
348
  st.session_state["job_counter"] += 1
349
  st.session_state["results"] = {
350
  "job_id": (image_json.get("job_id") or out.get("job_id")),
351
  "schema_version": (image_json.get("schema_version") or out.get("schema_version")),
352
+ "image_urls_norm": urls_norm,
353
+ "image_urls_raw": urls_raw,
354
  "video_url": None,
355
  }
356
 
357
  except requests.HTTPError as e:
358
  st.error(f"HTTP {e.response.status_code}")
359
  except Exception as e:
360
+ st.error(f"Error: {e}")
361
+
362
+ # ===== Helper: dual download (normalized + native) =====
363
+ def show_dual_image_tile(col, title: str, norm_src: str|bytes|None, native_src: str|bytes|None,
364
+ filename_stub="image", key_prefix=""):
365
+ preview_src = norm_src or native_src
366
+ _, preview_bytes = show_image_tile(col, title, preview_src, filename_stub=filename_stub, key_prefix=key_prefix)
367
+
368
+ norm_bytes = None; native_bytes = None
369
+ if isinstance(norm_src, (bytes, bytearray)): norm_bytes = bytes(norm_src)
370
+ elif isinstance(norm_src, str): norm_bytes = fetch_bytes(norm_src)
371
+ if isinstance(native_src, (bytes, bytearray)): native_bytes = bytes(native_src)
372
+ elif isinstance(native_src, str): native_bytes = fetch_bytes(native_src)
373
+
374
+ if norm_bytes:
375
+ extn = infer_ext(norm_bytes)
376
+ mime = "image/jpeg" if extn in [".jpg",".jpeg"] else ("image/png" if extn==".png" else "image/webp")
377
+ col.download_button(
378
+ "Download 9:16 (normalized)",
379
+ data=norm_bytes,
380
+ file_name=f"{filename_stub}_9x16{extn}",
381
+ mime=mime,
382
+ key=unique_key(f"{key_prefix}_{filename_stub}_norm_dl")
383
+ )
384
+ if native_bytes:
385
+ ext = infer_ext(native_bytes)
386
+ mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp")
387
+ col.download_button(
388
+ "Download native",
389
+ data=native_bytes,
390
+ file_name=f"{filename_stub}_native{ext}",
391
+ mime=mime,
392
+ key=unique_key(f"{key_prefix}_{filename_stub}_raw_dl")
393
+ )
394
+ return preview_bytes, norm_bytes, native_bytes
395
+
396
+ # ================ Results ================
397
  res = st.session_state.get("results") or {}
398
  if res:
399
  key_prefix = f"job{st.session_state['job_counter']}"
400
+ img_urls_norm = res.get("image_urls_norm") or []
401
+ img_urls_raw = res.get("image_urls_raw") or []
402
  video_url = res.get("video_url")
403
 
404
+ st.markdown("### Results")
405
  c1, c2, c3, c4 = st.columns([1,1,1,1])
406
  named = []
407
 
408
+ def pair_at(i):
409
+ norm = img_urls_norm[i] if i < len(img_urls_norm) else None
410
+ raw = img_urls_raw[i] if i < len(img_urls_raw) else None
411
+ return norm, raw
412
+
413
  for idx, col in enumerate([c1, c2, c3]):
414
+ norm_u, raw_u = pair_at(idx)
415
+ if norm_u or raw_u:
416
  title = "Look" if idx == 0 else f"Look #{idx+1}"
417
+ _, b_norm, b_raw = show_dual_image_tile(
418
+ col, title, norm_u, raw_u, filename_stub=f"look_{idx+1}", key_prefix=f"{key_prefix}_{idx}"
419
+ )
420
+ if b_norm: named.append((f"look_{idx+1}_9x16{infer_ext(b_norm)}", b_norm))
421
+ if b_raw: named.append((f"look_{idx+1}_native{infer_ext(b_raw)}", b_raw))
422
  else:
423
  show_image_tile(col, f"Look #{idx+1}", None, key_prefix=f"{key_prefix}_{idx}")
424
 
425
+ # --- Single, persistent video slot ---
426
+ video_slot = c4.empty()
427
+ vbytes = render_video_placeholder(video_slot, video_url, key_prefix=key_prefix)
 
 
 
 
428
 
429
+ # Controls
430
  st.markdown("---")
431
+ colv1, colv2 = st.columns([1.2, 1.2])
432
  with colv1:
433
+ gen_video = st.checkbox("Generate video from first look", value=False, key="video_toggle")
434
  with colv2:
435
+ go_video = st.button("Create Video", use_container_width=True, key="video_btn")
 
 
436
 
437
  if gen_video and go_video:
438
+ src_for_video = (img_urls_norm[0] if img_urls_norm else (img_urls_raw[0] if img_urls_raw else None))
439
  if not src_for_video:
440
+ st.warning("No source image to create video.")
441
  else:
442
+ with st.spinner("Generating video…"):
443
+ v = post_video(src_for_video, gender=st.session_state.get("gender","auto"),
444
+ age_group=st.session_state.get("age_group","auto"))
 
 
 
 
 
 
445
  vurl = (v.get("result") or {}).get("video", {}).get("url")
446
  if vurl:
447
  st.session_state["results"]["video_url"] = vurl
448
+ # Update same placeholder in-place → no duplicate heading
449
+ vbytes = render_video_placeholder(video_slot, vurl, key_prefix=key_prefix)
450
  else:
451
+ st.info("Video URL not returned.")
452
 
453
+ # ZIP + GIF
454
  if named or vbytes:
455
  if vbytes:
456
  named.append(("video.mp4", vbytes))
457
  zip_bytes = make_zip(named)
458
+ st.download_button(
459
+ "Download All (ZIP)",
460
+ data=zip_bytes,
461
+ file_name="fitstudio_outputs.zip",
462
+ mime="application/zip",
463
+ key=unique_key(f"{key_prefix}_zip_dl")
464
+ )
465
+ if vbytes:
466
+ try:
467
+ with st.spinner("Preparing GIF…"):
468
+ gif_bytes = mp4_to_gif_bytes(vbytes, target_w=GIF_MAX_W, fps=GIF_FPS, max_frames=GIF_MAX_FRAMES)
469
+ st.download_button(
470
+ f"Download GIF ({GIF_MAX_W}px, {GIF_FPS}fps)",
471
+ data=gif_bytes,
472
+ file_name="fitstudio_video.gif",
473
+ mime="image/gif",
474
+ key=unique_key(f"{key_prefix}_gif_dl")
475
+ )
476
+ except Exception as e:
477
+ st.info(f"GIF conversion not available: {e}. Try installing ffmpeg / imageio-ffmpeg.")
478
 
479
  # ================= Sidebar =================
480
  with st.sidebar:
481
  st.caption(f"Resolved API_BASE: {API_BASE}")
482
  st.caption("Auth header: " + ("ON" if HF_TOKEN else "OFF"))
483
+ st.markdown("---")
484
+ st.markdown("**About this demo**")
485
+ st.caption("Production-style, no local persistence. Built to show scalable image→video chaining with guardrails for consistent 9:16 outputs.")
486
+ if st.button("Ping /health", key=unique_key("ping_health")):
487
  try:
488
  r = requests.get(f"{API_BASE}/health", headers=HEADERS if HF_TOKEN else None, timeout=10)
489
  st.write(r.status_code)
 
491
  except Exception: st.code((r.text or "")[:1000])
492
  except Exception as e:
493
  st.error(f"Health error: {e}")
494
+ if st.button("Clear cache", key=unique_key("clear_cache")):
 
495
  st.cache_data.clear(); st.success("Cache cleared.")
496
+ if st.button("Clear results", key=unique_key("clear_results")):
497
  st.session_state["results"] = None; st.success("Results cleared.")