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.
- server/__init__.py +3 -0
- server/app.py +332 -0
- vibes_api/__init__.py +121 -0
- vibes_api/cli.py +852 -0
- vibes_api/client.py +3972 -0
- vibes_api/composition.py +852 -0
- vibes_api/ingredients.py +206 -0
- vibes_api/models.py +165 -0
- vibesai_api-1.3.2.dist-info/METADATA +600 -0
- vibesai_api-1.3.2.dist-info/RECORD +14 -0
- vibesai_api-1.3.2.dist-info/WHEEL +5 -0
- vibesai_api-1.3.2.dist-info/entry_points.txt +2 -0
- vibesai_api-1.3.2.dist-info/licenses/LICENSE +28 -0
- vibesai_api-1.3.2.dist-info/top_level.txt +2 -0
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
|
+
}
|