renderfy commited on
Commit
ae87190
·
verified ·
1 Parent(s): 8da8c3d

Upload streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +294 -166
streamlit_app.py CHANGED
@@ -1,13 +1,13 @@
1
- # streamlit_app.py — LamboVision (Car) UI v0.7.0
2
- # Konfigüratör: model, color, finish, wheel, caliper + scene preset → backend'e prompt'lar olarak gider.
3
 
4
  import os, io, base64, zipfile, requests, streamlit as st
5
  from PIL import Image
6
 
7
- # ===== Theme (programmatic dark orange) =====
8
  try:
9
  st._config.set_option("theme.base", "dark")
10
- st._config.set_option("theme.primaryColor", "#ff7a1a")
11
  st._config.set_option("theme.backgroundColor", "#0e0f12")
12
  st._config.set_option("theme.secondaryBackgroundColor", "#161a1f")
13
  st._config.set_option("theme.textColor", "#e7e9ed")
@@ -15,8 +15,8 @@ except Exception:
15
  pass
16
 
17
  # ================= Env & API =================
18
- API_BASE = (os.getenv("LAMBO_API_BASE", os.getenv("AI_LIGHTBOX_API", "http://127.0.0.1:7860")) or "http://127.0.0.1:7860").strip().strip("'\"").rstrip("/")
19
- HF_TOKEN = (os.getenv("LAMBO_API_TOKEN", os.getenv("AI_LIGHTBOX_TOKEN", "")) or "").strip().strip("'\"")
20
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
21
 
22
  VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))
@@ -24,8 +24,6 @@ VIDEO_RES_OPTIONS = ["512P", "768P"]
24
  VIDEO_DUR_OPTIONS = ["6", "10"]
25
  ASPECT_OPTIONS = ["16:9", "9:16", "1:1", "4:3"]
26
 
27
- SCENE_PRESETS = ["Studio Cinematic", "Monaco Sunset", "Dubai Night", "Track Day", "Factory Hall"]
28
-
29
  # ================= Session =================
30
  if "results" not in st.session_state:
31
  st.session_state["results"] = None
@@ -35,14 +33,6 @@ if "video_ui_max_px" not in st.session_state:
35
  st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT
36
 
37
  # ================= Helpers =================
38
- def backend_ok() -> bool:
39
- try:
40
- r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)
41
- r.raise_for_status()
42
- return True
43
- except Exception:
44
- return False
45
-
46
  def _needs_auth(url: str) -> bool:
47
  return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/")
48
 
@@ -57,20 +47,28 @@ def _abs_backend_url(url_or_path: str) -> str:
57
  return f"{API_BASE}/{url_or_path}"
58
  return f"{API_BASE}/{url_or_path.lstrip('/')}"
59
 
 
 
 
 
 
 
 
 
60
  def post_chain(data: dict, files_payload):
61
  r = requests.post(f"{API_BASE}/v1/tryon/chain", data=data, files=files_payload or None,
62
  headers=HEADERS, timeout=600)
63
  r.raise_for_status()
64
  return r.json()
65
 
66
- def post_video(image_url: str, duration="6", resolution="768P", preview_aspect="16:9", prompt=""):
67
  payload = {
68
  "image_url": image_url,
69
  "duration": duration,
70
  "resolution": resolution,
71
- "prompt_optimizer": "false",
72
  "preview_aspect": preview_aspect,
73
- "prompt": prompt or "", # backend default lifestyle prompt’ı kullanır
 
74
  }
75
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
76
  r.raise_for_status()
@@ -88,12 +86,16 @@ def fetch_bytes(url_or_path: str):
88
  except Exception:
89
  return None
90
 
91
- def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
92
  try:
93
  fmt = (Image.open(io.BytesIO(b)).format or "JPEG").lower()
 
94
  except Exception:
95
- fmt = "jpeg"
96
- mime = mime or {"jpeg":"image/jpeg","jpg":"image/jpeg","png":"image/png","webp":"image/webp"}.get(fmt,"image/jpeg")
 
 
 
97
  enc = base64.b64encode(b).decode("ascii")
98
  return f"data:{mime};base64,{enc}"
99
 
@@ -121,31 +123,43 @@ def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
121
  if b: z.writestr(fname, b)
122
  buf.seek(0); return buf.read()
123
 
 
 
 
 
 
 
124
  def inject_base_css():
125
  st.markdown("""
126
  <style>
127
  .stApp { background: #0e0f12; }
128
  header[data-testid="stHeader"] { background: transparent; }
129
  .block-container { padding-top: 1.25rem; }
 
 
 
 
 
 
 
 
 
 
130
  .lb-card-title { font-weight: 700; font-size: 1.05rem; margin: 2px 0 8px 0; color: #eaecef; }
131
  .lb-frame {
132
- position: relative; width: 100%; border-radius: 12px; overflow: hidden;
133
- background: #111419; display:flex; align-items:center; justify-content:center;
 
 
134
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
135
  }
136
  .lb-frame img { width:100%; height:100%; display:block; }
 
137
  .lb-video video { border-radius:8px; max-width: 540px; width:100%; height:auto; }
138
- label { color: #ff8a33 !important; font-weight: 600 !important; }
139
- .stButton > button[kind="primary"]{ background:#ff7a1a !important; border:0 !important; color:#111 !important; }
140
  </style>
141
  """, unsafe_allow_html=True)
142
 
143
- def _parse_aspect_str(s: str):
144
- try:
145
- a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
146
- except Exception:
147
- return (16, 9)
148
-
149
  def inject_preview_css(aspect: str, fit_mode="contain"):
150
  aw, ah = _parse_aspect_str(aspect)
