"""A2A-Video demo UI, rebuilt to match video4m-demo-prototype.html. Two tabs: - Any-to-Any Generation: pick one input representation (video / caption / abstract modality of an example) and an ordered generation chain. The input card and all predictions appear together in a 3-per-row grid when the chain finishes; mid-run feedback is a plain "generating" line (the backend still streams via generate_any_to_any_stream — the display is buffered). - Future Prediction: ported functionally unchanged from the previous app (single non-streaming call, two-stage layout-then-generate), restyled. Compatibility: written against the API subset present in BOTH gradio 4.44.1 (the cluster conda env actually used by run_demo.sh) and gradio 5.6.0 (the pin in requirements.txt / the HF Space sdk_version). Custom CSS targets our own elem_ids/elem_classes and leaf tags only, never gradio-internal class names, so it survives the DOM differences between the two versions. Env toggles (both off by default): FOURM_FAKE_STREAM=1 exercise the full streaming UI with synthetic events + placeholder media, no model / GPU. FOURM_DISABLE_STREAMING=1 kill switch: run the proven single-call generate_any_to_any and synthesize the same events from its returned dict at the end. """ import base64 import io import os import tempfile import time import traceback from pathlib import Path from PIL import Image as PILImage import gradio as gr import gradio_client.utils as _gradio_client_utils import numpy as np import spaces from decord import VideoReader, cpu # gradio==5.6.0 pins gradio_client==1.4.3 exactly, and that exact pair has a # bug: _json_schema_to_python_type() crashes (TypeError: argument of type # 'bool' is not iterable) whenever a component's JSON schema has # `additionalProperties: true` -- a bare bool, which pydantic emits for # Dict[str, Any]-shaped fields -- because get_type() unconditionally does # `"const" in schema`. This fires on every page load (routes.py's `/` handler # builds the API-docs schema unconditionally), so it's not optional to avoid # triggering; patch the recursive step to treat a bool schema as "Any", same # as an empty `{}` schema is already treated. _original_json_schema_to_python_type = _gradio_client_utils._json_schema_to_python_type def _patched_json_schema_to_python_type(schema, defs): if isinstance(schema, bool): return "Any" return _original_json_schema_to_python_type(schema, defs) _gradio_client_utils._json_schema_to_python_type = _patched_json_schema_to_python_type # diffusers==0.28.0 (pinned for its internal unet_2d_blocks module, needed by # fourm/vq/models/uvit.py) unconditionally does `from huggingface_hub import # cached_download` at import time, in the code path that loads custom # pipeline code from the Hub -- a feature this app never uses. cached_download # itself was removed in huggingface_hub 0.26.0, and huggingface_hub is pinned # >=0.30.0 (transformers>=4.53.0, needed for V-JEPA2, requires that floor), so # the two pins are individually necessary but jointly incompatible without # this shim. hf_hub_download is a reasonable stand-in since the real # cached_download is provably dead code on every path this app exercises. import huggingface_hub as _huggingface_hub if not hasattr(_huggingface_hub, "cached_download"): _huggingface_hub.cached_download = _huggingface_hub.hf_hub_download # fourm/vq/scheduling/scheduling_{ddpm,pndm,ddim}.py each do # `from diffusers.utils import BaseOutput, randn_tensor`. diffusers 0.20.0 # (this code's original target) re-exported randn_tensor from # diffusers.utils.torch_utils at the diffusers.utils top level; 0.28.0 # dropped that re-export (BaseOutput is still re-exported, only randn_tensor # moved), so patch it back the same way rather than edit three fourm/ files. import diffusers.utils as _diffusers_utils from diffusers.utils.torch_utils import randn_tensor as _randn_tensor if not hasattr(_diffusers_utils, "randn_tensor"): _diffusers_utils.randn_tensor = _randn_tensor from helper_functions import save_video_with_imageio from inference import ( generate_any_to_any, generate_any_to_any_stream, generate_future_prediction, list_examples, list_future_examples, get_example_preview, get_example_transcription_tagged, FPS, CONFIGS, HYPERPARAM_PRESETS, FUTURE_CHAIN, FUTURE_SEED_MODALITIES, FUTURE_SEED_TOKEN_OPTIONS, SEED_TOKENS_TO_FRAMES, ) # --- modality registry -------------------------------------------------------- MODALITY_DISPLAY_NAMES = { "rgb": "RGB", "siglip": "SigLIP-2", "dinov2": "DINOv2", "vjepa": "V-JEPA-2", "caption": "Caption", "transcription": "Transcription", "det": "Bboxes", "depth": "Depth", "normal": "S. Normals", "opticalflow": "Optical Flow", } # Fixed order for the hyperparameter accordion only (unrelated to the # dynamic, chain-order-following output slots). ALL_MODALITIES = ["rgb", "depth", "normal", "opticalflow", "dinov2", "vjepa", "siglip", "det", "caption", "transcription"] TEXT_MODALITIES = {"caption", "transcription"} # Which tuned HYPERPARAM_PRESETS set fits a given input modality -- dense # visual/feature-map inputs carry a lot of information already, sparse # text-like inputs need the more exploratory sparse-to-dense tuning. DENSE_INPUT_MODALITIES = {"rgb", "depth", "normal", "opticalflow", "dinov2", "vjepa", "siglip"} # Accent colors, one per modality (prototype :root palette). rgb's original # "#c6ccd8" was tuned for contrast against a dark background -- against the # white theme it was nearly invisible, so it's a darker slate-gray here. MODALITY_COLORS = { "rgb": "#6b7280", "caption": "#a78bfa", "transcription": "#22d3ee", "dinov2": "#34d399", "siglip": "#f472b6", "depth": "#fbbf24", "normal": "#84cc16", "opticalflow": "#60a5fa", "det": "#f87171", "vjepa": "#e879f9", } MODALITY_TIPS = { "rgb": "Raw pixel video — what a camera sees", "caption": "Natural-language description of the clip", "transcription": "Per-second dense description with [SEC_n] markers", "dinov2": "Self-supervised semantic feature maps — layout and object identity", "siglip": "Vision-language feature maps aligned with text", "depth": "Per-pixel distance from the camera", "normal": "Per-pixel surface orientation", "opticalflow": "Per-pixel motion between frames", "det": "Object detections as labeled bounding boxes", "vjepa": "Video self-supervised feature maps", } # Modalities offered as "abstract representation" inputs: the prototype's # ABS_MODS extended with the two remaining feature-map modalities (siglip, # vjepa) — the backend accepts any CONFIGS key as conditioning input. ABS_MODALITIES = ["depth", "dinov2", "siglip", "vjepa", "normal", "opticalflow", "det", "transcription"] # Per-input coarse-to-fine preset chains: abstract/semantic first, concrete # pixels last — a specific tuned ladder for every possible input modality. # Edit freely; the only rule is that a list must not contain its own key. COARSE_TO_FINE_BY_INPUT = { "rgb": ["siglip", "dinov2", "vjepa", "caption", "transcription", "det", "depth", "normal", "opticalflow"], "caption": ["transcription", "siglip", "dinov2", "vjepa", "det", "opticalflow", "normal", "depth", "rgb"], "transcription": ["caption", "siglip", "dinov2", "vjepa", "det", "opticalflow", "normal", "depth", "rgb"], "dinov2": ["siglip", "vjepa", "caption", "transcription", "det", "opticalflow", "normal", "depth", "rgb"], "siglip": ["dinov2", "vjepa", "caption", "transcription", "det", "opticalflow", "normal", "depth", "rgb"], "vjepa": ["siglip", "dinov2", "caption", "transcription", "det", "opticalflow", "normal", "depth", "rgb"], "depth": ["siglip", "dinov2", "vjepa", "caption", "transcription", "det", "normal", "opticalflow", "rgb"], "normal": ["siglip", "dinov2", "vjepa", "caption", "transcription", "det", "depth", "opticalflow", "rgb"], "opticalflow": ["siglip", "dinov2", "vjepa", "caption", "transcription", "det", "depth", "normal", "rgb"], "det": ["caption", "transcription", "siglip", "dinov2", "vjepa", "depth", "normal", "opticalflow", "rgb"], } DEFAULT_INPUT_MODALITY = "rgb" DEFAULT_ABS_MODALITY = "depth" DEFAULT_CHAIN_STATE = list(COARSE_TO_FINE_BY_INPUT[DEFAULT_INPUT_MODALITY]) # Predictions are 128x128; result cards show them at native size (upscaling # just looks pixelated). VIDEO_SIZE = 128 RESULT_VIDEO_HEIGHT = 128 # 1 input slot + up to 9 chain targets (every other modality). MAX_SLOTS = 10 TEXT_OUTPUT_LINES = 6 def _hyperparam_preset_key_for_modality(input_modality): return "rgb_to_others" if input_modality in DENSE_INPUT_MODALITIES else "text_to_rgb" def _save_to_mp4(video_array): tmp_dir = tempfile.mkdtemp(prefix="4m_demo_") out_path = str(Path(tmp_dir) / "video.mp4") save_video_with_imageio(list(video_array), out_path, FPS) return out_path def _safe_preview(stem, modality_key): """get_example_preview that can never take the UI down: any fetch/cache failure (offline node, missing per-modality preview on the Hub) just means 'no preview'.""" try: return get_example_preview(stem, modality_key) except Exception: traceback.print_exc() return None def _clean_chain(input_modality, chain): """Drops the input modality from chain if it's stuck there from before a modality switch (rather than wiping the whole chain), and dedupes. """ seen = set() cleaned = [] for k in chain: if k != input_modality and k not in seen: cleaned.append(k) seen.add(k) return cleaned def _preset_chain(preset, input_modality): """The chain a preset stands for, given the current input modality. Returns None for "custom" (leave the chain exactly as the user built it). """ if preset == "coarse": fallback = ["caption", "dinov2", "depth", "rgb"] return _clean_chain(input_modality, list(COARSE_TO_FINE_BY_INPUT.get(input_modality, fallback))) if preset == "direct": return ["depth"] if input_modality == "rgb" else ["rgb"] return None def _chain_for_input_change(preset, new_input_modality, current_chain): """What the chain becomes when the input modality changes: coarse rebuilds its per-input ladder, direct keeps its single chosen target while it stays valid (falling back to the direct default if the new input swallowed it), custom just drops the new input from the chain. """ if preset == "coarse": return _preset_chain("coarse", new_input_modality) cleaned = _clean_chain(new_input_modality, current_chain) if preset == "direct": return cleaned[:1] if cleaned else _preset_chain("direct", new_input_modality) return cleaned # --- HTML fragment builders (pure) -------------------------------------------- def _step_label_html(n, title, hint=None): hint_html = f'

