renderfy commited on
Commit
83cb36f
Β·
verified Β·
1 Parent(s): 8549301

Upload streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +159 -138
streamlit_app.py CHANGED
@@ -1,8 +1,6 @@
1
  # streamlit_app.py β€” LamboVision Β· AI Car Studio (Automotive, 3-step flow, no padded)
2
- # Step 1: Gallery (clean packshot, native)
3
- # Step 2: AI Composition (config prompt, native)
4
- # Step 3: AI Generated Video (from composition native)
5
- # Uniform tiles by CSS aspect; images/videos fit via object-fit: contain. No padding images are generated.
6
 
7
  import os, io, base64, zipfile, requests, streamlit as st
8
  from PIL import Image
@@ -25,7 +23,7 @@ HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
25
  VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))
26
  VIDEO_RES_OPTIONS = ["512P", "768P"]
27
  VIDEO_DUR_OPTIONS = ["6", "10"]
28
- ASPECT_OPTIONS = ["3:4", "9:16", "1:1", "16:9"] # visual frame only
29
 
30
  # ================= Session =================
31
  if "results" not in st.session_state:
@@ -35,26 +33,21 @@ if "job_counter" not in st.session_state:
35
  if "video_ui_max_px" not in st.session_state:
36
  st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT
37
 
38
- # ================= HTTP helpers =================
39
  def _needs_auth(url: str) -> bool:
40
  return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/")
41
 
42
  def _abs_backend_url(url_or_path: str) -> str:
43
- if not url_or_path:
44
- return ""
45
- if url_or_path.startswith(("http://","https://")):
46
- return url_or_path
47
- if url_or_path.startswith("/outputs/"):
48
- return f"{API_BASE}{url_or_path}"
49
- if url_or_path.startswith("outputs/"):
50
- return f"{API_BASE}/{url_or_path}"
51
  return f"{API_BASE}/{url_or_path.lstrip('/')}"
52
 
53
  def backend_ok() -> bool:
54
  try:
55
  r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)
56
- r.raise_for_status()
57
- return True
58
  except Exception:
59
  return False
60
 
@@ -64,14 +57,14 @@ def post_image_edit(data: dict, files_payload):
64
  r.raise_for_status()
65
  return r.json()
66
 
67
- def post_video(image_url: str, duration="6", resolution="768P", aspect="3:4", prompt_text="", prompt_optimizer=False):
68
  payload = {
69
  "image_url": image_url,
70
  "duration": duration,
71
  "resolution": resolution,
72
- "aspect": aspect,
73
  "prompt": prompt_text or "",
74
- "prompt_optimizer": "true" if prompt_optimizer else "false",
75
  }
76
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
77
  r.raise_for_status()
@@ -97,8 +90,8 @@ def _sniff_mime_from_bytes(b: bytes) -> str:
97
  return "image/jpeg"
98
 
99
  def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
100
- if not mime:
101
- mime = _sniff_mime_from_bytes(b)
102
  enc = base64.b64encode(b).decode("ascii")
103
  return f"data:{mime};base64,{enc}"
104
 
@@ -115,8 +108,7 @@ def infer_ext(data: bytes) -> str:
115
  fmt = (im.format or "JPEG").lower()
116
  return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg")
117
  except Exception:
118
- if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp":
119
- return ".mp4"
120
  return ".bin"
121
 
122
  def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
@@ -126,13 +118,6 @@ def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
126
  if b: z.writestr(fname, b)
127
  buf.seek(0); return buf.read()
128
 
129
- # ================= UI CSS =================
130
- def _parse_aspect_str(s: str):
131
- try:
132
- a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
133
- except Exception:
134
- return (3, 4)
135
-
136
  def inject_base_css(card_fit_mode="contain"):
