Spaces:
Sleeping
Sleeping
File size: 17,701 Bytes
1ba148a 8b7c167 1ba148a 20a21fe 1ba148a 8b7c167 1ba148a 8b7c167 8dcef41 1ba148a 0bcea6b 1ba148a 0ec40a9 1ba148a 20a21fe 8b7c167 86024d9 8b7c167 20a21fe 1ba148a 20a21fe 1ba148a 0bcea6b 1ba148a 0bcea6b 20a21fe 1ba148a 20a21fe 1ba148a 20a21fe 1ba148a 0bcea6b 1ba148a 0bcea6b 1ba148a 91bfaf0 1ba148a 0bcea6b 1ba148a 0bcea6b 8b7c167 86024d9 8b7c167 86024d9 8b7c167 86024d9 8b7c167 86024d9 8b7c167 1ba148a 8b7c167 1ba148a 8b7c167 1ba148a 8b7c167 91bfaf0 8b7c167 0bcea6b 91bfaf0 8b7c167 0bcea6b 91bfaf0 8b7c167 1ba148a 91bfaf0 1ba148a 0bcea6b 1ba148a 91bfaf0 1ba148a 91bfaf0 1ba148a 91bfaf0 1ba148a 20a21fe 1ba148a 91bfaf0 1ba148a 0bcea6b 8b7c167 0bcea6b 8b7c167 86024d9 91bfaf0 1ba148a 8b7c167 91bfaf0 1ba148a 91bfaf0 8b7c167 91bfaf0 1ba148a 20a21fe 1ba148a 86024d9 8b7c167 86024d9 8b7c167 86024d9 8b7c167 86024d9 8b7c167 86024d9 8b7c167 1ba148a 91bfaf0 1ba148a 91bfaf0 0bcea6b 86024d9 0bcea6b 91bfaf0 1ba148a 8dcef41 1ba148a 8dcef41 1ba148a 20a21fe 0ec40a9 91bfaf0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 | """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) |