diff --git a/README.md b/README.md index 9555b7f..41dac75 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ import webrtc async def main(): pc = webrtc.RTCPeerConnection() - stream = webrtc.get_user_media() + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True)) for track in stream.get_tracks(): pc.add_track(track, stream) diff --git a/docs/source/webrtc.functions.rst b/docs/source/webrtc.functions.rst deleted file mode 100644 index d034c8e..0000000 --- a/docs/source/webrtc.functions.rst +++ /dev/null @@ -1,15 +0,0 @@ -webrtc.functions package -======================== - -.. automodule:: webrtc.functions - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - webrtc.functions.get_user_media diff --git a/docs/source/webrtc.interfaces.media_devices.rst b/docs/source/webrtc.interfaces.media_devices.rst new file mode 100644 index 0000000..6174e85 --- /dev/null +++ b/docs/source/webrtc.interfaces.media_devices.rst @@ -0,0 +1,7 @@ +webrtc.interfaces.media\_devices module +======================================= + +.. automodule:: webrtc.interfaces.media_devices + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.interfaces.rst b/docs/source/webrtc.interfaces.rst index de53218..4013f3b 100644 --- a/docs/source/webrtc.interfaces.rst +++ b/docs/source/webrtc.interfaces.rst @@ -12,6 +12,7 @@ Submodules .. toctree:: :maxdepth: 4 + webrtc.interfaces.media_devices webrtc.interfaces.media_stream webrtc.interfaces.media_stream_track webrtc.interfaces.media_stream_track_processor @@ -21,7 +22,9 @@ Submodules webrtc.interfaces.rtc_ice_transport webrtc.interfaces.rtc_peer_connection webrtc.interfaces.rtc_rtp_receiver + webrtc.interfaces.rtc_rtp_script_transform webrtc.interfaces.rtc_rtp_sender webrtc.interfaces.rtc_rtp_transceiver webrtc.interfaces.rtc_sctp_transport + webrtc.interfaces.sframe_transform webrtc.interfaces.track_generator \ No newline at end of file diff --git a/docs/source/webrtc.interfaces.rtc_rtp_script_transform.rst b/docs/source/webrtc.interfaces.rtc_rtp_script_transform.rst new file mode 100644 index 0000000..ccfd090 --- /dev/null +++ b/docs/source/webrtc.interfaces.rtc_rtp_script_transform.rst @@ -0,0 +1,7 @@ +webrtc.interfaces.rtc\_rtp\_script\_transform +============================================= + +.. automodule:: webrtc.interfaces.rtc_rtp_script_transform + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/webrtc.interfaces.sframe_transform.rst b/docs/source/webrtc.interfaces.sframe_transform.rst new file mode 100644 index 0000000..57d34e0 --- /dev/null +++ b/docs/source/webrtc.interfaces.sframe_transform.rst @@ -0,0 +1,7 @@ +webrtc.interfaces.sframe\_transform +==================================== + +.. automodule:: webrtc.interfaces.sframe_transform + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.models.rst b/docs/source/webrtc.models.rst index 5bc076f..e4b8cb4 100644 --- a/docs/source/webrtc.models.rst +++ b/docs/source/webrtc.models.rst @@ -19,11 +19,14 @@ Submodules webrtc.models.media_track_constraints webrtc.models.rtc_certificate webrtc.models.rtc_configuration + webrtc.models.rtc_encoded_frame webrtc.models.rtc_ice_candidate + webrtc.models.rtc_offer_answer_options 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.sframe_transform_options webrtc.models.video_frame \ No newline at end of file diff --git a/docs/source/webrtc.functions.get_user_media.rst b/docs/source/webrtc.models.rtc_encoded_frame.rst similarity index 51% rename from docs/source/webrtc.functions.get_user_media.rst rename to docs/source/webrtc.models.rtc_encoded_frame.rst index 6396577..0b7a1b2 100644 --- a/docs/source/webrtc.functions.get_user_media.rst +++ b/docs/source/webrtc.models.rtc_encoded_frame.rst @@ -1,7 +1,7 @@ -webrtc.functions.get\_user\_media +webrtc.models.rtc\_encoded\_frame ================================= -.. automodule:: webrtc.functions.get_user_media +.. automodule:: webrtc.models.rtc_encoded_frame :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/source/webrtc.models.rtc_offer_answer_options.rst b/docs/source/webrtc.models.rtc_offer_answer_options.rst new file mode 100644 index 0000000..7464ee9 --- /dev/null +++ b/docs/source/webrtc.models.rtc_offer_answer_options.rst @@ -0,0 +1,7 @@ +webrtc.models.rtc\_offer\_answer\_options +========================================== + +.. automodule:: webrtc.models.rtc_offer_answer_options + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.models.sframe_transform_options.rst b/docs/source/webrtc.models.sframe_transform_options.rst new file mode 100644 index 0000000..335342d --- /dev/null +++ b/docs/source/webrtc.models.sframe_transform_options.rst @@ -0,0 +1,7 @@ +webrtc.models.sframe\_transform\_options +========================================= + +.. automodule:: webrtc.models.sframe_transform_options + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/webrtc.rst b/docs/source/webrtc.rst index a67985b..3eadcb1 100644 --- a/docs/source/webrtc.rst +++ b/docs/source/webrtc.rst @@ -12,7 +12,6 @@ Subpackages .. toctree:: :maxdepth: 4 - webrtc.functions webrtc.interfaces webrtc.models webrtc.utils diff --git a/examples/echo.py b/examples/echo.py index 6623c65..d8c228d 100755 --- a/examples/echo.py +++ b/examples/echo.py @@ -92,7 +92,9 @@ async def main() -> None: """Echoes the camera of the caller back to it.""" caller, echo = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() trickle(caller, echo) - camera = webrtc.get_user_media(audio=False, video=True).get_video_tracks()[0] + camera = (await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(video=True))).get_video_tracks()[ + 0 + ] caller.add_track(camera) generator = webrtc.VideoTrackGenerator() echoed = asyncio.get_running_loop().create_future() diff --git a/examples/recorder.py b/examples/recorder.py index 4f98f86..f243aa3 100755 --- a/examples/recorder.py +++ b/examples/recorder.py @@ -77,7 +77,7 @@ async def main() -> None: """Records the camera and the microphone for a few seconds.""" sender, receiver = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() trickle(sender, receiver) - stream = webrtc.get_user_media(audio=True, video=True) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) for track in stream.get_tracks(): sender.add_track(track, stream) diff --git a/pyproject.toml b/pyproject.toml index 8f56269..828be76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,7 +141,6 @@ ignore = [ "python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py" = ["PLR0904"] # the W3C interface # user callbacks error the stream, as the specification says; handler errors go to the loop's exception handler "python-webrtc/python/webrtc/{streams.py,utils/events.py}" = ["BLE001"] -"python-webrtc/python/webrtc/models/rtc_stats.py" = ["FURB189"] # a real dict, for json and isinstance # native leak counters and queues have no public API; chaos replays a seeded random "tests/**" = ["S101", "D1", "PLR2004", "S404", "S603", "SLF001", "S311"] "examples/**" = ["T201"] @@ -152,7 +151,7 @@ ignore = [ convention = "google" [tool.ruff.lint.flake8-builtins] -ignorelist = ["format", "id", "type"] # W3C member names +ignorelist = ["format", "hash", "id", "type"] # W3C member names [tool.ruff.lint.pydoclint] ignore-one-line-docstrings = true diff --git a/python-webrtc/cpp/src/enums/enums.h b/python-webrtc/cpp/src/enums/enums.h index ea0792c..39364db 100644 --- a/python-webrtc/cpp/src/enums/enums.h +++ b/python-webrtc/cpp/src/enums/enums.h @@ -90,7 +90,6 @@ namespace python_webrtc { {State::kIceConnectionFailed, "failed"}, {State::kIceConnectionDisconnected, "disconnected"}, {State::kIceConnectionClosed, "closed"}, - {State::kIceConnectionMax, "max"}, }); }; @@ -129,7 +128,7 @@ namespace python_webrtc { template <> struct StrEnum { using Direction = webrtc::RtpTransceiverDirection; - static constexpr auto name = pybind11::detail::const_name("TransceiverDirection"); + static constexpr auto name = pybind11::detail::const_name("RTCRtpTransceiverDirection"); static constexpr auto values = std::to_array>({ {Direction::kSendRecv, "sendrecv"}, {Direction::kSendOnly, "sendonly"}, @@ -186,7 +185,7 @@ namespace python_webrtc { template <> struct StrEnum { - static constexpr auto name = pybind11::detail::const_name("CricketIceGatheringState"); + static constexpr auto name = pybind11::detail::const_name("RTCIceGathererState"); static constexpr auto values = std::to_array>({ {webrtc::IceGatheringState::kIceGatheringNew, "new"}, {webrtc::IceGatheringState::kIceGatheringGathering, "gathering"}, @@ -197,7 +196,7 @@ namespace python_webrtc { template <> struct StrEnum { using State = webrtc::DtlsTransportState; - static constexpr auto name = pybind11::detail::const_name("DtlsTransportState"); + static constexpr auto name = pybind11::detail::const_name("RTCDtlsTransportState"); static constexpr auto values = std::to_array>({ {State::kNew, "new"}, {State::kConnecting, "connecting"}, @@ -210,10 +209,11 @@ namespace python_webrtc { template <> struct StrEnum { using State = webrtc::SctpTransportState; - static constexpr auto name = pybind11::detail::const_name("SctpTransportState"); + static constexpr auto name = pybind11::detail::const_name("RTCSctpTransportState"); static constexpr auto values = std::to_array>({ - {State::kNew, "new"}, {State::kConnecting, "connecting"}, + // libwebrtc only: the transport is connecting from its creation in the specification + {State::kNew, "connecting"}, {State::kConnected, "connected"}, {State::kClosed, "closed"}, }); diff --git a/python-webrtc/cpp/src/interfaces/interfaces.cpp b/python-webrtc/cpp/src/interfaces/interfaces.cpp index da6db65..08fdd81 100644 --- a/python-webrtc/cpp/src/interfaces/interfaces.cpp +++ b/python-webrtc/cpp/src/interfaces/interfaces.cpp @@ -7,7 +7,11 @@ #include "interfaces.h" +#include "../media/encoded_frame.h" +#include "../media/frame_transformer_bridge.h" #include "../media/media_stream_track_processor.h" +#include "../media/rtc_rtp_script_transform.h" +#include "../media/sframe_transform.h" #include "../media/track_generator.h" #include "../media/video_frame_buffer.h" #include "../utils/alive_count.h" @@ -65,6 +69,10 @@ namespace python_webrtc { {"MediaStreamTrackProcessor", AliveCount::count.load()}, {"TrackGenerator", AliveCount::count.load()}, {"VideoFrameBuffer", AliveCount::count.load()}, + {"RTCRtpScriptTransform", AliveCount::count.load()}, + {"SFrameTransform", AliveCount::count.load()}, + {"RTCEncodedFrame", AliveCount::count.load()}, + {"FrameTransformerBridge", AliveCount::count.load()}, }; }, nogil()); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp index 43c84cd..44a0514 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp @@ -128,12 +128,14 @@ namespace python_webrtc { void RTCPeerConnection::Init(pybind11::module &m) { Listeners::BindClass(m, "RTCPeerConnection") .def(pybind11::init(nogil_factory(+[](const std::optional &configuration) { - return std::shared_ptr(new RTCPeerConnection(configuration), DeleteOffLibwebrtcThread()); - }))) + return std::shared_ptr(new RTCPeerConnection(configuration), + DeleteOffLibwebrtcThread()); + })), + pybind11::arg("configuration")) .def("createOffer", WithCallbacks(&RTCPeerConnection::CreateOffer), pybind11::arg("onSuccess"), - pybind11::arg("onFailure"), pybind11::arg("iceRestart"), pybind11::arg("voiceActivityDetection")) + pybind11::arg("onFailure"), pybind11::arg("iceRestart")) .def("createAnswer", WithCallbacks(&RTCPeerConnection::CreateAnswer), pybind11::arg("onSuccess"), - pybind11::arg("onFailure"), pybind11::arg("voiceActivityDetection")) + pybind11::arg("onFailure")) .def("setLocalDescription", WithCallbacks(&RTCPeerConnection::SetLocalDescription), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("description")) .def("setRemoteDescription", WithCallbacks(&RTCPeerConnection::SetRemoteDescription), @@ -499,8 +501,7 @@ namespace python_webrtc { } void RTCPeerConnection::CreateOffer(std::function &onSuccess, - std::function &onFailure, bool iceRestart, - bool voiceActivityDetection) { + std::function &onFailure, bool iceRestart) { auto pc = connection(); auto state = pc ? pc->signaling_state() : SignalingState::kClosed; if (state == SignalingState::kClosed) { @@ -518,14 +519,12 @@ namespace python_webrtc { auto options = webrtc::PeerConnectionInterface::RTCOfferAnswerOptions(); options.ice_restart = iceRestart; - options.voice_activity_detection = voiceActivityDetection; pc->CreateOffer(observer.get(), options); } void RTCPeerConnection::CreateAnswer(std::function &onSuccess, - std::function &onFailure, - bool voiceActivityDetection) { + std::function &onFailure) { auto pc = connection(); if (!pc || pc->signaling_state() == SignalingState::kClosed) { onFailure(RTCCallbackException(closedError("createAnswer"))); @@ -533,9 +532,7 @@ namespace python_webrtc { } auto observer = webrtc::make_ref_counted(weak_from_this(), onSuccess, onFailure); - auto options = webrtc::PeerConnectionInterface::RTCOfferAnswerOptions(); - options.voice_activity_detection = voiceActivityDetection; - pc->CreateAnswer(observer.get(), options); + pc->CreateAnswer(observer.get(), webrtc::PeerConnectionInterface::RTCOfferAnswerOptions()); } void RTCPeerConnection::SaveCreatedDescription(const RTCSessionDescriptionInit &description) { @@ -1304,6 +1301,12 @@ namespace python_webrtc { pc->Close(); for (const auto &transceiver : pc->GetTransceivers()) { Wrap(_transceivers, transceiver)->GetReceiver()->GetTrack()->OnPeerConnectionClosed(); + if (auto sender = RTCRtpSender::holder().Find(transceiver->sender().get())) { + sender->ReleaseTransform(); + } + if (auto receiver = RTCRtpReceiver::holder().Find(transceiver->receiver().get())) { + receiver->ReleaseTransform(); + } } } diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h index 9c0b090..70c1b15 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h @@ -75,11 +75,10 @@ namespace python_webrtc { static void ReleaseElsewhere(std::shared_ptr &&connection); void CreateOffer(std::function &onSuccess, - std::function &onFailure, bool iceRestart, - bool voiceActivityDetection); + std::function &onFailure, bool iceRestart); void CreateAnswer(std::function &onSuccess, - std::function &onFailure, bool voiceActivityDetection); + std::function &onFailure); // the last description createOffer or createAnswer made, which are the only ones setLocalDescription takes void SaveCreatedDescription(const RTCSessionDescriptionInit &description); diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp index 0c7b560..b06978c 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp @@ -35,6 +35,7 @@ namespace python_webrtc { RTCRtpReceiver::~RTCRtpReceiver() { const BlockingDestructor release("RTCRtpReceiver"); + _transform.Release(); // callbacks run on the signaling thread, so after this none of them can be running or start again _factory->signalingThread()->BlockingCall([this]() { @@ -63,6 +64,7 @@ namespace python_webrtc { .def("getStats", WithCallbacks(&RTCRtpReceiver::GetStats), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) .def_static("getCapabilities", &RTCRtpReceiver::GetCapabilities, nogil(), pybind11::arg("kind")) + .def_property("transform", nogil_fn(&RTCRtpReceiver::GetTransform), nogil_fn(&RTCRtpReceiver::SetTransform)) .def("_getSources", &RTCRtpReceiver::GetSources, nogil()); } @@ -157,6 +159,33 @@ namespace python_webrtc { return sources; } + FrameSource RTCRtpReceiver::TransformSource() { + FrameSource source; + source.sender = false; + source.video = _receiver->media_type() == webrtc::MediaType::VIDEO; + source.sendKeyFrameRequest = [weak = weak_from_this()]() { + auto self = weak.lock(); + if (!self) { + return; + } + // the source of a remote video track asks the receiver's stream for a key frame (a PLI) + auto track = self->_receiver->track(); + auto *video = dynamic_cast(track.get()); + if (video != nullptr && video->GetSource() != nullptr) { + video->GetSource()->GenerateKeyFrame(); + } + }; + return source; + } + + void RTCRtpReceiver::SetTransform(const std::shared_ptr &transform) { + _transform.Set( + transform, [this]() { return TransformSource(); }, + [this](const webrtc::scoped_refptr &bridge) { + _receiver->SetFrameTransformer(bridge); + }); + } + std::optional RTCRtpReceiver::GetCapabilities(const std::string &kind) { auto type = mediaTypeOf(kind); if (!type) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.h b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.h index 93c7f71..894ea8c 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.h +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.h @@ -20,6 +20,7 @@ #include #include "../exceptions.h" +#include "../media/frame_transformer_bridge.h" #include "../utils/alive_guard.h" #include "media_stream_track.h" #include "peer_connection_factory.h" @@ -29,7 +30,9 @@ namespace python_webrtc { class RTCPeerConnection; - class RTCRtpReceiver : public webrtc::RtpReceiverObserverInterface, public SingleObserverSlot { + class RTCRtpReceiver : public webrtc::RtpReceiverObserverInterface, + public SingleObserverSlot, + public std::enable_shared_from_this { public: // (is a synchronization source, source, timestamp in ms since the Unix epoch, RTP timestamp, // audio level in -dBov) @@ -75,9 +78,17 @@ namespace python_webrtc { static std::optional GetCapabilities(const std::string &kind); + std::shared_ptr GetTransform() { return _transform.Get(); } + + void SetTransform(const std::shared_ptr &transform); + + void ReleaseTransform() { _transform.Release(); } + private: std::shared_ptr GetConnection(); + FrameSource TransformSource(); + std::shared_ptr _factory; webrtc::scoped_refptr _receiver; // a receiver has the same (remote) track for its whole life @@ -88,6 +99,7 @@ namespace python_webrtc { // the receiver owns the wrapper of its current transport std::shared_ptr _transport; std::optional _jitterBufferTarget; + TransformSlot _transform; // see AliveGuard AliveGuard _alive; diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp index d9bc00b..b138eb7 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp @@ -8,6 +8,7 @@ #include "rtc_rtp_sender.h" #include "../utils/python_callback.h" +#include #include #include #include @@ -62,6 +63,10 @@ namespace python_webrtc { webrtc::scoped_refptr sender) : _factory(std::move(factory)), _sender(std::move(sender)) {} + RTCRtpSender::~RTCRtpSender() { + _transform.Release(); + } + void RTCRtpSender::Init(pybind11::module &m) { pybind11::class_>(m, "RTCRtpSender") .def_property_readonly("track", nogil_fn(&RTCRtpSender::GetTrack)) @@ -76,6 +81,7 @@ namespace python_webrtc { .def("getStreamIds", &RTCRtpSender::GetStreamIds, nogil()) .def("getStats", WithCallbacks(&RTCRtpSender::GetStats), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) .def_static("getCapabilities", &RTCRtpSender::GetCapabilities, nogil(), pybind11::arg("kind")) + .def_property("transform", nogil_fn(&RTCRtpSender::GetTransform), nogil_fn(&RTCRtpSender::SetTransform)) .def("_transceiverStopped", &RTCRtpSender::IsTransceiverStopped, nogil()) .def("_lastParameters", &RTCRtpSender::GetLastParameters, nogil()) .def("_expireParameters", &RTCRtpSender::ExpireParameters, nogil(), @@ -261,6 +267,36 @@ namespace python_webrtc { return !transceiver || transceiver->stopping() || transceiver->stopped(); } + FrameSource RTCRtpSender::TransformSource() { + FrameSource source; + source.sender = true; + source.video = _sender->media_type() == webrtc::MediaType::VIDEO; + source.generateKeyFrame = [weak = weak_from_this()](const std::vector &rids) { + auto self = weak.lock(); + if (!self) { + return FrameSource::KeyFrameResult::kRequested; + } + auto encodings = self->_sender->GetParameters().encodings; + for (const auto &rid : rids) { + // a single encoding is no layer of a rid, even if it kept the rid of simulcast negotiated away + if (encodings.size() < 2 || + std::ranges::none_of(encodings, [&](const auto &encoding) { return encoding.rid == rid; })) { + return FrameSource::KeyFrameResult::kUnknownRid; + } + } + // fails only for a rid, checked above + (void)self->_sender->GenerateKeyFrame(rids); + return FrameSource::KeyFrameResult::kRequested; + }; + return source; + } + + void RTCRtpSender::SetTransform(const std::shared_ptr &transform) { + _transform.Set( + transform, [this]() { return TransformSource(); }, + [this](const webrtc::scoped_refptr &bridge) { _sender->SetFrameTransformer(bridge); }); + } + std::optional RTCRtpSender::GetCapabilities(const std::string &kind) { auto type = mediaTypeOf(kind); if (!type) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h index 92357d6..6618ab4 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h @@ -20,6 +20,7 @@ #include "../enums/enums.h" #include "../exceptions.h" +#include "../media/frame_transformer_bridge.h" #include "media_stream_track.h" #include "peer_connection_factory.h" #include "rtc_dtls_transport.h" @@ -29,11 +30,16 @@ namespace python_webrtc { class RTCPeerConnection; - class RTCRtpSender { + class RTCRtpSender : public std::enable_shared_from_this { public: explicit RTCRtpSender(std::shared_ptr factory, webrtc::scoped_refptr sender); + ~RTCRtpSender(); + + RTCRtpSender(const RTCRtpSender &) = delete; + RTCRtpSender &operator=(const RTCRtpSender &) = delete; + static void Init(pybind11::module &m); static InstanceHolder &holder(); @@ -81,7 +87,15 @@ namespace python_webrtc { static std::optional GetCapabilities(const std::string &kind); + std::shared_ptr GetTransform() { return _transform.Get(); } + + void SetTransform(const std::shared_ptr &transform); + + void ReleaseTransform() { _transform.Release(); } + private: + FrameSource TransformSource(); + // what the DTMF sender finds the transceiver of the sender with std::function()> TransceiverGetter(); @@ -95,6 +109,7 @@ namespace python_webrtc { std::shared_ptr _transport; std::shared_ptr _dtmf; std::optional _lastParameters; + TransformSlot _transform; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/boringssl.h b/python-webrtc/cpp/src/media/boringssl.h new file mode 100644 index 0000000..700b771 --- /dev/null +++ b/python-webrtc/cpp/src/media/boringssl.h @@ -0,0 +1,58 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_BORINGSSL_H_ +#define PYTHON_WEBRTC_MEDIA_BORINGSSL_H_ + +#include +#include + +// libwebrtc links BoringSSL without its headers: declared as in boringssl@572a4c68475d284b34675f45ddbb9c158ef3c2ae +// NOLINTBEGIN(readability-identifier-naming,readability-identifier-length,modernize-use-using,bugprone-reserved-identifier) +extern "C" { + +typedef struct env_md_st EVP_MD; +typedef struct evp_cipher_st EVP_CIPHER; +typedef struct evp_cipher_ctx_st EVP_CIPHER_CTX; +typedef struct evp_aead_st EVP_AEAD; +typedef struct evp_aead_ctx_st EVP_AEAD_CTX; +typedef struct engine_st ENGINE; + +const EVP_MD *EVP_sha256(void); +const EVP_MD *EVP_sha512(void); + +int HKDF_extract(uint8_t *out_key, size_t *out_len, const EVP_MD *digest, const uint8_t *secret, size_t secret_len, + const uint8_t *salt, size_t salt_len); +int HKDF_expand(uint8_t *out_key, size_t out_len, const EVP_MD *digest, const uint8_t *prk, size_t prk_len, + const uint8_t *info, size_t info_len); + +uint8_t *HMAC(const EVP_MD *evp_md, const void *key, size_t key_len, const uint8_t *data, size_t data_len, uint8_t *out, + unsigned int *out_len); + +const EVP_CIPHER *EVP_aes_128_ctr(void); +const EVP_CIPHER *EVP_aes_256_ctr(void); +EVP_CIPHER_CTX *EVP_CIPHER_CTX_new(void); +void EVP_CIPHER_CTX_free(EVP_CIPHER_CTX *ctx); +int EVP_EncryptInit_ex(EVP_CIPHER_CTX *ctx, const EVP_CIPHER *cipher, ENGINE *engine, const uint8_t *key, + const uint8_t *iv); +int EVP_EncryptUpdate(EVP_CIPHER_CTX *ctx, uint8_t *out, int *out_len, const uint8_t *in, int in_len); + +const EVP_AEAD *EVP_aead_aes_128_gcm(void); +const EVP_AEAD *EVP_aead_aes_256_gcm(void); +EVP_AEAD_CTX *EVP_AEAD_CTX_new(const EVP_AEAD *aead, const uint8_t *key, size_t key_len, size_t tag_len); +void EVP_AEAD_CTX_free(EVP_AEAD_CTX *ctx); +int EVP_AEAD_CTX_seal(const EVP_AEAD_CTX *ctx, uint8_t *out, size_t *out_len, size_t max_out_len, const uint8_t *nonce, + size_t nonce_len, const uint8_t *in, size_t in_len, const uint8_t *ad, size_t ad_len); +int EVP_AEAD_CTX_open(const EVP_AEAD_CTX *ctx, uint8_t *out, size_t *out_len, size_t max_out_len, const uint8_t *nonce, + size_t nonce_len, const uint8_t *in, size_t in_len, const uint8_t *ad, size_t ad_len); + +int CRYPTO_memcmp(const void *a, const void *b, size_t len); +void OPENSSL_cleanse(void *ptr, size_t len); +} +// NOLINTEND(readability-identifier-naming,readability-identifier-length,modernize-use-using,bugprone-reserved-identifier) + +#endif // PYTHON_WEBRTC_MEDIA_BORINGSSL_H_ diff --git a/python-webrtc/cpp/src/media/encoded_frame.cpp b/python-webrtc/cpp/src/media/encoded_frame.cpp new file mode 100644 index 0000000..639241b --- /dev/null +++ b/python-webrtc/cpp/src/media/encoded_frame.cpp @@ -0,0 +1,144 @@ +// +// 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. +// + +#include "encoded_frame.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "../utils/buffer.h" +#include "../utils/gil.h" + +namespace python_webrtc { + + namespace { + + // libwebrtc times frames with its monotonic clock, Python with the Unix epoch (like the RTP sources) + constexpr double kUsPerMs = 1000; + + double EpochMs(webrtc::Timestamp time) { + return (static_cast(time.us()) / kUsPerMs) + + static_cast(webrtc::TimeUTCMillis() - webrtc::TimeMillis()); + } + + using Value = std::variant>; + using Metadata = std::vector>; + + std::vector Integers(auto &&values) { + return {values.begin(), values.end()}; + } + + Metadata MetadataOf(const webrtc::TransformableFrameInterface &frame) { + Metadata metadata; + auto set = [&](const char *name, Value value) { metadata.emplace_back(name, std::move(value)); }; + set("synchronizationSource", static_cast(frame.GetSsrc())); + set("payloadType", static_cast(frame.GetPayloadType())); + set("rtpTimestamp", static_cast( + std::visit([](auto timestamp) { return timestamp.value; }, frame.GetRtpTimestampInfo()))); + if (auto time = frame.ReceiveTime()) { + set("receiveTime", EpochMs(*time)); + } + if (auto time = frame.CaptureTime()) { + // a received frame has the capture time of the remote clock, since the epoch already + const bool received = frame.GetDirection() == webrtc::TransformableFrameInterface::Direction::kReceiver; + set("captureTime", received ? static_cast(time->us()) / kUsPerMs : EpochMs(*time)); + } + if (auto offset = frame.SenderCaptureTimeOffset()) { + set("senderCaptureTimeOffset", offset->ms()); + } + set("mimeType", frame.GetMimeType()); + + if (const auto *video = dynamic_cast(&frame)) { + const auto videoMetadata = video->Metadata(); + set("contributingSources", Integers(videoMetadata.GetCsrcs())); + if (auto frameId = videoMetadata.GetFrameId()) { + set("frameId", *frameId); + } + if (auto dependencies = videoMetadata.GetDependencies()) { + set("dependencies", Integers(*dependencies)); + } + set("width", static_cast(videoMetadata.GetWidth())); + set("height", static_cast(videoMetadata.GetHeight())); + set("spatialIndex", static_cast(videoMetadata.GetSpatialIndex())); + set("temporalIndex", static_cast(videoMetadata.GetTemporalIndex())); + if (auto timestamp = frame.GetPresentationTimestamp()) { + set("timestamp", timestamp->us()); + } + set("keyFrame", video->IsKeyFrame()); + if (auto rid = video->Rid()) { + set("rid", *rid); + } + } else if (const auto *audio = dynamic_cast(&frame)) { + set("contributingSources", Integers(audio->GetContributingSources())); + if (auto sequenceNumber = audio->SequenceNumber()) { + set("sequenceNumber", static_cast(*sequenceNumber)); + } + if (auto level = audio->AudioLevel()) { + // -dBov, as the linear level of the RTP sources, 127 being silence + constexpr double base = 10; + constexpr double dbPerDecade = 20; + constexpr uint8_t silent = 127; + set("audioLevel", *level >= silent ? 0.0 : std::pow(base, -static_cast(*level) / dbPerDecade)); + } + } + return metadata; + } + + } // namespace + + EncodedFrame::EncodedFrame(std::unique_ptr frame, uint64_t source) + : _video(dynamic_cast(frame.get()) != nullptr), _source(source), + _frame(std::move(frame)) {} + + void EncodedFrame::Init(pybind11::module &m) { + pybind11::class_>(m, "RTCEncodedFrame") + .def_property_readonly("video", &EncodedFrame::IsVideo) + .def("getData", &EncodedFrame::GetData) + .def("getMetadata", &EncodedFrame::GetMetadata); + } + + pybind11::bytes EncodedFrame::GetData() { + std::vector data; + { + const std::scoped_lock lock(_mutex); + if (_frame) { + auto span = _frame->GetData(); + data.assign(span.begin(), span.end()); + } + } + return Bytes(data.data(), data.size()); + } + + pybind11::dict EncodedFrame::GetMetadata() { + Metadata metadata; + { + const std::scoped_lock lock(_mutex); + if (_frame) { + metadata = MetadataOf(*_frame); + } + } + pybind11::dict dict; + for (const auto &[name, value] : metadata) { + dict[name] = pybind11::cast(value); + } + return dict; + } + + std::unique_ptr EncodedFrame::Take() { + const std::scoped_lock lock(_mutex); + return std::move(_frame); + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/encoded_frame.h b/python-webrtc/cpp/src/media/encoded_frame.h new file mode 100644 index 0000000..36a1a15 --- /dev/null +++ b/python-webrtc/cpp/src/media/encoded_frame.h @@ -0,0 +1,51 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_ENCODED_FRAME_H_ +#define PYTHON_WEBRTC_MEDIA_ENCODED_FRAME_H_ + +#include +#include +#include + +#include + +#include + +#include "../utils/alive_count.h" + +namespace python_webrtc { + + class EncodedFrame { + public: + EncodedFrame(std::unique_ptr frame, uint64_t source); + + static void Init(pybind11::module &m); + + [[nodiscard]] bool IsVideo() const { return _video; } + + [[nodiscard]] uint64_t Source() const { return _source; } + + pybind11::bytes GetData(); + + pybind11::dict GetMetadata(); + + std::unique_ptr Take(); + + private: + AliveCount _counted; + const bool _video; + const uint64_t _source; + + // never held while taking the GIL: a thread with it may wait for the lock (Take() comes without it) + std::mutex _mutex; + std::unique_ptr _frame; + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_MEDIA_ENCODED_FRAME_H_ diff --git a/python-webrtc/cpp/src/media/frame_transformer_bridge.cpp b/python-webrtc/cpp/src/media/frame_transformer_bridge.cpp new file mode 100644 index 0000000..d083fda --- /dev/null +++ b/python-webrtc/cpp/src/media/frame_transformer_bridge.cpp @@ -0,0 +1,161 @@ +// +// 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. +// + +#include "frame_transformer_bridge.h" + +#include + +#include + +#include "../exceptions.h" +#include "../utils/libwebrtc_thread.h" + +namespace python_webrtc { + + void RtpTransform::Init(pybind11::module &m) { + pybind11::class_>(m, "_RtpTransform"); + } + + namespace { + + uint64_t NextBridgeId() { + static std::atomic last{0}; + return ++last; + } + + } // namespace + + FrameTransformerBridge::FrameTransformerBridge(FrameSource source) + : _source(std::move(source)), _id(NextBridgeId()) {} + + FrameTransformerBridge::~FrameTransformerBridge() { + // libwebrtc may release the bridge on its threads: the transform is then deleted elsewhere + const LibwebrtcThreadScope scope; + _transform.reset(); + } + + void FrameTransformerBridge::SetTransform(std::shared_ptr transform) { + std::shared_ptr previous; + const std::scoped_lock lock(_mutex); + // released out of the lock + previous = std::exchange(_transform, std::move(transform)); + } + + void FrameTransformerBridge::Transform(std::unique_ptr frame) { + // a transform replaced meanwhile is deleted elsewhere (see DeleteOffLibwebrtcThread) + const LibwebrtcThreadScope scope; + std::shared_ptr transform; + { + const std::scoped_lock lock(_mutex); + transform = _transform; + } + if (transform) { + transform->Transform(std::move(frame)); + } else { + Output(std::move(frame)); + } + } + + void FrameTransformerBridge::Output(std::unique_ptr frame) { + // called under the lock, as callbacks only post to a queue: none is called once unregistered (queue may be gone) + const std::scoped_lock lock(_mutex); + auto it = _sinkCallbacks.find(frame->GetSsrc()); + const auto &callback = it != _sinkCallbacks.end() ? it->second : _callback; + if (callback) { + callback->OnTransformedFrame(std::move(frame)); + } + } + + void FrameTransformerBridge::RegisterTransformedFrameCallback( + webrtc::scoped_refptr callback) { + const std::scoped_lock lock(_mutex); + _callback = std::move(callback); + } + + void FrameTransformerBridge::RegisterTransformedFrameSinkCallback( + webrtc::scoped_refptr callback, uint32_t ssrc) { + const std::scoped_lock lock(_mutex); + _sinkCallbacks[ssrc] = std::move(callback); + } + + void FrameTransformerBridge::UnregisterTransformedFrameCallback() { + webrtc::scoped_refptr previous; + const std::scoped_lock lock(_mutex); + previous = std::move(_callback); + } + + void FrameTransformerBridge::UnregisterTransformedFrameSinkCallback(uint32_t ssrc) { + webrtc::scoped_refptr previous; + const std::scoped_lock lock(_mutex); + auto it = _sinkCallbacks.find(ssrc); + if (it != _sinkCallbacks.end()) { + previous = std::move(it->second); + _sinkCallbacks.erase(it); + } + } + + std::shared_ptr TransformSlot::Get() { + const std::scoped_lock lock(_mutex); + return _transform; + } + + void TransformSlot::Set(const std::shared_ptr &transform, const std::function &source, + const std::function &)> &install) { + const std::scoped_lock setLock(_setMutex); + if (transform && transform == Get()) { + // browsers allow setting the same transform again + return; + } + if (transform && !transform->TakeOwnership()) { + throw RTCException(webrtc::RTCErrorType::INVALID_STATE, + "The transform is already used by an RTCRtpSender or RTCRtpReceiver"); + } + webrtc::scoped_refptr bridge; + std::shared_ptr previous; + bool installing = false; + { + const std::scoped_lock lock(_mutex); + if (!_bridge && transform) { + _bridge = webrtc::make_ref_counted(source()); + installing = true; + } + bridge = _bridge; + previous = std::exchange(_transform, transform); + } + if (transform) { + transform->Associate(bridge); + } + if (bridge) { + bridge->SetTransform(transform); + } + if (previous) { + previous->Disassociate(); + } + // installed after the transform is set, as frames may come right away + if (installing) { + install(bridge); + } + } + + void TransformSlot::Release() { + const std::scoped_lock setLock(_setMutex); + std::shared_ptr transform; + webrtc::scoped_refptr bridge; + { + const std::scoped_lock lock(_mutex); + transform = _transform; + bridge = _bridge; + } + if (bridge) { + bridge->SetTransform(nullptr); + } + if (transform) { + transform->Disassociate(); + } + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/frame_transformer_bridge.h b/python-webrtc/cpp/src/media/frame_transformer_bridge.h new file mode 100644 index 0000000..9ee04e2 --- /dev/null +++ b/python-webrtc/cpp/src/media/frame_transformer_bridge.h @@ -0,0 +1,127 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_FRAME_TRANSFORMER_BRIDGE_H_ +#define PYTHON_WEBRTC_MEDIA_FRAME_TRANSFORMER_BRIDGE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "../utils/alive_count.h" + +namespace python_webrtc { + + class FrameTransformerBridge; + + class RtpTransform { + public: + virtual ~RtpTransform() = default; + + RtpTransform() = default; + + RtpTransform(const RtpTransform &) = delete; + RtpTransform &operator=(const RtpTransform &) = delete; + + bool TakeOwnership() { return !_owned.exchange(true); } + + // on a libwebrtc thread: the frame goes back with bridge.Output(), or is dropped + virtual void Transform(std::unique_ptr frame) = 0; + + virtual void Associate(webrtc::scoped_refptr bridge) = 0; + + virtual void Disassociate() = 0; + + static void Init(pybind11::module &m); + + private: + std::atomic _owned{false}; + }; + + // the functions hold the sender or receiver weakly, and do nothing once it's gone + struct FrameSource { + enum class KeyFrameResult : uint8_t { kRequested, kUnknownRid }; + + bool sender = false; + bool video = false; + std::function &)> generateKeyFrame; + std::function sendKeyFrameRequest; + }; + + class FrameTransformerBridge : public webrtc::FrameTransformerInterface { + public: + explicit FrameTransformerBridge(FrameSource source); + + ~FrameTransformerBridge() override; + + FrameTransformerBridge(const FrameTransformerBridge &) = delete; + FrameTransformerBridge &operator=(const FrameTransformerBridge &) = delete; + + [[nodiscard]] const FrameSource &Source() const { return _source; } + + // identifies the bridge for the frames it gives, never reused (unlike its address) + [[nodiscard]] uint64_t Id() const { return _id; } + + void SetTransform(std::shared_ptr transform); + + void Output(std::unique_ptr frame); + + void Transform(std::unique_ptr frame) override; + + void RegisterTransformedFrameCallback(webrtc::scoped_refptr callback) override; + + void RegisterTransformedFrameSinkCallback(webrtc::scoped_refptr callback, + uint32_t ssrc) override; + + void UnregisterTransformedFrameCallback() override; + + void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) override; + + private: + AliveCount _counted; + const FrameSource _source; + const uint64_t _id; + + std::mutex _mutex; + std::shared_ptr _transform; + webrtc::scoped_refptr _callback; + // video streams register one per SSRC (simulcast layers) + std::map> _sinkCallbacks; + }; + + class TransformSlot { + public: + std::shared_ptr Get(); + + // calls libwebrtc: without the GIL + void Set(const std::shared_ptr &transform, const std::function &source, + const std::function &)> &install); + + void Release(); + + private: + // setters run one at a time, so transforms are associated in the order they're set + std::mutex _setMutex; + std::mutex _mutex; + std::shared_ptr _transform; + webrtc::scoped_refptr _bridge; + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_MEDIA_FRAME_TRANSFORMER_BRIDGE_H_ diff --git a/python-webrtc/cpp/src/media/media.cpp b/python-webrtc/cpp/src/media/media.cpp index f9024a6..8ee4945 100644 --- a/python-webrtc/cpp/src/media/media.cpp +++ b/python-webrtc/cpp/src/media/media.cpp @@ -8,7 +8,11 @@ #include "media.h" #include "audio_samples.h" +#include "encoded_frame.h" +#include "frame_transformer_bridge.h" #include "media_stream_track_processor.h" +#include "rtc_rtp_script_transform.h" +#include "sframe_transform.h" #include "track_generator.h" #include "video_frame_buffer.h" @@ -19,6 +23,10 @@ namespace python_webrtc { AudioSamples::Init(m); MediaStreamTrackProcessor::Init(m); TrackGenerator::Init(m); + RtpTransform::Init(m); + EncodedFrame::Init(m); + RTCRtpScriptTransform::Init(m); + SFrameTransform::Init(m); } } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp index d4574cc..fb6b5c4 100644 --- a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp +++ b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp @@ -105,8 +105,19 @@ namespace python_webrtc { } void MediaStreamTrackProcessor::OnFrame(const webrtc::VideoFrame &frame) { + int64_t timestampUs = frame.timestamp_us(); + // a received frame (local ones have no RTP timestamp), in the 90 kHz clock of RTP video since the first one + if (frame.rtp_timestamp() != 0) { + const int64_t rtp = _rtpUnwrapper.Unwrap(frame.rtp_timestamp()); + if (!_firstReceived) { + _firstReceived.emplace(timestampUs, rtp); + } + constexpr int64_t kRtpTicksPerMs = 90; + timestampUs = + _firstReceived->first + ((rtp - _firstReceived->second) * webrtc::kNumMicrosecsPerMillisec / kRtpTicksPerMs); + } Push(VideoItem{.buffer = frame.video_frame_buffer(), - .timestampUs = frame.timestamp_us(), + .timestampUs = timestampUs, .rotation = static_cast(frame.rotation()), .rtpTimestamp = frame.rtp_timestamp()}); } diff --git a/python-webrtc/cpp/src/media/media_stream_track_processor.h b/python-webrtc/cpp/src/media/media_stream_track_processor.h index 8734024..cae3a20 100644 --- a/python-webrtc/cpp/src/media/media_stream_track_processor.h +++ b/python-webrtc/cpp/src/media/media_stream_track_processor.h @@ -20,6 +20,7 @@ #include #include #include +#include #include @@ -124,6 +125,9 @@ namespace python_webrtc { bool _ended = false; // a wakeup is on its way to Python bool _wakePending = false; + // the timestamps of received video, from their RTP timestamps: their render times may repeat + webrtc::RtpTimestampUnwrapper _rtpUnwrapper; + std::optional> _firstReceived; std::atomic _totalFrames = 0; std::atomic _discardedFrames = 0; diff --git a/python-webrtc/cpp/src/media/rtc_rtp_script_transform.cpp b/python-webrtc/cpp/src/media/rtc_rtp_script_transform.cpp new file mode 100644 index 0000000..634a749 --- /dev/null +++ b/python-webrtc/cpp/src/media/rtc_rtp_script_transform.cpp @@ -0,0 +1,208 @@ +// +// 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. +// + +#include "rtc_rtp_script_transform.h" + +#include +#include +#include + +#include + +#include "../utils/buffer.h" +#include "../utils/gil.h" +#include "../utils/libwebrtc_thread.h" + +namespace python_webrtc { + + std::shared_ptr RTCRtpScriptTransform::Create() { + // the bridge may hold the last reference on a libwebrtc thread + return {new RTCRtpScriptTransform(), DeleteOffLibwebrtcThread()}; + } + + RTCRtpScriptTransform::~RTCRtpScriptTransform() { + const gil_release_if_held release; + { + const std::scoped_lock lock(_mutex); + _queue.clear(); + _bridge = nullptr; + } + DropListeners(); + } + + void RTCRtpScriptTransform::Init(pybind11::module &m) { + Listeners::BindClass(m, "RTCRtpScriptTransform") + .def(pybind11::init(nogil_factory(&RTCRtpScriptTransform::Create))) + .def("read", &RTCRtpScriptTransform::Read) + .def("write", &RTCRtpScriptTransform::Write, pybind11::arg("frame"), pybind11::arg("data")) + .def("_ackWakeup", &RTCRtpScriptTransform::AckWakeup, nogil()) + .def_property_readonly("state", + nogil_fn([](RTCRtpScriptTransform &self) { return static_cast(self.GetState()); })) + .def_property_readonly("sourceId", nogil_fn(&RTCRtpScriptTransform::GetSourceId)) + .def_property_readonly("sourceKind", nogil_fn(&RTCRtpScriptTransform::GetSourceKind)) + .def( + "generateKeyFrame", + [](RTCRtpScriptTransform &self, const std::optional &rid) { + return static_cast(self.GenerateKeyFrame(rid)); + }, + nogil(), pybind11::arg("rid")) + .def("sendKeyFrameRequest", &RTCRtpScriptTransform::SendKeyFrameRequest, nogil()); + } + + void RTCRtpScriptTransform::Transform(std::unique_ptr frame) { + std::unique_ptr dropped; + const std::scoped_lock lock(_mutex); + if (_state != State::kAssociated) { + return; + } + if (_queue.size() >= kMaxQueuedFrames) { + dropped = std::move(_queue.front()); + _queue.pop_front(); + } + _queue.push_back(std::move(frame)); + WakeLocked(); + } + + void RTCRtpScriptTransform::Associate(webrtc::scoped_refptr bridge) { + const std::scoped_lock lock(_mutex); + _bridge = std::move(bridge); + _state = State::kAssociated; + } + + void RTCRtpScriptTransform::Disassociate() { + std::deque> dropped; + const std::scoped_lock lock(_mutex); + if (_state == State::kDisassociated) { + return; + } + _state = State::kDisassociated; + std::swap(dropped, _queue); + // the end wakes Python even while a wakeup is pending + _wakePending = false; + WakeLocked(); + } + + void RTCRtpScriptTransform::WakeLocked() { + if (!_wakePending) { + _wakePending = true; + Wakeup::Post(weak_from_this()); + } + } + + void RTCRtpScriptTransform::OnWakeup() { + // read before the event, whose delivery ends the streams then + const bool ended = GetState() == State::kDisassociated; + Emit("_ready"); + if (ended) { + // never associated again: drops handlers that may reference the sender or receiver + CloseListeners(); + } + } + + void RTCRtpScriptTransform::AckWakeup() { + const std::scoped_lock lock(_mutex); + _wakePending = false; + } + + std::shared_ptr RTCRtpScriptTransform::Read() { + std::unique_ptr frame; + uint64_t source = 0; + { + const gil_release release; + const std::scoped_lock lock(_mutex); + if (_queue.empty()) { + return nullptr; + } + frame = std::move(_queue.front()); + _queue.pop_front(); + // frames are queued once associated, and the bridge is kept since + source = _bridge->Id(); + } + return std::make_shared(std::move(frame), source); + } + + bool RTCRtpScriptTransform::Write(EncodedFrame &frame, std::optional data) { + std::optional> payload; + if (data) { + auto info = ContiguousBuffer(*data); + const auto size = static_cast(info.size * info.itemsize); + payload.emplace(size); + if (size > 0) { + std::memcpy(payload->data(), info.ptr, size); + } + } + const gil_release release; + webrtc::scoped_refptr bridge; + { + const std::scoped_lock lock(_mutex); + if (_state != State::kAssociated) { + return false; + } + bridge = _bridge; + } + // a frame of another sender or receiver (another kind, direction) would be cast to what it isn't by libwebrtc + if (frame.Source() != bridge->Id()) { + return false; + } + auto transformable = frame.Take(); + if (!transformable) { + return false; + } + if (payload) { + transformable->SetData(*payload); + } + bridge->Output(std::move(transformable)); + return true; + } + + webrtc::scoped_refptr RTCRtpScriptTransform::Bridge() { + const std::scoped_lock lock(_mutex); + return _bridge; + } + + RTCRtpScriptTransform::State RTCRtpScriptTransform::GetState() { + const std::scoped_lock lock(_mutex); + return _state; + } + + uint64_t RTCRtpScriptTransform::GetSourceId() { + auto bridge = Bridge(); + return bridge ? bridge->Id() : 0; + } + + std::optional> RTCRtpScriptTransform::GetSourceKind() { + auto bridge = Bridge(); + if (!bridge) { + return std::nullopt; + } + return std::make_pair(bridge->Source().sender, bridge->Source().video); + } + + RTCRtpScriptTransform::KeyFrameResult RTCRtpScriptTransform::GenerateKeyFrame(const std::optional &rid) { + auto bridge = Bridge(); + if (!bridge || !bridge->Source().sender || !bridge->Source().video || !bridge->Source().generateKeyFrame) { + return KeyFrameResult::kInvalidState; + } + std::vector rids; + if (rid) { + rids.push_back(*rid); + } + return bridge->Source().generateKeyFrame(rids) == FrameSource::KeyFrameResult::kUnknownRid + ? KeyFrameResult::kNotFound + : KeyFrameResult::kRequested; + } + + bool RTCRtpScriptTransform::SendKeyFrameRequest() { + auto bridge = Bridge(); + if (!bridge || bridge->Source().sender || !bridge->Source().video || !bridge->Source().sendKeyFrameRequest) { + return false; + } + bridge->Source().sendKeyFrameRequest(); + return true; + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/rtc_rtp_script_transform.h b/python-webrtc/cpp/src/media/rtc_rtp_script_transform.h new file mode 100644 index 0000000..04a77f5 --- /dev/null +++ b/python-webrtc/cpp/src/media/rtc_rtp_script_transform.h @@ -0,0 +1,95 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_RTC_RTP_SCRIPT_TRANSFORM_H_ +#define PYTHON_WEBRTC_MEDIA_RTC_RTP_SCRIPT_TRANSFORM_H_ + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "../utils/alive_count.h" +#include "../utils/listeners.h" +#include "encoded_frame.h" +#include "frame_transformer_bridge.h" +#include "wakeup.h" + +namespace python_webrtc { + + class RTCRtpScriptTransform : public RtpTransform, + public Listeners, + public Wakeable, + public std::enable_shared_from_this { + public: + static constexpr size_t kMaxQueuedFrames = 120; + + enum class State : uint8_t { kNew, kAssociated, kDisassociated }; + + enum class KeyFrameResult : uint8_t { kRequested, kInvalidState, kNotFound }; + + static std::shared_ptr Create(); + + ~RTCRtpScriptTransform() override; + + RTCRtpScriptTransform(const RTCRtpScriptTransform &) = delete; + RTCRtpScriptTransform &operator=(const RTCRtpScriptTransform &) = delete; + + static void Init(pybind11::module &m); + + void Transform(std::unique_ptr frame) override; + + void Associate(webrtc::scoped_refptr bridge) override; + + void Disassociate() override; + + void OnWakeup() override; + + std::shared_ptr Read(); + + bool Write(EncodedFrame &frame, std::optional data); + + void AckWakeup(); + + State GetState(); + + uint64_t GetSourceId(); + + // (sender, video) + std::optional> GetSourceKind(); + + KeyFrameResult GenerateKeyFrame(const std::optional &rid); + + bool SendKeyFrameRequest(); + + private: + RTCRtpScriptTransform() = default; + + webrtc::scoped_refptr Bridge(); + + void WakeLocked(); + + AliveCount _counted; + + std::mutex _mutex; + State _state = State::kNew; + // kept once disassociated: key frames are still requested from it + webrtc::scoped_refptr _bridge; + std::deque> _queue; + bool _wakePending = false; + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_MEDIA_RTC_RTP_SCRIPT_TRANSFORM_H_ diff --git a/python-webrtc/cpp/src/media/sframe.cpp b/python-webrtc/cpp/src/media/sframe.cpp new file mode 100644 index 0000000..e10025d --- /dev/null +++ b/python-webrtc/cpp/src/media/sframe.cpp @@ -0,0 +1,530 @@ +// +// 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. +// + +#include "sframe.h" + +#include +#include +#include +#include +#include + +#include + +#include "../utils/buffer.h" +#include "../utils/gil.h" +#include "boringssl.h" + +namespace python_webrtc { + + // Nh, Nka, Nk, Nt of RFC 9605 Section 4.5 (Nn is 12 for all), and the primitives + struct SFrameKey::Suite { + SFrameCipherSuite id; + const EVP_MD *(*hash)(); + size_t hashSize; + size_t encryptionKeySize; + size_t keySize; + size_t tagSize; + const EVP_CIPHER *(*ctr)(); + const EVP_AEAD *(*gcm)(); + }; + + struct SFrameKey::AeadContext { + EVP_AEAD_CTX *ctx; + + explicit AeadContext(EVP_AEAD_CTX *context) : ctx(context) {} + + ~AeadContext() { EVP_AEAD_CTX_free(ctx); } + + AeadContext(const AeadContext &) = delete; + AeadContext &operator=(const AeadContext &) = delete; + }; + + namespace { + + constexpr size_t kNonceSize = 12; + constexpr size_t kAesBlockSize = 16; + constexpr size_t kMaxHashSize = 64; + constexpr uint8_t kExtendedFlag = 0x08; + constexpr uint8_t kValueMask = 0x07; + constexpr int kKeyIdShift = 4; + constexpr int kBitsPerByte = 8; + + using Suite = SFrameKey::Suite; + using Cipher = SFrameCipherSuite; + + // NOLINTBEGIN(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers): the table of the RFC + const std::array kSuites{{ + {.id = Cipher::kAes128CtrHmacSha256_80, + .hash = EVP_sha256, + .hashSize = 32, + .encryptionKeySize = 16, + .keySize = 48, + .tagSize = 10, + .ctr = EVP_aes_128_ctr, + .gcm = nullptr}, + {.id = Cipher::kAes128CtrHmacSha256_64, + .hash = EVP_sha256, + .hashSize = 32, + .encryptionKeySize = 16, + .keySize = 48, + .tagSize = 8, + .ctr = EVP_aes_128_ctr, + .gcm = nullptr}, + {.id = Cipher::kAes128CtrHmacSha256_32, + .hash = EVP_sha256, + .hashSize = 32, + .encryptionKeySize = 16, + .keySize = 48, + .tagSize = 4, + .ctr = EVP_aes_128_ctr, + .gcm = nullptr}, + {.id = Cipher::kAes128GcmSha256_128, + .hash = EVP_sha256, + .hashSize = 32, + .encryptionKeySize = 0, + .keySize = 16, + .tagSize = 16, + .ctr = nullptr, + .gcm = EVP_aead_aes_128_gcm}, + {.id = Cipher::kAes256GcmSha512_128, + .hash = EVP_sha512, + .hashSize = 64, + .encryptionKeySize = 0, + .keySize = 32, + .tagSize = 16, + .ctr = nullptr, + .gcm = EVP_aead_aes_256_gcm}, + {.id = Cipher::kAes256CtrHmacSha512_80, + .hash = EVP_sha512, + .hashSize = 64, + .encryptionKeySize = 32, + .keySize = 96, + .tagSize = 10, + .ctr = EVP_aes_256_ctr, + .gcm = nullptr}, + {.id = Cipher::kAes256CtrHmacSha512_64, + .hash = EVP_sha512, + .hashSize = 64, + .encryptionKeySize = 32, + .keySize = 96, + .tagSize = 8, + .ctr = EVP_aes_256_ctr, + .gcm = nullptr}, + {.id = Cipher::kAes256CtrHmacSha512_32, + .hash = EVP_sha512, + .hashSize = 64, + .encryptionKeySize = 32, + .keySize = 96, + .tagSize = 4, + .ctr = EVP_aes_256_ctr, + .gcm = nullptr}, + }}; + // NOLINTEND(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers) + + const Suite *FindSuite(SFrameCipherSuite id) { + for (const auto &suite : kSuites) { + if (suite.id == id) { + return &suite; + } + } + return nullptr; + } + + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a value and its size + void AppendBigEndian(Octets &out, uint64_t value, size_t size) { + for (size_t i = size; i > 0; --i) { + out.push_back(static_cast(value >> (kBitsPerByte * (i - 1)))); + } + } + + size_t MinimalSize(uint64_t value) { + size_t size = 1; + while (size < sizeof(value) && (value >> (kBitsPerByte * size)) != 0) { + ++size; + } + return size; + } + + std::pair HeaderField(uint64_t value) { + if (value <= kValueMask) { + return {static_cast(value), 0}; + } + const size_t size = MinimalSize(value); + return {static_cast(kExtendedFlag | (size - 1)), size}; + } + + std::array Nonce(const Octets &salt, uint64_t counter) { + std::array nonce{}; + for (size_t i = 0; i < kNonceSize; ++i) { + const size_t shift = kNonceSize - 1 - i; + const auto byte = shift < sizeof(counter) ? static_cast(counter >> (kBitsPerByte * shift)) : 0; + nonce.at(i) = static_cast(salt.at(i) ^ byte); + } + return nonce; + } + + bool AesCtr(const Suite &suite, OctetSpan key, const std::array &nonce, OctetSpan input, + uint8_t *out) { + if (input.empty()) { + return true; + } + if (input.size() > static_cast(std::numeric_limits::max())) { + return false; + } + std::array block{}; + std::memcpy(block.data(), nonce.data(), nonce.size()); + const std::unique_ptr ctx(EVP_CIPHER_CTX_new(), + &EVP_CIPHER_CTX_free); + int written = 0; + return ctx && EVP_EncryptInit_ex(ctx.get(), suite.ctr(), nullptr, key.data(), block.data()) == 1 && + EVP_EncryptUpdate(ctx.get(), out, &written, input.data(), static_cast(input.size())) == 1 && + static_cast(written) == input.size(); + } + + // compute_tag of RFC 9605 Section 4.5.1, the first Nt bytes of the HMAC + std::optional> CtrTag(const Suite &suite, OctetSpan authKey, + const std::array &nonce, OctetSpan aad, + OctetSpan ciphertext) { + Octets data; + data.reserve((3 * sizeof(uint64_t)) + nonce.size() + aad.size() + ciphertext.size()); + AppendBigEndian(data, aad.size(), sizeof(uint64_t)); + AppendBigEndian(data, ciphertext.size(), sizeof(uint64_t)); + AppendBigEndian(data, suite.tagSize, sizeof(uint64_t)); + data.insert(data.end(), nonce.begin(), nonce.end()); + data.insert(data.end(), aad.begin(), aad.end()); + data.insert(data.end(), ciphertext.begin(), ciphertext.end()); + std::array tag{}; + unsigned int size = 0; + if (HMAC(suite.hash(), authKey.data(), authKey.size(), data.data(), data.size(), tag.data(), &size) == nullptr || + size != suite.hashSize) { + return std::nullopt; + } + return tag; + } + + Octets Label(std::string_view prefix, uint64_t keyId, SFrameCipherSuite suite) { + Octets label(prefix.begin(), prefix.end()); + AppendBigEndian(label, keyId, sizeof(keyId)); + AppendBigEndian(label, static_cast(suite), sizeof(uint16_t)); + return label; + } + + } // namespace + + SFrameCipherSuite CipherSuiteOf(int id) { + for (const auto &suite : kSuites) { + if (static_cast(suite.id) == id) { + return suite.id; + } + } + throw pybind11::value_error("Unknown SFrame cipher suite"); + } + + void AppendSFrameHeader(Octets &out, uint64_t keyId, uint64_t counter) { + const auto [keyIdBits, keyIdSize] = HeaderField(keyId); + const auto [counterBits, counterSize] = HeaderField(counter); + out.push_back(static_cast((keyIdBits << kKeyIdShift) | counterBits)); + AppendBigEndian(out, keyId, keyIdSize); + AppendBigEndian(out, counter, counterSize); + } + + std::optional ParseSFrameHeader(OctetSpan data) { + if (data.empty()) { + return std::nullopt; + } + SFrameHeader header; + header.size = 1; + auto field = [&](uint8_t bits, uint64_t &value) { + if ((bits & kExtendedFlag) == 0) { + value = bits & kValueMask; + return true; + } + const size_t size = (bits & kValueMask) + 1U; + if (data.size() - header.size < size) { + return false; + } + value = 0; + for (size_t i = 0; i < size; ++i) { + value = (value << kBitsPerByte) | data[header.size + i]; + } + header.size += size; + return true; + }; + const uint8_t config = data[0]; + if (!field(static_cast(config >> kKeyIdShift), header.keyId) || + !field(static_cast(config & (kExtendedFlag | kValueMask)), header.counter)) { + return std::nullopt; + } + return header; + } + + SFrameKey::SFrameKey(const Suite &suite, uint64_t keyId) : _suite(suite), _keyId(keyId) {} + + SFrameKey::~SFrameKey() { + OPENSSL_cleanse(_key.data(), _key.size()); + OPENSSL_cleanse(_salt.data(), _salt.size()); + } + + size_t SFrameKey::TagSize() const { + return _suite.tagSize; + } + + std::shared_ptr SFrameKey::Derive(SFrameCipherSuite id, uint64_t keyId, OctetSpan baseKey) { + const Suite *suite = FindSuite(id); + if (suite == nullptr) { + return nullptr; + } + // HKDF-Extract with an empty salt: a non-null pointer, as a null HMAC key means the previous one + static constexpr uint8_t kNoSalt = 0; + std::array secret{}; + size_t secretSize = 0; + const std::shared_ptr key(new SFrameKey(*suite, keyId)); + key->_key.resize(suite->keySize); + key->_salt.resize(kNonceSize); + const auto keyLabel = Label("SFrame 1.0 Secret key ", keyId, id); + const auto saltLabel = Label("SFrame 1.0 Secret salt ", keyId, id); + const bool derived = + HKDF_extract(secret.data(), &secretSize, suite->hash(), baseKey.data(), baseKey.size(), &kNoSalt, 0) == 1 && + HKDF_expand(key->_key.data(), key->_key.size(), suite->hash(), secret.data(), secretSize, keyLabel.data(), + keyLabel.size()) == 1 && + HKDF_expand(key->_salt.data(), key->_salt.size(), suite->hash(), secret.data(), secretSize, saltLabel.data(), + saltLabel.size()) == 1; + OPENSSL_cleanse(secret.data(), secret.size()); + if (!derived) { + return nullptr; + } + if (suite->gcm != nullptr) { + auto *ctx = EVP_AEAD_CTX_new(suite->gcm(), key->_key.data(), key->_key.size(), suite->tagSize); + if (ctx == nullptr) { + return nullptr; + } + key->_aead = std::make_unique(ctx); + } + return key; + } + + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): in the order of the RFC + std::optional SFrameKey::Encrypt(uint64_t counter, OctetSpan metadata, OctetSpan plaintext) const { + Octets out; + AppendSFrameHeader(out, _keyId, counter); + const size_t headerSize = out.size(); + Octets aad(out); + aad.insert(aad.end(), metadata.begin(), metadata.end()); + const auto nonce = Nonce(_salt, counter); + out.resize(headerSize + plaintext.size() + _suite.tagSize); + uint8_t *ciphertext = out.data() + headerSize; + if (_aead) { + size_t written = 0; + if (EVP_AEAD_CTX_seal(_aead->ctx, ciphertext, &written, out.size() - headerSize, nonce.data(), nonce.size(), + plaintext.data(), plaintext.size(), aad.data(), aad.size()) != 1 || + written != out.size() - headerSize) { + return std::nullopt; + } + return out; + } + const OctetSpan key(_key); + if (!AesCtr(_suite, key.first(_suite.encryptionKeySize), nonce, plaintext, ciphertext)) { + return std::nullopt; + } + const auto tag = CtrTag(_suite, key.subspan(_suite.encryptionKeySize), nonce, aad, {ciphertext, plaintext.size()}); + if (!tag) { + return std::nullopt; + } + std::memcpy(ciphertext + plaintext.size(), tag->data(), _suite.tagSize); + return out; + } + + std::optional SFrameKey::Decrypt(const SFrameHeader &header, OctetSpan metadata, + OctetSpan sframeCiphertext) const { + if (sframeCiphertext.size() < header.size + _suite.tagSize) { + return std::nullopt; + } + Octets aad(sframeCiphertext.begin(), sframeCiphertext.begin() + static_cast(header.size)); + aad.insert(aad.end(), metadata.begin(), metadata.end()); + const auto nonce = Nonce(_salt, header.counter); + const auto ciphertext = sframeCiphertext.subspan(header.size); + const size_t plaintextSize = ciphertext.size() - _suite.tagSize; + Octets plaintext(plaintextSize); + if (_aead) { + size_t written = 0; + if (EVP_AEAD_CTX_open(_aead->ctx, plaintext.data(), &written, plaintext.size(), nonce.data(), nonce.size(), + ciphertext.data(), ciphertext.size(), aad.data(), aad.size()) != 1 || + written != plaintextSize) { + return std::nullopt; + } + return plaintext; + } + const OctetSpan key(_key); + const auto inner = ciphertext.first(plaintextSize); + const auto tag = CtrTag(_suite, key.subspan(_suite.encryptionKeySize), nonce, aad, inner); + if (!tag || CRYPTO_memcmp(tag->data(), ciphertext.data() + plaintextSize, _suite.tagSize) != 0) { + return std::nullopt; + } + if (!AesCtr(_suite, key.first(_suite.encryptionKeySize), nonce, inner, plaintext.data())) { + return std::nullopt; + } + return plaintext; + } + + bool SFrameContext::SetEncryptionKey(OctetSpan key, uint64_t keyId) { + auto derived = SFrameKey::Derive(_suite, keyId, key); + if (!derived) { + return false; + } + const std::scoped_lock lock(_mutex); + _encryptionKey = std::move(derived); + return true; + } + + bool SFrameContext::AddDecryptionKey(OctetSpan key, uint64_t keyId) { + auto derived = SFrameKey::Derive(_suite, keyId, key); + if (!derived) { + return false; + } + const std::scoped_lock lock(_mutex); + _decryptionKeys[keyId] = std::move(derived); + return true; + } + + void SFrameContext::RemoveDecryptionKey(uint64_t keyId) { + std::shared_ptr removed; + const std::scoped_lock lock(_mutex); + auto it = _decryptionKeys.find(keyId); + if (it != _decryptionKeys.end()) { + removed = std::move(it->second); + _decryptionKeys.erase(it); + } + } + + std::optional SFrameContext::Encrypt(OctetSpan plaintext) { + std::shared_ptr key; + uint64_t counter = 0; + { + const std::scoped_lock lock(_mutex); + if (!_encryptionKey || _countersUsedUp) { + return std::nullopt; + } + key = _encryptionKey; + counter = _counter; + if (_counter == std::numeric_limits::max()) { + _countersUsedUp = true; + } else { + ++_counter; + } + } + return key->Encrypt(counter, {}, plaintext); + } + + SFrameContext::Decrypted SFrameContext::Decrypt(OctetSpan ciphertext) { + Decrypted result; + const auto header = ParseSFrameHeader(ciphertext); + if (!header) { + result.error = SFrameError::kSyntax; + return result; + } + std::shared_ptr key; + { + const std::scoped_lock lock(_mutex); + auto it = _decryptionKeys.find(header->keyId); + if (it != _decryptionKeys.end()) { + key = it->second; + } + } + if (!key) { + result.error = SFrameError::kKeyId; + result.keyId = header->keyId; + return result; + } + if (ciphertext.size() < header->size + key->TagSize()) { + result.error = SFrameError::kSyntax; + return result; + } + auto plaintext = key->Decrypt(*header, {}, ciphertext); + if (!plaintext) { + result.error = SFrameError::kAuthentication; + return result; + } + result.data = std::move(*plaintext); + return result; + } + + void SFrameContext::Init(pybind11::module &m) { + m.def( + "_sframeHeader", + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): in the order of the header + [](uint64_t keyId, uint64_t counter) { + Octets out; + AppendSFrameHeader(out, keyId, counter); + return Bytes(out.data(), out.size()); + }, + pybind11::arg("keyId"), pybind11::arg("counter")); + m.def( + "_sframeParseHeader", + [](const pybind11::buffer &data) -> std::optional> { + pybind11::buffer_info info; + auto header = ParseSFrameHeader(BufferSpan(data, info)); + if (!header) { + return std::nullopt; + } + return std::make_tuple(header->keyId, header->counter, header->size); + }, + pybind11::arg("data")); + m.def( + "_sframeDerive", + [](int cipherSuite, const pybind11::buffer &baseKey, uint64_t keyId) { + pybind11::buffer_info info; + auto key = SFrameKey::Derive(CipherSuiteOf(cipherSuite), keyId, BufferSpan(baseKey, info)); + if (!key) { + throw pybind11::value_error("The key can't be derived"); + } + return std::make_pair(Bytes(key->Key().data(), key->Key().size()), + Bytes(key->Salt().data(), key->Salt().size())); + }, + pybind11::arg("cipherSuite"), pybind11::arg("baseKey"), pybind11::arg("keyId")); + m.def( + "_sframeEncrypt", + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): in the order of the RFC + [](int cipherSuite, const pybind11::buffer &baseKey, uint64_t keyId, uint64_t counter, + const pybind11::buffer &metadata, const pybind11::buffer &plaintext) { + pybind11::buffer_info keyInfo; + pybind11::buffer_info metadataInfo; + pybind11::buffer_info plaintextInfo; + auto key = SFrameKey::Derive(CipherSuiteOf(cipherSuite), keyId, BufferSpan(baseKey, keyInfo)); + auto out = + key ? key->Encrypt(counter, BufferSpan(metadata, metadataInfo), BufferSpan(plaintext, plaintextInfo)) + : std::nullopt; + if (!out) { + throw pybind11::value_error("The plaintext can't be encrypted"); + } + return Bytes(out->data(), out->size()); + }, + pybind11::arg("cipherSuite"), pybind11::arg("baseKey"), pybind11::arg("keyId"), pybind11::arg("counter"), + pybind11::arg("metadata"), pybind11::arg("plaintext")); + m.def( + "_sframeDecrypt", + [](int cipherSuite, const pybind11::buffer &baseKey, const pybind11::buffer &metadata, + const pybind11::buffer &ciphertext) -> std::optional { + pybind11::buffer_info keyInfo; + pybind11::buffer_info metadataInfo; + pybind11::buffer_info ciphertextInfo; + const auto data = BufferSpan(ciphertext, ciphertextInfo); + const auto header = ParseSFrameHeader(data); + if (!header) { + return std::nullopt; + } + auto key = SFrameKey::Derive(CipherSuiteOf(cipherSuite), header->keyId, BufferSpan(baseKey, keyInfo)); + auto out = key ? key->Decrypt(*header, BufferSpan(metadata, metadataInfo), data) : std::nullopt; + if (!out) { + return std::nullopt; + } + return Bytes(out->data(), out->size()); + }, + pybind11::arg("cipherSuite"), pybind11::arg("baseKey"), pybind11::arg("metadata"), pybind11::arg("ciphertext")); + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/sframe.h b/python-webrtc/cpp/src/media/sframe.h new file mode 100644 index 0000000..5ba932e --- /dev/null +++ b/python-webrtc/cpp/src/media/sframe.h @@ -0,0 +1,123 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_SFRAME_H_ +#define PYTHON_WEBRTC_MEDIA_SFRAME_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace python_webrtc { + + using Octets = std::vector; + using OctetSpan = std::span; + + // identifiers of RFC 9605 Section 4.5 and draft-barnes-sframe-iana-256 + enum class SFrameCipherSuite : uint8_t { + kAes128CtrHmacSha256_80 = 1, + kAes128CtrHmacSha256_64 = 2, + kAes128CtrHmacSha256_32 = 3, + kAes128GcmSha256_128 = 4, + kAes256GcmSha512_128 = 5, + kAes256CtrHmacSha512_80 = 6, + kAes256CtrHmacSha512_64 = 7, + kAes256CtrHmacSha512_32 = 8, + }; + + enum class SFrameError : uint8_t { kNone, kAuthentication, kKeyId, kSyntax }; + + struct SFrameHeader { + uint64_t keyId = 0; + uint64_t counter = 0; + size_t size = 0; + }; + + SFrameCipherSuite CipherSuiteOf(int id); + + void AppendSFrameHeader(Octets &out, uint64_t keyId, uint64_t counter); + + std::optional ParseSFrameHeader(OctetSpan data); + + class SFrameKey { + public: + static std::shared_ptr Derive(SFrameCipherSuite id, uint64_t keyId, OctetSpan baseKey); + + ~SFrameKey(); + + SFrameKey(const SFrameKey &) = delete; + SFrameKey &operator=(const SFrameKey &) = delete; + + [[nodiscard]] const Octets &Key() const { return _key; } + + [[nodiscard]] const Octets &Salt() const { return _salt; } + + [[nodiscard]] std::optional Encrypt(uint64_t counter, OctetSpan metadata, OctetSpan plaintext) const; + + [[nodiscard]] std::optional Decrypt(const SFrameHeader &header, OctetSpan metadata, + OctetSpan sframeCiphertext) const; + + [[nodiscard]] size_t TagSize() const; + + struct Suite; + + private: + SFrameKey(const Suite &suite, uint64_t keyId); + + const Suite &_suite; + const uint64_t _keyId; + Octets _key; + Octets _salt; + struct AeadContext; + std::unique_ptr _aead; + }; + + // used from any thread, without the GIL + class SFrameContext { + public: + explicit SFrameContext(SFrameCipherSuite suite) : _suite(suite) {} + + [[nodiscard]] SFrameCipherSuite Suite() const { return _suite; } + + bool SetEncryptionKey(OctetSpan key, uint64_t keyId); + + bool AddDecryptionKey(OctetSpan key, uint64_t keyId); + + void RemoveDecryptionKey(uint64_t keyId); + + std::optional Encrypt(OctetSpan plaintext); + + struct Decrypted { + Octets data; + SFrameError error = SFrameError::kNone; + std::optional keyId; + }; + + Decrypted Decrypt(OctetSpan ciphertext); + + static void Init(pybind11::module &m); + + private: + const SFrameCipherSuite _suite; + + std::mutex _mutex; + std::shared_ptr _encryptionKey; + // never reused, whatever the key + uint64_t _counter = 0; + bool _countersUsedUp = false; + std::map> _decryptionKeys; + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_MEDIA_SFRAME_H_ diff --git a/python-webrtc/cpp/src/media/sframe_transform.cpp b/python-webrtc/cpp/src/media/sframe_transform.cpp new file mode 100644 index 0000000..c1cd043 --- /dev/null +++ b/python-webrtc/cpp/src/media/sframe_transform.cpp @@ -0,0 +1,176 @@ +// +// 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. +// + +#include "sframe_transform.h" + +#include +#include +#include + +#include + +#include "../utils/buffer.h" +#include "../utils/gil.h" +#include "../utils/libwebrtc_thread.h" +#include "encoded_frame.h" + +namespace python_webrtc { + + SFrameTransform::SFrameTransform(SFrameCipherSuite suite, bool encrypting) + : _encrypting(encrypting), _context(suite) {} + + std::shared_ptr SFrameTransform::Create(int cipherSuite, bool encrypting) { + // the bridge may hold the last reference on a libwebrtc thread + return {new SFrameTransform(CipherSuiteOf(cipherSuite), encrypting), DeleteOffLibwebrtcThread()}; + } + + SFrameTransform::~SFrameTransform() { + const gil_release_if_held release; + { + const std::scoped_lock lock(_mutex); + _errors.clear(); + _bridge = nullptr; + } + DropListeners(); + } + + void SFrameTransform::Init(pybind11::module &m) { + SFrameContext::Init(m); + Listeners::BindClass(m, "SFrameTransform") + .def(pybind11::init(&SFrameTransform::Create), pybind11::arg("cipherSuite"), pybind11::arg("encrypting")) + .def_property_readonly("encrypting", &SFrameTransform::IsEncrypting) + .def( + "setEncryptionKey", + [](SFrameTransform &self, const pybind11::buffer &key, uint64_t keyId) { + pybind11::buffer_info info; + const auto span = BufferSpan(key, info); + const gil_release release; + return self.Context().SetEncryptionKey(span, keyId); + }, + pybind11::arg("key"), pybind11::arg("keyId")) + .def( + "addDecryptionKey", + [](SFrameTransform &self, const pybind11::buffer &key, uint64_t keyId) { + pybind11::buffer_info info; + const auto span = BufferSpan(key, info); + const gil_release release; + return self.Context().AddDecryptionKey(span, keyId); + }, + pybind11::arg("key"), pybind11::arg("keyId")) + .def( + "removeDecryptionKey", + [](SFrameTransform &self, uint64_t keyId) { self.Context().RemoveDecryptionKey(keyId); }, nogil(), + pybind11::arg("keyId")) + .def("encrypt", &SFrameTransform::Encrypt, pybind11::arg("data")) + .def("decrypt", &SFrameTransform::Decrypt, pybind11::arg("data")); + } + + pybind11::object SFrameTransform::Encrypt(const pybind11::buffer &data) { + pybind11::buffer_info info; + const auto span = BufferSpan(data, info); + std::optional out; + { + const gil_release release; + out = _context.Encrypt(span); + } + if (!out) { + return pybind11::none(); + } + return Bytes(out->data(), out->size()); + } + + std::tuple> SFrameTransform::Decrypt(const pybind11::buffer &data) { + pybind11::buffer_info info; + const auto span = BufferSpan(data, info); + SFrameContext::Decrypted result; + { + const gil_release release; + result = _context.Decrypt(span); + } + pybind11::object plaintext = pybind11::none(); + if (result.error == SFrameError::kNone) { + plaintext = Bytes(result.data.data(), result.data.size()); + } + return {plaintext, static_cast(result.error), result.keyId}; + } + + void SFrameTransform::Transform(std::unique_ptr frame) { + webrtc::scoped_refptr bridge; + { + const std::scoped_lock lock(_mutex); + bridge = _bridge; + } + // a frame that doesn't encrypt (no key) is dropped, never sent in clear + if (!bridge) { + return; + } + if (_encrypting) { + auto out = _context.Encrypt(frame->GetData()); + if (!out) { + return; + } + frame->SetData(*out); + bridge->Output(std::move(frame)); + return; + } + auto result = _context.Decrypt(frame->GetData()); + if (result.error == SFrameError::kNone) { + frame->SetData(result.data); + bridge->Output(std::move(frame)); + return; + } + Error dropped; + const std::scoped_lock lock(_mutex); + if (_errors.size() >= kMaxQueuedErrors) { + dropped = std::move(_errors.front()); + _errors.pop_front(); + } + _errors.push_back( + {.error = result.error, .keyId = result.keyId, .frame = std::move(frame), .source = bridge->Id()}); + WakeLocked(); + } + + void SFrameTransform::Associate(webrtc::scoped_refptr bridge) { + const std::scoped_lock lock(_mutex); + _bridge = std::move(bridge); + } + + void SFrameTransform::Disassociate() { + webrtc::scoped_refptr bridge; + const std::scoped_lock lock(_mutex); + bridge = std::move(_bridge); + _disassociated = true; + WakeLocked(); + } + + void SFrameTransform::WakeLocked() { + if (!_wakePending) { + _wakePending = true; + Wakeup::Post(weak_from_this()); + } + } + + void SFrameTransform::OnWakeup() { + std::deque errors; + bool ended = false; + { + const std::scoped_lock lock(_mutex); + std::swap(errors, _errors); + _wakePending = false; + ended = _disassociated; + } + for (auto &error : errors) { + Emit("error", static_cast(error.error), error.keyId, + std::make_shared(std::move(error.frame), error.source)); + } + if (ended) { + // never associated again: drops handlers that may reference the sender or receiver + CloseListeners(); + } + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/sframe_transform.h b/python-webrtc/cpp/src/media/sframe_transform.h new file mode 100644 index 0000000..9aa6282 --- /dev/null +++ b/python-webrtc/cpp/src/media/sframe_transform.h @@ -0,0 +1,89 @@ +// +// 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. +// + +#ifndef PYTHON_WEBRTC_MEDIA_SFRAME_TRANSFORM_H_ +#define PYTHON_WEBRTC_MEDIA_SFRAME_TRANSFORM_H_ + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "../utils/alive_count.h" +#include "../utils/listeners.h" +#include "frame_transformer_bridge.h" +#include "sframe.h" +#include "wakeup.h" + +namespace python_webrtc { + + class SFrameTransform : public RtpTransform, + public Listeners, + public Wakeable, + public std::enable_shared_from_this { + public: + static constexpr size_t kMaxQueuedErrors = 120; + + static std::shared_ptr Create(int cipherSuite, bool encrypting); + + ~SFrameTransform() override; + + SFrameTransform(const SFrameTransform &) = delete; + SFrameTransform &operator=(const SFrameTransform &) = delete; + + static void Init(pybind11::module &m); + + void Transform(std::unique_ptr frame) override; + + void Associate(webrtc::scoped_refptr bridge) override; + + void Disassociate() override; + + void OnWakeup() override; + + [[nodiscard]] bool IsEncrypting() const { return _encrypting; } + + SFrameContext &Context() { return _context; } + + pybind11::object Encrypt(const pybind11::buffer &data); + + // (plaintext or None, SFrameError, key id or None) + std::tuple> Decrypt(const pybind11::buffer &data); + + private: + SFrameTransform(SFrameCipherSuite suite, bool encrypting); + + void WakeLocked(); + + struct Error { + SFrameError error = SFrameError::kNone; + std::optional keyId; + std::unique_ptr frame; + uint64_t source = 0; + }; + + AliveCount _counted; + const bool _encrypting; + SFrameContext _context; + + std::mutex _mutex; + webrtc::scoped_refptr _bridge; + std::deque _errors; + bool _wakePending = false; + bool _disassociated = false; + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_MEDIA_SFRAME_TRANSFORM_H_ diff --git a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp index d7adda9..48f5cd9 100644 --- a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp +++ b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp @@ -23,7 +23,7 @@ namespace python_webrtc { void RTCSessionDescription::Init(pybind11::module &m) { pybind11::class_>(m, "RTCSessionDescription") - .def(pybind11::init()) + .def(pybind11::init(), pybind11::arg("descriptionInitDict")) .def_property_readonly("type", &RTCSessionDescription::getType) .def_property_readonly("sdp", &RTCSessionDescription::getSdp) .def_property_readonly("init", &RTCSessionDescription::init); diff --git a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp index c0e8f2b..90bbd25 100644 --- a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp +++ b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp @@ -16,7 +16,7 @@ namespace python_webrtc { void RTCSessionDescriptionInit::Init(pybind11::module &m) { pybind11::class_(m, "RTCSessionDescriptionInit") - .def(pybind11::init()) + .def(pybind11::init(), pybind11::arg("type"), pybind11::arg("sdp")) .def_readwrite("type", &RTCSessionDescriptionInit::type) .def_readwrite("sdp", &RTCSessionDescriptionInit::sdp); } diff --git a/python-webrtc/cpp/src/utils/buffer.h b/python-webrtc/cpp/src/utils/buffer.h index bd1df1f..d2f8f0b 100644 --- a/python-webrtc/cpp/src/utils/buffer.h +++ b/python-webrtc/cpp/src/utils/buffer.h @@ -10,6 +10,7 @@ #include #include +#include #include @@ -24,6 +25,12 @@ namespace python_webrtc { return info; } + // the octets of a contiguous buffer, valid while info is + inline std::span BufferSpan(const pybind11::buffer &buffer, pybind11::buffer_info &info) { + info = ContiguousBuffer(buffer); + return {static_cast(info.ptr), static_cast(info.size * info.itemsize)}; + } + // bytes of a buffer of octets inline pybind11::bytes Bytes(const uint8_t *data, size_t size) { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): Python bytes are chars, octets alias them diff --git a/python-webrtc/cpp/src/utils/listeners.h b/python-webrtc/cpp/src/utils/listeners.h index 37bf382..faee70b 100644 --- a/python-webrtc/cpp/src/utils/listeners.h +++ b/python-webrtc/cpp/src/utils/listeners.h @@ -32,9 +32,9 @@ namespace python_webrtc { Listeners &operator=(const Listeners &) = delete; // the class of a wrapper, with its listeners property - template - static pybind11::class_> BindClass(pybind11::module &m, const char *name) { - pybind11::class_> cls(m, name, TypeSetup()); + template + static pybind11::class_> BindClass(pybind11::module &m, const char *name) { + pybind11::class_> cls(m, name, TypeSetup()); cls.def_property("_listeners", &T::GetListeners, &T::SetListeners); return cls; } @@ -42,6 +42,10 @@ namespace python_webrtc { pybind11::object GetListeners() { return _listeners ? _listeners : pybind11::none(); } void SetListeners(pybind11::object listeners) { + // accepted and dropped once closed, like the handlers of a target that never dispatches again + if (_closed) { + return; + } _listeners = listeners.is_none() ? pybind11::object() : std::move(listeners); _active = static_cast(_listeners); if (_active) { @@ -84,6 +88,11 @@ namespace python_webrtc { ReleasePythonObject(_listeners); } + void CloseListeners() noexcept { + _closed = true; + DropListeners(); + } + private: // Lets the garbage collector see the handlers while Python is the only owner, so handlers referencing // the wrapper don't keep it alive forever @@ -158,6 +167,7 @@ namespace python_webrtc { } std::atomic _active{false}; + std::atomic _closed{false}; pybind11::object _listeners; HeldEvents _held; }; diff --git a/python-webrtc/python/test.py b/python-webrtc/python/test.py deleted file mode 100644 index 4d96741..0000000 --- a/python-webrtc/python/test.py +++ /dev/null @@ -1,89 +0,0 @@ -import asyncio - -import webrtc -import wrtc - -VALID_SDP = '''v=0 -o=- 6373938523134045336 2 IN IP4 127.0.0.1 -s=- -t=0 0 -a=extmap-allow-mixed -a=msid-semantic: WMS -''' - - -def get_dir(o): - return [m for m in dir(o) if not m.startswith('__')] - - -def idle(): - while True: - pass - - -async def main(): - wrtc.ping() - - enums = [ - webrtc.RTCPeerConnectionState, - webrtc.RTCIceConnectionState, - webrtc.RTCIceGatheringState, - webrtc.RTCSdpType, - webrtc.MediaStreamTrackState, - webrtc.MediaStreamSourceState, - webrtc.TransceiverDirection, - webrtc.RTCIceComponent, - webrtc.RTCIceRole, - webrtc.RTCIceTransportState, - webrtc.CricketIceGatheringState, - webrtc.DtlsTransportState, - webrtc.SctpTransportState, - webrtc.MediaType, - ] - for enum in enums: - print(f'{enum!r} = {enum.__members__}') - - pc = webrtc.RTCPeerConnection() - - stream = webrtc.getUserMedia() - # print(repr(stream), get_dir(stream)) - # for track in stream.getTracks(): - # print(repr(track), get_dir(track)) - # sender = pc.add_track(track, stream) - - # local_sdp = await pc.create_offer() - # await pc.set_local_description(local_sdp) - # - # transport = sender.transport - # transceivers = pc.get_transceivers() - - # params = webrtc.RtpEncodingParameters( - # max_bitrate=1234, max_framerate=20, rid="lolkek", scale_resolution_down_by=2.0 - # ) - # init = webrtc.RtpTransceiverInit( - # direction=webrtc.TransceiverDirection.recvonly, send_encodings=[params], streams=[stream] - # ) - # - # transceiver = pc.add_transceiver(webrtc.MediaType.audio) - # transceiver_with_params = pc.add_transceiver(webrtc.MediaType.audio, init) - # - # transceiver_by_track = pc.add_transceiver(stream.get_tracks()[0]) - # transceiver_by_track_with_params = pc.add_transceiver(stream.get_tracks()[0], init) - # - # local_sdp = await pc.create_offer() - # await pc.set_local_description(local_sdp) - - transceivers = pc.get_transceivers() - - pc.restart_ice() - - sender = pc.add_track(stream.get_tracks()[0], stream) - pc.remove_track(sender) - - print(pc.sctp) - - idle() - - -if __name__ == '__main__': - asyncio.run(main()) diff --git a/python-webrtc/python/tgcalls_test.py b/python-webrtc/python/tgcalls_test.py deleted file mode 100644 index 13da69b..0000000 --- a/python-webrtc/python/tgcalls_test.py +++ /dev/null @@ -1,189 +0,0 @@ -# tgcalls - a Python binding for C++ library by Telegram -# pytgcalls - a library connecting the Python binding with MTProto -# Copyright (C) 2020-2021 Il`ya (Marshal) -# -# This file is part of tgcalls and pytgcalls. -# -# tgcalls and pytgcalls is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# tgcalls and pytgcalls is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License v3 -# along with tgcalls. If not, see . - -import asyncio -import json -import os -import time - -# pip install pytgcalls[pyrogram]==3.0.0.dev21 -import pyrogram -import webrtc -from pytgcalls.mtproto.pyrogram_bridge import PyrogramBridge - -remote_sdp = None - - -def parse_sdp(sdp): - lines = sdp.split('\r\n') - - def lookup(prefix): - for line in lines: - if line.startswith(prefix): - return line[len(prefix) :] - - info = { - 'fingerprint': lookup('a=fingerprint:').split(' ')[1], - 'hash': lookup('a=fingerprint:').split(' ')[0], - 'setup': lookup('a=setup:'), - 'pwd': lookup('a=ice-pwd:'), - 'ufrag': lookup('a=ice-ufrag:'), - } - ssrc = lookup('a=ssrc:') - if ssrc: - info['source'] = int(ssrc.split(' ')[0]) - - return info - - -def get_params_from_parsed_sdp(info): - return { - 'fingerprints': [{'fingerprint': info['fingerprint'], 'hash': info['hash'], 'setup': 'active'}], - 'pwd': info['pwd'], - 'ssrc': info['source'], - 'ssrc-groups': [], - 'ufrag': info['ufrag'], - } - - -def build_answer(sdp): - def add_candidates(): - candidates_sdp = [] - for cand in sdp['transport']['candidates']: - candidates_sdp.append( - f"a=candidate:{cand['foundation']} {cand['component']} {cand['protocol']} " - f"{cand['priority']} {cand['ip']} {cand['port']} typ {cand['type']} " - f"generation {cand['generation']}" - ) - - return '\n'.join(candidates_sdp) - - return f"""v=0 -o=- {time.time()} 2 IN IP4 0.0.0.0 -s=- -t=0 0 -a=group:BUNDLE 0 -a=ice-lite -m=audio 1 RTP/SAVPF 111 126 -c=IN IP4 0.0.0.0 -a=mid:0 -a=ice-ufrag:{sdp['transport']['ufrag']} -a=ice-pwd:{sdp['transport']['pwd']} -a=fingerprint:sha-256 {sdp['transport']['fingerprints'][0]['fingerprint']} -a=setup:passive -{add_candidates()} -a=rtpmap:111 opus/48000/2 -a=rtpmap:126 telephone-event/8000 -a=fmtp:111 minptime=10; useinbandfec=1; usedtx=1 -a=rtcp:1 IN IP4 0.0.0.0 -a=rtcp-mux -a=rtcp-fb:111 transport-cc -a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level -a=recvonly -""" - # a=sendrecv - - -async def group_call_participants_update_callback(_): - pass - - -async def group_call_update_callback(update): - global remote_sdp - - data = update.call.params.data - remote_sdp = build_answer(json.loads(data)) - - -async def send_audio_data(generator): - """Writes test.raw (48 kHz stereo 16-bit) to the track, 10 ms at a time, at the pace of real time""" - writer = generator.writable.get_writer() - loop = asyncio.get_running_loop() - start = loop.time() - chunks = 0 - - with open('test.raw', 'rb') as f: - while data := f.read(480 * 4): # 480 frames of 2 channels of 16 bits - 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], - ) - ) - chunks += 1 - await asyncio.sleep(max(0.0, start + chunks / 100 - loop.time())) - - -async def main(client, input_peer): - pc = webrtc.RTCPeerConnection() - - # stream = webrtc.get_user_media() - # for track in stream.get_tracks(): - # track.enabled = True - # pc.add_track(track, stream) - - generator = webrtc.MediaStreamTrackGenerator('audio') - pc.add_track(generator) - - local_sdp = await pc.create_offer() - await pc.set_local_description(local_sdp) - - app = PyrogramBridge(client) - app.register_group_call_native_callback(group_call_participants_update_callback, group_call_update_callback) - await app.get_and_set_group_call(input_peer) - await app.resolve_and_set_join_as(None) - - def pre_update_processing(): - pass - - parsed_sdp = parse_sdp(local_sdp.sdp) - payload = get_params_from_parsed_sdp(parsed_sdp) - - await app.join_group_call(None, json.dumps(payload), False, False, pre_update_processing) - - while not remote_sdp: - await asyncio.sleep(0.1) - # await asyncio.wait_for(REMOTE_ANSWER_EVENT.wait(), 30) - - # TODO allow to pass RTCSessionDescriptionInit - await pc.set_remote_description( - webrtc.RTCSessionDescription(webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, remote_sdp)) - ) - - sending = asyncio.ensure_future(send_audio_data(generator)) - - # await asyncio.sleep(10) - await pyrogram.idle() - sending.cancel() - - -if __name__ == '__main__': - pyro_client = pyrogram.Client( - os.environ.get('SESSION_NAME'), api_hash=os.environ.get('API_HASH'), api_id=os.environ.get('API_ID') - ) - pyro_client.start() - - peer = os.environ.get('PEER') - - asyncio.get_event_loop().run_until_complete(main(pyro_client, peer)) diff --git a/python-webrtc/python/webrtc/__init__.py b/python-webrtc/python/webrtc/__init__.py index 9309667..3089eb3 100644 --- a/python-webrtc/python/webrtc/__init__.py +++ b/python-webrtc/python/webrtc/__init__.py @@ -19,13 +19,13 @@ RTCSdpType, MediaStreamTrackState, MediaStreamSourceState, - TransceiverDirection, + RTCRtpTransceiverDirection, RTCIceComponent, RTCIceRole, RTCIceTransportState, - CricketIceGatheringState, - DtlsTransportState, - SctpTransportState, + RTCIceGathererState, + RTCDtlsTransportState, + RTCSctpTransportState, MediaType, RTCDataChannelState, RTCPriorityType, @@ -38,16 +38,23 @@ RTCIceProtocol, RTCIceTcpCandidateType, RTCIceServerTransportProtocol, + RTCStatsType, + RTCQualityLimitationReason, + RTCDtlsRole, + RTCStatsIceCandidatePairState, RTCErrorDetailType, BinaryType, + EndingType, VideoPixelFormat, VideoColorPrimaries, VideoTransferCharacteristics, VideoMatrixCoefficients, AlphaOption, + PredefinedColorSpace, AudioSampleFormat, + ReadableStreamReaderMode, RTCSdpTypeValue, - TransceiverDirectionValue, + RTCRtpTransceiverDirectionValue, MediaTypeValue, RTCPriorityTypeValue, RTCDegradationPreferenceValue, @@ -58,12 +65,28 @@ RTCIceServerTransportProtocolValue, RTCErrorDetailTypeValue, BinaryTypeValue, + EndingTypeValue, VideoPixelFormatValue, VideoColorPrimariesValue, VideoTransferCharacteristicsValue, VideoMatrixCoefficientsValue, AlphaOptionValue, + PredefinedColorSpaceValue, AudioSampleFormatValue, + ReadableStreamReaderModeValue, + MediaDeviceKind, + VideoFacingModeEnum, + VideoResizeModeEnum, + EchoCancellationModeEnum, + EncodedVideoChunkType, + RTCRtpScriptTransformType, + RTCRtpScriptTransformTypeValue, + SFrameCipherSuite, + SFrameCipherSuiteValue, + SFrameType, + SFrameTypeValue, + SFrameTransformErrorEventType, + SFrameTransformErrorEventTypeValue, ) from .base import WebRTCObject from .exceptions import ( @@ -77,6 +100,9 @@ OperationError, NotSupportedError, NetworkError, + DataCloneError, + NotFoundError, + NotAllowedError, InvalidSyntaxError, InvalidRangeError, InvalidCharacterError, @@ -95,10 +121,24 @@ RTCDataChannelEvent, MediaStreamTrackEvent, RTCDTMFToneChangeEvent, + RTCPeerConnectionIceEventInit, + RTCPeerConnectionIceErrorEventInit, + RTCTrackEventInit, + RTCErrorEventInit, + RTCDataChannelEventInit, + MediaStreamTrackEventInit, + RTCDTMFToneChangeEventInit, + DeviceChangeEvent, + DeviceChangeEventInit, + RTCTransformEvent, + KeyFrameRequestEvent, + SFrameTransformErrorEvent, + SFrameTransformErrorEventInit, ) # the order matters: modules import each other through the package namespace from .models.rtc_session_description_init import RTCSessionDescriptionInit, RTCLocalSessionDescriptionInit +from .models.rtc_offer_answer_options import RTCOfferAnswerOptions, RTCOfferOptions, RTCAnswerOptions from .models.rtc_session_description import RTCSessionDescription from .models.media_track_constraints import ( ULongRange, @@ -109,11 +149,13 @@ ConstrainDOMStringParameters, ConstrainBooleanOrDOMStringParameters, MediaTrackSettings, + MediaTrackSupportedConstraints, + MediaStreamConstraints, MediaTrackCapabilities, MediaTrackConstraintSet, MediaTrackConstraints, ) -from .models.blob import Blob +from .models.blob import BlobPropertyBag, Blob from .models.video_frame import ( DOMRectReadOnly, DOMRectInit, @@ -127,11 +169,26 @@ VideoFrame, ) from .models.audio_data import AudioDataInit, AudioDataCopyToOptions, AudioData +from .models.rtc_encoded_frame import ( + RTCEncodedFrameMetadata, + RTCEncodedVideoFrameMetadata, + RTCEncodedAudioFrameMetadata, + RTCEncodedVideoFrameOptions, + RTCEncodedAudioFrameOptions, + RTCEncodedVideoFrame, + RTCEncodedAudioFrame, +) +from .models.sframe_transform_options import SFrameTransformOptions, RTCRtpSFrameEncryptorOptions from .streams import ( ReadableStream, ReadableStreamDefaultReader, ReadableStreamDefaultController, ReadableStreamReadResult, + ReadableStreamGetReaderOptions, + ReadableStreamIteratorOptions, + ReadableWritablePair, + StreamPipeOptions, + QueuingStrategy, WritableStream, WritableStreamDefaultWriter, WritableStreamDefaultController, @@ -139,6 +196,10 @@ TransformStreamDefaultController, ) from .models.rtp_parameters import ( + RTCRtpParameters, + RTCRtpCodingParameters, + RTCEncodingOptions, + RTCSetParameterOptions, RTCRtpCodec, RTCRtpCodecParameters, RTCRtpHeaderExtensionParameters, @@ -150,23 +211,47 @@ RTCRtpCapabilities, ) from .models.rtc_rtp_transceiver_init import RTCRtpTransceiverInit -from .models.rtc_stats import RTCStats, RTCStatsReport +from .models.rtc_stats import ( + RTCStats, + RTCRtpStreamStats, + RTCCodecStats, + RTCReceivedRtpStreamStats, + RTCInboundRtpStreamStats, + RTCRemoteInboundRtpStreamStats, + RTCSentRtpStreamStats, + RTCOutboundRtpStreamStats, + RTCRemoteOutboundRtpStreamStats, + RTCMediaSourceStats, + RTCAudioSourceStats, + RTCVideoSourceStats, + RTCAudioPlayoutStats, + RTCPeerConnectionStats, + RTCDataChannelStats, + RTCTransportStats, + RTCIceCandidateStats, + RTCIceCandidatePairStats, + RTCCertificateStats, + RTCStatsReport, +) from .models.rtp_source import RTCRtpContributingSource, RTCRtpSynchronizationSource from .models.rtc_certificate import ( Algorithm, EcKeyGenParams, + RTCCertificateExpiration, RsaHashedKeyGenParams, RTCCertificate, RTCDtlsFingerprint, ) from .models.rtc_configuration import ( RTCConfiguration, + RTCIceGatherOptions, RTCIceServer, RTCOAuthCredential, ) from .models.rtc_ice_candidate import ( RTCIceCandidate, RTCIceCandidateInit, + RTCLocalIceCandidateInit, RTCIceCandidatePair, RTCIceParameters, ) @@ -188,8 +273,15 @@ MediaStreamTrackGenerator, ) from .interfaces.rtc_dtmf_sender import RTCDTMFSender +from .interfaces.rtc_rtp_script_transform import WorkerAndParameters, RTCRtpScriptTransformer, RTCRtpScriptTransform +from .interfaces.sframe_transform import ( + RTCRtpSFrameEncryptor, + RTCRtpSFrameDecryptor, + SFrameEncryptorStream, + SFrameDecryptorStream, +) -from .functions.get_user_media import getUserMedia, get_user_media +from .interfaces.media_devices import MediaDeviceInfo, InputDeviceInfo, MediaDevices, media_devices, mediaDevices __all__ = [ @@ -204,29 +296,42 @@ 'BinaryType', 'BinaryTypeValue', 'Blob', + 'BlobPropertyBag', 'ConstrainBooleanOrDOMStringParameters', 'ConstrainBooleanParameters', 'ConstrainDOMStringParameters', 'ConstrainDoubleRange', 'ConstrainULongRange', - 'CricketIceGatheringState', 'DOMRectInit', 'DOMRectReadOnly', + 'DataCloneError', + 'DeviceChangeEvent', + 'DeviceChangeEventInit', 'DoubleRange', - 'DtlsTransportState', 'EcKeyGenParams', + 'EchoCancellationModeEnum', + 'EncodedVideoChunkType', + 'EndingType', + 'EndingTypeValue', 'Event', 'EventTarget', + 'InputDeviceInfo', 'InvalidAccessError', 'InvalidCharacterError', 'InvalidModificationError', 'InvalidRangeError', 'InvalidStateError', 'InvalidSyntaxError', + 'KeyFrameRequestEvent', + 'MediaDeviceInfo', + 'MediaDeviceKind', + 'MediaDevices', 'MediaStream', + 'MediaStreamConstraints', 'MediaStreamSourceState', 'MediaStreamTrack', 'MediaStreamTrackEvent', + 'MediaStreamTrackEventInit', 'MediaStreamTrackGenerator', 'MediaStreamTrackGeneratorInit', 'MediaStreamTrackProcessor', @@ -236,42 +341,72 @@ 'MediaTrackConstraintSet', 'MediaTrackConstraints', 'MediaTrackSettings', + 'MediaTrackSupportedConstraints', 'MediaType', 'MediaTypeValue', 'MessageEvent', 'NetworkError', + 'NotAllowedError', + 'NotFoundError', 'NotSupportedError', 'OperationError', 'OverconstrainedError', 'PlaneLayout', + 'PredefinedColorSpace', + 'PredefinedColorSpaceValue', 'PythonWebRTCException', 'PythonWebRTCExceptionBase', + 'QueuingStrategy', + 'RTCAnswerOptions', + 'RTCAudioPlayoutStats', + 'RTCAudioSourceStats', 'RTCBundlePolicy', 'RTCBundlePolicyValue', 'RTCCertificate', + 'RTCCertificateExpiration', + 'RTCCertificateStats', + 'RTCCodecStats', 'RTCConfiguration', 'RTCDTMFSender', 'RTCDTMFToneChangeEvent', + 'RTCDTMFToneChangeEventInit', 'RTCDataChannel', 'RTCDataChannelEvent', + 'RTCDataChannelEventInit', 'RTCDataChannelInit', 'RTCDataChannelState', + 'RTCDataChannelStats', 'RTCDegradationPreference', 'RTCDegradationPreferenceValue', 'RTCDtlsFingerprint', + 'RTCDtlsRole', 'RTCDtlsTransport', + 'RTCDtlsTransportState', + 'RTCEncodedAudioFrame', + 'RTCEncodedAudioFrameMetadata', + 'RTCEncodedAudioFrameOptions', + 'RTCEncodedFrameMetadata', + 'RTCEncodedVideoFrame', + 'RTCEncodedVideoFrameMetadata', + 'RTCEncodedVideoFrameOptions', + 'RTCEncodingOptions', 'RTCError', 'RTCErrorDetailType', 'RTCErrorDetailTypeValue', 'RTCErrorEvent', + 'RTCErrorEventInit', 'RTCErrorInit', 'RTCException', 'RTCIceCandidate', 'RTCIceCandidateInit', 'RTCIceCandidatePair', + 'RTCIceCandidatePairStats', + 'RTCIceCandidateStats', 'RTCIceCandidateType', 'RTCIceComponent', 'RTCIceConnectionState', + 'RTCIceGatherOptions', + 'RTCIceGathererState', 'RTCIceGatheringState', 'RTCIceParameters', 'RTCIceProtocol', @@ -284,51 +419,99 @@ 'RTCIceTransportPolicy', 'RTCIceTransportPolicyValue', 'RTCIceTransportState', + 'RTCInboundRtpStreamStats', + 'RTCLocalIceCandidateInit', 'RTCLocalSessionDescriptionInit', + 'RTCMediaSourceStats', 'RTCOAuthCredential', + 'RTCOfferAnswerOptions', + 'RTCOfferOptions', + 'RTCOutboundRtpStreamStats', 'RTCPeerConnection', 'RTCPeerConnectionIceErrorEvent', + 'RTCPeerConnectionIceErrorEventInit', 'RTCPeerConnectionIceEvent', + 'RTCPeerConnectionIceEventInit', 'RTCPeerConnectionState', + 'RTCPeerConnectionStats', 'RTCPriorityType', 'RTCPriorityTypeValue', + 'RTCQualityLimitationReason', + 'RTCReceivedRtpStreamStats', + 'RTCRemoteInboundRtpStreamStats', + 'RTCRemoteOutboundRtpStreamStats', 'RTCRtcpMuxPolicy', 'RTCRtcpMuxPolicyValue', 'RTCRtcpParameters', 'RTCRtpCapabilities', 'RTCRtpCodec', 'RTCRtpCodecParameters', + 'RTCRtpCodingParameters', 'RTCRtpContributingSource', 'RTCRtpEncodingParameters', 'RTCRtpHeaderEncryptionPolicy', 'RTCRtpHeaderEncryptionPolicyValue', 'RTCRtpHeaderExtensionCapability', 'RTCRtpHeaderExtensionParameters', + 'RTCRtpParameters', 'RTCRtpReceiveParameters', 'RTCRtpReceiver', + 'RTCRtpSFrameDecryptor', + 'RTCRtpSFrameEncryptor', + 'RTCRtpSFrameEncryptorOptions', + 'RTCRtpScriptTransform', + 'RTCRtpScriptTransformType', + 'RTCRtpScriptTransformTypeValue', + 'RTCRtpScriptTransformer', 'RTCRtpSendParameters', 'RTCRtpSender', + 'RTCRtpStreamStats', 'RTCRtpSynchronizationSource', 'RTCRtpTransceiver', + 'RTCRtpTransceiverDirection', + 'RTCRtpTransceiverDirectionValue', 'RTCRtpTransceiverInit', 'RTCSctpTransport', + 'RTCSctpTransportState', 'RTCSdpType', 'RTCSdpTypeValue', + 'RTCSentRtpStreamStats', 'RTCSessionDescription', 'RTCSessionDescriptionInit', + 'RTCSetParameterOptions', 'RTCSignalingState', 'RTCStats', + 'RTCStatsIceCandidatePairState', 'RTCStatsReport', + 'RTCStatsType', 'RTCTrackEvent', + 'RTCTrackEventInit', + 'RTCTransformEvent', + 'RTCTransportStats', + 'RTCVideoSourceStats', 'ReadableStream', 'ReadableStreamDefaultController', 'ReadableStreamDefaultReader', + 'ReadableStreamGetReaderOptions', + 'ReadableStreamIteratorOptions', 'ReadableStreamReadResult', + 'ReadableStreamReaderMode', + 'ReadableStreamReaderModeValue', + 'ReadableWritablePair', 'RsaHashedKeyGenParams', - 'SctpTransportState', + 'SFrameCipherSuite', + 'SFrameCipherSuiteValue', + 'SFrameDecryptorStream', + 'SFrameEncryptorStream', + 'SFrameTransformErrorEvent', + 'SFrameTransformErrorEventInit', + 'SFrameTransformErrorEventType', + 'SFrameTransformErrorEventTypeValue', + 'SFrameTransformOptions', + 'SFrameType', + 'SFrameTypeValue', 'SdpParseException', - 'TransceiverDirection', - 'TransceiverDirectionValue', + 'StreamPipeOptions', 'TransformStream', 'TransformStreamDefaultController', 'ULongRange', @@ -336,6 +519,7 @@ 'VideoColorPrimariesValue', 'VideoColorSpace', 'VideoColorSpaceInit', + 'VideoFacingModeEnum', 'VideoFrame', 'VideoFrameBufferInit', 'VideoFrameCopyToOptions', @@ -345,13 +529,15 @@ 'VideoMatrixCoefficientsValue', 'VideoPixelFormat', 'VideoPixelFormatValue', + 'VideoResizeModeEnum', 'VideoTrackGenerator', 'VideoTransferCharacteristics', 'VideoTransferCharacteristicsValue', 'WebRTCObject', + 'WorkerAndParameters', 'WritableStream', 'WritableStreamDefaultController', 'WritableStreamDefaultWriter', - 'getUserMedia', - 'get_user_media', + 'mediaDevices', + 'media_devices', ] diff --git a/python-webrtc/python/webrtc/enums.py b/python-webrtc/python/webrtc/enums.py index a32eac3..1156b9f 100644 --- a/python-webrtc/python/webrtc/enums.py +++ b/python-webrtc/python/webrtc/enums.py @@ -51,8 +51,6 @@ class RTCIceConnectionState(_StrEnum): failed = 'failed' disconnected = 'disconnected' closed = 'closed' - #: Not a state: the number of states in libwebrtc, never reported. - max = 'max' class RTCIceGatheringState(_StrEnum): @@ -92,8 +90,8 @@ class MediaStreamSourceState(_StrEnum): muted = 'muted' -class TransceiverDirection(_StrEnum): - """The direction of a transceiver, ``RTCRtpTransceiverDirection`` in the specification.""" +class RTCRtpTransceiverDirection(_StrEnum): + """The direction of a transceiver.""" sendrecv = 'sendrecv' sendonly = 'sendonly' @@ -102,8 +100,8 @@ class TransceiverDirection(_StrEnum): stopped = 'stopped' -#: The values of :obj:`TransceiverDirection`, which parameters taking it take too -TransceiverDirectionValue = Literal['sendrecv', 'sendonly', 'recvonly', 'inactive', 'stopped'] +#: The values of :obj:`RTCRtpTransceiverDirection`, which parameters taking it take too +RTCRtpTransceiverDirectionValue = Literal['sendrecv', 'sendonly', 'recvonly', 'inactive', 'stopped'] class MediaType(_StrEnum): @@ -119,6 +117,37 @@ class MediaType(_StrEnum): MediaTypeValue = Literal['audio', 'video', 'data', 'unsupported'] +class MediaDeviceKind(_StrEnum): + """The kind of a media device.""" + + audioinput = 'audioinput' + audiooutput = 'audiooutput' + videoinput = 'videoinput' + + +class VideoFacingModeEnum(_StrEnum): + """Where a camera faces.""" + + user = 'user' + environment = 'environment' + left = 'left' + right = 'right' + + +class VideoResizeModeEnum(_StrEnum): + """How the video of a source is resized.""" + + none = 'none' + crop_and_scale = 'crop-and-scale' + + +class EchoCancellationModeEnum(_StrEnum): + """Which echo is cancelled: of all the audio played, or of the remote audio only.""" + + all = 'all' + remote_only = 'remote-only' + + class RTCIceComponent(_StrEnum): """The component of an ICE transport or candidate.""" @@ -146,16 +175,16 @@ class RTCIceTransportState(_StrEnum): closed = 'closed' -class CricketIceGatheringState(_StrEnum): - """The candidate gathering state of an ICE transport, ``RTCIceGathererState`` in the specification.""" +class RTCIceGathererState(_StrEnum): + """The candidate gathering state of an ICE transport.""" new = 'new' gathering = 'gathering' complete = 'complete' -class DtlsTransportState(_StrEnum): - """The state of a DTLS transport, ``RTCDtlsTransportState`` in the specification.""" +class RTCDtlsTransportState(_StrEnum): + """The state of a DTLS transport.""" new = 'new' connecting = 'connecting' @@ -164,10 +193,9 @@ class DtlsTransportState(_StrEnum): failed = 'failed' -class SctpTransportState(_StrEnum): - """The state of an SCTP transport, ``RTCSctpTransportState`` in the specification.""" +class RTCSctpTransportState(_StrEnum): + """The state of an SCTP transport.""" - new = 'new' connecting = 'connecting' connected = 'connected' closed = 'closed' @@ -293,6 +321,52 @@ class RTCIceServerTransportProtocol(_StrEnum): RTCIceServerTransportProtocolValue = Literal['udp', 'tcp', 'tls'] +class RTCStatsType(_StrEnum): + """The type of stats, which tells the dictionary they are.""" + + codec = 'codec' + inbound_rtp = 'inbound-rtp' + outbound_rtp = 'outbound-rtp' + remote_inbound_rtp = 'remote-inbound-rtp' + remote_outbound_rtp = 'remote-outbound-rtp' + media_source = 'media-source' + media_playout = 'media-playout' + peer_connection = 'peer-connection' + data_channel = 'data-channel' + transport = 'transport' + candidate_pair = 'candidate-pair' + local_candidate = 'local-candidate' + remote_candidate = 'remote-candidate' + certificate = 'certificate' + + +class RTCQualityLimitationReason(_StrEnum): + """What limits the resolution or frame rate of a video sender the most.""" + + none = 'none' + cpu = 'cpu' + bandwidth = 'bandwidth' + other = 'other' + + +class RTCDtlsRole(_StrEnum): + """The role of a DTLS transport in the handshake.""" + + client = 'client' + server = 'server' + unknown = 'unknown' + + +class RTCStatsIceCandidatePairState(_StrEnum): + """The state of an ICE candidate pair in the checklist.""" + + frozen = 'frozen' + waiting = 'waiting' + in_progress = 'in-progress' + failed = 'failed' + succeeded = 'succeeded' + + class RTCErrorDetailType(_StrEnum): """The WebRTC-specific cause of an :obj:`webrtc.RTCError`.""" @@ -330,6 +404,19 @@ class BinaryType(_StrEnum): BinaryTypeValue = Literal['arraybuffer', 'blob'] +class EndingType(_StrEnum): + """How a :obj:`webrtc.Blob` writes the line endings of its string parts.""" + + #: As they are + transparent = 'transparent' + #: As the ones of the platform: ``\r\n`` on Windows, ``\n`` elsewhere + native = 'native' + + +#: The values of :obj:`EndingType`, which parameters taking it take too +EndingTypeValue = Literal['transparent', 'native'] + + class VideoPixelFormat(_StrEnum): """The layout of the pixels of a :obj:`webrtc.VideoFrame`. @@ -444,6 +531,19 @@ class AlphaOption(_StrEnum): AlphaOptionValue = Literal['keep', 'discard'] +class PredefinedColorSpace(_StrEnum): + """The color space :meth:`webrtc.VideoFrame.copy_to` converts RGB pixels to.""" + + srgb = 'srgb' + srgb_linear = 'srgb-linear' + display_p3 = 'display-p3' + display_p3_linear = 'display-p3-linear' + + +#: The values of :obj:`PredefinedColorSpace`, which parameters taking it take too +PredefinedColorSpaceValue = Literal['srgb', 'srgb-linear', 'display-p3', 'display-p3-linear'] + + class AudioSampleFormat(_StrEnum): """The type of the samples of an :obj:`webrtc.AudioData`, interleaved or in a plane per channel.""" @@ -459,3 +559,80 @@ class AudioSampleFormat(_StrEnum): #: The values of :obj:`AudioSampleFormat`, which parameters taking it take too AudioSampleFormatValue = Literal['u8', 's16', 's32', 'f32', 'u8-planar', 's16-planar', 's32-planar', 'f32-planar'] + + +class ReadableStreamReaderMode(_StrEnum): + """The type of reader :meth:`webrtc.ReadableStream.get_reader` returns.""" + + byob = 'byob' + + +#: The values of :obj:`ReadableStreamReaderMode`, which parameters taking it take too +ReadableStreamReaderModeValue = Literal['byob'] + + +class EncodedVideoChunkType(_StrEnum): + """Whether an encoded video frame is a key frame, which decodes on its own, or depends on earlier frames.""" + + key = 'key' + delta = 'delta' + + +class RTCRtpScriptTransformType(_StrEnum): + """How an :obj:`webrtc.RTCRtpScriptTransform` packetizes the frames it outputs.""" + + #: The frames are SFrame-encrypted, packetized as SFrame + sframe = 'sframe' + + +#: The values of :obj:`RTCRtpScriptTransformType`, which parameters taking it take too +RTCRtpScriptTransformTypeValue = Literal['sframe'] + + +class SFrameCipherSuite(_StrEnum): + """The SFrame cipher suites of RFC 9605 and draft-barnes-sframe-iana-256: AES-CTR with HMAC tags, or AES-GCM.""" + + AES_128_CTR_HMAC_SHA256_80 = 'AES_128_CTR_HMAC_SHA256_80' + AES_128_CTR_HMAC_SHA256_64 = 'AES_128_CTR_HMAC_SHA256_64' + AES_128_CTR_HMAC_SHA256_32 = 'AES_128_CTR_HMAC_SHA256_32' + AES_128_GCM_SHA256_128 = 'AES_128_GCM_SHA256_128' + AES_256_GCM_SHA512_128 = 'AES_256_GCM_SHA512_128' + AES_256_CTR_HMAC_SHA512_80 = 'AES_256_CTR_HMAC_SHA512_80' + AES_256_CTR_HMAC_SHA512_64 = 'AES_256_CTR_HMAC_SHA512_64' + AES_256_CTR_HMAC_SHA512_32 = 'AES_256_CTR_HMAC_SHA512_32' + + +#: The values of :obj:`SFrameCipherSuite`, which parameters taking it take too +SFrameCipherSuiteValue = Literal[ + 'AES_128_CTR_HMAC_SHA256_80', + 'AES_128_CTR_HMAC_SHA256_64', + 'AES_128_CTR_HMAC_SHA256_32', + 'AES_128_GCM_SHA256_128', + 'AES_256_GCM_SHA512_128', + 'AES_256_CTR_HMAC_SHA512_80', + 'AES_256_CTR_HMAC_SHA512_64', + 'AES_256_CTR_HMAC_SHA512_32', +] + + +class SFrameType(_StrEnum): + """Whether an :obj:`webrtc.RTCRtpSFrameEncryptor` encrypts whole frames or each RTP packet.""" + + per_frame = 'per-frame' + per_packet = 'per-packet' + + +#: The values of :obj:`SFrameType`, which parameters taking it take too +SFrameTypeValue = Literal['per-frame', 'per-packet'] + + +class SFrameTransformErrorEventType(_StrEnum): + """Why a frame didn't decrypt: it didn't authenticate, its key id is unknown, or it isn't SFrame.""" + + authentication = 'authentication' + key_id = 'keyID' + syntax = 'syntax' + + +#: The values of :obj:`SFrameTransformErrorEventType`, which parameters taking it take too +SFrameTransformErrorEventTypeValue = Literal['authentication', 'keyID', 'syntax'] diff --git a/python-webrtc/python/webrtc/exceptions.py b/python-webrtc/python/webrtc/exceptions.py index c3bb58a..ec41ed7 100644 --- a/python-webrtc/python/webrtc/exceptions.py +++ b/python-webrtc/python/webrtc/exceptions.py @@ -25,7 +25,12 @@ class RTCException(PythonWebRTCException): - """Base class of the errors reported by libwebrtc.""" + """Base class of the errors reported by libwebrtc, the ``DOMException`` of the specification.""" + + @property + def message(self) -> str: + """:obj:`str`: A description of the error.""" + return str(self.args[0]) if len(self.args) > 0 else '' class InvalidStateError(RTCException): @@ -52,6 +57,18 @@ class NetworkError(RTCException): """An error of an underlying network protocol.""" +class NotFoundError(RTCException): + """An object isn't found, like a simulcast layer of an unknown ``rid``.""" + + +class NotAllowedError(RTCException): + """The operation isn't allowed, like with a malformed ``rid``.""" + + +class DataCloneError(RTCException): + """An object can't be transferred, like a buffer listed twice in ``transfer``.""" + + class InvalidSyntaxError(RTCException, ValueError): """A string couldn't be parsed, like an ICE server URL. ``SyntaxError`` in the specification.""" @@ -127,7 +144,6 @@ class RTCError(OperationError): def __init__(self, init: RTCErrorInit, message: str = '') -> None: super().__init__(message) - self.message = message self.error_detail = RTCErrorDetailType(init.error_detail) self.sdp_line_number = init.sdp_line_number self.sctp_cause_code = init.sctp_cause_code diff --git a/python-webrtc/python/webrtc/functions/__init__.py b/python-webrtc/python/webrtc/functions/__init__.py deleted file mode 100644 index a36721d..0000000 --- a/python-webrtc/python/webrtc/functions/__init__.py +++ /dev/null @@ -1,8 +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 functions of the API.""" diff --git a/python-webrtc/python/webrtc/functions/get_user_media.py b/python-webrtc/python/webrtc/functions/get_user_media.py deleted file mode 100644 index 5822daf..0000000 --- a/python-webrtc/python/webrtc/functions/get_user_media.py +++ /dev/null @@ -1,79 +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. -# - -"""getUserMedia of Media Capture and Streams, with a synthetic microphone and camera.""" - -from __future__ import annotations - -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 - from webrtc.models.media_track_constraints import ConstrainDouble, ConstrainULong - - -def get_user_media( - *, - audio: bool = True, - video: bool = False, - 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. - - The audio track is quiet noise, the video track draws a moving pattern (use :obj:`webrtc.VideoTrackGenerator` - and :obj:`webrtc.MediaStreamTrackGenerator` for real media). The constraints given are the ones of the tracks - (see :meth:`webrtc.MediaStreamTrack.get_constraints`). - - 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` 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. - - Raises: - TypeError: If neither audio nor video is requested, or a value isn't a finite number (negative for - the size). - webrtc.OverconstrainedError: If a required value (``exact``, ``min``, ``max``) is beyond what the - camera can do: 1 to 4096 pixels wide and high, 1 to 120 frames per second. Other values are brought - within that. - """ - if not audio and not video: - msg = 'audio or video must be requested' - raise TypeError(msg) - constraints = MediaTrackConstraints(width=width, height=height, frame_rate=frame_rate) - _check_numbers(constraints) - if video: - failed = _unsatisfied(constraints, _CAMERA_CAPABILITIES, MediaTrackSettings()) - if failed is not None: - raise OverconstrainedError(failed, f"The constraint {failed} can't be satisfied") - # the camera's defaults, within the constraints and the camera's capabilities - capabilities = _CAMERA_CAPABILITIES - selected_width = _selected(width, 640, capabilities.width) - selected_height = _selected(height, 480, capabilities.height) - selected_frame_rate = _selected(frame_rate, 30.0, capabilities.frame_rate) - stream = MediaStream._wrap( - wrtc.getUserMedia(bool(audio), bool(video), selected_width, selected_height, float(selected_frame_rate)) - ) - for track in stream.get_video_tracks(): - track._native_obj._constraints = constraints - return stream - - -#: Alias for :func:`get_user_media` -getUserMedia = get_user_media diff --git a/python-webrtc/python/webrtc/interfaces/media_devices.py b/python-webrtc/python/webrtc/interfaces/media_devices.py new file mode 100644 index 0000000..59adc2a --- /dev/null +++ b/python-webrtc/python/webrtc/interfaces/media_devices.py @@ -0,0 +1,273 @@ +# +# 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. +# + +"""MediaDevices of Media Capture and Streams: the synthetic camera and microphone of the library.""" + +from __future__ import annotations + +import copy +import dataclasses +from types import SimpleNamespace +from typing import TYPE_CHECKING, ClassVar + +from typing_extensions import override + +from webrtc import ( + MediaDeviceKind, + MediaStream, + MediaStreamConstraints, + MediaTrackConstraints, + MediaTrackSettings, + MediaTrackSupportedConstraints, + OverconstrainedError, + wrtc, +) +from webrtc.interfaces.media_stream_track import ( + _CAMERA_CAPABILITIES, + _GROUP_ID, + _MICROPHONE_CAPABILITIES, + CAMERA_DEVICE_ID, + MICROPHONE_DEVICE_ID, + _check_numbers, + _selected, + _unsatisfied, +) +from webrtc.utils.events import EventTarget +from webrtc.utils.names import Alias, alias + +if TYPE_CHECKING: + import webrtc + + +class MediaDeviceInfo: + """A media device, as :meth:`MediaDevices.enumerate_devices` lists it. + + Args: + device_id (:obj:`str`): The id of the device. + kind (:obj:`webrtc.MediaDeviceKind`): Whether it's a microphone, a speaker or a camera. + label (:obj:`str`): The name of the device. + group_id (:obj:`str`): The id of the physical device it belongs to. + """ + + def __init__(self, *, device_id: str, kind: webrtc.MediaDeviceKind, label: str, group_id: str) -> None: + self._device_id = device_id + self._kind = MediaDeviceKind(kind) + self._label = label + self._group_id = group_id + + @property + def device_id(self) -> str: + """:obj:`str`: The id of the device.""" + return self._device_id + + @property + def kind(self) -> webrtc.MediaDeviceKind: + """:obj:`webrtc.MediaDeviceKind`: Whether it's a microphone, a speaker or a camera.""" + return self._kind + + @property + def label(self) -> str: + """:obj:`str`: The name of the device.""" + return self._label + + @property + def group_id(self) -> str: + """:obj:`str`: The id of the physical device it belongs to.""" + return self._group_id + + def to_json(self) -> dict[str, str]: + """Returns the device as a JSON-serializable dictionary. + + Returns: + :obj:`dict`: ``deviceId``, ``kind``, ``label`` and ``groupId``. + """ + return {'deviceId': self.device_id, 'kind': self.kind.value, 'label': self.label, 'groupId': self.group_id} + + def __repr__(self) -> str: + return f'{type(self).__name__}(device_id={self.device_id!r}, kind={self.kind.value!r}, label={self.label!r})' + + #: Alias for :attr:`device_id` + deviceId: ClassVar[Alias[str]] = alias('device_id') + #: Alias for :attr:`group_id` + groupId: ClassVar[Alias[str]] = alias('group_id') + #: Alias for :meth:`to_json` + toJSON = to_json + + +class InputDeviceInfo(MediaDeviceInfo): + """An input device: the synthetic camera or microphone. + + Args: + device_id (:obj:`str`): The id of the device. + kind (:obj:`webrtc.MediaDeviceKind`): Whether it's a microphone or a camera. + label (:obj:`str`): The name of the device. + group_id (:obj:`str`): The id of the physical device it belongs to. + capabilities (:obj:`webrtc.MediaTrackCapabilities`): What the device can do. + """ + + def __init__( + self, + *, + device_id: str, + kind: webrtc.MediaDeviceKind, + label: str, + group_id: str, + capabilities: webrtc.MediaTrackCapabilities, + ) -> None: + super().__init__(device_id=device_id, kind=kind, label=label, group_id=group_id) + self._capabilities = capabilities + + def get_capabilities(self) -> webrtc.MediaTrackCapabilities: + """Returns what the device can do, as the tracks of it have. + + Returns: + :obj:`webrtc.MediaTrackCapabilities`: A copy of the capabilities. + """ + return copy.deepcopy(self._capabilities) + + #: Alias for :meth:`get_capabilities` + getCapabilities = get_capabilities + + +def _devices() -> list[MediaDeviceInfo]: + return [ + InputDeviceInfo( + device_id=MICROPHONE_DEVICE_ID, + kind=MediaDeviceKind.audioinput, + label='Synthetic microphone', + group_id=_GROUP_ID, + capabilities=_MICROPHONE_CAPABILITIES, + ), + InputDeviceInfo( + device_id=CAMERA_DEVICE_ID, + kind=MediaDeviceKind.videoinput, + label='Synthetic camera', + group_id=_GROUP_ID, + capabilities=_CAMERA_CAPABILITIES, + ), + ] + + +def _track_constraints(constraints: webrtc.MediaStreamConstraints, kind: str) -> MediaTrackConstraints | None: + """The constraints of a requested kind of track, :obj:`None` if it isn't requested.""" + value: bool | MediaTrackConstraints = getattr(constraints, kind) + if isinstance(value, MediaTrackConstraints): + return value + return MediaTrackConstraints() if value else None + + +def _check_satisfiable(constraints: MediaTrackConstraints, capabilities: webrtc.MediaTrackCapabilities) -> None: + _check_numbers(constraints) + failed = _unsatisfied(constraints, capabilities, MediaTrackSettings()) + if failed is not None: + raise OverconstrainedError(failed, f"The constraint {failed} can't be satisfied") + + +def _capture_mode( + video: MediaTrackConstraints | None, camera: webrtc.MediaTrackCapabilities +) -> tuple[float, float, float]: + """The width, height and frame rate of the camera: its defaults, within the constraints and its capabilities.""" + constraints = video if video is not None else MediaTrackConstraints() + return ( + _selected(constraints.width, 640, camera.width), + _selected(constraints.height, 480, camera.height), + _selected(constraints.frame_rate, 30.0, camera.frame_rate), + ) + + +class MediaDevices(EventTarget): + """The media devices of the library: a synthetic microphone and camera, as :data:`webrtc.media_devices`. + + The microphone plays quiet noise, the camera draws a moving pattern (use :obj:`webrtc.VideoTrackGenerator` and + :obj:`webrtc.MediaStreamTrackGenerator` for real media). + + Events (see :meth:`on`): + ``devicechange`` (:obj:`webrtc.DeviceChangeEvent`): The devices changed, which they never do. + """ + + _events = ('devicechange',) + + def __init__(self) -> None: + # the listeners of the events, which a native object holds for other targets + self._native = SimpleNamespace(_listeners=None) + # the devices never change + self._devices = _devices() + self._supported = [field.name for field in dataclasses.fields(MediaTrackSupportedConstraints)] + + @property + @override + def _native_obj(self) -> SimpleNamespace: + return self._native + + def _capabilities(self) -> dict[webrtc.MediaDeviceKind, webrtc.MediaTrackCapabilities]: + return {d.kind: d.get_capabilities() for d in self._devices if isinstance(d, InputDeviceInfo)} + + async def enumerate_devices(self) -> list[webrtc.MediaDeviceInfo]: + """Lists the devices: the synthetic microphone and camera. + + Returns: + :obj:`list` of :obj:`webrtc.MediaDeviceInfo`: The devices, :obj:`webrtc.InputDeviceInfo` ones. + """ + return list(self._devices) + + def get_supported_constraints(self) -> webrtc.MediaTrackSupportedConstraints: + """Returns the constraints the library recognizes: all of them. + + Returns: + :obj:`webrtc.MediaTrackSupportedConstraints`: The constraints. + """ + return MediaTrackSupportedConstraints(**dict.fromkeys(self._supported, True)) + + async def get_user_media(self, constraints: webrtc.MediaStreamConstraints | None = None) -> webrtc.MediaStream: + """Returns a stream of the synthetic microphone and/or camera, as requested. + + The constraints given are the ones of the tracks (see :meth:`webrtc.MediaStreamTrack.get_constraints`). + + Args: + constraints (:obj:`webrtc.MediaStreamConstraints`, optional): Whether to get an audio and a video track, + and the constraints of each. + + Returns: + :obj:`webrtc.MediaStream`: The stream. + + Raises: + TypeError: If neither audio nor video is requested, or a value isn't a finite number (negative for + the size). + webrtc.OverconstrainedError: If a required value (``exact``, ``min``, ``max``) is beyond what the + device can do, like more than 4096 pixels wide or 120 frames per second for the camera. Other + values are brought within that. + """ + constraints = constraints if constraints is not None else MediaStreamConstraints() + audio = _track_constraints(constraints, 'audio') + video = _track_constraints(constraints, 'video') + if audio is None and video is None: + msg = 'audio or video must be requested' + raise TypeError(msg) + capabilities = self._capabilities() + for requested, kind in ((audio, MediaDeviceKind.audioinput), (video, MediaDeviceKind.videoinput)): + if requested is not None: + _check_satisfiable(requested, capabilities[kind]) + width, height, frame_rate = _capture_mode(video, capabilities[MediaDeviceKind.videoinput]) + stream = MediaStream._wrap(wrtc.getUserMedia(audio is not None, video is not None, width, height, frame_rate)) + for track in stream.get_audio_tracks(): + track._native_obj._constraints = audio + for track in stream.get_video_tracks(): + track._native_obj._constraints = video + return stream + + #: Alias for :meth:`enumerate_devices` + enumerateDevices = enumerate_devices + #: Alias for :meth:`get_supported_constraints` + getSupportedConstraints = get_supported_constraints + #: Alias for :meth:`get_user_media` + getUserMedia = get_user_media + + +#: The media devices of the library, ``navigator.mediaDevices`` in a browser +media_devices = MediaDevices() +#: Alias for :data:`media_devices` +mediaDevices = media_devices diff --git a/python-webrtc/python/webrtc/interfaces/media_stream.py b/python-webrtc/python/webrtc/interfaces/media_stream.py index 3d37c5b..56fdafa 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream.py @@ -13,7 +13,7 @@ from typing_extensions import override -from webrtc import MediaStreamTrack, MediaStreamTrackEvent, MediaType, WebRTCObject, wrtc +from webrtc import MediaStreamTrack, MediaStreamTrackEvent, MediaStreamTrackEventInit, MediaType, WebRTCObject, wrtc from webrtc.utils.events import EventTarget if TYPE_CHECKING: @@ -70,7 +70,7 @@ def _on_event(self, name: str, *_args: object) -> None: @override def _create_event(self, name: str, *args: object) -> webrtc.Event | None: (track,) = cast('tuple[wrtc.MediaStreamTrack]', args) - return MediaStreamTrackEvent(name, MediaStreamTrack._wrap(track), target=self) + return MediaStreamTrackEvent(name, MediaStreamTrackEventInit(MediaStreamTrack._wrap(track))) @property def id(self) -> str: diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track.py b/python-webrtc/python/webrtc/interfaces/media_stream_track.py index c8b3353..44bdf00 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track.py @@ -84,6 +84,9 @@ 'echo_cancellation', 'auto_gain_control', 'noise_suppression', + 'facing_mode', + 'latency', + 'background_blur', ) @@ -166,7 +169,7 @@ def _selected( # the members of constraints that are numbers: unsigned longs, and restricted doubles _ULONG_CONSTRAINTS = ('width', 'height', 'sample_rate', 'sample_size', 'channel_count') -_DOUBLE_CONSTRAINTS = ('aspect_ratio', 'frame_rate') +_DOUBLE_CONSTRAINTS = ('aspect_ratio', 'frame_rate', 'latency') def _check_numbers(constraint_set: MediaTrackConstraintSet) -> None: 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 ce59e30..fc53ac8 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py @@ -16,7 +16,7 @@ from webrtc import AudioData, MediaStreamTrack, MediaType, VideoFrame, WebRTCObject, wrtc from webrtc.models.dictionary import Dictionary -from webrtc.streams import ReadableStream +from webrtc.streams import QueuingStrategy, ReadableStream from webrtc.utils.events import EventTarget from webrtc.utils.names import Alias, alias @@ -134,8 +134,13 @@ def __init__(self, init: MediaStreamTrackProcessorInit) -> None: # the native processor doesn't keep the track, Python does self._track = track self._source = _TrackSource(self) - self._readable: ReadableStream[VideoFrame | AudioData] = ReadableStream(self._source, high_water_mark=0) + self._readable: ReadableStream[VideoFrame | AudioData] = ReadableStream( + self._source, QueuingStrategy(high_water_mark=0) + ) self._attach() + if self._native_obj._listeners is not None: + # media comes as soon as the sink is attached: a wakeup sent before the listeners were set was dropped + self._native_obj._ackWakeup() @override def _on_event(self, name: str, *_args: object) -> None: diff --git a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py index 7c646a3..2ab8175 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py @@ -20,6 +20,7 @@ MessageEvent, RTCDataChannelState, RTCErrorEvent, + RTCErrorEventInit, RTCPriorityType, WebRTCObject, wrtc, @@ -149,10 +150,10 @@ def _create_event(self, name: str, *args: object) -> webrtc.Event | None: # binary_type as of delivery, per the specification if isinstance(data, bytes) and self._native_obj.binaryType == BinaryType.blob: data = Blob([data]) - return MessageEvent(name, data, target=self) + return MessageEvent(name, data) if name == 'error': (error,) = cast('tuple[wrtc.RTCCallbackException]', args) - return RTCErrorEvent(name, _event_error(error), target=self) + return RTCErrorEvent(name, RTCErrorEventInit(_event_error(error))) return super()._create_event(name, *args) @property diff --git a/python-webrtc/python/webrtc/interfaces/rtc_dtls_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_dtls_transport.py index 66cebc4..7d0d59f 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_dtls_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_dtls_transport.py @@ -14,7 +14,7 @@ from typing_extensions import override import webrtc -from webrtc import DtlsTransportState, RTCErrorEvent, WebRTCObject, wrtc +from webrtc import RTCDtlsTransportState, RTCErrorEvent, RTCErrorEventInit, WebRTCObject, wrtc from webrtc.exceptions import _event_error from webrtc.utils.events import EventTarget @@ -37,14 +37,14 @@ class RTCDtlsTransport(WebRTCObject[wrtc.RTCDtlsTransport], EventTarget): def _on_event(self, name: str, *args: object) -> None: # the state changes along with its event if name == 'statechange': - (state,) = cast('tuple[DtlsTransportState]', args) + (state,) = cast('tuple[RTCDtlsTransportState]', args) self._native_obj._surfaceState(state) @override def _create_event(self, name: str, *args: object) -> webrtc.Event | None: if name == 'error': (error,) = cast('tuple[wrtc.RTCCallbackException]', args) - return RTCErrorEvent(name, _event_error(error), target=self) + return RTCErrorEvent(name, RTCErrorEventInit(_event_error(error))) return super()._create_event(name, *args) @property @@ -53,8 +53,8 @@ def ice_transport(self) -> webrtc.RTCIceTransport: return webrtc.RTCIceTransport._wrap(self._native_obj.iceTransport) @property - def state(self) -> webrtc.DtlsTransportState: - """:obj:`webrtc.DtlsTransportState`: The state of the DTLS transport.""" + def state(self) -> webrtc.RTCDtlsTransportState: + """:obj:`webrtc.RTCDtlsTransportState`: The state of the DTLS transport.""" return self._native_obj.state def get_remote_certificates(self) -> list[bytes]: diff --git a/python-webrtc/python/webrtc/interfaces/rtc_dtmf_sender.py b/python-webrtc/python/webrtc/interfaces/rtc_dtmf_sender.py index ac19993..6209b81 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_dtmf_sender.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_dtmf_sender.py @@ -14,7 +14,7 @@ from typing_extensions import override -from webrtc import InvalidCharacterError, RTCDTMFToneChangeEvent, WebRTCObject, wrtc +from webrtc import InvalidCharacterError, RTCDTMFToneChangeEvent, RTCDTMFToneChangeEventInit, WebRTCObject, wrtc from webrtc.utils.events import EventTarget if TYPE_CHECKING: @@ -43,7 +43,7 @@ def _on_event(self, name: str, *args: object) -> None: @override def _create_event(self, name: str, *args: object) -> webrtc.Event | None: tone, _, _ = cast('tuple[str, str, int]', args) - return RTCDTMFToneChangeEvent(name, tone, target=self) + return RTCDTMFToneChangeEvent(name, RTCDTMFToneChangeEventInit(tone)) def insert_dtmf(self, tones: str, duration: int = 100, inter_tone_gap: int = 70) -> None: """Plays tones, replacing the ones not played yet. diff --git a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py index bb51a31..0a8f2ed 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py @@ -16,24 +16,25 @@ from typing_extensions import override from webrtc import ( - CricketIceGatheringState, InvalidStateError, InvalidSyntaxError, RTCIceCandidate, + RTCIceCandidateInit, RTCIceCandidatePair, + RTCIceGathererState, + RTCIceGatherOptions, RTCIceParameters, RTCIceRole, RTCIceServer, RTCIceTransportState, RTCPeerConnectionIceEvent, + RTCPeerConnectionIceEventInit, WebRTCObject, wrtc, ) from webrtc.utils.events import EventTarget if TYPE_CHECKING: - from collections.abc import Sequence - import webrtc @@ -59,10 +60,11 @@ class RTCIceTransport(WebRTCObject[wrtc.RTCIceTransport], EventTarget): ``selectedcandidatepairchange`` (:obj:`webrtc.Event`): :meth:`get_selected_candidate_pair` changed. ``icecandidate`` (:obj:`webrtc.RTCPeerConnectionIceEvent`): A standalone transport gathered a candidate, or :obj:`None` once it gathered them all. + ``error`` (:obj:`webrtc.Event`): A standalone transport failed, which libwebrtc doesn't report. """ _class = wrtc.RTCIceTransport - _events = ('statechange', 'gatheringstatechange', 'selectedcandidatepairchange', 'icecandidate') + _events = ('statechange', 'gatheringstatechange', 'selectedcandidatepairchange', 'icecandidate', 'error') def __init__(self) -> None: super().__init__() @@ -88,7 +90,7 @@ def _on_event(self, name: str, *args: object) -> None: (state,) = cast('tuple[RTCIceTransportState]', args) self._native_obj._surfaceState(state) elif name == 'gatheringstatechange': - (gathering_state,) = cast('tuple[CricketIceGatheringState]', args) + (gathering_state,) = cast('tuple[RTCIceGathererState]', args) self._native_obj._surfaceGatheringState(gathering_state) elif name == 'icecandidate' and len(args) > 0 and args[0] is not None: self._native_obj._surfaceCandidate() @@ -99,7 +101,7 @@ def _create_event(self, name: str, *args: object) -> webrtc.Event | None: # the end of candidates has none native = cast('wrtc.IceCandidateInit | None', args[0]) if len(args) > 0 else None candidate = self._candidate_of(native) if native is not None else None - return RTCPeerConnectionIceEvent(name, candidate, None, target=self) + return RTCPeerConnectionIceEvent(name, RTCPeerConnectionIceEventInit(candidate)) return super()._create_event(name, *args) def _check_standalone(self, operation: str) -> None: @@ -113,16 +115,11 @@ def _check_open(self, operation: str) -> None: msg = f'Can not {operation}: the transport is stopped' raise InvalidStateError(msg) - def gather( - self, - gather_policy: webrtc.RTCIceTransportPolicy | webrtc.RTCIceTransportPolicyValue = 'all', - ice_servers: Sequence[webrtc.RTCIceServer] | None = None, - ) -> None: + def gather(self, options: webrtc.RTCIceGatherOptions | 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. + options (:obj:`webrtc.RTCIceGatherOptions`, optional): The policy and the ICE servers to gather with. Raises: webrtc.InvalidStateError: If it's stopped, gathering already, or belongs to a connection. @@ -131,16 +128,16 @@ def gather( TypeError: If the policy isn't a value of :obj:`webrtc.RTCIceTransportPolicy`. """ self._check_open('gather') - if self.gathering_state != CricketIceGatheringState.new: + if self.gathering_state != RTCIceGathererState.new: msg = 'The transport gathers its candidates already' raise InvalidStateError(msg) - self._native_obj.gather( - gather_policy, RTCIceServer._to_native_list(ice_servers if ice_servers is not None else ()) - ) + options = options if options is not None else RTCIceGatherOptions() + servers = options.ice_servers if options.ice_servers is not None else () + self._native_obj.gather(options.gather_policy, RTCIceServer._to_native_list(servers)) def start( self, - remote_parameters: webrtc.RTCIceParameters, + remote_parameters: webrtc.RTCIceParameters | None = None, role: webrtc.RTCIceRole | str = 'controlled', ) -> None: """Starts connecting a standalone transport to the remote agent, with the candidates added, or later. @@ -148,8 +145,8 @@ def start( Remote parameters that differ from the ones given before remove the remote candidates. Args: - remote_parameters (:obj:`webrtc.RTCIceParameters`): The username fragment and the password of the - remote agent. + remote_parameters (:obj:`webrtc.RTCIceParameters`, optional): The username fragment and the password of + the remote agent, which are required. role (:obj:`webrtc.RTCIceRole`, optional): Controlling or controlled (the default). When both agents take the same role, one of them switches. @@ -159,22 +156,27 @@ def start( ValueError: If the role is neither controlling nor controlled. """ self._check_open('start') - if _UFRAG.fullmatch(remote_parameters.username_fragment) is None: - msg = f'{remote_parameters.username_fragment!r} is not a valid ICE username fragment' + parameters = remote_parameters if remote_parameters is not None else RTCIceParameters() + ufrag, password = parameters.username_fragment, parameters.password + if ufrag is None or _UFRAG.fullmatch(ufrag) is None: + msg = f'{ufrag!r} is not a valid ICE username fragment' raise InvalidSyntaxError(msg) - if _PASSWORD.fullmatch(remote_parameters.password) is None: + if password is None or _PASSWORD.fullmatch(password) is None: msg = 'the ICE password is not valid' raise InvalidSyntaxError(msg) if role not in {RTCIceRole.controlling, RTCIceRole.controlled}: msg = 'role must be controlling or controlled' raise ValueError(msg) - self._native_obj.start(remote_parameters.username_fragment, remote_parameters.password, role) + self._native_obj.start(ufrag, password, role) - def add_remote_candidate(self, candidate: webrtc.RTCIceCandidate | webrtc.RTCIceCandidateInit) -> None: + def add_remote_candidate( + self, remote_candidate: webrtc.RTCIceCandidate | webrtc.RTCIceCandidateInit | None = None + ) -> None: """Adds a candidate of the remote agent to a standalone transport. Args: - candidate (:obj:`webrtc.RTCIceCandidate` or :obj:`webrtc.RTCIceCandidateInit`): The candidate. + remote_candidate (:obj:`webrtc.RTCIceCandidate` or :obj:`webrtc.RTCIceCandidateInit`, optional): The + candidate, which needs ``sdp_mid`` or ``sdp_m_line_index``. Raises: TypeError: If the candidate has neither ``sdp_mid`` nor ``sdp_m_line_index``. @@ -182,6 +184,7 @@ def add_remote_candidate(self, candidate: webrtc.RTCIceCandidate | webrtc.RTCIce webrtc.OperationError: If the candidate can't be parsed. """ self._check_open('add a remote candidate') + candidate = remote_candidate if remote_candidate is not None else RTCIceCandidateInit() if not isinstance(candidate, RTCIceCandidate): candidate = RTCIceCandidate(*RTCIceCandidate._members_of(candidate)) self._native_obj.addRemoteCandidate( @@ -242,7 +245,7 @@ def get_local_parameters(self) -> webrtc.RTCIceParameters | None: :obj:`webrtc.RTCIceParameters`, optional: The parameters, :obj:`None` without a local description. """ parameters = self._native_obj.getLocalParameters() - return RTCIceParameters(*parameters) if parameters is not None else None + return RTCIceParameters(*parameters, ice_lite=False) if parameters is not None else None def get_remote_parameters(self) -> webrtc.RTCIceParameters | None: """Returns the ICE parameters of the transport in the remote description. @@ -259,8 +262,8 @@ def component(self) -> webrtc.RTCIceComponent: return self._native_obj.component @property - def gathering_state(self) -> webrtc.CricketIceGatheringState: - """:obj:`webrtc.CricketIceGatheringState`: The gathering state of the ICE agent.""" + def gathering_state(self) -> webrtc.RTCIceGathererState: + """:obj:`webrtc.RTCIceGathererState`: The gathering state of the ICE agent.""" return self._native_obj.gatheringState @property diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index 6cbfa0d..adb63e0 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -17,20 +17,26 @@ import webrtc from webrtc import ( - CricketIceGatheringState, Event, InvalidAccessError, InvalidStateError, MediaType, OperationError, + RTCAnswerOptions, RTCCertificate, RTCConfiguration, RTCDataChannelEvent, + RTCDataChannelEventInit, RTCIceCandidate, + RTCIceGathererState, RTCLocalSessionDescriptionInit, + RTCOfferOptions, RTCPeerConnectionIceErrorEvent, + RTCPeerConnectionIceErrorEventInit, RTCPeerConnectionIceEvent, + RTCPeerConnectionIceEventInit, RTCRtpCodec, + RTCRtpTransceiverDirection, RTCRtpTransceiverInit, RTCSdpType, RTCSessionDescription, @@ -38,7 +44,7 @@ RTCSignalingState, RTCStatsReport, RTCTrackEvent, - TransceiverDirection, + RTCTrackEventInit, WebRTCObject, wrtc, ) @@ -211,7 +217,7 @@ def _complete_gathering(self, transports: list[wrtc.RTCIceTransport], state: web """ ice_transports = webrtc.RTCIceTransport._wrap_many(transports) for ice_transport in ice_transports: - ice_transport._native_obj._surfaceGatheringState(CricketIceGatheringState(state)) + ice_transport._native_obj._surfaceGatheringState(RTCIceGathererState(state)) self._native_obj._surfaceIceGatheringState(state) self._native_obj._refreshDescriptions() for ice_transport in ice_transports: @@ -238,40 +244,44 @@ def _negotiation_needed_event(self, event_id: int) -> webrtc.Event | None: return None if not self._native_obj._shouldFireNegotiationNeededEvent(event_id): return None - return Event('negotiationneeded', self) + return Event('negotiationneeded') - def _ice_candidate_event(self, candidate: wrtc.IceCandidateInit | None = None) -> webrtc.Event: + @staticmethod + def _ice_candidate_event(candidate: wrtc.IceCandidateInit | None = None) -> webrtc.Event: if candidate is None: - return RTCPeerConnectionIceEvent('icecandidate', None, None, target=self) + return RTCPeerConnectionIceEvent('icecandidate') kwargs = candidate.kwargs() - return RTCPeerConnectionIceEvent('icecandidate', RTCIceCandidate(**kwargs), kwargs['url'], target=self) + return RTCPeerConnectionIceEvent( + 'icecandidate', RTCPeerConnectionIceEventInit(RTCIceCandidate(**kwargs), kwargs['url']) + ) - def _ice_candidate_error_event(self, *native: object) -> webrtc.Event: + @staticmethod + def _ice_candidate_error_event(*native: object) -> webrtc.Event: address, port, url, error_code, error_text = cast('tuple[str, int, str, int, str]', native) return RTCPeerConnectionIceErrorEvent( 'icecandidateerror', - address if address != '' else None, - port if port != 0 else None, - url, - error_code, - error_text, - target=self, + RTCPeerConnectionIceErrorEventInit( + error_code, address if address != '' else None, port if port != 0 else None, url, error_text + ), ) - def _data_channel_event(self, channel: wrtc.RTCDataChannel) -> webrtc.Event: - return RTCDataChannelEvent('datachannel', webrtc.RTCDataChannel._wrap(channel), target=self) + @staticmethod + def _data_channel_event(channel: wrtc.RTCDataChannel) -> webrtc.Event: + return RTCDataChannelEvent('datachannel', RTCDataChannelEventInit(webrtc.RTCDataChannel._wrap(channel))) + @staticmethod def _track_event( - self, transceiver: wrtc.RTCRtpTransceiver, receiver: wrtc.RTCRtpReceiver, streams: list[wrtc.MediaStream] + transceiver: wrtc.RTCRtpTransceiver, receiver: wrtc.RTCRtpReceiver, streams: list[wrtc.MediaStream] ) -> webrtc.Event: wrapped_receiver = webrtc.RTCRtpReceiver._wrap(receiver) return RTCTrackEvent( 'track', - wrapped_receiver, - wrapped_receiver.track, - webrtc.MediaStream._wrap_many(streams), - webrtc.RTCRtpTransceiver._wrap(transceiver), - target=self, + RTCTrackEventInit( + wrapped_receiver, + wrapped_receiver.track, + webrtc.RTCRtpTransceiver._wrap(transceiver), + webrtc.MediaStream._wrap_many(streams), + ), ) def _apply_legacy_offer_option(self, kind: webrtc.MediaType, *, receive: bool | None) -> None: @@ -281,7 +291,7 @@ def _apply_legacy_offer_option(self, kind: webrtc.MediaType, *, receive: bool | """ if receive is None: return - directions = TransceiverDirection + directions = RTCRtpTransceiverDirection transceivers = [t for t in self.get_transceivers() if not t.stopped and t.receiver.track.kind == kind] if not receive: for transceiver in transceivers: @@ -300,14 +310,7 @@ def _completed_description(self) -> None: for sender in self._native_obj.getSenders(): sender._expireParameters() - async def create_offer( - self, - *, - ice_restart: bool = False, - offer_to_receive_audio: bool | None = None, - offer_to_receive_video: bool | None = None, - voice_activity_detection: bool = True, - ) -> webrtc.RTCSessionDescriptionInit: + async def create_offer(self, options: webrtc.RTCOfferOptions | None = None) -> webrtc.RTCSessionDescriptionInit: """Initiates the creation of an SDP offer for the purpose of starting a new WebRTC connection to a remote peer. The SDP offer includes information about any MediaStreamTrack objects already attached to the WebRTC session, @@ -316,13 +319,7 @@ async def create_offer( the configuration of an existing connection. Args: - ice_restart (:obj:`bool`, optional): Whether to restart ICE, gathering new credentials and candidates. - :meth:`restart_ice` is the preferred way. - offer_to_receive_audio (:obj:`bool`, optional): Legacy: :obj:`True` adds a receiving audio transceiver - if there's none, :obj:`False` stops receiving audio on the existing ones. - :meth:`add_transceiver` is the preferred way. - offer_to_receive_video (:obj:`bool`, optional): The same for video. - voice_activity_detection (:obj:`bool`, optional): Whether audio codecs may use voice activity detection. + options (:obj:`webrtc.RTCOfferOptions`, optional): How to create the offer. Returns: :obj:`webrtc.RTCSessionDescriptionInit`: The offer, to set with :meth:`set_local_description`. @@ -332,32 +329,38 @@ async def create_offer( """ async with self._operation(): self._check_state('create an offer', RTCSignalingState.stable, RTCSignalingState.have_local_offer) - self._apply_legacy_offer_option(MediaType.audio, receive=offer_to_receive_audio) - self._apply_legacy_offer_option(MediaType.video, receive=offer_to_receive_video) + options = options if options is not None else RTCOfferOptions() + self._apply_legacy_offer_option(MediaType.audio, receive=options.offer_to_receive_audio) + self._apply_legacy_offer_option(MediaType.video, receive=options.offer_to_receive_video) await later() - return _init_of(await call_native(self._native_obj.createOffer, ice_restart, voice_activity_detection)) + return _init_of(await call_native(self._native_obj.createOffer, options.ice_restart)) - async def create_answer(self, *, voice_activity_detection: bool = True) -> webrtc.RTCSessionDescriptionInit: + async def create_answer(self, options: webrtc.RTCAnswerOptions | None = None) -> webrtc.RTCSessionDescriptionInit: """Creates an SDP answer to an offer received from the remote peer. The answer contains information about any media already attached to the session, codecs and options supported by the machine, and any ICE candidates already gathered. Args: - voice_activity_detection (:obj:`bool`, optional): Whether audio codecs may use voice activity detection. + options (:obj:`webrtc.RTCAnswerOptions`, optional): How to create the answer. Returns: :obj:`webrtc.RTCSessionDescriptionInit`: The answer, to set with :meth:`set_local_description`. Raises: + TypeError: If the options aren't an :obj:`webrtc.RTCAnswerOptions`. webrtc.InvalidStateError: If the connection is closed or has no remote offer. """ + # the options have no members yet, only their type is checked + if options is not None and not isinstance(options, RTCAnswerOptions): + msg = f'options must be an RTCAnswerOptions, not {type(options).__name__}' + raise TypeError(msg) async with self._operation(): self._check_state( 'create an answer', RTCSignalingState.have_remote_offer, RTCSignalingState.have_local_pranswer ) await later() - return _init_of(await call_native(self._native_obj.createAnswer, voice_activity_detection)) + return _init_of(await call_native(self._native_obj.createAnswer)) async def set_local_description( self, description: _Description | RTCLocalSessionDescriptionInit | None = None @@ -387,7 +390,9 @@ async def set_local_description( await call_native(self._native_obj.setLocalDescription, init) self._completed_description() - async def set_remote_description(self, description: _Description) -> None: + async def set_remote_description( + self, description: webrtc.RTCSessionDescriptionInit | webrtc.RTCSessionDescription + ) -> None: """Sets the specified session description as the remote peer's current offer or answer. The description specifies the properties of the remote end of the connection, including the media format. @@ -395,9 +400,9 @@ async def set_remote_description(self, description: _Description) -> None: An offer set while there's a local offer rolls the local one back first. Args: - description (:obj:`webrtc.RTCSessionDescription`): The description received from the remote peer. - An :obj:`webrtc.RTCSessionDescriptionInit` is accepted too, like one from - :meth:`webrtc.RTCSessionDescriptionInit.from_json`. + description (:obj:`webrtc.RTCSessionDescriptionInit`): The description received from the remote peer, + like one from :meth:`webrtc.RTCSessionDescriptionInit.from_json`. An + :obj:`webrtc.RTCSessionDescription` is accepted too. Raises: webrtc.InvalidStateError: If the type doesn't match the signaling state, or the connection is closed. @@ -411,30 +416,20 @@ async def set_remote_description(self, description: _Description) -> None: await call_native(self._native_obj.setRemoteDescription, init) self._completed_description() - def add_track( - self, - track: webrtc.MediaStreamTrack, - stream: webrtc.MediaStream | list[webrtc.MediaStream] | None = None, - ) -> webrtc.RTCRtpSender: + def add_track(self, track: webrtc.MediaStreamTrack, *streams: webrtc.MediaStream) -> webrtc.RTCRtpSender: """Adds a new :obj:`webrtc.MediaStreamTrack` to the set of tracks which will be transmitted to the other peer. Args: track (:obj:`webrtc.MediaStreamTrack`): A :obj:`webrtc.MediaStreamTrack` object representing the media track to add to the peer connection. - stream (:obj:`webrtc.MediaStream` or :obj:`list` of :obj:`webrtc.MediaStream`, optional): One or more - local :obj:`webrtc.MediaStream` objects to which the track should be added. + *streams (:obj:`webrtc.MediaStream`): The local streams the remote peer receives the track in. Returns: :obj:`webrtc.RTCRtpSender`: The :obj:`webrtc.RTCRtpSender` object which will be used to transmit the media data. """ - if stream is None or (isinstance(stream, list) and len(stream) == 0): - sender = self._native_obj.addTrack(track._native_obj, None) - elif isinstance(stream, list): - native_objects = [s._native_obj for s in stream] - sender = self._native_obj.addTrack(track._native_obj, native_objects) - else: - sender = self._native_obj.addTrack(track._native_obj, stream._native_obj) + native_streams = [stream._native_obj for stream in streams] if len(streams) > 0 else None + sender = self._native_obj.addTrack(track._native_obj, native_streams) return webrtc.RTCRtpSender._wrap(sender) @@ -569,7 +564,7 @@ async def add_ice_candidate( 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 | None = None + self, label: str, data_channel_dict: webrtc.RTCDataChannelInit | None = None ) -> webrtc.RTCDataChannel: """Creates a channel to send messages to the remote peer, negotiated with the next offer. @@ -577,7 +572,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`, optional): How to create the channel. + data_channel_dict (:obj:`webrtc.RTCDataChannelInit`, optional): How to create the channel. Returns: :obj:`webrtc.RTCDataChannel`: The channel. @@ -588,7 +583,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. """ - init = options if options is not None else RTCDataChannelInit() + init = data_channel_dict if data_channel_dict is not None else RTCDataChannelInit() check_utf8_length('label', label) init._check() native = self._native_obj.createDataChannel( @@ -631,25 +626,24 @@ async def get_stats(self, selector: webrtc.MediaStreamTrack | None = None) -> we return RTCStatsReport._from_native(await call_native(self._native_obj.getStats), self.get_receivers()) @staticmethod - async def generate_certificate( - algorithm: AlgorithmIdentifier = 'ECDSA', expires: float | None = None - ) -> webrtc.RTCCertificate: - """Generates a certificate for :attr:`webrtc.RTCConfiguration.certificates`. - - The same as :meth:`webrtc.RTCCertificate.generate`. + async def generate_certificate(keygen_algorithm: AlgorithmIdentifier) -> webrtc.RTCCertificate: + """Generates a key and a self-signed certificate on a worker thread, for :attr:`RTCConfiguration.certificates`. Args: - 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. + keygen_algorithm (:obj:`str` or :obj:`webrtc.Algorithm`): A WebCrypto algorithm: ``'ECDSA'`` + (with the P-256 curve), an :obj:`webrtc.EcKeyGenParams`, or an :obj:`webrtc.RsaHashedKeyGenParams` + like ``RsaHashedKeyGenParams('RSASSA-PKCS1-v1_5', modulus_length=2048, + public_exponent=bytes([1, 0, 1]), hash='SHA-256')``. Its ``expires`` is in how many milliseconds + the certificate expires, at most a year (30 days by default). Returns: :obj:`webrtc.RTCCertificate`: The certificate. Raises: webrtc.NotSupportedError: If the algorithm isn't supported. - ValueError: If ``expires`` is negative. + TypeError: If ``expires`` isn't an unsigned 64-bit integer. """ - return await RTCCertificate.generate(algorithm, expires) + return await RTCCertificate._generate(keygen_algorithm) def get_configuration(self) -> webrtc.RTCConfiguration: """Returns the configuration of the connection, as it was last set. diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py index 6eddf9d..3299d9d 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py @@ -22,6 +22,7 @@ WebRTCObject, wrtc, ) +from webrtc.interfaces.rtc_rtp_sender import _native_transform from webrtc.utils.native_calls import call_native _SourceT = TypeVar('_SourceT', bound=RTCRtpContributingSource) @@ -50,6 +51,26 @@ def transport(self) -> webrtc.RTCDtlsTransport | None: """:obj:`webrtc.RTCDtlsTransport`, optional: The transport of the packets, :obj:`None` until there's one.""" return webrtc.RTCDtlsTransport._wrap_optional(self._native_obj.transport) + @property + def transform(self) -> webrtc.RTCRtpScriptTransform | webrtc.RTCRtpSFrameDecryptor | None: + """:obj:`webrtc.RTCRtpScriptTransform` or :obj:`webrtc.RTCRtpSFrameDecryptor`, optional: The frame transform. + + It transforms the encoded frames before they're decoded, :obj:`None` decodes them as they're received. + A transform is used by one sender or receiver only: a transform that had one can't be set again. + + Raises: + TypeError: If the value set isn't a transform of a receiver. + webrtc.InvalidStateError: If the transform set had a sender or receiver. + """ + native = self._native_obj.transform + if isinstance(native, wrtc.SFrameTransform): + return webrtc.RTCRtpSFrameDecryptor._wrap(native) + return webrtc.RTCRtpScriptTransform._of_native(native) + + @transform.setter + def transform(self, transform: webrtc.RTCRtpScriptTransform | webrtc.RTCRtpSFrameDecryptor | None) -> None: + self._native_obj.transform = _native_transform(transform, webrtc.RTCRtpSFrameDecryptor) + @property def jitter_buffer_target(self) -> float | None: """:obj:`float`, optional: How many milliseconds of media the receiver should buffer (0 to 4000). diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_script_transform.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_script_transform.py new file mode 100644 index 0000000..49e66c2 --- /dev/null +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_script_transform.py @@ -0,0 +1,358 @@ +# +# 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. +# + +"""RTCRtpScriptTransform of WebRTC Encoded Transform: the encoded frames of a sender or receiver, in Python. + +Python has no Workers: the worker of a transform is a function called on the event loop the transform was created on. +""" + +from __future__ import annotations + +import asyncio +import inspect +import re +import weakref +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, NamedTuple, Union, cast + +from typing_extensions import override + +from webrtc import ( + DataCloneError, + InvalidStateError, + NotAllowedError, + NotFoundError, + RTCEncodedAudioFrame, + RTCEncodedVideoFrame, + RTCRtpScriptTransformType, + RTCTransformEvent, + WebRTCObject, + wrtc, +) +from webrtc.enums import EncodedVideoChunkType +from webrtc.models.dictionary import Dictionary +from webrtc.streams import QueuingStrategy, ReadableStream, WritableStream, _handled +from webrtc.utils.events import EventTarget, _handler_tasks + +if TYPE_CHECKING: + from webrtc.enums import RTCRtpScriptTransformTypeValue + from webrtc.streams import ReadableStreamDefaultController, WritableStreamDefaultController + +#: The function a transform calls with its ``rtctransform`` event +Worker = Callable[[RTCTransformEvent], object] +EncodedFrame = Union[RTCEncodedVideoFrame, RTCEncodedAudioFrame] + +# a rid of RFC 8851: alphanumeric, at most 255 characters +_RID = re.compile(r'[A-Za-z0-9]{1,255}') + +# the transforms of the native ones: once disassociated, a native transform lets go of its transformer +_transforms: weakref.WeakValueDictionary[int, RTCRtpScriptTransform] = weakref.WeakValueDictionary() + +_DISASSOCIATED = 2 +_KEY_FRAME_INVALID_STATE, _KEY_FRAME_NOT_FOUND = 1, 2 + + +@dataclass +class WorkerAndParameters(Dictionary): + """The worker of an :obj:`RTCRtpScriptTransform` and how the transform packetizes frames. + + Args: + worker (:obj:`callable`): The function called with the ``rtctransform`` event, a coroutine function too. + type (:obj:`webrtc.RTCRtpScriptTransformType`, optional): ``'sframe'`` if the worker outputs SFrame-encrypted + frames. + + Raises: + TypeError: If the worker isn't callable. + ValueError: If the type isn't a member of :obj:`webrtc.RTCRtpScriptTransformType`. + """ + + worker: Worker + type: RTCRtpScriptTransformType | RTCRtpScriptTransformTypeValue | None = None + + def __post_init__(self) -> None: + if not callable(self.worker): + msg = f'worker must be callable, not {type(self.worker).__name__}' + raise TypeError(msg) + if self.type is not None: + self.type = RTCRtpScriptTransformType(self.type) + + +class _KeyFrameRequest(NamedTuple): + rid: str | None + future: asyncio.Future[None] + + +class _FrameSource: + """Takes frames from the native queue only for pending reads, so that queue drops the oldest when full.""" + + def __init__(self, transformer: RTCRtpScriptTransformer) -> None: + self._transformer = transformer + self._controller: ReadableStreamDefaultController[EncodedFrame] | None = None + + def start(self, controller: ReadableStreamDefaultController[EncodedFrame]) -> None: + self._controller = controller + + def pull(self, _controller: ReadableStreamDefaultController[EncodedFrame]) -> None: + self._transformer._deliver() + + +class _FrameSink: + def __init__(self, transformer: RTCRtpScriptTransformer) -> None: + self._transformer = transformer + + def write(self, chunk: object, _controller: WritableStreamDefaultController[EncodedFrame]) -> None: + self._transformer._write(chunk) + + +class RTCRtpScriptTransformer(EventTarget): + """The encoded frames of the sender or receiver of an :obj:`RTCRtpScriptTransform`, as streams. + + The worker of the transform gets it with the ``rtctransform`` event. Frames are read from :attr:`readable` and + written to :attr:`writable` to be sent (or decoded), changed or not; frames that aren't written are dropped. + Frames are queued until they're read, up to 120, then the oldest one is dropped. + + Events: + ``keyframerequest`` (:obj:`webrtc.KeyFrameRequestEvent`): the remote peer asked for a key frame. + """ + + _events = ('keyframerequest',) + + def __init__(self, transform: RTCRtpScriptTransform, options: object) -> None: + self._transform = transform + self._options = options + self._source = _FrameSource(self) + self._readable: ReadableStream[EncodedFrame] = ReadableStream(self._source, QueuingStrategy(high_water_mark=0)) + self._writable: WritableStream[EncodedFrame] = WritableStream( + _FrameSink(self), QueuingStrategy(high_water_mark=float('inf')) + ) + self._last_enqueued = 0 + self._last_received = 0 + self._key_frame_requests: list[_KeyFrameRequest] = [] + self._ended = False + + @property + @override + def _native_obj(self) -> wrtc.RTCRtpScriptTransform: + return self._transform._native_obj + + @override + def _on_event(self, name: str, *_args: object) -> None: + if name == '_ready': + self._native_obj._ackWakeup() + self._deliver() + + def _deliver(self) -> None: + native = self._native_obj + stream = self._readable + controller = self._source._controller + if controller is None: + return + owner = native.sourceId + while stream._state == 'readable' and stream._reader is not None and len(stream._reader._read_requests) > 0: + item = native.read() + if item is None: + break + self._last_enqueued += 1 + cls = RTCEncodedVideoFrame if item.video else RTCEncodedAudioFrame + frame = cls._from_native(item, owner, self._last_enqueued) + controller.enqueue(frame) + # after the read it fulfills, so whoever awaits generate_key_frame() finds the frame read + if isinstance(frame, RTCEncodedVideoFrame) and frame.type == EncodedVideoChunkType.key: + self._key_frame_produced(frame._rid) + if native.state == _DISASSOCIATED and not self._ended: + self._end() + + def _end(self) -> None: + self._ended = True + error = InvalidStateError('The transform was removed from its sender or receiver') + _ = _handled(self._readable._cancel(error)) + _ = _handled(self._writable._abort(error)) + + def _write(self, chunk: object) -> None: + if not isinstance(chunk, (RTCEncodedVideoFrame, RTCEncodedAudioFrame)): + msg = f'the writable stream takes RTCEncodedVideoFrame and RTCEncodedAudioFrame, not {type(chunk).__name__}' + raise TypeError(msg) + # a frame of another sender or receiver, a constructed one, or one out of order is dropped + if chunk._owner == 0 or chunk._owner != self._native_obj.sourceId: + return + if chunk._counter <= self._last_received: + return + self._last_received = chunk._counter + native, payload = chunk._detach() + if native is not None: + _ = self._native_obj.write(native, payload) + + def _key_frame_produced(self, rid: str | None) -> None: + remaining: list[_KeyFrameRequest] = [] + for request in self._key_frame_requests: + if request.future.done(): + continue + if request.rid is None or rid is None or request.rid == rid: + request.future.set_result(None) + else: + remaining.append(request) + self._key_frame_requests = remaining + + @property + def readable(self) -> ReadableStream[EncodedFrame]: + """:obj:`webrtc.ReadableStream`: The frames of the sender or receiver, encoded video or audio frames.""" + return self._readable + + @property + def writable(self) -> WritableStream[EncodedFrame]: + """:obj:`webrtc.WritableStream`: Takes the frames read back, to be sent or decoded. + + Frames of another transformer, copies and frames written out of order (or twice) are dropped. Anything else + than a frame errors the stream with :obj:`TypeError`. + """ + return self._writable + + @property + def options(self) -> object: + """The options of the transform, as given to it.""" + return self._options + + async def generate_key_frame(self, rid: str | None = None) -> None: + """Asks the encoder of the sender for a key frame, done once one of the layer is read from :attr:`readable`. + + Args: + rid (:obj:`str`, optional): The simulcast layer, any if omitted. + + Raises: + webrtc.InvalidStateError: If the transform isn't of a video sender. + webrtc.NotAllowedError: If the rid isn't alphanumeric, or longer than 255 characters. + webrtc.NotFoundError: If the sender has no layer of that rid. + """ + kind = self._native_obj.sourceKind + if kind is None or not kind[0] or not kind[1]: + msg = 'generate_key_frame() is for the transform of a video sender' + raise InvalidStateError(msg) + if rid is not None and _RID.fullmatch(rid) is None: + msg = f'{rid!r} is not a valid rid' + raise NotAllowedError(msg) + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + result = self._native_obj.generateKeyFrame(rid) + if result == _KEY_FRAME_INVALID_STATE: + msg = 'generate_key_frame() is for the transform of a video sender' + raise InvalidStateError(msg) + if result == _KEY_FRAME_NOT_FOUND: + msg = f'The sender has no layer of rid {rid!r}' + raise NotFoundError(msg) + self._key_frame_requests.append(_KeyFrameRequest(rid, future)) + await future + + async def send_key_frame_request(self) -> None: + """Asks the remote sender for a key frame (a picture loss indication). + + Raises: + webrtc.InvalidStateError: If the transform isn't of a video receiver. + """ + if not self._native_obj.sendKeyFrameRequest(): + msg = 'send_key_frame_request() is for the transform of a video receiver' + raise InvalidStateError(msg) + + #: Alias for :meth:`generate_key_frame` + generateKeyFrame = generate_key_frame + #: Alias for :meth:`send_key_frame_request` + sendKeyFrameRequest = send_key_frame_request + + +class RTCRtpScriptTransform(WebRTCObject[wrtc.RTCRtpScriptTransform]): + """Transforms the encoded frames of a sender or receiver in Python, set as its ``transform``. + + The worker is called on the event loop the transform is created on, with an :obj:`webrtc.RTCTransformEvent` + whose ``transformer`` reads and writes the frames. A coroutine function runs as a task of that loop. + A transform is used by one sender or receiver only. + + Args: + worker_or_worker_and_parameters (:obj:`callable` or :obj:`WorkerAndParameters`): The worker. + options (optional): Any value, the ``options`` of the transformer. + transfer (:obj:`list`, optional): Objects given up to the transformer. Python can't detach them, they're + only checked to be listed once. + + Raises: + TypeError: If the worker isn't callable, or ``transfer`` isn't a sequence. + webrtc.DataCloneError: If an object is in ``transfer`` more than once. + RuntimeError: If called outside of a running event loop. + + Example:: + + async def worker(event): + transformer = event.transformer + async for frame in transformer.readable: + frame.data = encrypt(frame.data) + await transformer.writable.get_writer().write(frame) + + + sender.transform = webrtc.RTCRtpScriptTransform(worker) + """ + + _class = wrtc.RTCRtpScriptTransform + + def __init__( + self, + worker_or_worker_and_parameters: Worker | WorkerAndParameters, + options: object = None, + transfer: Iterable[object] | None = None, + ) -> None: + self._type: RTCRtpScriptTransformType | None = None + if isinstance(worker_or_worker_and_parameters, WorkerAndParameters): + worker = worker_or_worker_and_parameters.worker + self._type = cast('RTCRtpScriptTransformType | None', worker_or_worker_and_parameters.type) + else: + worker = worker_or_worker_and_parameters + if not callable(worker): + msg = f'the worker must be callable, not {type(worker).__name__}' + raise TypeError(msg) + _check_transfer(transfer) + loop = asyncio.get_running_loop() + super().__init__() + self._transformer = RTCRtpScriptTransformer(self, options) + self._transformer._attach() + # the native object, and so its id, lives as long as this one + _transforms[id(self._native_obj)] = self + _ = loop.call_soon(self._fire, worker, loop) + + def _fire(self, worker: Worker, loop: asyncio.AbstractEventLoop) -> None: + event = RTCTransformEvent('rtctransform', self._transformer) + try: + result = worker(event) + if inspect.isawaitable(result): + task = asyncio.ensure_future(result, loop=loop) + _handler_tasks.add(task) + task.add_done_callback(_handler_tasks.discard) + except Exception as e: # ruff: ignore[blind-except] # reported like the exception of a handler + loop.call_exception_handler({ + 'message': 'Exception in the worker of an RTCRtpScriptTransform', + 'exception': e, + 'event': event, + }) + + @classmethod + def _of_native(cls, native: wrtc._RtpTransform | None) -> RTCRtpScriptTransform | None: + """The transform of a native one, or a new wrapper once that's gone.""" + if not isinstance(native, wrtc.RTCRtpScriptTransform): + return None + transform = _transforms.get(id(native)) + if transform is not None and transform._native_obj is native: + return transform + return cls._wrap(native) + + +def _check_transfer(transfer: Iterable[object] | None) -> None: + if transfer is None: + return + if isinstance(transfer, (str, bytes, bytearray, memoryview)) or not isinstance(transfer, Iterable): + msg = 'transfer is a sequence of objects' + raise TypeError(msg) + seen: list[object] = [] + for item in transfer: + if any(item is other for other in seen): + msg = 'An object is in transfer more than once' + raise DataCloneError(msg) + seen.append(item) diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py index 241f276..579fb69 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py @@ -55,8 +55,28 @@ def dtmf(self) -> webrtc.RTCDTMFSender | None: return webrtc.RTCDTMFSender._wrap_optional(self._native_obj.dtmf) @property - def kind(self) -> webrtc.MediaType: - """:obj:`webrtc.MediaType`: The kind of media the sender sends, audio or video.""" + def transform(self) -> webrtc.RTCRtpScriptTransform | webrtc.RTCRtpSFrameEncryptor | None: + """:obj:`webrtc.RTCRtpScriptTransform` or :obj:`webrtc.RTCRtpSFrameEncryptor`, optional: The frame transform. + + It transforms the encoded frames before they're sent, :obj:`None` sends them as they're encoded. + A transform is used by one sender or receiver only: a transform that had one can't be set again. + + Raises: + TypeError: If the value set isn't a transform of a sender. + webrtc.InvalidStateError: If the transform set had a sender or receiver. + """ + native = self._native_obj.transform + if isinstance(native, wrtc.SFrameTransform): + return webrtc.RTCRtpSFrameEncryptor._wrap(native) + return webrtc.RTCRtpScriptTransform._of_native(native) + + @transform.setter + def transform(self, transform: webrtc.RTCRtpScriptTransform | webrtc.RTCRtpSFrameEncryptor | None) -> None: + self._native_obj.transform = _native_transform(transform, webrtc.RTCRtpSFrameEncryptor) + + @property + def _kind(self) -> webrtc.MediaType: + # the kind of the native object, which the receiver's track has too return self._native_obj.kind def get_parameters(self) -> webrtc.RTCRtpSendParameters: @@ -69,28 +89,30 @@ def get_parameters(self) -> webrtc.RTCRtpSendParameters: :obj:`webrtc.RTCRtpSendParameters`: The parameters, with a new ``transaction_id``. """ parameters = RTCRtpSendParameters._from_native(self._native_obj.getParameters()) - if self.kind == MediaType.video: + if self._kind == MediaType.video: _default_scale_resolution_down_by(parameters.encodings) # they expire when the current task (with the code it resumed) is over, never without a loop _ = TaskQueue.post_to_running(self._native_obj._expireParameters, parameters.transaction_id, after_ready=True) return parameters async def set_parameters( - self, parameters: webrtc.RTCRtpSendParameters, *, key_frames: Sequence[bool] | None = None + self, + parameters: webrtc.RTCRtpSendParameters, + set_parameter_options: webrtc.RTCSetParameterOptions | None = None, ) -> None: """Changes how the sender sends: its encodings and degradation preference. Args: parameters (:obj:`webrtc.RTCRtpSendParameters`): The parameters :meth:`get_parameters` returned in the current task, modified. - key_frames (:obj:`list` of :obj:`bool`, optional): For each encoding, whether it sends a key frame - right away. + set_parameter_options (:obj:`webrtc.RTCSetParameterOptions`, optional): How to change the encodings, + like sending a key frame right away. Raises: webrtc.InvalidStateError: If :meth:`get_parameters` wasn't called in the current task. webrtc.InvalidModificationError: If the ``transaction_id``, the codecs, the header extensions, the RTCP parameters, the number of encodings or their ``rid`` changed, the codec of an encoding - isn't negotiated, or ``key_frames`` isn't one per encoding. + isn't negotiated, or ``encoding_options`` aren't one per encoding. webrtc.InvalidRangeError: If a value is out of range, like ``scale_resolution_down_by`` below 1. """ if self._native_obj._transceiverStopped(): @@ -100,41 +122,42 @@ async def set_parameters( if last is None: msg = 'get_parameters() must be called before set_parameters(), in the same task' raise InvalidStateError(msg) - _check_unchanged(parameters, RTCRtpSendParameters._from_native(last), key_frames) - if self.kind == MediaType.video: + options = set_parameter_options.encoding_options if set_parameter_options is not None else None + _check_unchanged(parameters, RTCRtpSendParameters._from_native(last), options) + if self._kind == MediaType.video: _check_video_ranges(parameters.encodings) - kind = self.kind + kind = self._kind # a copy (pybind returns one): changed, then set back encodings = last.encodings for native, encoding in zip(encodings, parameters.encodings): _ = encoding._for_kind(kind)._apply(native) - for native, key_frame in zip(encodings, key_frames if key_frames is not None else ()): - native.requestKeyFrame = bool(key_frame) + for native, option in zip(encodings, options if options is not None else ()): + native.requestKeyFrame = bool(option.key_frame) last.encodings = encodings last.degradationPreference = parameters.degradation_preference await call_native(self._native_obj.setParameters, last) - async def replace_track(self, track: webrtc.MediaStreamTrack | None) -> None: + async def replace_track(self, with_track: webrtc.MediaStreamTrack | None) -> None: """Replaces the track the sender sends, without negotiation. The track is replaced in the operations chain of the connection, after the operations started before (like setting a description), and not before the code that called it runs on. Args: - track (:obj:`webrtc.MediaStreamTrack`, optional): The new track, of the same kind, or :obj:`None` + with_track (:obj:`webrtc.MediaStreamTrack`, optional): The new track, of the same kind, or :obj:`None` to stop sending. Raises: TypeError: If the track is of another kind. webrtc.InvalidStateError: If the transceiver of the sender is stopped, or the connection closed. """ - if track is not None and track.kind != self.kind: - msg = f'a {track.kind} track can not replace the track of a {self.kind} sender' + if with_track is not None and with_track.kind != self._kind: + msg = f'a {with_track.kind} track can not replace the track of a {self._kind} sender' raise TypeError(msg) def replace() -> None: - native_track = track._native_obj if track is not None else None + native_track = with_track._native_obj if with_track is not None else None if self._native_obj._transceiverStopped() or not self._native_obj.replaceTrack(native_track): msg = 'The track of a stopped sender can not be replaced' raise InvalidStateError(msg) @@ -198,15 +221,30 @@ async def get_stats(self) -> webrtc.RTCStatsReport: getCapabilities = get_capabilities +def _native_transform( + transform: webrtc.RTCRtpScriptTransform | webrtc.RTCRtpSFrameEncryptor | webrtc.RTCRtpSFrameDecryptor | None, + sframe: type[webrtc.RTCRtpSFrameEncryptor | webrtc.RTCRtpSFrameDecryptor], +) -> wrtc._RtpTransform | None: + """The native transform of the transform attribute of a sender or receiver, whose SFrame transform is given.""" + if transform is None: + return None + if not isinstance(transform, (webrtc.RTCRtpScriptTransform, sframe)): + msg = ( + f'transform must be an RTCRtpScriptTransform, an {sframe.__name__} or None, not {type(transform).__name__}' + ) + raise TypeError(msg) + return transform._native_obj + + def _check_unchanged( parameters: webrtc.RTCRtpSendParameters, returned: webrtc.RTCRtpSendParameters, - key_frames: Sequence[bool] | None, + encoding_options: Sequence[webrtc.RTCEncodingOptions] | None, ) -> None: """Checks what set_parameters() can't change against the parameters get_parameters() returned last. Raises: - webrtc.InvalidModificationError: If something changed, or ``key_frames`` isn't one per encoding. + webrtc.InvalidModificationError: If something changed, or ``encoding_options`` aren't one per encoding. """ if parameters.transaction_id != returned.transaction_id: msg = "The transaction_id doesn't match the one of the last get_parameters()" @@ -218,8 +256,8 @@ def _check_unchanged( if [e.rid for e in parameters.encodings] != [e.rid for e in returned.encodings]: msg = 'The number of encodings and their rid can not be changed' raise InvalidModificationError(msg) - if key_frames is not None and len(key_frames) != len(parameters.encodings): - msg = 'key_frames must have one value per encoding' + if encoding_options is not None and len(encoding_options) != len(parameters.encodings): + msg = 'encoding_options must have one value per encoding' raise InvalidModificationError(msg) diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_transceiver.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_transceiver.py index 638b9c8..000f45c 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_transceiver.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_transceiver.py @@ -42,13 +42,13 @@ def stopped(self) -> bool: """:obj:`bool`: Whether both the :attr:`sender` and the :attr:`receiver` stopped for good. Warning: - Deprecated: This feature is no longer recommended. + Deprecated: compare :attr:`current_direction` with :obj:`webrtc.RTCRtpTransceiverDirection.stopped`. """ return self._native_obj.stopped @property - def direction(self) -> webrtc.TransceiverDirection: - """A member of :obj:`webrtc.TransceiverDirection` enum, indicating the transceiver's preferred direction. + def direction(self) -> webrtc.RTCRtpTransceiverDirection: + """A member of :obj:`webrtc.RTCRtpTransceiverDirection` enum, indicating the transceiver's preferred direction. Note: The transceiver's current direction is indicated by the :attr:`currentDirection` property. @@ -56,26 +56,23 @@ def direction(self) -> webrtc.TransceiverDirection: return self._native_obj.direction @direction.setter - def direction(self, new_direction: webrtc.TransceiverDirection | webrtc.TransceiverDirectionValue) -> None: + def direction( + self, new_direction: webrtc.RTCRtpTransceiverDirection | webrtc.RTCRtpTransceiverDirectionValue + ) -> None: self._native_obj.direction = new_direction @property - def current_direction(self) -> webrtc.TransceiverDirection | None: - """:obj:`webrtc.TransceiverDirection`, optional: The negotiated direction of the transceiver.""" + def current_direction(self) -> webrtc.RTCRtpTransceiverDirection | None: + """:obj:`webrtc.RTCRtpTransceiverDirection`, optional: The negotiated direction of the transceiver.""" return self._native_obj.currentDirection def stop(self) -> None: - """Stops the transceiver for good, its :obj:`webrtc.RTCRtpSender` and its :obj:`webrtc.RTCRtpReceiver`. - - Note: - To check whether the transceiver is stopped, compare :attr:`currentDirection` with - :obj:`webrtc.TransceiverDirection.stopped` rather than reading the deprecated :attr:`stopped`. - """ + """Stops the transceiver for good, its :obj:`webrtc.RTCRtpSender` and its :obj:`webrtc.RTCRtpReceiver`.""" self._native_obj.stop() @property - def kind(self) -> webrtc.MediaType: - """:obj:`webrtc.MediaType`: The kind of media the transceiver sends and receives, audio or video.""" + def _kind(self) -> webrtc.MediaType: + # the kind of the native object, which the receiver's track has too return self._native_obj.kind def set_codec_preferences(self, codecs: list[webrtc.RTCRtpCodec]) -> None: @@ -90,7 +87,7 @@ def set_codec_preferences(self, codecs: list[webrtc.RTCRtpCodec]) -> None: webrtc.InvalidModificationError: If a codec isn't supported, or only resiliency codecs (like RTX or FEC) are given. """ - kind = self.kind + kind = self._kind natives: list[wrtc.RtpCodecCapability] = [] for source in (wrtc.RTCRtpReceiver.getCapabilities(kind), wrtc.RTCRtpSender.getCapabilities(kind)): natives.extend(source.codecs if source is not None else []) @@ -109,7 +106,7 @@ def get_header_extensions_to_negotiate(self) -> list[webrtc.RTCRtpHeaderExtensio Returns: :obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionCapability`: The extensions, with the direction they're - negotiated in, :attr:`webrtc.TransceiverDirection.stopped` for the ones that aren't. + negotiated in, :attr:`webrtc.RTCRtpTransceiverDirection.stopped` for the ones that aren't. """ return [ RTCRtpHeaderExtensionCapability._from_native(e) for e in self._native_obj.getHeaderExtensionsToNegotiate() @@ -146,7 +143,7 @@ def get_negotiated_header_extensions(self) -> list[webrtc.RTCRtpHeaderExtensionC Returns: :obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionCapability`: Every extension that can be negotiated, - :attr:`webrtc.TransceiverDirection.stopped` for the ones that weren't. + :attr:`webrtc.RTCRtpTransceiverDirection.stopped` for the ones that weren't. """ return [ RTCRtpHeaderExtensionCapability._from_native(e) for e in self._native_obj.getNegotiatedHeaderExtensions() diff --git a/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py index b0682eb..56e11c2 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py @@ -33,7 +33,7 @@ class RTCSctpTransport(WebRTCObject[wrtc.RTCSctpTransport], EventTarget): @override def _on_event(self, name: str, *args: object) -> None: - (state,) = cast('tuple[webrtc.SctpTransportState]', args) + (state,) = cast('tuple[webrtc.RTCSctpTransportState]', args) # the state changes along with its event self._native_obj._surfaceState(state) @@ -43,8 +43,8 @@ def transport(self) -> webrtc.RTCDtlsTransport: return webrtc.RTCDtlsTransport._wrap(self._native_obj.transport) @property - def state(self) -> webrtc.SctpTransportState: - """:obj:`webrtc.SctpTransportState`: An enumerated value indicating the state of the SCTP transport.""" + def state(self) -> webrtc.RTCSctpTransportState: + """:obj:`webrtc.RTCSctpTransportState`: An enumerated value indicating the state of the SCTP transport.""" return self._native_obj.state @property diff --git a/python-webrtc/python/webrtc/interfaces/sframe_transform.py b/python-webrtc/python/webrtc/interfaces/sframe_transform.py new file mode 100644 index 0000000..0726c06 --- /dev/null +++ b/python-webrtc/python/webrtc/interfaces/sframe_transform.py @@ -0,0 +1,331 @@ +# +# 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. +# + +"""SFrame (RFC 9605) of WebRTC Encoded Transform: end-to-end encryption of the encoded frames. + +Keys are the raw bytes of the base keys of RFC 9605 (Python has no ``CryptoKey``), each with its key id. Frames are +encrypted whole ("per-frame"), so codecs whose RTP packetization parses the payload (H.264, AV1) don't go through: +VP8, VP9 and Opus do. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Generic, Union, cast + +from typing_extensions import Buffer, TypeVar + +from webrtc import ( + InvalidModificationError, + InvalidRangeError, + NotSupportedError, + RTCEncodedAudioFrame, + RTCEncodedVideoFrame, + SFrameTransformErrorEvent, + SFrameTransformErrorEventInit, + WebRTCObject, + wrtc, +) +from webrtc.enums import SFrameCipherSuite, SFrameTransformErrorEventType, SFrameType +from webrtc.models.sframe_transform_options import RTCRtpSFrameEncryptorOptions, SFrameTransformOptions +from webrtc.streams import ReadableStream, TransformStream, WritableStream +from webrtc.utils.events import EventTarget + +if TYPE_CHECKING: + from webrtc.streams import TransformStreamDefaultController + +#: What the SFrame streams take: a frame, or any buffer +SFrameInput = Union[RTCEncodedVideoFrame, RTCEncodedAudioFrame, Buffer] +#: What the SFrame streams output: the frame, or the bytes of a buffer +SFrameChunk = Union[RTCEncodedVideoFrame, RTCEncodedAudioFrame, bytes] + +_C = TypeVar('_C', bound=SFrameChunk, default=Union[RTCEncodedVideoFrame, RTCEncodedAudioFrame]) + +# the identifiers of the cipher suites, of RFC 9605 Section 8.1 and draft-barnes-sframe-iana-256 +_CIPHER_SUITE_IDS = {suite: index + 1 for index, suite in enumerate(SFrameCipherSuite)} +_ERROR_TYPES = { + 1: SFrameTransformErrorEventType.authentication, + 2: SFrameTransformErrorEventType.key_id, + 3: SFrameTransformErrorEventType.syntax, +} +_MAX_KEY_ID = 2**64 - 1 + + +def _key(key: Buffer) -> bytes: + try: + return bytes(memoryview(key)) + except TypeError: + msg = f'key must be the bytes of the key, not {type(key).__name__}' + raise TypeError(msg) from None + + +def _key_id(key_id: int) -> int: + if isinstance(key_id, bool) or not isinstance(key_id, int): + msg = f'key_id must be an int, not {type(key_id).__name__}' + raise TypeError(msg) + if not 0 <= key_id <= _MAX_KEY_ID: + msg = 'Not a 64 bits integer' + raise InvalidRangeError(msg) + return key_id + + +def _buffer(chunk: object) -> memoryview: + try: + return memoryview(cast('Buffer', chunk)) + except TypeError: + msg = f'An SFrame stream takes encoded frames and buffers, not {type(chunk).__name__}' + raise TypeError(msg) from None + + +def _native(options: SFrameTransformOptions, *, encrypting: bool) -> wrtc.SFrameTransform: + if not isinstance(options, SFrameTransformOptions): + msg = f'options must be an SFrameTransformOptions, not {type(options).__name__}' + raise TypeError(msg) + return wrtc.SFrameTransform(_CIPHER_SUITE_IDS[SFrameCipherSuite(options.cipher_suite)], encrypting) + + +def _error_event(name: str, *args: object) -> SFrameTransformErrorEvent: + error, key_id, frame = args + if isinstance(frame, wrtc.RTCEncodedFrame): + # a frame libwebrtc gave, of no transformer + cls = RTCEncodedVideoFrame if frame.video else RTCEncodedAudioFrame + frame = cls._from_native(frame, 0, 0) + if not isinstance(frame, (RTCEncodedVideoFrame, RTCEncodedAudioFrame, bytes)): + msg = f'An SFrame error is of a frame or bytes, not {type(frame).__name__}' + raise TypeError(msg) + error_type = _ERROR_TYPES[cast('int', error)] + is_key_id = error_type == SFrameTransformErrorEventType.key_id + init = SFrameTransformErrorEventInit(error_type, frame, cast('int | None', key_id) if is_key_id else None) + return SFrameTransformErrorEvent(name, init) + + +class _SFrameEncryptorManager: + if TYPE_CHECKING: + + @property + def _native_obj(self) -> wrtc.SFrameTransform: ... + + async def set_encryption_key(self, key: Buffer, key_id: int) -> None: + """Encrypts with a key from now on, in place of the previous one. + + The counter of the encryptor goes on, so a key set again is never used with a counter twice. + + Args: + key (:obj:`bytes`): The base key of RFC 9605, any buffer. + key_id (:obj:`int`): The key id frames are sent with, from 0 to 2**64 - 1. + + Raises: + TypeError: If the key isn't a buffer, or the key id isn't an :obj:`int`. + webrtc.InvalidRangeError: If the key id doesn't fit 64 bits. + webrtc.InvalidModificationError: If the key can't be derived. + """ + raw, key_id = _key(key), _key_id(key_id) + if not self._native_obj.setEncryptionKey(raw, key_id): + msg = 'The key can not be derived' + raise InvalidModificationError(msg) + + #: Alias for :meth:`set_encryption_key` + setEncryptionKey = set_encryption_key + + +class _SFrameDecryptorManager: + if TYPE_CHECKING: + + @property + def _native_obj(self) -> wrtc.SFrameTransform: ... + + _events: tuple[str, ...] = ('error',) + + async def add_decryption_key(self, key: Buffer, key_id: int) -> None: + """Decrypts the frames of a key id with a key, in place of the previous key of that id. + + Args: + key (:obj:`bytes`): The base key of RFC 9605, any buffer. + key_id (:obj:`int`): The key id, from 0 to 2**64 - 1. + + Raises: + TypeError: If the key isn't a buffer, or the key id isn't an :obj:`int`. + webrtc.InvalidRangeError: If the key id doesn't fit 64 bits. + webrtc.InvalidModificationError: If the key can't be derived. + """ + raw, key_id = _key(key), _key_id(key_id) + if not self._native_obj.addDecryptionKey(raw, key_id): + msg = 'The key can not be derived' + raise InvalidModificationError(msg) + + async def remove_decryption_key(self, key_id: int) -> None: + """Stops decrypting the frames of a key id: they're ``keyID`` errors from now on. + + Args: + key_id (:obj:`int`): The key id, from 0 to 2**64 - 1. + + Raises: + TypeError: If the key id isn't an :obj:`int`. + webrtc.InvalidRangeError: If the key id doesn't fit 64 bits. + """ + self._native_obj.removeDecryptionKey(_key_id(key_id)) + + @staticmethod + def _create_event(name: str, *args: object) -> SFrameTransformErrorEvent: + return _error_event(name, *args) + + #: Alias for :meth:`add_decryption_key` + addDecryptionKey = add_decryption_key + #: Alias for :meth:`remove_decryption_key` + removeDecryptionKey = remove_decryption_key + + +class RTCRtpSFrameEncryptor(_SFrameEncryptorManager, WebRTCObject[wrtc.SFrameTransform]): + """Encrypts the frames of a sender with SFrame, set as its ``transform``. + + Frames are encrypted on the threads of libwebrtc. Until a key is set, frames are dropped rather than sent in clear. + + Args: + options (:obj:`webrtc.RTCRtpSFrameEncryptorOptions`): The cipher suite. + + Raises: + TypeError: If the options aren't :obj:`webrtc.RTCRtpSFrameEncryptorOptions`. + webrtc.NotSupportedError: For the ``'per-packet'`` type, as libwebrtc has no SFrame packetization. + + Example:: + + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions('AES_128_GCM_SHA256_128')) + await encryptor.set_encryption_key(key, 1) + sender.transform = encryptor + """ + + def __init__(self, options: RTCRtpSFrameEncryptorOptions) -> None: + native = _native(options, encrypting=True) + if SFrameType(getattr(options, 'type', SFrameType.per_frame)) == SFrameType.per_packet: + msg = 'per-packet SFrame needs the SFrame packetization of RTP, which libwebrtc lacks' + raise NotSupportedError(msg) + super().__init__(native) + + +class RTCRtpSFrameDecryptor(_SFrameDecryptorManager, WebRTCObject[wrtc.SFrameTransform], EventTarget): + """Decrypts the SFrame frames of a receiver, set as its ``transform``. + + Frames are decrypted on the threads of libwebrtc. Frames that don't decrypt are dropped, each reported by an + ``error`` event. + + Args: + options (:obj:`webrtc.SFrameTransformOptions`): The cipher suite. + + Raises: + TypeError: If the options aren't :obj:`webrtc.SFrameTransformOptions`. + + Events: + ``error`` (:obj:`webrtc.SFrameTransformErrorEvent`): a frame didn't decrypt. + """ + + def __init__(self, options: SFrameTransformOptions) -> None: + super().__init__(_native(options, encrypting=False)) + self._attach() + + +class _SFrameStreamTransformer(Generic[_C]): + def __init__(self, stream: _SFrameStream[_C], *, encrypting: bool) -> None: + self._stream = stream + self._encrypting = encrypting + + def transform(self, chunk: object, controller: TransformStreamDefaultController[SFrameInput, SFrameChunk]) -> None: + frame = chunk if isinstance(chunk, (RTCEncodedVideoFrame, RTCEncodedAudioFrame)) else None + data = frame.data if frame is not None else _buffer(chunk) + out = self._encrypt(data) if self._encrypting else self._decrypt(data, frame) + if out is None: + return + if frame is not None: + frame.data = bytearray(out) + controller.enqueue(frame) + else: + controller.enqueue(out) + + def _encrypt(self, data: Buffer) -> bytes | None: + return self._stream._native_obj.encrypt(data) + + def _decrypt(self, data: Buffer, frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame | None) -> bytes | None: + out, error, key_id = self._stream._native_obj.decrypt(data) + stream = self._stream + if out is None and isinstance(stream, SFrameDecryptorStream): + # the event is queued, like a task + reported = frame if frame is not None else bytes(data) + _ = asyncio.get_running_loop().call_soon(stream._dispatch, 'error', error, key_id, reported) + return out + + +class _SFrameStream(WebRTCObject[wrtc.SFrameTransform], Generic[_C]): + """A GenericTransformStream of SFrame: frames or buffers written to :attr:`writable` are read transformed. + + Typed by its chunks: encoded frames by default, like the streams of a transformer it's piped between; ``bytes`` + (``SFrameEncryptorStream[bytes]``) for buffers, or :obj:`SFrameChunk` for both. + """ + + def __init__(self, native: wrtc.SFrameTransform, *, encrypting: bool) -> None: + super().__init__(native) + self._transform = TransformStream[SFrameInput, SFrameChunk]( + _SFrameStreamTransformer(self, encrypting=encrypting) + ) + + @property + def readable(self) -> ReadableStream[_C]: + """:obj:`webrtc.ReadableStream`: The transformed chunks: the frames written, or :obj:`bytes`.""" + return cast('ReadableStream[_C]', self._transform.readable) + + @property + def writable(self) -> WritableStream[_C | Buffer]: + """:obj:`webrtc.WritableStream`: The chunks to transform, encoded frames or buffers. + + Takes :obj:`webrtc.RTCEncodedVideoFrame`, :obj:`webrtc.RTCEncodedAudioFrame` or any buffer. Anything else + errors the stream with :obj:`TypeError`. + """ + return self._transform.writable + + +class SFrameEncryptorStream(_SFrameEncryptorManager, _SFrameStream[_C]): + """Encrypts the frames or buffers written to it with SFrame, like a :obj:`webrtc.TransformStream`. + + A frame is read back with its data encrypted, a buffer as the :obj:`bytes` of the SFrame ciphertext. Until a key + is set, chunks are dropped. + + Args: + options (:obj:`webrtc.SFrameTransformOptions`): The cipher suite. + + Raises: + TypeError: If the options aren't :obj:`webrtc.SFrameTransformOptions`. + + Example:: + + async def worker(event): + encryptor = webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions('AES_128_GCM_SHA256_128')) + await encryptor.set_encryption_key(key, 1) + transformer = event.transformer + await transformer.readable.pipe_through(encryptor).pipe_to(transformer.writable) + """ + + def __init__(self, options: SFrameTransformOptions) -> None: + super().__init__(_native(options, encrypting=True), encrypting=True) + + +class SFrameDecryptorStream(_SFrameDecryptorManager, _SFrameStream[_C], EventTarget): + """Decrypts the SFrame frames or buffers written to it, like a :obj:`webrtc.TransformStream`. + + A frame is read back with its data decrypted, a buffer as the :obj:`bytes` of the plaintext. Chunks that don't + decrypt are dropped, each reported by an ``error`` event. + + Args: + options (:obj:`webrtc.SFrameTransformOptions`): The cipher suite. + + Raises: + TypeError: If the options aren't :obj:`webrtc.SFrameTransformOptions`. + + Events: + ``error`` (:obj:`webrtc.SFrameTransformErrorEvent`): a chunk didn't decrypt. + """ + + def __init__(self, options: SFrameTransformOptions) -> None: + super().__init__(_native(options, encrypting=False), encrypting=False) + self._attach() diff --git a/python-webrtc/python/webrtc/interfaces/track_generator.py b/python-webrtc/python/webrtc/interfaces/track_generator.py index a94f7f7..0c751a3 100644 --- a/python-webrtc/python/webrtc/interfaces/track_generator.py +++ b/python-webrtc/python/webrtc/interfaces/track_generator.py @@ -55,7 +55,7 @@ def _write_audio(self, data: object) -> None: audio = data._take() data_bytes = audio._data if audio.format == AudioSampleFormat.s16 and data_bytes is not None: - samples = data_bytes + samples = bytes(data_bytes) else: # closed, copy_to() raises buffer = bytearray(audio.number_of_frames * audio.number_of_channels * 2) diff --git a/python-webrtc/python/webrtc/models/audio_data.py b/python-webrtc/python/webrtc/models/audio_data.py index ece2e0b..4dff33a 100644 --- a/python-webrtc/python/webrtc/models/audio_data.py +++ b/python-webrtc/python/webrtc/models/audio_data.py @@ -11,7 +11,7 @@ import math import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, NamedTuple, cast from webrtc import ( @@ -25,9 +25,10 @@ from webrtc.models.closable import Closable from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias +from webrtc.utils.transfer import Transfer if TYPE_CHECKING: - from typing_extensions import Buffer + from typing_extensions import Buffer, TypeGuard _SAMPLE_BYTES = {'u8': 1, 's16': 2, 's32': 4, 'f32': 4} @@ -58,7 +59,10 @@ class AudioDataInit(Dictionary): number_of_frames (:obj:`int`): The number of frames (samples per channel). number_of_channels (:obj:`int`): The number of channels. timestamp (:obj:`int`): The presentation time in microseconds. - data: A bytes-like buffer of the samples, which is copied. + data: A bytes-like buffer of the samples, which is copied unless transferred. + transfer (:obj:`list` of bytes-like buffers, optional): Buffers given up to the data: if the memory of + ``data`` is among them, the samples are kept without being copied, so the buffer must not be changed + afterwards. Transferred :obj:`memoryview` objects are released; Python can't detach other buffers. """ format: AudioSampleFormat | AudioSampleFormatValue @@ -67,6 +71,7 @@ class AudioDataInit(Dictionary): number_of_channels: int timestamp: int data: Buffer + transfer: list[Buffer] = field(default_factory=list) #: Alias for :attr:`sample_rate` sampleRate: ClassVar[Alias[float]] = alias('sample_rate') @@ -114,8 +119,16 @@ def _sample_rate(value: object) -> float: return float(value) -def _buffer(data: object, size: int) -> bytes: - """The first bytes of a bytes-like buffer.""" +def _is_buffer(data: object) -> TypeGuard[Buffer]: + try: + _ = memoryview(cast('Buffer', data)) + except TypeError: + return False + return True + + +def _buffer(data: object, size: int, *, keep: bool) -> bytes | memoryview: + """The first bytes of a bytes-like buffer, copied or a read-only view of them.""" try: # memoryview() is the check of the buffer protocol view = memoryview(cast('Buffer', data)).cast('B') @@ -125,7 +138,7 @@ def _buffer(data: object, size: int) -> bytes: if view.nbytes < size: msg = f'data must be at least {size} bytes for this format and size' raise TypeError(msg) - return bytes(view[:size]) + return view[:size].toreadonly() if keep else bytes(view[:size]) class _Layout(NamedTuple): @@ -136,7 +149,7 @@ class _Layout(NamedTuple): class _CopyPlan(NamedTuple): - data: bytes + data: bytes | memoryview format: AudioSampleFormat plane_index: int frame_offset: int @@ -169,7 +182,7 @@ class AudioData(Closable): ) """ - _data: bytes | None + _data: bytes | memoryview | None _format: AudioSampleFormat _sample_rate: float _frames: int @@ -189,10 +202,13 @@ def __init__(self, init: AudioDataInit) -> None: if isinstance(init.timestamp, bool) or not isinstance(init.timestamp, int): msg = 'The timestamp is an integer of microseconds' raise TypeError(msg) - data = _buffer(init.data, frames * channels * _sample_bytes(format)) + transfer = Transfer(init.transfer) + size = frames * channels * _sample_bytes(format) + data = _buffer(init.data, size, keep=_is_buffer(init.data) and transfer.has(init.data)) + transfer.detach() self._set(data, _Layout(format, sample_rate, frames, channels), init.timestamp) - def _set(self, data: bytes, layout: _Layout, timestamp: int) -> None: + def _set(self, data: bytes | memoryview, layout: _Layout, timestamp: int) -> None: self._data = data self._format, self._sample_rate, self._frames, self._channels = layout self._timestamp = timestamp diff --git a/python-webrtc/python/webrtc/models/blob.py b/python-webrtc/python/webrtc/models/blob.py index 61bffd5..a6f718e 100644 --- a/python-webrtc/python/webrtc/models/blob.py +++ b/python-webrtc/python/webrtc/models/blob.py @@ -13,18 +13,29 @@ from __future__ import annotations import asyncio +import codecs +import os +from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar, Union +import webrtc # the streams come after the models in the package +from webrtc import EndingType, EndingTypeValue +from webrtc.models.dictionary import Dictionary + if TYPE_CHECKING: import builtins from collections.abc import Iterable + from webrtc.streams import ReadableStream, ReadableStreamDefaultController + BlobPart = Union[str, bytes, bytearray, memoryview, 'Blob'] _T = TypeVar('_T') # the printable ASCII range of a MIME type _MIN_TYPE_CHAR = 0x20 _MAX_TYPE_CHAR = 0x7E +# the bytes a stream of a blob reads at once +_CHUNK_SIZE = 65536 def _done(value: _T) -> asyncio.Future[_T]: @@ -33,27 +44,82 @@ def _done(value: _T) -> asyncio.Future[_T]: return future +def _content_type(value: str) -> str: + """The type of a blob: lowercased, or empty if it has characters outside of U+0020-U+007E.""" + value = str(value) + return value.lower() if all(_MIN_TYPE_CHAR <= ord(c) <= _MAX_TYPE_CHAR for c in value) else '' + + +def _native_endings(text: str) -> str: + """Converts every CR LF, CR and LF to the line ending of the platform.""" + return text.replace('\r\n', '\n').replace('\r', '\n').replace('\n', os.linesep) + + +def _decode(data: builtins.bytes) -> str: + # UTF-8 decode: a leading BOM is dropped and invalid bytes become U+FFFD + return data.decode('utf-8-sig', 'replace') + + +@dataclass +class BlobPropertyBag(Dictionary): + """How to create a :obj:`Blob`. + + Args: + type (:obj:`str`, optional): The MIME type, lowercased; empty if it has characters outside of U+0020-U+007E. + endings (:obj:`webrtc.EndingType`, optional): How the line endings of the string parts are written. + """ + + type: str = '' + endings: EndingType | EndingTypeValue = EndingType.transparent + + +class _Source: + """The underlying source of a stream of a blob, which reads it a chunk at a time.""" + + def __init__(self, data: builtins.bytes, *, text: bool) -> None: + self._data = data + self._offset = 0 + self._decoder = codecs.getincrementaldecoder('utf-8-sig')('replace') if text else None + + def pull(self, controller: ReadableStreamDefaultController[bytes | str]) -> None: + chunk = self._data[self._offset : self._offset + _CHUNK_SIZE] + self._offset += len(chunk) + final = self._offset >= len(self._data) + if self._decoder is None: + if len(chunk) > 0: + controller.enqueue(chunk) + else: + text = self._decoder.decode(chunk, final=final) + if text != '': + controller.enqueue(text) + if final: + controller.close() + + class Blob: """Immutable bytes with a MIME type. Args: - parts (iterable, optional): Strings (encoded as UTF-8), bytes-like objects and blobs, concatenated. - type (:obj:`str`, optional): The MIME type, lowercased; empty if it has characters outside of U+0020-U+007E. + blob_parts (iterable, optional): Strings (encoded as UTF-8), bytes-like objects and blobs, concatenated. + options (:obj:`BlobPropertyBag`, optional): The MIME type, and how the line endings of strings are written. """ - def __init__(self, parts: Iterable[BlobPart] | None = None, type: str = '') -> None: - chunks: list[bytes] = [] - for part in parts if parts is not None else (): + def __init__(self, blob_parts: Iterable[BlobPart] | None = None, options: BlobPropertyBag | None = None) -> None: + if options is None: + options = BlobPropertyBag() + native = EndingType(options.endings) == EndingType.native + chunks: list[builtins.bytes] = [] + for part in blob_parts if blob_parts is not None else (): if isinstance(part, Blob): chunks.append(part._bytes) elif isinstance(part, str): # lone surrogates become U+FFFD - chunks.append(part.encode('utf-8', 'surrogatepass').decode('utf-8', 'replace').encode()) + text = part.encode('utf-8', 'surrogatepass').decode('utf-8', 'replace') + chunks.append((_native_endings(text) if native else text).encode()) else: chunks.append(bytes(memoryview(part))) self._bytes = b''.join(chunks) - type = str(type) - self._type = type.lower() if all(_MIN_TYPE_CHAR <= ord(c) <= _MAX_TYPE_CHAR for c in type) else '' + self._type = _content_type(options.type) @property def size(self) -> int: @@ -76,7 +142,7 @@ def slice(self, start: int = 0, end: int | None = None, content_type: str = '') size = len(self._bytes) start = max(size + start, 0) if start < 0 else min(start, size) end = size if end is None else (max(size + end, 0) if end < 0 else min(end, size)) - return Blob([self._bytes[start : max(start, end)]], content_type) + return Blob([self._bytes[start : max(start, end)]], BlobPropertyBag(type=content_type)) def array_buffer(self) -> asyncio.Future[bytes]: """Returns a future of the bytes, as :obj:`bytes`.""" @@ -88,7 +154,15 @@ def bytes(self) -> asyncio.Future[bytes]: def text(self) -> asyncio.Future[str]: """Returns a future of the bytes decoded as UTF-8.""" - return _done(self._bytes.decode('utf-8', 'replace')) + return _done(_decode(self._bytes)) + + def stream(self) -> ReadableStream[builtins.bytes]: + """Returns a :obj:`webrtc.ReadableStream` of the bytes, in :obj:`bytes` chunks.""" + return webrtc.ReadableStream(_Source(self._bytes, text=False)) + + def text_stream(self) -> ReadableStream[str]: + """Returns a :obj:`webrtc.ReadableStream` of the bytes decoded as UTF-8, in :obj:`str` chunks.""" + return webrtc.ReadableStream(_Source(self._bytes, text=True)) # the bytes() method hides the builtin in the class def __bytes__(self) -> builtins.bytes: @@ -110,3 +184,5 @@ def __repr__(self) -> str: #: Alias for :meth:`array_buffer` arrayBuffer = array_buffer + #: Alias for :meth:`text_stream` + textStream = text_stream diff --git a/python-webrtc/python/webrtc/models/dictionary.py b/python-webrtc/python/webrtc/models/dictionary.py index 069c50d..ba924dd 100644 --- a/python-webrtc/python/webrtc/models/dictionary.py +++ b/python-webrtc/python/webrtc/models/dictionary.py @@ -9,11 +9,12 @@ from __future__ import annotations +import enum from collections.abc import Mapping from dataclasses import fields from typing import TYPE_CHECKING, ClassVar -from webrtc.utils.names import members +from webrtc.utils.names import camel_case, members if TYPE_CHECKING: from dataclasses import Field @@ -55,6 +56,22 @@ def from_json(cls, value: Mapping[str, object]) -> Self: kwargs[name] = dictionary._from_json_member(kwargs[name]) return cls(**kwargs) + def to_json(self) -> dict[str, object]: + """The JSON form of the dictionary, like a message to the remote peer. + + Keys are the camelCase names of the specification, members that are :obj:`None` are left out, enums are + their values, and nested dictionaries are converted too. + + Returns: + :obj:`dict`: The JSON form. + """ + json: dict[str, object] = {} + for field in fields(self): + value: object = getattr(self, field.name) + if value is not None: + json[camel_case(field.name)] = _json_value(value) + return json + @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.""" @@ -63,3 +80,14 @@ def _from_json_member(cls, value: object) -> object: if isinstance(value, list): return [cls.from_json(item) if isinstance(item, Mapping) else item for item in value] return value + + +def _json_value(value: object) -> object: + if isinstance(value, Dictionary): + return value.to_json() + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (list, tuple)): + items: list[object] | tuple[object, ...] = value + return [_json_value(item) for item in items] + return value diff --git a/python-webrtc/python/webrtc/models/events.py b/python-webrtc/python/webrtc/models/events.py index dfd9256..436f363 100644 --- a/python-webrtc/python/webrtc/models/events.py +++ b/python-webrtc/python/webrtc/models/events.py @@ -9,13 +9,16 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar +from webrtc.enums import SFrameTransformErrorEventType +from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias if TYPE_CHECKING: import webrtc + from webrtc.enums import SFrameTransformErrorEventTypeValue class Event: @@ -23,56 +26,96 @@ class Event: Args: type (:obj:`str`): The name of the event, like ``'signalingstatechange'``. - target (:obj:`object`, optional): The object that emitted the event. + + Attributes: + target (:obj:`object`): The object that emitted the event, :obj:`None` until it's dispatched. """ - def __init__(self, type: str, target: webrtc.EventTarget | None = None) -> None: + def __init__(self, type: str) -> None: self.type = type - self.target = target + self.target: webrtc.EventTarget | None = None def __repr__(self) -> str: fields = ', '.join(f'{k}={v!r}' for k, v in vars(self).items() if k != 'target') return f'{type(self).__name__}({fields})' -@dataclass(eq=False, repr=False) -class RTCPeerConnectionIceEvent(Event): - """An ``icecandidate`` event of :obj:`webrtc.RTCPeerConnection`. +@dataclass +class RTCPeerConnectionIceEventInit(Dictionary): + """The members of a :obj:`RTCPeerConnectionIceEvent`. Args: - type (:obj:`str`): The name of the event. candidate (:obj:`webrtc.RTCIceCandidate`, optional): The new candidate, :obj:`None` at the end of candidates. url (:obj:`str`, optional): The URL of the STUN or TURN server that gathered the candidate. - target (:obj:`object`, optional): The object that emitted the event. """ - type: str candidate: webrtc.RTCIceCandidate | None = None url: str | None = None - target: webrtc.EventTarget | None = None -@dataclass(eq=False, repr=False) -class RTCPeerConnectionIceErrorEvent(Event): - """An ``icecandidateerror`` event of :obj:`webrtc.RTCPeerConnection`: a STUN or TURN server failed. +class RTCPeerConnectionIceEvent(Event): + """An ``icecandidate`` event of :obj:`webrtc.RTCPeerConnection`. Args: type (:obj:`str`): The name of the event. + event_init_dict (:obj:`RTCPeerConnectionIceEventInit`, optional): The members of the event. + """ + + candidate: webrtc.RTCIceCandidate | None + url: str | None + + def __init__(self, type: str, event_init_dict: RTCPeerConnectionIceEventInit | None = None) -> None: + super().__init__(type) + init = event_init_dict if event_init_dict is not None else RTCPeerConnectionIceEventInit() + self.candidate = init.candidate + self.url = init.url + + +@dataclass +class RTCPeerConnectionIceErrorEventInit(Dictionary): + """The members of a :obj:`RTCPeerConnectionIceErrorEvent`. + + Args: + error_code (:obj:`int`): The STUN error code, or 701 if the server couldn't be reached. address (:obj:`str`, optional): The local address used to reach the server. port (:obj:`int`, optional): The local port used to reach the server. - url (:obj:`str`): The URL of the server. - error_code (:obj:`int`): The STUN error code, or 701 if the server couldn't be reached. - error_text (:obj:`str`): The STUN reason text. - target (:obj:`object`, optional): The object that emitted the event. + url (:obj:`str`, optional): The URL of the server. + error_text (:obj:`str`, optional): The STUN reason text. + """ + + error_code: int + address: str | None = None + port: int | None = None + url: str = '' + error_text: str = '' + + #: Alias for :attr:`error_code` + errorCode: ClassVar[Alias[int]] = alias('error_code') + #: Alias for :attr:`error_text` + errorText: ClassVar[Alias[str]] = alias('error_text') + + +class RTCPeerConnectionIceErrorEvent(Event): + """An ``icecandidateerror`` event of :obj:`webrtc.RTCPeerConnection`: a STUN or TURN server failed. + + Args: + type (:obj:`str`): The name of the event. + event_init_dict (:obj:`RTCPeerConnectionIceErrorEventInit`): The members of the event. """ - type: str address: str | None port: int | None url: str error_code: int error_text: str - target: webrtc.EventTarget | None = None + + def __init__(self, type: str, event_init_dict: RTCPeerConnectionIceErrorEventInit) -> None: + super().__init__(type) + self.address = event_init_dict.address + self.port = event_init_dict.port + self.url = event_init_dict.url + self.error_code = event_init_dict.error_code + self.error_text = event_init_dict.error_text #: Alias for :attr:`error_code` errorCode: ClassVar[Alias[int]] = alias('error_code') @@ -80,7 +123,6 @@ class RTCPeerConnectionIceErrorEvent(Event): errorText: ClassVar[Alias[str]] = alias('error_text') -@dataclass(eq=False, repr=False) class MessageEvent(Event): """A ``message`` event of :obj:`webrtc.RTCDataChannel`. @@ -88,90 +130,266 @@ class MessageEvent(Event): type (:obj:`str`): The name of the event. data (:obj:`str`, :obj:`bytes` or :obj:`webrtc.Blob`): The message, :obj:`bytes` (or a :obj:`webrtc.Blob` with the ``blob`` binary type) if it was sent as binary. - target (:obj:`object`, optional): The object that emitted the event. """ - type: str data: str | bytes | webrtc.Blob - target: webrtc.EventTarget | None = None + + def __init__(self, type: str, data: str | bytes | webrtc.Blob) -> None: + super().__init__(type) + self.data = data + + +@dataclass +class RTCDataChannelEventInit(Dictionary): + """The members of a :obj:`RTCDataChannelEvent`. + + Args: + channel (:obj:`webrtc.RTCDataChannel`): The new channel. + """ + + channel: webrtc.RTCDataChannel -@dataclass(eq=False, repr=False) class RTCDataChannelEvent(Event): """A ``datachannel`` event of :obj:`webrtc.RTCPeerConnection`: the remote peer created a channel. Args: type (:obj:`str`): The name of the event. - channel (:obj:`webrtc.RTCDataChannel`): The new channel. - target (:obj:`object`, optional): The object that emitted the event. + event_init_dict (:obj:`RTCDataChannelEventInit`): The members of the event. """ - type: str channel: webrtc.RTCDataChannel - target: webrtc.EventTarget | None = None + + def __init__(self, type: str, event_init_dict: RTCDataChannelEventInit) -> None: + super().__init__(type) + self.channel = event_init_dict.channel + + +@dataclass +class MediaStreamTrackEventInit(Dictionary): + """The members of a :obj:`MediaStreamTrackEvent`. + + Args: + track (:obj:`webrtc.MediaStreamTrack`): The track added or removed. + """ + + track: webrtc.MediaStreamTrack -@dataclass(eq=False, repr=False) class MediaStreamTrackEvent(Event): """An ``addtrack`` or ``removetrack`` event of :obj:`webrtc.MediaStream`. Args: type (:obj:`str`): The name of the event. - track (:obj:`webrtc.MediaStreamTrack`): The track added or removed. - target (:obj:`object`, optional): The object that emitted the event. + event_init_dict (:obj:`MediaStreamTrackEventInit`): The members of the event. """ - type: str track: webrtc.MediaStreamTrack - target: webrtc.EventTarget | None = None + + def __init__(self, type: str, event_init_dict: MediaStreamTrackEventInit) -> None: + super().__init__(type) + self.track = event_init_dict.track + + +@dataclass +class RTCDTMFToneChangeEventInit(Dictionary): + """The members of a :obj:`RTCDTMFToneChangeEvent`. + + Args: + tone (:obj:`str`, optional): The tone that started playing, empty when all tones were played. + """ + + tone: str = '' -@dataclass(eq=False, repr=False) class RTCDTMFToneChangeEvent(Event): """A ``tonechange`` event of :obj:`webrtc.RTCDTMFSender`. Args: type (:obj:`str`): The name of the event. - tone (:obj:`str`): The tone that started playing, empty when all tones were played. - target (:obj:`object`, optional): The object that emitted the event. + event_init_dict (:obj:`RTCDTMFToneChangeEventInit`, optional): The members of the event. """ - type: str - tone: str = '' - target: webrtc.EventTarget | None = None + tone: str + def __init__(self, type: str, event_init_dict: RTCDTMFToneChangeEventInit | None = None) -> None: + super().__init__(type) + self.tone = event_init_dict.tone if event_init_dict is not None else '' -@dataclass(eq=False, repr=False) -class RTCErrorEvent(Event): - """An ``error`` event, carrying the :obj:`webrtc.RTCError` that occurred. + +@dataclass +class DeviceChangeEventInit(Dictionary): + """The members of a :obj:`DeviceChangeEvent`. + + Args: + devices (:obj:`list` of :obj:`webrtc.MediaDeviceInfo`, optional): The devices after the change. + """ + + devices: list[webrtc.MediaDeviceInfo] = field(default_factory=list) + + +class DeviceChangeEvent(Event): + """A ``devicechange`` event of :obj:`webrtc.MediaDevices`. Args: type (:obj:`str`): The name of the event. + event_init_dict (:obj:`DeviceChangeEventInit`, optional): The members of the event. + """ + + devices: list[webrtc.MediaDeviceInfo] + user_inserted_devices: list[webrtc.MediaDeviceInfo] + + def __init__(self, type: str, event_init_dict: DeviceChangeEventInit | None = None) -> None: + super().__init__(type) + self.devices = list(event_init_dict.devices) if event_init_dict is not None else [] + self.user_inserted_devices = [] + + #: Alias for :attr:`user_inserted_devices` + userInsertedDevices: ClassVar[Alias[list[webrtc.MediaDeviceInfo]]] = alias('user_inserted_devices') + + +@dataclass +class RTCErrorEventInit(Dictionary): + """The members of a :obj:`RTCErrorEvent`. + + Args: error (:obj:`webrtc.RTCError`): The error. - target (:obj:`object`, optional): The object that emitted the event. """ - type: str error: webrtc.RTCError - target: webrtc.EventTarget | None = None -@dataclass(eq=False, repr=False) -class RTCTrackEvent(Event): - """A ``track`` event of :obj:`webrtc.RTCPeerConnection`: a remote track was negotiated. +class RTCErrorEvent(Event): + """An ``error`` event, carrying the :obj:`webrtc.RTCError` that occurred. Args: type (:obj:`str`): The name of the event. + event_init_dict (:obj:`RTCErrorEventInit`): The members of the event. + """ + + error: webrtc.RTCError + + def __init__(self, type: str, event_init_dict: RTCErrorEventInit) -> None: + super().__init__(type) + self.error = event_init_dict.error + + +@dataclass +class RTCTrackEventInit(Dictionary): + """The members of a :obj:`RTCTrackEvent`. + + Args: receiver (:obj:`webrtc.RTCRtpReceiver`): The receiver of the track. track (:obj:`webrtc.MediaStreamTrack`): The remote track. - streams (:obj:`list` of :obj:`webrtc.MediaStream`): The remote streams of the track. transceiver (:obj:`webrtc.RTCRtpTransceiver`): The transceiver of the receiver. - target (:obj:`object`, optional): The object that emitted the event. + streams (:obj:`list` of :obj:`webrtc.MediaStream`, optional): The remote streams of the track. + """ + + receiver: webrtc.RTCRtpReceiver + track: webrtc.MediaStreamTrack + transceiver: webrtc.RTCRtpTransceiver + streams: list[webrtc.MediaStream] = field(default_factory=list) + + +class RTCTrackEvent(Event): + """A ``track`` event of :obj:`webrtc.RTCPeerConnection`: a remote track was negotiated. + + Args: + type (:obj:`str`): The name of the event. + event_init_dict (:obj:`RTCTrackEventInit`): The members of the event. """ - type: str receiver: webrtc.RTCRtpReceiver track: webrtc.MediaStreamTrack streams: list[webrtc.MediaStream] transceiver: webrtc.RTCRtpTransceiver - target: webrtc.EventTarget | None = None + + def __init__(self, type: str, event_init_dict: RTCTrackEventInit) -> None: + super().__init__(type) + self.receiver = event_init_dict.receiver + self.track = event_init_dict.track + self.streams = list(event_init_dict.streams) + self.transceiver = event_init_dict.transceiver + + +class RTCTransformEvent(Event): + """The ``rtctransform`` event the worker of a :obj:`webrtc.RTCRtpScriptTransform` is called with. + + Args: + type (:obj:`str`): The name of the event. + transformer (:obj:`webrtc.RTCRtpScriptTransformer`): The transformer of the transform. + """ + + transformer: webrtc.RTCRtpScriptTransformer + + def __init__(self, type: str, transformer: webrtc.RTCRtpScriptTransformer) -> None: + super().__init__(type) + self.transformer = transformer + + +class KeyFrameRequestEvent(Event): + """A ``keyframerequest`` event of :obj:`webrtc.RTCRtpScriptTransformer`: the receiver asked for a key frame. + + Args: + type (:obj:`str`): The name of the event. + rid (:obj:`str`, optional): The ``rid`` of the layer the key frame is asked for, :obj:`None` for any. + """ + + rid: str | None + + def __init__(self, type: str, rid: str | None = None) -> None: + super().__init__(type) + self.rid = rid + + +@dataclass +class SFrameTransformErrorEventInit(Dictionary): + """The members of a :obj:`SFrameTransformErrorEvent`. + + Args: + error_type (:obj:`webrtc.SFrameTransformErrorEventType`): Why the frame didn't decrypt, or its value. + frame (:obj:`webrtc.RTCEncodedVideoFrame`, :obj:`webrtc.RTCEncodedAudioFrame` or :obj:`bytes`): The frame + that didn't decrypt, the chunk for a buffer written to an :obj:`webrtc.SFrameDecryptorStream`. + key_id (:obj:`int`, optional): The unknown key id, for a ``keyID`` error. + + Raises: + ValueError: If the error type isn't a member of :obj:`webrtc.SFrameTransformErrorEventType`. + """ + + error_type: SFrameTransformErrorEventType | SFrameTransformErrorEventTypeValue + frame: webrtc.RTCEncodedVideoFrame | webrtc.RTCEncodedAudioFrame | bytes + key_id: int | None = None + + def __post_init__(self) -> None: + self.error_type = SFrameTransformErrorEventType(self.error_type) + + #: Alias for :attr:`error_type` + errorType: ClassVar[Alias[SFrameTransformErrorEventType | SFrameTransformErrorEventTypeValue]] = alias('error_type') + #: Alias for :attr:`key_id` + keyID: ClassVar[Alias[int | None]] = alias('key_id') + + +class SFrameTransformErrorEvent(Event): + """An ``error`` event of an SFrame decryptor: a frame didn't decrypt, and was dropped. + + It's an event of :obj:`webrtc.RTCRtpSFrameDecryptor` and :obj:`webrtc.SFrameDecryptorStream`. + + Args: + type (:obj:`str`): The name of the event. + event_init_dict (:obj:`SFrameTransformErrorEventInit`): The members of the event. + """ + + error_type: SFrameTransformErrorEventType + key_id: int | None + frame: webrtc.RTCEncodedVideoFrame | webrtc.RTCEncodedAudioFrame | bytes + + def __init__(self, type: str, event_init_dict: SFrameTransformErrorEventInit) -> None: + super().__init__(type) + self.error_type = SFrameTransformErrorEventType(event_init_dict.error_type) + self.key_id = event_init_dict.key_id + self.frame = event_init_dict.frame + + #: Alias for :attr:`error_type` + errorType: ClassVar[Alias[SFrameTransformErrorEventType]] = alias('error_type') + #: Alias for :attr:`key_id` + keyID: ClassVar[Alias[int | None]] = alias('key_id') diff --git a/python-webrtc/python/webrtc/models/media_track_constraints.py b/python-webrtc/python/webrtc/models/media_track_constraints.py index 1493306..ac62ed1 100644 --- a/python-webrtc/python/webrtc/models/media_track_constraints.py +++ b/python-webrtc/python/webrtc/models/media_track_constraints.py @@ -126,6 +126,59 @@ class ConstrainBooleanOrDOMStringParameters(Dictionary): ConstrainBooleanOrDOMString = Union[bool, str, ConstrainBooleanOrDOMStringParameters] +@dataclass +class MediaTrackSupportedConstraints(Dictionary): + """The constraints the library recognizes, all of them (:meth:`webrtc.MediaDevices.get_supported_constraints`). + + Args: + width (:obj:`bool`, optional): :obj:`True`, like every other member. + """ + + width: bool = True + height: bool = True + aspect_ratio: bool = True + frame_rate: bool = True + facing_mode: bool = True + resize_mode: bool = True + sample_rate: bool = True + sample_size: bool = True + echo_cancellation: bool = True + auto_gain_control: bool = True + noise_suppression: bool = True + latency: bool = True + channel_count: bool = True + device_id: bool = True + group_id: bool = True + background_blur: bool = True + + #: Alias for :attr:`aspect_ratio` + aspectRatio: ClassVar[Alias[bool]] = alias('aspect_ratio') + #: Alias for :attr:`frame_rate` + frameRate: ClassVar[Alias[bool]] = alias('frame_rate') + #: Alias for :attr:`facing_mode` + facingMode: ClassVar[Alias[bool]] = alias('facing_mode') + #: Alias for :attr:`resize_mode` + resizeMode: ClassVar[Alias[bool]] = alias('resize_mode') + #: Alias for :attr:`sample_rate` + sampleRate: ClassVar[Alias[bool]] = alias('sample_rate') + #: Alias for :attr:`sample_size` + sampleSize: ClassVar[Alias[bool]] = alias('sample_size') + #: Alias for :attr:`echo_cancellation` + echoCancellation: ClassVar[Alias[bool]] = alias('echo_cancellation') + #: Alias for :attr:`auto_gain_control` + autoGainControl: ClassVar[Alias[bool]] = alias('auto_gain_control') + #: Alias for :attr:`noise_suppression` + noiseSuppression: ClassVar[Alias[bool]] = alias('noise_suppression') + #: Alias for :attr:`channel_count` + channelCount: ClassVar[Alias[bool]] = alias('channel_count') + #: Alias for :attr:`device_id` + deviceId: ClassVar[Alias[bool]] = alias('device_id') + #: Alias for :attr:`group_id` + groupId: ClassVar[Alias[bool]] = alias('group_id') + #: Alias for :attr:`background_blur` + backgroundBlur: ClassVar[Alias[bool]] = alias('background_blur') + + @dataclass class MediaTrackSettings(Dictionary): """What a track carries, as far as it's known (:meth:`webrtc.MediaStreamTrack.get_settings`). @@ -146,6 +199,9 @@ class MediaTrackSettings(Dictionary): echo_cancellation (:obj:`bool`, optional): Whether echo is cancelled. auto_gain_control (:obj:`bool`, optional): Whether the gain is controlled. noise_suppression (:obj:`bool`, optional): Whether noise is suppressed. + facing_mode (:obj:`str`, optional): Where the camera faces, like ``'user'``. + latency (:obj:`float`, optional): The latency of the audio in seconds. + background_blur (:obj:`bool`, optional): Whether the background is blurred. """ width: int | None = None @@ -158,9 +214,12 @@ class MediaTrackSettings(Dictionary): sample_rate: int | None = None sample_size: int | None = None channel_count: int | None = None - echo_cancellation: bool | None = None + echo_cancellation: bool | str | None = None auto_gain_control: bool | None = None noise_suppression: bool | None = None + facing_mode: str | None = None + latency: float | None = None + background_blur: bool | None = None #: Alias for :attr:`aspect_ratio` aspectRatio: ClassVar[Alias[float | None]] = alias('aspect_ratio') @@ -179,11 +238,15 @@ class MediaTrackSettings(Dictionary): #: Alias for :attr:`channel_count` channelCount: ClassVar[Alias[int | None]] = alias('channel_count') #: Alias for :attr:`echo_cancellation` - echoCancellation: ClassVar[Alias[bool | None]] = alias('echo_cancellation') + echoCancellation: ClassVar[Alias[bool | str | None]] = alias('echo_cancellation') #: Alias for :attr:`auto_gain_control` autoGainControl: ClassVar[Alias[bool | None]] = alias('auto_gain_control') #: Alias for :attr:`noise_suppression` noiseSuppression: ClassVar[Alias[bool | None]] = alias('noise_suppression') + #: Alias for :attr:`facing_mode` + facingMode: ClassVar[Alias[str | None]] = alias('facing_mode') + #: Alias for :attr:`background_blur` + backgroundBlur: ClassVar[Alias[bool | None]] = alias('background_blur') @dataclass @@ -207,6 +270,9 @@ class MediaTrackCapabilities(Dictionary): echo_cancellation (:obj:`list` of :obj:`bool`, optional): Whether echo can be cancelled. auto_gain_control (:obj:`list` of :obj:`bool`, optional): Whether the gain can be controlled. noise_suppression (:obj:`list` of :obj:`bool`, optional): Whether noise can be suppressed. + facing_mode (:obj:`list` of :obj:`str`, optional): Where the camera can face. + latency (:obj:`DoubleRange`, optional): The latencies of the audio in seconds. + background_blur (:obj:`list` of :obj:`bool`, optional): Whether the background can be blurred. """ width: ULongRange | None = None @@ -219,9 +285,12 @@ class MediaTrackCapabilities(Dictionary): sample_rate: ULongRange | None = None sample_size: ULongRange | None = None channel_count: ULongRange | None = None - echo_cancellation: list[bool] | None = None + echo_cancellation: list[bool | str] | None = None auto_gain_control: list[bool] | None = None noise_suppression: list[bool] | None = None + facing_mode: list[str] | None = None + latency: DoubleRange | None = None + background_blur: list[bool] | None = None #: Alias for :attr:`aspect_ratio` aspectRatio: ClassVar[Alias[DoubleRange | None]] = alias('aspect_ratio') @@ -240,11 +309,15 @@ class MediaTrackCapabilities(Dictionary): #: Alias for :attr:`channel_count` channelCount: ClassVar[Alias[ULongRange | None]] = alias('channel_count') #: Alias for :attr:`echo_cancellation` - echoCancellation: ClassVar[Alias[list[bool] | None]] = alias('echo_cancellation') + echoCancellation: ClassVar[Alias[list[bool | str] | None]] = alias('echo_cancellation') #: Alias for :attr:`auto_gain_control` autoGainControl: ClassVar[Alias[list[bool] | None]] = alias('auto_gain_control') #: Alias for :attr:`noise_suppression` noiseSuppression: ClassVar[Alias[list[bool] | None]] = alias('noise_suppression') + #: Alias for :attr:`facing_mode` + facingMode: ClassVar[Alias[list[str] | None]] = alias('facing_mode') + #: Alias for :attr:`background_blur` + backgroundBlur: ClassVar[Alias[list[bool] | None]] = alias('background_blur') @dataclass @@ -270,6 +343,10 @@ class MediaTrackConstraintSet(Dictionary): 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. + facing_mode (:obj:`str` or :obj:`ConstrainDOMStringParameters`, optional): Where the camera faces. + latency (:obj:`float` or :obj:`ConstrainDoubleRange`, optional): The latency of the audio in seconds. + background_blur (:obj:`bool` or :obj:`ConstrainBooleanParameters`, optional): Whether the background is + blurred. """ _dictionaries: ClassVar = { @@ -286,6 +363,9 @@ class MediaTrackConstraintSet(Dictionary): 'echo_cancellation': ConstrainBooleanOrDOMStringParameters, 'auto_gain_control': ConstrainBooleanParameters, 'noise_suppression': ConstrainBooleanParameters, + 'facing_mode': ConstrainDOMStringParameters, + 'latency': ConstrainDoubleRange, + 'background_blur': ConstrainBooleanParameters, } width: ConstrainULong | None = None @@ -301,6 +381,9 @@ class MediaTrackConstraintSet(Dictionary): echo_cancellation: ConstrainBooleanOrDOMString | None = None auto_gain_control: ConstrainBoolean | None = None noise_suppression: ConstrainBoolean | None = None + facing_mode: ConstrainDOMString | None = None + latency: ConstrainDouble | None = None + background_blur: ConstrainBoolean | None = None #: Alias for :attr:`aspect_ratio` aspectRatio: ClassVar[Alias[ConstrainDouble | None]] = alias('aspect_ratio') @@ -324,6 +407,10 @@ class MediaTrackConstraintSet(Dictionary): autoGainControl: ClassVar[Alias[ConstrainBoolean | None]] = alias('auto_gain_control') #: Alias for :attr:`noise_suppression` noiseSuppression: ClassVar[Alias[ConstrainBoolean | None]] = alias('noise_suppression') + #: Alias for :attr:`facing_mode` + facingMode: ClassVar[Alias[ConstrainDOMString | None]] = alias('facing_mode') + #: Alias for :attr:`background_blur` + backgroundBlur: ClassVar[Alias[ConstrainBoolean | None]] = alias('background_blur') @dataclass @@ -340,3 +427,20 @@ class MediaTrackConstraints(MediaTrackConstraintSet): _dictionaries: ClassVar = {**MediaTrackConstraintSet._dictionaries, 'advanced': MediaTrackConstraintSet} advanced: list[MediaTrackConstraintSet] | None = None + + +@dataclass +class MediaStreamConstraints(Dictionary): + """The tracks :meth:`webrtc.MediaDevices.get_user_media` returns. + + Args: + video (:obj:`bool` or :obj:`MediaTrackConstraints`, optional): Whether to get a video track, and its + constraints. + audio (:obj:`bool` or :obj:`MediaTrackConstraints`, optional): Whether to get an audio track, and its + constraints. + """ + + video: bool | MediaTrackConstraints = False + audio: bool | MediaTrackConstraints = False + + _dictionaries: ClassVar = {'video': MediaTrackConstraints, 'audio': MediaTrackConstraints} diff --git a/python-webrtc/python/webrtc/models/rtc_certificate.py b/python-webrtc/python/webrtc/models/rtc_certificate.py index 44253ae..b081ed7 100644 --- a/python-webrtc/python/webrtc/models/rtc_certificate.py +++ b/python-webrtc/python/webrtc/models/rtc_certificate.py @@ -21,46 +21,84 @@ @dataclass -class Algorithm(Dictionary): - """A WebCrypto algorithm, by its name. +class RTCCertificateExpiration(Dictionary): + """When a generated certificate expires, a member of the algorithm it's generated with. + + Args: + expires (:obj:`int`, optional): In how many milliseconds the certificate expires, at most a year + (the default is 30 days). + """ + + expires: int | None = None + + +# the members are required, but expires keyword-only after them, which dataclasses can't do before 3.10 +@dataclass(init=False) +class Algorithm(RTCCertificateExpiration): + """A WebCrypto algorithm, by its name, for :meth:`webrtc.RTCPeerConnection.generate_certificate`. Args: name (:obj:`str`): The name, like ``'ECDSA'``. + expires (:obj:`int`, optional): In how many milliseconds the certificate expires. """ name: str + def __init__(self, name: str, *, expires: int | None = None) -> None: + super().__init__(expires) + self.name = name -@dataclass + +@dataclass(init=False) class EcKeyGenParams(Algorithm): - """A WebCrypto algorithm of an elliptic curve key, for :meth:`webrtc.RTCCertificate.generate`. + """A WebCrypto algorithm of an elliptic curve key. Args: name (:obj:`str`): ``'ECDSA'``. named_curve (:obj:`str`): The curve, ``'P-256'`` as the only one supported. + expires (:obj:`int`, optional): In how many milliseconds the certificate expires. """ named_curve: str + def __init__(self, name: str, named_curve: str, *, expires: int | None = None) -> None: + super().__init__(name, expires=expires) + self.named_curve = named_curve + #: Alias for :attr:`named_curve` namedCurve: ClassVar[Alias[str]] = alias('named_curve') -@dataclass +@dataclass(init=False) class RsaHashedKeyGenParams(Algorithm): - """A WebCrypto algorithm of an RSA key, for :meth:`webrtc.RTCCertificate.generate`. + """A WebCrypto algorithm of an RSA key. 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. + expires (:obj:`int`, optional): In how many milliseconds the certificate expires. """ modulus_length: int public_exponent: bytes hash: str | Algorithm + def __init__( + self, + name: str, + *, + modulus_length: int, + public_exponent: bytes, + hash: str | Algorithm, + expires: int | None = None, + ) -> None: + super().__init__(name, expires=expires) + self.modulus_length = modulus_length + self.public_exponent = public_exponent + self.hash = hash + _dictionaries: ClassVar = {'hash': Algorithm} #: Alias for :attr:`modulus_length` @@ -78,12 +116,12 @@ class RTCDtlsFingerprint(Dictionary): """A fingerprint of a certificate, as in the ``a=fingerprint`` line of SDP. Args: - algorithm (:obj:`str`): The hash function, like ``'sha-256'``. - value (:obj:`str`): The hash in lowercase hex bytes separated with colons. + algorithm (:obj:`str`, optional): The hash function, like ``'sha-256'``. + value (:obj:`str`, optional): The hash in lowercase hex bytes separated with colons. """ - algorithm: str - value: str + algorithm: str | None = None + value: str | None = None _KeyParams = tuple[str, int, int] @@ -125,41 +163,23 @@ def _key_params(algorithm: AlgorithmIdentifier) -> _KeyParams: class RTCCertificate(WebRTCObject[wrtc.RTCCertificate]): """A certificate a connection uses to authenticate with DTLS. - Generated with :meth:`generate` and set with :attr:`webrtc.RTCConfiguration.certificates`. Without one, - a connection generates its own. + Generated with :meth:`webrtc.RTCPeerConnection.generate_certificate` and set with + :attr:`webrtc.RTCConfiguration.certificates`. Without one, a connection generates its own. """ _class = wrtc.RTCCertificate @classmethod - 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:`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). - - Returns: - :obj:`webrtc.RTCCertificate`: The certificate. - - Raises: - webrtc.NotSupportedError: If the algorithm isn't supported. - ValueError: If ``expires`` is negative. - """ + async def _generate(cls, algorithm: AlgorithmIdentifier) -> RTCCertificate: + # see RTCPeerConnection.generate_certificate key_type, modulus_length, exponent = _key_params(algorithm) - if expires is not None and expires < 0: - msg = f'expires must not be negative, not {expires}' - raise ValueError(msg) + expires = algorithm.expires if isinstance(algorithm, Algorithm) else None + valid = expires is None or (type(expires) is int and expires >= 0) + if not valid: + msg = f'expires must be an unsigned 64-bit integer, not {expires!r}' + raise TypeError(msg) native = await asyncio.get_running_loop().run_in_executor( - None, - wrtc.RTCCertificate.generate, - key_type, - modulus_length, - exponent, - int(expires) if expires is not None else None, + None, wrtc.RTCCertificate.generate, key_type, modulus_length, exponent, expires ) if native is None: msg = 'the key could not be generated with these parameters' @@ -171,9 +191,7 @@ def expires(self) -> float: """:obj:`float`: When the certificate expires, in milliseconds since the epoch.""" return float(self._native_obj.expires) - @property - def expired(self) -> bool: - """:obj:`bool`: Whether the certificate has expired.""" + def _expired(self) -> bool: return self.expires <= time.time() * 1000 def get_fingerprints(self) -> list[RTCDtlsFingerprint]: diff --git a/python-webrtc/python/webrtc/models/rtc_configuration.py b/python-webrtc/python/webrtc/models/rtc_configuration.py index 3588c53..a534ae6 100644 --- a/python-webrtc/python/webrtc/models/rtc_configuration.py +++ b/python-webrtc/python/webrtc/models/rtc_configuration.py @@ -169,6 +169,26 @@ def _password(self) -> str | None: credentialType: ClassVar[Alias[str]] = alias('credential_type') +@dataclass +class RTCIceGatherOptions(Dictionary): + """How a standalone :obj:`webrtc.RTCIceTransport` gathers candidates, for :meth:`webrtc.RTCIceTransport.gather`. + + 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. + """ + + gather_policy: RTCIceTransportPolicy | RTCIceTransportPolicyValue = RTCIceTransportPolicy.all + ice_servers: list[RTCIceServer] | None = None + + _dictionaries: ClassVar = {'ice_servers': RTCIceServer} + + #: Alias for :attr:`gather_policy` + gatherPolicy: ClassVar[Alias[RTCIceTransportPolicy | RTCIceTransportPolicyValue]] = alias('gather_policy') + #: Alias for :attr:`ice_servers` + iceServers: ClassVar[Alias[list[RTCIceServer] | None]] = alias('ice_servers') + + @dataclass class RTCConfiguration(Dictionary): """The configuration of a :obj:`webrtc.RTCPeerConnection`. @@ -235,7 +255,7 @@ def _to_native(self) -> wrtc.ConfigurationInit: if self.certificates is not None: for certificate in self.certificates: - if certificate.expired: + if certificate._expired(): msg = 'the certificate has expired' raise InvalidAccessError(msg) native.certificates = [certificate._native_obj for certificate in self.certificates] diff --git a/python-webrtc/python/webrtc/models/rtc_encoded_frame.py b/python-webrtc/python/webrtc/models/rtc_encoded_frame.py new file mode 100644 index 0000000..0b928ff --- /dev/null +++ b/python-webrtc/python/webrtc/models/rtc_encoded_frame.py @@ -0,0 +1,322 @@ +# +# 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 encoded frames an :obj:`webrtc.RTCRtpScriptTransformer` reads and writes, and their metadata.""" + +from __future__ import annotations + +import copy +import dataclasses +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar + +from typing_extensions import override + +from webrtc.enums import EncodedVideoChunkType +from webrtc.exceptions import DataCloneError +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias + +if TYPE_CHECKING: + from typing_extensions import Buffer, Self + + import wrtc + + +@dataclass +class RTCEncodedFrameMetadata(Dictionary): + """The metadata of an encoded frame. Members a frame doesn't have are :obj:`None`. + + Args: + synchronization_source (:obj:`int`, optional): The SSRC of the RTP stream of the frame. + payload_type (:obj:`int`, optional): The RTP payload type of the frame. + contributing_sources (:obj:`list` of :obj:`int`, optional): The CSRCs of the frame. + rtp_timestamp (:obj:`int`, optional): The RTP timestamp of the frame. + receive_time (:obj:`float`, optional): When the first packet of a received frame arrived, in milliseconds + since the Unix epoch. + capture_time (:obj:`float`, optional): When the frame was captured, in milliseconds since the Unix epoch + (of the remote clock, for a received frame). + sender_capture_time_offset (:obj:`float`, optional): The offset of the clock of the capturer from the clock + of the sender, in milliseconds, for a received frame. + mime_type (:obj:`str`, optional): The codec of the frame, like ``'video/VP8'``. + """ + + synchronization_source: int | None = None + payload_type: int | None = None + contributing_sources: list[int] | None = None + rtp_timestamp: int | None = None + receive_time: float | None = None + capture_time: float | None = None + sender_capture_time_offset: float | None = None + mime_type: str | None = None + + #: Alias for :attr:`synchronization_source` + synchronizationSource: ClassVar[Alias[int | None]] = alias('synchronization_source') + #: Alias for :attr:`payload_type` + payloadType: ClassVar[Alias[int | None]] = alias('payload_type') + #: Alias for :attr:`contributing_sources` + contributingSources: ClassVar[Alias[list[int] | None]] = alias('contributing_sources') + #: Alias for :attr:`rtp_timestamp` + rtpTimestamp: ClassVar[Alias[int | None]] = alias('rtp_timestamp') + #: Alias for :attr:`receive_time` + receiveTime: ClassVar[Alias[float | None]] = alias('receive_time') + #: Alias for :attr:`capture_time` + captureTime: ClassVar[Alias[float | None]] = alias('capture_time') + #: Alias for :attr:`sender_capture_time_offset` + senderCaptureTimeOffset: ClassVar[Alias[float | None]] = alias('sender_capture_time_offset') + #: Alias for :attr:`mime_type` + mimeType: ClassVar[Alias[str | None]] = alias('mime_type') + + +@dataclass +class RTCEncodedVideoFrameMetadata(RTCEncodedFrameMetadata): + """The metadata of an :obj:`RTCEncodedVideoFrame`. See :obj:`RTCEncodedFrameMetadata` for the common members. + + Args: + frame_id (:obj:`int`, optional): The identifier of the frame, which dependencies refer to. + dependencies (:obj:`list` of :obj:`int`, optional): The identifiers of the frames this one depends on. + width (:obj:`int`, optional): The width of the frame, in pixels. + height (:obj:`int`, optional): The height of the frame, in pixels. + spatial_index (:obj:`int`, optional): The spatial layer of the frame. + temporal_index (:obj:`int`, optional): The temporal layer of the frame. + timestamp (:obj:`int`, optional): The presentation timestamp of the frame, in microseconds. + """ + + frame_id: int | None = None + dependencies: list[int] | None = None + width: int | None = None + height: int | None = None + spatial_index: int | None = None + temporal_index: int | None = None + timestamp: int | None = None + + #: Alias for :attr:`frame_id` + frameId: ClassVar[Alias[int | None]] = alias('frame_id') + #: Alias for :attr:`spatial_index` + spatialIndex: ClassVar[Alias[int | None]] = alias('spatial_index') + #: Alias for :attr:`temporal_index` + temporalIndex: ClassVar[Alias[int | None]] = alias('temporal_index') + + +@dataclass +class RTCEncodedAudioFrameMetadata(RTCEncodedFrameMetadata): + """The metadata of an :obj:`RTCEncodedAudioFrame`. See :obj:`RTCEncodedFrameMetadata` for the common members. + + Args: + sequence_number (:obj:`int`, optional): The RTP sequence number of a received frame. + audio_level (:obj:`float`, optional): The audio level of the frame, from 0 (silence) to 1 (0 dBov). + """ + + sequence_number: int | None = None + audio_level: float | None = None + + #: Alias for :attr:`sequence_number` + sequenceNumber: ClassVar[Alias[int | None]] = alias('sequence_number') + #: Alias for :attr:`audio_level` + audioLevel: ClassVar[Alias[float | None]] = alias('audio_level') + + +@dataclass +class RTCEncodedVideoFrameOptions(Dictionary): + """The options of the copy constructor of :obj:`RTCEncodedVideoFrame`. + + Args: + metadata (:obj:`RTCEncodedVideoFrameMetadata`, optional): Members replacing those of the original frame, + the ones that aren't :obj:`None`. + """ + + metadata: RTCEncodedVideoFrameMetadata | None = None + + _dictionaries: ClassVar = {'metadata': RTCEncodedVideoFrameMetadata} + + +@dataclass +class RTCEncodedAudioFrameOptions(Dictionary): + """The options of the copy constructor of :obj:`RTCEncodedAudioFrame`. + + Args: + metadata (:obj:`RTCEncodedAudioFrameMetadata`, optional): Members replacing those of the original frame, + the ones that aren't :obj:`None`. + """ + + metadata: RTCEncodedAudioFrameMetadata | None = None + + _dictionaries: ClassVar = {'metadata': RTCEncodedAudioFrameMetadata} + + +_MetadataT = TypeVar('_MetadataT', bound=RTCEncodedFrameMetadata) + + +class _RTCEncodedFrame(Generic[_MetadataT]): + _metadata_class: type[_MetadataT] + + _native: wrtc.RTCEncodedFrame | None + _payload: bytearray | None + _metadata: _MetadataT + # the sender or receiver the frame came from (see RTCRtpScriptTransformer), 0 for a constructed frame + _owner: int + _counter: int + # given up to libwebrtc by a write, like a transferred ArrayBuffer + _detached: bool + + def _init_copy(self, original_frame: _RTCEncodedFrame[_MetadataT], metadata: _MetadataT | None) -> None: + if not isinstance(original_frame, type(self)): + msg = f'original_frame must be an {type(self).__name__}, not {type(original_frame).__name__}' + raise TypeError(msg) + if original_frame._detached: + msg = 'The data of the original frame was written to its sender or receiver' + raise DataCloneError(msg) + merged = copy.deepcopy(original_frame._metadata) + if metadata is not None: + for field in dataclasses.fields(metadata): + value: object = getattr(metadata, field.name) + if value is not None: + setattr(merged, field.name, copy.deepcopy(value)) + self._native = None + self._payload = bytearray(original_frame.data) + self._metadata = merged + self._owner = 0 + self._counter = 0 + self._detached = False + + @classmethod + def _from_native(cls, native: wrtc.RTCEncodedFrame, owner: int, counter: int) -> Self: + frame = cls.__new__(cls) + values = native.getMetadata() + frame._native = native + frame._payload = None + frame._metadata = cls._metadata_class.from_json(values) + frame._owner = owner + frame._counter = counter + frame._detached = False + frame._init_native(values) + return frame + + def _init_native(self, values: dict[str, object]) -> None: + """Takes what the metadata dataclass doesn't have from the native metadata.""" + + def _detach(self) -> tuple[wrtc.RTCEncodedFrame | None, bytearray | None]: + native, payload = self._native, self._payload + self._native = None + self._payload = None + self._detached = True + return native, payload + + @property + def data(self) -> bytearray: + """:obj:`bytearray`: The encoded payload, which can be changed in place or replaced. + + A :obj:`bytearray` set is used as it is, other buffers are copied. Empty once the frame is written. + + Raises: + TypeError: If the value set isn't a contiguous buffer. + """ + if self._payload is None: + self._payload = bytearray(self._native.getData()) if self._native is not None else bytearray() + return self._payload + + @data.setter + def data(self, value: Buffer) -> None: + if isinstance(value, bytearray): + self._payload = value + return + try: + view = memoryview(value) + except TypeError: + msg = f'data must be a buffer, not {type(value).__name__}' + raise TypeError(msg) from None + if not view.contiguous: + msg = 'data must be a contiguous buffer' + raise TypeError(msg) + self._payload = bytearray(view.cast('B') if view.ndim == 1 else view.tobytes()) + + def _copied_metadata(self) -> _MetadataT: + return copy.deepcopy(self._metadata) + + def __repr__(self) -> str: + return f'' + + +class RTCEncodedVideoFrame(_RTCEncodedFrame[RTCEncodedVideoFrameMetadata]): + """An encoded video frame an :obj:`webrtc.RTCRtpScriptTransformer` reads, and writes back changed or not. + + Args: + original_frame (:obj:`RTCEncodedVideoFrame`): The frame to copy, its payload and metadata. + options (:obj:`RTCEncodedVideoFrameOptions`, optional): Metadata replacing that of the original. + + A copy is a frame of no sender or receiver: writing it to a transformer drops it. + + Raises: + TypeError: If ``original_frame`` isn't an :obj:`RTCEncodedVideoFrame`. + webrtc.DataCloneError: If ``original_frame`` was written. + """ + + _metadata_class = RTCEncodedVideoFrameMetadata + + def __init__( + self, original_frame: RTCEncodedVideoFrame, options: RTCEncodedVideoFrameOptions | None = None + ) -> None: + self._init_copy(original_frame, options.metadata if options is not None else None) + self._type = original_frame._type + + @override + def _init_native(self, values: dict[str, object]) -> None: + self._type = EncodedVideoChunkType.key if values.get('keyFrame') is True else EncodedVideoChunkType.delta + rid = values.get('rid') + self._rid = rid if isinstance(rid, str) else None + + _type: EncodedVideoChunkType + _rid: str | None = None + + @property + def type(self) -> EncodedVideoChunkType: + """:obj:`webrtc.EncodedVideoChunkType`: Whether it's a key frame or a delta frame.""" + return self._type + + def get_metadata(self) -> RTCEncodedVideoFrameMetadata: + """Returns the metadata of the frame. + + Returns: + :obj:`RTCEncodedVideoFrameMetadata`: A copy, which changing doesn't change the frame. + """ + return self._copied_metadata() + + #: Alias for :meth:`get_metadata` + getMetadata = get_metadata + + +class RTCEncodedAudioFrame(_RTCEncodedFrame[RTCEncodedAudioFrameMetadata]): + """An encoded audio frame an :obj:`webrtc.RTCRtpScriptTransformer` reads, and writes back changed or not. + + Args: + original_frame (:obj:`RTCEncodedAudioFrame`): The frame to copy, its payload and metadata. + options (:obj:`RTCEncodedAudioFrameOptions`, optional): Metadata replacing that of the original. + + A copy is a frame of no sender or receiver: writing it to a transformer drops it. + + Raises: + TypeError: If ``original_frame`` isn't an :obj:`RTCEncodedAudioFrame`. + webrtc.DataCloneError: If ``original_frame`` was written. + """ + + _metadata_class = RTCEncodedAudioFrameMetadata + + def __init__( + self, original_frame: RTCEncodedAudioFrame, options: RTCEncodedAudioFrameOptions | None = None + ) -> None: + self._init_copy(original_frame, options.metadata if options is not None else None) + + def get_metadata(self) -> RTCEncodedAudioFrameMetadata: + """Returns the metadata of the frame. + + Returns: + :obj:`RTCEncodedAudioFrameMetadata`: A copy, which changing doesn't change the frame. + """ + return self._copied_metadata() + + #: Alias for :meth:`get_metadata` + getMetadata = get_metadata diff --git a/python-webrtc/python/webrtc/models/rtc_ice_candidate.py b/python-webrtc/python/webrtc/models/rtc_ice_candidate.py index c4d5740..73ed9f6 100644 --- a/python-webrtc/python/webrtc/models/rtc_ice_candidate.py +++ b/python-webrtc/python/webrtc/models/rtc_ice_candidate.py @@ -27,8 +27,6 @@ from webrtc.utils.names import Alias, alias if TYPE_CHECKING: - from collections.abc import Mapping - import wrtc from webrtc.enums import RTCIceServerTransportProtocolValue @@ -173,15 +171,19 @@ class RTCIceParameters(Dictionary): """The ICE username fragment and password of one end of an :obj:`webrtc.RTCIceTransport`. Args: - username_fragment (:obj:`str`): The username fragment (``a=ice-ufrag``). - password (:obj:`str`): The password (``a=ice-pwd``). + username_fragment (:obj:`str`, optional): The username fragment (``a=ice-ufrag``). + password (:obj:`str`, optional): The password (``a=ice-pwd``). + ice_lite (:obj:`bool`, optional): Whether the agent is an ICE lite one, unknown for a remote agent. """ - username_fragment: str - password: str + username_fragment: str | None = None + password: str | None = None + ice_lite: bool | None = None #: Alias for :attr:`username_fragment` - usernameFragment: ClassVar[Alias[str]] = alias('username_fragment') + usernameFragment: ClassVar[Alias[str | None]] = alias('username_fragment') + #: Alias for :attr:`ice_lite` + iceLite: ClassVar[Alias[bool | None]] = alias('ice_lite') @dataclass @@ -211,6 +213,29 @@ class RTCIceCandidateInit(Dictionary): usernameFragment: ClassVar[Alias[str | None]] = alias('username_fragment') +@dataclass +class RTCLocalIceCandidateInit(RTCIceCandidateInit): + """A candidate with what's only known locally, the members :obj:`webrtc.RTCIceCandidate` is created from. + + Args: + candidate (:obj:`str`, optional): The candidate-attribute from SDP. + 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. + relay_protocol (:obj:`webrtc.RTCIceServerTransportProtocol`, optional): For a local relay candidate, + the protocol used to reach the TURN server. + url (:obj:`str`, optional): For a local candidate, the STUN or TURN server that gathered it. + """ + + relay_protocol: RTCIceServerTransportProtocol | RTCIceServerTransportProtocolValue | None = None + url: str | None = None + + #: Alias for :attr:`relay_protocol` + relayProtocol: ClassVar[Alias[RTCIceServerTransportProtocol | RTCIceServerTransportProtocolValue | None]] = alias( + 'relay_protocol' + ) + + @dataclass(frozen=True) class RTCIceCandidatePair: """The local and the remote candidate an :obj:`webrtc.RTCIceTransport` sends and receives with. @@ -301,21 +326,6 @@ def _peer_reflexive(cls, kwargs: wrtc._IceCandidateKwargs) -> RTCIceCandidate: vars(candidate)['_parsed'] = parsed return candidate - @classmethod - def from_json(cls, value: Mapping[str, object]) -> RTCIceCandidate: - """Creates a candidate from its JSON form, as :meth:`to_json` returns it. - - Args: - 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(RTCIceCandidateInit.from_json(value))) - @property def foundation(self) -> str | None: """:obj:`str`, optional: An identifier of candidates of the same type, base and server.""" @@ -400,5 +410,3 @@ def __repr__(self) -> str: relatedPort: ClassVar = related_port #: Alias for :attr:`to_json` toJSON: ClassVar = to_json - #: Alias for :attr:`from_json` - fromJSON: ClassVar = from_json diff --git a/python-webrtc/python/webrtc/models/rtc_offer_answer_options.py b/python-webrtc/python/webrtc/models/rtc_offer_answer_options.py new file mode 100644 index 0000000..354f12f --- /dev/null +++ b/python-webrtc/python/webrtc/models/rtc_offer_answer_options.py @@ -0,0 +1,51 @@ +# +# 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 options of creating an offer or an answer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias + + +@dataclass +class RTCOfferAnswerOptions(Dictionary): + """The options of creating an offer or an answer, which :obj:`RTCOfferOptions` adds to.""" + + +@dataclass +class RTCOfferOptions(RTCOfferAnswerOptions): + """The options of :meth:`webrtc.RTCPeerConnection.create_offer`. + + Args: + ice_restart (:obj:`bool`, optional): Whether to restart ICE, gathering new credentials and candidates. + :meth:`webrtc.RTCPeerConnection.restart_ice` is the preferred way. + offer_to_receive_audio (:obj:`bool`, optional): Legacy: :obj:`True` adds a receiving audio transceiver + if there's none, :obj:`False` stops receiving audio on the existing ones. + :meth:`webrtc.RTCPeerConnection.add_transceiver` is the preferred way. + offer_to_receive_video (:obj:`bool`, optional): The same for video. + """ + + ice_restart: bool = False + offer_to_receive_audio: bool | None = None + offer_to_receive_video: bool | None = None + + #: Alias for :attr:`ice_restart` + iceRestart: ClassVar[Alias[bool]] = alias('ice_restart') + #: Alias for :attr:`offer_to_receive_audio` + offerToReceiveAudio: ClassVar[Alias[bool | None]] = alias('offer_to_receive_audio') + #: Alias for :attr:`offer_to_receive_video` + offerToReceiveVideo: ClassVar[Alias[bool | None]] = alias('offer_to_receive_video') + + +@dataclass +class RTCAnswerOptions(RTCOfferAnswerOptions): + """The options of :meth:`webrtc.RTCPeerConnection.create_answer`.""" diff --git a/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py b/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py index 611e696..5c8554e 100644 --- a/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py +++ b/python-webrtc/python/webrtc/models/rtc_rtp_transceiver_init.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, ClassVar from webrtc import wrtc -from webrtc.enums import TransceiverDirection, TransceiverDirectionValue +from webrtc.enums import RTCRtpTransceiverDirection, RTCRtpTransceiverDirectionValue from webrtc.models.dictionary import Dictionary from webrtc.models.rtp_parameters import RTCRtpEncodingParameters from webrtc.utils.names import Alias, alias @@ -27,7 +27,7 @@ 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`` + direction (:obj:`webrtc.RTCRtpTransceiverDirection`, 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. @@ -35,17 +35,17 @@ class RTCRtpTransceiverInit(Dictionary): sender, one per simulcast layer. Raises: - ValueError: If the direction isn't a member of :obj:`webrtc.TransceiverDirection`. + ValueError: If the direction isn't a member of :obj:`webrtc.RTCRtpTransceiverDirection`. """ - direction: TransceiverDirection | TransceiverDirectionValue = TransceiverDirection.sendrecv + direction: RTCRtpTransceiverDirection | RTCRtpTransceiverDirectionValue = RTCRtpTransceiverDirection.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) + self.direction = RTCRtpTransceiverDirection(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).""" 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 c371471..2f15925 100644 --- a/python-webrtc/python/webrtc/models/rtc_session_description_init.py +++ b/python-webrtc/python/webrtc/models/rtc_session_description_init.py @@ -10,7 +10,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar from webrtc import wrtc from webrtc.enums import RTCSdpType, RTCSdpTypeValue @@ -44,20 +43,9 @@ def __post_init__(self) -> None: 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. - - Returns: - :obj:`dict`: ``type`` (like ``'offer'``) and ``sdp``. - """ - return {'type': RTCSdpType(self.type).value, 'sdp': self.sdp} - def __repr__(self) -> str: return f'RTCSessionDescriptionInit(type={RTCSdpType(self.type).value!r}, sdp={len(self.sdp)} characters)' - #: Alias for :attr:`to_json` - toJSON: ClassVar = to_json - @dataclass class RTCLocalSessionDescriptionInit(Dictionary): diff --git a/python-webrtc/python/webrtc/models/rtc_stats.py b/python-webrtc/python/webrtc/models/rtc_stats.py index 12004e9..10e5442 100644 --- a/python-webrtc/python/webrtc/models/rtc_stats.py +++ b/python-webrtc/python/webrtc/models/rtc_stats.py @@ -11,74 +11,1148 @@ import json from collections.abc import Iterable, Iterator, Mapping -from typing import TYPE_CHECKING, Union +from dataclasses import MISSING, dataclass, fields +from typing import TYPE_CHECKING, ClassVar -from webrtc.utils.names import camel_case +from webrtc import ( + RTCDataChannelState, + RTCDtlsRole, + RTCDtlsTransportState, + RTCIceCandidateType, + RTCIceRole, + RTCIceServerTransportProtocol, + RTCIceTcpCandidateType, + RTCIceTransportState, + RTCQualityLimitationReason, + RTCStatsIceCandidatePairState, + RTCStatsType, +) +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias if TYPE_CHECKING: + import enum + import webrtc -_CANDIDATE_TYPES = frozenset({'local-candidate', 'remote-candidate'}) -# a value of the JSON libwebrtc serializes the stats to -StatsValue = Union[str, int, float, bool, None, list['StatsValue'], dict[str, 'StatsValue']] +def _enum_value(cls: type[enum.Enum], value: object) -> object: + """The member of an enum with a value, or the value itself if libwebrtc reports one the enum lacks.""" + try: + return cls(value) + except ValueError: + return value + + +@dataclass(init=False) +class RTCStats(Dictionary): + """Stats of one object, like an outbound RTP stream: the members every stats dictionary has. + + A report has the dictionary of the type of the stats, like :obj:`webrtc.RTCOutboundRtpStreamStats`, or this one + for a type the specification doesn't define. Members are keyword-only. -def _is_empty(value: StatsValue) -> bool: - return value is None or value == '' + Args: + timestamp (:obj:`float`): When the stats were collected, in milliseconds since the epoch. + type (:obj:`webrtc.RTCStatsType`): The type of the stats, like ``'outbound-rtp'``. + id (:obj:`str`): Identifies the stats in its report. + Raises: + TypeError: If a required member is missing, or a member is unknown. + """ + + timestamp: float + type: RTCStatsType | str + id: str -class RTCStats(dict[str, StatsValue]): - """Stats of one object, like an outbound RTP stream. + _enums: ClassVar[Mapping[str, type[enum.Enum]]] = {'type': RTCStatsType} - A :obj:`dict` of the members of the stats dictionary of the WebRTC Statistics specification, by their names - there (like ``'bytesSent'``). + def __init__(self, **members: object) -> None: + cls = self.__class__ + unknown = sorted(members.keys() - {field.name for field in fields(cls)}) + if len(unknown) > 0: + msg = f'{cls.__name__} has no member {unknown[0]!r}' + raise TypeError(msg) + enums: dict[str, type[enum.Enum]] = {} + for klass in cls.__mro__: + own: Mapping[str, type[enum.Enum]] = vars(klass).get('_enums', {}) + enums.update(own) + for field in fields(cls): + if field.name in members: + value = members[field.name] + elif field.default is not MISSING: + value = field.default + else: + msg = f'{cls.__name__} is missing the required member {field.name!r}' + raise TypeError(msg) + if field.name in enums and isinstance(value, str): + value = _enum_value(enums[field.name], value) + setattr(self, field.name, value) - Members can also be read as attributes with snake_case names:: - stats['bytesSent'] == stats.bytes_sent +@dataclass(init=False) +class RTCRtpStreamStats(RTCStats): + """Stats of an RTP stream. - ``id``, ``type`` and ``timestamp`` (milliseconds since the epoch) are always present. + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + ssrc (:obj:`int`): The SSRC of the RTP stream. + kind (:obj:`str`): The kind of the media, ``'audio'`` or ``'video'``. + transport_id (:obj:`str`, optional): The id of the stats of the transport of the stream. + codec_id (:obj:`str`, optional): The id of the stats of the codec of the stream. """ - def __getattr__(self, name: str) -> StatsValue: - key = camel_case(name) - try: - return self[key] - except KeyError: - msg = f'{type(self).__name__} of type {self.get("type")!r} has no {name!r}' - raise AttributeError(msg) from None + ssrc: int + kind: str + transport_id: str | None = None + codec_id: str | None = None - def _string(self, key: str) -> str: - value = self[key] - if not isinstance(value, str): - msg = f'{key} of stats is a {type(value).__name__}, not a str' - raise TypeError(msg) - return value + #: Alias for :attr:`transport_id` + transportId: ClassVar[Alias[str | None]] = alias('transport_id') + #: Alias for :attr:`codec_id` + codecId: ClassVar[Alias[str | None]] = alias('codec_id') - @property - def id(self) -> str: - """:obj:`str`: Identifies the stats in its report.""" - return self._string('id') - - @property - def type(self) -> str: - """:obj:`str`: The type of the stats, like ``'outbound-rtp'``.""" - return self._string('type') - - @property - def timestamp(self) -> float: - """:obj:`float`: When the stats were collected, in milliseconds since the epoch.""" - value = self['timestamp'] - if isinstance(value, bool) or not isinstance(value, (int, float)): - msg = f'timestamp of stats is a {type(value).__name__}, not a number' - raise TypeError(msg) - return value + +@dataclass(init=False) +class RTCCodecStats(RTCStats): + """Stats of a codec negotiated on a transport, of type ``'codec'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + payload_type (:obj:`int`): The RTP payload type of the codec. + transport_id (:obj:`str`): The id of the stats of the transport the codec is negotiated on. + mime_type (:obj:`str`): The type and subtype of the codec, like ``'audio/opus'``. + clock_rate (:obj:`int`, optional): The clock rate in Hz. + channels (:obj:`int`, optional): The number of audio channels. + sdp_fmtp_line (:obj:`str`, optional): The parameters of the codec, as in the ``a=fmtp`` line of SDP. + """ + + payload_type: int + transport_id: str + mime_type: str + clock_rate: int | None = None + channels: int | None = None + sdp_fmtp_line: str | None = None + + #: Alias for :attr:`payload_type` + payloadType: ClassVar[Alias[int]] = alias('payload_type') + #: Alias for :attr:`transport_id` + transportId: ClassVar[Alias[str]] = alias('transport_id') + #: Alias for :attr:`mime_type` + mimeType: ClassVar[Alias[str]] = alias('mime_type') + #: Alias for :attr:`clock_rate` + clockRate: ClassVar[Alias[int | None]] = alias('clock_rate') + #: Alias for :attr:`sdp_fmtp_line` + sdpFmtpLine: ClassVar[Alias[str | None]] = alias('sdp_fmtp_line') + + +@dataclass(init=False) +class RTCReceivedRtpStreamStats(RTCRtpStreamStats): + """Stats of an RTP stream, as its receiver measures it. + + Has the members of :obj:`webrtc.RTCRtpStreamStats` too. + + Args: + packets_received (:obj:`int`, optional): The packets received. + packets_received_with_ect1 (:obj:`int`, optional): The packets received with the ECT(1) ECN marking. + packets_received_with_ce (:obj:`int`, optional): The packets received with the CE ECN marking. + packets_reported_as_lost (:obj:`int`, optional): The packets reported lost in congestion control feedback. + packets_reported_as_lost_but_recovered (:obj:`int`, optional): The packets reported lost that were received + later. + packets_lost (:obj:`int`, optional): The packets lost, as RTCP reports count them. + jitter (:obj:`float`, optional): The packet jitter in seconds. + """ + + packets_received: int | None = None + packets_received_with_ect1: int | None = None + packets_received_with_ce: int | None = None + packets_reported_as_lost: int | None = None + packets_reported_as_lost_but_recovered: int | None = None + packets_lost: int | None = None + jitter: float | None = None + + #: Alias for :attr:`packets_received` + packetsReceived: ClassVar[Alias[int | None]] = alias('packets_received') + #: Alias for :attr:`packets_received_with_ect1` + packetsReceivedWithEct1: ClassVar[Alias[int | None]] = alias('packets_received_with_ect1') + #: Alias for :attr:`packets_received_with_ce` + packetsReceivedWithCe: ClassVar[Alias[int | None]] = alias('packets_received_with_ce') + #: Alias for :attr:`packets_reported_as_lost` + packetsReportedAsLost: ClassVar[Alias[int | None]] = alias('packets_reported_as_lost') + #: Alias for :attr:`packets_reported_as_lost_but_recovered` + packetsReportedAsLostButRecovered: ClassVar[Alias[int | None]] = alias('packets_reported_as_lost_but_recovered') + #: Alias for :attr:`packets_lost` + packetsLost: ClassVar[Alias[int | None]] = alias('packets_lost') + + +@dataclass(init=False) +class RTCInboundRtpStreamStats(RTCReceivedRtpStreamStats): + """Stats of an RTP stream the connection receives, of type ``'inbound-rtp'``. + + Has the members of :obj:`webrtc.RTCReceivedRtpStreamStats` too. + + Args: + track_identifier (:obj:`str`): The id of the track. + mid (:obj:`str`, optional): The media id of the transceiver. + remote_id (:obj:`str`, optional): The id of the remote-outbound-rtp stats of the stream. + frames_decoded (:obj:`int`, optional): The frames decoded. + key_frames_decoded (:obj:`int`, optional): The key frames decoded. + frames_rendered (:obj:`int`, optional): The frames rendered. + frames_dropped (:obj:`int`, optional): The frames dropped before decoding. + frame_width (:obj:`int`, optional): The width of the last frame. + frame_height (:obj:`int`, optional): The height of the last frame. + frames_per_second (:obj:`float`, optional): The frames per second over the last second. + qp_sum (:obj:`int`, optional): The sum of the quantization parameters of the frames. + total_decode_time (:obj:`float`, optional): The total seconds spent decoding. + total_inter_frame_delay (:obj:`float`, optional): The total seconds between rendered frames. + total_squared_inter_frame_delay (:obj:`float`, optional): The sum of the squared seconds between rendered + frames. + pause_count (:obj:`int`, optional): The video pauses. + total_pauses_duration (:obj:`float`, optional): The total seconds of the pauses. + freeze_count (:obj:`int`, optional): The video freezes. + total_freezes_duration (:obj:`float`, optional): The total seconds of the freezes. + last_packet_received_timestamp (:obj:`float`, optional): When the last packet was received, in milliseconds + since the epoch. + header_bytes_received (:obj:`int`, optional): The bytes of RTP headers and padding received. + packets_discarded (:obj:`int`, optional): The packets the jitter buffer discarded. + fec_bytes_received (:obj:`int`, optional): The bytes of FEC payload received. + fec_packets_received (:obj:`int`, optional): The FEC packets received. + fec_packets_discarded (:obj:`int`, optional): The FEC packets discarded. + bytes_received (:obj:`int`, optional): The bytes received. + nack_count (:obj:`int`, optional): The NACK packets. + fir_count (:obj:`int`, optional): The FIR packets. + pli_count (:obj:`int`, optional): The PLI packets. + total_processing_delay (:obj:`float`, optional): The total seconds from receiving frames or samples to decoding + them. + estimated_playout_timestamp (:obj:`float`, optional): When the last frame or sample is estimated to play out, in + milliseconds since the epoch. + jitter_buffer_delay (:obj:`float`, optional): The total seconds frames or samples spent in the jitter buffer. + jitter_buffer_target_delay (:obj:`float`, optional): The sum of the target delays of the jitter buffer, in + seconds. + jitter_buffer_emitted_count (:obj:`int`, optional): The frames or samples that left the jitter buffer. + jitter_buffer_minimum_delay (:obj:`float`, optional): The sum of the minimum delays of the jitter buffer, in + seconds. + total_samples_received (:obj:`int`, optional): The audio samples received. + concealed_samples (:obj:`int`, optional): The audio samples concealed. + silent_concealed_samples (:obj:`int`, optional): The audio samples concealed with silence. + concealment_events (:obj:`int`, optional): The concealment events. + inserted_samples_for_deceleration (:obj:`int`, optional): The audio samples inserted to slow playout down. + removed_samples_for_acceleration (:obj:`int`, optional): The audio samples removed to speed playout up. + audio_level (:obj:`float`, optional): The audio level, between 0 and 1. + total_audio_energy (:obj:`float`, optional): The total audio energy. + total_samples_duration (:obj:`float`, optional): The total seconds of the audio samples. + frames_received (:obj:`int`, optional): The complete frames received. + decoder_implementation (:obj:`str`, optional): The decoder, like ``libvpx``. + playout_id (:obj:`str`, optional): The id of the media-playout stats of the audio. + power_efficient_decoder (:obj:`bool`, optional): Whether the decoder is power efficient. + frames_assembled_from_multiple_packets (:obj:`int`, optional): The frames assembled from more than one packet. + total_assembly_time (:obj:`float`, optional): The total seconds spent assembling those frames. + retransmitted_packets_received (:obj:`int`, optional): The retransmitted packets received. + retransmitted_bytes_received (:obj:`int`, optional): The bytes of retransmitted payload received. + rtx_ssrc (:obj:`int`, optional): The SSRC of the RTX stream. + fec_ssrc (:obj:`int`, optional): The SSRC of the FEC stream. + total_corruption_probability (:obj:`float`, optional): The sum of the probabilities of video corruption. + total_squared_corruption_probability (:obj:`float`, optional): The sum of the squared probabilities of video + corruption. + corruption_measurements (:obj:`int`, optional): The video corruption measurements. + """ + + track_identifier: str + mid: str | None = None + remote_id: str | None = None + frames_decoded: int | None = None + key_frames_decoded: int | None = None + frames_rendered: int | None = None + frames_dropped: int | None = None + frame_width: int | None = None + frame_height: int | None = None + frames_per_second: float | None = None + qp_sum: int | None = None + total_decode_time: float | None = None + total_inter_frame_delay: float | None = None + total_squared_inter_frame_delay: float | None = None + pause_count: int | None = None + total_pauses_duration: float | None = None + freeze_count: int | None = None + total_freezes_duration: float | None = None + last_packet_received_timestamp: float | None = None + header_bytes_received: int | None = None + packets_discarded: int | None = None + fec_bytes_received: int | None = None + fec_packets_received: int | None = None + fec_packets_discarded: int | None = None + bytes_received: int | None = None + nack_count: int | None = None + fir_count: int | None = None + pli_count: int | None = None + total_processing_delay: float | None = None + estimated_playout_timestamp: float | None = None + jitter_buffer_delay: float | None = None + jitter_buffer_target_delay: float | None = None + jitter_buffer_emitted_count: int | None = None + jitter_buffer_minimum_delay: float | None = None + total_samples_received: int | None = None + concealed_samples: int | None = None + silent_concealed_samples: int | None = None + concealment_events: int | None = None + inserted_samples_for_deceleration: int | None = None + removed_samples_for_acceleration: int | None = None + audio_level: float | None = None + total_audio_energy: float | None = None + total_samples_duration: float | None = None + frames_received: int | None = None + decoder_implementation: str | None = None + playout_id: str | None = None + power_efficient_decoder: bool | None = None + frames_assembled_from_multiple_packets: int | None = None + total_assembly_time: float | None = None + retransmitted_packets_received: int | None = None + retransmitted_bytes_received: int | None = None + rtx_ssrc: int | None = None + fec_ssrc: int | None = None + total_corruption_probability: float | None = None + total_squared_corruption_probability: float | None = None + corruption_measurements: int | None = None + + #: Alias for :attr:`track_identifier` + trackIdentifier: ClassVar[Alias[str]] = alias('track_identifier') + #: Alias for :attr:`remote_id` + remoteId: ClassVar[Alias[str | None]] = alias('remote_id') + #: Alias for :attr:`frames_decoded` + framesDecoded: ClassVar[Alias[int | None]] = alias('frames_decoded') + #: Alias for :attr:`key_frames_decoded` + keyFramesDecoded: ClassVar[Alias[int | None]] = alias('key_frames_decoded') + #: Alias for :attr:`frames_rendered` + framesRendered: ClassVar[Alias[int | None]] = alias('frames_rendered') + #: Alias for :attr:`frames_dropped` + framesDropped: ClassVar[Alias[int | None]] = alias('frames_dropped') + #: Alias for :attr:`frame_width` + frameWidth: ClassVar[Alias[int | None]] = alias('frame_width') + #: Alias for :attr:`frame_height` + frameHeight: ClassVar[Alias[int | None]] = alias('frame_height') + #: Alias for :attr:`frames_per_second` + framesPerSecond: ClassVar[Alias[float | None]] = alias('frames_per_second') + #: Alias for :attr:`qp_sum` + qpSum: ClassVar[Alias[int | None]] = alias('qp_sum') + #: Alias for :attr:`total_decode_time` + totalDecodeTime: ClassVar[Alias[float | None]] = alias('total_decode_time') + #: Alias for :attr:`total_inter_frame_delay` + totalInterFrameDelay: ClassVar[Alias[float | None]] = alias('total_inter_frame_delay') + #: Alias for :attr:`total_squared_inter_frame_delay` + totalSquaredInterFrameDelay: ClassVar[Alias[float | None]] = alias('total_squared_inter_frame_delay') + #: Alias for :attr:`pause_count` + pauseCount: ClassVar[Alias[int | None]] = alias('pause_count') + #: Alias for :attr:`total_pauses_duration` + totalPausesDuration: ClassVar[Alias[float | None]] = alias('total_pauses_duration') + #: Alias for :attr:`freeze_count` + freezeCount: ClassVar[Alias[int | None]] = alias('freeze_count') + #: Alias for :attr:`total_freezes_duration` + totalFreezesDuration: ClassVar[Alias[float | None]] = alias('total_freezes_duration') + #: Alias for :attr:`last_packet_received_timestamp` + lastPacketReceivedTimestamp: ClassVar[Alias[float | None]] = alias('last_packet_received_timestamp') + #: Alias for :attr:`header_bytes_received` + headerBytesReceived: ClassVar[Alias[int | None]] = alias('header_bytes_received') + #: Alias for :attr:`packets_discarded` + packetsDiscarded: ClassVar[Alias[int | None]] = alias('packets_discarded') + #: Alias for :attr:`fec_bytes_received` + fecBytesReceived: ClassVar[Alias[int | None]] = alias('fec_bytes_received') + #: Alias for :attr:`fec_packets_received` + fecPacketsReceived: ClassVar[Alias[int | None]] = alias('fec_packets_received') + #: Alias for :attr:`fec_packets_discarded` + fecPacketsDiscarded: ClassVar[Alias[int | None]] = alias('fec_packets_discarded') + #: Alias for :attr:`bytes_received` + bytesReceived: ClassVar[Alias[int | None]] = alias('bytes_received') + #: Alias for :attr:`nack_count` + nackCount: ClassVar[Alias[int | None]] = alias('nack_count') + #: Alias for :attr:`fir_count` + firCount: ClassVar[Alias[int | None]] = alias('fir_count') + #: Alias for :attr:`pli_count` + pliCount: ClassVar[Alias[int | None]] = alias('pli_count') + #: Alias for :attr:`total_processing_delay` + totalProcessingDelay: ClassVar[Alias[float | None]] = alias('total_processing_delay') + #: Alias for :attr:`estimated_playout_timestamp` + estimatedPlayoutTimestamp: ClassVar[Alias[float | None]] = alias('estimated_playout_timestamp') + #: Alias for :attr:`jitter_buffer_delay` + jitterBufferDelay: ClassVar[Alias[float | None]] = alias('jitter_buffer_delay') + #: Alias for :attr:`jitter_buffer_target_delay` + jitterBufferTargetDelay: ClassVar[Alias[float | None]] = alias('jitter_buffer_target_delay') + #: Alias for :attr:`jitter_buffer_emitted_count` + jitterBufferEmittedCount: ClassVar[Alias[int | None]] = alias('jitter_buffer_emitted_count') + #: Alias for :attr:`jitter_buffer_minimum_delay` + jitterBufferMinimumDelay: ClassVar[Alias[float | None]] = alias('jitter_buffer_minimum_delay') + #: Alias for :attr:`total_samples_received` + totalSamplesReceived: ClassVar[Alias[int | None]] = alias('total_samples_received') + #: Alias for :attr:`concealed_samples` + concealedSamples: ClassVar[Alias[int | None]] = alias('concealed_samples') + #: Alias for :attr:`silent_concealed_samples` + silentConcealedSamples: ClassVar[Alias[int | None]] = alias('silent_concealed_samples') + #: Alias for :attr:`concealment_events` + concealmentEvents: ClassVar[Alias[int | None]] = alias('concealment_events') + #: Alias for :attr:`inserted_samples_for_deceleration` + insertedSamplesForDeceleration: ClassVar[Alias[int | None]] = alias('inserted_samples_for_deceleration') + #: Alias for :attr:`removed_samples_for_acceleration` + removedSamplesForAcceleration: ClassVar[Alias[int | None]] = alias('removed_samples_for_acceleration') + #: Alias for :attr:`audio_level` + audioLevel: ClassVar[Alias[float | None]] = alias('audio_level') + #: Alias for :attr:`total_audio_energy` + totalAudioEnergy: ClassVar[Alias[float | None]] = alias('total_audio_energy') + #: Alias for :attr:`total_samples_duration` + totalSamplesDuration: ClassVar[Alias[float | None]] = alias('total_samples_duration') + #: Alias for :attr:`frames_received` + framesReceived: ClassVar[Alias[int | None]] = alias('frames_received') + #: Alias for :attr:`decoder_implementation` + decoderImplementation: ClassVar[Alias[str | None]] = alias('decoder_implementation') + #: Alias for :attr:`playout_id` + playoutId: ClassVar[Alias[str | None]] = alias('playout_id') + #: Alias for :attr:`power_efficient_decoder` + powerEfficientDecoder: ClassVar[Alias[bool | None]] = alias('power_efficient_decoder') + #: Alias for :attr:`frames_assembled_from_multiple_packets` + framesAssembledFromMultiplePackets: ClassVar[Alias[int | None]] = alias('frames_assembled_from_multiple_packets') + #: Alias for :attr:`total_assembly_time` + totalAssemblyTime: ClassVar[Alias[float | None]] = alias('total_assembly_time') + #: Alias for :attr:`retransmitted_packets_received` + retransmittedPacketsReceived: ClassVar[Alias[int | None]] = alias('retransmitted_packets_received') + #: Alias for :attr:`retransmitted_bytes_received` + retransmittedBytesReceived: ClassVar[Alias[int | None]] = alias('retransmitted_bytes_received') + #: Alias for :attr:`rtx_ssrc` + rtxSsrc: ClassVar[Alias[int | None]] = alias('rtx_ssrc') + #: Alias for :attr:`fec_ssrc` + fecSsrc: ClassVar[Alias[int | None]] = alias('fec_ssrc') + #: Alias for :attr:`total_corruption_probability` + totalCorruptionProbability: ClassVar[Alias[float | None]] = alias('total_corruption_probability') + #: Alias for :attr:`total_squared_corruption_probability` + totalSquaredCorruptionProbability: ClassVar[Alias[float | None]] = alias('total_squared_corruption_probability') + #: Alias for :attr:`corruption_measurements` + corruptionMeasurements: ClassVar[Alias[int | None]] = alias('corruption_measurements') + + +@dataclass(init=False) +class RTCRemoteInboundRtpStreamStats(RTCReceivedRtpStreamStats): + """Stats of a stream the connection sends, as the remote peer receives it, of type ``'remote-inbound-rtp'``. + + Has the members of :obj:`webrtc.RTCReceivedRtpStreamStats` too. + + Args: + local_id (:obj:`str`, optional): The id of the outbound-rtp stats of the stream. + round_trip_time (:obj:`float`, optional): The last round trip time in seconds. + total_round_trip_time (:obj:`float`, optional): The total seconds of the round trip times. + fraction_lost (:obj:`float`, optional): The fraction of packets lost in the last RTCP report. + round_trip_time_measurements (:obj:`int`, optional): The round trip time measurements. + packets_with_bleached_ect1_marking (:obj:`int`, optional): The packets sent with ECT(1) that arrived without it. + """ + + local_id: str | None = None + round_trip_time: float | None = None + total_round_trip_time: float | None = None + fraction_lost: float | None = None + round_trip_time_measurements: int | None = None + packets_with_bleached_ect1_marking: int | None = None + + #: Alias for :attr:`local_id` + localId: ClassVar[Alias[str | None]] = alias('local_id') + #: Alias for :attr:`round_trip_time` + roundTripTime: ClassVar[Alias[float | None]] = alias('round_trip_time') + #: Alias for :attr:`total_round_trip_time` + totalRoundTripTime: ClassVar[Alias[float | None]] = alias('total_round_trip_time') + #: Alias for :attr:`fraction_lost` + fractionLost: ClassVar[Alias[float | None]] = alias('fraction_lost') + #: Alias for :attr:`round_trip_time_measurements` + roundTripTimeMeasurements: ClassVar[Alias[int | None]] = alias('round_trip_time_measurements') + #: Alias for :attr:`packets_with_bleached_ect1_marking` + packetsWithBleachedEct1Marking: ClassVar[Alias[int | None]] = alias('packets_with_bleached_ect1_marking') + + +@dataclass(init=False) +class RTCSentRtpStreamStats(RTCRtpStreamStats): + """Stats of an RTP stream, as its sender measures it. + + Has the members of :obj:`webrtc.RTCRtpStreamStats` too. + + Args: + packets_sent (:obj:`int`, optional): The packets sent. + bytes_sent (:obj:`int`, optional): The bytes sent. + """ + + packets_sent: int | None = None + bytes_sent: int | None = None + + #: Alias for :attr:`packets_sent` + packetsSent: ClassVar[Alias[int | None]] = alias('packets_sent') + #: Alias for :attr:`bytes_sent` + bytesSent: ClassVar[Alias[int | None]] = alias('bytes_sent') + + +@dataclass(init=False) +class RTCOutboundRtpStreamStats(RTCSentRtpStreamStats): + """Stats of an RTP stream the connection sends, of type ``'outbound-rtp'``. + + Has the members of :obj:`webrtc.RTCSentRtpStreamStats` too. + + Args: + mid (:obj:`str`, optional): The media id of the transceiver. + media_source_id (:obj:`str`, optional): The id of the media-source stats of the track sent. + remote_id (:obj:`str`, optional): The id of the remote-inbound-rtp stats of the stream. + rid (:obj:`str`, optional): The RTP stream id of the simulcast layer. + encoding_index (:obj:`int`, optional): The index of the encoding in the parameters of the sender. + header_bytes_sent (:obj:`int`, optional): The bytes of RTP headers and padding sent. + retransmitted_packets_sent (:obj:`int`, optional): The packets retransmitted. + retransmitted_bytes_sent (:obj:`int`, optional): The bytes of payload retransmitted. + rtx_ssrc (:obj:`int`, optional): The SSRC of the RTX stream. + target_bitrate (:obj:`float`, optional): The target bitrate of the encoder, in bits per second. + total_encoded_bytes_target (:obj:`int`, optional): The sum of the target sizes of the encoded frames, in bytes. + frame_width (:obj:`int`, optional): The width of the last frame. + frame_height (:obj:`int`, optional): The height of the last frame. + frames_per_second (:obj:`float`, optional): The frames per second over the last second. + frames_sent (:obj:`int`, optional): The frames sent. + huge_frames_sent (:obj:`int`, optional): The huge frames sent, like key frames. + frames_encoded (:obj:`int`, optional): The frames encoded. + key_frames_encoded (:obj:`int`, optional): The key frames encoded. + qp_sum (:obj:`int`, optional): The sum of the quantization parameters of the frames. + psnr_sum (:obj:`dict` of :obj:`str` to :obj:`float`, optional): The sums of the PSNR of the encoded frames, by + component (``y``, ``u``, ``v``). + psnr_measurements (:obj:`int`, optional): The PSNR measurements. + total_encode_time (:obj:`float`, optional): The total seconds spent encoding. + total_packet_send_delay (:obj:`float`, optional): The total seconds packets waited to be sent. + quality_limitation_reason (:obj:`webrtc.RTCQualityLimitationReason`, optional): What limits the resolution or + frame rate the most. + quality_limitation_durations (:obj:`dict` of :obj:`str` to :obj:`float`, optional): The seconds limited by each + reason. + quality_limitation_resolution_changes (:obj:`int`, optional): The resolution changes because of quality + limitations. + nack_count (:obj:`int`, optional): The NACK packets. + fir_count (:obj:`int`, optional): The FIR packets. + pli_count (:obj:`int`, optional): The PLI packets. + encoder_implementation (:obj:`str`, optional): The encoder, like ``libvpx``. + power_efficient_encoder (:obj:`bool`, optional): Whether the encoder is power efficient. + active (:obj:`bool`, optional): Whether the encoding is sent. + scalability_mode (:obj:`str`, optional): The scalability mode, like ``'L1T3'``. + packets_sent_with_ect1 (:obj:`int`, optional): The packets sent with the ECT(1) ECN marking. + """ + + mid: str | None = None + media_source_id: str | None = None + remote_id: str | None = None + rid: str | None = None + encoding_index: int | None = None + header_bytes_sent: int | None = None + retransmitted_packets_sent: int | None = None + retransmitted_bytes_sent: int | None = None + rtx_ssrc: int | None = None + target_bitrate: float | None = None + total_encoded_bytes_target: int | None = None + frame_width: int | None = None + frame_height: int | None = None + frames_per_second: float | None = None + frames_sent: int | None = None + huge_frames_sent: int | None = None + frames_encoded: int | None = None + key_frames_encoded: int | None = None + qp_sum: int | None = None + psnr_sum: dict[str, float] | None = None + psnr_measurements: int | None = None + total_encode_time: float | None = None + total_packet_send_delay: float | None = None + quality_limitation_reason: RTCQualityLimitationReason | str | None = None + quality_limitation_durations: dict[str, float] | None = None + quality_limitation_resolution_changes: int | None = None + nack_count: int | None = None + fir_count: int | None = None + pli_count: int | None = None + encoder_implementation: str | None = None + power_efficient_encoder: bool | None = None + active: bool | None = None + scalability_mode: str | None = None + packets_sent_with_ect1: int | None = None + + _enums: ClassVar = {'quality_limitation_reason': RTCQualityLimitationReason} + + #: Alias for :attr:`media_source_id` + mediaSourceId: ClassVar[Alias[str | None]] = alias('media_source_id') + #: Alias for :attr:`remote_id` + remoteId: ClassVar[Alias[str | None]] = alias('remote_id') + #: Alias for :attr:`encoding_index` + encodingIndex: ClassVar[Alias[int | None]] = alias('encoding_index') + #: Alias for :attr:`header_bytes_sent` + headerBytesSent: ClassVar[Alias[int | None]] = alias('header_bytes_sent') + #: Alias for :attr:`retransmitted_packets_sent` + retransmittedPacketsSent: ClassVar[Alias[int | None]] = alias('retransmitted_packets_sent') + #: Alias for :attr:`retransmitted_bytes_sent` + retransmittedBytesSent: ClassVar[Alias[int | None]] = alias('retransmitted_bytes_sent') + #: Alias for :attr:`rtx_ssrc` + rtxSsrc: ClassVar[Alias[int | None]] = alias('rtx_ssrc') + #: Alias for :attr:`target_bitrate` + targetBitrate: ClassVar[Alias[float | None]] = alias('target_bitrate') + #: Alias for :attr:`total_encoded_bytes_target` + totalEncodedBytesTarget: ClassVar[Alias[int | None]] = alias('total_encoded_bytes_target') + #: Alias for :attr:`frame_width` + frameWidth: ClassVar[Alias[int | None]] = alias('frame_width') + #: Alias for :attr:`frame_height` + frameHeight: ClassVar[Alias[int | None]] = alias('frame_height') + #: Alias for :attr:`frames_per_second` + framesPerSecond: ClassVar[Alias[float | None]] = alias('frames_per_second') + #: Alias for :attr:`frames_sent` + framesSent: ClassVar[Alias[int | None]] = alias('frames_sent') + #: Alias for :attr:`huge_frames_sent` + hugeFramesSent: ClassVar[Alias[int | None]] = alias('huge_frames_sent') + #: Alias for :attr:`frames_encoded` + framesEncoded: ClassVar[Alias[int | None]] = alias('frames_encoded') + #: Alias for :attr:`key_frames_encoded` + keyFramesEncoded: ClassVar[Alias[int | None]] = alias('key_frames_encoded') + #: Alias for :attr:`qp_sum` + qpSum: ClassVar[Alias[int | None]] = alias('qp_sum') + #: Alias for :attr:`psnr_sum` + psnrSum: ClassVar[Alias[dict[str, float] | None]] = alias('psnr_sum') + #: Alias for :attr:`psnr_measurements` + psnrMeasurements: ClassVar[Alias[int | None]] = alias('psnr_measurements') + #: Alias for :attr:`total_encode_time` + totalEncodeTime: ClassVar[Alias[float | None]] = alias('total_encode_time') + #: Alias for :attr:`total_packet_send_delay` + totalPacketSendDelay: ClassVar[Alias[float | None]] = alias('total_packet_send_delay') + #: Alias for :attr:`quality_limitation_reason` + qualityLimitationReason: ClassVar[Alias[RTCQualityLimitationReason | str | None]] = alias( + 'quality_limitation_reason' + ) + #: Alias for :attr:`quality_limitation_durations` + qualityLimitationDurations: ClassVar[Alias[dict[str, float] | None]] = alias('quality_limitation_durations') + #: Alias for :attr:`quality_limitation_resolution_changes` + qualityLimitationResolutionChanges: ClassVar[Alias[int | None]] = alias('quality_limitation_resolution_changes') + #: Alias for :attr:`nack_count` + nackCount: ClassVar[Alias[int | None]] = alias('nack_count') + #: Alias for :attr:`fir_count` + firCount: ClassVar[Alias[int | None]] = alias('fir_count') + #: Alias for :attr:`pli_count` + pliCount: ClassVar[Alias[int | None]] = alias('pli_count') + #: Alias for :attr:`encoder_implementation` + encoderImplementation: ClassVar[Alias[str | None]] = alias('encoder_implementation') + #: Alias for :attr:`power_efficient_encoder` + powerEfficientEncoder: ClassVar[Alias[bool | None]] = alias('power_efficient_encoder') + #: Alias for :attr:`scalability_mode` + scalabilityMode: ClassVar[Alias[str | None]] = alias('scalability_mode') + #: Alias for :attr:`packets_sent_with_ect1` + packetsSentWithEct1: ClassVar[Alias[int | None]] = alias('packets_sent_with_ect1') + + +@dataclass(init=False) +class RTCRemoteOutboundRtpStreamStats(RTCSentRtpStreamStats): + """Stats of a stream the connection receives, as the remote peer sends it, of type ``'remote-outbound-rtp'``. + + Has the members of :obj:`webrtc.RTCSentRtpStreamStats` too. + + Args: + local_id (:obj:`str`, optional): The id of the inbound-rtp stats of the stream. + remote_timestamp (:obj:`float`, optional): When the remote peer sent its report, in milliseconds since the + epoch. + reports_sent (:obj:`int`, optional): The RTCP sender reports sent. + round_trip_time (:obj:`float`, optional): The last round trip time in seconds. + total_round_trip_time (:obj:`float`, optional): The total seconds of the round trip times. + round_trip_time_measurements (:obj:`int`, optional): The round trip time measurements. + """ + + local_id: str | None = None + remote_timestamp: float | None = None + reports_sent: int | None = None + round_trip_time: float | None = None + total_round_trip_time: float | None = None + round_trip_time_measurements: int | None = None + + #: Alias for :attr:`local_id` + localId: ClassVar[Alias[str | None]] = alias('local_id') + #: Alias for :attr:`remote_timestamp` + remoteTimestamp: ClassVar[Alias[float | None]] = alias('remote_timestamp') + #: Alias for :attr:`reports_sent` + reportsSent: ClassVar[Alias[int | None]] = alias('reports_sent') + #: Alias for :attr:`round_trip_time` + roundTripTime: ClassVar[Alias[float | None]] = alias('round_trip_time') + #: Alias for :attr:`total_round_trip_time` + totalRoundTripTime: ClassVar[Alias[float | None]] = alias('total_round_trip_time') + #: Alias for :attr:`round_trip_time_measurements` + roundTripTimeMeasurements: ClassVar[Alias[int | None]] = alias('round_trip_time_measurements') + + +@dataclass(init=False) +class RTCMediaSourceStats(RTCStats): + """Stats of a track a sender sends, of type ``'media-source'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + track_identifier (:obj:`str`): The id of the track. + kind (:obj:`str`): The kind of the track, ``'audio'`` or ``'video'``. + """ + + track_identifier: str + kind: str + + #: Alias for :attr:`track_identifier` + trackIdentifier: ClassVar[Alias[str]] = alias('track_identifier') + + +@dataclass(init=False) +class RTCAudioSourceStats(RTCMediaSourceStats): + """Stats of an audio track a sender sends, of type ``'media-source'``. + + Has the members of :obj:`webrtc.RTCMediaSourceStats` too. + + Args: + audio_level (:obj:`float`, optional): The audio level, between 0 and 1. + total_audio_energy (:obj:`float`, optional): The total audio energy. + total_samples_duration (:obj:`float`, optional): The total seconds of the audio samples. + echo_return_loss (:obj:`float`, optional): The echo return loss in decibels. + echo_return_loss_enhancement (:obj:`float`, optional): The echo return loss enhancement in decibels. + """ + + audio_level: float | None = None + total_audio_energy: float | None = None + total_samples_duration: float | None = None + echo_return_loss: float | None = None + echo_return_loss_enhancement: float | None = None + + #: Alias for :attr:`audio_level` + audioLevel: ClassVar[Alias[float | None]] = alias('audio_level') + #: Alias for :attr:`total_audio_energy` + totalAudioEnergy: ClassVar[Alias[float | None]] = alias('total_audio_energy') + #: Alias for :attr:`total_samples_duration` + totalSamplesDuration: ClassVar[Alias[float | None]] = alias('total_samples_duration') + #: Alias for :attr:`echo_return_loss` + echoReturnLoss: ClassVar[Alias[float | None]] = alias('echo_return_loss') + #: Alias for :attr:`echo_return_loss_enhancement` + echoReturnLossEnhancement: ClassVar[Alias[float | None]] = alias('echo_return_loss_enhancement') + + +@dataclass(init=False) +class RTCVideoSourceStats(RTCMediaSourceStats): + """Stats of a video track a sender sends, of type ``'media-source'``. + + Has the members of :obj:`webrtc.RTCMediaSourceStats` too. + + Args: + width (:obj:`int`, optional): The width of the last frame. + height (:obj:`int`, optional): The height of the last frame. + frames (:obj:`int`, optional): The frames from the source. + frames_per_second (:obj:`float`, optional): The frames per second over the last second. + """ + + width: int | None = None + height: int | None = None + frames: int | None = None + frames_per_second: float | None = None + + #: Alias for :attr:`frames_per_second` + framesPerSecond: ClassVar[Alias[float | None]] = alias('frames_per_second') + + +@dataclass(init=False) +class RTCAudioPlayoutStats(RTCStats): + """Stats of the playout of received audio, of type ``'media-playout'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + kind (:obj:`str`): The kind of the media, ``'audio'``. + synthesized_samples_duration (:obj:`float`, optional): The total seconds of samples synthesized for playout. + synthesized_samples_events (:obj:`int`, optional): The events of synthesizing samples. + total_samples_duration (:obj:`float`, optional): The total seconds of samples played out. + total_playout_delay (:obj:`float`, optional): The sum of the playout delays of the samples, in seconds. + total_samples_count (:obj:`int`, optional): The samples played out. + """ + + kind: str + synthesized_samples_duration: float | None = None + synthesized_samples_events: int | None = None + total_samples_duration: float | None = None + total_playout_delay: float | None = None + total_samples_count: int | None = None + + #: Alias for :attr:`synthesized_samples_duration` + synthesizedSamplesDuration: ClassVar[Alias[float | None]] = alias('synthesized_samples_duration') + #: Alias for :attr:`synthesized_samples_events` + synthesizedSamplesEvents: ClassVar[Alias[int | None]] = alias('synthesized_samples_events') + #: Alias for :attr:`total_samples_duration` + totalSamplesDuration: ClassVar[Alias[float | None]] = alias('total_samples_duration') + #: Alias for :attr:`total_playout_delay` + totalPlayoutDelay: ClassVar[Alias[float | None]] = alias('total_playout_delay') + #: Alias for :attr:`total_samples_count` + totalSamplesCount: ClassVar[Alias[int | None]] = alias('total_samples_count') + + +@dataclass(init=False) +class RTCPeerConnectionStats(RTCStats): + """Stats of the connection, of type ``'peer-connection'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + data_channels_opened (:obj:`int`, optional): The data channels opened. + data_channels_closed (:obj:`int`, optional): The data channels closed. + """ + + data_channels_opened: int | None = None + data_channels_closed: int | None = None + + #: Alias for :attr:`data_channels_opened` + dataChannelsOpened: ClassVar[Alias[int | None]] = alias('data_channels_opened') + #: Alias for :attr:`data_channels_closed` + dataChannelsClosed: ClassVar[Alias[int | None]] = alias('data_channels_closed') + + +@dataclass(init=False) +class RTCDataChannelStats(RTCStats): + """Stats of a data channel, of type ``'data-channel'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + label (:obj:`str`, optional): The label of the channel. + protocol (:obj:`str`, optional): The subprotocol of the channel. + data_channel_identifier (:obj:`int`, optional): The id of the channel. + state (:obj:`webrtc.RTCDataChannelState`): The state of the channel. + messages_sent (:obj:`int`, optional): The messages sent. + bytes_sent (:obj:`int`, optional): The bytes sent. + messages_received (:obj:`int`, optional): The messages received. + bytes_received (:obj:`int`, optional): The bytes received. + """ + + label: str | None = None + protocol: str | None = None + data_channel_identifier: int | None = None + state: RTCDataChannelState | str + messages_sent: int | None = None + bytes_sent: int | None = None + messages_received: int | None = None + bytes_received: int | None = None + + _enums: ClassVar = {'state': RTCDataChannelState} + + #: Alias for :attr:`data_channel_identifier` + dataChannelIdentifier: ClassVar[Alias[int | None]] = alias('data_channel_identifier') + #: Alias for :attr:`messages_sent` + messagesSent: ClassVar[Alias[int | None]] = alias('messages_sent') + #: Alias for :attr:`bytes_sent` + bytesSent: ClassVar[Alias[int | None]] = alias('bytes_sent') + #: Alias for :attr:`messages_received` + messagesReceived: ClassVar[Alias[int | None]] = alias('messages_received') + #: Alias for :attr:`bytes_received` + bytesReceived: ClassVar[Alias[int | None]] = alias('bytes_received') + + +@dataclass(init=False) +class RTCTransportStats(RTCStats): + """Stats of a transport, of type ``'transport'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + packets_sent (:obj:`int`, optional): The packets sent. + packets_received (:obj:`int`, optional): The packets received. + bytes_sent (:obj:`int`, optional): The bytes sent. + bytes_received (:obj:`int`, optional): The bytes received. + ice_role (:obj:`webrtc.RTCIceRole`, optional): The ICE role. + ice_local_username_fragment (:obj:`str`, optional): The local ICE username fragment. + dtls_state (:obj:`webrtc.RTCDtlsTransportState`): The DTLS state. + ice_state (:obj:`webrtc.RTCIceTransportState`, optional): The ICE state. + selected_candidate_pair_id (:obj:`str`, optional): The id of the stats of the selected candidate pair. + local_certificate_id (:obj:`str`, optional): The id of the stats of the local certificate. + remote_certificate_id (:obj:`str`, optional): The id of the stats of the remote certificate. + tls_version (:obj:`str`, optional): The DTLS version, in hex. + dtls_cipher (:obj:`str`, optional): The DTLS cipher suite. + dtls_role (:obj:`webrtc.RTCDtlsRole`, optional): The DTLS role. + srtp_cipher (:obj:`str`, optional): The SRTP protection profile. + selected_candidate_pair_changes (:obj:`int`, optional): The changes of the selected candidate pair. + ccfb_messages_sent (:obj:`int`, optional): The congestion control feedback messages sent. + ccfb_messages_received (:obj:`int`, optional): The congestion control feedback messages received. + """ + + packets_sent: int | None = None + packets_received: int | None = None + bytes_sent: int | None = None + bytes_received: int | None = None + ice_role: RTCIceRole | str | None = None + ice_local_username_fragment: str | None = None + dtls_state: RTCDtlsTransportState | str + ice_state: RTCIceTransportState | str | None = None + selected_candidate_pair_id: str | None = None + local_certificate_id: str | None = None + remote_certificate_id: str | None = None + tls_version: str | None = None + dtls_cipher: str | None = None + dtls_role: RTCDtlsRole | str | None = None + srtp_cipher: str | None = None + selected_candidate_pair_changes: int | None = None + ccfb_messages_sent: int | None = None + ccfb_messages_received: int | None = None + + _enums: ClassVar = { + 'ice_role': RTCIceRole, + 'dtls_state': RTCDtlsTransportState, + 'ice_state': RTCIceTransportState, + 'dtls_role': RTCDtlsRole, + } + + #: Alias for :attr:`packets_sent` + packetsSent: ClassVar[Alias[int | None]] = alias('packets_sent') + #: Alias for :attr:`packets_received` + packetsReceived: ClassVar[Alias[int | None]] = alias('packets_received') + #: Alias for :attr:`bytes_sent` + bytesSent: ClassVar[Alias[int | None]] = alias('bytes_sent') + #: Alias for :attr:`bytes_received` + bytesReceived: ClassVar[Alias[int | None]] = alias('bytes_received') + #: Alias for :attr:`ice_role` + iceRole: ClassVar[Alias[RTCIceRole | str | None]] = alias('ice_role') + #: Alias for :attr:`ice_local_username_fragment` + iceLocalUsernameFragment: ClassVar[Alias[str | None]] = alias('ice_local_username_fragment') + #: Alias for :attr:`dtls_state` + dtlsState: ClassVar[Alias[RTCDtlsTransportState | str]] = alias('dtls_state') + #: Alias for :attr:`ice_state` + iceState: ClassVar[Alias[RTCIceTransportState | str | None]] = alias('ice_state') + #: Alias for :attr:`selected_candidate_pair_id` + selectedCandidatePairId: ClassVar[Alias[str | None]] = alias('selected_candidate_pair_id') + #: Alias for :attr:`local_certificate_id` + localCertificateId: ClassVar[Alias[str | None]] = alias('local_certificate_id') + #: Alias for :attr:`remote_certificate_id` + remoteCertificateId: ClassVar[Alias[str | None]] = alias('remote_certificate_id') + #: Alias for :attr:`tls_version` + tlsVersion: ClassVar[Alias[str | None]] = alias('tls_version') + #: Alias for :attr:`dtls_cipher` + dtlsCipher: ClassVar[Alias[str | None]] = alias('dtls_cipher') + #: Alias for :attr:`dtls_role` + dtlsRole: ClassVar[Alias[RTCDtlsRole | str | None]] = alias('dtls_role') + #: Alias for :attr:`srtp_cipher` + srtpCipher: ClassVar[Alias[str | None]] = alias('srtp_cipher') + #: Alias for :attr:`selected_candidate_pair_changes` + selectedCandidatePairChanges: ClassVar[Alias[int | None]] = alias('selected_candidate_pair_changes') + #: Alias for :attr:`ccfb_messages_sent` + ccfbMessagesSent: ClassVar[Alias[int | None]] = alias('ccfb_messages_sent') + #: Alias for :attr:`ccfb_messages_received` + ccfbMessagesReceived: ClassVar[Alias[int | None]] = alias('ccfb_messages_received') + + +@dataclass(init=False) +class RTCIceCandidateStats(RTCStats): + """Stats of an ICE candidate, of type ``'local-candidate'`` or ``'remote-candidate'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + transport_id (:obj:`str`): The id of the stats of the transport of the candidate. + address (:obj:`str`, optional): The address of the candidate, :obj:`None` if it isn't exposed. + port (:obj:`int`, optional): The port. + protocol (:obj:`str`, optional): The protocol, ``'udp'`` or ``'tcp'``. + candidate_type (:obj:`webrtc.RTCIceCandidateType`): The type of the candidate. + priority (:obj:`int`, optional): The priority. + url (:obj:`str`, optional): The URL of the ICE server the candidate is from. + relay_protocol (:obj:`webrtc.RTCIceServerTransportProtocol`, optional): The protocol between the client and the + TURN server. + foundation (:obj:`str`, optional): The foundation. + related_address (:obj:`str`, optional): The related address. + related_port (:obj:`int`, optional): The related port. + username_fragment (:obj:`str`, optional): The ICE username fragment. + tcp_type (:obj:`webrtc.RTCIceTcpCandidateType`, optional): The type of a TCP candidate. + """ + + transport_id: str + address: str | None = None + port: int | None = None + protocol: str | None = None + candidate_type: RTCIceCandidateType | str + priority: int | None = None + url: str | None = None + relay_protocol: RTCIceServerTransportProtocol | str | None = None + foundation: str | None = None + related_address: str | None = None + related_port: int | None = None + username_fragment: str | None = None + tcp_type: RTCIceTcpCandidateType | str | None = None + + _enums: ClassVar = { + 'candidate_type': RTCIceCandidateType, + 'relay_protocol': RTCIceServerTransportProtocol, + 'tcp_type': RTCIceTcpCandidateType, + } + + #: Alias for :attr:`transport_id` + transportId: ClassVar[Alias[str]] = alias('transport_id') + #: Alias for :attr:`candidate_type` + candidateType: ClassVar[Alias[RTCIceCandidateType | str]] = alias('candidate_type') + #: Alias for :attr:`relay_protocol` + relayProtocol: ClassVar[Alias[RTCIceServerTransportProtocol | str | None]] = alias('relay_protocol') + #: Alias for :attr:`related_address` + relatedAddress: ClassVar[Alias[str | None]] = alias('related_address') + #: Alias for :attr:`related_port` + relatedPort: ClassVar[Alias[int | None]] = alias('related_port') + #: Alias for :attr:`username_fragment` + usernameFragment: ClassVar[Alias[str | None]] = alias('username_fragment') + #: Alias for :attr:`tcp_type` + tcpType: ClassVar[Alias[RTCIceTcpCandidateType | str | None]] = alias('tcp_type') + + +@dataclass(init=False) +class RTCIceCandidatePairStats(RTCStats): + """Stats of an ICE candidate pair, of type ``'candidate-pair'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + transport_id (:obj:`str`): The id of the stats of the transport of the pair. + local_candidate_id (:obj:`str`): The id of the stats of the local candidate. + remote_candidate_id (:obj:`str`): The id of the stats of the remote candidate. + state (:obj:`webrtc.RTCStatsIceCandidatePairState`): The state of the pair in the checklist. + nominated (:obj:`bool`, optional): Whether the pair is nominated. + packets_sent (:obj:`int`, optional): The packets sent. + packets_received (:obj:`int`, optional): The packets received. + bytes_sent (:obj:`int`, optional): The bytes sent. + bytes_received (:obj:`int`, optional): The bytes received. + last_packet_sent_timestamp (:obj:`float`, optional): When the last packet was sent, in milliseconds since the + epoch. + last_packet_received_timestamp (:obj:`float`, optional): When the last packet was received, in milliseconds + since the epoch. + total_round_trip_time (:obj:`float`, optional): The total seconds of the round trip times of STUN requests. + current_round_trip_time (:obj:`float`, optional): The last round trip time of STUN requests, in seconds. + available_outgoing_bitrate (:obj:`float`, optional): The estimated outgoing bitrate available, in bits per + second. + available_incoming_bitrate (:obj:`float`, optional): The estimated incoming bitrate available, in bits per + second. + requests_received (:obj:`int`, optional): The connectivity check requests received. + requests_sent (:obj:`int`, optional): The connectivity check requests sent. + responses_received (:obj:`int`, optional): The connectivity check responses received. + responses_sent (:obj:`int`, optional): The connectivity check responses sent. + consent_requests_sent (:obj:`int`, optional): The consent requests sent. + packets_discarded_on_send (:obj:`int`, optional): The packets that failed to be sent. + bytes_discarded_on_send (:obj:`int`, optional): The bytes that failed to be sent. + """ + + transport_id: str + local_candidate_id: str + remote_candidate_id: str + state: RTCStatsIceCandidatePairState | str + nominated: bool | None = None + packets_sent: int | None = None + packets_received: int | None = None + bytes_sent: int | None = None + bytes_received: int | None = None + last_packet_sent_timestamp: float | None = None + last_packet_received_timestamp: float | None = None + total_round_trip_time: float | None = None + current_round_trip_time: float | None = None + available_outgoing_bitrate: float | None = None + available_incoming_bitrate: float | None = None + requests_received: int | None = None + requests_sent: int | None = None + responses_received: int | None = None + responses_sent: int | None = None + consent_requests_sent: int | None = None + packets_discarded_on_send: int | None = None + bytes_discarded_on_send: int | None = None + + _enums: ClassVar = {'state': RTCStatsIceCandidatePairState} + + #: Alias for :attr:`transport_id` + transportId: ClassVar[Alias[str]] = alias('transport_id') + #: Alias for :attr:`local_candidate_id` + localCandidateId: ClassVar[Alias[str]] = alias('local_candidate_id') + #: Alias for :attr:`remote_candidate_id` + remoteCandidateId: ClassVar[Alias[str]] = alias('remote_candidate_id') + #: Alias for :attr:`packets_sent` + packetsSent: ClassVar[Alias[int | None]] = alias('packets_sent') + #: Alias for :attr:`packets_received` + packetsReceived: ClassVar[Alias[int | None]] = alias('packets_received') + #: Alias for :attr:`bytes_sent` + bytesSent: ClassVar[Alias[int | None]] = alias('bytes_sent') + #: Alias for :attr:`bytes_received` + bytesReceived: ClassVar[Alias[int | None]] = alias('bytes_received') + #: Alias for :attr:`last_packet_sent_timestamp` + lastPacketSentTimestamp: ClassVar[Alias[float | None]] = alias('last_packet_sent_timestamp') + #: Alias for :attr:`last_packet_received_timestamp` + lastPacketReceivedTimestamp: ClassVar[Alias[float | None]] = alias('last_packet_received_timestamp') + #: Alias for :attr:`total_round_trip_time` + totalRoundTripTime: ClassVar[Alias[float | None]] = alias('total_round_trip_time') + #: Alias for :attr:`current_round_trip_time` + currentRoundTripTime: ClassVar[Alias[float | None]] = alias('current_round_trip_time') + #: Alias for :attr:`available_outgoing_bitrate` + availableOutgoingBitrate: ClassVar[Alias[float | None]] = alias('available_outgoing_bitrate') + #: Alias for :attr:`available_incoming_bitrate` + availableIncomingBitrate: ClassVar[Alias[float | None]] = alias('available_incoming_bitrate') + #: Alias for :attr:`requests_received` + requestsReceived: ClassVar[Alias[int | None]] = alias('requests_received') + #: Alias for :attr:`requests_sent` + requestsSent: ClassVar[Alias[int | None]] = alias('requests_sent') + #: Alias for :attr:`responses_received` + responsesReceived: ClassVar[Alias[int | None]] = alias('responses_received') + #: Alias for :attr:`responses_sent` + responsesSent: ClassVar[Alias[int | None]] = alias('responses_sent') + #: Alias for :attr:`consent_requests_sent` + consentRequestsSent: ClassVar[Alias[int | None]] = alias('consent_requests_sent') + #: Alias for :attr:`packets_discarded_on_send` + packetsDiscardedOnSend: ClassVar[Alias[int | None]] = alias('packets_discarded_on_send') + #: Alias for :attr:`bytes_discarded_on_send` + bytesDiscardedOnSend: ClassVar[Alias[int | None]] = alias('bytes_discarded_on_send') + + +@dataclass(init=False) +class RTCCertificateStats(RTCStats): + """Stats of a certificate, of type ``'certificate'``. + + Has the members of :obj:`webrtc.RTCStats` too. + + Args: + fingerprint (:obj:`str`): The fingerprint of the certificate. + fingerprint_algorithm (:obj:`str`): The hash function of the fingerprint, like ``'sha-256'``. + base64_certificate (:obj:`str`): The DER of the certificate, in base64. + issuer_certificate_id (:obj:`str`, optional): The id of the stats of the issuer certificate. + """ + + fingerprint: str + fingerprint_algorithm: str + base64_certificate: str + issuer_certificate_id: str | None = None + + #: Alias for :attr:`fingerprint_algorithm` + fingerprintAlgorithm: ClassVar[Alias[str]] = alias('fingerprint_algorithm') + #: Alias for :attr:`base64_certificate` + base64Certificate: ClassVar[Alias[str]] = alias('base64_certificate') + #: Alias for :attr:`issuer_certificate_id` + issuerCertificateId: ClassVar[Alias[str | None]] = alias('issuer_certificate_id') + + +# the dictionary of each type of stats, but media-source, which also depends on the kind +_DICTIONARIES: Mapping[str, type[RTCStats]] = { + 'codec': RTCCodecStats, + 'inbound-rtp': RTCInboundRtpStreamStats, + 'outbound-rtp': RTCOutboundRtpStreamStats, + 'remote-inbound-rtp': RTCRemoteInboundRtpStreamStats, + 'remote-outbound-rtp': RTCRemoteOutboundRtpStreamStats, + 'media-playout': RTCAudioPlayoutStats, + 'peer-connection': RTCPeerConnectionStats, + 'data-channel': RTCDataChannelStats, + 'transport': RTCTransportStats, + 'candidate-pair': RTCIceCandidatePairStats, + 'local-candidate': RTCIceCandidateStats, + 'remote-candidate': RTCIceCandidateStats, + 'certificate': RTCCertificateStats, +} +_SOURCES: Mapping[object, type[RTCStats]] = {'audio': RTCAudioSourceStats, 'video': RTCVideoSourceStats} + + +def _dictionary(entry: Mapping[str, object]) -> type[RTCStats]: + if entry.get('type') == 'media-source': + return _SOURCES.get(entry.get('kind'), RTCMediaSourceStats) + stats_type = entry.get('type') + return _DICTIONARIES.get(stats_type, RTCStats) if isinstance(stats_type, str) else RTCStats + + +def _stats(entry: Mapping[str, object]) -> RTCStats: + try: + return _dictionary(entry).from_json(entry) + except TypeError: + # libwebrtc lacks a required member of the dictionary: only the members of every stats are left + return RTCStats.from_json(entry) class RTCStatsReport(Mapping[str, RTCStats]): """The stats of a connection, or of a sender or a receiver. - A read-only mapping of their ids to :obj:`webrtc.RTCStats`. + A read-only mapping of their ids to the stats, each the dictionary of its type, like + :obj:`webrtc.RTCInboundRtpStreamStats` for ``'inbound-rtp'``. """ def __init__(self, stats: Mapping[str, RTCStats]) -> None: @@ -89,20 +1163,19 @@ def _from_native(cls, report: str, receivers: Iterable[webrtc.RTCRtpReceiver] = """The report from the JSON libwebrtc serializes it to, with the receivers whose tracks it refers to.""" # remote tracks have their own ids, rather than the libwebrtc ones in the stats track_ids = {receiver.track._native_obj._nativeId: receiver.track.id for receiver in receivers} - entries: list[dict[str, StatsValue]] = json.loads(report if report != '' else '[]') - stats = [RTCStats(entry) for entry in entries] - for entry in stats: + entries: list[dict[str, object]] = json.loads(report if report != '' else '[]') + for entry in entries: # libwebrtc serializes microseconds - entry['timestamp'] = entry.timestamp / 1000 - if entry.get('type') == 'inbound-rtp' and entry.get('trackIdentifier') in track_ids: - entry['trackIdentifier'] = track_ids[entry['trackIdentifier']] + timestamp = entry.get('timestamp') + if isinstance(timestamp, (int, float)): + entry['timestamp'] = timestamp / 1000 + track_id = entry.get('trackIdentifier') + if entry.get('type') == 'inbound-rtp' and isinstance(track_id, str) and track_id in track_ids: + entry['trackIdentifier'] = track_ids[track_id] # libwebrtc leaves the addresses of candidates it doesn't expose (like peer-reflexive ones) empty - if ( - entry.get('type') in {'local-candidate', 'remote-candidate'} - and 'address' in entry - and _is_empty(entry['address']) - ): + if entry.get('type') in {'local-candidate', 'remote-candidate'} and entry.get('address') == '': entry['address'] = None + stats = [_stats(entry) for entry in entries] return cls({entry.id: entry for entry in stats}) def __getitem__(self, stats_id: str) -> RTCStats: @@ -114,19 +1187,5 @@ def __iter__(self) -> Iterator[str]: def __len__(self) -> int: return len(self._stats) - def of_type(self, stats_type: str) -> list[RTCStats]: - """Returns the stats of a type. - - Args: - stats_type (:obj:`str`): The type, like ``'inbound-rtp'``. - - Returns: - :obj:`list` of :obj:`webrtc.RTCStats`: The stats of the type. - """ - return [stats for stats in self._stats.values() if stats['type'] == stats_type] - def __repr__(self) -> str: return f'RTCStatsReport({len(self)} stats)' - - #: Alias for :attr:`of_type` - ofType = of_type diff --git a/python-webrtc/python/webrtc/models/rtp_parameters.py b/python-webrtc/python/webrtc/models/rtp_parameters.py index f13dc58..a74d59c 100644 --- a/python-webrtc/python/webrtc/models/rtp_parameters.py +++ b/python-webrtc/python/webrtc/models/rtp_parameters.py @@ -16,7 +16,7 @@ from typing_extensions import TypedDict -from webrtc import MediaType, RTCDegradationPreference, RTCPriorityType, TransceiverDirection, wrtc +from webrtc import MediaType, RTCDegradationPreference, RTCPriorityType, RTCRtpTransceiverDirection, wrtc from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias @@ -25,7 +25,7 @@ MediaTypeValue, RTCDegradationPreferenceValue, RTCPriorityTypeValue, - TransceiverDirectionValue, + RTCRtpTransceiverDirectionValue, ) _NativeCodecT = TypeVar('_NativeCodecT', bound='wrtc.RtpCodec') @@ -211,14 +211,25 @@ class RTCRtcpParameters(Dictionary): @dataclass -class RTCRtpEncodingParameters(Dictionary): +class RTCRtpCodingParameters(Dictionary): + """The members of an encoding that identify it. + + Args: + rid (:obj:`str`, optional): The RTP stream id of a simulcast layer. Can't be changed once set. + """ + + rid: str | None = None + + +@dataclass +class RTCRtpEncodingParameters(RTCRtpCodingParameters): """An encoding of a sender, one per simulcast layer. Args: + rid (:obj:`str`, optional): The RTP stream id of a simulcast layer. Can't be changed once set. active (:obj:`bool`, optional): Whether the encoding is sent. max_bitrate (:obj:`int`, optional): The highest bitrate in bits per second. max_framerate (:obj:`float`, optional): The highest frame rate of video. - rid (:obj:`str`, optional): The RTP stream id of a simulcast layer. Can't be changed once set. scale_resolution_down_by (:obj:`float`, optional): How much video is scaled down (at least 1). priority (:obj:`webrtc.RTCPriorityType`, optional): The share of the bitrate the encoding gets. network_priority (:obj:`webrtc.RTCPriorityType`, optional): The DSCP marking of its packets. @@ -230,7 +241,6 @@ class RTCRtpEncodingParameters(Dictionary): active: bool = True max_bitrate: int | None = None max_framerate: float | None = None - rid: str | None = None scale_resolution_down_by: float | None = None priority: RTCPriorityType | RTCPriorityTypeValue = RTCPriorityType.low network_priority: RTCPriorityType | RTCPriorityTypeValue = RTCPriorityType.low @@ -309,18 +319,18 @@ def _to_native(self) -> wrtc.RtpEncodingParameters: @dataclass -class RTCRtpReceiveParameters(Dictionary): - """The parameters a receiver receives with. +class RTCRtpParameters(Dictionary): + """The parameters of a sender or a receiver. Args: - codecs (:obj:`list` of :obj:`webrtc.RTCRtpCodecParameters`): The codecs it can receive. header_extensions (:obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionParameters`): The header extensions. rtcp (:obj:`webrtc.RTCRtcpParameters`): The RTCP parameters. + codecs (:obj:`list` of :obj:`webrtc.RTCRtpCodecParameters`): The negotiated codecs. """ - codecs: list[RTCRtpCodecParameters] = field(default_factory=list) - header_extensions: list[RTCRtpHeaderExtensionParameters] = field(default_factory=list) - rtcp: RTCRtcpParameters = field(default_factory=RTCRtcpParameters) + header_extensions: list[RTCRtpHeaderExtensionParameters] + rtcp: RTCRtcpParameters + codecs: list[RTCRtpCodecParameters] _dictionaries: ClassVar = { 'codecs': RTCRtpCodecParameters, @@ -328,6 +338,20 @@ class RTCRtpReceiveParameters(Dictionary): 'rtcp': RTCRtcpParameters, } + #: Alias for :attr:`header_extensions` + headerExtensions: ClassVar[Alias[list[RTCRtpHeaderExtensionParameters]]] = alias('header_extensions') + + +@dataclass +class RTCRtpReceiveParameters(RTCRtpParameters): + """The parameters a receiver receives with. + + Args: + header_extensions (:obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionParameters`): The header extensions. + rtcp (:obj:`webrtc.RTCRtcpParameters`): The RTCP parameters. + codecs (:obj:`list` of :obj:`webrtc.RTCRtpCodecParameters`): The codecs it can receive. + """ + @classmethod def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpReceiveParameters: return cls( @@ -336,31 +360,25 @@ def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpReceiveParameters: rtcp=RTCRtcpParameters(reduced_size=native.rtcp.reducedSize), ) - #: Alias for :attr:`header_extensions` - headerExtensions: ClassVar[Alias[list[RTCRtpHeaderExtensionParameters]]] = alias('header_extensions') - @dataclass -class RTCRtpSendParameters(Dictionary): +class RTCRtpSendParameters(RTCRtpParameters): """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 :meth:`webrtc.RTCRtpSender.set_parameters`. Args: - transaction_id (:obj:`str`): Identifies the call of ``get_parameters`` the parameters come from. - encodings (:obj:`list` of :obj:`webrtc.RTCRtpEncodingParameters`): The encodings. - codecs (:obj:`list` of :obj:`webrtc.RTCRtpCodecParameters`): The negotiated codecs. header_extensions (:obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionParameters`): The header extensions. rtcp (:obj:`webrtc.RTCRtcpParameters`): The RTCP parameters. + codecs (:obj:`list` of :obj:`webrtc.RTCRtpCodecParameters`): The negotiated codecs. + transaction_id (:obj:`str`): Identifies the call of ``get_parameters`` the parameters come from. + encodings (:obj:`list` of :obj:`webrtc.RTCRtpEncodingParameters`): The encodings. degradation_preference (:obj:`webrtc.RTCDegradationPreference`, optional): What video degrades first. """ transaction_id: str - encodings: list[RTCRtpEncodingParameters] = field(default_factory=list) - codecs: list[RTCRtpCodecParameters] = field(default_factory=list) - header_extensions: list[RTCRtpHeaderExtensionParameters] = field(default_factory=list) - rtcp: RTCRtcpParameters = field(default_factory=RTCRtcpParameters) + encodings: list[RTCRtpEncodingParameters] degradation_preference: RTCDegradationPreference | RTCDegradationPreferenceValue | None = None _dictionaries: ClassVar = { @@ -383,8 +401,6 @@ def _from_native(cls, native: wrtc.RtpParameters) -> RTCRtpSendParameters: #: Alias for :attr:`transaction_id` transactionId: ClassVar[Alias[str]] = alias('transaction_id') - #: Alias for :attr:`header_extensions` - headerExtensions: ClassVar[Alias[list[RTCRtpHeaderExtensionParameters]]] = alias('header_extensions') #: Alias for :attr:`degradation_preference` degradationPreference: ClassVar[Alias[RTCDegradationPreference | None]] = alias('degradation_preference') @@ -395,12 +411,12 @@ class RTCRtpHeaderExtensionCapability(Dictionary): Args: uri (:obj:`str`): The URI of the extension. - direction (:obj:`webrtc.TransceiverDirection`, optional): In which directions it's negotiated, - :attr:`webrtc.TransceiverDirection.stopped` for not at all. + direction (:obj:`webrtc.RTCRtpTransceiverDirection`, optional): In which directions it's negotiated, + :attr:`webrtc.RTCRtpTransceiverDirection.stopped` for not at all. """ uri: str - direction: TransceiverDirection | TransceiverDirectionValue = TransceiverDirection.sendrecv + direction: RTCRtpTransceiverDirection | RTCRtpTransceiverDirectionValue = RTCRtpTransceiverDirection.sendrecv @classmethod def _from_native(cls, native: wrtc.RtpHeaderExtensionCapability) -> RTCRtpHeaderExtensionCapability: @@ -416,8 +432,8 @@ class RTCRtpCapabilities(Dictionary): header_extensions (:obj:`list` of :obj:`webrtc.RTCRtpHeaderExtensionCapability`): The header extensions. """ - codecs: list[RTCRtpCodec] = field(default_factory=list) - header_extensions: list[RTCRtpHeaderExtensionCapability] = field(default_factory=list) + codecs: list[RTCRtpCodec] + header_extensions: list[RTCRtpHeaderExtensionCapability] _dictionaries: ClassVar = {'codecs': RTCRtpCodec, 'header_extensions': RTCRtpHeaderExtensionCapability} @@ -443,3 +459,34 @@ def _supported( #: Alias for :attr:`header_extensions` headerExtensions: ClassVar[Alias[list[RTCRtpHeaderExtensionCapability]]] = alias('header_extensions') + + +@dataclass +class RTCEncodingOptions(Dictionary): + """How :meth:`webrtc.RTCRtpSender.set_parameters` changes an encoding (WebRTC Extensions). + + Args: + key_frame (:obj:`bool`, optional): Whether the encoding sends a key frame right away. + """ + + key_frame: bool = False + + #: Alias for :attr:`key_frame` + keyFrame: ClassVar[Alias[bool]] = alias('key_frame') + + +@dataclass +class RTCSetParameterOptions(Dictionary): + """The options of :meth:`webrtc.RTCRtpSender.set_parameters`. + + Args: + encoding_options (:obj:`list` of :obj:`webrtc.RTCEncodingOptions`, optional): One per encoding + (WebRTC Extensions). + """ + + encoding_options: list[RTCEncodingOptions] = field(default_factory=list) + + _dictionaries: ClassVar = {'encoding_options': RTCEncodingOptions} + + #: Alias for :attr:`encoding_options` + encodingOptions: ClassVar[Alias[list[RTCEncodingOptions]]] = alias('encoding_options') diff --git a/python-webrtc/python/webrtc/models/sframe_transform_options.py b/python-webrtc/python/webrtc/models/sframe_transform_options.py new file mode 100644 index 0000000..42ef584 --- /dev/null +++ b/python-webrtc/python/webrtc/models/sframe_transform_options.py @@ -0,0 +1,60 @@ +# +# 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 options of the SFrame transforms of WebRTC Encoded Transform.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from webrtc.enums import SFrameCipherSuite, SFrameType +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias + +if TYPE_CHECKING: + from webrtc.enums import SFrameCipherSuiteValue, SFrameTypeValue + + +@dataclass +class SFrameTransformOptions(Dictionary): + """The options of an :obj:`webrtc.RTCRtpSFrameDecryptor` and of the SFrame streams. + + Args: + cipher_suite (:obj:`webrtc.SFrameCipherSuite`): The cipher suite, or its value. + + Raises: + ValueError: If the cipher suite isn't a member of :obj:`webrtc.SFrameCipherSuite`. + """ + + cipher_suite: SFrameCipherSuite | SFrameCipherSuiteValue + + def __post_init__(self) -> None: + self.cipher_suite = SFrameCipherSuite(self.cipher_suite) + + #: Alias for :attr:`cipher_suite` + cipherSuite: ClassVar[Alias[SFrameCipherSuite | SFrameCipherSuiteValue]] = alias('cipher_suite') + + +@dataclass +class RTCRtpSFrameEncryptorOptions(SFrameTransformOptions): + """The options of an :obj:`webrtc.RTCRtpSFrameEncryptor`. + + Args: + cipher_suite (:obj:`webrtc.SFrameCipherSuite`): The cipher suite, or its value. + type (:obj:`webrtc.SFrameType`, optional): Whether whole frames or RTP packets are encrypted, or its value. + Only ``'per-frame'`` is supported. + + Raises: + ValueError: If the cipher suite or the type isn't a member of its enum. + """ + + type: SFrameType | SFrameTypeValue = SFrameType.per_frame + + def __post_init__(self) -> None: + super().__post_init__() + self.type = SFrameType(self.type) diff --git a/python-webrtc/python/webrtc/models/video_frame.py b/python-webrtc/python/webrtc/models/video_frame.py index 8e59a02..4d8280a 100644 --- a/python-webrtc/python/webrtc/models/video_frame.py +++ b/python-webrtc/python/webrtc/models/video_frame.py @@ -10,17 +10,21 @@ from __future__ import annotations import asyncio +import copy import math import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING, ClassVar, NamedTuple, TypeVar, cast, overload from webrtc import ( AlphaOption, AlphaOptionValue, + DataCloneError, InvalidStateError, NotSupportedError, + PredefinedColorSpace, + PredefinedColorSpaceValue, RTCException, VideoColorPrimaries, VideoColorPrimariesValue, @@ -35,6 +39,7 @@ from webrtc.models.closable import Closable from webrtc.models.dictionary import Dictionary from webrtc.utils.names import Alias, alias +from webrtc.utils.transfer import Transfer if TYPE_CHECKING: from typing_extensions import Buffer, TypeGuard @@ -81,6 +86,35 @@ def left(self) -> float: """:obj:`float`: The left edge.""" return min(self.x, self.x + self.width) + @classmethod + def from_rect(cls, other: DOMRectInit | DOMRectReadOnly | None = None) -> DOMRectReadOnly: + """Returns a rectangle of the members of another one. + + Args: + other (:obj:`DOMRectInit`, optional): The rectangle, empty by default. + """ + if other is None: + return cls() + return cls(other.x, other.y, other.width, other.height) + + def to_json(self) -> dict[str, float]: + """Returns the attributes as a dictionary, like ``toJSON()``.""" + return { + 'x': self.x, + 'y': self.y, + 'width': self.width, + 'height': self.height, + 'top': self.top, + 'right': self.right, + 'bottom': self.bottom, + 'left': self.left, + } + + #: Alias for :meth:`from_rect` + fromRect: ClassVar = from_rect + #: Alias for :meth:`to_json` + toJSON: ClassVar = to_json + @dataclass class DOMRectInit(Dictionary): @@ -210,9 +244,17 @@ class VideoFrameBufferInit(Dictionary): 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:`VideoColorSpaceInit`, optional): The color space. + metadata (:obj:`VideoFrameMetadata`, optional): What else is known of the frame, copied. + transfer (:obj:`list` of bytes-like buffers, optional): Buffers given up to the frame. The pixels are copied + all the same; transferred :obj:`memoryview` objects are released, and Python can't detach other buffers. """ - _dictionaries: ClassVar = {'layout': PlaneLayout, 'visible_rect': DOMRectInit, 'color_space': VideoColorSpaceInit} + _dictionaries: ClassVar = { + 'layout': PlaneLayout, + 'visible_rect': DOMRectInit, + 'color_space': VideoColorSpaceInit, + 'metadata': VideoFrameMetadata, + } format: VideoPixelFormat | VideoPixelFormatValue coded_width: int @@ -226,6 +268,8 @@ class VideoFrameBufferInit(Dictionary): display_width: int | None = None display_height: int | None = None color_space: VideoColorSpaceInit | VideoColorSpace | None = None + metadata: VideoFrameMetadata | None = None + transfer: list[Buffer] = field(default_factory=list) #: Alias for :attr:`coded_width` codedWidth: ClassVar[Alias[int]] = alias('coded_width') @@ -254,9 +298,10 @@ class VideoFrameInit(Dictionary): 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``. + metadata (:obj:`VideoFrameMetadata`, optional): What else is known of the frame, copied. """ - _dictionaries: ClassVar = {'visible_rect': DOMRectInit} + _dictionaries: ClassVar = {'visible_rect': DOMRectInit, 'metadata': VideoFrameMetadata} timestamp: int | None = None duration: int | None = None @@ -266,6 +311,7 @@ class VideoFrameInit(Dictionary): flip: bool = False display_width: int | None = None display_height: int | None = None + metadata: VideoFrameMetadata | None = None #: Alias for :attr:`visible_rect` visibleRect: ClassVar[Alias[DOMRectInit | DOMRectReadOnly | None]] = alias('visible_rect') @@ -284,6 +330,8 @@ class VideoFrameCopyToOptions(Dictionary): 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``. + color_space (:obj:`webrtc.PredefinedColorSpace`, optional): The color space to convert to an RGB format in, + ``srgb`` by default and the only one supported. """ _dictionaries: ClassVar = {'rect': DOMRectInit, 'layout': PlaneLayout} @@ -291,6 +339,10 @@ class VideoFrameCopyToOptions(Dictionary): rect: DOMRectInit | DOMRectReadOnly | None = None layout: list[PlaneLayout] | None = None format: VideoPixelFormat | VideoPixelFormatValue | None = None + color_space: PredefinedColorSpace | PredefinedColorSpaceValue | None = None + + #: Alias for :attr:`color_space` + colorSpace: ClassVar[Alias[PredefinedColorSpace | PredefinedColorSpaceValue | None]] = alias('color_space') class _Plane(NamedTuple): @@ -510,6 +562,19 @@ def _color_space(value: VideoColorSpaceInit | VideoColorSpace | None) -> VideoCo ) +def _copy_metadata(metadata: VideoFrameMetadata | None) -> VideoFrameMetadata: + """Copy VideoFrame metadata: a deep copy, as structured cloning makes.""" + if metadata is None: + return VideoFrameMetadata() + if not isinstance(metadata, VideoFrameMetadata): + msg = f'metadata is a VideoFrameMetadata, not {type(metadata).__name__}' + raise TypeError(msg) + try: + return copy.deepcopy(metadata) + except (TypeError, copy.Error) as e: + raise DataCloneError(str(e)) from None + + def _coded_size(init: VideoFrameBufferInit) -> tuple[int, int]: size = _dimension(init.coded_width, 'coded_width'), _dimension(init.coded_height, 'coded_height') if 0 in size: @@ -586,14 +651,15 @@ class VideoFrame(Closable): _metadata: VideoFrameMetadata @overload - def __init__(self, source: VideoFrame, init: VideoFrameInit | None = None) -> None: ... + def __init__(self, image: VideoFrame, /, init: VideoFrameInit | None = None) -> None: ... @overload - def __init__(self, source: Buffer, init: VideoFrameBufferInit) -> None: ... + def __init__(self, data: Buffer, /, init: VideoFrameBufferInit) -> None: ... def __init__( self, source: Buffer | VideoFrame, + /, init: VideoFrameBufferInit | VideoFrameInit | None = None, ) -> None: self._resource = None @@ -619,11 +685,13 @@ def _init_from_buffer(self, data: Buffer, init: VideoFrameBufferInit) -> None: msg = 'The timestamp is an integer of microseconds' raise TypeError(msg) + transfer = Transfer(init.transfer) resource, (width, height) = _visible_resource(data, init, format, coded_size=coded_size) rotation = _rotation(init.rotation) color_space = _color_space(init.color_space) if color_space is None: color_space = _SRGB if format in _RGB_FORMATS else _REC709 + transfer.detach() self._set( resource, format, @@ -633,7 +701,7 @@ def _init_from_buffer(self, data: Buffer, init: VideoFrameBufferInit) -> None: rotation, flip=bool(init.flip), ), - info=_FrameInfo(init.timestamp, init.duration, color_space, VideoFrameMetadata()), + info=_FrameInfo(init.timestamp, init.duration, color_space, _copy_metadata(init.metadata)), ) def _init_from_frame(self, other: VideoFrame, init: VideoFrameInit) -> None: @@ -660,7 +728,7 @@ def _init_from_frame(self, other: VideoFrame, init: VideoFrameInit) -> None: init.timestamp if init.timestamp is not None else other._timestamp, init.duration if init.duration is not None else other._duration, other._color_space, - other._metadata, + other._metadata if init.metadata is None else _copy_metadata(init.metadata), ), ) @@ -789,7 +857,7 @@ def metadata(self) -> VideoFrameMetadata: if self._resource is None: msg = 'The frame is closed' raise InvalidStateError(msg) - return VideoFrameMetadata(self._metadata.rtp_timestamp) + return _copy_metadata(self._metadata) def _plan_copy(self, options: VideoFrameCopyToOptions | None) -> _CopyPlan: resource = self._resource @@ -804,6 +872,12 @@ def _plan_copy(self, options: VideoFrameCopyToOptions | None) -> _CopyPlan: if format != self._format and format not in _RGB_FORMATS: msg = f'Frames are converted to RGB formats only, not {format.value}' raise NotSupportedError(msg) + if options.color_space is not None: + color_space = _enum(PredefinedColorSpace, options.color_space) + # libyuv keeps the primaries and transfer of the frame, which is sRGB as far as it can tell + if options.format is not None and format in _RGB_FORMATS and color_space != PredefinedColorSpace.srgb: + msg = f'Frames are converted to RGB in srgb only, not {color_space.value}' + raise NotSupportedError(msg) coded_size = (self.coded_width, self.coded_height) rect = _parse_visible_rect(self._visible_rect, _rect(options.rect), coded_size=coded_size, format=self._format) size, planes = _compute_layout(rect, format, _layout(options.layout)) diff --git a/python-webrtc/python/webrtc/streams.py b/python-webrtc/python/webrtc/streams.py index 0aafb79..ae4f1ec 100644 --- a/python-webrtc/python/webrtc/streams.py +++ b/python-webrtc/python/webrtc/streams.py @@ -16,19 +16,31 @@ import asyncio import collections import inspect +import math +from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Generator, Iterable, Iterator from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Generic, NamedTuple, Protocol, cast +from typing import TYPE_CHECKING, Callable, ClassVar, Generic, Protocol, cast from typing_extensions import TypeVar +from webrtc.enums import ReadableStreamReaderMode +from webrtc.exceptions import InvalidRangeError +from webrtc.models.dictionary import Dictionary +from webrtc.utils.names import Alias, alias + if TYPE_CHECKING: - from collections.abc import AsyncIterator + from webrtc.enums import ReadableStreamReaderModeValue __all__ = [ + 'QueuingStrategy', 'ReadableStream', 'ReadableStreamDefaultController', 'ReadableStreamDefaultReader', + 'ReadableStreamGetReaderOptions', + 'ReadableStreamIteratorOptions', 'ReadableStreamReadResult', + 'ReadableWritablePair', + 'StreamPipeOptions', 'TransformStream', 'TransformStreamDefaultController', 'WritableStream', @@ -42,6 +54,8 @@ #: The type of the chunks a transform stream outputs _O = TypeVar('_O', default=object) _R = TypeVar('_R') +#: The type of the chunks a writable stream takes: one taking any object takes chunks of any type +_W_contra = TypeVar('_W_contra', contravariant=True, default=object) def _loop() -> asyncio.AbstractEventLoop: @@ -105,12 +119,11 @@ def _reject(future: asyncio.Future[_R], error: BaseException) -> asyncio.Future[ return future -def _error_or_default(error: BaseException | None) -> BaseException: - return error if error is not None else TypeError('The stream errored') - - -def _reason_error(reason: object) -> BaseException: - return reason if isinstance(reason, BaseException) else TypeError(str(reason)) +def _error_of(e: object) -> BaseException: + """The exception for the error of a stream, which may be any value as in the specification.""" + if isinstance(e, BaseException): + return e + return TypeError('The stream errored' if e is None else str(e)) def _member(obj: object, name: str) -> Callable[..., object] | None: @@ -177,45 +190,151 @@ def _run( _then(result, on_done, on_error) -class _ReadableWritablePair(Protocol[_T, _O]): +class _GenericTransformStream(Protocol[_W_contra, _O]): + """A pair of streams like :obj:`TransformStream`.""" + @property def readable(self) -> ReadableStream[_O]: ... @property - def writable(self) -> WritableStream[_T]: ... + def writable(self) -> WritableStream[_W_contra]: ... + + +@dataclass +class QueuingStrategy(Dictionary, Generic[_T]): + """How a stream counts its queue, for the constructors of streams. + + Args: + high_water_mark (:obj:`float`, optional): The total size of the chunks queued until the stream applies + backpressure, which depends on the stream by default. + size (optional): The size of a chunk, 1 for each by default. + """ + + high_water_mark: float | None = None + size: Callable[[_T], float] | None = None + + #: Alias for :attr:`high_water_mark` + highWaterMark: ClassVar[Alias[float | None]] = alias('high_water_mark') + + +@dataclass(frozen=True) +class _Strategy(Generic[_T]): + high_water_mark: float + size: Callable[[_T], float] | None -class _PipeOptions(NamedTuple): - prevent_close: bool - prevent_abort: bool - prevent_cancel: bool +def _extract_strategy(strategy: QueuingStrategy[_T] | None, default: float) -> _Strategy[_T]: + """The strategy with its default high water mark, which must be a non-negative number.""" + if strategy is None: + strategy = QueuingStrategy() + high_water_mark = default if strategy.high_water_mark is None else float(strategy.high_water_mark) + if math.isnan(high_water_mark) or high_water_mark < 0: + msg = 'The high water mark is negative or NaN' + raise InvalidRangeError(msg) + return _Strategy(high_water_mark, strategy.size) + + +def _chunk_size(strategy: _Strategy[_T], chunk: _T) -> float: + """The size of a chunk, which must be a finite non-negative number.""" + if strategy.size is None: + return 1 + size = float(strategy.size(chunk)) + if not math.isfinite(size) or size < 0: + msg = 'The size of a chunk is negative, NaN or infinite' + raise InvalidRangeError(msg) + return size @dataclass -class ReadableStreamReadResult(Generic[_T]): - """The result of :meth:`ReadableStreamDefaultReader.read`. +class ReadableStreamGetReaderOptions(Dictionary): + """The options of :meth:`ReadableStream.get_reader`. Args: - value: The chunk, :obj:`None` once done. - done (:obj:`bool`): Whether the stream is closed and has no more chunks. + mode (:obj:`webrtc.ReadableStreamReaderMode`, optional): The type of reader, a default one if :obj:`None`. + + Raises: + ValueError: If the mode isn't a member of :obj:`webrtc.ReadableStreamReaderMode`. + """ + + mode: ReadableStreamReaderMode | ReadableStreamReaderModeValue | None = None + + def __post_init__(self) -> None: + if self.mode is not None: + self.mode = ReadableStreamReaderMode(self.mode) + + +@dataclass +class ReadableStreamIteratorOptions(Dictionary): + """The options of :meth:`ReadableStream.values`. + + Args: + prevent_cancel (:obj:`bool`, optional): Whether the stream is left open when the iteration stops early. """ - value: _T | None - done: bool + prevent_cancel: bool = False + #: Alias for :attr:`prevent_cancel` + preventCancel: ClassVar[Alias[bool]] = alias('prevent_cancel') -#: The result of a read, by another name: tests/idl/expectations.json expects read() not to name it yet -_ReadResult = ReadableStreamReadResult + +@dataclass +class ReadableWritablePair(Dictionary, Generic[_T, _O]): + """A writable stream and the readable one its chunks come out of, for :meth:`ReadableStream.pipe_through`. + + Args: + readable (:obj:`ReadableStream`): The stream to read. + writable (:obj:`WritableStream`): The stream to write. + """ + + readable: ReadableStream[_O] + writable: WritableStream[_T] + + +@dataclass +class StreamPipeOptions(Dictionary): + """The options of :meth:`ReadableStream.pipe_to` and :meth:`ReadableStream.pipe_through`. + + Args: + prevent_close (:obj:`bool`, optional): Whether the destination is left open when the source closes. + prevent_abort (:obj:`bool`, optional): Whether the destination is left as it is when the source errors. + prevent_cancel (:obj:`bool`, optional): Whether the source is left as it is when the destination errors. + """ + + prevent_close: bool = False + prevent_abort: bool = False + prevent_cancel: bool = False + + #: Alias for :attr:`prevent_close` + preventClose: ClassVar[Alias[bool]] = alias('prevent_close') + #: Alias for :attr:`prevent_abort` + preventAbort: ClassVar[Alias[bool]] = alias('prevent_abort') + #: Alias for :attr:`prevent_cancel` + preventCancel: ClassVar[Alias[bool]] = alias('prevent_cancel') + + +@dataclass +class ReadableStreamReadResult(Dictionary, Generic[_T]): + """The result of :meth:`ReadableStreamDefaultReader.read`. + + Args: + value (optional): The chunk, :obj:`None` once done. + done (:obj:`bool`, optional): Whether the stream is closed and has no more chunks. + """ + + value: _T | None = None + done: bool = False class ReadableStreamDefaultController(Generic[_T]): """Lets an underlying source enqueue chunks, close or error its stream.""" - def __init__(self, stream: ReadableStream[_T], source: object, high_water_mark: float) -> None: + def __init__(self, stream: ReadableStream[_T], source: object, strategy: _Strategy[_T]) -> None: self._stream = stream self._source = source - self._high_water_mark = high_water_mark - self._queue: collections.deque[_T] = collections.deque() + self._strategy = strategy + # (chunk, size) of the queued chunks + self._queue: collections.deque[tuple[_T, float]] = collections.deque() + self._queue_total_size = 0.0 self._close_requested = False self._started = False self._pulling = False @@ -223,29 +342,26 @@ def __init__(self, stream: ReadableStream[_T], source: object, high_water_mark: @property def desired_size(self) -> float | None: - """:obj:`float`, optional: How many chunks the queue can take until it's full, :obj:`None` if errored.""" + """:obj:`float`, optional: The size of the chunks the queue takes until it's full, :obj:`None` if errored.""" state = self._stream._state if state == 'errored': return None if state == 'closed': return 0 - return self._high_water_mark - len(self._queue) + return self._strategy.high_water_mark - self._queue_total_size - def enqueue(self, chunk: _T) -> None: + def enqueue(self, chunk: _T | None = None) -> None: """Enqueues a chunk, which fulfills a pending read if there's one. Raises: TypeError: If the stream is closed or closing. + webrtc.InvalidRangeError: If the size of the chunk isn't a finite non-negative number, which errors the + stream, as anything the size function raises does. """ - if self._close_requested or self._stream._state != 'readable': + if not self._can_close_or_enqueue(): msg = 'The stream is closed or closing' raise TypeError(msg) - reader = self._stream._reader - if reader is not None and len(reader._read_requests) > 0: - _settle(reader._read_requests.popleft(), ReadableStreamReadResult(chunk, done=False)) - else: - self._queue.append(chunk) - self._call_pull_if_needed() + self._enqueue(cast('_T', chunk)) def close(self) -> None: """Closes the stream once its queue is read. @@ -253,19 +369,43 @@ def close(self) -> None: Raises: TypeError: If the stream is closed or closing. """ - if self._close_requested or self._stream._state != 'readable': + if not self._can_close_or_enqueue(): msg = 'The stream is closed or closing' raise TypeError(msg) - self._close_requested = True - if len(self._queue) == 0: - self._stream._close() + self._close() - def error(self, error: BaseException | None = None) -> None: + def error(self, e: object = None) -> None: """Errors the stream: pending and later reads fail with the error.""" if self._stream._state != 'readable': return + self._reset_queue() + self._stream._error(_error_of(e)) + + def _can_close_or_enqueue(self) -> bool: + return not self._close_requested and self._stream._state == 'readable' + + def _enqueue(self, chunk: _T) -> None: + reader = self._stream._reader + if reader is not None and len(reader._read_requests) > 0: + _settle(reader._read_requests.popleft(), ReadableStreamReadResult(chunk, done=False)) + else: + try: + size = _chunk_size(self._strategy, chunk) + except Exception as e: + self.error(e) + raise + self._queue.append((chunk, size)) + self._queue_total_size += size + self._call_pull_if_needed() + + def _close(self) -> None: + self._close_requested = True + if len(self._queue) == 0: + self._stream._close() + + def _reset_queue(self) -> None: self._queue.clear() - self._stream._error(_error_or_default(error)) + self._queue_total_size = 0.0 def _start(self) -> None: def started() -> None: @@ -299,9 +439,13 @@ def pulled() -> None: _run(self._source, 'pull', self, on_done=pulled, on_error=self.error) - def _read(self, reader: ReadableStreamDefaultReader[_T], request: asyncio.Future[_ReadResult[_T]]) -> None: + def _read( + self, reader: ReadableStreamDefaultReader[_T], request: asyncio.Future[ReadableStreamReadResult[_T]] + ) -> None: if len(self._queue) > 0: - chunk = self._queue.popleft() + chunk, size = self._queue.popleft() + # rounding errors could leave it below 0 + self._queue_total_size = max(0.0, self._queue_total_size - size) if self._close_requested and len(self._queue) == 0: self._stream._close() else: @@ -312,7 +456,7 @@ def _read(self, reader: ReadableStreamDefaultReader[_T], request: asyncio.Future self._call_pull_if_needed() def _cancel(self, reason: object) -> object: - self._queue.clear() + self._reset_queue() return _call(self._source, 'cancel', reason) #: Alias for :attr:`desired_size` @@ -325,26 +469,62 @@ class ReadableStream(Generic[_T]): Args: underlying_source (optional): An object with optional ``start(controller)``, ``pull(controller)`` and ``cancel(reason)`` methods (or a :obj:`dict` of them), which may be coroutine functions. - high_water_mark (:obj:`float`, optional): How many chunks are queued ahead of reads, 1 by default. + strategy (:obj:`QueuingStrategy`, optional): How the queue is counted, a chunk each up to 1 by default. + + Raises: + webrtc.InvalidRangeError: If the high water mark of the strategy is negative or NaN. """ - def __init__(self, underlying_source: object = None, high_water_mark: float = 1) -> None: + def __init__(self, underlying_source: object = None, strategy: QueuingStrategy[_T] | None = None) -> None: + extracted = _extract_strategy(strategy, 1) self._state = 'readable' self._stored_error: BaseException | None = None self._reader: ReadableStreamDefaultReader[_T] | None = None - self._controller = ReadableStreamDefaultController(self, underlying_source, high_water_mark) + self._controller = ReadableStreamDefaultController(self, underlying_source, extracted) self._controller._start() + @staticmethod + def from_(async_iterable: AsyncIterable[_R] | Iterable[_R]) -> ReadableStream[_R]: + """Returns a stream of the items of an iterable, asynchronous or not (``ReadableStream.from`` in browsers). + + Canceling the stream closes the iterator, like a generator, if it can be closed. + + Args: + async_iterable: The iterable. + + Returns: + :obj:`ReadableStream`: The stream. + + Raises: + TypeError: If the object isn't iterable. + """ + iterator: AsyncIterator[_R] | Iterator[_R] + if isinstance(async_iterable, AsyncIterable): + iterator = async_iterable.__aiter__() + elif isinstance(async_iterable, Iterable): + iterator = iter(async_iterable) + else: + msg = f'{type(async_iterable).__name__} is not iterable' + raise TypeError(msg) + return ReadableStream(_IteratorSource(iterator), QueuingStrategy(high_water_mark=0)) + @property def locked(self) -> bool: """:obj:`bool`: Whether a reader holds the stream.""" return self._reader is not None - def get_reader(self) -> ReadableStreamDefaultReader[_T]: + def get_reader(self, options: ReadableStreamGetReaderOptions | None = None) -> ReadableStreamDefaultReader[_T]: """Returns a reader, which holds the stream until it's released. - Raises :obj:`TypeError` if the stream is locked. + Args: + options (:obj:`ReadableStreamGetReaderOptions`, optional): The type of reader, a default one if not set. + + Raises: + TypeError: If the stream is locked, or the mode is ``byob``, which only byte streams support. """ + if options is not None and options.mode == ReadableStreamReaderMode.byob: + msg = 'Only byte streams have BYOB readers' + raise TypeError(msg) return ReadableStreamDefaultReader(self) def cancel(self, reason: object = None) -> asyncio.Future[None]: @@ -358,20 +538,15 @@ def cancel(self, reason: object = None) -> asyncio.Future[None]: return self._cancel(reason) def pipe_to( - self, - destination: WritableStream[_T], - *, - prevent_close: bool = False, - prevent_abort: bool = False, - prevent_cancel: bool = False, + self, destination: WritableStream[_T], options: StreamPipeOptions | None = None ) -> asyncio.Future[None]: """Writes every chunk of the stream to a writable stream, waiting for it when it's full. + Canceling the returned future stops the pipe like an abort signal in browsers. + Args: destination (:obj:`WritableStream`): The stream to write to. - prevent_close (:obj:`bool`, optional): Whether the destination is left open when this stream closes. - prevent_abort (:obj:`bool`, optional): Whether the destination is left as it is when this stream errors. - prevent_cancel (:obj:`bool`, optional): Whether this stream is left as it is when the destination errors. + options (:obj:`StreamPipeOptions`, optional): What the pipe leaves as it is when it stops. Returns: :obj:`asyncio.Future`: Done once every chunk is written, or failed with the error that stopped it. @@ -380,8 +555,9 @@ def pipe_to( return _rejected(TypeError('A stream is locked')) reader = self.get_reader() writer = destination.get_writer() - options = _PipeOptions(prevent_close, prevent_abort, prevent_cancel) - pipe = asyncio.ensure_future(self._pipe(reader, writer, options)) + pipe = asyncio.ensure_future( + self._pipe(reader, writer, options if options is not None else StreamPipeOptions()) + ) # kept until done, as in browsers: asyncio keeps tasks weakly _running_pipes.add(pipe) pipe.add_done_callback(_running_pipes.discard) @@ -389,7 +565,7 @@ def pipe_to( @staticmethod async def _pipe( - reader: ReadableStreamDefaultReader[_T], writer: WritableStreamDefaultWriter[_T], options: _PipeOptions + reader: ReadableStreamDefaultReader[_T], writer: WritableStreamDefaultWriter[_T], options: StreamPipeOptions ) -> None: try: await _pipe_chunks(reader, writer, prevent_close=options.prevent_close) @@ -403,31 +579,56 @@ async def _pipe( reader.release_lock() writer.release_lock() - def pipe_through(self, transform: _ReadableWritablePair[_T, _O], **options: bool) -> ReadableStream[_O]: + def pipe_through( + self, + transform: ReadableWritablePair[_T, _O] | _GenericTransformStream[_T, _O], + options: StreamPipeOptions | None = None, + ) -> ReadableStream[_O]: """Pipes the stream into the writable side of a transform (like :obj:`TransformStream`). Args: - transform: An object with ``writable`` and ``readable`` streams. - **options: The options of :meth:`pipe_to`. + transform (:obj:`ReadableWritablePair`): The streams, or an object with ``writable`` and ``readable`` + ones, like :obj:`TransformStream`. + options (:obj:`StreamPipeOptions`, optional): The options of :meth:`pipe_to`. Returns: :obj:`ReadableStream`: The readable side of the transform. + + Raises: + TypeError: If this stream or the writable side is locked. """ - _ = _handled(self.pipe_to(transform.writable, **options)) + if self.locked or transform.writable.locked: + msg = 'A stream is locked' + raise TypeError(msg) + _ = _handled(self.pipe_to(transform.writable, options)) return transform.readable - def values(self, *, prevent_cancel: bool = False) -> AsyncIterator[_T]: + def tee(self) -> list[ReadableStream[_T]]: + """Splits the stream into two branches, each reading every chunk, which locks the stream. + + The stream is canceled once both branches are, and the branches error when it does. + + Returns: + :obj:`list` of :obj:`ReadableStream`: The two branches. + + Raises: + TypeError: If the stream is locked. + """ + return _Tee(self).branches + + def values(self, options: ReadableStreamIteratorOptions | None = None) -> AsyncIterator[_T]: """Iterates over the chunks, like ``async for``. Stopping early cancels the stream once the iterator is finalized, right away with :func:`contextlib.aclosing`. Args: - prevent_cancel (:obj:`bool`, optional): Whether the stream is left open when the iteration stops early. + options (:obj:`ReadableStreamIteratorOptions`, optional): Whether stopping early leaves the stream open. Returns: An asynchronous iterator of the chunks. """ + prevent_cancel = options is not None and options.prevent_cancel return _iterate(self.get_reader(), prevent_cancel=prevent_cancel) def __aiter__(self) -> AsyncIterator[_T]: @@ -455,7 +656,7 @@ def _has_read_requests(self) -> bool: def _error_stored(self) -> BaseException: """The error of an errored stream.""" - return _error_or_default(self._stored_error) + return _error_of(self._stored_error) def _cancel(self, reason: object) -> asyncio.Future[None]: if self._state == 'closed': @@ -488,7 +689,7 @@ def __init__(self, stream: ReadableStream[_T]) -> None: msg = 'The stream is locked' raise TypeError(msg) self._stream: ReadableStream[_T] | None = stream - self._read_requests: collections.deque[asyncio.Future[_ReadResult[_T]]] = collections.deque() + self._read_requests: collections.deque[asyncio.Future[ReadableStreamReadResult[_T]]] = collections.deque() self._closed: asyncio.Future[None] = _handled(_pending()) stream._reader = self if stream._state == 'closed': @@ -501,7 +702,7 @@ def closed(self) -> asyncio.Future[None]: """:obj:`asyncio.Future`: Done once the stream is closed, failed if it errors or the lock is released.""" return self._closed - def read(self) -> asyncio.Future[_ReadResult[_T]]: + def read(self) -> asyncio.Future[ReadableStreamReadResult[_T]]: """Reads the next chunk. Returns: @@ -515,7 +716,7 @@ def read(self) -> asyncio.Future[_ReadResult[_T]]: return _resolved(ReadableStreamReadResult(None, done=True)) if stream._state == 'errored': return _rejected(stream._error_stored()) - request: asyncio.Future[_ReadResult[_T]] = _pending() + request: asyncio.Future[ReadableStreamReadResult[_T]] = _pending() stream._controller._read(self, request) return request @@ -575,7 +776,7 @@ async def _stop_pipe( writer: WritableStreamDefaultWriter[_T], error: BaseException, *, - options: _PipeOptions, + options: StreamPipeOptions, ) -> None: """Cancels the source or aborts the destination of a pipe that failed, unless the options prevent it.""" stream = writer._stream @@ -606,29 +807,134 @@ async def _iterate(reader: ReadableStreamDefaultReader[_T], *, prevent_cancel: b reader.release_lock() +class _Tee(Generic[_T]): + """Reads a stream for two branches, as ReadableStreamDefaultTee in the specification.""" + + def __init__(self, stream: ReadableStream[_T]) -> None: + self._stream = stream + self._reader = ReadableStreamDefaultReader(stream) + self._reading = False + self._read_again = False + self._canceled = [False, False] + self._reasons: list[object] = [None, None] + self._canceled_future: asyncio.Future[None] = _handled(_pending()) + self.branches: list[ReadableStream[_T]] = [ReadableStream(_TeeBranch(self, i)) for i in range(2)] + self._reader.closed.add_done_callback(self._closed) + + def _pull(self) -> None: + if self._reading: + self._read_again = True + return + self._reading = True + self._reader.read().add_done_callback(self._read) + + def _open_branches(self) -> list[ReadableStreamDefaultController[_T]]: + controllers = [branch._controller for i, branch in enumerate(self.branches) if not self._canceled[i]] + return [controller for controller in controllers if controller._can_close_or_enqueue()] + + def _read(self, read: asyncio.Future[ReadableStreamReadResult[_T]]) -> None: + if read.cancelled() or read.exception() is not None: + # the closed future errors the branches + self._reading = False + return + result = read.result() + if result.done: + self._reading = False + for controller in self._open_branches(): + controller._close() + if not all(self._canceled): + _settle(self._canceled_future, None) + return + self._read_again = False + for controller in self._open_branches(): + controller._enqueue(_chunk(result)) + self._reading = False + if self._read_again: + self._pull() + + def _cancel(self, index: int, reason: object) -> asyncio.Future[None]: + self._canceled[index] = True + self._reasons[index] = reason + if all(self._canceled): + # the stream is canceled with the reasons of both branches + canceled = self._stream._cancel(list(self._reasons)) + + def settle(future: asyncio.Future[None]) -> None: + error = asyncio.CancelledError() if future.cancelled() else future.exception() + _settle(self._canceled_future, None, error) + + canceled.add_done_callback(settle) + return self._canceled_future + + def _closed(self, closed: asyncio.Future[None]) -> None: + error = None if closed.cancelled() else closed.exception() + if error is None: + return + for branch in self.branches: + branch._controller.error(error) + if not all(self._canceled): + _settle(self._canceled_future, None) + + +class _TeeBranch(Generic[_T]): + def __init__(self, tee: _Tee[_T], index: int) -> None: + self._tee = tee + self._index = index + + def pull(self, _controller: ReadableStreamDefaultController[_T]) -> None: + self._tee._pull() + + def cancel(self, reason: object) -> asyncio.Future[None]: + return self._tee._cancel(self._index, reason) + + +class _IteratorSource(Generic[_T]): + """The underlying source of :meth:`ReadableStream.from_`.""" + + def __init__(self, iterator: AsyncIterator[_T] | Iterator[_T]) -> None: + self._iterator = iterator + + async def pull(self, controller: ReadableStreamDefaultController[_T]) -> None: + iterator = self._iterator + try: + chunk = await iterator.__anext__() if isinstance(iterator, AsyncIterator) else next(iterator) + except (StopAsyncIteration, StopIteration): + controller.close() + return + controller.enqueue(chunk) + + async def cancel(self, _reason: object) -> None: + # the return() of an iterator in the specification + iterator = self._iterator + if isinstance(iterator, AsyncGenerator): + await iterator.aclose() + elif isinstance(iterator, Generator): + iterator.close() + + # the close request queued after the writes _CLOSE = object() -class WritableStreamDefaultController(Generic[_T]): +class WritableStreamDefaultController(Generic[_W_contra]): """Lets an underlying sink error its stream.""" - def __init__(self, stream: WritableStream[_T], sink: object, high_water_mark: float) -> None: + def __init__(self, stream: WritableStream[_W_contra], sink: object, strategy: _Strategy[_W_contra]) -> None: self._stream = stream self._sink = sink - self._high_water_mark = high_water_mark - # (chunk, future) of the writes, then (_CLOSE, future) - self._queue: collections.deque[tuple[object, asyncio.Future[None]]] = collections.deque() + self._strategy = strategy + # (chunk, future, size) of the writes, then (_CLOSE, future, 0) + self._queue: collections.deque[tuple[object, asyncio.Future[None], float]] = collections.deque() self._started = False self._in_flight = False - def error(self, error: BaseException | None = None) -> None: + def error(self, e: object = None) -> None: """Errors the stream: pending and later writes fail with the error.""" if self._stream._state == 'writable': - self._stream._start_erroring(_error_or_default(error)) + self._stream._start_erroring(_error_of(e)) def _desired_size(self) -> float: - return self._high_water_mark - sum(1 for chunk, _ in self._queue if chunk is not _CLOSE) + return self._strategy.high_water_mark - sum(size for _, _, size in self._queue) def _start(self) -> None: def started() -> None: @@ -641,14 +947,21 @@ def failed(error: BaseException) -> None: _then(_call(self._sink, 'start', self), started, failed) - def _write(self, chunk: _T, future: asyncio.Future[None]) -> None: - self._queue.append((chunk, future)) + def _write(self, chunk: _W_contra, future: asyncio.Future[None]) -> None: + try: + size = _chunk_size(self._strategy, chunk) + except Exception as e: + # queued to be rejected once the stream errors + self._queue.append((chunk, future, 0)) + self.error(e) + return + self._queue.append((chunk, future, size)) # advance first: a sink done right away leaves no backpressure to signal self._advance() self._stream._update_backpressure() def _close(self, future: asyncio.Future[None]) -> None: - self._queue.append((_CLOSE, future)) + self._queue.append((_CLOSE, future, 0)) self._advance() def _advance(self) -> None: @@ -660,7 +973,7 @@ def _advance(self) -> None: return if stream._state != 'writable': return - chunk, future = self._queue[0] + chunk, future, _ = self._queue[0] self._in_flight = True def failed(error: BaseException) -> None: @@ -692,29 +1005,32 @@ def _closed(self, future: asyncio.Future[None]) -> None: def _reject_queue(self, error: BaseException) -> None: """Rejects the queued requests but the one in flight.""" in_flight = self._queue.popleft() if self._in_flight else None - for _, future in self._queue: + for _, future, _ in self._queue: _fail(future, error) self._queue.clear() if in_flight is not None: self._queue.append(in_flight) -class WritableStream(Generic[_T]): +class WritableStream(Generic[_W_contra]): """A stream to write chunks to (https://developer.mozilla.org/en-US/docs/Web/API/WritableStream). Args: underlying_sink (optional): An object with optional ``start(controller)``, ``write(chunk, controller)``, ``close()`` and ``abort(reason)`` methods (or a :obj:`dict` of them), which may be coroutine functions. - high_water_mark (:obj:`float`, optional): How many chunks are queued until writers see backpressure, - 1 by default. + strategy (:obj:`QueuingStrategy`, optional): How the queue is counted, a chunk each up to 1 by default. + + Raises: + webrtc.InvalidRangeError: If the high water mark of the strategy is negative or NaN. """ - def __init__(self, underlying_sink: object = None, high_water_mark: float = 1) -> None: + def __init__(self, underlying_sink: object = None, strategy: QueuingStrategy[_W_contra] | None = None) -> None: + extracted = _extract_strategy(strategy, 1) self._state = 'writable' self._stored_error: BaseException | None = None - self._writer: WritableStreamDefaultWriter[_T] | None = None + self._writer: WritableStreamDefaultWriter[_W_contra] | None = None self._close_requested = False - self._controller = WritableStreamDefaultController(self, underlying_sink, high_water_mark) + self._controller = WritableStreamDefaultController(self, underlying_sink, extracted) self._controller._start() @property @@ -722,7 +1038,7 @@ def locked(self) -> bool: """:obj:`bool`: Whether a writer holds the stream.""" return self._writer is not None - def get_writer(self) -> WritableStreamDefaultWriter[_T]: + def get_writer(self) -> WritableStreamDefaultWriter[_W_contra]: """Returns a writer, which holds the stream until it's released. Raises :obj:`TypeError` if the stream is locked. @@ -779,7 +1095,7 @@ def _start_erroring(self, error: BaseException) -> None: def _finish_erroring(self) -> None: self._state = 'errored' - error = _error_or_default(self._stored_error) + error = _error_of(self._stored_error) self._controller._reject_queue(error) self._reject_writer(error) _ = _call(self._controller._sink, 'abort', self._stored_error) @@ -812,7 +1128,7 @@ def _update_backpressure(self) -> None: getWriter = get_writer -class WritableStreamDefaultWriter(Generic[_T]): +class WritableStreamDefaultWriter(Generic[_W_contra]): """Writes chunks to a stream, which it locks until :meth:`release_lock`. Args: @@ -822,11 +1138,11 @@ class WritableStreamDefaultWriter(Generic[_T]): TypeError: If the stream is locked. """ - def __init__(self, stream: WritableStream[_T]) -> None: + def __init__(self, stream: WritableStream[_W_contra]) -> None: if stream.locked: msg = 'The stream is locked' raise TypeError(msg) - self._stream: WritableStream[_T] | None = stream + self._stream: WritableStream[_W_contra] | None = stream stream._writer = self self._closed: asyncio.Future[None] = _handled(_pending()) self._ready: asyncio.Future[None] = _handled(_pending()) @@ -867,21 +1183,22 @@ def desired_size(self) -> float | None: return 0 return stream._controller._desired_size() - def write(self, chunk: _T) -> asyncio.Future[None]: + def write(self, chunk: _W_contra | None = None) -> asyncio.Future[None]: """Writes a chunk. Returns: - :obj:`asyncio.Future`: Done once the sink took it, failed if it didn't. + :obj:`asyncio.Future`: Done once the sink took it, failed if it didn't, or if the size of the chunk + errored the stream. """ stream = self._stream if stream is None: return _rejected(TypeError('The writer is released')) if stream._state in {'errored', 'erroring'}: - return _rejected(_error_or_default(stream._stored_error)) + return _rejected(_error_of(stream._stored_error)) if stream._close_requested or stream._state == 'closed': return _rejected(TypeError('The stream is closed or closing')) future: asyncio.Future[None] = _pending() - stream._controller._write(chunk, future) + stream._controller._write(cast('_W_contra', chunk), future) return future def close(self) -> asyncio.Future[None]: @@ -924,13 +1241,13 @@ def desired_size(self) -> float | None: """:obj:`float`, optional: The desired size of the readable side.""" return self._stream._readable._controller.desired_size - def enqueue(self, chunk: _O) -> None: + def enqueue(self, chunk: _O | None = None) -> None: """Enqueues a chunk to the readable side.""" self._stream._readable._controller.enqueue(chunk) - def error(self, error: BaseException | None = None) -> None: + def error(self, reason: object = None) -> None: """Errors both sides.""" - error = _error_or_default(error) + error = _error_of(reason) self._stream._readable._controller.error(error) self._stream._writable._controller.error(error) @@ -954,15 +1271,33 @@ class TransformStream(Generic[_T, _O]): transformer (optional): An object with optional ``start(controller)``, ``transform(chunk, controller)`` and ``flush(controller)`` methods (or a :obj:`dict` of them), which may be coroutine functions. Chunks pass unchanged without ``transform``. + writable_strategy (:obj:`QueuingStrategy`, optional): How the queue of the writable side is counted, a chunk + each up to 1 by default. + readable_strategy (:obj:`QueuingStrategy`, optional): How the queue of the readable side is counted, a chunk + each up to 0 by default. + + Raises: + webrtc.InvalidRangeError: If the high water mark of a strategy is negative or NaN. """ - def __init__(self, transformer: object = None) -> None: + def __init__( + self, + transformer: object = None, + writable_strategy: QueuingStrategy[_T] | None = None, + readable_strategy: QueuingStrategy[_O] | None = None, + ) -> None: + writable = _extract_strategy(writable_strategy, 1) + readable = _extract_strategy(readable_strategy, 0) self._transformer = transformer self._controller: TransformStreamDefaultController[_T, _O] = TransformStreamDefaultController(self) # settled by a pull of the readable side, which relieves backpressure self._pull_waiter: asyncio.Future[None] | None = None - self._readable: ReadableStream[_O] = ReadableStream(_TransformSource(self), high_water_mark=0) - self._writable: WritableStream[_T] = WritableStream(_TransformSink(self), high_water_mark=1) + self._readable: ReadableStream[_O] = ReadableStream( + _TransformSource(self), QueuingStrategy(readable.high_water_mark, readable.size) + ) + self._writable: WritableStream[_T] = WritableStream( + _TransformSink(self), QueuingStrategy(writable.high_water_mark, writable.size) + ) _ = _call(transformer, 'start', self._controller) @property @@ -1006,7 +1341,7 @@ async def close(self) -> None: stream._readable._controller.close() def abort(self, reason: object) -> None: - self._stream._readable._controller.error(_reason_error(reason)) + self._stream._readable._controller.error(_error_of(reason)) class _TransformSource(Generic[_T, _O]): @@ -1017,4 +1352,4 @@ def pull(self, _controller: ReadableStreamDefaultController[_O]) -> None: _settle(self._stream._pull_waiter, None) def cancel(self, reason: object) -> None: - self._stream._writable._controller.error(_reason_error(reason)) + self._stream._writable._controller.error(_error_of(reason)) diff --git a/python-webrtc/python/webrtc/utils/events.py b/python-webrtc/python/webrtc/utils/events.py index ce38249..7df6a3f 100644 --- a/python-webrtc/python/webrtc/utils/events.py +++ b/python-webrtc/python/webrtc/utils/events.py @@ -77,6 +77,9 @@ def ensure_primary_loop(self) -> asyncio.AbstractEventLoop | None: return loop def deliver(self, loop: asyncio.AbstractEventLoop, name: str, args: tuple[object, ...]) -> None: + if 'registrations' not in self.__dict__: + # the garbage collector cleared this object since the event was posted + return if loop is self.primary_loop: self.target._on_event(name, *args) registrations = [r for r in self.registrations.get(name, ()) if r.loop is loop] @@ -85,7 +88,12 @@ def deliver(self, loop: asyncio.AbstractEventLoop, name: str, args: tuple[object event = self.target._create_event(name, *args) if event is None: return + event.target = self.target + self._call(loop, name, event, registrations=registrations) + def _call( + self, loop: asyncio.AbstractEventLoop, name: str, event: webrtc.Event, *, registrations: list[_Registration] + ) -> None: for registration in registrations: if registration.once: self.remove(name, registration.handler) @@ -202,9 +210,10 @@ def _on_event(self, name: str, *args: object) -> None: method, never handlers. """ - def _create_event(self, name: str, *_args: object) -> webrtc.Event | None: + @staticmethod + def _create_event(name: str, *_args: object) -> webrtc.Event | None: """Creates the event object from the native arguments of an event, or returns :obj:`None` to drop it.""" - return webrtc.Event(name, self) + return webrtc.Event(name) @overload def on(self, name: str, handler: None = None) -> Callable[[_H], _H]: ... diff --git a/python-webrtc/python/webrtc/utils/transfer.py b/python-webrtc/python/webrtc/utils/transfer.py new file mode 100644 index 0000000..eeaff7d --- /dev/null +++ b/python-webrtc/python/webrtc/utils/transfer.py @@ -0,0 +1,63 @@ +# +# 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 ``transfer`` member of the WebCodecs inits: buffers given up to the created object. + +Python can't detach a buffer the way JS detaches an ``ArrayBuffer``: transferred :obj:`memoryview` objects are released, +and other buffers are only promised not to be used anymore. +""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING + +from webrtc.exceptions import DataCloneError + +if TYPE_CHECKING: + from collections.abc import Sequence + + from typing_extensions import Buffer + + +class Transfer: + """The validated buffers of a ``transfer`` member, by the object that owns their memory.""" + + def __init__(self, transfer: Sequence[Buffer]) -> None: + if isinstance(transfer, (str, bytes, bytearray, memoryview)): + msg = 'transfer is a sequence of buffers' + raise TypeError(msg) + self._views: list[memoryview] = [] + for item in transfer: + try: + view = memoryview(item) + except TypeError: + msg = f'transfer takes buffers, not {type(item).__name__}' + raise TypeError(msg) from None + except ValueError: + # a released memoryview is the detached buffer of Python + msg = 'A buffer in transfer is released' + raise DataCloneError(msg) from None + if self.has(view): + msg = 'A buffer is in transfer more than once' + raise DataCloneError(msg) + self._views.append(view) + self._items = list(transfer) + + def has(self, data: Buffer) -> bool: + """Whether the memory of a buffer is transferred, which the created object may then keep without copying.""" + owner = memoryview(data).obj + return any(view.obj is owner for view in self._views) + + def detach(self) -> None: + """Releases the transferred memoryviews, once the object is created.""" + for view in self._views: + view.release() + for item in self._items: + if isinstance(item, memoryview): + with contextlib.suppress(BufferError): # views of it are exported, like to numpy + item.release() diff --git a/stubs/wrtc/__init__.pyi b/stubs/wrtc/__init__.pyi index a8ff505..7767757 100644 --- a/stubs/wrtc/__init__.pyi +++ b/stubs/wrtc/__init__.pyi @@ -6,7 +6,7 @@ import webrtc.enums import webrtc.exceptions import webrtc.models.media_track_constraints import webrtc.utils.events -__all__: list[str] = ['ConfigurationInit', 'DataChannelMessage', 'IceCandidateInit', 'IceServerInit', 'MediaStream', 'MediaStreamTrack', 'MediaStreamTrackProcessor', 'PeerConnectionFactory', 'PythonWebRTCException', 'PythonWebRTCExceptionBase', 'RTCCallbackException', 'RTCCertificate', 'RTCDTMFSender', 'RTCDataChannel', 'RTCDtlsTransport', 'RTCIceTransport', 'RTCPeerConnection', 'RTCRtpReceiver', 'RTCRtpSender', 'RTCRtpTransceiver', 'RTCSctpTransport', 'RTCSessionDescription', 'RTCSessionDescriptionInit', 'RtcpParameters', 'RtpCapabilities', 'RtpCodec', 'RtpCodecCapability', 'RtpCodecParameters', 'RtpEncodingParameters', 'RtpExtension', 'RtpHeaderExtensionCapability', 'RtpParameters', 'RtpTransceiverInit', 'SdpParseException', 'TrackGenerator', 'VideoFrameBuffer', 'copyAudioSamples', 'getUserMedia', 'ping'] +__all__: list[str] = ['ConfigurationInit', 'DataChannelMessage', 'IceCandidateInit', 'IceServerInit', 'MediaStream', 'MediaStreamTrack', 'MediaStreamTrackProcessor', 'PeerConnectionFactory', 'PythonWebRTCException', 'PythonWebRTCExceptionBase', 'RTCCallbackException', 'RTCCertificate', 'RTCDTMFSender', 'RTCDataChannel', 'RTCDtlsTransport', 'RTCEncodedFrame', 'RTCIceTransport', 'RTCPeerConnection', 'RTCRtpReceiver', 'RTCRtpScriptTransform', 'RTCRtpSender', 'RTCRtpTransceiver', 'RTCSctpTransport', 'RTCSessionDescription', 'RTCSessionDescriptionInit', 'RtcpParameters', 'RtpCapabilities', 'RtpCodec', 'RtpCodecCapability', 'RtpCodecParameters', 'RtpEncodingParameters', 'RtpExtension', 'RtpHeaderExtensionCapability', 'RtpParameters', 'RtpTransceiverInit', 'SFrameTransform', 'SdpParseException', 'TrackGenerator', 'VideoFrameBuffer', 'copyAudioSamples', 'getUserMedia', 'ping'] class RTCCallbackException: def toPython(self) -> webrtc.exceptions.RTCException: ... @@ -22,12 +22,12 @@ class RTCSessionDescriptionInit: def type(self) -> webrtc.enums.RTCSdpType: ... @type.setter - def type(self, arg0: webrtc.enums.RTCSdpType | webrtc.enums.RTCSdpTypeValue) -> None: + def type(self, value: webrtc.enums.RTCSdpType | webrtc.enums.RTCSdpTypeValue) -> None: ... - def __init__(self, arg0: webrtc.enums.RTCSdpType | webrtc.enums.RTCSdpTypeValue, arg1: str) -> None: + def __init__(self, type: webrtc.enums.RTCSdpType | webrtc.enums.RTCSdpTypeValue, sdp: str) -> None: ... class RTCSessionDescription: - def __init__(self, arg0: RTCSessionDescriptionInit) -> None: + def __init__(self, descriptionInitDict: RTCSessionDescriptionInit) -> None: ... @property def init(self) -> RTCSessionDescriptionInit: @@ -59,7 +59,7 @@ class IceServerInit: def urls(self) -> list[str]: ... @urls.setter - def urls(self, arg0: collections.abc.Sequence[str]) -> None: + def urls(self, value: collections.abc.Sequence[str]) -> None: ... class ConfigurationInit: alwaysNegotiateDataChannels: bool @@ -67,25 +67,25 @@ class ConfigurationInit: def bundlePolicy(self) -> webrtc.enums.RTCBundlePolicy: ... @bundlePolicy.setter - def bundlePolicy(self, arg0: webrtc.enums.RTCBundlePolicy | webrtc.enums.RTCBundlePolicyValue) -> None: + def bundlePolicy(self, value: webrtc.enums.RTCBundlePolicy | webrtc.enums.RTCBundlePolicyValue) -> None: ... @property def iceTransportPolicy(self) -> webrtc.enums.RTCIceTransportPolicy: ... @iceTransportPolicy.setter - def iceTransportPolicy(self, arg0: webrtc.enums.RTCIceTransportPolicy | webrtc.enums.RTCIceTransportPolicyValue) -> None: + def iceTransportPolicy(self, value: webrtc.enums.RTCIceTransportPolicy | webrtc.enums.RTCIceTransportPolicyValue) -> None: ... @property def rtcpMuxPolicy(self) -> webrtc.enums.RTCRtcpMuxPolicy: ... @rtcpMuxPolicy.setter - def rtcpMuxPolicy(self, arg0: webrtc.enums.RTCRtcpMuxPolicy | webrtc.enums.RTCRtcpMuxPolicyValue) -> None: + def rtcpMuxPolicy(self, value: webrtc.enums.RTCRtcpMuxPolicy | webrtc.enums.RTCRtcpMuxPolicyValue) -> None: ... @property def rtpHeaderEncryptionPolicy(self) -> webrtc.enums.RTCRtpHeaderEncryptionPolicy: ... @rtpHeaderEncryptionPolicy.setter - def rtpHeaderEncryptionPolicy(self, arg0: webrtc.enums.RTCRtpHeaderEncryptionPolicy | webrtc.enums.RTCRtpHeaderEncryptionPolicyValue) -> None: + def rtpHeaderEncryptionPolicy(self, value: webrtc.enums.RTCRtpHeaderEncryptionPolicy | webrtc.enums.RTCRtpHeaderEncryptionPolicyValue) -> None: ... def __init__(self) -> None: ... @@ -93,32 +93,32 @@ class ConfigurationInit: def certificates(self) -> list[RTCCertificate] | None: ... @certificates.setter - def certificates(self, arg0: collections.abc.Sequence[RTCCertificate] | None) -> None: + def certificates(self, value: collections.abc.Sequence[RTCCertificate] | None) -> None: ... @property def iceCandidatePoolSize(self) -> int: ... @iceCandidatePoolSize.setter - def iceCandidatePoolSize(self, arg0: typing.SupportsInt | typing.SupportsIndex) -> None: + def iceCandidatePoolSize(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... @property def iceServers(self) -> list[IceServerInit]: ... @iceServers.setter - def iceServers(self, arg0: collections.abc.Sequence[IceServerInit]) -> None: + def iceServers(self, value: collections.abc.Sequence[IceServerInit]) -> None: ... @property def portRange(self) -> tuple[int, int] | None: ... @portRange.setter - def portRange(self, arg0: tuple[typing.SupportsInt | typing.SupportsIndex, typing.SupportsInt | typing.SupportsIndex] | None) -> None: + def portRange(self, value: tuple[typing.SupportsInt | typing.SupportsIndex, typing.SupportsInt | typing.SupportsIndex] | None) -> None: ... class RtpCodec: @property def kind(self) -> webrtc.enums.MediaType: ... @kind.setter - def kind(self, arg0: webrtc.enums.MediaType | webrtc.enums.MediaTypeValue) -> None: + def kind(self, value: webrtc.enums.MediaType | webrtc.enums.MediaTypeValue) -> None: ... name: str def __init__(self) -> None: @@ -127,7 +127,7 @@ class RtpCodec: def clockRate(self) -> int | None: ... @clockRate.setter - def clockRate(self, arg0: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def clockRate(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... @property def mimeType(self) -> str: @@ -136,13 +136,13 @@ class RtpCodec: def numChannels(self) -> int | None: ... @numChannels.setter - def numChannels(self, arg0: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def numChannels(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... @property def parameters(self) -> dict[str, str]: ... @parameters.setter - def parameters(self, arg1: collections.abc.Mapping[str, str]) -> None: + def parameters(self, value: collections.abc.Mapping[str, str]) -> None: ... class RtpCodecCapability(RtpCodec): def __init__(self) -> None: @@ -151,7 +151,7 @@ class RtpCodecCapability(RtpCodec): def preferredPayloadType(self) -> int | None: ... @preferredPayloadType.setter - def preferredPayloadType(self, arg0: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def preferredPayloadType(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... class RtpCodecParameters(RtpCodec): def __init__(self) -> None: @@ -160,7 +160,7 @@ class RtpCodecParameters(RtpCodec): def payloadType(self) -> int: ... @payloadType.setter - def payloadType(self, arg0: typing.SupportsInt | typing.SupportsIndex) -> None: + def payloadType(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... class RtpExtension: encrypt: bool @@ -171,14 +171,14 @@ class RtpExtension: def id(self) -> int: ... @id.setter - def id(self, arg1: typing.SupportsInt | typing.SupportsIndex) -> None: + def id(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... class RtpHeaderExtensionCapability: @property - def direction(self) -> webrtc.enums.TransceiverDirection: + def direction(self) -> webrtc.enums.RTCRtpTransceiverDirection: ... @direction.setter - def direction(self, arg0: webrtc.enums.TransceiverDirection | webrtc.enums.TransceiverDirectionValue) -> None: + def direction(self, value: webrtc.enums.RTCRtpTransceiverDirection | webrtc.enums.RTCRtpTransceiverDirectionValue) -> None: ... uri: str def __init__(self) -> None: @@ -187,7 +187,7 @@ class RtpHeaderExtensionCapability: def preferredId(self) -> int | None: ... @preferredId.setter - def preferredId(self, arg1: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def preferredId(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... class RtcpParameters: cname: str @@ -202,7 +202,7 @@ class RtpEncodingParameters: def networkPriority(self) -> webrtc.enums.RTCPriorityType: ... @networkPriority.setter - def networkPriority(self, arg0: webrtc.enums.RTCPriorityType | webrtc.enums.RTCPriorityTypeValue) -> None: + def networkPriority(self, value: webrtc.enums.RTCPriorityType | webrtc.enums.RTCPriorityTypeValue) -> None: ... requestKeyFrame: bool rid: str @@ -213,38 +213,38 @@ class RtpEncodingParameters: def bitratePriority(self) -> float: ... @bitratePriority.setter - def bitratePriority(self, arg0: typing.SupportsFloat | typing.SupportsIndex) -> None: + def bitratePriority(self, value: typing.SupportsFloat | typing.SupportsIndex) -> None: ... @property def maxBitrate(self) -> int | None: ... @maxBitrate.setter - def maxBitrate(self, arg0: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def maxBitrate(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... @property def maxFramerate(self) -> float | None: ... @maxFramerate.setter - def maxFramerate(self, arg0: typing.SupportsFloat | typing.SupportsIndex | None) -> None: + def maxFramerate(self, value: typing.SupportsFloat | typing.SupportsIndex | None) -> None: ... @property def scaleResolutionDownBy(self) -> float | None: ... @scaleResolutionDownBy.setter - def scaleResolutionDownBy(self, arg0: typing.SupportsFloat | typing.SupportsIndex | None) -> None: + def scaleResolutionDownBy(self, value: typing.SupportsFloat | typing.SupportsIndex | None) -> None: ... @property def ssrc(self) -> int | None: ... @ssrc.setter - def ssrc(self, arg0: typing.SupportsInt | typing.SupportsIndex | None) -> None: + def ssrc(self, value: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... class RtpParameters: @property def degradationPreference(self) -> webrtc.enums.RTCDegradationPreference | None: ... @degradationPreference.setter - def degradationPreference(self, arg0: webrtc.enums.RTCDegradationPreference | webrtc.enums.RTCDegradationPreferenceValue | None) -> None: + def degradationPreference(self, value: webrtc.enums.RTCDegradationPreference | webrtc.enums.RTCDegradationPreferenceValue | None) -> None: ... mid: str rtcp: RtcpParameters @@ -255,19 +255,19 @@ class RtpParameters: def codecs(self) -> list[RtpCodecParameters]: ... @codecs.setter - def codecs(self, arg0: collections.abc.Sequence[RtpCodecParameters]) -> None: + def codecs(self, value: collections.abc.Sequence[RtpCodecParameters]) -> None: ... @property def encodings(self) -> list[RtpEncodingParameters]: ... @encodings.setter - def encodings(self, arg0: collections.abc.Sequence[RtpEncodingParameters]) -> None: + def encodings(self, value: collections.abc.Sequence[RtpEncodingParameters]) -> None: ... @property def headerExtensions(self) -> list[RtpExtension]: ... @headerExtensions.setter - def headerExtensions(self, arg0: collections.abc.Sequence[RtpExtension]) -> None: + def headerExtensions(self, value: collections.abc.Sequence[RtpExtension]) -> None: ... class RtpCapabilities: def __init__(self) -> None: @@ -276,20 +276,20 @@ class RtpCapabilities: def codecs(self) -> list[RtpCodecCapability]: ... @codecs.setter - def codecs(self, arg0: collections.abc.Sequence[RtpCodecCapability]) -> None: + def codecs(self, value: collections.abc.Sequence[RtpCodecCapability]) -> None: ... @property def headerExtensions(self) -> list[RtpHeaderExtensionCapability]: ... @headerExtensions.setter - def headerExtensions(self, arg0: collections.abc.Sequence[RtpHeaderExtensionCapability]) -> None: + def headerExtensions(self, value: collections.abc.Sequence[RtpHeaderExtensionCapability]) -> None: ... class RtpTransceiverInit: @property - def direction(self) -> webrtc.enums.TransceiverDirection: + def direction(self) -> webrtc.enums.RTCRtpTransceiverDirection: ... @direction.setter - def direction(self, arg0: webrtc.enums.TransceiverDirection | webrtc.enums.TransceiverDirectionValue) -> None: + def direction(self, value: webrtc.enums.RTCRtpTransceiverDirection | webrtc.enums.RTCRtpTransceiverDirectionValue) -> None: ... def __init__(self) -> None: ... @@ -297,13 +297,13 @@ class RtpTransceiverInit: def sendEncodings(self) -> list[RtpEncodingParameters]: ... @sendEncodings.setter - def sendEncodings(self, arg0: collections.abc.Sequence[RtpEncodingParameters]) -> None: + def sendEncodings(self, value: collections.abc.Sequence[RtpEncodingParameters]) -> None: ... @property def streamIds(self) -> list[str]: ... @streamIds.setter - def streamIds(self, arg0: collections.abc.Sequence[str]) -> None: + def streamIds(self, value: collections.abc.Sequence[str]) -> None: ... class PeerConnectionFactory: @staticmethod @@ -382,7 +382,7 @@ class RTCIceTransport: ... def _surfaceCandidate(self) -> None: ... - def _surfaceGatheringState(self, state: webrtc.enums.CricketIceGatheringState) -> None: + def _surfaceGatheringState(self, state: webrtc.enums.RTCIceGathererState) -> None: ... def _surfaceState(self, state: webrtc.enums.RTCIceTransportState) -> None: ... @@ -411,7 +411,7 @@ class RTCIceTransport: def component(self) -> webrtc.enums.RTCIceComponent: ... @property - def gatheringState(self) -> webrtc.enums.CricketIceGatheringState: + def gatheringState(self) -> webrtc.enums.RTCIceGathererState: ... @property def role(self) -> webrtc.enums.RTCIceRole: @@ -421,7 +421,7 @@ class RTCIceTransport: ... class RTCDtlsTransport: _listeners: webrtc.utils.events._Listeners | None - def _surfaceState(self, state: webrtc.enums.DtlsTransportState) -> None: + def _surfaceState(self, state: webrtc.enums.RTCDtlsTransportState) -> None: ... def getRemoteCertificates(self) -> list[bytes]: ... @@ -429,11 +429,11 @@ class RTCDtlsTransport: def iceTransport(self) -> RTCIceTransport: ... @property - def state(self) -> webrtc.enums.DtlsTransportState: + def state(self) -> webrtc.enums.RTCDtlsTransportState: ... class RTCSctpTransport: _listeners: webrtc.utils.events._Listeners | None - def _surfaceState(self, state: webrtc.enums.SctpTransportState) -> None: + def _surfaceState(self, state: webrtc.enums.RTCSctpTransportState) -> None: ... @property def maxChannels(self) -> int | None: @@ -442,7 +442,7 @@ class RTCSctpTransport: def maxMessageSize(self) -> float | None: ... @property - def state(self) -> webrtc.enums.SctpTransportState: + def state(self) -> webrtc.enums.RTCSctpTransportState: ... @property def transport(self) -> RTCDtlsTransport: @@ -460,6 +460,12 @@ class RTCDTMFSender: def toneBuffer(self) -> str: ... class RTCRtpSender: + @property + def transform(self) -> _RtpTransform | None: + ... + @transform.setter + def transform(self, value: _RtpTransform | None) -> None: + ... @staticmethod def getCapabilities(kind: str) -> RtpCapabilities | None: ... @@ -494,6 +500,12 @@ class RTCRtpSender: def transport(self) -> RTCDtlsTransport | None: ... class RTCRtpReceiver: + @property + def transform(self) -> _RtpTransform | None: + ... + @transform.setter + def transform(self, value: _RtpTransform | None) -> None: + ... @staticmethod def getCapabilities(kind: str) -> RtpCapabilities | None: ... @@ -507,7 +519,7 @@ class RTCRtpReceiver: def jitterBufferTarget(self) -> float | None: ... @jitterBufferTarget.setter - def jitterBufferTarget(self, arg1: typing.SupportsFloat | typing.SupportsIndex | None) -> None: + def jitterBufferTarget(self, value: typing.SupportsFloat | typing.SupportsIndex | None) -> None: ... @property def track(self) -> MediaStreamTrack: @@ -517,10 +529,10 @@ class RTCRtpReceiver: ... class RTCRtpTransceiver: @property - def direction(self) -> webrtc.enums.TransceiverDirection: + def direction(self) -> webrtc.enums.RTCRtpTransceiverDirection: ... @direction.setter - def direction(self, arg0: webrtc.enums.TransceiverDirection | webrtc.enums.TransceiverDirectionValue) -> None: + def direction(self, value: webrtc.enums.RTCRtpTransceiverDirection | webrtc.enums.RTCRtpTransceiverDirectionValue) -> None: ... def getCodecPreferences(self) -> list[RtpCodecCapability]: ... @@ -535,7 +547,7 @@ class RTCRtpTransceiver: def stop(self) -> None: ... @property - def currentDirection(self) -> webrtc.enums.TransceiverDirection | None: + def currentDirection(self) -> webrtc.enums.RTCRtpTransceiverDirection | None: ... @property def kind(self) -> webrtc.enums.MediaType: @@ -579,7 +591,7 @@ class RTCDataChannel: def bufferedAmountLowThreshold(self) -> int: ... @bufferedAmountLowThreshold.setter - def bufferedAmountLowThreshold(self, arg1: typing.SupportsInt | typing.SupportsIndex) -> None: + def bufferedAmountLowThreshold(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... @property def id(self) -> int | None: @@ -613,7 +625,7 @@ class RTCPeerConnection: @staticmethod def _connectionOf(sender: RTCRtpSender) -> RTCPeerConnection | None: ... - def __init__(self, arg0: ConfigurationInit | None) -> None: + def __init__(self, configuration: ConfigurationInit | None) -> None: ... def _applyDescriptions(self, snapshot: typing.SupportsInt | typing.SupportsIndex | None = None) -> None: ... @@ -645,11 +657,11 @@ class RTCPeerConnection: ... def close(self) -> None: ... - def createAnswer(self, onSuccess: collections.abc.Callable[[RTCSessionDescription], None], onFailure: collections.abc.Callable[[RTCCallbackException], None], voiceActivityDetection: bool) -> None: + def createAnswer(self, onSuccess: collections.abc.Callable[[RTCSessionDescription], None], onFailure: collections.abc.Callable[[RTCCallbackException], None]) -> None: ... def createDataChannel(self, label: str, ordered: bool, maxPacketLifeTime: typing.SupportsInt | typing.SupportsIndex | None, maxRetransmits: typing.SupportsInt | typing.SupportsIndex | None, protocol: str, negotiated: bool, id: typing.SupportsInt | typing.SupportsIndex | None, priority: webrtc.enums.RTCPriorityType | webrtc.enums.RTCPriorityTypeValue) -> RTCDataChannel: ... - def createOffer(self, onSuccess: collections.abc.Callable[[RTCSessionDescription], None], onFailure: collections.abc.Callable[[RTCCallbackException], None], iceRestart: bool, voiceActivityDetection: bool) -> None: + def createOffer(self, onSuccess: collections.abc.Callable[[RTCSessionDescription], None], onFailure: collections.abc.Callable[[RTCCallbackException], None], iceRestart: bool) -> None: ... def getConfiguration(self) -> ConfigurationInit: ... @@ -745,6 +757,56 @@ class MediaStreamTrackProcessor: @property def totalFrames(self) -> int: ... +class _RtpTransform: + pass +class RTCEncodedFrame: + def getData(self) -> bytes: + ... + def getMetadata(self) -> dict[str, object]: + ... + @property + def video(self) -> bool: + ... +class RTCRtpScriptTransform(_RtpTransform): + _listeners: webrtc.utils.events._Listeners | None + def __init__(self) -> None: + ... + def _ackWakeup(self) -> None: + ... + def generateKeyFrame(self, rid: str | None) -> int: + ... + def read(self) -> RTCEncodedFrame | None: + ... + def sendKeyFrameRequest(self) -> bool: + ... + def write(self, frame: RTCEncodedFrame, data: typing_extensions.Buffer | None) -> bool: + ... + @property + def sourceId(self) -> int: + ... + @property + def sourceKind(self) -> tuple[bool, bool] | None: + ... + @property + def state(self) -> int: + ... +class SFrameTransform(_RtpTransform): + _listeners: webrtc.utils.events._Listeners | None + def __init__(self, cipherSuite: typing.SupportsInt | typing.SupportsIndex, encrypting: bool) -> None: + ... + def addDecryptionKey(self, key: typing_extensions.Buffer, keyId: typing.SupportsInt | typing.SupportsIndex) -> bool: + ... + def decrypt(self, data: typing_extensions.Buffer) -> tuple[bytes | None, int, int | None]: + ... + def encrypt(self, data: typing_extensions.Buffer) -> bytes | None: + ... + def removeDecryptionKey(self, keyId: typing.SupportsInt | typing.SupportsIndex) -> None: + ... + def setEncryptionKey(self, key: typing_extensions.Buffer, keyId: typing.SupportsInt | typing.SupportsIndex) -> bool: + ... + @property + def encrypting(self) -> bool: + ... class TrackGenerator: muted: bool def __init__(self, kind: str) -> None: @@ -768,6 +830,16 @@ def _alive() -> dict[str, int]: ... def _alive_factories() -> int: ... +def _sframeDecrypt(cipherSuite: typing.SupportsInt | typing.SupportsIndex, baseKey: typing_extensions.Buffer, metadata: typing_extensions.Buffer, ciphertext: typing_extensions.Buffer) -> bytes | None: + ... +def _sframeDerive(cipherSuite: typing.SupportsInt | typing.SupportsIndex, baseKey: typing_extensions.Buffer, keyId: typing.SupportsInt | typing.SupportsIndex) -> tuple[bytes, bytes]: + ... +def _sframeEncrypt(cipherSuite: typing.SupportsInt | typing.SupportsIndex, baseKey: typing_extensions.Buffer, keyId: typing.SupportsInt | typing.SupportsIndex, counter: typing.SupportsInt | typing.SupportsIndex, metadata: typing_extensions.Buffer, plaintext: typing_extensions.Buffer) -> bytes: + ... +def _sframeHeader(keyId: typing.SupportsInt | typing.SupportsIndex, counter: typing.SupportsInt | typing.SupportsIndex) -> bytes: + ... +def _sframeParseHeader(data: typing_extensions.Buffer) -> tuple[int, int, int] | None: + ... def copyAudioSamples(source: typing_extensions.Buffer, sourceFormat: str, channels: typing.SupportsInt | typing.SupportsIndex, frames: typing.SupportsInt | typing.SupportsIndex, destination: typing_extensions.Buffer, destinationFormat: str, planeIndex: typing.SupportsInt | typing.SupportsIndex, frameOffset: typing.SupportsInt | typing.SupportsIndex, frameCount: typing.SupportsInt | typing.SupportsIndex) -> None: ... def getUserMedia(audio: bool, video: bool, width: typing.SupportsInt | typing.SupportsIndex, height: typing.SupportsInt | typing.SupportsIndex, frameRate: typing.SupportsFloat | typing.SupportsIndex) -> MediaStream: diff --git a/tests/chaos.py b/tests/chaos.py index 1b53131..8582de0 100644 --- a/tests/chaos.py +++ b/tests/chaos.py @@ -10,12 +10,14 @@ Every step is logged before it runs, so the output of a crash names the sequence; the same seed replays it. python -m tests.chaos --seed 7 --steps 500 + python -m tests.chaos --seed 7 --steps 500 --transforms """ from __future__ import annotations import argparse import asyncio +import contextlib import gc import logging import random @@ -26,7 +28,7 @@ import webrtc import wrtc -from tests.helpers import connect +from tests.helpers import connect, copy_frame if TYPE_CHECKING: from collections.abc import Callable, Coroutine @@ -59,6 +61,8 @@ def __init__(self, seed: int) -> None: self.generators: list[tuple[webrtc.WritableStreamDefaultWriter, str]] = [] self.frames: list[webrtc.VideoFrame] = [] self.tasks: list[threading.Thread | asyncio.Future[None]] = [] + self.transformers: list[webrtc.RTCRtpScriptTransformer] = [] + self.encoded: list[webrtc.RTCEncodedVideoFrame | webrtc.RTCEncodedAudioFrame] = [] def pick(self, pool: list[T]) -> T | None: return self.random.choice(pool) if len(pool) > 0 else None @@ -71,6 +75,7 @@ def handler(self) -> Callable[[webrtc.Event], str | None]: """A handler doing something to a random object: closing, raising, referencing (a cycle), collecting.""" target = self.pick([*self.connections, *self.channels, *self.tracks]) action = self.random.randrange(5) + collected = [float('-inf')] def handle(_event: webrtc.Event) -> str | None: if action == 0 and target is not None: @@ -78,7 +83,9 @@ def handle(_event: webrtc.Event) -> str | None: elif action == 1: msg = 'a handler raises' raise RuntimeError(msg) - elif action == 2: + # at most once a second: per event, as SFrame errors come for every frame, it would starve the loop + elif action == 2 and time.monotonic() - collected[0] > 1: + collected[0] = time.monotonic() gc.collect() elif action == 3: return repr(target) @@ -127,7 +134,7 @@ async def add_transceiver(self) -> None: if self.random.random() < 0.3: transceiver.stop() elif self.random.random() < 0.3: - transceiver.direction = self.random.choice(list(webrtc.TransceiverDirection)[:4]) + transceiver.direction = self.random.choice(list(webrtc.RTCRtpTransceiverDirection)[:4]) async def negotiate(self) -> None: pc = self.pick(self.connections) @@ -186,7 +193,11 @@ class MediaSteps(State): """Steps of tracks, processors, generators and frames.""" async def get_user_media(self) -> None: - self.tracks.extend(webrtc.get_user_media(audio=True, video=True).get_tracks()) + self.tracks.extend( + ( + await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) + ).get_tracks() + ) async def stop_track(self) -> None: track = self.pick(self.tracks) @@ -322,6 +333,132 @@ async def stream(self) -> None: stream.get_tracks() +class TransformSteps(State): + """Steps of encoded transforms, SFrame and encoded frames.""" + + def _parts(self) -> list[webrtc.RTCRtpSender | webrtc.RTCRtpReceiver]: + return [part for pc in self.connections for part in (*pc.get_senders(), *pc.get_receivers())] + + def _worker(self) -> Callable[[webrtc.RTCTransformEvent], Coroutine[object, object, None]]: + """Passes frames through, drops them, holds them or never reads.""" + mode = self.random.choice(['pass', 'drop', 'hold', 'idle']) + + async def worker(event: webrtc.RTCTransformEvent) -> None: + transformer = event.transformer + self.transformers.append(transformer) + if mode == 'idle': + return + reader = transformer.readable.get_reader() + writer = transformer.writable.get_writer() + while True: + result = await reader.read() + if result.done or result.value is None: + return + if mode == 'hold' and len(self.encoded) < 200: + self.encoded.append(result.value) + elif mode == 'pass': + # rejected once the transform is removed + writer.write(result.value).add_done_callback(lambda f: f.cancelled() or f.exception()) + + return worker + + async def script_transform(self) -> None: + part = self.pick(self._parts()) + if part is not None: + part.transform = webrtc.RTCRtpScriptTransform(self._worker()) + + async def sframe_transform(self) -> None: + part = self.pick(self._parts()) + if part is None: + return + suite = self.random.choice(list(webrtc.SFrameCipherSuite)) + key = bytes(self.random.randrange(256) for _ in range(self.random.choice([0, 16, 32]))) + if isinstance(part, webrtc.RTCRtpSender): + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(suite)) + if self.random.random() < 0.8: + await encryptor.set_encryption_key(key, self.random.randrange(4)) + part.transform = encryptor + else: + decryptor = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(suite)) + await decryptor.add_decryption_key(key, self.random.randrange(4)) + decryptor.on('error', self.random.choice([self.handler(), self._keep_frame])) + part.transform = decryptor + + def _keep_frame(self, event: webrtc.SFrameTransformErrorEvent) -> None: + if isinstance(event.frame, (webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame)): + self.encoded.append(event.frame) + + async def remove_transform(self) -> None: + part = self.pick(self._parts()) + if part is not None: + part.transform = None + + async def rotate_key(self) -> None: + part = self.pick(self._parts()) + transform = part.transform if part is not None else None + key, key_id = bytes(range(self.random.choice([1, 16]))), self.random.randrange(4) + if isinstance(transform, webrtc.RTCRtpSFrameEncryptor): + await transform.set_encryption_key(key, key_id) + elif isinstance(transform, webrtc.RTCRtpSFrameDecryptor): + if self.random.random() < 0.5: + await transform.add_decryption_key(key, key_id) + else: + await transform.remove_decryption_key(key_id) + + async def write_frame(self) -> None: + """Writes a held frame, or a copy, to any transformer: frames of others are dropped.""" + frame, transformer = self.pick(self.encoded), self.pick(self.transformers) + if frame is None or transformer is None: + return + if self.random.random() < 0.3: + frame = copy_frame(frame) + if self.random.random() < 0.5: + frame.data = bytearray(self.random.randrange(2000)) + if self.random.random() < 0.5 and frame._native is not None: + # natively, past the checks of Python + transformer._native_obj.write(frame._native, None) + return + if not transformer.writable.locked: + writer = transformer.writable.get_writer() + writer.write(frame).add_done_callback(lambda f: f.cancelled() or f.exception()) + writer.release_lock() + + async def use_encoded_frame(self) -> None: + frame = self.pick(self.encoded) + if frame is not None: + _ = frame.data, frame.get_metadata() + if self.random.random() < 0.3: + self.encoded.append(copy_frame(frame)) + if self.random.random() < 0.3: + self.drop(self.encoded) + + async def key_frame(self) -> None: + transformer = self.pick(self.transformers) + if transformer is not None: + request = self.random.choice([transformer.generate_key_frame, transformer.send_key_frame_request]) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(request(), 0.2) + + async def sframe_stream(self) -> None: + suite = self.random.choice(list(webrtc.SFrameCipherSuite)) + encryptor = webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions(suite)) + decryptor = webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions(suite)) + decryptor.on('error', self.handler()) + await encryptor.set_encryption_key(b'key', 5) + await decryptor.add_decryption_key(b'key', self.random.choice([5, 6])) + writer = encryptor.writable.get_writer() + chunks = [bytes(self.random.randrange(100)), *(f for f in self.encoded[-3:] if self.random.random() < 0.5)] + for chunk in chunks: + writer.write(chunk).add_done_callback(lambda f: f.cancelled() or f.exception()) + if self.random.random() < 0.5: + reader = encryptor.readable.pipe_through(decryptor).get_reader() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(reader.read(), 0.2) + + async def drop_transformer(self) -> None: + self.drop(self.transformers) + + class LoopSteps(State): """Steps of threads, the garbage collector and the loop.""" @@ -348,16 +485,31 @@ async def pause(self) -> None: await asyncio.sleep(self.random.random() * 0.05) -class Chaos(ConnectionSteps, MediaSteps, LoopSteps): +class Chaos(ConnectionSteps, MediaSteps, TransformSteps, LoopSteps): """Every step, run in a random sequence.""" - STEPS: ClassVar[list[str]] = sorted([*steps(ConnectionSteps), *steps(MediaSteps), *steps(LoopSteps)]) + STEPS: ClassVar[list[str]] = sorted([ + *steps(ConnectionSteps), + *steps(MediaSteps), + *steps(TransformSteps), + *steps(LoopSteps), + ]) + #: The steps of transforms, and those getting media to flow through them + TRANSFORM_STEPS: ClassVar[list[str]] = sorted([ + *steps(TransformSteps), + *('new_connection', 'close_connection', 'drop_connection', 'connect_two', 'add_track', 'get_user_media'), + *steps(LoopSteps), + ]) + + def __init__(self, seed: int, *, transforms: bool = False) -> None: + super().__init__(seed) + self.steps = self.TRANSFORM_STEPS if transforms else self.STEPS async def run(self, count: int) -> None: # handlers raise on purpose asyncio.get_running_loop().set_exception_handler(lambda _loop, _context: None) for index in range(count): - await self.step(index, self.random.choice(self.STEPS)) + await self.step(index, self.random.choice(self.steps)) for pc in self.connections: pc.close() for track in self.tracks: @@ -389,10 +541,11 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument('--seed', type=int, default=0) parser.add_argument('--steps', type=int, default=300) + parser.add_argument('--transforms', action='store_true', help='the steps of transforms only, and media for them') args = parser.parse_args() logging.basicConfig(stream=sys.stdout, format='%(message)s', level=logging.INFO) log.info('seed %d, %d steps', args.seed, args.steps) - asyncio.run(Chaos(args.seed).run(args.steps)) + asyncio.run(Chaos(args.seed, transforms=args.transforms).run(args.steps)) # the last references may be released on helper threads deadline = time.monotonic() + 1 while wrtc._alive_factories() != 0 and time.monotonic() < deadline: diff --git a/tests/conftest.py b/tests/conftest.py index b70082a..7f6ba60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import gc import threading from typing import TYPE_CHECKING @@ -74,7 +75,7 @@ def create(configuration: webrtc.RTCConfiguration | None = None) -> webrtc.RTCPe def get_stream(constraints: dict[str, bool], request: pytest.FixtureRequest) -> webrtc.MediaStream: - stream = webrtc.get_user_media(**constraints) + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(**constraints))) def stop_tracks() -> None: for track in stream.get_tracks(): diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md index 47d17fc..44868f3 100644 --- a/tests/fuzz/README.md +++ b/tests/fuzz/README.md @@ -10,6 +10,9 @@ crashes are caught. | `audio_data` | `AudioData` and `copy_to` with every format and layout conversion | | `native_buffers` | The native `wrtc.VideoFrameBuffer` and `wrtc.copyAudioSamples` directly, without the Python checks | | `generator` | `AudioData` and `VideoFrame` written to generators sent over a connection, read back by processors | +| `sframe` | The native SFrame of RFC 9605: headers, key derivation, decryption of arbitrary or tampered ciphertexts, round trips of every suite | +| `sframe_stream` | `SFrameEncryptorStream` and `SFrameDecryptorStream` with keys added, removed and rotated between chunks, checked against a model of the keys | +| `encoded_frame` | Script transforms of a connection rewriting, dropping, copying and reordering encoded frames, and SFrame transforms set and keyed mid-stream | Linux only (Apple Clang has no libFuzzer), in the manylinux image; the build is kept in `build/fuzz`: @@ -19,4 +22,5 @@ make fuzz T=audio_data O=tests/fuzz/crashes/audio_data-crash-... # replays a c ``` Only the documented exceptions are expected, and a few oracles check results (a frame or samples copied out as they -are give the same bytes). A crash becomes a regression test in `tests/`. +are give the same bytes, SFrame decrypts what it encrypted and nothing tampered). A crash becomes a regression test in +`tests/`. diff --git a/tests/fuzz/fuzz_encoded_frame.py b/tests/fuzz/fuzz_encoded_frame.py new file mode 100644 index 0000000..6961805 --- /dev/null +++ b/tests/fuzz/fuzz_encoded_frame.py @@ -0,0 +1,393 @@ +# +# 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. +# + +"""Fuzzes the encoded frames of a connection: script transforms rewriting them, and SFrame set and keyed mid-stream. + +The workers of the senders and receivers rewrite frames with arbitrary data (empty, garbage, large), drop, reorder, +copy and write foreign frames, which libwebrtc packetizes, depacketizes and decodes. Transforms are replaced, removed +and swapped for SFrame encryptors and decryptors with fuzzed keys between slices of media. Only the documented +errors are expected, and the metadata of frames read stays in the ranges of RTP. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import functools +import math +import pathlib +import sys +from typing import Callable, Union + +import atheris + +with atheris.instrument_imports(): + from inputs import EDGES, FLOATS, Input + + import webrtc + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) +from tests.helpers import connect, copy_frame, mistyped + +EXPECTED = ( + TypeError, + ValueError, + webrtc.DataCloneError, + webrtc.InvalidStateError, + webrtc.NotAllowedError, + webrtc.NotFoundError, + webrtc.InvalidRangeError, + webrtc.InvalidModificationError, +) +SUITES = list(webrtc.SFrameCipherSuite) +SLICE = 0.25 +LARGE = [1 << 10, 1 << 14, 1 << 16, 1 << 18, 1 << 20] +RIDS = [None, 'a', 'hi', '', 'not valid', 'x' * 256, '0'] +MEMBERS: list[Callable[[Input], object]] = [ + lambda inp: inp.choice(EDGES), + lambda inp: -inp.unsigned(1 << 16) - 1, + lambda inp: inp.choice(FLOATS), + lambda inp: inp.choice(['', 'video/VP8', 'audio/opus', 'x' * 1000, b'1', [], [1, -1, 2**64], {}, True]), + lambda inp: [inp.unsigned(1 << 16) for _ in range(inp.small(4))], + lambda inp: inp.maybe(lambda: inp.unsigned(1 << 16)), +] + +Frame = Union[webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame] +Metadata = Union[webrtc.RTCEncodedVideoFrameMetadata, webrtc.RTCEncodedAudioFrameMetadata] +SFrameTransform = Union[webrtc.RTCRtpSFrameEncryptor, webrtc.RTCRtpSFrameDecryptor] +SFrameStream = Union[webrtc.SFrameEncryptorStream, webrtc.SFrameDecryptorStream] +Transform = Union[webrtc.RTCRtpScriptTransform, SFrameTransform, None] +Transformer = webrtc.RTCRtpScriptTransformer +Writer = webrtc.WritableStreamDefaultWriter[Frame] +Slot = Union[webrtc.RTCRtpSender, webrtc.RTCRtpReceiver] + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + + +def metadata(inp: Input, *, video: bool) -> Metadata: + cls = webrtc.RTCEncodedVideoFrameMetadata if video else webrtc.RTCEncodedAudioFrameMetadata + fields = [name for name in cls.__dataclass_fields__ if inp.flag()] + return cls(**{name: mistyped(inp.choice(MEMBERS)(inp)) for name in fields}) + + +def same(got: object, member: object) -> bool: + return got == member or (isinstance(member, float) and math.isnan(member)) + + +def check_metadata(frame: Frame) -> None: + """What libwebrtc gives is in the ranges of RTP.""" + meta = frame.get_metadata() + for member in (meta.synchronization_source, meta.rtp_timestamp): + assert member is None or 0 <= member < 2**32 + assert meta.payload_type is None or 0 <= meta.payload_type < 128 + csrcs = meta.contributing_sources + assert csrcs is None or all(0 <= csrc < 2**32 for csrc in csrcs) + kind = 'video/' if isinstance(frame, webrtc.RTCEncodedVideoFrame) else 'audio/' + assert meta.mime_type is None or meta.mime_type == '' or meta.mime_type.startswith(kind) + if isinstance(meta, webrtc.RTCEncodedVideoFrameMetadata): + for member in (meta.width, meta.height, meta.spatial_index, meta.temporal_index): + assert member is None or 0 <= member < 2**32 + else: + assert meta.audio_level is None or 0 <= meta.audio_level <= 1 + + +class Session: + """A connected pair sending audio and video through transforms that the fuzz data drives.""" + + caller: webrtc.RTCPeerConnection + callee: webrtc.RTCPeerConnection + senders: list[webrtc.RTCRtpSender] + receivers: list[webrtc.RTCRtpReceiver] + + def __init__(self) -> None: + self.inp = Input(b'') + self.failures: list[BaseException] = [] + self.history: list[Frame] = [] + self.keys: list[bytes] = [bytes(16)] + + async def start(self) -> None: + self.caller, self.callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + receivers: list[webrtc.RTCRtpReceiver] = [] + self.callee.on('track', lambda event: receivers.append(event.receiver)) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) + self.senders = [self.caller.add_track(track) for track in stream.get_tracks()] + await connect(self.caller, self.callee) + self.receivers = receivers + + def stop(self) -> None: + self.caller.close() + self.callee.close() + + def fail(self, error: BaseException) -> None: + if not isinstance(error, EXPECTED): + self.failures.append(error) + + def key(self) -> bytes: + if self.inp.flag(): + self.keys = [*self.keys[-7:], bytes(self.inp.contiguous(self.inp.small(48)))] + return self.inp.choice(self.keys) + + def key_id(self) -> int: + return self.inp.choice([0, 1, 2**64 - 1]) if self.inp.small(7) < 7 else mistyped(self.inp.integer(16)) + + async def keyed(self, transform: SFrameTransform) -> None: + for _ in range(self.inp.small(3)): + if isinstance(transform, webrtc.RTCRtpSFrameEncryptor): + await transform.set_encryption_key(self.key(), self.key_id()) + elif self.inp.flag(): + await transform.add_decryption_key(self.key(), self.key_id()) + else: + await transform.remove_decryption_key(self.key_id()) + + def on_error(self, event: webrtc.SFrameTransformErrorEvent) -> None: + if not ( + isinstance(event.error_type, webrtc.SFrameTransformErrorEventType) + and (event.key_id is not None) == (event.error_type == webrtc.SFrameTransformErrorEventType.key_id) + and isinstance(event.frame, (webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame)) + ): + self.failures.append(AssertionError(f'a wrong error event: {vars(event)}')) + + async def sframe_transform(self, *, sending: bool) -> SFrameTransform: + suite = self.inp.choice(SUITES) + transform: SFrameTransform + if sending: + transform = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(suite)) + else: + transform = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(suite)) + transform.on('error', self.on_error) + await self.keyed(transform) + return transform + + def script_transform(self, *, sending: bool) -> webrtc.RTCRtpScriptTransform: + worker: Callable[[webrtc.RTCTransformEvent], object] = Worker(self) + if self.inp.flag(): + worker = functools.partial(self.piped, stream=self.sframe_stream(sending=sending)) + if self.inp.flag(): + return webrtc.RTCRtpScriptTransform(worker, self.inp.maybe(lambda: self.inp.small(4))) + parameters = webrtc.WorkerAndParameters(worker, mistyped(self.inp.choice([None, 'sframe', 'other']))) + return webrtc.RTCRtpScriptTransform(parameters) + + async def transform_of(self, *, sending: bool) -> Transform: + mode = self.inp.small(3) + if mode == 0: + return None + if mode == 1: + return await self.sframe_transform(sending=sending) + return self.script_transform(sending=sending) + + def sframe_stream(self, *, sending: bool) -> SFrameStream: + options = webrtc.SFrameTransformOptions(self.inp.choice(SUITES)) + if sending: + return webrtc.SFrameEncryptorStream(options) + decryptor = webrtc.SFrameDecryptorStream(options) + decryptor.on('error', self.on_error) + return decryptor + + async def piped(self, event: webrtc.RTCTransformEvent, stream: SFrameStream) -> None: + try: + await self.pipe(event.transformer, stream) + except Exception as e: # ruff: ignore[blind-except] # checked by the session + self.fail(e) + + async def pipe(self, transformer: webrtc.RTCRtpScriptTransformer, stream: SFrameStream) -> None: + if isinstance(stream, webrtc.SFrameEncryptorStream): + await stream.set_encryption_key(self.key(), 1) + else: + await stream.add_decryption_key(self.key(), 1) + piped = transformer.readable.pipe_through(stream) + await piped.pipe_to(transformer.writable) + + async def configure(self) -> None: + slots: list[Slot] = [*self.senders, *self.receivers] + for _ in range(self.inp.small(4)): + with contextlib.suppress(*EXPECTED): + await self.reconfigure(self.inp.choice(slots), slots) + + async def reconfigure(self, slot: Slot, slots: list[Slot]) -> None: + mode = self.inp.small(3) + current = slot.transform + if mode == 0 and isinstance(current, (webrtc.RTCRtpSFrameEncryptor, webrtc.RTCRtpSFrameDecryptor)): + await self.keyed(current) + elif mode == 1: + slot.transform = mistyped(self.inp.choice(slots).transform) + else: + sending = isinstance(slot, webrtc.RTCRtpSender) != (self.inp.small(7) == 7) + slot.transform = mistyped(await self.transform_of(sending=sending)) + + async def run(self, inp: Input) -> None: + if any(pc.connection_state != webrtc.RTCPeerConnectionState.connected for pc in (self.caller, self.callee)): + self.stop() + await self.start() + self.inp = inp + await self.configure() + await asyncio.sleep(SLICE) + failures = self.failures + self.failures = [] + if len(failures) > 0: + raise failures[0] + + +class Worker: + """Reads the frames of a transformer and does what the fuzz data says with each.""" + + def __init__(self, session: Session) -> None: + self.session = session + self.held: list[Frame] = [] + self.actions: list[Callable[[Frame, webrtc.RTCRtpScriptTransformer], Frame | None]] = [ + self.drop, + self.replace, + self.enlarge, + self.edit, + self.mistype, + self.copied, + self.foreign, + self.reorder, + self.request_key_frame, + ] + + async def __call__(self, event: webrtc.RTCTransformEvent) -> None: + try: + await self.work(event.transformer) + except Exception as e: # ruff: ignore[blind-except] # checked by the session + self.session.fail(e) + + async def work(self, transformer: Transformer) -> None: + reader = transformer.readable.get_reader() + writer = transformer.writable.get_writer() + while True: + result = await reader.read() + if result.done: + return + frame = result.value + assert frame is not None + check_metadata(frame) + history = self.session.history + history.append(frame) + del history[:-16] + await self.handle(frame, transformer, writer) + + async def handle(self, frame: Frame, transformer: Transformer, writer: Writer) -> None: + inp = self.session.inp + try: + written = frame if inp.exhausted() else inp.choice(self.actions)(frame, transformer) + except EXPECTED: + written = frame + if written is None: + return + # a frame of a previous transform of the sender or receiver is written as its own, as the specification says, + # which drops the frames read after it until their counters catch up + fresh = written is frame and frame._counter > transformer._last_received + await writer.write(written) + if fresh: + assert len(frame.data) == 0 + copied = False + with contextlib.suppress(webrtc.DataCloneError): + _ = copy_frame(frame) + copied = True + assert not copied + if inp.small(63) == 63: + # errors the stream, which ends the worker + await writer.write(mistyped(b'not a frame')) + + @staticmethod + def drop(_frame: Frame, _transformer: Transformer) -> Frame | None: + return None + + def replace(self, frame: Frame, _transformer: Transformer) -> Frame | None: + frame.data = self.session.inp.contiguous(self.session.inp.small(2048)) + return frame + + def enlarge(self, frame: Frame, _transformer: Transformer) -> Frame | None: + inp = self.session.inp + frame.data = (b'\0' + bytes(inp.contiguous(inp.small(64)))) * (inp.choice(LARGE) // 64 + 1) + return frame + + def edit(self, frame: Frame, _transformer: Transformer) -> Frame | None: + inp = self.session.inp + data = frame.data + del data[inp.small(len(data)) :] + data.extend(inp.contiguous(inp.small(32))) + if len(data) > 0: + data[inp.small(len(data) - 1)] ^= inp.small(255) + return frame + + def mistype(self, frame: Frame, _transformer: Transformer) -> Frame | None: + """Data of another type, or strided: TypeError.""" + frame.data = mistyped(self.session.inp.choice([None, 1, 'data', memoryview(b'abcd')[::2], memoryview(b'ab')])) + return frame + + def copied(self, frame: Frame, _transformer: Transformer) -> Frame | None: + """A copy with other metadata, which has it and the data of the frame; written, it's dropped.""" + inp = self.session.inp + meta = metadata(inp, video=isinstance(frame, webrtc.RTCEncodedVideoFrame)) if inp.flag() else None + if isinstance(frame, webrtc.RTCEncodedVideoFrame): + copy: Frame = webrtc.RTCEncodedVideoFrame(frame, webrtc.RTCEncodedVideoFrameOptions(mistyped(meta))) + else: + copy = webrtc.RTCEncodedAudioFrame(frame, webrtc.RTCEncodedAudioFrameOptions(mistyped(meta))) + assert copy.data == frame.data + if meta is not None: + got = copy.get_metadata() + for name in type(meta).__dataclass_fields__: + member = getattr(meta, name) + assert member is None or same(getattr(got, name), member) + self.session.history.append(copy) + return copy if inp.flag() else frame + + def foreign(self, frame: Frame, _transformer: Transformer) -> Frame | None: + """A frame read before, maybe written, or by another worker: dropped.""" + return self.session.inp.choice(self.session.history) if self.session.inp.flag() else frame + + def reorder(self, frame: Frame, _transformer: Transformer) -> Frame | None: + """Held, and one held written later: older than the last written, it's dropped.""" + inp = self.session.inp + self.held.append(frame) + del self.held[:-8] + if inp.flag(): + return None + return self.held.pop(inp.small(len(self.held) - 1)) + + def request_key_frame(self, frame: Frame, transformer: Transformer) -> Frame | None: + inp = self.session.inp + if inp.flag(): + request = asyncio.ensure_future(transformer.generate_key_frame(inp.choice(RIDS))) + else: + request = asyncio.ensure_future(transformer.send_key_frame_request()) + request.add_done_callback(self.requested) + return frame + + def requested(self, request: asyncio.Future[None]) -> None: + error = None if request.cancelled() else request.exception() + if error is not None: + self.session.fail(error) + + +session = Session() + + +def on_loop_exception(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + """An exception of a callback or handler, which the fuzzer would miss: only the documented ones are expected.""" + error = context.get('exception') + if isinstance(error, BaseException): + session.fail(error) + + +loop.set_exception_handler(on_loop_exception) +loop.run_until_complete(session.start()) + + +def test_one_input(data: bytes) -> None: + loop.run_until_complete(session.run(Input(data))) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/fuzz_sframe.py b/tests/fuzz/fuzz_sframe.py new file mode 100644 index 0000000..985d8df --- /dev/null +++ b/tests/fuzz/fuzz_sframe.py @@ -0,0 +1,207 @@ +# +# 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. +# + +"""Fuzzes SFrame (RFC 9605) natively: headers, key derivation, and decryption of what a remote peer sends. + +A decryptor parses untrusted frames: whatever the bytes, it gives the plaintext or nothing. Oracles check that a +header parses back to its key id and counter, and that every suite decrypts what it encrypted, and nothing tampered. +""" + +from __future__ import annotations + +import contextlib +import pathlib +import sys + +import atheris + +with atheris.instrument_imports(): + from inputs import Input + +from webrtc import wrtc + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) +from tests.helpers import mistyped + +# tag sizes (Nt) of the suite identifiers +TAGS = {1: 10, 2: 8, 3: 4, 4: 16, 5: 16, 6: 10, 7: 8, 8: 4} +SUITES = list(TAGS) +# pybind11 raises TypeError for arguments it can't convert, and for strided buffers; ValueError for unknown suites +EXPECTED = (TypeError, ValueError) +MAX_U64 = 2**64 - 1 +NONE, AUTHENTICATION, KEY_ID, SYNTAX = 0, 1, 2, 3 + + +def u64(inp: Input) -> int: + if inp.flag(): + return inp.unsigned(16) & MAX_U64 + return inp.small(MAX_U64) >> (inp.small(8) * 8) + + +def field_size(value: int) -> int: + return 0 if value < 8 else (value.bit_length() + 7) // 8 + + +def suite(inp: Input) -> int: + return inp.choice(SUITES) if inp.small(7) < 7 else mistyped(inp.integer(16)) + + +def octets(inp: Input, limit: int = 64) -> bytes: + return bytes(inp.contiguous(inp.small(limit))) + + +def parse(inp: Input) -> None: + """Any bytes: a header within them, which parses the same from its own bytes and not without its last one.""" + data = inp.contiguous(inp.small(40)) + header = wrtc._sframeParseHeader(data) + if header is None: + return + key_id, counter, size = header + raw = bytes(data) + assert 1 <= size <= min(len(raw), 17) + assert 0 <= key_id <= MAX_U64 + assert 0 <= counter <= MAX_U64 + assert wrtc._sframeParseHeader(raw[:size]) == header + assert wrtc._sframeParseHeader(raw[: size - 1]) is None + + +def header(inp: Input) -> None: + key_id, counter = u64(inp), u64(inp) + encoded = wrtc._sframeHeader(key_id, counter) + size = 1 + field_size(key_id) + field_size(counter) + assert len(encoded) == size + assert wrtc._sframeParseHeader(encoded + octets(inp, 8)) == (key_id, counter, size) + + +def decrypt(inp: Input) -> None: + """Arbitrary ciphertexts, or tampered real ones: decrypted or None, never a crash.""" + ciphertext = inp.contiguous(inp.small(256)) + if inp.flag(): + real = wrtc._sframeEncrypt(inp.choice(SUITES), octets(inp), u64(inp), u64(inp), b'', octets(inp, 128)) + tampered = bytearray(real) + for _ in range(inp.small(4)): + if len(tampered) > 0: + tampered[inp.small(len(tampered) - 1)] ^= inp.small(254) + 1 + ciphertext = bytes(tampered[: inp.small(len(tampered))] if inp.flag() else tampered) + out = wrtc._sframeDecrypt(suite(inp), inp.contiguous(inp.small(64)), inp.contiguous(inp.small(32)), ciphertext) + assert out is None or isinstance(out, bytes) + + +def round_trip(inp: Input) -> None: + """Encrypted then decrypted gives the plaintext back; any byte changed, or other metadata, gives None.""" + suite_id = inp.choice(SUITES) + key, key_id, counter = octets(inp), u64(inp), u64(inp) + metadata, plaintext = octets(inp, 32), octets(inp, 512) + ciphertext = wrtc._sframeEncrypt(suite_id, key, key_id, counter, metadata, plaintext) + size = 1 + field_size(key_id) + field_size(counter) + assert len(ciphertext) == size + len(plaintext) + TAGS[suite_id] + assert wrtc._sframeParseHeader(ciphertext) == (key_id, counter, size) + assert wrtc._sframeDecrypt(suite_id, key, metadata, ciphertext) == plaintext + assert wrtc._sframeEncrypt(suite_id, key, key_id, counter, metadata, plaintext) == ciphertext + + tampered = bytearray(ciphertext) + tampered[inp.small(len(tampered) - 1)] ^= inp.small(254) + 1 + assert wrtc._sframeDecrypt(suite_id, key, metadata, tampered) is None + other = octets(inp, 32) + if other != metadata: + assert wrtc._sframeDecrypt(suite_id, key, other, ciphertext) is None + other = octets(inp) + if other != key: + assert wrtc._sframeDecrypt(suite_id, other, metadata, ciphertext) is None + assert wrtc._sframeDecrypt(suite_id, key, metadata, ciphertext[: inp.small(size + TAGS[suite_id] - 1)]) is None + + +def derive(inp: Input) -> None: + suite_id = suite(inp) + key, salt = wrtc._sframeDerive( + suite_id, inp.contiguous(inp.small(128)), u64(inp) if inp.small(7) < 7 else mistyped(inp.integer(16)) + ) + assert len(salt) == 12 + assert len(key) in {16, 32, 48, 96} + + +class Context: + """The native transform of the streams: keys added, removed and replaced between decryptions.""" + + def __init__(self, inp: Input) -> None: + self.inp = inp + self.suite = inp.choice(SUITES) + self.decryptor_suite = self.suite if inp.small(7) < 7 else inp.choice(SUITES) + self.encryptor = wrtc.SFrameTransform(self.suite, encrypting=True) + self.decryptor = wrtc.SFrameTransform(self.decryptor_suite, encrypting=False) + self.keys: dict[int, bytes] = {} + self.sending: tuple[bytes, int] | None = None + + def key_id(self) -> int: + return u64(self.inp) if self.inp.small(3) < 3 else self.inp.choice([0, *self.keys]) + + def add_key(self) -> None: + key, key_id = octets(self.inp), self.key_id() + assert self.decryptor.addDecryptionKey(key, key_id) + self.keys[key_id] = key + + def remove_key(self) -> None: + key_id = self.key_id() + self.decryptor.removeDecryptionKey(key_id) + self.keys.pop(key_id, None) + + def set_key(self) -> None: + key, key_id = octets(self.inp), self.key_id() + assert self.encryptor.setEncryptionKey(key, key_id) + self.sending = (key, key_id) + if self.inp.flag(): + assert self.decryptor.addDecryptionKey(key, key_id) + self.keys[key_id] = key + + def round_trip(self) -> None: + plaintext = self.inp.contiguous(self.inp.small(256)) + ciphertext = self.encryptor.encrypt(plaintext) + assert (ciphertext is None) == (self.sending is None) + if ciphertext is None or self.sending is None: + return + key, key_id = self.sending + result = self.decryptor.decrypt(ciphertext) + if key_id not in self.keys: + assert result == (None, KEY_ID, key_id) + elif self.suite == self.decryptor_suite and self.keys[key_id] == key: + assert result == (bytes(plaintext), NONE, None) + else: + assert result[0] is None + assert result[1] in {AUTHENTICATION, SYNTAX} + + def decrypt(self) -> None: + out, error, key_id = self.decryptor.decrypt(self.inp.contiguous(self.inp.small(256))) + assert error in {NONE, AUTHENTICATION, KEY_ID, SYNTAX} + assert (out is None) == (error != NONE) + assert (key_id is not None) == (error == KEY_ID) + assert key_id is None or key_id not in self.keys + + def run(self) -> None: + actions = [self.add_key, self.remove_key, self.set_key, self.round_trip, self.decrypt] + for _ in range(self.inp.small(12)): + if self.inp.exhausted(): + break + self.inp.choice(actions)() + + +def context(inp: Input) -> None: + Context(inp).run() + + +def test_one_input(data: bytes) -> None: + inp = Input(data) + with contextlib.suppress(*EXPECTED): + inp.choice([parse, header, decrypt, round_trip, derive, context])(inp) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/fuzz_sframe_stream.py b/tests/fuzz/fuzz_sframe_stream.py new file mode 100644 index 0000000..d35b269 --- /dev/null +++ b/tests/fuzz/fuzz_sframe_stream.py @@ -0,0 +1,320 @@ +# +# 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. +# + +"""Fuzzes SFrameEncryptorStream and SFrameDecryptorStream: keys added, removed and rotated between chunks. + +Chunks are arbitrary bytes, views, ciphertexts of the encryptor written in any order or tampered, or not buffers at +all. Each chunk is read back transformed, dropped, or (for the decryptor) reported by an ``error`` event, as a model +of the keys predicts; anything else than a buffer errors the stream with TypeError. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import pathlib +import sys +from typing import TYPE_CHECKING, Generic, TypeVar + +import atheris + +with atheris.instrument_imports(): + from inputs import Input + + import webrtc + from webrtc import wrtc + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent)) +from tests.helpers import mistyped + +if TYPE_CHECKING: + from typing_extensions import Buffer + +# a key or key id of the wrong type, or out of 64 bits +EXPECTED = (TypeError, webrtc.InvalidRangeError, webrtc.InvalidModificationError) +SUITES = list(webrtc.SFrameCipherSuite) +MAX_U64 = 2**64 - 1 +# generous: the fuzz VM stalls for seconds under load, a hang is the timeout of libFuzzer +TIMEOUT = 20 +DROPPED, ERRORED = object(), object() + +S = TypeVar('S', webrtc.SFrameEncryptorStream, webrtc.SFrameDecryptorStream) + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +failures: list[BaseException] = [] + + +def on_loop_exception(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + error = context.get('exception') + if isinstance(error, BaseException) and not isinstance(error, EXPECTED): + failures.append(error) + + +loop.set_exception_handler(on_loop_exception) + + +class Pipe(Generic[S]): + """A stream with a read always pending, which relieves its backpressure: only an output fulfills it.""" + + def __init__(self, stream: S) -> None: + self.stream = stream + self.writer = stream.writable.get_writer() + self.reader = stream.readable.get_reader() + self.read = asyncio.ensure_future(self.reader.read()) + self.errors: list[webrtc.SFrameTransformErrorEvent] = [] + if isinstance(stream, webrtc.SFrameDecryptorStream): + stream.on('error', self.errors.append) + + async def write(self, chunk: object) -> object: + """What came out of a chunk: its output, DROPPED, or ERRORED.""" + try: + await asyncio.wait_for(self.writer.write(mistyped(chunk)), TIMEOUT) + except TypeError: + assert isinstance(await asyncio.wait_for(self._failure(), TIMEOUT), TypeError) + return ERRORED + # the read fulfilled is resolved in a later turn of the loop + for _ in range(20): + if self.read.done(): + break + await asyncio.sleep(0) + if not self.read.done(): + return DROPPED + result = self.read.result() + assert not result.done + self.read = asyncio.ensure_future(self.reader.read()) + return result.value + + async def _failure(self) -> BaseException | None: + try: + await self.read + except TypeError as e: + return e + return None + + async def error(self) -> webrtc.SFrameTransformErrorEvent: + """The error event of the chunk last dropped, queued like a task.""" + for _ in range(100): + if len(self.errors) > 0: + break + await asyncio.sleep(0) + assert len(self.errors) == 1 + event = self.errors.pop() + assert event.target is self.stream + assert event.type == 'error' + return event + + def close(self) -> None: + self.read.cancel() + + +def u64(inp: Input) -> int: + if inp.flag(): + return inp.unsigned(16) & MAX_U64 + return inp.small(MAX_U64) >> (inp.small(8) * 8) + + +def key_id(inp: Input, known: list[int]) -> int: + mode = inp.small(15) + if mode == 15: + return mistyped(inp.integer(16)) + if mode < 6 and len(known) > 0: + return inp.choice(known) + return u64(inp) + + +def key(inp: Input) -> Buffer: + if inp.small(31) == 31: + return mistyped(inp.choice(['key', 1, None, [1, 2]])) + return inp.contiguous(inp.small(48)) + + +def chunk(inp: Input) -> object: + if inp.small(31) == 31: + return inp.choice([None, 1, 'chunk', [1], {}]) + return inp.contiguous(inp.small(512)) + + +class Session: + def __init__(self, inp: Input) -> None: + self.inp = inp + self.suite = inp.choice(SUITES) + self.decryptor_suite = self.suite if inp.small(7) < 7 else inp.choice(SUITES) + self.encryptor = Pipe(webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions(self.suite))) + self.decryptor = Pipe(webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions(self.decryptor_suite))) + self.sending: tuple[bytes, int] | None = None + self.counter = 0 + self.keys: dict[int, bytes] = {} + self.sent: list[tuple[bytes, bytes, int, bytes]] = [] + + async def set_key(self) -> None: + raw, kid = key(self.inp), key_id(self.inp, list(self.keys)) + await self.encryptor.stream.set_encryption_key(raw, kid) + self.sending = (bytes(memoryview(raw)), kid) + if self.inp.flag(): + await self.decryptor.stream.add_decryption_key(raw, kid) + self.keys[kid] = self.sending[0] + + async def add_key(self) -> None: + sending: list[int] = [self.sending[1]] if self.sending is not None else [] + raw, kid = key(self.inp), key_id(self.inp, sending + list(self.keys)) + await self.decryptor.stream.add_decryption_key(raw, kid) + self.keys[kid] = bytes(memoryview(raw)) + + async def remove_key(self) -> None: + kid = key_id(self.inp, list(self.keys)) + await self.decryptor.stream.remove_decryption_key(kid) + self.keys.pop(kid, None) + + async def encrypt(self) -> None: + plaintext = chunk(self.inp) + out = await self.encryptor.write(plaintext) + if out is ERRORED: + self.restart_encryptor() + return + if self.sending is None: + assert out is DROPPED + return + assert isinstance(out, bytes) + raw, kid = self.sending + header = wrtc._sframeParseHeader(out) + assert header is not None + # one counter for the encryptor, whatever the key + assert header[:2] == (kid, self.counter) + self.counter += 1 + self.sent.append((out, raw, kid, bytes(memoryview(mistyped(plaintext))))) + + async def decrypt_sent(self) -> None: + if len(self.sent) == 0: + return + ciphertext, raw, kid, plaintext = self.inp.choice(self.sent) + tampered = self.inp.small(3) == 3 + if tampered: + changed = bytearray(ciphertext) + changed[self.inp.small(len(changed) - 1)] ^= self.inp.small(254) + 1 + ciphertext = bytes(changed) + out = await self.decryptor.write(ciphertext) + assert out is not ERRORED + if tampered: + assert out is DROPPED + await self.decryptor.error() + return + if kid not in self.keys: + assert out is DROPPED + event = await self.decryptor.error() + assert event.error_type == webrtc.SFrameTransformErrorEventType.key_id + assert event.key_id == kid + elif self.keys[kid] == raw and self.suite == self.decryptor_suite: + assert out == plaintext + else: + assert out is DROPPED + event = await self.decryptor.error() + assert event.error_type in { + webrtc.SFrameTransformErrorEventType.authentication, + webrtc.SFrameTransformErrorEventType.syntax, + } + assert event.key_id is None + assert event.frame == ciphertext + + async def decrypt(self) -> None: + data = chunk(self.inp) + out = await self.decryptor.write(data) + if out is ERRORED: + self.restart_decryptor() + return + if out is not DROPPED: + assert isinstance(out, bytes) + assert self.decryptor.errors == [] + return + event = await self.decryptor.error() + assert event.frame == bytes(memoryview(mistyped(data))) + assert (event.key_id is not None) == (event.error_type == webrtc.SFrameTransformErrorEventType.key_id) + if event.key_id is not None: + assert event.key_id not in self.keys + + async def burst(self) -> None: + if self.sending is None: + return + plaintexts = [bytes(self.inp.contiguous(self.inp.small(64))) for _ in range(self.inp.small(6) + 1)] + writes = [self.encryptor.writer.write(plaintext) for plaintext in plaintexts] + outs: list[object] = [] + for _ in plaintexts: + result = await asyncio.wait_for(self.encryptor.read, TIMEOUT) + self.encryptor.read = asyncio.ensure_future(self.encryptor.reader.read()) + outs.append(result.value) + await asyncio.wait_for(asyncio.gather(*writes), TIMEOUT) + raw, kid = self.sending + for out, plaintext in zip(outs, plaintexts): + assert isinstance(out, bytes) + header = wrtc._sframeParseHeader(out) + assert header is not None + assert header[:2] == (kid, self.counter) + self.counter += 1 + self.sent.append((out, raw, kid, plaintext)) + + def restart_encryptor(self) -> None: + self.encryptor.close() + self.encryptor = Pipe(webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions(self.suite))) + self.sending = None + self.counter = 0 + + def restart_decryptor(self) -> None: + self.decryptor.close() + self.decryptor = Pipe(webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions(self.decryptor_suite))) + self.keys = {} + + async def end(self) -> None: + pipe = self.encryptor if self.inp.flag() else self.decryptor + if self.inp.flag(): + await asyncio.wait_for(pipe.writer.close(), TIMEOUT) + result = await asyncio.wait_for(pipe.read, TIMEOUT) + assert result.done + else: + await asyncio.wait_for(pipe.writer.abort(ValueError('aborted')), TIMEOUT) + failed = False + try: + await asyncio.wait_for(pipe.read, TIMEOUT) + except ValueError: + failed = True + assert failed + if pipe is self.encryptor: + self.restart_encryptor() + else: + self.restart_decryptor() + + async def run(self) -> None: + actions = [self.set_key, self.add_key, self.remove_key, self.encrypt, self.decrypt_sent, self.decrypt] + actions += [self.burst, self.end] + try: + for _ in range(self.inp.small(24)): + if self.inp.exhausted(): + break + with contextlib.suppress(*EXPECTED): + await self.inp.choice(actions)() + finally: + self.encryptor.close() + self.decryptor.close() + + +async def session(inp: Input) -> None: + # the streams are made on the running loop + await Session(inp).run() + + +def test_one_input(data: bytes) -> None: + loop.run_until_complete(session(Input(data))) + if len(failures) > 0: + raise failures.pop() + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/inputs.py b/tests/fuzz/inputs.py index 28e5b85..c00c308 100644 --- a/tests/fuzz/inputs.py +++ b/tests/fuzz/inputs.py @@ -38,6 +38,9 @@ class Input: def __init__(self, data: bytes) -> None: self._fdp = atheris.FuzzedDataProvider(data) + def exhausted(self) -> bool: + return self._fdp.remaining_bytes() == 0 + def flag(self) -> bool: return self._fdp.ConsumeBool() @@ -85,6 +88,13 @@ def buffer(self, size: int) -> Buffer: kind = self._fdp.ConsumeIntInRange(0, 7) return VIEWS[kind](data) if kind < len(VIEWS) else data + def contiguous(self, size: int) -> Buffer: + """Like buffer, but rarely strided: native functions reject strided views before reading them.""" + data = self.buffer(size) + if isinstance(data, memoryview) and not data.contiguous and self.small(15) < 15: + return data.tobytes() + return data + def destination(self, size: int) -> Buffer: """A writable buffer of about size bytes.""" size = max(0, size + self.choice([0, 0, 0, -1, 1, 64])) diff --git a/tests/helpers.py b/tests/helpers.py index b0b869e..3ca7b23 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -32,6 +32,20 @@ _T = TypeVar('_T') +def stats_of_type(report: webrtc.RTCStatsReport, stats_type: str) -> list[webrtc.RTCStats]: + """The stats of a type in a report.""" + return [stats for stats in report.values() if stats.type == stats_type] + + +def copy_frame( + frame: webrtc.RTCEncodedVideoFrame | webrtc.RTCEncodedAudioFrame, +) -> webrtc.RTCEncodedVideoFrame | webrtc.RTCEncodedAudioFrame: + """A copy of an encoded frame, by the constructor of its kind.""" + if isinstance(frame, webrtc.RTCEncodedVideoFrame): + return webrtc.RTCEncodedVideoFrame(frame) + return webrtc.RTCEncodedAudioFrame(frame) + + def mistyped(value: object) -> _T: """A value of the wrong type, passed where a test checks that the library rejects it at runtime.""" return cast('_T', value) diff --git a/tests/idl/compare.py b/tests/idl/compare.py index 2206c5e..1b7e054 100644 --- a/tests/idl/compare.py +++ b/tests/idl/compare.py @@ -19,6 +19,7 @@ import enum import functools import inspect +import keyword import re import textwrap from typing import TYPE_CHECKING @@ -49,7 +50,10 @@ 'maplike': ('__getitem__', '__iter__', '__len__', '__contains__', 'get', 'keys', 'values', 'items'), 'setlike': ('__iter__', '__len__', '__contains__'), 'iterable': ('__iter__',), + 'async_iterable': ('__aiter__', 'values'), } +# toJSON returns the JSON form of its dictionary, which is a dict in Python +_JSON = re.compile(r'\b(dict|Mapping)\b') def snake_case(name: str) -> str: @@ -57,6 +61,11 @@ def snake_case(name: str) -> str: return _BOUNDARY.sub('_', name).lower() +def python_name(name: str) -> str: + """``'from'`` -> ``'from_'``: a keyword takes a trailing underscore, which Python names can't do without.""" + return f'{name}_' if keyword.iskeyword(name) else name + + @functools.cache def _assigned(cls: type) -> frozenset[str]: """The attributes the methods of a class assign to ``self``.""" @@ -145,10 +154,12 @@ def named(name: str) -> bool: class _Arguments(_Types): """Matches the arguments of one overload with the parameters of a signature.""" - def __init__(self, spec: Spec, label: str, parameters: list[inspect.Parameter]) -> None: + def __init__(self, spec: Spec, label: str, parameters: list[inspect.Parameter], *, span: int = 0) -> None: super().__init__(spec) self.label = label self.parameters = parameters + # the arguments other overloads take: optional parameters in their positions merge those overloads + self.span = span self.unused = {parameter.name: parameter for parameter in parameters} self.order: list[int] = [] self.found: list[str] = [] @@ -159,13 +170,20 @@ def check(self, arguments: list[Argument]) -> list[str]: if self.order != sorted(self.order): self.found.append(f'{self.label}: arguments out of order') for parameter in self.unused.values(): + if self.merged(parameter): + continue prefix = '*' if parameter.kind is _P.VAR_POSITIONAL else '**' if parameter.kind is _P.VAR_KEYWORD else '' self.found.append(f'{self.label}({prefix}{parameter.name}): extra argument') return self.found + def merged(self, parameter: inspect.Parameter) -> bool: + """Whether an optional parameter takes an argument of another overload, like ``MediaStream(tracks=None)``.""" + positional = parameter.kind in {_P.POSITIONAL_ONLY, _P.POSITIONAL_OR_KEYWORD} + return positional and parameter.default is not _P.empty and self.parameters.index(parameter) < self.span + def take(self, name: str) -> inspect.Parameter | None: - parameter = self.unused.pop(snake_case(name), None) - return parameter if parameter is not None else self.unused.pop(name, None) + parameter = self.unused.pop(python_name(snake_case(name)), None) + return parameter if parameter is not None else self.unused.pop(python_name(name), None) def argument(self, index: int, argument: Argument) -> None: path = f'{self.label}({argument["name"]})' @@ -179,7 +197,9 @@ def argument(self, index: int, argument: Argument) -> None: if parameter is None: self.found.append(f'{path}: missing argument') return - self.found.append(f'{path}: named {parameter.name}') + # a positional-only parameter has no name callers use + if parameter.kind is not _P.POSITIONAL_ONLY: + self.found.append(f'{path}: named {parameter.name}') if argument['variadic'] != (parameter.kind is _P.VAR_POSITIONAL): self.found.append(f'{path}: should {"" if argument["variadic"] else "not "}be variadic') @@ -243,16 +263,16 @@ def __init__(self, spec: Spec, definition: Definition, cls: type) -> None: def resolve(self, idl_name: str, kind: str) -> str | None: """Checks the snake_case name and the camelCase alias of a member, returning the one to inspect.""" - snake = snake_case(idl_name) - self.expected |= {idl_name, snake} - if snake not in self.names and idl_name not in self.names: + snake, camel = python_name(snake_case(idl_name)), python_name(idl_name) + self.expected |= {camel, snake} + if snake not in self.names and camel not in self.names: self.found.append(f'{idl_name}: missing {kind}') return None if snake not in self.names: self.found.append(f'{idl_name}: missing snake_case name {snake}') - elif idl_name not in self.names: + elif camel not in self.names: self.found.append(f'{idl_name}: missing camelCase alias') - return snake if snake in self.names else idl_name + return snake if snake in self.names else camel def annotation(self, name: str) -> object: for klass in self.cls.__mro__: @@ -262,11 +282,11 @@ def annotation(self, name: str) -> object: return _P.empty def extras(self) -> list[str]: - infrastructure = set() + # the names of bases are infrastructure, or the extras of a base definition, reported once for it + inherited = set() for base in self.cls.__mro__[1:]: - if base.__name__ not in self.spec.definitions: - infrastructure |= _names(base) - extra = self.names - self.expected - infrastructure + inherited |= _names(base) + extra = self.names - self.expected - inherited # a snake_case name and its camelCase alias are one member return [f'{name}: extra member' for name in extra if snake_case(name) == name or snake_case(name) not in extra] @@ -376,7 +396,13 @@ def signature() -> inspect.Signature: return result.replace(parameters=list(result.parameters.values())[1:]) # self or cls self.check_overloads(idl_name, overloads, signature) - self.found += self.lacks(f'{idl_name}()', overloads[0]['idlType'], signature().return_annotation) + self.check_return(idl_name, overloads[0], signature().return_annotation) + + def check_return(self, idl_name: str, operation: Operation, returned: object) -> None: + if idl_name != 'toJSON': + self.found += self.lacks(f'{idl_name}()', operation['idlType'], returned) + elif not _mentions(_JSON, returned): + self.found.append(f'{idl_name}(): should return a dict') def check_overloads( self, label: str, overloads: Sequence[Operation | Constructor], signature: Callable[[], inspect.Signature] @@ -386,7 +412,10 @@ def check_overloads( parameters = list(signature().parameters.values()) except (TypeError, ValueError): return - results = [_Arguments(self.spec, label, parameters).check(overload['arguments']) for overload in overloads] + span = max(len(overload['arguments']) for overload in overloads) if len(overloads) > 1 else 0 + results = [ + _Arguments(self.spec, label, parameters, span=span).check(overload['arguments']) for overload in overloads + ] self.found += min(results, key=len) diff --git a/tests/idl/expectations.json b/tests/idl/expectations.json index fbdd283..e3b7bdd 100644 --- a/tests/idl/expectations.json +++ b/tests/idl/expectations.json @@ -1,503 +1,49 @@ { - "AudioDataInit": [ - "transfer: missing member" - ], - "Blob": [ - "constructor(blobParts): named parts", - "constructor(options.endings): missing member", - "stream: missing method", - "textStream: missing method" - ], - "BlobPropertyBag": [ - "missing dictionary" - ], "CameraDevicePermissionDescriptor": [ "missing dictionary" ], - "DOMRectReadOnly": [ - "fromRect: missing method", - "toJSON: missing method" - ], - "DeviceChangeEvent": [ - "missing interface" - ], - "DeviceChangeEventInit": [ - "missing dictionary" - ], - "EchoCancellationModeEnum": [ - "missing enum" - ], - "EncodedVideoChunkType": [ - "missing enum" - ], - "EndingType": [ - "missing enum" - ], - "InputDeviceInfo": [ - "missing interface" - ], - "KeyFrameRequestEvent": [ - "missing interface" - ], - "MediaDeviceInfo": [ - "missing interface" - ], - "MediaDeviceKind": [ - "missing enum" - ], - "MediaDevices": [ - "missing interface" - ], - "MediaStream": [ - "constructor(tracks): extra argument" - ], - "MediaStreamConstraints": [ - "missing dictionary" - ], - "MediaStreamTrack": [ - "content_hint: extra member" - ], - "MediaStreamTrackEvent": [ - "constructor(target): extra argument" - ], - "MediaStreamTrackEventInit": [ - "missing dictionary" - ], "MediaStreamTrackHandle": [ "missing interface" ], "MediaStreamTrackProcessorInit": [ "track: type lacks MediaStreamTrackHandle" ], - "MediaTrackCapabilities": [ - "backgroundBlur: missing member", - "facingMode: missing member", - "latency: missing member" - ], - "MediaTrackConstraintSet": [ - "backgroundBlur: missing member", - "facingMode: missing member", - "latency: missing member" - ], - "MediaTrackConstraints": [ - "backgroundBlur: missing member", - "facingMode: missing member", - "latency: missing member" - ], - "MediaTrackSettings": [ - "backgroundBlur: missing member", - "facingMode: missing member", - "latency: missing member" - ], - "MediaTrackSupportedConstraints": [ - "missing dictionary" - ], - "QueuingStrategy": [ - "missing dictionary" - ], - "RTCAnswerOptions": [ - "missing dictionary" - ], - "RTCAudioPlayoutStats": [ - "missing dictionary" - ], - "RTCAudioSourceStats": [ - "missing dictionary" - ], - "RTCCertificate": [ - "expired: extra member", - "generate: extra member" - ], - "RTCCertificateExpiration": [ - "missing dictionary" - ], - "RTCCertificateStats": [ - "missing dictionary" - ], - "RTCCodecStats": [ - "missing dictionary" - ], "RTCConfiguration": [ "always_negotiate_data_channels: extra member", "port_range: extra member", "rtp_header_encryption_policy: extra member" ], - "RTCDTMFToneChangeEvent": [ - "constructor(target): extra argument" - ], - "RTCDTMFToneChangeEventInit": [ - "missing dictionary" - ], - "RTCDataChannelEvent": [ - "constructor(target): extra argument" - ], - "RTCDataChannelEventInit": [ - "missing dictionary" - ], - "RTCDataChannelStats": [ - "missing dictionary" - ], - "RTCDtlsFingerprint": [ - "algorithm: should be optional", - "value: should be optional" - ], - "RTCDtlsRole": [ - "missing enum" - ], - "RTCDtlsTransport": [ - "state: type lacks RTCDtlsTransportState" - ], - "RTCDtlsTransportState": [ - "missing enum" - ], - "RTCEncodedAudioFrame": [ - "missing interface" - ], - "RTCEncodedAudioFrameMetadata": [ - "missing dictionary" - ], - "RTCEncodedAudioFrameOptions": [ - "missing dictionary" - ], - "RTCEncodedFrameMetadata": [ - "missing dictionary" - ], - "RTCEncodedVideoFrame": [ - "missing interface" - ], - "RTCEncodedVideoFrameMetadata": [ - "missing dictionary" - ], - "RTCEncodedVideoFrameOptions": [ - "missing dictionary" - ], "RTCError": [ - "http_request_status_code: extra member", - "message: extra member" - ], - "RTCErrorEvent": [ - "constructor(target): extra argument" - ], - "RTCErrorEventInit": [ - "missing dictionary" + "http_request_status_code: extra member" ], "RTCErrorInit": [ "http_request_status_code: extra member" ], - "RTCIceCandidate": [ - "from_json: extra member", - "toJSON(): type lacks RTCIceCandidateInit" - ], - "RTCIceCandidatePairStats": [ - "missing dictionary" - ], - "RTCIceCandidateStats": [ - "missing dictionary" - ], - "RTCIceConnectionState": [ - "extra value 'max'" - ], - "RTCIceGatherOptions": [ - "missing dictionary" - ], - "RTCIceGathererState": [ - "missing enum" - ], - "RTCIceParameters": [ - "iceLite: missing member", - "password: should be optional", - "usernameFragment: should be optional" - ], "RTCIceServer": [ "credential_type: extra member" ], - "RTCIceTransport": [ - "addRemoteCandidate(remoteCandidate): named candidate", - "addRemoteCandidate(remoteCandidate): should be optional", - "gatheringState: type lacks RTCIceGathererState", - "onerror: missing event", - "start(remoteParameters): should be optional" - ], - "RTCInboundRtpStreamStats": [ - "missing dictionary" - ], - "RTCLocalIceCandidateInit": [ - "missing dictionary" - ], - "RTCMediaSourceStats": [ - "missing dictionary" - ], - "RTCOfferAnswerOptions": [ - "missing dictionary" - ], - "RTCOfferOptions": [ - "missing dictionary" - ], - "RTCOutboundRtpStreamStats": [ - "missing dictionary" - ], - "RTCPeerConnection": [ - "addTrack(streams): named stream", - "addTrack(streams): should be variadic", - "createAnswer(options): missing argument", - "createAnswer(voice_activity_detection): extra argument", - "createDataChannel(dataChannelDict): named options", - "createOffer(voice_activity_detection): extra argument", - "generateCertificate(expires): extra argument", - "generateCertificate(keygenAlgorithm): named algorithm", - "generateCertificate(keygenAlgorithm): should be required", - "setRemoteDescription(description): type lacks RTCSessionDescriptionInit" - ], - "RTCPeerConnectionIceErrorEvent": [ - "constructor(eventInitDict.address): should be optional", - "constructor(eventInitDict.errorText): should be optional", - "constructor(eventInitDict.port): should be optional", - "constructor(eventInitDict.url): should be optional", - "constructor(target): extra argument" - ], - "RTCPeerConnectionIceErrorEventInit": [ - "missing dictionary" - ], - "RTCPeerConnectionIceEvent": [ - "constructor(target): extra argument" - ], - "RTCPeerConnectionIceEventInit": [ - "missing dictionary" - ], - "RTCPeerConnectionStats": [ - "missing dictionary" - ], - "RTCQualityLimitationReason": [ - "missing enum" - ], - "RTCReceivedRtpStreamStats": [ - "missing dictionary" - ], - "RTCRemoteInboundRtpStreamStats": [ - "missing dictionary" - ], - "RTCRemoteOutboundRtpStreamStats": [ - "missing dictionary" - ], - "RTCRtpCapabilities": [ - "codecs: should be required", - "headerExtensions: should be required" - ], - "RTCRtpCodingParameters": [ - "missing dictionary" - ], "RTCRtpEncodingParameters": [ "adaptive_ptime: extra member" ], "RTCRtpHeaderExtensionCapability": [ "direction: extra member" ], - "RTCRtpParameters": [ - "missing dictionary" - ], - "RTCRtpReceiveParameters": [ - "codecs: should be required", - "headerExtensions: should be required", - "rtcp: should be required" - ], - "RTCRtpReceiver": [ - "transform: missing attribute" - ], - "RTCRtpSFrameDecryptor": [ - "missing interface" - ], - "RTCRtpSFrameEncryptor": [ - "missing interface" - ], - "RTCRtpSFrameEncryptorOptions": [ - "missing dictionary" - ], - "RTCRtpScriptTransform": [ - "missing interface" - ], - "RTCRtpScriptTransformType": [ - "missing enum" - ], - "RTCRtpScriptTransformer": [ - "missing interface" - ], - "RTCRtpSendParameters": [ - "codecs: should be required", - "degradation_preference: extra member", - "encodings: should be required", - "headerExtensions: should be required", - "rtcp: should be required" - ], - "RTCRtpSender": [ - "kind: extra member", - "replaceTrack(withTrack): named track", - "setParameters(key_frames): extra argument", - "setParameters(setParameterOptions): missing argument", - "transform: missing attribute" - ], - "RTCRtpStreamStats": [ - "missing dictionary" - ], "RTCRtpTransceiver": [ - "currentDirection: type lacks RTCRtpTransceiverDirection", - "direction: type lacks RTCRtpTransceiverDirection", "get_header_extensions_to_negotiate: extra member", "get_negotiated_header_extensions: extra member", - "kind: extra member", "set_header_extensions_to_negotiate: extra member", "stopped: extra member" ], - "RTCRtpTransceiverDirection": [ - "missing enum" - ], - "RTCRtpTransceiverInit": [ - "direction: type lacks RTCRtpTransceiverDirection" - ], - "RTCSctpTransport": [ - "state: type lacks RTCSctpTransportState" - ], - "RTCSctpTransportState": [ - "missing enum" - ], - "RTCSentRtpStreamStats": [ - "missing dictionary" - ], - "RTCSessionDescription": [ - "toJSON(): type lacks RTCSessionDescriptionInit" - ], - "RTCSessionDescriptionInit": [ - "to_json: extra member" - ], "RTCSetParameterOptions": [ - "missing dictionary" - ], - "RTCStatsIceCandidatePairState": [ - "missing enum" - ], - "RTCStatsReport": [ - "of_type: extra member" - ], - "RTCStatsType": [ - "missing enum" - ], - "RTCTrackEvent": [ - "constructor(eventInitDict.streams): should be optional", - "constructor(target): extra argument" - ], - "RTCTrackEventInit": [ - "missing dictionary" - ], - "RTCTransformEvent": [ - "missing interface" - ], - "RTCTransportStats": [ - "missing dictionary" - ], - "RTCVideoSourceStats": [ - "missing dictionary" - ], - "ReadableStream": [ - "constructor(strategy.size): missing member", - "from: missing method", - "getReader(options): missing argument", - "pipeThrough(options): should be optional", - "pipeThrough(options): type lacks StreamPipeOptions", - "pipeThrough(transform): type lacks ReadableWritablePair", - "pipeTo(options.signal): missing member", - "tee: missing method", - "values: extra member" - ], - "ReadableStreamDefaultController": [ - "enqueue(chunk): should be optional", - "error(e): named error" - ], - "ReadableStreamDefaultReader": [ - "read(): type lacks ReadableStreamReadResult" - ], - "ReadableStreamGetReaderOptions": [ - "missing dictionary" - ], - "ReadableStreamIteratorOptions": [ - "missing dictionary" - ], - "ReadableStreamReadResult": [ - "done: should be optional", - "value: should be optional" - ], - "ReadableStreamReaderMode": [ - "missing enum" - ], - "ReadableWritablePair": [ - "missing dictionary" - ], - "SFrameCipherSuite": [ - "missing enum" - ], - "SFrameDecryptorStream": [ - "missing interface" - ], - "SFrameEncryptorStream": [ - "missing interface" - ], - "SFrameTransformErrorEvent": [ - "missing interface" - ], - "SFrameTransformErrorEventInit": [ - "missing dictionary" - ], - "SFrameTransformErrorEventType": [ - "missing enum" - ], - "SFrameTransformOptions": [ - "missing dictionary" - ], - "SFrameType": [ - "missing enum" + "encoding_options: extra member" ], "StreamPipeOptions": [ - "missing dictionary" - ], - "TransformStream": [ - "constructor(readableStrategy): missing argument", - "constructor(writableStrategy): missing argument" - ], - "TransformStreamDefaultController": [ - "enqueue(chunk): should be optional", - "error(reason): named error" - ], - "VideoFacingModeEnum": [ - "missing enum" - ], - "VideoFrame": [ - "constructor(image): named source" - ], - "VideoFrameBufferInit": [ - "metadata: missing member", - "transfer: missing member" - ], - "VideoFrameCopyToOptions": [ - "colorSpace: missing member" - ], - "VideoFrameInit": [ - "metadata: missing member" + "signal: missing member" ], "VideoFrameMetadata": [ "rtp_timestamp: extra member" ], - "VideoResizeModeEnum": [ - "missing enum" - ], - "WorkerAndParameters": [ - "missing dictionary" - ], - "WritableStream": [ - "constructor(strategy.size): missing member" - ], "WritableStreamDefaultController": [ - "error(e): named error", "signal: missing attribute" - ], - "WritableStreamDefaultWriter": [ - "write(chunk): should be optional" ] } diff --git a/tests/idl/spec.py b/tests/idl/spec.py index 825ec54..df5ea9a 100644 --- a/tests/idl/spec.py +++ b/tests/idl/spec.py @@ -38,6 +38,7 @@ 'webrtc-encoded-transform.idl': None, 'mediacapture-streams.idl': None, 'mediacapture-transform.idl': None, + 'mst-content-hint.idl': None, 'webcodecs.idl': {'VideoFrame', 'AudioData'}, 'streams.idl': { 'ReadableStream', diff --git a/tests/idl/test_compare.py b/tests/idl/test_compare.py index 2a41cc7..03b5087 100644 --- a/tests/idl/test_compare.py +++ b/tests/idl/test_compare.py @@ -17,7 +17,7 @@ import pytest -from tests.idl.compare import compare, snake_case +from tests.idl.compare import compare, python_name, snake_case from tests.idl.spec import AVAILABLE, parse if TYPE_CHECKING: @@ -43,7 +43,18 @@ undefined stopAll(); }; interface Report { readonly maplike; }; +interface Stream { + constructor(); + constructor(Stream other); + constructor(sequence items); + async_iterable; + Options toJSON(); +}; interface Unused {}; +interface Source { + static Source from(sequence items); + undefined import(DOMString async); +}; """ @@ -107,6 +118,11 @@ def test_snake_case() -> None: assert snake_case('sdpMLineIndex') == 'sdp_m_line_index' +def test_python_name() -> None: + assert python_name('from') == 'from_' + assert python_name('frame') == 'frame' + + def test_missing_definitions() -> None: found = differences() assert found['Unused'] == ['missing interface'] @@ -141,7 +157,7 @@ def test_type_and_rename() -> None: class Other: color: str - def move(self, where: Options, /) -> None: ... + def move(self, where: Options) -> None: ... async def create(self) -> None: ... @@ -166,3 +182,73 @@ def start(self) -> asyncio.Future[None]: raise NotImplementedError assert 'start: should be async' not in differences(Thing=Thing)['Thing'] + + +class Stream: + def __init__(self, items: list[str] | Stream | None = None) -> None: ... + + def __aiter__(self) -> Stream: + return self + + def values(self) -> Stream: + return self + + def to_json(self) -> dict[str, object]: + return dict(vars(self)) + + +Stream.toJSON = Stream.to_json + + +def test_overloads_merge_into_optional_parameters() -> None: + assert 'Stream' not in differences(Stream=Stream) + + +def test_positional_only_parameters_match_by_position() -> None: + class Renamed: + def __init__(self, source: list[str] | None = None, /) -> None: ... + + def __aiter__(self) -> Renamed: + return self + + def values(self) -> Renamed: + return self + + def to_json(self) -> Options: + return Options(**vars(self)) + + Renamed.toJSON = Renamed.to_json + assert differences(Stream=Renamed)['Stream'] == ['toJSON(): should return a dict'] + + +def test_async_iterable() -> None: + class Plain: + def to_json(self) -> dict[str, object]: + return dict(vars(self)) + + Plain.toJSON = Plain.to_json + assert differences(Stream=Plain)['Stream'] == [ + 'async_iterable: missing __aiter__', + 'async_iterable: missing values', + ] + + +def test_extras_of_a_base_are_reported_once() -> None: + class Loud(Thing): + pass + + found = differences(Base=Thing, Thing=Loud, Color=Color) + assert 'extra: extra member' in found['Base'] + assert 'extra: extra member' not in found['Thing'] + + +def test_keywords_take_a_trailing_underscore() -> None: + class Source: + @staticmethod + def from_(items: list[str]) -> Source: + raise NotImplementedError + + def import_(self, async_: str) -> None: ... + + assert 'Source' not in differences(Source=Source) + assert differences(Source=type('Source', (), {}))['Source'] == ['from: missing method', 'import: missing method'] diff --git a/tests/rtc_peer_connection/test_add_track.py b/tests/rtc_peer_connection/test_add_track.py index 3ca703d..d60519d 100644 --- a/tests/rtc_peer_connection/test_add_track.py +++ b/tests/rtc_peer_connection/test_add_track.py @@ -65,7 +65,7 @@ def test_4(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> No stream2 = audio_stream.clone() stream2.add_track(track) - sender = pc.add_track(track, [audio_stream, stream2]) + sender = pc.add_track(track, audio_stream, stream2) assert isinstance(sender, webrtc.RTCRtpSender), 'Expect sender to be instance of RTCRtpSender' @@ -84,18 +84,18 @@ 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.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) assert transceiver.sender.track is None - assert transceiver.direction == webrtc.TransceiverDirection.recvonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.recvonly track, *_ = audio_stream.get_tracks() sender = pc.add_track(track) assert sender == transceiver.sender assert sender.track == track - assert transceiver.direction == webrtc.TransceiverDirection.sendrecv + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendrecv assert [sender] == pc.get_senders() @@ -103,7 +103,7 @@ def test_7(pc: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream) -> No """add_track with existing sender that has not been used to send should reuse the sender.""" transceiver = pc.add_transceiver(webrtc.MediaType.audio) assert transceiver.sender.track is None - assert transceiver.direction == webrtc.TransceiverDirection.sendrecv + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendrecv track, *_ = audio_stream.get_tracks() sender = pc.add_track(track) @@ -122,14 +122,14 @@ async def test_8( await exchange_offer_answer(caller, callee) - assert transceiver.current_direction == webrtc.TransceiverDirection.sendonly + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.sendonly caller.remove_track(transceiver.sender) await exchange_offer_answer(caller, callee) - assert transceiver.direction == webrtc.TransceiverDirection.recvonly - assert transceiver.current_direction == webrtc.TransceiverDirection.inactive + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.recvonly + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.inactive # transceiver.sender is currently not used for sending, # but it should not be reused because it has been used for sending before @@ -141,11 +141,11 @@ 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.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.video, init) assert transceiver.sender.track is None - assert transceiver.direction == webrtc.TransceiverDirection.recvonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.recvonly track, *_ = audio_stream.get_tracks() sender = pc.add_track(track) @@ -174,7 +174,7 @@ async def test_10( await exchange_offer_answer(caller, callee) - assert transceiver.current_direction == webrtc.TransceiverDirection.sendonly + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.sendonly await wait_for_ice_gathering_complete(caller) await wait_for_ice_gathering_complete(callee) diff --git a/tests/rtc_peer_connection/test_add_transceiver.py b/tests/rtc_peer_connection/test_add_transceiver.py index fdb5949..8b66a62 100644 --- a/tests/rtc_peer_connection/test_add_transceiver.py +++ b/tests/rtc_peer_connection/test_add_transceiver.py @@ -30,7 +30,7 @@ def _create_and_test_transceiver(pc: webrtc.RTCPeerConnection, kind: webrtc.Medi assert transceiver.mid is None assert transceiver.stopped is False - assert transceiver.direction == webrtc.TransceiverDirection.sendrecv + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendrecv assert transceiver.current_direction is None assert [transceiver] == pc.get_transceivers(), ( @@ -71,15 +71,15 @@ 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.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.inactive) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.inactive) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) - assert transceiver.direction == webrtc.TransceiverDirection.inactive + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.inactive 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'): + with pytest.raises(ValueError, match='not a valid RTCRtpTransceiverDirection'): webrtc.RTCRtpTransceiverInit(direction=mistyped('invalid')) @@ -182,10 +182,10 @@ def test_11(pc: webrtc.RTCPeerConnection) -> None: def test_12(pc: webrtc.RTCPeerConnection) -> None: """add_transceiver with direction sendonly should have result transceiver.direction be the same.""" - init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.sendonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) - assert transceiver.direction == webrtc.TransceiverDirection.sendonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendonly def test_13(pc: webrtc.RTCPeerConnection) -> None: diff --git a/tests/rtc_rtp_transceiver/test_direction.py b/tests/rtc_rtp_transceiver/test_direction.py index 9fb08be..337f0b6 100644 --- a/tests/rtc_rtp_transceiver/test_direction.py +++ b/tests/rtc_rtp_transceiver/test_direction.py @@ -17,40 +17,40 @@ def test_1(pc: webrtc.RTCPeerConnection) -> None: """Setting direction should change transceiver.direction.""" transceiver = pc.add_transceiver(webrtc.MediaType.audio) - assert transceiver.direction == webrtc.TransceiverDirection.sendrecv + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendrecv assert transceiver.current_direction is None - transceiver.direction = webrtc.TransceiverDirection.recvonly - assert transceiver.direction == webrtc.TransceiverDirection.recvonly + transceiver.direction = webrtc.RTCRtpTransceiverDirection.recvonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.recvonly assert transceiver.current_direction is None, 'Expect transceiver.currentDirection to not change' def test_2(pc: webrtc.RTCPeerConnection) -> None: """Setting direction with same direction should have no effect.""" - init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.sendonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) - assert transceiver.direction == webrtc.TransceiverDirection.sendonly - transceiver.direction = webrtc.TransceiverDirection.sendonly - assert transceiver.direction == webrtc.TransceiverDirection.sendonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendonly + transceiver.direction = webrtc.RTCRtpTransceiverDirection.sendonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendonly @pytest.mark.asyncio async def test_3(pc: webrtc.RTCPeerConnection) -> None: """Setting direction should change transceiver.direction independent of transceiver.currentDirection.""" - init = webrtc.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.recvonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.recvonly) transceiver = pc.add_transceiver(webrtc.MediaType.audio, init) - assert transceiver.direction == webrtc.TransceiverDirection.recvonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.recvonly assert transceiver.current_direction is None offer = await pc.create_offer() await pc.set_local_description(offer) await pc.set_remote_description(await generate_answer(offer)) - assert transceiver.current_direction == webrtc.TransceiverDirection.inactive + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.inactive - transceiver.direction = webrtc.TransceiverDirection.sendrecv - assert transceiver.direction == webrtc.TransceiverDirection.sendrecv + transceiver.direction = webrtc.RTCRtpTransceiverDirection.sendrecv + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendrecv - assert transceiver.current_direction == webrtc.TransceiverDirection.inactive + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.inactive diff --git a/tests/rtc_rtp_transceiver/test_stop.py b/tests/rtc_rtp_transceiver/test_stop.py index b87a910..fdab89f 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.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.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.RTCRtpTransceiverInit(direction=webrtc.TransceiverDirection.sendonly) + init = webrtc.RTCRtpTransceiverInit(direction=webrtc.RTCRtpTransceiverDirection.sendonly) pc.add_transceiver(webrtc.MediaType.audio, init) pc.add_transceiver(webrtc.MediaType.video) @@ -59,7 +59,7 @@ async def test_3(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnect async def _test_inactive_m_section( - caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, direction: webrtc.TransceiverDirection + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, direction: webrtc.RTCRtpTransceiverDirection ) -> None: caller.add_transceiver(webrtc.MediaType.audio) @@ -76,13 +76,13 @@ async def _test_inactive_m_section( @pytest.mark.asyncio async def test_4(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: """A stopped sendonly transceiver should generate an inactive m-section in the offer.""" - await _test_inactive_m_section(caller, callee, webrtc.TransceiverDirection.sendonly) + await _test_inactive_m_section(caller, callee, webrtc.RTCRtpTransceiverDirection.sendonly) @pytest.mark.asyncio async def test_5(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: """A stopped inactive transceiver should generate an inactive m-section in the offer.""" - await _test_inactive_m_section(caller, callee, webrtc.TransceiverDirection.inactive) + await _test_inactive_m_section(caller, callee, webrtc.RTCRtpTransceiverDirection.inactive) @pytest.mark.asyncio @@ -150,5 +150,5 @@ async def test_9(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnect await exchange_offer_answer(caller, callee) - assert caller_transceiver.direction == webrtc.TransceiverDirection.stopped - assert callee_transceiver.direction == webrtc.TransceiverDirection.stopped + assert caller_transceiver.direction == webrtc.RTCRtpTransceiverDirection.stopped + assert callee_transceiver.direction == webrtc.RTCRtpTransceiverDirection.stopped diff --git a/tests/test_audio_data.py b/tests/test_audio_data.py index de300d7..e4d655f 100644 --- a/tests/test_audio_data.py +++ b/tests/test_audio_data.py @@ -22,6 +22,8 @@ if TYPE_CHECKING: from collections.abc import Callable + from typing_extensions import Buffer + def f32(*values: float) -> bytes: return array.array('f', values).tobytes() @@ -32,8 +34,8 @@ def audio_data( format: webrtc.AudioSampleFormatValue = 'f32-planar', channels: int = 2, frames: int = 5, - data: bytes | None = None, - **init: object, + data: bytes | bytearray | None = None, + transfer: list[Buffer] | None = None, ) -> webrtc.AudioData: size = {'u8': 1, 's16': 2}.get(format.split('-', maxsplit=1)[0], 4) return webrtc.AudioData( @@ -44,7 +46,7 @@ def audio_data( number_of_channels=channels, timestamp=1234, data=data if data is not None else bytes(channels * frames * size), - **init, + transfer=transfer if transfer is not None else [], ) ) @@ -201,3 +203,38 @@ def test_s16_bytes_are_little_endian() -> None: audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0, format='f32')) assert array.array('f', out).tolist() == [1 / 32768, -1 / 32768] audio.close() + + +def test_transfer_keeps_the_buffer() -> None: + """Transferred data is kept without a copy, and the buffer can't be resized while kept.""" + data = bytearray(f32(1, 2)) + audio = audio_data(format='f32', channels=1, frames=2, data=data, transfer=[data]) + with pytest.raises(BufferError): + data.append(0) + out = bytearray(8) + audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0)) + assert bytes(out) == f32(1, 2) + audio.close() + + +def test_data_is_copied_unless_transferred() -> None: + """Data not in transfer is copied, while transferred memoryviews are released.""" + data = bytearray(f32(1, 2)) + other = bytearray(4) + view = memoryview(other) + audio = audio_data(format='f32', channels=1, frames=2, data=data, transfer=[view]) + data[:4] = f32(9) + with pytest.raises(ValueError, match='released'): + view.tobytes() + other.append(0) + out = bytearray(8) + audio.copy_to(out, webrtc.AudioDataCopyToOptions(plane_index=0)) + assert bytes(out) == f32(1, 2) + audio.close() + + +def test_transfer_twice() -> None: + """A buffer transferred twice, even through a view, is a DataCloneError.""" + data = bytearray(8) + with pytest.raises(webrtc.DataCloneError, match='more than once'): + audio_data(format='f32', channels=1, frames=2, data=data, transfer=[data, memoryview(data)]) diff --git a/tests/test_blob.py b/tests/test_blob.py new file mode 100644 index 0000000..55d0f52 --- /dev/null +++ b/tests/test_blob.py @@ -0,0 +1,72 @@ +# +# 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. +# + +"""Blob of the File API: parts, line endings, slices and streams.""" + +from __future__ import annotations + +import os + +import pytest + +import webrtc +from tests.helpers import mistyped + + +@pytest.mark.asyncio +async def test_blob() -> None: + """A Blob is immutable bytes with a type, sliced like a sequence.""" + blob = webrtc.Blob(['héllo', b' ', bytearray(b'world')], webrtc.BlobPropertyBag(type='Text/Plain')) + assert blob.size == len(bytes(blob)) == 12 + assert blob.type == 'text/plain' + assert await blob.text() == 'héllo world' + assert await blob.slice(-5).bytes() == b'world' + assert await blob.slice(1, 3).array_buffer() == b'\xc3\xa9' + assert blob.slice(0, 1, 'A/B').type == 'a/b' + assert webrtc.Blob(options=webrtc.BlobPropertyBag(type='é')).type == '' + assert webrtc.Blob().size == 0 + + +def test_endings() -> None: + """Native endings convert the line breaks of string parts only, transparent ones keep them.""" + parts: list[str | bytes] = ['a\r\nb\rc\nd', b'\r\n'] + assert bytes(webrtc.Blob(parts)) == b'a\r\nb\rc\nd\r\n' + native = webrtc.Blob(parts, webrtc.BlobPropertyBag(endings=webrtc.EndingType.native)) + assert bytes(native) == f'a{os.linesep}b{os.linesep}c{os.linesep}d'.encode() + b'\r\n' + with pytest.raises(ValueError, match='not a valid EndingType'): + webrtc.Blob(['a'], webrtc.BlobPropertyBag(endings=mistyped('crlf'))) + + +@pytest.mark.asyncio +async def test_text_drops_bom() -> None: + """text() decodes UTF-8 as the spec does: a leading BOM is dropped, invalid bytes become U+FFFD.""" + assert await webrtc.Blob([b'\xef\xbb\xbfhi\xff']).text() == 'hi�' + + +@pytest.mark.asyncio +async def test_stream() -> None: + """stream() reads the bytes in chunks, and an empty blob closes at once.""" + data = bytes(range(256)) * 1000 + stream = webrtc.Blob([data]).stream() + assert isinstance(stream, webrtc.ReadableStream) + chunks = [chunk async for chunk in stream] + assert len(chunks) > 1 + assert all(isinstance(chunk, bytes) for chunk in chunks) + assert b''.join(chunks) == data + assert [chunk async for chunk in webrtc.Blob().stream()] == [] + + +@pytest.mark.asyncio +async def test_text_stream() -> None: + """textStream() decodes across chunk boundaries.""" + text = 'é' * 100_000 + stream = webrtc.Blob([text]).textStream() + chunks = [chunk async for chunk in stream] + assert len(chunks) > 1 + assert all(isinstance(chunk, str) for chunk in chunks) + assert ''.join(chunks) == text + assert [chunk async for chunk in webrtc.Blob([b'\xef\xbb\xbf']).text_stream()] == [] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index c06faca..9fbcf2a 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -172,21 +172,22 @@ async def test_generate_ecdsa_certificate() -> None: """An ECDSA certificate expires in the future and has a SHA-256 fingerprint.""" certificate = await webrtc.RTCPeerConnection.generate_certificate('ECDSA') assert certificate.expires > time.time() * 1000 - assert not certificate.expired + assert not certificate._expired() (fingerprint,) = certificate.get_fingerprints() assert fingerprint.algorithm == 'sha-256' + assert fingerprint.value is not None assert len(fingerprint.value.split(':')) == 32 @pytest.mark.asyncio async def test_generate_rsa_certificate() -> None: """An RSA certificate is generated from WebCrypto parameters.""" - rsa = await webrtc.RTCCertificate.generate( + rsa = await webrtc.RTCPeerConnection.generate_certificate( webrtc.RsaHashedKeyGenParams( 'RSASSA-PKCS1-v1_5', modulus_length=1024, public_exponent=b'\x01\x00\x01', hash='SHA-256' ) ) - assert not rsa.expired + assert not rsa._expired() @pytest.mark.asyncio @@ -207,17 +208,18 @@ async def test_generate_rsa_certificate() -> 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) + await webrtc.RTCPeerConnection.generate_certificate(algorithm) @pytest.mark.asyncio async def test_configured_certificate(create_pc: CreatePC) -> None: """The certificate of a configuration is the one of the connection, whose offer has its fingerprint.""" - certificate = await webrtc.RTCCertificate.generate('ECDSA') + certificate = await webrtc.RTCPeerConnection.generate_certificate('ECDSA') (fingerprint,) = certificate.get_fingerprints() pc = create_pc(webrtc.RTCConfiguration(certificates=[certificate])) pc.add_transceiver(webrtc.MediaType.audio) offer = await pc.create_offer() + assert fingerprint.value is not None assert fingerprint.value.upper() in offer.sdp certificates = pc.get_configuration().certificates assert certificates is not None @@ -227,7 +229,7 @@ async def test_configured_certificate(create_pc: CreatePC) -> None: @pytest.mark.asyncio async def test_certificates_can_not_change(create_pc: CreatePC) -> None: """A configuration without certificates keeps the ones of the connection, other ones aren't allowed.""" - ecdsa, other = [await webrtc.RTCCertificate.generate('ECDSA') for _ in range(2)] + ecdsa, other = [await webrtc.RTCPeerConnection.generate_certificate('ECDSA') for _ in range(2)] pc = create_pc(webrtc.RTCConfiguration(certificates=[ecdsa])) pc.set_configuration(webrtc.RTCConfiguration()) with pytest.raises(webrtc.InvalidModificationError): @@ -237,13 +239,28 @@ async def test_certificates_can_not_change(create_pc: CreatePC) -> None: @pytest.mark.asyncio async def test_expired_certificate() -> None: """A connection can't be created with an expired certificate.""" - expired = await webrtc.RTCCertificate.generate('ECDSA', expires=0) + expired = await webrtc.RTCPeerConnection.generate_certificate(webrtc.Algorithm('ECDSA', expires=0)) # it expires at the millisecond it's generated, which has passed 10 ms later await asyncio.sleep(0.01) with pytest.raises(webrtc.InvalidAccessError): webrtc.RTCPeerConnection(webrtc.RTCConfiguration(certificates=[expired])) +@pytest.mark.asyncio +@pytest.mark.parametrize('expires', [-1, 1.5, True]) +async def test_invalid_expires(expires: object) -> None: + """Expires is an unsigned long long, which WebIDL converts with [EnforceRange].""" + with pytest.raises(TypeError): + await webrtc.RTCPeerConnection.generate_certificate(webrtc.Algorithm('ECDSA', expires=mistyped(expires))) + + +def test_algorithm_from_json() -> None: + """The expires member of RTCCertificateExpiration is one of the algorithm.""" + algorithm = webrtc.EcKeyGenParams.from_json({'name': 'ECDSA', 'namedCurve': 'P-256', 'expires': 1000}) + assert algorithm == webrtc.EcKeyGenParams('ECDSA', 'P-256', expires=1000) + assert isinstance(algorithm, webrtc.RTCCertificateExpiration) + + def test_ice_candidate_parsing() -> None: """The attributes of a candidate are parsed from its candidate string.""" candidate = webrtc.RTCIceCandidate( @@ -262,7 +279,10 @@ def test_ice_candidate_parsing() -> None: 'sdpMLineIndex': None, 'usernameFragment': None, } - assert webrtc.RTCIceCandidate.from_json(candidate.to_json()).candidate == candidate.candidate + assert ( + webrtc.RTCIceCandidate(**vars(webrtc.RTCIceCandidateInit.from_json(candidate.to_json()))).candidate + == candidate.candidate + ) def test_invalid_ice_candidate() -> None: diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index 43b775b..cfd200c 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -15,7 +15,7 @@ from typing_extensions import TypedDict, Unpack import webrtc -from tests.helpers import connect, mistyped, wait_for_event, wait_until +from tests.helpers import connect, mistyped, stats_of_type, wait_for_event, wait_until class ChannelOptions(TypedDict, total=False, closed=True): @@ -202,13 +202,19 @@ async def test_send_larger_than_max_message_size( async def test_stats_are_current(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: """The stats of a channel count a message right after it's received.""" channel, remote = await open_pair(caller, callee) - before = (await callee.get_stats()).of_type('data-channel')[0].bytes_received + + async def bytes_received() -> int | None: + [stats] = stats_of_type(await callee.get_stats(), 'data-channel') + assert isinstance(stats, webrtc.RTCDataChannelStats) + return stats.bytes_received + + before = await bytes_received() received = wait_for_event(remote, 'message') channel.send('hello') await received # libwebrtc reuses a report for 50 ms: the stats right after a message count it - after = (await callee.get_stats()).of_type('data-channel')[0].bytes_received - assert isinstance(before, int) + after = await bytes_received() + assert before is not None assert after == before + 5 @@ -224,7 +230,7 @@ async def test_max_channels_once_connected(caller: webrtc.RTCPeerConnection, cal def connected() -> bool: assert callee.sctp is not None - return callee.sctp.state == webrtc.SctpTransportState.connected + return callee.sctp.state == webrtc.RTCSctpTransportState.connected await wait_until(connected, 'SCTP to connect') assert callee.sctp is not None @@ -264,15 +270,3 @@ async def test_binary_type(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCP with pytest.raises(ValueError, match='not a valid BinaryType'): remote.binary_type = mistyped('buffer') assert remote.binary_type == webrtc.BinaryType.blob - - -@pytest.mark.asyncio -async def test_blob() -> None: - """A Blob is immutable bytes with a type, sliced like a sequence.""" - blob = webrtc.Blob(['héllo', b' ', bytearray(b'world')], type='Text/Plain') - assert blob.size == len(bytes(blob)) == 12 - assert blob.type == 'text/plain' - assert await blob.text() == 'héllo world' - assert await blob.slice(-5).bytes() == b'world' - assert await blob.slice(1, 3).array_buffer() == b'\xc3\xa9' - assert webrtc.Blob(type='é').type == '' diff --git a/tests/test_encoded_transform.py b/tests/test_encoded_transform.py new file mode 100644 index 0000000..a2d545d --- /dev/null +++ b/tests/test_encoded_transform.py @@ -0,0 +1,497 @@ +# +# 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. +# + +"""WebRTC Encoded Transform: RTCRtpScriptTransform on senders and receivers of a real connection.""" + +from __future__ import annotations + +import asyncio +import gc +import time +from typing import TYPE_CHECKING, Callable, Union + +import pytest + +import webrtc +import wrtc +from tests.helpers import QUIET_PERIOD, connect, mistyped, wait_until, wait_until_unmuted + +if TYPE_CHECKING: + from collections.abc import Iterator + +TIMEOUT = 15 +MARK = 0xAB + +Frame = Union[webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame] + + +class Recorder: + """A worker that records the frames it reads, writing them back after ``change``.""" + + def __init__(self, change: Callable[[Frame], None] | None = None, *, write: bool = True) -> None: + self.change = change + self.write = write + self.frames: list[Frame] = [] + self.transformer: webrtc.RTCRtpScriptTransformer | None = None + self.started = asyncio.get_running_loop().create_future() + self.done = asyncio.get_running_loop().create_future() + self._waiters: list[tuple[int, asyncio.Future[None]]] = [] + + async def __call__(self, event: webrtc.RTCTransformEvent) -> None: + self.transformer = event.transformer + self.started.set_result(event) + reader = event.transformer.readable.get_reader() + writer = event.transformer.writable.get_writer() if self.write else None + while True: + result = await reader.read() + if result.done: + break + frame = result.value + assert frame is not None + self.frames.append(frame) + if self.change is not None: + self.change(frame) + if writer is not None: + _ = writer.write(frame) + for count, waiter in self._waiters: + if len(self.frames) >= count and not waiter.done(): + waiter.set_result(None) + self.done.set_result(None) + + async def wait_frames(self, count: int) -> None: + waiter = asyncio.get_running_loop().create_future() + self._waiters.append((count, waiter)) + if len(self.frames) >= count: + waiter.set_result(None) + await asyncio.wait_for(waiter, TIMEOUT) + + +def key_frames(recorder: Recorder) -> int: + return sum(1 for frame in recorder.frames if getattr(frame, 'type', None) == webrtc.EncodedVideoChunkType.key) + + +def mark(frame: Frame) -> None: + frame.data.append(MARK) + + +def unmark(frame: Frame) -> None: + if len(frame.data) > 0 and frame.data[-1] == MARK: + del frame.data[-1] + + +@pytest.fixture +def pair() -> Iterator[tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + yield caller, callee + caller.close() + callee.close() + + +async def local_track(kind: str) -> webrtc.MediaStreamTrack: + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(**{kind: True})) + return stream.get_tracks()[0] + + +async def transformed_call( + caller: webrtc.RTCPeerConnection, + callee: webrtc.RTCPeerConnection, + kind: str, + *, + sender_worker: Recorder | None = None, + receiver_worker: Recorder | None = None, +) -> tuple[webrtc.RTCRtpSender, webrtc.RTCRtpReceiver]: + """Sends a track with transforms set before negotiation, and connects.""" + sender = caller.add_track(await local_track(kind)) + if sender_worker is not None: + sender.transform = webrtc.RTCRtpScriptTransform(sender_worker) + + receivers: list[webrtc.RTCRtpReceiver] = [] + + def on_track(event: webrtc.RTCTrackEvent) -> None: + receivers.append(event.receiver) + if receiver_worker is not None: + event.receiver.transform = webrtc.RTCRtpScriptTransform(receiver_worker) + + callee.on('track', on_track) + await connect(caller, callee) + assert len(receivers) == 1 + return sender, receivers[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['video', 'audio']) +async def test_frames_pass_through_transforms( + pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection], kind: str +) -> None: + """The receiver's transform sees what the sender's transform changed, and removes it before decoding.""" + caller, callee = pair + sending, receiving = Recorder(mark), Recorder(unmark) + marked: list[bool] = [] + + def check(frame: Frame) -> None: + marked.append(len(frame.data) > 0 and frame.data[-1] == MARK) + unmark(frame) + + receiving.change = check + _, receiver = await transformed_call(caller, callee, kind, sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(20) + assert sum(marked) >= 15 + + frame_class = webrtc.RTCEncodedVideoFrame if kind == 'video' else webrtc.RTCEncodedAudioFrame + assert all(isinstance(frame, frame_class) for frame in sending.frames + receiving.frames) + await wait_until_unmuted(receiver.track) + + +@pytest.mark.asyncio +async def test_video_metadata(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending, receiving = Recorder(), Recorder() + await transformed_call(caller, callee, 'video', sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(5) + + first = sending.frames[0] + assert isinstance(first, webrtc.RTCEncodedVideoFrame) + assert first.type == webrtc.EncodedVideoChunkType.key + sent = first.get_metadata() + assert isinstance(sent, webrtc.RTCEncodedVideoFrameMetadata) + assert sent.mime_type is not None + assert sent.mime_type.startswith('video/') + assert sent.width == 640 + assert sent.height == 480 + assert sent.synchronization_source is not None + assert sent.payload_type is not None + assert sent.rtp_timestamp is not None + assert sent.receive_time is None + assert sent.capture_time is not None + assert abs(sent.capture_time - time.time() * 1000) < 60_000 + assert sent.synchronizationSource == sent.synchronization_source + + received = receiving.frames[0].get_metadata() + assert isinstance(received, webrtc.RTCEncodedVideoFrameMetadata) + assert received.synchronization_source == sent.synchronization_source + assert received.receive_time is not None + assert abs(received.receive_time - time.time() * 1000) < 60_000 + received.width = 1 + again = receiving.frames[0].get_metadata() + assert isinstance(again, webrtc.RTCEncodedVideoFrameMetadata) + assert again.width != 1 + + +@pytest.mark.asyncio +async def test_audio_metadata(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + receiving = Recorder() + await transformed_call(caller, callee, 'audio', receiver_worker=receiving) + await receiving.wait_frames(5) + + metadata = receiving.frames[-1].get_metadata() + assert isinstance(metadata, webrtc.RTCEncodedAudioFrameMetadata) + assert metadata.mime_type == 'audio/opus' + assert metadata.sequence_number is not None + assert metadata.contributing_sources == [] + assert metadata.audio_level is None or 0 <= metadata.audio_level <= 1 + assert not hasattr(receiving.frames[-1], 'type') + + +@pytest.mark.asyncio +async def test_copy_construction(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending = Recorder(write=False) + await transformed_call(caller, callee, 'video', sender_worker=sending) + await sending.wait_frames(1) + frame = sending.frames[0] + assert isinstance(frame, webrtc.RTCEncodedVideoFrame) + + clone = webrtc.RTCEncodedVideoFrame(frame) + assert clone.type == frame.type + assert clone.data == frame.data + assert clone.data is not frame.data + assert clone.get_metadata() == frame.get_metadata() + + options = webrtc.RTCEncodedVideoFrameOptions(webrtc.RTCEncodedVideoFrameMetadata(width=7, dependencies=[1, 2])) + changed = webrtc.RTCEncodedVideoFrame(frame, options) + expected = frame.get_metadata() + expected.width, expected.dependencies = 7, [1, 2] + assert changed.get_metadata() == expected + + with pytest.raises(TypeError): + webrtc.RTCEncodedAudioFrame(mistyped(frame)) + + +@pytest.mark.asyncio +async def test_frame_data(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending = Recorder(write=False) + await transformed_call(caller, callee, 'audio', sender_worker=sending) + await sending.wait_frames(1) + frame = sending.frames[0] + + data = frame.data + assert isinstance(data, bytearray) + assert frame.data is data + frame.data = b'\x01\x02' + assert frame.data == bytearray(b'\x01\x02') + shared = bytearray(b'\x03') + frame.data = shared + assert frame.data is shared + frame.data = memoryview(b'\x04\x05')[::1] + assert frame.data == bytearray(b'\x04\x05') + with pytest.raises(TypeError): + frame.data = mistyped('text') + with pytest.raises(TypeError): + frame.data = memoryview(b'abcd')[::2] + + +@pytest.mark.asyncio +async def test_written_frame_is_detached(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending = Recorder() + await transformed_call(caller, callee, 'audio', sender_worker=sending) + await sending.wait_frames(2) + frame = sending.frames[0] + assert frame.data == bytearray() + assert frame.get_metadata().mime_type == 'audio/opus' + with pytest.raises(webrtc.DataCloneError): + webrtc.RTCEncodedAudioFrame(mistyped(frame)) + + +@pytest.mark.asyncio +async def test_write_rules(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + """Copies, frames written twice or out of order, and frames of another transformer are dropped.""" + caller, callee = pair + payloads: list[bytes] = [] + receiving = Recorder(lambda frame: payloads.append(bytes(frame.data))) + sending = Recorder(write=False) + await transformed_call(caller, callee, 'audio', sender_worker=sending, receiver_worker=receiving) + await sending.wait_frames(3) + assert sending.transformer is not None + writer = sending.transformer.writable.get_writer() + first, second, third = sending.frames[:3] + + copy = webrtc.RTCEncodedAudioFrame(mistyped(first)) + copy.data = bytearray(b'copy') + await writer.write(copy) + second.data = bytearray(b'accepted') + await writer.write(second) + second.data = bytearray(b'twice') + await writer.write(second) + first.data = bytearray(b'reordered') + await writer.write(first) + await asyncio.sleep(QUIET_PERIOD * 3) + assert b'accepted' in payloads + assert {b'copy', b'twice', b'reordered'}.isdisjoint(payloads) + assert third.get_metadata().mime_type == 'audio/opus' + + with pytest.raises(TypeError): + await writer.write(mistyped(None)) + + +@pytest.mark.asyncio +async def test_generate_key_frame(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending, receiving = Recorder(), Recorder() + await transformed_call(caller, callee, 'video', sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(5) + assert sending.transformer is not None + assert receiving.transformer is not None + + keys = key_frames(sending) + await asyncio.wait_for(sending.transformer.generate_key_frame(), TIMEOUT) + await asyncio.sleep(QUIET_PERIOD) + assert key_frames(sending) > keys + await asyncio.wait_for(sending.transformer.generateKeyFrame(), TIMEOUT) + + with pytest.raises(webrtc.NotFoundError): + await sending.transformer.generate_key_frame('foo') + for rid in ('', 'foo-bar', 'foo_bar', '!?', 'a' * 256): + with pytest.raises(webrtc.NotAllowedError): + await sending.transformer.generate_key_frame(rid) + with pytest.raises(webrtc.InvalidStateError): + await receiving.transformer.generate_key_frame() + + +@pytest.mark.asyncio +async def test_generate_key_frame_of_audio(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending = Recorder() + await transformed_call(caller, callee, 'audio', sender_worker=sending) + await sending.wait_frames(1) + assert sending.transformer is not None + with pytest.raises(webrtc.InvalidStateError): + await sending.transformer.generate_key_frame() + + +@pytest.mark.asyncio +async def test_send_key_frame_request(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + sending, receiving = Recorder(), Recorder() + await transformed_call(caller, callee, 'video', sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(5) + assert sending.transformer is not None + assert receiving.transformer is not None + + keys = key_frames(sending) + await receiving.transformer.send_key_frame_request() + await receiving.transformer.sendKeyFrameRequest() + + await wait_until(lambda: key_frames(sending) > keys, 'a key frame', TIMEOUT) + with pytest.raises(webrtc.InvalidStateError): + await sending.transformer.send_key_frame_request() + + +@pytest.mark.asyncio +async def test_removing_a_transform(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + """The streams of a removed transform end, and frames flow without it.""" + caller, callee = pair + sending, receiving = Recorder(mark), Recorder(unmark) + sender, receiver = await transformed_call(caller, callee, 'video', sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(5) + transform = sender.transform + assert isinstance(transform, webrtc.RTCRtpScriptTransform) + + sender.transform = None + assert sender.transform is None + await asyncio.wait_for(sending.done, TIMEOUT) + count = len(receiving.frames) + await receiving.wait_frames(count + 10) + assert receiving.frames[-1].data[-1:] != bytes([MARK]) + + replacement = Recorder() + sender.transform = webrtc.RTCRtpScriptTransform(replacement) + await replacement.wait_frames(5) + assert receiver.transform is not None + + +@pytest.mark.asyncio +async def test_a_transform_has_one_sender_or_receiver( + pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection], +) -> None: + caller, _ = pair + first = caller.add_transceiver(webrtc.MediaType.video).sender + second = caller.add_transceiver(webrtc.MediaType.video).sender + recorder = Recorder() + transform = webrtc.RTCRtpScriptTransform(recorder) + first.transform = transform + assert first.transform is transform + await asyncio.wait_for(recorder.started, TIMEOUT) + # set again on the same sender, as browsers allow + first.transform = transform + with pytest.raises(webrtc.InvalidStateError): + second.transform = transform + with pytest.raises(webrtc.InvalidStateError): + caller.get_transceivers()[1].receiver.transform = transform + first.transform = None + with pytest.raises(webrtc.InvalidStateError): + first.transform = transform + assert second.transform is None + with pytest.raises(TypeError): + second.transform = mistyped(object()) + + +@pytest.mark.asyncio +async def test_rtctransform_event() -> None: + events: list[webrtc.RTCTransformEvent] = [] + options = {'name': 'sender'} + transform = webrtc.RTCRtpScriptTransform(events.append, options, [options]) + assert events == [] + await asyncio.sleep(0) + assert len(events) == 1 + event = events[0] + assert isinstance(event, webrtc.RTCTransformEvent) + assert event.type == 'rtctransform' + assert event.transformer.options is options + assert isinstance(event.transformer.readable, webrtc.ReadableStream) + assert isinstance(event.transformer.writable, webrtc.WritableStream) + assert transform is not None + + parameters = webrtc.WorkerAndParameters(events.append, 'sframe') + assert parameters.type == webrtc.RTCRtpScriptTransformType.sframe + webrtc.RTCRtpScriptTransform(parameters) + with pytest.raises(ValueError, match='other'): + webrtc.WorkerAndParameters(events.append, mistyped('other')) + with pytest.raises(TypeError): + webrtc.RTCRtpScriptTransform(mistyped(None)) + with pytest.raises(TypeError): + webrtc.RTCRtpScriptTransform(events.append, None, mistyped('transfer')) + with pytest.raises(webrtc.DataCloneError): + webrtc.RTCRtpScriptTransform(events.append, options, [options, options]) + + +def test_a_transform_needs_a_loop() -> None: + with pytest.raises(RuntimeError): + webrtc.RTCRtpScriptTransform(lambda _event: None) + + +def test_key_frame_request_event() -> None: + event = webrtc.KeyFrameRequestEvent('keyframerequest') + assert event.type == 'keyframerequest' + assert event.rid is None + assert webrtc.KeyFrameRequestEvent('keyframerequest', 'hi').rid == 'hi' + + +@pytest.mark.asyncio +async def test_nobody_reading(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + """Frames of a transformer that isn't read are dropped, nothing blocks.""" + caller, callee = pair + started: list[webrtc.RTCTransformEvent] = [] + sender = caller.add_track(await local_track('video')) + sender.transform = webrtc.RTCRtpScriptTransform(started.append) + await connect(caller, callee) + + async def queue_overflowed() -> bool: + # past the 120 frames a transformer queues, the oldest are dropped + stats = (await sender.get_stats()).values() + encoded = [s.frames_encoded for s in stats if isinstance(s, webrtc.RTCOutboundRtpStreamStats)] + return any(frames is not None and frames > 130 for frames in encoded) + + await wait_until(queue_overflowed, 'more frames than the queue keeps', 30) + assert len(started) == 1 + caller.close() + callee.close() + + +def released_to(baseline: dict[str, int]) -> bool: + """Whether the native objects are back to the baseline, polled without blocking the loop.""" + gc.collect() + return all(count <= baseline.get(name, 0) for name, count in wrtc._alive().items()) + + +def alive_objects() -> dict[str, int]: + """The native objects of transforms alive, once releases on helper threads are done.""" + + def current() -> dict[str, int]: + gc.collect() + alive = wrtc._alive() + return {name: alive[name] for name in ('RTCRtpScriptTransform', 'RTCEncodedFrame', 'FrameTransformerBridge')} + + alive = current() + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + time.sleep(0.05) + now = current() + if now == alive: + break + alive = now + return alive + + +@pytest.mark.asyncio +async def test_closing_releases_transforms() -> None: + baseline = alive_objects() + + async def session() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + sending, receiving = Recorder(mark), Recorder(unmark) + await transformed_call(caller, callee, 'video', sender_worker=sending, receiver_worker=receiving) + await receiving.wait_frames(5) + caller.add_track(await local_track('audio')).transform = webrtc.RTCRtpScriptTransform(lambda _event: None) + caller.close() + callee.close() + await asyncio.wait_for(asyncio.gather(sending.done, receiving.done), TIMEOUT) + + await session() + await wait_until(lambda: released_to(baseline), 'the transforms released', TIMEOUT) diff --git a/tests/test_enums.py b/tests/test_enums.py index 78a9d7e..0f0bd15 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -53,16 +53,16 @@ def test_native_getters_return_members(pc: webrtc.RTCPeerConnection) -> None: """The native API returns members.""" assert pc.signaling_state is webrtc.RTCSignalingState.stable transceiver = pc.add_transceiver('audio') - assert transceiver.kind is webrtc.MediaType.audio - assert transceiver.direction is webrtc.TransceiverDirection.sendrecv + assert transceiver.receiver.track.kind is webrtc.MediaType.audio + assert transceiver.direction is webrtc.RTCRtpTransceiverDirection.sendrecv def test_native_setters_take_members_and_values(pc: webrtc.RTCPeerConnection) -> None: """The native API takes members and their values.""" transceiver = pc.add_transceiver(webrtc.MediaType.audio) transceiver.direction = 'recvonly' - assert transceiver.direction is webrtc.TransceiverDirection.recvonly - transceiver.direction = webrtc.TransceiverDirection.inactive + assert transceiver.direction is webrtc.RTCRtpTransceiverDirection.recvonly + transceiver.direction = webrtc.RTCRtpTransceiverDirection.inactive assert transceiver.direction == 'inactive' diff --git a/tests/test_events.py b/tests/test_events.py index 7b4265f..2a2472d 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -14,7 +14,8 @@ import pytest import webrtc -from tests.helpers import QUIET_PERIOD, connect, wait_for_event +from tests.helpers import QUIET_PERIOD, connect, next_task, wait_for_event +from webrtc.utils.task_queue import TaskQueue @pytest.mark.asyncio @@ -205,3 +206,23 @@ async def second() -> None: asyncio.run(first()) asyncio.run(second()) + + +@pytest.mark.asyncio +async def test_event_delivered_after_the_collector_cleared_its_listeners() -> None: + """An event posted before the collector cleared the listeners (in a cycle with their target) is dropped.""" + loop = asyncio.get_running_loop() + reported: list[dict[str, object]] = [] + loop.set_exception_handler(lambda _loop, context: reported.append(context)) + try: + decryptor = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions('AES_128_GCM_SHA256_128')) + decryptor.on('error', lambda _event: None) + listeners = decryptor._listeners() + assert listeners is not None + TaskQueue.of(loop).post(listeners.deliver, loop, 'error', (1, None, b'')) + # what tp_clear does to the listeners + listeners.__dict__.clear() + await next_task() + assert reported == [] + finally: + loop.set_exception_handler(None) diff --git a/tests/test_ice_transport.py b/tests/test_ice_transport.py index 2cf6406..aae71a3 100644 --- a/tests/test_ice_transport.py +++ b/tests/test_ice_transport.py @@ -96,7 +96,7 @@ def on_candidate(event: webrtc.RTCPeerConnectionIceEvent, other: webrtc.RTCIceTr connected = [wait_for_event(t, 'statechange') for t in (local, remote)] local.gather() remote.gather() - assert local.gathering_state == webrtc.CricketIceGatheringState.gathering + assert local.gathering_state == webrtc.RTCIceGathererState.gathering # both take the controlling role: one of them switches local.start(local_parameters(remote), 'controlling') remote.start(local_parameters(local), 'controlling') diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 18863c6..5f4d082 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -11,6 +11,7 @@ import asyncio import gc +import inspect import pathlib import subprocess import sys @@ -93,7 +94,7 @@ def test_factories_return_to_baseline_when_everything_is_gone() -> None: baseline = alive_factories() pc = webrtc.RTCPeerConnection() - stream = webrtc.get_user_media() + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))) generator = webrtc.MediaStreamTrackGenerator('audio') pc.add_track(stream.get_tracks()[0]) pc.add_track(generator) @@ -110,7 +111,7 @@ def test_factories_return_to_baseline_when_everything_is_gone() -> None: def test_everything_alive_shares_one_factory() -> None: """New connections use the factory of the media alive.""" - stream = webrtc.get_user_media() + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))) source_track = webrtc.MediaStreamTrackGenerator('audio') before = alive_factories() @@ -140,7 +141,9 @@ def test_closed_connection_keeps_its_factory_shared() -> None: def test_dropped_track_wrappers_are_not_notified() -> None: """Toggling a track notifies its observers, a dropped wrapper must not be one of them.""" - streams = [webrtc.get_user_media() for _ in range(50)] + streams = [ + asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))) for _ in range(50) + ] tracks = [stream.get_tracks()[0] for stream in streams] del tracks collect() @@ -153,7 +156,7 @@ def test_dropped_track_wrappers_are_not_notified() -> None: def test_destroyed_track_wrapper_is_not_notified() -> None: """A track wrapper dies while libwebrtc keeps the track alive in a sender.""" pc = webrtc.RTCPeerConnection() - stream = webrtc.get_user_media() + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))) pc.add_track(stream.get_tracks()[0]) del stream collect() @@ -170,7 +173,7 @@ def test_destroyed_track_wrapper_is_not_notified() -> None: def test_track_state_survives_gc() -> None: """The state of a track is kept by its native object, not by its wrapper.""" - stream = webrtc.get_user_media() + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))) track = stream.get_tracks()[0] track.enabled = False track.stop() @@ -279,7 +282,7 @@ async def test_transports_outlive_closed_connection( callee.close() collect() - assert transport.state == webrtc.DtlsTransportState.closed + assert transport.state == webrtc.RTCDtlsTransportState.closed assert ice_transport.state == webrtc.RTCIceTransportState.closed @@ -542,16 +545,16 @@ def test_generator_track_stays_ended_without_its_wrapper() -> None: assert webrtc.MediaStreamTrack._wrap(generator.track).ready_state == webrtc.MediaStreamTrackState.ended -def processor_with_handler_on_its_track() -> webrtc.MediaStreamTrackProcessor: - track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] +async def processor_with_handler_on_its_track() -> webrtc.MediaStreamTrackProcessor: + track = (await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(video=True))).get_tracks()[0] processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)) track.on('ended', lambda _: processor.readable) track.stop() return processor -def stream_with_handler_on_its_track() -> webrtc.MediaStream: - stream = webrtc.get_user_media(audio=True, video=False) +async def stream_with_handler_on_its_track() -> webrtc.MediaStream: + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True)) stream.get_tracks()[0].on('ended', lambda _: stream.id) return stream @@ -572,7 +575,11 @@ def processor_of_generator_with_handler() -> webrtc.MediaStreamTrackProcessor: async def test_handlers_of_owned_tracks_do_not_keep_owners_alive(create: Callable[[], object]) -> None: """Handlers of a track referencing its processor or stream don't keep them alive.""" baseline = alive_factories() - refs = [weakref.ref(create()) for _ in range(5)] + refs: list[weakref.ref[object]] = [] + for _ in range(5): + created = create() + refs.append(weakref.ref(await created if inspect.isawaitable(created) else created)) + del created await asyncio.sleep(QUIET_PERIOD) collect() @@ -582,7 +589,11 @@ async def test_handlers_of_owned_tracks_do_not_keep_owners_alive(create: Callabl def test_stream_keeps_the_state_of_its_tracks() -> None: """The native stream keeps its tracks weakly, the Python one keeps them: a stopped track stays stopped.""" - stream = webrtc.MediaStream(webrtc.get_user_media(audio=True, video=True).get_tracks()) + stream = webrtc.MediaStream( + asyncio.run( + webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) + ).get_tracks() + ) for track in stream.get_tracks(): track.stop() del track @@ -602,10 +613,12 @@ def test_stream_keeps_the_state_of_its_tracks() -> None: async def test_handler_of_a_track_referencing_its_sender_or_receiver(part: str) -> None: baseline = alive_factories() - def create() -> weakref.ref[webrtc.RTCRtpSender | webrtc.RTCRtpReceiver]: + async def create() -> weakref.ref[webrtc.RTCRtpSender | webrtc.RTCRtpReceiver]: pc = webrtc.RTCPeerConnection() if part == 'sender': - owner = pc.add_track(webrtc.get_user_media(audio=True, video=False).get_tracks()[0]) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True)) + owner = pc.add_track(stream.get_tracks()[0]) + del stream else: owner = pc.add_transceiver(webrtc.MediaType.audio).receiver track = owner.track @@ -618,7 +631,7 @@ def on_ended(_event: webrtc.Event) -> webrtc.MediaStreamTrack | None: pc.close() return weakref.ref(owner) - ref = create() + ref = await create() await asyncio.sleep(QUIET_PERIOD) collect() @@ -648,7 +661,7 @@ async def test_a_session_releases_every_native_object() -> None: async def session() -> None: caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() - stream = webrtc.get_user_media(audio=True, video=True) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) for track in stream.get_tracks(): caller.add_track(track, stream) generator = webrtc.VideoTrackGenerator() diff --git a/tests/test_lifetime_transforms.py b/tests/test_lifetime_transforms.py new file mode 100644 index 0000000..e854b75 --- /dev/null +++ b/tests/test_lifetime_transforms.py @@ -0,0 +1,553 @@ +# +# 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. +# + +"""Lifetime of encoded transforms, SFrame transforms and encoded frames: no leaks, no use after free.""" + +from __future__ import annotations + +import asyncio +import contextlib +import weakref +from typing import TYPE_CHECKING, Union + +import pytest + +import webrtc +import wrtc +from tests.helpers import QUIET_PERIOD, connect, copy_frame, wait_until +from tests.test_lifetime import alive_factories, alive_objects, collect + +if TYPE_CHECKING: + from collections.abc import Iterator + +TIMEOUT = 15 +KEY = bytes(range(16)) +OTHER_KEY = bytes(range(1, 17)) +SUITE = webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128 + +Frame = Union[webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame] + + +class Worker: + """Passes frames through, keeping the first ``hold`` ones; references ``owner`` (like its connection).""" + + def __init__(self, *, hold: int = 0, write: bool = True, owner: object = None) -> None: + loop = asyncio.get_running_loop() + self.hold = hold + self.write = write + self.owner = owner + self.held: list[Frame] = [] + self.count = 0 + self.transformer: webrtc.RTCRtpScriptTransformer | None = None + self.writer: webrtc.WritableStreamDefaultWriter[Frame] | None = None + self.flowing: asyncio.Future[None] = loop.create_future() + self.ended: asyncio.Future[None] = loop.create_future() + + async def __call__(self, event: webrtc.RTCTransformEvent) -> None: + self.transformer = event.transformer + reader = event.transformer.readable.get_reader() + writer = self.writer = event.transformer.writable.get_writer() + try: + while True: + result = await reader.read() + if result.done: + break + frame = result.value + assert frame is not None + self.count += 1 + if self.count >= 5 and not self.flowing.done(): + self.flowing.set_result(None) + if len(self.held) < self.hold: + self.held.append(frame) + elif self.write: + # rejected once the transform is removed + writer.write(frame).add_done_callback(lambda f: f.cancelled() or f.exception()) + finally: + self.ended.set_result(None) + + +@pytest.fixture +def isolated() -> Iterator[None]: + collect() + yield + collect() + + +pytestmark = pytest.mark.usefixtures('isolated') + + +async def media() -> list[webrtc.MediaStreamTrack]: + constraints = webrtc.MediaStreamConstraints(audio=True, video=True) + return (await webrtc.media_devices.get_user_media(constraints)).get_tracks() + + +async def script_call( + caller: webrtc.RTCPeerConnection, + callee: webrtc.RTCPeerConnection, + *, + hold: int = 0, + owner: object = None, +) -> list[Worker]: + """Sends audio and video through script transforms on both ends, and waits for frames to flow.""" + workers: list[Worker] = [] + for track in await media(): + sending = Worker(hold=hold, owner=owner) + caller.add_track(track).transform = webrtc.RTCRtpScriptTransform(sending) + workers.append(sending) + + def on_track(event: webrtc.RTCTrackEvent) -> None: + receiving = Worker(hold=hold, owner=owner) + event.receiver.transform = webrtc.RTCRtpScriptTransform(receiving) + workers.append(receiving) + + callee.on('track', on_track) + await connect(caller, callee) + await wait_until(lambda: len(workers) == 4, 'the transforms of the receivers', TIMEOUT) + await asyncio.wait_for(asyncio.gather(*(w.flowing for w in workers)), TIMEOUT) + return workers + + +async def sframe_call( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, *, decryption_key: bytes = KEY +) -> list[webrtc.RTCRtpSFrameDecryptor]: + """Sends audio and video encrypted with SFrame, decrypted with a key (the wrong one makes errors).""" + for track in await media(): + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(SUITE)) + await encryptor.set_encryption_key(KEY, 1) + caller.add_track(track).transform = encryptor + decryptors: list[webrtc.RTCRtpSFrameDecryptor] = [] + + async def on_track(event: webrtc.RTCTrackEvent) -> None: + decryptor = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(SUITE)) + event.receiver.transform = decryptor + decryptors.append(decryptor) + await decryptor.add_decryption_key(decryption_key, 1) + + callee.on('track', on_track) + await connect(caller, callee) + await wait_until(lambda: len(decryptors) == 2, 'the decryptors', TIMEOUT) + return decryptors + + +async def settled(baseline: dict[str, int]) -> dict[str, int]: + """What's alive once back to the baseline, or after a while: cycles are collectable once helper threads let go.""" + deadline = asyncio.get_running_loop().time() + 10 + while True: + await asyncio.sleep(QUIET_PERIOD) + collect() + alive = wrtc._alive() + if alive == baseline or asyncio.get_running_loop().time() > deadline: + return alive + + +@pytest.mark.asyncio +async def test_transforms_of_connections_dropped_without_close() -> None: + """Dropping connections ends the streams of their transforms, and releases everything.""" + baseline, factories = alive_objects(), alive_factories() + + async def session() -> tuple[list[Worker], list[weakref.ref[object]]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers = await script_call(caller, callee) + transforms = [part.transform for part in (*caller.get_senders(), *callee.get_receivers())] + return workers, [weakref.ref(item) for item in (caller, callee, *transforms)] + + workers, refs = await session() + collect() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + del workers + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == factories + + +@pytest.mark.asyncio +async def test_sframe_transforms_of_connections_dropped_without_close() -> None: + baseline, factories = alive_objects(), alive_factories() + + async def session() -> weakref.ref[object]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + decryptors = await sframe_call(caller, callee, decryption_key=OTHER_KEY) + errors: list[webrtc.SFrameTransformErrorEvent] = [] + for decryptor in decryptors: + decryptor.on('error', errors.append) + await wait_until(lambda: len(errors) > 5, 'errors', TIMEOUT) + # the right key stops the errors, which every frame makes: --gc-on-emit collects for each one + for decryptor in decryptors: + await decryptor.add_decryption_key(KEY, 1) + return weakref.ref(decryptors[0]) + + ref = await session() + assert await settled(baseline) == baseline + assert ref() is None + assert alive_factories() == factories + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dropped', [False, True]) +async def test_worker_referencing_its_connection_with_a_pending_read(*, dropped: bool) -> None: + """A worker waiting for a frame, referencing its connection, ends on close (or once its sender is gone).""" + baseline = alive_objects() + + async def session() -> list[Worker]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers = await script_call(caller, callee, owner=None if dropped else (caller, callee)) + if not dropped: + caller.close() + callee.close() + return workers + + workers = await session() + collect() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + refs = [weakref.ref(worker) for worker in workers] + del workers + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + + +def use_frame(frame: Frame) -> None: + assert len(frame.data) > 0 + assert frame.get_metadata().synchronization_source is not None + assert copy_frame(frame).data == frame.data + + +async def use_frames_after_close(worker: Worker) -> None: + assert worker.transformer is not None + native = worker.transformer._native_obj + for frame in worker.held: + use_frame(frame) + assert frame._native is not None + assert not native.write(frame._native, None) + assert not native.write(frame._native, b'\x00') + assert worker.writer is not None + with pytest.raises(webrtc.InvalidStateError): + await worker.writer.write(worker.held[-1]) + + +@pytest.mark.asyncio +async def test_frames_used_after_their_connection_is_gone() -> None: + """Frames kept past close and collection still read, copy, and are dropped when written.""" + baseline = alive_objects() + + async def session() -> list[Worker]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers = await script_call(caller, callee, hold=3) + caller.close() + callee.close() + return workers + + workers = await session() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + collect() + await asyncio.gather(*(use_frames_after_close(worker) for worker in workers)) + del workers + assert await settled(baseline) == baseline + + +@pytest.mark.asyncio +async def test_native_frames_are_written_to_their_sender_or_receiver_only() -> None: + """A frame of another sender or receiver (another kind, direction) written natively is dropped, not sent.""" + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + try: + workers = await script_call(caller, callee, hold=2) + for target in workers: + assert target.transformer is not None + native = target.transformer._native_obj + for frame in (source.held[-1] for source in workers if source is not target): + assert frame._native is not None + assert not native.write(frame._native, None) + assert frame._native.getData() == bytes(frame.data) + own = workers[0].held[0] + assert workers[0].transformer is not None + assert own._native is not None + assert workers[0].transformer._native_obj.write(own._native, None) + assert own._native.getData() == b'' + await asyncio.sleep(QUIET_PERIOD) + assert all(not w.ended.done() for w in workers) + finally: + caller.close() + callee.close() + + +async def replace_transform( + part: webrtc.RTCRtpSender | webrtc.RTCRtpReceiver, index: int, workers: list[Worker] +) -> None: + """Sets a script transform, none or SFrame, by the index.""" + choice = index % 4 + if choice == 0: + worker = Worker(hold=1, write=index % 3 != 0) + workers.append(worker) + part.transform = webrtc.RTCRtpScriptTransform(worker) + elif choice == 1: + part.transform = None + elif choice == 2 and isinstance(part, webrtc.RTCRtpSender): + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(SUITE)) + await encryptor.set_encryption_key(KEY, index) + part.transform = encryptor + elif choice == 2 and isinstance(part, webrtc.RTCRtpReceiver): + decryptor = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(SUITE)) + await decryptor.add_decryption_key(KEY, index - 2) + decryptor.on('error', lambda _event: None) + part.transform = decryptor + + +@pytest.mark.asyncio +async def test_transforms_replaced_while_media_flows() -> None: + """Transforms set, replaced and removed many times while frames flow are all released.""" + baseline = alive_objects() + + async def session() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers = await script_call(caller, callee, hold=1) + senders, receivers = caller.get_senders(), callee.get_receivers() + for index in range(30): + for part in (*senders, *receivers): + await replace_transform(part, index, workers) + await asyncio.sleep(0.02) + final = [Worker() for _ in (*senders, *receivers)] + for part, worker in zip((*senders, *receivers), final): + part.transform = webrtc.RTCRtpScriptTransform(worker) + await asyncio.wait_for(asyncio.gather(*(w.flowing for w in final)), TIMEOUT) + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + caller.close() + callee.close() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in final)), TIMEOUT) + + await session() + assert await settled(baseline) == baseline + + +@pytest.mark.asyncio +async def test_decryptor_handler_referencing_the_decryptor() -> None: + """An error handler referencing its decryptor (a cycle through C++) doesn't keep it alive.""" + baseline, factories = alive_objects(), alive_factories() + + async def session() -> list[weakref.ref[object]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + decryptors = await sframe_call(caller, callee, decryption_key=OTHER_KEY) + seen: list[object] = [] + for decryptor in decryptors: + decryptor.on('error', lambda event, decryptor=decryptor: seen.append((decryptor, event.frame))) + await wait_until(lambda: len(seen) > 5, 'errors', TIMEOUT) + caller.close() + callee.close() + return [weakref.ref(decryptor) for decryptor in decryptors] + + refs = await session() + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == factories + + +@pytest.mark.asyncio +async def test_error_frames_used_after_close() -> None: + """The frames of error events, kept past close and collection, are still frames.""" + baseline = alive_objects() + + async def session() -> list[webrtc.SFrameTransformErrorEvent]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + decryptors = await sframe_call(caller, callee, decryption_key=OTHER_KEY) + errors: list[webrtc.SFrameTransformErrorEvent] = [] + for decryptor in decryptors: + decryptor.on('error', errors.append) + await wait_until(lambda: len(errors) > 20, 'errors', TIMEOUT) + caller.close() + callee.close() + return errors + + errors = await session() + collect() + frames = [event.frame for event in errors] + del errors + encoded = [ + frame for frame in frames if isinstance(frame, (webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame)) + ] + assert len(encoded) == len(frames) + list(map(use_frame, encoded)) + del frames, encoded + assert await settled(baseline) == baseline + + +class Piped: + """A worker piping its frames through SFrame streams, which the test drops midway.""" + + def __init__(self) -> None: + self.encryptor = webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions(SUITE)) + self.decryptor = webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions(SUITE)) + self.decryptor.on('error', lambda _event: None) + self.started: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self.ended: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + async def __call__(self, event: webrtc.RTCTransformEvent) -> None: + await self.encryptor.set_encryption_key(KEY, 3) + await self.decryptor.add_decryption_key(KEY, 3) + transformer = event.transformer + self.started.set_result(None) + with contextlib.suppress(Exception): + through = transformer.readable.pipe_through(self.encryptor) + through = through.pipe_through(self.decryptor) + await through.pipe_to(transformer.writable) + self.ended.set_result(None) + + +@pytest.mark.asyncio +async def test_sframe_streams_dropped_mid_pipe() -> None: + baseline = alive_objects() + + async def session() -> list[weakref.ref[object]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers: list[Piped] = [] + for track in await media(): + worker = Piped() + caller.add_track(track).transform = webrtc.RTCRtpScriptTransform(worker) + workers.append(worker) + await connect(caller, callee) + await asyncio.wait_for(asyncio.gather(*(w.started for w in workers)), TIMEOUT) + await asyncio.sleep(0.5) + refs: list[weakref.ref[object]] = [weakref.ref(w.encryptor) for w in workers] + for worker in workers: + del worker.encryptor, worker.decryptor + collect() + await asyncio.sleep(0.2) + caller.close() + callee.close() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + return refs + + refs = await session() + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + + +@pytest.mark.asyncio +async def test_senders_and_receivers_collected_while_transforms_are_set() -> None: + """The transform attribute outlives the wrappers of senders and receivers dropped by Python.""" + baseline = alive_objects() + + async def session() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + workers = await script_call(caller, callee) + collect() + counts = [w.count for w in workers] + await wait_until(lambda: all(w.count > c + 5 for w, c in zip(workers, counts)), 'more frames', TIMEOUT) + for pc, parts in ((caller, caller.get_senders), (callee, callee.get_receivers)): + for part in parts(): + assert isinstance(part.transform, webrtc.RTCRtpScriptTransform) + assert pc.connection_state == webrtc.RTCPeerConnectionState.connected + caller.close() + callee.close() + await asyncio.wait_for(asyncio.gather(*(w.ended for w in workers)), TIMEOUT) + + await session() + assert await settled(baseline) == baseline + + +async def referencing_sframe(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: + """Decryptors whose error handlers reference the connection and its receivers.""" + decryptors = await sframe_call(caller, callee, decryption_key=OTHER_KEY) + seen: list[object] = [] + receivers = callee.get_receivers() + for decryptor in decryptors: + decryptor.on('error', lambda event: seen.append((callee, receivers, event.frame))) + await wait_until(lambda: len(seen) > 5, 'errors', TIMEOUT) + + +async def referencing_script(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: + """Script transforms whose options and handlers reference the connections, senders and receivers.""" + workers = await script_call(caller, callee) + for part in (*caller.get_senders(), *callee.get_receivers()): + transform = part.transform + assert isinstance(transform, webrtc.RTCRtpScriptTransform) + transform._transformer._options = (caller, callee, part) + transform._transformer.on('keyframerequest', lambda _event: (caller, callee)) + await wait_until(lambda: all(w.count > 10 for w in workers), 'frames', TIMEOUT) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['sframe', 'script']) +@pytest.mark.parametrize( + 'closed', + [ + True, + pytest.param( + False, + marks=pytest.mark.xfail( + strict=True, + reason='known leak: an open connection keeps its transforms, whose handlers referencing it are a cycle ' + 'through C++ (released once it closes, as a transform is never associated again)', + ), + ), + ], +) +async def test_handlers_referencing_their_connection(kind: str, *, closed: bool) -> None: + """Handlers and options of a transform referencing its connection (a cycle through C++) don't keep it alive.""" + baseline, factories = alive_objects(), alive_factories() + + async def session() -> list[weakref.ref[webrtc.RTCPeerConnection]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + await (referencing_sframe if kind == 'sframe' else referencing_script)(caller, callee) + if closed: + caller.close() + callee.close() + return [weakref.ref(caller), weakref.ref(callee)] + + refs = await session() + try: + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == factories + finally: + for ref in refs: + pc = ref() + if isinstance(pc, webrtc.RTCPeerConnection): + pc.close() + + +class Handler: + """A handler referencing a connection and its receivers, which the test sees collected.""" + + def __init__(self, pc: webrtc.RTCPeerConnection, receivers: list[webrtc.RTCRtpReceiver]) -> None: + self.pc = pc + self.receivers = receivers + + def __call__(self, _event: webrtc.Event) -> None: + _ = self.pc, self.receivers + + +@pytest.mark.asyncio +async def test_handlers_registered_once_detached_are_not_kept() -> None: + """A detached transform never dispatches again: handlers registered on it then are dropped, not kept in a cycle.""" + baseline, factories = alive_objects(), alive_factories() + + async def session() -> list[weakref.ref[object]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + decryptors = await sframe_call(caller, callee, decryption_key=OTHER_KEY) + transform = webrtc.RTCRtpScriptTransform(Worker()) + receivers = callee.get_receivers() + receivers[0].transform = transform + caller.close() + callee.close() + await asyncio.sleep(QUIET_PERIOD) + handlers = [Handler(callee, receivers) for _ in range(4)] + decryptors[1].on('error', handlers[0]) + decryptors[1].once('error', handlers[1]) + transform._transformer.on('keyframerequest', handlers[2]) + transform._transformer.once('keyframerequest', handlers[3]) + decryptors[1].off('error', handlers[0]) + assert receivers[1].transform is not None + return [weakref.ref(item) for item in (caller, callee, *handlers)] + + refs = await session() + try: + assert await settled(baseline) == baseline + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == factories + finally: + for ref in refs: + pc = ref() + if isinstance(pc, webrtc.RTCPeerConnection): + pc.close() diff --git a/tests/test_media_devices.py b/tests/test_media_devices.py new file mode 100644 index 0000000..c32bb61 --- /dev/null +++ b/tests/test_media_devices.py @@ -0,0 +1,80 @@ +# +# 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. +# + +"""MediaDevices: the synthetic microphone and camera.""" + +from __future__ import annotations + +import pytest + +import webrtc + + +@pytest.mark.asyncio +async def test_enumerate_devices() -> None: + """The microphone and the camera are input devices, with the capabilities of their tracks.""" + microphone, camera = await webrtc.media_devices.enumerate_devices() + assert isinstance(microphone, webrtc.InputDeviceInfo) + assert isinstance(camera, webrtc.InputDeviceInfo) + assert microphone.kind == webrtc.MediaDeviceKind.audioinput + assert camera.kind == webrtc.MediaDeviceKind.videoinput + assert camera.to_json() == { + 'deviceId': camera.device_id, + 'kind': 'videoinput', + 'label': camera.label, + 'groupId': camera.group_id, + } + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) + for track, device in zip(stream.get_tracks(), (microphone, camera)): + assert track.get_capabilities() == device.get_capabilities() + assert track.get_settings().device_id == device.device_id + track.stop() + + +def test_supported_constraints() -> None: + supported = webrtc.media_devices.get_supported_constraints() + assert supported.width + assert supported.facingMode + + +@pytest.mark.asyncio +async def test_audio_constraints() -> None: + """Audio constraints are checked against the microphone, and kept as the ones of the track.""" + with pytest.raises(webrtc.OverconstrainedError): + await webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints( + audio=webrtc.MediaTrackConstraints(sample_rate=webrtc.ConstrainULongRange(exact=8000)) + ) + ) + constraints = webrtc.MediaTrackConstraints(channel_count=1) + (track,) = ( + await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=constraints)) + ).get_tracks() + assert track.get_constraints() == constraints + track.stop() + + +@pytest.mark.asyncio +async def test_facing_mode_of_a_camera_without_one() -> None: + """The camera faces nowhere, so a required facing mode can't be satisfied.""" + video = webrtc.MediaTrackConstraints(facing_mode=webrtc.ConstrainDOMStringParameters(exact='user')) + with pytest.raises(webrtc.OverconstrainedError): + await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(video=video)) + + +def test_constraints_from_json() -> None: + constraints = webrtc.MediaStreamConstraints.from_json({'audio': True, 'video': {'width': 320}}) + assert constraints == webrtc.MediaStreamConstraints(audio=True, video=webrtc.MediaTrackConstraints(width=320)) + + +@pytest.mark.asyncio +async def test_devicechange_handler() -> None: + """Devices never change, but a handler can be registered like on any target.""" + assert (await webrtc.media_devices.enumerate_devices()) != [] + handler = webrtc.media_devices.on('devicechange', lambda _: None) + webrtc.media_devices.off('devicechange', handler) + assert webrtc.DeviceChangeEvent('devicechange').devices == [] diff --git a/tests/test_media_stream_track_processor.py b/tests/test_media_stream_track_processor.py index 43f2d58..3ddfa72 100644 --- a/tests/test_media_stream_track_processor.py +++ b/tests/test_media_stream_track_processor.py @@ -11,6 +11,7 @@ import array import asyncio +import time from typing import TypeVar import pytest @@ -83,6 +84,29 @@ async def test_audio_data_of_a_microphone(audio_stream: webrtc.MediaStream) -> N await asyncio.wait_for(reader.closed, TIMEOUT) +@pytest.mark.asyncio +async def test_wakeup_sent_before_the_listeners_is_not_lost( + audio_stream: webrtc.MediaStream, monkeypatch: pytest.MonkeyPatch +) -> None: + """Media comes as soon as the sink is attached, before the listeners are: the wakeup it sends is dropped.""" + attach = webrtc.MediaStreamTrackProcessor._attach + + def attach_once_media_came(processor: webrtc.MediaStreamTrackProcessor) -> None: + deadline = time.monotonic() + 5 + while processor._native_obj.totalFrames == 0 and time.monotonic() < deadline: + time.sleep(0.01) + attach(processor) + + monkeypatch.setattr(webrtc.MediaStreamTrackProcessor, '_attach', attach_once_media_came) + init = webrtc.MediaStreamTrackProcessorInit(audio_stream.get_tracks()[0], max_buffer_size=1) + reader = webrtc.MediaStreamTrackProcessor(init).readable.get_reader() + for _ in range(5): + chunk = (await asyncio.wait_for(reader.read(), 5)).value + assert isinstance(chunk, webrtc.AudioData) + chunk.close() + await reader.cancel() + + def test_init_forms() -> None: """The processor takes its init, also from its JSON form, with a buffer size in range.""" generator = webrtc.VideoTrackGenerator() diff --git a/tests/test_media_stress.py b/tests/test_media_stress.py index 7454b9b..53def9f 100644 --- a/tests/test_media_stress.py +++ b/tests/test_media_stress.py @@ -133,7 +133,7 @@ async def test_close_connection_while_reading( def test_loop_closed_while_frames_arrive() -> None: """A processor whose loop is closed doesn't block the media threads, nor fail once collected.""" - stream = webrtc.get_user_media(audio=True, video=True) + stream = asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True))) async def start() -> list[webrtc.MediaStreamTrackProcessor]: processors = [ diff --git a/tests/test_robustness_chaos.py b/tests/test_robustness_chaos.py index 1653b22..d1ce8fb 100644 --- a/tests/test_robustness_chaos.py +++ b/tests/test_robustness_chaos.py @@ -20,8 +20,10 @@ from tests.helpers import ROOT -def run_chaos(seed: int, steps: int, timeout: float) -> None: +def run_chaos(seed: int, steps: int, timeout: float, *, transforms: bool = False) -> None: command = [sys.executable, '-m', 'tests.chaos', '--seed', str(seed), '--steps', str(steps)] + if transforms: + command.append('--transforms') try: result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, cwd=ROOT, check=False) except subprocess.TimeoutExpired as e: @@ -36,8 +38,21 @@ def test_chaos(seed: int) -> None: run_chaos(seed, steps=150, timeout=120) +@pytest.mark.parametrize('seed', range(2)) +def test_chaos_of_transforms(seed: int) -> None: + """Transforms, SFrame keys and encoded frames, on connections sending media.""" + run_chaos(seed, steps=150, timeout=120, transforms=True) + + @pytest.mark.stress @pytest.mark.timeout(900) @pytest.mark.parametrize('seed', range(100, 120)) def test_chaos_long(seed: int) -> None: run_chaos(seed, steps=1000, timeout=600) + + +@pytest.mark.stress +@pytest.mark.timeout(900) +@pytest.mark.parametrize('seed', range(200, 210)) +def test_chaos_of_transforms_long(seed: int) -> None: + run_chaos(seed, steps=1000, timeout=600, transforms=True) diff --git a/tests/test_robustness_exit.py b/tests/test_robustness_exit.py index e0bb1b3..8367902 100644 --- a/tests/test_robustness_exit.py +++ b/tests/test_robustness_exit.py @@ -22,7 +22,7 @@ from tests.helpers import connect async def main(): - stream = webrtc.get_user_media(audio=True, video=True) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() for track in stream.get_tracks(): caller.add_track(track, stream) @@ -97,7 +97,8 @@ def test_forked_child_leaves_the_objects_of_its_parent_alone() -> None: async def use(): caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() - track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + constraints = webrtc.MediaStreamConstraints(video=True) + track = (await webrtc.media_devices.get_user_media(constraints)).get_tracks()[0] caller.add_track(track) await asyncio.wait_for(connect(caller, callee), 10) reader = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track)).readable.get_reader() @@ -106,7 +107,8 @@ async def use(): caller.close() callee.close() - parent = [webrtc.RTCPeerConnection(), webrtc.get_user_media(audio=True, video=True)] + constraints = webrtc.MediaStreamConstraints(audio=True, video=True) + parent = [webrtc.RTCPeerConnection(), asyncio.run(webrtc.media_devices.get_user_media(constraints))] asyncio.run(use()) pid = os.fork() if pid == 0: diff --git a/tests/test_robustness_threads.py b/tests/test_robustness_threads.py index f61b8b3..8b40722 100644 --- a/tests/test_robustness_threads.py +++ b/tests/test_robustness_threads.py @@ -18,12 +18,14 @@ def test_constructors_from_many_threads() -> None: """Constructors register their Python object with the GIL: pybind11's registry was corrupted.""" output = run_isolated( """ + import asyncio import gc import threading import time import webrtc - track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + constraints = webrtc.MediaStreamConstraints(video=True) + track = asyncio.run(webrtc.media_devices.get_user_media(constraints)).get_tracks()[0] stop = threading.Event() errors = [] @@ -125,12 +127,15 @@ async def main(): def create(): while not stop.is_set(): - for track in webrtc.get_user_media(audio=True, video=True).get_tracks(): + constraints = webrtc.MediaStreamConstraints(audio=True, video=True) + for track in asyncio.run(webrtc.media_devices.get_user_media(constraints)).get_tracks(): track.stop() def release(): while not stop.is_set(): - tracks = [webrtc.get_user_media(audio=True, video=False).get_tracks()[0] for _ in range(5)] + constraints = webrtc.MediaStreamConstraints(audio=True) + get = webrtc.media_devices.get_user_media + tracks = [asyncio.run(get(constraints)).get_tracks()[0] for _ in range(5)] del tracks threads = [threading.Thread(target=f, daemon=True) for f in (create, create, release, release)] @@ -167,7 +172,7 @@ def test_wrappers_created_while_a_description_wraps_them() -> None: from tests.helpers import exchange_offer_answer async def main(): - stream = webrtc.get_user_media(audio=True, video=True) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) stop = threading.Event() def create(): @@ -215,7 +220,7 @@ def test_objects_of_connections_read_while_they_connect() -> None: from tests.helpers import connect async def main(): - stream = webrtc.get_user_media(audio=True, video=True) + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) connections = [] stop = threading.Event() diff --git a/tests/test_robustness_transforms.py b/tests/test_robustness_transforms.py new file mode 100644 index 0000000..fd2a6a7 --- /dev/null +++ b/tests/test_robustness_transforms.py @@ -0,0 +1,280 @@ +# +# 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. +# + +"""Encoded transforms and SFrame used from many threads, and at exit, while media flows: no crash, no deadlock.""" + +from __future__ import annotations + +import pytest + +from tests.helpers import run_isolated + +# Connections sending audio and video through script transforms and SFrame both ways, defined in the scripts +SETUP = """ + import asyncio + import gc + import random + import threading + import time + import webrtc + import wrtc + from tests.helpers import connect + + KEY = bytes(range(16)) + SUITE = webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_80 + held = [] + + def handled(future): + future.add_done_callback(lambda f: f.cancelled() or f.exception()) + + async def worker(event): + # passes frames through late, holding the last ones + reader = event.transformer.readable.get_reader() + writer = event.transformer.writable.get_writer() + mine = [] + while True: + result = await reader.read() + if result.done: + return + mine.append(result.value) + held.append(result.value) + del held[:-200] + if len(mine) > 3: + handled(writer.write(mine.pop(0))) + + def encryptor(key_id=1): + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(SUITE)) + encryptor._native_obj.setEncryptionKey(KEY, key_id) + return encryptor + + def decryptor(key_id=1): + decryptor = webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(SUITE)) + decryptor._native_obj.addDecryptionKey(KEY, key_id) + # handlers are registered on a loop + if threading.current_thread() is threading.main_thread(): + decryptor.on('error', lambda event: held.append(event.frame)) + return decryptor + + async def call(): + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True, video=True)) + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + for track in stream.get_tracks(): + sender = caller.add_track(track) + sender.transform = encryptor() if track.kind == 'audio' else webrtc.RTCRtpScriptTransform(worker) + + def on_track(event): + kind = event.track.kind + event.receiver.transform = decryptor() if kind == 'audio' else webrtc.RTCRtpScriptTransform(worker) + + callee.on('track', on_track) + await connect(caller, callee) + await asyncio.sleep(0.3) + return caller, callee +""" + + +@pytest.mark.parametrize('_attempt', range(4)) +def test_exit_while_frames_are_transformed(_attempt: int) -> None: + """Frames in flight, queued and held, and threads using them, as the interpreter exits.""" + output = run_isolated( + SETUP + + """ + def spin(): + while True: + for frame in list(held): + native = frame._native + if native is not None: + native.getData() + native.getMetadata() + + async def main(): + caller, callee = await call() + threading.Thread(target=spin, daemon=True).start() + await asyncio.sleep(0.2) + return caller, callee + + # kept alive until the interpreter finalizes + objects = asyncio.run(main()) + print('exiting') + """, + timeout=60, + ) + assert 'exiting' in output + + +def test_exit_from_a_worker() -> None: + """The interpreter exits while workers wait for frames and SFrame errors queue.""" + output = run_isolated( + SETUP + + """ + import os + import sys + + async def main(): + caller, callee = await call() + callee.get_receivers()[0].transform = decryptor(key_id=7) + await asyncio.sleep(0.2) + print('exiting', flush=True) + sys.exit(0) + + asyncio.run(main()) + """, + timeout=60, + ) + assert 'exiting' in output + + +def test_threads_race_transforms_keys_and_frames() -> None: + """Transforms set and removed, keys rotated, frames read and written and the connections closed, all at once.""" + output = run_isolated( + SETUP + + """ + stop = threading.Event() + errors = [] + + def guarded(function): + def run(): + rng = random.Random(threading.get_ident()) + try: + while not stop.is_set(): + function(rng) + except Exception as e: + errors.append(repr(e)) + return run + + async def main(): + loop = asyncio.get_running_loop() + caller, callee = await call() + senders, receivers = caller.get_senders(), callee.get_receivers() + # script transforms need the loop: made on it, set from the threads + spare = [webrtc.RTCRtpScriptTransform(worker) for _ in range(200)] + streams = [wrtc.SFrameTransform(1, True), wrtc.SFrameTransform(1, False)] + natives = [] + + def set_transforms(rng): + part = rng.choice(senders + receivers) + choice = rng.randrange(3) + try: + if choice == 0: + part.transform = None + elif choice == 1 and spare: + part.transform = spare.pop() + elif choice == 2: + part.transform = encryptor() if part in senders else decryptor(rng.randrange(3)) + except webrtc.InvalidStateError: + pass + time.sleep(0.005) + + def rotate_keys(rng): + for part in senders + receivers: + native = part._native_obj.transform + if isinstance(native, wrtc.SFrameTransform): + key_id = rng.randrange(3) + if native.encrypting: + native.setEncryptionKey(KEY, key_id) + elif rng.random() < 0.5: + native.addDecryptionKey(KEY, key_id) + else: + native.removeDecryptionKey(key_id) + for stream in streams: + stream.setEncryptionKey(KEY, 1) if stream.encrypting else stream.addDecryptionKey(KEY, 1) + time.sleep(0.001) + + def frames(rng): + for part in senders + receivers: + native = part._native_obj.transform + if isinstance(native, wrtc.RTCRtpScriptTransform): + frame = native.read() + if frame is not None: + natives.append(frame) + del natives[:-100] + if natives: + data = rng.choice([None, b'', bytes(rng.randrange(3000))]) + native.write(rng.choice(natives), data) + for frame in [*natives[-10:], *(f._native for f in held[-10:] if f._native is not None)]: + frame.getData() + frame.getMetadata() + streams[1].decrypt(streams[0].encrypt(frame.getData()) or b'') + time.sleep(0.001) + + def collect(_rng): + gc.collect() + time.sleep(0.01) + + threads = [ + threading.Thread(target=guarded(f), daemon=True) + for f in (set_transforms, set_transforms, rotate_keys, frames, frames, collect) + ] + for thread in threads: + thread.start() + await asyncio.sleep(3) + await loop.run_in_executor(None, caller.close) + callee.close() + await asyncio.sleep(0.5) + stop.set() + for thread in threads: + await loop.run_in_executor(None, thread.join, 20) + assert not thread.is_alive(), 'stuck' + assert not errors, errors + assert natives, 'no frame was read' + print('done') + + asyncio.run(main()) + """, + timeout=90, + ) + assert 'done' in output + + +def test_threads_race_frames_of_one_transformer() -> None: + """The same frames read, copied and written from several threads: each is given back once at most.""" + output = run_isolated( + SETUP + + """ + async def main(): + loop = asyncio.get_running_loop() + caller, callee = await call() + native = caller.get_senders()[1]._native_obj.transform + if not isinstance(native, wrtc.RTCRtpScriptTransform): + native = caller.get_senders()[0]._native_obj.transform + stop = threading.Event() + frames = [] + written = [] + + def read(): + while not stop.is_set(): + frame = native.read() + if frame is not None: + frames.append(frame) + del frames[:-50] + + def use(): + while not stop.is_set(): + for frame in list(frames): + frame.getData() + frame.getMetadata() + if native.write(frame, None): + written.append(frame) + + threads = [threading.Thread(target=f, daemon=True) for f in (read, use, use, use)] + for thread in threads: + thread.start() + await asyncio.sleep(3) + stop.set() + for thread in threads: + await loop.run_in_executor(None, thread.join, 20) + assert not thread.is_alive(), 'stuck' + assert len(written) == len({id(frame) for frame in written}) > 0 + caller.close() + callee.close() + print('done') + + asyncio.run(main()) + """, + timeout=60, + ) + assert 'done' in output diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index c0a8e63..eb78fb3 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -16,7 +16,7 @@ import pytest import webrtc -from tests.helpers import connect, exchange_offer_answer, mistyped, next_task, wait_for_event, wait_until +from tests.helpers import connect, exchange_offer_answer, mistyped, next_task, stats_of_type, wait_for_event, wait_until if TYPE_CHECKING: from collections.abc import Callable @@ -156,9 +156,9 @@ def test_codec_preferences_and_header_extensions(pc: webrtc.RTCPeerConnection) - transceiver.set_codec_preferences([webrtc.RTCRtpCodec('audio/nonsense', 8000)]) extensions = transceiver.get_header_extensions_to_negotiate() - extensions[-1].direction = webrtc.TransceiverDirection.stopped + extensions[-1].direction = webrtc.RTCRtpTransceiverDirection.stopped transceiver.set_header_extensions_to_negotiate(extensions) - assert transceiver.get_header_extensions_to_negotiate()[-1].direction == webrtc.TransceiverDirection.stopped + assert transceiver.get_header_extensions_to_negotiate()[-1].direction == webrtc.RTCRtpTransceiverDirection.stopped @pytest.mark.asyncio @@ -202,8 +202,13 @@ async def test_set_parameters_key_frames(caller: webrtc.RTCPeerConnection, calle sender = caller.add_transceiver(webrtc.MediaType.video).sender await exchange_offer_answer(caller, callee) with pytest.raises(webrtc.InvalidModificationError): - await sender.set_parameters(sender.get_parameters(), key_frames=[True, False]) - await sender.set_parameters(sender.get_parameters(), key_frames=[True]) + await sender.set_parameters( + sender.get_parameters(), + webrtc.RTCSetParameterOptions([webrtc.RTCEncodingOptions(key_frame=True), webrtc.RTCEncodingOptions()]), + ) + await sender.set_parameters( + sender.get_parameters(), webrtc.RTCSetParameterOptions([webrtc.RTCEncodingOptions(key_frame=True)]) + ) @pytest.mark.asyncio @@ -275,7 +280,8 @@ async def test_synchronization_sources( # sources are known once media is decoded (audio is only played out by a real audio device) await wait_until(receiver.get_synchronization_sources, 'a synchronization source', timeout=5) [source] = receiver.get_synchronization_sources() - inbound = (await receiver.get_stats()).of_type('inbound-rtp')[0] + inbound = stats_of_type(await receiver.get_stats(), 'inbound-rtp')[0] + assert isinstance(inbound, webrtc.RTCInboundRtpStreamStats) assert isinstance(source, webrtc.RTCRtpSynchronizationSource) assert source.source == inbound.ssrc assert abs(source.timestamp - time.time() * 1000) < 5_000 @@ -319,6 +325,6 @@ def test_transceiver_init_from_json(pc: webrtc.RTCPeerConnection) -> None: 'x': 1, }) transceiver = pc.add_transceiver(webrtc.MediaType.video, init) - assert transceiver.direction == webrtc.TransceiverDirection.sendonly + assert transceiver.direction == webrtc.RTCRtpTransceiverDirection.sendonly encodings = transceiver.sender.get_parameters().encodings assert [(e.rid, e.max_bitrate) for e in encodings] == [('a', 100000), ('b', None)] diff --git a/tests/test_sframe.py b/tests/test_sframe.py new file mode 100644 index 0000000..caf322f --- /dev/null +++ b/tests/test_sframe.py @@ -0,0 +1,692 @@ +# +# 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. +# + +"""SFrame of WebRTC Encoded Transform: RFC 9605 vectors, the streams, and the transforms of a real connection.""" + +from __future__ import annotations + +import asyncio +import contextlib +import gc +import time +from typing import TYPE_CHECKING, NamedTuple, Union + +import pytest + +import webrtc +import wrtc +from tests.helpers import connect, mistyped, wait_until + +if TYPE_CHECKING: + from collections.abc import Iterator + + from typing_extensions import Buffer + +TIMEOUT = 15 +SUITES = list(webrtc.SFrameCipherSuite) +KEY = bytes(range(16)) +OTHER_KEY = bytes(range(1, 17)) + +Frame = Union[webrtc.RTCEncodedVideoFrame, webrtc.RTCEncodedAudioFrame] + + +class Vector(NamedTuple): + cipher_suite: int + sframe_key: str + sframe_salt: str + nonce: str + ct: str + + +# RFC 9605 Appendix C.4 (suites 1 to 5) and draft-barnes-sframe-iana-256 Appendix A.2 (6 to 8): KID 0x123, +# CTR 0x4567 and the same base key, metadata and plaintext +VECTORS = [ + Vector( + 1, + '3f7d9a7c83ae8e1c8a11ae695ab59314b367e359fadac7b9c46b2bc6f81f46e16b96f0811868d59402b7e870102720b3', + '50b29329a04dc0f184ac3168', + '50b29329a04dc0f184ac740f', + '9901234567449408b6f490086165b9d6f62b24ae1a59a56486b4ae8ed036b88912e24f11', + ), + Vector( + 2, + 'e2ec5c797540310483b16bf6e7a570d2a27d192fe869c7ccd8584a8d9dab91549fbe553f5113461ec6aa83bf3865553e', + 'e68ac8dd3d02fbcd368c5577', + 'e68ac8dd3d02fbcd368c1010', + '99012345673f31438db4d09434e43afa0f8a2f00867a2be085046a9f5cb4f101d607', + ), + Vector( + 3, + '2c5703089cbb8c583475e4fc461d97d18809df79b6d550f78eb6d50ffa80d89211d57909934f46f5405e38cd583c69fe', + '38c16e4f5159700c00c7f350', + '38c16e4f5159700c00c7b637', + '990123456717fc8af28a5a695afcfc6c8df6358a17e26b2fcb3bae32e443', + ), + Vector( + 4, + 'd34f547f4ca4f9a7447006fe7fcbf768', + '75234edefe07819026751816', + '75234edefe07819026755d71', + '9901234567b7412c2513a1b66dbb48841bbaf17f598751176ad847681a69c6d0b091c07018ce4adb34eb', + ), + Vector( + 5, + 'd3e27b0d4a5ae9e55df01a70e6d4d28d969b246e2936f4b7a5d9b494da6b9633', + '84991c167b8cd23c93708ec7', + '84991c167b8cd23c9370cba0', + '990123456794f509d36e9beacb0e261d99c7d1e972f1fed787d4049f17ca21353c1cc24d56ceabced279', + ), + Vector( + 6, + '3c343886ec1c79278836863e00fe934c8894460cfa367ebdc4856b0a9268a4f4fb99437876819394ef90b10ee12602d0' + '23f7128ee50f2314c2cc3cff4c56616d2fe03ad2a254cc2ed29b2a4d3f2534c0dda9e7c391ad1917ea07aa221dd4b224', + 'e082f7ce012ad30c87c49e3f', + 'e082f7ce012ad30c87c4db58', + '9901234567b369e03ec6467ad505ddc84914115069280c5c797555be6e32cde6ac25bc9e', + ), + Vector( + 7, + '7271d6c6cbccd2e2343d480ebea65718a7bb379eefcf3f8d107c1e2a76e755293a497fd9e4e8291b965161987ef4ef24' + '983eabb06cb0a392defaab18654780a39c106ffa4a47d4183a6e593cd0c1bcab2b9c6dcf049215845bfb7580c4dea80e', + '46b4367993a314910d4d9f3d', + '46b4367993a314910d4dda5a', + '990123456797cb5644d8831ff8bdc080249990b24b569144cab2a87be22c20d97976', + ), + Vector( + 8, + 'afe92c81e0df8c00fab619e0559fe5aeefce1ef77789d4c728af1b1c1f2e3552c405d274415a5291ec075c2d9954c450' + 'fbd36682a4e978494808b703ce78b409f9fec29b91e6e703a75c4131377c80c9d51b8906088092452e2593eb142eea2d', + 'f6de647bac1263524cfb6533', + 'f6de647bac1263524cfb2054', + '9901234567112a94a288b85b49ffef1d279f2830165c39d76cac8884011c', + ), +] +VECTOR_KID = 0x123 +VECTOR_CTR = 0x4567 +VECTOR_BASE_KEY = bytes.fromhex('000102030405060708090a0b0c0d0e0f') +VECTOR_METADATA = bytes.fromhex('4945544620534672616d65205747') +VECTOR_PT = bytes.fromhex('64726166742d696574662d736672616d652d656e63') + +# RFC 9605 Appendix C.1, a sample of each size of KID and CTR +HEADERS = [ + (0, 0, '00'), + (0, 7, '07'), + (0, 255, '08ff'), + (0, 256, '090100'), + (0, 0xFFFFFFFFFFFFFFFF, '0fffffffffffffffff'), + (1, 0, '10'), + (1, 65536, '1a010000'), + (255, 1, '81ff'), + (255, 72057594037927935, '8effffffffffffffff'), + (256, 256, '9901000100'), + (65535, 16777216, '9bffff01000000'), + (65536, 4294967296, 'ac0100000100000000'), + (16777215, 0, 'a0ffffff'), + (4294967296, 1099511627775, 'cc0100000000ffffffffff'), + (281474976710656, 281474976710655, 'ed01000000000000ffffffffffff'), + (72057594037927936, 72057594037927936, 'ff01000000000000000100000000000000'), + (0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 'ffffffffffffffffffffffffffffffffff'), +] + + +@pytest.mark.parametrize('vector', VECTORS, ids=[f'suite-{v.cipher_suite}' for v in VECTORS]) +def test_rfc_vectors(vector: Vector) -> None: + key, salt = wrtc._sframeDerive(vector.cipher_suite, VECTOR_BASE_KEY, VECTOR_KID) + assert key.hex() == vector.sframe_key + assert salt.hex() == vector.sframe_salt + # nonce = salt XOR CTR + nonce = bytes(a ^ b for a, b in zip(salt, VECTOR_CTR.to_bytes(12, 'big'))) + assert nonce.hex() == vector.nonce + + ciphertext = wrtc._sframeEncrypt( + vector.cipher_suite, VECTOR_BASE_KEY, VECTOR_KID, VECTOR_CTR, VECTOR_METADATA, VECTOR_PT + ) + assert ciphertext.hex() == vector.ct + assert wrtc._sframeDecrypt(vector.cipher_suite, VECTOR_BASE_KEY, VECTOR_METADATA, bytes.fromhex(vector.ct)) == ( + VECTOR_PT + ) + assert wrtc._sframeDecrypt(vector.cipher_suite, VECTOR_BASE_KEY, b'', bytes.fromhex(vector.ct)) is None + tampered = bytearray.fromhex(vector.ct) + tampered[-1] ^= 1 + assert wrtc._sframeDecrypt(vector.cipher_suite, VECTOR_BASE_KEY, VECTOR_METADATA, tampered) is None + + +@pytest.mark.parametrize(('kid', 'ctr', 'encoded'), HEADERS) +def test_rfc_headers(kid: int, ctr: int, encoded: str) -> None: + assert wrtc._sframeHeader(kid, ctr).hex() == encoded + assert wrtc._sframeParseHeader(bytes.fromhex(encoded) + b'payload') == (kid, ctr, len(encoded) // 2) + + +def test_truncated_headers() -> None: + assert wrtc._sframeParseHeader(b'') is None + assert wrtc._sframeParseHeader(bytes.fromhex('9901')) is None + assert wrtc._sframeParseHeader(bytes.fromhex('0f01')) is None + + +def test_the_suites_are_numbered_like_rfc() -> None: + names = [suite.value for suite in webrtc.SFrameCipherSuite] + assert names == [ + 'AES_128_CTR_HMAC_SHA256_80', + 'AES_128_CTR_HMAC_SHA256_64', + 'AES_128_CTR_HMAC_SHA256_32', + 'AES_128_GCM_SHA256_128', + 'AES_256_GCM_SHA512_128', + 'AES_256_CTR_HMAC_SHA512_80', + 'AES_256_CTR_HMAC_SHA512_64', + 'AES_256_CTR_HMAC_SHA512_32', + ] + + +class Errors: + """Records the error events of a decryptor.""" + + def __init__(self, target: webrtc.RTCRtpSFrameDecryptor | webrtc.SFrameDecryptorStream) -> None: + self.events: list[webrtc.SFrameTransformErrorEvent] = [] + self._waiters: list[asyncio.Future[None]] = [] + target.on('error', self._on_error) + + def _on_error(self, event: webrtc.SFrameTransformErrorEvent) -> None: + self.events.append(event) + for waiter in self._waiters: + if not waiter.done(): + waiter.set_result(None) + + async def wait(self) -> webrtc.SFrameTransformErrorEvent: + if len(self.events) == 0: + waiter = asyncio.get_running_loop().create_future() + self._waiters.append(waiter) + await asyncio.wait_for(waiter, TIMEOUT) + return self.events[-1] + + async def wait_for(self, error_type: webrtc.SFrameTransformErrorEventType, key_id: int) -> None: + """Waits for an error of a type and key id, as errors of frames sent before a key change may come first.""" + await wait_until( + lambda: any(e.error_type == error_type and e.key_id == key_id for e in self.events), + f'a {error_type.value} error of key {key_id}', + TIMEOUT, + ) + + +def options(suite: webrtc.SFrameCipherSuite) -> webrtc.SFrameTransformOptions: + return webrtc.SFrameTransformOptions(suite) + + +async def through(stream: webrtc.SFrameEncryptorStream | webrtc.SFrameDecryptorStream, chunk: Buffer) -> object: + """Writes a chunk and reads what comes out.""" + writer = stream.writable.get_writer() + reader = stream.readable.get_reader() + try: + written = writer.write(chunk) + result = await asyncio.wait_for(reader.read(), TIMEOUT) + await written + return result.value + finally: + writer.release_lock() + reader.release_lock() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('suite', SUITES) +async def test_streams_round_trip(suite: webrtc.SFrameCipherSuite) -> None: + encryptor = webrtc.SFrameEncryptorStream(options(suite)) + decryptor = webrtc.SFrameDecryptorStream(options(suite)) + await encryptor.set_encryption_key(KEY, 300) + await decryptor.add_decryption_key(KEY, 300) + errors = Errors(decryptor) + + plaintexts = [b'', b'x', bytes(range(256)) * 40] + for counter, plaintext in enumerate(plaintexts): + ciphertext = await through(encryptor, plaintext) + assert isinstance(ciphertext, bytes) + header = wrtc._sframeParseHeader(ciphertext) + assert header is not None + assert header[:2] == (300, counter) + assert ciphertext[header[2] : header[2] + len(plaintext)] != plaintext or len(plaintext) == 0 + assert await through(decryptor, ciphertext) == plaintext + assert errors.events == [] + + +@pytest.mark.asyncio +async def test_streams_take_any_buffer() -> None: + """Like WPT sframe-transform-buffer-source: views of the same bytes encrypt the same.""" + results: list[object] = [] + data = bytes(range(10)) + padded = bytearray(11) + padded[1:] = data + for chunk in (data, bytearray(data), memoryview(padded)[1:]): + stream = webrtc.SFrameEncryptorStream(options(webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_80)) + await stream.set_encryption_key(KEY, 0) + results.append(await through(stream, chunk)) + assert results[0] == results[1] == results[2] + + +@pytest.mark.asyncio +async def test_stream_errors_on_other_chunks() -> None: + stream = webrtc.SFrameDecryptorStream(options(webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128)) + writer = stream.writable.get_writer() + # a pending read relieves the backpressure of the readable side + read = asyncio.ensure_future(stream.readable.get_reader().read()) + with pytest.raises(TypeError): + await writer.write(mistyped({})) + with pytest.raises(TypeError): + await writer.closed + with pytest.raises(TypeError): + await read + + +@pytest.mark.asyncio +async def test_encryptor_stream_without_a_key_drops() -> None: + stream = webrtc.SFrameEncryptorStream(options(webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128)) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(through(stream, b'clear'), 0.3) + + +class Probe: + """Writes to a decryptor stream with a read pending, which relieves backpressure and only plaintext fulfills.""" + + def __init__(self, decryptor: webrtc.SFrameDecryptorStream) -> None: + self.errors = Errors(decryptor) + self.writer = decryptor.writable.get_writer() + self.reader = decryptor.readable.get_reader() + self.read = asyncio.ensure_future(self.reader.read()) + + async def error_of(self, chunk: bytes) -> webrtc.SFrameTransformErrorEvent: + self.errors.events.clear() + await self.writer.write(chunk) + return await self.errors.wait() + + async def plaintext_of(self, chunk: bytes) -> object: + await self.writer.write(chunk) + result = await asyncio.wait_for(self.read, TIMEOUT) + self.read = asyncio.ensure_future(self.reader.read()) + return result.value + + +async def sealed(suite: webrtc.SFrameCipherSuite, key_id: int, plaintext: bytes) -> bytes: + encryptor = webrtc.SFrameEncryptorStream(options(suite)) + await encryptor.set_encryption_key(KEY, key_id) + ciphertext = await through(encryptor, plaintext) + assert isinstance(ciphertext, bytes) + return ciphertext + + +@pytest.mark.asyncio +async def test_decryptor_stream_errors() -> None: + suite = webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_32 + ciphertext = await sealed(suite, 7, b'secret') + decryptor = webrtc.SFrameDecryptorStream(options(suite)) + probe = Probe(decryptor) + + event = await probe.error_of(ciphertext) + assert event.error_type == webrtc.SFrameTransformErrorEventType.key_id + assert event.errorType == 'keyID' + assert event.key_id == 7 + assert event.keyID == 7 + assert event.frame == ciphertext + assert event.target == decryptor + + await decryptor.add_decryption_key(OTHER_KEY, 7) + event = await probe.error_of(ciphertext) + assert event.error_type == webrtc.SFrameTransformErrorEventType.authentication + assert event.key_id is None + + for chunk in (b'', bytes.fromhex('9901'), ciphertext[:4]): + event = await probe.error_of(chunk) + assert event.error_type == webrtc.SFrameTransformErrorEventType.syntax + assert not probe.read.done() + probe.read.cancel() + + +@pytest.mark.asyncio +async def test_decryptor_stream_keys() -> None: + suite = webrtc.SFrameCipherSuite.AES_256_GCM_SHA512_128 + ciphertext = await sealed(suite, 2**64 - 1, b'secret') + decryptor = webrtc.SFrameDecryptorStream(options(suite)) + probe = Probe(decryptor) + + await decryptor.add_decryption_key(OTHER_KEY, 2**64 - 1) + event = await probe.error_of(ciphertext) + assert event.error_type == webrtc.SFrameTransformErrorEventType.authentication + await decryptor.add_decryption_key(KEY, 2**64 - 1) + assert await probe.plaintext_of(ciphertext) == b'secret' + await decryptor.remove_decryption_key(2**64 - 1) + event = await probe.error_of(ciphertext) + assert event.error_type == webrtc.SFrameTransformErrorEventType.key_id + assert event.key_id == 2**64 - 1 + probe.read.cancel() + + +@pytest.mark.asyncio +async def test_key_validation() -> None: + encryptor = webrtc.SFrameEncryptorStream(options(webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128)) + decryptor = webrtc.RTCRtpSFrameDecryptor(options(webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128)) + for key_id in (0, 1, 2**64 - 1): + await encryptor.setEncryptionKey(KEY, key_id) + await decryptor.addDecryptionKey(KEY, key_id) + await decryptor.removeDecryptionKey(key_id) + for key_id in (-1, 2**64): + with pytest.raises(webrtc.InvalidRangeError, match='Not a 64 bits integer'): + await encryptor.set_encryption_key(KEY, key_id) + with pytest.raises(webrtc.InvalidRangeError): + await decryptor.add_decryption_key(KEY, key_id) + with pytest.raises(webrtc.InvalidRangeError): + await decryptor.remove_decryption_key(key_id) + for key_id in ('1', 1.0, True, None): + with pytest.raises(TypeError): + await encryptor.set_encryption_key(KEY, mistyped(key_id)) + with pytest.raises(TypeError): + await decryptor.remove_decryption_key(mistyped(key_id)) + for key in ('key', 1, None): + with pytest.raises(TypeError): + await encryptor.set_encryption_key(mistyped(key), 1) + with pytest.raises(TypeError): + await decryptor.add_decryption_key(mistyped(key), 1) + # any length: the base key of RFC 9605 is the input of HKDF + await encryptor.set_encryption_key(b'', 1) + await encryptor.set_encryption_key(bytes(100), 1) + + +def test_options() -> None: + assert webrtc.RTCRtpSFrameEncryptorOptions('AES_128_GCM_SHA256_128').type == webrtc.SFrameType.per_frame + assert webrtc.SFrameTransformOptions('AES_256_GCM_SHA512_128').cipherSuite == 'AES_256_GCM_SHA512_128' + assert webrtc.SFrameTransformOptions.from_json({'cipherSuite': 'AES_128_CTR_HMAC_SHA256_32'}).cipher_suite == ( + webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_32 + ) + with pytest.raises(ValueError, match='AES_512'): + webrtc.SFrameTransformOptions(mistyped('AES_512')) + with pytest.raises(ValueError, match='per-byte'): + webrtc.RTCRtpSFrameEncryptorOptions('AES_128_GCM_SHA256_128', mistyped('per-byte')) + with pytest.raises(TypeError): + webrtc.RTCRtpSFrameEncryptor(mistyped({'cipherSuite': 'AES_128_GCM_SHA256_128'})) + with pytest.raises(webrtc.NotSupportedError): + webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions('AES_128_GCM_SHA256_128', 'per-packet')) + + +def test_error_event() -> None: + frame = b'\x00' + event = webrtc.SFrameTransformErrorEvent('error', webrtc.SFrameTransformErrorEventInit('keyID', frame, key_id=5)) + assert event.type == 'error' + assert event.error_type == webrtc.SFrameTransformErrorEventType.key_id + assert event.key_id == 5 + assert event.frame is frame + + +@pytest.fixture +def pair() -> Iterator[tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + yield caller, callee + caller.close() + callee.close() + + +async def local_track(kind: str) -> webrtc.MediaStreamTrack: + stream = await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(**{kind: True})) + return stream.get_tracks()[0] + + +async def encrypted_call( + caller: webrtc.RTCPeerConnection, + callee: webrtc.RTCPeerConnection, + kind: str, + *, + encryptor: webrtc.RTCRtpSFrameEncryptor, + receiver_transform: webrtc.RTCRtpSFrameDecryptor | webrtc.RTCRtpScriptTransform, +) -> tuple[webrtc.RTCRtpSender, webrtc.RTCRtpReceiver]: + """Sends a track with transforms set before negotiation, and connects.""" + sender = caller.add_track(await local_track(kind)) + sender.transform = encryptor + receivers: list[webrtc.RTCRtpReceiver] = [] + + def on_track(event: webrtc.RTCTrackEvent) -> None: + receivers.append(event.receiver) + event.receiver.transform = receiver_transform + + callee.on('track', on_track) + await connect(caller, callee) + assert len(receivers) == 1 + return sender, receivers[0] + + +async def read_media(track: webrtc.MediaStreamTrack, count: int) -> list[object]: + """Reads decoded frames (or audio data) of a remote track.""" + processor = webrtc.MediaStreamTrackProcessor(webrtc.MediaStreamTrackProcessorInit(track, max_buffer_size=10)) + reader = processor.readable.get_reader() + chunks: list[object] = [] + try: + while len(chunks) < count: + chunk = (await asyncio.wait_for(reader.read(), TIMEOUT)).value + assert isinstance(chunk, (webrtc.VideoFrame, webrtc.AudioData)) + chunks.append(chunk) + chunk.close() + finally: + await reader.cancel() + return chunks + + +def sframe_pair( + suite: webrtc.SFrameCipherSuite = webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128, +) -> tuple[webrtc.RTCRtpSFrameEncryptor, webrtc.RTCRtpSFrameDecryptor]: + return ( + webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(suite)), + webrtc.RTCRtpSFrameDecryptor(webrtc.SFrameTransformOptions(suite)), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('kind', 'suite'), + [ + ('video', webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_80), + ('video', webrtc.SFrameCipherSuite.AES_256_GCM_SHA512_128), + ('audio', webrtc.SFrameCipherSuite.AES_128_GCM_SHA256_128), + ('audio', webrtc.SFrameCipherSuite.AES_256_CTR_HMAC_SHA512_32), + ], +) +async def test_media_flows_encrypted( + pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection], kind: str, suite: webrtc.SFrameCipherSuite +) -> None: + caller, callee = pair + encryptor, decryptor = sframe_pair(suite) + await encryptor.set_encryption_key(KEY, 1) + await decryptor.add_decryption_key(KEY, 1) + errors = Errors(decryptor) + sender, receiver = await encrypted_call(caller, callee, kind, encryptor=encryptor, receiver_transform=decryptor) + + assert sender.transform == encryptor + assert isinstance(sender.transform, webrtc.RTCRtpSFrameEncryptor) + assert receiver.transform == decryptor + assert isinstance(receiver.transform, webrtc.RTCRtpSFrameDecryptor) + await read_media(receiver.track, 20) + assert errors.events == [] + + +class Inspector: + """A receiver worker reading the SFrame frames on the wire, and decrypting them with a decryptor stream.""" + + def __init__(self, suite: webrtc.SFrameCipherSuite) -> None: + self.decryptor = webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions(suite)) + self.headers: list[tuple[int, int, int]] = [] + self.done: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + def record(self, frame: Frame, controller: webrtc.TransformStreamDefaultController[Frame, Frame]) -> None: + header = wrtc._sframeParseHeader(frame.data) + assert header is not None + self.headers.append(header) + controller.enqueue(frame) + + async def __call__(self, event: webrtc.RTCTransformEvent) -> None: + transformer = event.transformer + inspect: webrtc.TransformStream[Frame, Frame] = webrtc.TransformStream({'transform': self.record}) + with contextlib.suppress(Exception): + decrypted = transformer.readable.pipe_through(inspect).pipe_through(self.decryptor) + await decrypted.pipe_to(transformer.writable) + self.done.set_result(None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['video', 'audio']) +async def test_frames_are_encrypted_on_the_wire( + pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection], kind: str +) -> None: + """Like WPT sframe-transform-in-worker: the receiver decrypts with a stream in its script transform.""" + caller, callee = pair + suite = webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_64 + encryptor = webrtc.RTCRtpSFrameEncryptor(webrtc.RTCRtpSFrameEncryptorOptions(suite)) + await encryptor.set_encryption_key(KEY, 9) + inspector = Inspector(suite) + await inspector.decryptor.add_decryption_key(KEY, 9) + errors = Errors(inspector.decryptor) + _, receiver = await encrypted_call( + caller, callee, kind, encryptor=encryptor, receiver_transform=webrtc.RTCRtpScriptTransform(inspector) + ) + await read_media(receiver.track, 10) + # audio playout conceals missing packets, so decoded audio doesn't mean frames arrived yet + await wait_until(lambda: len({counter for _, counter, _ in inspector.headers}) >= 5, 'five frames', TIMEOUT) + + assert all(key_id == 9 for key_id, _, _ in inspector.headers) + # retransmitted packets may assemble a received frame again, out of order + assert len({counter for _, counter, _ in inspector.headers}) >= 5 + assert errors.events == [] + + +@pytest.mark.asyncio +async def test_decryption_errors(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + encryptor, decryptor = sframe_pair() + await encryptor.set_encryption_key(KEY, 1) + await decryptor.add_decryption_key(OTHER_KEY, 1) + errors = Errors(decryptor) + sender, receiver = await encrypted_call(caller, callee, 'video', encryptor=encryptor, receiver_transform=decryptor) + + event = await errors.wait() + assert event.error_type == webrtc.SFrameTransformErrorEventType.authentication + assert event.key_id is None + assert isinstance(event.frame, webrtc.RTCEncodedVideoFrame) + assert event.frame.get_metadata().synchronization_source is not None + assert event.target == decryptor + + await encryptor.set_encryption_key(KEY, 2**64 - 1) + await errors.wait_for(webrtc.SFrameTransformErrorEventType.key_id, 2**64 - 1) + + await decryptor.add_decryption_key(KEY, 2**64 - 1) + await read_media(receiver.track, 5) + assert sender.transform == encryptor + + +@pytest.mark.asyncio +async def test_key_rotation(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + caller, callee = pair + encryptor, decryptor = sframe_pair(webrtc.SFrameCipherSuite.AES_128_CTR_HMAC_SHA256_80) + await encryptor.set_encryption_key(KEY, 1) + await decryptor.add_decryption_key(KEY, 1) + await decryptor.add_decryption_key(OTHER_KEY, 2) + errors = Errors(decryptor) + _, receiver = await encrypted_call(caller, callee, 'audio', encryptor=encryptor, receiver_transform=decryptor) + await read_media(receiver.track, 10) + + await encryptor.set_encryption_key(OTHER_KEY, 2) + await decryptor.remove_decryption_key(1) + await read_media(receiver.track, 10) + # only frames sent before the key change may fail, with the key removed + key_id = webrtc.SFrameTransformErrorEventType.key_id + assert all(e.error_type == key_id and e.key_id == 1 for e in errors.events) + + await decryptor.remove_decryption_key(2) + await errors.wait_for(key_id, 2) + assert all(isinstance(e.frame, webrtc.RTCEncodedAudioFrame) for e in errors.events) + + +@pytest.mark.asyncio +async def test_encryptor_without_a_key_sends_nothing( + pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection], +) -> None: + caller, callee = pair + encryptor, decryptor = sframe_pair() + await decryptor.add_decryption_key(KEY, 0) + errors = Errors(decryptor) + _, receiver = await encrypted_call(caller, callee, 'audio', encryptor=encryptor, receiver_transform=decryptor) + await asyncio.sleep(1) + assert errors.events == [] + assert receiver.track.muted + + await encryptor.set_encryption_key(KEY, 0) + await read_media(receiver.track, 5) + + +def test_transform_attribute_types(pair: tuple[webrtc.RTCPeerConnection, webrtc.RTCPeerConnection]) -> None: + """Like WPT sframe-transform: an encryptor is for a sender, a decryptor for a receiver, each for one.""" + caller, _ = pair + encryptor, decryptor = sframe_pair() + audio = caller.add_transceiver('audio') + video = caller.add_transceiver('video') + with pytest.raises(TypeError): + audio.sender.transform = mistyped(decryptor) + with pytest.raises(TypeError): + audio.receiver.transform = mistyped(encryptor) + with pytest.raises(TypeError): + audio.sender.transform = mistyped( + webrtc.SFrameEncryptorStream(webrtc.SFrameTransformOptions('AES_128_GCM_SHA256_128')) + ) + + audio.sender.transform = encryptor + audio.receiver.transform = decryptor + with pytest.raises(webrtc.InvalidStateError): + video.sender.transform = encryptor + with pytest.raises(webrtc.InvalidStateError): + video.receiver.transform = decryptor + audio.sender.transform = encryptor + audio.receiver.transform = decryptor + assert audio.sender.transform == encryptor + assert audio.receiver.transform == decryptor + audio.sender.transform = None + audio.receiver.transform = None + assert audio.sender.transform is None + assert audio.receiver.transform is None + + +def released_to(baseline: int) -> bool: + """Whether the native SFrame transforms are back to the baseline, polled without blocking the loop.""" + gc.collect() + return wrtc._alive()['SFrameTransform'] <= baseline + + +def alive_transforms() -> int: + """The native SFrame transforms alive, once releases on helper threads are done.""" + gc.collect() + alive = wrtc._alive()['SFrameTransform'] + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + time.sleep(0.05) + gc.collect() + now = wrtc._alive()['SFrameTransform'] + if now == alive: + break + alive = now + return alive + + +@pytest.mark.asyncio +async def test_closing_releases_transforms() -> None: + baseline = alive_transforms() + + async def session() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + encryptor, decryptor = sframe_pair() + await encryptor.set_encryption_key(KEY, 1) + await decryptor.add_decryption_key(OTHER_KEY, 1) + errors = Errors(decryptor) + await encrypted_call(caller, callee, 'video', encryptor=encryptor, receiver_transform=decryptor) + await errors.wait() + # the right key stops the errors, which every frame makes + await decryptor.add_decryption_key(KEY, 1) + stream = webrtc.SFrameDecryptorStream(webrtc.SFrameTransformOptions('AES_128_GCM_SHA256_128')) + Errors(stream) + caller.close() + callee.close() + + await session() + await wait_until(lambda: released_to(baseline), 'the transforms released', TIMEOUT) diff --git a/tests/test_stats.py b/tests/test_stats.py index 12aee6e..0beecff 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -9,12 +9,13 @@ from __future__ import annotations +import json import time import pytest import webrtc -from tests.helpers import connect, wait_for_event, wait_until, wait_until_unmuted +from tests.helpers import connect, stats_of_type, wait_for_event, wait_until, wait_until_unmuted async def send_audio( @@ -35,15 +36,23 @@ async def send_audio( async def test_connection_stats( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream ) -> None: - """A report of the connection has its stats, as dictionaries and attributes, with timestamps in milliseconds.""" + """A report of the connection has its stats, as the dictionaries of their types, with timestamps in milliseconds.""" await send_audio(caller, callee, audio_stream) report = await caller.get_stats() assert isinstance(report, webrtc.RTCStatsReport) - assert len(report.of_type('peer-connection')) > 0 - outbound = report.of_type('outbound-rtp')[0] - assert outbound['kind'] == outbound.kind == 'audio' + assert all(isinstance(stats, webrtc.RTCPeerConnectionStats) for stats in stats_of_type(report, 'peer-connection')) + outbound = stats_of_type(report, webrtc.RTCStatsType.outbound_rtp)[0] + assert isinstance(outbound, webrtc.RTCOutboundRtpStreamStats) + assert outbound.kind == 'audio' + assert outbound.type is webrtc.RTCStatsType.outbound_rtp assert abs(outbound.timestamp - time.time() * 1000) < 60_000 + [transport] = stats_of_type(report, 'transport') + assert isinstance(transport, webrtc.RTCTransportStats) + assert transport.dtls_state is webrtc.RTCDtlsTransportState.connected + assert transport.dtlsState is transport.dtls_state + [source] = stats_of_type(report, 'media-source') + assert isinstance(source, webrtc.RTCAudioSourceStats) @pytest.mark.asyncio @@ -54,8 +63,8 @@ async def test_sender_stats( await send_audio(caller, callee, audio_stream) sender_report = await caller.get_senders()[0].get_stats() - assert len(sender_report.of_type('outbound-rtp')) > 0 - assert len(sender_report.of_type('inbound-rtp')) == 0 + assert len(stats_of_type(sender_report, 'outbound-rtp')) > 0 + assert len(stats_of_type(sender_report, 'inbound-rtp')) == 0 assert len(await caller.get_stats(audio_stream.get_tracks()[0])) == len(sender_report) @@ -68,9 +77,12 @@ async def test_receiver_stats( receiver = callee.get_receivers()[0] async def receives() -> list[webrtc.RTCStats]: - return (await receiver.get_stats()).of_type('inbound-rtp') + return stats_of_type(await receiver.get_stats(), 'inbound-rtp') await wait_until(receives, 'inbound-rtp stats') + [inbound] = await receives() + assert isinstance(inbound, webrtc.RTCInboundRtpStreamStats) + assert inbound.track_identifier == receiver.track.id @pytest.mark.asyncio @@ -90,7 +102,7 @@ async def test_closed_connection_has_stats( """A closed connection still has stats.""" await send_audio(caller, callee, audio_stream) caller.close() - assert len((await caller.get_stats()).of_type('peer-connection')) > 0 + assert len(stats_of_type(await caller.get_stats(), 'peer-connection')) > 0 @pytest.mark.asyncio @@ -102,11 +114,158 @@ async def test_remote_audio_is_played_out( receiver = callee.get_receivers()[0] async def decoded() -> bool: - inbound = (await receiver.get_stats()).of_type('inbound-rtp') + inbound = stats_of_type(await receiver.get_stats(), 'inbound-rtp') if len(inbound) == 0: return False - received = inbound[0].get('totalSamplesReceived', 0) - assert isinstance(received, int) - return received > 0 + assert isinstance(inbound[0], webrtc.RTCInboundRtpStreamStats) + received = inbound[0].total_samples_received + return received is not None and received > 0 await wait_until(decoded, 'decoded remote audio') + + +def report_of(*entries: dict[str, object]) -> webrtc.RTCStatsReport: + """A report from the JSON of libwebrtc, with timestamps in microseconds.""" + return webrtc.RTCStatsReport._from_native(json.dumps(list(entries))) + + +CODEC = { + 'type': 'codec', + 'id': 'C', + 'timestamp': 1_000_000, + 'payloadType': 111, + 'transportId': 'T', + 'mimeType': 'audio/opus', +} + + +@pytest.mark.parametrize( + ('entry', 'dictionary'), + [ + (CODEC, webrtc.RTCCodecStats), + ({'type': 'inbound-rtp', 'ssrc': 1, 'kind': 'audio', 'trackIdentifier': 't'}, webrtc.RTCInboundRtpStreamStats), + ({'type': 'outbound-rtp', 'ssrc': 1, 'kind': 'video'}, webrtc.RTCOutboundRtpStreamStats), + ({'type': 'remote-inbound-rtp', 'ssrc': 1, 'kind': 'audio'}, webrtc.RTCRemoteInboundRtpStreamStats), + ({'type': 'remote-outbound-rtp', 'ssrc': 1, 'kind': 'audio'}, webrtc.RTCRemoteOutboundRtpStreamStats), + ({'type': 'media-source', 'trackIdentifier': 't', 'kind': 'audio'}, webrtc.RTCAudioSourceStats), + ({'type': 'media-source', 'trackIdentifier': 't', 'kind': 'video'}, webrtc.RTCVideoSourceStats), + ({'type': 'media-playout', 'kind': 'audio'}, webrtc.RTCAudioPlayoutStats), + ({'type': 'peer-connection'}, webrtc.RTCPeerConnectionStats), + ({'type': 'data-channel', 'state': 'open'}, webrtc.RTCDataChannelStats), + ({'type': 'transport', 'dtlsState': 'new'}, webrtc.RTCTransportStats), + ( + { + 'type': 'candidate-pair', + 'transportId': 'T', + 'localCandidateId': 'L', + 'remoteCandidateId': 'R', + 'state': 'frozen', + }, + webrtc.RTCIceCandidatePairStats, + ), + ({'type': 'local-candidate', 'transportId': 'T', 'candidateType': 'host'}, webrtc.RTCIceCandidateStats), + ({'type': 'remote-candidate', 'transportId': 'T', 'candidateType': 'prflx'}, webrtc.RTCIceCandidateStats), + ( + {'type': 'certificate', 'fingerprint': 'AB', 'fingerprintAlgorithm': 'sha-256', 'base64Certificate': 'MII'}, + webrtc.RTCCertificateStats, + ), + ], +) +def test_stats_are_the_dictionary_of_their_type(entry: dict[str, object], dictionary: type[webrtc.RTCStats]) -> None: + """Each type of stats is the dictionary the specification defines for it.""" + report = report_of({**entry, 'id': 'S', 'timestamp': 1_500_000}) + stats = report['S'] + assert type(stats) is dictionary + assert stats.id == 'S' + assert stats.timestamp == 1500 + assert stats_of_type(report, str(entry['type'])) == [stats] + + +def test_stats_members() -> None: + """Members have snake_case names and camelCase aliases, enums are converted, and unknown members are dropped.""" + report = report_of( + { + 'type': 'outbound-rtp', + 'id': 'O', + 'timestamp': 0, + 'ssrc': 1, + 'kind': 'video', + 'bytesSent': 10, + 'qualityLimitationReason': 'cpu', + 'qualityLimitationDurations': {'cpu': 1.5, 'none': 0}, + 'googSomething': 'x', + }, + { + 'type': 'local-candidate', + 'id': 'L', + 'timestamp': 0, + 'transportId': 'T', + 'candidateType': 'host', + 'address': '', + }, + ) + outbound = report['O'] + assert isinstance(outbound, webrtc.RTCOutboundRtpStreamStats) + assert outbound.bytes_sent == outbound.bytesSent == 10 + assert outbound.packets_sent is None + assert outbound.quality_limitation_reason is webrtc.RTCQualityLimitationReason.cpu + assert outbound.quality_limitation_durations == {'cpu': 1.5, 'none': 0} + assert not hasattr(outbound, 'goog_something') + assert outbound.to_json() == { + 'timestamp': 0, + 'type': 'outbound-rtp', + 'id': 'O', + 'ssrc': 1, + 'kind': 'video', + 'bytesSent': 10, + 'qualityLimitationReason': 'cpu', + 'qualityLimitationDurations': {'cpu': 1.5, 'none': 0}, + } + candidate = report['L'] + assert isinstance(candidate, webrtc.RTCIceCandidateStats) + # libwebrtc leaves addresses it doesn't expose empty + assert candidate.address is None + assert candidate.candidate_type is webrtc.RTCIceCandidateType.host + + +def test_unknown_values_are_kept() -> None: + """An enum value or a type of stats the specification lacks stays a str, and the type has the common members.""" + report = report_of( + {'type': 'data-channel', 'id': 'D', 'timestamp': 0, 'state': 'opening'}, + {'type': 'csrc', 'id': 'X', 'timestamp': 0, 'contributorSsrc': 1}, + ) + channel = report['D'] + assert isinstance(channel, webrtc.RTCDataChannelStats) + assert channel.state == 'opening' + assert not isinstance(channel.state, webrtc.RTCDataChannelState) + unknown = report['X'] + assert type(unknown) is webrtc.RTCStats + assert unknown.type == 'csrc' + assert unknown.to_json() == {'timestamp': 0, 'type': 'csrc', 'id': 'X'} + + +def test_stats_lacking_a_required_member() -> None: + """Stats libwebrtc reports without a required member of their dictionary have the common members only.""" + report = report_of({'type': 'codec', 'id': 'C', 'timestamp': 0, 'payloadType': 111}) + assert type(report['C']) is webrtc.RTCStats + + +def test_stats_dictionaries() -> None: + """Stats dictionaries take keyword members, require the required ones, and convert from and to JSON.""" + codec = webrtc.RTCCodecStats.from_json({**CODEC, 'clockRate': 48000}) + assert codec == webrtc.RTCCodecStats( + id='C', + type='codec', + timestamp=1_000_000, + payload_type=111, + transport_id='T', + mime_type='audio/opus', + clock_rate=48000, + ) + assert codec.type is webrtc.RTCStatsType.codec + assert codec.channels is None + assert webrtc.RTCCodecStats.from_json(codec.to_json()) == codec + with pytest.raises(TypeError, match='missing the required member'): + webrtc.RTCCodecStats(id='C', type='codec', timestamp=0, payload_type=111, transport_id='T') + with pytest.raises(TypeError, match='has no member'): + webrtc.RTCStats(id='C', type='codec', timestamp=0, bytes_sent=1) diff --git a/tests/test_streams.py b/tests/test_streams.py index 53c8308..98cbab7 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -12,7 +12,7 @@ import asyncio import gc import weakref -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING, NoReturn, cast import pytest from typing_extensions import override @@ -21,7 +21,7 @@ from tests.helpers import wait_until if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import AsyncIterator, Iterable class Chunks: @@ -68,7 +68,7 @@ async def test_read_until_done() -> None: async def test_reads_are_requested_when_called() -> None: """Reads are pending from the call on, like promises, and settled in order.""" source = Controlled() - reader = webrtc.ReadableStream(source, high_water_mark=0).get_reader() + reader = webrtc.ReadableStream(source, webrtc.QueuingStrategy(high_water_mark=0)).get_reader() reads = [reader.read() for _ in range(3)] for i in range(3): source.controller.enqueue(i) @@ -101,7 +101,7 @@ def pull(self, controller: webrtc.ReadableStreamDefaultController) -> None: pass source = Idle([]) - stream = webrtc.ReadableStream(source, high_water_mark=0) + stream = webrtc.ReadableStream(source, webrtc.QueuingStrategy(high_water_mark=0)) reader = stream.get_reader() read = reader.read() await reader.cancel('stop') @@ -288,7 +288,7 @@ def write(chunk: int, _controller: webrtc.WritableStreamDefaultController) -> No def start() -> asyncio.Future[None]: # only the last pipe is referenced - source = webrtc.ReadableStream(Woken(waiting), high_water_mark=0) + source = webrtc.ReadableStream(Woken(waiting), webrtc.QueuingStrategy(high_water_mark=0)) sink = webrtc.WritableStream({'write': write}) return source.pipe_through(webrtc.TransformStream()).pipe_to(sink) @@ -323,3 +323,127 @@ def write(chunk: int, _controller: webrtc.WritableStreamDefaultController) -> No await wait_until(lambda: len(written) >= 3, 'chunks written') pipe.cancel() assert written[:3] == [20, 20, 20] + + +@pytest.mark.asyncio +async def test_strategy_size_counts_the_queue() -> None: + """The size of the strategy counts the queue for the desired size, and an invalid one errors the stream.""" + source = Controlled() + strategy: webrtc.QueuingStrategy[str] = webrtc.QueuingStrategy(high_water_mark=10, size=len) + stream = webrtc.ReadableStream(source, strategy) + await asyncio.sleep(0) + source.controller.enqueue('abc') + assert source.controller.desired_size == 7 + reader = stream.get_reader() + assert (await reader.read()).value == 'abc' + assert source.controller.desired_size == 10 + # what the size raises errors the stream + with pytest.raises(TypeError): + source.controller.enqueue(None) + with pytest.raises(TypeError): + await reader.read() + with pytest.raises(webrtc.InvalidRangeError): + webrtc.ReadableStream(strategy=webrtc.QueuingStrategy(high_water_mark=-1)) + + +@pytest.mark.asyncio +async def test_writable_strategy_size() -> None: + """A writable stream counts its queue with the size of its strategy.""" + sizes = {'small': 1.0, 'big': 5.0, 'bad': -1.0} + + class Sink: + @staticmethod + async def write(_chunk: str, _controller: webrtc.WritableStreamDefaultController) -> None: + await asyncio.sleep(0.01) + + stream = webrtc.WritableStream(Sink(), webrtc.QueuingStrategy(high_water_mark=4, size=sizes.__getitem__)) + writer = stream.get_writer() + await writer.ready + first = writer.write('small') + second = writer.write('big') + assert writer.desired_size == -2 + await asyncio.gather(first, second) + assert writer.desired_size == 4 + with pytest.raises(webrtc.InvalidRangeError): + await writer.write('bad') + + +@pytest.mark.asyncio +async def test_tee() -> None: + """Both branches read every chunk, and the stream is canceled once both branches are.""" + source = Chunks([1, 2, 3]) + stream = webrtc.ReadableStream(source) + first, second = stream.tee() + assert stream.locked + assert [chunk async for chunk in first] == [1, 2, 3] + assert [chunk async for chunk in second] == [1, 2, 3] + + source = Chunks(range(100)) + branches = webrtc.ReadableStream(source).tee() + # canceling a branch is done once the other is canceled too + first_canceled = branches[0].cancel('first') + await asyncio.sleep(0.01) + assert not first_canceled.done() + assert source.canceled is None + await asyncio.gather(first_canceled, branches[1].cancel('second')) + assert source.canceled == ['first', 'second'] + + +@pytest.mark.asyncio +async def test_tee_errors_both_branches() -> None: + """An error of the stream errors both branches.""" + source = Controlled() + branches = webrtc.ReadableStream(source).tee() + readers = [branch.get_reader() for branch in branches] + source.controller.error(ValueError('broken')) + for reader in readers: + with pytest.raises(ValueError, match='broken'): + await reader.read() + + +@pytest.mark.asyncio +async def test_from_iterables() -> None: + """A stream of the items of an iterable, asynchronous or not, which canceling closes.""" + assert [chunk async for chunk in webrtc.ReadableStream.from_([1, 2])] == [1, 2] + + closed: list[bool] = [] + + async def numbers() -> AsyncIterator[int]: + try: + for i in range(100): + await asyncio.sleep(0) + yield i + finally: + closed.append(True) + + reader = webrtc.ReadableStream.from_(numbers()).get_reader() + assert (await reader.read()).value == 0 + await reader.cancel() + assert closed == [True] + with pytest.raises(TypeError): + webrtc.ReadableStream.from_(cast('Iterable[int]', 1)) + + +@pytest.mark.asyncio +async def test_get_reader_options() -> None: + """Only default readers exist: a BYOB one is for byte streams.""" + stream = webrtc.ReadableStream(Chunks([1])) + with pytest.raises(TypeError): + stream.get_reader(webrtc.ReadableStreamGetReaderOptions(mode='byob')) + with pytest.raises(ValueError, match='nope'): + webrtc.ReadableStreamGetReaderOptions.from_json({'mode': 'nope'}) + reader = stream.get_reader(webrtc.ReadableStreamGetReaderOptions()) + assert (await reader.read()).value == 1 + + +@pytest.mark.asyncio +async def test_pipe_options_and_locked_pipe_through() -> None: + """Pipe options leave the destination open, and piping through a locked transform raises.""" + stream = webrtc.WritableStream() + await webrtc.ReadableStream(Chunks([1])).pipe_to(stream, webrtc.StreamPipeOptions(prevent_close=True)) + writer = stream.get_writer() + await writer.write(2) + transform = webrtc.TransformStream() + _ = transform.writable.get_writer() + with pytest.raises(TypeError): + webrtc.ReadableStream(Chunks([1])).pipe_through(transform) diff --git a/tests/test_track_settings.py b/tests/test_track_settings.py index e0374fa..f72b37a 100644 --- a/tests/test_track_settings.py +++ b/tests/test_track_settings.py @@ -9,6 +9,8 @@ from __future__ import annotations +import asyncio + import pytest from typing_extensions import TypedDict @@ -28,7 +30,9 @@ class VideoConstraints(TypedDict, total=False, closed=True): @pytest.mark.asyncio async def test_camera_settings_and_capabilities() -> None: """A camera track has the size and measured frame rate of its frames, and the capabilities of the camera.""" - stream = webrtc.get_user_media(audio=False, video=True, width=320, height=240, frame_rate=30) + stream = await webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints(video=webrtc.MediaTrackConstraints(width=320, height=240, frame_rate=30)) + ) track = stream.get_tracks()[0] await wait_until(lambda: track.get_settings().frame_rate is not None, 'the frame rate') @@ -151,7 +155,9 @@ async def test_constraints_have_their_webidl_types( with pytest.raises(TypeError): await video_stream.get_tracks()[0].apply_constraints(C(**constraints)) with pytest.raises(TypeError): - webrtc.get_user_media(audio=False, video=True, **constraints) + await webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints(video=webrtc.MediaTrackConstraints(**constraints)) + ) @pytest.mark.asyncio @@ -171,16 +177,32 @@ async def test_camera_stays_within_its_capabilities( await track.apply_constraints(C(**constraints)) assert capture_mode(track) == expected - track = webrtc.get_user_media(audio=False, video=True, **constraints).get_tracks()[0] + track = ( + await webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints(video=webrtc.MediaTrackConstraints(**constraints)) + ) + ).get_tracks()[0] assert capture_mode(track) == expected track.stop() 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=webrtc.ConstrainULongRange(exact=5000)) + asyncio.run( + webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints( + video=webrtc.MediaTrackConstraints(width=webrtc.ConstrainULongRange(exact=5000)) + ) + ) + ) with pytest.raises(webrtc.OverconstrainedError): - webrtc.get_user_media(audio=False, video=True, frame_rate=webrtc.ConstrainDoubleRange(min=500)) + asyncio.run( + webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints( + video=webrtc.MediaTrackConstraints(frame_rate=webrtc.ConstrainDoubleRange(min=500)) + ) + ) + ) def test_camera_of_impossible_sizes() -> None: @@ -191,7 +213,8 @@ def test_camera_of_impossible_sizes() -> None: import webrtc async def main(): - track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + constraints = webrtc.MediaStreamConstraints(video=True) + track = (await webrtc.media_devices.get_user_media(constraints)).get_tracks()[0] C, Range = webrtc.MediaTrackConstraints, webrtc.ConstrainULongRange for negative in (C(width=-1), C(height=Range(ideal=-5))): try: @@ -203,7 +226,8 @@ async def main(): 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() + constraints = webrtc.MediaStreamConstraints(video=webrtc.MediaTrackConstraints(width=0, height=0)) + (track,) = (await webrtc.media_devices.get_user_media(constraints)).get_tracks() print(track._native_obj._camera()) asyncio.run(main()) diff --git a/tests/test_tracks.py b/tests/test_tracks.py index b125337..169a442 100644 --- a/tests/test_tracks.py +++ b/tests/test_tracks.py @@ -132,7 +132,7 @@ async def test_remote_track_mute_and_stream_events( await wait_until_unmuted(remote_video) muted = wait_for_event(remote_video, 'mute') - transceiver.direction = webrtc.TransceiverDirection.inactive + transceiver.direction = webrtc.RTCRtpTransceiverDirection.inactive await exchange_offer(caller, callee) removed_event = await removed diff --git a/tests/test_video.py b/tests/test_video.py index b8e7af7..2fc0296 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -9,7 +9,7 @@ from __future__ import annotations -import functools +import asyncio import pytest @@ -19,7 +19,11 @@ def test_get_user_media_video() -> None: """A stream of video only has one video track.""" - stream = webrtc.get_user_media(audio=False, video=True, width=320, height=240) + stream = asyncio.run( + webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints(video=webrtc.MediaTrackConstraints(width=320, height=240)) + ) + ) (track,) = stream.get_tracks() assert track.kind == webrtc.MediaType.video assert stream.get_video_tracks() == [track] @@ -29,47 +33,57 @@ def test_get_user_media_video() -> None: def test_get_user_media_needs_audio_or_video() -> None: """A stream of nothing isn't a request.""" with pytest.raises(TypeError): - webrtc.get_user_media(audio=False, video=False) + asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints())) @pytest.mark.parametrize( - ('get_user_media', 'error'), + ('constraints', 'error'), [ ( - functools.partial(webrtc.get_user_media, width=webrtc.ConstrainULongRange(exact=0)), + webrtc.MediaTrackConstraints(width=webrtc.ConstrainULongRange(exact=0)), webrtc.OverconstrainedError, ), ( - functools.partial(webrtc.get_user_media, frame_rate=webrtc.ConstrainDoubleRange(max=0)), + webrtc.MediaTrackConstraints(frame_rate=webrtc.ConstrainDoubleRange(max=0)), webrtc.OverconstrainedError, ), - (functools.partial(webrtc.get_user_media, width=webrtc.ConstrainULongRange(min=0, max=-1)), TypeError), + (webrtc.MediaTrackConstraints(width=webrtc.ConstrainULongRange(min=0, max=-1)), TypeError), ], ids=['exact', 'max', 'negative'], ) def test_get_user_media_constraint_beyond_the_camera( - get_user_media: functools.partial[webrtc.MediaStream], error: type[Exception] + constraints: webrtc.MediaTrackConstraints, error: type[Exception] ) -> None: """A required value the camera can't have is overconstrained, a negative size isn't an unsigned long.""" with pytest.raises(error): - get_user_media(audio=False, video=True) + asyncio.run(webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(video=constraints))) 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=webrtc.ConstrainULongRange(ideal=0)).get_tracks() + (track,) = asyncio.run( + webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints( + video=webrtc.MediaTrackConstraints(height=webrtc.ConstrainULongRange(ideal=0)) + ) + ) + ).get_tracks() assert capture_mode(track) == (640, 1, 30) track.stop() 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=webrtc.ConstrainULongRange(ideal=320), - height=webrtc.ConstrainULongRange(min=100, max=240), - frame_rate=webrtc.ConstrainDoubleRange(exact=15), + stream = asyncio.run( + webrtc.media_devices.get_user_media( + webrtc.MediaStreamConstraints( + video=webrtc.MediaTrackConstraints( + 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 1ce7ff0..9a9506b 100644 --- a/tests/test_video_frame.py +++ b/tests/test_video_frame.py @@ -350,3 +350,102 @@ async def test_visible_rect_of_a_buffer_is_the_frame() -> None: await frame.copy_to(out) assert list(out) == [3, 4, 7, 8, 10, 12] frame.close() + + +def test_metadata_is_copied() -> None: + """The metadata of an init is a deep copy, kept by frames of the frame unless given again.""" + metadata = webrtc.VideoFrameMetadata(rtp_timestamp=7) + frame = webrtc.VideoFrame( + I420_DATA, + webrtc.VideoFrameBufferInit(format='I420', coded_width=4, coded_height=2, timestamp=0, metadata=metadata), + ) + metadata.rtp_timestamp = 8 + assert frame.metadata() == webrtc.VideoFrameMetadata(rtp_timestamp=7) + assert frame.metadata() is not frame.metadata() + other = webrtc.VideoFrame(frame) + assert other.metadata().rtp_timestamp == 7 + replaced = webrtc.VideoFrame(frame, webrtc.VideoFrameInit(metadata=webrtc.VideoFrameMetadata())) + assert replaced.metadata().rtp_timestamp is None + assert i420_4x2().metadata() == webrtc.VideoFrameMetadata() + for f in (frame, other, replaced): + f.close() + with pytest.raises(webrtc.InvalidStateError): + frame.metadata() + + +def test_init_metadata_from_json() -> None: + """The metadata of an init comes from its JSON form too.""" + init = webrtc.VideoFrameInit.from_json({'metadata': {'rtpTimestamp': 3}}) + assert init.metadata == webrtc.VideoFrameMetadata(rtp_timestamp=3) + + +@pytest.mark.asyncio +async def test_transfer() -> None: + """Transferred buffers are validated and memoryviews released; the pixels are copied all the same.""" + data = bytearray(I420_DATA) + view = memoryview(data) + + def init(transfer: list[bytes | bytearray | memoryview]) -> webrtc.VideoFrameBufferInit: + return webrtc.VideoFrameBufferInit( + format='I420', coded_width=4, coded_height=2, timestamp=0, transfer=list(transfer) + ) + + with pytest.raises(webrtc.DataCloneError): + webrtc.VideoFrame(data, init([data, view])) + with pytest.raises(TypeError, match='transfer takes buffers'): + webrtc.VideoFrame(data, init([mistyped(1)])) + frame = webrtc.VideoFrame(view, init([view])) + with pytest.raises(ValueError, match='released'): + view.tobytes() + data[0] = 0 + data.append(0) # not held by the frame + out = bytearray(12) + await frame.copy_to(out) + assert bytes(out) == I420_DATA + frame.close() + released = memoryview(b'') + released.release() + with pytest.raises(webrtc.DataCloneError, match='released'): + webrtc.VideoFrame(I420_DATA, init([released])) + + +@pytest.mark.parametrize('format', ['RGBA', 'BGRX']) +def test_copy_to_color_space(format: webrtc.VideoPixelFormatValue) -> None: + """Conversions to RGB are in srgb, the only color space libyuv converts to.""" + frame = i420_4x2() + options = webrtc.VideoFrameCopyToOptions(format=format, color_space='srgb') + assert frame.allocation_size(options) == 32 + assert webrtc.VideoFrameCopyToOptions.from_json({'colorSpace': 'srgb'}).color_space == 'srgb' + for color_space in ('srgb-linear', 'display-p3', 'display-p3-linear'): + with pytest.raises(webrtc.NotSupportedError, match=color_space): + frame.allocation_size(webrtc.VideoFrameCopyToOptions(format=format, color_space=color_space)) + # copies without a conversion take no color space + assert frame.allocation_size(webrtc.VideoFrameCopyToOptions(color_space='display-p3')) == 12 + with pytest.raises(TypeError, match='not a PredefinedColorSpace'): + frame.allocation_size(webrtc.VideoFrameCopyToOptions(color_space=mistyped('rec2020'))) + frame.close() + + +def test_construct_with_positional_source() -> None: + """The image or data of a frame is positional only, as it has another name per overload.""" + frame = i420_4x2() + with pytest.raises(TypeError): + webrtc.VideoFrame(source=frame) # pyrefly: ignore[no-matching-overload] + frame.close() + + +def test_dom_rect_from_rect_and_to_json() -> None: + """fromRect() copies a rect init, toJSON() gives every attribute.""" + rect = webrtc.DOMRectReadOnly.fromRect(webrtc.DOMRectInit(x=1, y=2, width=-3, height=4)) + assert rect == webrtc.DOMRectReadOnly(1, 2, -3, 4) + assert webrtc.DOMRectReadOnly.from_rect() == webrtc.DOMRectReadOnly() + assert rect.toJSON() == { + 'x': 1, + 'y': 2, + 'width': -3, + 'height': 4, + 'top': 2, + 'right': 1, + 'bottom': 6, + 'left': -2, + } diff --git a/tests/wpt/bridge.py b/tests/wpt/bridge.py index c34a89e..e3c41f0 100644 --- a/tests/wpt/bridge.py +++ b/tests/wpt/bridge.py @@ -18,28 +18,19 @@ import inspect import sys import time -from typing import TYPE_CHECKING, Callable, TypeVar, Union, cast +from typing import TYPE_CHECKING, Callable, TypeVar, Union import pythonmonkey as pm -from typing_extensions import TypedDict import webrtc import webrtc.enums +from tests.wpt.loader import WPT_ROOT if TYPE_CHECKING: from collections.abc import Awaitable, Coroutine from _typeshed import DataclassInstance - from webrtc.models.media_track_constraints import ConstrainDouble, ConstrainULong - - class _UserMediaOptions(TypedDict, total=False, closed=True): - audio: bool - video: bool - width: ConstrainULong | None - height: ConstrainULong | None - frame_rate: ConstrainDouble | None - _T = TypeVar('_T') @@ -63,10 +54,19 @@ class _UserMediaOptions(TypedDict, total=False, closed=True): webrtc.WritableStreamDefaultWriter, webrtc.VideoTrackGenerator, webrtc.VideoColorSpace, + webrtc.RTCRtpScriptTransformer, + webrtc.RTCEncodedVideoFrame, + webrtc.RTCEncodedAudioFrame, ) +# WebIDL names that aren't the camelCase of the snake_case ones +_IDL_NAMES = {'key_id': 'keyID'} + + def _camel_case(name: str) -> str: + if name in _IDL_NAMES: + return _IDL_NAMES[name] first, *rest = name.split('_') return first + ''.join(part.title() for part in rest) @@ -113,8 +113,16 @@ def _error_to_js(value: BaseException) -> object: return {'__error': _error(value)['error']} +def _one_stats_to_js(stats: webrtc.RTCStats) -> object: + members = _dictionary_to_js(stats) + # address is nullable: null when the address isn't exposed + if isinstance(stats, webrtc.RTCIceCandidateStats): + members['address'] = stats.address + return members + + def _stats_to_js(value: webrtc.RTCStatsReport) -> object: - return {'__statsReport': [[stats_id, dict(stats)] for stats_id, stats in value.items()]} + return {'__statsReport': [[stats_id, _one_stats_to_js(stats)] for stats_id, stats in value.items()]} def _bytes_to_js(value: bytes) -> object: @@ -321,13 +329,15 @@ def copy() -> bytes: return _guard(copy) -def construct(name: str, kwargs: dict[str, object]) -> Result: - return _guard(lambda: getattr(webrtc, name)(**from_js(dict(kwargs)))) +def construct(name: str, kwargs: dict[str, object], args: list[object] | None = None) -> Result: + return _guard( + lambda: getattr(webrtc, name)(*from_js(list(args) if args is not None else []), **from_js(dict(kwargs))) + ) -def get_user_media(kwargs: dict[str, object]) -> Result: +def get_user_media(constraints: object) -> asyncio.Future[Result]: # the shim converts the constraints as WebIDL does, the library validates them - return _guard(lambda: webrtc.get_user_media(**cast('_UserMediaOptions', from_js(dict(kwargs))))) + return call_async_method(webrtc.media_devices, 'get_user_media', {'args': [constraints]}) def call_static(class_name: str, name: str, args: list[object]) -> Result: @@ -356,6 +366,28 @@ def add_listener() -> None: return _guard(add_listener) +def wrap(value: object) -> Result: + """A Python object JS got as it is (like the event a JS callback is called with), as the shim reads it.""" + return _guard(lambda: value) + + +def get_buffer(obj: object, name: str) -> Result: + """A bytearray attribute as [identity, bytearray, detached]: JS shares its memory, one ArrayBuffer for each.""" + + def get() -> list[object]: + value = getattr(obj, name) + # the data of an encoded frame written to its transformer, an ArrayBuffer transferred in a browser + detached = bool(getattr(obj, '_detached', False)) + return [id(value), value, detached] + + return _guard(get) + + +def read_text(path: str) -> Result: + """A file of the WPT checkout by its path from the root, like the script of a Worker.""" + return _guard((WPT_ROOT / path.lstrip('/')).read_text) + + EXPORTS = { f.__name__: f for f in ( @@ -372,5 +404,8 @@ def add_listener() -> None: get_user_media, now, subscribe, + wrap, + get_buffer, + read_text, ) } diff --git a/tests/wpt/expectations.json b/tests/wpt/expectations.json index 6f7ab6a..268e1c1 100644 --- a/tests/wpt/expectations.json +++ b/tests/wpt/expectations.json @@ -1,12 +1,8 @@ { "skip": { - "mediacapture-insertable-streams/MediaStreamTrackProcessor-with-window-tracks.https.html": "runs in a Worker, which a Python library has no counterpart of", - "mediacapture-insertable-streams/VideoTrackGenerator-with-window-tracks.https.html": "runs in a Worker, which a Python library has no counterpart of", "mediacapture-insertable-streams/idlharness.any.js": "checks the shape of JS interfaces, which shim.js defines rather than the library", "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-in-service-worker.https.html": "needs a service worker", "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-in-shared-worker.https.html": "runs in a Worker, which a Python library has no counterpart of", - "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-in-worker.https.html": "runs in a Worker, which a Python library has no counterpart of", - "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-pipes-data-in-worker.https.html": "runs in a Worker, which a Python library has no counterpart of", "mediacapture-insertable-streams/legacy/MediaStreamTrackProcessor-backpressure.https.html": "draws frames with canvas, ImageBitmap or WebGL, which a Python library has no counterpart of", "webcodecs/audio-data-serialization.any.js": "transfers frames with postMessage or structured clone, which a Python library has no counterpart of", "webcodecs/video-frame-serialization.any.js": "transfers frames with postMessage or structured clone, which a Python library has no counterpart of", @@ -22,28 +18,13 @@ "webcodecs/videoFrame-serialization.crossAgentCluster.https.html": "transfers frames with postMessage or structured clone, which a Python library has no counterpart of", "webcodecs/videoFrame-serialization.https.html": "transfers frames with postMessage or structured clone, which a Python library has no counterpart of", "webcodecs/videoFrame-texImage.any.js": "draws frames with canvas, ImageBitmap or WebGL, which a Python library has no counterpart of", - "webrtc-encoded-transform/RTCRtpScriptTransform-bad-chunk.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/RTCRtpScriptTransform-encoded-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/RTCRtpScriptTransform-padding.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/RTCRtpScriptTransform-sender-worker-single-frame.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/RTCRtpScriptTransform-write-rules.https.html": "runs in a Worker, which a Python library has no counterpart of", "webrtc-encoded-transform/idlharness.https.window.js": "checks the shape of JS interfaces, which shim.js defines rather than the library", - "webrtc-encoded-transform/script-audio-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-change-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-metadata-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-transform-generateKeyFrame-simulcast.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-transform-generateKeyFrame.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-transform-sendKeyFrameRequest.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/script-write-twice-transform.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/sframe-transform-in-worker.https.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/tentative/RTCEncodedAudioFrame-audiolevel.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/tentative/RTCEncodedAudioFrame-constructor.html": "runs in a Worker, which a Python library has no counterpart of", + "webrtc-encoded-transform/tentative/RTCEncodedAudioFrame-constructor.html": "needs the absolute capture time header extension, which libwebrtc doesn't send here: its cases time out", "webrtc-encoded-transform/tentative/RTCEncodedAudioFrame-serviceworker-failure.https.html": "needs a service worker", - "webrtc-encoded-transform/tentative/RTCEncodedFrame-timestamps.html": "runs in a Worker, which a Python library has no counterpart of", - "webrtc-encoded-transform/tentative/RTCEncodedVideoFrame-constructor.html": "runs in a Worker, which a Python library has no counterpart of", + "webrtc-encoded-transform/tentative/RTCEncodedFrame-timestamps.html": "needs the absolute capture time header extension, which libwebrtc doesn't send here: its cases time out", + "webrtc-encoded-transform/tentative/RTCEncodedVideoFrame-constructor.html": "needs the absolute capture time header extension, which libwebrtc doesn't send here: its cases time out", "webrtc-encoded-transform/tentative/RTCEncodedVideoFrame-serviceworker-failure.https.html": "needs a service worker", - "webrtc-encoded-transform/tentative/RTCPeerConnection-insertable-streams-worker.https.html": "runs in a Worker, which a Python library has no counterpart of", + "webrtc-encoded-transform/tentative/RTCPeerConnection-insertable-streams-worker.https.html": "uses createEncodedStreams, Chrome's nonstandard API that RTCRtpScriptTransform replaces", "webrtc-extensions/RTCRtpEncodingParameters-scaleResolutionDownTo.https.html": "needs DOM elements (video, canvas or iframes)", "webrtc-extensions/RTCRtpSynchronizationSource-captureTimestamp.html": "needs DOM elements (video, canvas or iframes)", "webrtc-extensions/RTCRtpSynchronizationSource-senderCaptureTimeOffset.html": "needs DOM elements (video, canvas or iframes)", @@ -52,7 +33,6 @@ "webrtc-identity/RTCPeerConnection-peerIdentity.https.html": "needs an identity provider hosted by the browser", "webrtc-identity/idlharness.https.window.js": "checks the shape of JS interfaces, which shim.js defines rather than the library", "webrtc-stats/idlharness.window.js": "checks the shape of JS interfaces, which shim.js defines rather than the library", - "webrtc/RTCDataChannel-worker-GC.html": "runs in a Worker, which a Python library has no counterpart of", "webrtc/RTCPeerConnection-capture-video.https.html": "needs DOM elements (video, canvas or iframes)", "webrtc/RTCPeerConnection-perfect-negotiation-stress-glare-linear.https.html": "needs DOM elements (video, canvas or iframes)", "webrtc/RTCPeerConnection-perfect-negotiation-stress-glare.https.html": "needs DOM elements (video, canvas or iframes)", @@ -69,13 +49,23 @@ "webrtc/idlharness.https.window.js": "checks the shape of JS interfaces, which shim.js defines rather than the library", "webrtc/legacy/simplecall_callbacks.optional.https.html": "needs DOM elements (video, canvas or iframes)", "webrtc/simplecall-no-ssrcs.https.html": "needs DOM elements (video, canvas or iframes)", - "webrtc/simplecall.https.html": "needs DOM elements (video, canvas or iframes)", - "webrtc/transfer-datachannel.html": "runs in a Worker, which a Python library has no counterpart of" + "webrtc/simplecall.https.html": "needs DOM elements (video, canvas or iframes)" }, "results": { + "mediacapture-insertable-streams/MediaStreamTrackProcessor-with-window-tracks.https.html": { + "Tests that the reader of a video MediaStreamTrackProcessor produces VideoFrame objects and is closed on track stop while running on a worker": "FAIL" + }, + "mediacapture-insertable-streams/VideoTrackGenerator-with-window-tracks.https.html": { + "Tests that frames are actually rendered correctly in a stream sent over a peer connection.": "FAIL", + "Tests that frames are actually rendered correctly in a stream used for a video element.": "FAIL", + "Tests that frames are sent correctly with RTCRtpEncodingParameters.scaleResolutionDownBy.": "FAIL" + }, "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-audio.https.html": { "Tests that audio actually flows to a connected audio element": "FAIL" }, + "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-pipes-data-in-worker.https.html": { + "A worker is able to pipe data through a MediaStreamTrackGenerator without crashing": "FAIL" + }, "mediacapture-insertable-streams/legacy/MediaStreamTrackGenerator-video.https.html": { "Mismatched frame and generator kind throws on write.": "FAIL", "Tests that VideoFrames are destroyed on write.": "FAIL", @@ -205,57 +195,54 @@ "webrtc-encoded-transform/RTCEncodedFrame-copy-construction.https.html": { "RTCEncodedAudioFrame cannot be serialized for storage on main thread.": "FAIL", "RTCEncodedAudioFrame copy c'tor using transferred frame throws DataCloneError on main thread.": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override on main thread.": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (audioLevel).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (captureTime).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (contributingSources).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (mimeType).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (payloadType).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (receiveTime).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (rtpTimestamp).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (senderCaptureTimeOffset).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (sequenceNumber).": "FAIL", - "RTCEncodedAudioFrame copy construction metadata override/addition on main thread (synchronizationSource).": "FAIL", - "RTCEncodedAudioFrame copy construction on main thread.": "FAIL", - "RTCEncodedAudioFrame copying leaves original data buffer alone on main thread.": "FAIL", - "RTCEncodedAudioFrame data rejects buffers it cannot take on main thread.": "FAIL", - "RTCEncodedAudioFrame structuredClone after in-place modification on main thread.": "FAIL", "RTCEncodedAudioFrame structuredClone keeps a buffer shared between frames shared on main thread.": "FAIL", "RTCEncodedAudioFrame structuredClone of transferred frame throws DataCloneError on main thread.": "FAIL", - "RTCEncodedAudioFrame structuredClone on main thread.": "FAIL", "RTCEncodedVideoFrame cannot be serialized for storage on main thread.": "FAIL", "RTCEncodedVideoFrame copy c'tor using transferred frame throws DataCloneError on main thread.": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override on main thread.": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (captureTime).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (contributingSources).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (dependencies).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (frameId).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (height).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (mimeType).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (payloadType).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (receiveTime).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (rtpTimestamp).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (senderCaptureTimeOffset).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (spatialIndex).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (synchronizationSource).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (temporalIndex).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (timestamp).": "FAIL", - "RTCEncodedVideoFrame copy construction metadata override/addition on main thread (width).": "FAIL", - "RTCEncodedVideoFrame copy construction on main thread.": "FAIL", - "RTCEncodedVideoFrame copying leaves original data buffer alone on main thread.": "FAIL", - "RTCEncodedVideoFrame data rejects buffers it cannot take on main thread.": "FAIL", - "RTCEncodedVideoFrame structuredClone after in-place modification on main thread.": "FAIL", "RTCEncodedVideoFrame structuredClone keeps a buffer shared between frames shared on main thread.": "FAIL", - "RTCEncodedVideoFrame structuredClone of transferred frame throws DataCloneError on main thread.": "FAIL", - "RTCEncodedVideoFrame structuredClone on main thread.": "FAIL" + "RTCEncodedVideoFrame structuredClone of transferred frame throws DataCloneError on main thread.": "FAIL" }, "webrtc-encoded-transform/RTCEncodedFrame-shared-buffer.https.html": { "RTCEncodedAudioFrame data rejects a SharedArrayBuffer.": "FAIL", "RTCEncodedVideoFrame data rejects a SharedArrayBuffer.": "FAIL" }, + "webrtc-encoded-transform/RTCRtpScriptTransform-encoded-transform.https.html": { + "Receiver and sender read, modifiy and write audio frames.": "FAIL", + "Receiver and sender read, modifiy and write video frames.": "FAIL", + "Receiver and sender read/write frames (detach, audio).": "FAIL", + "Receiver and sender read/write frames (detach, video).": "FAIL", + "Sender reads frames but doesn't write them back. Receiver doesn't receive any frames.": "FAIL", + "Sender skips some frames and only writes one back. Receiver only receives that frame.": "FAIL" + }, + "webrtc-encoded-transform/script-audio-transform.https.html": { + "script-audio-transform": "FAIL" + }, + "webrtc-encoded-transform/script-change-transform.https.html": { + "change sender transform": "FAIL" + }, "webrtc-encoded-transform/script-late-transform.https.html": { "video exchange with late receiver transform": "FAIL" }, + "webrtc-encoded-transform/script-metadata-transform.https.html": { + "audio frame: timestamp (renamed to metadata.rtpTimestamp": "FAIL", + "audio metadata: senderCaptureTimeOffset": "FAIL", + "video frame: timestamp (renamed to metadata.rtpTimestamp": "FAIL" + }, + "webrtc-encoded-transform/script-transform-generateKeyFrame.https.html": { + "generateKeyFrame timestamp should advance": "FAIL", + "generateKeyFrame(null) resolves for video sender, and throws for video receiver": "FAIL" + }, + "webrtc-encoded-transform/script-transform-sendKeyFrameRequest.https.html": { + "sendKeyFrameRequest resolves for video receiver, and throws for video sender": "FAIL", + "sendKeyFrameRequest throws for audio sender/receiver": "FAIL" + }, + "webrtc-encoded-transform/script-transform.https.html": { + "audio exchange with transform": "FAIL", + "video exchange with transform": "FAIL" + }, + "webrtc-encoded-transform/script-write-twice-transform.https.html": { + "video exchange with write twice transform": "FAIL" + }, "webrtc-encoded-transform/sframe-keys.https.html": { "Audio exchange with SFrame setup": "FAIL", "Passing various key IDs": "FAIL" @@ -263,15 +250,17 @@ "webrtc-encoded-transform/sframe-transform-buffer-source.html": { "Uint8Array as input to SFrameEncrypterStream": "FAIL" }, + "webrtc-encoded-transform/sframe-transform-in-worker.https.html": { + "video exchange with SFrame transform in worker": "FAIL" + }, "webrtc-encoded-transform/sframe-transform-readable.html": { "sframe-transform-readable": "FAIL" }, "webrtc-encoded-transform/sframe-transform.html": { - "Cannot reuse attached transforms": "FAIL", - "SFrame decryption with array buffer that is too small": "FAIL", - "SFrame transform gets errored if trying to process unexpected value types": "FAIL", - "SFrame with array buffer - AES_128_CTR_HMAC_SHA256_80": "FAIL", - "SFrameEncrypterStream exposes readable and writable": "FAIL" + "SFrame decryption with array buffer that is too small": "NOTRUN", + "SFrame transform gets errored if trying to process unexpected value types": "NOTRUN", + "SFrame with array buffer - AES_128_CTR_HMAC_SHA256_80": "TIMEOUT", + "[harness]": "TIMEOUT" }, "webrtc-encoded-transform/tentative/RTCEncodedAudioFrame-clone.https.html": { "Cloning before sending works": "FAIL" @@ -334,18 +323,6 @@ "webrtc-identity/RTCPeerConnection-constructor.html": { "RTCPeerConnection constructor throws if the given peerIdentity getter throws": "FAIL" }, - "webrtc-stats/RTCDataChannel-stats.html": { - "answerer on worker: Check that RTCDataChannelStats are present": "FAIL", - "answerer on worker: Check that RTCDataChannelStats have a valid initial state": "FAIL", - "answerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various binary messages received as arraybuffer": "FAIL", - "answerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various binary messages received as blob": "FAIL", - "answerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various strings": "FAIL", - "offerer on worker: Check that RTCDataChannelStats are present": "FAIL", - "offerer on worker: Check that RTCDataChannelStats have a valid initial state": "FAIL", - "offerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various binary messages received as arraybuffer": "FAIL", - "offerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various binary messages received as blob": "FAIL", - "offerer on worker: Check that RTCDataChannelStats messages/bytes Sent/Received are correct for various strings": "FAIL" - }, "webrtc-stats/hardware-capability-stats.https.html": { "decoderImplementation not exposed when fullscreen and not capturing.": "FAIL", "decoderImplementation not exposed when not capturing.": "FAIL", @@ -435,6 +412,9 @@ "webrtc/RTCDataChannel-send.html": { "Sending multiple messages simultaneously in unordered mode works reliably": "FAIL" }, + "webrtc/RTCDataChannel-worker-GC.html": { + "While remote PC remains open, its worker datachannel should not be collected": "FAIL" + }, "webrtc/RTCDtlsTransport-getRemoteCertificates.html": { "RTCDtlsTransport-getRemoteCertificates": "FAIL" }, @@ -815,6 +795,10 @@ "webrtc/simulcast/vp9.https.html": { "VP9 simulcast setup with two streams": "FAIL", "[harness]": "ERROR" + }, + "webrtc/transfer-datachannel.html": { + "Failing to transfer a data channel": "FAIL", + "Sanity check worker shim: RTCDataChannel not transferable": "FAIL" } } } diff --git a/tests/wpt/polyfills.js b/tests/wpt/polyfills.js index 11e9a1e..19adc51 100644 --- a/tests/wpt/polyfills.js +++ b/tests/wpt/polyfills.js @@ -56,6 +56,37 @@ } }; + // WebCrypto as far as WebRTC tests use it: raw secret keys, which shim.js's SFrame transforms take + const KEY_DATA = Symbol.for('wpt.keyData'); + globalThis.CryptoKey ??= class CryptoKey { + #algorithm; + #extractable; + #usages; + + constructor(token, data, algorithm, extractable, usages) { + if (token !== KEY_DATA) throw new TypeError('Illegal constructor'); + Object.defineProperty(this, KEY_DATA, {value: data}); + this.#algorithm = Object.freeze({...algorithm}); + this.#extractable = extractable; + this.#usages = Object.freeze([...usages]); + } + + get type() { return 'secret'; } + get extractable() { return this.#extractable; } + get algorithm() { return this.#algorithm; } + get usages() { return this.#usages; } + }; + globalThis.crypto ??= {}; + globalThis.crypto.subtle ??= { + async importKey(format, keyData, algorithm, extractable, usages) { + if (format !== 'raw') throw new DOMException(`Unsupported key format ${format}`, 'NotSupportedError'); + const view = keyData instanceof ArrayBuffer + ? new Uint8Array(keyData) : new Uint8Array(keyData.buffer, keyData.byteOffset, keyData.byteLength); + const name = typeof algorithm === 'object' ? String(algorithm.name) : String(algorithm); + return new CryptoKey(KEY_DATA, Uint8Array.from(view), {name}, Boolean(extractable), Array.from(usages, String)); + }, + }; + // Blob and structuredClone as far as tests of WebRTC use them: in-memory bytes and plain data globalThis.Blob ??= class Blob { #bytes; @@ -106,13 +137,196 @@ readAsArrayBuffer(blob) { this.#read(blob, (bytes) => bytes.slice().buffer); } readAsText(blob) { this.#read(blob, (bytes) => new TextDecoder().decode(bytes)); } }; - globalThis.structuredClone ??= function structuredClone(value) { + // platform objects that are [Serializable] define how they're cloned (see shim.js) + const SERIALIZE = Symbol.for('wpt.serialize'); + // transferred objects are kept as they are: the worker of polyfills below runs in this realm + function cloneWith(value, transferred) { if (value === null || typeof value !== 'object') return value; + if (transferred.has(value)) return value; + if (typeof value[SERIALIZE] === 'function') return value[SERIALIZE](); if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) return new value.constructor(value); if (value instanceof Blob) return value; - if (Array.isArray(value)) return value.map(structuredClone); - return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, structuredClone(v)])); + if (Array.isArray(value)) return value.map((item) => cloneWith(item, transferred)); + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cloneWith(v, transferred)])); + } + const transferList = (transferOrOptions) => + (Array.isArray(transferOrOptions) ? transferOrOptions : transferOrOptions?.transfer ?? []); + globalThis.structuredClone ??= (value, options) => cloneWith(value, new Set(transferList(options))); + + // browser messages cross threads, each a task: tests listen for the next one only once a message is handled + const CROSS_THREAD_DELAY_MS = 5; + + class Mailbox { + #queue = []; + + post(callback) { + this.#queue.push(callback); + if (this.#queue.length === 1) setTimeout(() => this.#next(), CROSS_THREAD_DELAY_MS); + } + + #next() { + const callback = this.#queue[0]; + try { + callback(); + } finally { + this.#queue.shift(); + if (this.#queue.length > 0) setTimeout(() => this.#next(), CROSS_THREAD_DELAY_MS); + } + } + } + + // MessageChannel: messages are cloned and delivered as tasks, once the port is started (or has onmessage) + class MessagePort extends EventTarget { + #other = null; + #mailbox = new Mailbox(); + #queue = []; + #started = false; + #onmessage = null; + + static entangle(port1, port2) { + port1.#other = port2; + port2.#other = port1; + } + + postMessage(message, transferOrOptions) { + const other = this.#other; + const data = cloneWith(message, new Set(transferList(transferOrOptions))); + other.#mailbox.post(() => other.#receive(data)); + } + + start() { + this.#started = true; + for (const data of this.#queue.splice(0)) this.#deliver(data); + } + + close() { this.#other = null; } + + get onmessage() { return this.#onmessage; } + + set onmessage(handler) { + this.#onmessage = handler; + this.start(); + } + + #receive(data) { + if (this.#started) this.#deliver(data); + else this.#queue.push(data); + } + + #deliver(data) { + const event = new Event('message'); + Object.defineProperty(event, 'data', {value: data}); + this.dispatchEvent(event); + } + } + globalThis.MessagePort ??= MessagePort; + globalThis.MessageChannel ??= class MessageChannel { + constructor() { + this.port1 = new MessagePort(); + this.port2 = new MessagePort(); + MessagePort.entangle(this.port1, this.port2); + } + }; + + // Worker, in process: the script runs on this global and loop, with a scope of its own + const messageEvent = (data) => { + const event = new Event('message'); + Object.defineProperty(event, 'data', {value: data}); + return event; + }; + + function workerScript(url) { + if (url.startsWith('data:')) { + const comma = url.indexOf(','); + const body = url.slice(comma + 1); + return url.slice(0, comma).endsWith(';base64') ? atob(body) : decodeURIComponent(body); + } + const base = globalThis.location.pathname; + const path = url.startsWith('/') ? url : base.slice(0, base.lastIndexOf('/') + 1) + url; + const result = bridge.read_text(path); + if (result.error) throw new Error(`Worker: can't load ${url}: ${result.error.message}`); + return result.ok; + } + + globalThis.Worker ??= class Worker extends EventTarget { + #scope; + #ready; + #mailbox = new Mailbox(); + onmessage = null; + onerror = null; + + constructor(url) { + super(); + const worker = this; + const own = new EventTarget(); + const locals = { + postMessage(message, transferOrOptions) { + const data = cloneWith(message, new Set(transferList(transferOrOptions))); + worker.#mailbox.post(() => worker.#dispatch(worker, messageEvent(data))); + }, + addEventListener: own.addEventListener.bind(own), + removeEventListener: own.removeEventListener.bind(own), + dispatchEvent: own.dispatchEvent.bind(own), + close() {}, + onmessage: null, + onrtctransform: null, + }; + this.#scope = new Proxy(locals, { + has: (target, key) => key !== Symbol.unscopables, + get(target, key) { + if (key === 'self' || key === 'globalThis') return worker.#scope; + if (key in target) return target[key]; + const value = globalThis[key]; + // global functions called through the scope get it as this + return typeof value === 'function' && !('prototype' in value) ? value.bind(globalThis) : value; + }, + set(target, key, value) { + target[key] = value; + return true; + }, + }); + const code = workerScript(String(url)); + this.#ready = new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + // eslint-disable-next-line no-new-func + new Function('scope', `with (scope) {\n${code}\n}`)(this.#scope); + }).catch((error) => this.#error(error)); + } + + postMessage(message, transferOrOptions) { + const data = cloneWith(message, new Set(transferList(transferOrOptions))); + this.#ready.then(() => this.#dispatch(this.#scope, messageEvent(data))); + } + + terminate() {} + + __dispatchInScope(event) { + this.#ready.then(() => this.#dispatch(this.#scope, event)); + } + + #dispatch(target, event) { + try { + if (target === this) { + this.dispatchEvent(event); + return; + } + target.dispatchEvent(event); + const handler = target[`on${event.type}`]; + if (typeof handler === 'function') { + const result = handler.call(target, event); + if (result instanceof Promise) result.catch((error) => this.#error(error)); + } + } catch (error) { + this.#error(error); + } + } + + #error(error) { + const event = new Event('error'); + Object.defineProperty(event, 'message', {value: String(error?.message ?? error)}); + Object.defineProperty(event, 'error', {value: error}); + this.dispatchEvent(event); + } }; // the geometry of the visible rect of a VideoFrame diff --git a/tests/wpt/shim.js b/tests/wpt/shim.js index aff73ac..d5ecdd7 100644 --- a/tests/wpt/shim.js +++ b/tests/wpt/shim.js @@ -104,8 +104,11 @@ OperationError: 'OperationError', NotSupportedError: 'NotSupportedError', NetworkError: 'NetworkError', + DataCloneError: 'DataCloneError', InvalidSyntaxError: 'SyntaxError', InvalidCharacterError: 'InvalidCharacterError', + NotFoundError: 'NotFoundError', + NotAllowedError: 'NotAllowedError', }; const JS_ERROR_BY_CLASS = { TypeError, @@ -128,8 +131,9 @@ return fromPy(result.ok); } - function construct(name, kwargs = {}) { - const result = bridge.construct(name, kwargs); + // positional-only parameters take args + function construct(name, kwargs = {}, args = []) { + const result = bridge.construct(name, kwargs, args); if (result.error) throw toJsError(result.error); return result.ok; } @@ -367,6 +371,7 @@ function toAlgorithm(algorithm) { if (typeof algorithm !== 'object' || algorithm === null) return String(algorithm); const converted = {name: String(algorithm.name)}; + if (algorithm.expires !== undefined) converted.expires = enforceRange(algorithm.expires, 0, Number.MAX_SAFE_INTEGER); if (algorithm.namedCurve !== undefined) converted.namedCurve = String(algorithm.namedCurve); if (algorithm.modulusLength !== undefined) converted.modulusLength = enforceRange(algorithm.modulusLength, 0, 2 ** 32 - 1); if (algorithm.publicExponent !== undefined) converted.publicExponent = algorithm.publicExponent; @@ -377,7 +382,7 @@ 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}); + return pyJson('Algorithm', {name: converted.name, expires: converted.expires}); } class RTCIceCandidate extends Interface { @@ -444,7 +449,7 @@ if (init.iceServers === null) throw new TypeError('iceServers is not a sequence'); kwargs.ice_servers = Array.from(init.iceServers, toIceServer); } - callMethodWithKeywords(this, 'gather', [], kwargs); + callMethod(this, 'gather', pyModel('RTCIceGatherOptions', kwargs)); } start(remoteParameters, role = 'controlled') { @@ -563,8 +568,10 @@ const converted = toSendParameters(parameters); const {encodingOptions} = requireDictionary(options, 'RTCSetParameterOptions'); if (encodingOptions === undefined) return callAsyncMethod(this, 'set_parameters', converted); - const keyFrames = Array.from(encodingOptions, (option) => Boolean(option?.keyFrame)); - return callAsyncMethodWithKeywords(this, 'set_parameters', [converted], {key_frames: keyFrames}); + const encodingOptionsList = Array.from(encodingOptions, + (option) => pyModel('RTCEncodingOptions', {key_frame: Boolean(option?.keyFrame)})); + return callAsyncMethod(this, 'set_parameters', converted, + pyModel('RTCSetParameterOptions', {encoding_options: encodingOptionsList})); } async replaceTrack(track) { @@ -583,10 +590,19 @@ return callStatic('RTCRtpSender', 'get_capabilities', String(kind)); } } + const toTransform = (sframe) => (value) => { + if (value === null || value === undefined) return null; + if (!(value instanceof RTCRtpScriptTransform) && !(value instanceof sframe())) { + throw new TypeError(`transform: argument is not of type RTCRtpScriptTransform or ${sframe().name}`); + } + return toPy(value); + }; + defineAttributes(RTCRtpSender, [ ['track', 'track'], ['transport', 'transport'], ['dtmf', 'dtmf'], + ['transform', 'transform', toTransform(() => RTCRtpSFrameEncryptor)], ]); class RTCRtpReceiver extends Interface { @@ -605,6 +621,7 @@ ['transport', 'transport'], // a nullable DOMHighResTimeStamp ['jitterBufferTarget', 'jitter_buffer_target', (v) => (v === null ? null : Number(v))], + ['transform', 'transform', toTransform(() => RTCRtpSFrameDecryptor)], ]); class RTCRtpTransceiver extends Interface { @@ -623,7 +640,7 @@ requireMembers(e, 'RTCRtpHeaderExtensionCapability', ['uri']); return pyModel('RTCRtpHeaderExtensionCapability', { uri: String(e.uri), - ...(e.direction === undefined ? {} : {direction: pyEnum('TransceiverDirection', e.direction)}), + ...(e.direction === undefined ? {} : {direction: pyEnum('RTCRtpTransceiverDirection', e.direction)}), }); })); } @@ -632,9 +649,9 @@ ['mid', 'mid'], ['sender', 'sender'], ['receiver', 'receiver'], - ['stopped', 'stopped'], - ['direction', 'direction', (v) => pyEnum('TransceiverDirection', v)], + ['direction', 'direction', (v) => pyEnum('RTCRtpTransceiverDirection', v)], ['currentDirection', 'current_direction'], + ['stopped', 'stopped'], ]); class RTCDataChannel extends Interface { @@ -698,7 +715,7 @@ convertDictionary(e, 'RTCRtpEncodingParameters', ENCODING_PARAMETERS))); const TRANSCEIVER_INIT = { - direction: ['direction', (v) => pyEnum('TransceiverDirection', v)], + direction: ['direction', (v) => pyEnum('RTCRtpTransceiverDirection', v)], streams: ['streams', (v) => Array.from(v, toPy)], sendEncodings: ['send_encodings', (v) => toEncodings(v)], }; @@ -735,9 +752,7 @@ const toConfiguration = (configuration) => pyModel('RTCConfiguration', convertDictionary( requireDictionary(configuration, 'RTCConfiguration'), 'RTCConfiguration', CONFIGURATION)); - const ANSWER_OPTIONS = { - voiceActivityDetection: ['voice_activity_detection', Boolean], - }; + const ANSWER_OPTIONS = {}; const OFFER_OPTIONS = { ...ANSWER_OPTIONS, iceRestart: ['ice_restart', Boolean], @@ -765,13 +780,13 @@ async createOffer(options) { const kwargs = convertDictionary(requireDictionary(options, 'RTCOfferOptions'), 'RTCOfferOptions', OFFER_OPTIONS); - return callAsyncMethodWithKeywords(this, 'create_offer', [], kwargs); + return callAsyncMethod(this, 'create_offer', pyModel('RTCOfferOptions', kwargs)); } async createAnswer(options) { const kwargs = convertDictionary(requireDictionary(options, 'RTCAnswerOptions'), 'RTCAnswerOptions', ANSWER_OPTIONS); - return callAsyncMethodWithKeywords(this, 'create_answer', [], kwargs); + return callAsyncMethod(this, 'create_answer', pyModel('RTCAnswerOptions', kwargs)); } async setLocalDescription(description) { @@ -785,7 +800,7 @@ addTrack(track, ...streams) { requireInterface(track, MediaStreamTrack, 'RTCPeerConnection.addTrack'); streams.forEach((stream) => requireInterface(stream, MediaStream, 'RTCPeerConnection.addTrack')); - return callMethod(this, 'add_track', track, streams.length ? streams : null); + return callMethod(this, 'add_track', track, ...streams); } addTransceiver(trackOrKind, init) { @@ -831,11 +846,7 @@ static async generateCertificate(algorithm) { requireArguments(arguments, 1, 'RTCPeerConnection.generateCertificate'); - const args = [toAlgorithm(algorithm)]; - if (typeof algorithm === 'object' && algorithm !== null && algorithm.expires !== undefined) { - args.push(enforceRange(algorithm.expires, 0, Number.MAX_SAFE_INTEGER)); - } - return unwrap(await bridge.call_async_static('RTCPeerConnection', 'generate_certificate', args)); + return unwrap(await bridge.call_async_static('RTCPeerConnection', 'generate_certificate', [toAlgorithm(algorithm)])); } getConfiguration() { return callMethod(this, 'get_configuration'); } @@ -917,27 +928,54 @@ MediaStreamTrackEvent: defineEvent('MediaStreamTrackEvent', ['track']), RTCDTMFToneChangeEvent: defineEvent('RTCDTMFToneChangeEvent', [], {tone: ''}), MessageEvent: defineEvent('MessageEvent', [], {data: null, origin: '', lastEventId: '', source: null, ports: []}), + RTCTransformEvent: defineEvent('RTCTransformEvent', ['transformer'], {}, {transformer: () => RTCRtpScriptTransformer}), + SFrameTransformErrorEvent: defineEvent('SFrameTransformErrorEvent', ['errorType', 'frame'], {keyID: null}), + KeyFrameRequestEvent: class KeyFrameRequestEvent extends Event { + constructor(type, rid) { + requireArguments(arguments, 1, 'KeyFrameRequestEvent'); + super(String(type)); + const value = typeof rid === 'object' && rid !== null ? rid.rid : rid; + this.rid = value === undefined || value === null ? null : String(value); + } + }, }; // Streams, frames, processors and generators of python-webrtc. Streams are the Python ones: scripts read and // write them, but can't construct them with a JS underlying source. class ReadableStream extends Interface { getReader(options) { - if (options?.mode !== undefined) throw new TypeError('ReadableStream.getReader: only default readers exist'); - return callMethod(this, 'get_reader'); + const dict = requireDictionary(options, 'ReadableStreamGetReaderOptions'); + return callMethod(this, 'get_reader', pyJson('ReadableStreamGetReaderOptions', pick(dict, ['mode']))); } cancel() { return callAsyncMethod(this, 'cancel'); } - pipeTo(destination, options = {}) { + pipeTo(destination, options) { requireInterface(destination, WritableStream, 'ReadableStream.pipeTo'); - return callAsyncMethodWithKeywords(this, 'pipe_to', [destination], { - prevent_close: Boolean(options.preventClose), - prevent_abort: Boolean(options.preventAbort), - prevent_cancel: Boolean(options.preventCancel), - }); + const dict = requireDictionary(options, 'StreamPipeOptions'); + return callAsyncMethod(this, 'pipe_to', destination, pyJson('StreamPipeOptions', { + preventClose: Boolean(dict.preventClose), + preventAbort: Boolean(dict.preventAbort), + preventCancel: Boolean(dict.preventCancel), + })); + } + + pipeThrough(transform, options) { + const pair = requireMembers(transform, 'ReadableWritablePair', ['readable', 'writable']); + requireInterface(pair.readable, ReadableStream, 'ReadableStream.pipeThrough'); + requireInterface(pair.writable, WritableStream, 'ReadableStream.pipeThrough'); + const dict = requireDictionary(options, 'StreamPipeOptions'); + return callMethod(this, 'pipe_through', pyModel('ReadableWritablePair', { + readable: toPy(pair.readable), writable: toPy(pair.writable), + }), pyJson('StreamPipeOptions', { + preventClose: Boolean(dict.preventClose), + preventAbort: Boolean(dict.preventAbort), + preventCancel: Boolean(dict.preventCancel), + })); } + tee() { return callMethod(this, 'tee'); } + async* [Symbol.asyncIterator]() { const reader = this.getReader(); try { @@ -993,13 +1031,13 @@ }; const VIDEO_FRAME_BUFFER_INIT = [ 'format', 'codedWidth', 'codedHeight', 'timestamp', 'duration', 'layout', 'visibleRect', 'rotation', 'flip', - 'displayWidth', 'displayHeight', 'colorSpace', + 'displayWidth', 'displayHeight', 'colorSpace', 'metadata', ]; const VIDEO_FRAME_INIT = [ - 'duration', 'timestamp', 'alpha', 'visibleRect', 'rotation', 'flip', 'displayWidth', 'displayHeight', + 'duration', 'timestamp', 'alpha', 'visibleRect', 'rotation', 'flip', 'displayWidth', 'displayHeight', 'metadata', ]; const copyToOptions = (options) => - pyJson('VideoFrameCopyToOptions', nested(pick(requireDictionary(options, 'VideoFrameCopyToOptions'), ['rect', 'layout', 'format']))); + pyJson('VideoFrameCopyToOptions', nested(pick(requireDictionary(options, 'VideoFrameCopyToOptions'), ['rect', 'layout', 'format', 'colorSpace']))); class VideoFrame extends Interface { constructor(...args) { @@ -1008,11 +1046,11 @@ const dict = requireDictionary(init, 'VideoFrameInit'); if (image instanceof VideoFrame) { const init = pyJson('VideoFrameInit', nested(pick(dict, VIDEO_FRAME_INIT))); - return construct('VideoFrame', {source: toPy(image), init}); + return construct('VideoFrame', {init}, [toPy(image)]); } if (image instanceof ArrayBuffer || ArrayBuffer.isView(image)) { const init = pyJson('VideoFrameBufferInit', nested(pick(dict, VIDEO_FRAME_BUFFER_INIT))); - return construct('VideoFrame', {source: bytesOf(image), init}); + return construct('VideoFrame', {init}, [bytesOf(image)]); } // images, canvases and video elements are the browser's throw new TypeError('VideoFrame: the source is not a VideoFrame nor a BufferSource'); @@ -1146,6 +1184,168 @@ } defineAttributes(MediaStreamTrackGenerator, [['writable', 'writable']]); + // WebRTC Encoded Transform: the worker is a Worker of polyfills.js + const transformerOptions = new WeakMap(); + + class RTCRtpScriptTransform extends Interface { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, (workerOrWorkerAndParameters, options, transfer) => { + requireArguments(args, 1, 'RTCRtpScriptTransform'); + let worker = workerOrWorkerAndParameters; + let type; + if (!(worker instanceof Worker)) { + const dict = requireMembers(worker, 'WorkerAndParameters', ['worker']); + worker = requireInterface(dict.worker, Worker, 'WorkerAndParameters.worker'); + type = dict.type; + } + const deliver = (pyEvent) => { + const event = unwrap(bridge.wrap(pyEvent)); + transformerOptions.set(event.transformer, options); + worker.__dispatchInScope(event); + }; + const pyWorker = type === undefined + ? deliver + : pyModel('WorkerAndParameters', {worker: deliver, type: pyEnum('RTCRtpScriptTransformType', type)}); + const transferList = transfer === undefined ? null : Array.from(transfer, () => ({})); + return construct('RTCRtpScriptTransform', {}, [pyWorker, null, transferList]); + })); + } + } + + class RTCRtpScriptTransformer extends Interface { + get options() { return transformerOptions.get(this); } + + generateKeyFrame(rid) { + return callAsyncMethod(this, 'generate_key_frame', rid === undefined ? null : String(rid)).then(() => undefined); + } + + sendKeyFrameRequest() { return callAsyncMethod(this, 'send_key_frame_request').then(() => undefined); } + } + defineAttributes(RTCRtpScriptTransformer, [ + ['readable', 'readable'], + ['writable', 'writable'], + ]); + defineEventHandlers(RTCRtpScriptTransformer, ['keyframerequest']); + + // shares the memory of the library's bytearray, the same ArrayBuffer while that bytearray is + const frameBuffers = new WeakMap(); + + class EncodedFrame extends Interface { + get data() { + const result = bridge.get_buffer(pyObjects.get(this), 'data'); + if (result.error) throw toJsError(result.error); + const [id, bytes, detached] = result.ok; + const cached = frameBuffers.get(this); + if (cached?.id === id) return cached.buffer; + let {buffer} = bytes; + if (detached) { + buffer = new ArrayBuffer(0); + buffer.transfer(); + } + frameBuffers.set(this, {id, buffer}); + return buffer; + } + + set data(value) { + if (!(value instanceof ArrayBuffer) || value.resizable) throw new TypeError('data is not an ArrayBuffer'); + setAttr(this, 'data', value); + } + + getMetadata() { return callMethod(this, 'get_metadata'); } + + // [Serializable]: a copy of the frame (see structuredClone in polyfills.js) + [Symbol.for('wpt.serialize')]() { return new this.constructor(this); } + } + + const frameConstructor = (name, optionsName) => function (args) { + const [originalFrame, options] = args; + requireArguments(args, 1, name); + requireInterface(originalFrame, this, name); + const dict = requireDictionary(options, optionsName); + const pyOptions = dict.metadata === undefined ? null : pyJson(optionsName, {metadata: requireDictionary(dict.metadata, 'metadata')}); + return construct(name, {}, [toPy(originalFrame), pyOptions]); + }; + + class RTCEncodedVideoFrame extends EncodedFrame { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => frameConstructor('RTCEncodedVideoFrame', 'RTCEncodedVideoFrameOptions').call(RTCEncodedVideoFrame, args))); + } + } + defineAttributes(RTCEncodedVideoFrame, [['type', 'type']]); + + class RTCEncodedAudioFrame extends EncodedFrame { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => frameConstructor('RTCEncodedAudioFrame', 'RTCEncodedAudioFrameOptions').call(RTCEncodedAudioFrame, args))); + } + } + + function keyBytes(key) { + const data = key?.[Symbol.for('wpt.keyData')]; + if (!(data instanceof Uint8Array)) throw new TypeError('key is not a CryptoKey'); + return data; + } + + // CryptoKeyID, (SmallCryptoKeyID or bigint): the library checks the range of a bigint + const toKeyID = (value) => (typeof value === 'bigint' ? value : enforceRange(value, 0, Number.MAX_SAFE_INTEGER)); + + const sframeOptions = (args, name, dictName, members) => { + requireArguments(args, 1, name); + return pyJson(dictName, pick(requireMembers(args[0], dictName, ['cipherSuite']), members)); + }; + + const encryptorManager = { + async setEncryptionKey(key, keyId) { + requireArguments(arguments, 2, 'setEncryptionKey'); + return callAsyncMethod(this, 'set_encryption_key', keyBytes(key), toKeyID(keyId)).then(() => undefined); + }, + }; + const decryptorManager = { + async addDecryptionKey(key, keyId) { + requireArguments(arguments, 2, 'addDecryptionKey'); + return callAsyncMethod(this, 'add_decryption_key', keyBytes(key), toKeyID(keyId)).then(() => undefined); + }, + async removeDecryptionKey(keyId) { + requireArguments(arguments, 1, 'removeDecryptionKey'); + return callAsyncMethod(this, 'remove_decryption_key', toKeyID(keyId)).then(() => undefined); + }, + }; + + class RTCRtpSFrameEncryptor extends Interface { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => construct('RTCRtpSFrameEncryptor', {}, [ + sframeOptions(args, 'RTCRtpSFrameEncryptor', 'RTCRtpSFrameEncryptorOptions', ['cipherSuite', 'type'])]))); + } + } + Object.assign(RTCRtpSFrameEncryptor.prototype, encryptorManager); + + class RTCRtpSFrameDecryptor extends Interface { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => construct('RTCRtpSFrameDecryptor', {}, [ + sframeOptions(args, 'RTCRtpSFrameDecryptor', 'SFrameTransformOptions', ['cipherSuite'])]))); + } + } + Object.assign(RTCRtpSFrameDecryptor.prototype, decryptorManager); + defineEventHandlers(RTCRtpSFrameDecryptor, ['error']); + + class SFrameEncryptorStream extends Interface { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => construct('SFrameEncryptorStream', {}, [ + sframeOptions(args, 'SFrameEncryptorStream', 'SFrameTransformOptions', ['cipherSuite'])]))); + } + } + Object.assign(SFrameEncryptorStream.prototype, encryptorManager); + defineAttributes(SFrameEncryptorStream, [['readable', 'readable'], ['writable', 'writable']]); + + class SFrameDecryptorStream extends Interface { + constructor(...args) { + super(INTERNAL, pyObjectOf(args, () => construct('SFrameDecryptorStream', {}, [ + sframeOptions(args, 'SFrameDecryptorStream', 'SFrameTransformOptions', ['cipherSuite'])]))); + } + } + Object.assign(SFrameDecryptorStream.prototype, decryptorManager); + defineAttributes(SFrameDecryptorStream, [['readable', 'readable'], ['writable', 'writable']]); + defineEventHandlers(SFrameDecryptorStream, ['error']); + class OverconstrainedError extends DOMException { constructor(constraint, message = '') { super(message, 'OverconstrainedError'); @@ -1178,8 +1378,23 @@ MediaStreamTrackProcessor, VideoTrackGenerator, MediaStreamTrackGenerator, + RTCRtpScriptTransform, + RTCRtpScriptTransformer, + RTCEncodedVideoFrame, + RTCEncodedAudioFrame, + RTCRtpSFrameEncryptor, + RTCRtpSFrameDecryptor, + SFrameEncryptorStream, + SFrameDecryptorStream, }; Object.assign(globalThis, interfaces, {OverconstrainedError}); + // the names WebKit shipped SFrame with, which WPT tests use + Object.assign(globalThis, { + RTCRtpSFrameEncrypter: RTCRtpSFrameEncryptor, + RTCRtpSFrameDecrypter: RTCRtpSFrameDecryptor, + SFrameEncrypterStream: SFrameEncryptorStream, + SFrameDecrypterStream: SFrameDecryptorStream, + }); const {Event: _, ...eventInterfaces} = events; Object.assign(globalThis, eventInterfaces, {RTCError, RTCStatsReport}); @@ -1194,11 +1409,11 @@ globalThis.navigator = { mediaDevices: { async getUserMedia(constraints = {}) { - const kwargs = {audio: Boolean(constraints.audio), video: Boolean(constraints.video)}; - if (typeof constraints.video === 'object' && constraints.video !== null) { - Object.assign(kwargs, convertDictionary(constraints.video, 'MediaTrackConstraints', VIDEO_CONSTRAINTS)); - } - return unwrap(bridge.get_user_media(kwargs)); + const video = typeof constraints.video === 'object' && constraints.video !== null + ? pyModel('MediaTrackConstraints', convertDictionary(constraints.video, 'MediaTrackConstraints', VIDEO_CONSTRAINTS)) + : Boolean(constraints.video); + return unwrap(await bridge.get_user_media( + pyModel('MediaStreamConstraints', {audio: Boolean(constraints.audio), video}))); }, }, };