Kaleidoscope / app.py
TrueAl's picture
small improvements in veo handling
91bfaf0
Raw
History Blame Contribute Delete
17.7 kB
"""Kaleidoscope: upload an image, fan it out to every configured model
provider concurrently, and preview each provider's generated video.
Every run (input image + per-provider output videos + metadata) is
persisted under /data so past runs can be shown in the history section.
Provider API keys are BYOK: each browser user supplies their own key(s) in
the UI. Keys are persisted in browser localStorage and are never written to
disk by this server.
"""
from __future__ import annotations
import dataclasses
import logging
import os
import time
import warnings
from concurrent.futures import ThreadPoolExecutor, as_completed
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
import gradio as gr
from dotenv import load_dotenv
from providers import PROVIDERS, apply_global_settings
from runs import (
Run,
RunResult,
create_run,
ensure_data_dirs,
get_data_dir,
load_runs,
save_output_video,
save_run,
)
load_dotenv()
warnings.filterwarnings(
"ignore",
message=".*HTTP_422_UNPROCESSABLE_ENTITY.*",
category=DeprecationWarning,
module=r"gradio\.routes",
)
TOKEN_ENVS = sorted({provider.api_token_env for provider in PROVIDERS})
EXTRA_CONFIG_ENVS = sorted({name for provider in PROVIDERS for name in provider.extra_config_envs})
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
ensure_data_dirs()
logger.info("Data directory resolved to %s", get_data_dir())
def _call_with_timing(provider, image_path, prompt, duration_seconds, resolution):
start = time.monotonic()
effective_provider = apply_global_settings(provider, prompt, duration_seconds, resolution)
logger.info("Provider %s: starting", provider.name)
try:
video_bytes = effective_provider.call(effective_provider, image_path)
duration = time.monotonic() - start
logger.info("Provider %s: succeeded in %.1fs", provider.name, duration)
return effective_provider, video_bytes, None, duration
except Exception as exc: # noqa: BLE001 - isolate provider failure, report it in the UI
duration = time.monotonic() - start
logger.exception("Provider %s: failed after %.1fs", provider.name, duration)
return effective_provider, None, exc, duration
def run_providers(providers, image_path, prompt, duration_seconds, resolution):
"""Fans out image_path to every provider concurrently.
Yields (provider, video_bytes, error, duration_seconds) tuples in
completion order (not submission order) so the caller can update the UI
incrementally instead of waiting on the slowest provider.
"""
with ThreadPoolExecutor(max_workers=max(len(providers), 1)) as executor:
futures = [
executor.submit(_call_with_timing, provider, image_path, prompt, duration_seconds, resolution)
for provider in providers
]
for future in as_completed(futures):
yield future.result()
def format_metadata(provider, status, error=None, duration=None):
lines = [f"**{provider.name}**", f"- status: {status}"]
if duration is not None:
lines.append(f"- duration: {duration:.1f}s")
if provider.params:
lines.append(f"- params: {provider.params}")
if error is not None:
lines.append(f"- error: {error}")
return "\n".join(lines)
# Small per-provider status indicator shown next to its checkbox (visible
# without expanding that provider's accordion) - a CSS spinner while a
# request is in flight, then a static icon once it settles. The spinner's
# look is defined by STATUS_SPINNER_CSS, injected once via gr.Blocks(css=...).
_STATUS_ICONS = {
"idle": "",
"running": '<span class="ks-spinner" title="Running"></span>',
"ok": '<span title="Done" style="font-size:1.1em;">\u2705</span>',
"error": '<span title="Error" style="font-size:1.1em;">\u274c</span>',
"skipped": '<span title="Skipped" style="font-size:1.1em;">\u23ed\ufe0f</span>',
}
def format_status_icon(status: str) -> str:
return _STATUS_ICONS.get(status, "")
def format_result_metadata(result: RunResult) -> str:
lines = [
f"**{result.provider_name}**",
f"- status: {result.status}",
f"- duration: {result.duration_seconds:.1f}s",
f"- params: {result.params_used}",
]
if result.error:
lines.append(f"- error: {result.error}")
return "\n".join(lines)
def on_submit(image_path, prompt, duration_seconds, resolution, history, *dynamic_inputs):
if not image_path:
raise gr.Error("Please upload an image first.")
duration_seconds = int(duration_seconds) if duration_seconds is not None else None
token_values = list(dynamic_inputs[: len(TOKEN_ENVS)])
extra_config_values = list(dynamic_inputs[len(TOKEN_ENVS) : len(TOKEN_ENVS) + len(EXTRA_CONFIG_ENVS)])
enabled_values = list(dynamic_inputs[len(TOKEN_ENVS) + len(EXTRA_CONFIG_ENVS) :])
token_map = {
token_env: (token_value.strip() if isinstance(token_value, str) else "")
for token_env, token_value in zip(TOKEN_ENVS, token_values)
}
extra_config_map = {
config_env: (config_value.strip() if isinstance(config_value, str) else "")
for config_env, config_value in zip(EXTRA_CONFIG_ENVS, extra_config_values)
}
selected_providers = [provider for provider, enabled in zip(PROVIDERS, enabled_values) if enabled]
if not selected_providers:
raise gr.Error("Select at least one model to run.")
missing_token_envs = sorted(
{
provider.api_token_env
for provider in selected_providers
if not token_map.get(provider.api_token_env)
}
)
missing_extra_config_envs = sorted(
{
config_env
for provider in selected_providers
for config_env in provider.extra_config_envs
if not extra_config_map.get(config_env)
}
)
if missing_token_envs or missing_extra_config_envs:
missing_label = ", ".join(missing_token_envs + missing_extra_config_envs)
raise gr.Error(f"Missing required configuration: {missing_label}")
selected_runtime_providers = [
dataclasses.replace(
provider,
api_token_value=token_map.get(provider.api_token_env, ""),
extra_config_values={
config_env: extra_config_map.get(config_env, "") for config_env in provider.extra_config_envs
},
)
for provider in selected_providers
]
run_id, run_dir, input_path = create_run(image_path)
logger.info(
"Run %s: created (selected_providers=%d total_providers=%d, prompt=%r)",
run_id,
len(selected_runtime_providers),
len(PROVIDERS),
bool(prompt),
)
provider_index = {provider.name: i for i, provider in enumerate(PROVIDERS)}
results_by_name: dict[str, RunResult] = {}
selected_names = {provider.name for provider in selected_runtime_providers}
metadata_values = []
status_values = []
for provider in PROVIDERS:
if provider.name in selected_names:
metadata_values.append(
format_metadata(apply_global_settings(provider, prompt, duration_seconds, resolution), "running")
)
status_values.append(format_status_icon("running"))
else:
metadata_values.append(
format_metadata(apply_global_settings(provider, prompt, duration_seconds, resolution), "skipped")
)
status_values.append(format_status_icon("skipped"))
results_by_name[provider.name] = RunResult(
provider_name=provider.name,
output_path=None,
status="skipped",
error=None,
duration_seconds=0.0,
params_used=provider.params,
)
video_values = [None] * len(PROVIDERS)
yield metadata_values + status_values + video_values + [history]
for provider, video_bytes, error, duration in run_providers(
selected_runtime_providers, image_path, prompt, duration_seconds, resolution
):
index = provider_index[provider.name]
if error is None:
output_path = save_output_video(run_dir, provider.name, video_bytes)
results_by_name[provider.name] = RunResult(
provider_name=provider.name,
output_path=output_path,
status="ok",
error=None,
duration_seconds=duration,
params_used=provider.params,
)
metadata_values[index] = format_metadata(provider, "ok", duration=duration)
status_values[index] = format_status_icon("ok")
video_values[index] = output_path
else:
results_by_name[provider.name] = RunResult(
provider_name=provider.name,
output_path=None,
status="error",
error=str(error),
duration_seconds=duration,
params_used=provider.params,
)
metadata_values[index] = format_metadata(provider, "error", error=str(error), duration=duration)
status_values[index] = format_status_icon("error")
video_values[index] = None
yield metadata_values + status_values + video_values + [history]
run = Run(
run_id=run_id,
timestamp=time.time(),
input_path=input_path,
prompt=prompt or None,
results=[results_by_name[provider.name] for provider in PROVIDERS],
)
save_run(run)
logger.info("Run %s: saved", run_id)
yield metadata_values + status_values + video_values + [history + [run]]
# CSS for the small per-provider running spinner (see format_status_icon) -
# a plain rotating-border circle so no extra asset/dependency is needed.
STATUS_SPINNER_CSS = """
.ks-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border-color-primary, #999);
border-top-color: var(--color-accent, #555);
border-radius: 50%;
animation: ks-spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes ks-spin {
to { transform: rotate(360deg); }
}
"""
with gr.Blocks(title="Kaleidoscope") as demo:
gr.Markdown(
"# Kaleidoscope\n"
"Upload an image to generate a short video with each configured provider."
)
with gr.Accordion(label="Settings", open=False):
gr.Markdown(
"Applied to every model that supports it (translated into each "
"model's own params as needed); models without a matching field "
"ignore it."
)
duration_input = gr.Number(
value=6,
precision=0,
label="Duration (seconds)",
)
resolution_input = gr.Dropdown(
choices=["720p", "1080p"],
value="720p",
label="Resolution",
)
with gr.Accordion(label="API Keys (BYOK)", open=False):
gr.Markdown("Keys are saved in your browser local storage and never persisted by this server.")
token_inputs = []
for token_env in TOKEN_ENVS:
token_inputs.append(
gr.Textbox(
label=token_env,
placeholder=f"Enter {token_env}",
type="password",
)
)
extra_config_inputs = []
if EXTRA_CONFIG_ENVS:
gr.Markdown(
"Some providers need more than just a key (e.g. a "
"per-resource endpoint URL) - configure those here too."
)
# Per-field placeholder overrides for config values whose
# expected format isn't obvious from the label alone (falls back
# to a generic placeholder for anything not listed here).
extra_config_placeholders = {
"AZURE_SORA_ENDPOINT": "https://<resource>.openai.azure.com/openai/v1",
}
for config_env in EXTRA_CONFIG_ENVS:
extra_config_inputs.append(
gr.Textbox(
label=config_env,
placeholder=extra_config_placeholders.get(config_env, f"Enter {config_env}"),
)
)
with gr.Accordion(label="Input image", open=True) as image_accordion:
image_input = gr.Image(type="filepath", label="Input image")
prompt_input = gr.Textbox(
label="Prompt (optional)",
placeholder="Used by providers that support a prompt; ignored by the rest.",
)
submit_btn = gr.Button("Submit", variant="primary")
gr.Markdown("## Results")
check_all_btn = gr.Button("Check all models")
enabled_components = []
status_components = []
metadata_components = []
video_components = []
for provider in PROVIDERS:
with gr.Row():
with gr.Column(scale=0, min_width=90):
enabled_components.append(gr.Checkbox(label="Use", value=True))
status_components.append(gr.HTML(value=format_status_icon("idle")))
with gr.Accordion(label=provider.name, open=False):
with gr.Row():
with gr.Column(scale=1):
metadata_components.append(gr.Markdown(format_metadata(provider, "idle")))
with gr.Column(scale=2):
video_components.append(gr.Video(label=provider.name, interactive=False))
gr.Markdown("## Past runs")
# Start empty and populate via demo.load() below, so every new page
# load/session re-reads runs from disk instead of reusing a snapshot
# taken once when the server process started.
history_state = gr.State([])
demo.load(load_runs, outputs=history_state)
persisted_inputs = token_inputs + extra_config_inputs
storage_keys = [f"kaleidoscope.byok.{token_env.lower()}" for token_env in TOKEN_ENVS] + [
f"kaleidoscope.byok.{config_env.lower()}" for config_env in EXTRA_CONFIG_ENVS
]
if persisted_inputs:
# Gradio's JS-return convention mirrors Python fn returns: with exactly
# one output, return the bare value (not wrapped in an array); with
# multiple outputs, return an array of values in output order.
# Returning a 1-element array for a single output corrupts that
# component's internal block registry (it gets replaced by the raw
# list), crashing later interactions with
# "'list' object has no attribute 'stateful'" - so the single- and
# multi-output cases must be built differently below.
if len(persisted_inputs) == 1:
load_js = f"() => localStorage.getItem('{storage_keys[0]}') || ''"
else:
load_js = (
"() => ["
+ ", ".join(f"localStorage.getItem('{key}') || ''" for key in storage_keys)
+ "]"
)
demo.load(fn=None, inputs=None, outputs=persisted_inputs, js=load_js)
for storage_key, persisted_input in zip(storage_keys, persisted_inputs):
persisted_input.change(
fn=None,
inputs=[persisted_input],
outputs=[],
js=f"(value) => localStorage.setItem('{storage_key}', value || '')",
)
check_all_btn.click(
# A bare `True` (not a list) when there's exactly one output, else a
# list matching the output count - see the load_js comment above for
# why this distinction matters.
fn=(lambda: True) if len(enabled_components) == 1 else (lambda: [True] * len(enabled_components)),
inputs=None,
outputs=enabled_components,
)
@gr.render(inputs=history_state)
def render_history(history):
if not history:
gr.Markdown("_No past runs yet._")
return
for run in reversed(history):
run_label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(run.timestamp))
with gr.Accordion(label=run_label, open=False):
gr.Image(value=run.input_path, label="Input")
if run.prompt:
gr.Markdown(f"**Prompt:** {run.prompt}")
for result in run.results:
with gr.Row():
with gr.Column(scale=1):
gr.Markdown(format_result_metadata(result))
with gr.Column(scale=2):
gr.Video(value=result.output_path, interactive=False)
submit_btn.click(
fn=lambda: (gr.update(interactive=False), gr.update(open=False)),
inputs=None,
outputs=[submit_btn, image_accordion],
).then(
fn=on_submit,
inputs=[image_input, prompt_input, duration_input, resolution_input, history_state]
+ token_inputs
+ extra_config_inputs
+ enabled_components,
outputs=metadata_components + status_components + video_components + [history_state],
).then(
fn=lambda: gr.update(interactive=True),
inputs=None,
outputs=submit_btn,
)
demo.queue()
if __name__ == "__main__":
logger.info("Starting Kaleidoscope with %d provider(s): %s", len(PROVIDERS), [p.name for p in PROVIDERS])
# /data (on a Hugging Face Space) lives outside the cwd and the system
# temp dir, so Gradio refuses to serve run files from it unless the
# directory is explicitly allow-listed here.
demo.launch(allowed_paths=[get_data_dir()], css=STATUS_SPINNER_CSS)