diff --git a/benchmarks/media.py b/benchmarks/media.py index 0d78dab..0a186ba 100644 --- a/benchmarks/media.py +++ b/benchmarks/media.py @@ -204,7 +204,9 @@ async def run(self) -> VideoResult: # enough for the size, so the encoder doesn't drop frames for bitrate bitrate = int(self.width * self.height * self.fps * 0.2) remote = await _remote_track(caller, callee, generator.track, max_bitrate=bitrate) - processor = webrtc.MediaStreamTrackProcessor(remote, max_buffer_size=self.max_buffer_size) + processor = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(remote, max_buffer_size=self.max_buffer_size) + ) sampling = asyncio.ensure_future(run.sample_rss(self.rss_every)) if self.rss_every else None lag = await run.phases.measure( run.result, processor, write=lambda: run.write(generator), read=lambda: run.read(processor) @@ -243,13 +245,18 @@ async def write(self, generator: webrtc.VideoTrackGenerator) -> None: if self.phases.measuring.is_set(): self.result.sent += 1 await writer.write( - webrtc.VideoFrame(frame, format='I420', coded_width=width, coded_height=height, timestamp=number) + webrtc.VideoFrame( + frame, + webrtc.VideoFrameBufferInit( + format='I420', coded_width=width, coded_height=height, timestamp=number + ), + ) ) number += 1 await asyncio.sleep(max(0.0, start + number / fps - loop.time())) async def read(self, processor: webrtc.MediaStreamTrackProcessor) -> None: - header = {'rect': {'x': 0, 'y': 0, 'width': BITS * BLOCK, 'height': BLOCK}} + header = webrtc.VideoFrameCopyToOptions(rect=webrtc.DOMRectInit(x=0, y=0, width=BITS * BLOCK, height=BLOCK)) loop = asyncio.get_running_loop() async for frame in processor.readable: now = loop.time() @@ -310,7 +317,7 @@ async def audio_loopback(channels: int = 2, seconds: float = 60, warmup: float = async with _connection() as (caller, callee): generator = webrtc.MediaStreamTrackGenerator('audio') remote = await _remote_track(caller, callee, generator) - processor = webrtc.MediaStreamTrackProcessor(remote, max_buffer_size=50) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(remote, max_buffer_size=50)) async def write() -> None: writer = generator.writable.get_writer() @@ -319,12 +326,14 @@ async def write() -> None: while not phases.done.is_set(): await writer.write( webrtc.AudioData( - format='s16', - sample_rate=48000, - number_of_frames=480, - number_of_channels=channels, - timestamp=written * 10_000, - data=chunk, + webrtc.AudioDataInit( + format='s16', + sample_rate=48000, + number_of_frames=480, + number_of_channels=channels, + timestamp=written * 10_000, + data=chunk, + ) ) ) written += 1 @@ -370,10 +379,11 @@ async def copy_costs( for width, height in sizes: chroma = (width // 2) * (height // 2) frame = webrtc.VideoFrame( - bytes(width * height + 2 * chroma), format='I420', coded_width=width, coded_height=height, timestamp=0 + bytes(width * height + 2 * chroma), + webrtc.VideoFrameBufferInit(format='I420', coded_width=width, coded_height=height, timestamp=0), ) for format in formats: - options = {'format': format} + options = webrtc.VideoFrameCopyToOptions(format=format) destination = bytearray(frame.allocation_size(options)) runs = 0 start = time.perf_counter() @@ -391,6 +401,8 @@ def construct_cost(width: int, height: int, budget: float = 1.0) -> float: runs = 0 start = time.perf_counter() while time.perf_counter() - start < budget: - webrtc.VideoFrame(data, format='I420', coded_width=width, coded_height=height, timestamp=0).close() + webrtc.VideoFrame( + data, webrtc.VideoFrameBufferInit(format='I420', coded_width=width, coded_height=height, timestamp=0) + ).close() runs += 1 return (time.perf_counter() - start) / runs * 1000 diff --git a/docs/source/media.rst b/docs/source/media.rst index 575095f..13a2e80 100644 --- a/docs/source/media.rst +++ b/docs/source/media.rst @@ -22,9 +22,10 @@ Receiving @pc.on('track') async def on_track(event): - async for frame in webrtc.MediaStreamTrackProcessor(event.track).readable: - rgba = bytearray(frame.allocation_size({'format': 'RGBA'})) - await frame.copy_to(rgba, {'format': 'RGBA'}) + rgba_options = webrtc.VideoFrameCopyToOptions(format='RGBA') + async for frame in webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(event.track)).readable: + rgba = bytearray(frame.allocation_size(rgba_options)) + await frame.copy_to(rgba, rgba_options) frame.close() Sending @@ -35,13 +36,14 @@ Sending generator = webrtc.VideoTrackGenerator() pc.add_track(generator.track) writer = generator.writable.get_writer() - await writer.write(webrtc.VideoFrame(i420, format='I420', coded_width=640, coded_height=480, timestamp=0)) + init = webrtc.VideoFrameBufferInit(format='I420', coded_width=640, coded_height=480, timestamp=0) + await writer.write(webrtc.VideoFrame(i420, init)) microphone = webrtc.MediaStreamTrackGenerator('audio') pc.add_track(microphone) await microphone.writable.get_writer().write( - webrtc.AudioData(format='s16', sample_rate=48000, number_of_frames=480, number_of_channels=1, - timestamp=0, data=pcm) + webrtc.AudioData(webrtc.AudioDataInit(format='s16', sample_rate=48000, number_of_frames=480, + number_of_channels=1, timestamp=0, data=pcm)) ) Transforming @@ -49,6 +51,6 @@ Transforming .. code-block:: python - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) generator = webrtc.VideoTrackGenerator() await processor.readable.pipe_through(webrtc.TransformStream(transformer)).pipe_to(generator.writable) diff --git a/docs/source/webrtc.models.dictionary.rst b/docs/source/webrtc.models.dictionary.rst new file mode 100644 index 0000000..3ab0456 --- /dev/null +++ b/docs/source/webrtc.models.dictionary.rst @@ -0,0 +1,7 @@ +webrtc.models.dictionary +======================== + +.. automodule:: webrtc.models.dictionary + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.models.rst b/docs/source/webrtc.models.rst index 80d8c14..5bc076f 100644 --- a/docs/source/webrtc.models.rst +++ b/docs/source/webrtc.models.rst @@ -14,15 +14,16 @@ Submodules webrtc.models.audio_data webrtc.models.blob + webrtc.models.dictionary webrtc.models.events webrtc.models.media_track_constraints webrtc.models.rtc_certificate webrtc.models.rtc_configuration webrtc.models.rtc_ice_candidate + webrtc.models.rtc_rtp_transceiver_init webrtc.models.rtc_session_description webrtc.models.rtc_session_description_init webrtc.models.rtc_stats webrtc.models.rtp_parameters webrtc.models.rtp_source - webrtc.models.rtp_transceiver_init webrtc.models.video_frame \ No newline at end of file diff --git a/docs/source/webrtc.models.rtc_rtp_transceiver_init.rst b/docs/source/webrtc.models.rtc_rtp_transceiver_init.rst new file mode 100644 index 0000000..c73377a --- /dev/null +++ b/docs/source/webrtc.models.rtc_rtp_transceiver_init.rst @@ -0,0 +1,7 @@ +webrtc.models.rtc\_rtp\_transceiver\_init +======================================== + +.. automodule:: webrtc.models.rtc_rtp_transceiver_init + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.models.rtp_transceiver_init.rst b/docs/source/webrtc.models.rtp_transceiver_init.rst deleted file mode 100644 index 1956c53..0000000 --- a/docs/source/webrtc.models.rtp_transceiver_init.rst +++ /dev/null @@ -1,7 +0,0 @@ -webrtc.models.rtp\_transceiver\_init -==================================== - -.. automodule:: webrtc.models.rtp_transceiver_init - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/examples/echo.py b/examples/echo.py index d00b201..183a108 100755 --- a/examples/echo.py +++ b/examples/echo.py @@ -24,17 +24,17 @@ async def grayscale(frame: webrtc.VideoFrame, controller: webrtc.TransformStreamDefaultController) -> None: """Transforms an I420 frame: U and V at 128 leave only the luma.""" - data = bytearray(frame.allocation_size({'format': 'I420'})) - await frame.copy_to(data, {'format': 'I420'}) + i420 = webrtc.VideoFrameCopyToOptions(format='I420') + data = bytearray(frame.allocation_size(i420)) + await frame.copy_to(data, i420) luma = frame.coded_width * frame.coded_height data[luma:] = b'\x80' * (len(data) - luma) controller.enqueue( webrtc.VideoFrame( data, - format='I420', - coded_width=frame.coded_width, - coded_height=frame.coded_height, - timestamp=frame.timestamp, + webrtc.VideoFrameBufferInit( + format='I420', coded_width=frame.coded_width, coded_height=frame.coded_height, timestamp=frame.timestamp + ), ) ) frame.close() @@ -42,14 +42,15 @@ async def grayscale(frame: webrtc.VideoFrame, controller: webrtc.TransformStream async def watch(track: webrtc.MediaStreamTrack) -> None: """Reads the echoed frames for a while, then prints whether the last one is gray.""" - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() loop = asyncio.get_running_loop() end = loop.time() + SECONDS frames = 0 while loop.time() < end: frame = (await reader.read()).value - rgba = bytearray(frame.allocation_size({'format': 'RGBA'})) - await frame.copy_to(rgba, {'format': 'RGBA'}) + options = webrtc.VideoFrameCopyToOptions(format='RGBA') + rgba = bytearray(frame.allocation_size(options)) + await frame.copy_to(rgba, options) frame.close() frames += 1 red, green, blue = rgba[0:3] @@ -91,7 +92,7 @@ async def main() -> None: @echo.on('track') def on_echo_track(event: webrtc.RTCTrackEvent) -> None: - readable = webrtc.MediaStreamTrackProcessor(event.track).readable + readable = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(event.track)).readable pipe = readable.pipe_through(webrtc.TransformStream({'transform': grayscale})).pipe_to(generator.writable) pipes.append(asyncio.ensure_future(pipe)) diff --git a/examples/janus_streaming.py b/examples/janus_streaming.py index 8fba539..f35a074 100755 --- a/examples/janus_streaming.py +++ b/examples/janus_streaming.py @@ -129,10 +129,13 @@ def fullscreen() -> Iterator[None]: async def watch(track: webrtc.MediaStreamTrack) -> None: """Draws the frames of the video track until it ends.""" # a buffer of one frame drops the frames the terminal is too slow for - async for frame in webrtc.MediaStreamTrackProcessor(track, max_buffer_size=1).readable: + async for frame in webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=1) + ).readable: with frame: - rgbx = bytearray(frame.allocation_size({'format': 'RGBX'})) - await frame.copy_to(rgbx, {'format': 'RGBX'}) + options = webrtc.VideoFrameCopyToOptions(format='RGBX') + rgbx = bytearray(frame.allocation_size(options)) + await frame.copy_to(rgbx, options) size = frame.visible_rect draw(rgbx, int(size.width), int(size.height)) @@ -141,9 +144,11 @@ async def listen(track: webrtc.MediaStreamTrack) -> None: """Plays the audio track until it ends.""" speakers = None try: - async for data in webrtc.MediaStreamTrackProcessor(track, max_buffer_size=50).readable: + async for data in webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=50) + ).readable: with data: - options = {'plane_index': 0, 'format': 's16'} + options = webrtc.AudioDataCopyToOptions(plane_index=0, format='s16') samples = bytearray(data.allocation_size(options)) data.copy_to(samples, options) speakers = speakers or Speakers(int(data.sample_rate), data.number_of_channels) @@ -157,7 +162,7 @@ async def answer(pc: webrtc.RTCPeerConnection, offer: dict[str, str]) -> dict[st """Answers with all the ICE candidates in the SDP, since there is no trickling.""" gathered = asyncio.Event() pc.on('icegatheringstatechange', lambda _: pc.ice_gathering_state == 'complete' and gathered.set()) - await pc.set_remote_description(offer) + await pc.set_remote_description(webrtc.RTCSessionDescriptionInit.from_json(offer)) await pc.set_local_description(await pc.create_answer()) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(gathered.wait(), 5) diff --git a/examples/openai_live.py b/examples/openai_live.py index b408677..87a0523 100755 --- a/examples/openai_live.py +++ b/examples/openai_live.py @@ -238,7 +238,7 @@ async def run(self, hang_up: asyncio.Event) -> None: console.info(f'Creating a {args.model} session...') answer = await self._create_session(self.pc.local_description.sdp) - await self.pc.set_remote_description({'type': 'answer', 'sdp': answer}) + await self.pc.set_remote_description(webrtc.RTCSessionDescriptionInit('answer', answer)) self.microphone.start() self.tasks.append(asyncio.ensure_future(self._send_microphone(generator.writable.get_writer()))) @@ -312,12 +312,14 @@ async def _send_microphone(self, writer: webrtc.WritableStreamDefaultWriter) -> ) guarded = not self.args.barge_in and time.monotonic() - self.assistant_spoke_at < ECHO_TAIL data = webrtc.AudioData( - format='s16', - sample_rate=SAMPLE_RATE, - number_of_frames=FRAME, - number_of_channels=1, - timestamp=timestamp, - data=silence if guarded else chunk, + webrtc.AudioDataInit( + format='s16', + sample_rate=SAMPLE_RATE, + number_of_frames=FRAME, + number_of_channels=1, + timestamp=timestamp, + data=silence if guarded else chunk, + ) ) await writer.write(data) timestamp += 10_000 @@ -325,10 +327,13 @@ async def _send_microphone(self, writer: webrtc.WritableStreamDefaultWriter) -> async def _play(self, track: webrtc.MediaStreamTrack) -> None: """Plays the assistant's audio.""" heard = False - async for data in webrtc.MediaStreamTrackProcessor(track, max_buffer_size=50).readable: + async for data in webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=50) + ).readable: with data: - samples = bytearray(data.allocation_size({'plane_index': 0, 'format': 's16'})) - data.copy_to(samples, {'plane_index': 0, 'format': 's16'}) + options = webrtc.AudioDataCopyToOptions(plane_index=0, format='s16') + samples = bytearray(data.allocation_size(options)) + data.copy_to(samples, options) rate, channels = int(data.sample_rate), data.number_of_channels if peak(samples) > VOICE_LEVEL: self.assistant_spoke_at = time.monotonic() diff --git a/examples/recorder.py b/examples/recorder.py index d3440a5..02c7fc5 100755 --- a/examples/recorder.py +++ b/examples/recorder.py @@ -30,10 +30,13 @@ async def record(track: webrtc.MediaStreamTrack, file: BinaryIO) -> None: """Writes the frames of a track to a file until the track ends.""" frames = 0 with file: - async for media in webrtc.MediaStreamTrackProcessor(track, max_buffer_size=30).readable: + async for media in webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=30) + ).readable: if track.kind == 'audio': - data = bytearray(media.allocation_size({'plane_index': 0})) - media.copy_to(data, {'plane_index': 0}) + options = webrtc.AudioDataCopyToOptions(plane_index=0) + data = bytearray(media.allocation_size(options)) + media.copy_to(data, options) else: data = bytearray(media.allocation_size()) await media.copy_to(data) diff --git a/examples/telegram_group_calls.py b/examples/telegram_group_calls.py index 5374663..83624f1 100755 --- a/examples/telegram_group_calls.py +++ b/examples/telegram_group_calls.py @@ -133,12 +133,14 @@ async def send_audio_data(generator: webrtc.MediaStreamTrackGenerator, file: Bin frames = len(data) // 4 await writer.write( webrtc.AudioData( - format='s16', - sample_rate=48000, - number_of_frames=frames, - number_of_channels=2, - timestamp=chunks * 10_000, - data=data[: frames * 4], + webrtc.AudioDataInit( + format='s16', + sample_rate=48000, + number_of_frames=frames, + number_of_channels=2, + timestamp=chunks * 10_000, + data=data[: frames * 4], + ) ) ) chunks += 1 diff --git a/python-webrtc/python/webrtc/__init__.py b/python-webrtc/python/webrtc/__init__.py index 61adc6d..8d6ea0b 100644 --- a/python-webrtc/python/webrtc/__init__.py +++ b/python-webrtc/python/webrtc/__init__.py @@ -80,20 +80,28 @@ ) # the order matters: modules import each other through the package namespace -from .models.rtc_session_description_init import RTCSessionDescriptionInit +from .models.rtc_session_description_init import RTCSessionDescriptionInit, RTCLocalSessionDescriptionInit from .models.rtc_session_description import RTCSessionDescription from .models.media_track_constraints import ( ULongRange, DoubleRange, + ConstrainULongRange, + ConstrainDoubleRange, + ConstrainBooleanParameters, + ConstrainDOMStringParameters, + ConstrainBooleanOrDOMStringParameters, MediaTrackSettings, MediaTrackCapabilities, + MediaTrackConstraintSet, MediaTrackConstraints, ) from .models.blob import Blob from .models.video_frame import ( DOMRectReadOnly, + DOMRectInit, PlaneLayout, VideoColorSpace, + VideoColorSpaceInit, VideoFrameMetadata, VideoFrameBufferInit, VideoFrameInit, @@ -123,10 +131,16 @@ RTCRtpHeaderExtensionCapability, RTCRtpCapabilities, ) -from .models.rtp_transceiver_init import RtpTransceiverInit +from .models.rtc_rtp_transceiver_init import RTCRtpTransceiverInit from .models.rtc_stats import RTCStats, RTCStatsReport from .models.rtp_source import RTCRtpContributingSource, RTCRtpSynchronizationSource -from .models.rtc_certificate import RTCCertificate, RTCDtlsFingerprint +from .models.rtc_certificate import ( + Algorithm, + EcKeyGenParams, + RsaHashedKeyGenParams, + RTCCertificate, + RTCDtlsFingerprint, +) from .models.rtc_configuration import ( RTCConfiguration, RTCIceServer, @@ -134,6 +148,7 @@ ) from .models.rtc_ice_candidate import ( RTCIceCandidate, + RTCIceCandidateInit, RTCIceCandidatePair, RTCIceParameters, ) @@ -160,6 +175,7 @@ __all__ = [ + 'Algorithm', 'AlphaOption', 'AudioData', 'AudioDataCopyToOptions', @@ -167,10 +183,17 @@ 'AudioSampleFormat', 'BinaryType', 'Blob', + 'ConstrainBooleanOrDOMStringParameters', + 'ConstrainBooleanParameters', + 'ConstrainDOMStringParameters', + 'ConstrainDoubleRange', + 'ConstrainULongRange', 'CricketIceGatheringState', + 'DOMRectInit', 'DOMRectReadOnly', 'DoubleRange', 'DtlsTransportState', + 'EcKeyGenParams', 'Event', 'EventTarget', 'InvalidAccessError', @@ -189,6 +212,7 @@ 'MediaStreamTrackProcessorInit', 'MediaStreamTrackState', 'MediaTrackCapabilities', + 'MediaTrackConstraintSet', 'MediaTrackConstraints', 'MediaTrackSettings', 'MediaType', @@ -218,6 +242,7 @@ 'RTCErrorInit', 'RTCException', 'RTCIceCandidate', + 'RTCIceCandidateInit', 'RTCIceCandidatePair', 'RTCIceCandidateType', 'RTCIceComponent', @@ -232,6 +257,7 @@ 'RTCIceTransport', 'RTCIceTransportPolicy', 'RTCIceTransportState', + 'RTCLocalSessionDescriptionInit', 'RTCOAuthCredential', 'RTCPeerConnection', 'RTCPeerConnectionIceErrorEvent', @@ -254,6 +280,7 @@ 'RTCRtpSender', 'RTCRtpSynchronizationSource', 'RTCRtpTransceiver', + 'RTCRtpTransceiverInit', 'RTCSctpTransport', 'RTCSdpType', 'RTCSessionDescription', @@ -266,7 +293,7 @@ 'ReadableStreamDefaultController', 'ReadableStreamDefaultReader', 'ReadableStreamReadResult', - 'RtpTransceiverInit', + 'RsaHashedKeyGenParams', 'SctpTransportState', 'SdpParseException', 'TransceiverDirection', @@ -275,6 +302,7 @@ 'ULongRange', 'VideoColorPrimaries', 'VideoColorSpace', + 'VideoColorSpaceInit', 'VideoFrame', 'VideoFrameBufferInit', 'VideoFrameCopyToOptions', diff --git a/python-webrtc/python/webrtc/exceptions.py b/python-webrtc/python/webrtc/exceptions.py index 8e9eb9a..8772a1c 100644 --- a/python-webrtc/python/webrtc/exceptions.py +++ b/python-webrtc/python/webrtc/exceptions.py @@ -9,14 +9,12 @@ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import TYPE_CHECKING, Any, ClassVar +from dataclasses import dataclass +from typing import ClassVar from webrtc import RTCErrorDetailType, wrtc -from webrtc.utils.names import Alias, alias, members - -if TYPE_CHECKING: - from collections.abc import Mapping +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias PythonWebRTCExceptionBase = wrtc.PythonWebRTCExceptionBase PythonWebRTCException = wrtc.PythonWebRTCException @@ -77,7 +75,7 @@ def __init__(self, constraint: str, message: str = '') -> None: @dataclass -class RTCErrorInit: +class RTCErrorInit(Dictionary): """The WebRTC-specific information of an :obj:`RTCError`. Args: @@ -120,21 +118,11 @@ class RTCError(OperationError): """An error carrying WebRTC-specific information, the members of its :obj:`RTCErrorInit`. Args: - options (:obj:`RTCErrorInit` or :obj:`dict`): The WebRTC-specific information, or a dictionary of its - members. + init (:obj:`RTCErrorInit`): The WebRTC-specific information. message (:obj:`str`, optional): A description of the error. - - Raises: - TypeError: If a dictionary has no ``error_detail``. - ValueError: If ``error_detail`` isn't a member of :obj:`RTCErrorDetailType`. """ - def __init__(self, options: RTCErrorInit | Mapping[str, Any], message: str = '') -> None: - init = ( - options - if isinstance(options, RTCErrorInit) - else RTCErrorInit(**members(options, [field.name for field in fields(RTCErrorInit)])) - ) + def __init__(self, init: RTCErrorInit, message: str = '') -> None: super().__init__(message) self.message = message self.error_detail = init.error_detail diff --git a/python-webrtc/python/webrtc/functions/get_user_media.py b/python-webrtc/python/webrtc/functions/get_user_media.py index f907032..df19090 100644 --- a/python-webrtc/python/webrtc/functions/get_user_media.py +++ b/python-webrtc/python/webrtc/functions/get_user_media.py @@ -9,26 +9,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from webrtc import MediaStream, MediaTrackConstraints, MediaTrackSettings, OverconstrainedError, wrtc from webrtc.interfaces.media_stream_track import _CAMERA_CAPABILITIES, _check_numbers, _selected, _unsatisfied if TYPE_CHECKING: import webrtc - - -#: A value, or a constraint on it: a :obj:`dict` with any of ``exact``, ``ideal``, ``min`` and ``max`` -Constrain = Union[float, dict[str, float]] + from webrtc.models.media_track_constraints import ConstrainDouble, ConstrainULong def get_user_media( *, audio: bool = True, video: bool = False, - width: Constrain | None = None, - height: Constrain | None = None, - frame_rate: Constrain | None = None, + width: ConstrainULong | None = None, + height: ConstrainULong | None = None, + frame_rate: ConstrainDouble | None = None, ) -> webrtc.MediaStream: """Returns a stream of local media, as requested: a synthetic microphone and/or camera. @@ -39,11 +36,12 @@ def get_user_media( Args: audio (:obj:`bool`, optional): Whether the stream has an audio track. video (:obj:`bool`, optional): Whether the stream has a video track. - width (:obj:`int` | :obj:`dict`, optional): The width of the video, or a constraint on it (see - :obj:`Constrain`), 640 by default. - height (:obj:`int` | :obj:`dict`, optional): The height of the video, or a constraint on it, 480 by default. - frame_rate (:obj:`float` | :obj:`dict`, optional): The frames per second of the video, or a constraint on - it, 30 by default. + width (:obj:`int` or :obj:`webrtc.ConstrainULongRange`, optional): The width of the video, or a constraint + on it, 640 by default. + height (:obj:`int` or :obj:`webrtc.ConstrainULongRange`, optional): The height of the video, or a constraint + on it, 480 by default. + frame_rate (:obj:`float` or :obj:`webrtc.ConstrainDoubleRange`, optional): The frames per second of the + video, or a constraint on it, 30 by default. Returns: :obj:`webrtc.MediaStream`: The stream. diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track.py b/python-webrtc/python/webrtc/interfaces/media_stream_track.py index 0a1bc7e..666f1c8 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track.py @@ -11,12 +11,18 @@ import asyncio import math -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union from webrtc import ( + ConstrainBooleanOrDOMStringParameters, + ConstrainBooleanParameters, + ConstrainDOMStringParameters, + ConstrainDoubleRange, + ConstrainULongRange, DoubleRange, MediaTrackCapabilities, MediaTrackConstraints, + MediaTrackConstraintSet, MediaTrackSettings, OverconstrainedError, ULongRange, @@ -28,6 +34,14 @@ if TYPE_CHECKING: import webrtc +_Parameters = Union[ + ConstrainULongRange, + ConstrainDoubleRange, + ConstrainBooleanParameters, + ConstrainDOMStringParameters, + ConstrainBooleanOrDOMStringParameters, +] + #: The device of the video tracks of :func:`webrtc.get_user_media` CAMERA_DEVICE_ID = 'synthetic-camera' #: The device of the audio tracks of :func:`webrtc.get_user_media` @@ -71,6 +85,17 @@ ) +# the constraints with required parts, the others are ideal values +_PARAMETERS = ( + ConstrainULongRange, + ConstrainDoubleRange, + ConstrainBooleanParameters, + ConstrainDOMStringParameters, + ConstrainBooleanOrDOMStringParameters, +) +_RANGES = (ConstrainULongRange, ConstrainDoubleRange) + + def _satisfied(value: object, capability: object, current: float | str | None) -> bool: """Whether the required parts of a constraint (exact, min, max) are satisfiable. @@ -79,17 +104,20 @@ def _satisfied(value: object, capability: object, current: float | str | None) - Returns: :obj:`bool`: Whether they are. """ - if not isinstance(value, dict) or all(value.get(key) is None for key in ('exact', 'min', 'max')): + required = isinstance(value, _PARAMETERS) and any( + getattr(value, name, None) is not None for name in ('exact', 'min', 'max') + ) + if not required: return True - if isinstance(capability, (ULongRange, DoubleRange)): + if isinstance(value, _RANGES) and isinstance(capability, (ULongRange, DoubleRange)): return _within_range(value, capability) if capability is None: return _satisfied_by_setting(value, current) - return _matches(value.get('exact'), capability) + return _matches(value.exact, capability) -def _within_range(value: dict[str, float], capability: ULongRange | DoubleRange) -> bool: - exact, low, high = value.get('exact'), value.get('min'), value.get('max') +def _within_range(value: ConstrainULongRange | ConstrainDoubleRange, capability: ULongRange | DoubleRange) -> bool: + exact, low, high = value.exact, value.min, value.max low_cap = capability.min if capability.min is not None else float('-inf') high_cap = capability.max if capability.max is not None else float('inf') exact_within = exact is None or low_cap <= exact <= high_cap @@ -106,14 +134,16 @@ def _matches(exact: object, capability: object) -> bool: return exact == capability or (isinstance(exact, list) and capability in exact) -def _satisfied_by_setting(value: dict[str, float], current: float | str | None) -> bool: - if current is None or not _matches(value.get('exact'), current): +def _satisfied_by_setting(value: _Parameters, current: float | str | None) -> bool: + if current is None or not _matches(value.exact, current): return False - low, high = value.get('min'), value.get('max') + low, high = getattr(value, 'min', None), getattr(value, 'max', None) return (low is None or low <= current) and (high is None or current <= high) -def _selected(value: float | dict[str, float] | None, current: float, capability: object = None) -> float: +def _selected( + value: float | ConstrainULongRange | ConstrainDoubleRange | None, current: float, capability: object = None +) -> float: """The value a constraint selects (exact, ideal or current), the nearest within its range and the capability.""" low, high = float('-inf'), float('inf') if isinstance(capability, (ULongRange, DoubleRange)): @@ -121,14 +151,14 @@ def _selected(value: float | dict[str, float] | None, current: float, capability high = capability.max if capability.max is not None else high if value is None: selected = current - elif not isinstance(value, dict): + elif not isinstance(value, _RANGES): selected = value - elif value.get('exact') is not None: - selected = value['exact'] + elif value.exact is not None: + selected = value.exact else: - low = max(low, value['min']) if value.get('min') is not None else low - high = min(high, value['max']) if value.get('max') is not None else high - selected = value['ideal'] if value.get('ideal') is not None else current + low = max(low, value.min) if value.min is not None else low + high = min(high, value.max) if value.max is not None else high + selected = value.ideal if value.ideal is not None else current return min(max(selected, low), high) @@ -137,11 +167,11 @@ def _selected(value: float | dict[str, float] | None, current: float, capability _DOUBLE_CONSTRAINTS = ('aspect_ratio', 'frame_rate') -def _check_numbers(constraint_set: MediaTrackConstraints) -> None: +def _check_numbers(constraint_set: MediaTrackConstraintSet) -> None: """The WebIDL types of the numbers of a constraint set: finite, and not negative for unsigned longs.""" for name in _ULONG_CONSTRAINTS + _DOUBLE_CONSTRAINTS: value = getattr(constraint_set, name) - members = [value.get(key) for key in ('exact', 'ideal', 'min', 'max')] if isinstance(value, dict) else [value] + members = [value.exact, value.ideal, value.min, value.max] if isinstance(value, _RANGES) else [value] unsigned = name in _ULONG_CONSTRAINTS for member in members: if member is not None and not _valid_number(member, unsigned=unsigned): @@ -157,7 +187,7 @@ def _valid_number(member: object, *, unsigned: bool) -> bool: def _unsatisfied( - constraint_set: MediaTrackConstraints, capabilities: MediaTrackCapabilities, settings: MediaTrackSettings + constraint_set: MediaTrackConstraintSet, capabilities: MediaTrackCapabilities, settings: MediaTrackSettings ) -> str | None: """The name of the first constraint of the set that can't be satisfied, if any.""" for name in _CONSTRAINABLE: @@ -291,17 +321,14 @@ def get_constraints(self) -> MediaTrackConstraints: constraints = self._native_obj._constraints return constraints if constraints is not None else MediaTrackConstraints() - def apply_constraints( - self, constraints: MediaTrackConstraints | dict[str, Any] | None = None - ) -> asyncio.Future[None]: + def apply_constraints(self, constraints: MediaTrackConstraints | None = None) -> asyncio.Future[None]: """Applies constraints to the track. The synthetic camera of :func:`webrtc.get_user_media` changes its size and frame rate, the source of other tracks stays as it is. Args: - constraints (:obj:`webrtc.MediaTrackConstraints` or :obj:`dict`, optional): The constraints, none to - remove them. + constraints (:obj:`webrtc.MediaTrackConstraints`, optional): The constraints, none to remove them. Returns: :obj:`asyncio.Future`: Done once applied, failed with :obj:`webrtc.OverconstrainedError` if a required @@ -309,14 +336,14 @@ def apply_constraints( """ future = asyncio.get_running_loop().create_future() try: - self._apply_constraints(MediaTrackConstraints._parse(constraints)) + self._apply_constraints(constraints if constraints is not None else MediaTrackConstraints()) future.set_result(None) except (OverconstrainedError, TypeError) as e: future.set_exception(e) return future def _apply_constraints(self, constraints: MediaTrackConstraints) -> None: - advanced = [MediaTrackConstraints._parse(constraint_set) for constraint_set in constraints.advanced or ()] + advanced = list(constraints.advanced or ()) for constraint_set in [constraints, *advanced]: _check_numbers(constraint_set) if self.ready_state == 'ended': diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py b/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py index 7a93484..97599a0 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from webrtc import AudioData, MediaStreamTrack, MediaType, VideoFrame, WebRTCObject, wrtc +from webrtc.models.dictionary import Dictionary from webrtc.streams import ReadableStream from webrtc.utils.events import EventTarget from webrtc.utils.names import Alias, alias @@ -28,7 +29,7 @@ @dataclass -class MediaStreamTrackProcessorInit: +class MediaStreamTrackProcessorInit(Dictionary): """How to create a :obj:`MediaStreamTrackProcessor`. Args: @@ -43,30 +44,6 @@ class MediaStreamTrackProcessorInit: maxBufferSize: ClassVar[Alias[int | None]] = alias('max_buffer_size') -def _parse_init( - track: object, max_buffer_size: int | None, options: dict[str, object] -) -> tuple[MediaStreamTrack, int | None]: - """The track and the buffer size, from the init, its dict form, or the arguments.""" - if isinstance(track, MediaStreamTrackProcessorInit): - track, max_buffer_size = track.track, track.max_buffer_size - elif isinstance(track, dict): - init = dict(track) - track = init.pop('track', None) - max_buffer_size = init.pop('max_buffer_size', init.pop('maxBufferSize', max_buffer_size)) - if init: - msg = f'MediaStreamTrackProcessorInit has no member {next(iter(init))!r}' - raise TypeError(msg) - if 'maxBufferSize' in options: - max_buffer_size = options.pop('maxBufferSize') - if options: - msg = f'Unexpected arguments: {", ".join(options)}' - raise TypeError(msg) - if not isinstance(track, MediaStreamTrack): - msg = f'track must be a MediaStreamTrack, not {type(track).__name__}' - raise TypeError(msg) - return track, max_buffer_size - - class _TrackSource: """The underlying source of :attr:`MediaStreamTrackProcessor.readable`. @@ -118,17 +95,15 @@ class MediaStreamTrackProcessor(WebRTCObject[wrtc.MediaStreamTrackProcessor], Ev when the track ends. Frames read are to be closed once used. Args: - track (:obj:`webrtc.MediaStreamTrack` or :obj:`MediaStreamTrackProcessorInit`): The track to read, or the - init with it. A dictionary of the init's members is taken too. - max_buffer_size (:obj:`int`, optional): How many items are queued: 1 frame of video by default, 10 chunks - of audio. + init (:obj:`MediaStreamTrackProcessorInit`): The track to read, and how many items are queued: 1 frame of + video by default, 10 chunks of audio. Raises: - TypeError: If the track isn't a :obj:`webrtc.MediaStreamTrack`, or the size isn't from 0 to 65535. + TypeError: If the size isn't an integer from 0 to 65535. Example:: - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) async for frame in processor.readable: ... frame.close() @@ -136,13 +111,8 @@ class MediaStreamTrackProcessor(WebRTCObject[wrtc.MediaStreamTrackProcessor], Ev _class = wrtc.MediaStreamTrackProcessor - def __init__( - self, - track: MediaStreamTrack | MediaStreamTrackProcessorInit | dict[str, Any] | None = None, - max_buffer_size: int | None = None, - **options: object, - ) -> None: - track, max_buffer_size = _parse_init(track, max_buffer_size, options) + def __init__(self, init: MediaStreamTrackProcessorInit) -> None: + track, max_buffer_size = init.track, init.max_buffer_size video = track.kind == MediaType.video if max_buffer_size is None: max_buffer_size = DEFAULT_VIDEO_BUFFER_SIZE if video else DEFAULT_AUDIO_BUFFER_SIZE diff --git a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py index 1350ca9..09f58f5 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py @@ -22,6 +22,7 @@ WebRTCObject, wrtc, ) +from webrtc.models.dictionary import Dictionary from webrtc.utils.events import EventTarget from webrtc.utils.names import Alias, alias @@ -44,7 +45,7 @@ def check_utf8_length(name: str, value: str) -> None: @dataclass -class RTCDataChannelInit: +class RTCDataChannelInit(Dictionary): """How :meth:`webrtc.RTCPeerConnection.create_data_channel` creates a channel. Args: diff --git a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py index bf37c94..6f5c22e 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py @@ -11,7 +11,7 @@ import re import weakref -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from webrtc import ( CricketIceGatheringState, @@ -110,14 +110,13 @@ def _check_open(self, operation: str) -> None: def gather( self, gather_policy: webrtc.RTCIceTransportPolicy | str = 'all', - ice_servers: Sequence[webrtc.RTCIceServer | dict[str, Any]] | None = None, + ice_servers: Sequence[webrtc.RTCIceServer] | None = None, ) -> None: """Gathers the candidates of a standalone transport, sent in ``icecandidate`` events. Args: gather_policy (:obj:`webrtc.RTCIceTransportPolicy`, optional): All candidates, or only relay ones. ice_servers (:obj:`list` of :obj:`webrtc.RTCIceServer`, optional): STUN and TURN servers to gather with. - A :obj:`dict` of the arguments of :obj:`webrtc.RTCIceServer` is accepted too. Raises: webrtc.InvalidStateError: If it's stopped, gathering already, or belongs to a connection. @@ -133,7 +132,7 @@ def gather( def start( self, - remote_parameters: webrtc.RTCIceParameters | dict[str, str], + remote_parameters: webrtc.RTCIceParameters, role: webrtc.RTCIceRole | str = 'controlled', ) -> None: """Starts connecting a standalone transport to the remote agent, with the candidates added, or later. @@ -142,7 +141,7 @@ def start( Args: remote_parameters (:obj:`webrtc.RTCIceParameters`): The username fragment and the password of the - remote agent. A :obj:`dict` of the arguments of :obj:`webrtc.RTCIceParameters` is accepted too. + remote agent. role (:obj:`webrtc.RTCIceRole`, optional): Controlling or controlled (the default). When both agents take the same role, one of them switches. @@ -152,8 +151,6 @@ def start( ValueError: If the role is neither controlling nor controlled. """ self._check_open('start') - if isinstance(remote_parameters, dict): - remote_parameters = RTCIceParameters(**remote_parameters) if not _UFRAG.fullmatch(remote_parameters.username_fragment): msg = f'{remote_parameters.username_fragment!r} is not a valid ICE username fragment' raise InvalidSyntaxError(msg) @@ -165,12 +162,11 @@ def start( raise ValueError(msg) self._native_obj.start(remote_parameters.username_fragment, remote_parameters.password, role) - def add_remote_candidate(self, candidate: webrtc.RTCIceCandidate | dict[str, Any]) -> None: + def add_remote_candidate(self, candidate: webrtc.RTCIceCandidate | webrtc.RTCIceCandidateInit) -> None: """Adds a candidate of the remote agent to a standalone transport. Args: - candidate (:obj:`webrtc.RTCIceCandidate`): The candidate, or its JSON form - (see :meth:`webrtc.RTCIceCandidate.to_json`). + candidate (:obj:`webrtc.RTCIceCandidate` or :obj:`webrtc.RTCIceCandidateInit`): The candidate. Raises: TypeError: If the candidate has neither ``sdp_mid`` nor ``sdp_m_line_index``. @@ -179,7 +175,7 @@ def add_remote_candidate(self, candidate: webrtc.RTCIceCandidate | dict[str, Any """ self._check_open('add a remote candidate') if not isinstance(candidate, RTCIceCandidate): - candidate = RTCIceCandidate.from_json(candidate) + candidate = RTCIceCandidate(*RTCIceCandidate._members_of(candidate)) self._native_obj.addRemoteCandidate( candidate.candidate, candidate.sdp_mid or '', candidate.sdp_m_line_index or 0, candidate.username_fragment ) diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index 74a590a..b470442 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -10,9 +10,8 @@ from __future__ import annotations import asyncio -import dataclasses import re -from typing import TYPE_CHECKING, Any, ClassVar, Union +from typing import TYPE_CHECKING, ClassVar, Union import webrtc from webrtc import ( @@ -25,24 +24,23 @@ RTCConfiguration, RTCDataChannelEvent, RTCIceCandidate, + RTCLocalSessionDescriptionInit, RTCPeerConnectionIceErrorEvent, RTCPeerConnectionIceEvent, RTCRtpCodec, - RTCRtpEncodingParameters, + RTCRtpTransceiverInit, RTCSdpType, RTCSessionDescription, RTCSessionDescriptionInit, RTCSignalingState, RTCStatsReport, RTCTrackEvent, - RtpTransceiverInit, TransceiverDirection, WebRTCObject, wrtc, ) from webrtc.interfaces.rtc_data_channel import RTCDataChannelInit, check_utf8_length from webrtc.utils.events import EventTarget -from webrtc.utils.names import members from webrtc.utils.native_calls import call_native from webrtc.utils.operations import OperationsChain, later from webrtc.utils.task_queue import TaskQueue @@ -53,7 +51,7 @@ from typing_extensions import Self #: A description, as the methods that set one take it -_Description = Union[RTCSessionDescription, RTCSessionDescriptionInit, dict[str, Any]] +_Description = Union[RTCSessionDescription, RTCSessionDescriptionInit] # the signaling states a local description of a type can be set in _LOCAL_DESCRIPTION_STATES = { @@ -276,7 +274,7 @@ def _apply_legacy_offer_option(self, kind: webrtc.MediaType, *, receive: bool | elif transceiver.direction == directions.recvonly: transceiver.direction = directions.inactive elif not any(t.direction in {directions.sendrecv, directions.recvonly} for t in transceivers): - self.add_transceiver(kind, RtpTransceiverInit(direction=directions.recvonly)) + self.add_transceiver(kind, RTCRtpTransceiverInit(direction=directions.recvonly)) def _completed_description(self) -> None: """The success task of setting a description.""" @@ -345,7 +343,9 @@ async def create_answer(self, *, voice_activity_detection: bool = True) -> webrt await later() return _init_of(await call_native(self._native_obj.createAnswer, voice_activity_detection)) - async def set_local_description(self, description: _Description | None = None) -> None: + async def set_local_description( + self, description: _Description | RTCLocalSessionDescriptionInit | None = None + ) -> None: """Changes the local description associated with the connection. This description specifies the properties of the local end of the connection, including the media format. @@ -353,8 +353,8 @@ async def set_local_description(self, description: _Description | None = None) - Args: description (:obj:`webrtc.RTCSessionDescription`, optional): The description, as returned by :meth:`create_offer` or :meth:`create_answer`, or a ``rollback`` one. An - :obj:`webrtc.RTCSessionDescriptionInit` or a :obj:`dict` with ``type`` and ``sdp`` keys is accepted - too. Without it, or with an empty ``sdp``, the offer or the answer the signaling state calls for + :obj:`webrtc.RTCSessionDescriptionInit` or :obj:`webrtc.RTCLocalSessionDescriptionInit` is accepted + too. Without it, or without a type and an SDP, the offer or the answer the signaling state calls for is created and set. Raises: @@ -380,8 +380,8 @@ async def set_remote_description(self, description: _Description) -> None: Args: description (:obj:`webrtc.RTCSessionDescription`): The description received from the remote peer. - An :obj:`webrtc.RTCSessionDescriptionInit` or a :obj:`dict` with ``type`` and ``sdp`` keys - is accepted too. + An :obj:`webrtc.RTCSessionDescriptionInit` is accepted too, like one from + :meth:`webrtc.RTCSessionDescriptionInit.from_json`. Raises: webrtc.InvalidStateError: If the type doesn't match the signaling state, or the connection is closed. @@ -425,7 +425,7 @@ def add_track( def add_transceiver( self, track_or_kind: webrtc.MediaStreamTrack | webrtc.MediaType, - init: webrtc.RtpTransceiverInit | dict[str, Any] | None = None, + init: webrtc.RTCRtpTransceiverInit | None = None, ) -> webrtc.RTCRtpTransceiver: """Creates a new :obj:`webrtc.RTCRtpTransceiver` and adds it to the transceivers of the connection. @@ -437,9 +437,8 @@ def add_transceiver( :obj:`webrtc.MediaStreamTrack` to associate with the transceiver, or :attr:`webrtc.MediaType.audio` or :attr:`webrtc.MediaType.video` (or its value), which is used as the kind of the receiver's track, and by extension of the :obj:`webrtc.RTCRtpReceiver` itself. - init (:obj:`webrtc.RtpTransceiverInit` or :obj:`dict`, optional): An object for specifying any options - when creating the new transceiver, or a dictionary of its members (the encodings may be dictionaries - too). It isn't changed. + init (:obj:`webrtc.RTCRtpTransceiverInit`, optional): The options of the new transceiver. It isn't + changed. Returns: :obj:`webrtc.RTCRtpTransceiver`: The new transceiver. @@ -455,13 +454,9 @@ def add_transceiver( msg = f'{kind!r} is not a kind of track' raise TypeError(msg) native_init = None - if isinstance(init, dict): - init = _transceiver_init(init) if init is not None: _check_send_encodings(init.send_encodings, kind) - # a copy, with the encodings for the kind - encodings = [encoding._for_kind(kind) for encoding in init.send_encodings] - native_init = RtpTransceiverInit(init.direction, encodings, init.streams)._native_obj + native_init = init._to_native([encoding._for_kind(kind) for encoding in init.send_encodings]) if isinstance(track_or_kind, webrtc.MediaStreamTrack): transceiver = self._native_obj.addTransceiver(track_or_kind._native_obj, native_init) @@ -526,13 +521,15 @@ def remove_track(self, sender: webrtc.RTCRtpSender) -> None: """ self._native_obj.removeTrack(sender._native_obj) - async def add_ice_candidate(self, candidate: webrtc.RTCIceCandidate | dict[str, Any] | None = None) -> None: + async def add_ice_candidate( + self, candidate: webrtc.RTCIceCandidateInit | webrtc.RTCIceCandidate | None = None + ) -> None: """Adds a candidate received from the remote peer to the remote description. Args: - candidate (:obj:`webrtc.RTCIceCandidate` or :obj:`dict`, optional): The candidate, or its JSON form - (see :meth:`webrtc.RTCIceCandidate.to_json`). A candidate with an empty - :attr:`webrtc.RTCIceCandidate.candidate`, or :obj:`None`, means the end of candidates. + candidate (:obj:`webrtc.RTCIceCandidateInit` or :obj:`webrtc.RTCIceCandidate`, optional): The + candidate, like one from :meth:`webrtc.RTCIceCandidateInit.from_json`. An empty ``candidate``, + or :obj:`None`, means the end of candidates. Raises: TypeError: If a non-empty candidate has neither ``sdp_mid`` nor ``sdp_m_line_index``. @@ -556,7 +553,7 @@ async def add_ice_candidate(self, candidate: webrtc.RTCIceCandidate | dict[str, await call_native(self._native_obj.addIceCandidate, candidate_str, sdp_mid, sdp_m_line_index, ufrag) def create_data_channel( - self, label: str, options: webrtc.RTCDataChannelInit | dict[str, Any] | None = None + self, label: str, options: webrtc.RTCDataChannelInit | None = None ) -> webrtc.RTCDataChannel: """Creates a channel to send messages to the remote peer, negotiated with the next offer. @@ -564,8 +561,7 @@ def create_data_channel( Args: label (:obj:`str`): The name of the channel, up to 65535 bytes in UTF-8. - options (:obj:`webrtc.RTCDataChannelInit` or :obj:`dict`, optional): How to create the channel, or - a dictionary of its members. + options (:obj:`webrtc.RTCDataChannelInit`, optional): How to create the channel. Returns: :obj:`webrtc.RTCDataChannel`: The channel. @@ -576,11 +572,7 @@ def create_data_channel( webrtc.InvalidStateError: If the connection is closed. webrtc.OperationError: If the ``id`` is in use, or no id is left. """ - if isinstance(options, dict): - options = RTCDataChannelInit( - **members(options, [field.name for field in dataclasses.fields(RTCDataChannelInit)]) - ) - init = options or RTCDataChannelInit() + init = options if options is not None else RTCDataChannelInit() check_utf8_length('label', label) init._check() native = self._native_obj.createDataChannel( @@ -624,14 +616,14 @@ async def get_stats(self, selector: webrtc.MediaStreamTrack | None = None) -> we @staticmethod async def generate_certificate( - algorithm: webrtc.models.rtc_certificate.Algorithm = 'ECDSA', expires: float | None = None + algorithm: webrtc.models.rtc_certificate.AlgorithmIdentifier = 'ECDSA', expires: float | None = None ) -> webrtc.RTCCertificate: """Generates a certificate for :attr:`webrtc.RTCConfiguration.certificates`. The same as :meth:`webrtc.RTCCertificate.generate`. Args: - algorithm (:obj:`str` or :obj:`dict`, optional): The WebCrypto algorithm of the key. + algorithm (:obj:`str` or :obj:`webrtc.Algorithm`, optional): The WebCrypto algorithm of the key. expires (:obj:`float`, optional): In how many milliseconds the certificate expires. Returns: @@ -670,7 +662,9 @@ def set_configuration(self, configuration: webrtc.RTCConfiguration | None = None ValueError: If a member of the configuration is out of range. TypeError: If a member of the configuration has a wrong type, or a value its enum doesn't have. """ - self._native_obj.setConfiguration((configuration or RTCConfiguration())._to_native()) + if configuration is None: + configuration = RTCConfiguration() + self._native_obj.setConfiguration(configuration._to_native()) def restart_ice(self) -> None: """Allows to easily request that ICE candidate gathering be redone on both ends of the connection. @@ -812,22 +806,20 @@ def ice_gathering_state(self) -> webrtc.RTCIceGatheringState: def _description_init( - description: _Description | None, *, allow_implicit: bool + description: _Description | RTCLocalSessionDescriptionInit | None, *, allow_implicit: bool ) -> wrtc.RTCSessionDescriptionInit | None: """The native RTCSessionDescriptionInit of a description, or :obj:`None` for an implicit one.""" - if description is None and allow_implicit: - return None - if isinstance(description, dict): - if description.get('type') is None: - if allow_implicit and not description.get('sdp'): - return None - msg = 'the type of a description is required' - raise TypeError(msg) - description = RTCSessionDescription(description) if isinstance(description, RTCSessionDescription): return description._native_obj.init + if allow_implicit and isinstance(description, RTCLocalSessionDescriptionInit): + if description.type is None and description.sdp: + msg = 'the type of a description is required' + raise TypeError(msg) + description = None if description.type is None else RTCSessionDescriptionInit(description.type, description.sdp) if isinstance(description, RTCSessionDescriptionInit): - return description._native_obj + return description._to_native() + if allow_implicit and description is None: + return None msg = f'expected an RTCSessionDescription, not {type(description).__name__}' raise TypeError(msg) @@ -836,18 +828,6 @@ def _init_of(description: wrtc.RTCSessionDescription) -> webrtc.RTCSessionDescri return RTCSessionDescriptionInit(description.type, description.sdp) -def _transceiver_init(init: dict[str, Any]) -> RtpTransceiverInit: - """An init from a dictionary, as in browsers, with its encodings dictionaries too.""" - values = members(init, ('direction', 'send_encodings', 'streams')) - encodings = values.get('send_encodings') - if encodings is not None: - names = [field.name for field in dataclasses.fields(RTCRtpEncodingParameters)] - values['send_encodings'] = [ - RTCRtpEncodingParameters(**members(e, names)) if isinstance(e, dict) else e for e in encodings - ] - return RtpTransceiverInit(**values) - - def _check_send_encodings(encodings: list[webrtc.RTCRtpEncodingParameters], kind: webrtc.MediaType) -> None: """Validates the send encodings of a new transceiver, as the specification requires. diff --git a/python-webrtc/python/webrtc/interfaces/track_generator.py b/python-webrtc/python/webrtc/interfaces/track_generator.py index c41fa5d..c8610ea 100644 --- a/python-webrtc/python/webrtc/interfaces/track_generator.py +++ b/python-webrtc/python/webrtc/interfaces/track_generator.py @@ -14,6 +14,8 @@ from webrtc import AudioData, AudioSampleFormat, MediaStreamTrack, MediaType, VideoFrame, wrtc from webrtc.exceptions import NotSupportedError +from webrtc.models.audio_data import AudioDataCopyToOptions +from webrtc.models.dictionary import Dictionary from webrtc.streams import WritableStream if TYPE_CHECKING: @@ -54,7 +56,7 @@ def _write_audio(self, data: object) -> None: samples = audio._data else: samples = bytearray(audio.number_of_frames * audio.number_of_channels * 2) - audio.copy_to(samples, {'plane_index': 0, 'format': AudioSampleFormat.s16}) + audio.copy_to(samples, AudioDataCopyToOptions(plane_index=0, format=AudioSampleFormat.s16)) samples = bytes(samples) # rates beyond an int are unsupported too: the native check rejects them rate = min(int(audio.sample_rate), 2**31 - 1) @@ -85,7 +87,8 @@ class VideoTrackGenerator: generator = webrtc.VideoTrackGenerator() pc.add_track(generator.track) writer = generator.writable.get_writer() - await writer.write(webrtc.VideoFrame(i420, format='I420', coded_width=640, coded_height=480, timestamp=0)) + init = webrtc.VideoFrameBufferInit(format='I420', coded_width=640, coded_height=480, timestamp=0) + await writer.write(webrtc.VideoFrame(i420, init)) """ def __init__(self) -> None: @@ -115,7 +118,7 @@ def muted(self, value: bool) -> None: @dataclass -class MediaStreamTrackGeneratorInit: +class MediaStreamTrackGeneratorInit(Dictionary): """How to create a :obj:`MediaStreamTrackGenerator`. Args: @@ -135,17 +138,15 @@ class MediaStreamTrackGenerator(MediaStreamTrack): Args: kind (:obj:`webrtc.MediaType`, :obj:`str` or :obj:`MediaStreamTrackGeneratorInit`): ``'audio'`` or ``'video'``, - or the init with it. A dictionary of the init's members is taken too. + or the init with it. Raises: TypeError: If the kind isn't audio or video. """ - def __init__(self, kind: str | MediaType | MediaStreamTrackGeneratorInit | dict[str, str]) -> None: + def __init__(self, kind: str | MediaType | MediaStreamTrackGeneratorInit) -> None: if isinstance(kind, MediaStreamTrackGeneratorInit): kind = kind.kind - elif isinstance(kind, dict): - kind = kind.get('kind') if kind not in {'audio', 'video'}: msg = f"The kind must be 'audio' or 'video', not {kind!r}" raise TypeError(msg) diff --git a/python-webrtc/python/webrtc/models/audio_data.py b/python-webrtc/python/webrtc/models/audio_data.py index 426df19..74a0712 100644 --- a/python-webrtc/python/webrtc/models/audio_data.py +++ b/python-webrtc/python/webrtc/models/audio_data.py @@ -16,7 +16,8 @@ from webrtc import AudioSampleFormat, InvalidRangeError, InvalidStateError, NotSupportedError, wrtc from webrtc.models.closable import Closable -from webrtc.utils.names import Alias, alias, snake_case +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias _SAMPLE_BYTES = {'u8': 1, 's16': 2, 's32': 4, 'f32': 4} @@ -38,7 +39,7 @@ def _is_planar(format: AudioSampleFormat) -> bool: @dataclass -class AudioDataInit: +class AudioDataInit(Dictionary): """How to create an :obj:`AudioData`. Args: @@ -66,7 +67,7 @@ class AudioDataInit: @dataclass -class AudioDataCopyToOptions: +class AudioDataCopyToOptions(Dictionary): """What :meth:`AudioData.copy_to` copies. Args: @@ -89,23 +90,6 @@ class AudioDataCopyToOptions: frameCount: ClassVar[Alias[int | None]] = alias('frame_count') -def _copy_options(value: object) -> AudioDataCopyToOptions: - if isinstance(value, AudioDataCopyToOptions): - return value - if isinstance(value, dict): - kwargs = {snake_case(k): v for k, v in value.items() if v is not None} - if 'plane_index' not in kwargs: - msg = 'plane_index is required' - raise TypeError(msg) - try: - return AudioDataCopyToOptions(**kwargs) - except TypeError as e: - msg = f'Invalid AudioDataCopyToOptions: {e}' - raise TypeError(msg) from None - msg = f'{value!r} is not an AudioDataCopyToOptions' - raise TypeError(msg) - - def _unsigned(value: object, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: msg = f'{name} must be a non-negative integer, not {value!r}' @@ -113,25 +97,6 @@ def _unsigned(value: object, name: str) -> int: return value -def _audio_data_init(init: object, options: dict[str, object]) -> AudioDataInit: - if init is None: - init = options - elif options: - msg = 'Pass either an init or keyword arguments' - raise TypeError(msg) - if isinstance(init, dict): - kwargs = {snake_case(k): v for k, v in init.items() if k != 'transfer'} - try: - init = AudioDataInit(**kwargs) - except TypeError as e: - msg = f'Invalid AudioDataInit: {e}' - raise TypeError(msg) from None - if not isinstance(init, AudioDataInit): - msg = f'{init!r} is not an AudioDataInit' - raise TypeError(msg) - return init - - def _sample_rate(value: object) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 < value < math.inf: msg = 'sample_rate must be positive and finite' @@ -173,8 +138,7 @@ class AudioData(Closable): Samples read from a track hold memory until :meth:`close`, like a :obj:`webrtc.VideoFrame`. Args: - init (:obj:`AudioDataInit`, optional): The samples and their format. A dictionary of its members, or keyword - arguments, can be passed instead. + init (:obj:`AudioDataInit`): The samples and their format. Raises: TypeError: If the init isn't valid, or the data is too small for it. @@ -182,12 +146,18 @@ class AudioData(Closable): Example:: data = webrtc.AudioData( - format='s16', sample_rate=48000, number_of_frames=480, number_of_channels=1, timestamp=0, data=bytes(960) + webrtc.AudioDataInit( + format='s16', + sample_rate=48000, + number_of_frames=480, + number_of_channels=1, + timestamp=0, + data=bytes(960), + ) ) """ - def __init__(self, init: AudioDataInit | dict[str, object] | None = None, **options: object) -> None: - init = _audio_data_init(init, options) + def __init__(self, init: AudioDataInit) -> None: format = _sample_format(init.format) sample_rate = _sample_rate(init.sample_rate) frames = _unsigned(init.number_of_frames, 'number_of_frames') @@ -261,11 +231,10 @@ def timestamp(self) -> int: """:obj:`int`: The presentation time in microseconds.""" return self._timestamp - def _plan_copy(self, options: AudioDataCopyToOptions | dict[str, object]) -> _CopyPlan: + def _plan_copy(self, options: AudioDataCopyToOptions) -> _CopyPlan: if self._data is None: msg = 'The data is closed' raise InvalidStateError(msg) - options = _copy_options(options) plane_index = _unsigned(options.plane_index, 'plane_index') frame_offset = _unsigned(options.frame_offset, 'frame_offset') destination = self._format if options.format is None else _sample_format(options.format) @@ -289,7 +258,7 @@ def _plan_copy(self, options: AudioDataCopyToOptions | dict[str, object]) -> _Co elements = frame_count if _is_planar(destination) else frame_count * self._channels return _CopyPlan(destination, plane_index, frame_offset, frame_count, elements * _sample_bytes(destination)) - def allocation_size(self, options: AudioDataCopyToOptions | dict[str, object]) -> int: + def allocation_size(self, options: AudioDataCopyToOptions) -> int: """Returns how many bytes :meth:`copy_to` needs. Raises :obj:`webrtc.InvalidStateError` if the data is closed, :obj:`TypeError` if the options aren't valid @@ -300,7 +269,7 @@ def allocation_size(self, options: AudioDataCopyToOptions | dict[str, object]) - """ return self._plan_copy(options).size - def copy_to(self, destination: bytearray | memoryview, options: AudioDataCopyToOptions | dict[str, object]) -> None: + def copy_to(self, destination: bytearray | memoryview, options: AudioDataCopyToOptions) -> None: """Copies samples into a buffer, converting them to another format if asked. Raises the errors of :meth:`allocation_size` too. diff --git a/python-webrtc/python/webrtc/models/dictionary.py b/python-webrtc/python/webrtc/models/dictionary.py new file mode 100644 index 0000000..2f23497 --- /dev/null +++ b/python-webrtc/python/webrtc/models/dictionary.py @@ -0,0 +1,65 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The base of the WebIDL dictionaries, which methods take as typed models only.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import fields +from typing import TYPE_CHECKING, Any, ClassVar + +from webrtc.utils.names import members + +if TYPE_CHECKING: + from dataclasses import Field + + from typing_extensions import Self + + +class Dictionary: + """A dataclass of a WebIDL dictionary, which can be created from its JSON form with :meth:`from_json`.""" + + # members holding dictionaries, or lists of them, whose JSON forms from_json converts too + _dictionaries: ClassVar[Mapping[str, type[Dictionary]]] = {} + + if TYPE_CHECKING: # every subclass is a dataclass + __dataclass_fields__: ClassVar[dict[str, Field[Any]]] + + @classmethod + def from_json(cls, value: Mapping[str, Any]) -> Self: + """Creates the dictionary from its JSON form, like a message from the remote peer. + + Keys are the camelCase names of the specification or the snake_case ones, unknown keys are ignored, and + nested dictionaries are converted too. + + Args: + value (:obj:`dict`): The JSON form. + + Returns: + The dictionary. + + Raises: + TypeError: If the value isn't a mapping, or a required member is missing. + """ + if not isinstance(value, Mapping): + msg = f'{cls.__name__} is created from a mapping, not {type(value).__name__}' + raise TypeError(msg) + kwargs = members(value, [field.name for field in fields(cls)]) + for name, dictionary in cls._dictionaries.items(): + if name in kwargs: + kwargs[name] = dictionary._from_json_member(kwargs[name]) + return cls(**kwargs) + + @classmethod + def _from_json_member(cls, value: object) -> object: + """A member that holds the dictionary, a list of them, or another type of a union, which stays as it is.""" + if isinstance(value, Mapping): + return cls.from_json(value) + if isinstance(value, list): + return [cls.from_json(item) if isinstance(item, Mapping) else item for item in value] + return value diff --git a/python-webrtc/python/webrtc/models/media_track_constraints.py b/python-webrtc/python/webrtc/models/media_track_constraints.py index adfb83e..1493306 100644 --- a/python-webrtc/python/webrtc/models/media_track_constraints.py +++ b/python-webrtc/python/webrtc/models/media_track_constraints.py @@ -12,17 +12,15 @@ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import Any, ClassVar, Union +from dataclasses import dataclass +from typing import ClassVar, Union -from webrtc.utils.names import Alias, alias, snake_case - -#: A value, or a constraint on it: a :obj:`dict` with any of ``exact``, ``ideal``, ``min`` and ``max`` -ConstrainValue = Union[float, int, str, bool, dict[str, Any]] +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias @dataclass -class ULongRange: +class ULongRange(Dictionary): """A range of integers. Args: @@ -35,7 +33,7 @@ class ULongRange: @dataclass -class DoubleRange: +class DoubleRange(Dictionary): """A range of numbers. Args: @@ -48,7 +46,88 @@ class DoubleRange: @dataclass -class MediaTrackSettings: +class ConstrainULongRange(ULongRange): + """A constraint on an integer. + + Args: + min (:obj:`int`, optional): The lowest value, required. + max (:obj:`int`, optional): The highest value, required. + exact (:obj:`int`, optional): The value, required. + ideal (:obj:`int`, optional): The value to get as near as possible. + """ + + exact: int | None = None + ideal: int | None = None + + +@dataclass +class ConstrainDoubleRange(DoubleRange): + """A constraint on a number. + + Args: + min (:obj:`float`, optional): The lowest value, required. + max (:obj:`float`, optional): The highest value, required. + exact (:obj:`float`, optional): The value, required. + ideal (:obj:`float`, optional): The value to get as near as possible. + """ + + exact: float | None = None + ideal: float | None = None + + +@dataclass +class ConstrainBooleanParameters(Dictionary): + """A constraint on a boolean. + + Args: + exact (:obj:`bool`, optional): The value, required. + ideal (:obj:`bool`, optional): The value to get if possible. + """ + + exact: bool | None = None + ideal: bool | None = None + + +@dataclass +class ConstrainDOMStringParameters(Dictionary): + """A constraint on a string. + + Args: + exact (:obj:`str` or :obj:`list` of :obj:`str`, optional): The value, or the values allowed, required. + ideal (:obj:`str` or :obj:`list` of :obj:`str`, optional): The value, or the values, to get if possible. + """ + + exact: str | list[str] | None = None + ideal: str | list[str] | None = None + + +@dataclass +class ConstrainBooleanOrDOMStringParameters(Dictionary): + """A constraint on a boolean or a string. + + Args: + exact (:obj:`bool` or :obj:`str`, optional): The value, required. + ideal (:obj:`bool` or :obj:`str`, optional): The value to get if possible. + """ + + exact: bool | str | None = None + ideal: bool | str | None = None + + +#: An integer (an ideal one), or a constraint on it +ConstrainULong = Union[int, ConstrainULongRange] +#: A number (an ideal one), or a constraint on it +ConstrainDouble = Union[float, ConstrainDoubleRange] +#: A boolean (an ideal one), or a constraint on it +ConstrainBoolean = Union[bool, ConstrainBooleanParameters] +#: A string or strings (ideal ones), or a constraint on them +ConstrainDOMString = Union[str, list[str], ConstrainDOMStringParameters] +#: A boolean or a string (an ideal one), or a constraint on it +ConstrainBooleanOrDOMString = Union[bool, str, ConstrainBooleanOrDOMStringParameters] + + +@dataclass +class MediaTrackSettings(Dictionary): """What a track carries, as far as it's known (:meth:`webrtc.MediaStreamTrack.get_settings`). Members are :obj:`None` when they don't apply to the track. @@ -108,7 +187,7 @@ class MediaTrackSettings: @dataclass -class MediaTrackCapabilities: +class MediaTrackCapabilities(Dictionary): """What the source of a track can do (:meth:`webrtc.MediaStreamTrack.get_capabilities`). The synthetic camera and microphone of :func:`webrtc.get_user_media` have capabilities, other tracks don't control @@ -169,79 +248,95 @@ class MediaTrackCapabilities: @dataclass -class MediaTrackConstraints: - """What a track is asked to be (:meth:`webrtc.MediaStreamTrack.apply_constraints`). +class MediaTrackConstraintSet(Dictionary): + """A set of constraints on a track, see :obj:`MediaTrackConstraints`. - Each member is a value (an ideal one) or a :obj:`dict` of ``exact``, ``ideal``, ``min`` and ``max``: the required + Each member is a value (an ideal one) or a constraint with ``exact``, ``ideal``, ``min`` and ``max``: the required ones make the constraints fail if the source can't satisfy them. Args: - width (optional): The width of the video. - height (optional): The height of the video. - aspect_ratio (optional): The aspect ratio of the video. - frame_rate (optional): The frame rate of the video. - resize_mode (optional): How the source is resized. - device_id (optional): The device. - group_id (optional): The group of the device. - sample_rate (optional): The sample rate of the audio. - sample_size (optional): The bits per sample of the audio. - channel_count (optional): The channels of the audio. - echo_cancellation (optional): Whether echo is cancelled. - auto_gain_control (optional): Whether the gain is controlled. - noise_suppression (optional): Whether noise is suppressed. - advanced (:obj:`list` of :obj:`dict`, optional): Sets of constraints tried in order, each applied if it can - be satisfied. + width (:obj:`int` or :obj:`ConstrainULongRange`, optional): The width of the video. + height (:obj:`int` or :obj:`ConstrainULongRange`, optional): The height of the video. + aspect_ratio (:obj:`float` or :obj:`ConstrainDoubleRange`, optional): The aspect ratio of the video. + frame_rate (:obj:`float` or :obj:`ConstrainDoubleRange`, optional): The frame rate of the video. + resize_mode (:obj:`str` or :obj:`ConstrainDOMStringParameters`, optional): How the source is resized. + device_id (:obj:`str` or :obj:`ConstrainDOMStringParameters`, optional): The device. + group_id (:obj:`str` or :obj:`ConstrainDOMStringParameters`, optional): The group of the device. + sample_rate (:obj:`int` or :obj:`ConstrainULongRange`, optional): The sample rate of the audio. + sample_size (:obj:`int` or :obj:`ConstrainULongRange`, optional): The bits per sample of the audio. + channel_count (:obj:`int` or :obj:`ConstrainULongRange`, optional): The channels of the audio. + echo_cancellation (:obj:`bool` or :obj:`ConstrainBooleanOrDOMStringParameters`, optional): Whether echo is + cancelled. + auto_gain_control (:obj:`bool` or :obj:`ConstrainBooleanParameters`, optional): Whether the gain is + controlled. + noise_suppression (:obj:`bool` or :obj:`ConstrainBooleanParameters`, optional): Whether noise is suppressed. """ - width: ConstrainValue | None = None - height: ConstrainValue | None = None - aspect_ratio: ConstrainValue | None = None - frame_rate: ConstrainValue | None = None - resize_mode: ConstrainValue | None = None - device_id: ConstrainValue | None = None - group_id: ConstrainValue | None = None - sample_rate: ConstrainValue | None = None - sample_size: ConstrainValue | None = None - channel_count: ConstrainValue | None = None - echo_cancellation: ConstrainValue | None = None - auto_gain_control: ConstrainValue | None = None - noise_suppression: ConstrainValue | None = None - advanced: list[dict[str, Any]] | None = None - - @classmethod - def _parse(cls, value: object) -> MediaTrackConstraints: - """Constraints from an instance or a dictionary, with snake_case or camelCase names.""" - if value is None: - return cls() - if isinstance(value, cls): - return value - if not isinstance(value, dict): - msg = f'{value!r} is not a MediaTrackConstraints' - raise TypeError(msg) - names = {f.name for f in fields(cls)} - members = {snake_case(k): v for k, v in value.items()} - # unknown members are ignored, as the specification says - return cls(**{k: v for k, v in members.items() if k in names}) + _dictionaries: ClassVar = { + 'width': ConstrainULongRange, + 'height': ConstrainULongRange, + 'aspect_ratio': ConstrainDoubleRange, + 'frame_rate': ConstrainDoubleRange, + 'resize_mode': ConstrainDOMStringParameters, + 'device_id': ConstrainDOMStringParameters, + 'group_id': ConstrainDOMStringParameters, + 'sample_rate': ConstrainULongRange, + 'sample_size': ConstrainULongRange, + 'channel_count': ConstrainULongRange, + 'echo_cancellation': ConstrainBooleanOrDOMStringParameters, + 'auto_gain_control': ConstrainBooleanParameters, + 'noise_suppression': ConstrainBooleanParameters, + } + + width: ConstrainULong | None = None + height: ConstrainULong | None = None + aspect_ratio: ConstrainDouble | None = None + frame_rate: ConstrainDouble | None = None + resize_mode: ConstrainDOMString | None = None + device_id: ConstrainDOMString | None = None + group_id: ConstrainDOMString | None = None + sample_rate: ConstrainULong | None = None + sample_size: ConstrainULong | None = None + channel_count: ConstrainULong | None = None + echo_cancellation: ConstrainBooleanOrDOMString | None = None + auto_gain_control: ConstrainBoolean | None = None + noise_suppression: ConstrainBoolean | None = None #: Alias for :attr:`aspect_ratio` - aspectRatio: ClassVar[Alias[ConstrainValue | None]] = alias('aspect_ratio') + aspectRatio: ClassVar[Alias[ConstrainDouble | None]] = alias('aspect_ratio') #: Alias for :attr:`frame_rate` - frameRate: ClassVar[Alias[ConstrainValue | None]] = alias('frame_rate') + frameRate: ClassVar[Alias[ConstrainDouble | None]] = alias('frame_rate') #: Alias for :attr:`resize_mode` - resizeMode: ClassVar[Alias[ConstrainValue | None]] = alias('resize_mode') + resizeMode: ClassVar[Alias[ConstrainDOMString | None]] = alias('resize_mode') #: Alias for :attr:`device_id` - deviceId: ClassVar[Alias[ConstrainValue | None]] = alias('device_id') + deviceId: ClassVar[Alias[ConstrainDOMString | None]] = alias('device_id') #: Alias for :attr:`group_id` - groupId: ClassVar[Alias[ConstrainValue | None]] = alias('group_id') + groupId: ClassVar[Alias[ConstrainDOMString | None]] = alias('group_id') #: Alias for :attr:`sample_rate` - sampleRate: ClassVar[Alias[ConstrainValue | None]] = alias('sample_rate') + sampleRate: ClassVar[Alias[ConstrainULong | None]] = alias('sample_rate') #: Alias for :attr:`sample_size` - sampleSize: ClassVar[Alias[ConstrainValue | None]] = alias('sample_size') + sampleSize: ClassVar[Alias[ConstrainULong | None]] = alias('sample_size') #: Alias for :attr:`channel_count` - channelCount: ClassVar[Alias[ConstrainValue | None]] = alias('channel_count') + channelCount: ClassVar[Alias[ConstrainULong | None]] = alias('channel_count') #: Alias for :attr:`echo_cancellation` - echoCancellation: ClassVar[Alias[ConstrainValue | None]] = alias('echo_cancellation') + echoCancellation: ClassVar[Alias[ConstrainBooleanOrDOMString | None]] = alias('echo_cancellation') #: Alias for :attr:`auto_gain_control` - autoGainControl: ClassVar[Alias[ConstrainValue | None]] = alias('auto_gain_control') + autoGainControl: ClassVar[Alias[ConstrainBoolean | None]] = alias('auto_gain_control') #: Alias for :attr:`noise_suppression` - noiseSuppression: ClassVar[Alias[ConstrainValue | None]] = alias('noise_suppression') + noiseSuppression: ClassVar[Alias[ConstrainBoolean | None]] = alias('noise_suppression') + + +@dataclass +class MediaTrackConstraints(MediaTrackConstraintSet): + """What a track is asked to be (:meth:`webrtc.MediaStreamTrack.apply_constraints`). + + The members are the ones of :obj:`MediaTrackConstraintSet`, and: + + Args: + advanced (:obj:`list` of :obj:`MediaTrackConstraintSet`, optional): Sets of constraints tried in order, each + applied if it can be satisfied. + """ + + _dictionaries: ClassVar = {**MediaTrackConstraintSet._dictionaries, 'advanced': MediaTrackConstraintSet} + + advanced: list[MediaTrackConstraintSet] | None = None diff --git a/python-webrtc/python/webrtc/models/rtc_certificate.py b/python-webrtc/python/webrtc/models/rtc_certificate.py index d8ee752..bf55f79 100644 --- a/python-webrtc/python/webrtc/models/rtc_certificate.py +++ b/python-webrtc/python/webrtc/models/rtc_certificate.py @@ -11,20 +11,70 @@ import asyncio import time -from collections.abc import Mapping from dataclasses import dataclass from itertools import starmap -from typing import Union +from typing import ClassVar, Union from webrtc import NotSupportedError, WebRTCObject, wrtc -from webrtc.utils.names import snake_case +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias -#: A WebCrypto algorithm: its name (like ``'ECDSA'``), or a dictionary with its name and parameters. -Algorithm = Union[str, Mapping[str, object]] + +@dataclass +class Algorithm(Dictionary): + """A WebCrypto algorithm, by its name. + + Args: + name (:obj:`str`): The name, like ``'ECDSA'``. + """ + + name: str + + +@dataclass +class EcKeyGenParams(Algorithm): + """A WebCrypto algorithm of an elliptic curve key, for :meth:`webrtc.RTCCertificate.generate`. + + Args: + name (:obj:`str`): ``'ECDSA'``. + named_curve (:obj:`str`): The curve, ``'P-256'`` as the only one supported. + """ + + named_curve: str + + #: Alias for :attr:`named_curve` + namedCurve: ClassVar[Alias[str]] = alias('named_curve') + + +@dataclass +class RsaHashedKeyGenParams(Algorithm): + """A WebCrypto algorithm of an RSA key, for :meth:`webrtc.RTCCertificate.generate`. + + Args: + name (:obj:`str`): ``'RSASSA-PKCS1-v1_5'``. + modulus_length (:obj:`int`): The length of the modulus in bits, like 2048. + public_exponent (:obj:`bytes`): The public exponent, big-endian, like ``bytes([1, 0, 1])`` for 65537. + hash (:obj:`str` or :obj:`webrtc.Algorithm`): The hash function, ``'SHA-256'`` as the only one supported. + """ + + modulus_length: int + public_exponent: bytes + hash: str | Algorithm + + _dictionaries: ClassVar = {'hash': Algorithm} + + #: Alias for :attr:`modulus_length` + modulusLength: ClassVar[Alias[int]] = alias('modulus_length') + #: Alias for :attr:`public_exponent` + publicExponent: ClassVar[Alias[bytes]] = alias('public_exponent') + + +#: A WebCrypto algorithm, or its name +AlgorithmIdentifier = Union[str, Algorithm] @dataclass(frozen=True) -class RTCDtlsFingerprint: +class RTCDtlsFingerprint(Dictionary): """A fingerprint of a certificate, as in the ``a=fingerprint`` line of SDP. Args: @@ -36,50 +86,38 @@ class RTCDtlsFingerprint: value: str -#: The key type, modulus length and public exponent, as the native generate() takes them. _KeyParams = tuple[str, int, int] -def _member(algorithm: Mapping[str, object], name: str, default: object = None) -> object: - """A member of a WebCrypto algorithm dictionary, by its camelCase or snake_case name.""" - return algorithm.get(name, algorithm.get(snake_case(name), default)) - - -def _ecdsa_params(algorithm: Mapping[str, object]) -> _KeyParams: - curve = _member(algorithm, 'namedCurve', 'P-256') +def _ecdsa_params(algorithm: Algorithm) -> _KeyParams: + curve = algorithm.named_curve if isinstance(algorithm, EcKeyGenParams) else 'P-256' if curve != 'P-256': msg = f'the {curve} curve is not supported, only P-256 is' raise NotSupportedError(msg) return 'ecdsa', 0, 0 -def _rsa_params(algorithm: Mapping[str, object]) -> _KeyParams: - hash_name = _member(algorithm, 'hash') - if isinstance(hash_name, Mapping): - hash_name = hash_name.get('name') - modulus_length = _member(algorithm, 'modulusLength') - exponent = _member(algorithm, 'publicExponent') - if hash_name is None or modulus_length is None or exponent is None: - msg = 'RSASSA-PKCS1-v1_5 needs a hash, a modulus length and a public exponent' +def _rsa_params(algorithm: Algorithm) -> _KeyParams: + if not isinstance(algorithm, RsaHashedKeyGenParams): + msg = 'RSASSA-PKCS1-v1_5 needs an RsaHashedKeyGenParams, with a hash, a modulus length and a public exponent' raise NotSupportedError(msg) - if str(hash_name).upper() != 'SHA-256': + hash_name = algorithm.hash.name if isinstance(algorithm.hash, Algorithm) else algorithm.hash + if hash_name.upper() != 'SHA-256': msg = f'the {hash_name} hash is not supported, only SHA-256 is' raise NotSupportedError(msg) - if isinstance(exponent, (bytes, bytearray, memoryview)): - exponent = int.from_bytes(bytes(exponent), 'big') - return 'rsa', int(modulus_length), int(exponent) + return 'rsa', algorithm.modulus_length, int.from_bytes(bytes(algorithm.public_exponent), 'big') _KEY_PARAMS = {'ECDSA': _ecdsa_params, 'RSASSA-PKCS1-V1_5': _rsa_params} -def _key_params(algorithm: Algorithm) -> _KeyParams: +def _key_params(algorithm: AlgorithmIdentifier) -> _KeyParams: """The key parameters for an algorithm.""" if isinstance(algorithm, str): - algorithm = {'name': algorithm} - key_params = _KEY_PARAMS.get(str(_member(algorithm, 'name', '')).upper()) + algorithm = Algorithm(algorithm) + key_params = _KEY_PARAMS.get(algorithm.name.upper()) if key_params is None: - msg = f'the {algorithm.get("name")!r} algorithm is not supported, ECDSA and RSASSA-PKCS1-v1_5 are' + msg = f'the {algorithm.name!r} algorithm is not supported, ECDSA and RSASSA-PKCS1-v1_5 are' raise NotSupportedError(msg) return key_params(algorithm) @@ -94,13 +132,13 @@ class RTCCertificate(WebRTCObject): _class = wrtc.RTCCertificate @classmethod - async def generate(cls, algorithm: Algorithm = 'ECDSA', expires: float | None = None) -> RTCCertificate: + async def generate(cls, algorithm: AlgorithmIdentifier = 'ECDSA', expires: float | None = None) -> RTCCertificate: """Generates a key and a self-signed certificate, on a worker thread. Args: - algorithm (:obj:`str` or :obj:`dict`, optional): A WebCrypto algorithm: ``'ECDSA'`` (with the P-256 curve), - or a dictionary like ``{'name': 'RSASSA-PKCS1-v1_5', 'modulus_length': 2048, - 'public_exponent': 65537, 'hash': 'SHA-256'}``. + algorithm (:obj:`str` or :obj:`webrtc.Algorithm`, optional): A WebCrypto algorithm: ``'ECDSA'`` + (with the P-256 curve), an :obj:`webrtc.EcKeyGenParams`, or an :obj:`webrtc.RsaHashedKeyGenParams` + like ``RsaHashedKeyGenParams('RSASSA-PKCS1-v1_5', 2048, bytes([1, 0, 1]), 'SHA-256')``. expires (:obj:`float`, optional): In how many milliseconds the certificate expires, at most a year (the default is 30 days). diff --git a/python-webrtc/python/webrtc/models/rtc_configuration.py b/python-webrtc/python/webrtc/models/rtc_configuration.py index d45ed53..bf05f40 100644 --- a/python-webrtc/python/webrtc/models/rtc_configuration.py +++ b/python-webrtc/python/webrtc/models/rtc_configuration.py @@ -12,7 +12,7 @@ import ipaddress import re from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, ClassVar from webrtc import ( InvalidAccessError, @@ -25,6 +25,7 @@ RTCRtpHeaderEncryptionPolicy, wrtc, ) +from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias if TYPE_CHECKING: @@ -75,7 +76,7 @@ def _check_url(url: str) -> str: @dataclass -class RTCOAuthCredential: +class RTCOAuthCredential(Dictionary): """An OAuth credential of a TURN server (RFC 7635). Args: @@ -93,7 +94,7 @@ class RTCOAuthCredential: @dataclass -class RTCIceServer: +class RTCIceServer(Dictionary): """A STUN or TURN server used to gather ICE candidates. Args: @@ -111,10 +112,12 @@ class RTCIceServer: credential: str | RTCOAuthCredential | None = None credential_type: str = 'password' + _dictionaries: ClassVar = {'credential': RTCOAuthCredential} + @classmethod - def _to_native_list(cls, servers: Iterable[RTCIceServer | dict[str, Any]]) -> list[wrtc.IceServerInit]: - """The native servers of a list of servers, or of their keyword arguments.""" - return [(cls(**server) if isinstance(server, dict) else server)._to_native() for server in servers] + def _to_native_list(cls, servers: Iterable[RTCIceServer]) -> list[wrtc.IceServerInit]: + """The native servers of a list of servers.""" + return [server._to_native() for server in servers] def _to_native(self) -> wrtc.IceServerInit: urls = [self.urls] if isinstance(self.urls, str) else list(self.urls) @@ -152,12 +155,12 @@ def _to_native(self) -> wrtc.IceServerInit: @dataclass -class RTCConfiguration: +class RTCConfiguration(Dictionary): """The configuration of a :obj:`webrtc.RTCPeerConnection`. Args: ice_servers (:obj:`list` of :obj:`webrtc.RTCIceServer`, optional): STUN and TURN servers to gather - ICE candidates with. A :obj:`dict` of the arguments of :obj:`webrtc.RTCIceServer` is accepted too. + ICE candidates with. ice_transport_policy (:obj:`webrtc.RTCIceTransportPolicy`, optional): Which candidates may be used, all of them (the default) or only relay ones. bundle_policy (:obj:`webrtc.RTCBundlePolicy`, optional): How media is bundled when the remote peer @@ -177,7 +180,7 @@ class RTCConfiguration: a remote description without it fails (``require``). Can't be changed. """ - ice_servers: list[RTCIceServer | dict[str, Any]] = field(default_factory=list) + ice_servers: list[RTCIceServer] = field(default_factory=list) ice_transport_policy: RTCIceTransportPolicy = RTCIceTransportPolicy.all bundle_policy: RTCBundlePolicy = RTCBundlePolicy.balanced rtcp_mux_policy: RTCRtcpMuxPolicy = RTCRtcpMuxPolicy.require @@ -187,6 +190,8 @@ class RTCConfiguration: always_negotiate_data_channels: bool = False rtp_header_encryption_policy: RTCRtpHeaderEncryptionPolicy = RTCRtpHeaderEncryptionPolicy.negotiate + _dictionaries: ClassVar = {'ice_servers': RTCIceServer} + def _to_native(self) -> wrtc.ConfigurationInit: """Validates the configuration and creates the native one. @@ -248,7 +253,7 @@ def _from_native(cls, native: wrtc.ConfigurationInit) -> RTCConfiguration: ) #: Alias for :attr:`ice_servers` - iceServers: ClassVar[Alias[list[RTCIceServer | dict[str, Any]]]] = alias('ice_servers') + iceServers: ClassVar[Alias[list[RTCIceServer]]] = alias('ice_servers') #: Alias for :attr:`ice_transport_policy` iceTransportPolicy: ClassVar[Alias[RTCIceTransportPolicy]] = alias('ice_transport_policy') #: Alias for :attr:`bundle_policy` diff --git a/python-webrtc/python/webrtc/models/rtc_ice_candidate.py b/python-webrtc/python/webrtc/models/rtc_ice_candidate.py index 3332efd..cc60617 100644 --- a/python-webrtc/python/webrtc/models/rtc_ice_candidate.py +++ b/python-webrtc/python/webrtc/models/rtc_ice_candidate.py @@ -12,7 +12,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import Any, ClassVar, TypeVar, Union +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union from webrtc import ( RTCIceCandidateType, @@ -21,8 +21,12 @@ RTCIceServerTransportProtocol, RTCIceTcpCandidateType, ) +from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias +if TYPE_CHECKING: + from collections.abc import Mapping + _FOUNDATION = re.compile(r'[A-Za-z0-9+/]{1,32}') _DIGITS = re.compile(r'[0-9]+') _TOKEN = re.compile(r"[!#$%&'*+\-.^_`{|}~A-Za-z0-9]+") @@ -147,7 +151,7 @@ def _member_or_none(cls: type[_EnumT], value: object) -> _EnumT | None: @dataclass(frozen=True) -class RTCIceParameters: +class RTCIceParameters(Dictionary): """The ICE username fragment and password of one end of an :obj:`webrtc.RTCIceTransport`. Args: @@ -162,6 +166,33 @@ class RTCIceParameters: usernameFragment: ClassVar[Alias[str]] = alias('username_fragment') +@dataclass +class RTCIceCandidateInit(Dictionary): + """A candidate as it's signaled, for :meth:`webrtc.RTCPeerConnection.add_ice_candidate`. + + The JSON form :meth:`webrtc.RTCIceCandidate.to_json` returns, which :meth:`from_json` reads. + + Args: + candidate (:obj:`str`, optional): The candidate-attribute from SDP. An empty string means the end of + candidates. + sdp_mid (:obj:`str`, optional): The media stream identification tag of the media section of the candidate. + sdp_m_line_index (:obj:`int`, optional): The index of the media section of the candidate. + username_fragment (:obj:`str`, optional): The ICE username fragment the candidate belongs to. + """ + + candidate: str = '' + sdp_mid: str | None = None + sdp_m_line_index: int | None = None + username_fragment: str | None = None + + #: Alias for :attr:`sdp_mid` + sdpMid: ClassVar[Alias[str | None]] = alias('sdp_mid') + #: Alias for :attr:`sdp_m_line_index` + sdpMLineIndex: ClassVar[Alias[int | None]] = alias('sdp_m_line_index') + #: Alias for :attr:`username_fragment` + usernameFragment: ClassVar[Alias[str | None]] = alias('username_fragment') + + @dataclass(frozen=True) class RTCIceCandidatePair: """The local and the remote candidate an :obj:`webrtc.RTCIceTransport` sends and receives with. @@ -215,20 +246,10 @@ def __post_init__(self) -> None: @staticmethod def _members_of( - candidate: RTCIceCandidate | dict[str, Any], + candidate: RTCIceCandidate | RTCIceCandidateInit, ) -> tuple[str, str | None, int | None, str | None]: - """The candidate, sdp_mid, sdp_m_line_index and username_fragment of a candidate or of its JSON form.""" - if isinstance(candidate, RTCIceCandidate): - return candidate.candidate, candidate.sdp_mid, candidate.sdp_m_line_index, candidate.username_fragment - if isinstance(candidate, dict): - return ( - candidate.get('candidate') or '', - candidate.get('sdpMid'), - candidate.get('sdpMLineIndex'), - candidate.get('usernameFragment'), - ) - msg = f'candidate must be an RTCIceCandidate or a dict, not {type(candidate).__name__}' - raise TypeError(msg) + """The candidate, sdp_mid, sdp_m_line_index and username_fragment of a candidate or of its init.""" + return candidate.candidate or '', candidate.sdp_mid, candidate.sdp_m_line_index, candidate.username_fragment @classmethod def _peer_reflexive(cls, kwargs: dict[str, Any]) -> RTCIceCandidate: @@ -251,17 +272,19 @@ def _peer_reflexive(cls, kwargs: dict[str, Any]) -> RTCIceCandidate: return candidate @classmethod - def from_json(cls, init: dict[str, Any]) -> RTCIceCandidate: + def from_json(cls, value: Mapping[str, Any]) -> RTCIceCandidate: """Creates a candidate from its JSON form, as :meth:`to_json` returns it. Args: - init (:obj:`dict`): A dictionary with ``candidate``, ``sdpMid``, ``sdpMLineIndex`` - and ``usernameFragment`` keys, all optional. + value (:obj:`dict`): The JSON form, read as :meth:`webrtc.RTCIceCandidateInit.from_json` does. Returns: :obj:`webrtc.RTCIceCandidate`: The candidate. + + Raises: + TypeError: If both ``sdpMid`` and ``sdpMLineIndex`` are missing. """ - return cls(*cls._members_of(init)) + return cls(*cls._members_of(RTCIceCandidateInit.from_json(value))) @property def foundation(self) -> str | None: diff --git a/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py b/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py new file mode 100644 index 0000000..e3bce1e --- /dev/null +++ b/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py @@ -0,0 +1,59 @@ +# +# Copyright 2022 Il`ya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The options of a new transceiver.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, ClassVar + +from webrtc import wrtc +from webrtc.enums import TransceiverDirection +from webrtc.models.dictionary import Dictionary +from webrtc.models.rtp_parameters import RTCRtpEncodingParameters +from webrtc.utils.names import Alias, alias + +if TYPE_CHECKING: + import webrtc + + +@dataclass +class RTCRtpTransceiverInit(Dictionary): + """The options of a new transceiver, for :meth:`webrtc.RTCPeerConnection.add_transceiver`. + + Args: + direction (:obj:`webrtc.TransceiverDirection`, optional): The direction of the transceiver, ``sendrecv`` + by default. + streams (:obj:`list` of :obj:`webrtc.MediaStream`, optional): The streams the remote peer receives the track + of its sender in. + send_encodings (:obj:`list` of :obj:`webrtc.RTCRtpEncodingParameters`, optional): The encodings of its + sender, one per simulcast layer. + + Raises: + ValueError: If the direction isn't a member of :obj:`webrtc.TransceiverDirection`. + """ + + direction: TransceiverDirection = TransceiverDirection.sendrecv + streams: list[webrtc.MediaStream] = field(default_factory=list) + send_encodings: list[RTCRtpEncodingParameters] = field(default_factory=list) + + _dictionaries: ClassVar = {'send_encodings': RTCRtpEncodingParameters} + + def __post_init__(self) -> None: + self.direction = TransceiverDirection(self.direction) + + def _to_native(self, encodings: list[RTCRtpEncodingParameters]) -> wrtc.RtpTransceiverInit: + """The native init, with the encodings to send (those of the init, adapted to the kind of the track).""" + native = wrtc.RtpTransceiverInit() + native.direction = self.direction + native.sendEncodings = [encoding._to_native() for encoding in encodings] + native.streamIds = [stream.id for stream in self.streams] + return native + + #: Alias for :attr:`send_encodings` + sendEncodings: ClassVar[Alias[list[RTCRtpEncodingParameters]]] = alias('send_encodings') diff --git a/python-webrtc/python/webrtc/models/rtc_session_description.py b/python-webrtc/python/webrtc/models/rtc_session_description.py index 33d2a01..6187836 100644 --- a/python-webrtc/python/webrtc/models/rtc_session_description.py +++ b/python-webrtc/python/webrtc/models/rtc_session_description.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from webrtc import RTCSessionDescriptionInit, WebRTCObject, wrtc @@ -32,37 +32,30 @@ class RTCSessionDescription(WebRTCObject): Note: :meth:`webrtc.RTCPeerConnection.set_local_description` and :meth:`webrtc.RTCPeerConnection.set_remote_description` also take an - :obj:`webrtc.RTCSessionDescriptionInit` or a :obj:`dict`, so creating an + :obj:`webrtc.RTCSessionDescriptionInit`, so creating an :obj:`webrtc.RTCSessionDescription` isn't necessary. Args: type (:obj:`webrtc.RTCSdpType`): The type of the description, required as in the specification. - An :obj:`webrtc.RTCSessionDescriptionInit` or its JSON form (a :obj:`dict` with a ``type`` key and an - optional ``sdp`` one) is accepted too. + An :obj:`webrtc.RTCSessionDescriptionInit` is accepted too (see + :meth:`webrtc.RTCSessionDescriptionInit.from_json` for its JSON form). sdp (:obj:`str`, optional): The SDP of the description, empty by default. It's parsed when the description is set. Raises: - TypeError: If the type is missing, or the SDP is :obj:`None`. + ValueError: If the type isn't a member of :obj:`webrtc.RTCSdpType`. + TypeError: If the SDP is :obj:`None`. """ _class = wrtc.RTCSessionDescription def __init__( self, - type: webrtc.RTCSdpType | webrtc.RTCSessionDescriptionInit | dict[str, Any], + type: webrtc.RTCSdpType | webrtc.RTCSessionDescriptionInit, sdp: str = '', ) -> None: - if isinstance(type, dict): - if type.get('type') is None: - msg = 'RTCSessionDescriptionInit requires a type' - raise TypeError(msg) - type, sdp = type['type'], type.get('sdp', '') - if sdp is None: - msg = 'The SDP of a description may not be None' - raise TypeError(msg) init = type if isinstance(type, RTCSessionDescriptionInit) else RTCSessionDescriptionInit(type, sdp) - super().__init__(self._class(init._native_obj)) + super().__init__(self._class(init._to_native())) def to_json(self) -> dict[str, str]: """The description as a JSON-serializable dictionary, to send to the remote peer. diff --git a/python-webrtc/python/webrtc/models/rtc_session_description_init.py b/python-webrtc/python/webrtc/models/rtc_session_description_init.py index 700f05d..78f625a 100644 --- a/python-webrtc/python/webrtc/models/rtc_session_description_init.py +++ b/python-webrtc/python/webrtc/models/rtc_session_description_init.py @@ -9,15 +9,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import ClassVar -from webrtc import WebRTCObject, wrtc +from webrtc import wrtc +from webrtc.enums import RTCSdpType +from webrtc.models.dictionary import Dictionary -if TYPE_CHECKING: - import webrtc - -class RTCSessionDescriptionInit(WebRTCObject): +@dataclass +class RTCSessionDescriptionInit(Dictionary): """The type and the SDP of a description. As :meth:`webrtc.RTCPeerConnection.create_offer` and :meth:`webrtc.RTCPeerConnection.create_answer` return them. @@ -25,33 +26,23 @@ class RTCSessionDescriptionInit(WebRTCObject): Args: type (:obj:`webrtc.RTCSdpType`): The type of the description, or its value (like ``'offer'``). sdp (:obj:`str`, optional): The SDP of the description. - """ - - _class = wrtc.RTCSessionDescriptionInit - - def __init__(self, type: webrtc.RTCSdpType, sdp: str = '') -> None: - super().__init__(self._class(type, sdp)) - - @property - def type(self) -> webrtc.RTCSdpType: - """:obj:`webrtc.RTCSdpType`: A member of the :obj:`webrtc.RTCSdpType` enum.""" - return self._native_obj.type - @type.setter - def type(self, value: webrtc.RTCSdpType) -> None: - self._native_obj.type = value + Raises: + ValueError: If the type isn't a member of :obj:`webrtc.RTCSdpType`. + TypeError: If the SDP is :obj:`None`. + """ - @property - def sdp(self) -> str: - """:obj:`str`: A string containing a SDP message describing the session. + type: RTCSdpType + sdp: str = '' - This value is an empty string by default and may not be :obj:`None`. - """ - return self._native_obj.sdp + def __post_init__(self) -> None: + self.type = RTCSdpType(self.type) + if self.sdp is None: + msg = 'The SDP of a description may not be None' + raise TypeError(msg) - @sdp.setter - def sdp(self, value: str) -> None: - self._native_obj.sdp = value + def _to_native(self) -> wrtc.RTCSessionDescriptionInit: + return wrtc.RTCSessionDescriptionInit(self.type, self.sdp) def to_json(self) -> dict[str, str]: """The description as a JSON-serializable dictionary, to send to the remote peer. @@ -65,4 +56,30 @@ def __repr__(self) -> str: return f'RTCSessionDescriptionInit(type={self.type.value!r}, sdp={len(self.sdp)} characters)' #: Alias for :attr:`to_json` - toJSON = to_json + toJSON: ClassVar = to_json + + +@dataclass +class RTCLocalSessionDescriptionInit(Dictionary): + """A local description, whose type may be left out (:meth:`webrtc.RTCPeerConnection.set_local_description`). + + Without a type, nor an SDP, the offer or the answer the signaling state calls for is created and set. + + Args: + type (:obj:`webrtc.RTCSdpType`, optional): The type of the description, or its value (like ``'offer'``). + sdp (:obj:`str`, optional): The SDP of the description. + + Raises: + ValueError: If the type isn't a member of :obj:`webrtc.RTCSdpType`. + TypeError: If the SDP is :obj:`None`. + """ + + type: RTCSdpType | None = None + sdp: str = '' + + def __post_init__(self) -> None: + if self.type is not None: + self.type = RTCSdpType(self.type) + if self.sdp is None: + msg = 'The SDP of a description may not be None' + raise TypeError(msg) diff --git a/python-webrtc/python/webrtc/models/rtp_parameters.py b/python-webrtc/python/webrtc/models/rtp_parameters.py index 07c4522..4ca1b9d 100644 --- a/python-webrtc/python/webrtc/models/rtp_parameters.py +++ b/python-webrtc/python/webrtc/models/rtp_parameters.py @@ -15,6 +15,7 @@ from typing import Any, ClassVar, TypeVar from webrtc import MediaType, RTCDegradationPreference, RTCPriorityType, TransceiverDirection, wrtc +from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias _NativeCodecT = TypeVar('_NativeCodecT', bound='wrtc.RtpCodec') @@ -65,7 +66,7 @@ def _codec_members(native: wrtc.RtpCodec) -> dict[str, Any]: @dataclass -class RTCRtpCodec: +class RTCRtpCodec(Dictionary): """A codec. Args: @@ -117,7 +118,7 @@ def _matches(self, other: RTCRtpCodec) -> bool: @dataclass -class RTCRtpCodecParameters: +class RTCRtpCodecParameters(Dictionary): """A codec negotiated for a sender or a receiver. Args: @@ -149,7 +150,7 @@ def _from_native(cls, native: wrtc.RtpCodecParameters) -> RTCRtpCodecParameters: @dataclass -class RTCRtpHeaderExtensionParameters: +class RTCRtpHeaderExtensionParameters(Dictionary): """An RTP header extension negotiated for a sender or a receiver. Args: @@ -168,7 +169,7 @@ def _from_native(cls, native: wrtc.RtpExtension) -> RTCRtpHeaderExtensionParamet @dataclass -class RTCRtcpParameters: +class RTCRtcpParameters(Dictionary): """RTCP parameters of a sender or a receiver. Args: @@ -184,7 +185,7 @@ class RTCRtcpParameters: @dataclass -class RTCRtpEncodingParameters: +class RTCRtpEncodingParameters(Dictionary): """An encoding of a sender, one per simulcast layer. Args: @@ -211,6 +212,8 @@ class RTCRtpEncodingParameters: adaptive_ptime: bool = False codec: RTCRtpCodec | None = None + _dictionaries: ClassVar = {'codec': RTCRtpCodec} + @classmethod def _from_native(cls, native: wrtc.RtpEncodingParameters) -> RTCRtpEncodingParameters: priority = min(_BITRATE_PRIORITY, key=lambda p: abs(_BITRATE_PRIORITY[p] - native.bitratePriority)) @@ -280,7 +283,7 @@ def _to_native(self) -> wrtc.RtpEncodingParameters: @dataclass -class RTCRtpReceiveParameters: +class RTCRtpReceiveParameters(Dictionary): """The parameters a receiver receives with. Args: @@ -293,6 +296,12 @@ class RTCRtpReceiveParameters: header_extensions: list[RTCRtpHeaderExtensionParameters] = field(default_factory=list) rtcp: RTCRtcpParameters = field(default_factory=RTCRtcpParameters) + _dictionaries: ClassVar = { + 'codecs': RTCRtpCodecParameters, + 'header_extensions': RTCRtpHeaderExtensionParameters, + 'rtcp': RTCRtcpParameters, + } + @classmethod def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpReceiveParameters: return cls( @@ -306,7 +315,7 @@ def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpReceiveParameters: @dataclass -class RTCRtpSendParameters: +class RTCRtpSendParameters(Dictionary): """The parameters a sender sends with, from :meth:`webrtc.RTCRtpSender.get_parameters`. Only :attr:`encodings` (all but their ``rid``) and :attr:`degradation_preference` can be changed with @@ -328,6 +337,13 @@ class RTCRtpSendParameters: rtcp: RTCRtcpParameters = field(default_factory=RTCRtcpParameters) degradation_preference: RTCDegradationPreference | None = None + _dictionaries: ClassVar = { + 'encodings': RTCRtpEncodingParameters, + 'codecs': RTCRtpCodecParameters, + 'header_extensions': RTCRtpHeaderExtensionParameters, + 'rtcp': RTCRtcpParameters, + } + @classmethod def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpSendParameters: return cls( @@ -348,7 +364,7 @@ def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpSendParameters: @dataclass -class RTCRtpHeaderExtensionCapability: +class RTCRtpHeaderExtensionCapability(Dictionary): """An RTP header extension that can be negotiated. Args: @@ -366,7 +382,7 @@ def _from_native(cls, native: wrtc.RtpHeaderExtensionCapability) -> RTCRtpHeader @dataclass -class RTCRtpCapabilities: +class RTCRtpCapabilities(Dictionary): """The codecs and header extensions a sender or a receiver supports. Args: @@ -377,6 +393,8 @@ class RTCRtpCapabilities: codecs: list[RTCRtpCodec] = field(default_factory=list) header_extensions: list[RTCRtpHeaderExtensionCapability] = field(default_factory=list) + _dictionaries: ClassVar = {'codecs': RTCRtpCodec, 'header_extensions': RTCRtpHeaderExtensionCapability} + @classmethod def _from_native(cls, native: wrtc.RtpCapabilities) -> RTCRtpCapabilities: return cls( diff --git a/python-webrtc/python/webrtc/models/rtp_source.py b/python-webrtc/python/webrtc/models/rtp_source.py index b4d17b2..dafdd89 100644 --- a/python-webrtc/python/webrtc/models/rtp_source.py +++ b/python-webrtc/python/webrtc/models/rtp_source.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from typing import ClassVar +from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias # RFC 6464 and RFC 6465 levels are -dBov, 127 being silence @@ -19,7 +20,7 @@ @dataclass(frozen=True) -class RTCRtpContributingSource: +class RTCRtpContributingSource(Dictionary): """A source of the media an :obj:`webrtc.RTCRtpReceiver` received in the last 10 seconds. Args: diff --git a/python-webrtc/python/webrtc/models/rtp_transceiver_init.py b/python-webrtc/python/webrtc/models/rtp_transceiver_init.py deleted file mode 100644 index f41ee49..0000000 --- a/python-webrtc/python/webrtc/models/rtp_transceiver_init.py +++ /dev/null @@ -1,94 +0,0 @@ -# -# Copyright 2022 Il`ya (Marshal) . All rights reserved. -# -# Use of this source code is governed by a BSD-style license -# that can be found in the LICENSE.md file in the root of the project. -# - -"""The options of a new transceiver.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from webrtc import WebRTCObject, wrtc - -if TYPE_CHECKING: - import webrtc - - -class RtpTransceiverInit(WebRTCObject): - """The options of a new transceiver, for :meth:`webrtc.RTCPeerConnection.add_transceiver`. - - Args: - direction (:obj:`webrtc.TransceiverDirection`, optional): The direction of the transceiver, ``sendrecv`` - by default. - send_encodings (:obj:`list` of :obj:`webrtc.RTCRtpEncodingParameters`, optional): The encodings of its - sender, one per simulcast layer. - streams (:obj:`list` of :obj:`webrtc.MediaStream`, optional): The streams the remote peer receives the track - of its sender in. - """ - - _class = wrtc.RtpTransceiverInit - - def __init__( - self, - direction: webrtc.TransceiverDirection | None = None, - send_encodings: list[webrtc.RTCRtpEncodingParameters] | None = None, - streams: list[webrtc.MediaStream] | None = None, - ) -> None: - super().__init__() - self.__send_encodings = [] - - if direction: - self.direction = direction - if send_encodings: - self.send_encodings = send_encodings - - self.__original_streams = None - if streams: - self.streams = streams - - @property - def direction(self) -> webrtc.TransceiverDirection: - """:obj:`webrtc.TransceiverDirection`: The new transceiver's preferred directionality. - - This value is used to initialize the new :obj:`webrtc.RTCRtpTransceiver` object's - :attr:`webrtc.RTCRtpTransceiver.direction` property. - """ - return self._native_obj.direction - - @direction.setter - def direction(self, value: webrtc.TransceiverDirection) -> None: - self._native_obj.direction = value - - @property - def send_encodings(self) -> list[webrtc.RTCRtpEncodingParameters]: - """:obj:`list` of :obj:`webrtc.RTCRtpEncodingParameters`: The encodings of the sender. - - The encodings to allow when sending RTP media from the :obj:`webrtc.RTCRtpSender`, one per simulcast layer. - """ - return list(self.__send_encodings) - - @send_encodings.setter - def send_encodings(self, value: list[webrtc.RTCRtpEncodingParameters]) -> None: - self.__send_encodings = list(value) - self._native_obj.sendEncodings = [param._to_native() for param in value] - - @property - def streams(self) -> list[webrtc.MediaStream]: - """:obj:`list` of :obj:`webrtc.MediaStream`: The streams of the track of the sender. - - When the remote peer's :obj:`webrtc.RTCPeerConnection`'s track event occurs, these are the streams that will be - specified by that event. - """ - return self.__original_streams - - @streams.setter - def streams(self, value: list[webrtc.MediaStream]) -> None: - self.__original_streams = value - - self._native_obj.streamIds = [stream.id for stream in value] - - #: Alias for :attr:`send_encodings` - sendEncodings = send_encodings diff --git a/python-webrtc/python/webrtc/models/video_frame.py b/python-webrtc/python/webrtc/models/video_frame.py index 1606b2f..dd0b294 100644 --- a/python-webrtc/python/webrtc/models/video_frame.py +++ b/python-webrtc/python/webrtc/models/video_frame.py @@ -12,7 +12,7 @@ import asyncio import math import warnings -from dataclasses import dataclass, fields +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypeVar @@ -28,15 +28,14 @@ wrtc, ) from webrtc.models.closable import Closable -from webrtc.utils.names import Alias, alias, snake_case +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias if TYPE_CHECKING: - from collections.abc import Iterable - from typing_extensions import Buffer _EnumT = TypeVar('_EnumT', bound=Enum) -_InitT = TypeVar('_InitT') +_InitT = TypeVar('_InitT', 'VideoFrameBufferInit', 'VideoFrameInit') _MAX_UNSIGNED_LONG = 2**32 - 1 _RGB_FORMATS = (VideoPixelFormat.RGBA, VideoPixelFormat.RGBX, VideoPixelFormat.BGRA, VideoPixelFormat.BGRX) @@ -80,7 +79,24 @@ def left(self) -> float: @dataclass -class PlaneLayout: +class DOMRectInit(Dictionary): + """A rectangle to give, like the visible part of a new frame. A :obj:`DOMRectReadOnly` is taken as well. + + Args: + x (:obj:`float`, optional): The left edge. + y (:obj:`float`, optional): The top edge. + width (:obj:`float`, optional): The width. + height (:obj:`float`, optional): The height. + """ + + x: float = 0 + y: float = 0 + width: float = 0 + height: float = 0 + + +@dataclass +class PlaneLayout(Dictionary): """Where a plane is in a buffer. Args: @@ -123,6 +139,26 @@ def to_json(self) -> dict[str, Any]: toJSON: ClassVar = to_json +@dataclass +class VideoColorSpaceInit(Dictionary): + """A color space to give a new frame. A :obj:`VideoColorSpace` is taken as well. + + Args: + primaries (:obj:`webrtc.VideoColorPrimaries`, optional): The color primaries. + transfer (:obj:`webrtc.VideoTransferCharacteristics`, optional): The transfer characteristics. + matrix (:obj:`webrtc.VideoMatrixCoefficients`, optional): The matrix coefficients. + full_range (:obj:`bool`, optional): Whether the samples use the full range of their bits. + """ + + primaries: VideoColorPrimaries | None = None + transfer: VideoTransferCharacteristics | None = None + matrix: VideoMatrixCoefficients | None = None + full_range: bool | None = None + + #: Alias for :attr:`full_range` + fullRange: ClassVar[Alias[bool | None]] = alias('full_range') + + _REC709 = VideoColorSpace( VideoColorPrimaries.bt709, VideoTransferCharacteristics.bt709, VideoMatrixCoefficients.bt709, full_range=False ) @@ -139,7 +175,7 @@ def to_json(self) -> dict[str, Any]: @dataclass -class VideoFrameMetadata: +class VideoFrameMetadata(Dictionary): """What else is known of a frame. Args: @@ -153,7 +189,7 @@ class VideoFrameMetadata: @dataclass -class VideoFrameBufferInit: +class VideoFrameBufferInit(Dictionary): """How to create a :obj:`VideoFrame` from a buffer of pixels. Args: @@ -164,67 +200,71 @@ class VideoFrameBufferInit: duration (:obj:`int`, optional): The duration in microseconds. layout (:obj:`list` of :obj:`PlaneLayout`, optional): Where the planes are in the buffer, packed one after another by default. - visible_rect (:obj:`DOMRectReadOnly`, optional): The part of the frame to show, all of it by default. + visible_rect (:obj:`DOMRectInit`, optional): The part of the frame to show, all of it by default. rotation (:obj:`float`, optional): How the frame is rotated clockwise to be shown, rounded to a multiple of 90. flip (:obj:`bool`, optional): Whether the frame is mirrored horizontally to be shown, before the rotation. display_width (:obj:`int`, optional): The width to show the frame at, with ``display_height``. display_height (:obj:`int`, optional): The height to show the frame at, with ``display_width``. - color_space (:obj:`VideoColorSpace`, optional): The color space. + color_space (:obj:`VideoColorSpaceInit`, optional): The color space. """ + _dictionaries: ClassVar = {'layout': PlaneLayout, 'visible_rect': DOMRectInit, 'color_space': VideoColorSpaceInit} + format: VideoPixelFormat coded_width: int coded_height: int timestamp: int duration: int | None = None layout: list[PlaneLayout] | None = None - visible_rect: DOMRectReadOnly | None = None + visible_rect: DOMRectInit | DOMRectReadOnly | None = None rotation: float = 0 flip: bool = False display_width: int | None = None display_height: int | None = None - color_space: VideoColorSpace | None = None + color_space: VideoColorSpaceInit | VideoColorSpace | None = None #: Alias for :attr:`coded_width` codedWidth: ClassVar[Alias[int]] = alias('coded_width') #: Alias for :attr:`coded_height` codedHeight: ClassVar[Alias[int]] = alias('coded_height') #: Alias for :attr:`visible_rect` - visibleRect: ClassVar[Alias[DOMRectReadOnly | None]] = alias('visible_rect') + visibleRect: ClassVar[Alias[DOMRectInit | DOMRectReadOnly | None]] = alias('visible_rect') #: Alias for :attr:`display_width` displayWidth: ClassVar[Alias[int | None]] = alias('display_width') #: Alias for :attr:`display_height` displayHeight: ClassVar[Alias[int | None]] = alias('display_height') #: Alias for :attr:`color_space` - colorSpace: ClassVar[Alias[VideoColorSpace | None]] = alias('color_space') + colorSpace: ClassVar[Alias[VideoColorSpaceInit | VideoColorSpace | None]] = alias('color_space') @dataclass -class VideoFrameInit: +class VideoFrameInit(Dictionary): """How to create a :obj:`VideoFrame` from another one. Members left out are the ones of that frame. Args: timestamp (:obj:`int`, optional): The presentation time in microseconds. duration (:obj:`int`, optional): The duration in microseconds. alpha (:obj:`webrtc.AlphaOption`, optional): Whether the alpha channel is kept. - visible_rect (:obj:`DOMRectReadOnly`, optional): The part of the frame to show. + visible_rect (:obj:`DOMRectInit`, optional): The part of the frame to show. rotation (:obj:`float`, optional): A rotation added to the one of the frame. flip (:obj:`bool`, optional): Whether to mirror the frame, in addition to the frame's own flip. display_width (:obj:`int`, optional): The width to show the frame at, with ``display_height``. display_height (:obj:`int`, optional): The height to show the frame at, with ``display_width``. """ + _dictionaries: ClassVar = {'visible_rect': DOMRectInit} + timestamp: int | None = None duration: int | None = None alpha: AlphaOption = AlphaOption.keep - visible_rect: DOMRectReadOnly | None = None + visible_rect: DOMRectInit | DOMRectReadOnly | None = None rotation: float = 0 flip: bool = False display_width: int | None = None display_height: int | None = None #: Alias for :attr:`visible_rect` - visibleRect: ClassVar[Alias[DOMRectReadOnly | None]] = alias('visible_rect') + visibleRect: ClassVar[Alias[DOMRectInit | DOMRectReadOnly | None]] = alias('visible_rect') #: Alias for :attr:`display_width` displayWidth: ClassVar[Alias[int | None]] = alias('display_width') #: Alias for :attr:`display_height` @@ -232,17 +272,19 @@ class VideoFrameInit: @dataclass -class VideoFrameCopyToOptions: +class VideoFrameCopyToOptions(Dictionary): """How :meth:`VideoFrame.copy_to` copies a frame. Args: - rect (:obj:`DOMRectReadOnly`, optional): The part to copy, the visible one by default. + rect (:obj:`DOMRectInit`, optional): The part to copy, the visible one by default. layout (:obj:`list` of :obj:`PlaneLayout`, optional): Where to put the planes, one after another by default. format (:obj:`webrtc.VideoPixelFormat`, optional): The format to convert to: the frame's own one, or one of ``RGBA``, ``RGBX``, ``BGRA`` and ``BGRX``. """ - rect: DOMRectReadOnly | None = None + _dictionaries: ClassVar = {'rect': DOMRectInit, 'layout': PlaneLayout} + + rect: DOMRectInit | DOMRectReadOnly | None = None layout: list[PlaneLayout] | None = None format: VideoPixelFormat | None = None @@ -342,23 +384,16 @@ def _oriented(width: int, height: int, rotation: int) -> tuple[int, int]: return (height, width) if _is_sideways(rotation) else (width, height) -def _rect(value: object) -> DOMRectReadOnly | None: +def _rect(value: DOMRectInit | DOMRectReadOnly | None) -> DOMRectReadOnly | None: if value is None or isinstance(value, DOMRectReadOnly): return value - if isinstance(value, dict): - return DOMRectReadOnly(**{k: v for k, v in value.items() if k in {'x', 'y', 'width', 'height'}}) - msg = f'{value!r} is not a DOMRectReadOnly' - raise TypeError(msg) + return DOMRectReadOnly(value.x, value.y, value.width, value.height) -def _layout(value: Iterable[PlaneLayout | dict[str, int]] | None) -> list[PlaneLayout] | None: +def _layout(value: list[PlaneLayout] | None) -> list[PlaneLayout] | None: if value is None: return None - layout = [] - for item in value: - plane = PlaneLayout(item['offset'], item['stride']) if isinstance(item, dict) else item - layout.append(PlaneLayout(_dimension(plane.offset, 'offset'), _dimension(plane.stride, 'stride'))) - return layout + return [PlaneLayout(_dimension(plane.offset, 'offset'), _dimension(plane.stride, 'stride')) for plane in value] def _rotation(value: float) -> int: @@ -458,19 +493,9 @@ def _plane_copy( return _PlaneCopy(left_bytes, top, width_bytes, height, layout[index].offset, layout[index].stride) -def _color_space(value: object) -> VideoColorSpace | None: +def _color_space(value: VideoColorSpaceInit | VideoColorSpace | None) -> VideoColorSpace | None: if value is None: return None - if isinstance(value, dict): - value = VideoColorSpace( - primaries=value.get('primaries'), - transfer=value.get('transfer'), - matrix=value.get('matrix'), - full_range=value.get('full_range', value.get('fullRange')), - ) - if not isinstance(value, VideoColorSpace): - msg = f'{value!r} is not a VideoColorSpace' - raise TypeError(msg) return VideoColorSpace( _optional_enum(VideoColorPrimaries, value.primaries), _optional_enum(VideoTransferCharacteristics, value.transfer), @@ -479,30 +504,13 @@ def _color_space(value: object) -> VideoColorSpace | None: ) -def _init_from(init: object, cls: type[_InitT], options: dict[str, object]) -> _InitT: - """The init of a constructor: a dataclass, a dictionary (with snake_case or camelCase names), or keywords.""" - if init is None: - init = options - elif options: - msg = 'Pass either an init or keyword arguments' - raise TypeError(msg) - if isinstance(init, cls): +def _init_of(init: _InitT | None, cls: type[_InitT]) -> _InitT: + """The init of a constructor, which a frame of a buffer requires.""" + if init is not None: return init - if isinstance(init, dict): - names = {f.name for f in fields(cls)} - kwargs = {} - for key, value in init.items(): - name = snake_case(key) - if name not in names: - msg = f'{cls.__name__} has no member {key!r}' - raise TypeError(msg) - kwargs[name] = value - try: - return cls(**kwargs) - except TypeError as e: - msg = f'Invalid {cls.__name__}: {e}' - raise TypeError(msg) from None - msg = f'{init!r} is not a {cls.__name__}' + if cls is VideoFrameInit: + return cls() + msg = f'A VideoFrame of a buffer needs a {cls.__name__}' raise TypeError(msg) @@ -557,7 +565,7 @@ class VideoFrame(Closable): Args: source: A bytes-like buffer of pixels, or a :obj:`VideoFrame` to create another frame of the same pixels. init (:obj:`VideoFrameBufferInit` or :obj:`VideoFrameInit`, optional): How to create the frame, for a buffer - (required) or a frame. A dictionary of its members, or keyword arguments, can be passed instead. + (required) or a frame. Raises: TypeError: If the init isn't valid, or the buffer is too small for it. @@ -565,20 +573,21 @@ class VideoFrame(Closable): Example:: - frame = webrtc.VideoFrame(i420, format='I420', coded_width=640, coded_height=480, timestamp=0) + frame = webrtc.VideoFrame( + i420, webrtc.VideoFrameBufferInit(format='I420', coded_width=640, coded_height=480, timestamp=0) + ) """ def __init__( self, source: Buffer | VideoFrame, - init: VideoFrameBufferInit | VideoFrameInit | dict[str, object] | None = None, - **options: object, + init: VideoFrameBufferInit | VideoFrameInit | None = None, ) -> None: self._resource = None if isinstance(source, VideoFrame): - self._init_from_frame(source, _init_from(init, VideoFrameInit, options)) + self._init_from_frame(source, _init_of(init, VideoFrameInit)) elif _is_buffer(source): - self._init_from_buffer(source, _init_from(init, VideoFrameBufferInit, options)) + self._init_from_buffer(source, _init_of(init, VideoFrameBufferInit)) else: msg = f'A VideoFrame is created from a buffer or a VideoFrame, not {type(source).__name__}' raise TypeError(msg) @@ -759,11 +768,12 @@ def metadata(self) -> VideoFrameMetadata: raise InvalidStateError(msg) return VideoFrameMetadata(self._metadata.rtp_timestamp) - def _plan_copy(self, options: VideoFrameCopyToOptions | dict[str, object] | None) -> _CopyPlan: + def _plan_copy(self, options: VideoFrameCopyToOptions | None) -> _CopyPlan: if self._resource is None: msg = 'The frame is closed' raise InvalidStateError(msg) - options = _init_from(options, VideoFrameCopyToOptions, {}) + if options is None: + options = VideoFrameCopyToOptions() format = self._format if options.format is not None: format = _enum(VideoPixelFormat, options.format) @@ -775,7 +785,7 @@ def _plan_copy(self, options: VideoFrameCopyToOptions | dict[str, object] | None size, planes = _compute_layout(rect, format, _layout(options.layout)) return _CopyPlan(format, rect, size, planes) - def allocation_size(self, options: VideoFrameCopyToOptions | dict[str, object] | None = None) -> int: + def allocation_size(self, options: VideoFrameCopyToOptions | None = None) -> int: """Returns how many bytes :meth:`copy_to` needs. Raises :obj:`webrtc.InvalidStateError` if the frame is closed, :obj:`TypeError` if the options aren't valid @@ -787,7 +797,7 @@ def allocation_size(self, options: VideoFrameCopyToOptions | dict[str, object] | return self._plan_copy(options).size def copy_to( - self, destination: bytearray | memoryview, options: VideoFrameCopyToOptions | dict[str, object] | None = None + self, destination: bytearray | memoryview, options: VideoFrameCopyToOptions | None = None ) -> asyncio.Future[list[PlaneLayout]]: """Copies the pixels into a buffer. @@ -807,7 +817,7 @@ def copy_to( return future def _copy_to( - self, destination: bytearray | memoryview, options: VideoFrameCopyToOptions | dict[str, object] | None + self, destination: bytearray | memoryview, options: VideoFrameCopyToOptions | None ) -> list[PlaneLayout]: plan = self._plan_copy(options) if _buffer_size(destination) < plan.size: diff --git a/tests/chaos.py b/tests/chaos.py index 0575245..3205ec2 100644 --- a/tests/chaos.py +++ b/tests/chaos.py @@ -204,7 +204,9 @@ async def drop_track(self) -> None: async def new_processor(self) -> None: track = self.pick(self.tracks) if track: - processor = webrtc.MediaStreamTrackProcessor(track, max_buffer_size=self.random.randrange(4)) + processor = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=self.random.randrange(4)) + ) track.on('ended', self.handler()) self.processors.append((processor, processor.readable.get_reader())) @@ -243,18 +245,21 @@ async def write(self) -> None: if kind == 'video': width, height = self.random.choice([(2, 2), (33, 17), (320, 240)]) chunk = webrtc.VideoFrame( - bytes(width * height * 4), format='RGBA', coded_width=width, coded_height=height, timestamp=0 + bytes(width * height * 4), + webrtc.VideoFrameBufferInit(format='RGBA', coded_width=width, coded_height=height, timestamp=0), ) else: rate, channels = self.random.choice([(48000, 2), (8000, 1), (44100, 1), (1000, 1), (48000, 20)]) frames = rate // 100 chunk = webrtc.AudioData( - format='s16', - sample_rate=rate, - number_of_frames=frames, - number_of_channels=channels, - timestamp=0, - data=bytes(frames * channels * 2), + webrtc.AudioDataInit( + format='s16', + sample_rate=rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=0, + data=bytes(frames * channels * 2), + ) ) await writer.write(chunk) @@ -270,9 +275,10 @@ async def frame(self) -> None: fmt = self.random.choice(list(webrtc.VideoPixelFormat)) width, height = self.random.randrange(1, 40), self.random.randrange(1, 40) frame = webrtc.VideoFrame( - bytes(width * height * 8), format=fmt, coded_width=width, coded_height=height, timestamp=0 + bytes(width * height * 8), + webrtc.VideoFrameBufferInit(format=fmt, coded_width=width, coded_height=height, timestamp=0), ) - options = self.random.choice([None, {'format': 'RGBA'}, {'format': 'BGRX'}]) + options = self.random.choice([None, *(webrtc.VideoFrameCopyToOptions(format=f) for f in ('RGBA', 'BGRX'))]) await frame.copy_to(bytearray(frame.allocation_size(options)), options) self.frames.append(frame) @@ -284,7 +290,7 @@ async def use_frame(self) -> None: async def pipe(self) -> None: track = self.pick([track for track in self.tracks if track.kind == 'video']) if track: - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) generator = webrtc.VideoTrackGenerator() self.tracks.append(generator.track) self.tasks.append(processor.readable.pipe_through(webrtc.TransformStream()).pipe_to(generator.writable)) @@ -293,7 +299,13 @@ async def constraints(self) -> None: track = self.pick(self.tracks) if track: track.get_settings() - track.apply_constraints(self.random.choice([{'width': 320}, {'frame_rate': 5}, {'width': {'exact': 7}}])) + track.apply_constraints( + self.random.choice([ + webrtc.MediaTrackConstraints(width=320), + webrtc.MediaTrackConstraints(frame_rate=5), + webrtc.MediaTrackConstraints(width=webrtc.ConstrainULongRange(exact=7)), + ]) + ) async def stream(self) -> None: tracks = self.random.sample(self.tracks, min(len(self.tracks), 2)) diff --git a/tests/fuzz/fuzz_audio_data.py b/tests/fuzz/fuzz_audio_data.py index b31d182..c614311 100644 --- a/tests/fuzz/fuzz_audio_data.py +++ b/tests/fuzz/fuzz_audio_data.py @@ -27,8 +27,8 @@ def check_identity(audio: webrtc.AudioData, data: bytes) -> None: """Interleaved samples copied out in their own format are the same bytes.""" if audio.format.value.endswith('-planar'): return - out = bytearray(audio.allocation_size({'plane_index': 0})) - audio.copy_to(out, {'plane_index': 0}) + out = bytearray(audio.allocation_size(webrtc.AudioDataCopyToOptions(plane_index=0))) + audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0)) assert bytes(out) == data[: len(out)], f'{audio!r} copied out differently' @@ -45,12 +45,14 @@ def test_one_input(data: bytes) -> None: source = inp.buffer(size) try: audio = webrtc.AudioData( - format=format, - sample_rate=sample_rate, - number_of_frames=frames, - number_of_channels=channels, - timestamp=inp.integer(), - data=source, + webrtc.AudioDataInit( + format=format, + sample_rate=sample_rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=inp.integer(), + data=source, + ) ) except EXPECTED: return @@ -60,13 +62,13 @@ def test_one_input(data: bytes) -> None: def exercise(inp: Input, audio: webrtc.AudioData) -> None: for _ in range(inp.small(4)): - options: dict[str, object] = {'plane_index': inp.integer(8)} + options = webrtc.AudioDataCopyToOptions(plane_index=inp.integer(8)) if inp.flag(): - options['frame_offset'] = inp.integer(512) + options.frame_offset = inp.integer(512) if inp.flag(): - options['frame_count'] = inp.integer(512) + options.frame_count = inp.integer(512) if inp.flag(): - options['format'] = inp.choice(FORMATS) + options.format = inp.choice(FORMATS) try: size = audio.allocation_size(options) audio.copy_to(inp.destination(min(size, 1 << 20)), options) diff --git a/tests/fuzz/fuzz_generator.py b/tests/fuzz/fuzz_generator.py index 000c4f5..032818a 100644 --- a/tests/fuzz/fuzz_generator.py +++ b/tests/fuzz/fuzz_generator.py @@ -43,7 +43,12 @@ class Session: async def start(self) -> None: self.caller, self.callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() self.processors = [] - self.callee.on('track', lambda event: self.processors.append(webrtc.MediaStreamTrackProcessor(event.track))) + self.callee.on( + 'track', + lambda event: self.processors.append( + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(event.track)) + ), + ) self.senders, self.writers = {}, {} for kind in ('audio', 'video'): generator = webrtc.MediaStreamTrackGenerator(kind) @@ -72,12 +77,14 @@ def audio_data(inp: Input) -> webrtc.AudioData: channels, frames = 1, 480 size = min(frames * channels * SAMPLE_BYTES[format.value.split('-')[0]], 1 << 20) return webrtc.AudioData( - format=format, - sample_rate=rate, - number_of_frames=frames, - number_of_channels=channels, - timestamp=inp.integer(), - data=bytes(size), + webrtc.AudioDataInit( + format=format, + sample_rate=rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=inp.integer(), + data=bytes(size), + ) ) @@ -85,9 +92,9 @@ def video_frame(inp: Input) -> webrtc.VideoFrame: format = inp.choice(PIXEL_FORMATS) width = inp.small(64) + 1 if inp.flag() else inp.choice([1, 2, 3, 15, 16, 17, 639, 640, 1920, 4096]) height = inp.small(64) + 1 if inp.flag() else inp.choice([1, 2, 3, 15, 16, 17, 479, 480, 1080, 4096]) - init = {'format': format, 'coded_width': width, 'coded_height': height, 'timestamp': inp.integer()} + init = webrtc.VideoFrameBufferInit(format=format, coded_width=width, coded_height=height, timestamp=inp.integer()) if inp.flag(): - init['rotation'] = inp.choice([0, 90, 180, 270]) + init.rotation = inp.choice([0, 90, 180, 270]) # enough for every format: 4 planes of 16-bit samples at most return webrtc.VideoFrame(inp.buffer(width * height * 8), init) diff --git a/tests/fuzz/fuzz_video_frame.py b/tests/fuzz/fuzz_video_frame.py index 90810c1..673d498 100644 --- a/tests/fuzz/fuzz_video_frame.py +++ b/tests/fuzz/fuzz_video_frame.py @@ -26,42 +26,44 @@ loop = asyncio.new_event_loop() -async def _copy_to(frame: webrtc.VideoFrame, destination: Buffer, options: dict[str, object] | None) -> None: +async def _copy_to( + frame: webrtc.VideoFrame, destination: Buffer, options: webrtc.VideoFrameCopyToOptions | None +) -> None: await frame.copy_to(destination, options) -def copy_to(frame: webrtc.VideoFrame, destination: Buffer, options: dict[str, object] | None) -> None: +def copy_to(frame: webrtc.VideoFrame, destination: Buffer, options: webrtc.VideoFrameCopyToOptions | None) -> None: loop.run_until_complete(_copy_to(frame, destination, options)) -def rect(inp: Input) -> dict[str, float]: - return {'x': inp.number(), 'y': inp.number(), 'width': inp.number(), 'height': inp.number()} +def rect(inp: Input) -> webrtc.DOMRectInit: + return webrtc.DOMRectInit(inp.number(), inp.number(), inp.number(), inp.number()) -def layout(inp: Input) -> list[dict[str, object]]: - return [{'offset': inp.integer(4096), 'stride': inp.integer(256)} for _ in range(inp.small(4))] +def layout(inp: Input) -> list[webrtc.PlaneLayout]: + return [webrtc.PlaneLayout(inp.integer(4096), inp.integer(256)) for _ in range(inp.small(4))] -def copy_options(inp: Input) -> dict[str, object]: - options: dict[str, object] = {} +def copy_options(inp: Input) -> webrtc.VideoFrameCopyToOptions: + options = webrtc.VideoFrameCopyToOptions() if inp.flag(): - options['rect'] = rect(inp) + options.rect = rect(inp) if inp.flag(): - options['layout'] = layout(inp) + options.layout = layout(inp) if inp.flag(): - options['format'] = inp.choice(FORMATS) + options.format = inp.choice(FORMATS) return options def frame_of_frame(inp: Input, frame: webrtc.VideoFrame) -> webrtc.VideoFrame: - init: dict[str, object] = {'visible_rect': rect(inp)} if inp.flag() else {} + init = webrtc.VideoFrameInit(visible_rect=rect(inp) if inp.flag() else None) if inp.flag(): - init['alpha'] = inp.choice(['keep', 'discard']) + init.alpha = inp.choice([webrtc.AlphaOption.keep, webrtc.AlphaOption.discard]) if inp.flag(): - init['rotation'] = inp.number(360) - init['flip'] = inp.flag() + init.rotation = inp.number(360) + init.flip = inp.flag() if inp.flag(): - init['display_width'], init['display_height'] = inp.integer(), inp.integer() + init.display_width, init.display_height = inp.integer(), inp.integer() return webrtc.VideoFrame(frame, init) @@ -85,7 +87,7 @@ def exercise(inp: Input, frame: webrtc.VideoFrame) -> None: def check_identity(format: webrtc.VideoPixelFormat, size: tuple[int, int], data: Buffer) -> None: """A packed frame copied out as it is gives the same bytes.""" width, height = size - init = {'format': format, 'coded_width': width, 'coded_height': height, 'timestamp': 0} + init = webrtc.VideoFrameBufferInit(format=format, coded_width=width, coded_height=height, timestamp=0) size = webrtc.VideoFrame(bytes(1 << 16), init).allocation_size() if width * height <= 1024 else 0 if not size: return @@ -104,20 +106,15 @@ def test_one_input(data: bytes) -> None: check_identity(format, (width, height), inp.buffer(width * height * 8)) return width, height = inp.integer(), inp.integer() - init: dict[str, object] = { - 'format': format, - 'coded_width': width, - 'coded_height': height, - 'timestamp': inp.integer(), - } + init = webrtc.VideoFrameBufferInit(format=format, coded_width=width, coded_height=height, timestamp=inp.integer()) if inp.flag(): - init['layout'] = layout(inp) + init.layout = layout(inp) if inp.flag(): - init['visible_rect'] = rect(inp) + init.visible_rect = rect(inp) if inp.flag(): - init['rotation'] = inp.number(360) + init.rotation = inp.number(360) if inp.flag(): - init['display_width'], init['display_height'] = inp.integer(), inp.integer() + init.display_width, init.display_height = inp.integer(), inp.integer() try: frame = webrtc.VideoFrame(inp.buffer(inp.small(1 << 15)), init) exercise(inp, frame) diff --git a/tests/helpers.py b/tests/helpers.py index 4b696ae..a5595a0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -200,7 +200,10 @@ async def write_video( timestamp = 0 while not stop.is_set(): await writer.write( - webrtc.VideoFrame(data, format='I420', coded_width=width, coded_height=height, timestamp=timestamp) + webrtc.VideoFrame( + data, + webrtc.VideoFrameBufferInit(format='I420', coded_width=width, coded_height=height, timestamp=timestamp), + ) ) timestamp += round(interval * 1_000_000) await asyncio.sleep(interval) diff --git a/tests/idl/compare.py b/tests/idl/compare.py index f1a610a..2af220c 100644 --- a/tests/idl/compare.py +++ b/tests/idl/compare.py @@ -116,7 +116,11 @@ def lacks(self, label: str, idl_type: Node, annotation: object) -> list[str]: text = _text(annotation) if text is None: return [] - missing = sorted(name for name in self.spec.named_types(idl_type) if not re.search(rf'\b{name}\b', text)) + + def named(name: str) -> bool: + return re.search(rf'\b{name}\b', text) is not None + + missing = sorted(name for name in self.spec.named_types(idl_type, named) if not named(name)) return [f'{label}: type lacks {", ".join(missing)}'] if missing else [] diff --git a/tests/idl/expectations.json b/tests/idl/expectations.json index d4f1a32..fbdd283 100644 --- a/tests/idl/expectations.json +++ b/tests/idl/expectations.json @@ -1,14 +1,9 @@ { - "AudioData": [ - "constructor(**options): extra argument", - "constructor(init): should be required" - ], "AudioDataInit": [ "transfer: missing member" ], "Blob": [ "constructor(blobParts): named parts", - "constructor(blobParts): type lacks Blob", "constructor(options.endings): missing member", "stream: missing method", "textStream: missing method" @@ -19,24 +14,6 @@ "CameraDevicePermissionDescriptor": [ "missing dictionary" ], - "ConstrainBooleanOrDOMStringParameters": [ - "missing dictionary" - ], - "ConstrainBooleanParameters": [ - "missing dictionary" - ], - "ConstrainDOMStringParameters": [ - "missing dictionary" - ], - "ConstrainDoubleRange": [ - "missing dictionary" - ], - "ConstrainULongRange": [ - "missing dictionary" - ], - "DOMRectInit": [ - "missing dictionary" - ], "DOMRectReadOnly": [ "fromRect: missing method", "toJSON: missing method" @@ -89,11 +66,6 @@ "MediaStreamTrackHandle": [ "missing interface" ], - "MediaStreamTrackProcessor": [ - "constructor(**options): extra argument", - "constructor(init.track): should be required", - "constructor(init.track): type lacks MediaStreamTrackHandle" - ], "MediaStreamTrackProcessorInit": [ "track: type lacks MediaStreamTrackHandle" ], @@ -103,26 +75,14 @@ "latency: missing member" ], "MediaTrackConstraintSet": [ - "missing dictionary" + "backgroundBlur: missing member", + "facingMode: missing member", + "latency: missing member" ], "MediaTrackConstraints": [ - "advanced: type lacks MediaTrackConstraintSet", - "aspectRatio: type lacks ConstrainDoubleRange", - "autoGainControl: type lacks ConstrainBooleanParameters", "backgroundBlur: missing member", - "channelCount: type lacks ConstrainULongRange", - "deviceId: type lacks ConstrainDOMStringParameters", - "echoCancellation: type lacks ConstrainBooleanOrDOMStringParameters", "facingMode: missing member", - "frameRate: type lacks ConstrainDoubleRange", - "groupId: type lacks ConstrainDOMStringParameters", - "height: type lacks ConstrainULongRange", - "latency: missing member", - "noiseSuppression: type lacks ConstrainBooleanParameters", - "resizeMode: type lacks ConstrainDOMStringParameters", - "sampleRate: type lacks ConstrainULongRange", - "sampleSize: type lacks ConstrainULongRange", - "width: type lacks ConstrainULongRange" + "latency: missing member" ], "MediaTrackSettings": [ "backgroundBlur: missing member", @@ -212,7 +172,6 @@ "missing dictionary" ], "RTCError": [ - "constructor(init): named options", "http_request_status_code: extra member", "message: extra member" ], @@ -229,9 +188,6 @@ "from_json: extra member", "toJSON(): type lacks RTCIceCandidateInit" ], - "RTCIceCandidateInit": [ - "missing dictionary" - ], "RTCIceCandidatePairStats": [ "missing dictionary" ], @@ -258,7 +214,6 @@ "RTCIceTransport": [ "addRemoteCandidate(remoteCandidate): named candidate", "addRemoteCandidate(remoteCandidate): should be optional", - "addRemoteCandidate(remoteCandidate): type lacks RTCIceCandidateInit", "gatheringState: type lacks RTCIceGathererState", "onerror: missing event", "start(remoteParameters): should be optional" @@ -269,9 +224,6 @@ "RTCLocalIceCandidateInit": [ "missing dictionary" ], - "RTCLocalSessionDescriptionInit": [ - "missing dictionary" - ], "RTCMediaSourceStats": [ "missing dictionary" ], @@ -285,10 +237,8 @@ "missing dictionary" ], "RTCPeerConnection": [ - "addIceCandidate(candidate): type lacks RTCIceCandidateInit", "addTrack(streams): named stream", "addTrack(streams): should be variadic", - "addTransceiver(init): type lacks RTCRtpTransceiverInit", "createAnswer(options): missing argument", "createAnswer(voice_activity_detection): extra argument", "createDataChannel(dataChannelDict): named options", @@ -296,7 +246,6 @@ "generateCertificate(expires): extra argument", "generateCertificate(keygenAlgorithm): named algorithm", "generateCertificate(keygenAlgorithm): should be required", - "setLocalDescription(description): type lacks RTCLocalSessionDescriptionInit", "setRemoteDescription(description): type lacks RTCSessionDescriptionInit" ], "RTCPeerConnectionIceErrorEvent": [ @@ -402,7 +351,7 @@ "missing enum" ], "RTCRtpTransceiverInit": [ - "missing dictionary" + "direction: type lacks RTCRtpTransceiverDirection" ], "RTCSctpTransport": [ "state: type lacks RTCSctpTransportState" @@ -516,29 +465,21 @@ "enqueue(chunk): should be optional", "error(reason): named error" ], - "VideoColorSpaceInit": [ - "missing dictionary" - ], "VideoFacingModeEnum": [ "missing enum" ], "VideoFrame": [ - "constructor(**options): extra argument", "constructor(image): named source" ], "VideoFrameBufferInit": [ - "colorSpace: type lacks VideoColorSpaceInit", "metadata: missing member", - "transfer: missing member", - "visibleRect: type lacks DOMRectInit" + "transfer: missing member" ], "VideoFrameCopyToOptions": [ - "colorSpace: missing member", - "rect: type lacks DOMRectInit" + "colorSpace: missing member" ], "VideoFrameInit": [ - "metadata: missing member", - "visibleRect: type lacks DOMRectInit" + "metadata: missing member" ], "VideoFrameMetadata": [ "rtp_timestamp: extra member" diff --git a/tests/idl/spec.py b/tests/idl/spec.py index b0cfd32..55cbfb7 100644 --- a/tests/idl/spec.py +++ b/tests/idl/spec.py @@ -15,7 +15,10 @@ from dataclasses import dataclass, field from importlib.util import find_spec from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable WPT_ROOT = Path(__file__).resolve().parents[2] / 'wpt' @@ -78,14 +81,14 @@ def members(self, name: str) -> list[Node]: """The members of a definition, inherited ones included.""" return [member for definition in self.lineage(name) for member in definition.members] - def named_types(self, idl_type: Node | list[Node] | str) -> set[str]: - """The definitions of the spec a type refers to, through unions, generics and typedefs.""" + def named_types(self, idl_type: Node | list[Node] | str, known: Callable[[str], bool] | None = None) -> set[str]: + """The definitions a type refers to through unions, generics and typedefs, but not ``known`` typedefs.""" if isinstance(idl_type, list): - return set().union(*(self.named_types(item) for item in idl_type)) + return set().union(*(self.named_types(item, known) for item in idl_type)) if isinstance(idl_type, dict): - return self.named_types(idl_type['idlType']) + return self.named_types(idl_type['idlType'], known) if idl_type in self.typedefs: - return self.named_types(self.typedefs[idl_type]) + return set() if known and known(idl_type) else self.named_types(self.typedefs[idl_type], known) return {idl_type} if idl_type in self.definitions else set() def dictionary(self, idl_type: Node) -> Definition | None: diff --git a/tests/rtc_peer_connection/test_add_track.py b/tests/rtc_peer_connection/test_add_track.py index c5e6d23..898926e 100644 --- a/tests/rtc_peer_connection/test_add_track.py +++ b/tests/rtc_peer_connection/test_add_track.py @@ -84,7 +84,7 @@ def test_5(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> No def test_6(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> None: """add_track with existing sender with None track, same kind, and recvonly direction should reuse sender.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.sender.track is None @@ -141,7 +141,7 @@ async def test_8( def test_9(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> None: """add_track with existing recvonly sender with null track of a different kind should create new sender.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.video, init) assert transceiver.sender.track is None diff --git a/tests/rtc_peer_connection/test_add_transceiver.py b/tests/rtc_peer_connection/test_add_transceiver.py index 81afd7a..2ba5628 100644 --- a/tests/rtc_peer_connection/test_add_transceiver.py +++ b/tests/rtc_peer_connection/test_add_transceiver.py @@ -70,16 +70,16 @@ def test_3(pc: webrtc.RTCPeerConnection) -> None: def test_4(pc: webrtc.RTCPeerConnection) -> None: """add_transceiver with direction inactive should have result transceiver.direction be the same.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.inactive) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.inactive) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.direction == webrtc.TransceiverDirection.inactive -def test_5(pc: webrtc.RTCPeerConnection) -> None: - """add_transceiver with invalid direction should throw TypeError.""" - with pytest.raises(TypeError): - pc.add_transceiver(webrtc.MediaType.audio, {'direction': 'invalid'}) +def test_5() -> None: + """An init with an invalid direction can't be created, so add_transceiver can't get one.""" + with pytest.raises(ValueError, match='not a valid TransceiverDirection'): + webrtc.RTCRtpTransceiverInit(direction='invalid') def test_6(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> None: @@ -147,7 +147,7 @@ def test_7(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> No def test_8(pc: webrtc.RTCPeerConnection, kind: webrtc.MediaType) -> None: """add_transceiver with rid containing invalid non-alphanumeric characters should throw ValueError.""" encodings = [webrtc.RTCRtpEncodingParameters(rid='@Invalid!')] - init = webrtc.RtpTransceiverInit(send_encodings=encodings) + init = webrtc.RTCRtpTransceiverInit(send_encodings=encodings) with pytest.raises(ValueError, match='is not a valid rid'): pc.add_transceiver(kind, init) @@ -157,7 +157,7 @@ def test_8(pc: webrtc.RTCPeerConnection, kind: webrtc.MediaType) -> None: def test_9(pc: webrtc.RTCPeerConnection, kind: webrtc.MediaType) -> None: """add_transceiver with rid longer than 16 characters should throw ValueError.""" encodings = [webrtc.RTCRtpEncodingParameters(rid='a' * 17)] - init = webrtc.RtpTransceiverInit(send_encodings=encodings) + init = webrtc.RTCRtpTransceiverInit(send_encodings=encodings) with pytest.raises(ValueError, match='is not a valid rid'): pc.add_transceiver(kind, init) @@ -167,7 +167,7 @@ def test_9(pc: webrtc.RTCPeerConnection, kind: webrtc.MediaType) -> None: def test_10(pc: webrtc.RTCPeerConnection, kind: webrtc.MediaType) -> None: """add_transceiver with valid rid value should succeed.""" encodings = [webrtc.RTCRtpEncodingParameters(rid='foo')] - init = webrtc.RtpTransceiverInit(send_encodings=encodings) + init = webrtc.RTCRtpTransceiverInit(send_encodings=encodings) pc.add_transceiver(kind, init) @@ -175,13 +175,13 @@ def test_11(pc: webrtc.RTCPeerConnection) -> None: """add_transceiver with valid sendEncodings should succeed.""" # dtx and ptime from the original test aren't supported by RTCRtpEncodingParameters encodings = [webrtc.RTCRtpEncodingParameters(active=False, max_bitrate=8, max_framerate=25, rid='foo')] - init = webrtc.RtpTransceiverInit(send_encodings=encodings) + init = webrtc.RTCRtpTransceiverInit(send_encodings=encodings) pc.add_transceiver(webrtc.MediaType.video, init) def test_12(pc: webrtc.RTCPeerConnection) -> None: """add_transceiver with direction sendonly should have result transceiver.direction be the same.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.direction == webrtc.TransceiverDirection.sendonly @@ -190,5 +190,5 @@ def test_12(pc: webrtc.RTCPeerConnection) -> None: def test_13(pc: webrtc.RTCPeerConnection) -> None: """add_transceiver with multiple rid values should succeed.""" encodings = [webrtc.RTCRtpEncodingParameters(rid='a'), webrtc.RTCRtpEncodingParameters(rid='b')] - init = webrtc.RtpTransceiverInit(send_encodings=encodings) + init = webrtc.RTCRtpTransceiverInit(send_encodings=encodings) pc.add_transceiver(webrtc.MediaType.video, init) diff --git a/tests/rtc_rtp_transceiver/test_direction.py b/tests/rtc_rtp_transceiver/test_direction.py index fa35589..9fb08be 100644 --- a/tests/rtc_rtp_transceiver/test_direction.py +++ b/tests/rtc_rtp_transceiver/test_direction.py @@ -27,7 +27,7 @@ def test_1(pc: webrtc.RTCPeerConnection) -> None: def test_2(pc: webrtc.RTCPeerConnection) -> None: """Setting direction with same direction should have no effect.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.direction == webrtc.TransceiverDirection.sendonly @@ -38,7 +38,7 @@ def test_2(pc: webrtc.RTCPeerConnection) -> None: @pytest.mark.asyncio async def test_3(pc: webrtc.RTCPeerConnection) -> None: """Setting direction should change transceiver.direction independent of transceiver.currentDirection.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.direction == webrtc.TransceiverDirection.recvonly diff --git a/tests/rtc_rtp_transceiver/test_stop.py b/tests/rtc_rtp_transceiver/test_stop.py index c36e08d..b87a910 100644 --- a/tests/rtc_rtp_transceiver/test_stop.py +++ b/tests/rtc_rtp_transceiver/test_stop.py @@ -16,7 +16,7 @@ @pytest.mark.asyncio async def test_1(pc: webrtc.RTCPeerConnection) -> None: """A transceiver added and stopped before the initial offer should not get an m-section in it.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) pc.add_transceiver(webrtc.MediaType.audio, init) pc.add_transceiver(webrtc.MediaType.video) pc.get_transceivers()[0].stop() @@ -29,7 +29,7 @@ async def test_1(pc: webrtc.RTCPeerConnection) -> None: def test_2(pc: webrtc.RTCPeerConnection) -> None: """A transceiver added and stopped should not crash when getting receiver's transport.""" - init = webrtc.RtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) pc.add_transceiver(webrtc.MediaType.audio, init) pc.add_transceiver(webrtc.MediaType.video) diff --git a/tests/test_audio_data.py b/tests/test_audio_data.py index 6e45f27..de4a5a6 100644 --- a/tests/test_audio_data.py +++ b/tests/test_audio_data.py @@ -27,13 +27,15 @@ def audio_data( ) -> webrtc.AudioData: size = {'u8': 1, 's16': 2}.get(format.split('-', maxsplit=1)[0], 4) return webrtc.AudioData( - format=format, - sample_rate=8000, - number_of_frames=frames, - number_of_channels=channels, - timestamp=1234, - data=data if data is not None else bytes(channels * frames * size), - **init, + webrtc.AudioDataInit( + format=format, + sample_rate=8000, + number_of_frames=frames, + number_of_channels=channels, + timestamp=1234, + data=data if data is not None else bytes(channels * frames * size), + **init, + ) ) @@ -48,8 +50,8 @@ def test_construct() -> None: audio.close() -def test_init_as_dictionary() -> None: - """The init is also a dictionary with camelCase names.""" +def test_init_from_json() -> None: + """The init comes from its JSON form with camelCase names, a dictionary isn't taken itself.""" init = { 'format': 's16', 'sampleRate': 48000, @@ -57,9 +59,12 @@ def test_init_as_dictionary() -> None: 'numberOfChannels': 1, 'timestamp': -10, 'data': bytes(960), + 'transfer': [], } - audio = webrtc.AudioData(init) + audio = webrtc.AudioData(webrtc.AudioDataInit.from_json(init)) assert audio.timestamp == -10 + options = webrtc.AudioDataCopyToOptions.from_json({'planeIndex': 0, 'frameCount': 2}) + assert audio.allocation_size(options) == 4 audio.close() @@ -86,7 +91,7 @@ def test_close_and_clone() -> None: audio.close() assert (audio.format, audio.sample_rate, audio.number_of_frames, audio.number_of_channels) == (None, 0, 0, 0) with pytest.raises(webrtc.InvalidStateError): - audio.copy_to(bytearray(20), {'plane_index': 0}) + audio.copy_to(bytearray(20), webrtc.AudioDataCopyToOptions(plane_index=0)) assert clone.number_of_frames == 5 clone.close() @@ -106,11 +111,11 @@ def test_copy_to_interleaved_and_planar() -> None: """Planar data copies to an interleaved format with every channel, and back one channel at a time.""" audio = audio_data(data=f32(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) out = bytearray(40) - audio.copy_to(out, {'planeIndex': 0, 'format': 'f32'}) + audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0, format='f32')) assert array.array('f', out).tolist() == [1, 6, 2, 7, 3, 8, 4, 9, 5, 10] interleaved = audio_data(format='f32', data=bytes(out)) plane = bytearray(20) - interleaved.copy_to(plane, {'plane_index': 1, 'format': 'f32-planar'}) + interleaved.copy_to(plane, webrtc.AudioDataCopyToOptions(plane_index=1, format='f32-planar')) assert array.array('f', plane).tolist() == [6, 7, 8, 9, 10] audio.close() interleaved.close() @@ -119,13 +124,13 @@ def test_copy_to_interleaved_and_planar() -> None: @pytest.mark.parametrize( 'options', [ - {'plane_index': 2}, - {'plane_index': 1, 'format': 'f32'}, - {'plane_index': 0, 'frame_offset': 5}, - {'plane_index': 0, 'frame_offset': 1, 'frame_count': 5}, + webrtc.AudioDataCopyToOptions(plane_index=2), + webrtc.AudioDataCopyToOptions(plane_index=1, format='f32'), + webrtc.AudioDataCopyToOptions(plane_index=0, frame_offset=5), + webrtc.AudioDataCopyToOptions(plane_index=0, frame_offset=1, frame_count=5), ], ) -def test_copy_ranges(options: dict[str, object]) -> None: +def test_copy_ranges(options: webrtc.AudioDataCopyToOptions) -> None: """Planes and frames that don't exist are a RangeError.""" audio = audio_data() with pytest.raises(webrtc.InvalidRangeError): @@ -137,7 +142,7 @@ def test_destination_too_small() -> None: """A destination smaller than the copy is a RangeError.""" audio = audio_data() with pytest.raises(webrtc.InvalidRangeError): - audio.copy_to(bytearray(19), {'plane_index': 0}) + audio.copy_to(bytearray(19), webrtc.AudioDataCopyToOptions(plane_index=0)) audio.close() @@ -158,7 +163,7 @@ def test_sample_conversions(source: str, destination: str) -> None: audio = audio_data(format=source, channels=1, frames=4, data=array.array(code, values).tobytes()) expected, destination_code = VALUES[destination] out = array.array(destination_code, [0] * 4) - audio.copy_to(memoryview(out).cast('B'), {'plane_index': 0, 'format': destination}) + audio.copy_to(memoryview(out).cast('B'), webrtc.AudioDataCopyToOptions(plane_index=0, format=destination)) # a coarser source can't reach the extremes of a finer destination exactly tolerance = {'u8': 1, 's16': 256, 's32': 2**24, 'f32': 1 / 64}[destination] for got, want in zip(out.tolist(), expected): @@ -173,7 +178,7 @@ def test_non_finite_f32_samples_convert(destination: str) -> None: audio = audio_data(format='f32', channels=1, frames=3, data=array.array('f', values).tobytes()) silence, maximum, minimum = VALUES[destination][0][3], VALUES[destination][0][1], VALUES[destination][0][0] out = array.array(VALUES[destination][1], [1] * 3) - audio.copy_to(memoryview(out).cast('B'), {'plane_index': 0, 'format': destination}) + audio.copy_to(memoryview(out).cast('B'), webrtc.AudioDataCopyToOptions(plane_index=0, format=destination)) assert out.tolist() == [silence, maximum, minimum] audio.close() @@ -182,6 +187,6 @@ def test_s16_bytes_are_little_endian() -> None: """s16 samples are little endian, scaled by 1/32768 to f32.""" audio = audio_data(format='s16', channels=1, frames=2, data=struct.pack('<2h', 1, -1)) out = bytearray(8) - audio.copy_to(out, {'plane_index': 0, 'format': 'f32'}) + audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0, format='f32')) assert array.array('f', out).tolist() == [1 / 32768, -1 / 32768] audio.close() diff --git a/tests/test_configuration.py b/tests/test_configuration.py index d3d58ef..5d30979 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -116,16 +116,31 @@ def test_ice_server_of_ipv6_address(create_pc: CreatePC) -> None: def test_oauth_ice_server() -> None: """An OAuth credential is an RTCOAuthCredential, which libwebrtc doesn't support.""" - server = {'urls': 'turns:turn.example.org', 'username': 'user', 'credential': 'cred', 'credential_type': 'oauth'} + server = webrtc.RTCIceServer('turns:turn.example.org', 'user', 'cred', credential_type='oauth') with pytest.raises(webrtc.InvalidAccessError): webrtc.RTCPeerConnection(webrtc.RTCConfiguration(ice_servers=[server])) - server['credential'] = webrtc.RTCOAuthCredential( + server.credential = webrtc.RTCOAuthCredential( mac_key=base64.b64encode(b'key').decode(), access_token=base64.b64encode(b'token').decode() ) with pytest.raises(webrtc.NotSupportedError): webrtc.RTCPeerConnection(webrtc.RTCConfiguration(ice_servers=[server])) +def test_configuration_from_json() -> None: + """Nested dictionaries are converted, with camelCase names.""" + configuration = webrtc.RTCConfiguration.from_json({ + 'iceServers': [ + {'urls': 'stun:stun.example.org'}, + {'urls': 'turns:turn.example.org', 'credential': {'macKey': 'a2V5', 'accessToken': 'dG9rZW4='}}, + ], + 'iceTransportPolicy': 'relay', + }) + stun, turn = configuration.ice_servers + assert stun == webrtc.RTCIceServer('stun:stun.example.org') + assert turn.credential == webrtc.RTCOAuthCredential('a2V5', 'dG9rZW4=') + assert configuration.ice_transport_policy == 'relay' + + @pytest.mark.asyncio async def test_always_negotiate_data_channels_and_header_encryption(create_pc: CreatePC) -> None: """As configured, an offer always has a data section and encrypts header extensions; neither can change.""" @@ -163,21 +178,30 @@ async def test_generate_ecdsa_certificate() -> None: @pytest.mark.asyncio async def test_generate_rsa_certificate() -> None: """An RSA certificate is generated from WebCrypto parameters.""" - rsa = await webrtc.RTCCertificate.generate({ - 'name': 'RSASSA-PKCS1-v1_5', - 'modulus_length': 1024, - 'public_exponent': 65537, - 'hash': 'SHA-256', - }) + rsa = await webrtc.RTCCertificate.generate( + webrtc.RsaHashedKeyGenParams( + 'RSASSA-PKCS1-v1_5', modulus_length=1024, public_exponent=b'\x01\x00\x01', hash='SHA-256' + ) + ) assert not rsa.expired @pytest.mark.asyncio @pytest.mark.parametrize( 'algorithm', - ['nonsense', {'name': 'RSASSA-PKCS1-v1_5', 'modulusLength': 2048, 'publicExponent': 3, 'hash': 'SHA-1'}], + [ + 'nonsense', + webrtc.Algorithm('RSASSA-PKCS1-v1_5'), + webrtc.EcKeyGenParams('ECDSA', named_curve='P-384'), + webrtc.RsaHashedKeyGenParams.from_json({ + 'name': 'RSASSA-PKCS1-v1_5', + 'modulusLength': 2048, + 'publicExponent': bytes([3]), + 'hash': {'name': 'SHA-1'}, + }), + ], ) -async def test_generate_unsupported_certificate(algorithm: str | dict[str, object]) -> None: +async def test_generate_unsupported_certificate(algorithm: str | webrtc.Algorithm) -> None: """Algorithms other than ECDSA and RSASSA-PKCS1-v1_5 with SHA-256 and the exponent 65537 aren't supported.""" with pytest.raises(webrtc.NotSupportedError): await webrtc.RTCCertificate.generate(algorithm) @@ -277,21 +301,22 @@ def test_rtc_error() -> None: webrtc.RTCErrorInit('nonsense') -def test_rtc_error_options_dict() -> None: - """Options can be a dictionary with camelCase names, as in browsers.""" - error = webrtc.RTCError({'errorDetail': 'sdp-syntax-error', 'sdpLineNumber': 3, 'unknown': 1}, 'bad') +def test_rtc_error_init_from_json() -> None: + """The init can come from its JSON form, with camelCase names and unknown ones ignored.""" + init = webrtc.RTCErrorInit.from_json({'errorDetail': 'sdp-syntax-error', 'sdpLineNumber': 3, 'unknown': 1}) + error = webrtc.RTCError(init, 'bad') assert error.error_detail == webrtc.RTCErrorDetailType.sdp_syntax_error assert error.sdp_line_number == 3 assert str(error) == 'bad' with pytest.raises(TypeError, match='error_detail'): - webrtc.RTCError({}) + webrtc.RTCErrorInit.from_json({}) @pytest.mark.asyncio async def test_description_errors(pc: webrtc.RTCPeerConnection) -> None: """An answer without an offer is in the wrong state, and invalid SDP is an RTCError of its syntax.""" with pytest.raises(webrtc.InvalidStateError): - await pc.set_remote_description({'type': 'answer', 'sdp': 'invalid'}) + await pc.set_remote_description(webrtc.RTCSessionDescriptionInit('answer', 'invalid')) with pytest.raises(webrtc.RTCError) as info: await pc.set_remote_description(webrtc.RTCSessionDescription('offer', 'v=0\r\nnonsense')) assert info.value.error_detail == webrtc.RTCErrorDetailType.sdp_syntax_error @@ -305,7 +330,9 @@ async def test_created_descriptions(pc: webrtc.RTCPeerConnection) -> None: assert isinstance(offer, webrtc.RTCSessionDescriptionInit) assert offer.to_json() == {'type': 'offer', 'sdp': offer.sdp} with pytest.raises(webrtc.InvalidModificationError): - await pc.set_local_description({'type': 'offer', 'sdp': offer.sdp.replace('a=mid:0', 'a=mid:1')}) + await pc.set_local_description( + webrtc.RTCSessionDescriptionInit('offer', offer.sdp.replace('a=mid:0', 'a=mid:1')) + ) await pc.set_local_description(offer) # not compared by identity: gathered candidates change the description (and its object) between reads assert pc.pending_local_description.type == pc.local_description.type == offer.type @@ -320,7 +347,7 @@ async def test_provisional_answers_without_sdp( caller.add_transceiver(webrtc.MediaType.audio) await exchange_offer(caller, callee) - await callee.set_local_description({'type': 'pranswer'}) + await callee.set_local_description(webrtc.RTCSessionDescriptionInit('pranswer')) assert callee.signaling_state == webrtc.RTCSignalingState.have_local_pranswer assert callee.pending_local_description.type == webrtc.RTCSdpType.pranswer # without a type, the final answer diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index 4f1e106..d698ad1 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -21,9 +21,10 @@ async def open_pair( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, **options: object ) -> tuple[webrtc.RTCDataChannel, webrtc.RTCDataChannel]: """Opens a channel of the caller and returns it with its remote end.""" - channel = caller.create_data_channel('chat', options) - if options.get('negotiated'): - remote = callee.create_data_channel('chat', options) + init = webrtc.RTCDataChannelInit(**options) + channel = caller.create_data_channel('chat', init) + if init.negotiated: + remote = callee.create_data_channel('chat', init) opened = [wait_for_event(channel, 'open'), wait_for_event(remote, 'open')] await connect(caller, callee) await asyncio.gather(*opened) @@ -117,24 +118,26 @@ def on_remote_closing(_event: webrtc.Event) -> None: def test_invalid_data_channel_init(pc: webrtc.RTCPeerConnection, init: dict[str, object], error: str) -> None: """A channel has at most one of the limits, and a negotiated one an id in range.""" with pytest.raises(ValueError, match=error): - pc.create_data_channel('x', init) + pc.create_data_channel('x', webrtc.RTCDataChannelInit(**init)) def test_id_is_ignored_unless_negotiated(pc: webrtc.RTCPeerConnection) -> None: """The id of a channel that isn't negotiated is chosen once SCTP is up.""" - assert pc.create_data_channel('x', {'id': 65535}).id is None + assert pc.create_data_channel('x', webrtc.RTCDataChannelInit(id=65535)).id is None def test_id_taken(pc: webrtc.RTCPeerConnection) -> None: """Two negotiated channels can't have the same id.""" - pc.create_data_channel('taken', {'negotiated': True, 'id': 1}) + pc.create_data_channel('taken', webrtc.RTCDataChannelInit(negotiated=True, id=1)) with pytest.raises(webrtc.OperationError): pc.create_data_channel('again', webrtc.RTCDataChannelInit(negotiated=True, id=1)) def test_data_channel_options(pc: webrtc.RTCPeerConnection) -> None: """A new channel has its options and is connecting, and only sends text and bytes.""" - channel = pc.create_data_channel('x', {'priority': webrtc.RTCPriorityType.high, 'ordered': False}) + channel = pc.create_data_channel( + 'x', webrtc.RTCDataChannelInit(priority=webrtc.RTCPriorityType.high, ordered=False) + ) assert channel.priority == webrtc.RTCPriorityType.high assert channel.ordered is False assert channel.ready_state == webrtc.RTCDataChannelState.connecting diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 99e80e5..bc41294 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -66,12 +66,14 @@ async def test_peers_connect_and_send_audio(caller: webrtc.RTCPeerConnection, ca for i in range(10): await writer.write( webrtc.AudioData( - format='s16', - sample_rate=48000, - number_of_frames=480, - number_of_channels=1, - timestamp=i * 10_000, - data=bytes(480 * 2), + webrtc.AudioDataInit( + format='s16', + sample_rate=48000, + number_of_frames=480, + number_of_channels=1, + timestamp=i * 10_000, + data=bytes(480 * 2), + ) ) ) await asyncio.sleep(0.01) diff --git a/tests/test_enums.py b/tests/test_enums.py index 78338d1..70b1e9d 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -71,4 +71,4 @@ def test_invalid_values_are_type_errors(pc: webrtc.RTCPeerConnection) -> None: def test_data_channel_priority(pc: webrtc.RTCPeerConnection) -> None: """Every priority of a data channel round-trips through libwebrtc.""" for priority in webrtc.RTCPriorityType: - assert pc.create_data_channel('x', {'priority': priority.value}).priority is priority + assert pc.create_data_channel('x', webrtc.RTCDataChannelInit(priority=priority.value)).priority is priority diff --git a/tests/test_events.py b/tests/test_events.py index 7b43ae2..8992d8b 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -84,7 +84,7 @@ def on_change(_event: webrtc.Event) -> None: await pc.set_local_description() assert states == [webrtc.RTCSignalingState.have_local_offer] - await pc.set_local_description({'type': 'rollback'}) + await pc.set_local_description(webrtc.RTCSessionDescriptionInit('rollback')) assert states == [webrtc.RTCSignalingState.have_local_offer, webrtc.RTCSignalingState.stable] diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 970520d..fa93b1e 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -531,7 +531,7 @@ def test_generator_track_stays_ended_without_its_wrapper() -> None: def processor_with_handler_on_its_track() -> webrtc.MediaStreamTrackProcessor: track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) track.on('ended', lambda _: processor.readable) track.stop() return processor @@ -545,7 +545,7 @@ def stream_with_handler_on_its_track() -> webrtc.MediaStream: def processor_of_generator_with_handler() -> webrtc.MediaStreamTrackProcessor: generator = webrtc.MediaStreamTrackGenerator('video') - processor = webrtc.MediaStreamTrackProcessor(generator) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(generator)) generator.on('ended', lambda _: processor.readable) generator.stop() return processor @@ -638,10 +638,13 @@ async def session() -> None: received = wait_for_event(callee, 'track') await connect(caller, callee) remote = (await received).track - reader = webrtc.MediaStreamTrackProcessor(remote).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(remote)).readable.get_reader() writer = generator.writable.get_writer() await writer.write( - webrtc.VideoFrame(bytes(64 * 48 * 4), format='RGBA', coded_width=64, coded_height=48, timestamp=0) + webrtc.VideoFrame( + bytes(64 * 48 * 4), + webrtc.VideoFrameBufferInit(format='RGBA', coded_width=64, coded_height=48, timestamp=0), + ) ) (await asyncio.wait_for(reader.read(), 5)).value.close() await caller.get_stats() diff --git a/tests/test_media_e2e.py b/tests/test_media_e2e.py index 9178698..8a28e1d 100644 --- a/tests/test_media_e2e.py +++ b/tests/test_media_e2e.py @@ -38,12 +38,14 @@ async def write_sine(generator: webrtc.MediaStreamTrackGenerator, frequency: flo 'h', (int(12000 * math.sin(2 * math.pi * frequency * (written + i) / 48000)) for i in range(480)) ) audio = webrtc.AudioData( - format='s16', - sample_rate=48000, - number_of_frames=480, - number_of_channels=1, - timestamp=written * 1_000_000 // 48000, - data=samples.tobytes(), + webrtc.AudioDataInit( + format='s16', + sample_rate=48000, + number_of_frames=480, + number_of_channels=1, + timestamp=written * 1_000_000 // 48000, + data=samples.tobytes(), + ) ) await writer.write(audio) written += 480 @@ -64,8 +66,8 @@ async def read_frames(reader: webrtc.ReadableStreamDefaultReader, count: int) -> assert (frame.coded_width, frame.coded_height) == (WIDTH, HEIGHT) assert frame.metadata().rtp_timestamp > 0 timestamps.append(frame.timestamp) - rgba = bytearray(frame.allocation_size({'format': 'RGBA'})) - await frame.copy_to(rgba, {'format': 'RGBA'}) + rgba = bytearray(frame.allocation_size(webrtc.VideoFrameCopyToOptions(format='RGBA'))) + await frame.copy_to(rgba, webrtc.VideoFrameCopyToOptions(format='RGBA')) frame.close() return timestamps, rgba @@ -77,7 +79,9 @@ async def test_video_through_a_connection(caller: webrtc.RTCPeerConnection, call # pure red in BT.601 limited range async with writing(write_video, generator, solid_i420(81, 90, 240), (WIDTH, HEIGHT)): remote = await connect_track(caller, callee, generator.track, timeout=TIMEOUT) - reader = webrtc.MediaStreamTrackProcessor(remote, max_buffer_size=5).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(remote, max_buffer_size=5) + ).readable.get_reader() timestamps, rgba = await read_frames(reader, 10) await reader.cancel() generator.track.stop() @@ -98,13 +102,17 @@ async def test_audio_through_a_connection(caller: webrtc.RTCPeerConnection, call generator = webrtc.MediaStreamTrackGenerator('audio') async with writing(write_sine, generator, 440): remote = await connect_track(caller, callee, generator, timeout=TIMEOUT) - reader = webrtc.MediaStreamTrackProcessor(remote, max_buffer_size=100).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(remote, max_buffer_size=100) + ).readable.get_reader() samples = [] for chunk in range(150): audio = (await asyncio.wait_for(reader.read(), TIMEOUT)).value assert audio.sample_rate == 48000 plane = array.array('f', [0.0] * audio.number_of_frames) - audio.copy_to(memoryview(plane).cast('B'), {'plane_index': 0, 'format': 'f32-planar'}) + audio.copy_to( + memoryview(plane).cast('B'), webrtc.AudioDataCopyToOptions(plane_index=0, format='f32-planar') + ) audio.close() # the first half second is the jitter buffer filling up if chunk >= 50: @@ -124,7 +132,7 @@ async def test_remote_track_end_closes_the_processor( generator = webrtc.VideoTrackGenerator() async with writing(write_video, generator, solid_i420(128, 128, 128), (WIDTH, HEIGHT)): remote = await connect_track(caller, callee, generator.track, timeout=TIMEOUT) - reader = webrtc.MediaStreamTrackProcessor(remote).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(remote)).readable.get_reader() (await asyncio.wait_for(reader.read(), TIMEOUT)).value.close() callee.close() await asyncio.wait_for(reader.closed, TIMEOUT) diff --git a/tests/test_media_stream_track_processor.py b/tests/test_media_stream_track_processor.py index 804f538..ddd323d 100644 --- a/tests/test_media_stream_track_processor.py +++ b/tests/test_media_stream_track_processor.py @@ -27,7 +27,8 @@ def i420(width: int, height: int) -> bytes: def video_frame(timestamp: int, width: int = 4, height: int = 2) -> webrtc.VideoFrame: return webrtc.VideoFrame( - i420(width, height), format='I420', coded_width=width, coded_height=height, timestamp=timestamp + i420(width, height), + webrtc.VideoFrameBufferInit(format='I420', coded_width=width, coded_height=height, timestamp=timestamp), ) @@ -39,7 +40,7 @@ async def read(reader: webrtc.ReadableStreamDefaultReader) -> webrtc.ReadableStr async def test_video_frames_of_a_camera(video_stream: webrtc.MediaStream) -> None: """A processor of a video track reads its frames, and closes when the track stops.""" track = video_stream.get_tracks()[0] - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) reader = processor.readable.get_reader() result = await read(reader) assert not result.done @@ -58,7 +59,7 @@ async def test_video_frames_of_a_camera(video_stream: webrtc.MediaStream) -> Non async def test_audio_data_of_a_microphone(audio_stream: webrtc.MediaStream) -> None: """A processor of an audio track reads its samples, 10 ms at a time.""" track = audio_stream.get_tracks()[0] - reader = webrtc.MediaStreamTrackProcessor(track=track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() audio = (await read(reader)).value assert isinstance(audio, webrtc.AudioData) assert audio.format == webrtc.AudioSampleFormat.s16 @@ -69,19 +70,18 @@ async def test_audio_data_of_a_microphone(audio_stream: webrtc.MediaStream) -> N def test_init_forms() -> None: - """The processor takes a track, an init or a dictionary, and rejects anything else.""" + """The processor takes its init, also from its JSON form, with a buffer size in range.""" generator = webrtc.VideoTrackGenerator() track = generator.track for processor in ( - webrtc.MediaStreamTrackProcessor(track, max_buffer_size=3), - webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, 3)), - webrtc.MediaStreamTrackProcessor({'track': track, 'maxBufferSize': 3}), + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=3)), + webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit.from_json({'track': track, 'maxBufferSize': 3}) + ), ): assert isinstance(processor.readable, webrtc.ReadableStream) with pytest.raises(TypeError): - webrtc.MediaStreamTrackProcessor(generator) - with pytest.raises(TypeError): - webrtc.MediaStreamTrackProcessor(track, max_buffer_size=70000) + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=70000)) track.stop() @@ -89,7 +89,7 @@ def test_init_forms() -> None: async def test_full_buffer_drops_the_oldest_frames(video_stream: webrtc.MediaStream) -> None: """Frames nobody reads are dropped once the buffer is full, oldest first, and counted.""" track = video_stream.get_tracks()[0] - processor = webrtc.MediaStreamTrackProcessor(track, max_buffer_size=2) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=2)) reader = processor.readable.get_reader() await wait_until(lambda: processor.total_frames >= 5, 'frames to arrive') # the total first: frames keep arriving, and the discarded ones are the total less the 2 queued at any time @@ -104,7 +104,7 @@ async def test_full_buffer_drops_the_oldest_frames(video_stream: webrtc.MediaStr @pytest.mark.asyncio async def test_cancel_stops_reading(video_stream: webrtc.MediaStream) -> None: """Canceling the stream detaches the processor from the track.""" - processor = webrtc.MediaStreamTrackProcessor(video_stream.get_tracks()[0]) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(video_stream.get_tracks()[0])) reader = processor.readable.get_reader() (await read(reader)).value.close() await reader.cancel() @@ -119,7 +119,7 @@ async def test_processor_of_an_ended_track(video_stream: webrtc.MediaStream) -> """The stream of an ended track is closed.""" track = video_stream.get_tracks()[0] track.stop() - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() assert (await read(reader)).done @@ -128,7 +128,9 @@ async def test_generator_forwards_frames_with_their_timestamps() -> None: """A frame written to a generator reaches a processor of its track, with its size and timestamp, and is closed.""" generator = webrtc.VideoTrackGenerator() track = generator.track - reader = webrtc.MediaStreamTrackProcessor(track, max_buffer_size=10).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=10) + ).readable.get_reader() writer = generator.writable.get_writer() # reads issued before the frames arrive are settled in order @@ -167,7 +169,7 @@ async def test_closing_the_generator_ends_its_track() -> None: generator = webrtc.VideoTrackGenerator() track = generator.track ended = wait_for_event(track, 'ended') - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() await generator.writable.get_writer().close() await ended assert track.ready_state == webrtc.MediaStreamTrackState.ended @@ -185,7 +187,7 @@ async def test_muted_generator_drops_frames() -> None: assert generator.muted assert track.muted - processor = webrtc.MediaStreamTrackProcessor(track, max_buffer_size=10) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=10)) reader = processor.readable.get_reader() writer = generator.writable.get_writer() await writer.write(video_frame(1)) @@ -205,13 +207,17 @@ async def test_audio_generator_sends_10_ms_frames() -> None: generator = webrtc.MediaStreamTrackGenerator('audio') assert isinstance(generator, webrtc.MediaStreamTrack) assert generator.kind == webrtc.MediaType.audio - reader = webrtc.MediaStreamTrackProcessor(generator, max_buffer_size=100).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(generator, max_buffer_size=100) + ).readable.get_reader() writer = generator.writable.get_writer() # 25 ms of f32 at 48 kHz: two frames now, the rest with the next samples samples = array.array('f', [0.5] * 1200).tobytes() data = webrtc.AudioData( - format='f32', sample_rate=48000, number_of_frames=1200, number_of_channels=1, timestamp=0, data=samples + webrtc.AudioDataInit( + format='f32', sample_rate=48000, number_of_frames=1200, number_of_channels=1, timestamp=0, data=samples + ) ) await writer.write(data) assert data.format is None, 'written data is closed' @@ -219,7 +225,7 @@ async def test_audio_generator_sends_10_ms_frames() -> None: for audio in received: assert (audio.number_of_frames, audio.sample_rate) == (480, 48000) out = array.array('h', [0] * 480) - audio.copy_to(memoryview(out).cast('B'), {'plane_index': 0}) + audio.copy_to(memoryview(out).cast('B'), webrtc.AudioDataCopyToOptions(plane_index=0)) assert set(out) == {16384} audio.close() @@ -229,9 +235,10 @@ async def test_audio_generator_sends_10_ms_frames() -> None: def test_generator_kinds() -> None: - """A MediaStreamTrackGenerator is created for a kind, as a string, an init or a dictionary.""" + """A MediaStreamTrackGenerator is created for a kind, as a string or an init.""" assert webrtc.MediaStreamTrackGenerator('video').kind == webrtc.MediaType.video - assert webrtc.MediaStreamTrackGenerator({'kind': 'audio'}).kind == webrtc.MediaType.audio + init = webrtc.MediaStreamTrackGeneratorInit.from_json({'kind': 'audio'}) + assert webrtc.MediaStreamTrackGenerator(init).kind == webrtc.MediaType.audio assert ( webrtc.MediaStreamTrackGenerator(webrtc.MediaStreamTrackGeneratorInit(webrtc.MediaType.video)).kind == webrtc.MediaType.video @@ -244,15 +251,19 @@ def test_generator_kinds() -> None: async def test_pipe_processor_to_generator(video_stream: webrtc.MediaStream) -> None: """The frames of a track are piped through a transform to a generator, as in a browser.""" generator = webrtc.VideoTrackGenerator() - reader = webrtc.MediaStreamTrackProcessor(generator.track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(generator.track) + ).readable.get_reader() class Stamp: @staticmethod def transform(frame: webrtc.VideoFrame, controller: webrtc.TransformStreamDefaultController) -> None: - controller.enqueue(webrtc.VideoFrame(frame, timestamp=42)) + controller.enqueue(webrtc.VideoFrame(frame, webrtc.VideoFrameInit(timestamp=42))) frame.close() - source = webrtc.MediaStreamTrackProcessor(video_stream.get_tracks()[0]).readable + source = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(video_stream.get_tracks()[0]) + ).readable pipe = asyncio.ensure_future(source.pipe_through(webrtc.TransformStream(Stamp())).pipe_to(generator.writable)) frame = (await read(reader)).value assert frame.timestamp == 42 @@ -267,7 +278,9 @@ def transform(frame: webrtc.VideoFrame, controller: webrtc.TransformStreamDefaul @pytest.mark.asyncio async def test_frames_wait_in_the_native_queue_only(video_stream: webrtc.MediaStream) -> None: """Frames are taken from the processor for pending reads only: the rest stays in its buffer, where it's dropped.""" - processor = webrtc.MediaStreamTrackProcessor(video_stream.get_tracks()[0], max_buffer_size=2) + processor = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(video_stream.get_tracks()[0], max_buffer_size=2) + ) reader = processor.readable.get_reader() frames = await asyncio.wait_for(asyncio.gather(*(reader.read() for _ in range(4))), TIMEOUT) for result in frames: diff --git a/tests/test_media_stress.py b/tests/test_media_stress.py index 3bd8d9f..8b010a5 100644 --- a/tests/test_media_stress.py +++ b/tests/test_media_stress.py @@ -25,7 +25,8 @@ def frame(timestamp: int = 0, width: int = 16, height: int = 16) -> webrtc.VideoFrame: return webrtc.VideoFrame( - bytes(width * height * 3 // 2), format='I420', coded_width=width, coded_height=height, timestamp=timestamp + bytes(width * height * 3 // 2), + webrtc.VideoFrameBufferInit(format='I420', coded_width=width, coded_height=height, timestamp=timestamp), ) @@ -39,7 +40,7 @@ async def test_cancel_during_pending_reads(video_stream: webrtc.MediaStream) -> """Canceling while reads wait for frames settles them, over and over.""" track = video_stream.get_tracks()[0] for _ in range(50): - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() reads = [reader.read() for _ in range(3)] await asyncio.sleep(0) await reader.cancel() @@ -57,7 +58,7 @@ async def test_stop_track_while_reading(video_stream: webrtc.MediaStream, audio_ async def read_all(track: webrtc.MediaStreamTrack) -> int: count = 0 - async for media in webrtc.MediaStreamTrackProcessor(track).readable: + async for media in webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable: media.close() count += 1 if count == 1: @@ -76,7 +77,10 @@ async def read_all(track: webrtc.MediaStreamTrack) -> int: async def test_many_processors_of_one_track(video_stream: webrtc.MediaStream) -> None: """Every processor of a track gets its frames.""" track = video_stream.get_tracks()[0] - readers = [webrtc.MediaStreamTrackProcessor(track).readable.get_reader() for _ in range(20)] + readers = [ + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() + for _ in range(20) + ] for result in await asyncio.wait_for(asyncio.gather(*(r.read() for r in readers)), TIMEOUT): result.value.close() track.stop() @@ -89,12 +93,12 @@ async def test_garbage_collected_with_pending_reads(video_stream: webrtc.MediaSt track = video_stream.get_tracks()[0] refs = [] for _ in range(20): - processor = webrtc.MediaStreamTrackProcessor(track) + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) processor.readable.get_reader().read() refs.append(weakref.ref(processor)) del processor await wait_until(lambda: collected(refs), 'the processors to be collected') - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() (await asyncio.wait_for(reader.read(), TIMEOUT)).value.close() @@ -106,7 +110,10 @@ async def test_close_connection_while_reading( generator = webrtc.VideoTrackGenerator() async with writing(write_video, generator, bytes(64 * 64 * 3 // 2), (64, 64), interval=0.01): remote = await connect_track(caller, callee, generator.track, timeout=TIMEOUT) - readers = [webrtc.MediaStreamTrackProcessor(remote).readable.get_reader() for _ in range(5)] + readers = [ + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(remote)).readable.get_reader() + for _ in range(5) + ] (await asyncio.wait_for(readers[0].read(), TIMEOUT)).value.close() pending = [r.read() for r in readers] callee.close() @@ -123,7 +130,9 @@ def test_loop_closed_while_frames_arrive() -> None: stream = webrtc.get_user_media(audio=True, video=True) async def start() -> list[webrtc.MediaStreamTrackProcessor]: - processors = [webrtc.MediaStreamTrackProcessor(t) for t in stream.get_tracks()] + processors = [ + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(t)) for t in stream.get_tracks() + ] for p in processors: p.readable.get_reader().read() await asyncio.sleep(0.1) @@ -146,7 +155,9 @@ async def test_create_and_destroy_cycles_do_not_leak() -> None: async def cycle() -> tuple[weakref.ref[object], weakref.ref[object]]: generator = webrtc.VideoTrackGenerator() - processor = webrtc.MediaStreamTrackProcessor(generator.track, max_buffer_size=2) + processor = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(generator.track, max_buffer_size=2) + ) reader = processor.readable.get_reader() await generator.writable.get_writer().write(frame()) (await reader.read()).value.close() @@ -169,7 +180,9 @@ async def cycle() -> tuple[weakref.ref[object], weakref.ref[object]]: @pytest.mark.asyncio async def test_unread_frames_do_not_grow_memory(video_stream: webrtc.MediaStream) -> None: """A processor nobody reads keeps at most its buffer: memory stays flat while frames keep coming.""" - processor = webrtc.MediaStreamTrackProcessor(video_stream.get_tracks()[0], max_buffer_size=3) + processor = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(video_stream.get_tracks()[0], max_buffer_size=3) + ) reader = processor.readable.get_reader() # warm up, then measure for 2 s await asyncio.sleep(0.5) @@ -184,7 +197,9 @@ async def test_unread_frames_do_not_grow_memory(video_stream: webrtc.MediaStream async def test_reader_that_never_yields_queues_nothing() -> None: """Frames read as fast as they're written, without the loop running, leave no callbacks piling up for it.""" generator = webrtc.VideoTrackGenerator() - reader = webrtc.MediaStreamTrackProcessor(generator.track, max_buffer_size=2).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor( + webrtc.MediaStreamTrackProcessorInit(generator.track, max_buffer_size=2) + ).readable.get_reader() writer = generator.writable.get_writer() loop = asyncio.get_running_loop() for timestamp in range(2000): diff --git a/tests/test_robustness_exit.py b/tests/test_robustness_exit.py index 09ef9b5..e0bb1b3 100644 --- a/tests/test_robustness_exit.py +++ b/tests/test_robustness_exit.py @@ -100,7 +100,7 @@ async def use(): track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] caller.add_track(track) await asyncio.wait_for(connect(caller, callee), 10) - reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() (await asyncio.wait_for(reader.read(), 5)).value.close() track.stop() caller.close() diff --git a/tests/test_robustness_media.py b/tests/test_robustness_media.py index 7f93845..0fd9417 100644 --- a/tests/test_robustness_media.py +++ b/tests/test_robustness_media.py @@ -22,7 +22,10 @@ def i420_frame() -> webrtc.VideoFrame: - return webrtc.VideoFrame(bytes(I420_SIZE), format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0) + return webrtc.VideoFrame( + bytes(I420_SIZE), + webrtc.VideoFrameBufferInit(format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0), + ) def reversed_view(size: int) -> memoryview: @@ -37,14 +40,17 @@ def strided_view(size: int) -> memoryview: @pytest.mark.parametrize('view', [reversed_view, strided_view]) def test_frame_from_non_contiguous_buffer_is_rejected(view: Callable[[int], memoryview]) -> None: with pytest.raises(TypeError, match='contiguous'): - webrtc.VideoFrame(view(I420_SIZE), format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0) + webrtc.VideoFrame( + view(I420_SIZE), + webrtc.VideoFrameBufferInit(format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0), + ) @pytest.mark.asyncio @pytest.mark.parametrize('view', [reversed_view, strided_view]) -@pytest.mark.parametrize('options', [None, {'format': 'RGBA'}]) +@pytest.mark.parametrize('options', [None, webrtc.VideoFrameCopyToOptions(format='RGBA')]) async def test_frame_copy_to_non_contiguous_destination_is_rejected( - view: Callable[[int], memoryview], options: dict[str, str] | None + view: Callable[[int], memoryview], options: webrtc.VideoFrameCopyToOptions | None ) -> None: frame = i420_frame() with pytest.raises(TypeError, match='contiguous'): @@ -55,10 +61,12 @@ async def test_frame_copy_to_non_contiguous_destination_is_rejected( @pytest.mark.parametrize('view', [reversed_view, strided_view]) def test_audio_copy_to_non_contiguous_destination_is_rejected(view: Callable[[int], memoryview]) -> None: data = webrtc.AudioData( - format='s16', sample_rate=48000, number_of_frames=480, number_of_channels=2, timestamp=0, data=bytes(1920) + webrtc.AudioDataInit( + format='s16', sample_rate=48000, number_of_frames=480, number_of_channels=2, timestamp=0, data=bytes(1920) + ) ) with pytest.raises(TypeError, match='contiguous'): - data.copy_to(view(1920), {'plane_index': 0}) + data.copy_to(view(1920), webrtc.AudioDataCopyToOptions(plane_index=0)) data.close() @@ -97,7 +105,9 @@ def test_native_bounds_checks_do_not_overflow() -> None: def test_audio_data_sample_rate_is_positive_and_finite(rate: float) -> None: with pytest.raises(TypeError): webrtc.AudioData( - format='s16', sample_rate=rate, number_of_frames=1, number_of_channels=1, timestamp=0, data=bytes(2) + webrtc.AudioDataInit( + format='s16', sample_rate=rate, number_of_frames=1, number_of_channels=1, timestamp=0, data=bytes(2) + ) ) @@ -119,9 +129,9 @@ async def write(rate, channels): frames = max(1, min(int(rate) // 100, 4800)) try: for i in range(10): - data = webrtc.AudioData(format='s16', sample_rate=rate, number_of_frames=frames, - number_of_channels=channels, timestamp=i * 10000, - data=bytes(frames * channels * 2)) + data = webrtc.AudioData(webrtc.AudioDataInit( + format='s16', sample_rate=rate, number_of_frames=frames, number_of_channels=channels, + timestamp=i * 10000, data=bytes(frames * channels * 2))) await writer.write(data) await asyncio.sleep(0.01) result = 'written' diff --git a/tests/test_robustness_threads.py b/tests/test_robustness_threads.py index c2efcd1..f61b8b3 100644 --- a/tests/test_robustness_threads.py +++ b/tests/test_robustness_threads.py @@ -30,7 +30,7 @@ def test_constructors_from_many_threads() -> None: def construct(): try: while not stop.is_set(): - webrtc.MediaStreamTrackProcessor(track) + webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) webrtc.RTCPeerConnection().close() webrtc.RTCIceTransport().stop() gc.collect() @@ -79,7 +79,8 @@ async def main(): elif {kind!r} == 'audio generator': released = webrtc.MediaStreamTrackGenerator('audio') else: - released = webrtc.MediaStreamTrackProcessor(webrtc.VideoTrackGenerator().track) + init = webrtc.MediaStreamTrackProcessorInit(webrtc.VideoTrackGenerator().track) + released = webrtc.MediaStreamTrackProcessor(init) gc.collect() # the signaling thread delivers the messages, waiting for the GIL this thread keeps sys.setswitchinterval(1000) diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index 941fba6..05b086b 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -28,7 +28,7 @@ def test_capabilities() -> None: def add_simulcast_sender(pc: webrtc.RTCPeerConnection) -> webrtc.RTCRtpSender: - init = webrtc.RtpTransceiverInit( + init = webrtc.RTCRtpTransceiverInit( send_encodings=[webrtc.RTCRtpEncodingParameters(rid='hi'), webrtc.RTCRtpEncodingParameters(rid='lo')] ) return pc.add_transceiver(webrtc.MediaType.video, init).sender @@ -100,7 +100,7 @@ async def test_parameters_expire_with_their_task(pc: webrtc.RTCPeerConnection) - ) def test_invalid_send_encodings(pc: webrtc.RTCPeerConnection, encodings: list[dict[str, str]], error: str) -> None: """The rids of send encodings are unique, present when there are several, and alphanumeric.""" - init = webrtc.RtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(**e) for e in encodings]) + init = webrtc.RTCRtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(**e) for e in encodings]) with pytest.raises(ValueError, match=error): pc.add_transceiver(webrtc.MediaType.video, init) @@ -109,7 +109,7 @@ def test_send_encoding_of_an_unknown_codec(pc: webrtc.RTCPeerConnection) -> None """The codec of a send encoding must be one the sender supports.""" unknown = webrtc.RTCRtpEncodingParameters(codec=webrtc.RTCRtpCodec('audio/unknown', 8000)) with pytest.raises(webrtc.OperationError): - pc.add_transceiver(webrtc.MediaType.audio, webrtc.RtpTransceiverInit(send_encodings=[unknown])) + pc.add_transceiver(webrtc.MediaType.audio, webrtc.RTCRtpTransceiverInit(send_encodings=[unknown])) @pytest.mark.asyncio @@ -167,7 +167,7 @@ async def test_sender_codecs_leave_out_unknown_remote_codecs( sdp = sdp.replace(m_line, m_line + ' 125').replace( '\r\na=rtpmap:', '\r\na=rtpmap:125 flarglblurp/8000/2\r\na=rtpmap:', 1 ) - await caller.set_remote_description({'type': 'answer', 'sdp': sdp}) + await caller.set_remote_description(webrtc.RTCSessionDescriptionInit('answer', sdp)) parameters = sender.get_parameters() assert parameters.codecs @@ -200,7 +200,7 @@ async def test_set_parameters_after_rollback(pc: webrtc.RTCPeerConnection) -> No """A sender rolled back out of its offer has no media channel: setting parameters rejects, not hangs.""" sender = pc.add_transceiver(webrtc.MediaType.video).sender await pc.set_local_description() - await pc.set_local_description({'type': 'rollback'}) + await pc.set_local_description(webrtc.RTCSessionDescriptionInit('rollback')) with pytest.raises(webrtc.InvalidStateError): await asyncio.wait_for(sender.set_parameters(sender.get_parameters()), 5) @@ -211,7 +211,7 @@ async def test_simulcast_receiver_parameters( ) -> None: """The receiver of simulcast has the negotiated codecs and header extensions.""" encodings = [webrtc.RTCRtpEncodingParameters(rid='a'), webrtc.RTCRtpEncodingParameters(rid='b')] - caller.add_transceiver(webrtc.MediaType.video, webrtc.RtpTransceiverInit(send_encodings=encodings)) + caller.add_transceiver(webrtc.MediaType.video, webrtc.RTCRtpTransceiverInit(send_encodings=encodings)) await exchange_offer_answer(caller, callee) parameters = callee.get_transceivers()[0].receiver.get_parameters() assert parameters.codecs @@ -278,19 +278,23 @@ def test_encodings_have_their_webidl_types(pc: webrtc.RTCPeerConnection, encodin with pytest.raises(TypeError): pc.add_transceiver( webrtc.MediaType.video, - webrtc.RtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(**encoding)]), + webrtc.RTCRtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(**encoding)]), ) def test_encoding_bitrate_beyond_an_int_is_no_limit(pc: webrtc.RTCPeerConnection) -> None: - init = webrtc.RtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(max_bitrate=2**32 - 1)]) + init = webrtc.RTCRtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(max_bitrate=2**32 - 1)]) sender = pc.add_transceiver(webrtc.MediaType.video, init).sender assert sender.get_parameters().encodings[0].max_bitrate == 2**31 - 1 -def test_transceiver_init_as_a_dictionary(pc: webrtc.RTCPeerConnection) -> None: - """As in browsers, with camelCase or snake_case names, the encodings too; unknown members are ignored.""" - init = {'direction': 'sendonly', 'sendEncodings': [{'rid': 'a', 'maxBitrate': 100000}, {'rid': 'b'}], 'x': 1} +def test_transceiver_init_from_json(pc: webrtc.RTCPeerConnection) -> None: + """From the JSON form, with camelCase or snake_case names, the encodings too; unknown members are ignored.""" + init = webrtc.RTCRtpTransceiverInit.from_json({ + 'direction': 'sendonly', + 'sendEncodings': [{'rid': 'a', 'maxBitrate': 100000}, {'rid': 'b'}], + 'x': 1, + }) transceiver = pc.add_transceiver(webrtc.MediaType.video, init) assert transceiver.direction == webrtc.TransceiverDirection.sendonly encodings = transceiver.sender.get_parameters().encodings diff --git a/tests/test_session_description.py b/tests/test_session_description.py index 78a5bed..b1735fd 100644 --- a/tests/test_session_description.py +++ b/tests/test_session_description.py @@ -19,18 +19,23 @@ def test_type_is_required() -> None: with pytest.raises(TypeError): webrtc.RTCSessionDescription() with pytest.raises(TypeError): - webrtc.RTCSessionDescription({'sdp': ''}) + webrtc.RTCSessionDescriptionInit.from_json({'sdp': ''}) with pytest.raises(TypeError): - webrtc.RTCSessionDescription({'type': 'offer', 'sdp': None}) + webrtc.RTCSessionDescription('offer', None) + with pytest.raises(ValueError, match='not a valid RTCSdpType'): + webrtc.RTCSessionDescription({'type': 'offer'}) @pytest.mark.parametrize( - 'init', [{'type': 'rollback'}, webrtc.RTCSessionDescriptionInit('rollback'), webrtc.RTCSdpType.rollback] + 'init', + [ + webrtc.RTCSessionDescriptionInit.from_json({'type': 'rollback'}), + webrtc.RTCSessionDescriptionInit('rollback'), + webrtc.RTCSdpType.rollback, + ], ) -def test_sdp_is_empty_by_default( - init: dict[str, str] | webrtc.RTCSessionDescriptionInit | webrtc.RTCSdpType, -) -> None: - """A description from a dictionary, an init or a type has an empty SDP.""" +def test_sdp_is_empty_by_default(init: webrtc.RTCSessionDescriptionInit | webrtc.RTCSdpType) -> None: + """A description from the JSON form, an init or a type has an empty SDP.""" description = webrtc.RTCSessionDescription(init) assert description.type == webrtc.RTCSdpType.rollback assert not description.sdp diff --git a/tests/test_track_settings.py b/tests/test_track_settings.py index 3e87551..a0b102b 100644 --- a/tests/test_track_settings.py +++ b/tests/test_track_settings.py @@ -14,6 +14,8 @@ import webrtc from tests.helpers import capture_mode, connect_track, run_isolated, wait_until +C = webrtc.MediaTrackConstraints + @pytest.mark.asyncio async def test_camera_settings_and_capabilities() -> None: @@ -52,11 +54,13 @@ async def test_microphone_settings(audio_stream: webrtc.MediaStream) -> None: async def test_apply_constraints_to_the_camera(video_stream: webrtc.MediaStream) -> None: """Constraints change the size and frame rate of the camera, and are kept by the track.""" track = video_stream.get_tracks()[0] - await track.apply_constraints({'width': 160, 'height': {'exact': 120}, 'frameRate': {'max': 10}}) + await track.apply_constraints( + C(width=160, height=webrtc.ConstrainULongRange(exact=120), frame_rate=webrtc.ConstrainDoubleRange(max=10)) + ) await wait_until(lambda: track.get_settings().width == 160, 'the new size') await wait_until(lambda: (track.get_settings().frame_rate or 0) < 12, 'the new frame rate') assert track.get_settings().height == 120 - assert track.getConstraints().height == {'exact': 120} + assert track.getConstraints().height == webrtc.ConstrainULongRange(exact=120) clone = track.clone() assert clone.get_settings().device_id == 'synthetic-camera' @@ -67,19 +71,19 @@ async def test_apply_constraints_to_the_camera(video_stream: webrtc.MediaStream) async def test_overconstrained(video_stream: webrtc.MediaStream, audio_stream: webrtc.MediaStream) -> None: """A required constraint the source can't satisfy fails, leaving the track as it was.""" video = video_stream.get_tracks()[0] - await video.apply_constraints({'width': 320}) + await video.apply_constraints(C(width=320)) with pytest.raises(webrtc.OverconstrainedError) as error: - await video.apply_constraints({'width': {'min': 100000}}) + await video.apply_constraints(C(width=webrtc.ConstrainULongRange(min=100000))) assert error.value.constraint == 'width' assert video.get_constraints().width == 320 audio = audio_stream.get_tracks()[0] with pytest.raises(webrtc.OverconstrainedError): - await audio.apply_constraints({'sampleRate': {'exact': 44100}}) + await audio.apply_constraints(C(sample_rate=webrtc.ConstrainULongRange(exact=44100))) with pytest.raises(webrtc.OverconstrainedError): - await audio.apply_constraints({'width': {'exact': 640}}) + await audio.apply_constraints(C(width=webrtc.ConstrainULongRange(exact=640))) # ideal values are satisfied as far as possible - await audio.apply_constraints({'sampleRate': 44100, 'echoCancellation': True}) + await audio.apply_constraints(C(sample_rate=44100, echo_cancellation=True)) @pytest.mark.asyncio @@ -94,7 +98,7 @@ async def test_remote_track_settings( assert settings.device_id is None assert remote.get_capabilities() == webrtc.MediaTrackCapabilities() with pytest.raises(webrtc.OverconstrainedError): - await remote.apply_constraints({'width': {'exact': 100}}) + await remote.apply_constraints(C(width=webrtc.ConstrainULongRange(exact=100))) def test_content_hint(audio_stream: webrtc.MediaStream, video_stream: webrtc.MediaStream) -> None: @@ -116,7 +120,7 @@ async def test_constraints_of_an_ended_track(video_stream: webrtc.MediaStream) - """Constraints of an ended track are accepted, even ones it couldn't satisfy.""" track = video_stream.get_tracks()[0] track.stop() - await track.apply_constraints({'width': {'exact': 100000}}) + await track.apply_constraints(C(width=webrtc.ConstrainULongRange(exact=100000))) @pytest.mark.asyncio @@ -129,7 +133,7 @@ async def test_constraints_have_their_webidl_types( ) -> None: """Unsigned longs and restricted doubles: other values are a TypeError.""" with pytest.raises(TypeError): - await video_stream.get_tracks()[0].apply_constraints(constraints) + await video_stream.get_tracks()[0].apply_constraints(C(**constraints)) with pytest.raises(TypeError): webrtc.get_user_media(audio=False, video=True, **constraints) @@ -140,7 +144,7 @@ async def test_constraints_have_their_webidl_types( [ ({'frame_rate': 10**9}, (640, 480, 120)), ({'frame_rate': 0}, (640, 480, 1)), - ({'frame_rate': {'ideal': -5}}, (640, 480, 1)), + ({'frame_rate': webrtc.ConstrainDoubleRange(ideal=-5)}, (640, 480, 1)), ], ) async def test_camera_stays_within_its_capabilities( @@ -148,7 +152,7 @@ async def test_camera_stays_within_its_capabilities( ) -> None: """Ideal values beyond the capabilities select the nearest ones.""" track = video_stream.get_tracks()[0] - await track.apply_constraints(constraints) + await track.apply_constraints(C(**constraints)) assert capture_mode(track) == expected track = webrtc.get_user_media(audio=False, video=True, **constraints).get_tracks()[0] @@ -158,9 +162,9 @@ async def test_camera_stays_within_its_capabilities( def test_get_user_media_rejects_what_the_camera_cannot_do() -> None: with pytest.raises(webrtc.OverconstrainedError): - webrtc.get_user_media(audio=False, video=True, width={'exact': 5000}) + webrtc.get_user_media(audio=False, video=True, width=webrtc.ConstrainULongRange(exact=5000)) with pytest.raises(webrtc.OverconstrainedError): - webrtc.get_user_media(audio=False, video=True, frame_rate={'min': 500}) + webrtc.get_user_media(audio=False, video=True, frame_rate=webrtc.ConstrainDoubleRange(min=500)) def test_camera_of_impossible_sizes() -> None: @@ -172,14 +176,15 @@ def test_camera_of_impossible_sizes() -> None: async def main(): track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] - for negative in ({'width': -1}, {'height': {'ideal': -5}}): + C, Range = webrtc.MediaTrackConstraints, webrtc.ConstrainULongRange + for negative in (C(width=-1), C(height=Range(ideal=-5))): try: await track.apply_constraints(negative) except TypeError: print('rejected') - await track.apply_constraints({'width': 10**6, 'height': {'ideal': 10**6}}) + await track.apply_constraints(C(width=10**6, height=Range(ideal=10**6))) print(track._native_obj._camera()) - await track.apply_constraints({'width': 0, 'height': 0}) + await track.apply_constraints(C(width=0, height=0)) print(track._native_obj._camera()) track.stop() (track,) = webrtc.get_user_media(audio=False, video=True, width=0, height=0).get_tracks() @@ -191,3 +196,31 @@ async def main(): assert output.count('rejected') == 2, output assert output.count('(4096, 4096, 30.0)') == 1, output assert output.count('(1, 1, 30.0)') == 2, output + + +def test_constraints_from_json() -> None: + """The JSON form of constraints has nested constraints of each type, which are converted too.""" + constraints = C.from_json({ + 'width': {'exact': 320}, + 'frameRate': {'ideal': 15, 'max': 30}, + 'deviceId': ['a', 'b'], + 'resizeMode': {'exact': 'none'}, + 'echoCancellation': {'ideal': 'all'}, + 'autoGainControl': {'exact': False}, + 'height': 240, + 'advanced': [{'width': {'min': 100}}, {'aspectRatio': 1.5}], + 'unknown': 1, + }) + assert constraints == C( + width=webrtc.ConstrainULongRange(exact=320), + frame_rate=webrtc.ConstrainDoubleRange(max=30, ideal=15), + device_id=['a', 'b'], + resize_mode=webrtc.ConstrainDOMStringParameters(exact='none'), + echo_cancellation=webrtc.ConstrainBooleanOrDOMStringParameters(ideal='all'), + auto_gain_control=webrtc.ConstrainBooleanParameters(exact=False), + height=240, + advanced=[ + webrtc.MediaTrackConstraintSet(width=webrtc.ConstrainULongRange(min=100)), + webrtc.MediaTrackConstraintSet(aspect_ratio=1.5), + ], + ) diff --git a/tests/test_tracks.py b/tests/test_tracks.py index fbd009d..2c378d9 100644 --- a/tests/test_tracks.py +++ b/tests/test_tracks.py @@ -58,7 +58,7 @@ async def test_rollback_ends_the_track_of_a_removed_transceiver( await exchange_offer(caller, callee) [transceiver] = callee.get_transceivers() - await callee.set_remote_description({'type': 'rollback'}) + await callee.set_remote_description(webrtc.RTCSessionDescriptionInit('rollback')) track = transceiver.receiver.track assert track.ready_state == webrtc.MediaStreamTrackState.live await wait_for_event(track, 'ended') @@ -118,7 +118,7 @@ async def test_remote_track_mute_and_stream_events( (audio,), (video,) = audio_stream.get_tracks(), video_stream.get_tracks() stream = webrtc.MediaStream([audio, video]) caller.add_track(audio, stream) - transceiver = caller.add_transceiver(video, webrtc.RtpTransceiverInit(streams=[stream])) + transceiver = caller.add_transceiver(video, webrtc.RTCRtpTransceiverInit(streams=[stream])) events = [] callee.on('track', events.append) await connect(caller, callee) diff --git a/tests/test_video.py b/tests/test_video.py index 0d6fbea..e3b96eb 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -33,9 +33,9 @@ def test_get_user_media_needs_audio_or_video() -> None: @pytest.mark.parametrize( ('constraints', 'error'), [ - ({'width': {'exact': 0}}, webrtc.OverconstrainedError), - ({'frame_rate': {'max': 0}}, webrtc.OverconstrainedError), - ({'width': {'min': 0, 'max': -1}}, TypeError), + ({'width': webrtc.ConstrainULongRange(exact=0)}, webrtc.OverconstrainedError), + ({'frame_rate': webrtc.ConstrainDoubleRange(max=0)}, webrtc.OverconstrainedError), + ({'width': webrtc.ConstrainULongRange(min=0, max=-1)}, TypeError), ], ids=['exact', 'max', 'negative'], ) @@ -47,7 +47,7 @@ def test_get_user_media_constraint_beyond_the_camera(constraints: dict[str, obje def test_get_user_media_ideal_beyond_the_camera() -> None: """An ideal value selects the nearest one the camera can have.""" - (track,) = webrtc.get_user_media(audio=False, video=True, height={'ideal': 0}).get_tracks() + (track,) = webrtc.get_user_media(audio=False, video=True, height=webrtc.ConstrainULongRange(ideal=0)).get_tracks() assert capture_mode(track) == (640, 1, 30) track.stop() @@ -55,7 +55,11 @@ def test_get_user_media_ideal_beyond_the_camera() -> None: def test_get_user_media_constraints() -> None: """Constraints that select a positive value are accepted.""" stream = webrtc.get_user_media( - audio=False, video=True, width={'ideal': 320}, height={'min': 100, 'max': 240}, frame_rate={'exact': 15} + audio=False, + video=True, + width=webrtc.ConstrainULongRange(ideal=320), + height=webrtc.ConstrainULongRange(min=100, max=240), + frame_rate=webrtc.ConstrainDoubleRange(exact=15), ) for track in stream.get_tracks(): track.stop() diff --git a/tests/test_video_frame.py b/tests/test_video_frame.py index 2ba665c..a491935 100644 --- a/tests/test_video_frame.py +++ b/tests/test_video_frame.py @@ -23,7 +23,10 @@ def i420_4x2(data: bytes = I420_DATA, **init: object) -> webrtc.VideoFrame: - return webrtc.VideoFrame(data, **{'format': 'I420', 'coded_width': 4, 'coded_height': 2, 'timestamp': 0, **init}) + return webrtc.VideoFrame( + data, + webrtc.VideoFrameBufferInit(**{'format': 'I420', 'coded_width': 4, 'coded_height': 2, 'timestamp': 0, **init}), + ) def test_construct_from_buffer() -> None: @@ -41,15 +44,33 @@ def test_construct_from_buffer() -> None: frame.close() -def test_init_as_dataclass_or_dictionary() -> None: - """The init is a dataclass, a dictionary with camelCase names, or keyword arguments.""" - init = webrtc.VideoFrameBufferInit(format=VideoPixelFormat.I420, coded_width=4, coded_height=2, timestamp=7) - for frame in ( - webrtc.VideoFrame(I420_DATA, init), - webrtc.VideoFrame(I420_DATA, {'format': 'I420', 'codedWidth': 4, 'codedHeight': 2, 'timestamp': 7}), - ): +def test_init_from_json() -> None: + """An init comes from its JSON form, with camelCase names and nested dictionaries.""" + init = webrtc.VideoFrameBufferInit.from_json({ + 'format': 'I420', + 'codedWidth': 4, + 'codedHeight': 2, + 'timestamp': 7, + 'visibleRect': {'x': 2, 'width': 2, 'height': 2}, + 'layout': [{'offset': 0, 'stride': 4}, {'offset': 8, 'stride': 2}, {'offset': 10, 'stride': 2}], + 'colorSpace': {'fullRange': True}, + 'unknown': 1, + }) + assert init.visible_rect == webrtc.DOMRectInit(x=2, width=2, height=2) + assert init.layout == [PlaneLayout(0, 4), PlaneLayout(8, 2), PlaneLayout(10, 2)] + assert init.color_space == webrtc.VideoColorSpaceInit(full_range=True) + with webrtc.VideoFrame(I420_DATA, init) as frame: assert frame.timestamp == 7 - frame.close() + assert frame.visible_rect == webrtc.DOMRectReadOnly(0, 0, 2, 2) + assert frame.color_space.full_range is True + options = webrtc.VideoFrameCopyToOptions.from_json({'rect': {'width': 4, 'height': 2}, 'format': 'RGBA'}) + assert options.rect == webrtc.DOMRectInit(width=4, height=2) + + +def test_buffer_needs_an_init() -> None: + """A frame of a buffer has no defaults for its format and size.""" + with pytest.raises(TypeError, match='needs a VideoFrameBufferInit'): + webrtc.VideoFrame(I420_DATA) @pytest.mark.parametrize( @@ -67,7 +88,7 @@ def test_init_as_dataclass_or_dictionary() -> None: def test_invalid_init(init: dict[str, object]) -> None: """An invalid init, or a rect that isn't aligned to the chroma planes, is a TypeError.""" with pytest.raises(TypeError): - webrtc.VideoFrame(I420_DATA, **init) + webrtc.VideoFrame(I420_DATA, webrtc.VideoFrameBufferInit.from_json(init)) def test_buffer_too_small() -> None: @@ -100,7 +121,7 @@ async def test_copy_to_layouts() -> None: assert bytes(out) == I420_DATA layout = [PlaneLayout(9, 5), PlaneLayout(1, 3), PlaneLayout(5, 3)] - assert frame.allocation_size({'layout': layout}) == 19 + assert frame.allocation_size(webrtc.VideoFrameCopyToOptions(layout=layout)) == 19 out = bytearray(19) await frame.copy_to(out, webrtc.VideoFrameCopyToOptions(layout=layout)) assert list(out) == [0, 9, 10, 0, 0, 11, 12, 0, 0, 1, 2, 3, 4, 0, 5, 6, 7, 8, 0] @@ -111,12 +132,12 @@ async def test_copy_to_layouts() -> None: async def test_copy_to_rect() -> None: """A rect copies part of the frame, aligned to the chroma planes.""" frame = i420_4x2() - options = {'rect': {'x': 2, 'y': 0, 'width': 2, 'height': 2}} + options = webrtc.VideoFrameCopyToOptions(rect=webrtc.DOMRectInit(x=2, y=0, width=2, height=2)) out = bytearray(frame.allocation_size(options)) await frame.copy_to(out, options) assert list(out) == [3, 4, 7, 8, 10, 12] with pytest.raises(TypeError): - frame.allocation_size({'rect': {'x': 1, 'y': 0, 'width': 2, 'height': 2}}) + frame.allocation_size(webrtc.VideoFrameCopyToOptions(rect=webrtc.DOMRectInit(x=1, y=0, width=2, height=2))) frame.close() @@ -127,12 +148,14 @@ async def test_copy_to_errors() -> None: with pytest.raises(TypeError): await frame.copy_to(bytearray(11)) with pytest.raises(TypeError): - frame.allocation_size({'layout': [PlaneLayout(0, 4)]}) + frame.allocation_size(webrtc.VideoFrameCopyToOptions(layout=[PlaneLayout(0, 4)])) with pytest.raises(TypeError): # overlapping planes - frame.allocation_size({'layout': [PlaneLayout(0, 4), PlaneLayout(0, 2), PlaneLayout(10, 2)]}) + frame.allocation_size( + webrtc.VideoFrameCopyToOptions(layout=[PlaneLayout(0, 4), PlaneLayout(0, 2), PlaneLayout(10, 2)]) + ) with pytest.raises(webrtc.NotSupportedError): - frame.allocation_size({'format': 'NV12'}) + frame.allocation_size(webrtc.VideoFrameCopyToOptions(format='NV12')) frame.close() @@ -144,15 +167,17 @@ async def test_convert_i420_to_rgb(format: str) -> None: data = bytes([81] * 16 + [90] * 4 + [240] * 4) frame = webrtc.VideoFrame( data, - format='I420', - coded_width=4, - coded_height=4, - timestamp=0, - color_space={'matrix': 'smpte170m', 'full_range': False}, + webrtc.VideoFrameBufferInit( + format='I420', + coded_width=4, + coded_height=4, + timestamp=0, + color_space=webrtc.VideoColorSpaceInit(matrix='smpte170m', full_range=False), + ), ) - out = bytearray(frame.allocation_size({'format': format})) + out = bytearray(frame.allocation_size(webrtc.VideoFrameCopyToOptions(format=format))) assert len(out) == 64 - assert await frame.copy_to(out, {'format': format}) == [PlaneLayout(0, 16)] + assert await frame.copy_to(out, webrtc.VideoFrameCopyToOptions(format=format)) == [PlaneLayout(0, 16)] r, g, b, a = out[:4] if format.startswith('RGB') else (out[2], out[1], out[0], out[3]) assert r > 245 assert g < 10 @@ -164,13 +189,15 @@ async def test_convert_i420_to_rgb(format: str) -> None: @pytest.mark.asyncio async def test_rgb_formats_swap_and_alpha() -> None: """RGBA converts to BGRA by swapping R and B, keeping alpha, and to RGBX without it.""" - frame = webrtc.VideoFrame(bytes([1, 2, 3, 4] * 4), format='RGBA', coded_width=2, coded_height=2, timestamp=0) + frame = webrtc.VideoFrame( + bytes([1, 2, 3, 4] * 4), webrtc.VideoFrameBufferInit(format='RGBA', coded_width=2, coded_height=2, timestamp=0) + ) assert frame.color_space.matrix == 'rgb' assert frame.color_space.full_range out = bytearray(16) - await frame.copy_to(out, {'format': 'BGRA'}) + await frame.copy_to(out, webrtc.VideoFrameCopyToOptions(format='BGRA')) assert list(out[:4]) == [3, 2, 1, 4] - await frame.copy_to(out, {'format': 'RGBX'}) + await frame.copy_to(out, webrtc.VideoFrameCopyToOptions(format='RGBX')) assert list(out[:4]) == [1, 2, 3, 255] frame.close() @@ -190,13 +217,15 @@ async def test_rgb_formats_swap_and_alpha() -> None: async def test_other_formats_round_trip(format: str, size: int) -> None: """Every planar format is kept as it is, and converts to RGBA.""" data = bytes(i % 200 for i in range(size)) - frame = webrtc.VideoFrame(data, format=format, coded_width=4, coded_height=2, timestamp=0) + frame = webrtc.VideoFrame( + data, webrtc.VideoFrameBufferInit(format=format, coded_width=4, coded_height=2, timestamp=0) + ) assert frame.allocation_size() == size out = bytearray(size) await frame.copy_to(out) assert bytes(out) == data rgba = bytearray(32) - await frame.copy_to(rgba, {'format': 'RGBA'}) + await frame.copy_to(rgba, webrtc.VideoFrameCopyToOptions(format='RGBA')) frame.close() @@ -204,7 +233,9 @@ def test_high_bit_depth_samples_are_little_endian_16_bit() -> None: """P10 formats have 2 bytes a sample.""" y = struct.pack('<8H', *[1023] * 8) uv = struct.pack('<4H', *[512] * 4) - frame = webrtc.VideoFrame(y + uv, format='I420P10', coded_width=4, coded_height=2, timestamp=0) + frame = webrtc.VideoFrame( + y + uv, webrtc.VideoFrameBufferInit(format='I420P10', coded_width=4, coded_height=2, timestamp=0) + ) assert frame.allocation_size() == 24 frame.close() @@ -212,29 +243,33 @@ def test_high_bit_depth_samples_are_little_endian_16_bit() -> None: def test_frame_from_frame() -> None: """A frame from another one shares its pixels, with a visible rect, display size, timestamp or alpha of its own.""" frame = i420_4x2(timestamp=1234, display_width=8, display_height=2) - crop = webrtc.VideoFrame(frame, visible_rect={'x': 2, 'y': 0, 'width': 2, 'height': 2}) + crop = webrtc.VideoFrame(frame, webrtc.VideoFrameInit(visible_rect=webrtc.DOMRectInit(x=2, y=0, width=2, height=2))) assert (crop.coded_width, crop.visible_rect.x, crop.visible_rect.width) == (4, 2, 2) assert (crop.display_width, crop.display_height) == (4, 2) assert crop.timestamp == 1234 - later = webrtc.VideoFrame(frame, timestamp=5, duration=6) + later = webrtc.VideoFrame(frame, webrtc.VideoFrameInit(timestamp=5, duration=6)) assert (later.timestamp, later.duration) == (5, 6) - alpha = webrtc.VideoFrame(bytes(20), format='I420A', coded_width=4, coded_height=2, timestamp=0) - assert webrtc.VideoFrame(alpha, alpha='discard').format == VideoPixelFormat.I420 - assert webrtc.VideoFrame(alpha, alpha='keep').format == VideoPixelFormat.I420A + alpha = webrtc.VideoFrame( + bytes(20), webrtc.VideoFrameBufferInit(format='I420A', coded_width=4, coded_height=2, timestamp=0) + ) + assert webrtc.VideoFrame(alpha, webrtc.VideoFrameInit(alpha='discard')).format == VideoPixelFormat.I420 + assert webrtc.VideoFrame(alpha, webrtc.VideoFrameInit(alpha='keep')).format == VideoPixelFormat.I420A for f in (frame, crop, later, alpha): f.close() def test_rotation_and_flip() -> None: """Rotations are rounded to a multiple of 90, and combine with the flip of the frame they're added to.""" - frame = webrtc.VideoFrame(bytes(32), format='RGBX', coded_width=4, coded_height=2, timestamp=0, rotation=-315) + frame = webrtc.VideoFrame( + bytes(32), webrtc.VideoFrameBufferInit(format='RGBX', coded_width=4, coded_height=2, timestamp=0, rotation=-315) + ) assert frame.rotation == 90 assert (frame.display_width, frame.display_height) == (2, 4) - flipped = webrtc.VideoFrame(frame, rotation=90, flip=True) + flipped = webrtc.VideoFrame(frame, webrtc.VideoFrameInit(rotation=90, flip=True)) assert (flipped.rotation, flipped.flip) == (180, True) - again = webrtc.VideoFrame(flipped, rotation=90) + again = webrtc.VideoFrame(flipped, webrtc.VideoFrameInit(rotation=90)) assert (again.rotation, again.flip) == (90, True) for f in (frame, flipped, again): f.close() @@ -244,10 +279,15 @@ def test_rotation_and_flip() -> None: def test_rotation_must_be_finite(rotation: float) -> None: """A rotation is a WebIDL double: non-finite values are a TypeError, not an OverflowError (found by fuzzing).""" with pytest.raises(TypeError): - webrtc.VideoFrame(bytes(32), format='RGBX', coded_width=4, coded_height=2, timestamp=0, rotation=rotation) - frame = webrtc.VideoFrame(bytes(32), format='RGBX', coded_width=4, coded_height=2, timestamp=0) + webrtc.VideoFrame( + bytes(32), + webrtc.VideoFrameBufferInit(format='RGBX', coded_width=4, coded_height=2, timestamp=0, rotation=rotation), + ) + frame = webrtc.VideoFrame( + bytes(32), webrtc.VideoFrameBufferInit(format='RGBX', coded_width=4, coded_height=2, timestamp=0) + ) with frame, pytest.raises(TypeError): - webrtc.VideoFrame(frame, rotation=rotation) + webrtc.VideoFrame(frame, webrtc.VideoFrameInit(rotation=rotation)) @pytest.mark.asyncio @@ -289,7 +329,7 @@ def test_unclosed_frame_warns() -> None: @pytest.mark.asyncio async def test_visible_rect_of_a_buffer_is_the_frame() -> None: """A frame created from a buffer keeps its visible rect only, which becomes the whole frame.""" - frame = i420_4x2(visible_rect={'x': 2, 'y': 0, 'width': 2, 'height': 2}) + frame = i420_4x2(visible_rect=webrtc.DOMRectInit(x=2, y=0, width=2, height=2)) assert (frame.coded_width, frame.coded_height) == (2, 2) assert frame.visible_rect == webrtc.DOMRectReadOnly(0, 0, 2, 2) out = bytearray(frame.allocation_size()) diff --git a/tests/wpt/bridge.py b/tests/wpt/bridge.py index b5abaee..3151da6 100644 --- a/tests/wpt/bridge.py +++ b/tests/wpt/bridge.py @@ -128,6 +128,9 @@ def _dict_from_js(value: dict[str, object]) -> object: if '__model' in value: # a WebIDL dictionary the library has a keyword model for return getattr(webrtc, str(value['__model']))(**from_js(value['kwargs'])) + if '__json' in value: + # a WebIDL dictionary as JS has it, which the library converts with its from_json + return getattr(webrtc, str(value['__json'])).from_json(from_js(value['value'])) return {k: from_js(v) for k, v in value.items()} diff --git a/tests/wpt/shim.js b/tests/wpt/shim.js index ceb0375..aff73ac 100644 --- a/tests/wpt/shim.js +++ b/tests/wpt/shim.js @@ -65,6 +65,8 @@ const pyEnum = (name, value, strict = true) => ({__enum: name, value: String(value), strict}); // a dictionary the library takes as a keyword model const pyModel = (name, kwargs) => ({__model: name, kwargs}); + // a dictionary with the members JS gives, converted by the from_json of the library's model + const pyJson = (name, value) => ({__json: name, value}); // USVString conversion replaces lone surrogates const toUSVString = (value) => String(value).toWellFormed(); @@ -309,7 +311,8 @@ getCapabilities() { return callMethod(this, 'get_capabilities'); } getConstraints() { return callMethod(this, 'get_constraints'); } applyConstraints(constraints) { - return callAsyncMethod(this, 'apply_constraints', requireDictionary(constraints, 'MediaTrackConstraints')); + return callAsyncMethod(this, 'apply_constraints', + pyJson('MediaTrackConstraints', requireDictionary(constraints, 'MediaTrackConstraints'))); } } defineAttributes(MediaStreamTrack, [ @@ -360,6 +363,7 @@ } defineAttributes(RTCCertificate, [['expires', 'expires']]); + // the WebCrypto dictionary an algorithm normalizes to, as far as its members are given function toAlgorithm(algorithm) { if (typeof algorithm !== 'object' || algorithm === null) return String(algorithm); const converted = {name: String(algorithm.name)}; @@ -369,7 +373,11 @@ if (algorithm.hash !== undefined) { converted.hash = typeof algorithm.hash === 'object' ? String(algorithm.hash.name) : String(algorithm.hash); } - return converted; + const name = converted.name.toUpperCase(); + if (name === 'ECDSA' && converted.namedCurve !== undefined) return pyJson('EcKeyGenParams', converted); + const rsa = ['modulusLength', 'publicExponent', 'hash'].every((key) => converted[key] !== undefined); + if (name === 'RSASSA-PKCS1-V1_5' && rsa) return pyJson('RsaHashedKeyGenParams', converted); + return pyJson('Algorithm', {name: converted.name}); } class RTCIceCandidate extends Interface { @@ -744,9 +752,9 @@ const sdp = init.sdp === undefined ? '' : String(init.sdp); if (init.type === undefined) { if (typeRequired) throw new TypeError('RTCSessionDescriptionInit: missing required member type'); - return {sdp}; + return pyModel('RTCLocalSessionDescriptionInit', {sdp}); } - return {type: pyEnum('RTCSdpType', init.type), sdp}; + return pyModel('RTCSessionDescriptionInit', {type: pyEnum('RTCSdpType', init.type), sdp}); } class RTCPeerConnection extends Interface { @@ -786,7 +794,7 @@ : requireInterface(trackOrKind, MediaStreamTrack, 'RTCPeerConnection.addTransceiver'); if (init === undefined) return callMethod(this, 'add_transceiver', trackOrPyKind); return callMethod(this, 'add_transceiver', trackOrPyKind, - pyModel('RtpTransceiverInit', convertDictionary(init, 'RTCRtpTransceiverInit', TRANSCEIVER_INIT))); + pyModel('RTCRtpTransceiverInit', convertDictionary(init, 'RTCRtpTransceiverInit', TRANSCEIVER_INIT))); } getTransceivers() { return callMethod(this, 'get_transceivers'); } @@ -800,19 +808,18 @@ requireArguments(arguments, 1, 'RTCPeerConnection.createDataChannel'); const kwargs = convertDictionary(requireDictionary(init, 'RTCDataChannelInit'), 'RTCDataChannelInit', DATA_CHANNEL_INIT); - return callMethod(this, 'create_data_channel', toUSVString(label), kwargs); + return callMethod(this, 'create_data_channel', toUSVString(label), pyModel('RTCDataChannelInit', kwargs)); } async addIceCandidate(candidate) { if (candidate instanceof RTCIceCandidate) return callAsyncMethod(this, 'add_ice_candidate', candidate); - // the JSON form of a candidate (the keys of RTCIceCandidateInit), which add_ice_candidate takes as it is const init = candidate ?? {}; - return callAsyncMethod(this, 'add_ice_candidate', { + return callAsyncMethod(this, 'add_ice_candidate', pyModel('RTCIceCandidateInit', { candidate: init.candidate === undefined ? '' : String(init.candidate), - sdpMid: init.sdpMid ?? null, - sdpMLineIndex: init.sdpMLineIndex ?? null, - usernameFragment: init.usernameFragment ?? null, - }); + sdp_mid: init.sdpMid ?? null, + sdp_m_line_index: init.sdpMLineIndex ?? null, + username_fragment: init.usernameFragment ?? null, + })); } async getStats(selector) { @@ -973,9 +980,17 @@ // members of the WebIDL dictionaries the library takes, the others are left out as WebIDL does const pick = (dict, members) => Object.fromEntries(members.filter((m) => dict[m] !== undefined).map((m) => [m, dict[m]])); - // a DOMRectInit, read from any object with its members (like a DOMRectReadOnly, whose members are getters) + // nested dictionaries, read from any object with their members (like a DOMRectReadOnly, whose members are getters) const toRectInit = (rect) => (rect == null ? rect : pick(rect, ['x', 'y', 'width', 'height'])); - const withRect = (dict, name) => (dict[name] === undefined ? dict : {...dict, [name]: toRectInit(dict[name])}); + const toColorSpaceInit = (space) => (space == null ? space : pick(space, ['primaries', 'transfer', 'matrix', 'fullRange'])); + const toLayout = (layout) => (layout == null ? layout : Array.from(layout, (plane) => pick(plane, ['offset', 'stride']))); + const nested = (dict) => { + const result = {...dict}; + for (const name of ['visibleRect', 'rect']) if (result[name] !== undefined) result[name] = toRectInit(result[name]); + if (result.colorSpace !== undefined) result.colorSpace = toColorSpaceInit(result.colorSpace); + if (result.layout !== undefined) result.layout = toLayout(result.layout); + return result; + }; const VIDEO_FRAME_BUFFER_INIT = [ 'format', 'codedWidth', 'codedHeight', 'timestamp', 'duration', 'layout', 'visibleRect', 'rotation', 'flip', 'displayWidth', 'displayHeight', 'colorSpace', @@ -983,7 +998,8 @@ const VIDEO_FRAME_INIT = [ 'duration', 'timestamp', 'alpha', 'visibleRect', 'rotation', 'flip', 'displayWidth', 'displayHeight', ]; - const copyToOptions = (options) => withRect(requireDictionary(options, 'VideoFrameCopyToOptions'), 'rect'); + const copyToOptions = (options) => + pyJson('VideoFrameCopyToOptions', nested(pick(requireDictionary(options, 'VideoFrameCopyToOptions'), ['rect', 'layout', 'format']))); class VideoFrame extends Interface { constructor(...args) { @@ -991,11 +1007,11 @@ requireArguments(args, 1, 'VideoFrame'); const dict = requireDictionary(init, 'VideoFrameInit'); if (image instanceof VideoFrame) { - const init = withRect(pick(dict, VIDEO_FRAME_INIT), 'visibleRect'); + const init = pyJson('VideoFrameInit', nested(pick(dict, VIDEO_FRAME_INIT))); return construct('VideoFrame', {source: toPy(image), init}); } if (image instanceof ArrayBuffer || ArrayBuffer.isView(image)) { - const init = withRect(pick(dict, VIDEO_FRAME_BUFFER_INIT), 'visibleRect'); + const init = pyJson('VideoFrameBufferInit', nested(pick(dict, VIDEO_FRAME_BUFFER_INIT))); return construct('VideoFrame', {source: bytesOf(image), init}); } // images, canvases and video elements are the browser's @@ -1054,6 +1070,8 @@ ]); const AUDIO_DATA_INIT = ['format', 'sampleRate', 'numberOfFrames', 'numberOfChannels', 'timestamp', 'data']; + const audioCopyToOptions = (options) => pyJson('AudioDataCopyToOptions', + pick(requireDictionary(options, 'options'), ['planeIndex', 'frameOffset', 'frameCount', 'format'])); class AudioData extends Interface { constructor(...args) { @@ -1061,15 +1079,15 @@ requireArguments(args, 1, 'AudioData'); const dict = pick(requireDictionary(init, 'AudioDataInit'), AUDIO_DATA_INIT); if (dict.data !== undefined) dict.data = bytesOf(dict.data, 'AudioDataInit.data'); - return construct('AudioData', {init: dict}); + return construct('AudioData', {init: pyJson('AudioDataInit', dict)}); })); } - allocationSize(options) { return callMethod(this, 'allocation_size', requireDictionary(options, 'options')); } + allocationSize(options) { return callMethod(this, 'allocation_size', audioCopyToOptions(options)); } copyTo(destination, options) { const bytes = bytesOf(destination, 'destination'); - const data = unwrap(bridge.audio_data_copy_to(pyObjects.get(this), bytes, requireDictionary(options, 'options'))); + const data = unwrap(bridge.audio_data_copy_to(pyObjects.get(this), bytes, audioCopyToOptions(options))); bytes.set(new Uint8Array(data)); } @@ -1096,7 +1114,7 @@ requireInterface(dict.track, MediaStreamTrack, 'MediaStreamTrackProcessor'); const kwargs = {track: toPy(dict.track)}; if (dict.maxBufferSize !== undefined) kwargs.max_buffer_size = enforceRange(dict.maxBufferSize, 0, 65535); - return construct('MediaStreamTrackProcessor', kwargs); + return construct('MediaStreamTrackProcessor', {init: pyModel('MediaStreamTrackProcessorInit', kwargs)}); })); } } @@ -1165,8 +1183,13 @@ const {Event: _, ...eventInterfaces} = events; Object.assign(globalThis, eventInterfaces, {RTCError, RTCStatsReport}); - // each a value, or a constraint on it (ConstrainULong, ConstrainDouble), which the library takes as they are - const VIDEO_CONSTRAINTS = {width: ['width'], height: ['height'], frameRate: ['frame_rate']}; + // each a value, or a constraint on it (ConstrainULong, ConstrainDouble) the library has a model of + const constrain = (name) => (v) => (typeof v === 'object' && v !== null ? pyJson(name, v) : v); + const VIDEO_CONSTRAINTS = { + width: ['width', constrain('ConstrainULongRange')], + height: ['height', constrain('ConstrainULongRange')], + frameRate: ['frame_rate', constrain('ConstrainDoubleRange')], + }; globalThis.navigator = { mediaDevices: {