#!/usr/bin/env python3
# ADOBE CONFIDENTIAL
# Copyright 2026 Adobe
# All Rights Reserved.
# NOTICE: All information contained herein is, and remains
# the property of Adobe and its suppliers, if any. The intellectual
# and technical concepts contained herein are proprietary to Adobe
# and its suppliers and are protected by all applicable intellectual
# property laws, including trade secret and copyright laws.
# Dissemination of this information or reproduction of this material
# is strictly forbidden unless prior written permission is obtained
# from Adobe.
"""TPIPS embedding demo as a Gradio ZeroGPU app.
ZeroGPU only supports the Gradio SDK, and it exposes a GPU *only* for the
duration of a function wrapped with ``@spaces.GPU`` (allocated on demand, freed
right after). That rules out the Flask + SSE + persistent-worker design of the
non-ZeroGPU demo, so this file rebuilds the same two experiences natively:
* **Image pair** - one reference image compared against any number of candidate
images across an editable list of aspects, shown as grouped similarity bars.
* **Video** - cosine similarity of every sampled frame against a chosen
reference frame, over an editable list of aspects, shown as a line plot.
Per-session isolation is handled by ``gr.State`` (Gradio keeps one copy per
browser session automatically). All GPU compute funnels through
``embed_images`` so ZeroGPU only holds the accelerator while embeddings run.
"""
from __future__ import annotations
import asyncio
import base64
import html as _html
import io
import json
import math
import os
import uuid
from typing import Any
# Gradio's queue creates its asyncio locks via ``asyncio.get_event_loop()`` at
# construction time. On Python 3.12+ that call raises ``RuntimeError`` when no
# loop is running in the main thread, so Gradio silently stores ``None`` locks
# and every queued request 500s with "NoneType ... asynchronous context
# manager". Ensure a loop exists up front; no-op on older Pythons and on the
# Space's default interpreter.
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
import gradio as gr
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from PIL import Image
# Compatibility shim for a gradio_client bug: its JSON-schema-to-type converter
# assumes every subschema is a dict, but newer pydantic emits boolean subschemas
# (e.g. ``"additionalProperties": false``) for common components (Image, Files,
# Gallery, Video). Without this, ``get_api_info()`` raises "argument of type
# 'bool' is not a container" and the root page 500s on load. Guarded so it is a
# no-op if a future gradio_client renames the internal or fixes the bug.
try:
import gradio_client.utils as _gc_utils
_orig_schema_to_type = _gc_utils._json_schema_to_python_type
def _schema_to_type(schema, defs=None):
if isinstance(schema, bool):
return "Any" if schema else "None"
return _orig_schema_to_type(schema, defs)
_gc_utils._json_schema_to_python_type = _schema_to_type
except Exception: # pragma: no cover - defensive: never block startup on the shim
pass
# ``spaces`` provides the ZeroGPU allocator on the Space. Keep a no-op fallback
# so the same file still runs locally (e.g. on CPU) where it is not installed.
try:
import spaces
gpu = spaces.GPU
except Exception: # pragma: no cover - only exercised off-Space
def gpu(*args, **kwargs):
if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0]
def decorator(function):
return function
return decorator
from tpips import load_model
DEFAULT_FACTOR = "overall"
# Chips offered in the UI; users may also type any custom aspect.
PRESET_FACTORS = [DEFAULT_FACTOR, "lighting", "color palette", "composition", "camera angle"]
# Similarity axis range shared by the bar plot and the video line plot.
BAR_MIN, BAR_MAX = 0.4, 1.0
MAX_VIDEO_SECONDS = float(os.environ.get("TPIPS_MAX_VIDEO_SECONDS", "10"))
# ZeroGPU allocates the accelerator per call with a bounded duration, so cap the
# number of sampled frames instead of decoding at the native frame rate.
MAX_VIDEO_FRAMES = int(os.environ.get("TPIPS_MAX_VIDEO_FRAMES", "60"))
# Images per forward pass. Small keeps peak GPU memory bounded (raise on big GPUs
# like ZeroGPU's H200 for speed; lower if you still OOM on a small card).
EMBED_BATCH = int(os.environ.get("TPIPS_EMBED_BATCH", "8"))
GPU_SECONDS_PER_BATCH = int(os.environ.get("TPIPS_GPU_SECONDS_PER_BATCH", "10"))
MODEL_PATH = os.environ.get("TPIPS_MODEL_PATH", "sywang/TPIPS-Embed-Qwen3VL-8B")
CACHE_DIR = os.environ.get("HF_HOME")
# Keep the active checkpoint on local disk so ZeroGPU can pack its memory-mapped
# tensors quickly. CACHE_DIR still routes the separately loaded backbone through
# the persistent Hugging Face cache mounted at /data.
LOCAL_CHECKPOINT_CACHE = "/home/user/.cache/huggingface"
# On ZeroGPU the ``spaces`` runtime patches CUDA so a cuda placement here is
# resolved inside the GPU worker; set TPIPS_DEVICE=cpu for local CPU testing.
DEVICE = os.environ.get("TPIPS_DEVICE", "cuda")
print(f"Loading TPIPS embedding model '{MODEL_PATH}' for device '{DEVICE}' ...", flush=True)
checkpoint_path = snapshot_download(
repo_id=MODEL_PATH,
cache_dir=LOCAL_CHECKPOINT_CACHE,
)
model = load_model("embedding", checkpoint_path, device=DEVICE, cache_dir=CACHE_DIR)
def gpu_duration(images: list[Image.Image], factor: str) -> int:
"""Reserve ten GPU seconds per embedding batch, capped at 50 seconds."""
del factor
batches = max(1, math.ceil(len(images) / EMBED_BATCH))
return min(50, GPU_SECONDS_PER_BATCH * batches)
def zero_gpu_identity_status(request: gr.Request) -> str:
"""Report whether the Hub forwarded a ZeroGPU request identity."""
headers = {
str(key).lower(): str(value)
for key, value in dict(getattr(request, "headers", {})).items()
}
if headers.get("x-ip-token"):
return (
"✅ **ZeroGPU identity forwarding detected.** Hugging Face will apply "
"the caller's account tier when scheduling GPU work."
)
return (
"⚠️ **ZeroGPU identity forwarding not detected.** Sign in on Hugging Face "
"and use the app through its public Space page to use account quota."
)
@gpu(duration=gpu_duration)
def embed_images(images: list[Image.Image], factor: str) -> torch.Tensor:
"""Return L2-normalized embeddings for ``images`` under ``factor``.
This is the only GPU entry point; ZeroGPU holds the accelerator for the
length of this call and releases it on return.
Images are embedded in small sub-batches so peak GPU memory stays bounded
regardless of how many candidates/frames are scored at once (embedding all
60 video frames in one forward pass OOMs an 8B model on a 24 GB card).
"""
outputs: list[torch.Tensor] = []
with torch.inference_mode():
for start in range(0, len(images), EMBED_BATCH):
chunk = images[start : start + EMBED_BATCH]
result = model.embed(chunk, factor=factor, normalized=True)
tensor = result.detach() if isinstance(result, torch.Tensor) else torch.as_tensor(result)
outputs.append(F.normalize(tensor.float().cpu(), dim=-1))
return torch.cat(outputs, dim=0)
def normalize_factors(factors: Any) -> list[str]:
"""Clean, de-duplicate, and cap the aspect list, falling back to 'overall'."""
if isinstance(factors, str):
factors = [factors]
cleaned: list[str] = []
for raw in factors or []:
name = " ".join(str(raw).strip().split())[:120]
if name and name not in cleaned:
cleaned.append(name)
return cleaned or [DEFAULT_FACTOR]
# --------------------------------------------------------------------------- #
# Image pair tab
#
# Faithful to the local demo: a reference plus any number of candidate cards in
# a scrollable row, each candidate showing one green->red gradient bar per aspect
# (the bar width maps the 0.4..1.0 similarity range). Scores recompute
# automatically whenever the reference, candidates, or aspects change.
# --------------------------------------------------------------------------- #
def _bar_row(name: str, value: float | None) -> str:
label = _html.escape(name)
if value is None:
right = "—"
else:
pct = max(0.0, min(1.0, (value - BAR_MIN) / (BAR_MAX - BAR_MIN)))
red = round(255 * (1 - pct))
green = round(255 * pct)
right = (
f"
"
f"{value:.3f}"
)
return (
f"
"
f"{label}{right}
"
)
def candidate_bars_html(scores: dict[str, float], aspects: list[str]) -> str:
if not aspects:
return "
No aspects yet.
"
rows = "".join(_bar_row(a, (scores or {}).get(a)) for a in aspects)
return f"
{rows}
"
def recompute_image_scores(
ref_path: str | None,
candidates: list[dict[str, Any]] | None,
aspects: list[str] | None,
) -> list[dict[str, Any]]:
"""Return candidates with each one's per-aspect cosine similarity filled in."""
candidates = [dict(c, scores={}) for c in (candidates or [])]
if not ref_path or not candidates or not aspects:
return candidates
ref = Image.open(ref_path).convert("RGB")
imgs = [Image.open(c["path"]).convert("RGB") for c in candidates]
for factor in aspects:
embeddings = embed_images([ref] + imgs, factor)
sims = F.cosine_similarity(
embeddings[0:1].expand_as(embeddings[1:]), embeddings[1:]
).tolist()
for c, value in zip(candidates, sims):
c["scores"][factor] = round(value, 4)
return candidates
# --------------------------------------------------------------------------- #
# Video tab
#
# Faithful to the local demo: the GPU embeds every sampled frame once per aspect;
# from those (L2-normalized) embeddings we build the full N x N cosine matrix per
# aspect and ship it to the browser with small frame previews. The interactive
# viewer (dual reference/candidate frames, play button, ref+frame sliders, a
# Chart.js curve with a moving scrubber, and live per-aspect bars with show/hide)
# then runs entirely client-side: moving the reference frame just indexes a row of
# the cached matrix, so it never touches the GPU (mirrors the local demo).
# --------------------------------------------------------------------------- #
def extract_video_frames(path: str) -> tuple[list[Image.Image], list[float], float]:
"""Evenly sample up to ``MAX_VIDEO_FRAMES`` frames within the time limit."""
try:
from moviepy import VideoFileClip
except ImportError: # moviepy 1.x compatibility
from moviepy.editor import VideoFileClip
clip = VideoFileClip(path)
try:
fps = float(clip.fps or 0.0)
duration = float(clip.duration or 0.0)
if fps <= 0 or duration <= 0:
raise ValueError("video has no readable frames or duration")
processed = min(duration, MAX_VIDEO_SECONDS)
native = max(1, int(math.ceil(processed * fps - 1e-9)))
count = min(native, MAX_VIDEO_FRAMES)
# Sample within the processed window; keep true timestamps for the x axis.
step = processed / count
timestamps = [round(index * step, 3) for index in range(count)]
frames = [
Image.fromarray(clip.get_frame(min(ts, processed)).astype("uint8")).convert("RGB")
for ts in timestamps
]
return frames, timestamps, duration
finally:
clip.close()
def _frame_data_uri(image: Image.Image, max_dim: int = 360, quality: int = 72) -> str:
"""Small JPEG data URI so the whole frame strip travels in one JSON payload."""
width, height = image.size
scale = min(1.0, max_dim / max(width, height))
if scale < 1.0:
image = image.resize((max(1, round(width * scale)), max(1, round(height * scale))))
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=quality)
return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def _empty_video_state() -> dict[str, Any]:
return {"loaded": False, "frames": [], "matrices": {}, "nonce": 0}
def build_video_payload(state: dict[str, Any] | None, aspects: list[str]) -> str:
"""Serialize the client-side viewer's data: previews + per-aspect NxN cosine."""
if not state or not state.get("loaded"):
return json.dumps({"loaded": False, "nonce": (state or {}).get("nonce", 0)})
matrices = state.get("matrices", {})
ordered = [a for a in aspects if a in matrices]
state["nonce"] = int(state.get("nonce", 0)) + 1
return json.dumps(
{
"loaded": True,
"nonce": state["nonce"],
"n_frames": len(state["frames"]),
"fps": state["fps"],
"processed_duration": state["processed"],
"original_duration": state["duration"],
"notification": state.get("notification", ""),
"frames": state["previews"],
"timestamps": state["timestamps"],
"factors": ordered,
"matrices": {a: matrices[a] for a in ordered},
}
)
def load_video_state(path: str | None):
"""Upload handler: extract frames (no GPU) and reset per-aspect embeddings."""
if not path:
state = _empty_video_state()
return state, build_video_payload(state, [])
frames, timestamps, duration = extract_video_frames(path)
processed = min(duration, MAX_VIDEO_SECONDS)
note = ""
if duration > MAX_VIDEO_SECONDS + 1e-6:
note = (
f"⚠️ Only the first {MAX_VIDEO_SECONDS:g}s were analyzed "
f"(processed {processed:.2f}s of {duration:.2f}s). "
)
state = {
"loaded": True,
"frames": frames,
"previews": [_frame_data_uri(f) for f in frames],
"timestamps": timestamps,
"fps": len(frames) / max(processed, 1e-6),
"processed": processed,
"duration": duration,
"notification": note.strip(),
"matrices": {},
"nonce": 0,
}
return state, build_video_payload(state, [])
def score_video_state(state: dict[str, Any] | None, aspects: Any):
"""Embed every frame once per aspect (GPU) and cache the full NxN cosine matrix.
Reference-frame moves happen client-side by indexing a row of the matrix, so
this only runs the model when a brand-new aspect is added.
"""
if not state or not state.get("loaded"):
state = state or _empty_video_state()
return state, build_video_payload(state, [])
aspects = normalize_factors(aspects)
frames = state["frames"]
matrices = state.setdefault("matrices", {})
for aspect in aspects:
if aspect not in matrices:
embeddings = embed_images(frames, aspect) # (N, D), L2-normalized
cosine = (embeddings @ embeddings.T).clamp_(-1.0, 1.0)
matrices[aspect] = [[round(v, 4) for v in row] for row in cosine.tolist()]
return state, build_video_payload(state, aspects)
CUSTOM_CSS = """
.tp-compare-row { flex-wrap: nowrap !important; overflow-x: auto !important; gap: 14px; padding-bottom: 10px; align-items: flex-start !important; }
.tp-card { min-width: 300px !important; max-width: 340px !important; flex: 0 0 auto !important; }
.tp-head { font-weight: 600; margin: 2px 0 6px; font-size: 0.95rem; }
.tp-cand-scores { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.tp-score-line { display: flex; align-items: center; gap: 8px; }
.tp-score-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.86rem; opacity: 0.8; }
.tp-bar-bg { flex: 1.6; height: 12px; background: rgba(128,128,128,0.25); border-radius: 3px; overflow: hidden; }
.tp-bar-fill { height: 100%; width: 0; border-radius: 3px; transition: width .3s ease, background .3s ease; }
.tp-score-val { width: 48px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.86rem; }
.tp-status { opacity: 0.5; }
.tp-empty { opacity: 0.6; font-size: 0.85rem; padding: 8px 0; }
/* aspects sidebar */
.tp-aspect-add { align-items: center !important; gap: 6px !important; }
.tp-chip-row { flex-wrap: wrap !important; gap: 6px !important; }
.tp-chip { flex: 0 0 auto !important; min-width: 0 !important; }
.tp-chip button, button.tp-chip { padding: 3px 12px !important; font-size: 0.78rem !important; border-radius: 14px !important; min-width: 0 !important; }
.tp-aspect-row { align-items: center !important; gap: 8px !important; margin-bottom: 4px !important; flex-wrap: nowrap !important; }
.tp-aspect-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; padding: 4px 2px; }
.tp-x { flex: 0 0 auto !important; }
.tp-x button, button.tp-x { min-width: 34px !important; padding: 2px 6px !important; }
/* video viewer (client-side, mirrors demo.html) */
.tpv-main { display: flex; flex-direction: row; gap: 18px; }
.tpv-left { flex: 1; min-width: 0; }
.tpv-right { width: 260px; flex-shrink: 0; }
.tpv-frames-row { display: flex; gap: 14px; }
.tpv-frame-box { flex: 1; min-width: 0; }
.tpv-frame-box h4 { margin: 0 0 6px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-slot { position: relative; height: 300px; border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; overflow: hidden; background: rgba(128,128,128,0.08); display: flex; align-items: center; justify-content: center; }
.tpv-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.tpv-empty-slot { color: rgba(128,128,128,0.9); font-size: 0.85rem; padding: 12px; text-align: center; }
.tpv-frame-time { z-index: 2; position: absolute; right: 8px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: rgba(15,28,24,0.85); color: #fff; font-family: monospace; font-size: 0.72rem; }
.tpv-play { z-index: 2; position: absolute; left: 10px; bottom: 10px; width: 42px; height: 42px; border-radius: 50%; border: none; cursor: pointer; background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 10px rgba(0,0,0,.35); }
.tpv-play:hover { opacity: .9; }
.tpv-slider-row { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; }
.tpv-slider-row .tpv-lbl { width: 40px; flex-shrink: 0; opacity: .7; font-size: 0.8rem; }
.tpv-slider-row input[type=range] { flex: 1; cursor: pointer; }
.tpv-frame-label { font-family: monospace; font-size: 0.8rem; white-space: nowrap; min-width: 70px; text-align: right; opacity: .8; }
.tpv-frame-label.ref { color: #2563eb; opacity: 1; }
.tpv-meta { opacity: .65; font-size: 0.78rem; margin: 6px 0 4px; }
.tpv-chart-wrap { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; padding: 12px 14px; margin-top: 10px; }
.tpv-chart-wrap h4 { margin: 0 0 8px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-chart-holder { position: relative; height: 250px; }
.tpv-factors-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.tpv-factors-head h4 { margin: 0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-btn-sm { border: 1px solid rgba(128,128,128,0.4); background: transparent; color: inherit; border-radius: 6px; padding: 3px 8px; font-size: 0.7rem; cursor: pointer; margin-left: 6px; }
.tpv-btn-sm:hover { border-color: #2563eb; }
.tpv-hint { opacity: .6; font-size: 0.75rem; margin-bottom: 12px; }
.tpv-factor-item { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; padding: 10px 11px; margin-bottom: 8px; }
.tpv-factor-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
.tpv-fname-wrap { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
.tpv-fname { font-size: 0.85rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tpv-eye { background: none; border: none; cursor: pointer; padding: 0; opacity: .75; display: flex; align-items: center; }
.tpv-eye.off { opacity: .3; }
.tpv-cosine-row { display: flex; align-items: center; gap: 8px; }
.tpv-bar-bg { flex: 1; height: 12px; background: rgba(128,128,128,0.25); border-radius: 3px; overflow: hidden; }
.tpv-bar-fill { height: 100%; width: 0; border-radius: 3px; transition: width .12s, background .12s; }
.tpv-score-val { width: 46px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.8rem; }
.tpv-empty { opacity: .6; font-size: 0.82rem; padding: 10px 0; }
@media (max-width: 900px) { .tpv-main { flex-direction: column; } .tpv-right { width: 100%; } }
"""
# Static skeleton for the video viewer. Rendered once as trusted HTML (gr.HTML
# does not sanitize developer content); every element carries a stable id so the
# controller in VIDEO_HEAD can drive it from the JSON payload. No inline scripts
# (gr.HTML would strip them) - all behaviour lives in VIDEO_HEAD.
VIDEO_VIEWER_HTML = """
Reference frame
Upload a video and score an aspect to begin.
0.00s
Ref0.00s
Candidate frame
The candidate frame plays here.
0.00s
Frame0.00s
Cosine similarity over video time
Scored aspects
Bars track the candidate frame — drag the frame slider or hit play. Moving the reference re-reads cached scores (no model rerun).
Score an aspect to see per-aspect curves.
"""
# Chart.js + the client-side controller for the video viewer. Injected into
# ; ``window.tpvInit(payload)`` is invoked from the payload textbox's
# .change (see the Video tab wiring) each time the server ships new scores.
VIDEO_HEAD = """
"""
# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
with gr.Blocks(
title="TPIPS interactive demo", theme=gr.themes.Soft(), css=CUSTOM_CSS, head=VIDEO_HEAD
) as demo:
gr.Markdown(
"# TPIPS interactive demo\n"
"Embedding-based perceptual similarity for image pairs and short videos. "
"Pick or type any aspect to score against. "
"More details on our [project page](https://peterwang512.github.io/TPIPS)."
)
identity_status = gr.Markdown()
demo.load(zero_gpu_identity_status, outputs=identity_status)
# Per-session image-tab state (Gradio keeps one copy per browser session).
img_ref_state = gr.State(None) # reference image filepath
img_cands_state = gr.State([]) # list of {"id", "path", "scores": {aspect: value}}
img_aspects_state = gr.State([DEFAULT_FACTOR])
# Per-session video-tab state (frames, previews, cached per-aspect NxN cosine).
video_state = gr.State(_empty_video_state())
video_aspects_state = gr.State([DEFAULT_FACTOR])
with gr.Tabs():
with gr.Tab("Image Pair"):
gr.Markdown(
"Upload a **reference** and add any number of **candidates**. Each "
"candidate shows a green→red similarity bar per aspect; bars update "
"automatically as you add candidates or aspects."
)
with gr.Row():
with gr.Column(scale=3):
# Reference + candidates + add tile all live inside one render so
# they are flex siblings of a single horizontally scrollable row.
@gr.render(inputs=[img_ref_state, img_cands_state, img_aspects_state])
def render_compare(ref_path, cands, aspects):
with gr.Row(elem_classes="tp-compare-row"):
with gr.Column(min_width=300, elem_classes="tp-card"):
gr.HTML("
")
gr.Image(
value=cand["path"],
interactive=False,
height=280,
show_label=False,
)
gr.HTML(candidate_bars_html(cand.get("scores", {}), aspects))
remove_btn = gr.Button("✕ Remove", size="sm")
def _remove(cs, asp, ref, cid=cand["id"]):
cs = [c for c in cs if c["id"] != cid]
return recompute_image_scores(ref, cs, asp)
remove_btn.click(
_remove,
[img_cands_state, img_aspects_state, img_ref_state],
img_cands_state,
)
with gr.Column(min_width=260, elem_classes="tp-card"):
gr.HTML("
")
add_btn = gr.UploadButton(
"+ Add candidate",
file_count="multiple",
file_types=["image"],
)
def _add(files, cs, asp, ref):
paths = [getattr(f, "name", f) for f in (files or [])]
cs = list(cs) + [
{"id": uuid.uuid4().hex, "path": p, "scores": {}}
for p in paths
]
return recompute_image_scores(ref, cs, asp)
add_btn.upload(
_add,
[add_btn, img_cands_state, img_aspects_state, img_ref_state],
img_cands_state,
)
with gr.Column(scale=1, min_width=240):
gr.Markdown("### Comparison aspects")
with gr.Row(elem_classes="tp-aspect-add"):
aspect_box = gr.Textbox(
placeholder="e.g. material texture",
show_label=False,
container=False,
scale=4,
max_lines=1,
)
aspect_add = gr.Button("Add", scale=1, size="sm")
with gr.Row(elem_classes="tp-chip-row"):
chip_btns = [
gr.Button(p, size="sm", elem_classes="tp-chip") for p in PRESET_FACTORS
]
@gr.render(inputs=[img_aspects_state])
def render_aspects(aspects):
if not aspects:
gr.HTML("
Add an aspect to compare.
")
for name in aspects:
with gr.Row(elem_classes="tp-aspect-row"):
gr.HTML(f"
{_html.escape(name)}
")
remove_aspect = gr.Button(
"✕", size="sm", scale=0, min_width=34, elem_classes="tp-x"
)
def _remove_aspect(asp, cs, ref, target=name):
asp = [a for a in asp if a != target]
return asp, recompute_image_scores(ref, cs, asp)
remove_aspect.click(
_remove_aspect,
[img_aspects_state, img_cands_state, img_ref_state],
[img_aspects_state, img_cands_state],
)
def _add_aspect(text, asp, cs, ref):
name = " ".join(str(text or "").strip().split())[:120]
asp = list(asp)
if name and name not in asp:
asp = asp + [name]
return "", asp, recompute_image_scores(ref, cs, asp)
aspect_add.click(
_add_aspect,
[aspect_box, img_aspects_state, img_cands_state, img_ref_state],
[aspect_box, img_aspects_state, img_cands_state],
)
aspect_box.submit(
_add_aspect,
[aspect_box, img_aspects_state, img_cands_state, img_ref_state],
[aspect_box, img_aspects_state, img_cands_state],
)
for chip, preset in zip(chip_btns, PRESET_FACTORS):
def _add_chip(asp, cs, ref, name=preset):
asp = list(asp)
if name not in asp:
asp = asp + [name]
return asp, recompute_image_scores(ref, cs, asp)
chip.click(
_add_chip,
[img_aspects_state, img_cands_state, img_ref_state],
[img_aspects_state, img_cands_state],
)
with gr.Tab("Video"):
gr.Markdown(
f"Upload a clip (first {MAX_VIDEO_SECONDS:g}s analyzed), pick a reference "
"frame, then play the candidate — the curve shows how similar every frame "
"is to the reference along each aspect, and the bars update live as it plays."
)
# Hidden channel: server writes the JSON payload here, and its .change
# forwards it to the client-side controller (window.tpvInit). Declared
# early because the aspect-remove buttons rendered below target it.
video_payload = gr.Textbox(visible=False, elem_id="tpv-payload")
with gr.Row():
with gr.Column(scale=3):
video_input = gr.Video(
label=f"Video (first {MAX_VIDEO_SECONDS:g}s analyzed)", height=220
)
with gr.Column(scale=1, min_width=240):
gr.Markdown("### Video aspects")
with gr.Row(elem_classes="tp-aspect-add"):
v_aspect_box = gr.Textbox(
placeholder="e.g. camera motion",
show_label=False,
container=False,
scale=4,
max_lines=1,
)
v_aspect_add = gr.Button("Add", scale=1, size="sm")
with gr.Row(elem_classes="tp-chip-row"):
v_chip_btns = [
gr.Button(p, size="sm", elem_classes="tp-chip") for p in PRESET_FACTORS
]
@gr.render(inputs=[video_aspects_state])
def render_video_aspects(aspects):
if not aspects:
gr.HTML("
Add an aspect to embed frames.
")
for name in aspects:
with gr.Row(elem_classes="tp-aspect-row"):
gr.HTML(
f"
{_html.escape(name)}
"
)
v_remove = gr.Button(
"✕", size="sm", scale=0, min_width=34, elem_classes="tp-x"
)
def _remove_v_aspect(asp, st, target=name):
asp = [a for a in asp if a != target]
st, payload = score_video_state(st, asp)
return asp, st, payload
v_remove.click(
_remove_v_aspect,
[video_aspects_state, video_state],
[video_aspects_state, video_state, video_payload],
)
# The interactive viewer (static skeleton; behaviour is in VIDEO_HEAD).
gr.HTML(VIDEO_VIEWER_HTML)
video_payload.change(
None, video_payload, None, js="(p) => { if (window.tpvInit) window.tpvInit(p); }"
)
# Upload -> extract frames (no GPU) -> auto-score the current aspects.
video_input.change(
load_video_state, [video_input], [video_state, video_payload]
).then(
score_video_state,
[video_state, video_aspects_state],
[video_state, video_payload],
)
def _add_v_aspect(text, asp, st):
name = " ".join(str(text or "").strip().split())[:120]
asp = list(asp)
if name and name not in asp:
asp = asp + [name]
st, payload = score_video_state(st, asp)
return "", asp, st, payload
v_aspect_add.click(
_add_v_aspect,
[v_aspect_box, video_aspects_state, video_state],
[v_aspect_box, video_aspects_state, video_state, video_payload],
)
v_aspect_box.submit(
_add_v_aspect,
[v_aspect_box, video_aspects_state, video_state],
[v_aspect_box, video_aspects_state, video_state, video_payload],
)
for v_chip, v_preset in zip(v_chip_btns, PRESET_FACTORS):
def _add_v_chip(asp, st, name=v_preset):
asp = list(asp)
if name not in asp:
asp = asp + [name]
st, payload = score_video_state(st, asp)
return asp, st, payload
v_chip.click(
_add_v_chip,
[video_aspects_state, video_state],
[video_aspects_state, video_state, video_payload],
)
if __name__ == "__main__":
demo.queue(max_size=32).launch(
# Bind all interfaces by default (required on the Space); override with
# GRADIO_SERVER_NAME=127.0.0.1 for local testing on hosts where
# ``localhost`` resolves only to IPv6 and Gradio's self-check would fail.
server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.environ.get("PORT", 7860)),
)