TrueAl commited on
Commit
8b7c167
·
1 Parent(s): 20a21fe

added byok and replicate

Browse files
Files changed (6) hide show
  1. .env.example +7 -6
  2. DEVELOPER.md +23 -20
  3. app.py +126 -16
  4. providers.py +193 -10
  5. requirements.txt +1 -0
  6. 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
- # On Hugging Face Spaces, set these as Space secrets instead - they are
3
- # injected directly into the environment and .env is not used there.
4
-
5
- # fal.ai API key, used by both the ltx-2-fal and wan2.1-i2v-720p-fal
6
- # providers configured in providers.py. Get one at https://fal.ai/dashboard/keys
7
- FAL_KEY=your_fal_api_key_here
 
 
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
- ## Environment variables / API tokens
8
-
9
- - **On the Space**: set provider tokens (e.g. `FAL_KEY`) as [Space secrets](https://huggingface.co/docs/hub/spaces-overview#managing-secrets).
10
- They are injected directly into the process environment - no extra setup
11
- needed.
12
- - **Locally**: copy [.env.example](.env.example) to `.env` and fill in real
13
- values. `app.py` calls `load_dotenv()` on startup, which loads `.env` into
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` (the env var name to read at call time), so different
20
- providers can use different tokens. The two configured fal.ai providers
21
- both read `FAL_KEY` (fal's own standard key name), since one fal.ai key
22
- covers all fal-hosted models.
 
 
 
 
 
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, or write a new
51
- `call` function matching the signature `(provider, image_path) -> bytes` for
52
- providers with a different API contract (see `generic_sync_call` and
53
- `polling_call` for templates of direct-response vs. job-polling contracts).
54
- No subclassing is needed - the `call` field is what makes this duck-typed.
 
 
 
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 tokens are read from the process environment. On a Hugging
8
- Face Space, secrets configured in the Space settings are injected directly
9
- into the environment. For local development, load_dotenv() below reads a
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("Run %s: created (providers=%d, prompt=%r)", run_id, len(PROVIDERS), bool(prompt))
 
 
 
 
 
 
115
  provider_index = {provider.name: i for i, provider in enumerate(PROVIDERS)}
116
  results_by_name: dict[str, RunResult] = {}
 
117
 
118
- metadata_values = [
119
- format_metadata(_effective_provider(provider, prompt), "running") for provider in PROVIDERS
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(PROVIDERS, image_path, prompt):
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
- with gr.Column(scale=1):
185
- metadata_components.append(gr.Markdown(format_metadata(provider, "idle")))
186
- with gr.Column(scale=2):
187
- video_components.append(gr.Video(label=provider.name))
 
 
 
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
- """Reads the provider's API token from the Space environment at call time."""
37
- return os.environ.get(self.api_token_env, "")
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
- with open(image_path, "rb") as image_file:
121
- encoded = base64.b64encode(image_file.read()).decode("ascii")
 
 
 
 
 
 
 
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.raise_for_status()
 
 
 
 
 
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
- # Registry of configured providers. Both use fal.ai's queue API (submit ->
186
- # poll -> fetch result) via the shared `fal_queue_call`, authenticated with
187
- # the same FAL_KEY (fal's standard API key env var). Set FAL_KEY as a secret
188
- # on the Hugging Face Space, or in a local .env file for development.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (FileNotFoundError, NotADirectoryError, json.JSONDecodeError, OSError) as exc:
 
 
 
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