renderfy commited on
Commit
38049a8
·
verified ·
1 Parent(s): 84bfdd1

Upload streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +105 -97
streamlit_app.py CHANGED
@@ -1,13 +1,6 @@
1
- # streamlit_app.py — LamboVision · AI Car Studio (frontend) v0.6.3
2
- # Previews: 9:16 cards (Input / Packshot / Padded / Scene / Video)
3
- # Configurator: model, body color, finish, wheel style, caliper color + Extra prompt
4
- # Endpoints used:
5
- # POST /v1/tryon/chain (recommended full pipeline)
6
- # POST /v1/video/from-image (on-demand video)
7
- #
8
- # Env:
9
- # AI_LIGHTBOX_API -> http://host:port (default: http://127.0.0.1:8000)
10
- # AI_LIGHTBOX_TOKEN -> Bearer (optional)
11
 
12
  import os, io, base64, zipfile, requests, streamlit as st
13
  from PIL import Image
@@ -30,7 +23,8 @@ HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
30
  VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))
31
  VIDEO_RES_OPTIONS = ["512P", "768P"]
32
  VIDEO_DUR_OPTIONS = ["6", "10"]
33
- ASPECT_OPTIONS = ["9:16", "3:4", "1:1"]
 
34
 
35
  # ==== Session ====
36
  if "results" not in st.session_state:
@@ -65,16 +59,9 @@ def post_chain(data: dict, files_payload):
65
  r.raise_for_status()
66
  return r.json()
67
 
68
- def post_video(image_url: str, duration="6", resolution="768P", prompt_optimizer=False, preview_aspect="9:16", prompt=None):
69
- payload = {
70
- "image_url": image_url,
71
- "duration": duration,
72
- "resolution": resolution,
73
- "prompt_optimizer": "true" if prompt_optimizer else "false",
74
- "preview_aspect": preview_aspect,
75
- }
76
- if prompt is not None:
77
- payload["prompt"] = prompt
78
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
79
  r.raise_for_status()
80
  return r.json()
@@ -99,8 +86,7 @@ def _sniff_mime_from_bytes(b: bytes) -> str:
99
  return "image/jpeg"
100
 
101
  def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
102
- if not mime:
103
- mime = _sniff_mime_from_bytes(b)
104
  enc = base64.b64encode(b).decode("ascii")
105
  return f"data:{mime};base64,{enc}"
106
 
@@ -117,8 +103,7 @@ def infer_ext(data: bytes) -> str:
117
  fmt = (im.format or "JPEG").lower()
118
  return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg")
119
  except Exception:
120
- if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp":
121
- return ".mp4"
122
  return ".bin"
123
 
124
  def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
@@ -128,12 +113,12 @@ def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
128
  if b: z.writestr(fname, b)
129
  buf.seek(0); return buf.read()
130
 
131
- # ==== Preview helpers (uniform 9:16 cards) ====
132
  def _parse_aspect_str(s: str):
133
  try:
134
  a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
135
  except Exception:
136
- return (9, 16)
137
 
138
  def inject_base_css():
139
  st.markdown("""
@@ -141,39 +126,61 @@ def inject_base_css():
141
  .stApp { background: #0e0f12; }
142
  header[data-testid="stHeader"] { background: transparent; }
143
  .block-container { padding-top: 1.1rem; }
 
144
  h1, .stMarkdown h1 { font-size: 1.85rem; line-height: 1.25; margin-bottom: 0.1rem; }
145
  .stMarkdown p { margin-top: 0.15rem; }
 
146
  label, .stTextInput label, .stSelectbox label, .stNumberInput label, .stFileUploader label,
147
  .stCheckbox label, .stSlider label, .stRadio label {
148
- color: #ff8a33 !important;
149
- font-weight: 600 !important;
150
  }
 
151
  .stButton > button[kind="primary"]{
152
  background: #ff7a1a !important; border: 0 !important; color: #111 !important;
153
  }
154
  .stButton > button:hover { filter: brightness(1.05); }
 
155
  .stAlert { border-radius: 10px; }
 
156
  .lv-card-title { font-weight: 700; font-size: 1.05rem; margin: 2px 0 8px 0; color: #eaecef; }
 
 
157
  .lv-frame {
158
- position: relative; width: 100%;
159
- border-radius: 12px; overflow: hidden;
 
 
 
160
  background: #111419;
161
- display:flex; align-items:center; justify-content:center;
162
  box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset;
163
  }
164
- .lv-frame img { width:100%; height:100%; display:block; }
165
- .lv-video { display:flex; justify-content:center; }
166
- .lv-video video { border-radius:8px; max-width: 540px; width:100%; height:auto; }
167
- .stCaption, .st-emotion-cache-1r6slb0 { color: #ffb07a !important; }
 
 
 
 
 
 
 
 
 
 
168
  </style>
169
  """, unsafe_allow_html=True)