137
  st.markdown(f"""
138
  <style>
@@ -154,6 +139,7 @@ def inject_base_css(card_fit_mode="contain"):
154
  background: #111419;
155
  display:flex; align-items:center; justify-content:center;
156
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
 
157
  }}
158
  .lb-frame img, .lb-frame video {{
159
  width:100%; height:100%; display:block; object-fit:{card_fit_mode};
@@ -162,15 +148,24 @@ def inject_base_css(card_fit_mode="contain"):
162
  </style>
163
  """, unsafe_allow_html=True)
164
 
165
- def inject_preview_css(aspect: str):
166
- aw, ah = _parse_aspect_str(aspect)
167
- st.markdown(f"""
168
- <style>
169
- .lb-frame {{ aspect-ratio: {aw} / {ah}; }}
170
- </style>
171
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
172
 
173
- # ================= Prompts (builder) =================
 
 
174
  def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str:
175
  base = (
176
  "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. "
@@ -187,8 +182,7 @@ def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style:
187
  f"Wheels: {wheel_style}; brake calipers {caliper_color}. "
188
  "Maintain original stance; no suspension changes; no aero additions."
189
  )
190
- if extra.strip():
191
- cfg += " " + extra.strip()
192
  return base + " " + cfg
193
 
194
  SCENE_PRESETS = {
@@ -202,6 +196,26 @@ def build_video_prompt(scene_name: str, extra: str = "") -> str:
202
  if extra.strip(): return base + " " + lock + " " + extra.strip()
203
  return base + " " + lock
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  # ================= Page =================
206
  st.set_page_config(page_title="LamboVision Β· AI Car Studio", layout="wide", page_icon="🏎️")
207
  inject_base_css(card_fit_mode="contain")
@@ -212,13 +226,13 @@ st.caption("Step 1: Gallery (clean packshot) Β· Step 2: AI Composition (config)
212
  ok = backend_ok()
213
 
214
  # -------- Top control bar --------
215
- col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.1, 1.1, 1.0, 1.1, 1.1])
216
  with col1:
217
  file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")
218
  with col2:
219
  image_url = st.text_input("Or URL", key="image_url", placeholder="https://... (optional)")
220
  with col3:
221
- preview_aspect = st.selectbox("Tile Aspect", ASPECT_OPTIONS, index=0, key="preview_aspect") # only frame
222
  with col4:
223
  to_video = st.checkbox("Generate Video", False, key="to_video")
224
  with col5:
@@ -228,8 +242,7 @@ with col6:
228
  with col7:
229
  run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")
230
 
231
- inject_preview_css(preview_aspect)
232
- st.caption("Tiles share the same aspect ratio (CSS). Images and videos fit; no overflow. No padded images are generated.")
233
 
234
  # -------- Configurator (dropdowns + custom) --------
235
  st.markdown("### Configurator")
@@ -273,15 +286,17 @@ with c5:
273
  with c6:
274
  extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, …", key="cfg_extra")
275
 
276
- # -------- Sample Previews (no padded) --------
277
- def load_local_demo():
278
- demo = {}
279
- for fname in ["input.jpg", "packshot.jpg", "tryon.mp4"]:
280
- if os.path.exists(fname):
281
- with open(fname, "rb") as f: demo[fname] = f.read()
282
- return demo
283
- demo_files = load_local_demo()
 
284
 
 
285
  st.subheader("Sample Previews")
286
  col_in, col_gallery, col_comp, col_vid = st.columns(4)
287
 
@@ -294,32 +309,41 @@ elif image_url:
294
  elif demo_files.get("input.jpg"):
295
  input_bytes = demo_files["input.jpg"]
296
 
297
- def show_card(col, title, display_bytes=None, download_bytes=None, url=None, fname="image"):
298
  col.markdown(f'<div class="lb-card-title">{title}</div>', unsafe_allow_html=True)
 
 
299
  if display_bytes is not None:
300
- src = "data:" + (_sniff_mime_from_bytes(display_bytes)) + ";base64," + base64.b64encode(display_bytes).decode("ascii")
301
- col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
302
- elif url:
303
- abs_url = _abs_backend_url(url)
304
- col.markdown(f'<div class="lb-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True)
305
  else:
306
- col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
307
- col.caption("β€”"); return
308
- native = download_bytes or (fetch_bytes(url) if url else display_bytes)
309
  if native:
310
- sz = image_size_from_bytes(native)
311
  if sz: col.caption(f"Real Res: {sz[0]}Γ—{sz[1]} px")
312
- ext = infer_ext(native)
313
- mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp")
314
- col.download_button("Download (Full Quality)", data=native, file_name=f"{fname}{ext if ext!='.bin' else '.jpg'}", mime=mime)
 
 
 
 
 
 
 
 
 
 
 
315
 
316
- show_card(col_in, "Input", display_bytes=input_bytes or demo_files.get("input.jpg"), download_bytes=input_bytes or demo_files.get("input.jpg"), fname="input")
317
- show_card(col_gallery, "Gallery", display_bytes=demo_files.get("packshot.jpg") or input_bytes, download_bytes=demo_files.get("packshot.jpg") or input_bytes, fname="gallery")
318
- show_card(col_comp, "AI Composition", display_bytes=demo_files.get("packshot.jpg") or input_bytes, download_bytes=demo_files.get("packshot.jpg") or input_bytes, fname="ai_composition")
319
 
 
320
  col_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
321
- if demo_files.get("tryon.mp4"):
322
- data_url = "data:video/mp4;base64," + base64.b64encode(demo_files["tryon.mp4"]).decode("ascii")
323
  col_vid.markdown(
324
  f"""
325
  <div class="lb-frame">
@@ -329,19 +353,11 @@ if demo_files.get("tryon.mp4"):
329
  </div>
330
  """, unsafe_allow_html=True
331
  )
332
- col_vid.download_button("Download Video (MP4)", data=demo_files["tryon.mp4"], file_name="tryon.mp4", mime="video/mp4")
333
  else:
334
- col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
335
- col_vid.caption("β€”")
336
-
337
- # ================= RUN (3-step chain, native only) =================
338
- def make_gallery_payload():
339
- return {"category": "auto", "prompt": "", "num_images": "1", "upscale": "false", "upscale_factor": "2"}
340
-
341
- def make_composition_payload(edit_prompt, upscale=False, upscale_factor="2"):
342
- return {"category": "auto", "prompt": edit_prompt, "num_images": "1",
343
- "upscale": "true" if upscale else "false", "upscale_factor": upscale_factor}
344
 
 
345
  if run:
346
  if not ok:
347
  st.error("Backend not reachable. Check API_BASE / TOKEN.")
@@ -354,51 +370,50 @@ if run:
354
  for f in file_list:
355
  files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg")))
356
 
357
- # Step 1: Gallery (clean)
358
- g_payload = make_gallery_payload()
359
- if image_url: g_payload["image_urls"] = image_url
360
- out_gallery = post_image_edit(g_payload, files_payload if files_payload else None)
361
  gallery_native_url = (out_gallery["result"]["images_native"] or [{}])[0].get("url") if out_gallery else None
362
 
363
- # Step 2: AI Composition (config)
364
  edit_prompt = build_car_prompt(
365
- model_name=model_name or "Lamborghini",
366
- body_color=body_color or "Verde Ithaca",
367
  finish=finish,
368
- wheel_style=wheel_style or "lightweight forged",
369
- caliper_color=caliper_color or "yellow",
370
- extra=extra_prompt or ""
371
  )
372
- c_payload = make_composition_payload(edit_prompt, upscale=False, upscale_factor="2")
373
- c_payload["image_urls"] = gallery_native_url or "" # pass /outputs/* back; backend re-uploads to fal
374
- out_comp = post_image_edit(c_payload, files_payload=None)
375
  composition_native_url = (out_comp["result"]["images_native"] or [{}])[0].get("url") if out_comp else None
376
 
377
- # Step 3: Video (optional)
378
  vurl = None; ui_hints = {}
379
  if st.session_state.get("to_video", False) and (composition_native_url or gallery_native_url):
380
- video_prompt = build_video_prompt(st.session_state.get("scene_name","Studio Cinematic"),
381
- st.session_state.get("scene_extra",""))
382
  v = post_video(
383
  composition_native_url or gallery_native_url,
384
- duration=st.session_state.get("duration_sel","6"),
385
- resolution=st.session_state.get("resolution_sel","768P"),
386
- aspect=st.session_state.get("preview_aspect","3:4"),
387
  prompt_text=video_prompt,
388
- prompt_optimizer=False,
389
  )
390
  vurl = (v.get("result") or {}).get("video", {}).get("url")
391
  ui_hints = v.get("ui_hints") or {}
392
- if isinstance(ui_hints, dict):
393
- st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))
394
 
 
395
  st.session_state["job_counter"] += 1
396
  st.session_state["results"] = {
397
  "gallery_native_url": gallery_native_url,
398
  "composition_native_url": composition_native_url,
399
  "video_url": vurl,
400
  "api_base": API_BASE,
401
- "preview_aspect": st.session_state.get("preview_aspect","3:4"),
402
  }
403
 
404
  except requests.HTTPError as e:
@@ -411,29 +426,43 @@ res = st.session_state.get("results")
411
  vbytes = None
412
  if res:
413
  st.subheader("Results")
414
- c_gallery, c_comp, c_vid = st.columns(3)
415
-
416
- # Gallery (native only)
417
- def show_card_url(col, title, url, fname):
418
- col.markdown(f'<div class="lb-card-title">{title}</div>', unsafe_allow_html=True)
419
- if url:
420
- abs_url = _abs_backend_url(url)
421
- col.markdown(f'<div class="lb-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True)
422
- nb = fetch_bytes(abs_url)
423
- if nb:
424
- sz = image_size_from_bytes(nb)
425
- if sz: col.caption(f"Real Res: {sz[0]}Γ—{sz[1]} px")
426
- ext = infer_ext(nb)
427
- mime = "image/jpeg" if ext in [".jpg",".jpeg"] else ("image/png" if ext==".png" else "image/webp")
428
- col.download_button("Download (Full Quality)", data=nb, file_name=f"{fname}{ext if ext!='.bin' else '.jpg'}", mime=mime)
429
- else:
430
- col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
431
- col.caption("β€”")
432
-
433
- show_card_url(c_gallery, "Gallery", res.get("gallery_native_url"), "gallery_native")
434
- show_card_url(c_comp, "AI Composition", res.get("composition_native_url"), "ai_composition_native")
435
-
436
- # Video
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
438
  vurl = res.get("video_url")
439
  if vurl:
@@ -453,8 +482,7 @@ if res:
453
  c_vid.download_button("Download Video (MP4)", data=vb, file_name="lambovision_video.mp4",
454
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
455
  else:
456
- c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
457
- c_vid.caption("β€”")
458
 
459
  # ZIP bundle
460
  named = []
@@ -469,18 +497,11 @@ if res:
469
  file_name="lambovision_outputs.zip", mime="application/zip",
470
  key=f"job{st.session_state['job_counter']}_zip_dl")
471
 
472
- # ================= Scene controls (moved under results to avoid clutter) =================
473
- st.markdown("### Scene")
474
- s1, s2 = st.columns([1.2, 1.8])
475
- with s1:
476
- scene_name = st.selectbox("Scene preset", list(SCENE_PRESETS.keys()), index=0, key="scene_name")
477
- with s2:
478
- scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra")
479
-
480
  # ================= Sidebar =================
481
  with st.sidebar:
482
  st.caption(f"API_BASE: {API_BASE}")
483
  if HF_TOKEN: st.caption("Auth: Bearer (active)")
484
- st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"], step=10, key="video_ui_max_px")
 
485
  if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.")
486
  if st.button("Clear results"): st.session_state["results"] = None; st.success("Cleared.")
 
1
  # streamlit_app.py β€” LamboVision Β· AI Car Studio (Automotive, 3-step flow, no padded)
2
+ # Step 1: Gallery (clean packshot) Step 2: AI Composition (config) Step 3: AI Generated Video
3
+ # Uniform tiles via CSS aspect-ratio; images/videos displayed native (object-fit: contain).
 
 
4
 
5
  import os, io, base64, zipfile, requests, streamlit as st
6
  from PIL import Image
 
23
  VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))
24
  VIDEO_RES_OPTIONS = ["512P", "768P"]
25
  VIDEO_DUR_OPTIONS = ["6", "10"]
26
+ ASPECT_OPTIONS = ["3:4", "9:16", "1:1", "16:9"] # default 3:4
27
 
28
  # ================= Session =================
29
  if "results" not in st.session_state:
 
33
  if "video_ui_max_px" not in st.session_state:
34
  st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT
35
 
36
+ # ================= Helpers =================
37
  def _needs_auth(url: str) -> bool:
38
  return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/")
39
 
40
  def _abs_backend_url(url_or_path: str) -> str:
41
+ if not url_or_path: return ""
42
+ if url_or_path.startswith(("http://","https://")): return url_or_path
43
+ if url_or_path.startswith("/outputs/"): return f"{API_BASE}{url_or_path}"
44
+ if url_or_path.startswith("outputs/"): return f"{API_BASE}/{url_or_path}"
 
 
 
 
45
  return f"{API_BASE}/{url_or_path.lstrip('/')}"
46
 
47
  def backend_ok() -> bool:
48
  try:
49
  r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)
50
+ r.raise_for_status(); return True
 
51
  except Exception:
52
  return False
53
 
 
57
  r.raise_for_status()
58
  return r.json()
59
 
60
+ def post_video(image_url: str, duration="6", resolution="768P", preview_aspect="3:4", prompt_text=""):
61
  payload = {
62
  "image_url": image_url,
63
  "duration": duration,
64
  "resolution": resolution,
65
+ "preview_aspect": preview_aspect,
66
  "prompt": prompt_text or "",
67
+ "prompt_optimizer": "false",
68
  }
69
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
70
  r.raise_for_status()
 
90
  return "image/jpeg"
91
 
92
  def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
93
+ if not mime: mime = _sniff_mime_from_bytes(b)
94
+ import base64
95
  enc = base64.b64encode(b).decode("ascii")
96
  return f"data:{mime};base64,{enc}"
97
 
 
108
  fmt = (im.format or "JPEG").lower()
109
  return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg")
110
  except Exception:
111
+ if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp": return ".mp4"
 
112
  return ".bin"
113
 
114
  def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
 
118
  if b: z.writestr(fname, b)
119
  buf.seek(0); return buf.read()
120
 
 
 
 
 
 
 
 
121
  def inject_base_css(card_fit_mode="contain"):
122
  st.markdown(f"""
123
  <style>
 
139
  background: #111419;
140
  display:flex; align-items:center; justify-content:center;
141
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
142
+ aspect-ratio: var(--tile-ar, 3/4);
143
  }}