{hint}

' if hint else "" return f'
{n}

{title}

{hint_html}' def _chip_html(key, is_input=False): name = MODALITY_DISPLAY_NAMES[key] in_tag = ' in' if is_input else "" return ( f'' f'{name}{in_tag}' ) def _card_head_html(key, role=""): """No step numbers or timings here on purpose: predictions should read as one parallel set, not a sequence.""" name = MODALITY_DISPLAY_NAMES[key] role_html = f'{role}' if role else "" return ( f'
' f'{name}{role_html}
' ) def _prog_html(key, pct, indeterminate=False): if indeterminate: return f'
' return f'
' def _status_html(text): return f'
{text}
' def _empty_state_html(): return '

Predictions will appear here.

' def _loading_state_html(seed_value, status_text=None): """Shown in the results panel while the model runs; status_text swaps the "warming up" line for the current step once generation is underway. The spinner is an SVG animated via SMIL (), NOT CSS — so it keeps visibly spinning even under CSS animation resets, including the prefers-reduced-motion blanket rule and gradio theme quirks.""" return ( '
' '' '' '' '' "" f'

{status_text or "warming up"} · seed {seed_value}

' "

Inference has started — the first run can take an extra 1-2 minutes while tokenizers load.

" ) def _header_html(): return """

A2A-Video: Modeling the world across time and modalities

