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.
@@ -0,0 +1,3 @@
1
+ """Package metadata."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Brandly CLI — AI product video orchestrator for any AI tool."""
2
+
3
+ from brandly_cli.__about__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m brandly_cli`."""
2
+
3
+ from brandly_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)}