"""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