151
  st.markdown(f"""
@@ -168,7 +182,6 @@ def show_tile_card_display_download(
168
  if not display_url and not display_bytes:
169
  col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
170
  col.caption("—"); return None, None
171
-
172
  if display_bytes is not None:
173
  src = image_bytes_to_data_url(display_bytes)
174
  col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
@@ -189,8 +202,63 @@ def show_tile_card_display_download(
189
  mime=mime, key=f"{key_prefix}_{filename_stub}_dl")
190
  else:
191
  col.caption("—")
 
192
 
193
- # ---- Prompt builders (frontend) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str:
195
  base = (
196
  "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. "
@@ -205,40 +273,35 @@ def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style:
205
  f" Subject: {model_name}. "
206
  f"Change body color to {body_color} with {finish} finish. "
207
  f"Wheels: {wheel_style}; brake calipers {caliper_color}. "
208
- "Maintain original stance; no suspension changes; no aero additions. "
209
- "Avoid warped wheels, distorted logos, text artifacts, fake reflections, stretched tires."
210
  )
211
  if extra.strip():
212
  cfg += " " + extra.strip()
213
  return base + " " + cfg
214
 
215
- def build_video_prompt(scene: str, extras: str = "") -> str:
216
- mapping = {
217
- "Monaco Sunset": "Cinematic boulevard at golden hour, subtle dolly-in, clean highlights.",
218
- "Dubai Night": "Glistening city lights at night, slow lateral parallax, dry road.",
219
- "Studio Cinematic": "High-end studio turntable feel, controlled reflections, no props.",
220
- "Track Day": "Asphalt track background, shallow depth, subtle camera micro-jitter.",
221
- "Factory Hall": "Clean factory interior, cool lights, gentle dolly-forward.",
222
- }
223
- base = (
224
- "Cinematic short clip of the given Lamborghini image. Subtle dolly or parallax; "
225
- "no zoom morph; no geometry distortion. Realistic paint reflections. "
226
- "Keep logos intact; preserve round wheels; natural camera micro-motion."
227
- )
228
- out = base + " " + mapping.get(scene, "")
229
- if extras.strip():
230
- out += " " + extras.strip()
231
- return out
232
 
233
  # ================= Page =================
234
  st.set_page_config(page_title="LamboVision · Car Configurator", layout="wide", page_icon="🏎️")
235
  inject_base_css()
236
 
237
  st.title("🏎️ LamboVision · AI Car Studio")
238
- st.caption("Upload · Configure · Generate Cinematic")
 
239
  ok = backend_ok()
240
 
241
- # -------- Top bar --------
242
  col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.0, 1.2, 0.9, 1.1, 0.9])
243
  with col1:
244
  file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")
@@ -247,11 +310,12 @@ with col2:
247
  with col3:
248
  category = st.selectbox("Category", ["auto","car","vehicle"], index=0, key="category")
249
  with col4:
250
- preview_ratio = st.number_input("Preview Scale (0.10–0.99)", min_value=0.10, max_value=0.99, value=0.92, step=0.01, key="preview_ratio")
 
251
  with col5:
252
  upscale = st.checkbox("Super Res", False, key="upscale")
253
  with col6:
254
- upscale_stage = st.selectbox("SR Step", ["both","final","packshot"], index=0, key="upscale_stage")
255
  with col7:
256
  upscale_factor = st.selectbox("SR Factor", ["2","4"], index=0, key="upscale_factor")
257
 
@@ -259,30 +323,19 @@ colA, colB, colC, colD, colE, colF = st.columns([1.0, 1.0, 1.1, 0.9, 0.9, 1.2])
259
  with colA:
260
  st.info(f"Connection: {'Online' if ok else 'Offline'}", icon="🔌")
261
  with colB:
262
- to_video = st.checkbox("Generate Video", True, key="to_video")
263
  with colC:
264
- duration = st.selectbox("Duration", VIDEO_DUR_OPTIONS, index=0, key="duration_sel")
265
  with colD:
266
- resolution = st.selectbox("Video Res", VIDEO_RES_OPTIONS, index=1, key="resolution_sel")
267
  with colE:
268
- pass
269
  with colF:
270
  run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")
271
 
272
- # Canvas/frame controls
273
- colT1, colT2, colT3 = st.columns([1.0, 1.0, 1.0])
274
- with colT1:
275
- preview_aspect = st.selectbox("Preview Frame Ratio", ASPECT_OPTIONS, index=0, key="preview_aspect")
276
- with colT2:
277
- preview_long_edge = st.number_input("Preview Long Edge (px)", min_value=256, max_value=8192, value=1920, step=64, key="preview_long_edge")
278
- with colT3:
279
- fit_mode = st.selectbox("Preview Layout", ["fit","fill"], index=0, key="fit_mode")
280
-
281
- inject_preview_css(preview_aspect, fit_mode="contain" if fit_mode=="fit" else "cover")
282
-
283
- # -------- Configurator --------
284
  st.markdown("### Configurator")
285
- c1, c2, c3, c4, c5, c6 = st.columns([1.6, 1.0, 0.9, 1.2, 1.0, 1.2])
286
  with c1:
287
  model_name = st.text_input("Model", value="Lamborghini Revuelto", key="cfg_model")
288
  with c2:
@@ -296,74 +349,100 @@ with c5:
296
  with c6:
297
  extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, ...", key="cfg_extra")
298
 
299
- st.markdown("### Scene")
300
- s1, s2 = st.columns([1.2, 2.0])
 
301
  with s1:
302
- scene = st.selectbox("Preset", SCENE_PRESETS, index=0, key="scene")
303
  with s2:
304
- scene_extra = st.text_input("Scene extra (optional)", value="", placeholder="light rain on track, dawn fog, ...", key="scene_extra")
 
 
 
 
305
 
306
- # ---- Previews (static/demo if provided locally) ----
307
- st.subheader("Previews")
 
 
 
308
  col_in, col_pack, col_pad, col_vid = st.columns(4)
309
- demo_input = None
 
310
  if file_list:
311
- try: demo_input = file_list[0].getvalue()
312
- except Exception: demo_input = None
313
- show_tile_card_display_download(col_in, "Sample Input preview", display_bytes=demo_input, download_bytes=demo_input, filename_stub="input", key_prefix="pre")
314
- pack_bytes_demo = demo_input
315
- show_tile_card_display_download(col_pack, "Sample Packshot (Studio)", display_bytes=pack_bytes_demo, download_bytes=pack_bytes_demo, filename_stub="packshot", key_prefix="pre")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  padded_bytes = None
318
  if pack_bytes_demo:
319
  try:
320
- # UI-only pad preview
321
- from PIL import Image as _Img
322
- im = _Img.open(io.BytesIO(pack_bytes_demo)).convert("RGBA")
323
- aw, ah = [int(x) for x in (preview_aspect.split(":") if ":" in preview_aspect else ["16","9"])]
324
- if aw >= ah:
325
- W = int(preview_long_edge); H = max(1, int(W * ah / aw))
326
- else:
327
- H = int(preview_long_edge); W = max(1, int(H * aw / ah))
328
- ratio = max(0.10, min(0.99, float(preview_ratio)))
329
- target = int(min(W, H) * ratio if aw == ah else (H * ratio if aw < ah else W * ratio))
330
- w, h = im.size
331
- if w >= h: new_w, new_h = target, max(1, int(h * (target / max(1, w))))
332
- else: new_h, new_w = target, max(1, int(w * (new_h / max(1, h))))
333
- im2 = im.resize((new_w, new_h), _Img.LANCZOS)
334
- canvas = _Img.new("RGBA", (W, H), (255,255,255,255))
335
- off = ((W - new_w)//2, (H - new_h)//2); canvas.paste(im2, off, im2)
336
- out = io.BytesIO(); canvas.convert("RGB").save(out, format="JPEG", quality=90, subsampling=1)
337
- padded_bytes = out.getvalue()
338
  except Exception:
339
  padded_bytes = None
340
-
341
- show_tile_card_display_download(col_pad, "Sample Padded Preview ", display_bytes=padded_bytes or pack_bytes_demo, download_bytes=padded_bytes or pack_bytes_demo, filename_stub="padded_ui", key_prefix="pre")
342
-
 
 
 
 
 
343
  col_vid.markdown('<div class="lb-card-title">AI Generated Video Preview</div>', unsafe_allow_html=True)
344
- col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
345
- col_vid.caption("—")
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
  # ================= RUN =================
348
  if run:
349
  if not ok:
350
  st.error("Backend erişilemiyor. API_BASE / TOKEN kontrol edin.")
351
- elif not (file_list or image_url):
352
  st.error("En az bir görsel girişi yapın (dosya yükle veya URL).")
353
  else:
354
  try:
355
- # Build prompts from UI
356
  edit_prompt = build_car_prompt(
357
  model_name=model_name,
358
  body_color=body_color,
359
  finish=finish,
360
  wheel_style=wheel_style,
361
  caliper_color=caliper_color,
362
- extra=extra_prompt,
363
  )
364
- video_prompt = build_video_prompt(scene=scene, extras=scene_extra)
365
 
366
- # Files
367
  img_urls = image_url or ""
368
  files_payload = []
369
  if file_list:
@@ -372,54 +451,67 @@ if run:
372
 
373
  data = {
374
  "category": category,
375
- "edit_prompt": edit_prompt,
376
  "num_images": "1",
377
  "image_urls": img_urls,
 
 
378
  # Preview controls
379
- "preview_aspect": st.session_state.get("preview_aspect","16:9"),
380
- "preview_long_edge": str(int(st.session_state.get("preview_long_edge", 1920))),
381
- "preview_ratio": str(float(st.session_state.get("preview_ratio", 0.92))),
382
- "padding_ratio": str(float(st.session_state.get("preview_ratio", 0.92))),
383
  # SR
384
- "upscale": "true" if st.session_state.get("upscale", False) else "false",
385
  "upscale_factor": st.session_state.get("upscale_factor","2"),
386
  "upscale_stage": st.session_state.get("upscale_stage","both"),
387
- # Video
388
- "to_video": "true" if st.session_state.get("to_video", True) else "false",
389
- "video_prompt": video_prompt,
390
  "duration": st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
391
  "resolution": st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
392
  "prompt_optimizer": "false",
393
  }
394
 
395
- with st.spinner("Generating Packshot and (optional) Video…"):
396
  out = post_chain(data, files_payload if files_payload else None)
397
 
398
  st.session_state["job_counter"] += 1
399
 
400
- # Extract outputs
401
- pack_native_url = (out["packshot_step"]["result"]["images_native"] or [{}])[0].get("url")
402
- padded_native_url = (out["packshot_step"]["result"]["padded_native"] or [{}])[0].get("url")
403
- vurl = (out.get("video_step") or {}).get("result", {}).get("video", {}).get("url")
404
-
405
- # Make display JPGs for fast UI
406
- pack_display_jpeg = None
407
- if pack_native_url:
408
- pn = fetch_bytes(pack_native_url)
409
- if pn:
410
- im = Image.open(io.BytesIO(pn))
411
- if im.mode == "RGBA" or ("transparency" in im.info):
412
- bg = Image.new("RGBA", im.size, (255,255,255,255)); bg.paste(im, (0,0), im); im = bg.convert("RGB")
413
- else:
414
- im = im.convert("RGB")
415
- outb = io.BytesIO(); im.save(outb, format="JPEG", quality=88, subsampling=1); pack_display_jpeg = outb.getvalue()
416
-
417
- padded_display_jpeg = None
418
- if padded_native_url:
419
- nb = fetch_bytes(padded_native_url)
420
- if nb:
421
- im = Image.open(io.BytesIO(nb)).convert("RGB")
422
- outb = io.BytesIO(); im.save(outb, format="JPEG", quality=88, subsampling=1); padded_display_jpeg = outb.getvalue()
 
 
 
 
 
 
 
 
 
 
 
423
 
424
  st.session_state["results"] = {
425
  "job_id": out.get("job_id"),
@@ -428,10 +520,12 @@ if run:
428
  "packshot_display_jpeg": pack_display_jpeg,
429
  "padded_native_url": padded_native_url,
430
  "padded_display_jpeg": padded_display_jpeg,
 
 
431
  "video_url": vurl,
432
  "api_base": API_BASE,
433
  "has_auth_header": bool(HEADERS),
434
- "preview_aspect": st.session_state.get("preview_aspect","16:9"),
435
  }
436
 
437
  except requests.HTTPError as e:
@@ -444,7 +538,7 @@ res = st.session_state.get("results")
444
  vbytes = None
445
  if res:
446
  st.subheader("Results")
447
- c_pack, c_pad, c_vid = st.columns([1.2, 1.2, 1.6])
448
 
449
  show_tile_card_display_download(
450
  c_pack, "Packshot (Studio)",
@@ -462,6 +556,14 @@ if res:
462
  key_prefix=f"job{st.session_state['job_counter']}"
463
  )
464
 
 
 
 
 
 
 
 
 
465
  c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
466
  vurl = res.get("video_url")
467
  if vurl:
@@ -469,7 +571,7 @@ if res:
469
  c_vid.markdown(
470
  f"""
471
  <div class="lb-frame" style="display:flex;align-items:center;justify-content:center;">
472
- <video autoplay loop muted controls playsinline style="width:100%;height:100%;object-fit:cover;max-width:{st.session_state['video_ui_max_px']}px;">
473
  <source src="{abs_v}" type="video/mp4">
474
  </video>
475
  </div>
@@ -482,25 +584,51 @@ if res:
482
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
483
  else:
484
  c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
485
- # Generate on demand
486
- src_for_video = res.get("packshot_native_url")
487
- if src_for_video and st.button("Generate Video From This Result"):
488
- with st.spinner("Generating Video …"):
489
- try:
490
- v = post_video(
491
- src_for_video,
492
- duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
493
- resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
494
- preview_aspect=st.session_state.get("preview_aspect","16:9"),
495
- prompt=build_video_prompt(scene=scene, extras=scene_extra),
496
- )
 
 
 
 
 
 
 
 
 
497
  vurl2 = (v.get("result") or {}).get("video", {}).get("url")
 
498
  if vurl2:
499
  st.session_state["results"]["video_url"] = vurl2
500
- st.success("Video is ready!")
 
 
 
501
  st.rerun()
502
- except requests.HTTPError as e:
503
- st.error(f"HTTP {e.response.status_code}")
 
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
  # ================= Sidebar =================
506
  with st.sidebar:
@@ -509,4 +637,4 @@ with st.sidebar:
509
  st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"],
510
  step=10, key="video_ui_max_px")
