renderfy commited on
Commit
754815d
·
verified ·
1 Parent(s): 83cb36f

Upload streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +190 -127
streamlit_app.py CHANGED
@@ -1,6 +1,6 @@
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
@@ -38,16 +38,21 @@ 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,14 +62,14 @@ def post_image_edit(data: dict, files_payload):
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,8 +95,8 @@ def _sniff_mime_from_bytes(b: bytes) -> str:
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,7 +113,8 @@ def infer_ext(data: bytes) -> str:
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,6 +124,12 @@ 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,7 +151,6 @@ def inject_base_css(card_fit_mode="contain"):
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,24 +159,75 @@ def inject_base_css(card_fit_mode="contain"):
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,7 +244,8 @@ def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style:
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,26 +259,40 @@ def build_video_prompt(scene_name: str, extra: str = "") -> str:
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")
@@ -232,7 +309,7 @@ with col1:
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,7 +319,8 @@ with col6:
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,17 +364,15 @@ with c5:
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,36 +385,28 @@ elif image_url:
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)
@@ -355,9 +423,10 @@ if vid_demo:
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,50 +439,55 @@ if run:
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,41 +500,25 @@ res = st.session_state.get("results")
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)
@@ -482,7 +540,8 @@ if res:
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 = []
@@ -491,11 +550,15 @@ if res:
491
  if gnat: named.append((f"gallery_native{infer_ext(gnat)}", gnat))
492
  if cnat: named.append((f"ai_composition_native{infer_ext(cnat)}", cnat))
493
  if vbytes: named.append(("video.mp4", vbytes))
494
- if named:
495
- zip_bytes = make_zip(named)
496
- st.download_button("Download All (ZIP)", data=zip_bytes,
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:
 
1
+ # streamlit_app.py — LamboVision · AI Car Studio (native-only UI, no padding)
2
+ # Flow: Step 1 Gallery (clean packshot)Step 2 AI Composition (config)Step 3 AI Generated Video
3
+ # All tiles are uniform aspect (configurable). Images/videos fit inside (no overflow).
4
 
5
  import os, io, base64, zipfile, requests, streamlit as st
6
  from PIL import Image
 
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:
42
+ return ""
43
+ if url_or_path.startswith(("http://","https://")):
44
+ return url_or_path
45
+ if url_or_path.startswith("/outputs/"):
46
+ return f"{API_BASE}{url_or_path}"
47
+ if url_or_path.startswith("outputs/"):
48
+ return f"{API_BASE}/{url_or_path}"
49
  return f"{API_BASE}/{url_or_path.lstrip('/')}"
50
 
51
  def backend_ok() -> bool:
52
  try:
53
  r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)
54
+ r.raise_for_status()
55
+ return True
56
  except Exception:
57
  return False
58
 
 
62
  r.raise_for_status()
63
  return r.json()
64
 
65
+ def post_video(image_url: str, duration="6", resolution="768P", preview_aspect="3:4", prompt_text="", prompt_optimizer=False):
66
  payload = {
67
  "image_url": image_url,
68
  "duration": duration,
69
  "resolution": resolution,
70
  "preview_aspect": preview_aspect,
71
  "prompt": prompt_text or "",
72
+ "prompt_optimizer": "true" if prompt_optimizer else "false",
73
  }
74
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
75
  r.raise_for_status()
 
95
  return "image/jpeg"
96
 
97
  def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
98
+ if not mime:
99
+ mime = _sniff_mime_from_bytes(b)
100
  enc = base64.b64encode(b).decode("ascii")
101
  return f"data:{mime};base64,{enc}"
102
 
 
113
  fmt = (im.format or "JPEG").lower()
114
  return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg")
115
  except Exception:
116
+ if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp":
117
+ return ".mp4"
118
  return ".bin"
119
 
120
  def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
 
124
  if b: z.writestr(fname, b)
125
  buf.seek(0); return buf.read()
126
 
127
+ def _parse_aspect_str(s: str):
128
+ try:
129
+ a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
130
+ except Exception:
131
+ return (3, 4)
132
+
133
  def inject_base_css(card_fit_mode="contain"):
134
  st.markdown(f"""
135
  <style>
 
151
  background: #111419;
152
  display:flex; align-items:center; justify-content:center;
153
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
 
154
  }}
155
  .lb-frame img, .lb-frame video {{
156
  width:100%; height:100%; display:block; object-fit:{card_fit_mode};
 
159
  </style>
160
  """, unsafe_allow_html=True)
