Spaces:
Sleeping
Sleeping
| # streamlit_app.py — LamboVision · AI Car Studio (native-only UI, no padding) | |
| # Flow: Step 1 Gallery (Background Cleaned) → Step 2 AI Configurated (config) → Step 3 AI Generated Video | |
| # Tiles use a uniform aspect. Images/videos fit inside (no overflow). “Generate Video From These Results” button included. | |
| import os, io, base64, zipfile, requests, streamlit as st | |
| from PIL import Image | |
| # ===== Theme (dark + Lamborghini orange) ===== | |
| try: | |
| st._config.set_option("theme.base", "dark") | |
| st._config.set_option("theme.primaryColor", "#ff7a1a") | |
| st._config.set_option("theme.backgroundColor", "#0e0f12") | |
| st._config.set_option("theme.secondaryBackgroundColor", "#161a1f") | |
| st._config.set_option("theme.textColor", "#e7e9ed") | |
| except Exception: | |
| pass | |
| # ================= Env & API ================= | |
| API_BASE = (os.getenv("AI_LamboVision_API", "http://127.0.0.1:7860") or "http://127.0.0.1:7860").strip().strip("'\"").rstrip("/") | |
| HF_TOKEN = (os.getenv("AI_LamboVision_TOKEN", "") or "").strip().strip("'\"") | |
| HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} | |
| VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720")) | |
| VIDEO_RES_OPTIONS = ["512P", "768P"] | |
| VIDEO_DUR_OPTIONS = ["6", "10"] | |
| ASPECT_OPTIONS = ["3:4", "9:16", "1:1", "16:9"] # default 3:4 | |
| # ================= Session ================= | |
| if "results" not in st.session_state: | |
| st.session_state["results"] = None | |
| if "job_counter" not in st.session_state: | |
| st.session_state["job_counter"] = 0 | |
| if "video_ui_max_px" not in st.session_state: | |
| st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT | |
| # ================= Helpers ================= | |
| def _needs_auth(url: str) -> bool: | |
| return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/") | |
| def _abs_backend_url(url_or_path: str) -> str: | |
| if not url_or_path: | |
| return "" | |
| if url_or_path.startswith(("http://","https://")): | |
| return url_or_path | |
| if url_or_path.startswith("/outputs/"): | |
| return f"{API_BASE}{url_or_path}" | |
| if url_or_path.startswith("outputs/"): | |
| return f"{API_BASE}/{url_or_path}" | |
| return f"{API_BASE}/{url_or_path.lstrip('/')}" | |
| def backend_ok() -> bool: | |
| try: | |
| r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10) | |
| r.raise_for_status() | |
| return True | |
| except Exception: | |
| return False | |
| def post_image_edit(data: dict, files_payload): | |
| r = requests.post(f"{API_BASE}/v1/image/edit", data=data, files=files_payload or None, | |
| headers=HEADERS, timeout=600) | |
| r.raise_for_status() | |
| return r.json() | |
| def post_video(image_url: str, duration="6", resolution="768P", preview_aspect="3:4", prompt_text="", prompt_optimizer=False): | |
| payload = { | |
| "image_url": image_url, | |
| "duration": duration, | |
| "resolution": resolution, | |
| "preview_aspect": preview_aspect, | |
| "prompt": prompt_text or "", | |
| "prompt_optimizer": "true" if prompt_optimizer else "false", | |
| } | |
| r = requests.post(f"{API_BASE}/v1/video/from-image", data=payload, headers=HEADERS, timeout=600) | |
| r.raise_for_status() | |
| return r.json() | |
| def fetch_bytes(url_or_path: str): | |
| if not url_or_path: return None | |
| try: | |
| url = _abs_backend_url(url_or_path) | |
| headers = HEADERS if _needs_auth(url) else None | |
| r = requests.get(url, headers=headers, timeout=180) | |
| r.raise_for_status() | |
| return r.content | |
| except Exception: | |
| return None | |
| def _sniff_mime_from_bytes(b: bytes) -> str: | |
| try: | |
| fmt = (Image.open(io.BytesIO(b)).format or "JPEG").lower() | |
| return {"jpeg":"image/jpeg","jpg":"image/jpeg","png":"image/png","webp":"image/webp"}.get(fmt,"image/jpeg") | |
| except Exception: | |
| return "image/jpeg" | |
| def image_bytes_to_data_url(b: bytes, mime: str | None = None) -> str: | |
| if not mime: | |
| mime = _sniff_mime_from_bytes(b) | |
| enc = base64.b64encode(b).decode("ascii") | |
| return f"data:{mime};base64,{enc}" | |
| def image_size_from_bytes(b: bytes): | |
| try: | |
| im = Image.open(io.BytesIO(b)); return im.size | |
| except Exception: | |
| return None | |
| def infer_ext(data: bytes) -> str: | |
| try: | |
| im = Image.open(io.BytesIO(data)) | |
| fmt = (im.format or "JPEG").lower() | |
| return "." + {"jpeg":"jpg","jpg":"jpg","png":"png","webp":"webp"}.get(fmt,"jpg") | |
| except Exception: | |
| if data[:4] == b"\x00\x00\x00\x18" or data[4:8] == b"ftyp": | |
| return ".mp4" | |
| return ".bin" | |
| def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes: | |
| buf = io.BytesIO() | |
| with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: | |
| for fname, b in named_bytes: | |
| if b: z.writestr(fname, b) | |
| buf.seek(0); return buf.read() | |
| def _parse_aspect_str(s: str): | |
| try: | |
| a, b = s.split(":"); return max(1, int(a)), max(1, int(b)) | |
| except Exception: | |
| return (3, 4) | |
| def inject_base_css(card_fit_mode="contain"): | |
| st.markdown(f""" | |
| <style> | |
| .stApp {{ background: #0e0f12; }} | |
| header[data-testid="stHeader"] {{ background: transparent; }} | |
| .block-container {{ padding-top: 1.0rem; }} | |
| h1, .stMarkdown h1 {{ font-size: 1.85rem; margin-bottom: 0.15rem; }} | |
| .stMarkdown p {{ margin-top: 0.1rem; }} | |
| label, .stTextInput label, .stSelectbox label, .stNumberInput label, .stFileUploader label, | |
| .stCheckbox label, .stSlider label, .stRadio label {{ | |
| color: #ff8a33 !important; font-weight: 600 !important; | |
| }} | |
| .stButton > button[kind="primary"]{{ background:#ff7a1a !important; border:0 !important; color:#111 !important; }} | |
| .stButton > button:hover {{ filter: brightness(1.05); }} | |
| .lb-card-title {{ font-weight:700; font-size:1.05rem; margin:2px 0 8px 0; color:#eaecef; }} | |
| .lb-frame {{ | |
| position: relative; width: 100%; | |
| border-radius: 12px; overflow: hidden; | |
| background: #111419; | |
| display:flex; align-items:center; justify-content:center; | |
| box-shadow: 0 0 0 1px rgba(255,122,26,0.15) inset; | |
| }} | |
| .lb-frame img, .lb-frame video {{ | |
| width:100%; height:100%; display:block; object-fit:{card_fit_mode}; | |
| }} | |
| .stCaption {{ color:#ffb07a !important; }} | |
| </style> | |
| """, unsafe_allow_html=True) | |
| def inject_preview_css(aspect: str): | |
| aw, ah = _parse_aspect_str(aspect) | |
| st.markdown(f""" | |
| <style> | |
| .lb-frame {{ aspect-ratio: {aw} / {ah}; }} | |
| </style> | |
| """, unsafe_allow_html=True) | |
| def show_tile_card_display_download( | |
| col, title, | |
| display_url: str | None = None, | |
| display_bytes: bytes | None = None, | |
| download_url: str | None = None, | |
| download_bytes: bytes | None = None, | |
| filename_stub: str = "image", | |
| key_prefix: str = "", | |
| ): | |
| col.markdown(f'<div class="lb-card-title">{title}</div>', unsafe_allow_html=True) | |
| if not display_url and not display_bytes: | |
| col.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True) | |
| col.caption("—"); return None, None | |
| if display_bytes is not None: | |
| src = image_bytes_to_data_url(display_bytes) | |
| col.markdown(f'<div class="lb-frame"><img src="{src}"/></div>', unsafe_allow_html=True) | |
| else: | |
| abs_url = _abs_backend_url(display_url) | |
| col.markdown(f'<div class="lb-frame"><img src="{abs_url}"/></div>', unsafe_allow_html=True) | |
| native = download_bytes or (fetch_bytes(download_url) if download_url else None) | |
| if not native: | |
| native = display_bytes | |
| if native: | |
| sz = image_size_from_bytes(native) | |
| if sz: col.caption(f"Real Res: {sz[0]}×{sz[1]} px") | |
| ext = infer_ext(native) | |
| mime = "image/jpeg" if ext.lower() in [".jpg",".jpeg"] else ("image/png" if ext.lower()==".png" else "image/webp") | |
| col.download_button("Download (Full Quality)", data=native, | |
| file_name=f"{filename_stub}{ext if ext!='.bin' else '.jpg'}", | |
| mime=mime, key=f"{key_prefix}_{filename_stub}_dl") | |
| else: | |
| col.caption("—") | |
| return display_url or "data", native | |
| def rgba_to_rgb_on_bg(img_rgba: Image.Image, bg_rgb=(255,255,255)) -> Image.Image: | |
| if img_rgba.mode != "RGBA": | |
| return img_rgba.convert("RGB") | |
| bg = Image.new("RGBA", img_rgba.size, (*bg_rgb, 255)) | |
| bg.paste(img_rgba, (0,0), img_rgba) | |
| return bg.convert("RGB") | |
| def make_small_jpeg_from_native(native: bytes, target_long_edge: int = 1280, bg_rgb=(255,255,255)) -> bytes: | |
| im = Image.open(io.BytesIO(native)) | |
| if im.mode != "RGBA" and "transparency" not in im.info: | |
| im = im.convert("RGB") | |
| else: | |
| im = im.convert("RGBA") | |
| w, h = im.size | |
| if w >= h: | |
| new_w = min(target_long_edge, w) | |
| new_h = max(1, int(h * (new_w / max(1, w)))) | |
| else: | |
| new_h = min(target_long_edge, h) | |
| new_w = max(1, int(w * (new_h / max(1, h)))) | |
| im2 = im.resize((new_w, new_h), Image.LANCZOS) | |
| im2_rgb = rgba_to_rgb_on_bg(im2, bg_rgb=bg_rgb) | |
| out = io.BytesIO(); im2_rgb.save(out, format="JPEG", quality=88, subsampling=1) | |
| return out.getvalue() | |
| def build_car_prompt(model_name: str, body_color: str, finish: str, wheel_style: str, caliper_color: str, extra: str = "") -> str: | |
| base = ( | |
| "Catalog-ready studio photo of a Lamborghini from a three-quarter front view. " | |
| "Neutral studio background, soft rim light, realistic reflections, no text/logos in background. " | |
| "Keep true proportions, lens look (35–50mm), and paint micro-highlights. " | |
| "IDENTITY & GEOMETRY LOCK: Treat the car as a fixed real object. " | |
| "Do NOT redraw or stylize body panels; preserve body lines, panel gaps, reflections and lenses; " | |
| "no rotation/mirroring (Δyaw/pitch/roll ≤ 1°); keep wheel circles true, tire sidewalls, brake discs & calipers, ride height; " | |
| "preserve badges/logo integrity; no warp or fake text." | |
| ) | |
| cfg = ( | |
| f" Subject: {model_name}. " | |
| f"Change body color to {body_color} with {finish} finish. " | |
| f"Wheels: {wheel_style}; brake calipers {caliper_color}. " | |
| "Maintain original stance; no suspension changes; no aero additions." | |
| ) | |
| if extra.strip(): | |
| cfg += " " + extra.strip() | |
| return base + " " + cfg | |
| SCENE_PRESETS = { | |
| "Studio Cinematic": "High-end studio cyclorama, soft rim light, tripod-like stability, subtle parallax.", | |
| "Monaco Sunset": "Monaco coastal boulevard at golden hour; gentle dolly-in; realistic paint reflections.", | |
| "Dubai Night": "Dubai Marina night city lights; lateral parallax; crisp highlights; clean reflections." | |
| } | |
| def build_video_prompt(scene_name: str, extra: str = "") -> str: | |
| base = SCENE_PRESETS.get(scene_name, "High-end studio, subtle parallax, natural camera micro-motion.") | |
| lock = " Keep logos and wheel circles intact; no wheel warping; no added text; realistic reflections." | |
| if extra.strip(): return base + " " + lock + " " + extra.strip() | |
| return base + " " + lock | |
| def make_gallery_payload(preview_aspect): | |
| return { | |
| "category": "auto", | |
| "prompt": "", # server defaults to AUTO_CLEAN_PROMPT | |
| "num_images": "1", | |
| "preview_aspect": preview_aspect, | |
| "preview_long_edge": "1536", | |
| "preview_ratio": "0.9", | |
| "upscale": "false", | |
| "upscale_factor": "2", | |
| } | |
| def make_composition_payload(edit_prompt, preview_aspect, upscale, upscale_factor): | |
| return { | |
| "category": "auto", | |
| "prompt": edit_prompt, | |
| "num_images": "1", | |
| "preview_aspect": preview_aspect, | |
| "preview_long_edge": "1536", | |
| "preview_ratio": "0.9", | |
| "upscale": "true" if upscale else "false", | |
| "upscale_factor": upscale_factor, | |
| } | |
| # ================= Local demo assets (optional) ================= | |
| def load_local_demo(): | |
| demo = {} | |
| for fname in ["input.jpg", "packshot.jpg", "model.jpg", "tryon.mp4"]: | |
| if os.path.exists(fname): | |
| with open(fname, "rb") as f: demo[fname] = f.read() | |
| return demo | |
| demo_files = load_local_demo() | |
| # ================= Page ================= | |
| st.set_page_config(page_title="LamboVision · AI Car Studio", layout="wide", page_icon="🏎️") | |
| inject_base_css(card_fit_mode="contain") | |
| st.title("🏎️ LamboVision · AI Car Studio") | |
| st.caption("Step 1: Gallery (Background Cleaned) · Step 2: AI Configurated (config) · Step 3: AI Generated Video") | |
| ok = backend_ok() | |
| # -------- Top control bar -------- | |
| col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.0, 1.2, 0.9, 1.1, 0.9]) | |
| with col1: | |
| file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl") | |
| with col2: | |
| image_url = st.text_input("Or URL", key="image_url", placeholder="https://... (optional)") | |
| with col3: | |
| preview_aspect = st.selectbox("Tile Aspect", ASPECT_OPTIONS, index=0, key="preview_aspect") # default 3:4 | |
| with col4: | |
| to_video = st.checkbox("Auto-generate Video after Run", False, key="to_video") | |
| with col5: | |
| duration = st.selectbox("Video Duration", VIDEO_DUR_OPTIONS, index=0, key="duration_sel") | |
| with col6: | |
| resolution = st.selectbox("Video Res", VIDEO_RES_OPTIONS, index=1, key="resolution_sel") | |
| with col7: | |
| run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn") | |
| inject_preview_css(preview_aspect) | |
| st.caption("All tiles use the same aspect. Images and videos fit inside; no overflow.") | |
| # -------- Configurator (dropdowns + custom) -------- | |
| st.markdown("### Configurator") | |
| MODEL_CHOICES = [ | |
| "Revuelto", "Huracán Tecnica", "Huracán Sterrato", | |
| "Urus S", "Urus Performante", "Countach LPI 800-4", "Custom…" | |
| ] | |
| COLOR_CHOICES = [ | |
| "Verde Ithaca", "Giallo Orion", "Rosso Mars", | |
| "Blu Nethuns", "Nero Noctis", "Bianco Monocerus", "Arancio Borealis", "Custom…" | |
| ] | |
| FINISH_CHOICES = ["gloss", "matte", "satin"] | |
| WHEEL_CHOICES = [ | |
| "lightweight forged", "diamond-cut multi-spoke", | |
| "center-lock race", "Aesir (20\")", "Taigete (23\")", "Custom…" | |
| ] | |
| CALIPER_CHOICES = ["yellow", "red", "black", "green", "silver", "Custom…"] | |
| c1, c2, c3, c4, c5, c6 = st.columns([1.3, 1.1, 0.9, 1.2, 1.0, 1.4]) | |
| with c1: | |
| model_sel = st.selectbox("Model", MODEL_CHOICES, index=0, key="cfg_model_sel") | |
| model_name = st.text_input("Model (custom)", value="", key="cfg_model_txt") if model_sel == "Custom…" else model_sel | |
| with c2: | |
| color_sel = st.selectbox("Body color", COLOR_CHOICES, index=0, key="cfg_color_sel") | |
| body_color = st.text_input("Body color (custom)", value="", key="cfg_color_txt") if color_sel == "Custom…" else color_sel | |
| with c3: | |
| finish = st.selectbox("Finish", FINISH_CHOICES, index=0, key="cfg_finish") | |
| with c4: | |
| wheel_sel = st.selectbox("Wheel style", WHEEL_CHOICES, index=0, key="cfg_wheels_sel") | |
| wheel_style = st.text_input("Wheel style (custom)", value="", key="cfg_wheels_txt") if wheel_sel == "Custom…" else wheel_sel | |
| with c5: | |
| caliper_sel = st.selectbox("Caliper", CALIPER_CHOICES, index=0, key="cfg_caliper_sel") | |
| caliper_color = st.text_input("Caliper (custom)", value="", key="cfg_caliper_txt") if caliper_sel == "Custom…" else caliper_sel | |
| with c6: | |
| extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, …", key="cfg_extra") | |
| # -------- Scene (for video) -------- | |
| st.markdown("### Scene") | |
| s1, s2 = st.columns([1.2, 1.8]) | |
| with s1: | |
| scene_name = st.selectbox("Scene preset", list(SCENE_PRESETS.keys()), index=0, key="scene_name") | |
| with s2: | |
| scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra") | |
| # ================= SAMPLE PREVIEWS ================= | |
| st.subheader("Sample Previews") | |
| col_in, col_gallery, col_comp, col_vid = st.columns(4) | |
| input_bytes = None | |
| if file_list: | |
| try: input_bytes = file_list[0].getvalue() | |
| except Exception: input_bytes = None | |
| elif image_url: | |
| input_bytes = fetch_bytes(image_url) | |
| elif demo_files.get("input.jpg"): | |
| input_bytes = demo_files["input.jpg"] | |
| show_tile_card_display_download( | |
| col_in, "Input", | |
| display_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"), | |
| download_bytes=input_bytes if input_bytes else demo_files.get("input.jpg"), | |
| filename_stub="input", key_prefix="pre" | |
| ) | |
| gallery_bytes = demo_files.get("packshot.jpg") or input_bytes | |
| show_tile_card_display_download( | |
| col_gallery, "Gallery (Background Cleaned)", | |
| display_bytes=gallery_bytes, | |
| download_bytes=gallery_bytes, | |
| filename_stub="gallery", key_prefix="pre" | |
| ) | |
| conf_bytes = demo_files.get("model.jpg") or input_bytes | |
| show_tile_card_display_download( | |
| col_comp, "AI Configurated", | |
| display_bytes=conf_bytes, | |
| download_bytes=conf_bytes, | |
| filename_stub="ai_configurated", key_prefix="pre" | |
| ) | |
| vid_demo = demo_files.get("tryon.mp4") | |
| col_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True) | |
| if vid_demo: | |
| data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii") | |
| col_vid.markdown( | |
| f""" | |
| <div class="lb-frame"> | |
| <video autoplay loop muted playsinline> | |
| <source src="{data_url}" type="video/mp4"> | |
| </video> | |
| </div> | |
| """, unsafe_allow_html=True | |
| ) | |
| col_vid.download_button("Download Video (MP4)", data=vid_demo, file_name="tryon.mp4", mime="video/mp4", key="pre_video_dl") | |
| else: | |
| col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True) | |
| col_vid.caption("—") | |
| # ================= RUN (3-step chain) ================= | |
| if run: | |
| if not ok: | |
| st.error("Backend not reachable. Check API_BASE / TOKEN.") | |
| elif not (file_list or image_url or demo_files.get("input.jpg")): | |
| st.error("Provide at least one input image (upload or URL).") | |
| else: | |
| try: | |
| files_payload = [] | |
| if file_list: | |
| for f in file_list: | |
| files_payload.append(("files", (f.name, f.getvalue(), f.type or "image/jpeg"))) | |
| # ---- Step 1: Gallery (Background Cleaned; native only) ---- | |
| gallery_payload = make_gallery_payload(preview_aspect) | |
| if image_url: | |
| gallery_payload["image_urls"] = image_url | |
| out_gallery = post_image_edit(gallery_payload, files_payload if files_payload else None) | |
| gallery_native_url = (out_gallery["result"]["images_native"] or [{}])[0].get("url") if out_gallery else None | |
| # ---- Step 2: AI Configurated (config prompt; native only) ---- | |
| edit_prompt = build_car_prompt( | |
| model_name=(model_name or "Lamborghini"), | |
| body_color=(body_color or "Verde Ithaca"), | |
| finish=finish, | |
| wheel_style=(wheel_style or "lightweight forged"), | |
| caliper_color=(caliper_color or "yellow"), | |
| extra=extra_prompt | |
| ) | |
| comp_payload = make_composition_payload(edit_prompt, preview_aspect, upscale=False, upscale_factor="2") | |
| comp_payload["image_urls"] = gallery_native_url or "" | |
| out_comp = post_image_edit(comp_payload, files_payload=None) | |
| comp_native_url = (out_comp["result"]["images_native"] or [{}])[0].get("url") if out_comp else None | |
| # ---- Optional auto video right after Run ---- | |
| vurl = None; ui_hints = {} | |
| if st.session_state.get("to_video", False) and (comp_native_url or gallery_native_url): | |
| video_prompt = build_video_prompt(scene_name, scene_extra) | |
| v = post_video( | |
| comp_native_url or gallery_native_url, | |
| duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]), | |
| resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]), | |
| preview_aspect=st.session_state.get("preview_aspect","3:4"), | |
| prompt_text=video_prompt, | |
| prompt_optimizer=False, | |
| ) | |
| vurl = (v.get("result") or {}).get("video", {}).get("url") | |
| ui_hints = v.get("ui_hints") or {} | |
| if isinstance(ui_hints, dict): | |
| st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT)) | |
| # ---- Save results to session ---- | |
| st.session_state["job_counter"] += 1 | |
| st.session_state["results"] = { | |
| "gallery_native_url": gallery_native_url, | |
| "composition_native_url": comp_native_url, | |
| "video_url": vurl, | |
| "api_base": API_BASE, | |
| "preview_aspect": preview_aspect, | |
| } | |
| except requests.HTTPError as e: | |
| st.error(f"HTTP {e.response.status_code}") | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # ================= RESULTS ================= | |
| res = st.session_state.get("results") | |
| vbytes = None | |
| if res: | |
| st.subheader("Results") | |
| c_gallery, c_comp, c_vid = st.columns([1.2, 1.2, 1.2]) | |
| # Gallery (Background Cleaned, native) | |
| show_tile_card_display_download( | |
| c_gallery, "Gallery (Background Cleaned)", | |
| display_url=res.get("gallery_native_url"), | |
| download_url=res.get("gallery_native_url"), | |
| filename_stub="gallery_native", | |
| key_prefix=f"job{st.session_state['job_counter']}" | |
| ) | |
| # AI Configurated (native) | |
| show_tile_card_display_download( | |
| c_comp, "AI Configurated", | |
| display_url=res.get("composition_native_url"), | |
| download_url=res.get("composition_native_url"), | |
| filename_stub="ai_configurated_native", | |
| key_prefix=f"job{st.session_state['job_counter']}" | |
| ) | |
| # AI Generated Video | |
| c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True) | |
| vurl = res.get("video_url") | |
| if vurl: | |
| abs_v = _abs_backend_url(vurl) | |
| c_vid.markdown( | |
| f""" | |
| <div class="lb-frame"> | |
| <video autoplay loop muted controls playsinline> | |
| <source src="{abs_v}" type="video/mp4"> | |
| </video> | |
| </div> | |
| """, unsafe_allow_html=True | |
| ) | |
| vb = fetch_bytes(abs_v) | |
| if vb: | |
| vbytes = vb | |
| c_vid.download_button("Download Video (MP4)", data=vb, file_name="lambovision_video.mp4", | |
| mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl") | |
| else: | |
| c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True) | |
| # ---- Explicit manual video trigger button ---- | |
| gen_now = c_vid.button("Generate Video From These Results", key=f"gen_video_job{st.session_state['job_counter']}") | |
| if gen_now: | |
| src_for_video = res.get("composition_native_url") or res.get("gallery_native_url") | |
| if not src_for_video: | |
| st.warning("No image available to generate video.") | |
| else: | |
| with st.spinner("Generating Video …"): | |
| try: | |
| video_prompt = build_video_prompt(st.session_state.get("scene_name", "Studio Cinematic"), | |
| st.session_state.get("scene_extra","")) | |
| v = post_video( | |
| src_for_video, | |
| duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]), | |
| resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]), | |
| preview_aspect=st.session_state.get("preview_aspect","3:4"), | |
| prompt_text=video_prompt, | |
| prompt_optimizer=False, | |
| ) | |
| vurl2 = (v.get("result") or {}).get("video", {}).get("url") | |
| ui_hints = v.get("ui_hints") or {} | |
| if vurl2: | |
| st.session_state["results"]["video_url"] = vurl2 | |
| if isinstance(ui_hints, dict): | |
| st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT)) | |
| st.success("Video is ready. Showing preview…") | |
| st.rerun() | |
| else: | |
| st.info("Video URL not returned.") | |
| except requests.HTTPError as e: | |
| st.error(f"HTTP {e.response.status_code}") | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # ZIP bundle (bottom row) | |
| st.markdown("---") | |
| exp_col = st.container() | |
| with exp_col: | |
| st.markdown("#### Export") | |
| named = [] | |
| gnat = fetch_bytes(res.get("gallery_native_url")) if res.get("gallery_native_url") else None | |
| cnat = fetch_bytes(res.get("composition_native_url")) if res.get("composition_native_url") else None | |
| if gnat: named.append((f"gallery_native{infer_ext(gnat)}", gnat)) | |
| if cnat: named.append((f"ai_configurated_native{infer_ext(cnat)}", cnat)) | |
| if vbytes: named.append(("video.mp4", vbytes)) | |
| if named: | |
| zip_bytes = make_zip(named) | |
| st.download_button("Download All (ZIP)", data=zip_bytes, | |
| file_name="lambovision_outputs.zip", mime="application/zip", | |
| key=f"job{st.session_state['job_counter']}_zip_dl") | |
| else: | |
| st.caption("—") | |
| # ================= Sidebar ================= | |
| with st.sidebar: | |
| st.caption(f"API_BASE: {API_BASE}") | |
| if HF_TOKEN: st.caption("Auth: Bearer (active)") | |
| st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"], | |
| step=10, key="video_ui_max_px") | |
| if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.") | |
| if st.button("Clear results"): st.session_state["results"] = None; st.success("Cleared.") | |