511
  if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.")
512
- if st.button("Clear last results"): st.session_state["results"] = None; st.success("Cleared.")
 
1
+ # streamlit_app.py — LamboVision (Car) UI
2
+ # Pre-run Sample Preview kartları + Run sonrası sonuç kartları (Packshot/Padded/Composite/Video)
3
 
4
  import os, io, base64, zipfile, requests, streamlit as st
5
  from PIL import Image
6
 
7
+ # ===== Theme (dark + Lamborghini orange) =====
8
  try:
9
  st._config.set_option("theme.base", "dark")
10
+ st._config.set_option("theme.primaryColor", "#ff7a1a") # dark orange
11
  st._config.set_option("theme.backgroundColor", "#0e0f12")
12
  st._config.set_option("theme.secondaryBackgroundColor", "#161a1f")
13
  st._config.set_option("theme.textColor", "#e7e9ed")
 
15
  pass
16
 
17
  # ================= Env & API =================
18
+ API_BASE = (os.getenv("AI_LamboVision_API", "http://127.0.0.1:7860") or "http://127.0.0.1:7860").strip().strip("'\"").rstrip("/")
19
+ HF_TOKEN = (os.getenv("AI_LamboVision_TOKEN", "") or "").strip().strip("'\"")
20
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
21
 