161
 
162
+ def inject_preview_css(aspect: str):
163
+ aw, ah = _parse_aspect_str(aspect)
164
+ st.markdown(f"""
165
+ <style>
166
+ .lb-frame {{ aspect-ratio: {aw} / {ah}; }}
167
+ </style>
168
+ """, unsafe_allow_html=True)
169
 
170
+ def show_tile_card_display_download(
171
+ col, title,
172
+ display_url: str | None = None,
173
+ display_bytes: bytes | None = None,
174
+ download_url: str | None = None,
175
+ download_bytes: bytes | None = None,
176
+ filename_stub: str = "image",
177
+ key_prefix: str = "",
178
+ ):
179
+ col.markdown(f'<div class="lb-card-title">{title}</div>', unsafe_allow_html=True)
180
+ if not display_url and not display_bytes:
181
+ col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
182
+ col.caption("—"); return None, None
183
+ if display_bytes is not None:
184
+ src = image_bytes_to_data_url(display_bytes)
185
+ col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
186
+ else:
187
+ abs_url = _abs_backend_url(display_url)
188
+ col.markdown(f'<div class="lb-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True)
189
 
190
+ native = download_bytes or (fetch_bytes(download_url) if download_url else None)
191
+ if not native:
192
+ native = display_bytes
193
+ if native:
194
+ sz = image_size_from_bytes(native)
195
+ if sz: col.caption(f"Real Res: {sz[0]}×{sz[1]} px")
196
+ ext = infer_ext(native)
197
+ mime = "image/jpeg" if ext.lower() in [".jpg",".jpeg"] else ("image/png" if ext.lower()==".png" else "image/webp")
198
+ col.download_button("Download (Full Quality)", data=native,
199
+ file_name=f"{filename_stub}{ext if ext!='.bin' else '.jpg'}",
200
+ mime=mime, key=f"{key_prefix}_{filename_stub}_dl")
201
+ else:
202
+ col.caption("—")
203
+ return display_url or "data", native
204
+
205
+ def rgba_to_rgb_on_bg(img_rgba: Image.Image, bg_rgb=(255,255,255)) -> Image.Image:
206
+ if img_rgba.mode != "RGBA":
207
+ return img_rgba.convert("RGB")
208
+ bg = Image.new("RGBA", img_rgba.size, (*bg_rgb, 255))
209
+ bg.paste(img_rgba, (0,0), img_rgba)
210
+ return bg.convert("RGB")
211
+
212
+ @st.cache_data(ttl=300, show_spinner=False)
213
+ def make_small_jpeg_from_native(native: bytes, target_long_edge: int = 1280, bg_rgb=(255,255,255)) -> bytes:
214
+ im = Image.open(io.BytesIO(native))
215
+ if im.mode != "RGBA" and "transparency" not in im.info:
216
+ im = im.convert("RGB")
217
+ else:
218
+ im = im.convert("RGBA")
219
+ w, h = im.size
220
+ if w >= h:
221
+ new_w = min(target_long_edge, w)
222
+ new_h = max(1, int(h * (new_w / max(1, w))))
223
+ else:
224
+ new_h = min(target_long_edge, h)
225
+ new_w = max(1, int(w * (new_h / max(1, h))))
226
+ im2 = im.resize((new_w, new_h), Image.LANCZOS)
227
+ im2_rgb = rgba_to_rgb_on_bg(im2, bg_rgb=bg_rgb)
228
+ out = io.BytesIO(); im2_rgb.save(out, format="JPEG", quality=88, subsampling=1)
229
+ return out.getvalue()
230
 
 
231
  def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str:
232
  base = (
233
  "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. "
 
244
  f"Wheels: {wheel_style}; brake calipers {caliper_color}. "
245
  "Maintain original stance; no suspension changes; no aero additions."
246
  )
247
+ if extra.strip():
248
+ cfg += " " + extra.strip()
249
  return base + " " + cfg
250
 
251
  SCENE_PRESETS = {
 
259
  if extra.strip(): return base + " " + lock + " " + extra.strip()
260
  return base + " " + lock
261
 
262
+ def make_gallery_payload(preview_aspect):
263
  return {
264
  "category": "auto",
265
+ "prompt": "", # server defaults to AUTO_CLEAN_PROMPT
266
  "num_images": "1",
267
+ "preview_aspect": preview_aspect,
268
+ "preview_long_edge": "1536",
269
+ "preview_ratio": "0.9",
270
  "upscale": "false",
271
  "upscale_factor": "2",
272
  }
273
 
274
+ def make_composition_payload(edit_prompt, preview_aspect, upscale, upscale_factor):
275
  return {
276
  "category": "auto",
277
  "prompt": edit_prompt,
278
  "num_images": "1",
279
+ "preview_aspect": preview_aspect,
280
+ "preview_long_edge": "1536",
281
+ "preview_ratio": "0.9",
282
  "upscale": "true" if upscale else "false",
283
  "upscale_factor": upscale_factor,
284
  }
285
 
286
+ # ================= Local demo assets (optional) =================
287
+ def load_local_demo():
288
+ demo = {}
289
+ for fname in ["input.jpg", "packshot.jpg", "model.jpg", "tryon.mp4"]:
290
+ if os.path.exists(fname):
291
+ with open(fname, "rb") as f: demo[fname] = f.read()
292
+ return demo
293
+
294
+ demo_files = load_local_demo()
295
+
296
  # ================= Page =================
297
  st.set_page_config(page_title="LamboVision · AI Car Studio", layout="wide", page_icon="🏎️")
298
  inject_base_css(card_fit_mode="contain")
 
309
  with col2:
310
  image_url = st.text_input("Or URL", key="image_url", placeholder="https://... (optional)")
311
  with col3:
312
+ preview_aspect = st.selectbox("Tile Aspect", ASPECT_OPTIONS, index=0, key="preview_aspect") # default 3:4
313
  with col4:
314
  to_video = st.checkbox("Generate Video", False, key="to_video")
315
  with col5:
 
319
  with col7:
320
  run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")
321
 
322
+ inject_preview_css(preview_aspect)
323
+ st.caption("All tiles use the same aspect. Images and videos fit inside; no overflow.")
324
 
325
  # -------- Configurator (dropdowns + custom) --------
326
  st.markdown("### Configurator")
 
364
  with c6:
365
  extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, …", key="cfg_extra")
366
 
367
+ # -------- Scene (for video) --------
368
  st.markdown("### Scene")
369
  s1, s2 = st.columns([1.2, 1.8])
370
  with s1:
371
+ scene_name = st.selectbox("Scene preset", list(SCENE_PRESETS.keys()), index=0, key="scene_name")
372
  with s2:
373
  scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra")
374
 
375
+ # ================= SAMPLE PREVIEWS (no padding; just native demos) =================
 
 
376
  st.subheader("Sample Previews")
377
  col_in, col_gallery, col_comp, col_vid = st.columns(4)
378
 
 
385
  elif demo_files.get("input.jpg"):
386
  input_bytes = demo_files["input.jpg"]
387
 
388
+ show_tile_card_display_download(
389
+ col_in, "Input",
390
+ display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
391
+ download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
392
+ filename_stub="input", key_prefix="pre"
393
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  gallery_bytes = demo_files.get("packshot.jpg") or input_bytes
396
+ show_tile_card_display_download(
397
+ col_gallery, "Gallery",
398
+ display_bytes=gallery_bytes,
399
+ download_bytes=gallery_bytes,
400
+ filename_stub="gallery", key_prefix="pre"
401
+ )
402
+
403
+ comp_bytes = gallery_bytes
404
+ show_tile_card_display_download(
405
+ col_comp, "AI Composition",
406
+ display_bytes=comp_bytes,
407
+ download_bytes=comp_bytes,
408
+ filename_stub="ai_composition", key_prefix="pre"
409
+ )
410
 
411
  vid_demo = demo_files.get("tryon.mp4")
412
  col_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
 
423
  )
424
  col_vid.download_button("Download Video (MP4)", data=vid_demo, file_name="tryon.mp4", mime="video/mp4", key="pre_video_dl")
425
  else:
426
+ col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
427
+ col_vid.caption("—")
428
 
429
+ # ================= RUN (3-step chain) =================
430
  if run:
431
  if not ok:
432
  st.error("Backend not reachable. Check API_BASE / TOKEN.")
 
439
  for f in file_list:
440
  files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg")))
441
 
442
+ # ---- Step 1: Gallery (clean packshot; native only) ----
443
+ gallery_payload = make_gallery_payload(preview_aspect)
444
+ if image_url:
445
+ gallery_payload["image_urls"] = image_url
446
  out_gallery = post_image_edit(gallery_payload, files_payload if files_payload else None)
447
+
448
  gallery_native_url = (out_gallery["result"]["images_native"] or [{}])[0].get("url") if out_gallery else None
449
 
450
+ # ---- Step 2: AI Composition (config prompt; native only) ----
451
  edit_prompt = build_car_prompt(
452
+ model_name=(model_name or "Lamborghini"),
453
+ body_color=(body_color or "Verde Ithaca"),
454
  finish=finish,
455
+ wheel_style=(wheel_style or "lightweight forged"),
456
+ caliper_color=(caliper_color or "yellow"),
457
  extra=extra_prompt
458
  )
459
+ comp_payload = make_composition_payload(edit_prompt, preview_aspect, upscale=False, upscale_factor="2")
460
+ comp_payload["image_urls"] = gallery_native_url or ""
461
  out_comp = post_image_edit(comp_payload, files_payload=None)
462
+
463
+ comp_native_url = (out_comp["result"]["images_native"] or [{}])[0].get("url") if out_comp else None
464
 
465
  # ---- Step 3: Video (optional) ----
466
  vurl = None; ui_hints = {}
467
+ if st.session_state.get("to_video", False) and (comp_native_url or gallery_native_url):
468
  video_prompt = build_video_prompt(scene_name, scene_extra)
469
  v = post_video(
470
+ comp_native_url or gallery_native_url,
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
+ preview_aspect=st.session_state.get("preview_aspect","3:4"),
474
  prompt_text=video_prompt,
475
+ prompt_optimizer=False,
476
  )
477
  vurl = (v.get("result") or {}).get("video", {}).get("url")
478
  ui_hints = v.get("ui_hints") or {}
479
+
480
  if isinstance(ui_hints, dict):
481
  st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))