A2A-Video is an any-to-any multimodal video model that is able to predict any modality at any point in time, given any combination of observations from any other modalities -- thus able to traverse along both the modality and temporal axes.

We show two ways to use this demo:
i) Any-to-any generation -- for a given video, you can select any modality as input, and any modality as output. Optionally you can define the intermediate modalities as the chain.
ii) Future prediction in abstract spaces -- given initial observations in any modality space, predict the future in any desired modality, both unconditionally as well as conditionally (e.g. extra condition in the form of caption/transcription).

""" # --- hyperparameter accordion (ported) ---------------------------------------- def _build_hyperparam_controls(default_preset_key=None): """One row per CONFIGS modality with sliders for temp / cfg. Decoding steps aren't exposed -- they're applied silently from whichever HYPERPARAM_PRESETS set matches the current input modality (see run_demo_stream), same as temp/cfg defaults below. default_preset_key: which HYPERPARAM_PRESETS set to source initial slider values from. Pass None (Future Prediction tab) to source initial values straight from CONFIGS instead. Returns (flat_inputs, flat_spec): flat_inputs is the list of Gradio components to wire up as click() inputs; flat_spec is the matching list of (modality_key, param_name) tuples used to rebuild the overrides dict. """ flat_inputs, flat_spec = [], [] hp_defaults = HYPERPARAM_PRESETS[default_preset_key] if default_preset_key else {} gr.Markdown( "Temperature and CFG scale per modality -- defaults are auto-tuned to match your " "current input modality (Step 1); adjust here to override. Sliders for modalities " "not in your chain are ignored.", elem_classes=["hint-md"], ) for key in ALL_MODALITIES: _, _, _, _, _, temp_fallback, cfg_fallback = CONFIGS[key] temp_default = hp_defaults.get(key, {}).get("temp", temp_fallback) cfg_default = hp_defaults.get(key, {}).get("cfg", cfg_fallback) with gr.Row(): gr.Markdown(f"**{MODALITY_DISPLAY_NAMES.get(key, key)}**") temp = gr.Slider(0.0, 5.0, value=temp_default, step=0.01, label="Temperature") cfg = gr.Slider(0.0, 10.0, value=cfg_default, step=0.1, label="CFG scale") flat_inputs += [temp, cfg] flat_spec += [(key, "temp"), (key, "cfg")] return flat_inputs, flat_spec def _hyperparam_slider_updates(input_modality, hyperparam_spec): """The tuned hyperparameter set to load for a given input modality -- switching modality always gets you sensible defaults.""" hp = HYPERPARAM_PRESETS[_hyperparam_preset_key_for_modality(input_modality)] updates = [] for key, param in hyperparam_spec: if param in hp.get(key, {}): updates.append(gr.update(value=hp[key][param])) else: updates.append(gr.update()) return updates # --- example gallery (ported, restyled) ---------------------------------------- def _list_examples_safe(stems_fn): try: return [s for s in stems_fn() if _safe_preview(s, "rgb") is not None] except Exception: traceback.print_exc() return [] def _build_example_gallery(gallery_stems): """Renders the hover-to-preview / click-to-select example-clip grid and returns (selected_example_md, example_select_btns); callers wire their own .click() handlers onto example_select_btns (a list of (stem, button) pairs — the buttons are hidden by CSS, tile clicks are forwarded to them by _LOAD_JS) and own the shared example_state. """ if not gallery_stems: gr.Markdown("No example previews available yet.", elem_classes=["hint-md"]) else: gr.Markdown("Hover to preview · click to select.", elem_classes=["hint-md"]) example_select_btns = [] with gr.Column(elem_classes=["example-gallery-scroll"]): with gr.Row(elem_classes=["tile-grid"]): for stem in gallery_stems: with gr.Column(min_width=0, elem_classes=["example-tile-col"]): gr.Video( value=_safe_preview(stem, "rgb"), height=82, width=110, interactive=False, show_label=False, show_download_button=False, elem_classes=["example-preview-video", "clip-tile"], ) btn = gr.Button("Select", size="sm", elem_classes=["select-btn"]) example_select_btns.append((stem, btn)) selected_example_md = gr.Markdown("No example selected yet.", elem_classes=["hint-md"], elem_id="a2a-selected") return selected_example_md, example_select_btns # --- streaming plumbing --------------------------------------------------------- # Output tuple layout for the streaming click handler: # [status, empty_state, run_btn, results_state, # slot0(col, head, video, text, prog, use_btn), slot1(...), ...] IDX_STATUS, IDX_EMPTY, IDX_BTN, IDX_RESULTS = 0, 1, 2, 3 SLOT_BASE = 4 SLOT_WIDTH = 6 OFF_COL, OFF_HEAD, OFF_VIDEO, OFF_TEXT, OFF_PROG, OFF_USE = range(SLOT_WIDTH) N_STREAM_OUTPUTS = SLOT_BASE + MAX_SLOTS * SLOT_WIDTH # Only these generated modalities can be fed back as a new input: rgb via # re-tokenizing the generated mp4, caption via the raw text. REUSABLE_AS_INPUT = {"rgb", "caption"} def _slot_idx(i, off): return SLOT_BASE + i * SLOT_WIDTH + off def _new_stream_ctx(input_modality, chain): return { "input_modality": input_modality, "chain": list(chain), "next_slot": 0, "results": {}, "slot_keys": {}, } def _display_value(key, payload): """(value_for_ui, is_text). Visual payloads (numpy videos) are written to an mp4; failures degrade to an empty card rather than killing the run.""" if key in TEXT_MODALITIES: return payload, True if payload is None: return None, False try: return _save_to_mp4(payload), False except Exception: print(f"[stream] failed to save '{key}' to mp4:") traceback.print_exc() return None, False def _apply_stream_event(event, ctx): """Maps one inference stream event to sparse {output_index: gr.update}. Pure of gradio components (only gr.update dicts), so it's unit-testable without a browser or model; mutates ctx (slot bookkeeping + results). """ kind, key, payload = event updates = {} if kind == "input": i = 0 ctx["slot_keys"][i] = key ctx["next_slot"] = 1 value, is_text = _display_value(key, payload) ctx["results"][f"input_{key}"] = value # First real content: swap the loading indicator out for the cards. updates[IDX_EMPTY] = gr.update(value=_empty_state_html(), visible=False) updates[_slot_idx(i, OFF_COL)] = gr.update(visible=True) updates[_slot_idx(i, OFF_HEAD)] = gr.update(value=_card_head_html(key, "input")) updates[_slot_idx(i, OFF_VIDEO)] = gr.update(value=None if is_text else value, visible=not is_text) updates[_slot_idx(i, OFF_TEXT)] = gr.update(value=value if is_text else None, visible=is_text) updates[_slot_idx(i, OFF_PROG)] = gr.update(visible=False) updates[_slot_idx(i, OFF_USE)] = gr.update(visible=False) return updates if kind == "start": i = ctx["next_slot"] ctx["next_slot"] = i + 1 ctx["slot_keys"][i] = key total = payload updates[_slot_idx(i, OFF_COL)] = gr.update(visible=True) updates[_slot_idx(i, OFF_HEAD)] = gr.update(value=_card_head_html(key)) updates[_slot_idx(i, OFF_VIDEO)] = gr.update(value=None, visible=False) updates[_slot_idx(i, OFF_TEXT)] = gr.update(value=None, visible=False) updates[_slot_idx(i, OFF_PROG)] = gr.update(value=_prog_html(key, 0, indeterminate=total <= 1), visible=True) updates[_slot_idx(i, OFF_USE)] = gr.update(visible=False) return updates if kind == "progress": i = ctx["next_slot"] - 1 done, total = payload pct = 100.0 * done / max(total, 1) updates[_slot_idx(i, OFF_PROG)] = gr.update(value=_prog_html(key, pct)) return updates if kind == "result": i = ctx["next_slot"] - 1 value, is_text = _display_value(key, payload) ctx["results"][key] = value updates[_slot_idx(i, OFF_HEAD)] = gr.update(value=_card_head_html(key)) updates[_slot_idx(i, OFF_VIDEO)] = gr.update(value=None if is_text else value, visible=not is_text) updates[_slot_idx(i, OFF_TEXT)] = gr.update(value=value if is_text else None, visible=is_text) updates[_slot_idx(i, OFF_PROG)] = gr.update(visible=False) updates[_slot_idx(i, OFF_USE)] = gr.update(visible=key in REUSABLE_AS_INPUT and value is not None) return updates if kind == "done": updates[IDX_STATUS] = gr.update(value=_status_html("done")) updates[IDX_BTN] = gr.update(value="Generate again", interactive=True) results = dict(ctx["results"]) results["_slot_keys"] = dict(ctx["slot_keys"]) updates[IDX_RESULTS] = results return updates return updates def _reset_frame_updates(seed_value): """The 'arming' frame: show the spinner card, disable the button, hide and reset every result slot. Predictions are deliberately NOT revealed during generation — they all appear together at the end (see the buffering loop in run_demo_stream).""" updates = { IDX_STATUS: gr.update(value=_status_html(f"warming up · seed {seed_value}")), IDX_EMPTY: gr.update(value=_loading_state_html(seed_value), visible=True), IDX_BTN: gr.update(value="Generating…", interactive=False), IDX_RESULTS: {}, } for i in range(MAX_SLOTS): updates[_slot_idx(i, OFF_COL)] = gr.update(visible=False) updates[_slot_idx(i, OFF_HEAD)] = gr.update(value="") updates[_slot_idx(i, OFF_VIDEO)] = gr.update(value=None, visible=False) updates[_slot_idx(i, OFF_TEXT)] = gr.update(value=None, visible=False) updates[_slot_idx(i, OFF_PROG)] = gr.update(value="", visible=False) updates[_slot_idx(i, OFF_USE)] = gr.update(visible=False) return updates def _frame(updates): out = [gr.skip()] * N_STREAM_OUTPUTS for idx, upd in updates.items(): out[idx] = upd return tuple(out) def _synthesize_events_from_results(input_modality, chain, results): """Turns generate_any_to_any's returned dict into the same event stream generate_any_to_any_stream would emit (all at once, at the end).""" yield ("input", input_modality, results.get(f"input_{input_modality}")) for key in chain: yield ("start", key, 1) if results.get(key) is not None: yield ("result", key, results[key]) yield ("done", None, results) def _hex_to_rgb(hex_color): hex_color = hex_color.lstrip("#") return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) _FAKE_TEXT = { "caption": "A placeholder caption from FOURM_FAKE_STREAM — no model was run.", "transcription": "[SEC_1] placeholder… [SEC_2] streaming works… [SEC_3] wire me to the GPU. [EOS]", } def _fake_tinted_video(key): r, g, b = _hex_to_rgb(MODALITY_COLORS[key]) vid = np.zeros((17, 64, 64, 3), dtype=np.uint8) vid[..., 0], vid[..., 1], vid[..., 2] = r // 3, g // 3, b // 3 for t in range(17): vid[t, 8 + t:24 + t, 8:56] = (r, g, b) return vid def _fake_stream_events(input_modality, chain): """FOURM_FAKE_STREAM=1: synthetic events with placeholder media so the entire streaming UI (cards, progress, use-as-input) can be exercised on a login node with no GPU and no checkpoints.""" if input_modality in TEXT_MODALITIES: yield ("input", input_modality, _FAKE_TEXT[input_modality]) else: yield ("input", input_modality, _fake_tinted_video(input_modality)) results = {} for key in chain: total = CONFIGS[key][2] or 1 yield ("start", key, total) for done in range(5, total, 5): time.sleep(0.12) yield ("progress", key, (done, total)) time.sleep(0.3) value = _FAKE_TEXT[key] if key in TEXT_MODALITIES else _fake_tinted_video(key) results[key] = value yield ("result", key, value) yield ("done", None, results) def _chip_busy_js(scope_class, host_selector): """Client-side js= hook for chip clicks: dims that region's chip rows, blocks further clicks, and centers ONE spinner overlay on the region's main box until the re-rendered rows replace it (the busy state lives in the old DOM, so it self-clears). gradio executes this directly.""" return ( "() => { document.querySelectorAll('." + scope_class + "').forEach((r) => {" " r.classList.add('chips-busy');" " setTimeout(() => r.classList.remove('chips-busy'), 30000); });" " const host = document.querySelector('" + host_selector + "');" " if (host && !host.querySelector('.chips-overlay')) {" " const s = document.createElement('span'); s.className = 'chips-overlay';" " s.innerHTML = '" "" "" "" "'; host.appendChild(s); } }" ) def _sel_busy_js(host_id): """Client-side js= hook for sample-select clicks: dims the 'Selected:' line and centers our circle spinner on it. Removal is driven by a js-only .then() on the python event (_sel_done_js), which gradio runs when the round trip completes -- fully deterministic (15s failsafe).""" return ( "() => { const host = document.getElementById('" + host_id + "');" " if (!host || host.querySelector('.chips-overlay')) return;" " host.classList.add('chips-busy'); host.style.position = 'relative';" " const s = document.createElement('span'); s.className = 'chips-overlay';" " s.innerHTML = '" "" "" "" "'; host.appendChild(s);" " setTimeout(() => { s.remove(); host.classList.remove('chips-busy'); }, 15000); }" ) def _sel_done_js(host_id): """js-only .then() hook: clears the spinner set by _sel_busy_js once the select event's server round trip has finished.""" return ( "() => { const host = document.getElementById('" + host_id + "');" " if (!host) return; host.classList.remove('chips-busy');" " const s = host.querySelector('.chips-overlay'); if (s) s.remove(); }" ) # --- theme / CSS / JS ----------------------------------------------------------- # Wire up the example tiles: hover previews the clip (gr.Video has no built-in # hover-play), clicking anywhere on the tile selects it — the click is # forwarded to the tile's hidden gradio Select button (which carries the # python handler) and the tile gets a .sel class (orange border + ✓ badge, # prototype-style). A MutationObserver re-attaches after Gradio re-renders # (e.g. the abstract gallery re-rendering on modality switch). The same # observer pass also starts every result-card video together once the whole # batch is ready to play, instead of each one autoplaying independently. _LOAD_JS = """ () => { function attach() { document.querySelectorAll('.example-preview-video').forEach((container) => { if (container.dataset.hoverBound) return; container.dataset.hoverBound = "1"; container.addEventListener('mouseenter', () => { const video = container.querySelector('video'); if (!video) return; video.controls = false; video.muted = true; video.loop = true; video.play().catch(() => {}); }); container.addEventListener('mouseleave', () => { const video = container.querySelector('video'); if (video) video.pause(); }); }); document.querySelectorAll('.example-tile-col').forEach((col) => { if (col.dataset.selBound) return; col.dataset.selBound = "1"; col.addEventListener('click', (e) => { // Ignore the synthetic click we fire on the hidden button // below, or we'd recurse forever. if (e.target.closest && e.target.closest('button.select-btn')) return; const btn = col.querySelector('button.select-btn'); if (!btn) return; const scroller = col.closest('.example-gallery-scroll'); if (scroller) { scroller.querySelectorAll('.example-tile-col.sel') .forEach((x) => x.classList.remove('sel')); } col.classList.add('sel'); btn.click(); }); }); // Result cards all land in the DOM together, but each