22
  VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))
 
24
  VIDEO_DUR_OPTIONS = ["6", "10"]
25
  ASPECT_OPTIONS = ["16:9", "9:16", "1:1", "4:3"]
26
 
 
 
27
  # ================= Session =================
28
  if "results" not in st.session_state:
29
  st.session_state["results"] = None
 
33
  st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT
34
 
35
  # ================= Helpers =================
 
 
 
 
 
 
 
 
36
  def _needs_auth(url: str) -> bool:
37
  return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/")
38
 
 
47
  return f"{API_BASE}/{url_or_path}"
48
  return f"{API_BASE}/{url_or_path.lstrip('/')}"
49
 
50
+ def backend_ok() -> bool:
51
+ try:
52
+ r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)
53
+ r.raise_for_status()
54
+ return True
55
+ except Exception:
56
+ return False
57
+
58
  def post_chain(data: dict, files_payload):
59
  r = requests.post(f"{API_BASE}/v1/tryon/chain", data=data, files=files_payload or None,
60
  headers=HEADERS, timeout=600)
61
  r.raise_for_status()
62
  return r.json()
63
 
64
+ def post_video(image_url: str, duration="6", resolution="768P", prompt_optimizer=False, preview_aspect="16:9", prompt_text=""):
65
  payload = {
66
  "image_url": image_url,
67
  "duration": duration,
68
  "resolution": resolution,
 
69
  "preview_aspect": preview_aspect,
70
+ "prompt_optimizer": "true" if prompt_optimizer else "false",
71
+ "prompt": prompt_text or "", # backend boşsa kendi defaults'unu kullanır
72
  }