482
 
483
+ # ---- Save results to session ----
484
  st.session_state["job_counter"] += 1
485
  st.session_state["results"] = {
486
  "gallery_native_url": gallery_native_url,
487
+ "composition_native_url": comp_native_url,
488
  "video_url": vurl,
489
  "api_base": API_BASE,
490
+ "preview_aspect": preview_aspect,
491
  }
492
 
493
  except requests.HTTPError as e:
 
500
  vbytes = None
501
  if res:
502
  st.subheader("Results")
503
+ c_gallery, c_comp, c_vid, c_zip = st.columns([1.2, 1.2, 1.2, 0.6])
504
 
505
  # Gallery (clean packshot, native)
506
+ show_tile_card_display_download(
507
+ c_gallery, "Gallery",
508
+ display_url=res.get("gallery_native_url"),
509
+ download_url=res.get("gallery_native_url"),
510
+ filename_stub="gallery_native",
511
+ key_prefix=f"job{st.session_state['job_counter']}"
512
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
 
514
+ # AI Composition (configured, native)
515
+ show_tile_card_display_download(
516
+ c_comp, "AI Composition",
517
+ display_url=res.get("composition_native_url"),
518
+ download_url=res.get("composition_native_url"),
519
+ filename_stub="ai_composition_native",
520
+ key_prefix=f"job{st.session_state['job_counter']}"
521
+ )
522
 
523
  # AI Generated Video
524
  c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)
 
540
  c_vid.download_button("Download Video (MP4)", data=vb, file_name="lambovision_video.mp4",
541
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
542
  else:
543
+ c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)
544
+ c_vid.caption("—")
545
 
546
  # ZIP bundle
547
  named = []
 
550
  if gnat: named.append((f"gallery_native{infer_ext(gnat)}", gnat))
551
  if cnat: named.append((f"ai_composition_native{infer_ext(cnat)}", cnat))
552
  if vbytes: named.append(("video.mp4", vbytes))
553
+ with c_zip:
554
+ st.markdown('<div class="lb-card-title">Export</div>', unsafe_allow_html=True)
555
+ if named:
556
+ zip_bytes = make_zip(named)
557
+ st.download_button("Download All (ZIP)", data=zip_bytes,
558
+ file_name="lambovision_outputs.zip", mime="application/zip",
559
+ key=f"job{st.session_state['job_counter']}_zip_dl")
560
+ else:
561
+ st.caption("—")
562
 
563
  # ================= Sidebar =================
564
  with st.sidebar: