swarmauri_video_lipsync_synclabs 0.1.0.dev4__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.
- swarmauri_video_lipsync_synclabs/SyncLabsLipSync.py +477 -0
- swarmauri_video_lipsync_synclabs/__init__.py +5 -0
- swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/METADATA +118 -0
- swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/RECORD +6 -0
- swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/WHEEL +4 -0
- swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Sync Labs lip-sync provider implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import mimetypes
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal, cast
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import Field, SecretStr
|
|
13
|
+
|
|
14
|
+
from swarmauri_base.ComponentBase import ComponentBase
|
|
15
|
+
from swarmauri_base.video_lipsync.LipSyncBase import LipSyncBase
|
|
16
|
+
from swarmauri_core.video_lipsync.IQueuedLipSync import IQueuedLipSync
|
|
17
|
+
from swarmauri_core.video_lipsync.types import (
|
|
18
|
+
LipSyncJobEvent,
|
|
19
|
+
LipSyncJobStatus,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_TERMINAL_STATUSES = {"COMPLETED", "FAILED", "REJECTED"}
|
|
23
|
+
_STATUS_MAP: dict[str, LipSyncJobStatus] = {
|
|
24
|
+
"PENDING": "QUEUED",
|
|
25
|
+
"PROCESSING": "RUNNING",
|
|
26
|
+
"COMPLETED": "COMPLETED",
|
|
27
|
+
"FAILED": "FAILED",
|
|
28
|
+
"REJECTED": "REJECTED",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@ComponentBase.register_type(LipSyncBase, "SyncLabsLipSync")
|
|
33
|
+
class SyncLabsLipSync(LipSyncBase, IQueuedLipSync):
|
|
34
|
+
"""Generate lip-synced videos with Sync Labs' queued API."""
|
|
35
|
+
|
|
36
|
+
allowed_models: list[str] = [
|
|
37
|
+
"sync-3",
|
|
38
|
+
"lipsync-2",
|
|
39
|
+
"lipsync-2-pro",
|
|
40
|
+
"lipsync-1.9.0-beta",
|
|
41
|
+
"react-1",
|
|
42
|
+
]
|
|
43
|
+
api_key: SecretStr = Field(exclude=True, repr=False)
|
|
44
|
+
name: str = "lipsync-2"
|
|
45
|
+
type: Literal["SyncLabsLipSync"] = "SyncLabsLipSync"
|
|
46
|
+
base_url: str = "https://api.sync.so"
|
|
47
|
+
request_timeout: float = 60.0
|
|
48
|
+
poll_interval: float = 2.0
|
|
49
|
+
max_wait_time: float = 1800.0
|
|
50
|
+
max_retries: int = 3
|
|
51
|
+
|
|
52
|
+
def _headers(self) -> dict[str, str]:
|
|
53
|
+
return {
|
|
54
|
+
"accept": "application/json",
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
"x-api-key": self.api_key.get_secret_value(),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def _create_api_client(self) -> httpx.Client:
|
|
60
|
+
return httpx.Client(
|
|
61
|
+
base_url=self.base_url,
|
|
62
|
+
headers=self._headers(),
|
|
63
|
+
timeout=self.request_timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _create_external_client(self) -> httpx.Client:
|
|
67
|
+
return httpx.Client(timeout=self.request_timeout)
|
|
68
|
+
|
|
69
|
+
def _create_async_api_client(self) -> httpx.AsyncClient:
|
|
70
|
+
return httpx.AsyncClient(
|
|
71
|
+
base_url=self.base_url,
|
|
72
|
+
headers=self._headers(),
|
|
73
|
+
timeout=self.request_timeout,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _create_async_external_client(self) -> httpx.AsyncClient:
|
|
77
|
+
return httpx.AsyncClient(timeout=self.request_timeout)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _is_url(value: str) -> bool:
|
|
81
|
+
return urlparse(value).scheme in {"http", "https"}
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
85
|
+
retry_after = response.headers.get("Retry-After")
|
|
86
|
+
if retry_after:
|
|
87
|
+
try:
|
|
88
|
+
return max(float(retry_after), 0.0)
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
return min(2**attempt, 8)
|
|
92
|
+
|
|
93
|
+
def _request(
|
|
94
|
+
self,
|
|
95
|
+
client: httpx.Client,
|
|
96
|
+
method: str,
|
|
97
|
+
url: str,
|
|
98
|
+
*,
|
|
99
|
+
retry_server_errors: bool = True,
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
) -> httpx.Response:
|
|
102
|
+
for attempt in range(self.max_retries + 1):
|
|
103
|
+
response = client.request(method, url, **kwargs)
|
|
104
|
+
retryable = response.status_code == 429 or (
|
|
105
|
+
retry_server_errors
|
|
106
|
+
and response.status_code in {500, 502, 503, 504}
|
|
107
|
+
)
|
|
108
|
+
if not retryable or attempt == self.max_retries:
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
return response
|
|
111
|
+
time.sleep(self._retry_delay(response, attempt))
|
|
112
|
+
raise RuntimeError("unreachable retry state")
|
|
113
|
+
|
|
114
|
+
async def _arequest(
|
|
115
|
+
self,
|
|
116
|
+
client: httpx.AsyncClient,
|
|
117
|
+
method: str,
|
|
118
|
+
url: str,
|
|
119
|
+
*,
|
|
120
|
+
retry_server_errors: bool = True,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> httpx.Response:
|
|
123
|
+
for attempt in range(self.max_retries + 1):
|
|
124
|
+
response = await client.request(method, url, **kwargs)
|
|
125
|
+
retryable = response.status_code == 429 or (
|
|
126
|
+
retry_server_errors
|
|
127
|
+
and response.status_code in {500, 502, 503, 504}
|
|
128
|
+
)
|
|
129
|
+
if not retryable or attempt == self.max_retries:
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return response
|
|
132
|
+
await asyncio.sleep(self._retry_delay(response, attempt))
|
|
133
|
+
raise RuntimeError("unreachable retry state")
|
|
134
|
+
|
|
135
|
+
def _upload_local_asset(self, path_value: str, asset_type: str) -> str:
|
|
136
|
+
path = Path(path_value).expanduser().resolve(strict=True)
|
|
137
|
+
content_type = mimetypes.guess_type(path.name)[0]
|
|
138
|
+
if content_type is None:
|
|
139
|
+
content_type = (
|
|
140
|
+
"video/mp4" if asset_type == "VIDEO" else "audio/mpeg"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
with self._create_api_client() as api_client:
|
|
144
|
+
upload = self._request(
|
|
145
|
+
api_client,
|
|
146
|
+
"POST",
|
|
147
|
+
"/v2/assets/upload",
|
|
148
|
+
json={
|
|
149
|
+
"fileName": path.name,
|
|
150
|
+
"contentType": content_type,
|
|
151
|
+
"size": path.stat().st_size,
|
|
152
|
+
},
|
|
153
|
+
).json()
|
|
154
|
+
|
|
155
|
+
with (
|
|
156
|
+
path.open("rb") as media,
|
|
157
|
+
self._create_external_client() as external,
|
|
158
|
+
):
|
|
159
|
+
response = external.put(
|
|
160
|
+
upload["uploadUrl"],
|
|
161
|
+
content=media,
|
|
162
|
+
headers={"Content-Type": content_type},
|
|
163
|
+
)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
|
|
166
|
+
with self._create_api_client() as api_client:
|
|
167
|
+
asset = self._request(
|
|
168
|
+
api_client,
|
|
169
|
+
"POST",
|
|
170
|
+
"/v2/assets",
|
|
171
|
+
json={
|
|
172
|
+
"url": upload["url"],
|
|
173
|
+
"type": asset_type,
|
|
174
|
+
"name": path.name,
|
|
175
|
+
},
|
|
176
|
+
).json()
|
|
177
|
+
return cast(str, asset["id"])
|
|
178
|
+
|
|
179
|
+
async def _aupload_local_asset(
|
|
180
|
+
self, path_value: str, asset_type: str
|
|
181
|
+
) -> str:
|
|
182
|
+
path = Path(path_value).expanduser().resolve(strict=True)
|
|
183
|
+
content_type = mimetypes.guess_type(path.name)[0]
|
|
184
|
+
if content_type is None:
|
|
185
|
+
content_type = (
|
|
186
|
+
"video/mp4" if asset_type == "VIDEO" else "audio/mpeg"
|
|
187
|
+
)
|
|
188
|
+
size = path.stat().st_size
|
|
189
|
+
|
|
190
|
+
async with self._create_async_api_client() as api_client:
|
|
191
|
+
upload = (
|
|
192
|
+
await self._arequest(
|
|
193
|
+
api_client,
|
|
194
|
+
"POST",
|
|
195
|
+
"/v2/assets/upload",
|
|
196
|
+
json={
|
|
197
|
+
"fileName": path.name,
|
|
198
|
+
"contentType": content_type,
|
|
199
|
+
"size": size,
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
).json()
|
|
203
|
+
|
|
204
|
+
async def chunks() -> AsyncIterator[bytes]:
|
|
205
|
+
with path.open("rb") as media:
|
|
206
|
+
while chunk := await asyncio.to_thread(
|
|
207
|
+
media.read, 1024 * 1024
|
|
208
|
+
):
|
|
209
|
+
yield chunk
|
|
210
|
+
|
|
211
|
+
async with self._create_async_external_client() as external:
|
|
212
|
+
response = await external.put(
|
|
213
|
+
upload["uploadUrl"],
|
|
214
|
+
content=chunks(),
|
|
215
|
+
headers={"Content-Type": content_type},
|
|
216
|
+
)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
|
|
219
|
+
async with self._create_async_api_client() as api_client:
|
|
220
|
+
asset = (
|
|
221
|
+
await self._arequest(
|
|
222
|
+
api_client,
|
|
223
|
+
"POST",
|
|
224
|
+
"/v2/assets",
|
|
225
|
+
json={
|
|
226
|
+
"url": upload["url"],
|
|
227
|
+
"type": asset_type,
|
|
228
|
+
"name": path.name,
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
).json()
|
|
232
|
+
return cast(str, asset["id"])
|
|
233
|
+
|
|
234
|
+
def _input(self, value: str, media_type: str) -> dict[str, str]:
|
|
235
|
+
if self._is_url(value):
|
|
236
|
+
return {"type": media_type.lower(), "url": value}
|
|
237
|
+
asset_id = self._upload_local_asset(value, media_type)
|
|
238
|
+
return {"type": media_type.lower(), "assetId": asset_id}
|
|
239
|
+
|
|
240
|
+
async def _ainput(self, value: str, media_type: str) -> dict[str, str]:
|
|
241
|
+
if self._is_url(value):
|
|
242
|
+
return {"type": media_type.lower(), "url": value}
|
|
243
|
+
asset_id = await self._aupload_local_asset(value, media_type)
|
|
244
|
+
return {"type": media_type.lower(), "assetId": asset_id}
|
|
245
|
+
|
|
246
|
+
def _payload(
|
|
247
|
+
self,
|
|
248
|
+
video_input: dict[str, str],
|
|
249
|
+
audio_input: dict[str, str],
|
|
250
|
+
**kwargs: Any,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
options = dict(kwargs.pop("options", {}))
|
|
253
|
+
for key in tuple(kwargs):
|
|
254
|
+
if key not in {"webhook_url", "output_file_name", "project_id"}:
|
|
255
|
+
options[key] = kwargs.pop(key)
|
|
256
|
+
|
|
257
|
+
payload: dict[str, Any] = {
|
|
258
|
+
"model": self.name,
|
|
259
|
+
"input": [video_input, audio_input],
|
|
260
|
+
}
|
|
261
|
+
if options:
|
|
262
|
+
payload["options"] = options
|
|
263
|
+
aliases = {
|
|
264
|
+
"webhook_url": "webhookUrl",
|
|
265
|
+
"output_file_name": "outputFileName",
|
|
266
|
+
"project_id": "projectId",
|
|
267
|
+
}
|
|
268
|
+
for source, target in aliases.items():
|
|
269
|
+
value = kwargs.get(source)
|
|
270
|
+
if value is not None:
|
|
271
|
+
payload[target] = value
|
|
272
|
+
return payload
|
|
273
|
+
|
|
274
|
+
def submit(self, video: str, audio: str, **kwargs: Any) -> str:
|
|
275
|
+
"""Submit URL or local-file media to Sync Labs."""
|
|
276
|
+
payload = self._payload(
|
|
277
|
+
self._input(video, "VIDEO"),
|
|
278
|
+
self._input(audio, "AUDIO"),
|
|
279
|
+
**kwargs,
|
|
280
|
+
)
|
|
281
|
+
with self._create_api_client() as client:
|
|
282
|
+
response = self._request(
|
|
283
|
+
client,
|
|
284
|
+
"POST",
|
|
285
|
+
"/v2/generate",
|
|
286
|
+
retry_server_errors=False,
|
|
287
|
+
json=payload,
|
|
288
|
+
)
|
|
289
|
+
return cast(str, response.json()["id"])
|
|
290
|
+
|
|
291
|
+
async def _asubmit(self, video: str, audio: str, **kwargs: Any) -> str:
|
|
292
|
+
video_input, audio_input = await asyncio.gather(
|
|
293
|
+
self._ainput(video, "VIDEO"),
|
|
294
|
+
self._ainput(audio, "AUDIO"),
|
|
295
|
+
)
|
|
296
|
+
payload = self._payload(video_input, audio_input, **kwargs)
|
|
297
|
+
async with self._create_async_api_client() as client:
|
|
298
|
+
response = await self._arequest(
|
|
299
|
+
client,
|
|
300
|
+
"POST",
|
|
301
|
+
"/v2/generate",
|
|
302
|
+
retry_server_errors=False,
|
|
303
|
+
json=payload,
|
|
304
|
+
)
|
|
305
|
+
return cast(str, response.json()["id"])
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _event(generation: dict[str, Any]) -> LipSyncJobEvent:
|
|
309
|
+
provider_status = str(generation["status"])
|
|
310
|
+
if provider_status not in _STATUS_MAP:
|
|
311
|
+
raise ValueError(f"Unknown Sync Labs status: {provider_status}")
|
|
312
|
+
event = LipSyncJobEvent(
|
|
313
|
+
job_id=str(generation["id"]),
|
|
314
|
+
status=_STATUS_MAP[provider_status],
|
|
315
|
+
)
|
|
316
|
+
progress = generation.get("progress_percent")
|
|
317
|
+
if progress is not None:
|
|
318
|
+
event["progress"] = float(progress) / 100.0
|
|
319
|
+
if generation.get("error"):
|
|
320
|
+
event["message"] = str(generation["error"])
|
|
321
|
+
if generation.get("errorCode"):
|
|
322
|
+
event["error_code"] = str(generation["errorCode"])
|
|
323
|
+
if generation.get("outputUrl"):
|
|
324
|
+
event["output_url"] = str(generation["outputUrl"])
|
|
325
|
+
return event
|
|
326
|
+
|
|
327
|
+
def get_status(self, job_id: str) -> LipSyncJobEvent:
|
|
328
|
+
"""Fetch and normalize the current Sync Labs generation state."""
|
|
329
|
+
with self._create_api_client() as client:
|
|
330
|
+
response = self._request(
|
|
331
|
+
client,
|
|
332
|
+
"GET",
|
|
333
|
+
f"/v2/generate/{job_id}",
|
|
334
|
+
params={"include": "progress"},
|
|
335
|
+
)
|
|
336
|
+
return self._event(response.json())
|
|
337
|
+
|
|
338
|
+
async def _aget_status(self, job_id: str) -> LipSyncJobEvent:
|
|
339
|
+
async with self._create_async_api_client() as client:
|
|
340
|
+
response = await self._arequest(
|
|
341
|
+
client,
|
|
342
|
+
"GET",
|
|
343
|
+
f"/v2/generate/{job_id}",
|
|
344
|
+
params={"include": "progress"},
|
|
345
|
+
)
|
|
346
|
+
return self._event(response.json())
|
|
347
|
+
|
|
348
|
+
def iter_events(self, job_id: str) -> Iterator[LipSyncJobEvent]:
|
|
349
|
+
"""Poll and yield only changed normalized job states."""
|
|
350
|
+
started = time.monotonic()
|
|
351
|
+
previous: LipSyncJobEvent | None = None
|
|
352
|
+
while time.monotonic() - started <= self.max_wait_time:
|
|
353
|
+
event = self.get_status(job_id)
|
|
354
|
+
if event != previous:
|
|
355
|
+
yield event
|
|
356
|
+
previous = event
|
|
357
|
+
if event["status"] in _TERMINAL_STATUSES:
|
|
358
|
+
return
|
|
359
|
+
time.sleep(self.poll_interval)
|
|
360
|
+
raise TimeoutError(f"Sync Labs job {job_id} did not finish in time")
|
|
361
|
+
|
|
362
|
+
async def aiter_events(
|
|
363
|
+
self, job_id: str
|
|
364
|
+
) -> AsyncIterator[LipSyncJobEvent]:
|
|
365
|
+
"""Asynchronously poll and yield only changed normalized states."""
|
|
366
|
+
loop = asyncio.get_running_loop()
|
|
367
|
+
started = loop.time()
|
|
368
|
+
previous: LipSyncJobEvent | None = None
|
|
369
|
+
while loop.time() - started <= self.max_wait_time:
|
|
370
|
+
event = await self._aget_status(job_id)
|
|
371
|
+
if event != previous:
|
|
372
|
+
yield event
|
|
373
|
+
previous = event
|
|
374
|
+
if event["status"] in _TERMINAL_STATUSES:
|
|
375
|
+
return
|
|
376
|
+
await asyncio.sleep(self.poll_interval)
|
|
377
|
+
raise TimeoutError(f"Sync Labs job {job_id} did not finish in time")
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _completed_output(event: LipSyncJobEvent) -> str:
|
|
381
|
+
if event["status"] != "COMPLETED":
|
|
382
|
+
message = event.get(
|
|
383
|
+
"message", "provider returned no error message"
|
|
384
|
+
)
|
|
385
|
+
raise RuntimeError(
|
|
386
|
+
f"Lip-sync job ended with {event['status']}: {message}"
|
|
387
|
+
)
|
|
388
|
+
output_url = event.get("output_url")
|
|
389
|
+
if not output_url:
|
|
390
|
+
raise RuntimeError("Completed lip-sync job returned no output URL")
|
|
391
|
+
return output_url
|
|
392
|
+
|
|
393
|
+
def _download(self, output_url: str, output_path: str) -> str:
|
|
394
|
+
path = Path(output_path).expanduser().resolve()
|
|
395
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
with self._create_external_client() as client:
|
|
397
|
+
with client.stream("GET", output_url) as response:
|
|
398
|
+
response.raise_for_status()
|
|
399
|
+
with path.open("wb") as output:
|
|
400
|
+
for chunk in response.iter_bytes():
|
|
401
|
+
output.write(chunk)
|
|
402
|
+
return str(path)
|
|
403
|
+
|
|
404
|
+
async def _adownload(self, output_url: str, output_path: str) -> str:
|
|
405
|
+
path = Path(output_path).expanduser().resolve()
|
|
406
|
+
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
407
|
+
async with self._create_async_external_client() as client:
|
|
408
|
+
async with client.stream("GET", output_url) as response:
|
|
409
|
+
response.raise_for_status()
|
|
410
|
+
with path.open("wb") as output:
|
|
411
|
+
async for chunk in response.aiter_bytes():
|
|
412
|
+
await asyncio.to_thread(output.write, chunk)
|
|
413
|
+
return str(path)
|
|
414
|
+
|
|
415
|
+
def predict(
|
|
416
|
+
self,
|
|
417
|
+
video: str,
|
|
418
|
+
audio: str,
|
|
419
|
+
output_path: str = "output.mp4",
|
|
420
|
+
**kwargs: Any,
|
|
421
|
+
) -> str:
|
|
422
|
+
"""Submit, await, and download a lip-synced video."""
|
|
423
|
+
job_id = self.submit(video, audio, **kwargs)
|
|
424
|
+
final_event: LipSyncJobEvent | None = None
|
|
425
|
+
for final_event in self.iter_events(job_id):
|
|
426
|
+
pass
|
|
427
|
+
if final_event is None:
|
|
428
|
+
raise RuntimeError(f"Sync Labs job {job_id} returned no state")
|
|
429
|
+
return self._download(
|
|
430
|
+
self._completed_output(final_event),
|
|
431
|
+
output_path,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
async def apredict(
|
|
435
|
+
self,
|
|
436
|
+
video: str,
|
|
437
|
+
audio: str,
|
|
438
|
+
output_path: str = "output.mp4",
|
|
439
|
+
**kwargs: Any,
|
|
440
|
+
) -> str:
|
|
441
|
+
"""Asynchronously submit, await, and download a lip-synced video."""
|
|
442
|
+
job_id = await self._asubmit(video, audio, **kwargs)
|
|
443
|
+
final_event: LipSyncJobEvent | None = None
|
|
444
|
+
async for final_event in self.aiter_events(job_id):
|
|
445
|
+
pass
|
|
446
|
+
if final_event is None:
|
|
447
|
+
raise RuntimeError(f"Sync Labs job {job_id} returned no state")
|
|
448
|
+
return await self._adownload(
|
|
449
|
+
self._completed_output(final_event),
|
|
450
|
+
output_path,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def estimate_cost(self, video: str, audio: str, **kwargs: Any) -> float:
|
|
454
|
+
"""Return the summed estimated generation cost in US dollars."""
|
|
455
|
+
payload = self._payload(
|
|
456
|
+
self._input(video, "VIDEO"),
|
|
457
|
+
self._input(audio, "AUDIO"),
|
|
458
|
+
**kwargs,
|
|
459
|
+
)
|
|
460
|
+
with self._create_api_client() as client:
|
|
461
|
+
estimates = self._request(
|
|
462
|
+
client,
|
|
463
|
+
"POST",
|
|
464
|
+
"/v2/generations/estimate",
|
|
465
|
+
json=payload,
|
|
466
|
+
).json()
|
|
467
|
+
if isinstance(estimates, dict):
|
|
468
|
+
estimates = [estimates]
|
|
469
|
+
return sum(
|
|
470
|
+
float(
|
|
471
|
+
estimate.get(
|
|
472
|
+
"estimatedGenerationCost",
|
|
473
|
+
estimate.get("totalCost", 0.0),
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
for estimate in estimates
|
|
477
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swarmauri_video_lipsync_synclabs
|
|
3
|
+
Version: 0.1.0.dev4
|
|
4
|
+
Summary: Standalone Sync Labs queued lip-sync video provider for Swarmauri.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Keywords: swarmauri,sync labs,lip sync,video generation,video dubbing,media localization,queued api,multimodal ai
|
|
7
|
+
Author: Jacob Stewart
|
|
8
|
+
Author-email: jacob@swarmauri.com
|
|
9
|
+
Requires-Python: >=3.10,<3.13
|
|
10
|
+
Classifier: Development Status :: 1 - Planning
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Dist: httpx (>=0.27,<1)
|
|
23
|
+
Requires-Dist: pydantic (>=2.0)
|
|
24
|
+
Requires-Dist: swarmauri_base (>=0.11.0.dev5)
|
|
25
|
+
Requires-Dist: swarmauri_core (>=0.11.0.dev5)
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
# Swarmauri Sync Labs Lip Sync
|
|
31
|
+
|
|
32
|
+
`swarmauri_video_lipsync_synclabs` is the standalone Sync Labs provider for
|
|
33
|
+
Swarmauri lip-sync video generation. `SyncLabsLipSync` implements the common
|
|
34
|
+
`LipSyncBase` contract and exposes Sync Labs' queued job lifecycle without
|
|
35
|
+
misrepresenting progress events as streamed video.
|
|
36
|
+
|
|
37
|
+
[](https://pepy.tech/projects/swarmauri_video_lipsync_synclabs)
|
|
38
|
+
[](https://hits.sh/github.com/swarmauri/swarmauri-sdk/tree/master/pkgs/community/swarmauri_video_lipsync_synclabs/)
|
|
39
|
+
[](https://pypi.org/project/swarmauri_video_lipsync_synclabs/)
|
|
40
|
+
[](https://pypi.org/project/swarmauri_video_lipsync_synclabs/)
|
|
41
|
+
[](https://pypi.org/project/swarmauri_video_lipsync_synclabs/)
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- Common synchronous and asynchronous `predict` workflows.
|
|
46
|
+
- Observable queued jobs through `submit`, `get_status`, `iter_events`, and
|
|
47
|
+
`aiter_events`.
|
|
48
|
+
- Normalized queued, running, completed, failed, and rejected job events.
|
|
49
|
+
- Public URL inputs and local video/audio uploads through Sync Labs assets.
|
|
50
|
+
- Preflight cost estimation through Sync Labs' estimate endpoint.
|
|
51
|
+
- Retry handling for rate limits and safe transient requests.
|
|
52
|
+
- API credentials excluded from serialized component state and external media
|
|
53
|
+
requests.
|
|
54
|
+
- Current Sync Labs models: `sync-3`, `lipsync-2`, `lipsync-2-pro`,
|
|
55
|
+
`lipsync-1.9.0-beta`, and `react-1`.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add swarmauri_video_lipsync_synclabs
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install swarmauri_video_lipsync_synclabs
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Set `SYNC_API_KEY` before submitting billable work.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
Generate from public media URLs:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import os
|
|
75
|
+
|
|
76
|
+
from swarmauri_video_lipsync_synclabs import SyncLabsLipSync
|
|
77
|
+
|
|
78
|
+
lip_sync = SyncLabsLipSync(api_key=os.environ["SYNC_API_KEY"])
|
|
79
|
+
result = lip_sync.predict(
|
|
80
|
+
"https://example.com/speaker.mp4",
|
|
81
|
+
"https://example.com/dialogue.wav",
|
|
82
|
+
"dubbed.mp4",
|
|
83
|
+
sync_mode="cut_off",
|
|
84
|
+
)
|
|
85
|
+
print(result)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Local paths are uploaded as reusable Sync Labs assets before generation:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
result = lip_sync.predict("speaker.mp4", "dialogue.wav", "dubbed.mp4")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Observe a queued job without blocking for the final artifact:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
job_id = lip_sync.submit(
|
|
98
|
+
"https://example.com/speaker.mp4",
|
|
99
|
+
"https://example.com/dialogue.wav",
|
|
100
|
+
)
|
|
101
|
+
for event in lip_sync.iter_events(job_id):
|
|
102
|
+
print(event)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
These iterators report job progress. Sync Labs returns a completed video
|
|
106
|
+
artifact rather than incremental video bytes, so this provider intentionally
|
|
107
|
+
does not expose `stream` or `astream`.
|
|
108
|
+
|
|
109
|
+
## Contributing
|
|
110
|
+
|
|
111
|
+
This package is part of the
|
|
112
|
+
[Swarmauri SDK](https://github.com/swarmauri/swarmauri-sdk). Contributions
|
|
113
|
+
should follow the repository contribution and style guides.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Apache-2.0
|
|
118
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
swarmauri_video_lipsync_synclabs/SyncLabsLipSync.py,sha256=B39GNiWMVKGQn0--LfA5TBzV3rwR3djhvTtcQyx8tqE,17049
|
|
2
|
+
swarmauri_video_lipsync_synclabs/__init__.py,sha256=lxj1UHbAwMO3g6l40SzZYQVDecPeJ1KOJ7Lwwa7oDS4,152
|
|
3
|
+
swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/METADATA,sha256=WkCuhWeD7-hTmws8jb_gC1z6ZfzHSTotEjikhgEhUno,4455
|
|
4
|
+
swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
5
|
+
swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/entry_points.txt,sha256=rn2c-NQolmaeKNZMSVWBN9Pu6bgUxs5pElKDwK0aqAE,92
|
|
6
|
+
swarmauri_video_lipsync_synclabs-0.1.0.dev4.dist-info/RECORD,,
|