videorouter-live 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- videorouter_live/__init__.py +23 -0
- videorouter_live/_client.py +250 -0
- videorouter_live/_errors.py +107 -0
- videorouter_live/_session.py +295 -0
- videorouter_live/_types.py +63 -0
- videorouter_live/_videos.py +135 -0
- videorouter_live/py.typed +0 -0
- videorouter_live-0.2.0.dist-info/METADATA +111 -0
- videorouter_live-0.2.0.dist-info/RECORD +12 -0
- videorouter_live-0.2.0.dist-info/WHEEL +5 -0
- videorouter_live-0.2.0.dist-info/licenses/LICENSE +21 -0
- videorouter_live-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""videorouter-live — Python SDK for videorouter.sh live video & world models (https://videorouter.sh/docs/live-video).
|
|
2
|
+
|
|
3
|
+
The SDK is a client for the session BROKER: it opens a session, keeps it alive and settles it. It never carries media —
|
|
4
|
+
`session.connect` holds the host's short-lived credentials, which you hand to the host's own SDK in the browser.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._client import AsyncLiveVideo, DEFAULT_BASE_URL, LiveVideo
|
|
8
|
+
from ._errors import (AuthenticationError, ConcurrencyLimitError, HostCapacityError, InsufficientCreditsError,
|
|
9
|
+
InvalidRequestError, LiveVideoError, NotFoundError, PermissionDeniedError,
|
|
10
|
+
PricingUnavailableError, RateLimitError, SessionStateError, TimeoutError, UpstreamError,
|
|
11
|
+
VideoGenerationError)
|
|
12
|
+
from ._session import AsyncLiveSession, LiveSession
|
|
13
|
+
from ._types import Connect, Model, Status, Token
|
|
14
|
+
from ._videos import AsyncVideoJob, AsyncVideos, VideoJob, Videos
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"LiveVideo", "AsyncLiveVideo", "LiveSession", "AsyncLiveSession", "Model", "Token", "Status", "Connect",
|
|
19
|
+
"DEFAULT_BASE_URL", "LiveVideoError", "AuthenticationError", "PermissionDeniedError", "InvalidRequestError",
|
|
20
|
+
"InsufficientCreditsError", "PricingUnavailableError", "NotFoundError", "RateLimitError",
|
|
21
|
+
"ConcurrencyLimitError", "HostCapacityError", "UpstreamError", "SessionStateError", "TimeoutError",
|
|
22
|
+
"VideoGenerationError", "Videos", "AsyncVideos", "VideoJob", "AsyncVideoJob",
|
|
23
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._errors import LiveVideoError, UpstreamError, error_from_response
|
|
11
|
+
from ._session import AsyncLiveSession, LiveSession
|
|
12
|
+
from ._types import Model, Status, Token
|
|
13
|
+
from ._videos import AsyncVideos, Videos
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://videorouter.sh/api/v1"
|
|
16
|
+
_RETRY_STATUSES = (502, 503, 504)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_auth(api_key: Optional[str], token: Optional[str]) -> str:
|
|
20
|
+
secret = token or api_key or os.environ.get("VIDEOROUTER_API_KEY")
|
|
21
|
+
if not secret:
|
|
22
|
+
raise ValueError("pass api_key=... (server) or token=... (browser-safe, from create_token), "
|
|
23
|
+
"or set VIDEOROUTER_API_KEY")
|
|
24
|
+
return secret
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(resp: httpx.Response) -> Any:
|
|
28
|
+
try:
|
|
29
|
+
body = resp.json()
|
|
30
|
+
except ValueError:
|
|
31
|
+
body = resp.text
|
|
32
|
+
if resp.status_code >= 400:
|
|
33
|
+
raise error_from_response(resp.status_code, body, resp.headers)
|
|
34
|
+
return body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _session_body(model: str, max_duration_seconds: Optional[int], options: Optional[dict]) -> dict:
|
|
38
|
+
body: dict[str, Any] = {"model": model}
|
|
39
|
+
if max_duration_seconds is not None:
|
|
40
|
+
body["max_duration_seconds"] = max_duration_seconds
|
|
41
|
+
if options:
|
|
42
|
+
body["options"] = options
|
|
43
|
+
return body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _token_body(models, expires_in, max_sessions, max_session_duration_seconds) -> dict:
|
|
47
|
+
body: dict[str, Any] = {}
|
|
48
|
+
if models is not None:
|
|
49
|
+
body["models"] = list(models)
|
|
50
|
+
if expires_in is not None:
|
|
51
|
+
body["expires_in"] = expires_in
|
|
52
|
+
if max_sessions is not None:
|
|
53
|
+
body["max_sessions"] = max_sessions
|
|
54
|
+
if max_session_duration_seconds is not None:
|
|
55
|
+
body["max_session_duration_seconds"] = max_session_duration_seconds
|
|
56
|
+
return body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LiveVideo:
|
|
60
|
+
"""Synchronous client.
|
|
61
|
+
|
|
62
|
+
lv = LiveVideo(api_key="llmr_sk_...")
|
|
63
|
+
with lv.session("fal/happy-oyster-adventure", max_duration_seconds=60,
|
|
64
|
+
options={"world": {"prompt": "a rainy city", "perspective": "first_person"}}) as s:
|
|
65
|
+
hand_to_browser(s.connect) # host credentials, returned once
|
|
66
|
+
... # heartbeats run in the background
|
|
67
|
+
print(s.billed_seconds, s.cost_usd) # settled on exit
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, api_key: Optional[str] = None, *, token: Optional[str] = None, base_url: str = DEFAULT_BASE_URL,
|
|
71
|
+
timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.Client] = None) -> None:
|
|
72
|
+
self._auth = _resolve_auth(api_key, token)
|
|
73
|
+
self.base_url = base_url.rstrip("/")
|
|
74
|
+
self.max_retries = max_retries
|
|
75
|
+
self._owns = http_client is None
|
|
76
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
77
|
+
self.videos = Videos(self) # the /v1/videos generation API: lv.videos.generate({...})
|
|
78
|
+
|
|
79
|
+
# ── transport ────────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
def _request(self, method: str, path: str, json: Any = None, retry: bool = False) -> Any:
|
|
81
|
+
attempts = (self.max_retries if retry else 0) + 1
|
|
82
|
+
last: Optional[LiveVideoError] = None
|
|
83
|
+
for i in range(attempts):
|
|
84
|
+
try:
|
|
85
|
+
resp = self._http.request(method, self.base_url + path, json=json,
|
|
86
|
+
headers={"Authorization": f"Bearer {self._auth}"})
|
|
87
|
+
if resp.status_code in _RETRY_STATUSES and i + 1 < attempts:
|
|
88
|
+
time.sleep(min(0.5 * 2 ** i, 4))
|
|
89
|
+
continue
|
|
90
|
+
return _parse(resp)
|
|
91
|
+
except httpx.TransportError as exc:
|
|
92
|
+
last = UpstreamError(f"network error: {exc}")
|
|
93
|
+
if i + 1 >= attempts:
|
|
94
|
+
raise last from exc
|
|
95
|
+
time.sleep(min(0.5 * 2 ** i, 4))
|
|
96
|
+
raise last or UpstreamError("request failed")
|
|
97
|
+
|
|
98
|
+
# ── API ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
def models(self) -> list:
|
|
100
|
+
return [Model.from_dict(m) for m in self._request("GET", "/live-video/models", retry=True)["data"]]
|
|
101
|
+
|
|
102
|
+
def create_token(self, *, models: Optional[list] = None, expires_in: Optional[int] = None,
|
|
103
|
+
max_sessions: Optional[int] = None, max_session_duration_seconds: Optional[int] = None) -> Token:
|
|
104
|
+
"""Mint a short-lived, live-video-only credential for a browser. Call it from your server."""
|
|
105
|
+
return Token.from_dict(self._request("POST", "/live-video/tokens", json=_token_body(
|
|
106
|
+
models, expires_in, max_sessions, max_session_duration_seconds)))
|
|
107
|
+
|
|
108
|
+
def create_session(self, model: str, *, max_duration_seconds: Optional[int] = None,
|
|
109
|
+
options: Optional[dict] = None) -> LiveSession:
|
|
110
|
+
return LiveSession(self, self._request("POST", "/live-video/sessions",
|
|
111
|
+
json=_session_body(model, max_duration_seconds, options)))
|
|
112
|
+
|
|
113
|
+
def get_session(self, session_id: str) -> LiveSession:
|
|
114
|
+
return LiveSession(self, self._request("GET", f"/live-video/sessions/{session_id}", retry=True))
|
|
115
|
+
|
|
116
|
+
def session(self, model: str, *, max_duration_seconds: Optional[int] = None, options: Optional[dict] = None,
|
|
117
|
+
wait_until_ready: bool = True, auto_heartbeat: bool = True, ready_timeout: float = 300.0,
|
|
118
|
+
poll_interval: float = 4.0, heartbeat_interval: Optional[float] = None) -> "_SessionContext":
|
|
119
|
+
"""Context manager: opens the session, waits until it is ready, heartbeats in the background, and settles it on
|
|
120
|
+
exit (even on error) — the common case in one `with`."""
|
|
121
|
+
return _SessionContext(self, model, max_duration_seconds, options, wait_until_ready, auto_heartbeat,
|
|
122
|
+
ready_timeout, heartbeat_interval, poll_interval)
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
if self._owns:
|
|
126
|
+
self._http.close()
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> "LiveVideo":
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *exc: Any) -> None:
|
|
132
|
+
self.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _SessionContext:
|
|
136
|
+
def __init__(self, client: LiveVideo, model, max_duration_seconds, options, wait, beat, timeout, interval,
|
|
137
|
+
poll) -> None:
|
|
138
|
+
self._a = (client, model, max_duration_seconds, options, wait, beat, timeout, interval, poll)
|
|
139
|
+
self._s: Optional[LiveSession] = None
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> LiveSession:
|
|
142
|
+
client, model, dur, options, wait, beat, timeout, interval, poll = self._a
|
|
143
|
+
s = client.create_session(model, max_duration_seconds=dur, options=options)
|
|
144
|
+
self._s = s
|
|
145
|
+
try:
|
|
146
|
+
if wait:
|
|
147
|
+
s.wait_until_ready(timeout=timeout, poll_interval=poll)
|
|
148
|
+
if beat:
|
|
149
|
+
s.start_heartbeat(interval)
|
|
150
|
+
except BaseException:
|
|
151
|
+
s.end()
|
|
152
|
+
raise
|
|
153
|
+
return s
|
|
154
|
+
|
|
155
|
+
def __exit__(self, *exc: Any) -> None:
|
|
156
|
+
if self._s is not None:
|
|
157
|
+
self._s.end()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class AsyncLiveVideo:
|
|
161
|
+
"""Asyncio client — same surface as `LiveVideo`, every network method is a coroutine.
|
|
162
|
+
|
|
163
|
+
async with AsyncLiveVideo(api_key="llmr_sk_...") as lv:
|
|
164
|
+
async with lv.session("decart/lucy-2.5", max_duration_seconds=120) as s:
|
|
165
|
+
hand_to_browser(s.connect)
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, api_key: Optional[str] = None, *, token: Optional[str] = None, base_url: str = DEFAULT_BASE_URL,
|
|
169
|
+
timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.AsyncClient] = None) -> None:
|
|
170
|
+
self._auth = _resolve_auth(api_key, token)
|
|
171
|
+
self.base_url = base_url.rstrip("/")
|
|
172
|
+
self.max_retries = max_retries
|
|
173
|
+
self._owns = http_client is None
|
|
174
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
175
|
+
self.videos = AsyncVideos(self) # the /v1/videos generation API: await lv.videos.generate({...})
|
|
176
|
+
|
|
177
|
+
async def _request(self, method: str, path: str, json: Any = None, retry: bool = False) -> Any:
|
|
178
|
+
attempts = (self.max_retries if retry else 0) + 1
|
|
179
|
+
last: Optional[LiveVideoError] = None
|
|
180
|
+
for i in range(attempts):
|
|
181
|
+
try:
|
|
182
|
+
resp = await self._http.request(method, self.base_url + path, json=json,
|
|
183
|
+
headers={"Authorization": f"Bearer {self._auth}"})
|
|
184
|
+
if resp.status_code in _RETRY_STATUSES and i + 1 < attempts:
|
|
185
|
+
await asyncio.sleep(min(0.5 * 2 ** i, 4))
|
|
186
|
+
continue
|
|
187
|
+
return _parse(resp)
|
|
188
|
+
except httpx.TransportError as exc:
|
|
189
|
+
last = UpstreamError(f"network error: {exc}")
|
|
190
|
+
if i + 1 >= attempts:
|
|
191
|
+
raise last from exc
|
|
192
|
+
await asyncio.sleep(min(0.5 * 2 ** i, 4))
|
|
193
|
+
raise last or UpstreamError("request failed")
|
|
194
|
+
|
|
195
|
+
async def models(self) -> list:
|
|
196
|
+
return [Model.from_dict(m) for m in (await self._request("GET", "/live-video/models", retry=True))["data"]]
|
|
197
|
+
|
|
198
|
+
async def create_token(self, *, models: Optional[list] = None, expires_in: Optional[int] = None,
|
|
199
|
+
max_sessions: Optional[int] = None, max_session_duration_seconds: Optional[int] = None) -> Token:
|
|
200
|
+
return Token.from_dict(await self._request("POST", "/live-video/tokens", json=_token_body(
|
|
201
|
+
models, expires_in, max_sessions, max_session_duration_seconds)))
|
|
202
|
+
|
|
203
|
+
async def create_session(self, model: str, *, max_duration_seconds: Optional[int] = None,
|
|
204
|
+
options: Optional[dict] = None) -> AsyncLiveSession:
|
|
205
|
+
return AsyncLiveSession(self, await self._request("POST", "/live-video/sessions",
|
|
206
|
+
json=_session_body(model, max_duration_seconds, options)))
|
|
207
|
+
|
|
208
|
+
async def get_session(self, session_id: str) -> AsyncLiveSession:
|
|
209
|
+
return AsyncLiveSession(self, await self._request("GET", f"/live-video/sessions/{session_id}", retry=True))
|
|
210
|
+
|
|
211
|
+
def session(self, model: str, *, max_duration_seconds: Optional[int] = None, options: Optional[dict] = None,
|
|
212
|
+
wait_until_ready: bool = True, auto_heartbeat: bool = True, ready_timeout: float = 300.0,
|
|
213
|
+
poll_interval: float = 4.0, heartbeat_interval: Optional[float] = None) -> "_AsyncSessionContext":
|
|
214
|
+
return _AsyncSessionContext(self, model, max_duration_seconds, options, wait_until_ready, auto_heartbeat,
|
|
215
|
+
ready_timeout, heartbeat_interval, poll_interval)
|
|
216
|
+
|
|
217
|
+
async def aclose(self) -> None:
|
|
218
|
+
if self._owns:
|
|
219
|
+
await self._http.aclose()
|
|
220
|
+
|
|
221
|
+
async def __aenter__(self) -> "AsyncLiveVideo":
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
225
|
+
await self.aclose()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _AsyncSessionContext:
|
|
229
|
+
def __init__(self, client: AsyncLiveVideo, model, max_duration_seconds, options, wait, beat, timeout, interval,
|
|
230
|
+
poll) -> None:
|
|
231
|
+
self._a = (client, model, max_duration_seconds, options, wait, beat, timeout, interval, poll)
|
|
232
|
+
self._s: Optional[AsyncLiveSession] = None
|
|
233
|
+
|
|
234
|
+
async def __aenter__(self) -> AsyncLiveSession:
|
|
235
|
+
client, model, dur, options, wait, beat, timeout, interval, poll = self._a
|
|
236
|
+
s = await client.create_session(model, max_duration_seconds=dur, options=options)
|
|
237
|
+
self._s = s
|
|
238
|
+
try:
|
|
239
|
+
if wait:
|
|
240
|
+
await s.wait_until_ready(timeout=timeout, poll_interval=poll)
|
|
241
|
+
if beat:
|
|
242
|
+
s.start_heartbeat(interval)
|
|
243
|
+
except BaseException:
|
|
244
|
+
await s.end()
|
|
245
|
+
raise
|
|
246
|
+
return s
|
|
247
|
+
|
|
248
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
249
|
+
if self._s is not None:
|
|
250
|
+
await self._s.end()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Typed errors. Every failed API call raises a `LiveVideoError` subclass carrying the HTTP `status`, the API's own
|
|
2
|
+
error `code`/`type`/`message`, and `retry_after` when the server sent one — match on the class, not on strings."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LiveVideoError(Exception):
|
|
10
|
+
def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None,
|
|
11
|
+
type: Optional[str] = None, retry_after: Optional[float] = None, body: Any = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message, self.status, self.code, self.type = message, status, code, type
|
|
14
|
+
self.retry_after, self.body = retry_after, body
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
bits = [self.message]
|
|
18
|
+
if self.status is not None:
|
|
19
|
+
bits.append(f"(HTTP {self.status}{', ' + self.code if self.code else ''})")
|
|
20
|
+
return " ".join(bits)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthenticationError(LiveVideoError):
|
|
24
|
+
"""401 — missing, invalid or expired key/token."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PermissionDeniedError(LiveVideoError):
|
|
28
|
+
"""403 — missing `live_video` scope, a model/token restriction, or a token used outside live-video."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InvalidRequestError(LiveVideoError):
|
|
32
|
+
"""400 — a bad parameter (unknown model, duration above the host cap, unsupported option...)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InsufficientCreditsError(LiveVideoError):
|
|
36
|
+
"""402 — the credit hold for this session isn't covered (or a spend cap was hit)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PricingUnavailableError(LiveVideoError):
|
|
40
|
+
"""502 `pricing_unavailable` — the model has no verified price yet and can't be booked."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFoundError(LiveVideoError):
|
|
44
|
+
"""404 — no such session, or it belongs to another organization."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RateLimitError(LiveVideoError):
|
|
48
|
+
"""429 — too many requests."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConcurrencyLimitError(RateLimitError):
|
|
52
|
+
"""429 `concurrency_limit` / `token_session_limit` — too many sessions open. End one first."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HostCapacityError(RateLimitError):
|
|
56
|
+
"""429 `host_capacity` — the host has no free capacity right now. Retry shortly; sessions are never queued."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UpstreamError(LiveVideoError):
|
|
60
|
+
"""5xx — the host (or the gateway) failed."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SessionStateError(LiveVideoError):
|
|
64
|
+
"""Raised locally: an operation that needs a different session state (e.g. waiting on an ended session)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TimeoutError(LiveVideoError): # noqa: A001 — deliberate: scoped to this package
|
|
68
|
+
"""`wait_until_ready` ran out of time."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def error_from_response(status: int, body: Any, headers: Mapping[str, str]) -> LiveVideoError:
|
|
72
|
+
err = (body.get("error") if isinstance(body, dict) else None) or {}
|
|
73
|
+
message = err.get("message") if isinstance(err, dict) else None
|
|
74
|
+
message = message or (body if isinstance(body, str) else f"request failed with HTTP {status}")
|
|
75
|
+
code = err.get("code") if isinstance(err, dict) else None
|
|
76
|
+
etype = err.get("type") if isinstance(err, dict) else None
|
|
77
|
+
retry_after = None
|
|
78
|
+
ra = headers.get("retry-after") or headers.get("Retry-After")
|
|
79
|
+
if ra:
|
|
80
|
+
try:
|
|
81
|
+
retry_after = float(ra)
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
kw = dict(status=status, code=code, type=etype, retry_after=retry_after, body=body)
|
|
85
|
+
if status == 401:
|
|
86
|
+
return AuthenticationError(message, **kw)
|
|
87
|
+
if status == 403:
|
|
88
|
+
return PermissionDeniedError(message, **kw)
|
|
89
|
+
if status == 404:
|
|
90
|
+
return NotFoundError(message, **kw)
|
|
91
|
+
if status == 402:
|
|
92
|
+
return InsufficientCreditsError(message, **kw)
|
|
93
|
+
if status == 429:
|
|
94
|
+
if code in ("concurrency_limit", "token_session_limit"):
|
|
95
|
+
return ConcurrencyLimitError(message, **kw)
|
|
96
|
+
if code == "host_capacity":
|
|
97
|
+
return HostCapacityError(message, **kw)
|
|
98
|
+
return RateLimitError(message, **kw)
|
|
99
|
+
if code == "pricing_unavailable":
|
|
100
|
+
return PricingUnavailableError(message, **kw)
|
|
101
|
+
if 400 <= status < 500:
|
|
102
|
+
return InvalidRequestError(message, **kw)
|
|
103
|
+
return UpstreamError(message, **kw)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class VideoGenerationError(LiveVideoError):
|
|
107
|
+
"""Raised by `VideoJob.wait()` when a `/v1/videos` job ends in a failed state."""
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""`LiveSession` (sync) and `AsyncLiveSession`: one live-video session. Mirrors the shape of Reactor's client
|
|
2
|
+
(`status`, `session_id`, `on/off/once`, `last_error`) on top of videorouter's broker model — the SDK never touches
|
|
3
|
+
media; `connect` is handed to the host's own SDK."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import inspect
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from ._errors import AuthenticationError, LiveVideoError, NotFoundError, SessionStateError, TimeoutError
|
|
14
|
+
from ._types import Connect, Status
|
|
15
|
+
|
|
16
|
+
EVENTS = ("status_changed", "ended", "heartbeat", "error")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Emitter:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._handlers: dict[str, list[tuple[Callable, bool]]] = {}
|
|
22
|
+
|
|
23
|
+
def on(self, event: str, handler: Callable) -> None:
|
|
24
|
+
self._add(event, handler, False)
|
|
25
|
+
|
|
26
|
+
def once(self, event: str, handler: Callable) -> None:
|
|
27
|
+
self._add(event, handler, True)
|
|
28
|
+
|
|
29
|
+
def off(self, event: str, handler: Callable) -> None:
|
|
30
|
+
self._handlers[event] = [(h, o) for h, o in self._handlers.get(event, []) if h is not handler]
|
|
31
|
+
|
|
32
|
+
def _add(self, event: str, handler: Callable, once: bool) -> None:
|
|
33
|
+
if event not in EVENTS:
|
|
34
|
+
raise ValueError(f"unknown event {event!r} (events: {', '.join(EVENTS)})")
|
|
35
|
+
self._handlers.setdefault(event, []).append((handler, once))
|
|
36
|
+
|
|
37
|
+
def _emit(self, event: str, *args: Any) -> None:
|
|
38
|
+
for handler, once in list(self._handlers.get(event, [])):
|
|
39
|
+
if once:
|
|
40
|
+
self.off(event, handler)
|
|
41
|
+
try:
|
|
42
|
+
result = handler(*args)
|
|
43
|
+
if inspect.isawaitable(result):
|
|
44
|
+
try:
|
|
45
|
+
asyncio.get_running_loop().create_task(result) # type: ignore[arg-type]
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
pass
|
|
48
|
+
except Exception: # noqa: BLE001 — a user handler must never break the SDK's own loop
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _SessionBase(_Emitter):
|
|
53
|
+
"""State + events shared by the sync and async session. `raw` is always the last server response."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, client: Any, data: dict) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self._client = client
|
|
58
|
+
self._connect: Optional[Connect] = None
|
|
59
|
+
self._last_error: Optional[LiveVideoError] = None
|
|
60
|
+
self._raw: dict = {}
|
|
61
|
+
self._status: Status = Status("waiting")
|
|
62
|
+
self._apply(data, emit=False)
|
|
63
|
+
|
|
64
|
+
# ── state (Reactor-style getters) ────────────────────────────────────────────────────────────────────────
|
|
65
|
+
@property
|
|
66
|
+
def id(self) -> str:
|
|
67
|
+
return self._raw["id"]
|
|
68
|
+
|
|
69
|
+
session_id = id
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def model(self) -> str:
|
|
73
|
+
return self._raw["model"]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def status(self) -> Status:
|
|
77
|
+
return self._status
|
|
78
|
+
|
|
79
|
+
def get_status(self) -> Status:
|
|
80
|
+
return self._status
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def connect(self) -> Optional[Connect]:
|
|
84
|
+
"""Host credentials. Returned by the server ONCE (on the response that minted them) and kept here for the life
|
|
85
|
+
of this object; `None` while `waiting` and for a session re-fetched with `get_session`."""
|
|
86
|
+
return self._connect
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def world_id(self) -> Optional[str]:
|
|
90
|
+
return self._raw.get("world_id")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def billed_seconds(self) -> Optional[float]:
|
|
94
|
+
return self._raw.get("billed_seconds")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def cost_usd(self) -> Optional[float]:
|
|
98
|
+
return self._raw.get("cost_usd")
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def end_reason(self) -> Optional[str]:
|
|
102
|
+
return self._raw.get("end_reason")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def recording_url(self) -> Optional[str]:
|
|
106
|
+
return self._raw.get("recording_url")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def expires_at(self) -> Optional[str]:
|
|
110
|
+
return self._raw.get("expires_at")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def heartbeat_interval_seconds(self) -> float:
|
|
114
|
+
return float(self._raw.get("heartbeat_interval_seconds") or 5)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def raw(self) -> dict:
|
|
118
|
+
return dict(self._raw)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def last_error(self) -> Optional[LiveVideoError]:
|
|
122
|
+
return self._last_error
|
|
123
|
+
|
|
124
|
+
def get_last_error(self) -> Optional[LiveVideoError]:
|
|
125
|
+
return self._last_error
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"<{type(self).__name__} {self._raw.get('id')} {self.model} {self._status}>"
|
|
129
|
+
|
|
130
|
+
# ── internals ────────────────────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
def _apply(self, data: dict, emit: bool = True) -> None:
|
|
132
|
+
previous = self._status
|
|
133
|
+
self._raw = {**self._raw, **{k: v for k, v in data.items() if k != "connect"}}
|
|
134
|
+
if data.get("connect"):
|
|
135
|
+
self._connect = data["connect"]
|
|
136
|
+
self._status = Status(self._raw.get("status", "waiting"))
|
|
137
|
+
if emit and self._status != previous:
|
|
138
|
+
self._emit("status_changed", self._status)
|
|
139
|
+
if self._status == Status.ENDED:
|
|
140
|
+
self._emit("ended", self)
|
|
141
|
+
|
|
142
|
+
def _fail(self, exc: LiveVideoError) -> None:
|
|
143
|
+
self._last_error = exc
|
|
144
|
+
self._emit("error", exc)
|
|
145
|
+
|
|
146
|
+
def _require_open(self, what: str) -> None:
|
|
147
|
+
if self._status == Status.ENDED:
|
|
148
|
+
raise SessionStateError(f"cannot {what}: session {self.id} has ended ({self.end_reason})")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LiveSession(_SessionBase):
|
|
152
|
+
"""Synchronous session. Heartbeats run on a daemon thread."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, client: Any, data: dict) -> None:
|
|
155
|
+
super().__init__(client, data)
|
|
156
|
+
self._hb_thread: Optional[threading.Thread] = None
|
|
157
|
+
self._hb_stop = threading.Event()
|
|
158
|
+
|
|
159
|
+
def refresh(self) -> "LiveSession":
|
|
160
|
+
"""Re-read the session. For a `waiting` session this also advances it: the call that sees the world finish
|
|
161
|
+
building mints the host credentials and returns them (then available as `.connect`)."""
|
|
162
|
+
self._apply(self._client._request("GET", f"/live-video/sessions/{self.id}", retry=True))
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def wait_until_ready(self, timeout: float = 300.0, poll_interval: float = 4.0) -> "LiveSession":
|
|
166
|
+
deadline = time.monotonic() + timeout
|
|
167
|
+
while self._status == Status.WAITING:
|
|
168
|
+
if time.monotonic() >= deadline:
|
|
169
|
+
raise TimeoutError(f"session {self.id} was still waiting after {timeout:g}s")
|
|
170
|
+
time.sleep(poll_interval)
|
|
171
|
+
self.refresh()
|
|
172
|
+
if self._status == Status.ENDED:
|
|
173
|
+
raise SessionStateError(f"session {self.id} ended while waiting ({self.end_reason})")
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def heartbeat(self) -> "LiveSession":
|
|
177
|
+
self._require_open("heartbeat")
|
|
178
|
+
self._apply(self._client._request("POST", f"/live-video/sessions/{self.id}/heartbeat", retry=True))
|
|
179
|
+
self._emit("heartbeat", self)
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def start_heartbeat(self, interval: Optional[float] = None) -> None:
|
|
183
|
+
"""Beat in the background every `interval` seconds (default: the server's `heartbeat_interval_seconds`, 5 s)."""
|
|
184
|
+
if self._hb_thread and self._hb_thread.is_alive():
|
|
185
|
+
return
|
|
186
|
+
every = interval or self.heartbeat_interval_seconds
|
|
187
|
+
self._hb_stop.clear()
|
|
188
|
+
|
|
189
|
+
def loop() -> None:
|
|
190
|
+
while not self._hb_stop.wait(every):
|
|
191
|
+
if self._status == Status.ENDED:
|
|
192
|
+
return
|
|
193
|
+
try:
|
|
194
|
+
self.heartbeat()
|
|
195
|
+
except (NotFoundError, AuthenticationError, SessionStateError) as exc:
|
|
196
|
+
self._fail(exc)
|
|
197
|
+
return
|
|
198
|
+
except LiveVideoError as exc: # transient: keep beating, surface it
|
|
199
|
+
self._fail(exc)
|
|
200
|
+
|
|
201
|
+
self._hb_thread = threading.Thread(target=loop, name=f"live-video-heartbeat-{self.id}", daemon=True)
|
|
202
|
+
self._hb_thread.start()
|
|
203
|
+
|
|
204
|
+
def stop_heartbeat(self) -> None:
|
|
205
|
+
self._hb_stop.set()
|
|
206
|
+
t = self._hb_thread
|
|
207
|
+
if t and t.is_alive() and t is not threading.current_thread():
|
|
208
|
+
t.join(timeout=2)
|
|
209
|
+
self._hb_thread = None
|
|
210
|
+
|
|
211
|
+
def end(self, host_travel_id: Optional[str] = None) -> "LiveSession":
|
|
212
|
+
"""Settle the session now. Safe to call twice. `host_travel_id` (Happy Oyster) lets the server bill on the host's
|
|
213
|
+
authoritative duration and fetch the replay."""
|
|
214
|
+
self.stop_heartbeat()
|
|
215
|
+
if self._status != Status.ENDED:
|
|
216
|
+
body = {"host_travel_id": host_travel_id} if host_travel_id else None
|
|
217
|
+
self._apply(self._client._request("POST", f"/live-video/sessions/{self.id}/end", json=body, retry=True))
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> "LiveSession":
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __exit__(self, *exc: Any) -> None:
|
|
224
|
+
self.end()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AsyncLiveSession(_SessionBase):
|
|
228
|
+
"""Asyncio session. Heartbeats run as a background task."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, client: Any, data: dict) -> None:
|
|
231
|
+
super().__init__(client, data)
|
|
232
|
+
self._hb_task: Optional["asyncio.Task[None]"] = None
|
|
233
|
+
|
|
234
|
+
async def refresh(self) -> "AsyncLiveSession":
|
|
235
|
+
self._apply(await self._client._request("GET", f"/live-video/sessions/{self.id}", retry=True))
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
async def wait_until_ready(self, timeout: float = 300.0, poll_interval: float = 4.0) -> "AsyncLiveSession":
|
|
239
|
+
deadline = time.monotonic() + timeout
|
|
240
|
+
while self._status == Status.WAITING:
|
|
241
|
+
if time.monotonic() >= deadline:
|
|
242
|
+
raise TimeoutError(f"session {self.id} was still waiting after {timeout:g}s")
|
|
243
|
+
await asyncio.sleep(poll_interval)
|
|
244
|
+
await self.refresh()
|
|
245
|
+
if self._status == Status.ENDED:
|
|
246
|
+
raise SessionStateError(f"session {self.id} ended while waiting ({self.end_reason})")
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def heartbeat(self) -> "AsyncLiveSession":
|
|
250
|
+
self._require_open("heartbeat")
|
|
251
|
+
self._apply(await self._client._request("POST", f"/live-video/sessions/{self.id}/heartbeat", retry=True))
|
|
252
|
+
self._emit("heartbeat", self)
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def start_heartbeat(self, interval: Optional[float] = None) -> None:
|
|
256
|
+
if self._hb_task and not self._hb_task.done():
|
|
257
|
+
return
|
|
258
|
+
every = interval or self.heartbeat_interval_seconds
|
|
259
|
+
|
|
260
|
+
async def loop() -> None:
|
|
261
|
+
while self._status != Status.ENDED:
|
|
262
|
+
await asyncio.sleep(every)
|
|
263
|
+
if self._status == Status.ENDED:
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
await self.heartbeat()
|
|
267
|
+
except (NotFoundError, AuthenticationError, SessionStateError) as exc:
|
|
268
|
+
self._fail(exc)
|
|
269
|
+
return
|
|
270
|
+
except LiveVideoError as exc:
|
|
271
|
+
self._fail(exc)
|
|
272
|
+
|
|
273
|
+
self._hb_task = asyncio.get_running_loop().create_task(loop())
|
|
274
|
+
|
|
275
|
+
async def stop_heartbeat(self) -> None:
|
|
276
|
+
task, self._hb_task = self._hb_task, None
|
|
277
|
+
if task and not task.done() and task is not asyncio.current_task():
|
|
278
|
+
task.cancel()
|
|
279
|
+
try:
|
|
280
|
+
await task
|
|
281
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
async def end(self, host_travel_id: Optional[str] = None) -> "AsyncLiveSession":
|
|
285
|
+
await self.stop_heartbeat()
|
|
286
|
+
if self._status != Status.ENDED:
|
|
287
|
+
body = {"host_travel_id": host_travel_id} if host_travel_id else None
|
|
288
|
+
self._apply(await self._client._request("POST", f"/live-video/sessions/{self.id}/end", json=body, retry=True))
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
async def __aenter__(self) -> "AsyncLiveSession":
|
|
292
|
+
return self
|
|
293
|
+
|
|
294
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
295
|
+
await self.end()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Status(str):
|
|
8
|
+
"""Session status. A `str` subclass, so `session.status == "ready"` works."""
|
|
9
|
+
|
|
10
|
+
WAITING = "waiting" # the host is building a world; nothing is metered yet
|
|
11
|
+
READY = "ready" # credentials issued; the meter is running
|
|
12
|
+
ENDED = "ended" # settled
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Status.WAITING, Status.READY, Status.ENDED = Status("waiting"), Status("ready"), Status("ended")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Model:
|
|
20
|
+
id: str
|
|
21
|
+
kind: str # generate | world | avatar | edit
|
|
22
|
+
control: str # stream | actions
|
|
23
|
+
max_session_sec: int
|
|
24
|
+
available: bool # False until the model has a verified price
|
|
25
|
+
price_per_second_usd: Optional[float]
|
|
26
|
+
price_per_hour_usd: Optional[float]
|
|
27
|
+
resolutions: tuple = ()
|
|
28
|
+
description: str = ""
|
|
29
|
+
speeds: tuple = ()
|
|
30
|
+
fast_price_per_second_usd: Optional[float] = None
|
|
31
|
+
raw: dict = field(default_factory=dict, repr=False, compare=False)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, d: dict) -> "Model":
|
|
35
|
+
return cls(id=d["id"], kind=d.get("kind", ""), control=d.get("control", ""),
|
|
36
|
+
max_session_sec=int(d.get("max_session_sec") or 0), available=bool(d.get("available")),
|
|
37
|
+
price_per_second_usd=d.get("price_per_second_usd"), price_per_hour_usd=d.get("price_per_hour_usd"),
|
|
38
|
+
resolutions=tuple(d.get("resolutions") or ()), description=d.get("description", ""),
|
|
39
|
+
speeds=tuple(d.get("speeds") or ()), fast_price_per_second_usd=d.get("fast_price_per_second_usd"),
|
|
40
|
+
raw=d)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Token:
|
|
45
|
+
"""A short-lived credential for a browser. Mint it on YOUR server and hand `token` to the client SDK."""
|
|
46
|
+
token: str
|
|
47
|
+
expires_at: str
|
|
48
|
+
models: Optional[list] = None
|
|
49
|
+
max_sessions: Optional[int] = None
|
|
50
|
+
max_session_duration_seconds: Optional[int] = None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, d: dict) -> "Token":
|
|
54
|
+
return cls(token=d["token"], expires_at=d["expires_at"], models=d.get("models"),
|
|
55
|
+
max_sessions=d.get("max_sessions"),
|
|
56
|
+
max_session_duration_seconds=d.get("max_session_duration_seconds"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# `connect` is host-specific and handed to the HOST's own SDK; its shape per model family:
|
|
60
|
+
# Happy Oyster : {"ticket", "token", "api_base_url"}
|
|
61
|
+
# Odyssey : {"session_id", "signaling_url", "session_token", "expires_in", "capabilities"}
|
|
62
|
+
# Decart Lucy : {"api_key", "token", "base_url", "model", "expires_at", "max_session_duration_seconds"}
|
|
63
|
+
Connect = dict[str, Any]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""`client.videos` — the `/v1/videos` generation API (needs an API key; browser tokens are live-video only).
|
|
2
|
+
|
|
3
|
+
Params are the API's own fields, passed through unchanged: `model`, `prompt`, `duration_secs`, `resolution`,
|
|
4
|
+
`aspect_ratio`, `start_image_url`, ... See https://videorouter.sh/docs/video-generation.
|
|
5
|
+
|
|
6
|
+
job = lv.videos.generate({"model": "heygen-video-1", "prompt": "A presenter says: Welcome!", "duration_secs": 5})
|
|
7
|
+
print(job.url)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from ._errors import LiveVideoError, TimeoutError, VideoGenerationError
|
|
17
|
+
|
|
18
|
+
_DONE = ("completed", "succeeded")
|
|
19
|
+
_FAILED = ("failed", "error", "cancelled")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _JobBase:
|
|
23
|
+
def __init__(self, videos: Any, data: Optional[dict]) -> None:
|
|
24
|
+
self._videos = videos
|
|
25
|
+
self.raw: dict = dict(data or {})
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> Optional[str]:
|
|
29
|
+
return self.raw.get("id")
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def status(self) -> str:
|
|
33
|
+
""""queued" | "processing" | "completed" | "failed" (a host may also report `succeeded` / `cancelled`)."""
|
|
34
|
+
return self.raw.get("status") or "queued"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def done(self) -> bool:
|
|
38
|
+
return self.status in _DONE
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def failed(self) -> bool:
|
|
42
|
+
return self.status in _FAILED
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def urls(self) -> list:
|
|
46
|
+
"""Every output URL (usually one). Empty until the job completes."""
|
|
47
|
+
return [d["url"] for d in (self.raw.get("data") or []) if isinstance(d, dict) and d.get("url")]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def url(self) -> Optional[str]:
|
|
51
|
+
urls = self.urls
|
|
52
|
+
return urls[0] if urls else None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def error(self) -> Optional[dict]:
|
|
56
|
+
return self.raw.get("error")
|
|
57
|
+
|
|
58
|
+
def _failure(self) -> VideoGenerationError:
|
|
59
|
+
e = self.error or {}
|
|
60
|
+
return VideoGenerationError(e.get("message") or f"video {self.id} {self.status}", code=e.get("code"),
|
|
61
|
+
type=e.get("type"), body=self.raw)
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
return f"<VideoJob {self.id} {self.status}>"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class VideoJob(_JobBase):
|
|
68
|
+
def refresh(self) -> "VideoJob":
|
|
69
|
+
self.raw = self._videos._get(self.id)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def wait(self, timeout: float = 600.0, poll_interval: float = 5.0) -> "VideoJob":
|
|
73
|
+
"""Poll until the job completes. Raises `VideoGenerationError` if it fails, `TimeoutError` if it runs out of time."""
|
|
74
|
+
deadline = time.monotonic() + timeout
|
|
75
|
+
while not self.done:
|
|
76
|
+
if self.failed:
|
|
77
|
+
raise self._failure()
|
|
78
|
+
if time.monotonic() >= deadline:
|
|
79
|
+
raise TimeoutError(f"video {self.id} was still {self.status} after {timeout}s")
|
|
80
|
+
time.sleep(poll_interval)
|
|
81
|
+
self.refresh()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AsyncVideoJob(_JobBase):
|
|
86
|
+
async def refresh(self) -> "AsyncVideoJob":
|
|
87
|
+
self.raw = await self._videos._get(self.id)
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
async def wait(self, timeout: float = 600.0, poll_interval: float = 5.0) -> "AsyncVideoJob":
|
|
91
|
+
deadline = time.monotonic() + timeout
|
|
92
|
+
while not self.done:
|
|
93
|
+
if self.failed:
|
|
94
|
+
raise self._failure()
|
|
95
|
+
if time.monotonic() >= deadline:
|
|
96
|
+
raise TimeoutError(f"video {self.id} was still {self.status} after {timeout}s")
|
|
97
|
+
await asyncio.sleep(poll_interval)
|
|
98
|
+
await self.refresh()
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Videos:
|
|
103
|
+
def __init__(self, client: Any) -> None:
|
|
104
|
+
self._client = client
|
|
105
|
+
|
|
106
|
+
def create(self, params: dict) -> VideoJob:
|
|
107
|
+
"""Submit a job. Never retried: a retried create could be billed twice."""
|
|
108
|
+
return VideoJob(self, self._client._request("POST", "/videos", json=params))
|
|
109
|
+
|
|
110
|
+
def get(self, video_id: str) -> VideoJob:
|
|
111
|
+
return VideoJob(self, self._get(video_id))
|
|
112
|
+
|
|
113
|
+
def generate(self, params: dict, *, timeout: float = 600.0, poll_interval: float = 5.0) -> VideoJob:
|
|
114
|
+
"""Create and wait; returns the finished job (`job.url`)."""
|
|
115
|
+
return self.create(params).wait(timeout=timeout, poll_interval=poll_interval)
|
|
116
|
+
|
|
117
|
+
def _get(self, video_id: str) -> dict:
|
|
118
|
+
return self._client._request("GET", f"/videos/{video_id}", retry=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncVideos:
|
|
122
|
+
def __init__(self, client: Any) -> None:
|
|
123
|
+
self._client = client
|
|
124
|
+
|
|
125
|
+
async def create(self, params: dict) -> AsyncVideoJob:
|
|
126
|
+
return AsyncVideoJob(self, await self._client._request("POST", "/videos", json=params))
|
|
127
|
+
|
|
128
|
+
async def get(self, video_id: str) -> AsyncVideoJob:
|
|
129
|
+
return AsyncVideoJob(self, await self._get(video_id))
|
|
130
|
+
|
|
131
|
+
async def generate(self, params: dict, *, timeout: float = 600.0, poll_interval: float = 5.0) -> AsyncVideoJob:
|
|
132
|
+
return await (await self.create(params)).wait(timeout=timeout, poll_interval=poll_interval)
|
|
133
|
+
|
|
134
|
+
async def _get(self, video_id: str) -> dict:
|
|
135
|
+
return await self._client._request("GET", f"/videos/{video_id}", retry=True)
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: videorouter-live
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for videorouter.sh: live video & world models (open, keep alive, settle a real-time session) and the /v1/videos generation API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://videorouter.sh/docs/live-video
|
|
7
|
+
Keywords: videorouter,video,world-model,real-time,live-video,sdk
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: httpx>=0.25
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# videorouter-live (Python)
|
|
18
|
+
|
|
19
|
+
Python SDK for [videorouter.sh live video & world models](https://videorouter.sh/docs/live-video): open a real-time
|
|
20
|
+
session, keep it alive, settle it. Sync (`LiveVideo`) and asyncio (`AsyncLiveVideo`) clients; only dependency is `httpx`.
|
|
21
|
+
|
|
22
|
+
The SDK talks to the session **broker**. It never carries media: `session.connect` holds the host's short-lived
|
|
23
|
+
credentials, which you hand to the host's own SDK (Happy Oyster, Odyssey, Decart...) in the browser.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from videorouter_live import LiveVideo
|
|
27
|
+
|
|
28
|
+
lv = LiveVideo(api_key="llmr_sk_...") # or set VIDEOROUTER_API_KEY
|
|
29
|
+
|
|
30
|
+
with lv.session("fal/happy-oyster-adventure", max_duration_seconds=60,
|
|
31
|
+
options={"world": {"prompt": "a rainy cyberpunk city", "perspective": "first_person"}}) as s:
|
|
32
|
+
send_to_browser(s.connect) # host credentials — returned once by the server
|
|
33
|
+
... # heartbeats run in the background; the session is settled on exit
|
|
34
|
+
print(s.billed_seconds, s.cost_usd, s.end_reason)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`lv.session(...)` creates the session, waits until it is `ready` (a world may need ~30–120 s to build; nothing is
|
|
38
|
+
metered while `waiting`), starts the heartbeat and settles it on exit even if your code raised.
|
|
39
|
+
|
|
40
|
+
## Browser tokens
|
|
41
|
+
|
|
42
|
+
Never put your key in a browser. Mint a short-lived, live-video-only token on your server:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
token = lv.create_token(models=["fal/happy-oyster-adventure"], expires_in=300,
|
|
46
|
+
max_sessions=1, max_session_duration_seconds=60)
|
|
47
|
+
# hand token.token to the browser SDK: new LiveVideo({ token })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A token can only call `/v1/live-video` session endpoints — not billable endpoints, and not `create_token`.
|
|
51
|
+
|
|
52
|
+
## Generating videos
|
|
53
|
+
|
|
54
|
+
Both clients also wrap `/v1/videos` (API key required; browser tokens are live-video only). Creating a job is never retried.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
lv = LiveVideo() # reads VIDEOROUTER_API_KEY
|
|
58
|
+
job = lv.videos.generate({"model": "heygen-video-1", "prompt": "A presenter says: Welcome!", "duration_secs": 5})
|
|
59
|
+
print(job.url)
|
|
60
|
+
|
|
61
|
+
queued = lv.videos.create({"model": "wan-3.0-prime", "prompt": "..."})
|
|
62
|
+
queued.wait(timeout=600) # raises VideoGenerationError / TimeoutError
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`job.status`, `.done`, `.failed`, `.url` / `.urls`, `.error`, `.raw`, `.refresh()`, `.wait(timeout, poll_interval)`;
|
|
66
|
+
`AsyncLiveVideo` has the same surface as coroutines.
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
| | |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `LiveVideo(api_key=None, *, token=None, base_url=..., timeout=30, max_retries=2, http_client=None)` | `AsyncLiveVideo` has the same signature |
|
|
73
|
+
| `lv.models()` | `list[Model]` — `available` is `False` until a model has a verified price |
|
|
74
|
+
| `lv.create_token(*, models, expires_in, max_sessions, max_session_duration_seconds)` | `Token` |
|
|
75
|
+
| `lv.create_session(model, *, max_duration_seconds, options)` | `LiveSession` |
|
|
76
|
+
| `lv.get_session(id)` | `LiveSession` (never has `connect` — it is returned once) |
|
|
77
|
+
| `lv.session(model, ..., wait_until_ready=True, auto_heartbeat=True)` | context manager |
|
|
78
|
+
|
|
79
|
+
`LiveSession` (and `AsyncLiveSession`, where the network methods are coroutines), modelled on Reactor's client:
|
|
80
|
+
|
|
81
|
+
| | |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `.id` / `.session_id`, `.model`, `.status`, `.get_status()` | `Status` is a `str` subclass: `"waiting"`, `"ready"`, `"ended"` |
|
|
84
|
+
| `.connect` | host credentials, or `None` |
|
|
85
|
+
| `.billed_seconds`, `.cost_usd`, `.end_reason`, `.recording_url`, `.world_id`, `.raw` | |
|
|
86
|
+
| `.refresh()` | re-read; advances a `waiting` session and returns `connect` when the world is ready |
|
|
87
|
+
| `.wait_until_ready(timeout=300, poll_interval=4)` | raises `TimeoutError` / `SessionStateError` |
|
|
88
|
+
| `.heartbeat()`, `.start_heartbeat(interval=None)`, `.stop_heartbeat()` | default interval is the server's 5 s |
|
|
89
|
+
| `.end(host_travel_id=None)` | settle; safe to call twice |
|
|
90
|
+
| `.on(event, fn)`, `.once(...)`, `.off(...)` | events: `status_changed`, `ended`, `heartbeat`, `error` |
|
|
91
|
+
| `.last_error` / `.get_last_error()` | last background-heartbeat error |
|
|
92
|
+
|
|
93
|
+
### Errors
|
|
94
|
+
|
|
95
|
+
Every failure raises a `LiveVideoError` subclass carrying `status`, `code`, `type`, `retry_after`:
|
|
96
|
+
`AuthenticationError` (401), `PermissionDeniedError` (403), `InvalidRequestError` (400),
|
|
97
|
+
`InsufficientCreditsError` (402), `PricingUnavailableError` (502), `NotFoundError` (404),
|
|
98
|
+
`RateLimitError` (429) → `ConcurrencyLimitError` / `HostCapacityError`, `UpstreamError` (5xx), plus
|
|
99
|
+
`SessionStateError` and `TimeoutError` raised locally.
|
|
100
|
+
|
|
101
|
+
### Retries
|
|
102
|
+
|
|
103
|
+
GETs, heartbeats and `end` are retried on network errors and 502/503/504. **Creating a session or a token is never
|
|
104
|
+
retried** — a retried create could open a second billed session.
|
|
105
|
+
|
|
106
|
+
## Billing in one paragraph
|
|
107
|
+
|
|
108
|
+
Opening a session holds `max_duration_seconds × price` against your credits; ending it debits the real cost
|
|
109
|
+
(whole seconds, at least the host's own reported duration) and releases the rest. A session that never became ready
|
|
110
|
+
costs nothing. If the heartbeat stops, the server settles the session itself — a forgotten tab costs at most the
|
|
111
|
+
silence window.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
videorouter_live/__init__.py,sha256=vofWlyLlkxFXLf2zj8KHHTl6WEh9ODKLzIKzuwlfcjs,1498
|
|
2
|
+
videorouter_live/_client.py,sha256=3iouDC6xtSySq7UQYd5bn_syHbkDBYUfwmN6kXeM0Bs,12089
|
|
3
|
+
videorouter_live/_errors.py,sha256=k_p2zxGX_ExKBehVOT22ThnulYgtfvhaDzkJN117ZGQ,4160
|
|
4
|
+
videorouter_live/_session.py,sha256=RYmGfKRWGpSNe6onSDardOuwsfaqBKfRjckChnSl7J8,11867
|
|
5
|
+
videorouter_live/_types.py,sha256=WMPGQDU7f5B2FDrImViIjXl0vLYoc8t5f3lUfAXS8tE,2666
|
|
6
|
+
videorouter_live/_videos.py,sha256=s84zMMtssrx6IJ3qwnI1rkjGCXvcDGBNDcR2vwkWgrs,4947
|
|
7
|
+
videorouter_live/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
videorouter_live-0.2.0.dist-info/licenses/LICENSE,sha256=77GQ3aAhJiYTeh2sa0vlJANI_9CPVUigSvKG6eyngY8,1068
|
|
9
|
+
videorouter_live-0.2.0.dist-info/METADATA,sha256=FiqUg6k_9AlUWTNXeDrBd_YBVANGGy2idoHTfo__2os,5627
|
|
10
|
+
videorouter_live-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
videorouter_live-0.2.0.dist-info/top_level.txt,sha256=4RlIubOLiZNqiZ0Xb67SLpVZYReFHi-K-5t-LWiEOeI,17
|
|
12
|
+
videorouter_live-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Synvia Corp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
videorouter_live
|