73
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
74
  r.raise_for_status()
 
86
  except Exception:
87
  return None
88
 
89
+ def _sniff_mime_from_bytes(b: bytes) -> str:
90
  try:
91
  fmt = (Image.open(io.BytesIO(b)).format or "JPEG").lower()
92
+ return {"jpeg":"image/jpeg","jpg":"image/jpeg","png":"image/png","webp":"image/webp"}.get(fmt,"image/jpeg")
93
  except Exception:
94
+ return "image/jpeg"
95
+
96
+ def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
97
+ if not mime:
98
+ mime = _sniff_mime_from_bytes(b)
99
  enc = base64.b64encode(b).decode("ascii")
100
  return f"data:{mime};base64,{enc}"
101
 
 
123
  if b: z.writestr(fname, b)
124
  buf.seek(0); return buf.read()
125
 
126
+ def _parse_aspect_str(s: str):
127
+ try:
128
+ a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
129
+ except Exception:
130
+ return (16, 9)
131
+
132
  def inject_base_css():
133
  st.markdown("""
134
  <style>
135
  .stApp { background: #0e0f12; }
136
  header[data-testid="stHeader"] { background: transparent; }
137
  .block-container { padding-top: 1.25rem; }
138
+ h1, .stMarkdown h1 { font-size: 1.85rem; line-height: 1.3; margin-bottom: 0.15rem; }
139
+ .stMarkdown p { margin-top: 0.15rem; }
140
+ label, .stTextInput label, .stSelectbox label, .stNumberInput label, .stFileUploader label,
141
+ .stCheckbox label, .stSlider label, .stRadio label {
142
+ color: #ff8a33 !important; font-weight: 600 !important;
143
+ }
144
+ .stButton > button[kind="primary"]{
145
+ background: #ff7a1a !important; border: 0 !important; color: #111 !important;
146
+ }
147
+ .stButton > button:hover { filter: brightness(1.05); }
148
  .lb-card-title { font-weight: 700; font-size: 1.05rem; margin: 2px 0 8px 0; color: #eaecef; }
149
  .lb-frame {
150
+ position: relative; width: 100%;
151
+ border-radius: 12px; overflow: hidden;
152
+ background: #111419;
153
+ display:flex; align-items:center; justify-content:center;
154
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
155
  }
156
  .lb-frame img { width:100%; height:100%; display:block; }
157
+ .lb-video { display:flex; justify-content:center; }
158
  .lb-video video { border-radius:8px; max-width: 540px; width:100%; height:auto; }
159
+ .stCaption { color: #ffb07a !important; }
 
160
  </style>
161
  """, unsafe_allow_html=True)
162
 
 
 
 
 
 
 
163
  def inject_preview_css(aspect: str, fit_mode="contain"):
164
  aw, ah = _parse_aspect_str(aspect)