144
  .lb-frame img, .lb-frame video {{
145
  width:100%; height:100%; display:block; object-fit:{card_fit_mode};
 
148
  </style>
149
  """, unsafe_allow_html=True)
150
 
151
+ def set_tile_aspect_css(ar: str):
152
+ try:
153
+ a, b = [int(x) for x in ar.split(":")]
154
+ st.markdown(f"<style>:root {{ --tile-ar: {a}/{b}; }}</style>", unsafe_allow_html=True)
155
+ except Exception:
156
+ st.markdown("<style>:root { --tile-ar: 3/4; }</style>", unsafe_allow_html=True)
157
+
158
+ # ================= Local demo assets (optional) =================
159
+ def load_local_demo():
160
+ demo = {}
161
+ for fname in ["input.jpg", "packshot.jpg", "tryon.mp4"]:
162
+ if os.path.exists(fname):
163
+ with open(fname, "rb") as f: demo[fname] = f.read()
164
+ return demo
165
 
166
+ demo_files = load_local_demo()
167
+
168
+ # ================= Prompt builders =================
169
  def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str:
170
  base = (
171
  "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. "
 
182
  f"Wheels: {wheel_style}; brake calipers {caliper_color}. "
183
  "Maintain original stance; no suspension changes; no aero additions."
184
  )
185
+ if extra.strip(): cfg += " " + extra.strip()
 
186
  return base + " " + cfg
187
 
188
  SCENE_PRESETS = {
 
196
  if extra.strip(): return base + " " + lock + " " + extra.strip()
197
  return base + " " + lock
198
 
199
+ def make_gallery_payload(tile_aspect):
200
+ return {
201
+ "category": "auto",
202
+ "prompt": "", # server will default to AUTO_CLEAN_PROMPT
203
+ "num_images": "1",
204
+ "preview_aspect": tile_aspect, # only hint; backend does not pad
205
+ "upscale": "false",
206
+ "upscale_factor": "2",
207
+ }
208
+
209
+ def make_composition_payload(edit_prompt, tile_aspect, upscale, upscale_factor):
210
+ return {
211
+ "category": "auto",
212
+ "prompt": edit_prompt,
213
+ "num_images": "1",
214
+ "preview_aspect": tile_aspect, # only hint; backend does not pad
215
+ "upscale": "true" if upscale else "false",
216
+ "upscale_factor": upscale_factor,
217
+ }
218
+
219
  # ================= Page =================
220
  st.set_page_config(page_title="LamboVision Β· AI Car Studio", layout="wide", page_icon="🏎️")
221
  inject_base_css(card_fit_mode="contain")
 
226
  ok = backend_ok()
227
 
228
  # -------- Top control bar --------
229
+ col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.0, 1.2, 0.9, 1.1, 0.9])
230
  with col1:
231
  file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")
232
  with col2:
233
  image_url = st.text_input("Or URL", key="image_url", placeholder="https://... (optional)")
234
  with col3:
235
+ tile_aspect = st.selectbox("Tile Aspect", ASPECT_OPTIONS, index=0, key="tile_aspect") # default 3:4
236
  with col4:
237
  to_video = st.checkbox("Generate Video", False, key="to_video")
238
  with col5:
 
242
  with col7:
243
  run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")
244
 
245
+ set_tile_aspect_css(tile_aspect)
 
246
 
247
  # -------- Configurator (dropdowns + custom) --------
248
  st.markdown("### Configurator")
 
286
  with c6:
287
  extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, …", key="cfg_extra")
288
 
289
+ # -------- Scene --------
290
+ st.markdown("### Scene")
291
+ s1, s2 = st.columns([1.2, 1.8])
292
+ with s1:
293
+ scene_name = st.selectbox("Scene", list(SCENE_PRESETS.keys()), index=0, key="scene_name")
294
+ with s2:
295
+ scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra")
296
+
297
+ st.caption("Tiles use a fixed aspect in UI; images and videos are displayed native with contain.")
298
 
299
+ # ================= SAMPLE PREVIEWS =================
300
  st.subheader("Sample Previews")
301
  col_in, col_gallery, col_comp, col_vid = st.columns(4)
302
 
 
309
  elif demo_files.get("input.jpg"):
310
  input_bytes = demo_files["input.jpg"]
311
 
312
+ def show_tile(col, title, display_bytes=None, download_bytes=None, url=None, dl_url=None, fname="image", key_prefix="pre"):
313
  col.markdown(f'<div class="lb-card-title">{title}</div>', unsafe_allow_html=True)
314
+ if display_bytes is None and not url:
315
+ col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); col.caption("β€”"); return
316
  if display_bytes is not None:
317
+ src = image_bytes_to_data_url(display_bytes); col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
318
+ native = download_bytes or display_bytes
 
 
 
319
  else:
320
+ abs_url = _abs_backend_url(url); col.markdown(f'<div class="lb-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True)
321
+ native = fetch_bytes(dl_url or abs_url)
 
322
  if native:
323
+ sz = image_size_from_bytes(native);
324
  if sz: col.caption(f"Real Res: {sz[0]}Γ—{sz[1]} px")
325
+ ext = ".mp4" if native[:4] == b"\x00\x00\x00\x18" or native[4:8] == b"ftyp" else ".jpg"
326
+ mime = "video/mp4" if ext == ".mp4" else "image/jpeg"
327
+ col.download_button("Download (Full Quality)", data=native, file_name=f"{fname}{ext if ext!='.bin' else '.jpg'}",
328
+ mime=mime, key=f"{key_prefix}_{fname}_dl")
329
+ else:
330
+ col.caption("β€”")
331
+
332
+ show_tile(col_in, "Input", display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
333
+ download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
334
+ fname="input", key_prefix="pre")
335
+
336
+ gallery_bytes = demo_files.get("packshot.jpg") or input_bytes
337
+ show_tile(col_gallery, "Gallery", display_bytes=gallery_bytes, download_bytes=gallery_bytes,
338
+ fname="gallery", key_prefix="pre")
339
 
340
+ show_tile(col_comp, "AI Composition", display_bytes=gallery_bytes, download_bytes=gallery_bytes,
341
+ fname="ai_composition", key_prefix="pre")
 
342
 
343
+ vid_demo = demo_files.get("tryon.mp4")
344
  col_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
345
+ if vid_demo:
346
+ data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii")
347
  col_vid.markdown(
348
  f"""
349
  <div class="lb-frame">
 
353
  </div>
354
  """, unsafe_allow_html=True
355
  )
356
+ col_vid.download_button("Download Video (MP4)", data=vid_demo, file_name="tryon.mp4", mime="video/mp4", key="pre_video_dl")
357
  else:
358
+ col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); col_vid.caption("β€”")
 
 
 
 
 
 
 
 
 
359
 
360
+ # ================= RUN (3-step chain, no padded) =================
361
  if run:
362
  if not ok:
363
  st.error("Backend not reachable. Check API_BASE / TOKEN.")
 
370
  for f in file_list:
371
  files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg")))
372
 
373
+ # ---- Step 1: Gallery (clean packshot) ----
374
+ gallery_payload = make_gallery_payload(tile_aspect)
375
+ if image_url: gallery_payload["image_urls"] = image_url
376
+ out_gallery = post_image_edit(gallery_payload, files_payload if files_payload else None)
377
  gallery_native_url = (out_gallery["result"]["images_native"] or [{}])[0].get("url") if out_gallery else None
378
 
379
+ # ---- Step 2: AI Composition (config prompt) ----
380
  edit_prompt = build_car_prompt(
381
+ model_name=model_name if model_name else "Lamborghini",
382
+ body_color=body_color if body_color else "Verde Ithaca",
383
  finish=finish,
384
+ wheel_style=wheel_style if wheel_style else "lightweight forged",
385
+ caliper_color=caliper_color if caliper_color else "yellow",
386
+ extra=extra_prompt
387
  )
388
+ comp_payload = make_composition_payload(edit_prompt, tile_aspect, upscale=False, upscale_factor="2")
389
+ comp_payload["image_urls"] = gallery_native_url or "" # backend re-uploads /outputs/* to vendor
390
+ out_comp = post_image_edit(comp_payload, files_payload=None)
391
  composition_native_url = (out_comp["result"]["images_native"] or [{}])[0].get("url") if out_comp else None
392
 
393
+ # ---- Step 3: Video (optional) ----
394
  vurl = None; ui_hints = {}
395
  if st.session_state.get("to_video", False) and (composition_native_url or gallery_native_url):
396
+ video_prompt = build_video_prompt(scene_name, scene_extra)
 
397
  v = post_video(
398
  composition_native_url or gallery_native_url,
399
+ duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
400
+ resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
401
+ preview_aspect=st.session_state.get("tile_aspect","3:4"),
402
  prompt_text=video_prompt,
 
403
  )
404
  vurl = (v.get("result") or {}).get("video", {}).get("url")
405
  ui_hints = v.get("ui_hints") or {}
406
+ if isinstance(ui_hints, dict):
407
+ st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))
408
 
409
+ # ---- Save session ----
410
  st.session_state["job_counter"] += 1
411
  st.session_state["results"] = {
412
  "gallery_native_url": gallery_native_url,
413
  "composition_native_url": composition_native_url,
414
  "video_url": vurl,
415
  "api_base": API_BASE,
416
+ "tile_aspect": tile_aspect,
417
  }
418
 
419
  except requests.HTTPError as e:
 
426
  vbytes = None
427
  if res:
428
  st.subheader("Results")
429
+ c_gallery, c_comp, c_spare, c_vid = st.columns(4)
430
+
431
+ # Gallery (clean packshot, native)
432
+ c_gallery.markdown('<div class="lb-card-title">Gallery</div>', unsafe_allow_html=True)
433
+ g_url = res.get("gallery_native_url")
434
+ if g_url:
435
+ abs_g = _abs_backend_url(g_url)
436
+ c_gallery.markdown(f'<div class="lb-frame"><img src="{abs_g}"/></div>', unsafe_allow_html=True)
437
+ gb = fetch_bytes(abs_g)
438
+ if gb:
439
+ sz = image_size_from_bytes(gb)
440
+ if sz: c_gallery.caption(f"Real Res: {sz[0]}Γ—{sz[1]} px")
441
+ c_gallery.download_button("Download (Full Quality)", data=gb, file_name=f"gallery_native{infer_ext(gb)}",
442
+ mime="image/jpeg", key=f"job{st.session_state['job_counter']}_g_dl")
443
+ else:
444
+ c_gallery.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); c_gallery.caption("β€”")
445
+
446
+ # AI Composition (native)
447
+ c_comp.markdown('<div class="lb-card-title">AI Composition</div>', unsafe_allow_html=True)
448
+ c_url = res.get("composition_native_url")
449
+ if c_url:
450
+ abs_c = _abs_backend_url(c_url)
451
+ c_comp.markdown(f'<div class="lb-frame"><img src="{abs_c}"/></div>', unsafe_allow_html=True)
452
+ cb = fetch_bytes(abs_c)
453
+ if cb:
454
+ sz = image_size_from_bytes(cb)
455
+ if sz: c_comp.caption(f"Real Res: {sz[0]}Γ—{sz[1]} px")
456
+ c_comp.download_button("Download (Full Quality)", data=cb, file_name=f"ai_composition_native{infer_ext(cb)}",
457
+ mime="image/jpeg", key=f"job{st.session_state['job_counter']}_c_dl")
458
+ else:
459
+ c_comp.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); c_comp.caption("β€”")
460
+
461
+ # Spare placeholder (kept same size for grid balance)
462
+ c_spare.markdown('<div class="lb-card-title">β€”</div>', unsafe_allow_html=True)
463
+ c_spare.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); c_spare.caption("β€”")
464
+
465
+ # AI Generated Video
466
  c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
467
  vurl = res.get("video_url")
468
  if vurl:
 
482
  c_vid.download_button("Download Video (MP4)", data=vb, file_name="lambovision_video.mp4",
483
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
484
  else:
485
+ c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True); c_vid.caption("β€”")
 
486
 
487
  # ZIP bundle
488
  named = []
 
497
  file_name="lambovision_outputs.zip", mime="application/zip",
498
  key=f"job{st.session_state['job_counter']}_zip_dl")
499
 
 
 
 
 
 
 
 
 
500
  # ================= Sidebar =================
501
  with st.sidebar:
502
  st.caption(f"API_BASE: {API_BASE}")
503
  if HF_TOKEN: st.caption("Auth: Bearer (active)")
504
+ st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"],
505
+ step=10, key="video_ui_max_px")
506
  if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.")
507
  if st.button("Clear results"): st.session_state["results"] = None; st.success("Cleared.")