170
 
171
  def inject_preview_css(aspect: str, fit_mode="contain"):
172
- aw, ah = _parse_aspect_str(aspect)
 
 
 
173
  st.markdown(f"""
174
  <style>
175
- .lv-frame {{ aspect-ratio: {aw} / {ah}; }}
176
- .lv-frame img {{ object-fit:{fit_mode}; }}
177
  </style>
178
  """, unsafe_allow_html=True)
179
 
@@ -202,21 +209,21 @@ def make_small_jpeg_from_native(native: bytes, target_long_edge: int = 1280, bg_
202
 
203
  def make_preview_frame(img_bytes: bytes, aspect: str, long_edge: int, ratio: float, bg_rgb=(255,255,255)) -> bytes:
204
  im = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
205
- aw, ah = _parse_aspect_str(aspect)
206
- if aw >= ah:
207
- W = max(256, min(8192, int(long_edge))); H = max(1, int(W * ah / aw))
208
  else:
209
- H = max(256, min(8192, int(long_edge))); W = max(1, int(H * aw / ah))
210
  ratio = max(0.10, min(0.99, float(ratio)))
211
- target = int(min(W, H) * ratio if aw == ah else (H * ratio if aw < ah else W * ratio))
212
  w, h = im.size
213
- if w >= h: new_w, new_h = target, max(1, int(h * (target / max(1, w))))
214
- else: new_h, new_w = target, max(1, int(w * (target / max(1, h))))
215
  im_resized = im.resize((new_w, new_h), Image.LANCZOS)
216
  canvas = Image.new("RGBA", (W, H), (*bg_rgb, 255))
217
  off = ((W - new_w)//2, (H - new_h)//2); canvas.paste(im_resized, off, im_resized)
218
  out = io.BytesIO(); canvas.convert("RGB").save(out, format="JPEG", quality=90, subsampling=1)
219
- return out.getvalue()
220
 
221
  # ==== Local demo files (optional) ====
222
  def load_local_demo():
@@ -289,15 +296,15 @@ with colF:
289
 
290
  colT1, colT2, colT3, colT4 = st.columns([1.0, 1.0, 1.0, 1.2])
291
  with colT1:
292
- preview_aspect = st.selectbox("Preview Frame Ratio", ASPECT_OPTIONS, index=0, key="preview_aspect")
293
  with colT2:
294
- preview_long_edge = st.number_input("Preview Long Edge (px)", min_value=256, max_value=8192, value=1920, step=64, key="preview_long_edge")
295
  with colT3:
296
  fit_mode = st.selectbox("Preview Layout", ["fit","fill"], index=0, help="Affects previews only.", key="fit_mode")
297
  with colT4:
298
  st.info(f"Connection: {'Online' if ok else 'Offline'}", icon="🔌")
299
 
300
- # Inject CSS for aspect
301
  inject_preview_css(preview_aspect, fit_mode="contain" if fit_mode=="fit" else "cover")
302
 
303
  # ==== Build edit prompt (cars) ====
@@ -314,20 +321,10 @@ def build_edit_prompt(model, color, finish, wheel, caliper, extra):
314
  parts.append(extra.strip())
315
  return ", ".join(parts)
316
 
317
- # ==== Pre-run PREVIEWS (5 cards) ====
318
- st.subheader("Previews (9:16)")
319
  c_in, c_pack, c_pad, c_scene, c_video = st.columns(5)
320
 
321
- # Input bytes
322
- input_bytes = None
323
- if file_list:
324
- try: input_bytes = file_list[0].getvalue()
325
- except Exception: input_bytes = None
326
- elif image_url:
327
- input_bytes = fetch_bytes(image_url)
328
- elif demo_files.get("input.jpg"):
329
- input_bytes = demo_files["input.jpg"]
330
-
331
  def show_tile_card_display_download(
332
  col, title,
333
  display_url: str | None = None,
@@ -338,21 +335,22 @@ def show_tile_card_display_download(
338
  key_prefix: str = "",
339
  ):
340
  col.markdown(f'<div class="lv-card-title">{title}</div>', unsafe_allow_html=True)
341
- if not display_url and not display_bytes:
342
- col.markdown('<div class="lv-frame"></div>', unsafe_allow_html=True)
343
- col.caption("—"); return None, None
344
 
 
345
  if display_bytes is not None:
346
  src = image_bytes_to_data_url(display_bytes)
347
- col.markdown(f'<div class="lv-frame"><img src="{src}"/></div>', unsafe_allow_html=True)
348
- else:
349
  abs_url = _abs_backend_url(display_url)
350
- col.markdown(f'<div class="lv-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True)
 
 
 
351
 
 
352
  native = download_bytes or (fetch_bytes(download_url) if download_url else None)
353
  if not native:
354
  native = display_bytes
355
-
356
  if native:
357
  sz = image_size_from_bytes(native)
358
  if sz: col.caption(f"Real Res: {sz[0]}×{sz[1]} px")
@@ -366,8 +364,18 @@ def show_tile_card_display_download(
366
 
367
  return display_url or "data", native
368
 
 
 
 
 
 
 
 
 
 
 
369
  show_tile_card_display_download(
370
- c_in, "Sample Input preview",
371
  display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
372
  download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
373
  filename_stub="input", key_prefix="pre"
@@ -375,7 +383,7 @@ show_tile_card_display_download(
375
 
376
  pack_bytes_demo = demo_files.get("packshot.jpg") or input_bytes
377
  show_tile_card_display_download(
378
- c_pack, "Sample Packshot (Clean/Configured)",
379
  display_bytes=pack_bytes_demo,
380
  download_bytes=pack_bytes_demo,
381
  filename_stub="packshot", key_prefix="pre"
@@ -390,7 +398,7 @@ if pack_bytes_demo:
390
  except Exception:
391
  padded_bytes = None
392
  show_tile_card_display_download(
393
- c_pad, "Sample Padded (9:16 UI)",
394
  display_bytes=padded_bytes or pack_bytes_demo,
395
  download_bytes=padded_bytes or pack_bytes_demo,
396
  filename_stub="padded_ui", key_prefix="pre"
@@ -398,20 +406,20 @@ show_tile_card_display_download(
398
 
399
  scene_bytes_demo = demo_files.get("scene.jpg") or pack_bytes_demo
400
  show_tile_card_display_download(
401
- c_scene, "Sample Scene Composite",
402
  display_bytes=scene_bytes_demo,
403
  download_bytes=scene_bytes_demo,
404
  filename_stub="scene_composite", key_prefix="pre"
405
  )
406
 
407
- c_video.markdown('<div class="lv-card-title">AI Generated Video Preview</div>', unsafe_allow_html=True)
408
  vid_demo = demo_files.get("tryon.mp4")
409
  if vid_demo:
410
  data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii")
411
  c_video.markdown(
412
  f"""
413
- <div class="lv-frame" style="display:flex;align-items:center;justify-content:center;">
414
- <video autoplay loop muted playsinline style="width:100%;height:100%;object-fit:cover;">
415
  <source src="{data_url}" type="video/mp4">
416
  </video>
417
  </div>
@@ -435,7 +443,19 @@ with colAct4:
435
  with colAct5:
436
  run = st.button("Run ▶", type="primary", use_container_width=True, key="run_btn")
437
 
438
- # ==== Run pipeline ====
 
 
 
 
 
 
 
 
 
 
 
 
439
  if run:
440
  if not ok:
441
  st.error("Backend unreachable. Check API_BASE / TOKEN.")
@@ -443,48 +463,38 @@ if run:
443
  st.error("Provide at least one image (upload or URL).")
444
  else:
445
  try:
446
- # Build prompts
447
  edit_prompt = build_edit_prompt(model_sel, body_color, finish, wheel, caliper, extra_prompt)
448
  scene_text = SCENE_PRESETS.get(scene_name, "premium studio scene")
449
  img_urls = image_url or ""
450
-
451
  files_payload = []
452
  if file_list:
453
  for f in file_list:
454
  files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg")))
455
-
456
  data = {
457
  "category": "auto",
458
  "edit_prompt": edit_prompt,
459
  "num_images": "1",
460
  "image_urls": img_urls,
461
- # scene pass
462
  "do_scene_pass": "true" if do_scene_pass else "false",
463
  "scene_prompt_text": scene_text,
464
- # identity lock (backendside rules)
465
  "identity_lock": "true" if identity_lock else "false",
466
- # canvas/preview
467
  "preview_aspect": preview_aspect,
468
  "preview_long_edge": str(int(preview_long_edge)),
469
  "preview_ratio": str(float(preview_ratio)),
470
- # SR
471
  "upscale": "true" if upscale else "false",
472
  "upscale_factor": "2",
473
  "upscale_stage": st.session_state.get("upscale_stage","both"),
474
- # video
475
  "to_video": "true" if to_video else "false",
476
- "video_prompt": scene_text, # same vibe for camera move
477
  "duration": st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
478
  "resolution": st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
479
  "prompt_optimizer": "false",
480
  }
481
-
482
  with st.spinner("LamboVision pipeline running…"):
483
  out = post_chain(data, files_payload if files_payload else None)
484
 
485
  st.session_state["job_counter"] += 1
486
 
487
- # Parse outputs
488
  pack_native_url = None; pack_display_jpeg = None
489
  try:
490
  pack_native_url = (out["packshot_step"]["result"]["images_native"] or [{}])[0].get("url")
@@ -543,11 +553,11 @@ if run:
543
  except Exception as e:
544
  st.error(f"Error: {e}")
545
 
546
- # ==== Results ====
547
  res = st.session_state.get("results")
548
  vbytes = None
549
  if res:
550
- st.subheader("Results")
551
  c_pack2, c_pad2, c_scene2, c_video2 = st.columns(4)
552
 
553
  show_tile_card_display_download(
@@ -559,7 +569,7 @@ if res:
559
  )
560
 
561
  show_tile_card_display_download(
562
- c_pad2, "Padded (9:16 Canvas)",
563
  display_bytes=res.get("padded_display_jpeg"),
564
  download_url=res.get("padded_native_url"),
565
  filename_stub="padded_backend",
@@ -574,15 +584,14 @@ if res:
574
  key_prefix=f"job{st.session_state['job_counter']}"
575
  )
576
 
577
- # Video
578
- vurl = res.get("video_url")
579
  c_video2.markdown('<div class="lv-card-title">AI Cinematic</div>', unsafe_allow_html=True)
 
580
  if vurl:
581
  abs_v = _abs_backend_url(vurl)
582
  c_video2.markdown(
583
  f"""
584
- <div class="lv-frame" style="display:flex;align-items:center;justify-content:center;">
585
- <video autoplay loop muted controls playsinline style="width:100%;height:100%;object-fit:cover;">
586
  <source src="{abs_v}" type="video/mp4">
587
  </video>
588
  </div>
@@ -595,7 +604,6 @@ if res:
595
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
596
  else:
597
  c_video2.markdown('<div class="lv-frame"></div>', unsafe_allow_html=True)
598
- # On-demand video from scene or packshot
599
  vid_checkbox_key = f"video_toggle_job{st.session_state['job_counter']}"
600
  if st.checkbox("Generate Video From Scene", value=False, key=vid_checkbox_key):
601
  src_for_video = res.get("scene_native_url") or res.get("packshot_native_url")
@@ -608,7 +616,7 @@ if res:
608
  src_for_video,
609
  duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
610
  resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
611
- preview_aspect=st.session_state.get("preview_aspect","9:16"),
612
  prompt=res.get("scene_prompt_text")
613
  )
614
  except requests.HTTPError as e:
 
1
+ # streamlit_app.py — LamboVision · AI Car Studio (uniform cards) v0.6.4
2
+ # All cards have identical size; images/videos are fit inside the frame (no overflow).
3
+ # Default aspect is 3:4 (less tall), but user can switch to 9:16 or 1:1.
 
 
 
 
 
 
 
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
+ # Default 3:4 to avoid very tall cards; user can switch
27
+ ASPECT_OPTIONS = ["3:4", "9:16", "1:1"]
28
 
29
  # ==== Session ====
30
  if "results" not in st.session_state:
 
59
  r.raise_for_status()
60
  return r.json()
61
 
62
+ def post_video(image_url: str, duration="6", resolution="768P", preview_aspect="3:4", prompt=None):
63
+ payload = {"image_url": image_url, "duration": duration, "resolution": resolution, "preview_aspect": preview_aspect}
64
+ if prompt is not None: payload["prompt"] = prompt
 
 
 
 
 
 
 
65
  r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600)
66
  r.raise_for_status()
67
  return r.json()
 
86
  return "image/jpeg"
87
 
88
  def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str:
89
+ if not mime: mime = _sniff_mime_from_bytes(b)
 
90
  enc = base64.b64encode(b).decode("ascii")
91
  return f"data:{mime};base64,{enc}"
92
 
 
103
  fmt = (im.format or "JPEG").lower()
104
  return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg")
105
  except Exception:
106
+ if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp": return ".mp4"
 
107
  return ".bin"
108
 
109
  def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:
 
113
  if b: z.writestr(fname, b)
114
  buf.seek(0); return buf.read()
115
 
116
+ # ==== Preview helpers ====
117
  def _parse_aspect_str(s: str):
118
  try:
119
  a, b = s.split(":"); return max(1, int(a)), max(1, int(b))
120
  except Exception:
121
+ return (3, 4)
122
 
123
  def inject_base_css():
124
  st.markdown("""
 
126
  .stApp { background: #0e0f12; }
127
  header[data-testid="stHeader"] { background: transparent; }
128
  .block-container { padding-top: 1.1rem; }
129
+
130
  h1, .stMarkdown h1 { font-size: 1.85rem; line-height: 1.25; margin-bottom: 0.1rem; }
131
  .stMarkdown p { margin-top: 0.15rem; }
132
+
133
  label, .stTextInput label, .stSelectbox label, .stNumberInput label, .stFileUploader label,
134
  .stCheckbox label, .stSlider label, .stRadio label {
135
+ color: #ff8a33 !important; font-weight: 600 !important;
 
136
  }
137
+
138
  .stButton > button[kind="primary"]{
139
  background: #ff7a1a !important; border: 0 !important; color: #111 !important;
140
  }
141
  .stButton > button:hover { filter: brightness(1.05); }
142
+
143
  .stAlert { border-radius: 10px; }
144
+
145
  .lv-card-title { font-weight: 700; font-size: 1.05rem; margin: 2px 0 8px 0; color: #eaecef; }
146
+
147
+ /* UNIFORM FRAME: one box size for all media; content never overflows */
148
  .lv-frame {
149
+ position: relative;
150
+ width: 100%;
151
+ aspect-ratio: 3 / 4; /* default; overridden by inject_preview_css */
152
+ border-radius: 12px;
153
+ overflow: hidden;
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
+
159
+ /* CONTAIN for both IMG and VIDEO (no cropping, no spill) */
160
+ .lv-frame > img,
161
+ .lv-frame > video,
162
+ .lv-frame > .lv-media {
163
+ width: 100%;
164
+ height: 100%;
165
+ object-fit: contain !important;
166
+ display: block;
167
+ background: transparent;
168
+ }
169
+
170
+ /* reset inner element spacing to avoid shifting card height */
171
+ .lv-frame * { margin: 0 !important; padding: 0 !important; border: 0 !important; }
172
  </style>
173
  """, unsafe_allow_html=True)
174
 
175
  def inject_preview_css(aspect: str, fit_mode="contain"):
176
+ try:
177
+ a, b = [int(x) for x in aspect.split(":")]
178
+ except Exception:
179
+ a, b = 3, 4
180
  st.markdown(f"""
181
  <style>
182
+ .lv-frame {{ aspect-ratio: {a} / {b}; }}
183
+ .lv-frame > img, .lv-frame > video, .lv-frame > .lv-media {{ object-fit: {fit_mode}; }}
184
  </style>
185
  """, unsafe_allow_html=True)
186
 
 
209
 
210
  def make_preview_frame(img_bytes: bytes, aspect: str, long_edge: int, ratio: float, bg_rgb=(255,255,255)) -> bytes:
211
  im = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
212
+ a, b = _parse_aspect_str(aspect)
213
+ if a >= b:
214
+ W = max(256, min(8192, int(long_edge))); H = max(1, int(W * b / a))
215
  else:
216
+ H = max(256, min(8192, int(long_edge))); W = max(1, int(H * a / b))
217
  ratio = max(0.10, min(0.99, float(ratio)))
218
+ target = int(min(W, H) * ratio if a == b else (H * ratio if a < b else W * ratio))
219
  w, h = im.size
220
+ if w >= h: new_w, new_h = target, max(1, int(h * (new_w / max(1, w))))
221
+ else: new_h, new_w = target, max(1, int(w * (new_h / max(1, h))))
222
  im_resized = im.resize((new_w, new_h), Image.LANCZOS)
223
  canvas = Image.new("RGBA", (W, H), (*bg_rgb, 255))
224
  off = ((W - new_w)//2, (H - new_h)//2); canvas.paste(im_resized, off, im_resized)
225
  out = io.BytesIO(); canvas.convert("RGB").save(out, format="JPEG", quality=90, subsampling=1)
226
+ return out.read()
227
 
228
  # ==== Local demo files (optional) ====
229
  def load_local_demo():
 
296
 
297
  colT1, colT2, colT3, colT4 = st.columns([1.0, 1.0, 1.0, 1.2])
298
  with colT1:
299
+ preview_aspect = st.selectbox("Preview Frame Ratio", ASPECT_OPTIONS, index=0, key="preview_aspect") # default 3:4
300
  with colT2:
301
+ preview_long_edge = st.number_input("Preview Long Edge (px)", min_value=256, max_value=8192, value=1536, step=64, key="preview_long_edge")
302
  with colT3:
303
  fit_mode = st.selectbox("Preview Layout", ["fit","fill"], index=0, help="Affects previews only.", key="fit_mode")
304
  with colT4:
305
  st.info(f"Connection: {'Online' if ok else 'Offline'}", icon="🔌")
306
 
307
+ # Apply uniform frame CSS
308
  inject_preview_css(preview_aspect, fit_mode="contain" if fit_mode=="fit" else "cover")
309
 
310
  # ==== Build edit prompt (cars) ====
 
321
  parts.append(extra.strip())
322
  return ", ".join(parts)
323
 
324
+ # ==== Pre-run PREVIEWS (5 uniform cards) ====
325
+ st.subheader("Previews (Uniform Cards)")
326
  c_in, c_pack, c_pad, c_scene, c_video = st.columns(5)
327
 
 
 
 
 
 
 
 
 
 
 
328
  def show_tile_card_display_download(
329
  col, title,
330
  display_url: str | None = None,
 
335
  key_prefix: str = "",
336
  ):
337
  col.markdown(f'<div class="lv-card-title">{title}</div>', unsafe_allow_html=True)
 
 
 
338
 
339
+ # FRAME (always same size)
340
  if display_bytes is not None:
341
  src = image_bytes_to_data_url(display_bytes)
342
+ col.markdown(f'<div class="lv-frame"><img class="lv-media" src="{src}"/></div>', unsafe_allow_html=True)
343
+ elif display_url:
344
  abs_url = _abs_backend_url(display_url)
345
+ col.markdown(f'<div class="lv-frame"><img class="lv-media" src="{abs_url}"/></div>', unsafe_allow_html=True)
346
+ else:
347
+ col.markdown('<div class="lv-frame"></div>', unsafe_allow_html=True)
348
+ col.caption("—"); return None, None
349
 
350
+ # Native (caption + download)
351
  native = download_bytes or (fetch_bytes(download_url) if download_url else None)
352
  if not native:
353
  native = display_bytes
 
354
  if native:
355
  sz = image_size_from_bytes(native)
356
  if sz: col.caption(f"Real Res: {sz[0]}×{sz[1]} px")
 
364
 
365
  return display_url or "data", native
366
 
367
+ # Input bytes
368
+ input_bytes = None
369
+ if file_list:
370
+ try: input_bytes = file_list[0].getvalue()
371
+ except Exception: input_bytes = None
372
+ elif image_url:
373
+ input_bytes = fetch_bytes(image_url)
374
+ elif demo_files.get("input.jpg"):
375
+ input_bytes = demo_files["input.jpg"]
376
+
377
  show_tile_card_display_download(
378
+ c_in, "Sample Input",
379
  display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
380
  download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"),
381
  filename_stub="input", key_prefix="pre"
 
383
 
384
  pack_bytes_demo = demo_files.get("packshot.jpg") or input_bytes
385
  show_tile_card_display_download(
386
+ c_pack, "Sample Packshot (Configured)",
387
  display_bytes=pack_bytes_demo,
388
  download_bytes=pack_bytes_demo,
389
  filename_stub="packshot", key_prefix="pre"
 
398
  except Exception:
399
  padded_bytes = None
400
  show_tile_card_display_download(
401
+ c_pad, "Sample Padded (UI)",
402
  display_bytes=padded_bytes or pack_bytes_demo,
403
  download_bytes=padded_bytes or pack_bytes_demo,
404
  filename_stub="padded_ui", key_prefix="pre"
 
406
 
407
  scene_bytes_demo = demo_files.get("scene.jpg") or pack_bytes_demo
408
  show_tile_card_display_download(
409
+ c_scene, "Sample Scene",
410
  display_bytes=scene_bytes_demo,
411
  download_bytes=scene_bytes_demo,
412
  filename_stub="scene_composite", key_prefix="pre"
413
  )
414
 
415
+ c_video.markdown('<div class="lv-card-title">Sample Video</div>', unsafe_allow_html=True)
416
  vid_demo = demo_files.get("tryon.mp4")
417
  if vid_demo:
418
  data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii")
419
  c_video.markdown(
420
  f"""
421
+ <div class="lv-frame">
422
+ <video class="lv-media" autoplay loop muted playsinline>
423
  <source src="{data_url}" type="video/mp4">
424
  </video>
425
  </div>
 
443
  with colAct5:
444
  run = st.button("Run ▶", type="primary", use_container_width=True, key="run_btn")
445
 
446
+ # ==== Pipeline ====
447
+ def build_edit_prompt(model, color, finish, wheel, caliper, extra):
448
+ parts = [
449
+ f"Lamborghini {model}",
450
+ f"body color: {color}",
451
+ f"finish: {finish}",
452
+ f"wheel style: {wheel}",
453
+ f"brake calipers: {caliper}",
454
+ "catalog-clean alignment, keep true geometry, badges and proportions, realistic reflections"
455
+ ]
456
+ if extra and extra.strip(): parts.append(extra.strip())
457
+ return ", ".join(parts)
458
+
459
  if run:
460
  if not ok:
461
  st.error("Backend unreachable. Check API_BASE / TOKEN.")
 
463
  st.error("Provide at least one image (upload or URL).")
464
  else:
465
  try:
 
466
  edit_prompt = build_edit_prompt(model_sel, body_color, finish, wheel, caliper, extra_prompt)
467
  scene_text = SCENE_PRESETS.get(scene_name, "premium studio scene")
468
  img_urls = image_url or ""
 
469
  files_payload = []
470
  if file_list:
471
  for f in file_list:
472
  files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg")))
 
473
  data = {
474
  "category": "auto",
475
  "edit_prompt": edit_prompt,
476
  "num_images": "1",
477
  "image_urls": img_urls,
 
478
  "do_scene_pass": "true" if do_scene_pass else "false",
479
  "scene_prompt_text": scene_text,
 
480
  "identity_lock": "true" if identity_lock else "false",
 
481
  "preview_aspect": preview_aspect,
482
  "preview_long_edge": str(int(preview_long_edge)),
483
  "preview_ratio": str(float(preview_ratio)),
 
484
  "upscale": "true" if upscale else "false",
485
  "upscale_factor": "2",
486
  "upscale_stage": st.session_state.get("upscale_stage","both"),
 
487
  "to_video": "true" if to_video else "false",
488
+ "video_prompt": scene_text,
489
  "duration": st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
490
  "resolution": st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
491
  "prompt_optimizer": "false",
492
  }
 
493
  with st.spinner("LamboVision pipeline running…"):
494
  out = post_chain(data, files_payload if files_payload else None)
495
 
496
  st.session_state["job_counter"] += 1
497
 
 
498
  pack_native_url = None; pack_display_jpeg = None
499
  try:
500
  pack_native_url = (out["packshot_step"]["result"]["images_native"] or [{}])[0].get("url")
 
553
  except Exception as e:
554
  st.error(f"Error: {e}")
555
 
556
+ # ==== Results (Uniform Cards) ====
557
  res = st.session_state.get("results")
558
  vbytes = None
559
  if res:
560
+ st.subheader("Results (Uniform Cards)")
561
  c_pack2, c_pad2, c_scene2, c_video2 = st.columns(4)
562
 
563
  show_tile_card_display_download(
 
569
  )
570
 
571
  show_tile_card_display_download(
572
+ c_pad2, "Padded (Canvas)",
573
  display_bytes=res.get("padded_display_jpeg"),
574
  download_url=res.get("padded_native_url"),
575
  filename_stub="padded_backend",
 
584
  key_prefix=f"job{st.session_state['job_counter']}"
585
  )
586
 
 
 
587
  c_video2.markdown('<div class="lv-card-title">AI Cinematic</div>', unsafe_allow_html=True)
588
+ vurl = res.get("video_url")
589
  if vurl:
590
  abs_v = _abs_backend_url(vurl)
591
  c_video2.markdown(
592
  f"""
593
+ <div class="lv-frame">
594
+ <video class="lv-media" autoplay loop muted controls playsinline>
595
  <source src="{abs_v}" type="video/mp4">
596
  </video>
597
  </div>
 
604
  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")
605
  else:
606
  c_video2.markdown('<div class="lv-frame"></div>', unsafe_allow_html=True)
 
607
  vid_checkbox_key = f"video_toggle_job{st.session_state['job_counter']}"
608
  if st.checkbox("Generate Video From Scene", value=False, key=vid_checkbox_key):
609
  src_for_video = res.get("scene_native_url") or res.get("packshot_native_url")
 
616
  src_for_video,
617
  duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),
618
  resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),
619
+ preview_aspect=st.session_state.get("preview_aspect","3:4"),
620
  prompt=res.get("scene_prompt_text")
621
  )
622
  except requests.HTTPError as e: