diff --git a/decart/realtime/client.py b/decart/realtime/client.py index 6cc62d1..855e205 100644 --- a/decart/realtime/client.py +++ b/decart/realtime/client.py @@ -1,15 +1,15 @@ -from typing import Callable, Optional, Union +from typing import Callable, Optional, Union, TYPE_CHECKING import asyncio import base64 +import json import logging from pathlib import Path from urllib.parse import urlparse, quote import aiohttp -from aiortc import MediaStreamTrack from pydantic import BaseModel -from .webrtc_manager import WebRTCManager, WebRTCConfiguration -from .messages import PromptMessage, SessionIdMessage, GenerationTickMessage +from .livekit_manager import LiveKitManager, LiveKitConfiguration +from .messages import PromptMessage, LiveKitRoomInfoMessage, GenerationTickMessage from .subscribe import ( SubscribeClient, SubscribeOptions, @@ -21,6 +21,9 @@ from ..errors import DecartSDKError, InvalidInputError, WebRTCError from ..process.request import file_input_to_bytes +if TYPE_CHECKING: + from livekit.rtc import LocalVideoTrack + logger = logging.getLogger(__name__) PROMPT_TIMEOUT_S = 15.0 @@ -64,10 +67,46 @@ async def _image_to_base64( return image +def _realtime_base_to_http(base_url: str) -> str: + if base_url.startswith("wss://"): + return "https://" + base_url[len("wss://") :] + if base_url.startswith("ws://"): + return "http://" + base_url[len("ws://") :] + return base_url + + +async def _fetch_watch_stream_credentials( + base_url: str, + api_key: str, + room_name: str, +) -> LiveKitRoomInfoMessage: + http_base_url = _realtime_base_to_http(base_url).rstrip("/") + url = f"{http_base_url}/watch-stream/{quote(room_name)}" + async with aiohttp.ClientSession() as session: + async with session.post(url, headers={"x-api-key": api_key}) as response: + body = await response.text() + if response.status >= 400: + raise WebRTCError( + f"watch-stream request failed ({response.status}): {body or response.reason}" + ) + data = json.loads(body) + + try: + return LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url=data["livekit_url"], + token=data["token"], + room_name=data["room_name"], + session_id=data.get("session_id", data["room_name"]), + ) + except KeyError as e: + raise WebRTCError(f"watch-stream response missing required field: {e.args[0]}") from e + + class RealtimeClient: def __init__( self, - manager: WebRTCManager, + manager: LiveKitManager, http_session: Optional[aiohttp.ClientSession] = None, ): self._manager = manager @@ -88,43 +127,45 @@ def session_id(self) -> Optional[str]: def subscribe_token(self) -> Optional[str]: return self._subscribe_token - def _handle_session_id(self, msg: SessionIdMessage) -> None: + def _handle_session_started(self, msg: LiveKitRoomInfoMessage) -> None: self._session_id = msg.session_id - self._subscribe_token = encode_subscribe_token( - msg.session_id, msg.server_ip, msg.server_port - ) + self._subscribe_token = encode_subscribe_token(msg.room_name) @classmethod async def connect( cls, base_url: str, api_key: str, - local_track: Optional[MediaStreamTrack], + local_track: Optional["LocalVideoTrack"], options: RealtimeConnectOptions, integration: Optional[str] = None, ) -> "RealtimeClient": ws_url = f"{base_url}{options.model.url_path}" - ws_url += f"?api_key={quote(api_key)}&model={quote(options.model.name)}" + ws_url += ( + f"?api_key={quote(api_key)}" + f"&model={quote(options.model.name)}" + "&livekit_early_room_info=true" + ) if options.resolution is not None: ws_url += f"&resolution={quote(options.resolution)}" - config = WebRTCConfiguration( - webrtc_url=ws_url, + config = LiveKitConfiguration( + livekit_url=ws_url, api_key=api_key, session_id="", fps=options.model.fps, on_remote_stream=options.on_remote_stream, on_connection_state_change=None, on_error=None, - on_session_id=None, + on_session_started=None, initial_state=options.initial_state, - customize_offer=options.customize_offer, + preferred_video_codec=options.preferred_video_codec, integration=integration, ) http_session = aiohttp.ClientSession() - manager = WebRTCManager(config) + manager = LiveKitManager(config) client = cls( manager=manager, http_session=http_session, @@ -132,7 +173,7 @@ async def connect( config.on_connection_state_change = client._emit_connection_change config.on_error = lambda error: client._emit_error(WebRTCError(str(error), cause=error)) - config.on_session_id = client._handle_session_id + config.on_session_started = client._handle_session_started config.on_generation_tick = client._emit_generation_tick try: @@ -169,25 +210,25 @@ async def subscribe( integration: Optional[str] = None, ) -> SubscribeClient: token_data = decode_subscribe_token(options.token) - subscribe_url = ( - f"{base_url}/subscribe/{quote(token_data.sid)}" - f"?IP={quote(token_data.ip)}" - f"&port={quote(str(token_data.port))}" - f"&api_key={quote(api_key)}" + room_info = await _fetch_watch_stream_credentials( + base_url=base_url, + api_key=api_key, + room_name=token_data.room_name, ) - config = WebRTCConfiguration( - webrtc_url=subscribe_url, + config = LiveKitConfiguration( + livekit_url="", api_key=api_key, - session_id=token_data.sid, + session_id=token_data.room_name, fps=0, on_remote_stream=options.on_remote_stream, on_connection_state_change=None, on_error=None, + room_info=room_info, integration=integration, ) - manager = WebRTCManager(config) + manager = LiveKitManager(config) sub_client = SubscribeClient(manager) config.on_connection_state_change = sub_client._emit_connection_change diff --git a/decart/realtime/webrtc_connection.py b/decart/realtime/livekit_connection.py similarity index 51% rename from decart/realtime/webrtc_connection.py rename to decart/realtime/livekit_connection.py index 2c2ce9c..32b7841 100644 --- a/decart/realtime/webrtc_connection.py +++ b/decart/realtime/livekit_connection.py @@ -1,99 +1,103 @@ import asyncio import json import logging -from typing import Optional, Callable +from typing import Callable, Optional, TYPE_CHECKING from urllib.parse import quote + import aiohttp -from aiortc import ( - RTCPeerConnection, - RTCSessionDescription, - RTCIceCandidate, - RTCConfiguration, - RTCIceServer, - MediaStreamTrack, -) +from livekit import rtc -from ..errors import WebRTCError from .._user_agent import build_user_agent +from ..errors import WebRTCError from .messages import ( - parse_incoming_message, - message_to_json, - OfferMessage, - IceCandidateMessage, - IceCandidatePayload, - PromptMessage, - PromptAckMessage, - SetImageAckMessage, - SetAvatarImageMessage, ErrorMessage, - SessionIdMessage, GenerationTickMessage, + LiveKitJoinMessage, + LiveKitRoomInfoMessage, OutgoingMessage, + PromptAckMessage, + PromptMessage, + QueuePositionMessage, + SetAvatarImageMessage, + SetImageAckMessage, + message_to_json, + parse_incoming_message, ) -from .types import ConnectionState +from .types import ConnectionState, VideoCodec + +if TYPE_CHECKING: + from livekit.rtc import ( + LocalVideoTrack, + RemoteParticipant, + RemoteTrackPublication, + RemoteVideoTrack, + ) logger = logging.getLogger(__name__) +INFERENCE_SERVER_IDENTITY_PREFIX = "inference-server-" +LIVEKIT_HANDSHAKE_TIMEOUT = 15.0 + +VIDEO_CODEC_MAP = { + "h264": rtc.VideoCodec.H264, + "vp9": rtc.VideoCodec.VP9, +} + -class WebRTCConnection: +class LiveKitConnection: def __init__( self, - on_remote_stream: Optional[Callable[[MediaStreamTrack], None]] = None, + on_remote_stream: Optional[ + Callable[["RemoteVideoTrack", "RemoteTrackPublication", "RemoteParticipant"], None] + ] = None, on_state_change: Optional[Callable[[ConnectionState], None]] = None, on_error: Optional[Callable[[Exception], None]] = None, - on_session_id: Optional[Callable[[SessionIdMessage], None]] = None, + on_session_started: Optional[Callable[[LiveKitRoomInfoMessage], None]] = None, on_generation_tick: Optional[Callable[[GenerationTickMessage], None]] = None, - customize_offer: Optional[Callable] = None, + on_queue_position: Optional[Callable[[QueuePositionMessage], None]] = None, ): - self._pc: Optional[RTCPeerConnection] = None + self._room: Optional[rtc.Room] = None self._ws: Optional[aiohttp.ClientWebSocketResponse] = None self._session: Optional[aiohttp.ClientSession] = None self._state: ConnectionState = "disconnected" self._on_remote_stream = on_remote_stream self._on_state_change = on_state_change self._on_error = on_error - self._on_session_id = on_session_id + self._on_session_started = on_session_started self._on_generation_tick = on_generation_tick - self._customize_offer = customize_offer + self._on_queue_position = on_queue_position self._ws_task: Optional[asyncio.Task] = None - self._ice_candidates_queue: list[RTCIceCandidate] = [] self._pending_prompts: dict[str, tuple[asyncio.Event, dict]] = {} self._pending_image_set: Optional[tuple[asyncio.Event, dict]] = None - self._local_track: Optional[MediaStreamTrack] = None + self._pending_room_info: Optional[tuple[asyncio.Event, dict]] = None self._connection_error: Optional[str] = None - # Per-connect() dedup: _handle_error and connect()'s except branches both - # may see the same error; whichever fires first flips this to True and the - # other skips. Reset at the top of every connect() call. - self._on_error_fired: bool = False + self._intentional_disconnect = False + self._on_error_fired = False async def connect( self, url: str, - local_track: Optional[MediaStreamTrack], + local_track: Optional["LocalVideoTrack"], timeout: float, integration: Optional[str] = None, initial_image: Optional[str] = None, initial_prompt: Optional[dict] = None, + room_info: Optional[LiveKitRoomInfoMessage] = None, + preferred_video_codec: VideoCodec = "h264", ) -> None: try: - self._local_track = local_track self._connection_error = None + self._intentional_disconnect = False self._on_error_fired = False await self._set_state("connecting") + if room_info is None: + await self._connect_signaling(url, integration) + room_info = await self._join_livekit_room(timeout=LIVEKIT_HANDSHAKE_TIMEOUT) - ws_url = url.replace("https://", "wss://").replace("http://", "ws://") + has_initial_state = initial_image is not None or initial_prompt is not None - user_agent = build_user_agent(integration) - separator = "&" if "?" in ws_url else "?" - ws_url = f"{ws_url}{separator}user_agent={quote(user_agent)}" - - self._session = aiohttp.ClientSession() - self._ws = await self._session.ws_connect(ws_url) - - self._ws_task = asyncio.create_task(self._receive_messages()) - - if initial_image: + if initial_image is not None: await self._send_initial_image_and_wait( initial_image, prompt=initial_prompt.get("text") if initial_prompt else None, @@ -101,38 +105,127 @@ async def connect( ) elif initial_prompt: await self._send_initial_prompt_and_wait(initial_prompt) - elif local_track is not None: - # No image and no prompt — send passthrough (skip for subscribe mode which has no local stream) - await self._send_passthrough_and_wait() - await self._setup_peer_connection(local_track) - await self._create_and_send_offer() + await self._connect_room(room_info, local_track, preferred_video_codec) - deadline = asyncio.get_event_loop().time() + timeout - while asyncio.get_event_loop().time() < deadline: - if self._state in ("connected", "generating"): - return - if self._connection_error: - raise WebRTCError(self._connection_error) - await asyncio.sleep(0.1) + if not has_initial_state and local_track is not None: + await self._send_passthrough_and_wait() - raise TimeoutError("Connection timeout") + if self._on_session_started: + self._on_session_started(room_info) + await self._wait_until_connected(timeout) except WebRTCError as e: - logger.error(f"Connection failed: {e}") + logger.error("LiveKit connection failed: %s", e) await self._set_state("disconnected") - if self._on_error and not self._on_error_fired: - self._on_error_fired = True - self._on_error(e) + self._fire_error_once(e) raise except Exception as e: - logger.error(f"Connection failed: {e}") + logger.error("LiveKit connection failed: %s", e) await self._set_state("disconnected") - if self._on_error and not self._on_error_fired: - self._on_error_fired = True - self._on_error(e) + self._fire_error_once(e) raise WebRTCError(str(e), cause=e) + async def _connect_signaling(self, url: str, integration: Optional[str]) -> None: + ws_url = url.replace("https://", "wss://").replace("http://", "ws://") + user_agent = build_user_agent(integration) + separator = "&" if "?" in ws_url else "?" + ws_url = f"{ws_url}{separator}user_agent={quote(user_agent)}" + + self._session = aiohttp.ClientSession() + self._ws = await self._session.ws_connect(ws_url) + self._ws_task = asyncio.create_task(self._receive_messages()) + + async def _join_livekit_room(self, timeout: float) -> LiveKitRoomInfoMessage: + event = asyncio.Event() + result: dict = {"room_info": None, "error": None} + self._pending_room_info = (event, result) + + try: + await self._send_message(LiveKitJoinMessage(type="livekit_join")) + await asyncio.wait_for(event.wait(), timeout=timeout) + except asyncio.TimeoutError as e: + raise WebRTCError("LiveKit room info timed out") from e + finally: + self._pending_room_info = None + + if result["error"]: + raise WebRTCError(str(result["error"])) + room_info = result["room_info"] + if not isinstance(room_info, LiveKitRoomInfoMessage): + raise WebRTCError("Invalid LiveKit room info") + return room_info + + async def _connect_room( + self, + room_info: LiveKitRoomInfoMessage, + local_track: Optional["LocalVideoTrack"], + preferred_video_codec: VideoCodec = "h264", + ) -> None: + room = rtc.Room() + self._room = room + + @room.on("track_subscribed") + def on_track_subscribed(track, publication, participant): + if not participant.identity.startswith(INFERENCE_SERVER_IDENTITY_PREFIX): + return + if getattr(track, "kind", None) != rtc.TrackKind.KIND_VIDEO: + return + logger.debug( + "Received LiveKit remote video track: %s", getattr(track, "sid", "unknown") + ) + if self._on_remote_stream: + self._on_remote_stream(track, publication, participant) + + @room.on("connection_state_changed") + def on_connection_state_changed(connection_state): + mapped = self._map_room_state(connection_state) + if mapped: + asyncio.create_task(self._set_state(mapped)) + + @room.on("disconnected") + def on_disconnected(_reason=None): + if not self._intentional_disconnect: + logger.debug("LiveKit room disconnected") + asyncio.create_task(self._set_state("disconnected")) + + await room.connect(room_info.livekit_url, room_info.token) + + if local_track is not None: + await room.local_participant.publish_track( + local_track, + self._publish_options(preferred_video_codec), + ) + + await self._set_state("connected") + + def _publish_options(self, preferred_video_codec: VideoCodec) -> rtc.TrackPublishOptions: + options = rtc.TrackPublishOptions() + options.video_codec = VIDEO_CODEC_MAP[preferred_video_codec] + return options + + def _map_room_state(self, connection_state) -> Optional[ConnectionState]: + state_name = getattr(connection_state, "name", str(connection_state)).lower() + if "reconnecting" in state_name: + return "reconnecting" + if "connecting" in state_name: + return "connecting" + if "connected" in state_name and "disconnected" not in state_name: + return "connected" + if "disconnected" in state_name: + return "disconnected" + return None + + async def _wait_until_connected(self, timeout: float) -> None: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + if self._state in ("connected", "generating"): + return + if self._connection_error: + raise WebRTCError(self._connection_error) + await asyncio.sleep(0.1) + raise TimeoutError("Connection timeout") + async def _send_initial_image_and_wait( self, image_base64: str, @@ -150,7 +243,6 @@ async def _send_initial_image_and_wait( message.enhance_prompt = enhance await self._send_message(message) - try: await asyncio.wait_for(event.wait(), timeout=timeout) except asyncio.TimeoutError: @@ -164,7 +256,6 @@ async def _send_initial_image_and_wait( self.unregister_image_set_wait() async def _send_initial_prompt_and_wait(self, prompt: dict, timeout: float = 15.0) -> None: - """Send initial prompt and wait for acknowledgment before WebRTC handshake.""" prompt_text = prompt.get("text", "") enhance = prompt.get("enhance", True) @@ -174,7 +265,6 @@ async def _send_initial_prompt_and_wait(self, prompt: dict, timeout: float = 15. await self._send_message( PromptMessage(type="prompt", prompt=prompt_text, enhance_prompt=enhance) ) - try: await asyncio.wait_for(event.wait(), timeout=timeout) except asyncio.TimeoutError: @@ -188,18 +278,12 @@ async def _send_initial_prompt_and_wait(self, prompt: dict, timeout: float = 15. self.unregister_prompt_wait(prompt_text) async def _send_passthrough_and_wait(self, timeout: float = 30.0) -> None: - """Send passthrough set_image (null image + null prompt) and wait for ack. - - When connecting without an initial prompt or image, the server still - expects an explicit initial state. Sending image_data=null + prompt=null - tells the server to use passthrough mode. - """ event, result = self.register_image_set_wait() try: - message = SetAvatarImageMessage(type="set_image", image_data=None, prompt=None) - await self._send_message(message) - + await self._send_message( + SetAvatarImageMessage(type="set_image", image_data=None, prompt=None) + ) try: await asyncio.wait_for(event.wait(), timeout=timeout) except asyncio.TimeoutError: @@ -212,108 +296,47 @@ async def _send_passthrough_and_wait(self, timeout: float = 30.0) -> None: finally: self.unregister_image_set_wait() - async def _setup_peer_connection( - self, - local_track: Optional[MediaStreamTrack], - ) -> None: - config = RTCConfiguration(iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])]) - - self._pc = RTCPeerConnection(configuration=config) - - @self._pc.on("track") - def on_track(track: MediaStreamTrack): - logger.debug(f"Received remote track: {track.kind}") - if self._on_remote_stream: - self._on_remote_stream(track) - - @self._pc.on("icecandidate") - async def on_ice_candidate(candidate: RTCIceCandidate): - if candidate: - logger.debug(f"Local ICE candidate: {candidate.candidate}") - await self._send_message( - IceCandidateMessage( - type="ice-candidate", - candidate=IceCandidatePayload( - candidate=candidate.candidate, - sdpMLineIndex=candidate.sdpMLineIndex or 0, - sdpMid=candidate.sdpMid or "", - ), - ) - ) - - @self._pc.on("connectionstatechange") - async def on_connection_state_change(): - logger.debug(f"Peer connection state: {self._pc.connectionState}") - if self._pc.connectionState == "connected": - await self._set_state("connected") - elif self._pc.connectionState in ["failed", "closed"]: - await self._set_state("disconnected") - # Keep "generating" sticky unless actually disconnected (matches JS SDK) - - @self._pc.on("iceconnectionstatechange") - async def on_ice_connection_state_change(): - logger.debug(f"ICE connection state: {self._pc.iceConnectionState}") - - if local_track is None: - self._pc.addTransceiver("video", direction="recvonly") - self._pc.addTransceiver("audio", direction="recvonly") - logger.debug("Added video+audio transceivers (recvonly) for subscribe mode") - else: - self._pc.addTrack(local_track) - logger.debug("Added local track to peer connection") - - async def _create_and_send_offer(self) -> None: - logger.debug("Creating offer...") - - offer = await self._pc.createOffer() - logger.debug(f"Offer SDP:\n{offer.sdp}") - - if self._customize_offer: - await self._customize_offer(offer) - - await self._pc.setLocalDescription(offer) - logger.debug("Set local description (offer)") - - await self._send_message(OfferMessage(type="offer", sdp=self._pc.localDescription.sdp)) - async def _receive_messages(self) -> None: try: async for msg in self._ws: if msg.type == aiohttp.WSMsgType.TEXT: try: data = json.loads(msg.data) - logger.debug(f"Received {data.get('type', 'unknown')} message") - logger.debug(f"Message content: {msg.data}") + logger.debug("Received %s message", data.get("type", "unknown")) await self._handle_message(data) except Exception as e: - logger.error(f"Error handling message: {e}") + logger.error("Error handling message: %s", e) elif msg.type == aiohttp.WSMsgType.ERROR: - logger.error(f"WebSocket error: {self._ws.exception()}") + logger.error("WebSocket error: %s", self._ws.exception()) break + except asyncio.CancelledError: + raise except Exception as e: - logger.error(f"WebSocket receive error: {e}") + logger.error("WebSocket receive error: %s", e) if self._on_error: self._on_error(e) finally: - final_error = self._connection_error or "WebSocket disconnected" + final_error = self._connection_error or "Control channel disconnected" self._resolve_pending_waits(final_error) - await self._set_state("disconnected") + if not self._intentional_disconnect: + await self._set_state("disconnected") async def _handle_message(self, data: dict) -> None: try: message = parse_incoming_message(data) except Exception as e: - logger.warning(f"Failed to parse message: {e}") + logger.warning("Failed to parse message: %s", e) return - if message.type == "answer": - await self._handle_answer(message.sdp) - elif message.type == "ice-candidate": - await self._handle_ice_candidate(message.candidate) + if message.type == "livekit_room_info": + self._handle_room_info(message) + elif message.type == "queue_position": + if self._on_queue_position: + self._on_queue_position(message) elif message.type == "session_id": - logger.debug(f"Session ID: {message.session_id}") - if self._on_session_id: - self._on_session_id(message) + logger.debug( + "Ignoring legacy session_id message in LiveKit mode: %s", message.session_id + ) elif message.type == "prompt_ack": self._handle_prompt_ack(message) elif message.type == "set_image_ack": @@ -321,50 +344,24 @@ async def _handle_message(self, data: dict) -> None: elif message.type == "generation_started": await self._set_state("generating") elif message.type == "generation_tick": + if self._state == "connected": + await self._set_state("generating") if self._on_generation_tick: self._on_generation_tick(message) elif message.type == "generation_ended": - # Parsed but intentionally not exposed — unreliable (won't arrive on - # client disconnect/network drop), overlaps with connection_change - # "disconnected", and insufficient_credits is already covered by error event. - logger.debug(f"Generation ended: reason={message.reason}, seconds={message.seconds}") + logger.debug("Generation ended: reason=%s, seconds=%s", message.reason, message.seconds) elif message.type == "error": self._handle_error(message) elif message.type == "ready": - logger.debug("Received ready signal from server") - - async def _handle_answer(self, sdp: str) -> None: - logger.debug("Received answer from server") - logger.debug(f"Answer SDP:\n{sdp}") - - answer = RTCSessionDescription(sdp=sdp, type="answer") - await self._pc.setRemoteDescription(answer) - logger.debug("Set remote description (answer)") - - if self._ice_candidates_queue: - logger.debug(f"Adding {len(self._ice_candidates_queue)} queued ICE candidates") - for candidate in self._ice_candidates_queue: - await self._pc.addIceCandidate(candidate) - self._ice_candidates_queue.clear() - - async def _handle_ice_candidate(self, candidate_data: IceCandidatePayload) -> None: - logger.debug(f"Remote ICE candidate: {candidate_data.candidate}") - - candidate = RTCIceCandidate( - candidate=candidate_data.candidate, - sdpMLineIndex=candidate_data.sdpMLineIndex, - sdpMid=candidate_data.sdpMid, - ) - - if self._pc.remoteDescription: - logger.debug("Adding ICE candidate to peer connection") - await self._pc.addIceCandidate(candidate) - else: - logger.debug("Queuing ICE candidate (no remote description yet)") - self._ice_candidates_queue.append(candidate) + logger.debug("Ignoring legacy ready signal in LiveKit mode") + + def _handle_room_info(self, message: LiveKitRoomInfoMessage) -> None: + if self._pending_room_info: + event, result = self._pending_room_info + result["room_info"] = message + event.set() def _handle_prompt_ack(self, message: PromptAckMessage) -> None: - logger.debug(f"Received prompt_ack for: {message.prompt}, success: {message.success}") if message.prompt in self._pending_prompts: event, result = self._pending_prompts[message.prompt] result["success"] = message.success @@ -372,7 +369,6 @@ def _handle_prompt_ack(self, message: PromptAckMessage) -> None: event.set() def _handle_set_image_ack(self, message: SetImageAckMessage) -> None: - logger.debug(f"Received set_image_ack: success={message.success}, error={message.error}") if self._pending_image_set: event, result = self._pending_image_set result["success"] = message.success @@ -380,6 +376,11 @@ def _handle_set_image_ack(self, message: SetImageAckMessage) -> None: event.set() def _resolve_pending_waits(self, error_message: str) -> None: + if self._pending_room_info: + event, result = self._pending_room_info + result["error"] = error_message + event.set() + if self._pending_image_set: event, result = self._pending_image_set result["success"] = False @@ -392,14 +393,16 @@ def _resolve_pending_waits(self, error_message: str) -> None: event.set() def _handle_error(self, message: ErrorMessage) -> None: - logger.error(f"Received error from server: {message.error}") + logger.error("Received error from server: %s", message.error) error = WebRTCError(message.error) if not self._connection_error: self._connection_error = message.error self._resolve_pending_waits(message.error) + self._fire_error_once(error) - if self._on_error: + def _fire_error_once(self, error: Exception) -> None: + if self._on_error and not self._on_error_fired: self._on_error_fired = True self._on_error(error) @@ -423,11 +426,10 @@ def unregister_prompt_wait(self, prompt: str) -> None: async def _send_message(self, message: OutgoingMessage) -> None: if not self._ws or self._ws.closed: - raise RuntimeError("WebSocket not connected") + raise RuntimeError("Control channel not connected") msg_json = message_to_json(message) - logger.debug(f"Sending {message.type} message") - logger.debug(f"Message content: {msg_json}") + logger.debug("Sending %s message", message.type) await self._ws.send_str(msg_json) async def _set_state(self, state: ConnectionState) -> None: @@ -435,7 +437,7 @@ async def _set_state(self, state: ConnectionState) -> None: return if self._state != state: self._state = state - logger.debug(f"Connection state changed to: {state}") + logger.debug("Connection state changed to: %s", state) if self._on_state_change: self._on_state_change(state) @@ -446,21 +448,31 @@ async def send(self, message: OutgoingMessage) -> None: def state(self) -> ConnectionState: return self._state + @property + def room(self) -> Optional[rtc.Room]: + return self._room + async def cleanup(self) -> None: + self._intentional_disconnect = True + if self._ws_task: self._ws_task.cancel() try: await self._ws_task except asyncio.CancelledError: pass + self._ws_task = None - if self._pc: - await self._pc.close() + if self._room: + await self._room.disconnect() + self._room = None if self._ws and not self._ws.closed: await self._ws.close() + self._ws = None if self._session and not self._session.closed: await self._session.close() + self._session = None await self._set_state("disconnected") diff --git a/decart/realtime/webrtc_manager.py b/decart/realtime/livekit_manager.py similarity index 79% rename from decart/realtime/webrtc_manager.py rename to decart/realtime/livekit_manager.py index 5ea830d..bd738ff 100644 --- a/decart/realtime/webrtc_manager.py +++ b/decart/realtime/livekit_manager.py @@ -1,21 +1,29 @@ import asyncio import logging -from typing import Optional, Callable from dataclasses import dataclass -from aiortc import MediaStreamTrack +from typing import Callable, Optional, TYPE_CHECKING + from tenacity import ( + before_sleep_log, retry, + retry_if_exception, stop_after_attempt, wait_exponential, - retry_if_exception, - before_sleep_log, ) -from .webrtc_connection import WebRTCConnection -from .messages import OutgoingMessage, SessionIdMessage, GenerationTickMessage -from .types import ConnectionState +from .livekit_connection import LiveKitConnection +from .messages import ( + GenerationTickMessage, + LiveKitRoomInfoMessage, + OutgoingMessage, + QueuePositionMessage, +) +from .types import ConnectionState, VideoCodec from ..types import ModelState +if TYPE_CHECKING: + from livekit.rtc import LocalVideoTrack, RemoteVideoTrack + logger = logging.getLogger(__name__) PERMANENT_ERRORS = [ @@ -27,7 +35,7 @@ "unauthorized", ] -CONNECTION_TIMEOUT = 60 * 5 # 5 minutes +CONNECTION_TIMEOUT = 60 * 5 RETRY_MAX_ATTEMPTS = 5 RETRY_MIN_WAIT = 1 @@ -35,18 +43,20 @@ @dataclass -class WebRTCConfiguration: - webrtc_url: str +class LiveKitConfiguration: + livekit_url: str api_key: str session_id: str fps: int - on_remote_stream: Callable[[MediaStreamTrack], None] + on_remote_stream: Callable[["RemoteVideoTrack"], None] on_connection_state_change: Optional[Callable[[ConnectionState], None]] = None on_error: Optional[Callable[[Exception], None]] = None - on_session_id: Optional[Callable[[SessionIdMessage], None]] = None + on_session_started: Optional[Callable[[LiveKitRoomInfoMessage], None]] = None on_generation_tick: Optional[Callable[[GenerationTickMessage], None]] = None + on_queue_position: Optional[Callable[[QueuePositionMessage], None]] = None + room_info: Optional[LiveKitRoomInfoMessage] = None initial_state: Optional[ModelState] = None - customize_offer: Optional[Callable] = None + preferred_video_codec: VideoCodec = "h264" integration: Optional[str] = None @@ -61,11 +71,11 @@ def _is_retryable_error(exception: BaseException) -> bool: return not _is_permanent_error(exception) -class WebRTCManager: - def __init__(self, configuration: WebRTCConfiguration): +class LiveKitManager: + def __init__(self, configuration: LiveKitConfiguration): self._config = configuration - self._connection: Optional[WebRTCConnection] = None - self._local_track: Optional[MediaStreamTrack] = None + self._connection: Optional[LiveKitConnection] = None + self._local_track: Optional["LocalVideoTrack"] = None self._subscribe_mode = False self._manager_state: ConnectionState = "disconnected" self._has_connected = False @@ -74,9 +84,9 @@ def __init__(self, configuration: WebRTCConfiguration): self._reconnect_generation = 0 self._reconnect_task: Optional[asyncio.Task] = None - def _get_connection(self) -> WebRTCConnection: + def _get_connection(self) -> LiveKitConnection: if self._connection is None: - raise RuntimeError("WebRTCManager not connected") + raise RuntimeError("LiveKitManager not connected") return self._connection def _emit_state(self, state: ConnectionState) -> None: @@ -87,6 +97,9 @@ def _emit_state(self, state: ConnectionState) -> None: if self._config.on_connection_state_change: self._config.on_connection_state_change(state) + def _handle_remote_stream(self, track, _publication, _participant) -> None: + self._config.on_remote_stream(track) + def _handle_connection_state_change(self, state: ConnectionState) -> None: if self._intentional_disconnect: self._emit_state("disconnected") @@ -98,10 +111,9 @@ def _handle_connection_state_change(self, state: ConnectionState) -> None: self._emit_state(state) return - # Unexpected disconnect after having been connected → trigger auto-reconnect - # _has_connected guards against triggering during initial connect (which has its own retry) if state == "disconnected" and not self._intentional_disconnect and self._has_connected: - self._reconnect_task = asyncio.ensure_future(self._reconnect()) + if not self._reconnect_task or self._reconnect_task.done(): + self._reconnect_task = asyncio.ensure_future(self._reconnect()) return self._emit_state(state) @@ -120,7 +132,6 @@ async def _reconnect(self) -> None: try: await self._retry_reconnect(reconnect_generation) except asyncio.CancelledError: - # Task cancelled or intentional disconnect — don't emit error pass except Exception as error: if self._intentional_disconnect or reconnect_generation != self._reconnect_generation: @@ -150,10 +161,12 @@ async def _attempt(): conn = self._create_connection() self._connection = conn await conn.connect( - url=self._config.webrtc_url, + url=self._config.livekit_url, local_track=self._local_track, timeout=CONNECTION_TIMEOUT, integration=self._config.integration, + room_info=self._config.room_info, + preferred_video_codec=self._config.preferred_video_codec, ) if self._intentional_disconnect or reconnect_generation != self._reconnect_generation: @@ -171,7 +184,7 @@ async def _attempt(): ) async def connect( self, - local_track: Optional[MediaStreamTrack], + local_track: Optional["LocalVideoTrack"], initial_image: Optional[str] = None, initial_prompt: Optional[dict] = None, ) -> bool: @@ -186,28 +199,30 @@ async def connect( try: await self._connection.connect( - url=self._config.webrtc_url, + url=self._config.livekit_url, local_track=local_track, timeout=CONNECTION_TIMEOUT, integration=self._config.integration, initial_image=initial_image, initial_prompt=initial_prompt, + room_info=self._config.room_info, + preferred_video_codec=self._config.preferred_video_codec, ) return True except Exception as e: - logger.error(f"Connection attempt failed: {e}") + logger.error("Connection attempt failed: %s", e) await self._connection.cleanup() self._connection = None raise - def _create_connection(self) -> WebRTCConnection: - return WebRTCConnection( - on_remote_stream=self._config.on_remote_stream, + def _create_connection(self) -> LiveKitConnection: + return LiveKitConnection( + on_remote_stream=self._handle_remote_stream, on_state_change=self._handle_connection_state_change, on_error=self._config.on_error, - on_session_id=self._config.on_session_id, + on_session_started=self._config.on_session_started, on_generation_tick=self._config.on_generation_tick, - customize_offer=self._config.customize_offer, + on_queue_position=self._config.on_queue_position, ) async def set_image( @@ -258,6 +273,10 @@ async def cleanup(self) -> None: self._reconnect_generation += 1 if self._reconnect_task and not self._reconnect_task.done(): self._reconnect_task.cancel() + try: + await self._reconnect_task + except asyncio.CancelledError: + pass if self._connection: await self._connection.cleanup() self._connection = None diff --git a/decart/realtime/messages.py b/decart/realtime/messages.py index ed99237..5470c9b 100644 --- a/decart/realtime/messages.py +++ b/decart/realtime/messages.py @@ -1,40 +1,12 @@ from typing import Literal, Optional, Union, Annotated from pydantic import BaseModel, Field, TypeAdapter -try: - from aiortc import RTCSessionDescription, RTCIceCandidate -except ImportError: - RTCSessionDescription = None # type: ignore - RTCIceCandidate = None # type: ignore - # Incoming Messages (from server) -class AnswerMessage(BaseModel): - """WebRTC answer from server.""" - - type: Literal["answer"] - sdp: str - - -class IceCandidatePayload(BaseModel): - """ICE candidate data.""" - - candidate: str - sdpMLineIndex: int - sdpMid: str - - -class IceCandidateMessage(BaseModel): - """ICE candidate message.""" - - type: Literal["ice-candidate"] - candidate: IceCandidatePayload - - class SessionIdMessage(BaseModel): - """Session initialization message from server.""" + """Legacy session initialization message from the pre-LiveKit protocol.""" type: Literal["session_id"] session_id: str @@ -67,11 +39,29 @@ class ErrorMessage(BaseModel): class ReadyMessage(BaseModel): - """Server ready signal.""" + """Legacy server ready signal from the pre-LiveKit protocol.""" type: Literal["ready"] +class LiveKitRoomInfoMessage(BaseModel): + """LiveKit room credentials returned by the realtime control channel.""" + + type: Literal["livekit_room_info"] + livekit_url: str + token: str + room_name: str + session_id: str + + +class QueuePositionMessage(BaseModel): + """Queue position update while waiting for LiveKit room credentials.""" + + type: Literal["queue_position"] + position: int + queue_size: int + + class GenerationStartedMessage(BaseModel): """Server signals that generation has started.""" @@ -96,13 +86,13 @@ class GenerationEndedMessage(BaseModel): # Discriminated union for incoming messages IncomingMessage = Annotated[ Union[ - AnswerMessage, - IceCandidateMessage, SessionIdMessage, PromptAckMessage, SetImageAckMessage, ErrorMessage, ReadyMessage, + LiveKitRoomInfoMessage, + QueuePositionMessage, GenerationStartedMessage, GenerationTickMessage, GenerationEndedMessage, @@ -117,11 +107,10 @@ class GenerationEndedMessage(BaseModel): # Outgoing Messages (to server) -class OfferMessage(BaseModel): - """WebRTC offer to server.""" +class LiveKitJoinMessage(BaseModel): + """Ask the control channel for LiveKit room credentials.""" - type: Literal["offer"] - sdp: str + type: Literal["livekit_join"] class PromptMessage(BaseModel): @@ -142,7 +131,7 @@ class SetAvatarImageMessage(BaseModel): # Outgoing message union (no discriminator needed - we know what we're sending) -OutgoingMessage = Union[OfferMessage, IceCandidateMessage, PromptMessage, SetAvatarImageMessage] +OutgoingMessage = Union[LiveKitJoinMessage, PromptMessage, SetAvatarImageMessage] def parse_incoming_message(data: dict) -> IncomingMessage: diff --git a/decart/realtime/subscribe.py b/decart/realtime/subscribe.py index ae64cf7..f42031a 100644 --- a/decart/realtime/subscribe.py +++ b/decart/realtime/subscribe.py @@ -10,21 +10,19 @@ from .types import ConnectionState if TYPE_CHECKING: - from aiortc import MediaStreamTrack - from .webrtc_manager import WebRTCManager + from livekit.rtc import RemoteVideoTrack + from .livekit_manager import LiveKitManager logger = logging.getLogger(__name__) @dataclass class TokenPayload: - sid: str - ip: str - port: int + room_name: str -def encode_subscribe_token(session_id: str, server_ip: str, server_port: int) -> str: - payload = json.dumps({"sid": session_id, "ip": server_ip, "port": server_port}) +def encode_subscribe_token(room_name: str) -> str: + payload = json.dumps({"room_name": room_name}) return base64.urlsafe_b64encode(payload.encode()).decode() @@ -32,9 +30,9 @@ def decode_subscribe_token(token: str) -> TokenPayload: try: raw = base64.urlsafe_b64decode(token).decode() data = json.loads(raw) - if not data.get("sid") or not data.get("ip") or not data.get("port"): + if not data.get("room_name"): raise ValueError("Invalid subscribe token format") - return TokenPayload(sid=data["sid"], ip=data["ip"], port=data["port"]) + return TokenPayload(room_name=data["room_name"]) except Exception: raise ValueError("Invalid subscribe token") @@ -42,11 +40,11 @@ def decode_subscribe_token(token: str) -> TokenPayload: @dataclass class SubscribeOptions: token: str - on_remote_stream: Callable[[MediaStreamTrack], None] + on_remote_stream: Callable[["RemoteVideoTrack"], None] class SubscribeClient: - def __init__(self, manager: WebRTCManager): + def __init__(self, manager: "LiveKitManager"): self._manager = manager self._connection_callbacks: list[Callable[[ConnectionState], None]] = [] self._error_callbacks: list[Callable[[Exception], None]] = [] diff --git a/decart/realtime/types.py b/decart/realtime/types.py index 3313d2a..2345dd8 100644 --- a/decart/realtime/types.py +++ b/decart/realtime/types.py @@ -1,21 +1,20 @@ -from typing import Literal, Callable, Optional +from typing import Literal, Callable, Optional, TYPE_CHECKING from dataclasses import dataclass from ..models import ModelDefinition from ..types import ModelState -try: - from aiortc import MediaStreamTrack -except ImportError: - MediaStreamTrack = None # type: ignore +if TYPE_CHECKING: + from livekit.rtc import RemoteVideoTrack ConnectionState = Literal["connecting", "connected", "generating", "disconnected", "reconnecting"] +VideoCodec = Literal["h264", "vp9"] @dataclass class RealtimeConnectOptions: model: ModelDefinition - on_remote_stream: Callable[[MediaStreamTrack], None] + on_remote_stream: Callable[["RemoteVideoTrack"], None] initial_state: Optional[ModelState] = None - customize_offer: Optional[Callable] = None resolution: Optional[Literal["720p", "1080p"]] = None + preferred_video_codec: VideoCodec = "h264" diff --git a/examples/README.md b/examples/README.md index 053a314..45721eb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,8 +32,8 @@ First, install the realtime dependencies: pip install decart[realtime] ``` -- **`realtime_synthetic.py`** - Test realtime API with synthetic colored frames -- **`realtime_file.py`** - Process a video file in realtime +- **`realtime_synthetic.py`** - Publish synthetic colored frames through LiveKit +- **`realtime_file.py`** - Publish frames from a video file through LiveKit ### Running Examples @@ -50,10 +50,10 @@ python examples/process_image.py # Transform video from URL python examples/process_url.py -# Realtime API with synthetic video (saves to output_realtime_synthetic.mp4) +# Realtime API with synthetic video python examples/realtime_synthetic.py -# Realtime API with video file (saves to output_realtime_.mp4) +# Realtime API with video file python examples/realtime_file.py input.mp4 ``` diff --git a/examples/realtime_file.py b/examples/realtime_file.py index a09a073..0eff263 100644 --- a/examples/realtime_file.py +++ b/examples/realtime_file.py @@ -1,16 +1,61 @@ import asyncio import os from pathlib import Path +import cv2 from decart import DecartClient, models try: - from aiortc.contrib.media import MediaPlayer, MediaRecorder + from livekit import rtc except ImportError: - print("aiortc is required for this example.") + print("livekit is required for this example.") print("Install with: pip install decart[realtime]") exit(1) +class FileVideoSource: + """Reads frames from a video file and publishes them through a LiveKit source.""" + + def __init__(self, path: str, width: int, height: int, fps: int): + self.path = path + self.width = width + self.height = height + self.fps = fps + self.source = rtc.VideoSource(width, height) + self.track = rtc.LocalVideoTrack.create_video_track("file-video", self.source) + self._running = False + + async def start(self): + capture = cv2.VideoCapture(self.path) + if not capture.isOpened(): + raise RuntimeError(f"Could not open video file: {self.path}") + + self._running = True + frame_interval = 1 / self.fps + try: + while self._running: + ok, frame = capture.read() + if not ok: + capture.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + frame = cv2.resize(frame, (self.width, self.height)) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + self.source.capture_frame( + rtc.VideoFrame( + width=self.width, + height=self.height, + type=rtc.VideoBufferType.RGB24, + data=frame.tobytes(), + ) + ) + await asyncio.sleep(frame_interval) + finally: + capture.release() + + def stop(self): + self._running = False + + async def main(): api_key = os.getenv("DECART_API_KEY") if not api_key: @@ -30,13 +75,6 @@ async def main(): print(f"Error: Video file not found: {video_file}") return - print(f"Loading video file: {video_file}") - player = MediaPlayer(video_file) - - if not player.video: - print("Error: No video stream found in file") - return - try: from decart.realtime.client import RealtimeClient except ImportError: @@ -50,21 +88,22 @@ async def main(): print(f"Using model: {model.name}") frame_count = 0 - recorder = None input_name = Path(video_file).stem - output_file = Path(f"output_realtime_{input_name}.mp4") + output_file = Path(f"output_realtime_{input_name}.frames") + video_source = FileVideoSource(video_file, model.width, model.height, model.fps) + source_task = asyncio.create_task(video_source.start()) def on_remote_stream(track): - nonlocal frame_count, recorder - frame_count += 1 - if frame_count % 25 == 0: - print(f"šŸ“¹ Processed {frame_count} frames...") + print(f"šŸ“¹ Received remote LiveKit track: {track.sid}") + + async def consume_frames(): + nonlocal frame_count + async for event in rtc.VideoStream(track): + frame_count += 1 + if frame_count % 25 == 0: + print(f"šŸ“¹ Processed {frame_count} remote frames...") - if recorder is None: - print(f"šŸ’¾ Recording to {output_file}") - recorder = MediaRecorder(str(output_file)) - recorder.addTrack(track) - asyncio.create_task(recorder.start()) + asyncio.create_task(consume_frames()) def on_connection_change(state): print(f"šŸ”„ Connection state: {state}") @@ -81,7 +120,7 @@ def on_error(error): realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=player.video, + local_track=video_source.track, options=RealtimeConnectOptions( model=model, on_remote_stream=on_remote_stream, @@ -102,15 +141,7 @@ def on_error(error): except KeyboardInterrupt: print(f"\n\nāœ“ Processed {frame_count} frames total") finally: - if recorder: - try: - print(f"šŸ’¾ Saving video to {output_file}...") - await asyncio.sleep(0.5) - await recorder.stop() - print(f"āœ“ Video saved to {output_file}") - except Exception as e: - print(f"āš ļø Warning: Could not save video cleanly: {e}") - print(" Video file may be incomplete or corrupted") + print(f"Remote frame count written to console; placeholder output: {output_file}") except Exception as e: print(f"\nāŒ Connection failed: {e}") @@ -122,6 +153,12 @@ def on_error(error): print("\nDisconnecting...") await realtime_client.disconnect() print("āœ“ Disconnected") + video_source.stop() + source_task.cancel() + try: + await source_task + except asyncio.CancelledError: + pass if __name__ == "__main__": diff --git a/examples/realtime_synthetic.py b/examples/realtime_synthetic.py index 589c550..d06ce37 100644 --- a/examples/realtime_synthetic.py +++ b/examples/realtime_synthetic.py @@ -10,28 +10,37 @@ ) try: - from aiortc import VideoStreamTrack - from aiortc.contrib.media import MediaRecorder - from av import VideoFrame + from livekit import rtc except ImportError: - print("aiortc is required for this example.") + print("livekit is required for this example.") print("Install with: pip install decart[realtime]") exit(1) -class SyntheticVideoTrack(VideoStreamTrack): - """ - Generates synthetic video frames for testing. - Creates colored frames that change over time. - """ +class SyntheticVideoSource: + """Pushes synthetic RGB frames into a LiveKit video source.""" - def __init__(self): - super().__init__() + def __init__(self, width: int, height: int, fps: int): + self.width = width + self.height = height + self.fps = fps + self.source = rtc.VideoSource(width, height) + self.track = rtc.LocalVideoTrack.create_video_track("synthetic-video", self.source) self.counter = 0 + self._running = False - async def recv(self): - pts, time_base = await self.next_timestamp() + async def start(self): + self._running = True + frame_interval = 1 / self.fps + while self._running: + self.source.capture_frame(self._next_frame()) + await asyncio.sleep(frame_interval) + + def stop(self): + self._running = False + + def _next_frame(self): colors = [ (255, 0, 0), # Red (0, 255, 0), # Green @@ -42,16 +51,16 @@ async def recv(self): color_index = (self.counter // 25) % len(colors) color = colors[color_index] - img = np.zeros((704, 1280, 3), dtype=np.uint8) + img = np.zeros((self.height, self.width, 3), dtype=np.uint8) img[:] = color - frame = VideoFrame.from_ndarray(img, format="rgb24") - frame.pts = pts - frame.time_base = time_base - self.counter += 1 - - return frame + return rtc.VideoFrame( + width=self.width, + height=self.height, + type=rtc.VideoBufferType.RGB24, + data=img.tobytes(), + ) async def main(): @@ -71,26 +80,26 @@ async def main(): print("Creating Decart client...") async with DecartClient(api_key=api_key) as client: print("Creating synthetic video track...") - video_track = SyntheticVideoTrack() - model = models.realtime("lucy-2.1") + video_source = SyntheticVideoSource(model.width, model.height, model.fps) print(f"Using model: {model.name}") print(f"Model config - FPS: {model.fps}, Size: {model.width}x{model.height}") frame_count = 0 - recorder = None - output_file = Path("output_realtime_synthetic.mp4") + source_task = asyncio.create_task(video_source.start()) + output_file = Path("output_realtime_synthetic.frames") def on_remote_stream(track): - nonlocal frame_count, recorder - frame_count += 1 - print(f"šŸ“¹ Received remote stream frame #{frame_count}") + print(f"šŸ“¹ Received remote LiveKit track: {track.sid}") + + async def consume_frames(): + nonlocal frame_count + async for event in rtc.VideoStream(track): + frame_count += 1 + if frame_count % 25 == 0: + print(f"šŸ“¹ Received {frame_count} remote frames") - if recorder is None: - print(f"šŸ’¾ Recording to {output_file}") - recorder = MediaRecorder(str(output_file)) - recorder.addTrack(track) - asyncio.create_task(recorder.start()) + asyncio.create_task(consume_frames()) def on_connection_change(state): print(f"šŸ”„ Connection state: {state}") @@ -107,7 +116,7 @@ def on_error(error): realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=video_track, + local_track=video_source.track, options=RealtimeConnectOptions( model=model, on_remote_stream=on_remote_stream, @@ -142,15 +151,7 @@ def on_error(error): print(f"\nāœ“ Processed {frame_count} frames total") finally: - if recorder: - try: - print(f"šŸ’¾ Saving video to {output_file}...") - await asyncio.sleep(0.5) - await recorder.stop() - print(f"āœ“ Video saved to {output_file}") - except Exception as e: - print(f"āš ļø Warning: Could not save video cleanly: {e}") - print(" Video file may be incomplete or corrupted") + print(f"Remote frame count written to console; placeholder output: {output_file}") except Exception as e: print(f"\nāŒ Connection failed: {e}") @@ -162,6 +163,12 @@ def on_error(error): print("\nDisconnecting...") await realtime_client.disconnect() print("āœ“ Disconnected") + video_source.stop() + source_task.cancel() + try: + await source_task + except asyncio.CancelledError: + pass if __name__ == "__main__": diff --git a/playground/playground.py b/playground/playground.py index c28fdc4..1ac7082 100644 --- a/playground/playground.py +++ b/playground/playground.py @@ -1,47 +1,21 @@ #!/usr/bin/env python3 """ -Decart Python SDK — Local Playground +Decart Python SDK — Local LiveKit Playground OpenCV-based CLI playground for testing the Decart realtime API. - -Features: - 1. Model selection (CLI arg or interactive prompt) - 2. API key input (env var, CLI arg, or interactive prompt) - 3. Live camera → Decart → display window - 4. Prompt input (type in terminal, sent to Decart) - 5. Image reference (file path arg for initial state) - -Usage: - python playground.py # Interactive mode - python playground.py --model lucy-restyle-2 # Camera model - python playground.py --model lucy-restyle-2 --prompt "Anime" # With initial prompt - -Controls (while running): - Type text + Enter → Send prompt to Decart - /image → Send reference image - /set → Send prompt + enhance=True (same as plain text) - /quit or 'q' key → Exit - -Requirements: - pip install opencv-python - pip install decart[realtime] # or: pip install -e ..[realtime] """ from __future__ import annotations import argparse import asyncio -import fractions import logging import os import sys import threading -import time from pathlib import Path from typing import Optional, cast -# ── Dependency checks ──────────────────────────────────────────────────────── - def _check_deps() -> None: missing: list[str] = [] @@ -50,10 +24,9 @@ def _check_deps() -> None: except ImportError: missing.append("opencv-python") try: - import av # noqa: F401 - from aiortc import MediaStreamTrack # noqa: F401 + from livekit import rtc # noqa: F401 except ImportError: - missing.append("decart[realtime] (includes aiortc)") + missing.append("decart[realtime] (includes livekit)") try: from decart import DecartClient # noqa: F401 except ImportError: @@ -73,9 +46,7 @@ def _check_deps() -> None: import cv2 # noqa: E402 import numpy as np # noqa: E402 -from av import VideoFrame # noqa: E402 -from aiortc import MediaStreamTrack # noqa: E402 -from aiortc.mediastreams import MediaStreamError, VideoStreamTrack # noqa: E402 +from livekit import rtc # noqa: E402 from decart import DecartClient, models # noqa: E402 from decart.models import RealTimeModels # noqa: E402 @@ -84,10 +55,6 @@ def _check_deps() -> None: from decart.types import ModelState, Prompt # noqa: E402 logging.basicConfig(level=logging.WARNING) -logger = logging.getLogger("playground") - - -# ── Constants ──────────────────────────────────────────────────────────────── REALTIME_MODELS = [ "lucy-2.1", @@ -101,91 +68,65 @@ def _check_deps() -> None: ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•""" -# ── Camera Track ───────────────────────────────────────────────────────────── - - -class CameraTrack(VideoStreamTrack): - """Bridges OpenCV webcam capture to an aiortc video track.""" - - kind = "video" - +class CameraVideoSource: def __init__(self, device: int, width: int, height: int, fps: int) -> None: - super().__init__() + self.width = width + self.height = height + self.fps = fps self._cap = cv2.VideoCapture(device) if not self._cap.isOpened(): raise RuntimeError(f"Cannot open camera device {device}") self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) self._cap.set(cv2.CAP_PROP_FPS, fps) - self._fps = fps - self._count = 0 - self._t0: Optional[float] = None + self.source = rtc.VideoSource(width, height) + self.track = rtc.LocalVideoTrack.create_video_track("camera-video", self.source) self.last_frame: Optional[np.ndarray] = None - - async def recv(self) -> VideoFrame: - if self._t0 is None: - self._t0 = time.time() - - # Pace output to target FPS - target = self._t0 + self._count / self._fps - delay = target - time.time() - if delay > 0: - await asyncio.sleep(delay) - - ret, frame = self._cap.read() - if not ret: - raise MediaStreamError("Camera read failed") - - self.last_frame = frame.copy() - - vf = VideoFrame.from_ndarray(frame, format="bgr24") # type: ignore[arg-type] - vf.pts = self._count - vf.time_base = fractions.Fraction(1, self._fps) - self._count += 1 - return vf + self._running = False + + async def start(self) -> None: + self._running = True + frame_interval = 1 / self.fps + while self._running: + ok, frame = self._cap.read() + if not ok: + await asyncio.sleep(frame_interval) + continue + frame = cv2.resize(frame, (self.width, self.height)) + self.last_frame = frame.copy() + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + self.source.capture_frame( + rtc.VideoFrame( + width=self.width, + height=self.height, + type=rtc.VideoBufferType.RGB24, + data=rgb.tobytes(), + ) + ) + await asyncio.sleep(frame_interval) def stop(self) -> None: - super().stop() + self._running = False if self._cap.isOpened(): self._cap.release() -# ── CLI ────────────────────────────────────────────────────────────────────── - - def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Decart Python SDK — Local Playground", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""\ -Examples: - %(prog)s --model lucy-restyle-2 - %(prog)s --model lucy-restyle-2 --prompt "Anime style" - %(prog)s --model lucy-2.1 --image ref.png --prompt "Lego World" -""", - ) - p.add_argument("--model", "-m", choices=REALTIME_MODELS, help="Model name") - p.add_argument("--api-key", "-k", help="API key (or set DECART_API_KEY env var)") - p.add_argument( - "--image", - "-i", - help="Optional reference image (for lucy-2.1 / lucy-2.1-vton / lucy-restyle-2)", - ) - p.add_argument("--prompt", "-p", help="Initial prompt text") - p.add_argument("--camera", "-c", type=int, default=0, help="Camera device index (default: 0)") - p.add_argument("--no-local", action="store_true", help="Hide local camera feed") - p.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") - return p.parse_args() + parser = argparse.ArgumentParser(description="Decart Python SDK LiveKit playground") + parser.add_argument("--model", "-m", choices=REALTIME_MODELS, help="Model name") + parser.add_argument("--api-key", "-k", help="API key (or set DECART_API_KEY)") + parser.add_argument("--image", "-i", help="Optional reference image") + parser.add_argument("--prompt", "-p", help="Initial prompt text") + parser.add_argument("--camera", "-c", type=int, default=0, help="Camera device index") + parser.add_argument("--no-local", action="store_true", help="Hide local camera feed") + parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") + return parser.parse_args() def select_model_interactive() -> str: - """Interactive model selection menu.""" print("\nAvailable realtime models:") for i, name in enumerate(REALTIME_MODELS, 1): - note = "" - if name in ("lucy-2.1", "lucy-2.1-vton", "lucy-restyle-2"): - note = " (supports reference image)" - print(f" {i}. {name}{note}") + print(f" {i}. {name}") while True: choice = input(f"\nSelect model [1-{len(REALTIME_MODELS)}]: ").strip() @@ -199,9 +140,6 @@ def select_model_interactive() -> str: print(" Invalid choice, try again") -# ── Main ───────────────────────────────────────────────────────────────────── - - async def run() -> None: args = parse_args() print(BANNER) @@ -209,282 +147,118 @@ async def run() -> None: if args.verbose: logging.getLogger().setLevel(logging.DEBUG) - # ── API Key ────────────────────────────────────────────────────────── - api_key = args.api_key or os.getenv("DECART_API_KEY") - if not api_key: - api_key = input("Enter your Decart API key: ").strip() + api_key = args.api_key or os.getenv("DECART_API_KEY") or input("Enter your Decart API key: ") if not api_key: print("Error: API key is required") return - # ── Model Selection ────────────────────────────────────────────────── - model_name = args.model - if not model_name: - model_name = select_model_interactive() - + model_name = args.model or select_model_interactive() model = models.realtime(cast(RealTimeModels, model_name)) - print(f"\n Model : {model_name}") print(f" Res : {model.width}x{model.height} @ {model.fps}fps") - # ── Validate ───────────────────────────────────────────────────────── if args.image and not Path(args.image).exists(): print(f"\nError: Image not found: {args.image}") return - # ── Initial State ──────────────────────────────────────────────────── initial_state: Optional[ModelState] = None if args.image or args.prompt: initial_state = ModelState( image=args.image if args.image else None, prompt=Prompt(text=args.prompt) if args.prompt else None, ) - parts: list[str] = [] - if args.image: - parts.append(f"image={Path(args.image).name}") - if args.prompt: - parts.append(f'prompt="{args.prompt}"') - print(f" Init : {', '.join(parts)}") - - # ── Local Track ────────────────────────────────────────────────────── - camera_track: Optional[CameraTrack] = None - local_track: Optional[MediaStreamTrack] = None - - print(f"\n Opening camera (device {args.camera})...") - try: - camera_track = CameraTrack(args.camera, model.width, model.height, model.fps) - local_track = camera_track - actual_w = int(camera_track._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - actual_h = int(camera_track._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - print(f" āœ“ Camera opened ({actual_w}x{actual_h})") - except RuntimeError as e: - print(f" āœ— {e}") - return - # ── Connect ────────────────────────────────────────────────────────── + camera = CameraVideoSource(args.camera, model.width, model.height, model.fps) + camera_task = asyncio.create_task(camera.start()) remote_track_ready = asyncio.Event() - remote_video_track: list[Optional[MediaStreamTrack]] = [None] + latest_remote: list[Optional[np.ndarray]] = [None] - def on_remote_stream(track: MediaStreamTrack) -> None: - remote_video_track[0] = track - # Schedule event set on the running loop (callback may fire from aiortc thread) - try: - loop = asyncio.get_running_loop() - loop.call_soon_threadsafe(remote_track_ready.set) - except RuntimeError: - remote_track_ready.set() + def on_remote_stream(track) -> None: + print(f" āœ“ Remote LiveKit track received: {track.sid}") + + async def consume_frames() -> None: + async for event in rtc.VideoStream(track): + frame = event.frame.convert(rtc.VideoBufferType.RGB24) + data = np.frombuffer(frame.data, dtype=np.uint8).reshape( + (frame.height, frame.width, 3) + ) + latest_remote[0] = cv2.cvtColor(data, cv2.COLOR_RGB2BGR) + remote_track_ready.set() + + asyncio.create_task(consume_frames()) def on_connection_change(state: str) -> None: - print(f" šŸ”„ Connection: {state}") + print(f" Connection: {state}") def on_error(error: Exception) -> None: - print(f" āŒ Error: {error}") + print(f" Error: {error}") + + prompt_queue: asyncio.Queue[str] = asyncio.Queue() + stop = threading.Event() + loop = asyncio.get_running_loop() + + def _read_prompts() -> None: + while not stop.is_set(): + try: + line = input("prompt> ").strip() + if line: + asyncio.run_coroutine_threadsafe(prompt_queue.put(line), loop) + except (EOFError, KeyboardInterrupt): + asyncio.run_coroutine_threadsafe(prompt_queue.put("/quit"), loop) + break - print("\n Connecting to Decart...") + reader = threading.Thread(target=_read_prompts, daemon=True) + reader.start() realtime: Optional[RealtimeClient] = None try: client = DecartClient(api_key=api_key) - realtime = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=local_track, + local_track=camera.track, options=RealtimeConnectOptions( model=model, on_remote_stream=on_remote_stream, initial_state=initial_state, ), ) - realtime.on("connection_change", on_connection_change) realtime.on("error", on_error) - print(f" āœ“ Connected! Session: {realtime.session_id}") - print(" Waiting for remote stream...") - try: - await asyncio.wait_for(remote_track_ready.wait(), timeout=15.0) - print(" āœ“ Remote stream received\n") - except asyncio.TimeoutError: - print(" ⚠ No remote stream (timeout) — display may not work\n") - - # ── Controls ───────────────────────────────────────────────────── - print(" ā”Œā”€ Controls ─────────────────────────────") - print(" │ Type text + Enter → Send prompt") - print(" │ /image → Send reference image") - print(" │ /quit or 'q' key → Exit") - print(" └─────────────────────────────────────────") - print(" (Click the terminal to type prompts)\n") - - # ── Prompt reader thread ───────────────────────────────────────── - prompt_queue: asyncio.Queue[str] = asyncio.Queue() - stop = threading.Event() - loop = asyncio.get_running_loop() - - def _read_prompts() -> None: - while not stop.is_set(): - try: - line = input("prompt> ") - if line.strip(): - asyncio.run_coroutine_threadsafe(prompt_queue.put(line.strip()), loop) - except (EOFError, KeyboardInterrupt): - asyncio.run_coroutine_threadsafe(prompt_queue.put("/quit"), loop) - break - - reader = threading.Thread(target=_read_prompts, daemon=True) - reader.start() - - # ── Frame consumer ─────────────────────────────────────────────── - latest_remote: list[Optional[np.ndarray]] = [None] - frame_count = 0 - fps_t0 = time.time() - display_fps = 0.0 - - async def _consume_frames() -> None: - nonlocal frame_count, fps_t0, display_fps - track = remote_video_track[0] - if not track: + while True: + while not prompt_queue.empty(): + line = await prompt_queue.get() + if line in ("/quit", "q"): + return + if line.startswith("/image "): + await realtime.set_image(line[len("/image ") :].strip()) + else: + await realtime.set_prompt( + line[5:].strip() if line.startswith("/set ") else line + ) + + if not args.no_local and camera.last_frame is not None: + cv2.imshow("Local Camera", camera.last_frame) + if latest_remote[0] is not None: + cv2.imshow("Decart Remote", latest_remote[0]) + if cv2.waitKey(1) & 0xFF == ord("q"): return - try: - while True: - frame = await track.recv() - video_frame = cast("VideoFrame", frame) - latest_remote[0] = video_frame.to_ndarray(format="bgr24") - frame_count += 1 - elapsed = time.time() - fps_t0 - if elapsed >= 1.0: - display_fps = frame_count / elapsed - frame_count = 0 - fps_t0 = time.time() - except (MediaStreamError, asyncio.CancelledError): - pass - - consumer = asyncio.create_task(_consume_frames()) - - # ── Main display + command loop ────────────────────────────────── - window_name = f"Decart Playground — {model_name}" - show_local = not args.no_local and camera_track is not None - window_created = False - running = True - try: - while running: - while not prompt_queue.empty(): - try: - cmd = prompt_queue.get_nowait() - except asyncio.QueueEmpty: - break - - if cmd == "/quit": - running = False - break - elif cmd.startswith("/image "): - img_path = cmd[7:].strip() - if not Path(img_path).exists(): - print(f" File not found: {img_path}") - continue - try: - print(f" Sending image: {img_path}") - await realtime.set_image(img_path) - print(" āœ“ Image sent") - except Exception as e: - print(f" āœ— Failed: {e}") - elif cmd.startswith("/"): - print(f" Unknown command: {cmd}") - print(" Available: /image , /quit") - else: - try: - await realtime.set_prompt(cmd) - print(" āœ“ Prompt acknowledged") - except Exception as e: - print(f" āœ— Prompt failed: {e}") - - if not running: - break - - remote = latest_remote[0] - if remote is not None: - local_frame = camera_track.last_frame if camera_track else None - if show_local and local_frame is not None: - h, w = remote.shape[:2] - local_resized = cv2.resize(local_frame, (w, h)) - display = np.hstack([local_resized, remote]) - else: - display = remote - - if display_fps > 0: - cv2.putText( - display, - f"{display_fps:.1f} fps", - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.8, - (0, 255, 0), - 2, - ) - - cv2.imshow(window_name, display) - window_created = True - - key = cv2.waitKey(1) & 0xFF - if key == ord("q"): - running = False - break - - # Check if window was closed by user - if window_created: - try: - if cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1: - running = False - break - except cv2.error: - pass - - await asyncio.sleep(0.005) - - except KeyboardInterrupt: - pass - - # ── Cleanup ────────────────────────────────────────────────────── - print("\n Shutting down...") + await asyncio.sleep(0.01) + finally: stop.set() - consumer.cancel() + camera.stop() + camera_task.cancel() try: - await consumer + await camera_task except asyncio.CancelledError: pass - await realtime.disconnect() - if camera_track: - camera_track.stop() - cv2.destroyAllWindows() - print(" āœ“ Done") - - except KeyboardInterrupt: - print("\n Interrupted") if realtime: await realtime.disconnect() - if camera_track: - camera_track.stop() cv2.destroyAllWindows() - except Exception as e: - print(f"\n āœ— Connection failed: {e}") - if args.verbose: - import traceback - - traceback.print_exc() - if camera_track: - camera_track.stop() - cv2.destroyAllWindows() - - -def main() -> None: - try: - asyncio.run(run()) - except KeyboardInterrupt: - pass - if __name__ == "__main__": - main() + asyncio.run(run()) diff --git a/pyproject.toml b/pyproject.toml index a1767ba..be66c96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,7 @@ dependencies = [ [project.optional-dependencies] realtime = [ - "aiortc>=1.9.0", - "av>=12.0.0", + "livekit>=1.0.0", "tenacity>=8.0.0", ] dev = [ diff --git a/tests/test_realtime_unit.py b/tests/test_realtime_unit.py index aec3b41..b9e74ef 100644 --- a/tests/test_realtime_unit.py +++ b/tests/test_realtime_unit.py @@ -1,7 +1,9 @@ import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unittest.mock import AsyncMock, MagicMock, patch + from decart import DecartClient, ModelDefinition, models try: @@ -17,14 +19,24 @@ ) +def _mock_manager(connected: bool = True) -> AsyncMock: + manager = AsyncMock() + manager.connect = AsyncMock(return_value=True) + manager.is_connected = MagicMock(return_value=connected) + manager.get_connection_state = MagicMock( + return_value="connected" if connected else "disconnected" + ) + manager.send_message = AsyncMock() + manager.cleanup = AsyncMock() + return manager + + def test_realtime_client_available(): - """Test that realtime client is available when aiortc is installed""" assert REALTIME_AVAILABLE assert RealtimeClient is not None def test_realtime_models_available(): - """Test that realtime models are available""" model = models.realtime("lucy-restyle-2") assert model.name == "lucy-restyle-2" assert model.fps == 30 @@ -32,45 +44,30 @@ def test_realtime_models_available(): assert model.height == 704 assert model.url_path == "/v1/stream" - model2 = models.realtime("lucy-2.1") - assert model2.name == "lucy-2.1" - assert model2.fps == 30 - assert model2.width == 1088 - assert model2.height == 624 - assert model2.url_path == "/v1/stream" - @pytest.mark.asyncio -async def test_realtime_client_creation_with_mock(): - """Test client creation with mocked WebRTC""" - import asyncio +async def test_realtime_connect_wires_livekit_manager_and_session_started_callback(): + from decart.realtime.messages import LiveKitRoomInfoMessage + from decart.realtime.types import RealtimeConnectOptions + from decart.types import ModelState, Prompt client = DecartClient(api_key="test-key") - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.is_connected = MagicMock(return_value=True) - mock_manager.get_connection_state = MagicMock(return_value="connected") - mock_manager.send_message = AsyncMock() - - prompt_event = asyncio.Event() - prompt_result = {"success": True, "error": None} - prompt_event.set() - - mock_manager.register_prompt_wait = MagicMock(return_value=(prompt_event, prompt_result)) - mock_manager.unregister_prompt_wait = MagicMock() + with ( + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = _mock_manager() mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - from decart.types import ModelState, Prompt + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=mock_track, + local_track=MagicMock(), options=RealtimeConnectOptions( model=models.realtime("lucy-restyle-2"), on_remote_stream=lambda t: None, @@ -78,33 +75,31 @@ async def test_realtime_client_creation_with_mock(): ), ) - assert realtime_client is not None assert realtime_client.is_connected() - assert realtime_client.session_id is None - assert realtime_client.subscribe_token is None - - call_args = mock_manager_class.call_args - config = call_args[0][0] if call_args[0] else call_args[1]["configuration"] - assert config.on_session_id is not None, "on_session_id callback must be wired" - - from decart.realtime.messages import SessionIdMessage - - config.on_session_id( - SessionIdMessage( - type="session_id", - session_id="test-session-123", - server_ip="1.2.3.4", - server_port=8080, + config = mock_manager_class.call_args.args[0] + assert config.on_session_started is not None + assert "livekit_early_room_info=true" in config.livekit_url + assert config.preferred_video_codec == "h264" + + config.on_session_started( + LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", ) ) - assert realtime_client.session_id == "test-session-123" + assert realtime_client.session_id == "session-123" assert realtime_client.subscribe_token is not None + await realtime_client.disconnect() @pytest.mark.asyncio async def test_realtime_connect_accepts_custom_model_definition(): - """Custom realtime models can use arbitrary model names, matching the JS SDK escape hatch.""" + from decart.realtime.types import RealtimeConnectOptions + client = DecartClient(api_key="test-key") custom_model = ModelDefinition( name="lucy_2_rt_preview", @@ -115,12 +110,10 @@ async def test_realtime_connect_accepts_custom_model_definition(): ) with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.is_connected = MagicMock(return_value=True) + mock_manager = _mock_manager() mock_manager_class.return_value = mock_manager mock_session = MagicMock() @@ -128,8 +121,6 @@ async def test_realtime_connect_accepts_custom_model_definition(): mock_session.close = AsyncMock() mock_session_cls.return_value = mock_session - from decart.realtime.types import RealtimeConnectOptions - realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, @@ -140,25 +131,23 @@ async def test_realtime_connect_accepts_custom_model_definition(): ), ) - assert realtime_client is not None - call_args = mock_manager_class.call_args - config = call_args[0][0] if call_args[0] else call_args[1]["configuration"] - assert "model=lucy_2_rt_preview" in config.webrtc_url + config = mock_manager_class.call_args.args[0] + assert "model=lucy_2_rt_preview" in config.livekit_url assert config.fps == 20 await realtime_client.disconnect() async def _connect_and_capture_url(resolution=None) -> str: + from decart.realtime.types import RealtimeConnectOptions + client = DecartClient(api_key="test-key") with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.is_connected = MagicMock(return_value=True) + mock_manager = _mock_manager() mock_manager_class.return_value = mock_manager mock_session = MagicMock() @@ -166,8 +155,6 @@ async def _connect_and_capture_url(resolution=None) -> str: mock_session.close = AsyncMock() mock_session_cls.return_value = mock_session - from decart.realtime.types import RealtimeConnectOptions - kwargs = {"resolution": resolution} if resolution is not None else {} realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, @@ -180,18 +167,16 @@ async def _connect_and_capture_url(resolution=None) -> str: ), ) - call_args = mock_manager_class.call_args - config = call_args[0][0] if call_args[0] else call_args[1]["configuration"] - url = config.webrtc_url - + config = mock_manager_class.call_args.args[0] await realtime_client.disconnect() - return url + return config.livekit_url @pytest.mark.asyncio async def test_realtime_connect_omits_resolution_when_unset(): url = await _connect_and_capture_url() assert "resolution=" not in url + assert "livekit_early_room_info=true" in url @pytest.mark.asyncio @@ -201,1156 +186,445 @@ async def test_realtime_connect_appends_resolution_720p(): @pytest.mark.asyncio -async def test_realtime_connect_appends_resolution_1080p(): - url = await _connect_and_capture_url("1080p") - assert "&resolution=1080p" in url - - -@pytest.mark.asyncio -async def test_realtime_set_prompt_with_mock(): - """Test set_prompt with mocked WebRTC and prompt_ack""" - import asyncio +async def test_realtime_connect_allows_preferred_video_codec_override(): + from decart.realtime.types import RealtimeConnectOptions client = DecartClient(api_key="test-key") - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - prompt_event = asyncio.Event() - prompt_result = {"success": True, "error": None} - - def register_prompt_wait(prompt): - return prompt_event, prompt_result - - mock_manager.register_prompt_wait = MagicMock(side_effect=register_prompt_wait) - mock_manager.unregister_prompt_wait = MagicMock() - mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - async def set_event(): - await asyncio.sleep(0.01) - prompt_event.set() - - asyncio.create_task(set_event()) - await realtime_client.set_prompt("New prompt") - - mock_manager.send_message.assert_called() - call_args = mock_manager.send_message.call_args[0][0] - assert call_args.type == "prompt" - assert call_args.prompt == "New prompt" - assert call_args.enhance_prompt is True - mock_manager.unregister_prompt_wait.assert_called_with("New prompt") - - -@pytest.mark.asyncio -async def test_buffered_events_delivered_after_handler_registration(): - """Events emitted during connect() must be delivered to handlers registered after connect().""" - client = DecartClient(api_key="test-key") - - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - # Simulate events that were buffered during connect - realtime_client._buffer.append(("connection_change", "connecting")) - realtime_client._buffer.append(("connection_change", "connected")) - - received: list = [] - realtime_client.on("connection_change", lambda s: received.append(s)) - - # Yield to event loop — deferred flush fires and delivers buffered events - await asyncio.sleep(0) - - assert received == ["connecting", "connected"] - - -@pytest.mark.asyncio -async def test_realtime_events(): - """Test event handling""" - client = DecartClient(api_key="test-key") - - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - connection_states = [] - errors = [] - - def on_connection_change(state): - connection_states.append(state) - - def on_error(error): - errors.append(error) - - realtime_client.on("connection_change", on_connection_change) - realtime_client.on("error", on_error) - - # Yield to event loop so deferred _do_flush fires (mirrors JS setTimeout(0)) - await asyncio.sleep(0) - - realtime_client._emit_connection_change("connected") - assert connection_states == ["connected"] - - from decart.errors import DecartSDKError - - test_error = DecartSDKError("Test error") - realtime_client._emit_error(test_error) - assert len(errors) == 1 - assert errors[0].message == "Test error" - - -@pytest.mark.asyncio -async def test_realtime_set_prompt_timeout(): - """Test set_prompt raises on timeout""" - import asyncio - - client = DecartClient(api_key="test-key") - - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - prompt_event = asyncio.Event() - prompt_result = {"success": False, "error": None} - - def register_prompt_wait(prompt): - return prompt_event, prompt_result - - mock_manager.register_prompt_wait = MagicMock(side_effect=register_prompt_wait) - mock_manager.unregister_prompt_wait = MagicMock() - mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - from decart.errors import DecartSDKError - - # Mock asyncio.wait_for to immediately raise TimeoutError - with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): - with pytest.raises(DecartSDKError) as exc_info: - await realtime_client.set_prompt("New prompt") - - assert "timed out" in str(exc_info.value) - mock_manager.unregister_prompt_wait.assert_called_with("New prompt") - - -@pytest.mark.asyncio -async def test_realtime_set_prompt_server_error(): - """Test set_prompt raises on server error""" - import asyncio - - client = DecartClient(api_key="test-key") - - with patch("decart.realtime.client.WebRTCManager") as mock_manager_class: - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.send_message = AsyncMock() - - prompt_event = asyncio.Event() - prompt_result = {"success": False, "error": "Server rejected prompt"} - - def register_prompt_wait(prompt): - return prompt_event, prompt_result - - mock_manager.register_prompt_wait = MagicMock(side_effect=register_prompt_wait) - mock_manager.unregister_prompt_wait = MagicMock() - mock_manager_class.return_value = mock_manager - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - async def set_event(): - await asyncio.sleep(0.01) - prompt_event.set() - - asyncio.create_task(set_event()) - - from decart.errors import DecartSDKError - - with pytest.raises(DecartSDKError) as exc_info: - await realtime_client.set_prompt("New prompt") - - assert "Server rejected prompt" in str(exc_info.value) - mock_manager.unregister_prompt_wait.assert_called_with("New prompt") - - -@pytest.mark.asyncio -async def test_set_image_works_for_any_model(): - """Test that set_image works for non-avatar-live models""" - client = DecartClient(api_key="test-key") - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.file_input_to_bytes") as mock_file_input, + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() + mock_manager = _mock_manager() mock_manager_class.return_value = mock_manager - - mock_file_input.return_value = (b"image data", "image/png") - mock_session = MagicMock() mock_session.closed = False mock_session.close = AsyncMock() mock_session_cls.return_value = mock_session - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=mock_track, + local_track=MagicMock(), options=RealtimeConnectOptions( model=models.realtime("lucy-restyle-2"), on_remote_stream=lambda t: None, + preferred_video_codec="vp9", ), ) - await realtime_client.set_image(b"test image") - mock_manager.set_image.assert_called_once() + config = mock_manager_class.call_args.args[0] + assert config.preferred_video_codec == "vp9" + await realtime_client.disconnect() @pytest.mark.asyncio -async def test_set_image_null_clears_image(): - """Test that set_image(None) sends null to clear image""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - await realtime_client.set_image(None) - mock_manager.set_image.assert_called_once() - assert mock_manager.set_image.call_args[0][0] is None - +async def test_realtime_set_prompt_with_mock(): + from decart.realtime.types import RealtimeConnectOptions -@pytest.mark.asyncio -async def test_set_image_with_prompt_and_enhance(): - """Test that set_image passes prompt and enhance options""" client = DecartClient(api_key="test-key") with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.file_input_to_bytes") as mock_file_input, + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() + mock_manager = _mock_manager() + prompt_event = asyncio.Event() + prompt_result = {"success": True, "error": None} + mock_manager.register_prompt_wait = MagicMock(return_value=(prompt_event, prompt_result)) + mock_manager.unregister_prompt_wait = MagicMock() mock_manager_class.return_value = mock_manager - - mock_file_input.return_value = (b"img", "image/png") - mock_session = MagicMock() mock_session.closed = False mock_session.close = AsyncMock() mock_session_cls.return_value = mock_session - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - realtime_client = await RealtimeClient.connect( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=mock_track, + local_track=MagicMock(), options=RealtimeConnectOptions( model=models.realtime("lucy-restyle-2"), on_remote_stream=lambda t: None, ), ) - await realtime_client.set_image(b"img", prompt="a dog", enhance=False) - opts = mock_manager.set_image.call_args[0][1] - assert opts["prompt"] == "a dog" - assert opts["enhance"] is False + async def set_event(): + await asyncio.sleep(0.01) + prompt_event.set() + asyncio.create_task(set_event()) + await realtime_client.set_prompt("New prompt") -# Tests for set() method + call_args = mock_manager.send_message.call_args.args[0] + assert call_args.type == "prompt" + assert call_args.prompt == "New prompt" + assert call_args.enhance_prompt is True + mock_manager.unregister_prompt_wait.assert_called_with("New prompt") + await realtime_client.disconnect() @pytest.mark.asyncio -async def test_set_rejects_when_neither_prompt_nor_image(): - """Test set() raises when neither prompt nor image is provided""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager_class.return_value = mock_manager +async def test_subscribe_fetches_watch_stream_credentials_and_connects_room_directly(): + from decart.realtime.messages import LiveKitRoomInfoMessage + from decart.realtime.subscribe import SubscribeOptions, encode_subscribe_token - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput - from decart.errors import InvalidInputError - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - with pytest.raises(InvalidInputError, match="At least one of"): - await realtime_client.set(SetInput()) - - -@pytest.mark.asyncio -async def test_set_rejects_empty_prompt(): - """Test set() raises when prompt is empty string""" client = DecartClient(api_key="test-key") + room_info = LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="room-123", + ) with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + patch( + "decart.realtime.client._fetch_watch_stream_credentials", + AsyncMock(return_value=room_info), + ), + patch("decart.realtime.client.LiveKitManager") as mock_manager_class, ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) + mock_manager = _mock_manager() mock_manager_class.return_value = mock_manager - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput - from decart.errors import InvalidInputError - - realtime_client = await RealtimeClient.connect( + sub_client = await RealtimeClient.subscribe( base_url=client.realtime_base_url, api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), + options=SubscribeOptions( + token=encode_subscribe_token("room-123"), on_remote_stream=lambda t: None, ), ) - with pytest.raises(InvalidInputError, match="Prompt cannot be empty"): - await realtime_client.set(SetInput(prompt="")) + assert sub_client.is_connected() + config = mock_manager_class.call_args.args[0] + assert config.room_info == room_info + mock_manager.connect.assert_awaited_once_with(None) -@pytest.mark.asyncio -async def test_set_sends_prompt_only(): - """Test set() sends prompt-only via set_image with null image""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager +def test_subscribe_token_round_trip_uses_room_name(): + from decart.realtime.subscribe import decode_subscribe_token, encode_subscribe_token - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session + token = encode_subscribe_token("room-123") + assert decode_subscribe_token(token).room_name == "room-123" - mock_track = MagicMock() - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput +def test_legacy_subscribe_token_is_rejected(): + from decart.realtime.subscribe import decode_subscribe_token - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - await realtime_client.set(SetInput(prompt="a cat")) - - mock_manager.set_image.assert_called_once_with( - None, - { - "prompt": "a cat", - "enhance": True, - "timeout": 30.0, - }, - ) - - -@pytest.mark.asyncio -async def test_set_sends_prompt_with_enhance(): - """Test set() sends prompt with enhance flag""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session + legacy = json.dumps({"sid": "sid", "ip": "1.2.3.4", "port": 8080}).encode() + import base64 - mock_track = MagicMock() + token = base64.urlsafe_b64encode(legacy).decode() + with pytest.raises(ValueError, match="Invalid subscribe token"): + decode_subscribe_token(token) - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) +def test_livekit_messages_serialize_join_and_passthrough(): + from decart.realtime.messages import LiveKitJoinMessage, SetAvatarImageMessage, message_to_json - await realtime_client.set(SetInput(prompt="a cat", enhance=False)) + assert message_to_json(LiveKitJoinMessage(type="livekit_join")) == '{"type":"livekit_join"}' - mock_manager.set_image.assert_called_once_with( - None, - { - "prompt": "a cat", - "enhance": False, - "timeout": 30.0, - }, - ) + passthrough = json.loads( + message_to_json(SetAvatarImageMessage(type="set_image", image_data=None, prompt=None)) + ) + assert passthrough == {"type": "set_image", "image_data": None, "prompt": None} @pytest.mark.asyncio -async def test_set_sends_image_only(): - """Test set() sends image-only via set_image""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_image_to_base64.return_value = "convertedbase64" +async def test_livekit_connection_join_sends_join_and_resolves_room_info(): + from decart.realtime.livekit_connection import LiveKitConnection - mock_track = MagicMock() + class FakeWebSocket: + closed = False - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput + def __init__(self): + self.sent: list[str] = [] - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) + async def send_str(self, message: str): + self.sent.append(message) - await realtime_client.set(SetInput(image="rawbase64data")) + ws = FakeWebSocket() + connection = LiveKitConnection() + connection._ws = ws # type: ignore[attr-defined] - mock_image_to_base64.assert_called_once_with("rawbase64data", mock_session) - mock_manager.set_image.assert_called_once_with( - "convertedbase64", + async def resolve_room_info(): + await asyncio.sleep(0.01) + await connection._handle_message( { - "prompt": None, - "enhance": True, - "timeout": 30.0, - }, + "type": "livekit_room_info", + "livekit_url": "wss://livekit.example", + "token": "lk-token", + "room_name": "room-123", + "session_id": "session-123", + } ) + asyncio.create_task(resolve_room_info()) + room_info = await connection._join_livekit_room(timeout=1) -@pytest.mark.asyncio -async def test_set_sends_prompt_and_image(): - """Test set() sends prompt and image together""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_image_to_base64.return_value = "convertedbase64" - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) - - await realtime_client.set(SetInput(prompt="a cat", enhance=False, image="rawbase64")) - - mock_manager.set_image.assert_called_once_with( - "convertedbase64", - { - "prompt": "a cat", - "enhance": False, - "timeout": 30.0, - }, - ) + assert json.loads(ws.sent[0]) == {"type": "livekit_join"} + assert room_info.room_name == "room-123" + assert room_info.session_id == "session-123" @pytest.mark.asyncio -async def test_set_converts_bytes_image(): - """Test set() converts bytes image to base64""" - client = DecartClient(api_key="test-key") - - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - patch("decart.realtime.client._image_to_base64") as mock_image_to_base64, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.set_image = AsyncMock() - mock_manager_class.return_value = mock_manager +async def test_livekit_connection_can_connect_directly_with_room_info(): + from decart.realtime.livekit_connection import LiveKitConnection + from decart.realtime.messages import LiveKitRoomInfoMessage - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_image_to_base64.return_value = "blobbase64" - - mock_track = MagicMock() - - from decart.realtime.types import RealtimeConnectOptions - from decart.realtime.client import SetInput - - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - ), - ) + connection = LiveKitConnection() + connection._connect_signaling = AsyncMock() # type: ignore[method-assign] + connection._join_livekit_room = AsyncMock() # type: ignore[method-assign] + connection._connect_room = AsyncMock() # type: ignore[method-assign] + connection._wait_until_connected = AsyncMock() # type: ignore[method-assign] + room_info = LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", + ) - test_bytes = b"test-image-data" - await realtime_client.set(SetInput(image=test_bytes)) + await connection.connect(url="", local_track=None, timeout=1, room_info=room_info) - mock_image_to_base64.assert_called_once_with(test_bytes, mock_session) - mock_manager.set_image.assert_called_once_with( - "blobbase64", - { - "prompt": None, - "enhance": True, - "timeout": 30.0, - }, - ) + connection._connect_signaling.assert_not_called() + connection._join_livekit_room.assert_not_called() + connection._connect_room.assert_awaited_once_with(room_info, None, "h264") @pytest.mark.asyncio -async def test_connect_with_initial_prompt(): - """Test connection with initial_state.prompt option""" - - client = DecartClient(api_key="test-key") +async def test_livekit_connection_sends_initial_state_before_media_connect(): + from decart.realtime.livekit_connection import LiveKitConnection + from decart.realtime.messages import LiveKitRoomInfoMessage - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.is_connected = MagicMock(return_value=True) - mock_manager_class.return_value = mock_manager + order = [] + room_info = LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", + ) - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session + async def send_initial_prompt(_prompt): + order.append("initial_prompt") - mock_track = MagicMock() + async def connect_room(*_args): + order.append("connect_room") - from decart.realtime.types import RealtimeConnectOptions - from decart.types import ModelState, Prompt + connection = LiveKitConnection() + connection._connect_signaling = AsyncMock() # type: ignore[method-assign] + connection._join_livekit_room = AsyncMock(return_value=room_info) # type: ignore[method-assign] + connection._send_initial_prompt_and_wait = AsyncMock(side_effect=send_initial_prompt) # type: ignore[method-assign] + connection._connect_room = AsyncMock(side_effect=connect_room) # type: ignore[method-assign] + connection._wait_until_connected = AsyncMock() # type: ignore[method-assign] - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - initial_state=ModelState(prompt=Prompt(text="Test prompt", enhance=False)), - ), - ) + await connection.connect( + url="wss://example", + local_track=MagicMock(), + timeout=1, + initial_prompt={"text": "hello", "enhance": True}, + ) - assert realtime_client is not None - mock_manager.connect.assert_called_once() - call_kwargs = mock_manager.connect.call_args[1] - assert "initial_prompt" in call_kwargs - assert call_kwargs["initial_prompt"] == {"text": "Test prompt", "enhance": False} + assert order == ["initial_prompt", "connect_room"] @pytest.mark.asyncio -async def test_image_to_base64_raw_base64_string(): - """Raw base64 string (not a URL, data URI, or file path) is returned as-is.""" - from decart.realtime.client import _image_to_base64 - - mock_session = MagicMock() - raw = "iVBORw0KGgoAAAANSUhEUgAAAAUA" - result = await _image_to_base64(raw, mock_session) - assert result == raw - +async def test_livekit_connection_mounts_local_track_before_passthrough_without_initial_state(): + from decart.realtime.livekit_connection import LiveKitConnection + from decart.realtime.messages import LiveKitRoomInfoMessage -@pytest.mark.asyncio -async def test_image_to_base64_path_object(tmp_path): - """pathlib.Path input is read and base64-encoded.""" - import base64 - from decart.realtime.client import _image_to_base64 + order = [] + room_info = LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", + ) - img = tmp_path / "test.png" - img.write_bytes(b"\x89PNG_FAKE_DATA") + async def connect_room(*_args): + order.append("connect_room") - mock_session = MagicMock() - result = await _image_to_base64(img, mock_session) - assert result == base64.b64encode(b"\x89PNG_FAKE_DATA").decode("utf-8") + async def send_passthrough(): + order.append("passthrough") + connection = LiveKitConnection() + connection._connect_signaling = AsyncMock() # type: ignore[method-assign] + connection._join_livekit_room = AsyncMock(return_value=room_info) # type: ignore[method-assign] + connection._connect_room = AsyncMock(side_effect=connect_room) # type: ignore[method-assign] + connection._send_passthrough_and_wait = AsyncMock(side_effect=send_passthrough) # type: ignore[method-assign] + connection._wait_until_connected = AsyncMock() # type: ignore[method-assign] -@pytest.mark.asyncio -async def test_image_to_base64_bytes(): - """bytes input is base64-encoded.""" - import base64 - from decart.realtime.client import _image_to_base64 + await connection.connect(url="wss://example", local_track=MagicMock(), timeout=1) - mock_session = MagicMock() - data = b"raw-image-bytes" - result = await _image_to_base64(data, mock_session) - assert result == base64.b64encode(data).decode("utf-8") + assert order == ["connect_room", "passthrough"] -@pytest.mark.asyncio -async def test_image_to_base64_data_uri(): - """data: URI has its base64 payload extracted.""" - from decart.realtime.client import _image_to_base64 +def test_livekit_connection_maps_reconnecting_state_before_connecting(): + from decart.realtime.livekit_connection import LiveKitConnection - mock_session = MagicMock() - uri = "data:image/png;base64,abc123payload" - result = await _image_to_base64(uri, mock_session) - assert result == "abc123payload" + assert LiveKitConnection()._map_room_state("reconnecting") == "reconnecting" @pytest.mark.asyncio -async def test_image_to_base64_file_path_string(tmp_path): - """String that is a valid local file path is read and base64-encoded.""" - import base64 - from decart.realtime.client import _image_to_base64 +async def test_livekit_connection_filters_remote_tracks_to_inference_video(): + from livekit import rtc - img = tmp_path / "avatar.png" - img.write_bytes(b"PNGDATA") + from decart.realtime.livekit_connection import LiveKitConnection + from decart.realtime.messages import LiveKitRoomInfoMessage - mock_session = MagicMock() - result = await _image_to_base64(str(img), mock_session) - assert result == base64.b64encode(b"PNGDATA").decode("utf-8") + handlers = {} + class FakeParticipant: + def __init__(self, identity): + self.identity = identity -# Tests for passthrough mode (no initial prompt/image) + class FakeLocalParticipant: + def __init__(self): + self.publish_track = AsyncMock() + class FakeRoom: + def __init__(self): + self.local_participant = FakeLocalParticipant() -@pytest.mark.asyncio -async def test_connect_without_initial_state_sends_passthrough(): - """Connecting without prompt/image sends passthrough set_image (null image + null prompt).""" - client = DecartClient(api_key="test-key") + def on(self, event): + def decorator(callback): + handlers[event] = callback + return callback - with ( - patch("decart.realtime.client.WebRTCManager") as mock_manager_class, - patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, - ): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager.is_connected = MagicMock(return_value=True) - mock_manager_class.return_value = mock_manager + return decorator - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session_cls.return_value = mock_session - - mock_track = MagicMock() + async def connect(self, _url, _token): + return None - from decart.realtime.types import RealtimeConnectOptions + remote_tracks = [] + connection = LiveKitConnection( + on_remote_stream=lambda track, _pub, _p: remote_tracks.append(track) + ) - realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=mock_track, - options=RealtimeConnectOptions( - model=models.realtime("lucy-restyle-2"), - on_remote_stream=lambda t: None, - # No initial_state — should trigger passthrough + with patch("decart.realtime.livekit_connection.rtc.Room", FakeRoom): + await connection._connect_room( + LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", ), + local_track=MagicMock(), ) - assert realtime_client is not None - mock_manager.connect.assert_called_once() - call_kwargs = mock_manager.connect.call_args[1] - # initial_image and initial_prompt should both be None - assert call_kwargs.get("initial_image") is None - assert call_kwargs.get("initial_prompt") is None - - -@pytest.mark.asyncio -async def test_passthrough_sends_set_image_with_null_prompt(): - """_send_passthrough_and_wait sends set_image with null image_data and null prompt.""" - from decart.realtime.webrtc_connection import WebRTCConnection - - connection = WebRTCConnection() - - sent_messages: list = [] + video_track = MagicMock() + video_track.kind = rtc.TrackKind.KIND_VIDEO + audio_track = MagicMock() + audio_track.kind = rtc.TrackKind.KIND_AUDIO - async def capture_send(message): - sent_messages.append(message) - # Simulate set_image_ack arriving immediately (like FakeWebSocket in JS tests) - if connection._pending_image_set: - event, result = connection._pending_image_set - result["success"] = True - event.set() + handlers["track_subscribed"](video_track, MagicMock(), FakeParticipant("viewer")) + handlers["track_subscribed"](audio_track, MagicMock(), FakeParticipant("inference-server-1")) + handlers["track_subscribed"](video_track, MagicMock(), FakeParticipant("inference-server-1")) - connection._send_message = capture_send # type: ignore[assignment] + assert remote_tracks == [video_track] - await connection._send_passthrough_and_wait() - assert len(sent_messages) == 1 - msg = sent_messages[0] - assert msg.type == "set_image" - assert msg.image_data is None - assert msg.prompt is None +@pytest.mark.asyncio +async def test_livekit_connection_publishes_local_track_with_preferred_codec(): + from livekit import rtc - # Verify JSON serialization includes null values - from decart.realtime.messages import message_to_json - import json + from decart.realtime.livekit_connection import LiveKitConnection + from decart.realtime.messages import LiveKitRoomInfoMessage - json_str = message_to_json(msg) - parsed = json.loads(json_str) - assert parsed == {"type": "set_image", "image_data": None, "prompt": None} + handlers = {} + class FakeLocalParticipant: + def __init__(self): + self.publish_track = AsyncMock() -@pytest.mark.asyncio -async def test_subscribe_mode_skips_passthrough(): - """Subscribe mode (null local_track) must not send passthrough set_image.""" - client = DecartClient(api_key="test-key") + class FakeRoom: + def __init__(self): + self.local_participant = FakeLocalParticipant() - with (patch("decart.realtime.client.WebRTCManager") as mock_manager_class,): - mock_manager = AsyncMock() - mock_manager.connect = AsyncMock(return_value=True) - mock_manager_class.return_value = mock_manager + def on(self, event): + def decorator(callback): + handlers[event] = callback + return callback - # subscribe() passes local_track=None internally - from decart.realtime.subscribe import encode_subscribe_token + return decorator - token = encode_subscribe_token("test-sid", "1.2.3.4", 8080) + async def connect(self, _url, _token): + return None - from decart.realtime.subscribe import SubscribeOptions + local_track = MagicMock() + connection = LiveKitConnection() - sub_client = await RealtimeClient.subscribe( - base_url=client.realtime_base_url, - api_key=client.api_key, - options=SubscribeOptions( - token=token, - on_remote_stream=lambda t: None, + with patch("decart.realtime.livekit_connection.rtc.Room", FakeRoom): + await connection._connect_room( + LiveKitRoomInfoMessage( + type="livekit_room_info", + livekit_url="wss://livekit.example", + token="lk-token", + room_name="room-123", + session_id="session-123", ), + local_track=local_track, + preferred_video_codec="vp9", ) - assert sub_client is not None - # Verify connect was called with local_track=None (subscribe mode) - mock_manager.connect.assert_called_once() - call_args = mock_manager.connect.call_args - assert call_args[0][0] is None # first positional arg is local_track=None - - -@pytest.mark.asyncio -async def test_server_error_during_passthrough_fails_fast(): - """Server error during passthrough surfaces real error instead of 30s timeout.""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.realtime.messages import ErrorMessage - from decart.errors import WebRTCError - - connection = WebRTCConnection() - - async def fake_send(message): - # Simulate the server responding with an error instead of set_image_ack - await asyncio.sleep(0) # yield so wait_for is listening - connection._handle_error(ErrorMessage(type="error", error="insufficient_credits")) - - connection._send_message = fake_send # type: ignore[assignment] - - with pytest.raises(WebRTCError, match="insufficient_credits"): - await connection._send_passthrough_and_wait() + room = connection.room + publish_options = room.local_participant.publish_track.call_args.args[1] + assert publish_options.video_codec == rtc.VideoCodec.VP9 @pytest.mark.asyncio -async def test_server_error_during_initial_image_fails_fast(): - """Server error during initial image setup surfaces real error (pre-existing fix).""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.realtime.messages import ErrorMessage - from decart.errors import WebRTCError +async def test_fetch_watch_stream_credentials_uses_http_base_and_api_key(): + from decart.realtime.client import _fetch_watch_stream_credentials - connection = WebRTCConnection() + class FakeResponse: + status = 200 + reason = "OK" - async def fake_send(message): - await asyncio.sleep(0) - connection._handle_error(ErrorMessage(type="error", error="invalid_image")) - - connection._send_message = fake_send # type: ignore[assignment] - - with pytest.raises(WebRTCError, match="invalid_image"): - await connection._send_initial_image_and_wait("base64data") - - -@pytest.mark.asyncio -async def test_server_error_during_initial_prompt_fails_fast(): - """Server error during initial prompt setup surfaces real error (pre-existing fix).""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.realtime.messages import ErrorMessage - from decart.errors import WebRTCError - - connection = WebRTCConnection() - - async def fake_send(message): - await asyncio.sleep(0) - connection._handle_error(ErrorMessage(type="error", error="rate_limited")) - - connection._send_message = fake_send # type: ignore[assignment] - - with pytest.raises(WebRTCError, match="rate_limited"): - await connection._send_initial_prompt_and_wait({"text": "test", "enhance": True}) - - -@pytest.mark.asyncio -async def test_server_error_survives_ws_disconnect_race(): - """Server error reaches the caller; receive-loop finally must not clobber it.""" - import json - import aiohttp - from decart.realtime.webrtc_connection import WebRTCConnection - - connection = WebRTCConnection() - - event, result = connection.register_image_set_wait() - - capacity_payload = json.dumps( - {"type": "error", "error": "Server at capacity. Please try again later."} - ) - - text_msg = MagicMock() - text_msg.type = aiohttp.WSMsgType.TEXT - text_msg.data = capacity_payload - - class FakeWS: - def __init__(self, messages): - self._messages = list(messages) - - def __aiter__(self): + async def __aenter__(self): return self - async def __anext__(self): - if self._messages: - return self._messages.pop(0) - raise StopAsyncIteration - - connection._ws = FakeWS([text_msg]) # type: ignore[assignment] - - await connection._receive_messages() - - assert event.is_set() - assert result["success"] is False - assert result["error"] == "Server at capacity. Please try again later." - assert connection._connection_error == "Server at capacity. Please try again later." - - -@pytest.mark.asyncio -async def test_connect_raises_immediately_on_connection_error_subscribe_mode(): - """Subscribe mode: server error aborts connect() immediately, not at timeout.""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.errors import WebRTCError - - connection = WebRTCConnection() - - connection._send_passthrough_and_wait = AsyncMock() # type: ignore[assignment] - connection._setup_peer_connection = AsyncMock() # type: ignore[assignment] - connection._create_and_send_offer = AsyncMock() # type: ignore[assignment] - - async def _noop_receive(): - await asyncio.sleep(60) - - connection._receive_messages = _noop_receive # type: ignore[assignment] - - fake_ws = MagicMock() - fake_ws.closed = False - fake_ws.close = AsyncMock() - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session.ws_connect = AsyncMock(return_value=fake_ws) - - async def inject_error_soon(): - await asyncio.sleep(0.15) - connection._connection_error = "Server at capacity. Please try again later." + async def __aexit__(self, *_args): + return None - injector = asyncio.create_task(inject_error_soon()) - - try: - with patch( - "decart.realtime.webrtc_connection.aiohttp.ClientSession", - return_value=mock_session, - ): - with pytest.raises(WebRTCError, match="Server at capacity"): - await connection.connect( - url="https://example.com/ws", - local_track=None, - timeout=10.0, - ) - finally: - injector.cancel() - - -@pytest.mark.asyncio -async def test_connect_does_not_double_wrap_webrtc_error(): - """WebRTCError raised inside connect() re-raises as-is — no nested cause, no duplicate on_error.""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.errors import WebRTCError - - errors: list[Exception] = [] - connection = WebRTCConnection(on_error=lambda e: errors.append(e)) - - connection._setup_peer_connection = AsyncMock() # type: ignore[assignment] - connection._create_and_send_offer = AsyncMock() # type: ignore[assignment] - - async def _noop_receive(): - await asyncio.sleep(60) - - connection._receive_messages = _noop_receive # type: ignore[assignment] - - fake_ws = MagicMock() - fake_ws.closed = False - fake_ws.close = AsyncMock() - - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session.ws_connect = AsyncMock(return_value=fake_ws) - - async def inject_error_soon(): - await asyncio.sleep(0.15) - # Simulate _handle_error having run: it sets _connection_error, fires - # on_error, and marks _on_error_fired so connect() doesn't double-fire. - connection._connection_error = "Server at capacity. Please try again later." - errors.append(WebRTCError("Server at capacity. Please try again later.")) - connection._on_error_fired = True - - injector = asyncio.create_task(inject_error_soon()) - - try: - with patch( - "decart.realtime.webrtc_connection.aiohttp.ClientSession", - return_value=mock_session, - ): - with pytest.raises(WebRTCError) as exc_info: - await connection.connect( - url="https://example.com/ws", - local_track=None, - timeout=10.0, - ) - finally: - injector.cancel() - - assert exc_info.value.message == "Server at capacity. Please try again later." - assert not isinstance(exc_info.value.cause, WebRTCError) - assert len(errors) == 1, ( - "connect()'s WebRTCError handler must not fire on_error again when _handle_error " - f"already did; got {errors!r}" - ) - - -@pytest.mark.asyncio -async def test_connect_direct_raise_fires_on_error_once(): - """Direct-raise WebRTCError paths (e.g. ack timeouts) must fire on_error exactly once.""" - from decart.realtime.webrtc_connection import WebRTCConnection - from decart.errors import WebRTCError - - errors: list[Exception] = [] - connection = WebRTCConnection(on_error=lambda e: errors.append(e)) - - async def _raise_ack_timeout(prompt, timeout=15.0): - raise WebRTCError("Initial prompt acknowledgment timed out") + async def text(self): + return json.dumps( + { + "livekit_url": "wss://livekit.example", + "token": "lk-token", + "room_name": "room-123", + } + ) - connection._send_initial_prompt_and_wait = _raise_ack_timeout # type: ignore[assignment] - connection._setup_peer_connection = AsyncMock() # type: ignore[assignment] - connection._create_and_send_offer = AsyncMock() # type: ignore[assignment] + class FakeSession: + def __init__(self): + self.post_calls = [] - async def _noop_receive(): - await asyncio.sleep(60) + async def __aenter__(self): + return self - connection._receive_messages = _noop_receive # type: ignore[assignment] + async def __aexit__(self, *_args): + return None - fake_ws = MagicMock() - fake_ws.closed = False - fake_ws.close = AsyncMock() + def post(self, url, headers): + self.post_calls.append((url, headers)) + return FakeResponse() - mock_session = MagicMock() - mock_session.closed = False - mock_session.close = AsyncMock() - mock_session.ws_connect = AsyncMock(return_value=fake_ws) + fake_session = FakeSession() - with patch( - "decart.realtime.webrtc_connection.aiohttp.ClientSession", - return_value=mock_session, - ): - with pytest.raises(WebRTCError) as exc_info: - await connection.connect( - url="https://example.com/ws", - local_track=None, - timeout=10.0, - initial_prompt={"text": "hello", "enhance": True}, - ) + with patch("decart.realtime.client.aiohttp.ClientSession", return_value=fake_session): + room_info = await _fetch_watch_stream_credentials( + base_url="wss://api3.decart.ai", + api_key="test-key", + room_name="room-123", + ) - assert exc_info.value.message == "Initial prompt acknowledgment timed out" - assert not isinstance(exc_info.value.cause, WebRTCError) - assert ( - len(errors) == 1 - ), f"on_error should fire exactly once for direct-raise paths; got {errors!r}" - assert errors[0] is exc_info.value + assert fake_session.post_calls == [ + ("https://api3.decart.ai/watch-stream/room-123", {"x-api-key": "test-key"}) + ] + assert room_info.livekit_url == "wss://livekit.example" + assert room_info.session_id == "room-123" diff --git a/uv.lock b/uv.lock index b5b8a4c..5bc5a69 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'darwin'", @@ -151,37 +151,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/0f/27e4fdde899e1e90e35eeff56b54ed63826435ad6cdb06b09ed312d1b3fa/aiohttp-3.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1d6aa90546a4e8f20c3500cb68ab14679cd91f927fa52970035fd3207dfb3da", size = 496721, upload-time = "2025-10-17T14:02:42.199Z" }, ] -[[package]] -name = "aioice" -version = "0.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "ifaddr" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/a2/45dfab1d5a7f96c48595a5770379acf406cdf02a2cd1ac1729b599322b08/aioice-0.10.1.tar.gz", hash = "sha256:5c8e1422103448d171925c678fb39795e5fe13d79108bebb00aa75a899c2094a", size = 44304, upload-time = "2025-04-13T08:15:25.629Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/58/af07dda649c22a1ae954ffb7aaaf4d4a57f1bf00ebdf62307affc0b8552f/aioice-0.10.1-py3-none-any.whl", hash = "sha256:f31ae2abc8608b1283ed5f21aebd7b6bd472b152ff9551e9b559b2d8efed79e9", size = 24872, upload-time = "2025-04-13T08:15:24.044Z" }, -] - -[[package]] -name = "aiortc" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aioice" }, - { name = "av" }, - { name = "cryptography" }, - { name = "google-crc32c" }, - { name = "pyee" }, - { name = "pylibsrtp" }, - { name = "pyopenssl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/9c/4e027bfe0195de0442da301e2389329496745d40ae44d2d7c4571c4290ce/aiortc-1.14.0.tar.gz", hash = "sha256:adc8a67ace10a085721e588e06a00358ed8eaf5f6b62f0a95358ff45628dd762", size = 1180864, upload-time = "2025-10-13T21:40:37.905Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/ab/31646a49209568cde3b97eeade0d28bb78b400e6645c56422c101df68932/aiortc-1.14.0-py3-none-any.whl", hash = "sha256:4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", size = 93183, upload-time = "2025-10-13T21:40:36.59Z" }, -] - [[package]] name = "aiosignal" version = "1.4.0" @@ -222,63 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] -[[package]] -name = "av" -version = "16.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/c3/fd72a0315bc6c943ced1105aaac6e0ec1be57c70d8a616bd05acaa21ffee/av-16.0.1.tar.gz", hash = "sha256:dd2ce779fa0b5f5889a6d9e00fbbbc39f58e247e52d31044272648fe16ff1dbf", size = 3904030, upload-time = "2025-10-13T12:28:51.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/3c/eefa29b7d0f5afdf7af9197bbecad8ec2ad06bcb5ac7e909c05a624b00a6/av-16.0.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:8b141aaa29a3afc96a1d467d106790782c1914628b57309eaadb8c10c299c9c0", size = 27206679, upload-time = "2025-10-13T12:24:41.145Z" }, - { url = "https://files.pythonhosted.org/packages/ac/89/a474feb07d5b94aa5af3771b0fe328056e2e0a840039b329f4fa2a1fd13a/av-16.0.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:4b8a08a59a5be0082af063d3f4b216e3950340121c6ea95b505a3f5f5cc8f21d", size = 21774556, upload-time = "2025-10-13T12:24:44.332Z" }, - { url = "https://files.pythonhosted.org/packages/be/e5/4361010dcac398bc224823e4b2a47803845e159af9f95164662c523770dc/av-16.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:792e7fc3c08eae005ff36486983966476e553cbb55aaeb0ec99adc4909377320", size = 38176763, upload-time = "2025-10-13T12:24:46.98Z" }, - { url = "https://files.pythonhosted.org/packages/d4/db/b27bdd20c9dc80de5b8792dae16dd6f4edf16408c0c7b28070c6228a8057/av-16.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:4e8ef5df76d8d0ee56139789f80bb90ad1a82a7e6df6e080e2e95c06fa22aea7", size = 39696277, upload-time = "2025-10-13T12:24:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c8/dd48e6a3ac1e922c141475a0dc30e2b6dfdef9751b3274829889a9281cce/av-16.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4f7a6985784a7464f078e419c71f5528c3e550ee5d605e7149b4a37a111eb136", size = 39576660, upload-time = "2025-10-13T12:24:55.773Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f0/223d047e2e60672a2fb5e51e28913de8d52195199f3e949cbfda1e6cd64b/av-16.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3f45c8d7b803b6faa2a25a26de5964a0a897de68298d9c9672c7af9d65d8b48a", size = 40752775, upload-time = "2025-10-13T12:25:00.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/73/73acad21c9203bc63d806e8baf42fe705eb5d36dafd1996b71ab5861a933/av-16.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:58e6faf1d9328d8cc6be14c5aadacb7d2965ed6d6ae1af32696993096543ff00", size = 32302328, upload-time = "2025-10-13T12:25:06.042Z" }, - { url = "https://files.pythonhosted.org/packages/49/d3/f2a483c5273fccd556dfa1fce14fab3b5d6d213b46e28e54e254465a2255/av-16.0.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e310d1fb42879df9bad2152a8db6d2ff8bf332c8c36349a09d62cc122f5070fb", size = 27191982, upload-time = "2025-10-13T12:25:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/e0/39/dff28bd252131b3befd09d8587992fe18c09d5125eaefc83a6434d5f56ff/av-16.0.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:2f4b357e5615457a84e6b6290916b22864b76b43d5079e1a73bc27581a5b9bac", size = 21760305, upload-time = "2025-10-13T12:25:14.882Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4d/2312d50a09c84a9b4269f7fea5de84f05dd2b7c7113dd961d31fad6c64c4/av-16.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:286665c77034c3a98080169b8b5586d5568a15da81fbcdaf8099252f2d232d7c", size = 38691616, upload-time = "2025-10-13T12:25:20.063Z" }, - { url = "https://files.pythonhosted.org/packages/15/9a/3d2d30b56252f998e53fced13720e2ce809c4db477110f944034e0fa4c9f/av-16.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f88de8e5b8ea29e41af4d8d61df108323d050ccfbc90f15b13ec1f99ce0e841e", size = 40216464, upload-time = "2025-10-13T12:25:24.848Z" }, - { url = "https://files.pythonhosted.org/packages/98/cb/3860054794a47715b4be0006105158c7119a57be58d9e8882b72e4d4e1dd/av-16.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0cdb71ebe4d1b241cf700f8f0c44a7d2a6602b921e16547dd68c0842113736e1", size = 40094077, upload-time = "2025-10-13T12:25:30.238Z" }, - { url = "https://files.pythonhosted.org/packages/41/58/79830fb8af0a89c015250f7864bbd427dff09c70575c97847055f8a302f7/av-16.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:28c27a65d40e8cf82b6db2543f8feeb8b56d36c1938f50773494cd3b073c7223", size = 41279948, upload-time = "2025-10-13T12:25:35.24Z" }, - { url = "https://files.pythonhosted.org/packages/83/79/6e1463b04382f379f857113b851cf5f9d580a2f7bd794211cd75352f4e04/av-16.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:ffea39ac7574f234f5168f9b9602e8d4ecdd81853238ec4d661001f03a6d3f64", size = 32297586, upload-time = "2025-10-13T12:25:39.826Z" }, - { url = "https://files.pythonhosted.org/packages/44/78/12a11d7a44fdd8b26a65e2efa1d8a5826733c8887a989a78306ec4785956/av-16.0.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:e41a8fef85dfb2c717349f9ff74f92f9560122a9f1a94b1c6c9a8a9c9462ba71", size = 27206375, upload-time = "2025-10-13T12:25:44.423Z" }, - { url = "https://files.pythonhosted.org/packages/27/19/3a4d3882852a0ee136121979ce46f6d2867b974eb217a2c9a070939f55ad/av-16.0.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6352a64b25c9f985d4f279c2902db9a92424e6f2c972161e67119616f0796cb9", size = 21752603, upload-time = "2025-10-13T12:25:49.122Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6e/f7abefba6e008e2f69bebb9a17ba38ce1df240c79b36a5b5fcacf8c8fcfd/av-16.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5201f7b4b5ed2128118cb90c2a6d64feedb0586ca7c783176896c78ffb4bbd5c", size = 38931978, upload-time = "2025-10-13T12:25:55.021Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7a/1305243ab47f724fdd99ddef7309a594e669af7f0e655e11bdd2c325dfae/av-16.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:daecc2072b82b6a942acbdaa9a2e00c05234c61fef976b22713983c020b07992", size = 40549383, upload-time = "2025-10-13T12:26:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/32/b2/357cc063185043eb757b4a48782bff780826103bcad1eb40c3ddfc050b7e/av-16.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6573da96e8bebc3536860a7def108d7dbe1875c86517072431ced702447e6aea", size = 40241993, upload-time = "2025-10-13T12:26:06.993Z" }, - { url = "https://files.pythonhosted.org/packages/20/bb/ced42a4588ba168bf0ef1e9d016982e3ba09fde6992f1dda586fd20dcf71/av-16.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4bc064e48a8de6c087b97dd27cf4ef8c13073f0793108fbce3ecd721201b2502", size = 41532235, upload-time = "2025-10-13T12:26:12.488Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/c7811eca0f318d5fd3212f7e8c3d8335f75a54907c97a89213dc580b8056/av-16.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0c669b6b6668c8ae74451c15ec6d6d8a36e4c3803dc5d9910f607a174dd18f17", size = 32296912, upload-time = "2025-10-13T12:26:19.187Z" }, - { url = "https://files.pythonhosted.org/packages/86/59/972f199ccc4f8c9e51f59e0f8962a09407396b3f6d11355e2c697ba555f9/av-16.0.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:4c61c6c120f5c5d95c711caf54e2c4a9fb2f1e613ac0a9c273d895f6b2602e44", size = 27170433, upload-time = "2025-10-13T12:26:24.673Z" }, - { url = "https://files.pythonhosted.org/packages/53/9d/0514cbc185fb20353ab25da54197fbd169a233e39efcbb26533c36a9dbb9/av-16.0.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ecc2e41320c69095f44aff93470a0d32c30892b2dbad0a08040441c81efa379", size = 21717654, upload-time = "2025-10-13T12:26:29.12Z" }, - { url = "https://files.pythonhosted.org/packages/32/8c/881409dd124b4e07d909d2b70568acb21126fc747656390840a2238651c9/av-16.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:036f0554d6faef3f4a94acaeb0cedd388e3ab96eb0eb5a14ec27c17369c466c9", size = 38651601, upload-time = "2025-10-13T12:26:33.919Z" }, - { url = "https://files.pythonhosted.org/packages/35/fd/867ba4cc3ab504442dc89b0c117e6a994fc62782eb634c8f31304586f93e/av-16.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:876415470a62e4a3550cc38db2fc0094c25e64eea34d7293b7454125d5958190", size = 40278604, upload-time = "2025-10-13T12:26:39.2Z" }, - { url = "https://files.pythonhosted.org/packages/b3/87/63cde866c0af09a1fa9727b4f40b34d71b0535785f5665c27894306f1fbc/av-16.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56902a06bd0828d13f13352874c370670882048267191ff5829534b611ba3956", size = 39984854, upload-time = "2025-10-13T12:26:44.581Z" }, - { url = "https://files.pythonhosted.org/packages/71/3b/8f40a708bff0e6b0f957836e2ef1f4d4429041cf8d99a415a77ead8ac8a3/av-16.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe988c2bf0fc2d952858f791f18377ea4ae4e19ba3504793799cd6c2a2562edf", size = 41270352, upload-time = "2025-10-13T12:26:50.817Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b5/c114292cb58a7269405ae13b7ba48c7d7bfeebbb2e4e66c8073c065a4430/av-16.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:708a66c248848029bf518f0482b81c5803846f1b597ef8013b19c014470b620f", size = 32273242, upload-time = "2025-10-13T12:26:55.788Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e9/a5b714bc078fdcca8b46c8a0b38484ae5c24cd81d9c1703d3e8ae2b57259/av-16.0.1-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:79a77ee452537030c21a0b41139bedaf16629636bf764b634e93b99c9d5f4558", size = 27248984, upload-time = "2025-10-13T12:27:00.564Z" }, - { url = "https://files.pythonhosted.org/packages/06/ef/ff777aaf1f88e3f6ce94aca4c5806a0c360e68d48f9d9f0214e42650f740/av-16.0.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:080823a6ff712f81e7089ae9756fb1512ca1742a138556a852ce50f58e457213", size = 21828098, upload-time = "2025-10-13T12:27:05.433Z" }, - { url = "https://files.pythonhosted.org/packages/34/d7/a484358d24a42bedde97f61f5d6ee568a7dd866d9df6e33731378db92d9e/av-16.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:04e00124afa8b46a850ed48951ddda61de874407fb8307d6a875bba659d5727e", size = 40051697, upload-time = "2025-10-13T12:27:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/73/87/6772d6080837da5d5c810a98a95bde6977e1f5a6e2e759e8c9292af9ec69/av-16.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:bc098c1c6dc4e7080629a7e9560e67bd4b5654951e17e5ddfd2b1515cfcd37db", size = 41352596, upload-time = "2025-10-13T12:27:16.217Z" }, - { url = "https://files.pythonhosted.org/packages/bd/58/fe448c60cf7f85640a0ed8936f16bac874846aa35e1baa521028949c1ea3/av-16.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ffd3559a72c46a76aa622630751a821499ba5a780b0047ecc75105d43a6b61", size = 41183156, upload-time = "2025-10-13T12:27:21.574Z" }, - { url = "https://files.pythonhosted.org/packages/85/c6/a039a0979d0c278e1bed6758d5a6186416c3ccb8081970df893fdf9a0d99/av-16.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7a3f1a36b550adadd7513f4f5ee956f9e06b01a88e59f3150ef5fec6879d6f79", size = 42302331, upload-time = "2025-10-13T12:27:26.953Z" }, - { url = "https://files.pythonhosted.org/packages/18/7b/2ca4a9e3609ff155436dac384e360f530919cb1e328491f7df294be0f0dc/av-16.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c6de794abe52b8c0be55d8bb09ade05905efa74b1a5ab4860b4b9c2bfb6578bf", size = 32462194, upload-time = "2025-10-13T12:27:32.942Z" }, - { url = "https://files.pythonhosted.org/packages/14/9a/6d17e379906cf53a7a44dfac9cf7e4b2e7df2082ba2dbf07126055effcc1/av-16.0.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:4b55ba69a943ae592ad7900da67129422954789de9dc384685d6b529925f542e", size = 27167101, upload-time = "2025-10-13T12:27:38.886Z" }, - { url = "https://files.pythonhosted.org/packages/6c/34/891816cd82d5646cb5a51d201d20be0a578232536d083b7d939734258067/av-16.0.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d4a0c47b6c9bbadad8909b82847f5fe64a608ad392f0b01704e427349bcd9a47", size = 21722708, upload-time = "2025-10-13T12:27:43.29Z" }, - { url = "https://files.pythonhosted.org/packages/1d/20/c24ad34038423ab8c9728cef3301e0861727c188442dcfd70a4a10834c63/av-16.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8bba52f3035708456f6b1994d10b0371b45cfd8f917b5e84ff81aef4ec2f08bf", size = 38638842, upload-time = "2025-10-13T12:27:49.776Z" }, - { url = "https://files.pythonhosted.org/packages/d7/32/034412309572ba3ad713079d07a3ffc13739263321aece54a3055d7a4f1f/av-16.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:08e34c7e7b5e55e29931180bbe21095e1874ac120992bf6b8615d39574487617", size = 40197789, upload-time = "2025-10-13T12:27:55.688Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/40496298c32f9094e7df28641c5c58aa6fb07554dc232a9ac98a9894376f/av-16.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0d6250ab9db80c641b299987027c987f14935ea837ea4c02c5f5182f6b69d9e5", size = 39980829, upload-time = "2025-10-13T12:28:01.507Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7e/5c38268ac1d424f309b13b2de4597ad28daea6039ee5af061e62918b12a8/av-16.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7b621f28d8bcbb07cdcd7b18943ddc040739ad304545715ae733873b6e1b739d", size = 41205928, upload-time = "2025-10-13T12:28:08.431Z" }, - { url = "https://files.pythonhosted.org/packages/e3/07/3176e02692d8753a6c4606021c60e4031341afb56292178eee633b6760a4/av-16.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:92101f49082392580c9dba4ba2fe5b931b3bb0fb75a1a848bfb9a11ded68be91", size = 32272836, upload-time = "2025-10-13T12:28:13.405Z" }, - { url = "https://files.pythonhosted.org/packages/8a/47/10e03b88de097385d1550cbb6d8de96159131705c13adb92bd9b7e677425/av-16.0.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:07c464bf2bc362a154eccc82e235ef64fd3aaf8d76fc8ed63d0ae520943c6d3f", size = 27248864, upload-time = "2025-10-13T12:28:17.467Z" }, - { url = "https://files.pythonhosted.org/packages/b1/60/7447f206bec3e55e81371f1989098baa2fe9adb7b46c149e6937b7e7c1ca/av-16.0.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:750da0673864b669c95882c7b25768cd93ece0e47010d74ebcc29dbb14d611f8", size = 21828185, upload-time = "2025-10-13T12:28:21.461Z" }, - { url = "https://files.pythonhosted.org/packages/68/48/ee2680e7a01bc4911bbe902b814346911fa2528697a44f3043ee68e0f07e/av-16.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0b7c0d060863b2e341d07cd26851cb9057b7979814148b028fb7ee5d5eb8772d", size = 40040572, upload-time = "2025-10-13T12:28:26.585Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/2c43d28871721ae07cde432d6e36ae2f7035197cbadb43764cc5bf3d4b33/av-16.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e67c2eca6023ca7d76b0709c5f392b23a5defba499f4c262411f8155b1482cbd", size = 41344288, upload-time = "2025-10-13T12:28:32.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7f/1d801bff43ae1af4758c45eee2eaae64f303bbb460e79f352f08587fd179/av-16.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3243d54d84986e8fbdc1946db634b0c41fe69b6de35a99fa8b763e18503d040", size = 41175142, upload-time = "2025-10-13T12:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/e4/06/bb363138687066bbf8997c1433dbd9c81762bae120955ea431fb72d69d26/av-16.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bcf73efab5379601e6510abd7afe5f397d0f6defe69b1610c2f37a4a17996b", size = 42293932, upload-time = "2025-10-13T12:28:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/92/15/5e713098a085f970ccf88550194d277d244464d7b3a7365ad92acb4b6dc1/av-16.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6368d4ff153d75469d2a3217bc403630dc870a72fe0a014d9135de550d731a86", size = 32460624, upload-time = "2025-10-13T12:28:48.767Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -323,88 +235,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "click" version = "8.3.0" @@ -530,74 +360,9 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, -] - [[package]] name = "decart" -version = "0.0.38" +version = "0.0.39" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -619,8 +384,7 @@ dev = [ { name = "ruff" }, ] realtime = [ - { name = "aiortc" }, - { name = "av" }, + { name = "livekit" }, { name = "tenacity" }, ] @@ -638,9 +402,8 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = ">=23.0.0" }, { name = "aiohttp", specifier = ">=3.9.0" }, - { name = "aiortc", marker = "extra == 'realtime'", specifier = ">=1.9.0" }, - { name = "av", marker = "extra == 'realtime'", specifier = ">=12.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "livekit", marker = "extra == 'realtime'", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "numpy", specifier = ">=2.0.2" }, { name = "opencv-python", specifier = ">=4.11.0.86" }, @@ -664,15 +427,6 @@ dev = [ { name = "ruff", specifier = ">=0.1.0" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.0" @@ -806,41 +560,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "google-crc32c" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ae/87802e6d9f9d69adfaedfcfd599266bf386a54d0be058b532d04c794f76d/google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472", size = 14495, upload-time = "2025-03-26T14:29:13.32Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/69/b1b05cf415df0d86691d6a8b4b7e60ab3a6fb6efb783ee5cd3ed1382bfd3/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76", size = 30467, upload-time = "2025-03-26T14:31:11.92Z" }, - { url = "https://files.pythonhosted.org/packages/44/3d/92f8928ecd671bd5b071756596971c79d252d09b835cdca5a44177fa87aa/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d", size = 30311, upload-time = "2025-03-26T14:53:14.161Z" }, - { url = "https://files.pythonhosted.org/packages/33/42/c2d15a73df79d45ed6b430b9e801d0bd8e28ac139a9012d7d58af50a385d/google_crc32c-1.7.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c", size = 37889, upload-time = "2025-03-26T14:41:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/57/ea/ac59c86a3c694afd117bb669bde32aaf17d0de4305d01d706495f09cbf19/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb", size = 33028, upload-time = "2025-03-26T14:41:29.141Z" }, - { url = "https://files.pythonhosted.org/packages/60/44/87e77e8476767a4a93f6cf271157c6d948eacec63688c093580af13b04be/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603", size = 38026, upload-time = "2025-03-26T14:41:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/21ac7bb305cd7c1a6de9c52f71db0868e104a5b573a4977cd9d0ff830f82/google_crc32c-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a", size = 33476, upload-time = "2025-03-26T14:29:09.086Z" }, - { url = "https://files.pythonhosted.org/packages/f7/94/220139ea87822b6fdfdab4fb9ba81b3fff7ea2c82e2af34adc726085bffc/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06", size = 30468, upload-time = "2025-03-26T14:32:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/789b23bdeeb9d15dc2904660463ad539d0318286d7633fe2760c10ed0c1c/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9", size = 30313, upload-time = "2025-03-26T14:57:38.758Z" }, - { url = "https://files.pythonhosted.org/packages/81/b8/976a2b843610c211e7ccb3e248996a61e87dbb2c09b1499847e295080aec/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77", size = 33048, upload-time = "2025-03-26T14:41:30.679Z" }, - { url = "https://files.pythonhosted.org/packages/c9/16/a3842c2cf591093b111d4a5e2bfb478ac6692d02f1b386d2a33283a19dc9/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53", size = 32669, upload-time = "2025-03-26T14:41:31.432Z" }, - { url = "https://files.pythonhosted.org/packages/04/17/ed9aba495916fcf5fe4ecb2267ceb851fc5f273c4e4625ae453350cfd564/google_crc32c-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d", size = 33476, upload-time = "2025-03-26T14:29:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b7/787e2453cf8639c94b3d06c9d61f512234a82e1d12d13d18584bd3049904/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194", size = 30470, upload-time = "2025-03-26T14:34:31.655Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b4/6042c2b0cbac3ec3a69bb4c49b28d2f517b7a0f4a0232603c42c58e22b44/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e", size = 30315, upload-time = "2025-03-26T15:01:54.634Z" }, - { url = "https://files.pythonhosted.org/packages/29/ad/01e7a61a5d059bc57b702d9ff6a18b2585ad97f720bd0a0dbe215df1ab0e/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337", size = 33180, upload-time = "2025-03-26T14:41:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65", size = 32794, upload-time = "2025-03-26T14:41:33.264Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d6/77060dbd140c624e42ae3ece3df53b9d811000729a5c821b9fd671ceaac6/google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6", size = 33477, upload-time = "2025-03-26T14:29:10.94Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/b8d785e9184ba6297a8620c8a37cf6e39b81a8ca01bb0796d7cbb28b3386/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35", size = 30467, upload-time = "2025-03-26T14:36:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/5f18076968212067c4e8ea95bf3b69669f9fc698476e5f5eb97d5b37999f/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638", size = 30309, upload-time = "2025-03-26T15:06:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/92/83/9228fe65bf70e93e419f38bdf6c5ca5083fc6d32886ee79b450ceefd1dbd/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb", size = 33133, upload-time = "2025-03-26T14:41:34.388Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/1ea2fd13ff9f8955b85e7956872fdb7050c4ace8a2306a6d177edb9cf7fe/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6", size = 32773, upload-time = "2025-03-26T14:41:35.19Z" }, - { url = "https://files.pythonhosted.org/packages/89/32/a22a281806e3ef21b72db16f948cad22ec68e4bdd384139291e00ff82fe2/google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db", size = 33475, upload-time = "2025-03-26T14:29:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c5/002975aff514e57fc084ba155697a049b3f9b52225ec3bc0f542871dd524/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3", size = 33243, upload-time = "2025-03-26T14:41:35.975Z" }, - { url = "https://files.pythonhosted.org/packages/61/cb/c585282a03a0cea70fcaa1bf55d5d702d0f2351094d663ec3be1c6c67c52/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9", size = 32870, upload-time = "2025-03-26T14:41:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/31e57ce04530794917dfe25243860ec141de9fadf4aa9783dffe7dac7c39/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589", size = 28242, upload-time = "2025-03-26T14:41:42.858Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f3/8b84cd4e0ad111e63e30eb89453f8dd308e3ad36f42305cf8c202461cdf0/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b", size = 28049, upload-time = "2025-03-26T14:41:44.651Z" }, - { url = "https://files.pythonhosted.org/packages/16/1b/1693372bf423ada422f80fd88260dbfd140754adb15cbc4d7e9a68b1cb8e/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48", size = 28241, upload-time = "2025-03-26T14:41:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3c/2a19a60a473de48717b4efb19398c3f914795b64a96cf3fbe82588044f78/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82", size = 28048, upload-time = "2025-03-26T14:41:46.696Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -851,21 +570,32 @@ wheels = [ ] [[package]] -name = "ifaddr" -version = "0.2.0" +name = "iniconfig" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] -name = "iniconfig" -version = "2.3.0" +name = "livekit" +version = "1.1.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +dependencies = [ + { name = "aiofiles" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/14/3197e09850aa65d0f0df90a8b7c24994e130e92403f935de1f2ae3ae387e/livekit-1.1.9.tar.gz", hash = "sha256:62d288c222208e76433cc6eabc00eb2927ecafaace511d90820889fd5597a52e", size = 353630, upload-time = "2026-05-28T14:34:18.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/7f16535b70ce4261d94ed2eb6cb6905029c580faaf5772e13286e2c1feaf/livekit-1.1.9-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:e472b6fc4ad796d4ca95ccc2c926fbfdf4c8a1b1f3e1d5bcc7bce54db4c893d5", size = 10064439, upload-time = "2026-05-28T14:34:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/ef/83/6fbf21f88723765d43154b5656d6ac5efdbad0e6e76c0da01f1ae4558669/livekit-1.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18dc082ab6e6c620026bbca9a942068d1ff055460afd9909249babaac5ad6f04", size = 8895549, upload-time = "2026-05-28T14:34:10.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/0ada8ac4ffa418ce7145b9bf4e0ed0585f54acfeab54df7d6c1e5395cbf2/livekit-1.1.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:975f2f77e3d089e0a5d0fc84590e8a6b98a8c8e0eda449a86edd789b58a1f6e0", size = 9899965, upload-time = "2026-05-28T14:34:12.656Z" }, + { url = "https://files.pythonhosted.org/packages/cd/34/bc5dbb70b86c8a1ee1016af64cfd2902a46c70bc927642bc04070f361e2f/livekit-1.1.9-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:34302a874cce94488ecc49daf4389117b16c48f7f151a7bdb48603b550219570", size = 11280510, upload-time = "2026-05-28T14:34:14.966Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/465ae11edd75439311c021f6c9310f7c35cd347893fcc56f659658aadcaa/livekit-1.1.9-py3-none-win_amd64.whl", hash = "sha256:c31d2a3406bc75cf5edf4b457c9d54c665ebaae64e08800b03de622acd478381", size = 10637454, upload-time = "2026-05-28T14:34:17.003Z" }, ] [[package]] @@ -1385,12 +1115,18 @@ wheels = [ ] [[package]] -name = "pycparser" -version = "2.23" +name = "protobuf" +version = "7.35.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, ] [[package]] @@ -1522,18 +1258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] -[[package]] -name = "pyee" -version = "13.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1543,41 +1267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pylibsrtp" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, - { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, - { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, - { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, - { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, - { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, -] - -[[package]] -name = "pyopenssl" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, -] - [[package]] name = "pytest" version = "8.4.2" @@ -1717,6 +1406,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "types-protobuf" +version = "7.34.1.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/59/e2b13b499d15e6720150c4b1a8d91e31fcacf716b432397475b3151ff7e4/types_protobuf-7.34.1.20260518.tar.gz", hash = "sha256:28cfaded25889cb83ebfb63cfb0a43628f0b6f3785767bec17287dc6468795f2", size = 68936, upload-time = "2026-05-18T06:01:47.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/1f/ec5caf72c2e3b688ca3927e0979a04ddad19e1afc4bf1c199bd743e0f419/types_protobuf-7.34.1.20260518-py3-none-any.whl", hash = "sha256:a0a5337413347166439c0e07cbc26c6164d091401c6f01b1dfd8cdb966c4dd8f", size = 85992, upload-time = "2026-05-18T06:01:45.696Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"