165
  st.markdown(f"""
 
182
  if not display_url and not display_bytes:
183
  col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
184
  col.caption("—"); return None, None
 
185
  if display_bytes is not None:
186
  src = image_bytes_to_data_url(display_bytes)
187
  col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
 
202
  mime=mime, key=f"{key_prefix}_{filename_stub}_dl")
203
  else:
204
  col.caption("—")
205
+ return display_url or "data", native
206
 
207
+ def rgba_to_rgb_on_bg(img_rgba: Image.Image, bg_rgb=(255,255,255)) -> Image.Image:
208
+ if img_rgba.mode != "RGBA":
209
+ return img_rgba.convert("RGB")
210
+ bg = Image.new("RGBA", img_rgba.size, (*bg_rgb, 255))
211
+ bg.paste(img_rgba, (0,0), img_rgba)
212
+ return bg.convert("RGB")
213
+
214
+ @st.cache_data(ttl=300, show_spinner=False)
215
+ def make_small_jpeg_from_native(native: bytes, target_long_edge: int = 1280, bg_rgb=(255,255,255)) -> bytes:
216
+ im = Image.open(io.BytesIO(native))
217
+ if im.mode != "RGBA" and "transparency" not in im.info:
218
+ im = im.convert("RGB")
219
+ else:
220
+ im = im.convert("RGBA")
221
+ w, h = im.size
222
+ if w >= h:
223
+ new_w = min(target_long_edge, w)
224
+ new_h = max(1, int(h * (new_w / max(1, w))))
225
+ else:
226
+ new_h = min(target_long_edge, h)
227
+ new_w = max(1, int(w * (new_h / max(1, h))))
228
+ im2 = im.resize((new_w, new_h), Image.LANCZOS)
229
+ im2_rgb = rgba_to_rgb_on_bg(im2, bg_rgb=bg_rgb)
230
+ out = io.BytesIO(); im2_rgb.save(out, format="JPEG", quality=88, subsampling=1)
231
+ return out.getvalue()
232
+
233
+ def make_preview_frame(img_bytes: bytes, aspect: str, long_edge: int, ratio: float, bg_rgb=(255,255,255)) -> bytes:
234
+ im = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
235
+ a, b = _parse_aspect_str(aspect)
236
+ if a >= b:
237
+ W = max(256, min(8192, int(long_edge))); H = max(1, int(W * b / a))
238
+ else:
239
+ H = max(256, min(8192, int(long_edge))); W = max(1, int(H * a / b))
240
+ ratio = max(0.10, min(0.99, float(ratio)))
241
+ target = int(min(W, H) * ratio if a == b else (H * ratio if a < b else W * ratio))
242
+ w, h = im.size
243
+ if w >= h: new_w, new_h = target, max(1, int(h * (target / max(1, w))))
244
+ else: new_h, new_w = target, max(1, int(w * (target / max(1, h))))
245
+ im_resized = im.resize((new_w, new_h), Image.LANCZOS)
246
+ canvas = Image.new("RGBA", (W, H), (*bg_rgb, 255))
247
+ off = ((W - new_w)//2, (H - new_h)//2); canvas.paste(im_resized, off, im_resized)
248
+ out = io.BytesIO(); canvas.convert("RGB").save(out, format="JPEG", quality=90, subsampling=1)
249
+ return out.getvalue()
250
+
251
+ # ================= Local demo assets (optional) =================
252
+ def load_local_demo():
253
+ demo = {}
254
+ for fname in ["input.jpg", "packshot.jpg", "model.jpg", "tryon.mp4"]:
255
+ if os.path.exists(fname):
256
+ with open(fname, "rb") as f: demo[fname] = f.read()
257
+ return demo
258
+
259
+ demo_files = load_local_demo()
260
+
261
+ # ================= Prompt builders =================
262
  def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str:
263
  base = (
264
  "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. "
 
273
  f" Subject: {model_name}. "
274
  f"Change body color to {body_color} with {finish} finish. "
275
  f"Wheels: {wheel_style}; brake calipers {caliper_color}. "
276
+ "Maintain original stance; no suspension changes; no aero additions."
 
277
  )
278
  if extra.strip():
279
  cfg += " " + extra.strip()
280
  return base + " " + cfg
281
 
282
+ SCENE_PRESETS = {
283
+ "Monaco Sunset": "Monaco coastal boulevard at golden hour, subtle dolly-in, realistic paint reflections, no artifacts.",
284
+ "Dubai Night": "Dubai Marina night city lights, gentle lateral parallax, crisp highlights, no motion blur.",
285
+ "Studio Cinematic": "High-end studio cyclorama, soft rim light, tripod-like stability, micro-motion only."
286
+ }
287
+
288
+ def build_video_prompt(scene_name: str, extra: str = "") -> str:
289
+ base = SCENE_PRESETS.get(scene_name, "High-end studio, subtle parallax, natural camera micro-motion.")
290
+ lock = " Keep logos and wheel circles intact; no wheel warping; no added text; realistic reflections."
291
+ if extra.strip():
292
+ return base + " " + lock + " " + extra.strip()
293
+ return base + " " + lock
 
 
 
 
 
294
 
295
  # ================= Page =================
296
  st.set_page_config(page_title="LamboVision · Car Configurator", layout="wide", page_icon="🏎️")
297
  inject_base_css()
298
 
299
  st.title("🏎️ LamboVision · AI Car Studio")
300
+ st.caption("Configure your Lamborghini and generate instant cinematic previews for high-intent leads.")
301
+
302
  ok = backend_ok()
303
 
304
+ # -------- Top control bar --------
305
  col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.0, 1.2, 0.9, 1.1, 0.9])
306
  with col1:
307
  file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")
 
310
  with col3:
311
  category = st.selectbox("Category", ["auto","car","vehicle"], index=0, key="category")
312
  with col4:
313
+ preview_ratio = st.number_input("Preview Scale (0.10–0.99)", min_value=0.10, max_value=0.99, value=0.60, step=0.01,
314
+ help="Sadece önizleme kadrajında büyüklüğü etkiler.", key="preview_ratio")
315
  with col5:
316
  upscale = st.checkbox("Super Res", False, key="upscale")
317
  with col6:
318
+ upscale_stage = st.selectbox("SR Step", ["both","final","packshot"], index=0, help="Varsayılan: both", key="upscale_stage")
319
  with col7:
320
  upscale_factor = st.selectbox("SR Factor", ["2","4"], index=0, key="upscale_factor")
321
 
 
323
  with colA:
324
  st.info(f"Connection: {'Online' if ok else 'Offline'}", icon="🔌")
325
  with colB:
326
+ identity_lock = st.checkbox("Identity lock", True, key="identity_lock")
327
  with colC:
328
+ to_video = st.checkbox("One Click Generate Video", False, key="to_video")
329
  with colD:
330
+ duration = st.selectbox("Duration ", VIDEO_DUR_OPTIONS, index=0, key="duration_sel")
331
  with colE:
332
+ resolution = st.selectbox("Video Res", VIDEO_RES_OPTIONS, index=1, key="resolution_sel")
333
  with colF:
334
  run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")
335
 
336
+ # -------- Configurator row --------
 
 
 
 
 
 
 
 
 
 
 
337
  st.markdown("### Configurator")
338
+ c1, c2, c3, c4, c5, c6 = st.columns([1.4, 1.0, 0.9, 1.2, 1.0, 1.2])
339
  with c1:
340
  model_name = st.text_input("Model", value="Lamborghini Revuelto", key="cfg_model")
341
  with c2:
 
349
  with c6:
350
  extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, ...", key="cfg_extra")
351
 
352
+ # -------- Scene presets for video --------
353
+ st.markdown("### Scene Presets")
354
+ s1, s2, s3, s4 = st.columns([1.2, 1.2, 1.2, 1.2])
355
  with s1:
356
+ scene_name = st.selectbox("Scene", list(SCENE_PRESETS.keys()), index=0, key="scene_name")
357
  with s2:
358
+ scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra")
359
+ with s3:
360
+ preview_aspect = st.selectbox("Preview Frame Ratio", ASPECT_OPTIONS, index=0, key="preview_aspect")
361
+ with s4:
362
+ preview_long_edge = st.number_input("Preview Long Edge (px)", min_value=256, max_value=8192, value=1920, step=64, key="preview_long_edge")
363
 
364
+ fit_mode = st.selectbox("Preview Layout", ["fit","fill"], index=0, help="Sadece önizlemeyi etkiler.", key="fit_mode")
365
+ inject_preview_css(preview_aspect, fit_mode="contain" if fit_mode=="fit" else "cover")
366
+
367
+ # ================= PRE-RUN SAMPLE PREVIEWS =================
368
+ st.subheader("Sample Previews")
369
  col_in, col_pack, col_pad, col_vid = st.columns(4)
370
+
371
+ input_bytes = None
372
  if file_list:
373
+ try: input_bytes = file_list[0].getvalue()
374
+ except Exception: input_bytes = None
375
+ elif image_url:
376
+ input_bytes = fetch_bytes(image_url)
377
+ elif demo_files.get("input.jpg"):
378
+ input_bytes = demo_files["input.jpg"]
379
+
380
+ show_tile_card_display_download(
381
+ col_in, "Sample Input preview",
382
+ display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
383
+ download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
384
+ filename_stub="input", key_prefix="pre"
385
+ )
386
+
387
+ pack_bytes_demo = demo_files.get("packshot.jpg") or input_bytes
388
+ show_tile_card_display_download(
389
+ col_pack, "Sample Packshot (Studio)",
390
+ display_bytes=pack_bytes_demo,
391
+ download_bytes=pack_bytes_demo,
392
+ filename_stub="packshot", key_prefix="pre"
393
+ )
394
 
395
  padded_bytes = None
396
  if pack_bytes_demo:
397
  try:
398
+ padded_bytes = make_preview_frame(pack_bytes_demo, aspect=preview_aspect,
399
+ long_edge=int(preview_long_edge),
400
+ ratio=float(preview_ratio))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  except Exception:
402
  padded_bytes = None
403
+ show_tile_card_display_download(
404
+ col_pad, "Sample Padded Preview",
405
+ display_bytes=padded_bytes or pack_bytes_demo,
406
+ download_bytes=padded_bytes or pack_bytes_demo,
407
+ filename_stub="padded_ui", key_prefix="pre"
408
+ )
409
+
410
+ vid_demo = demo_files.get("tryon.mp4")
411
  col_vid.markdown('<div class="lb-card-title">AI Generated Video Preview</div>', unsafe_allow_html=True)
412
+ if vid_demo:
413
+ data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii")
414
+ col_vid.markdown(
415
+ f"""
416
+ <div class="lb-frame" style="display:flex;align-items:center;justify-content:center;">
417
+ <video autoplay loop muted playsinline style="width:100%;height:100%;object-fit:cover;">
418
+ <source src="{data_url}" type="video/mp4">
419
+ </video>
420
+ </div>
421
+ """, unsafe_allow_html=True
422
+ )
423
+ col_vid.download_button("Download Video (MP4)", data=vid_demo, file_name="tryon.mp4", mime="video/mp4", key="pre_video_dl")
424
+ else:
425
+ col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
426
+ col_vid.caption("—")
427
 
428
  # ================= RUN =================
429
  if run:
430
  if not ok:
431
  st.error("Backend erişilemiyor. API_BASE / TOKEN kontrol edin.")
432
+ elif not (file_list or image_url or demo_files.get("input.jpg")):
433
  st.error("En az bir görsel girişi yapın (dosya yükle veya URL).")
434
  else:
435
  try:
436
+ # Build edit prompt from configurator
437
  edit_prompt = build_car_prompt(
438
  model_name=model_name,
439
  body_color=body_color,
440
  finish=finish,
441
  wheel_style=wheel_style,
442
  caliper_color=caliper_color,
443
+ extra=extra_prompt
444
  )
 
445
 
 
446
  img_urls = image_url or ""
447
  files_payload = []
448
  if file_list:
 
451
 
452
  data = {
453
  "category": category,
454
+ "edit_prompt": edit_prompt, # backend doluysa bunu kullanır
455
  "num_images": "1",
456
  "image_urls": img_urls,
457
+ "mannequin_image_url": "", # otomotivde kullanılmıyor
458
+ "identity_lock": "true" if identity_lock else "false",
459
  # Preview controls
460
+ "preview_aspect": preview_aspect,
461
+ "preview_long_edge": str(int(preview_long_edge)),
462
+ "preview_ratio": str(float(preview_ratio)),
463
+ "padding_ratio": str(float(preview_ratio)),
464
  # SR
465
+ "upscale": "true" if upscale else "false",
466
  "upscale_factor": st.session_state.get("upscale_factor","2"),
467
  "upscale_stage": st.session_state.get("upscale_stage","both"),
468
+ # Video chain (optional)
469
+ "to_video": "true" if to_video else "false",
470
+ "video_prompt": build_video_prompt(scene_name, scene_extra) if to_video else "",
471
  "duration": st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
472
  "resolution": st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
473
  "prompt_optimizer": "false",
474
  }
475
 
476
+ with st.spinner("AI Chain Running…"):
477
  out = post_chain(data, files_payload if files_payload else None)
478
 
479
  st.session_state["job_counter"] += 1
480
 
481
+ # ---- Outputs ----
482
+ pack_native_url = None; pack_display_jpeg = None
483
+ try:
484
+ pack_native_url = (out["packshot_step"]["result"]["images_native"] or [{}])[0].get("url")
485
+ if pack_native_url:
486
+ pn = fetch_bytes(pack_native_url)
487
+ if pn: pack_display_jpeg = make_small_jpeg_from_native(pn, 1280, bg_rgb=(255,255,255))
488
+ except Exception: pass
489
+
490
+ padded_native_url = None; padded_display_jpeg = None
491
+ try:
492
+ padded_native_url = (out["packshot_step"]["result"]["padded_native"] or [{}])[0].get("url")
493
+ if padded_native_url:
494
+ nb = fetch_bytes(padded_native_url)
495
+ if nb: padded_display_jpeg = make_small_jpeg_from_native(nb, 1280, bg_rgb=(255,255,255))
496
+ except Exception: pass
497
+
498
+ placed_native_url = None; placed_display_jpeg = None
499
+ try:
500
+ placed_native_url = (out.get("image_step") or {}).get("result", {}).get("images_native", [{}])[0].get("url")
501
+ if placed_native_url:
502
+ mn = fetch_bytes(placed_native_url)
503
+ if mn: placed_display_jpeg = make_small_jpeg_from_native(mn, 1280, bg_rgb=(255,255,255))
504
+ except Exception: pass
505
+
506
+ vurl = None
507
+ try:
508
+ vurl = (out.get("video_step") or {}).get("result", {}).get("video", {}).get("url")
509
+ except Exception:
510
+ vurl = None
511
+
512
+ ui_hints = (out.get("video_step") or {}).get("ui_hints") or {}
513
+ if isinstance(ui_hints, dict):
514
+ st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))
515
 
516
  st.session_state["results"] = {
517
  "job_id": out.get("job_id"),
 
520
  "packshot_display_jpeg": pack_display_jpeg,
521
  "padded_native_url": padded_native_url,
522
  "padded_display_jpeg": padded_display_jpeg,
523
+ "placed_native_url": placed_native_url,
524
+ "placed_display_jpeg": placed_display_jpeg,
525
  "video_url": vurl,
526
  "api_base": API_BASE,
527
  "has_auth_header": bool(HEADERS),
528
+ "preview_aspect": preview_aspect,
529
  }
530
 
531
  except requests.HTTPError as e:
 
538
  vbytes = None
539
  if res:
540
  st.subheader("Results")
541
+ c_pack, c_pad, c_model, c_vid = st.columns(4)
542
 
543
  show_tile_card_display_download(
544
  c_pack, "Packshot (Studio)",
 
556
  key_prefix=f"job{st.session_state['job_counter']}"
557
  )
558
 
559
+ show_tile_card_display_download(
560
+ c_model, "Composite",
561
+ display_bytes=res.get("placed_display_jpeg"),
562
+ download_url=res.get("placed_native_url"),
563
+ filename_stub="composite_native",
564
+ key_prefix=f"job{st.session_state['job_counter']}"
565
+ )
566
+
567
  c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
568
  vurl = res.get("video_url")
569
  if vurl:
 
571
  c_vid.markdown(
572
  f"""
573
  <div class="lb-frame" style="display:flex;align-items:center;justify-content:center;">
574
+ <video autoplay loop muted controls playsinline style="width:100%;height:100%;object-fit:cover;">
575
  <source src="{abs_v}" type="video/mp4">
576
  </video>
577
  </div>
 
584
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
585
  else:
586
  c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
587
+ vid_checkbox_key = f"video_toggle_job{st.session_state['job_counter']}"
588
+ if st.checkbox("Generate Video From This Result", value=False, key=vid_checkbox_key):
589
+ src_for_video = res.get("packshot_native_url") or res.get("placed_native_url")
590
+ if not src_for_video:
591
+ st.warning("Video için kullanılacak native görsel bulunamadı.")
592
+ else:
593
+ with st.spinner("Generating Video …"):
594
+ try:
595
+ v = post_video(
596
+ src_for_video,
597
+ duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
598
+ resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
599
+ preview_aspect=st.session_state.get("preview_aspect","16:9"),
600
+ prompt_text=build_video_prompt(st.session_state.get("scene_name","Studio Cinematic"),
601
+ st.session_state.get("scene_extra","")),
602
+ prompt_optimizer=False,
603
+ )
604
+ except requests.HTTPError as e:
605
+ st.error(f"HTTP {e.response.status_code}")
606
+ v = None
607
+ if v:
608
  vurl2 = (v.get("result") or {}).get("video", {}).get("url")
609
+ ui_hints = v.get("ui_hints") or {}
610
  if vurl2:
611
  st.session_state["results"]["video_url"] = vurl2
612
+ if isinstance(ui_hints, dict):
613
+ st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))
614
+ st.session_state.pop(vid_checkbox_key, None)
615
+ st.success("Video is Ready! Showing preview…")
616
  st.rerun()
617
+ else:
618
+ st.info("Video URL gelmedi.")
619
+
620
+ # ZIP
621
+ named = []
622
+ pn = fetch_bytes(res.get("packshot_native_url")) if res.get("packshot_native_url") else None
623
+ mn = fetch_bytes(res.get("placed_native_url")) if res.get("placed_native_url") else None
624
+ if pn: named.append((f"packshot_native{infer_ext(pn)}", pn))
625
+ if mn: named.append((f"composite_native{infer_ext(mn)}", mn))
626
+ if vbytes: named.append(("video.mp4", vbytes))
627
+ if named:
628
+ zip_bytes = make_zip(named)
629
+ st.download_button("Download All (ZIP)", data=zip_bytes,
630
+ file_name="lambovision_outputs.zip", mime="application/zip",
631
+ key=f"job{st.session_state['job_counter']}_zip_dl")
632
 
633
  # ================= Sidebar =================
634
  with st.sidebar:
 
637
  st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"],
638
  step=10, key="video_ui_max_px")
639
  if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.")
640
+ if st.button("Clear results"): st.session_state["results"] = None; st.success("Cleared.")