brandly-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brandly_cli/__about__.py +3 -0
- brandly_cli/__init__.py +5 -0
- brandly_cli/__main__.py +6 -0
- brandly_cli/agnes_client.py +529 -0
- brandly_cli/analyzer.py +229 -0
- brandly_cli/ark_client.py +484 -0
- brandly_cli/audio_client.py +191 -0
- brandly_cli/autodirector.py +115 -0
- brandly_cli/beat_sync.py +241 -0
- brandly_cli/captions.py +196 -0
- brandly_cli/cli.py +2856 -0
- brandly_cli/constants.py +609 -0
- brandly_cli/cost_tracker.py +163 -0
- brandly_cli/director.py +554 -0
- brandly_cli/dubbing.py +276 -0
- brandly_cli/edit.py +405 -0
- brandly_cli/export_platforms.py +237 -0
- brandly_cli/memory.py +70 -0
- brandly_cli/minimax_client.py +369 -0
- brandly_cli/project_manager.py +144 -0
- brandly_cli/sharing.py +117 -0
- brandly_cli/stitch.py +240 -0
- brandly_cli/style_presets.py +33 -0
- brandly_cli/sync.py +286 -0
- brandly_cli/templates.py +127 -0
- brandly_cli/thumbnails.py +265 -0
- brandly_cli/trends.py +182 -0
- brandly_cli/types.py +201 -0
- brandly_cli/utils.py +530 -0
- brandly_cli/video_prompts.py +473 -0
- brandly_cli/webhook.py +85 -0
- brandly_cli-0.3.0.dist-info/METADATA +347 -0
- brandly_cli-0.3.0.dist-info/RECORD +35 -0
- brandly_cli-0.3.0.dist-info/WHEEL +4 -0
- brandly_cli-0.3.0.dist-info/entry_points.txt +2 -0
brandly_cli/__about__.py
ADDED
brandly_cli/__init__.py
ADDED
brandly_cli/__main__.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Async HTTP client for the Agnes AI API (image + video generation).
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- Exponential backoff retry for 429 (rate limit) and 503 (server error)
|
|
5
|
+
- Clear error messages for different failure modes
|
|
6
|
+
- Graceful fallback suggestions
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from brandly_cli.utils import now_iso
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
AGNES_BASE_URL = os.getenv(
|
|
23
|
+
"AGNES_BASE_URL",
|
|
24
|
+
"https://apihub.agnes-ai.com/v1",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_api_key() -> str:
|
|
29
|
+
key = os.getenv("AGNES_API_KEY")
|
|
30
|
+
if not key:
|
|
31
|
+
raise OSError(
|
|
32
|
+
"AGNES_API_KEY environment variable is not set. "
|
|
33
|
+
"Get your API key from https://apihub.agnes-ai.com and set it:\n"
|
|
34
|
+
" export AGNES_API_KEY=your_key"
|
|
35
|
+
)
|
|
36
|
+
return key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _headers() -> dict[str, str]:
|
|
40
|
+
return {
|
|
41
|
+
"Authorization": f"Bearer {_get_api_key()}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _retry_with_backoff(
|
|
47
|
+
request_func,
|
|
48
|
+
*,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
base_delay: float = 1.0,
|
|
51
|
+
max_delay: float = 60.0,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""Retry an async request with exponential backoff for 429/503 errors.
|
|
54
|
+
|
|
55
|
+
Non-retryable conditions (4xx with semantic error codes like
|
|
56
|
+
``model_not_found``) are raised immediately so the user gets a clear
|
|
57
|
+
error rather than burning 4 attempts on something that will never work.
|
|
58
|
+
|
|
59
|
+
Returns the response on success or raises the last error.
|
|
60
|
+
"""
|
|
61
|
+
last_error: Exception | None = None
|
|
62
|
+
|
|
63
|
+
for attempt in range(max_retries + 1):
|
|
64
|
+
try:
|
|
65
|
+
return await request_func()
|
|
66
|
+
except httpx.HTTPStatusError as e:
|
|
67
|
+
last_error = e
|
|
68
|
+
status = e.response.status_code
|
|
69
|
+
body_text = e.response.text or ""
|
|
70
|
+
|
|
71
|
+
# Agnes quirk: model_not_found / invalid_request / etc. are
|
|
72
|
+
# returned as 503 with a JSON body containing the real reason.
|
|
73
|
+
# Detect these and don't waste retries on them.
|
|
74
|
+
is_permanent = False
|
|
75
|
+
permanent_hint = ""
|
|
76
|
+
if status in (400, 403, 404, 422):
|
|
77
|
+
is_permanent = True
|
|
78
|
+
permanent_hint = f"HTTP {status}"
|
|
79
|
+
elif status == 503:
|
|
80
|
+
# Try to detect "not actually a transient outage" bodies
|
|
81
|
+
try:
|
|
82
|
+
import json as _json
|
|
83
|
+
|
|
84
|
+
payload = _json.loads(body_text)
|
|
85
|
+
err = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
86
|
+
code = (err.get("code") or "").lower() if isinstance(err, dict) else ""
|
|
87
|
+
msg = err.get("message") or "" if isinstance(err, dict) else ""
|
|
88
|
+
if code in (
|
|
89
|
+
"model_not_found",
|
|
90
|
+
"invalid_request",
|
|
91
|
+
"authentication_error",
|
|
92
|
+
"permission_error",
|
|
93
|
+
"billing_error",
|
|
94
|
+
):
|
|
95
|
+
is_permanent = True
|
|
96
|
+
permanent_hint = f"Agnes error code={code!r}: {msg[:200]}"
|
|
97
|
+
except (ValueError, AttributeError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
if is_permanent:
|
|
101
|
+
# Real semantic error, not a transient outage
|
|
102
|
+
console.print(
|
|
103
|
+
f"[red]✗ Permanent error ({permanent_hint}) — not retrying.[/red]"
|
|
104
|
+
)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
if status == 429: # Rate limit
|
|
108
|
+
wait_time = min(base_delay * (2**attempt), max_delay)
|
|
109
|
+
# Check for Retry-After header
|
|
110
|
+
retry_after = e.response.headers.get("retry-after")
|
|
111
|
+
if retry_after:
|
|
112
|
+
try:
|
|
113
|
+
wait_time = float(retry_after)
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
console.print(
|
|
118
|
+
f"[yellow]⚠ Rate limited (429). "
|
|
119
|
+
f"Waiting {wait_time:.1f}s before retry "
|
|
120
|
+
f"{attempt + 1}/{max_retries}...[/yellow]"
|
|
121
|
+
)
|
|
122
|
+
await asyncio.sleep(wait_time)
|
|
123
|
+
|
|
124
|
+
elif status == 503: # Service unavailable (genuine)
|
|
125
|
+
wait_time = min(base_delay * (2**attempt), max_delay)
|
|
126
|
+
console.print(
|
|
127
|
+
f"[yellow]⚠ Service unavailable (503). "
|
|
128
|
+
f"Waiting {wait_time:.1f}s before retry "
|
|
129
|
+
f"{attempt + 1}/{max_retries}...[/yellow]"
|
|
130
|
+
)
|
|
131
|
+
await asyncio.sleep(wait_time)
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
# Other 5xx, or other status — raise immediately
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
except httpx.TimeoutException as e:
|
|
138
|
+
last_error = e
|
|
139
|
+
wait_time = min(base_delay * (2**attempt), max_delay)
|
|
140
|
+
console.print(
|
|
141
|
+
f"[yellow]⚠ Request timeout. "
|
|
142
|
+
f"Waiting {wait_time:.1f}s before retry "
|
|
143
|
+
f"{attempt + 1}/{max_retries}...[/yellow]"
|
|
144
|
+
)
|
|
145
|
+
await asyncio.sleep(wait_time)
|
|
146
|
+
except httpx.NetworkError as e:
|
|
147
|
+
last_error = e
|
|
148
|
+
wait_time = min(base_delay * (2**attempt), max_delay)
|
|
149
|
+
console.print(
|
|
150
|
+
f"[yellow]⚠ Network error: {e}. "
|
|
151
|
+
f"Waiting {wait_time:.1f}s before retry "
|
|
152
|
+
f"{attempt + 1}/{max_retries}...[/yellow]"
|
|
153
|
+
)
|
|
154
|
+
await asyncio.sleep(wait_time)
|
|
155
|
+
|
|
156
|
+
# All retries exhausted
|
|
157
|
+
if last_error:
|
|
158
|
+
raise last_error
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Image generation
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def generate_image(
|
|
167
|
+
prompt: str,
|
|
168
|
+
*,
|
|
169
|
+
model: str = "agnes-image-2.1-flash",
|
|
170
|
+
size: str = "2K",
|
|
171
|
+
ratio: str = "16:9",
|
|
172
|
+
images: list[str] | None = None,
|
|
173
|
+
style_preset: str | None = None,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Generate an image via Agnes AI and return {url, revised_prompt}.
|
|
176
|
+
|
|
177
|
+
Note: The Agnes API does not support negative_prompt — accuracy is
|
|
178
|
+
achieved through prompt engineering via style presets.
|
|
179
|
+
"""
|
|
180
|
+
from brandly_cli.style_presets import apply_style_preset
|
|
181
|
+
|
|
182
|
+
enhanced = apply_style_preset(prompt, style_preset) if style_preset else prompt
|
|
183
|
+
|
|
184
|
+
body: dict[str, Any] = {
|
|
185
|
+
"model": model,
|
|
186
|
+
"prompt": enhanced,
|
|
187
|
+
"size": size,
|
|
188
|
+
}
|
|
189
|
+
if ratio:
|
|
190
|
+
body["ratio"] = ratio
|
|
191
|
+
if images:
|
|
192
|
+
body["extra_body"] = {"image": images, "response_format": "url"}
|
|
193
|
+
|
|
194
|
+
async def _request() -> Any:
|
|
195
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
196
|
+
resp = await client.post(
|
|
197
|
+
f"{AGNES_BASE_URL}/images/generations",
|
|
198
|
+
headers=_headers(),
|
|
199
|
+
json=body,
|
|
200
|
+
)
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
return resp.json()
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
data = await _retry_with_backoff(_request)
|
|
206
|
+
except httpx.HTTPStatusError as e:
|
|
207
|
+
if e.response.status_code == 429:
|
|
208
|
+
console.print(
|
|
209
|
+
"[red]Error: Rate limit exceeded. Please wait a moment and try again.[/red]"
|
|
210
|
+
)
|
|
211
|
+
console.print(
|
|
212
|
+
"[dim]Tip: Use v2.0 model for production (2.5-flash is rate-limited)[/dim]"
|
|
213
|
+
)
|
|
214
|
+
elif e.response.status_code == 503:
|
|
215
|
+
console.print(
|
|
216
|
+
"[red]Error: Agnes API is temporarily unavailable. Please try again later.[/red]"
|
|
217
|
+
)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
item = data.get("data", [{}])[0]
|
|
221
|
+
return {
|
|
222
|
+
"url": item.get("url"),
|
|
223
|
+
"b64_json": item.get("b64_json"),
|
|
224
|
+
"revised_prompt": item.get("revised_prompt"),
|
|
225
|
+
"model": model,
|
|
226
|
+
"generated_at": now_iso(),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Video generation
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def create_video_task(
|
|
236
|
+
prompt: str,
|
|
237
|
+
*,
|
|
238
|
+
model: str = "agnes-video-v2.0",
|
|
239
|
+
mode: str = "text",
|
|
240
|
+
duration: int | None = None,
|
|
241
|
+
aspect_ratio: str = "16:9",
|
|
242
|
+
size: str = "720P",
|
|
243
|
+
seed: int | None = None,
|
|
244
|
+
negative_prompt: str | None = None,
|
|
245
|
+
first_frame: str | None = None,
|
|
246
|
+
last_frame: str | None = None,
|
|
247
|
+
reference_images: list[str] | None = None,
|
|
248
|
+
reference_audios: list[str] | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Create a video generation task and return {id, video_id, status, progress}.
|
|
251
|
+
|
|
252
|
+
Supports three modes:
|
|
253
|
+
- text: plain text-to-video generation (maps to API mode 'ti2vid')
|
|
254
|
+
- keyframe: transition between first_frame and last_frame images (maps to 'keyframes')
|
|
255
|
+
- reference: use reference_images to maintain character/object consistency
|
|
256
|
+
(maps to 'multi_reference')
|
|
257
|
+
"""
|
|
258
|
+
from brandly_cli.style_presets import apply_style_preset
|
|
259
|
+
|
|
260
|
+
enhanced = apply_style_preset(prompt, "cinematic")
|
|
261
|
+
is_v25_flash = "flash" in model or "2.5-flash" in model
|
|
262
|
+
|
|
263
|
+
# Map CLI modes to API modes
|
|
264
|
+
mode_map = {"text": "ti2vid", "keyframe": "keyframes", "reference": "multi_reference"}
|
|
265
|
+
api_mode = mode_map.get(mode, "ti2vid")
|
|
266
|
+
|
|
267
|
+
body: dict[str, Any] = {
|
|
268
|
+
"model": model,
|
|
269
|
+
"prompt": enhanced,
|
|
270
|
+
"mode": api_mode,
|
|
271
|
+
}
|
|
272
|
+
if seed is not None:
|
|
273
|
+
body["seed"] = seed
|
|
274
|
+
|
|
275
|
+
# Build style suffix to enforce consistency
|
|
276
|
+
consistency_hint = (
|
|
277
|
+
"\n\nCharacter consistency notes: Maintain identical appearance, clothing, "
|
|
278
|
+
"and physical features across all shots. No identity drift. Same object "
|
|
279
|
+
"properties (color, texture, size) in every frame."
|
|
280
|
+
)
|
|
281
|
+
body["prompt"] = enhanced + consistency_hint
|
|
282
|
+
|
|
283
|
+
if is_v25_flash:
|
|
284
|
+
# Video 2.5 Flash: duration 4-12s, size fixed at 720P
|
|
285
|
+
body["seconds"] = str(max(4, min(12, duration or 5)))
|
|
286
|
+
body["size"] = "720P"
|
|
287
|
+
body["aspect_ratio"] = aspect_ratio
|
|
288
|
+
if mode == "keyframe":
|
|
289
|
+
if first_frame:
|
|
290
|
+
body["first_frame"] = first_frame
|
|
291
|
+
if last_frame:
|
|
292
|
+
body["last_frame"] = last_frame
|
|
293
|
+
elif mode == "reference":
|
|
294
|
+
if reference_images:
|
|
295
|
+
body["images"] = reference_images
|
|
296
|
+
if reference_audios:
|
|
297
|
+
body["audios"] = reference_audios
|
|
298
|
+
# Add reference hint for consistency
|
|
299
|
+
body["prompt"] += (
|
|
300
|
+
"\n\nReference image anchored: Preserve exact appearance, lighting, "
|
|
301
|
+
"and composition from the provided reference image(s)."
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
# v2.0 style (legacy)
|
|
305
|
+
frame_count = min(441, (duration or 5) * 24) + 1 if duration else 121
|
|
306
|
+
body["num_frames"] = frame_count
|
|
307
|
+
body["frame_rate"] = 24
|
|
308
|
+
if aspect_ratio:
|
|
309
|
+
body["ratio"] = aspect_ratio
|
|
310
|
+
if mode == "keyframe" and reference_images:
|
|
311
|
+
body["extra_body"] = {"image": reference_images, "mode": "keyframes"}
|
|
312
|
+
elif mode == "reference" and reference_images:
|
|
313
|
+
body["extra_body"] = {"image": reference_images, "mode": "multi_reference"}
|
|
314
|
+
elif reference_images:
|
|
315
|
+
body["image"] = reference_images[0]
|
|
316
|
+
body["prompt"] += (
|
|
317
|
+
"\n\nReference image anchored: Preserve exact appearance, lighting, "
|
|
318
|
+
"and composition from the provided reference image."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def _request() -> Any:
|
|
322
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
323
|
+
resp = await client.post(
|
|
324
|
+
f"{AGNES_BASE_URL}/videos",
|
|
325
|
+
headers=_headers(),
|
|
326
|
+
json=body,
|
|
327
|
+
)
|
|
328
|
+
resp.raise_for_status()
|
|
329
|
+
return resp.json()
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
data = await _retry_with_backoff(_request)
|
|
333
|
+
except httpx.HTTPStatusError as e:
|
|
334
|
+
if e.response.status_code == 429:
|
|
335
|
+
console.print("[red]Error: Rate limit exceeded. Using v2.0 model recommended.[/red]")
|
|
336
|
+
console.print("[dim]Tip: Switch to agnes-video-v2.0 for production use[/dim]")
|
|
337
|
+
elif e.response.status_code == 503:
|
|
338
|
+
console.print(
|
|
339
|
+
"[red]Error: Agnes API is temporarily unavailable. Please try again later.[/red]"
|
|
340
|
+
)
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"id": data.get("id"),
|
|
345
|
+
"video_id": data.get("video_id") or data.get("task_id") or data.get("id"),
|
|
346
|
+
"status": data.get("status", "pending"),
|
|
347
|
+
"progress": data.get("progress", 0),
|
|
348
|
+
"created_at": data.get("created_at"),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def get_video_status(video_id: str) -> dict[str, Any]:
|
|
353
|
+
"""Poll video generation status with retry for rate limits."""
|
|
354
|
+
|
|
355
|
+
async def _request() -> Any:
|
|
356
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
357
|
+
resp = await client.get(
|
|
358
|
+
f"{AGNES_BASE_URL}/agnesapi",
|
|
359
|
+
headers=_headers(),
|
|
360
|
+
params={"video_id": video_id},
|
|
361
|
+
)
|
|
362
|
+
resp.raise_for_status()
|
|
363
|
+
return resp.json()
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
data = await _retry_with_backoff(_request, max_retries=2, base_delay=2.0, max_delay=10.0)
|
|
367
|
+
except httpx.HTTPStatusError as e:
|
|
368
|
+
if e.response.status_code == 429:
|
|
369
|
+
console.print("[yellow]⚠ Rate limited while polling. Will retry shortly...[/yellow]")
|
|
370
|
+
elif e.response.status_code == 503:
|
|
371
|
+
console.print("[yellow]⚠ Service unavailable. Will retry shortly...[/yellow]")
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
error_obj = data.get("error")
|
|
375
|
+
error_msg = (
|
|
376
|
+
error_obj
|
|
377
|
+
if isinstance(error_obj, str)
|
|
378
|
+
else (error_obj or {}).get("message")
|
|
379
|
+
if isinstance(error_obj, dict)
|
|
380
|
+
else None
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
"id": data.get("id"),
|
|
385
|
+
"video_id": data.get("video_id") or data.get("task_id") or data.get("id"),
|
|
386
|
+
"status": data.get("status"),
|
|
387
|
+
"progress": data.get("progress", 0),
|
|
388
|
+
"url": data.get("url") or (data.get("metadata") or {}).get("url"),
|
|
389
|
+
"error": error_msg,
|
|
390
|
+
"created_at": data.get("created_at"),
|
|
391
|
+
"completed_at": data.get("completed_at"),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def poll_video(
|
|
396
|
+
video_id: str,
|
|
397
|
+
*,
|
|
398
|
+
max_wait_seconds: int = 300,
|
|
399
|
+
interval_seconds: int = 5,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""Poll until video generation completes or times out.
|
|
402
|
+
|
|
403
|
+
Handles rate limits gracefully by waiting and retrying.
|
|
404
|
+
"""
|
|
405
|
+
import asyncio
|
|
406
|
+
|
|
407
|
+
console.print(
|
|
408
|
+
f"[dim]Polling video status every {interval_seconds}s (max {max_wait_seconds}s)...[/dim]"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
deadline = asyncio.get_event_loop().time() + max_wait_seconds
|
|
412
|
+
attempts = 0
|
|
413
|
+
|
|
414
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
415
|
+
attempts += 1
|
|
416
|
+
try:
|
|
417
|
+
result = await get_video_status(video_id)
|
|
418
|
+
|
|
419
|
+
if result["status"] == "completed":
|
|
420
|
+
console.print(f"[green]✓ Video generated after {attempts} polls[/green]")
|
|
421
|
+
return result
|
|
422
|
+
if result["status"] == "failed":
|
|
423
|
+
raise RuntimeError(
|
|
424
|
+
f"Agnes video generation failed: {result.get('error') or 'unknown error'}"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Show progress
|
|
428
|
+
progress = result.get("progress", 0)
|
|
429
|
+
status = result.get("status", "processing")
|
|
430
|
+
console.print(f" Progress: {progress}% | Status: {status}")
|
|
431
|
+
|
|
432
|
+
except httpx.HTTPStatusError as e:
|
|
433
|
+
if e.response.status_code in (429, 503):
|
|
434
|
+
# Rate limit or service error - wait and continue
|
|
435
|
+
wait_time = min(10 * (2 ** (attempts % 3)), 60)
|
|
436
|
+
console.print(f"[yellow]⚠ API issue, waiting {wait_time}s...[/yellow]")
|
|
437
|
+
await asyncio.sleep(wait_time)
|
|
438
|
+
continue
|
|
439
|
+
raise
|
|
440
|
+
except RuntimeError:
|
|
441
|
+
# Re-raise generated-failure RuntimeError from above; don't swallow it
|
|
442
|
+
raise
|
|
443
|
+
except Exception as e:
|
|
444
|
+
# Other errors - wait and retry
|
|
445
|
+
console.print(f"[yellow]⚠ Poll error: {e}, retrying...[/yellow]")
|
|
446
|
+
await asyncio.sleep(interval_seconds)
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# Normal progression - wait for next poll
|
|
450
|
+
await asyncio.sleep(interval_seconds)
|
|
451
|
+
|
|
452
|
+
raise TimeoutError(
|
|
453
|
+
f"Agnes video generation timed out after {max_wait_seconds}s. "
|
|
454
|
+
f"Task ID: {video_id}. Check status manually with: brandly status {video_id}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# Job management
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
async def list_jobs(
|
|
463
|
+
*,
|
|
464
|
+
status: str | None = None,
|
|
465
|
+
limit: int = 20,
|
|
466
|
+
) -> list[dict[str, Any]]:
|
|
467
|
+
"""List recent video generation jobs from the Agnes API."""
|
|
468
|
+
async def _request() -> Any:
|
|
469
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
470
|
+
params: dict[str, Any] = {"limit": limit}
|
|
471
|
+
if status:
|
|
472
|
+
params["status"] = status
|
|
473
|
+
resp = await client.get(
|
|
474
|
+
f"{AGNES_BASE_URL}/videos",
|
|
475
|
+
headers=_headers(),
|
|
476
|
+
params=params,
|
|
477
|
+
)
|
|
478
|
+
resp.raise_for_status()
|
|
479
|
+
return resp.json()
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
data = await _request()
|
|
483
|
+
jobs = data.get("data", data if isinstance(data, list) else [])
|
|
484
|
+
if isinstance(jobs, dict):
|
|
485
|
+
jobs = jobs.get("jobs", [])
|
|
486
|
+
return [
|
|
487
|
+
{
|
|
488
|
+
"id": j.get("id", ""),
|
|
489
|
+
"video_id": j.get("video_id") or j.get("task_id") or j.get("id", ""),
|
|
490
|
+
"status": j.get("status", "unknown"),
|
|
491
|
+
"progress": j.get("progress", 0),
|
|
492
|
+
"model": j.get("model", "unknown"),
|
|
493
|
+
"prompt": (j.get("prompt") or "")[:100],
|
|
494
|
+
"created_at": j.get("created_at", ""),
|
|
495
|
+
"completed_at": j.get("completed_at", ""),
|
|
496
|
+
}
|
|
497
|
+
for j in jobs
|
|
498
|
+
]
|
|
499
|
+
except Exception as e:
|
|
500
|
+
console.print(f"[yellow]⚠ Could not fetch jobs: {e}[/yellow]")
|
|
501
|
+
return []
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def cancel_job(video_id: str) -> dict[str, Any]:
|
|
505
|
+
"""Cancel a pending/in-progress video generation job."""
|
|
506
|
+
async def _request() -> Any:
|
|
507
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
508
|
+
resp = await client.delete(
|
|
509
|
+
f"{AGNES_BASE_URL}/videos/{video_id}",
|
|
510
|
+
headers=_headers(),
|
|
511
|
+
)
|
|
512
|
+
resp.raise_for_status()
|
|
513
|
+
return resp.json()
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
data = await _request()
|
|
517
|
+
console.print(f"[green]✓ Job {video_id} cancelled.[/green]")
|
|
518
|
+
return {"video_id": video_id, "status": "cancelled", "result": data}
|
|
519
|
+
except httpx.HTTPStatusError as e:
|
|
520
|
+
if e.response.status_code == 404:
|
|
521
|
+
console.print(f"[red]Job {video_id} not found.[/red]")
|
|
522
|
+
elif e.response.status_code == 409:
|
|
523
|
+
console.print(f"[yellow]⚠ Job {video_id} already completed or cancelled.[/yellow]")
|
|
524
|
+
else:
|
|
525
|
+
console.print(f"[red]Error cancelling job: {e}[/red]")
|
|
526
|
+
return {"video_id": video_id, "status": "error", "error": str(e)}
|
|
527
|
+
except Exception as e:
|
|
528
|
+
console.print(f"[red]Error cancelling job: {e}[/red]")
|
|
529
|
+
return {"video_id": video_id, "status": "error", "error": str(e)}
|