Team Ai
Apppublic

renderfy/LamboVision

sourceHugging Faceupdated 1y agoView on Hugging Face
0likes
streamlit_app.py603 linesDownload Raw Back to root
1# streamlit_app.py — LamboVision · AI Car Studio (native-only UI, no padding)2# Flow: Step 1 Gallery (Background Cleaned) → Step 2 AI Configurated (config) → Step 3 AI Generated Video3# Tiles use a uniform aspect. Images/videos fit inside (no overflow). “Generate Video From These Results” button included.4 5import os, io, base64, zipfile, requests, streamlit as st6from PIL import Image7 8# ===== Theme (dark + Lamborghini orange) =====9try:10    st._config.set_option("theme.base", "dark")11    st._config.set_option("theme.primaryColor", "#ff7a1a")12    st._config.set_option("theme.backgroundColor", "#0e0f12")13    st._config.set_option("theme.secondaryBackgroundColor", "#161a1f")14    st._config.set_option("theme.textColor", "#e7e9ed")15except Exception:16    pass17 18# ================= Env & API =================19API_BASE = (os.getenv("AI_LamboVision_API", "http://127.0.0.1:7860") or "http://127.0.0.1:7860").strip().strip("'\"").rstrip("/")20HF_TOKEN = (os.getenv("AI_LamboVision_TOKEN", "") or "").strip().strip("'\"")21HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}22 23VIDEO_MAX_PX_DEFAULT = int(os.getenv("VIDEO_MAX_PX", "720"))24VIDEO_RES_OPTIONS = ["512P", "768P"]25VIDEO_DUR_OPTIONS = ["6", "10"]26ASPECT_OPTIONS = ["3:4", "9:16", "1:1", "16:9"]  # default 3:427 28# ================= Session =================29if "results" not in st.session_state:30    st.session_state["results"] = None31if "job_counter" not in st.session_state:32    st.session_state["job_counter"] = 033if "video_ui_max_px" not in st.session_state:34    st.session_state["video_ui_max_px"] = VIDEO_MAX_PX_DEFAULT35 36# ================= Helpers =================37def _needs_auth(url: str) -> bool:38    return url.startswith(API_BASE) or url.startswith("outputs/") or url.startswith("/outputs/")39 40def _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_path45    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 51def backend_ok() -> bool:52    try:53        r = requests.get(f"{API_BASE}/health", headers=HEADERS, timeout=10)54        r.raise_for_status()55        return True56    except Exception:57        return False58 59def post_image_edit(data: dict, files_payload):60    r = requests.post(f"{API_BASE}/v1/image/edit", data=data, files=files_payload or None,61                      headers=HEADERS, timeout=600)62    r.raise_for_status()63    return r.json()64 65def 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()76    return r.json()77 78@st.cache_data(ttl=300, show_spinner=False)79def fetch_bytes(url_or_path: str):80    if not url_or_path: return None81    try:82        url = _abs_backend_url(url_or_path)83        headers = HEADERS if _needs_auth(url) else None84        r = requests.get(url, headers=headers, timeout=180)85        r.raise_for_status()86        return r.content87    except Exception:88        return None89 90def _sniff_mime_from_bytes(b: bytes) -> str:91    try:92        fmt = (Image.open(io.BytesIO(b)).format or "JPEG").lower()93        return {"jpeg":"image/jpeg","jpg":"image/jpeg","png":"image/png","webp":"image/webp"}.get(fmt,"image/jpeg")94    except Exception:95        return "image/jpeg"96 97def 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 103@st.cache_data(ttl=300, show_spinner=False)104def image_size_from_bytes(b: bytes):105    try:106        im = Image.open(io.BytesIO(b)); return im.size107    except Exception:108        return None109 110def infer_ext(data: bytes) -> str:111    try:112        im = Image.open(io.BytesIO(data))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 120def make_zip(named_bytes: list[tuple[str, bytes]]) -> bytes:121    buf = io.BytesIO()122    with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:123        for fname, b in named_bytes:124            if b: z.writestr(fname, b)125    buf.seek(0); return buf.read()126 127def _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 133def inject_base_css(card_fit_mode="contain"):134    st.markdown(f"""135    <style>136      .stApp {{ background: #0e0f12; }}137      header[data-testid="stHeader"] {{ background: transparent; }}138      .block-container {{ padding-top: 1.0rem; }}139      h1, .stMarkdown h1 {{ font-size: 1.85rem; margin-bottom: 0.15rem; }}140      .stMarkdown p {{ margin-top: 0.1rem; }}141      label, .stTextInput label, .stSelectbox label, .stNumberInput label, .stFileUploader label,142      .stCheckbox label, .stSlider label, .stRadio label {{143        color: #ff8a33 !important; font-weight: 600 !important;144      }}145      .stButton > button[kind="primary"]{{ background:#ff7a1a !important; border:0 !important; color:#111 !important; }}146      .stButton > button:hover {{ filter: brightness(1.05); }}147      .lb-card-title {{ font-weight:700; font-size:1.05rem; margin:2px 0 8px 0; color:#eaecef; }}148      .lb-frame {{149        position: relative; width: 100%;150        border-radius: 12px; overflow: hidden;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};157      }}158      .stCaption {{ color:#ffb07a !important; }}159    </style>160    """, unsafe_allow_html=True)161 162def 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 170def 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, None183    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_bytes193    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", native204 205def 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)213def 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.size220    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 231def 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. "234        "Neutral studio background, soft rim light, realistic reflections, no text/logos in background. "235        "Keep true proportions, lens look (35–50mm), and paint micro-highlights. "236        "IDENTITY & GEOMETRY LOCK: Treat the car as a fixed real object. "237        "Do NOT redraw or stylize body panels; preserve body lines, panel gaps, reflections and lenses; "238        "no rotation/mirroring (Δyaw/pitch/roll ≤ 1°); keep wheel circles true, tire sidewalls, brake discs & calipers, ride height; "239        "preserve badges/logo integrity; no warp or fake text."240    )241    cfg = (242        f" Subject: {model_name}. "243        f"Change body color to {body_color} with {finish} finish. "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 + " " + cfg250 251SCENE_PRESETS = {252    "Studio Cinematic": "High-end studio cyclorama, soft rim light, tripod-like stability, subtle parallax.",253    "Monaco Sunset": "Monaco coastal boulevard at golden hour; gentle dolly-in; realistic paint reflections.",254    "Dubai Night": "Dubai Marina night city lights; lateral parallax; crisp highlights; clean reflections."255}256def build_video_prompt(scene_name: str, extra: str = "") -> str:257    base = SCENE_PRESETS.get(scene_name, "High-end studio, subtle parallax, natural camera micro-motion.")258    lock = " Keep logos and wheel circles intact; no wheel warping; no added text; realistic reflections."259    if extra.strip(): return base + " " + lock + " " + extra.strip()260    return base + " " + lock261 262def make_gallery_payload(preview_aspect):263    return {264        "category": "auto",265        "prompt": "",             # server defaults to AUTO_CLEAN_PROMPT266        "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 274def 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) =================287def 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 demo293 294demo_files = load_local_demo()295 296# ================= Page =================297st.set_page_config(page_title="LamboVision · AI Car Studio", layout="wide", page_icon="🏎️")298inject_base_css(card_fit_mode="contain")299 300st.title("🏎️ LamboVision · AI Car Studio")301st.caption("Step 1: Gallery (Background Cleaned) · Step 2: AI Configurated (config) · Step 3: AI Generated Video")302 303ok = backend_ok()304 305# -------- Top control bar --------306col1, col2, col3, col4, col5, col6, col7 = st.columns([1.6, 1.6, 1.0, 1.2, 0.9, 1.1, 0.9])307with col1:308    file_list = st.file_uploader("Car Image (Upload)", type=["jpg","jpeg","png","webp"], accept_multiple_files=True, key="file_upl")309with col2:310    image_url = st.text_input("Or URL", key="image_url", placeholder="https://... (optional)")311with col3:312    preview_aspect = st.selectbox("Tile Aspect", ASPECT_OPTIONS, index=0, key="preview_aspect")  # default 3:4313with col4:314    to_video = st.checkbox("Auto-generate Video after Run", False, key="to_video")315with col5:316    duration = st.selectbox("Video Duration", VIDEO_DUR_OPTIONS, index=0, key="duration_sel")317with col6:318    resolution = st.selectbox("Video Res", VIDEO_RES_OPTIONS, index=1, key="resolution_sel")319with col7:320    run = st.button("Run 🏁", type="primary", use_container_width=True, key="run_btn")321 322inject_preview_css(preview_aspect)323st.caption("All tiles use the same aspect. Images and videos fit inside; no overflow.")324 325# -------- Configurator (dropdowns + custom) --------326st.markdown("### Configurator")327 328MODEL_CHOICES = [329    "Revuelto", "Huracán Tecnica", "Huracán Sterrato",330    "Urus S", "Urus Performante", "Countach LPI 800-4", "Custom…"331]332COLOR_CHOICES = [333    "Verde Ithaca", "Giallo Orion", "Rosso Mars",334    "Blu Nethuns", "Nero Noctis", "Bianco Monocerus", "Arancio Borealis", "Custom…"335]336FINISH_CHOICES = ["gloss", "matte", "satin"]337WHEEL_CHOICES = [338    "lightweight forged", "diamond-cut multi-spoke",339    "center-lock race", "Aesir (20\")", "Taigete (23\")", "Custom…"340]341CALIPER_CHOICES = ["yellow", "red", "black", "green", "silver", "Custom…"]342 343c1, c2, c3, c4, c5, c6 = st.columns([1.3, 1.1, 0.9, 1.2, 1.0, 1.4])344 345with c1:346    model_sel = st.selectbox("Model", MODEL_CHOICES, index=0, key="cfg_model_sel")347    model_name = st.text_input("Model (custom)", value="", key="cfg_model_txt") if model_sel == "Custom…" else model_sel348 349with c2:350    color_sel = st.selectbox("Body color", COLOR_CHOICES, index=0, key="cfg_color_sel")351    body_color = st.text_input("Body color (custom)", value="", key="cfg_color_txt") if color_sel == "Custom…" else color_sel352 353with c3:354    finish = st.selectbox("Finish", FINISH_CHOICES, index=0, key="cfg_finish")355 356with c4:357    wheel_sel = st.selectbox("Wheel style", WHEEL_CHOICES, index=0, key="cfg_wheels_sel")358    wheel_style = st.text_input("Wheel style (custom)", value="", key="cfg_wheels_txt") if wheel_sel == "Custom…" else wheel_sel359 360with c5:361    caliper_sel = st.selectbox("Caliper", CALIPER_CHOICES, index=0, key="cfg_caliper_sel")362    caliper_color = st.text_input("Caliper (custom)", value="", key="cfg_caliper_txt") if caliper_sel == "Custom…" else caliper_sel363 364with c6:365    extra_prompt = st.text_input("Extra (optional)", value="", placeholder="stripe delete, studio floor reflection, …", key="cfg_extra")366 367# -------- Scene (for video) --------368st.markdown("### Scene")369s1, s2 = st.columns([1.2, 1.8])370with s1:371    scene_name = st.selectbox("Scene preset", list(SCENE_PRESETS.keys()), index=0, key="scene_name")372with s2:373    scene_extra = st.text_input("Scene extra (optional)", value="", key="scene_extra")374 375# ================= SAMPLE PREVIEWS =================376st.subheader("Sample Previews")377col_in, col_gallery, col_comp, col_vid = st.columns(4)378 379input_bytes = None380if file_list:381    try: input_bytes = file_list[0].getvalue()382    except Exception: input_bytes = None383elif image_url:384    input_bytes = fetch_bytes(image_url)385elif demo_files.get("input.jpg"):386    input_bytes = demo_files["input.jpg"]387 388show_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 395gallery_bytes = demo_files.get("packshot.jpg") or input_bytes396show_tile_card_display_download(397    col_gallery, "Gallery (Background Cleaned)",398    display_bytes=gallery_bytes,399    download_bytes=gallery_bytes,400    filename_stub="gallery", key_prefix="pre"401)402 403conf_bytes = demo_files.get("model.jpg") or input_bytes404show_tile_card_display_download(405    col_comp, "AI Configurated",406    display_bytes=conf_bytes,407    download_bytes=conf_bytes,408    filename_stub="ai_configurated", key_prefix="pre"409)410 411vid_demo = demo_files.get("tryon.mp4")412col_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)413if vid_demo:414    data_url = "data:video/mp4;base64," + base64.b64encode(vid_demo).decode("ascii")415    col_vid.markdown(416        f"""417        <div class="lb-frame">418          <video autoplay loop muted playsinline>419            <source src="{data_url}" type="video/mp4">420          </video>421        </div>422        """, unsafe_allow_html=True423    )424    col_vid.download_button("Download Video (MP4)", data=vid_demo, file_name="tryon.mp4", mime="video/mp4", key="pre_video_dl")425else:426    col_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)427    col_vid.caption("—")428 429# ================= RUN (3-step chain) =================430if run:431    if not ok:432        st.error("Backend not reachable. Check API_BASE / TOKEN.")433    elif not (file_list or image_url or demo_files.get("input.jpg")):434        st.error("Provide at least one input image (upload or URL).")435    else:436        try:437            files_payload = []438            if file_list: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 (Background Cleaned; native only) ----443            gallery_payload = make_gallery_payload(preview_aspect)444            if image_url:445                gallery_payload["image_urls"] = image_url446            out_gallery = post_image_edit(gallery_payload, files_payload if files_payload else None)447            gallery_native_url = (out_gallery["result"]["images_native"] or [{}])[0].get("url") if out_gallery else None448 449            # ---- Step 2: AI Configurated (config prompt; native only) ----450            edit_prompt = build_car_prompt(451                model_name=(model_name or "Lamborghini"),452                body_color=(body_color or "Verde Ithaca"),453                finish=finish,454                wheel_style=(wheel_style or "lightweight forged"),455                caliper_color=(caliper_color or "yellow"),456                extra=extra_prompt457            )458            comp_payload = make_composition_payload(edit_prompt, preview_aspect, upscale=False, upscale_factor="2")459            comp_payload["image_urls"] = gallery_native_url or ""460            out_comp = post_image_edit(comp_payload, files_payload=None)461            comp_native_url = (out_comp["result"]["images_native"] or [{}])[0].get("url") if out_comp else None462 463            # ---- Optional auto video right after Run ----464            vurl = None; ui_hints = {}465            if st.session_state.get("to_video", False) and (comp_native_url or gallery_native_url):466                video_prompt = build_video_prompt(scene_name, scene_extra)467                v = post_video(468                    comp_native_url or gallery_native_url,469                    duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),470                    resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),471                    preview_aspect=st.session_state.get("preview_aspect","3:4"),472                    prompt_text=video_prompt,473                    prompt_optimizer=False,474                )475                vurl = (v.get("result") or {}).get("video", {}).get("url")476                ui_hints = v.get("ui_hints") or {}477 478            if isinstance(ui_hints, dict):479                st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))480 481            # ---- Save results to session ----482            st.session_state["job_counter"] += 1483            st.session_state["results"] = {484                "gallery_native_url": gallery_native_url,485                "composition_native_url": comp_native_url,486                "video_url": vurl,487                "api_base": API_BASE,488                "preview_aspect": preview_aspect,489            }490 491        except requests.HTTPError as e:492            st.error(f"HTTP {e.response.status_code}")493        except Exception as e:494            st.error(f"Error: {e}")495 496# ================= RESULTS =================497res = st.session_state.get("results")498vbytes = None499if res:500    st.subheader("Results")501    c_gallery, c_comp, c_vid = st.columns([1.2, 1.2, 1.2])502 503    # Gallery (Background Cleaned, native)504    show_tile_card_display_download(505        c_gallery, "Gallery (Background Cleaned)",506        display_url=res.get("gallery_native_url"),507        download_url=res.get("gallery_native_url"),508        filename_stub="gallery_native",509        key_prefix=f"job{st.session_state['job_counter']}"510    )511 512    # AI Configurated (native)513    show_tile_card_display_download(514        c_comp, "AI Configurated",515        display_url=res.get("composition_native_url"),516        download_url=res.get("composition_native_url"),517        filename_stub="ai_configurated_native",518        key_prefix=f"job{st.session_state['job_counter']}"519    )520 521    # AI Generated Video522    c_vid.markdown('<div class="lb-card-title">AI Generated Video</div>', unsafe_allow_html=True)523    vurl = res.get("video_url")524    if vurl:525        abs_v = _abs_backend_url(vurl)526        c_vid.markdown(527            f"""528            <div class="lb-frame">529              <video autoplay loop muted controls playsinline>530                <source src="{abs_v}" type="video/mp4">531              </video>532            </div>533            """, unsafe_allow_html=True534        )535        vb = fetch_bytes(abs_v)536        if vb:537            vbytes = vb538            c_vid.download_button("Download Video (MP4)", data=vb, file_name="lambovision_video.mp4",539                                  mime="video/mp4", key=f"job{st.session_state['job_counter']}_video_dl")540    else:541        c_vid.markdown('<div class="lb-frame"></div>', unsafe_allow_html=True)542        # ---- Explicit manual video trigger button ----543        gen_now = c_vid.button("Generate Video From These Results", key=f"gen_video_job{st.session_state['job_counter']}")544        if gen_now:545            src_for_video = res.get("composition_native_url") or res.get("gallery_native_url")546            if not src_for_video:547                st.warning("No image available to generate video.")548            else:549                with st.spinner("Generating Video …"):550                    try:551                        video_prompt = build_video_prompt(st.session_state.get("scene_name", "Studio Cinematic"),552                                                          st.session_state.get("scene_extra",""))553                        v = post_video(554                            src_for_video,555                            duration=st.session_state.get("duration_sel", VIDEO_DUR_OPTIONS[0]),556                            resolution=st.session_state.get("resolution_sel", VIDEO_RES_OPTIONS[1]),557                            preview_aspect=st.session_state.get("preview_aspect","3:4"),558                            prompt_text=video_prompt,559                            prompt_optimizer=False,560                        )561                        vurl2 = (v.get("result") or {}).get("video", {}).get("url")562                        ui_hints = v.get("ui_hints") or {}563                        if vurl2:564                            st.session_state["results"]["video_url"] = vurl2565                            if isinstance(ui_hints, dict):566                                st.session_state["video_ui_max_px"] = int(ui_hints.get("suggested_video_max_px", VIDEO_MAX_PX_DEFAULT))567                            st.success("Video is ready. Showing preview…")568                            st.rerun()569                        else:570                            st.info("Video URL not returned.")571                    except requests.HTTPError as e:572                        st.error(f"HTTP {e.response.status_code}")573                    except Exception as e:574                        st.error(f"Error: {e}")575 576    # ZIP bundle (bottom row)577    st.markdown("---")578    exp_col = st.container()579    with exp_col:580        st.markdown("#### Export")581        named = []582        gnat = fetch_bytes(res.get("gallery_native_url")) if res.get("gallery_native_url") else None583        cnat = fetch_bytes(res.get("composition_native_url")) if res.get("composition_native_url") else None584        if gnat: named.append((f"gallery_native{infer_ext(gnat)}", gnat))585        if cnat: named.append((f"ai_configurated_native{infer_ext(cnat)}", cnat))586        if vbytes: named.append(("video.mp4", vbytes))587        if named:588            zip_bytes = make_zip(named)589            st.download_button("Download All (ZIP)", data=zip_bytes,590                               file_name="lambovision_outputs.zip", mime="application/zip",591                               key=f"job{st.session_state['job_counter']}_zip_dl")592        else:593            st.caption("—")594 595# ================= Sidebar =================596with st.sidebar:597    st.caption(f"API_BASE: {API_BASE}")598    if HF_TOKEN: st.caption("Auth: Bearer (active)")599    st.slider("Video preview width (px)", 360, 1080, st.session_state["video_ui_max_px"],600              step=10, key="video_ui_max_px")601    if st.button("Clear cache"): st.cache_data.clear(); st.success("Cache cleared.")602    if st.button("Clear results"): st.session_state["results"] = None; st.success("Cleared.")603