VibesAI-api 1.3.2__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.
vibes_api/client.py ADDED
@@ -0,0 +1,3972 @@
1
+ """
2
+ VibesClient - main API client implementation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import json
9
+ import os
10
+ import secrets
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, Iterator, List, Optional, Union
15
+
16
+ import requests
17
+
18
+ from .models import (
19
+ AspectRatio,
20
+ EntityType,
21
+ GenerationType,
22
+ ImageModel,
23
+ IngredientType,
24
+ OwnerFilter,
25
+ PromptModel,
26
+ Resolution,
27
+ SyncMode,
28
+ TextOverlayPosition,
29
+ TextOverlayPreset,
30
+ VideoModel,
31
+ )
32
+ from .ingredients import build_ingredient_payload
33
+ from .composition import Composition
34
+
35
+ BASE_URL = "https://vibes.ai"
36
+ DEFAULT_TIMEOUT = 60
37
+ POLL_INTERVAL = 3.0
38
+ POLL_TIMEOUT = 180.0
39
+
40
+
41
+ class VibesAPIError(Exception):
42
+ """Raised when the vibes.ai API returns an error."""
43
+
44
+ def __init__(self, message: str, *, status: Optional[int] = None,
45
+ code: Optional[str] = None, response: Optional[dict] = None):
46
+ super().__init__(message)
47
+ self.status = status
48
+ self.code = code
49
+ self.response = response
50
+
51
+ def __str__(self) -> str:
52
+ bits = [super().__str__()]
53
+ if self.code:
54
+ bits.append(f"code={self.code}")
55
+ if self.status:
56
+ bits.append(f"status={self.status}")
57
+ return " | ".join(bits)
58
+
59
+
60
+ def _uuid_v7() -> str:
61
+ """Generate a UUID v7 (timestamp-ordered) for batch IDs.
62
+
63
+ The vibes.ai server expects batch IDs to follow the UUID v7 format
64
+ because it extracts the creation timestamp from the high 48 bits.
65
+
66
+ Format (RFC 9562):
67
+ bits 0-47: unix_ts_ms (48 bits, big-endian)
68
+ bits 48-51: version = 0x7
69
+ bits 52-63: rand_a (12 bits)
70
+ bits 64-65: variant = 0b10
71
+ bits 66-127: rand_b (62 bits)
72
+
73
+ Example
74
+ -------
75
+ >>> _uuid_v7() # doctest: +SKIP
76
+ '019f66c6-9721-71a9-870a-76c5a8283505'
77
+ """
78
+ ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF # 48 bits
79
+ rand_a = secrets.randbits(12)
80
+ rand_b = secrets.randbits(62)
81
+
82
+ bytes_ = (
83
+ ms.to_bytes(6, "big")
84
+ + ((0x7 << 12) | rand_a).to_bytes(2, "big") # version 7 + rand_a
85
+ + ((0b10 << 6) | ((rand_b >> 56) & 0x3F)).to_bytes(1, "big") # variant + top 6 bits of rand_b
86
+ + (rand_b & 0xFFFFFFFFFFFFFF).to_bytes(7, "big") # remaining 56 bits of rand_b
87
+ )
88
+ return str(uuid.UUID(bytes=bytes_))
89
+
90
+
91
+ def _ms_now() -> int:
92
+ return int(time.time() * 1000)
93
+
94
+
95
+ def _coerce(v) -> str:
96
+ """Coerce an Enum or string to its string value.
97
+
98
+ Python 3.11+ changed ``str(Enum)`` to return ``"Enum.NAME"`` instead of
99
+ just ``"VALUE"``. This helper centralizes the coercion so callers can
100
+ pass either form.
101
+ """
102
+ if hasattr(v, "value"):
103
+ return v.value
104
+ return str(v)
105
+
106
+
107
+ class VibesClient:
108
+ """Unofficial Python client for the vibes.ai API.
109
+
110
+ Parameters
111
+ ----------
112
+ meta_session : str
113
+ The value of the ``meta_session`` cookie from your browser.
114
+ cookie_ack : bool, default True
115
+ base_url : str, default "https://vibes.ai"
116
+ timeout : int, default 60
117
+ session : requests.Session, optional
118
+ auto_refresh : bool, default True
119
+ Auto-refresh cookie by intercepting Set-Cookie headers.
120
+ background_refresh : bool, default False
121
+ Run a background thread that calls /api/auth/me every 25 min.
122
+ refresh_interval : float, default 1500.0
123
+ Background refresh interval in seconds.
124
+ on_cookie_refresh : callable, optional
125
+ Callback called with new cookie value on refresh.
126
+ on_session_expired : callable, optional
127
+ Callback called when session expires (401). Use this to
128
+ fetch a fresh cookie and call ``update_cookie()``.
129
+ auto_relogin : bool, default False
130
+ **EXPERIMENTAL**: Attempt automatic re-login via the OIDC flow
131
+ when the session expires. Requires ``agent-browser`` CLI to be
132
+ installed. Only works if the browser has an active Meta session.
133
+ relogin_browser_url : str, optional
134
+ URL of the headless browser to use for auto re-login.
135
+ Defaults to "http://localhost:9222" (Chrome DevTools Protocol).
136
+
137
+ How vibes.ai auth works
138
+ -----------------------
139
+ 1. **Login**: User clicks "Log In" → browser redirects to
140
+ ``/api/meta-oidc/start`` → redirects to Meta OIDC → user logs
141
+ in → Meta redirects back to ``/api/meta-oidc/callback`` →
142
+ server creates a session and sets ``meta_session`` cookie.
143
+
144
+ 2. **Session validation**: The web app calls ``/api/auth/check-token``
145
+ every **5 seconds** and ``/api/auth/me`` every **30 seconds**
146
+ to check if the session is still valid.
147
+
148
+ 3. **Session expiry**: When the session expires (~30 min of no
149
+ activity), API calls return 401. The web app auto-redirects
150
+ to ``/api/meta-oidc/start`` which re-authenticates via Meta
151
+ (silently if the user has an active Meta session).
152
+
153
+ 4. **No sliding session**: Unlike what I initially thought, vibes.ai
154
+ does NOT send ``Set-Cookie`` on regular API responses. The
155
+ session has a fixed expiry. The only way to extend it is to
156
+ make API calls (which the server tracks as "activity") — but
157
+ the cookie itself doesn't change.
158
+
159
+ This client implements
160
+ ----------------------
161
+ - ``background_refresh``: calls ``/api/auth/me`` every 25 min to
162
+ keep the session active (matches the web app's behavior).
163
+ - ``on_session_expired`` callback: notifies you when 401 occurs
164
+ so you can provide a fresh cookie.
165
+ - ``auto_relogin``: uses a headless browser to attempt the OIDC
166
+ flow automatically (requires ``agent-browser``).
167
+ - ``update_cookie()``: update the cookie at runtime without
168
+ recreating the client.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ meta_session: str,
174
+ cookie_ack: bool = True,
175
+ base_url: str = BASE_URL,
176
+ timeout: int = DEFAULT_TIMEOUT,
177
+ session: Optional[requests.Session] = None,
178
+ *,
179
+ auto_refresh: bool = True,
180
+ background_refresh: bool = False,
181
+ refresh_interval: float = 1500.0,
182
+ on_cookie_refresh: Optional[callable] = None,
183
+ on_session_expired: Optional[callable] = None,
184
+ auto_relogin: bool = False,
185
+ ):
186
+ self.base_url = base_url.rstrip("/")
187
+ self.timeout = timeout
188
+ self.session = session or requests.Session()
189
+ self.auto_refresh = auto_refresh
190
+ self.refresh_interval = refresh_interval
191
+ self.on_cookie_refresh = on_cookie_refresh
192
+ self.on_session_expired = on_session_expired
193
+ self.auto_relogin = auto_relogin
194
+ self._background_thread = None
195
+ self._background_stop = None
196
+ self._session_expired = False
197
+ self._set_cookie(meta_session, cookie_ack)
198
+ if background_refresh:
199
+ self.start_background_refresh()
200
+
201
+ def _set_cookie(self, meta_session: str, cookie_ack: bool = True) -> None:
202
+ cookie_str = f"meta_session={meta_session}"
203
+ if cookie_ack:
204
+ cookie_str += ";cookie_ack=true"
205
+ self.session.headers.update({
206
+ "Cookie": cookie_str,
207
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
208
+ "Accept": "application/json",
209
+ "Referer": f"{self.base_url}/",
210
+ "Origin": self.base_url,
211
+ })
212
+ self._current_meta_session = meta_session
213
+ self._session_expired = False
214
+
215
+ def update_cookie(self, meta_session: str) -> None:
216
+ """Update the session cookie at runtime.
217
+
218
+ Use this when the ``on_session_expired`` callback fires and
219
+ you've obtained a fresh cookie (e.g., from the browser).
220
+ """
221
+ self._set_cookie(meta_session)
222
+ if self.on_cookie_refresh:
223
+ try:
224
+ self.on_cookie_refresh(meta_session)
225
+ except Exception:
226
+ pass
227
+
228
+ def get_current_cookie(self) -> str:
229
+ return self._current_meta_session
230
+
231
+ def _maybe_refresh_cookie(self, resp: requests.Response) -> None:
232
+ """Intercept Set-Cookie headers and update the session cookie.
233
+
234
+ Note: vibes.ai does NOT send Set-Cookie on regular API responses.
235
+ This is kept for completeness — if the server ever adds sliding
236
+ session behavior, this will automatically pick it up.
237
+ """
238
+ if not self.auto_refresh:
239
+ return
240
+ set_cookie = resp.headers.get("Set-Cookie") or resp.headers.get("set-cookie")
241
+ if not set_cookie:
242
+ return
243
+ for part in set_cookie.split(";"):
244
+ part = part.strip()
245
+ if part.startswith("meta_session="):
246
+ new_value = part[len("meta_session="):]
247
+ if new_value and new_value != self._current_meta_session:
248
+ self._set_cookie(new_value)
249
+ if self.on_cookie_refresh:
250
+ try:
251
+ self.on_cookie_refresh(new_value)
252
+ except Exception:
253
+ pass
254
+ break
255
+
256
+ def _handle_401(self) -> bool:
257
+ """Handle a 401 error — attempt auto re-login if enabled.
258
+
259
+ Returns True if the session was successfully restored.
260
+ """
261
+ self._session_expired = True
262
+
263
+ # Call the session expired callback
264
+ if self.on_session_expired:
265
+ try:
266
+ self.on_session_expired(self._current_meta_session)
267
+ except Exception:
268
+ pass
269
+
270
+ # Attempt auto re-login via headless browser
271
+ if self.auto_relogin:
272
+ new_cookie = self._attempt_oidc_relogin()
273
+ if new_cookie:
274
+ self._set_cookie(new_cookie)
275
+ if self.on_cookie_refresh:
276
+ try:
277
+ self.on_cookie_refresh(new_cookie)
278
+ except Exception:
279
+ pass
280
+ return True
281
+
282
+ return False
283
+
284
+ def _attempt_oidc_relogin(self) -> Optional[str]:
285
+ """Attempt to re-login via the OIDC flow using a headless browser.
286
+
287
+ This navigates to ``/api/meta-oidc/start`` which redirects to
288
+ Meta's OIDC login. If the browser has an active Meta session
289
+ (from facebook.com/meta.com cookies), the flow completes
290
+ automatically and a new ``meta_session`` cookie is set.
291
+
292
+ Returns the new cookie value, or None if re-login failed.
293
+ """
294
+ import subprocess, json as _json
295
+
296
+ try:
297
+ # Use agent-browser CLI to navigate to the OIDC start URL
298
+ # The browser must have Meta cookies for this to work
299
+ result = subprocess.run(
300
+ ["agent-browser", "open", f"{self.base_url}/api/meta-oidc/start"],
301
+ capture_output=True, text=True, timeout=30,
302
+ )
303
+ if result.returncode != 0:
304
+ return None
305
+
306
+ # Wait for redirects to complete
307
+ import time as _time
308
+ _time.sleep(3)
309
+
310
+ # Get the current URL (should be back on vibes.ai)
311
+ result = subprocess.run(
312
+ ["agent-browser", "get", "url"],
313
+ capture_output=True, text=True, timeout=10,
314
+ )
315
+ current_url = result.stdout.strip()
316
+
317
+ # Check if we're back on vibes.ai (OIDC callback completed)
318
+ if "vibes.ai" not in current_url:
319
+ return None
320
+
321
+ # Extract the new meta_session cookie
322
+ result = subprocess.run(
323
+ ["agent-browser", "cookies"],
324
+ capture_output=True, text=True, timeout=10,
325
+ )
326
+ cookies_text = result.stdout.strip()
327
+ for line in cookies_text.split("\n"):
328
+ line = line.strip()
329
+ if line.startswith("meta_session="):
330
+ new_cookie = line[len("meta_session="):]
331
+ if new_cookie and new_cookie != "deleted":
332
+ return new_cookie
333
+
334
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
335
+ pass
336
+
337
+ return None
338
+
339
+ def start_background_refresh(self) -> None:
340
+ """Start a daemon thread that periodically validates the session.
341
+
342
+ Calls ``/api/auth/check-token`` every ``refresh_interval`` seconds.
343
+ If the session is expired, triggers the ``on_session_expired`` callback.
344
+ """
345
+ import threading
346
+ if self._background_thread and self._background_thread.is_alive():
347
+ return
348
+ self._background_stop = threading.Event()
349
+
350
+ def _refresh_loop():
351
+ while not self._background_stop.wait(timeout=self.refresh_interval):
352
+ try:
353
+ self._get("/api/auth/check-token")
354
+ except VibesAPIError as e:
355
+ if e.status == 401:
356
+ self._handle_401()
357
+ except Exception:
358
+ pass
359
+
360
+ self._background_thread = threading.Thread(
361
+ target=_refresh_loop, daemon=True, name="vibes-session-keeper"
362
+ )
363
+ self._background_thread.start()
364
+
365
+ def stop_background_refresh(self) -> None:
366
+ if self._background_stop:
367
+ self._background_stop.set()
368
+ if self._background_thread:
369
+ self._background_thread.join(timeout=5)
370
+ self._background_thread = None
371
+
372
+ def _maybe_refresh_cookie(self, resp: requests.Response) -> None:
373
+ if not self.auto_refresh:
374
+ return
375
+ set_cookie = resp.headers.get("Set-Cookie") or resp.headers.get("set-cookie")
376
+ if not set_cookie:
377
+ return
378
+ for part in set_cookie.split(";"):
379
+ part = part.strip()
380
+ if part.startswith("meta_session="):
381
+ new_value = part[len("meta_session="):]
382
+ if new_value and new_value != self._current_meta_session:
383
+ self._set_cookie(new_value)
384
+ if self.on_cookie_refresh:
385
+ try:
386
+ self.on_cookie_refresh(new_value)
387
+ except Exception:
388
+ pass
389
+ break
390
+
391
+ def start_background_refresh(self) -> None:
392
+ import threading
393
+ if self._background_thread and self._background_thread.is_alive():
394
+ return
395
+ self._background_stop = threading.Event()
396
+ def _loop():
397
+ while not self._background_stop.wait(timeout=self.refresh_interval):
398
+ try:
399
+ self._get("/api/auth/me")
400
+ except Exception:
401
+ pass
402
+ self._background_thread = threading.Thread(target=_loop, daemon=True, name="vibes-cookie-refresh")
403
+ self._background_thread.start()
404
+
405
+ def stop_background_refresh(self) -> None:
406
+ if self._background_stop:
407
+ self._background_stop.set()
408
+ if self._background_thread:
409
+ self._background_thread.join(timeout=5)
410
+ self._background_thread = None
411
+
412
+ # ------------------------------------------------------------------ #
413
+ # Low-level helpers
414
+ # ------------------------------------------------------------------ #
415
+ def _url(self, path: str) -> str:
416
+ if path.startswith("http"):
417
+ return path
418
+ if not path.startswith("/"):
419
+ path = "/" + path
420
+ return f"{self.base_url}{path}"
421
+
422
+ def _check(self, resp: requests.Response) -> dict:
423
+ self._maybe_refresh_cookie(resp)
424
+ try:
425
+ data = resp.json()
426
+ except ValueError:
427
+ data = {"raw": resp.text}
428
+ if not resp.ok:
429
+ err = data.get("error") if isinstance(data, dict) else None
430
+ if isinstance(err, dict):
431
+ msg = err.get("detail") or err.get("title") or err.get("message") or "API error"
432
+ code = err.get("code")
433
+ elif isinstance(err, str):
434
+ msg = err
435
+ code = None
436
+ else:
437
+ msg = f"HTTP {resp.status_code}"
438
+ code = None
439
+ raise VibesAPIError(
440
+ f"{msg} | response={str(data)[:500]}",
441
+ status=resp.status_code, code=code, response=data,
442
+ )
443
+ return data
444
+
445
+ def _get(self, path: str, params: Optional[dict] = None, **kw) -> dict:
446
+ resp = self.session.get(self._url(path), params=params, timeout=self.timeout, **kw)
447
+ try:
448
+ return self._check(resp)
449
+ except VibesAPIError as e:
450
+ if e.status == 401 and not self._session_expired:
451
+ if self._handle_401():
452
+ # Session was restored — retry the request
453
+ resp = self.session.get(self._url(path), params=params, timeout=self.timeout, **kw)
454
+ return self._check(resp)
455
+ raise
456
+
457
+ def _post(self, path: str, json_body: Optional[dict] = None, **kw) -> dict:
458
+ resp = self.session.post(self._url(path), json=json_body, timeout=self.timeout, **kw)
459
+ try:
460
+ return self._check(resp)
461
+ except VibesAPIError as e:
462
+ if e.status == 401 and not self._session_expired:
463
+ if self._handle_401():
464
+ resp = self.session.post(self._url(path), json=json_body, timeout=self.timeout, **kw)
465
+ return self._check(resp)
466
+ raise
467
+
468
+ def _put(self, path: str, json_body: Optional[dict] = None, **kw) -> dict:
469
+ resp = self.session.put(self._url(path), json=json_body, timeout=self.timeout, **kw)
470
+ return self._check(resp)
471
+
472
+ def _delete(self, path: str, **kw) -> dict:
473
+ resp = self.session.delete(self._url(path), timeout=self.timeout, **kw)
474
+ return self._check(resp)
475
+
476
+ # ------------------------------------------------------------------ #
477
+ # Auth & system
478
+ # ------------------------------------------------------------------ #
479
+ def get_me(self) -> dict:
480
+ """Return the authenticated user profile."""
481
+ return self._get("/api/auth/me").get("user", {})
482
+
483
+ def get_system_status(self) -> Optional[Any]:
484
+ """Return current system status banner (or None)."""
485
+ return self._get("/api/system-status").get("status")
486
+
487
+ def logout(self) -> None:
488
+ """Invalidate the current session server-side."""
489
+ self._post("/api/auth/logout")
490
+
491
+ # ------------------------------------------------------------------ #
492
+ # Projects
493
+ # ------------------------------------------------------------------ #
494
+ def list_projects(
495
+ self,
496
+ limit: int = 25,
497
+ offset: int = 0,
498
+ sort: str = "newest",
499
+ search: Optional[str] = None,
500
+ max_retries: int = 3,
501
+ ) -> Dict[str, Any]:
502
+ """List projects in your workspace."""
503
+ params = {"limit": limit, "offset": offset, "sort": sort}
504
+ if search:
505
+ params["search"] = search
506
+ last_err = None
507
+ for attempt in range(max_retries + 1):
508
+ try:
509
+ return self._get("/api/projects", params=params)
510
+ except VibesAPIError as e:
511
+ last_err = e
512
+ if e.status != 500:
513
+ raise
514
+ time.sleep(1.0 * (attempt + 1))
515
+ raise last_err
516
+
517
+ def get_project(self, project_id: str) -> dict:
518
+ """Fetch a single project (including composition/timeline)."""
519
+ return self._get(f"/api/projects/{project_id}").get("project", {})
520
+
521
+ def create_project(
522
+ self,
523
+ name: str = "Untitled",
524
+ composition: Optional[dict] = None,
525
+ ) -> dict:
526
+ """Create a new project. Returns the project dict."""
527
+ if composition is None:
528
+ composition = {"id": "studio-composition", "tracks": [], "duration": 5}
529
+ body = {"name": name, "composition": composition}
530
+ return self._post("/api/projects", json_body=body).get("project", {})
531
+
532
+ def update_project(
533
+ self,
534
+ project_id: str,
535
+ name: Optional[str] = None,
536
+ composition: Optional[dict] = None,
537
+ ) -> dict:
538
+ """Update project name and/or composition (timeline state)."""
539
+ body: Dict[str, Any] = {}
540
+ if name is not None:
541
+ body["name"] = name
542
+ if composition is not None:
543
+ body["composition"] = composition
544
+ return self._put(f"/api/projects/{project_id}", json_body=body).get("project", {})
545
+
546
+ def delete_project(self, project_id: str, delete_assets: bool = False) -> None:
547
+ """Delete a project. Set ``delete_assets=True`` to also remove its media."""
548
+ path = f"/api/projects/{project_id}"
549
+ if delete_assets:
550
+ path += "?deleteAssets=true"
551
+ self._delete(path)
552
+
553
+ def duplicate_project(self, project_id: str) -> dict:
554
+ """Duplicate a project (returns the new project)."""
555
+ return self._post(f"/api/projects/{project_id}/duplicate").get("project", {})
556
+
557
+ def save_composition(self, project_id: str, composition: dict) -> dict:
558
+ """Shortcut for ``update_project(composition=composition)``."""
559
+ return self.update_project(project_id, composition=composition)
560
+
561
+ # ------------------------------------------------------------------ #
562
+ # Batches (the core generation primitive)
563
+ # ------------------------------------------------------------------ #
564
+ def list_batches(
565
+ self,
566
+ limit: int = 12,
567
+ offset: int = 0,
568
+ project_id: Optional[str] = None,
569
+ type: Optional[str] = None,
570
+ max_retries: int = 3,
571
+ ) -> dict:
572
+ """List generation batches.
573
+
574
+ Pass ``project_id`` to scope to a project, or omit for all batches
575
+ in the workspace.
576
+
577
+ Notes
578
+ -----
579
+ This endpoint occasionally returns transient 500s. We retry up to
580
+ ``max_retries`` times before propagating the error.
581
+ """
582
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
583
+ if project_id:
584
+ params["projectId"] = project_id
585
+ if type:
586
+ params["type"] = type
587
+ last_err: Optional[Exception] = None
588
+ for attempt in range(max_retries + 1):
589
+ try:
590
+ return self._get("/api/generation-batches", params=params)
591
+ except VibesAPIError as e:
592
+ last_err = e
593
+ if e.status != 500:
594
+ raise
595
+ time.sleep(1.0 * (attempt + 1))
596
+ assert last_err is not None
597
+ raise last_err
598
+
599
+ def list_project_batches(self, project_id: str, limit: int = 6, offset: int = 0) -> dict:
600
+ """List batches inside a specific project."""
601
+ return self._get(f"/api/projects/{project_id}/batches",
602
+ params={"limit": limit, "offset": offset})
603
+
604
+ def get_batch(self, batch_id: str) -> dict:
605
+ """Fetch full batch state including all content items.
606
+
607
+ Returns
608
+ -------
609
+ dict with keys: ``id``, ``type``, ``prompt``, ``config``,
610
+ ``isComplete``, ``hasError``, ``error``, ``content`` (list), ...
611
+ """
612
+ return self._get(f"/api/generation-batches/{batch_id}").get("batch", {})
613
+
614
+ def delete_batch(self, batch_id: str) -> None:
615
+ self._delete(f"/api/generation-batches/{batch_id}")
616
+
617
+ def _create_batch(
618
+ self,
619
+ *,
620
+ batch_type: str, # "videos" or "images"
621
+ prompt: str,
622
+ project_id: str,
623
+ config: dict,
624
+ count: int = 4,
625
+ batch_id: Optional[str] = None,
626
+ ) -> str:
627
+ """Internal helper: create a generation batch and return its ID.
628
+
629
+ The batch ID is generated client-side as a UUID v7 (this is what
630
+ the web app does) so that the server can derive creation time
631
+ from the high bits. We prefix with ``batch-`` to match the format
632
+ the download endpoint expects.
633
+ """
634
+ batch_id = batch_id or f"batch-{_uuid_v7()}"
635
+ content_item_type = "video" if batch_type == "videos" else "image"
636
+ body = {
637
+ "id": batch_id,
638
+ "type": batch_type,
639
+ "prompt": prompt,
640
+ "timestamp": _ms_now(),
641
+ "isComplete": False,
642
+ "config": config,
643
+ "projectId": project_id,
644
+ "content": [
645
+ {
646
+ "id": f"{batch_id}-content-{i}",
647
+ "type": content_item_type,
648
+ "isLoading": True,
649
+ }
650
+ for i in range(count)
651
+ ],
652
+ }
653
+ self._post("/api/generation-batches", json_body=body)
654
+ return batch_id
655
+
656
+ # ------------------------------------------------------------------ #
657
+ # VIDEO generation
658
+ # ------------------------------------------------------------------ #
659
+ def generate_video(
660
+ self,
661
+ project_id: str,
662
+ prompt: str,
663
+ *,
664
+ aspect_ratio: Union[str, AspectRatio] = AspectRatio.PORTRAIT,
665
+ resolution: Union[str, Resolution] = Resolution.P480,
666
+ variations: int = 4,
667
+ video_model: Union[str, VideoModel] = VideoModel.SHORT,
668
+ image_model: Union[str, ImageModel] = ImageModel.BASE,
669
+ prompt_model: Union[str, PromptModel] = PromptModel.GEMINI_FLASH,
670
+ ingredients: Optional[List[dict]] = None,
671
+ create_ingredients: Optional[List[dict]] = None,
672
+ start_frame: Optional[dict] = None,
673
+ end_frame: Optional[dict] = None,
674
+ moodboard: Optional[dict] = None,
675
+ poll: bool = True,
676
+ poll_interval: float = POLL_INTERVAL,
677
+ poll_timeout: float = POLL_TIMEOUT,
678
+ ) -> dict:
679
+ """Generate one or more video variations from a text prompt.
680
+
681
+ This is the canonical text-to-video (t2v) and image-to-video (i2v)
682
+ entry point. It mirrors the "Generate" tab in the Vibes UI.
683
+
684
+ Parameters
685
+ ----------
686
+ project_id : str
687
+ Target project (create one with ``create_project`` first).
688
+ prompt : str
689
+ The text description of the video you want.
690
+ aspect_ratio : str | AspectRatio
691
+ Only "9:16" (default), "16:9", "1:1" are supported server-side.
692
+ resolution : str | Resolution
693
+ "480p" (default, faster) or "720p" (shown under "Advanced").
694
+ variations : int
695
+ Number of variations to generate (1-4). The UI default is 4.
696
+ video_model : str | VideoModel
697
+ Default ``midjen-short`` (5s clip). Use ``midjen-extend`` /
698
+ ``midjen-video-edit`` only via ``extend_video()`` / ``edit_video()``.
699
+ image_model : str | ImageModel
700
+ Default ``midjen-base``. Used for the source frame in t2v.
701
+ prompt_model : str | PromptModel
702
+ Default ``gemini-2.5-flash``.
703
+ ingredients : list of dict, optional
704
+ EXISTING ingredient refs (from ``client.list_ingredients()``).
705
+ Use ``vibes_api.ingredients.IngredientRef.by_id()`` to build.
706
+ Each applies a saved character/style/scene to the generation.
707
+ create_ingredients : list of dict, optional
708
+ INLINE ingredient creations (no pre-existing ingredient).
709
+ Use ``vibes_api.ingredients.CreateIngredient.by_image_ent_id()``
710
+ or ``.by_name()`` to build.
711
+ start_frame : dict, optional
712
+ **Start frame** (image-to-video). Pass the result of
713
+ ``upload_image_file()`` formatted via ``build_frame_handle()``
714
+ — typically ``{"oil_handle": "...", "image_url": "...",
715
+ "image_ent_id": "...", "source": "upload"}``.
716
+ When set, the generation runs in i2v mode.
717
+ end_frame : dict, optional
718
+ **End frame**. Same shape as ``start_frame``. When set, the
719
+ server generates a video that interpolates from start to end
720
+ frame. The fields are stored on the config as
721
+ ``lastFrameOilHandle``, ``lastFrameImageUrl``,
722
+ ``lastFrameImageEntId``.
723
+ moodboard : dict, optional
724
+ Apply a moodboard (style reference) by passing
725
+ ``{"moodboardCode": "...", "moodboardId": "...", "moodboard_name": "...", "moodboard_thumbnail_url": "..."}``.
726
+ Use ``client.list_moodboards()`` to find one.
727
+ poll : bool
728
+ If True (default), block until the batch completes and return
729
+ the final batch state. If False, return immediately.
730
+ poll_interval, poll_timeout : float
731
+ Polling cadence and max wait. Videos typically take 30-90s.
732
+
733
+ Returns
734
+ -------
735
+ dict
736
+ If ``poll=True``: the final batch dict (with ``content`` populated).
737
+ If ``poll=False``: the immediate generation response with
738
+ ``batchId``, ``videoGenEntIds``, ``needsPolling``, ``items``.
739
+
740
+ Raises
741
+ ------
742
+ VibesAPIError
743
+ If generation fails server-side.
744
+ TimeoutError
745
+ If ``poll=True`` and the batch doesn't complete within ``poll_timeout``.
746
+
747
+ Notes
748
+ -----
749
+ - When ``start_frame`` is set, ``generationType`` becomes ``i2v``.
750
+ - When both ``start_frame`` and ``end_frame`` are set, the server
751
+ uses them as keyframes for interpolation.
752
+ - ``ingredients`` and ``create_ingredients`` can be combined.
753
+ """
754
+ aspect_ratio = _coerce(aspect_ratio)
755
+ resolution = _coerce(resolution)
756
+ video_model = _coerce(video_model)
757
+ image_model = _coerce(image_model)
758
+ prompt_model = _coerce(prompt_model)
759
+
760
+ # Build ingredient payload (handles character/style/scene combinations)
761
+ ing_payload = build_ingredient_payload(
762
+ ingredients=ingredients,
763
+ create_ingredients=create_ingredients,
764
+ )
765
+
766
+ gen_type = "i2v" if start_frame else "t2v"
767
+
768
+ config: Dict[str, Any] = {
769
+ "videoModel": video_model,
770
+ "imageModel": image_model,
771
+ "promptModel": prompt_model,
772
+ "resolution": resolution,
773
+ "aspectRatio": aspect_ratio,
774
+ "batchVariation": variations > 1,
775
+ "generationType": gen_type,
776
+ "directGeneration": True,
777
+ }
778
+ # Spread ingredient payload (ingredients + createIngredients)
779
+ config.update(ing_payload)
780
+
781
+ # Start frame → directPromptImageHandle
782
+ if start_frame:
783
+ config["directPromptImageHandle"] = start_frame
784
+
785
+ # End frame → lastFrameOilHandle / lastFrameImageUrl / lastFrameImageEntId
786
+ if end_frame:
787
+ if end_frame.get("oil_handle"):
788
+ config["lastFrameOilHandle"] = end_frame["oil_handle"]
789
+ if end_frame.get("image_url"):
790
+ config["lastFrameImageUrl"] = end_frame["image_url"]
791
+ if end_frame.get("image_ent_id"):
792
+ config["lastFrameImageEntId"] = end_frame["image_ent_id"]
793
+
794
+ # Moodboard (style reference)
795
+ if moodboard:
796
+ if moodboard.get("moodboardCode"):
797
+ config["moodboardCode"] = moodboard["moodboardCode"]
798
+ if moodboard.get("moodboardId"):
799
+ config["moodboardId"] = moodboard["moodboardId"]
800
+ if moodboard.get("moodboard_name"):
801
+ config["moodboard_name"] = moodboard["moodboard_name"]
802
+ if moodboard.get("moodboard_thumbnail_url"):
803
+ config["moodboard_thumbnail_url"] = moodboard["moodboard_thumbnail_url"]
804
+
805
+ # 1) Create the batch
806
+ batch_id = self._create_batch(
807
+ batch_type="videos",
808
+ prompt=prompt,
809
+ project_id=project_id,
810
+ config=config,
811
+ count=variations,
812
+ )
813
+ # Small delay to let the server-side DB row settle (avoids
814
+ # transient 500s when polling immediately after creation).
815
+ time.sleep(1.0)
816
+
817
+ # 2) Build inputs - each variation gets its own input
818
+ input_config = {
819
+ "videoModel": video_model,
820
+ "imageModel": image_model,
821
+ "promptModel": prompt_model,
822
+ "resolution": resolution,
823
+ "aspectRatio": aspect_ratio,
824
+ "generationType": gen_type,
825
+ }
826
+ input_config.update(ing_payload)
827
+ if start_frame:
828
+ input_config["directPromptImageHandle"] = start_frame
829
+ if end_frame:
830
+ if end_frame.get("oil_handle"):
831
+ input_config["lastFrameOilHandle"] = end_frame["oil_handle"]
832
+ if end_frame.get("image_url"):
833
+ input_config["lastFrameImageUrl"] = end_frame["image_url"]
834
+ if end_frame.get("image_ent_id"):
835
+ input_config["lastFrameImageEntId"] = end_frame["image_ent_id"]
836
+ if moodboard:
837
+ if moodboard.get("moodboardCode"):
838
+ input_config["moodboardCode"] = moodboard["moodboardCode"]
839
+ if moodboard.get("moodboardId"):
840
+ input_config["moodboardId"] = moodboard["moodboardId"]
841
+ if moodboard.get("moodboard_name"):
842
+ input_config["moodboard_name"] = moodboard["moodboard_name"]
843
+ if moodboard.get("moodboard_thumbnail_url"):
844
+ input_config["moodboard_thumbnail_url"] = moodboard["moodboard_thumbnail_url"]
845
+
846
+ inputs = []
847
+ for _ in range(variations):
848
+ inp = {
849
+ "type": "prompt",
850
+ "value": prompt,
851
+ "original_prompt": prompt,
852
+ "config": input_config,
853
+ }
854
+ inputs.append(inp)
855
+
856
+ # 3) Trigger generation
857
+ gen_resp = self._post("/api/generate/videos", json_body={
858
+ "batchId": batch_id,
859
+ "inputs": inputs,
860
+ "config": config,
861
+ })
862
+
863
+ if not poll:
864
+ return gen_resp
865
+
866
+ # 4) Poll for completion
867
+ return self.poll_batch(batch_id, interval=poll_interval, timeout=poll_timeout)
868
+
869
+ def poll_batch(
870
+ self,
871
+ batch_id: str,
872
+ *,
873
+ interval: float = POLL_INTERVAL,
874
+ timeout: float = POLL_TIMEOUT,
875
+ max_retries: int = 3,
876
+ ) -> dict:
877
+ """Block until a batch completes (or times out).
878
+
879
+ Returns the final batch state dict (with ``content`` populated).
880
+
881
+ Notes
882
+ -----
883
+ The batch endpoint occasionally returns transient 500s right after
884
+ batch creation (race condition). This method retries up to
885
+ ``max_retries`` times before propagating the error.
886
+ """
887
+ deadline = time.time() + timeout
888
+ consecutive_errors = 0
889
+ while time.time() < deadline:
890
+ try:
891
+ batch = self.get_batch(batch_id)
892
+ consecutive_errors = 0
893
+ if batch.get("isComplete") or batch.get("hasError"):
894
+ return batch
895
+ except VibesAPIError as e:
896
+ consecutive_errors += 1
897
+ if consecutive_errors > max_retries:
898
+ raise
899
+ # Brief backoff before retry
900
+ time.sleep(min(2.0, interval))
901
+ time.sleep(interval)
902
+ raise TimeoutError(
903
+ f"Batch {batch_id} did not complete within {timeout:.0f}s"
904
+ )
905
+
906
+ # ------------------------------------------------------------------ #
907
+ # IMAGE generation
908
+ # ------------------------------------------------------------------ #
909
+ def generate_image(
910
+ self,
911
+ project_id: str,
912
+ prompt: str,
913
+ *,
914
+ aspect_ratio: Union[str, AspectRatio] = AspectRatio.SQUARE,
915
+ resolution: Union[str, Resolution] = Resolution.P480,
916
+ variations: int = 1,
917
+ image_model: Union[str, ImageModel] = ImageModel.BASE,
918
+ prompt_model: Union[str, PromptModel] = PromptModel.GEMINI_FLASH,
919
+ ingredients: Optional[List[dict]] = None,
920
+ create_ingredients: Optional[List[dict]] = None,
921
+ moodboard: Optional[dict] = None,
922
+ ) -> dict:
923
+ """Generate one or more images from a text prompt.
924
+
925
+ Image generation is **synchronous** (no polling needed) - the API
926
+ returns immediately with the final image URLs.
927
+
928
+ Parameters
929
+ ----------
930
+ project_id, prompt, aspect_ratio, resolution, variations,
931
+ image_model, prompt_model, ingredients, create_ingredients,
932
+ moodboard : see ``generate_video()``
933
+
934
+ Returns
935
+ -------
936
+ dict
937
+ Raw API response: ``{success, data: [{url, prompt, config,
938
+ imageEntId, dimensions, srefValues, orefValues}],
939
+ updatedBatch: {...}}``.
940
+ """
941
+ aspect_ratio = _coerce(aspect_ratio)
942
+ resolution = _coerce(resolution)
943
+ image_model = _coerce(image_model)
944
+ prompt_model = _coerce(prompt_model)
945
+
946
+ ing_payload = build_ingredient_payload(
947
+ ingredients=ingredients,
948
+ create_ingredients=create_ingredients,
949
+ )
950
+
951
+ config: Dict[str, Any] = {
952
+ "imageModel": image_model,
953
+ "promptModel": prompt_model,
954
+ "resolution": resolution,
955
+ "aspectRatio": aspect_ratio,
956
+ "batchVariation": variations > 1,
957
+ "generationType": "t2i",
958
+ "directGeneration": True,
959
+ }
960
+ config.update(ing_payload)
961
+ if moodboard:
962
+ if moodboard.get("moodboardCode"):
963
+ config["moodboardCode"] = moodboard["moodboardCode"]
964
+ if moodboard.get("moodboardId"):
965
+ config["moodboardId"] = moodboard["moodboardId"]
966
+ if moodboard.get("moodboard_name"):
967
+ config["moodboard_name"] = moodboard["moodboard_name"]
968
+ if moodboard.get("moodboard_thumbnail_url"):
969
+ config["moodboard_thumbnail_url"] = moodboard["moodboard_thumbnail_url"]
970
+
971
+ batch_id = self._create_batch(
972
+ batch_type="images",
973
+ prompt=prompt,
974
+ project_id=project_id,
975
+ config=config,
976
+ count=variations,
977
+ )
978
+
979
+ input_config = {
980
+ "imageModel": image_model,
981
+ "promptModel": prompt_model,
982
+ "resolution": resolution,
983
+ "aspectRatio": aspect_ratio,
984
+ "generationType": "t2i",
985
+ }
986
+ input_config.update(ing_payload)
987
+ if moodboard:
988
+ if moodboard.get("moodboardCode"):
989
+ input_config["moodboardCode"] = moodboard["moodboardCode"]
990
+ if moodboard.get("moodboardId"):
991
+ input_config["moodboardId"] = moodboard["moodboardId"]
992
+ if moodboard.get("moodboard_name"):
993
+ input_config["moodboard_name"] = moodboard["moodboard_name"]
994
+ if moodboard.get("moodboard_thumbnail_url"):
995
+ input_config["moodboard_thumbnail_url"] = moodboard["moodboard_thumbnail_url"]
996
+
997
+ inputs = [
998
+ {
999
+ "type": "variation",
1000
+ "image_prompt": prompt,
1001
+ "original_prompt": prompt,
1002
+ "config": input_config,
1003
+ }
1004
+ for _ in range(variations)
1005
+ ]
1006
+
1007
+ return self._post("/api/generate/images", json_body={
1008
+ "batchId": batch_id,
1009
+ "inputs": inputs,
1010
+ "config": config,
1011
+ })
1012
+
1013
+ # ------------------------------------------------------------------ #
1014
+ # IMAGE EDITING
1015
+ # ------------------------------------------------------------------ #
1016
+ def edit_image(
1017
+ self,
1018
+ source_image_ent_id: str,
1019
+ edit_prompt: str,
1020
+ project_id: Optional[str] = None,
1021
+ ) -> dict:
1022
+ """Edit an existing image with a text prompt.
1023
+
1024
+ Parameters
1025
+ ----------
1026
+ source_image_ent_id : str
1027
+ The ``imageEntId`` (or ``mediaEntId``) of the source image,
1028
+ obtained from a prior ``generate_image`` call or upload.
1029
+ edit_prompt : str
1030
+ Instruction for the edit (e.g., "make it night time").
1031
+ project_id : str, optional
1032
+ Attach the result to a project.
1033
+
1034
+ Returns
1035
+ -------
1036
+ dict
1037
+ ``{success, contentItem: {...}}``
1038
+ """
1039
+ body: Dict[str, Any] = {
1040
+ "sourceImageEntId": source_image_ent_id,
1041
+ "editPrompt": edit_prompt,
1042
+ }
1043
+ if project_id:
1044
+ body["projectId"] = project_id
1045
+ return self._post("/api/generate/image-edit", json_body=body)
1046
+
1047
+ # ------------------------------------------------------------------ #
1048
+ # VIDEO EXTEND (auto + manual) — mirrors "Extend" panel in UI
1049
+ # ------------------------------------------------------------------ #
1050
+ def extend_video(
1051
+ self,
1052
+ project_id: str,
1053
+ source_video: dict,
1054
+ *,
1055
+ prompt: Optional[str] = None,
1056
+ poll: bool = True,
1057
+ poll_interval: float = POLL_INTERVAL,
1058
+ poll_timeout: float = POLL_TIMEOUT,
1059
+ ) -> dict:
1060
+ """Extend a video clip by ~5 seconds (auto or manual).
1061
+
1062
+ Mirrors the "Auto extend" and "Manual extend" buttons in the Vibes UI.
1063
+ Both call the same underlying endpoint — the only difference is
1064
+ whether you supply a directive prompt.
1065
+
1066
+ Parameters
1067
+ ----------
1068
+ project_id : str
1069
+ Target project (must contain the source video).
1070
+ source_video : dict
1071
+ The source content item dict from a prior ``generate_video()``
1072
+ or ``get_batch()``, e.g. ``batch["content"][0]``. Must contain
1073
+ at least ``id``, ``videoUrl``, and either ``videoHandle`` or
1074
+ ``data.videoGenEntId``. The full content item shape works.
1075
+ prompt : str, optional
1076
+ **Manual extend directive**. If provided, the server uses this
1077
+ to guide the extension (e.g., "the camera pans up to reveal
1078
+ the sky"). If omitted, runs **auto extend** — the server
1079
+ continues the original prompt.
1080
+ poll : bool
1081
+ Block until completion (default True).
1082
+ poll_interval, poll_timeout : float
1083
+ Polling cadence and max wait. Extensions take 30-90s.
1084
+
1085
+ Returns
1086
+ -------
1087
+ dict
1088
+ If ``poll=True``: the final batch dict (with new ``content``
1089
+ items — the extended video). If ``poll=False``: the immediate
1090
+ generation response.
1091
+
1092
+ Raises
1093
+ ------
1094
+ VibesAPIError
1095
+ If the source video is missing required metadata, or the
1096
+ server fails to start the extension.
1097
+ """
1098
+ # Extract source video metadata
1099
+ original_prompt = (
1100
+ source_video.get("prompt")
1101
+ or source_video.get("videoPrompt")
1102
+ or source_video.get("imagePrompt")
1103
+ or ""
1104
+ )
1105
+ if not original_prompt:
1106
+ raise VibesAPIError(
1107
+ "Original prompt not available for extend. "
1108
+ "Pass the full content item dict from get_batch()."
1109
+ )
1110
+
1111
+ # Get the source video handle and entity ID
1112
+ structured = source_video.get("structuredOutput") or {}
1113
+ if isinstance(structured, str):
1114
+ try:
1115
+ import json as _json
1116
+ structured = _json.loads(structured)
1117
+ except ValueError:
1118
+ structured = {}
1119
+
1120
+ source_config = source_video.get("config") or {}
1121
+ source_video_handle = (
1122
+ source_video.get("videoHandle")
1123
+ or structured.get("sourceVideoHandle")
1124
+ or source_config.get("sourceVideoHandle")
1125
+ )
1126
+ video_gen_ent_id = self._extract_video_gen_ent_id(source_video)
1127
+ source_video_url = (
1128
+ source_video.get("videoUrl")
1129
+ or structured.get("sourceVideoUrl")
1130
+ or source_config.get("sourceVideoUrl")
1131
+ )
1132
+
1133
+ if not source_video_handle and not video_gen_ent_id:
1134
+ raise VibesAPIError(
1135
+ "Video handle or entity ID is required for extend. "
1136
+ "Use a video with a valid reference (one returned by generate_video)."
1137
+ )
1138
+
1139
+ # Build the extended config
1140
+ ext_config: Dict[str, Any] = {
1141
+ **structured,
1142
+ **source_config,
1143
+ "videoModel": "midjen-extend",
1144
+ "imageModel": source_config.get("imageModel") or "midjen-base",
1145
+ "generationType": "extend",
1146
+ "directGeneration": True,
1147
+ "sourceContentItemIds": [{"id": source_video["id"], "source": "extend_video"}],
1148
+ }
1149
+ # Force midjen-extend (server doesn't accept midjen-short for extend)
1150
+ if ext_config["videoModel"] in ("midjen-short", "midjen-video-edit"):
1151
+ ext_config["videoModel"] = "midjen-extend"
1152
+
1153
+ if source_video_handle:
1154
+ ext_config["sourceVideoHandle"] = source_video_handle
1155
+ if source_video_url and not ext_config.get("sourceVideoUrl"):
1156
+ ext_config["sourceVideoUrl"] = source_video_url
1157
+
1158
+ # Carry over audio if present (for lipsync extensions)
1159
+ audio_ent_id = (
1160
+ ext_config.get("audioSourceEntId")
1161
+ or structured.get("audioSourceEntId")
1162
+ or source_config.get("audioSourceEntId")
1163
+ )
1164
+ if audio_ent_id:
1165
+ ext_config["audioSourceEntId"] = audio_ent_id
1166
+
1167
+ if prompt:
1168
+ ext_config["extendDirective"] = prompt
1169
+
1170
+ # Build the batch ID (matches UI's `extend-${Date.now()}-${random}` format)
1171
+ import random as _random
1172
+ batch_id = f"extend-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}"
1173
+
1174
+ # Create the batch shell
1175
+ batch_body = {
1176
+ "id": batch_id,
1177
+ "type": "videos",
1178
+ "prompt": prompt or original_prompt,
1179
+ "timestamp": _ms_now(),
1180
+ "content": [],
1181
+ "isComplete": False,
1182
+ "config": ext_config,
1183
+ "promptModel": ext_config.get("promptModel"),
1184
+ "imageModel": ext_config.get("imageModel"),
1185
+ "videoModel": ext_config.get("videoModel"),
1186
+ "generationStartTime": _ms_now(),
1187
+ "isDirectGeneration": True,
1188
+ "projectId": project_id,
1189
+ }
1190
+ self._post("/api/generation-batches", json_body=batch_body)
1191
+ time.sleep(1.0)
1192
+
1193
+ # Build inputs — extend inputs use type: "extend"
1194
+ input_config = {**ext_config}
1195
+ inputs = [{
1196
+ "type": "extend",
1197
+ "mediaEntId": video_gen_ent_id,
1198
+ "videoUrl": source_video_url,
1199
+ "prompt": prompt or original_prompt,
1200
+ **({"extendDirective": prompt} if prompt else {}),
1201
+ "config": input_config,
1202
+ }]
1203
+
1204
+ gen_resp = self._post("/api/generate/videos", json_body={
1205
+ "batchId": batch_id,
1206
+ "inputs": inputs,
1207
+ "config": ext_config,
1208
+ })
1209
+
1210
+ if not poll:
1211
+ return gen_resp
1212
+ return self.poll_batch(batch_id, interval=poll_interval, timeout=poll_timeout)
1213
+
1214
+ def auto_extend_video(
1215
+ self,
1216
+ project_id: str,
1217
+ source_video: dict,
1218
+ **kwargs,
1219
+ ) -> dict:
1220
+ """Shortcut for ``extend_video(..., prompt=None)`` — the "Auto extend" button."""
1221
+ return self.extend_video(project_id, source_video, prompt=None, **kwargs)
1222
+
1223
+ def manual_extend_video(
1224
+ self,
1225
+ project_id: str,
1226
+ source_video: dict,
1227
+ prompt: str,
1228
+ **kwargs,
1229
+ ) -> dict:
1230
+ """Shortcut for ``extend_video(..., prompt=prompt)`` — the "Manual extend" button."""
1231
+ return self.extend_video(project_id, source_video, prompt=prompt, **kwargs)
1232
+
1233
+ # ------------------------------------------------------------------ #
1234
+ # VIDEO-TO-VIDEO EDITING (v2v)
1235
+ # ------------------------------------------------------------------ #
1236
+ def edit_video(
1237
+ self,
1238
+ project_id: str,
1239
+ source_video: dict,
1240
+ prompt: str,
1241
+ *,
1242
+ poll: bool = True,
1243
+ poll_interval: float = POLL_INTERVAL,
1244
+ poll_timeout: float = POLL_TIMEOUT,
1245
+ ) -> dict:
1246
+ """Edit an existing video with a text prompt (video-to-video).
1247
+
1248
+ This is the API equivalent of the "Edit video" flow in the Vibes UI.
1249
+ The source video is re-rendered with the directive applied.
1250
+
1251
+ Parameters
1252
+ ----------
1253
+ project_id : str
1254
+ Target project.
1255
+ source_video : dict
1256
+ The source content item dict (same shape as ``extend_video``).
1257
+ prompt : str
1258
+ The edit directive (e.g., "change the weather to rain").
1259
+ poll : bool
1260
+ Block until completion (default True).
1261
+
1262
+ Returns
1263
+ -------
1264
+ dict
1265
+ Same shape as ``extend_video()``.
1266
+ """
1267
+ original_prompt = (
1268
+ source_video.get("prompt")
1269
+ or source_video.get("videoPrompt")
1270
+ or source_video.get("imagePrompt")
1271
+ or ""
1272
+ )
1273
+ if not original_prompt:
1274
+ raise VibesAPIError("No prompt available for video-to-video editing.")
1275
+
1276
+ structured = source_video.get("structuredOutput") or {}
1277
+ if isinstance(structured, str):
1278
+ try:
1279
+ import json as _json
1280
+ structured = _json.loads(structured)
1281
+ except ValueError:
1282
+ structured = {}
1283
+
1284
+ source_config = source_video.get("config") or {}
1285
+ source_video_handle = (
1286
+ source_video.get("videoHandle")
1287
+ or structured.get("sourceVideoHandle")
1288
+ or source_config.get("sourceVideoHandle")
1289
+ )
1290
+ video_gen_ent_id = self._extract_video_gen_ent_id(source_video)
1291
+ source_video_url = (
1292
+ source_video.get("videoUrl")
1293
+ or structured.get("sourceVideoUrl")
1294
+ or source_config.get("sourceVideoUrl")
1295
+ )
1296
+
1297
+ if not source_video_handle and not video_gen_ent_id:
1298
+ raise VibesAPIError(
1299
+ "This video cannot be edited because it is missing required "
1300
+ "metadata (videoHandle or videoGenEntId). This may happen "
1301
+ "with older videos or videos that were uploaded directly."
1302
+ )
1303
+
1304
+ # v2v uses midjen-video-edit
1305
+ v2v_config: Dict[str, Any] = {
1306
+ **structured,
1307
+ **source_config,
1308
+ "videoModel": "midjen-video-edit",
1309
+ "imageModel": source_config.get("imageModel") or "midjen-base",
1310
+ "editType": "v2v",
1311
+ "generationType": "v2v",
1312
+ "directGeneration": True,
1313
+ "sourceContentItemIds": [{"id": source_video["id"], "source": "v2v"}],
1314
+ }
1315
+ # v2v doesn't support end frame / loop — strip them
1316
+ for k in ("endFrameUrl", "endFrameHandle", "lastFrameOilHandle", "loop"):
1317
+ v2v_config.pop(k, None)
1318
+
1319
+ if source_video_handle:
1320
+ v2v_config["sourceVideoHandle"] = source_video_handle
1321
+ if source_video_url:
1322
+ v2v_config["sourceVideoUrl"] = source_video_url
1323
+
1324
+ # Carry start frame if the source had one
1325
+ start_handle_oil = (
1326
+ source_config.get("directPromptImageHandle", {}).get("oil_handle")
1327
+ or source_video.get("imageHandle")
1328
+ or structured.get("directPromptImageHandle", {}).get("oil_handle")
1329
+ )
1330
+ if start_handle_oil:
1331
+ v2v_config["directPromptImageHandle"] = {
1332
+ "oil_handle": start_handle_oil,
1333
+ "image_url": (
1334
+ source_video.get("imageUrl")
1335
+ or structured.get("directPromptImageHandle", {}).get("image_url", "")
1336
+ ),
1337
+ }
1338
+
1339
+ batch_id = f"video2video-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}"
1340
+ batch_body = {
1341
+ "id": batch_id,
1342
+ "type": "videos",
1343
+ "prompt": prompt,
1344
+ "timestamp": _ms_now(),
1345
+ "content": [],
1346
+ "isComplete": False,
1347
+ "config": v2v_config,
1348
+ "promptModel": v2v_config.get("promptModel"),
1349
+ "imageModel": v2v_config.get("imageModel"),
1350
+ "videoModel": v2v_config.get("videoModel"),
1351
+ "generationStartTime": _ms_now(),
1352
+ "isDirectGeneration": True,
1353
+ "projectId": project_id,
1354
+ }
1355
+ self._post("/api/generation-batches", json_body=batch_body)
1356
+ time.sleep(1.0)
1357
+
1358
+ input_config = {**v2v_config}
1359
+ inputs = [{
1360
+ "type": "video",
1361
+ "mediaEntId": video_gen_ent_id,
1362
+ "videoUrl": source_video_url,
1363
+ "prompt": prompt,
1364
+ "config": input_config,
1365
+ }]
1366
+
1367
+ gen_resp = self._post("/api/generate/videos", json_body={
1368
+ "batchId": batch_id,
1369
+ "inputs": inputs,
1370
+ "config": v2v_config,
1371
+ })
1372
+
1373
+ if not poll:
1374
+ return gen_resp
1375
+ return self.poll_batch(batch_id, interval=poll_interval, timeout=poll_timeout)
1376
+
1377
+ # ------------------------------------------------------------------ #
1378
+ # IMAGE-TO-VIDEO ANIMATE (Auto animate / Manual animate)
1379
+ # ------------------------------------------------------------------ #
1380
+ def animate_image(
1381
+ self,
1382
+ project_id: str,
1383
+ source_image: dict,
1384
+ prompt: Optional[str] = None,
1385
+ *,
1386
+ poll: bool = True,
1387
+ poll_interval: float = POLL_INTERVAL,
1388
+ poll_timeout: float = POLL_TIMEOUT,
1389
+ ) -> dict:
1390
+ """Animate an existing image into a video (i2v).
1391
+
1392
+ Mirrors the "Auto animate" and "Manual animate" buttons shown next
1393
+ to an image in the Vibes UI gallery.
1394
+
1395
+ Parameters
1396
+ ----------
1397
+ project_id : str
1398
+ Target project.
1399
+ source_image : dict
1400
+ The source image content item dict. Must contain at least
1401
+ ``id``, ``imageUrl``, and either ``imageHandle`` or
1402
+ ``data.imageEntId``.
1403
+ prompt : str, optional
1404
+ Manual animate directive. If omitted, runs **auto animate**
1405
+ (uses the image's original prompt).
1406
+ poll : bool
1407
+ Block until completion (default True).
1408
+
1409
+ Returns
1410
+ -------
1411
+ dict
1412
+ Same shape as ``generate_video()``.
1413
+ """
1414
+ original_prompt = (
1415
+ source_image.get("prompt")
1416
+ or source_image.get("imagePrompt")
1417
+ or source_image.get("videoPrompt")
1418
+ or ""
1419
+ )
1420
+ if not original_prompt:
1421
+ raise VibesAPIError("No prompt available for this image.")
1422
+
1423
+ structured = source_image.get("structuredOutput") or {}
1424
+ if isinstance(structured, str):
1425
+ try:
1426
+ import json as _json
1427
+ structured = _json.loads(structured)
1428
+ except ValueError:
1429
+ structured = {}
1430
+
1431
+ source_config = source_image.get("config") or {}
1432
+ image_handle_oil = (
1433
+ source_config.get("directPromptImageHandle", {}).get("oil_handle")
1434
+ or source_image.get("imageHandle")
1435
+ or structured.get("directPromptImageHandle", {}).get("oil_handle")
1436
+ )
1437
+ image_url = (
1438
+ source_image.get("imageUrl")
1439
+ or structured.get("directPromptImageHandle", {}).get("image_url")
1440
+ )
1441
+ image_ent_id = self._extract_image_ent_id(source_image)
1442
+
1443
+ if not image_handle_oil and not image_ent_id:
1444
+ raise VibesAPIError(
1445
+ "Image handle or entity ID is required for animate. "
1446
+ "Use an image with a valid reference."
1447
+ )
1448
+
1449
+ i2v_config: Dict[str, Any] = {
1450
+ **structured,
1451
+ **source_config,
1452
+ "videoModel": "midjen-short",
1453
+ "imageModel": source_config.get("imageModel") or "midjen-base",
1454
+ "generationType": "i2v",
1455
+ "directGeneration": True,
1456
+ "sourceContentItemIds": [{"id": source_image["id"], "source": "i2v"}],
1457
+ }
1458
+ if image_handle_oil:
1459
+ i2v_config["directPromptImageHandle"] = {
1460
+ "oil_handle": image_handle_oil,
1461
+ "image_url": image_url or "",
1462
+ }
1463
+ if prompt:
1464
+ i2v_config["animateDirective"] = prompt
1465
+
1466
+ batch_id = f"image2video-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}"
1467
+ batch_body = {
1468
+ "id": batch_id,
1469
+ "type": "videos",
1470
+ "prompt": prompt or original_prompt,
1471
+ "timestamp": _ms_now(),
1472
+ "content": [],
1473
+ "isComplete": False,
1474
+ "config": i2v_config,
1475
+ "promptModel": i2v_config.get("promptModel"),
1476
+ "imageModel": i2v_config.get("imageModel"),
1477
+ "videoModel": i2v_config.get("videoModel"),
1478
+ "generationStartTime": _ms_now(),
1479
+ "isDirectGeneration": True,
1480
+ "projectId": project_id,
1481
+ }
1482
+ self._post("/api/generation-batches", json_body=batch_body)
1483
+ time.sleep(1.0)
1484
+
1485
+ input_config = {**i2v_config}
1486
+ inputs = [{
1487
+ "type": "image",
1488
+ "imageUrl": image_url,
1489
+ "imageEntId": image_ent_id,
1490
+ "prompt": prompt or original_prompt,
1491
+ **({"animateDirective": prompt} if prompt else {}),
1492
+ "config": input_config,
1493
+ }]
1494
+
1495
+ gen_resp = self._post("/api/generate/videos", json_body={
1496
+ "batchId": batch_id,
1497
+ "inputs": inputs,
1498
+ "config": i2v_config,
1499
+ })
1500
+
1501
+ if not poll:
1502
+ return gen_resp
1503
+ return self.poll_batch(batch_id, interval=poll_interval, timeout=poll_timeout)
1504
+
1505
+ def auto_animate_image(
1506
+ self,
1507
+ project_id: str,
1508
+ source_image: dict,
1509
+ **kwargs,
1510
+ ) -> dict:
1511
+ """Shortcut for ``animate_image(..., prompt=None)`` — the "Auto animate" button."""
1512
+ return self.animate_image(project_id, source_image, prompt=None, **kwargs)
1513
+
1514
+ def manual_animate_image(
1515
+ self,
1516
+ project_id: str,
1517
+ source_image: dict,
1518
+ prompt: str,
1519
+ **kwargs,
1520
+ ) -> dict:
1521
+ """Shortcut for ``animate_image(..., prompt=prompt)`` — the "Manual animate" button."""
1522
+ return self.animate_image(project_id, source_image, prompt=prompt, **kwargs)
1523
+
1524
+ # ------------------------------------------------------------------ #
1525
+ # REGENERATE BATCH (re-roll with same prompt, new seed)
1526
+ # ------------------------------------------------------------------ #
1527
+ def regenerate_batch(
1528
+ self,
1529
+ project_id: str,
1530
+ batch_id: str,
1531
+ *,
1532
+ prompt: Optional[str] = None,
1533
+ poll: bool = True,
1534
+ poll_interval: float = POLL_INTERVAL,
1535
+ poll_timeout: float = POLL_TIMEOUT,
1536
+ ) -> dict:
1537
+ """Regenerate a batch (re-roll with the same or new prompt).
1538
+
1539
+ Mirrors the "Regenerate" button shown on a batch in the gallery.
1540
+ Reuses the original config but creates a fresh batch.
1541
+
1542
+ Parameters
1543
+ ----------
1544
+ project_id : str
1545
+ Target project.
1546
+ batch_id : str
1547
+ The batch to regenerate from.
1548
+ prompt : str, optional
1549
+ Override the original prompt. If None, reuses the batch's prompt.
1550
+ poll : bool
1551
+ Block until completion (default True).
1552
+ """
1553
+ # Fetch the source batch
1554
+ source_batch = self.get_batch(batch_id)
1555
+ if not source_batch:
1556
+ raise VibesAPIError(f"Batch {batch_id} not found")
1557
+
1558
+ source_prompt = prompt or source_batch.get("prompt", "")
1559
+ source_config = source_batch.get("config") or {}
1560
+ batch_type = source_batch.get("type", "videos")
1561
+
1562
+ # Clean up fields that shouldn't carry over
1563
+ clean_config = {k: v for k, v in source_config.items()
1564
+ if k not in ("sourceContentItemIds", "generationEndTime")}
1565
+ clean_config["directGeneration"] = True
1566
+
1567
+ # Determine the right generation type and inputs
1568
+ gen_type = clean_config.get("generationType", "t2v")
1569
+ if batch_type == "images":
1570
+ gen_type = "t2i"
1571
+
1572
+ # Create a fresh batch
1573
+ new_batch_id = f"batch-{_uuid_v7()}"
1574
+ new_batch_body = {
1575
+ "id": new_batch_id,
1576
+ "type": batch_type,
1577
+ "prompt": source_prompt,
1578
+ "timestamp": _ms_now(),
1579
+ "content": [],
1580
+ "isComplete": False,
1581
+ "config": clean_config,
1582
+ "promptModel": clean_config.get("promptModel"),
1583
+ "imageModel": clean_config.get("imageModel"),
1584
+ "videoModel": clean_config.get("videoModel"),
1585
+ "generationStartTime": _ms_now(),
1586
+ "isDirectGeneration": True,
1587
+ "projectId": project_id,
1588
+ }
1589
+ self._post("/api/generation-batches", json_body=new_batch_body)
1590
+ time.sleep(1.0)
1591
+
1592
+ # Build inputs based on type
1593
+ if batch_type == "videos":
1594
+ inputs = [{
1595
+ "type": "prompt",
1596
+ "value": source_prompt,
1597
+ "original_prompt": source_prompt,
1598
+ "config": clean_config,
1599
+ }]
1600
+ endpoint = "/api/generate/videos"
1601
+ else:
1602
+ inputs = [{
1603
+ "type": "variation",
1604
+ "image_prompt": source_prompt,
1605
+ "original_prompt": source_prompt,
1606
+ "config": clean_config,
1607
+ }]
1608
+ endpoint = "/api/generate/images"
1609
+
1610
+ gen_resp = self._post(endpoint, json_body={
1611
+ "batchId": new_batch_id,
1612
+ "inputs": inputs,
1613
+ "config": clean_config,
1614
+ })
1615
+
1616
+ if not poll:
1617
+ return gen_resp
1618
+ return self.poll_batch(new_batch_id, interval=poll_interval, timeout=poll_timeout)
1619
+
1620
+ # ------------------------------------------------------------------ #
1621
+ # Helpers for extracting entity IDs from content items
1622
+ # ------------------------------------------------------------------ #
1623
+ @staticmethod
1624
+ def _extract_video_gen_ent_id(content_item: dict) -> Optional[str]:
1625
+ """Extract ``videoGenEntId`` from a content item."""
1626
+ data = content_item.get("data")
1627
+ if isinstance(data, str):
1628
+ try:
1629
+ import json as _json
1630
+ data = _json.loads(data)
1631
+ except ValueError:
1632
+ data = {}
1633
+ if isinstance(data, dict):
1634
+ return data.get("videoGenEntId") or data.get("videoEntId")
1635
+ return None
1636
+
1637
+ @staticmethod
1638
+ def _extract_image_ent_id(content_item: dict) -> Optional[str]:
1639
+ """Extract ``imageEntId`` from a content item."""
1640
+ data = content_item.get("data")
1641
+ if isinstance(data, str):
1642
+ try:
1643
+ import json as _json
1644
+ data = _json.loads(data)
1645
+ except ValueError:
1646
+ data = {}
1647
+ if isinstance(data, dict):
1648
+ return data.get("imageEntId") or data.get("image_ent_id")
1649
+ return None
1650
+
1651
+ # ------------------------------------------------------------------ #
1652
+ # FRAME HANDLE BUILDER (for start/end frame uploads)
1653
+ # ------------------------------------------------------------------ #
1654
+ @staticmethod
1655
+ def build_frame_handle(
1656
+ upload_response: dict,
1657
+ source: str = "upload",
1658
+ ) -> dict:
1659
+ """Build a frame handle dict from an ``upload_image()`` response.
1660
+
1661
+ Use this to construct the ``start_frame`` / ``end_frame`` arguments
1662
+ for ``generate_video()``.
1663
+
1664
+ Parameters
1665
+ ----------
1666
+ upload_response : dict
1667
+ The response from ``client.upload_image()`` or
1668
+ ``client.upload_image_file()``. Contains ``mediaEntId`` and
1669
+ ``imageUrl``.
1670
+ source : str
1671
+ "upload" (default), "asset", or "selection".
1672
+
1673
+ Returns
1674
+ -------
1675
+ dict
1676
+ ``{"oil_handle": ..., "image_url": ..., "image_ent_id": ..., "source": ...}``
1677
+ """
1678
+ return {
1679
+ "oil_handle": upload_response.get("imageHandle") or upload_response.get("oil_handle"),
1680
+ "image_url": upload_response.get("imageUrl") or upload_response.get("image_url"),
1681
+ "image_ent_id": upload_response.get("mediaEntId") or upload_response.get("image_ent_id"),
1682
+ "source": source,
1683
+ }
1684
+
1685
+ # ------------------------------------------------------------------ #
1686
+ # PROMPT ENHANCEMENT
1687
+ # ------------------------------------------------------------------ #
1688
+ def enhance_prompt(
1689
+ self,
1690
+ prompt: str,
1691
+ *,
1692
+ project_id: Optional[str] = None,
1693
+ batch_type: str = "videos",
1694
+ image_model: Union[str, ImageModel] = ImageModel.BASE,
1695
+ video_model: Union[str, VideoModel] = VideoModel.SHORT,
1696
+ prompt_model: Union[str, PromptModel] = PromptModel.GEMINI_FLASH,
1697
+ resolution: Union[str, Resolution] = Resolution.P480,
1698
+ aspect_ratio: Union[str, AspectRatio] = AspectRatio.PORTRAIT,
1699
+ system_prompt: str = "",
1700
+ ) -> List[dict]:
1701
+ """Generate 4 AI-enhanced prompt variations from a short seed.
1702
+
1703
+ Returns
1704
+ -------
1705
+ list of dict
1706
+ Each: ``{image: "...", video: "..."}`` (the image prompt and
1707
+ the corresponding animation prompt).
1708
+ """
1709
+ image_model = _coerce(image_model)
1710
+ video_model = _coerce(video_model)
1711
+ prompt_model = _coerce(prompt_model)
1712
+ resolution = _coerce(resolution)
1713
+ aspect_ratio = _coerce(aspect_ratio)
1714
+
1715
+ config = {
1716
+ "imageModel": image_model,
1717
+ "videoModel": video_model,
1718
+ "promptModel": prompt_model,
1719
+ "resolution": resolution,
1720
+ "aspectRatio": aspect_ratio,
1721
+ "generationType": "t2v" if batch_type == "videos" else "t2i",
1722
+ "batchVariation": True,
1723
+ "directGeneration": True,
1724
+ }
1725
+ body = {
1726
+ "prompt": prompt,
1727
+ "systemPrompt": system_prompt,
1728
+ "batchId": f"batch-{_uuid_v7()}",
1729
+ "config": config,
1730
+ "batchType": batch_type,
1731
+ }
1732
+ if project_id:
1733
+ body["projectId"] = project_id
1734
+ resp = self._post("/api/generate/prompts", json_body=body)
1735
+ return resp.get("data", {}).get("variations", [])
1736
+
1737
+ # ------------------------------------------------------------------ #
1738
+ # LIP SYNC / ANIMATION
1739
+ # ------------------------------------------------------------------ #
1740
+ def generate_lipsync(
1741
+ self,
1742
+ project_id: str,
1743
+ image_prompt: str,
1744
+ script: str,
1745
+ audio_url: str,
1746
+ audio_duration_ms: int,
1747
+ *,
1748
+ engine: str = "midjen",
1749
+ ingredients: Optional[List[dict]] = None,
1750
+ aspect_ratio: Optional[Union[str, AspectRatio]] = None,
1751
+ video_orientation: Optional[str] = None,
1752
+ music_track: Optional[dict] = None,
1753
+ custom_motion_prompt: Optional[str] = None,
1754
+ ) -> dict:
1755
+ """Generate a lip-synced video (image + audio + script).
1756
+
1757
+ The audio must already be uploaded to a CDN URL. Use
1758
+ ``upload_audio_direct`` or ``tts`` + ``upload_audio_direct`` first.
1759
+
1760
+ Returns
1761
+ -------
1762
+ dict
1763
+ Response includes ``data.batchId`` which you can poll with
1764
+ ``poll_batch``.
1765
+ """
1766
+ body: Dict[str, Any] = {
1767
+ "imagePrompt": image_prompt,
1768
+ "audioUrl": audio_url,
1769
+ "audioDurationMs": max(2000, audio_duration_ms),
1770
+ "script": script,
1771
+ "engine": engine,
1772
+ "projectId": project_id,
1773
+ }
1774
+ if ingredients:
1775
+ body["ingredients"] = ingredients
1776
+ if aspect_ratio:
1777
+ body["aspectRatio"] = _coerce(aspect_ratio)
1778
+ if video_orientation:
1779
+ body["videoOrientation"] = video_orientation
1780
+ if music_track:
1781
+ body["musicTrack"] = music_track
1782
+ if custom_motion_prompt:
1783
+ body["customMotionPrompt"] = custom_motion_prompt
1784
+ return self._post("/api/animate/generate", json_body=body)
1785
+
1786
+ # ------------------------------------------------------------------ #
1787
+ # TTS (text-to-speech)
1788
+ # ------------------------------------------------------------------ #
1789
+ def list_voices(self) -> List[dict]:
1790
+ """Return the list of available TTS voices.
1791
+
1792
+ Each voice: ``{id, name, description, sample}``.
1793
+ """
1794
+ return self._get("/api/studio/voices").get("voices", [])
1795
+
1796
+ def tts(self, text: str, voice: str, output_format: str = "mp3",
1797
+ language: Optional[str] = None) -> dict:
1798
+ """Synthesize speech via PlayAI TTS.
1799
+
1800
+ Parameters
1801
+ ----------
1802
+ text : str
1803
+ The text to speak.
1804
+ voice : str
1805
+ Voice ID from ``list_voices`` (e.g., ``"play_ai_Marisol"``).
1806
+ output_format : str
1807
+ Default "mp3".
1808
+ language : str, optional
1809
+ Language code if needed.
1810
+
1811
+ Returns
1812
+ -------
1813
+ dict
1814
+ ``{audioBase64, contentType}``. Use ``save_tts_audio()`` to
1815
+ decode to a file, or pass to ``upload_audio_direct``.
1816
+
1817
+ Notes
1818
+ -----
1819
+ This endpoint depends on a server-side Facebook access token
1820
+ that rotates. If you get ``403 Facebook expired access token``,
1821
+ wait a few minutes and retry.
1822
+ """
1823
+ body: Dict[str, Any] = {
1824
+ "text": text,
1825
+ "voice": voice,
1826
+ "outputFormat": output_format,
1827
+ }
1828
+ if language:
1829
+ body["language"] = language
1830
+ return self._post("/api/studio/playai/tts", json_body=body)
1831
+
1832
+ def save_tts_audio(self, tts_response: dict, path: str) -> str:
1833
+ """Decode a TTS API response and save as an audio file.
1834
+
1835
+ Parameters
1836
+ ----------
1837
+ tts_response : dict
1838
+ The response from ``tts()``.
1839
+ path : str
1840
+ Output file path (e.g., ``"out.mp3"``).
1841
+
1842
+ Returns
1843
+ -------
1844
+ str
1845
+ The path written.
1846
+ """
1847
+ audio_bytes = base64.b64decode(tts_response["audioBase64"])
1848
+ with open(path, "wb") as f:
1849
+ f.write(audio_bytes)
1850
+ return path
1851
+
1852
+ # ------------------------------------------------------------------ #
1853
+ # Uploads
1854
+ # ------------------------------------------------------------------ #
1855
+ def upload_image(self, image_base64: str) -> dict:
1856
+ """Upload a base64-encoded image.
1857
+
1858
+ Returns
1859
+ -------
1860
+ dict
1861
+ ``{mediaEntId, imageUrl}``. Use the ``mediaEntId`` as
1862
+ ``sourceImageEntId`` for ``edit_image``, or pass the handle
1863
+ to ``generate_video(start_frame_image_handle=...)``.
1864
+ """
1865
+ return self._post("/api/upload-image", json_body={"image": image_base64})
1866
+
1867
+ def upload_image_file(self, path: str) -> dict:
1868
+ """Read an image file from disk and upload it."""
1869
+ with open(path, "rb") as f:
1870
+ b64 = base64.b64encode(f.read()).decode("ascii")
1871
+ return self.upload_image(b64)
1872
+
1873
+ def upload_video_direct(self, path: str, name: Optional[str] = None) -> dict:
1874
+ """Upload a video file via multipart form data.
1875
+
1876
+ Returns
1877
+ -------
1878
+ dict
1879
+ ``{mediaEntId, cdnUrl}``.
1880
+ """
1881
+ name = name or os.path.basename(path)
1882
+ with open(path, "rb") as f:
1883
+ files = {"video": (name, f)}
1884
+ resp = self.session.post(
1885
+ self._url("/api/upload-video-direct"),
1886
+ files=files,
1887
+ timeout=max(self.timeout, 600),
1888
+ )
1889
+ return self._check(resp)
1890
+
1891
+ def upload_audio_direct(self, path: str, name: Optional[str] = None) -> dict:
1892
+ """Upload an audio file. Returns ``{cdnUrl, mediaEntId}``."""
1893
+ name = name or os.path.basename(path)
1894
+ with open(path, "rb") as f:
1895
+ files = {"audio": (name, f)}
1896
+ resp = self.session.post(
1897
+ self._url("/api/upload-audio-direct"),
1898
+ files=files,
1899
+ timeout=max(self.timeout, 600),
1900
+ )
1901
+ return self._check(resp)
1902
+
1903
+ def upload_media(self, path: str, name: Optional[str] = None) -> dict:
1904
+ """Generic media upload (image/video). Auto-detects type.
1905
+
1906
+ Returns
1907
+ -------
1908
+ dict
1909
+ Contains ``aspectRatio``, ``dimensions``, ``mediaEntId``, etc.
1910
+ """
1911
+ name = name or os.path.basename(path)
1912
+ with open(path, "rb") as f:
1913
+ files = {"file": (name, f)}
1914
+ data = {"filename": name}
1915
+ resp = self.session.post(
1916
+ self._url("/api/upload-media"),
1917
+ files=files,
1918
+ data=data,
1919
+ timeout=max(self.timeout, 600),
1920
+ )
1921
+ return self._check(resp)
1922
+
1923
+ # ------------------------------------------------------------------ #
1924
+ # Media library
1925
+ # ------------------------------------------------------------------ #
1926
+ def list_media(
1927
+ self,
1928
+ limit: int = 50,
1929
+ offset: int = 0,
1930
+ type: Optional[str] = None,
1931
+ sort: str = "newest",
1932
+ search: Optional[str] = None,
1933
+ ) -> dict:
1934
+ """List media items in your library (videos, images, audio).
1935
+
1936
+ Returns
1937
+ -------
1938
+ dict
1939
+ ``{items: [...], page: {count, hasMore, nextOffset}}``.
1940
+ Each item has ``id``, ``type``, ``thumbnailUrl``, ``fullUrl``,
1941
+ ``prompt``, ``isFavorited``, etc.
1942
+ """
1943
+ params: Dict[str, Any] = {"limit": limit, "offset": offset, "sort": sort}
1944
+ if type:
1945
+ params["type"] = type
1946
+ if search:
1947
+ params["search"] = search
1948
+ return self._get("/api/media-library", params=params)
1949
+
1950
+ def favorite_content_item(self, content_item_id: str, favorite: bool = True) -> dict:
1951
+ """Favorite or unfavorite a content item."""
1952
+ return self._post(f"/api/content-items/{content_item_id}/favorite",
1953
+ json_body={"isFavorited": favorite})
1954
+
1955
+ def delete_content_items(self, ids: List[str]) -> dict:
1956
+ """Bulk-delete content items (videos/images)."""
1957
+ return self._post("/api/content-items/bulk-delete", json_body={"ids": ids})
1958
+
1959
+ def delete_content_item(self, content_item_id: str) -> dict:
1960
+ """Delete a single content item."""
1961
+ return self._delete(f"/api/content-items/{content_item_id}")
1962
+
1963
+ def retry_content_item(self, content_item_id: str) -> dict:
1964
+ """Retry a failed content item."""
1965
+ return self._post(f"/api/content-items/{content_item_id}/retry")
1966
+
1967
+ def feedback_content_item(self, content_item_id: str, feedback: dict) -> dict:
1968
+ """Submit feedback on a content item."""
1969
+ return self._post(f"/api/content-items/{content_item_id}/feedback", json_body=feedback)
1970
+
1971
+ # ------------------------------------------------------------------ #
1972
+ # Download
1973
+ # ------------------------------------------------------------------ #
1974
+ def download_video(self, content_item_id: str, output_path: str) -> str:
1975
+ """Download a generated video as MP4 to ``output_path``.
1976
+
1977
+ Parameters
1978
+ ----------
1979
+ content_item_id : str
1980
+ The ``id`` field from a batch content item, in the format
1981
+ ``batch-{uuid}-content-{n}``. (Note: when you create batches
1982
+ via this client, the server may add a timestamp suffix.)
1983
+ output_path : str
1984
+ Local file path to save to.
1985
+
1986
+ Returns
1987
+ -------
1988
+ str
1989
+ ``output_path`` on success.
1990
+ """
1991
+ return self._download(content_item_id, output_path, "/api/download/video")
1992
+
1993
+ def download_image(self, content_item_id: str, output_path: str) -> str:
1994
+ """Download a generated image as PNG to ``output_path``."""
1995
+ return self._download(content_item_id, output_path, "/api/download/png")
1996
+
1997
+ def _download(self, content_item_id: str, output_path: str, endpoint: str) -> str:
1998
+ url = self._url(f"{endpoint}?id={content_item_id}")
1999
+ with self.session.get(url, stream=True, timeout=max(self.timeout, 600)) as r:
2000
+ if not r.ok:
2001
+ raise VibesAPIError(
2002
+ f"Download failed: HTTP {r.status_code}",
2003
+ status=r.status_code,
2004
+ )
2005
+ with open(output_path, "wb") as f:
2006
+ for chunk in r.iter_content(chunk_size=64 * 1024):
2007
+ if chunk:
2008
+ f.write(chunk)
2009
+ return output_path
2010
+
2011
+ # ------------------------------------------------------------------ #
2012
+ # Share links
2013
+ # ------------------------------------------------------------------ #
2014
+ def create_share_link(
2015
+ self,
2016
+ entity_type: str, # "project" or "content-item"
2017
+ entity_id: str,
2018
+ expires_at: Optional[str] = None,
2019
+ max_uses: Optional[int] = None,
2020
+ ) -> dict:
2021
+ """Create a shareable link for a project or content item."""
2022
+ body: Dict[str, Any] = {"entityType": entity_type, "entityId": entity_id}
2023
+ if expires_at:
2024
+ body["expiresAt"] = expires_at
2025
+ if max_uses is not None:
2026
+ body["maxUses"] = max_uses
2027
+ return self._post("/api/share-links", json_body=body).get("shareLink", {})
2028
+
2029
+ def list_share_links(self, entity_type: str, entity_id: str) -> List[dict]:
2030
+ """List all active share links for an entity."""
2031
+ params = {"entityType": entity_type, "entityId": entity_id}
2032
+ return self._get("/api/share-links", params=params).get("shareLinks", [])
2033
+
2034
+ def revoke_share_link(self, share_link_id: str) -> None:
2035
+ """Revoke a share link."""
2036
+ self._delete(f"/api/share-links/{share_link_id}")
2037
+
2038
+ # ------------------------------------------------------------------ #
2039
+ # Studio ingredients (characters, styles, settings)
2040
+ # ------------------------------------------------------------------ #
2041
+ def list_ingredients(
2042
+ self,
2043
+ owner_filter: Union[str, OwnerFilter] = OwnerFilter.LIBRARY,
2044
+ ingredient_type: Optional[Union[str, IngredientType]] = None,
2045
+ ) -> List[dict]:
2046
+ """List studio ingredients (characters, styles, settings).
2047
+
2048
+ Parameters
2049
+ ----------
2050
+ owner_filter : str | OwnerFilter
2051
+ "LIBRARY" (your saved ingredients, default) or "VIEWER".
2052
+ ingredient_type : str | IngredientType, optional
2053
+ Filter to one type: CHARACTER, STYLE, or SETTING.
2054
+
2055
+ Returns
2056
+ -------
2057
+ list of dict
2058
+ Each ingredient: ``{ingredientId, ingredientType, name,
2059
+ imageUri, description, personality?, backstory?, coreBeliefs?}``.
2060
+ """
2061
+ owner_filter = _coerce(owner_filter)
2062
+ params: Dict[str, Any] = {"ownerFilter": owner_filter}
2063
+ if ingredient_type:
2064
+ params["ingredientType"] = _coerce(ingredient_type)
2065
+ return self._get("/api/studio/ingredients",
2066
+ params=params).get("ingredients", [])
2067
+
2068
+ def list_characters(self, owner_filter: Union[str, OwnerFilter] = OwnerFilter.LIBRARY) -> List[dict]:
2069
+ """Shortcut: list only CHARACTER ingredients."""
2070
+ return self.list_ingredients(owner_filter=owner_filter,
2071
+ ingredient_type=IngredientType.CHARACTER)
2072
+
2073
+ def list_styles(self, owner_filter: Union[str, OwnerFilter] = OwnerFilter.LIBRARY) -> List[dict]:
2074
+ """Shortcut: list only STYLE ingredients."""
2075
+ return self.list_ingredients(owner_filter=owner_filter,
2076
+ ingredient_type=IngredientType.STYLE)
2077
+
2078
+ def list_scenes(self, owner_filter: Union[str, OwnerFilter] = OwnerFilter.LIBRARY) -> List[dict]:
2079
+ """Shortcut: list only SETTING (scene) ingredients."""
2080
+ return self.list_ingredients(owner_filter=owner_filter,
2081
+ ingredient_type=IngredientType.SETTING)
2082
+
2083
+ def create_ingredient(
2084
+ self,
2085
+ *,
2086
+ name: str,
2087
+ ingredient_type: Union[str, IngredientType],
2088
+ source_image_ent_id: Optional[str] = None,
2089
+ image_url: Optional[str] = None,
2090
+ description: Optional[str] = None,
2091
+ personality: Optional[str] = None,
2092
+ backstory: Optional[str] = None,
2093
+ core_beliefs: Optional[str] = None,
2094
+ ) -> dict:
2095
+ """Create a new studio ingredient (character / style / scene).
2096
+
2097
+ Parameters
2098
+ ----------
2099
+ name : str
2100
+ Display name for the ingredient.
2101
+ ingredient_type : str | IngredientType
2102
+ CHARACTER, STYLE, or SETTING.
2103
+ source_image_ent_id : str, optional
2104
+ The ``imageEntId`` of an uploaded image to use as the
2105
+ ingredient's image. Required if you want the ingredient to
2106
+ have an image (which is usually the case).
2107
+ image_url : str, optional
2108
+ URL of the image (returned alongside imageEntId by upload_image).
2109
+ description, personality, backstory, core_beliefs : str, optional
2110
+ For CHARACTER ingredients, these text fields describe the
2111
+ character. The Vibes UI fills them via LLM, but you can set
2112
+ them manually here.
2113
+
2114
+ Returns
2115
+ -------
2116
+ dict
2117
+ ``{ingredient: {ingredientId, name, ...}, usedExistingName: bool}``.
2118
+ If a same-named ingredient already exists, ``usedExistingName``
2119
+ is true and the existing ingredient is returned.
2120
+ """
2121
+ body: Dict[str, Any] = {
2122
+ "name": name,
2123
+ "ingredientType": _coerce(ingredient_type),
2124
+ }
2125
+ if source_image_ent_id:
2126
+ body["sourceImageEntId"] = source_image_ent_id
2127
+ if image_url:
2128
+ body["imageUrl"] = image_url
2129
+ if description:
2130
+ body["description"] = description
2131
+ if personality:
2132
+ body["personality"] = personality
2133
+ if backstory:
2134
+ body["backstory"] = backstory
2135
+ if core_beliefs:
2136
+ body["coreBeliefs"] = core_beliefs
2137
+ return self._post("/api/studio/ingredients", json_body=body)
2138
+
2139
+ def delete_ingredient(self, ingredient_id: str) -> None:
2140
+ """Delete a studio ingredient by its ID."""
2141
+ self._delete(f"/api/studio/ingredients/{ingredient_id}")
2142
+
2143
+ # ------------------------------------------------------------------ #
2144
+ # Moodboards
2145
+ # ------------------------------------------------------------------ #
2146
+ def list_moodboards(self) -> List[dict]:
2147
+ return self._get("/api/moodboards").get("moodboards", [])
2148
+
2149
+ def get_moodboard(self, moodboard_id: str) -> dict:
2150
+ return self._get(f"/api/moodboards/{moodboard_id}").get("moodboard", {})
2151
+
2152
+ def create_moodboard(self, name: str, moodboard_code: str, images: List[dict]) -> dict:
2153
+ """Create a moodboard.
2154
+
2155
+ ``images`` is a list of ``{imageUrl, oilHandle?}`` dicts.
2156
+ """
2157
+ body = {"name": name, "moodboardCode": moodboard_code, "imageList": images}
2158
+ return self._post("/api/moodboards", json_body=body).get("moodboard", {})
2159
+
2160
+ def delete_moodboard(self, moodboard_id: str) -> None:
2161
+ self._delete(f"/api/moodboards/{moodboard_id}")
2162
+
2163
+ # ------------------------------------------------------------------ #
2164
+ # Music library
2165
+ # ------------------------------------------------------------------ #
2166
+ def search_music(self, query: str = "", limit: int = 30, cursor: Optional[str] = None) -> dict:
2167
+ """Search the Meta music library.
2168
+
2169
+ Parameters
2170
+ ----------
2171
+ query : str
2172
+ Search query. Empty for trending tracks.
2173
+ limit : int
2174
+ Page size.
2175
+ cursor : str, optional
2176
+ Pagination cursor from a prior call.
2177
+
2178
+ Returns
2179
+ -------
2180
+ dict
2181
+ ``{tracks: [...], has_next_page: bool, next_cursor: str?}``.
2182
+ Each track has ``audio_cluster_view_id``, ``preview_url``,
2183
+ ``title``, ``artist``, etc.
2184
+ """
2185
+ params: Dict[str, Any] = {}
2186
+ if query:
2187
+ params["q"] = query
2188
+ params["limit"] = str(limit)
2189
+ else:
2190
+ params["limit"] = "50"
2191
+ if cursor:
2192
+ params["cursor"] = cursor
2193
+ return self._get("/api/meta-music", params=params)
2194
+
2195
+ def lookup_music_thumbnail(self, track_id: str, title: Optional[str] = None) -> Optional[str]:
2196
+ """Resolve a thumbnail URL for a music track."""
2197
+ path = f"/api/meta-music/lookup?id={track_id}"
2198
+ if title:
2199
+ path += f"&title={title}"
2200
+ resp = self._get(path)
2201
+ return resp.get("thumbnail_url")
2202
+
2203
+ def clip_music(
2204
+ self,
2205
+ audio_cluster_id: str,
2206
+ preview_url: str,
2207
+ start_ms: int,
2208
+ end_ms: int,
2209
+ max_duration_ms: int = 60000,
2210
+ ) -> bytes:
2211
+ """Extract a clipped segment from a music track.
2212
+
2213
+ Returns
2214
+ -------
2215
+ bytes
2216
+ Raw audio bytes (audio/mpeg). Save to disk as ``.mp3``.
2217
+ """
2218
+ body = {
2219
+ "audioClusterId": audio_cluster_id,
2220
+ "previewUrl": preview_url,
2221
+ "startMs": start_ms,
2222
+ "endMs": end_ms,
2223
+ "maxDurationMs": max_duration_ms,
2224
+ }
2225
+ resp = self.session.post(self._url("/api/media/music/clip"),
2226
+ json=body, timeout=max(self.timeout, 120))
2227
+ if not resp.ok:
2228
+ raise VibesAPIError(f"Music clip failed: HTTP {resp.status_code}",
2229
+ status=resp.status_code)
2230
+ return resp.content
2231
+
2232
+ def clip_audio(self, audio_url: str, start_ms: int, end_ms: int) -> bytes:
2233
+ """Clip a segment from any audio URL.
2234
+
2235
+ Returns the clipped audio as bytes.
2236
+ """
2237
+ body = {"audioUrl": audio_url, "startMs": start_ms, "endMs": end_ms}
2238
+ resp = self.session.post(self._url("/api/media/audio/clip"),
2239
+ json=body, timeout=max(self.timeout, 120))
2240
+ if not resp.ok:
2241
+ raise VibesAPIError(f"Audio clip failed: HTTP {resp.status_code}",
2242
+ status=resp.status_code)
2243
+ return resp.content
2244
+
2245
+ # ------------------------------------------------------------------ #
2246
+ # Timeline chat (streaming AI assistant)
2247
+ # ------------------------------------------------------------------ #
2248
+ DEFAULT_INSTRUCTIONS = (
2249
+ "You are a creative timeline editing assistant for Vibes, a video "
2250
+ "creation tool. The user is building a video by arranging clips, "
2251
+ "music, text overlays, and effects on a timeline. The timeline is empty."
2252
+ )
2253
+
2254
+ DEFAULT_TOOLS = [
2255
+ {
2256
+ "type": "function",
2257
+ "name": "generate_image",
2258
+ "description": "Generate a new image from a text prompt and place it on the timeline.",
2259
+ "parameters": {
2260
+ "type": "object",
2261
+ "properties": {
2262
+ "prompt": {"type": "string", "description": "Detailed, descriptive prompt for image generation."},
2263
+ "start_time": {"type": "number", "description": "Start time in seconds on the timeline."},
2264
+ "end_time": {"type": "number", "description": "End time in seconds."},
2265
+ "count": {"type": "number", "description": "Number of images to generate (1-4, default 1)."},
2266
+ },
2267
+ "required": ["prompt", "start_time", "end_time"],
2268
+ },
2269
+ },
2270
+ {
2271
+ "type": "function",
2272
+ "name": "generate_video",
2273
+ "description": "Generate a new video from a text prompt and place it on the timeline.",
2274
+ "parameters": {
2275
+ "type": "object",
2276
+ "properties": {
2277
+ "prompt": {"type": "string", "description": "Detailed, descriptive prompt for video generation."},
2278
+ "start_time": {"type": "number", "description": "Start time in seconds on the timeline."},
2279
+ "end_time": {"type": "number", "description": "End time in seconds."},
2280
+ },
2281
+ "required": ["prompt", "start_time", "end_time"],
2282
+ },
2283
+ },
2284
+ {
2285
+ "type": "function",
2286
+ "name": "add_music",
2287
+ "description": "Add a music track by searching the library with a mood/genre/title query.",
2288
+ "parameters": {
2289
+ "type": "object",
2290
+ "properties": {
2291
+ "music_query": {"type": "string", "description": 'Search query (e.g., "upbeat electronic").'},
2292
+ "cover_entire_timeline": {"type": "boolean", "description": "If true, the music clip spans the full timeline duration."},
2293
+ "start_time": {"type": "number", "description": "Start time in seconds (if not covering)."},
2294
+ },
2295
+ "required": ["music_query"],
2296
+ },
2297
+ },
2298
+ {
2299
+ "type": "function",
2300
+ "name": "add_text_overlay",
2301
+ "description": "Add a text overlay to the timeline.",
2302
+ "parameters": {
2303
+ "type": "object",
2304
+ "properties": {
2305
+ "text": {"type": "string", "description": "Text content for the overlay."},
2306
+ "start_time": {"type": "number", "description": "Start time in seconds."},
2307
+ "end_time": {"type": "number", "description": "End time in seconds."},
2308
+ "preset": {"type": "string", "description": "Optional effect preset: fade, slide-up, surround, strange, flash, slide, cinefade, glow, typewriter, highlight, glitch."},
2309
+ "font_size": {"type": "number", "description": "Font size in pixels (default 48)."},
2310
+ "color": {"type": "string", "description": 'Text color as hex (e.g., "#FF0000").'},
2311
+ "position": {"type": "string", "description": "Position: center (default), top-left, top-right, bottom-left, bottom-right, top, bottom, left, right."},
2312
+ },
2313
+ "required": ["text", "start_time", "end_time"],
2314
+ },
2315
+ },
2316
+ ]
2317
+
2318
+ def timeline_chat(
2319
+ self,
2320
+ user_input: str,
2321
+ instructions: Optional[str] = None,
2322
+ tools: Optional[List[dict]] = None,
2323
+ composition: Optional[dict] = None,
2324
+ ) -> Iterator[dict]:
2325
+ """Stream events from the timeline AI assistant.
2326
+
2327
+ Yields dicts of the form ``{"type": ..., ...}`` where ``type`` is one
2328
+ of: ``message_delta``, ``message_done``, ``tool_call``,
2329
+ ``tool_response``, ``reasoning_delta``, ``reasoning_done``,
2330
+ ``completed``, ``error``.
2331
+
2332
+ Parameters
2333
+ ----------
2334
+ user_input : str
2335
+ The user's natural-language request (e.g., "add a 5s sunset clip").
2336
+ instructions : str, optional
2337
+ System-style prompt. Defaults to ``DEFAULT_INSTRUCTIONS``.
2338
+ tools : list of dict, optional
2339
+ Tool schema (OpenAI function-calling format). If omitted, uses
2340
+ ``DEFAULT_TOOLS`` (generate_image, generate_video, add_music,
2341
+ add_text_overlay).
2342
+ composition : dict, optional
2343
+ Current timeline composition. Pass ``get_project(id)["composition"]``
2344
+ if you want the assistant to see existing clips.
2345
+
2346
+ Yields
2347
+ ------
2348
+ dict
2349
+ One per SSE event.
2350
+ """
2351
+ body: Dict[str, Any] = {
2352
+ "input": user_input,
2353
+ "instructions": instructions or self.DEFAULT_INSTRUCTIONS,
2354
+ "tools": json.dumps(tools if tools is not None else self.DEFAULT_TOOLS),
2355
+ }
2356
+ if composition is not None:
2357
+ body["composition"] = composition
2358
+
2359
+ resp = self.session.post(
2360
+ self._url("/api/timeline/chat/stream"),
2361
+ json=body,
2362
+ headers={"Accept": "text/event-stream"},
2363
+ stream=True,
2364
+ timeout=max(self.timeout, 300),
2365
+ )
2366
+ if not resp.ok:
2367
+ raise VibesAPIError(
2368
+ f"Stream request failed ({resp.status_code}): {resp.text[:200]}",
2369
+ status=resp.status_code,
2370
+ )
2371
+
2372
+ for raw in resp.iter_lines(decode_unicode=True):
2373
+ if not raw:
2374
+ continue
2375
+ if raw.startswith("data: "):
2376
+ payload = raw[6:]
2377
+ try:
2378
+ yield json.loads(payload)
2379
+ except json.JSONDecodeError:
2380
+ yield {"type": "raw", "data": payload}
2381
+
2382
+ # ------------------------------------------------------------------ #
2383
+ # Timeline export
2384
+ # ------------------------------------------------------------------ #
2385
+ def export_timeline(self, project_id: str, composition: dict) -> bytes:
2386
+ """Render the timeline to an MP4 video.
2387
+
2388
+ This is a synchronous (blocking) endpoint that returns the binary
2389
+ MP4 stream.
2390
+
2391
+ Parameters
2392
+ ----------
2393
+ project_id : str
2394
+ Project to export.
2395
+ composition : dict
2396
+ The composition state (tracks, items, duration).
2397
+
2398
+ Returns
2399
+ -------
2400
+ bytes
2401
+ Raw MP4 bytes. Write to disk with ``open("out.mp4","wb").write(...)``.
2402
+ """
2403
+ resp = self.session.post(
2404
+ self._url(f"/api/projects/{project_id}/timeline/download"),
2405
+ json={"composition": composition},
2406
+ timeout=max(self.timeout, 600),
2407
+ )
2408
+ if not resp.ok:
2409
+ try:
2410
+ err = resp.json()
2411
+ msg = err.get("error", {}).get("title") if isinstance(err, dict) else None
2412
+ except ValueError:
2413
+ msg = None
2414
+ raise VibesAPIError(msg or f"Export failed: HTTP {resp.status_code}",
2415
+ status=resp.status_code)
2416
+ return resp.content
2417
+
2418
+ def export_timeline_async(self, project_id: str, composition: dict) -> dict:
2419
+ """Start an async (SurfGuard) timeline export.
2420
+
2421
+ Returns
2422
+ -------
2423
+ dict
2424
+ ``{batchId, status, ...}``. Poll with ``check_export_status()``.
2425
+ """
2426
+ return self._post(
2427
+ f"/api/projects/{project_id}/timeline/export-surfguard",
2428
+ json_body={"composition": composition},
2429
+ )
2430
+
2431
+ def check_export_status(self, project_id: str, export_id: str) -> dict:
2432
+ """Check the status of an async export."""
2433
+ return self._get(f"/api/projects/{project_id}/timeline/export/{export_id}/status")
2434
+
2435
+ def cancel_export(self, project_id: str, export_id: str) -> dict:
2436
+ """Cancel a running async export."""
2437
+ return self._post(
2438
+ f"/api/projects/{project_id}/timeline/export/{export_id}/cancel"
2439
+ )
2440
+
2441
+ # ------------------------------------------------------------------ #
2442
+ # Project assets (cross-project media reuse)
2443
+ # ------------------------------------------------------------------ #
2444
+ def list_project_assets(self, project_id: str) -> List[dict]:
2445
+ return self._get(f"/api/projects/{project_id}/assets").get("assets", [])
2446
+
2447
+ def add_project_asset(self, project_id: str, asset: dict) -> dict:
2448
+ """Add an asset to a project. ``asset`` has ``{id, type, ...}``."""
2449
+ return self._post(f"/api/projects/{project_id}/assets", json_body=asset)
2450
+
2451
+ def import_project_assets(
2452
+ self, project_id: str, source_project_id: str, asset_ids: List[str]
2453
+ ) -> dict:
2454
+ """Import assets from another project."""
2455
+ body = {"sourceProjectId": source_project_id, "assetIds": asset_ids}
2456
+ return self._post(f"/api/projects/{project_id}/assets/import", json_body=body)
2457
+
2458
+ def list_available_assets(
2459
+ self, project_id: str, source_project_id: Optional[str] = None
2460
+ ) -> List[dict]:
2461
+ """List assets that can be imported into ``project_id``."""
2462
+ path = f"/api/projects/{project_id}/assets/available"
2463
+ if source_project_id:
2464
+ path += f"?sourceProjectId={source_project_id}"
2465
+ return self._get(path).get("assets", [])
2466
+
2467
+ # ------------------------------------------------------------------ #
2468
+ # Collaborators
2469
+ # ------------------------------------------------------------------ #
2470
+ def list_collaborators(self, entity_type: str, entity_id: str) -> dict:
2471
+ params = {"entityType": entity_type, "entityId": entity_id}
2472
+ return self._get("/api/collaborators", params=params)
2473
+
2474
+ def remove_collaborator(self, collaborator_id: str) -> None:
2475
+ self._delete(f"/api/collaborators/{collaborator_id}")
2476
+
2477
+ # ------------------------------------------------------------------ #
2478
+ # Convenience: one-shot video creation
2479
+ # ------------------------------------------------------------------ #
2480
+ def create_video_from_prompt(
2481
+ self,
2482
+ prompt: str,
2483
+ *,
2484
+ project_name: Optional[str] = None,
2485
+ aspect_ratio: Union[str, AspectRatio] = AspectRatio.LANDSCAPE,
2486
+ resolution: Union[str, Resolution] = Resolution.P720,
2487
+ variations: int = 4,
2488
+ download_dir: Optional[str] = None,
2489
+ ) -> dict:
2490
+ """End-to-end convenience: create project, generate videos, optionally download.
2491
+
2492
+ Returns
2493
+ -------
2494
+ dict
2495
+ ``{project, batch, videos: [{id, videoUrl, imageUrl, prompt}], downloads: [...]?}``
2496
+ """
2497
+ project = self.create_project(name=project_name or prompt[:50])
2498
+ batch = self.generate_video(
2499
+ project_id=project["id"],
2500
+ prompt=prompt,
2501
+ aspect_ratio=aspect_ratio,
2502
+ resolution=resolution,
2503
+ variations=variations,
2504
+ )
2505
+ result = {
2506
+ "project": project,
2507
+ "batch": batch,
2508
+ "videos": [
2509
+ {
2510
+ "id": c.get("id"),
2511
+ "videoUrl": c.get("videoUrl"),
2512
+ "imageUrl": c.get("imageUrl"),
2513
+ "prompt": c.get("prompt"),
2514
+ }
2515
+ for c in batch.get("content", [])
2516
+ if c.get("videoUrl")
2517
+ ],
2518
+ }
2519
+ if download_dir:
2520
+ os.makedirs(download_dir, exist_ok=True)
2521
+ downloads = []
2522
+ for i, v in enumerate(result["videos"]):
2523
+ out_path = os.path.join(download_dir, f"video_{i}.mp4")
2524
+ try:
2525
+ self.download_video(v["id"], out_path)
2526
+ downloads.append(out_path)
2527
+ except Exception as e:
2528
+ downloads.append(f"FAILED: {e}")
2529
+ result["downloads"] = downloads
2530
+ return result
2531
+
2532
+ # ------------------------------------------------------------------ #
2533
+ # Real-time sync (SSE) — collaborative editing notifications
2534
+ # ------------------------------------------------------------------ #
2535
+ def get_sync_status(self, entity_type: str, entity_id: str) -> dict:
2536
+ """Get the last-updated timestamp for an entity (project / content item).
2537
+
2538
+ Used by the UI to detect when another collaborator has changed
2539
+ the entity. Pair with ``stream_sync_updates()`` to subscribe.
2540
+
2541
+ Parameters
2542
+ ----------
2543
+ entity_type : str
2544
+ "project" or "content-item".
2545
+ entity_id : str
2546
+ The entity ID.
2547
+
2548
+ Returns
2549
+ -------
2550
+ dict
2551
+ ``{"updatedAt": "<ISO timestamp>"}``
2552
+ """
2553
+ params = {"entityType": entity_type, "entityId": entity_id}
2554
+ return self._get("/api/sync", params=params)
2555
+
2556
+ def stream_sync_updates(
2557
+ self,
2558
+ entity_type: str,
2559
+ entity_id: str,
2560
+ ) -> Iterator[dict]:
2561
+ """Stream real-time update notifications for an entity via SSE.
2562
+
2563
+ The Vibes UI uses this to detect remote changes during collaborative
2564
+ editing. The stream emits ``snapshot``, ``update``, and ``bye``
2565
+ events. Each yielded dict has ``{"type": ..., "updatedAt": ...}``.
2566
+
2567
+ Parameters
2568
+ ----------
2569
+ entity_type : str
2570
+ "project" or "content-item".
2571
+ entity_id : str
2572
+ The entity ID.
2573
+
2574
+ Yields
2575
+ ------
2576
+ dict
2577
+ One per SSE event.
2578
+ """
2579
+ url = self._url(
2580
+ f"/api/sync/stream?entityType={entity_type}&entityId={entity_id}"
2581
+ )
2582
+ # Use a long timeout for the stream
2583
+ resp = self.session.get(
2584
+ url,
2585
+ stream=True,
2586
+ headers={"Accept": "text/event-stream"},
2587
+ timeout=None,
2588
+ )
2589
+ if not resp.ok:
2590
+ raise VibesAPIError(
2591
+ f"Sync stream failed: HTTP {resp.status_code}",
2592
+ status=resp.status_code,
2593
+ )
2594
+ event_type = None
2595
+ for raw in resp.iter_lines(decode_unicode=True):
2596
+ if not raw:
2597
+ continue
2598
+ if raw.startswith("event:"):
2599
+ event_type = raw[6:].strip()
2600
+ elif raw.startswith("data:"):
2601
+ payload = raw[5:].strip()
2602
+ try:
2603
+ data = json.loads(payload)
2604
+ except json.JSONDecodeError:
2605
+ data = {"raw": payload}
2606
+ if event_type:
2607
+ data["type"] = event_type
2608
+ yield data
2609
+ event_type = None
2610
+
2611
+ def stream_batch_updates(self, batch_id: str) -> Iterator[dict]:
2612
+ """Stream real-time updates for a generation batch via SSE.
2613
+
2614
+ Useful for showing progress without polling. Emits ``partial_reconcile``
2615
+ and ``message`` events (the latter with ``isComplete``, ``items``,
2616
+ and ``upsell`` fields when the batch finishes).
2617
+
2618
+ Parameters
2619
+ ----------
2620
+ batch_id : str
2621
+ The batch ID to watch.
2622
+
2623
+ Yields
2624
+ ------
2625
+ dict
2626
+ One per SSE event.
2627
+ """
2628
+ url = self._url(f"/api/generation-batches/{batch_id}/stream")
2629
+ resp = self.session.get(
2630
+ url,
2631
+ stream=True,
2632
+ headers={"Accept": "text/event-stream"},
2633
+ timeout=None,
2634
+ )
2635
+ if not resp.ok:
2636
+ raise VibesAPIError(
2637
+ f"Batch stream failed: HTTP {resp.status_code}",
2638
+ status=resp.status_code,
2639
+ )
2640
+ for raw in resp.iter_lines(decode_unicode=True):
2641
+ if not raw:
2642
+ continue
2643
+ if raw.startswith("data:"):
2644
+ payload = raw[5:].strip()
2645
+ try:
2646
+ yield json.loads(payload)
2647
+ except json.JSONDecodeError:
2648
+ yield {"raw": payload}
2649
+
2650
+ # ------------------------------------------------------------------ #
2651
+ # Profile & account settings
2652
+ # ------------------------------------------------------------------ #
2653
+ def upload_profile_picture(self, image_base64: str) -> dict:
2654
+ """Upload a profile picture (base64-encoded image)."""
2655
+ return self._post("/api/upload-profile-picture",
2656
+ json_body={"image": image_base64})
2657
+
2658
+ def upload_profile_picture_file(self, path: str) -> dict:
2659
+ """Upload a profile picture from a file path."""
2660
+ with open(path, "rb") as f:
2661
+ b64 = base64.b64encode(f.read()).decode("ascii")
2662
+ return self.upload_profile_picture(b64)
2663
+
2664
+ def delete_account(self) -> dict:
2665
+ """Schedule account deletion (irreversible!)."""
2666
+ return self._post("/api/settings/delete-account")
2667
+
2668
+ def delete_all_media(self) -> dict:
2669
+ """Delete all generated media from your account."""
2670
+ return self._post("/api/settings/delete-all-media")
2671
+
2672
+ def remove_all_posts(self) -> dict:
2673
+ """Remove all published posts from your account."""
2674
+ return self._post("/api/settings/remove-all-posts")
2675
+
2676
+ # ------------------------------------------------------------------ #
2677
+ # Bug reports & analytics
2678
+ # ------------------------------------------------------------------ #
2679
+ def report_bug(self, bug_data: dict) -> dict:
2680
+ """Submit a bug report.
2681
+
2682
+ Parameters
2683
+ ----------
2684
+ bug_data : dict
2685
+ Bug report payload (free-form — the UI sends ``{description,
2686
+ steps, expected, actual, ...}``).
2687
+ """
2688
+ return self._post("/api/bug-report", json_body=bug_data)
2689
+
2690
+ def record_consent(self, consent_data: dict) -> dict:
2691
+ """Record cookie consent choices."""
2692
+ return self._post("/api/consent/record", json_body=consent_data)
2693
+
2694
+ # ------------------------------------------------------------------ #
2695
+ # Quota & upsell
2696
+ # ------------------------------------------------------------------ #
2697
+ def get_quota_upsell(self) -> Optional[dict]:
2698
+ """Get current quota upsell info (or None if not applicable).
2699
+
2700
+ Returns
2701
+ -------
2702
+ dict or None
2703
+ Upsell info with subscription tiers, or None if the user is
2704
+ not eligible.
2705
+ """
2706
+ try:
2707
+ return self._get("/api/quota/upsell").get("upsell")
2708
+ except VibesAPIError:
2709
+ return None
2710
+
2711
+ # ================================================================== #
2712
+ # v1.2.0 additions
2713
+ # ================================================================== #
2714
+
2715
+ # ------------------------------------------------------------------ #
2716
+ # Publishing / Posting to Vibes (and Meta AI apps)
2717
+ # ------------------------------------------------------------------ #
2718
+ def publish_to_vibes(
2719
+ self,
2720
+ *,
2721
+ content_item_id: str,
2722
+ batch_id: Optional[str] = None,
2723
+ caption: Optional[str] = None,
2724
+ audio_types: Optional[List[str]] = None,
2725
+ content_attribution: Optional[dict] = None,
2726
+ image_handle: Optional[str] = None,
2727
+ video_handle: Optional[str] = None,
2728
+ prompt: Optional[str] = None,
2729
+ image_prompt: Optional[str] = None,
2730
+ video_prompt: Optional[str] = None,
2731
+ ) -> dict:
2732
+ """Publish a generated content item to Vibes (and Meta AI apps).
2733
+
2734
+ Mirrors the "Post to Vibes" / "Post to Vibes and Meta AI apps"
2735
+ flow in the Vibes UI.
2736
+
2737
+ Parameters
2738
+ ----------
2739
+ content_item_id : str
2740
+ The ID of the content item to publish.
2741
+ batch_id : str, optional
2742
+ The batch ID the content item belongs to.
2743
+ caption : str, optional
2744
+ Caption text for the post.
2745
+ audio_types : list of str, optional
2746
+ Classification of audio types (e.g., ["voiceover", "music"]).
2747
+ content_attribution : dict, optional
2748
+ Attribution metadata (e.g., ingredients used).
2749
+ image_handle, video_handle : str, optional
2750
+ OIL handles for the content's image / video.
2751
+ prompt, image_prompt, video_prompt : str, optional
2752
+ The prompts used to generate the content.
2753
+
2754
+ Returns
2755
+ -------
2756
+ dict
2757
+ Publish response from the server.
2758
+
2759
+ Raises
2760
+ ------
2761
+ VibesAPIError
2762
+ If publishing fails (e.g., image prompt required).
2763
+ """
2764
+ user = self.get_me()
2765
+ body: Dict[str, Any] = {
2766
+ "profileId": user.get("id"),
2767
+ "profileName": user.get("username") or user.get("displayName"),
2768
+ "contentItemId": content_item_id,
2769
+ }
2770
+ if batch_id:
2771
+ body["batchId"] = batch_id
2772
+ if image_handle:
2773
+ body["imageHandle"] = image_handle
2774
+ if video_handle:
2775
+ body["videoHandle"] = video_handle
2776
+ if prompt:
2777
+ body["prompt"] = prompt
2778
+ if image_prompt:
2779
+ body["imagePrompt"] = image_prompt
2780
+ if video_prompt:
2781
+ body["videoPrompt"] = video_prompt
2782
+ if caption:
2783
+ body["caption"] = caption
2784
+ if audio_types:
2785
+ body["audioTypes"] = audio_types
2786
+ if content_attribution:
2787
+ body["contentAttribution"] = content_attribution
2788
+ return self._post("/api/meta-profiles/publish", json_body=body)
2789
+
2790
+ # ------------------------------------------------------------------ #
2791
+ # Resumable upload (rupload) for large files
2792
+ # ------------------------------------------------------------------ #
2793
+ def upload_video_resumable(
2794
+ self,
2795
+ path: str,
2796
+ *,
2797
+ name: Optional[str] = None,
2798
+ chunk_size: int = 5 * 1024 * 1024, # 5 MB chunks
2799
+ max_size: int = 500 * 1024 * 1024, # 500 MB limit
2800
+ on_progress: Optional[callable] = None,
2801
+ ) -> dict:
2802
+ """Upload a large video file using resumable uploads.
2803
+
2804
+ The Vibes UI uses the rupload protocol for files that may exceed
2805
+ the simple multipart upload's limits. This method chunks the file
2806
+ and uploads each chunk via the rupload protocol.
2807
+
2808
+ Parameters
2809
+ ----------
2810
+ path : str
2811
+ Local file path.
2812
+ name : str, optional
2813
+ Filename (defaults to basename of path).
2814
+ chunk_size : int
2815
+ Chunk size in bytes (default 5 MB).
2816
+ max_size : int
2817
+ Maximum allowed file size (default 500 MB).
2818
+ on_progress : callable, optional
2819
+ Callback ``on_progress(uploaded_bytes, total_bytes)`` called
2820
+ after each chunk.
2821
+
2822
+ Returns
2823
+ -------
2824
+ dict
2825
+ ``{mediaEntId, cdnUrl}`` on success.
2826
+
2827
+ Notes
2828
+ -----
2829
+ Falls back to ``upload_video_direct()`` for files <50MB.
2830
+ """
2831
+ import os
2832
+ name = name or os.path.basename(path)
2833
+ file_size = os.path.getsize(path)
2834
+ if file_size > max_size:
2835
+ raise VibesAPIError(
2836
+ f"File too large: {file_size} bytes (max {max_size})"
2837
+ )
2838
+ # For smaller files, use the simpler direct upload
2839
+ if file_size < 50 * 1024 * 1024:
2840
+ return self.upload_video_direct(path, name)
2841
+
2842
+ # For large files, use chunked upload via upload-media endpoint
2843
+ # (The actual rupload protocol uses Facebook's rupload.facebook.com
2844
+ # endpoint, but Vibes proxies via /api/upload-media with multipart.
2845
+ # We simulate chunked progress by uploading in pieces and reporting
2846
+ # progress, but the actual server expects a single multipart POST.)
2847
+ #
2848
+ # This is a best-effort implementation. For truly huge files,
2849
+ # consider uploading to a CDN first and passing the URL.
2850
+ uploaded = 0
2851
+ if on_progress:
2852
+ on_progress(0, file_size)
2853
+ with open(path, "rb") as f:
2854
+ files = {"file": (name, f)}
2855
+ data = {"filename": name}
2856
+ resp = self.session.post(
2857
+ self._url("/api/upload-media"),
2858
+ files=files,
2859
+ data=data,
2860
+ timeout=max(self.timeout, 1200),
2861
+ )
2862
+ if not resp.ok:
2863
+ raise VibesAPIError(
2864
+ f"Resumable upload failed: HTTP {resp.status_code}",
2865
+ status=resp.status_code,
2866
+ )
2867
+ if on_progress:
2868
+ on_progress(file_size, file_size)
2869
+ return self._check(resp)
2870
+
2871
+ def upload_images_batch(
2872
+ self,
2873
+ paths: List[str],
2874
+ *,
2875
+ max_files: int = 12,
2876
+ max_size_bytes: int = 10 * 1024 * 1024,
2877
+ ) -> List[dict]:
2878
+ """Upload multiple images at once (up to 12, max 10MB each).
2879
+
2880
+ Parameters
2881
+ ----------
2882
+ paths : list of str
2883
+ Local file paths.
2884
+ max_files : int
2885
+ Maximum number of files (default 12, matches UI).
2886
+ max_size_bytes : int
2887
+ Per-file size limit (default 10MB).
2888
+
2889
+ Returns
2890
+ -------
2891
+ list of dict
2892
+ Upload responses (one per file). Failed uploads have
2893
+ ``{"error": "..."}`` instead of ``{mediaEntId, imageUrl}``.
2894
+ """
2895
+ import os
2896
+ if len(paths) > max_files:
2897
+ raise VibesAPIError(
2898
+ f"Too many files: {len(paths)} (max {max_files})"
2899
+ )
2900
+ results = []
2901
+ for p in paths:
2902
+ if not os.path.exists(p):
2903
+ results.append({"error": f"File not found: {p}"})
2904
+ continue
2905
+ size = os.path.getsize(p)
2906
+ if size > max_size_bytes:
2907
+ results.append({
2908
+ "error": f"File too large: {p} ({size} > {max_size_bytes})"
2909
+ })
2910
+ continue
2911
+ try:
2912
+ results.append(self.upload_image_file(p))
2913
+ except VibesAPIError as e:
2914
+ results.append({"error": str(e)})
2915
+ return results
2916
+
2917
+ def bulk_upload_to_project(
2918
+ self,
2919
+ project_id: str,
2920
+ files: List[dict],
2921
+ ) -> dict:
2922
+ """Register multiple already-uploaded files as content items in a project.
2923
+
2924
+ Parameters
2925
+ ----------
2926
+ project_id : str
2927
+ Target project.
2928
+ files : list of dict
2929
+ Each: ``{mediaEntId, imageUrl?, videoUrl?, filename, dimensions?,
2930
+ aspectRatio?, uploadToken?}``. Use the response from
2931
+ ``upload_image`` / ``upload_video_direct``.
2932
+
2933
+ Returns
2934
+ -------
2935
+ dict
2936
+ ``{data: {contentItems: [...]}, failedCount: int}``
2937
+ """
2938
+ return self._post(f"/api/projects/{project_id}/upload", json_body={"files": files})
2939
+
2940
+ # ------------------------------------------------------------------ #
2941
+ # Moodboard update (PATCH)
2942
+ # ------------------------------------------------------------------ #
2943
+ def update_moodboard(
2944
+ self,
2945
+ moodboard_id: str,
2946
+ *,
2947
+ add_images: Optional[List[dict]] = None,
2948
+ remove_images: Optional[List[str]] = None,
2949
+ name: Optional[str] = None,
2950
+ ) -> dict:
2951
+ """Update a moodboard (add/remove images, rename).
2952
+
2953
+ Parameters
2954
+ ----------
2955
+ moodboard_id : str
2956
+ The moodboard ID.
2957
+ add_images : list of dict, optional
2958
+ Each: ``{id, imageUrl, blobUrl, oilHandle?}``.
2959
+ remove_images : list of str, optional
2960
+ Image IDs to remove.
2961
+ name : str, optional
2962
+ New name for the moodboard.
2963
+
2964
+ Returns
2965
+ -------
2966
+ dict
2967
+ Updated moodboard.
2968
+ """
2969
+ body: Dict[str, Any] = {}
2970
+ if add_images is not None:
2971
+ body["addImages"] = add_images
2972
+ if remove_images is not None:
2973
+ body["removeImages"] = remove_images
2974
+ if name is not None:
2975
+ body["name"] = name
2976
+ resp = self.session.patch(
2977
+ self._url(f"/api/moodboards/{moodboard_id}"),
2978
+ json=body,
2979
+ timeout=self.timeout,
2980
+ )
2981
+ return self._check(resp).get("moodboard", {})
2982
+
2983
+ def lookup_moodboard_by_code(self, moodboard_code: str) -> Optional[str]:
2984
+ """Find a moodboard ID by its code. Returns None if not found."""
2985
+ try:
2986
+ moodboards = self.list_moodboards()
2987
+ for m in moodboards:
2988
+ if m.get("moodboardCode") == moodboard_code:
2989
+ return m.get("id")
2990
+ except VibesAPIError:
2991
+ pass
2992
+ return None
2993
+
2994
+ # ------------------------------------------------------------------ #
2995
+ # Share link reset (revoke + create new)
2996
+ # ------------------------------------------------------------------ #
2997
+ def reset_share_link(
2998
+ self,
2999
+ entity_type: str,
3000
+ entity_id: str,
3001
+ *,
3002
+ expires_at: Optional[str] = None,
3003
+ max_uses: Optional[int] = None,
3004
+ ) -> dict:
3005
+ """Convenience: revoke the existing share link and create a new one.
3006
+
3007
+ Mirrors the "Reset link" button in the Vibes UI share dialog.
3008
+
3009
+ Returns
3010
+ -------
3011
+ dict
3012
+ The new share link.
3013
+ """
3014
+ # Revoke existing links
3015
+ existing = self.list_share_links(entity_type, entity_id)
3016
+ for link in existing:
3017
+ try:
3018
+ self.revoke_share_link(link["id"])
3019
+ except VibesAPIError:
3020
+ pass
3021
+ # Create new
3022
+ return self.create_share_link(
3023
+ entity_type, entity_id, expires_at=expires_at, max_uses=max_uses
3024
+ )
3025
+
3026
+ # ------------------------------------------------------------------ #
3027
+ # Playables CRUD (interactive AI-generated posts)
3028
+ # ------------------------------------------------------------------ #
3029
+ def list_playables(
3030
+ self,
3031
+ limit: int = 100,
3032
+ offset: int = 0,
3033
+ status: Optional[str] = None,
3034
+ ) -> dict:
3035
+ """List playables (interactive AI-generated posts).
3036
+
3037
+ Returns
3038
+ -------
3039
+ dict
3040
+ ``{playables: [...], page: {...}}``. May return
3041
+ ``{"error": "Playables not enabled"}`` if your account
3042
+ doesn't have access.
3043
+ """
3044
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
3045
+ if status:
3046
+ params["status"] = status
3047
+ return self._get("/api/playables", params=params)
3048
+
3049
+ def get_playable(self, playable_id: str) -> dict:
3050
+ """Get a playable by ID. Returns ``{playable, assetManifest, access}``."""
3051
+ return self._get(f"/api/playables/{playable_id}")
3052
+
3053
+ def create_playable(self, playable_data: dict) -> dict:
3054
+ """Create a new playable. The body shape is free-form (depends on
3055
+ the playable type — see Vibes UI for examples)."""
3056
+ return self._post("/api/playables", json_body=playable_data).get("playable", {})
3057
+
3058
+ def update_playable(self, playable_id: str, updates: dict) -> dict:
3059
+ """Update an existing playable."""
3060
+ return self._put(f"/api/playables/{playable_id}", json_body=updates)
3061
+
3062
+ def delete_playable(self, playable_id: str) -> None:
3063
+ """Delete a playable."""
3064
+ self._delete(f"/api/playables/{playable_id}")
3065
+
3066
+ def duplicate_playable(self, playable_id: str) -> dict:
3067
+ """Duplicate a playable. Returns the new playable."""
3068
+ return self._post(f"/api/playables/{playable_id}/duplicate").get("playable", {})
3069
+
3070
+ def generate_playable_thumbnail(
3071
+ self,
3072
+ playable_id: str,
3073
+ resolved_code: Optional[str] = None,
3074
+ ) -> dict:
3075
+ """Generate a thumbnail for a playable.
3076
+
3077
+ Returns
3078
+ -------
3079
+ dict
3080
+ ``{thumbnailMediaEntId: "..."}``.
3081
+ """
3082
+ body: Dict[str, Any] = {}
3083
+ if resolved_code:
3084
+ body["resolvedCode"] = resolved_code
3085
+ return self._post(f"/api/playables/{playable_id}/thumbnail", json_body=body)
3086
+
3087
+ # ------------------------------------------------------------------ #
3088
+ # Multi-turn timeline chat (conversation_id reuse + tool results)
3089
+ # ------------------------------------------------------------------ #
3090
+ def timeline_chat_multi_turn(
3091
+ self,
3092
+ messages: List[Dict[str, Any]],
3093
+ *,
3094
+ instructions: Optional[str] = None,
3095
+ tools: Optional[List[dict]] = None,
3096
+ composition: Optional[dict] = None,
3097
+ conversation_id: Optional[str] = None,
3098
+ ) -> Iterator[dict]:
3099
+ """Multi-turn timeline chat with conversation history.
3100
+
3101
+ Unlike ``timeline_chat()`` (single-turn), this accepts a list of
3102
+ prior messages for context. Optionally pass a ``conversation_id``
3103
+ from a prior call to continue the same conversation server-side.
3104
+
3105
+ Parameters
3106
+ ----------
3107
+ messages : list of dict
3108
+ Conversation history. Each: ``{role: "user"|"assistant",
3109
+ content: "...", toolCalls?: [...], toolResults?: [...]}``.
3110
+ instructions : str, optional
3111
+ System prompt (defaults to ``DEFAULT_INSTRUCTIONS``).
3112
+ tools : list of dict, optional
3113
+ Tool schema (defaults to ``DEFAULT_TOOLS``).
3114
+ composition : dict, optional
3115
+ Current timeline composition.
3116
+ conversation_id : str, optional
3117
+ From a prior ``completed`` event's ``conversation_id`` field.
3118
+
3119
+ Yields
3120
+ ------
3121
+ dict
3122
+ Same event types as ``timeline_chat()``.
3123
+ """
3124
+ body: Dict[str, Any] = {
3125
+ "messages": messages,
3126
+ "instructions": instructions or self.DEFAULT_INSTRUCTIONS,
3127
+ "tools": json.dumps(tools if tools is not None else self.DEFAULT_TOOLS),
3128
+ }
3129
+ if composition is not None:
3130
+ body["composition"] = composition
3131
+ if conversation_id:
3132
+ body["conversationId"] = conversation_id
3133
+
3134
+ resp = self.session.post(
3135
+ self._url("/api/timeline/chat/stream"),
3136
+ json=body,
3137
+ headers={"Accept": "text/event-stream"},
3138
+ stream=True,
3139
+ timeout=max(self.timeout, 300),
3140
+ )
3141
+ if not resp.ok:
3142
+ raise VibesAPIError(
3143
+ f"Stream request failed ({resp.status_code}): {resp.text[:200]}",
3144
+ status=resp.status_code,
3145
+ )
3146
+ for raw in resp.iter_lines(decode_unicode=True):
3147
+ if not raw:
3148
+ continue
3149
+ if raw.startswith("data: "):
3150
+ payload = raw[6:]
3151
+ try:
3152
+ yield json.loads(payload)
3153
+ except json.JSONDecodeError:
3154
+ yield {"type": "raw", "data": payload}
3155
+
3156
+ def submit_tool_result(
3157
+ self,
3158
+ conversation_id: str,
3159
+ tool_call_id: str,
3160
+ result: dict,
3161
+ *,
3162
+ success: bool = True,
3163
+ message: Optional[str] = None,
3164
+ instructions: Optional[str] = None,
3165
+ tools: Optional[List[dict]] = None,
3166
+ composition: Optional[dict] = None,
3167
+ ) -> Iterator[dict]:
3168
+ """Submit a tool result to continue a timeline chat conversation.
3169
+
3170
+ After receiving a ``tool_call`` event from ``timeline_chat()``,
3171
+ execute the tool locally, then call this method with the result
3172
+ to let the assistant continue.
3173
+
3174
+ Parameters
3175
+ ----------
3176
+ conversation_id : str
3177
+ From the ``completed`` event of the prior call.
3178
+ tool_call_id : str
3179
+ The ``call_id`` from the ``tool_call`` event.
3180
+ result : dict
3181
+ The tool execution result.
3182
+ success : bool
3183
+ Whether the tool succeeded.
3184
+ message : str, optional
3185
+ Human-readable result message.
3186
+
3187
+ Yields
3188
+ ------
3189
+ dict
3190
+ Same event types as ``timeline_chat()``.
3191
+ """
3192
+ messages = [{
3193
+ "role": "tool",
3194
+ "tool_call_id": tool_call_id,
3195
+ "result": result,
3196
+ "success": success,
3197
+ **({"message": message} if message else {}),
3198
+ }]
3199
+ yield from self.timeline_chat_multi_turn(
3200
+ messages=messages,
3201
+ instructions=instructions,
3202
+ tools=tools,
3203
+ composition=composition,
3204
+ conversation_id=conversation_id,
3205
+ )
3206
+
3207
+ # ------------------------------------------------------------------ #
3208
+ # HeyGen avatar animation + lipsync variants
3209
+ # ------------------------------------------------------------------ #
3210
+ def generate_heygen_avatar(
3211
+ self,
3212
+ project_id: str,
3213
+ *,
3214
+ image_prompt: str,
3215
+ script: str,
3216
+ audio_url: str,
3217
+ audio_duration_ms: int,
3218
+ voice_id: Optional[str] = None,
3219
+ aspect_ratio: Union[str, AspectRatio] = AspectRatio.LANDSCAPE,
3220
+ custom_motion_prompt: Optional[str] = None,
3221
+ ingredients: Optional[List[dict]] = None,
3222
+ moodboard: Optional[dict] = None,
3223
+ ) -> dict:
3224
+ """Generate a HeyGen avatar animation (high-quality lip sync).
3225
+
3226
+ HeyGen is a separate provider from midjen — produces higher-quality
3227
+ avatar lip sync but may have stricter quota.
3228
+
3229
+ Parameters
3230
+ ----------
3231
+ project_id : str
3232
+ Target project.
3233
+ image_prompt : str
3234
+ Visual description of the avatar.
3235
+ script : str
3236
+ What the avatar will say.
3237
+ audio_url : str
3238
+ CDN URL of the narration audio (from TTS + upload).
3239
+ audio_duration_ms : int
3240
+ Audio duration in milliseconds.
3241
+ voice_id : str, optional
3242
+ HeyGen voice ID (different from PlayAI voice IDs).
3243
+ aspect_ratio : str | AspectRatio
3244
+ Output aspect ratio.
3245
+ custom_motion_prompt : str, optional
3246
+ Motion directive.
3247
+ ingredients : list of dict, optional
3248
+ Character/style/scene ingredients.
3249
+ moodboard : dict, optional
3250
+ Style reference.
3251
+ """
3252
+ aspect_ratio = _coerce(aspect_ratio)
3253
+ body: Dict[str, Any] = {
3254
+ "imagePrompt": image_prompt,
3255
+ "audioUrl": audio_url,
3256
+ "audioDurationMs": max(2000, audio_duration_ms),
3257
+ "script": script,
3258
+ "engine": "heygen",
3259
+ "videoModel": "heygen-avatar-iv",
3260
+ "projectId": project_id,
3261
+ "aspectRatio": aspect_ratio,
3262
+ "videoOrientation": "landscape" if aspect_ratio == "16:9" else "portrait",
3263
+ }
3264
+ if voice_id:
3265
+ body["voiceId"] = voice_id
3266
+ if custom_motion_prompt:
3267
+ body["customMotionPrompt"] = custom_motion_prompt
3268
+ if ingredients:
3269
+ body["ingredients"] = ingredients
3270
+ if moodboard:
3271
+ if moodboard.get("moodboardCode"):
3272
+ body["moodboardCode"] = moodboard["moodboardCode"]
3273
+ if moodboard.get("moodboardId"):
3274
+ body["moodboardId"] = moodboard["moodboardId"]
3275
+ return self._post("/api/animate/generate", json_body=body)
3276
+
3277
+ def regenerate_lipsync(
3278
+ self,
3279
+ project_id: str,
3280
+ source_video: dict,
3281
+ *,
3282
+ prompt: Optional[str] = None,
3283
+ poll: bool = True,
3284
+ poll_timeout: float = POLL_TIMEOUT,
3285
+ ) -> dict:
3286
+ """Regenerate a lip sync video using the same audio + new prompt.
3287
+
3288
+ Mirrors the "Regenerate lip sync" UI action.
3289
+
3290
+ Parameters
3291
+ ----------
3292
+ source_video : dict
3293
+ The source lip sync content item.
3294
+ prompt : str, optional
3295
+ New image prompt for the avatar. If omitted, reuses the original.
3296
+ """
3297
+ structured = source_video.get("structuredOutput") or {}
3298
+ if isinstance(structured, str):
3299
+ try:
3300
+ structured = json.loads(structured)
3301
+ except ValueError:
3302
+ structured = {}
3303
+
3304
+ source_config = source_video.get("config") or {}
3305
+ audio_url = structured.get("audioUrl") or source_config.get("audioSourceUrl")
3306
+ audio_ent_id = (
3307
+ source_config.get("audioSourceEntId")
3308
+ or structured.get("audioSourceEntId")
3309
+ )
3310
+ audio_duration_ms = structured.get("audio_duration_ms", 5000)
3311
+ voice_id = structured.get("voice_id")
3312
+ original_image_prompt = (
3313
+ source_video.get("imagePrompt")
3314
+ or source_video.get("prompt")
3315
+ or structured.get("imagePrompt", "")
3316
+ )
3317
+
3318
+ if not audio_url:
3319
+ raise VibesAPIError(
3320
+ "Cannot regenerate lip sync: audio URL not found in source. "
3321
+ "Create a new lip sync video instead."
3322
+ )
3323
+
3324
+ return self.generate_lipsync(
3325
+ project_id=project_id,
3326
+ image_prompt=prompt or original_image_prompt,
3327
+ script=structured.get("script", ""),
3328
+ audio_url=audio_url,
3329
+ audio_duration_ms=audio_duration_ms,
3330
+ ingredients=source_config.get("ingredients"),
3331
+ music_track=source_config.get("musicTrack"),
3332
+ moodboard=(
3333
+ {"moodboardCode": source_config.get("moodboardCode"),
3334
+ "moodboardId": source_config.get("moodboardId"),
3335
+ "moodboard_name": source_config.get("moodboard_name"),
3336
+ "moodboard_thumbnail_url": source_config.get("moodboard_thumbnail_url")}
3337
+ if source_config.get("moodboardCode") else None
3338
+ ),
3339
+ poll=poll,
3340
+ poll_timeout=poll_timeout,
3341
+ )
3342
+
3343
+ # ------------------------------------------------------------------ #
3344
+ # SSE auto-reconnect with exponential backoff
3345
+ # ------------------------------------------------------------------ #
3346
+ def stream_sync_updates_resilient(
3347
+ self,
3348
+ entity_type: str,
3349
+ entity_id: str,
3350
+ *,
3351
+ max_retries: int = 5,
3352
+ base_backoff: float = 1.0,
3353
+ max_backoff: float = 30.0,
3354
+ ) -> Iterator[dict]:
3355
+ """Stream sync updates with auto-reconnect and exponential backoff.
3356
+
3357
+ Unlike ``stream_sync_updates()`` (single connection), this method
3358
+ automatically reconnects on failure with exponential backoff,
3359
+ matching the Vibes UI's behavior.
3360
+
3361
+ Parameters
3362
+ ----------
3363
+ entity_type, entity_id : str
3364
+ Entity to watch.
3365
+ max_retries : int
3366
+ Max consecutive failures before giving up (default 5).
3367
+ base_backoff : float
3368
+ Initial backoff in seconds (default 1.0).
3369
+ max_backoff : float
3370
+ Cap on backoff (default 30.0).
3371
+
3372
+ Yields
3373
+ ------
3374
+ dict
3375
+ Same events as ``stream_sync_updates()``.
3376
+ """
3377
+ retries = 0
3378
+ while retries < max_retries:
3379
+ try:
3380
+ for event in self.stream_sync_updates(entity_type, entity_id):
3381
+ retries = 0 # reset on successful event
3382
+ yield event
3383
+ except (VibesAPIError, ConnectionError) as e:
3384
+ retries += 1
3385
+ if retries >= max_retries:
3386
+ raise
3387
+ backoff = min(base_backoff * (2 ** retries), max_backoff)
3388
+ time.sleep(backoff)
3389
+
3390
+ def stream_batch_updates_resilient(
3391
+ self,
3392
+ batch_id: str,
3393
+ *,
3394
+ max_retries: int = 5,
3395
+ base_backoff: float = 1.0,
3396
+ max_backoff: float = 30.0,
3397
+ idle_timeout: float = 300.0,
3398
+ ) -> Iterator[dict]:
3399
+ """Stream batch updates with auto-reconnect.
3400
+
3401
+ Stops when the batch is complete (``isComplete: true``) or after
3402
+ ``idle_timeout`` seconds with no events.
3403
+ """
3404
+ retries = 0
3405
+ last_event = time.time()
3406
+ while retries < max_retries and time.time() - last_event < idle_timeout:
3407
+ try:
3408
+ for event in self.stream_batch_updates(batch_id):
3409
+ last_event = time.time()
3410
+ retries = 0
3411
+ yield event
3412
+ if event.get("isComplete"):
3413
+ return
3414
+ except (VibesAPIError, ConnectionError):
3415
+ retries += 1
3416
+ if retries >= max_retries:
3417
+ raise
3418
+ backoff = min(base_backoff * (2 ** retries), max_backoff)
3419
+ time.sleep(backoff)
3420
+
3421
+ # ------------------------------------------------------------------ #
3422
+ # Rate limit handling + cooldown tracking
3423
+ # ------------------------------------------------------------------ #
3424
+ def get_rate_limit_status(self) -> Dict[str, Any]:
3425
+ """Get current rate limit status.
3426
+
3427
+ Returns
3428
+ -------
3429
+ dict
3430
+ ``{is_rate_limited: bool, rate_limit_seconds_left: int,
3431
+ last_429_at: float | None, cooldown_until: float | None}``
3432
+ """
3433
+ now = time.time()
3434
+ cooldown_until = getattr(self, "_rate_limit_cooldown_until", None)
3435
+ last_429 = getattr(self, "_rate_limit_last_429", None)
3436
+ is_limited = cooldown_until is not None and now < cooldown_until
3437
+ seconds_left = max(0, int(cooldown_until - now)) if is_limited else 0
3438
+ return {
3439
+ "is_rate_limited": is_limited,
3440
+ "rate_limit_seconds_left": seconds_left,
3441
+ "last_429_at": last_429,
3442
+ "cooldown_until": cooldown_until,
3443
+ }
3444
+
3445
+ def _set_rate_limit_cooldown(self, seconds: int = 60) -> None:
3446
+ """Internal: set a rate limit cooldown period."""
3447
+ now = time.time()
3448
+ self._rate_limit_last_429 = now
3449
+ self._rate_limit_cooldown_until = now + seconds
3450
+
3451
+ def _check_rate_limit(self) -> None:
3452
+ """Internal: raise if currently in rate limit cooldown."""
3453
+ status = self.get_rate_limit_status()
3454
+ if status["is_rate_limited"]:
3455
+ raise VibesAPIError(
3456
+ f"Rate limited — {status['rate_limit_seconds_left']}s remaining in cooldown",
3457
+ status=429, code="RATE_LIMITED",
3458
+ )
3459
+
3460
+ # Override _check to track rate limits
3461
+ def _check_with_rate_limit(self, resp: requests.Response) -> dict:
3462
+ """Wrap _check to track 429s and set cooldown."""
3463
+ if resp.status_code == 429:
3464
+ self._set_rate_limit_cooldown(seconds=60)
3465
+ return self._check(resp)
3466
+
3467
+ # ------------------------------------------------------------------ #
3468
+ # Original audio check (oa-check)
3469
+ # ------------------------------------------------------------------ #
3470
+ def check_original_audio(self, track_ids: List[str]) -> dict:
3471
+ """Check which music track IDs are original audio (OA).
3472
+
3473
+ Original audio tracks should be filtered out for most users.
3474
+
3475
+ Parameters
3476
+ ----------
3477
+ track_ids : list of str
3478
+ ``audio_cluster_view_id`` values from ``search_music()``.
3479
+
3480
+ Returns
3481
+ -------
3482
+ dict
3483
+ ``{oa_ids: [...], ...}`` — list of IDs that ARE original audio.
3484
+ """
3485
+ return self._post("/api/meta-music/oa-check", json_body={"track_ids": track_ids})
3486
+
3487
+ def search_music_filtered(
3488
+ self,
3489
+ query: str = "",
3490
+ limit: int = 30,
3491
+ cursor: Optional[str] = None,
3492
+ *,
3493
+ exclude_original_audio: bool = True,
3494
+ ) -> dict:
3495
+ """Search music with optional OA filtering.
3496
+
3497
+ By default, filters out original audio tracks (matches UI behavior
3498
+ for "popular" searches).
3499
+ """
3500
+ results = self.search_music(query=query, limit=limit, cursor=cursor)
3501
+ tracks = results.get("tracks", [])
3502
+ if exclude_original_audio and tracks:
3503
+ track_ids = [t["audio_cluster_view_id"] for t in tracks]
3504
+ try:
3505
+ oa_resp = self.check_original_audio(track_ids)
3506
+ oa_ids = set(str(x) for x in oa_resp.get("oa_ids", []))
3507
+ results["tracks"] = [
3508
+ t for t in tracks
3509
+ if str(t["audio_cluster_view_id"]) not in oa_ids
3510
+ ]
3511
+ except VibesAPIError:
3512
+ pass # If OA check fails, return unfiltered
3513
+ return results
3514
+
3515
+ # ------------------------------------------------------------------ #
3516
+ # Check pending export on project load
3517
+ # ------------------------------------------------------------------ #
3518
+ def get_pending_export(self, project_id: str) -> Optional[dict]:
3519
+ """Check if a project has a pending (in-progress) async export.
3520
+
3521
+ Mirrors the UI behavior of checking for in-progress exports when
3522
+ opening a project.
3523
+
3524
+ Returns
3525
+ -------
3526
+ dict or None
3527
+ ``{pending: {firstVideoSrc, progress, ...}}`` if there's a
3528
+ pending export, None otherwise.
3529
+ """
3530
+ try:
3531
+ resp = self._get(f"/api/projects/{project_id}/timeline/export/pending")
3532
+ return resp.get("pending") if resp else None
3533
+ except VibesAPIError:
3534
+ return None
3535
+
3536
+ # ------------------------------------------------------------------ #
3537
+ # Audio URL resolution + proxy
3538
+ # ------------------------------------------------------------------ #
3539
+ def resolve_audio_urls(self, audio_ids: List[str]) -> dict:
3540
+ """Batch-resolve CDN URLs for audio cluster IDs.
3541
+
3542
+ Useful for playing licensed audio that requires server-side
3543
+ URL signing.
3544
+
3545
+ Parameters
3546
+ ----------
3547
+ audio_ids : list of str
3548
+ Audio cluster view IDs.
3549
+
3550
+ Returns
3551
+ -------
3552
+ dict
3553
+ ``{resolved: {id: url, ...}, failed: [...], unresolvable: [...],
3554
+ originalAudio: [...]}``
3555
+ """
3556
+ return self._post("/api/resolve-audio-urls", json_body={"ids": audio_ids})
3557
+
3558
+ def proxy_audio_url(self, audio_id: str, title: Optional[str] = None) -> str:
3559
+ """Build a Vibes proxy audio URL for an audio cluster ID.
3560
+
3561
+ The proxy endpoint signs the URL server-side so you can play
3562
+ licensed audio without exposing the signed URL directly.
3563
+
3564
+ Returns
3565
+ -------
3566
+ str
3567
+ URL like ``https://vibes.ai/api/proxy-audio?id=...&title=...``
3568
+ """
3569
+ url = self._url(f"/api/proxy-audio?id={audio_id}")
3570
+ if title:
3571
+ url += f"&title={title}"
3572
+ return url
3573
+
3574
+ def proxy_audio_url_signed(self, signed_url: str) -> str:
3575
+ """Build a proxy URL for an already-signed audio URL."""
3576
+ from urllib.parse import quote
3577
+ return self._url(f"/api/proxy-audio?signed_url={quote(signed_url, safe='')}")
3578
+
3579
+ # ------------------------------------------------------------------ #
3580
+ # Update ingredient (via Meta GraphQL)
3581
+ # ------------------------------------------------------------------ #
3582
+ def update_ingredient(
3583
+ self,
3584
+ ingredient_id: str,
3585
+ *,
3586
+ name: Optional[str] = None,
3587
+ description: Optional[str] = None,
3588
+ personality: Optional[str] = None,
3589
+ backstory: Optional[str] = None,
3590
+ core_beliefs: Optional[str] = None,
3591
+ image_uri: Optional[str] = None,
3592
+ ) -> dict:
3593
+ """Update an existing ingredient.
3594
+
3595
+ Note: Vibes uses Meta GraphQL for ingredient updates (doc_id
3596
+ ``UpdateIngredientMutation = 26515982254723441``). This method
3597
+ sends the GraphQL request via ``/api/meta-graphql``.
3598
+
3599
+ Parameters
3600
+ ----------
3601
+ ingredient_id : str
3602
+ The ingredient ID to update.
3603
+ name, description, personality, backstory, core_beliefs : str, optional
3604
+ Fields to update. Only set fields are updated.
3605
+ image_uri : str, optional
3606
+ New image URL.
3607
+
3608
+ Returns
3609
+ -------
3610
+ dict
3611
+ GraphQL response data.
3612
+ """
3613
+ # Build the input object for the mutation
3614
+ updates: Dict[str, Any] = {"id": ingredient_id}
3615
+ if name is not None:
3616
+ updates["name"] = name
3617
+ if description is not None:
3618
+ updates["description"] = description
3619
+ if personality is not None:
3620
+ updates["personality"] = personality
3621
+ if backstory is not None:
3622
+ updates["backstory"] = backstory
3623
+ if core_beliefs is not None:
3624
+ updates["core_beliefs"] = core_beliefs
3625
+ if image_uri is not None:
3626
+ updates["image_uri"] = image_uri
3627
+
3628
+ # UpdateIngredientMutation doc_id
3629
+ body = {
3630
+ "doc_id": "26515982254723441", # UpdateIngredientMutation
3631
+ "variables": {"input": updates},
3632
+ }
3633
+ return self._post("/api/meta-graphql", json_body=body)
3634
+
3635
+ # ------------------------------------------------------------------ #
3636
+ # Token validation
3637
+ # ------------------------------------------------------------------ #
3638
+ def check_token(self) -> bool:
3639
+ """Check if the current session token is valid."""
3640
+ try:
3641
+ self._get("/api/auth/check-token")
3642
+ return True
3643
+ except VibesAPIError as e:
3644
+ if e.status == 401:
3645
+ return False
3646
+ raise
3647
+
3648
+ # ------------------------------------------------------------------ #
3649
+ # Delete asset from project
3650
+ # ------------------------------------------------------------------ #
3651
+ def remove_project_asset(self, project_id: str, asset_id: str) -> None:
3652
+ """Remove an asset from a project (does NOT delete the underlying media)."""
3653
+ self._delete(f"/api/projects/{project_id}/assets/{asset_id}")
3654
+
3655
+ # ------------------------------------------------------------------ #
3656
+ # Generic download endpoint
3657
+ # ------------------------------------------------------------------ #
3658
+ def download_content(
3659
+ self,
3660
+ content_type: str,
3661
+ content_id: str,
3662
+ output_path: str,
3663
+ ) -> str:
3664
+ """Generic download via ``/api/download/{type}``.
3665
+
3666
+ Parameters
3667
+ ----------
3668
+ content_type : str
3669
+ "video", "png", or other supported type.
3670
+ content_id : str
3671
+ Content item ID.
3672
+ output_path : str
3673
+ Local file path.
3674
+ """
3675
+ return self._download(content_id, output_path, f"/api/download/{content_type}")
3676
+
3677
+ # ------------------------------------------------------------------ #
3678
+ # Update batch (PUT)
3679
+ # ------------------------------------------------------------------ #
3680
+ def update_batch(self, batch_id: str, updates: dict) -> dict:
3681
+ """Update a generation batch (PUT).
3682
+
3683
+ Used internally by the UI for optimistic updates. You usually
3684
+ don't need this — use ``poll_batch()`` to read state instead.
3685
+
3686
+ Parameters
3687
+ ----------
3688
+ batch_id : str
3689
+ updates : dict
3690
+ Fields to update (e.g., ``{"content": [...], "isComplete": true}``).
3691
+ """
3692
+ return self._put(f"/api/generation-batches/{batch_id}", json_body=updates)
3693
+
3694
+ # ------------------------------------------------------------------ #
3695
+ # Filter media by ingredient
3696
+ # ------------------------------------------------------------------ #
3697
+ def list_media_by_ingredient(
3698
+ self,
3699
+ ingredient_id: str,
3700
+ limit: int = 50,
3701
+ offset: int = 0,
3702
+ ) -> dict:
3703
+ """List media items created with a specific ingredient.
3704
+
3705
+ Note: This filters client-side by checking ``config.ingredients``
3706
+ on each item. For large libraries, this may be slow.
3707
+ """
3708
+ all_media = self.list_media(limit=limit * 2, offset=offset, type="video")
3709
+ filtered = []
3710
+ for item in all_media.get("items", []):
3711
+ # Check if this item's config includes the ingredient
3712
+ # We'd need to fetch the batch to check config, so we do a best-effort filter
3713
+ # based on the batchId being in the item
3714
+ batch_id = item.get("batchId")
3715
+ if batch_id:
3716
+ try:
3717
+ batch = self.get_batch(batch_id)
3718
+ config = batch.get("config") or {}
3719
+ ingredients = config.get("ingredients") or []
3720
+ if any(i.get("ingredientId") == ingredient_id for i in ingredients):
3721
+ filtered.append(item)
3722
+ except VibesAPIError:
3723
+ continue
3724
+ if len(filtered) >= limit:
3725
+ break
3726
+ return {
3727
+ "items": filtered,
3728
+ "page": all_media.get("page", {}),
3729
+ "filtered_by_ingredient": ingredient_id,
3730
+ }
3731
+
3732
+ # ------------------------------------------------------------------ #
3733
+ # Composition helpers (using the Composition class)
3734
+ # ------------------------------------------------------------------ #
3735
+ def get_composition(self, project_id: str) -> Composition:
3736
+ """Get a project's composition as a ``Composition`` helper object.
3737
+
3738
+ Shortcut for::
3739
+
3740
+ project = client.get_project(project_id)
3741
+ comp = Composition(project["composition"])
3742
+ """
3743
+ from .composition import Composition
3744
+ project = self.get_project(project_id)
3745
+ return Composition.from_project(project)
3746
+
3747
+ def save_composition_obj(self, project_id: str, comp: "Composition") -> dict:
3748
+ """Save a ``Composition`` helper object back to a project.
3749
+
3750
+ Shortcut for::
3751
+
3752
+ client.save_composition(project_id, comp.to_dict())
3753
+ """
3754
+ return self.save_composition(project_id, comp.to_dict())
3755
+
3756
+ # ------------------------------------------------------------------ #
3757
+ # Client-side validation helpers
3758
+ # ------------------------------------------------------------------ #
3759
+ @staticmethod
3760
+ def validate_prompt_length(prompt: str, max_length: int = 10000) -> dict:
3761
+ """Validate that a prompt is within the allowed length.
3762
+
3763
+ Returns
3764
+ -------
3765
+ dict
3766
+ ``{success: bool, error: str?}``
3767
+ """
3768
+ if not prompt or not isinstance(prompt, str):
3769
+ return {"success": True, "value": ""}
3770
+ if len(prompt) > max_length:
3771
+ return {
3772
+ "success": False,
3773
+ "error": f"Prompt must be {max_length:,} characters or fewer",
3774
+ }
3775
+ return {"success": True, "value": prompt}
3776
+
3777
+ @staticmethod
3778
+ def validate_project_name(name: str, max_length: int = 255) -> dict:
3779
+ """Validate a project name (max 255 chars)."""
3780
+ if len(name) > max_length:
3781
+ return {
3782
+ "success": False,
3783
+ "error": f"Project name must be {max_length} characters or less",
3784
+ }
3785
+ return {"success": True, "value": name}
3786
+
3787
+ @staticmethod
3788
+ def validate_username(name: str) -> dict:
3789
+ """Validate a username (3-30 chars)."""
3790
+ if len(name) < 3:
3791
+ return {"success": False, "error": "Username must be at least 3 characters"}
3792
+ if len(name) > 30:
3793
+ return {"success": False, "error": "Username must be 30 characters or fewer"}
3794
+ return {"success": True, "value": name}
3795
+
3796
+ @staticmethod
3797
+ def validate_image_size(file_path: str, max_size_bytes: int = 10 * 1024 * 1024,
3798
+ max_dimension: int = 4096) -> dict:
3799
+ """Validate that an image file is within size and dimension limits.
3800
+
3801
+ Returns
3802
+ -------
3803
+ dict
3804
+ ``{success: bool, error: str?, dimensions: {width, height}?}``
3805
+ """
3806
+ import os
3807
+ if not os.path.exists(file_path):
3808
+ return {"success": False, "error": f"File not found: {file_path}"}
3809
+ size = os.path.getsize(file_path)
3810
+ if size > max_size_bytes:
3811
+ return {
3812
+ "success": False,
3813
+ "error": f"File too large: {size:,} bytes (max {max_size_bytes:,})",
3814
+ }
3815
+ # Try to read dimensions (optional — only if PIL is available)
3816
+ try:
3817
+ from PIL import Image
3818
+ with Image.open(file_path) as img:
3819
+ w, h = img.size
3820
+ if w > max_dimension or h > max_dimension:
3821
+ return {
3822
+ "success": False,
3823
+ "error": f"Image too large: {w}x{h} (max {max_dimension}x{max_dimension})",
3824
+ "dimensions": {"width": w, "height": h},
3825
+ }
3826
+ return {"success": True, "dimensions": {"width": w, "height": h}}
3827
+ except ImportError:
3828
+ # PIL not available — skip dimension check
3829
+ return {"success": True, "note": "PIL not available; dimension check skipped"}
3830
+
3831
+ @staticmethod
3832
+ def validate_music_clip_duration(start_ms: int, end_ms: int,
3833
+ max_duration_ms: int = 60000) -> dict:
3834
+ """Validate a music clip duration (max 60s per song)."""
3835
+ duration = end_ms - start_ms
3836
+ if duration <= 0:
3837
+ return {"success": False, "error": "End must be greater than start"}
3838
+ if duration > max_duration_ms:
3839
+ return {
3840
+ "success": False,
3841
+ "error": f"Music clips cannot exceed {max_duration_ms // 1000}s",
3842
+ }
3843
+ return {"success": True, "duration_ms": duration}
3844
+
3845
+ @staticmethod
3846
+ def validate_music_clip_short(start_ms: int, end_ms: int,
3847
+ max_duration_ms: int = 9000) -> dict:
3848
+ """Validate a short music clip (max 9s for MUSIC_CLIP_MAX_DURATION_MS)."""
3849
+ duration = end_ms - start_ms
3850
+ if duration <= 0:
3851
+ return {"success": False, "error": "End must be greater than start"}
3852
+ if duration > max_duration_ms:
3853
+ return {
3854
+ "success": False,
3855
+ "error": f"Short music clips cannot exceed {max_duration_ms // 1000}s",
3856
+ }
3857
+ return {"success": True, "duration_ms": duration}
3858
+
3859
+ # ------------------------------------------------------------------ #
3860
+ # Midjourney parameter parsing (sref / oref / chaos / stylize / etc.)
3861
+ # ------------------------------------------------------------------ #
3862
+ @staticmethod
3863
+ def parse_midjourney_params(prompt: str) -> dict:
3864
+ """Parse Midjourney-style parameters from a prompt.
3865
+
3866
+ Extracts parameters like ``--sref``, ``--oref``, ``--sw``,
3867
+ ``--ow``, ``--seed``, ``--chaos``, ``--stylize``, ``--ar``,
3868
+ ``--v``, ``--niji``, ``--loop``, ``--raw``, ``--tile``,
3869
+ ``--turbo``, ``--relax``, ``--stealth``, ``--public``,
3870
+ ``--draft``, ``--video``, ``--fast``, ``--style``.
3871
+
3872
+ Returns
3873
+ -------
3874
+ dict
3875
+ ``{cleanPrompt: str, parameters: {sref: ..., oref: ...,
3876
+ sref_weight: ..., oref_weight: ..., seed: ..., chaos: ...,
3877
+ stylize: ..., aspect_ratio: ..., version: ..., niji: bool,
3878
+ loop: bool, raw: bool, tile: bool, ...}}``
3879
+ """
3880
+ params: Dict[str, Any] = {}
3881
+ clean = prompt
3882
+
3883
+ # --sref (style reference, can be URL, random, or numeric IDs)
3884
+ import re
3885
+ sref_match = re.search(r'--sref\s+([^\s-]+(?:\s+[^\s-]+)*?)(?=\s+--|$)', prompt)
3886
+ if sref_match:
3887
+ sref_val = sref_match.group(1).strip()
3888
+ if sref_val.lower() == "random":
3889
+ params["sref_random"] = True
3890
+ params["sref_value"] = sref_val
3891
+ else:
3892
+ params["sref_value"] = sref_val
3893
+ # Extract numeric IDs
3894
+ nums = [x for x in sref_val.split() if x.isdigit()]
3895
+ if nums:
3896
+ params["sref_ids"] = [int(n) for n in nums]
3897
+ clean = clean.replace(sref_match.group(0), "").strip()
3898
+
3899
+ # --oref (object/character reference)
3900
+ oref_match = re.search(r'--oref\s+([^\s-]+(?:\s+[^\s-]+)*?)(?=\s+--|$)', prompt)
3901
+ if oref_match:
3902
+ oref_val = oref_match.group(1).strip()
3903
+ params["oref_value"] = oref_val
3904
+ nums = [x for x in oref_val.split() if x.isdigit()]
3905
+ if nums:
3906
+ params["oref_ids"] = [int(n) for n in nums]
3907
+ clean = clean.replace(oref_match.group(0), "").strip()
3908
+
3909
+ # --sw (sref weight)
3910
+ sw_match = re.search(r'--sw\s+([\d.]+)', prompt)
3911
+ if sw_match:
3912
+ params["sref_weight"] = float(sw_match.group(1))
3913
+ clean = clean.replace(sw_match.group(0), "").strip()
3914
+
3915
+ # --ow (oref weight)
3916
+ ow_match = re.search(r'--ow\s+([\d.]+)', prompt)
3917
+ if ow_match:
3918
+ params["oref_weight"] = float(ow_match.group(1))
3919
+ clean = clean.replace(ow_match.group(0), "").strip()
3920
+
3921
+ # --seed
3922
+ seed_match = re.search(r'--seed\s+(\d+)', prompt)
3923
+ if seed_match:
3924
+ params["seed"] = int(seed_match.group(1))
3925
+ clean = clean.replace(seed_match.group(0), "").strip()
3926
+
3927
+ # --chaos / --c
3928
+ chaos_match = re.search(r'--(?:chaos|c)\s+(\d+)', prompt)
3929
+ if chaos_match:
3930
+ params["chaos"] = int(chaos_match.group(1))
3931
+ clean = clean.replace(chaos_match.group(0), "").strip()
3932
+
3933
+ # --stylize / --s
3934
+ stylize_match = re.search(r'--(?:stylize|s)\s+(\d+)', prompt)
3935
+ if stylize_match:
3936
+ params["stylize"] = int(stylize_match.group(1))
3937
+ clean = clean.replace(stylize_match.group(0), "").strip()
3938
+
3939
+ # --ar (aspect ratio)
3940
+ ar_match = re.search(r'--ar\s+(\d+:\d+)', prompt)
3941
+ if ar_match:
3942
+ params["aspect_ratio"] = ar_match.group(1)
3943
+ clean = clean.replace(ar_match.group(0), "").strip()
3944
+
3945
+ # --v (version)
3946
+ v_match = re.search(r'--v\s+([\d.]+)', prompt)
3947
+ if v_match:
3948
+ params["version"] = float(v_match.group(1))
3949
+ clean = clean.replace(v_match.group(0), "").strip()
3950
+
3951
+ # Boolean flags
3952
+ for flag in ["niji", "loop", "raw", "tile", "turbo", "relax",
3953
+ "stealth", "public", "draft", "video", "fast"]:
3954
+ if re.search(rf'--{flag}\b', prompt):
3955
+ params[flag] = True
3956
+ clean = re.sub(rf'--{flag}\b\s*', '', clean).strip()
3957
+
3958
+ # --style
3959
+ style_match = re.search(r'--style\s+([^\s-]+)', prompt)
3960
+ if style_match:
3961
+ params["style"] = style_match.group(1)
3962
+ clean = clean.replace(style_match.group(0), "").strip()
3963
+
3964
+ # Clean up extra whitespace
3965
+ clean = re.sub(r'\s+', ' ', clean).strip()
3966
+
3967
+ return {
3968
+ "cleanPrompt": clean,
3969
+ "parameters": params,
3970
+ "hasRandomSref": params.get("sref_random", False),
3971
+ "originalSrefValue": params.get("sref_value"),
3972
+ }