Spaces:
Sleeping
Sleeping
added byok and replicate
Browse files- .env.example +7 -6
- DEVELOPER.md +23 -20
- app.py +126 -16
- providers.py +193 -10
- requirements.txt +1 -0
- runs.py +4 -1
.env.example
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
# Copy this file to .env for local development (never commit .env itself).
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
|
|
|
|
|
|
| 1 |
# Copy this file to .env for local development (never commit .env itself).
|
| 2 |
+
#
|
| 3 |
+
# NOTE: provider API keys (fal.ai, Replicate, ...) are BYOK - each user
|
| 4 |
+
# enters their own key(s) in the app UI, persisted in browser localStorage.
|
| 5 |
+
# They are NOT read from this file or the server environment; nothing
|
| 6 |
+
# provider-related belongs here. This file is only for non-provider local
|
| 7 |
+
# config, e.g.:
|
| 8 |
+
# LOG_LEVEL=DEBUG
|
DEVELOPER.md
CHANGED
|
@@ -4,22 +4,23 @@ This documents how local development differs from running on the Hugging
|
|
| 4 |
Face Space, since a few things (persistent storage, secrets) work
|
| 5 |
differently in each environment.
|
| 6 |
|
| 7 |
-
##
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
the environment (without overriding any variable that's already set, so
|
| 15 |
-
Space secrets always win if both happen to be present).
|
| 16 |
-
- `.env` is gitignored and must never be committed. `.env.example` is the
|
| 17 |
-
template that *is* committed.
|
| 18 |
- Each `ModelProvider` in [providers.py](providers.py) declares its own
|
| 19 |
-
`api_token_env` (
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
## Persistent storage (`/data`)
|
| 25 |
|
|
@@ -47,8 +48,10 @@ Then open the printed local URL (e.g. `http://127.0.0.1:7860`).
|
|
| 47 |
## Adding a new model provider
|
| 48 |
|
| 49 |
Add a `ModelProvider(...)` entry to `PROVIDERS` in [providers.py](providers.py).
|
| 50 |
-
Reuse `fal_queue_call` if the new model is also on fal.ai,
|
| 51 |
-
`call` function matching the signature
|
| 52 |
-
providers with a different API contract
|
| 53 |
-
`polling_call` for templates of direct-response
|
| 54 |
-
No subclassing is needed - the `call` field is
|
|
|
|
|
|
|
|
|
| 4 |
Face Space, since a few things (persistent storage, secrets) work
|
| 5 |
differently in each environment.
|
| 6 |
|
| 7 |
+
## Provider API tokens (BYOK)
|
| 8 |
+
|
| 9 |
+
- Every provider is **bring-your-own-key**: each user pastes their own API
|
| 10 |
+
key(s) into the UI. Keys are persisted in the browser's `localStorage` and
|
| 11 |
+
sent to the server only transiently, as part of each generation request -
|
| 12 |
+
they are never read from the server environment and never written to
|
| 13 |
+
disk by this app.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
- Each `ModelProvider` in [providers.py](providers.py) declares its own
|
| 15 |
+
`api_token_env` (just a label identifying which key it needs, e.g.
|
| 16 |
+
`FAL_KEY` or `REPLICATE_API_TOKEN` - not an actual env var read at
|
| 17 |
+
runtime). `app.py` derives one key input field per distinct
|
| 18 |
+
`api_token_env` automatically (`TOKEN_ENVS` in [app.py](app.py)), so
|
| 19 |
+
adding a new provider with a new `api_token_env` adds its input field
|
| 20 |
+
with no other UI changes needed. Providers that share an `api_token_env`
|
| 21 |
+
(e.g. the two fal.ai providers both use `FAL_KEY`) share one input field.
|
| 22 |
+
- `.env` / `load_dotenv()` still exist for non-provider local config (e.g.
|
| 23 |
+
`LOG_LEVEL`), but no longer carry provider API keys.
|
| 24 |
|
| 25 |
## Persistent storage (`/data`)
|
| 26 |
|
|
|
|
| 48 |
## Adding a new model provider
|
| 49 |
|
| 50 |
Add a `ModelProvider(...)` entry to `PROVIDERS` in [providers.py](providers.py).
|
| 51 |
+
Reuse `fal_queue_call` if the new model is also on fal.ai, `replicate_call` if
|
| 52 |
+
it's on Replicate, or write a new `call` function matching the signature
|
| 53 |
+
`(provider, image_path) -> bytes` for providers with a different API contract
|
| 54 |
+
(see `generic_sync_call` and `polling_call` for templates of direct-response
|
| 55 |
+
vs. job-polling contracts). No subclassing is needed - the `call` field is
|
| 56 |
+
what makes this duck-typed. Give the provider an `api_token_env` label (new
|
| 57 |
+
or reused) - the UI picks up a BYOK input field for it automatically.
|
app.py
CHANGED
|
@@ -4,11 +4,9 @@ provider concurrently, and preview each provider's generated video.
|
|
| 4 |
Every run (input image + per-provider output videos + metadata) is
|
| 5 |
persisted under /data so past runs can be shown in the history section.
|
| 6 |
|
| 7 |
-
Provider API
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
.env file (if present) into the environment; it never overrides variables
|
| 11 |
-
that are already set, so Space secrets always take priority.
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
@@ -17,8 +15,11 @@ import dataclasses
|
|
| 17 |
import logging
|
| 18 |
import os
|
| 19 |
import time
|
|
|
|
| 20 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 21 |
|
|
|
|
|
|
|
| 22 |
import gradio as gr
|
| 23 |
from dotenv import load_dotenv
|
| 24 |
|
|
@@ -36,6 +37,15 @@ from runs import (
|
|
| 36 |
|
| 37 |
load_dotenv()
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
logging.basicConfig(
|
| 40 |
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
| 41 |
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
@@ -106,22 +116,68 @@ def format_result_metadata(result: RunResult) -> str:
|
|
| 106 |
return "\n".join(lines)
|
| 107 |
|
| 108 |
|
| 109 |
-
def on_submit(image_path, prompt, history):
|
| 110 |
if not image_path:
|
| 111 |
raise gr.Error("Please upload an image first.")
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
run_id, run_dir, input_path = create_run(image_path)
|
| 114 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
provider_index = {provider.name: i for i, provider in enumerate(PROVIDERS)}
|
| 116 |
results_by_name: dict[str, RunResult] = {}
|
|
|
|
| 117 |
|
| 118 |
-
metadata_values = [
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
video_values = [None] * len(PROVIDERS)
|
| 122 |
yield metadata_values + video_values + [history]
|
| 123 |
|
| 124 |
-
for provider, video_bytes, error, duration in run_providers(
|
| 125 |
index = provider_index[provider.name]
|
| 126 |
|
| 127 |
if error is None:
|
|
@@ -169,6 +225,18 @@ with gr.Blocks(title="Kaleidoscope") as demo:
|
|
| 169 |
"Upload an image to generate a short video with each configured provider."
|
| 170 |
)
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
image_input = gr.Image(type="filepath", label="Input image")
|
| 173 |
prompt_input = gr.Textbox(
|
| 174 |
label="Prompt (optional)",
|
|
@@ -177,14 +245,19 @@ with gr.Blocks(title="Kaleidoscope") as demo:
|
|
| 177 |
submit_btn = gr.Button("Submit", variant="primary")
|
| 178 |
|
| 179 |
gr.Markdown("## Results")
|
|
|
|
|
|
|
| 180 |
metadata_components = []
|
| 181 |
video_components = []
|
| 182 |
for provider in PROVIDERS:
|
| 183 |
with gr.Row():
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
gr.Markdown("## Past runs")
|
| 190 |
# Start empty and populate via demo.load() below, so every new page
|
|
@@ -193,6 +266,43 @@ with gr.Blocks(title="Kaleidoscope") as demo:
|
|
| 193 |
history_state = gr.State([])
|
| 194 |
demo.load(load_runs, outputs=history_state)
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
@gr.render(inputs=history_state)
|
| 197 |
def render_history(history):
|
| 198 |
if not history:
|
|
@@ -213,7 +323,7 @@ with gr.Blocks(title="Kaleidoscope") as demo:
|
|
| 213 |
|
| 214 |
submit_btn.click(
|
| 215 |
on_submit,
|
| 216 |
-
inputs=[image_input, prompt_input, history_state],
|
| 217 |
outputs=metadata_components + video_components + [history_state],
|
| 218 |
)
|
| 219 |
|
|
|
|
| 4 |
Every run (input image + per-provider output videos + metadata) is
|
| 5 |
persisted under /data so past runs can be shown in the history section.
|
| 6 |
|
| 7 |
+
Provider API keys are BYOK: each browser user supplies their own key(s) in
|
| 8 |
+
the UI. Keys are persisted in browser localStorage and are never written to
|
| 9 |
+
disk by this server.
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 15 |
import logging
|
| 16 |
import os
|
| 17 |
import time
|
| 18 |
+
import warnings
|
| 19 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 20 |
|
| 21 |
+
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 22 |
+
|
| 23 |
import gradio as gr
|
| 24 |
from dotenv import load_dotenv
|
| 25 |
|
|
|
|
| 37 |
|
| 38 |
load_dotenv()
|
| 39 |
|
| 40 |
+
warnings.filterwarnings(
|
| 41 |
+
"ignore",
|
| 42 |
+
message=".*HTTP_422_UNPROCESSABLE_ENTITY.*",
|
| 43 |
+
category=DeprecationWarning,
|
| 44 |
+
module=r"gradio\.routes",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
TOKEN_ENVS = sorted({provider.api_token_env for provider in PROVIDERS})
|
| 48 |
+
|
| 49 |
logging.basicConfig(
|
| 50 |
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
| 51 |
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
|
|
| 116 |
return "\n".join(lines)
|
| 117 |
|
| 118 |
|
| 119 |
+
def on_submit(image_path, prompt, history, *dynamic_inputs):
|
| 120 |
if not image_path:
|
| 121 |
raise gr.Error("Please upload an image first.")
|
| 122 |
|
| 123 |
+
token_values = list(dynamic_inputs[: len(TOKEN_ENVS)])
|
| 124 |
+
enabled_values = list(dynamic_inputs[len(TOKEN_ENVS) :])
|
| 125 |
+
|
| 126 |
+
token_map = {
|
| 127 |
+
token_env: (token_value.strip() if isinstance(token_value, str) else "")
|
| 128 |
+
for token_env, token_value in zip(TOKEN_ENVS, token_values)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
selected_providers = [provider for provider, enabled in zip(PROVIDERS, enabled_values) if enabled]
|
| 132 |
+
if not selected_providers:
|
| 133 |
+
raise gr.Error("Select at least one model to run.")
|
| 134 |
+
|
| 135 |
+
missing_token_envs = sorted(
|
| 136 |
+
{
|
| 137 |
+
provider.api_token_env
|
| 138 |
+
for provider in selected_providers
|
| 139 |
+
if not token_map.get(provider.api_token_env)
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
if missing_token_envs:
|
| 143 |
+
missing_label = ", ".join(missing_token_envs)
|
| 144 |
+
raise gr.Error(f"Missing required API key(s): {missing_label}")
|
| 145 |
+
|
| 146 |
+
selected_runtime_providers = [
|
| 147 |
+
dataclasses.replace(provider, api_token_value=token_map.get(provider.api_token_env, ""))
|
| 148 |
+
for provider in selected_providers
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
run_id, run_dir, input_path = create_run(image_path)
|
| 152 |
+
logger.info(
|
| 153 |
+
"Run %s: created (selected_providers=%d total_providers=%d, prompt=%r)",
|
| 154 |
+
run_id,
|
| 155 |
+
len(selected_runtime_providers),
|
| 156 |
+
len(PROVIDERS),
|
| 157 |
+
bool(prompt),
|
| 158 |
+
)
|
| 159 |
provider_index = {provider.name: i for i, provider in enumerate(PROVIDERS)}
|
| 160 |
results_by_name: dict[str, RunResult] = {}
|
| 161 |
+
selected_names = {provider.name for provider in selected_runtime_providers}
|
| 162 |
|
| 163 |
+
metadata_values = []
|
| 164 |
+
for provider in PROVIDERS:
|
| 165 |
+
if provider.name in selected_names:
|
| 166 |
+
metadata_values.append(format_metadata(_effective_provider(provider, prompt), "running"))
|
| 167 |
+
else:
|
| 168 |
+
metadata_values.append(format_metadata(_effective_provider(provider, prompt), "skipped"))
|
| 169 |
+
results_by_name[provider.name] = RunResult(
|
| 170 |
+
provider_name=provider.name,
|
| 171 |
+
output_path=None,
|
| 172 |
+
status="skipped",
|
| 173 |
+
error=None,
|
| 174 |
+
duration_seconds=0.0,
|
| 175 |
+
params_used=provider.params,
|
| 176 |
+
)
|
| 177 |
video_values = [None] * len(PROVIDERS)
|
| 178 |
yield metadata_values + video_values + [history]
|
| 179 |
|
| 180 |
+
for provider, video_bytes, error, duration in run_providers(selected_runtime_providers, image_path, prompt):
|
| 181 |
index = provider_index[provider.name]
|
| 182 |
|
| 183 |
if error is None:
|
|
|
|
| 225 |
"Upload an image to generate a short video with each configured provider."
|
| 226 |
)
|
| 227 |
|
| 228 |
+
gr.Markdown("## API Keys (BYOK)")
|
| 229 |
+
gr.Markdown("Keys are saved in your browser local storage and never persisted by this server.")
|
| 230 |
+
token_inputs = []
|
| 231 |
+
for token_env in TOKEN_ENVS:
|
| 232 |
+
token_inputs.append(
|
| 233 |
+
gr.Textbox(
|
| 234 |
+
label=token_env,
|
| 235 |
+
placeholder=f"Enter {token_env}",
|
| 236 |
+
type="password",
|
| 237 |
+
)
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
image_input = gr.Image(type="filepath", label="Input image")
|
| 241 |
prompt_input = gr.Textbox(
|
| 242 |
label="Prompt (optional)",
|
|
|
|
| 245 |
submit_btn = gr.Button("Submit", variant="primary")
|
| 246 |
|
| 247 |
gr.Markdown("## Results")
|
| 248 |
+
check_all_btn = gr.Button("Check all models")
|
| 249 |
+
enabled_components = []
|
| 250 |
metadata_components = []
|
| 251 |
video_components = []
|
| 252 |
for provider in PROVIDERS:
|
| 253 |
with gr.Row():
|
| 254 |
+
enabled_components.append(gr.Checkbox(label="Use", value=True))
|
| 255 |
+
with gr.Accordion(label=provider.name, open=False):
|
| 256 |
+
with gr.Row():
|
| 257 |
+
with gr.Column(scale=1):
|
| 258 |
+
metadata_components.append(gr.Markdown(format_metadata(provider, "idle")))
|
| 259 |
+
with gr.Column(scale=2):
|
| 260 |
+
video_components.append(gr.Video(label=provider.name))
|
| 261 |
|
| 262 |
gr.Markdown("## Past runs")
|
| 263 |
# Start empty and populate via demo.load() below, so every new page
|
|
|
|
| 266 |
history_state = gr.State([])
|
| 267 |
demo.load(load_runs, outputs=history_state)
|
| 268 |
|
| 269 |
+
storage_keys = [f"kaleidoscope.byok.{token_env.lower()}" for token_env in TOKEN_ENVS]
|
| 270 |
+
if token_inputs:
|
| 271 |
+
# Gradio's JS-return convention mirrors Python fn returns: with exactly
|
| 272 |
+
# one output, return the bare value (not wrapped in an array); with
|
| 273 |
+
# multiple outputs, return an array of values in output order.
|
| 274 |
+
# Returning a 1-element array for a single output corrupts that
|
| 275 |
+
# component's internal block registry (it gets replaced by the raw
|
| 276 |
+
# list), crashing later interactions with
|
| 277 |
+
# "'list' object has no attribute 'stateful'" - so the single- and
|
| 278 |
+
# multi-output cases must be built differently below.
|
| 279 |
+
if len(token_inputs) == 1:
|
| 280 |
+
load_js = f"() => localStorage.getItem('{storage_keys[0]}') || ''"
|
| 281 |
+
else:
|
| 282 |
+
load_js = (
|
| 283 |
+
"() => ["
|
| 284 |
+
+ ", ".join(f"localStorage.getItem('{key}') || ''" for key in storage_keys)
|
| 285 |
+
+ "]"
|
| 286 |
+
)
|
| 287 |
+
demo.load(fn=None, inputs=None, outputs=token_inputs, js=load_js)
|
| 288 |
+
|
| 289 |
+
for storage_key, token_input in zip(storage_keys, token_inputs):
|
| 290 |
+
token_input.change(
|
| 291 |
+
fn=None,
|
| 292 |
+
inputs=[token_input],
|
| 293 |
+
outputs=[],
|
| 294 |
+
js=f"(value) => localStorage.setItem('{storage_key}', value || '')",
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
check_all_btn.click(
|
| 298 |
+
# A bare `True` (not a list) when there's exactly one output, else a
|
| 299 |
+
# list matching the output count - see the load_js comment above for
|
| 300 |
+
# why this distinction matters.
|
| 301 |
+
fn=(lambda: True) if len(enabled_components) == 1 else (lambda: [True] * len(enabled_components)),
|
| 302 |
+
inputs=None,
|
| 303 |
+
outputs=enabled_components,
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
@gr.render(inputs=history_state)
|
| 307 |
def render_history(history):
|
| 308 |
if not history:
|
|
|
|
| 323 |
|
| 324 |
submit_btn.click(
|
| 325 |
on_submit,
|
| 326 |
+
inputs=[image_input, prompt_input, history_state] + token_inputs + enabled_components,
|
| 327 |
outputs=metadata_components + video_components + [history_state],
|
| 328 |
)
|
| 329 |
|
providers.py
CHANGED
|
@@ -11,30 +11,89 @@ provider instance, which is how providers with different API contracts
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import base64
|
|
|
|
| 14 |
import logging
|
| 15 |
import mimetypes
|
| 16 |
-
import os
|
| 17 |
import time
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from typing import Callable
|
| 20 |
|
| 21 |
import requests
|
|
|
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
@dataclass
|
| 27 |
class ModelProvider:
|
| 28 |
name: str
|
| 29 |
api_url: str
|
| 30 |
api_token_env: str
|
|
|
|
| 31 |
params: dict = field(default_factory=dict)
|
| 32 |
call: Callable[["ModelProvider", str], bytes] = None
|
| 33 |
|
| 34 |
@property
|
| 35 |
def api_token(self) -> str:
|
| 36 |
-
"""
|
| 37 |
-
return
|
| 38 |
|
| 39 |
|
| 40 |
def generic_sync_call(provider: ModelProvider, image_path: str) -> bytes:
|
|
@@ -117,8 +176,15 @@ FAL_QUEUE_BASE = "https://queue.fal.run"
|
|
| 117 |
def _image_to_data_uri(image_path: str) -> str:
|
| 118 |
mime_type, _ = mimetypes.guess_type(image_path)
|
| 119 |
mime_type = mime_type or "image/png"
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return f"data:{mime_type};base64,{encoded}"
|
| 123 |
|
| 124 |
|
|
@@ -141,6 +207,8 @@ def fal_queue_call(
|
|
| 141 |
"Content-Type": "application/json",
|
| 142 |
}
|
| 143 |
payload = {**provider.params, "image_url": _image_to_data_uri(image_path)}
|
|
|
|
|
|
|
| 144 |
|
| 145 |
logger.debug("fal.ai %s: submitting job to %s", provider.name, provider.api_url)
|
| 146 |
submit_response = requests.post(
|
|
@@ -173,7 +241,12 @@ def fal_queue_call(
|
|
| 173 |
raise TimeoutError(f"fal.ai job for '{provider.name}' timed out after {max_wait_seconds}s")
|
| 174 |
|
| 175 |
result_response = requests.get(response_url, headers=headers, timeout=30)
|
| 176 |
-
result_response.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
result = result_response.json()
|
| 178 |
video_url = result["video"]["url"]
|
| 179 |
|
|
@@ -182,10 +255,102 @@ def fal_queue_call(
|
|
| 182 |
return video_response.content
|
| 183 |
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
PROVIDERS: list[ModelProvider] = [
|
| 190 |
ModelProvider(
|
| 191 |
name="ltx-2-fal",
|
|
@@ -209,7 +374,25 @@ PROVIDERS: list[ModelProvider] = [
|
|
| 209 |
"resolution": "720p",
|
| 210 |
"num_frames": 81,
|
| 211 |
"frames_per_second": 16,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
},
|
| 213 |
call=fal_queue_call,
|
| 214 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
]
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import base64
|
| 14 |
+
import io
|
| 15 |
import logging
|
| 16 |
import mimetypes
|
|
|
|
| 17 |
import time
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from typing import Callable
|
| 20 |
|
| 21 |
import requests
|
| 22 |
+
from PIL import Image, ImageOps
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
+
# fal.ai's wan-i2v endpoint only supports these fixed aspect ratios; passing
|
| 27 |
+
# "auto" can resolve to an unsupported computed size (422) for some input
|
| 28 |
+
# image dimensions, so "auto" in a provider's params is a sentinel meaning
|
| 29 |
+
# "pick the closest of these from the actual uploaded image" (see
|
| 30 |
+
# _closest_supported_aspect_ratio below).
|
| 31 |
+
_SUPPORTED_ASPECT_RATIOS = {
|
| 32 |
+
"16:9": 16 / 9,
|
| 33 |
+
"9:16": 9 / 16,
|
| 34 |
+
"1:1": 1.0,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
_MIME_TO_PIL_FORMAT = {
|
| 38 |
+
"image/jpeg": "JPEG",
|
| 39 |
+
"image/png": "PNG",
|
| 40 |
+
"image/webp": "WEBP",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _load_oriented_image(image_path: str) -> Image.Image:
|
| 45 |
+
"""Opens `image_path` and bakes in its EXIF orientation, if any.
|
| 46 |
+
|
| 47 |
+
Phone photos are frequently stored with a raw pixel buffer in one
|
| 48 |
+
orientation plus an EXIF "Orientation" tag telling viewers to rotate it
|
| 49 |
+
(e.g. a portrait photo stored as landscape pixels + "rotate 90"). Image
|
| 50 |
+
viewers and Gradio's own preview apply that tag automatically, but a
|
| 51 |
+
naive `Image.open(...).size` read (and the raw bytes sent to fal.ai)
|
| 52 |
+
does not - so without correcting for it here, the aspect ratio we
|
| 53 |
+
compute and the pixels we send to the video model both end up
|
| 54 |
+
reflecting the wrong (physical, not visual) orientation, producing a
|
| 55 |
+
sideways video.
|
| 56 |
+
"""
|
| 57 |
+
image = Image.open(image_path)
|
| 58 |
+
return ImageOps.exif_transpose(image) or image
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _closest_supported_aspect_ratio(image_path: str) -> str:
|
| 62 |
+
with _load_oriented_image(image_path) as image:
|
| 63 |
+
width, height = image.size
|
| 64 |
+
ratio = width / height if height else 1.0
|
| 65 |
+
return min(_SUPPORTED_ASPECT_RATIOS, key=lambda label: abs(_SUPPORTED_ASPECT_RATIOS[label] - ratio))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _format_http_error(response: requests.Response) -> str:
|
| 69 |
+
"""Builds a concise, useful error from an HTTP response body."""
|
| 70 |
+
try:
|
| 71 |
+
body = response.json()
|
| 72 |
+
except ValueError:
|
| 73 |
+
text = (response.text or "").strip()
|
| 74 |
+
return text or f"HTTP {response.status_code}"
|
| 75 |
+
|
| 76 |
+
if isinstance(body, dict):
|
| 77 |
+
for key in ("detail", "error", "message"):
|
| 78 |
+
value = body.get(key)
|
| 79 |
+
if value:
|
| 80 |
+
return str(value)
|
| 81 |
+
return str(body)
|
| 82 |
+
|
| 83 |
|
| 84 |
@dataclass
|
| 85 |
class ModelProvider:
|
| 86 |
name: str
|
| 87 |
api_url: str
|
| 88 |
api_token_env: str
|
| 89 |
+
api_token_value: str = ""
|
| 90 |
params: dict = field(default_factory=dict)
|
| 91 |
call: Callable[["ModelProvider", str], bytes] = None
|
| 92 |
|
| 93 |
@property
|
| 94 |
def api_token(self) -> str:
|
| 95 |
+
"""Returns the per-request API token supplied by the current browser user."""
|
| 96 |
+
return self.api_token_value or ""
|
| 97 |
|
| 98 |
|
| 99 |
def generic_sync_call(provider: ModelProvider, image_path: str) -> bytes:
|
|
|
|
| 176 |
def _image_to_data_uri(image_path: str) -> str:
|
| 177 |
mime_type, _ = mimetypes.guess_type(image_path)
|
| 178 |
mime_type = mime_type or "image/png"
|
| 179 |
+
|
| 180 |
+
with _load_oriented_image(image_path) as image:
|
| 181 |
+
pil_format = _MIME_TO_PIL_FORMAT.get(mime_type, image.format or "PNG")
|
| 182 |
+
if pil_format == "JPEG" and image.mode in ("RGBA", "P"):
|
| 183 |
+
image = image.convert("RGB")
|
| 184 |
+
buffer = io.BytesIO()
|
| 185 |
+
image.save(buffer, format=pil_format)
|
| 186 |
+
|
| 187 |
+
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 188 |
return f"data:{mime_type};base64,{encoded}"
|
| 189 |
|
| 190 |
|
|
|
|
| 207 |
"Content-Type": "application/json",
|
| 208 |
}
|
| 209 |
payload = {**provider.params, "image_url": _image_to_data_uri(image_path)}
|
| 210 |
+
if payload.get("aspect_ratio") == "auto":
|
| 211 |
+
payload["aspect_ratio"] = _closest_supported_aspect_ratio(image_path)
|
| 212 |
|
| 213 |
logger.debug("fal.ai %s: submitting job to %s", provider.name, provider.api_url)
|
| 214 |
submit_response = requests.post(
|
|
|
|
| 241 |
raise TimeoutError(f"fal.ai job for '{provider.name}' timed out after {max_wait_seconds}s")
|
| 242 |
|
| 243 |
result_response = requests.get(response_url, headers=headers, timeout=30)
|
| 244 |
+
if not result_response.ok:
|
| 245 |
+
detail = _format_http_error(result_response)
|
| 246 |
+
raise RuntimeError(
|
| 247 |
+
f"fal.ai result fetch failed for '{provider.name}' "
|
| 248 |
+
f"({result_response.status_code}): {detail}"
|
| 249 |
+
)
|
| 250 |
result = result_response.json()
|
| 251 |
video_url = result["video"]["url"]
|
| 252 |
|
|
|
|
| 255 |
return video_response.content
|
| 256 |
|
| 257 |
|
| 258 |
+
REPLICATE_API_BASE = "https://api.replicate.com/v1"
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _extract_replicate_output_url(output) -> str | None:
|
| 262 |
+
"""Normalizes Replicate's `output` field to a single file URL.
|
| 263 |
+
|
| 264 |
+
Different model schemas represent a single output file as a bare
|
| 265 |
+
string URL, a one-item list, or (rarely) a dict with a "url"/"video"
|
| 266 |
+
key - this isn't a universal contract, just the shapes seen in
|
| 267 |
+
practice, so adapt if a new provider's schema differs.
|
| 268 |
+
"""
|
| 269 |
+
if isinstance(output, str):
|
| 270 |
+
return output
|
| 271 |
+
if isinstance(output, list) and output:
|
| 272 |
+
return _extract_replicate_output_url(output[0])
|
| 273 |
+
if isinstance(output, dict):
|
| 274 |
+
return output.get("url") or output.get("video")
|
| 275 |
+
return None
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def replicate_call(
|
| 279 |
+
provider: ModelProvider,
|
| 280 |
+
image_path: str,
|
| 281 |
+
poll_interval_seconds: float = 2.0,
|
| 282 |
+
max_wait_seconds: float = 600.0,
|
| 283 |
+
) -> bytes:
|
| 284 |
+
"""Calls a Replicate model via its official REST API (create prediction
|
| 285 |
+
-> poll -> fetch output), authenticated with the user's own Replicate
|
| 286 |
+
API token (BYOK).
|
| 287 |
+
|
| 288 |
+
`provider.api_url` must be the Replicate model id, e.g.
|
| 289 |
+
"alibaba/happyhorse-1.1". `provider.params` is sent as the prediction's
|
| 290 |
+
"input", merged with the uploaded image as a base64 data URI in an
|
| 291 |
+
"images" array - Replicate's API accepts a data URI directly for
|
| 292 |
+
file-type inputs, so no public upload step is needed.
|
| 293 |
+
See https://replicate.com/docs/reference/http for the create/poll
|
| 294 |
+
prediction contract.
|
| 295 |
+
"""
|
| 296 |
+
headers = {
|
| 297 |
+
"Authorization": f"Bearer {provider.api_token}",
|
| 298 |
+
"Content-Type": "application/json",
|
| 299 |
+
}
|
| 300 |
+
payload = {"input": {**provider.params, "images": [_image_to_data_uri(image_path)]}}
|
| 301 |
+
|
| 302 |
+
logger.debug("replicate %s: creating prediction for %s", provider.name, provider.api_url)
|
| 303 |
+
create_response = requests.post(
|
| 304 |
+
f"{REPLICATE_API_BASE}/models/{provider.api_url}/predictions",
|
| 305 |
+
headers=headers,
|
| 306 |
+
json=payload,
|
| 307 |
+
timeout=30,
|
| 308 |
+
)
|
| 309 |
+
if not create_response.ok:
|
| 310 |
+
detail = _format_http_error(create_response)
|
| 311 |
+
raise RuntimeError(
|
| 312 |
+
f"replicate prediction creation failed for '{provider.name}' "
|
| 313 |
+
f"({create_response.status_code}): {detail}"
|
| 314 |
+
)
|
| 315 |
+
prediction = create_response.json()
|
| 316 |
+
status_url = prediction["urls"]["get"]
|
| 317 |
+
|
| 318 |
+
deadline = time.monotonic() + max_wait_seconds
|
| 319 |
+
while True:
|
| 320 |
+
status_response = requests.get(status_url, headers=headers, timeout=30)
|
| 321 |
+
status_response.raise_for_status()
|
| 322 |
+
prediction = status_response.json()
|
| 323 |
+
status = prediction.get("status")
|
| 324 |
+
|
| 325 |
+
if status == "succeeded":
|
| 326 |
+
logger.debug("replicate %s: prediction succeeded", provider.name)
|
| 327 |
+
break
|
| 328 |
+
if status in ("failed", "canceled"):
|
| 329 |
+
logger.error("replicate %s: prediction %s: %s", provider.name, status, prediction.get("error"))
|
| 330 |
+
raise RuntimeError(f"replicate prediction {status} for '{provider.name}': {prediction.get('error')}")
|
| 331 |
+
|
| 332 |
+
if time.monotonic() >= deadline:
|
| 333 |
+
logger.error("replicate %s: prediction timed out after %.0fs", provider.name, max_wait_seconds)
|
| 334 |
+
raise TimeoutError(f"replicate prediction for '{provider.name}' timed out after {max_wait_seconds}s")
|
| 335 |
+
|
| 336 |
+
time.sleep(poll_interval_seconds)
|
| 337 |
+
|
| 338 |
+
video_url = _extract_replicate_output_url(prediction.get("output"))
|
| 339 |
+
if not video_url:
|
| 340 |
+
raise RuntimeError(f"replicate prediction for '{provider.name}' succeeded but returned no output URL")
|
| 341 |
+
|
| 342 |
+
# Per Replicate's docs, output file URLs require the Authorization header
|
| 343 |
+
# to fetch, unlike fal.ai's (unauthenticated) delivery URLs above.
|
| 344 |
+
video_response = requests.get(video_url, headers=headers, timeout=180)
|
| 345 |
+
video_response.raise_for_status()
|
| 346 |
+
return video_response.content
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# Registry of configured providers. The fal.ai providers use fal's queue API
|
| 350 |
+
# (submit -> poll -> fetch result) via the shared `fal_queue_call`; the
|
| 351 |
+
# Replicate provider uses Replicate's REST API via `replicate_call`. Every
|
| 352 |
+
# provider is BYOK - the user's API key is supplied at request time in the
|
| 353 |
+
# UI, never read from the server environment.
|
| 354 |
PROVIDERS: list[ModelProvider] = [
|
| 355 |
ModelProvider(
|
| 356 |
name="ltx-2-fal",
|
|
|
|
| 374 |
"resolution": "720p",
|
| 375 |
"num_frames": 81,
|
| 376 |
"frames_per_second": 16,
|
| 377 |
+
# Resolved to a concrete supported ratio (16:9 / 9:16 / 1:1) from
|
| 378 |
+
# the actual uploaded image at call time - see
|
| 379 |
+
# _closest_supported_aspect_ratio.
|
| 380 |
+
"aspect_ratio": "auto",
|
| 381 |
},
|
| 382 |
call=fal_queue_call,
|
| 383 |
),
|
| 384 |
+
ModelProvider(
|
| 385 |
+
name="happyhorse-1.1-replicate",
|
| 386 |
+
api_url="alibaba/happyhorse-1.1",
|
| 387 |
+
api_token_env="REPLICATE_API_TOKEN",
|
| 388 |
+
params={
|
| 389 |
+
"prompt": "Animate this image with natural, smooth motion.",
|
| 390 |
+
"resolution": "1080p",
|
| 391 |
+
"duration": 5,
|
| 392 |
+
# No aspect_ratio param: per the model's schema, aspect_ratio only
|
| 393 |
+
# applies to text-to-video/reference-to-video - for image-to-video
|
| 394 |
+
# (single image, our case) the image's own aspect ratio is used.
|
| 395 |
+
},
|
| 396 |
+
call=replicate_call,
|
| 397 |
+
),
|
| 398 |
]
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
gradio==6.19.0
|
| 2 |
requests
|
| 3 |
python-dotenv
|
|
|
|
|
|
| 1 |
gradio==6.19.0
|
| 2 |
requests
|
| 3 |
python-dotenv
|
| 4 |
+
Pillow
|
runs.py
CHANGED
|
@@ -113,7 +113,10 @@ def load_runs() -> list[Run]:
|
|
| 113 |
try:
|
| 114 |
with open(run_path, "r") as run_file:
|
| 115 |
entry = json.load(run_file)
|
| 116 |
-
except
|
|
|
|
|
|
|
|
|
|
| 117 |
logger.warning("Skipping run %s: could not read %s (%s)", run_id, run_path, exc)
|
| 118 |
continue
|
| 119 |
|
|
|
|
| 113 |
try:
|
| 114 |
with open(run_path, "r") as run_file:
|
| 115 |
entry = json.load(run_file)
|
| 116 |
+
except FileNotFoundError:
|
| 117 |
+
# Legacy/partial run dirs can exist without metadata; skip quietly.
|
| 118 |
+
continue
|
| 119 |
+
except (NotADirectoryError, json.JSONDecodeError, OSError) as exc:
|
| 120 |
logger.warning("Skipping run %s: could not read %s (%s)", run_id, run_path, exc)
|
| 121 |
continue
|
| 122 |
|