sora-sdk 2026.2.0.dev1__tar.gz → 2026.2.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {sora_sdk-2026.2.0.dev1/src/sora_sdk.egg-info → sora_sdk-2026.2.0.dev3}/PKG-INFO +1 -1
  2. sora_sdk-2026.2.0.dev3/VERSION +1 -0
  3. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/pyproject.toml +1 -1
  4. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/setup.py +6 -1
  5. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk/sora_sdk_ext.cpython-312-darwin.so +0 -0
  6. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk/sora_sdk_ext.pyi +4 -0
  7. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3/src/sora_sdk.egg-info}/PKG-INFO +1 -1
  8. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk.egg-info/SOURCES.txt +1 -0
  9. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_amd_amf.py +20 -1
  10. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_field_trials.py +48 -10
  11. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_intel_vpl.py +20 -1
  12. sora_sdk-2026.2.0.dev3/tests/test_key_frame_request.py +131 -0
  13. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_nvidia_video_codec.py +21 -2
  14. sora_sdk-2026.2.0.dev3/tests/test_rpc.py +202 -0
  15. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_type_disconnect.py +1 -1
  16. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_type_switched.py +5 -1
  17. sora_sdk-2026.2.0.dev1/VERSION +0 -1
  18. sora_sdk-2026.2.0.dev1/tests/test_key_frame_request.py +0 -75
  19. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/LICENSE +0 -0
  20. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/MANIFEST.in +0 -0
  21. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/README.md +0 -0
  22. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/buildbase.py +0 -0
  23. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/pypath.py +0 -0
  24. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/run.py +0 -0
  25. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/setup.cfg +0 -0
  26. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk/__init__.py +0 -0
  27. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk/py.typed +0 -0
  28. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk.egg-info/dependency_links.txt +0 -0
  29. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/src/sora_sdk.egg-info/top_level.txt +0 -0
  30. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_apple_video_toolbox.py +0 -0
  31. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_audio_sink_callbacks.py +0 -0
  32. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_audio_sink_read_gil.py +0 -0
  33. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_audio_source_invalid_params.py +0 -0
  34. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_authz.py +0 -0
  35. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_authz_simulcast.py +0 -0
  36. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_ca_cert.py +0 -0
  37. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_capability.py +0 -0
  38. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_convert_json_value_int64.py +0 -0
  39. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_degradation_preference.py +0 -0
  40. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_disconnect_timeout.py +0 -0
  41. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_encoded_transform.py +0 -0
  42. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_messaging.py +0 -0
  43. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_messaging_header.py +0 -0
  44. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_ndarray_parent_reference.py +0 -0
  45. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_openh264.py +0 -0
  46. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_openh264_simulcast.py +0 -0
  47. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_opus.py +0 -0
  48. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_raspberry_pi.py +0 -0
  49. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_re_offer_re_answer_sdp.py +0 -0
  50. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_rtc_log_refcount.py +0 -0
  51. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_send_data_channel_get_stats_after_disconnect.py +0 -0
  52. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_sendonly_recvonly.py +0 -0
  53. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_signaling.py +0 -0
  54. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_signaling_message.py +0 -0
  55. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_signaling_notify.py +0 -0
  56. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_simulcast.py +0 -0
  57. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_sora_disconnect.py +0 -0
  58. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_vad.py +0 -0
  59. {sora_sdk-2026.2.0.dev1 → sora_sdk-2026.2.0.dev3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sora_sdk
3
- Version: 2026.2.0.dev1
3
+ Version: 2026.2.0.dev3
4
4
  Summary: WebRTC SFU Sora Python SDK
5
5
  Home-page: https://github.com/shiguredo/sora-python-sdk
6
6
  Author-email: "Shiguredo Inc." <contact+pypi@shiguredo.jp>
@@ -0,0 +1 @@
1
+ 2026.2.0.dev3
@@ -25,7 +25,7 @@ Source = "https://github.com/shiguredo/sora-python-sdk"
25
25
 
26
26
  [dependency-groups]
27
27
  dev = [
28
- "nanobind==3.0.1",
28
+ "nanobind==3.1.0",
29
29
  { include-group = "test" },
30
30
  ]
31
31
  test = [
@@ -46,7 +46,12 @@ def run_setup(build_platform, target_platform):
46
46
  def get_tag(self):
47
47
  _, _, plat2 = super().get_tag()
48
48
  impl = "cp" + sysconfig.get_config_var("py_version_nodot")
49
- return impl, impl, plat if plat is not None else plat2
49
+ abi = impl
50
+ # free-threaded ビルド (3.13t 以降) は ABI タグに t を付ける (例: cp313-cp313t)。
51
+ # interpreter タグは cp313 のままで、t は ABI タグにのみ付く
52
+ if sysconfig.get_config_var("Py_GIL_DISABLED"):
53
+ abi += "t"
54
+ return impl, abi, plat if plat is not None else plat2
50
55
 
51
56
  setup(
52
57
  version=version,
@@ -25,6 +25,8 @@ class SoraSignalingErrorCode(enum.IntEnum):
25
25
 
26
26
  ICE_FAILED = 8
27
27
 
28
+ DATACHANNEL_CLOSED = 9
29
+
28
30
  class SoraSignalingType(enum.IntEnum):
29
31
  WEBSOCKET = 0
30
32
 
@@ -183,6 +185,8 @@ class SoraConnection:
183
185
 
184
186
  def send_data_channel(self, label: str, data: bytes) -> bool: ...
185
187
 
188
+ def send_rpc(self, id: int | None, method: str, params: object | None = None) -> bool: ...
189
+
186
190
  def get_stats(self) -> str: ...
187
191
 
188
192
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sora_sdk
3
- Version: 2026.2.0.dev1
3
+ Version: 2026.2.0.dev3
4
4
  Summary: WebRTC SFU Sora Python SDK
5
5
  Home-page: https://github.com/shiguredo/sora-python-sdk
6
6
  Author-email: "Shiguredo Inc." <contact+pypi@shiguredo.jp>
@@ -40,6 +40,7 @@ tests/test_openh264_simulcast.py
40
40
  tests/test_opus.py
41
41
  tests/test_raspberry_pi.py
42
42
  tests/test_re_offer_re_answer_sdp.py
43
+ tests/test_rpc.py
43
44
  tests/test_rtc_log_refcount.py
44
45
  tests/test_send_data_channel_get_stats_after_disconnect.py
45
46
  tests/test_sendonly_recvonly.py
@@ -83,7 +83,25 @@ def test_amd_amf_key_frame_request(settings, video_codec_type):
83
83
  )
84
84
  sendonly.connect(fake_video=True)
85
85
 
86
- time.sleep(3)
86
+ # RequestKeyFrame はキーフレームを受け取る相手がいる配信者にだけ PLI を送るため、
87
+ # 同じチャンネルに視聴者 (recvonly) を 1 本用意して受信を開始するまで待つ。
88
+ # ハードウェアエンコーダの映像はハードウェアデコーダを指定しないと受信できない
89
+ recvonly = SoraClient(
90
+ settings,
91
+ SoraRole.RECVONLY,
92
+ audio=False,
93
+ video=True,
94
+ video_codec_preference=SoraVideoCodecPreference(
95
+ codecs=[
96
+ SoraVideoCodecPreference.Codec(
97
+ type=codec_type_string_to_codec_type(video_codec_type),
98
+ decoder=SoraVideoCodecImplementation.AMD_AMF,
99
+ ),
100
+ ]
101
+ ),
102
+ )
103
+ recvonly.connect()
104
+ recvonly.wait_video_received()
87
105
 
88
106
  assert sendonly.connection_id is not None
89
107
 
@@ -100,6 +118,7 @@ def test_amd_amf_key_frame_request(settings, video_codec_type):
100
118
  sendonly_stats = sendonly.get_stats()
101
119
 
102
120
  sendonly.disconnect()
121
+ recvonly.disconnect()
103
122
 
104
123
  # outbound-rtp が無かったら StopIteration 例外が上がる
105
124
  outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp")
@@ -64,6 +64,29 @@ def connect_sendonly(settings, field_trials: str | None) -> SoraClient:
64
64
  return sendonly
65
65
 
66
66
 
67
+ def connect_recvonly(settings, simulcast_request_rid: str) -> SoraClient:
68
+ """
69
+ 同じチャンネルに視聴者 (recvonly) を接続する。
70
+
71
+ RequestKeyFrame はキーフレームを受け取る相手がいる配信者にだけ、その相手が
72
+ 受信している rid へ PLI を送るため、視聴者が受信する rid を明示しておく。
73
+ 視聴者の downstream が確定するまで固定で 5 秒待ってから返す (条件待ちはしていない)。
74
+ """
75
+ recvonly = SoraClient(
76
+ settings,
77
+ SoraRole.RECVONLY,
78
+ audio=False,
79
+ video=True,
80
+ simulcast_request_rid=simulcast_request_rid,
81
+ )
82
+ recvonly.connect()
83
+
84
+ # 視聴者の downstream が確定するまで待つ
85
+ recvonly.wait_video_received()
86
+
87
+ return recvonly
88
+
89
+
67
90
  def test_field_trials(settings):
68
91
  """
69
92
  WebRTC-Video-PerSsrcKeyframes を指定した Sora でキーフレーム要求が動作すること。
@@ -74,7 +97,7 @@ def test_field_trials(settings):
74
97
 
75
98
  期待:
76
99
  - 不正な文字列では RuntimeError になる
77
- - キーフレーム要求 API の呼び出しで有効な全レイヤのキーフレーム数が増える
100
+ - キーフレーム要求 API の呼び出しで PLI が届いた rid のキーフレーム数が増える
78
101
  """
79
102
  # 文字列の末尾に "/" が無い場合は不正なフィールドトライアル文字列として扱われる
80
103
  with pytest.raises(RuntimeError):
@@ -84,37 +107,50 @@ def test_field_trials(settings):
84
107
  )
85
108
 
86
109
  sendonly = connect_sendonly(settings, "WebRTC-Video-PerSsrcKeyframes/Enabled/")
110
+ recvonly = connect_recvonly(settings, "r0")
87
111
 
88
112
  # connection_id は None になり得るため、API 呼び出しに使う前に絞り込む
89
113
  assert sendonly.connection_id is not None
90
114
 
91
- before = get_key_frames_encoded(get_active_video_stats_by_rid(sendonly))
115
+ before_stats = get_active_video_stats_by_rid(sendonly)
116
+ before = get_key_frames_encoded(before_stats)
92
117
  print("キーフレーム要求前のキーフレーム数:", before)
93
118
 
94
119
  response = request_key_frame_api(settings.api_url, sendonly.channel_id, sendonly.connection_id)
95
120
 
96
121
  time.sleep(5)
97
122
 
98
- after = get_key_frames_encoded(get_active_video_stats_by_rid(sendonly))
123
+ after_stats = get_active_video_stats_by_rid(sendonly)
124
+ after = get_key_frames_encoded(after_stats)
99
125
  print("キーフレーム要求後のキーフレーム数:", after)
100
126
 
101
127
  sendonly.disconnect()
128
+ recvonly.disconnect()
102
129
 
103
130
  # 接続を閉じてから確認する。ここで失敗しても接続が残らない
104
131
  assert response.status_code == 200, response.text
105
132
 
106
133
  # サイマルキャストの全レイヤが有効になっていること
107
- assert len(after) == 3
108
- # キーフレーム要求により有効な全レイヤのキーフレーム数が増えていること
109
- for rid, count in before.items():
110
- assert after.get(rid, 0) > count, f"rid={rid} のキーフレーム数が増えていない"
134
+ assert len(after_stats) == 3
135
+ # rid を指定しない要求は視聴者が受信している rid に PLI を送る。視聴者が受信している
136
+ # rid は視聴環境によって変わり得るため、PLI が届いた rid を対象にキーフレーム数を確認する
137
+ increased_rids = [
138
+ rid
139
+ for rid in sorted(before_stats)
140
+ if after_stats[rid]["pliCount"] > before_stats[rid]["pliCount"]
141
+ ]
142
+ assert increased_rids, "キーフレーム要求で PLI が届いていない"
143
+ for rid in increased_rids:
144
+ assert after[rid] > before[rid], (
145
+ f"rid={rid} のキーフレーム数が増えていない: before={before[rid]}, after={after[rid]}"
146
+ )
111
147
 
112
148
 
113
149
  @pytest.mark.parametrize(
114
150
  ("field_trials", "increased_rids"),
115
151
  [
116
152
  # フィールドトライアル有効時は PLI を受けた rid のレイヤだけがキーフレームを生成する
117
- ("WebRTC-Video-PerSsrcKeyframes/Enabled/", {"r2"}),
153
+ ("WebRTC-Video-PerSsrcKeyframes/Enabled/", {"r0"}),
118
154
  # フィールドトライアル無効時はどの rid への PLI でも全レイヤがキーフレームを生成する
119
155
  (None, {"r0", "r1", "r2"}),
120
156
  ],
@@ -132,7 +168,7 @@ def test_per_ssrc_keyframes(settings, field_trials, increased_rids):
132
168
  - RequestKeyFrame API の rid 指定は Sora 2026.2.0-canary.15 以降で利用できる
133
169
 
134
170
  期待:
135
- - フィールドトライアル有効: rid=r2 のキーフレーム要求で r2 のみキーフレーム数が増える
171
+ - フィールドトライアル有効: rid=r0 のキーフレーム要求で r0 のみキーフレーム数が増える
136
172
  - フィールドトライアル無効: r0 / r1 / r2 すべてのキーフレーム数が増える
137
173
 
138
174
  前提が崩れた場合:
@@ -141,6 +177,7 @@ def test_per_ssrc_keyframes(settings, field_trials, increased_rids):
141
177
  いない場合は skip する
142
178
  """
143
179
  sendonly = connect_sendonly(settings, field_trials)
180
+ recvonly = connect_recvonly(settings, "r0")
144
181
 
145
182
  # connection_id は None になり得るため、API 呼び出しに使う前に絞り込む
146
183
  assert sendonly.connection_id is not None
@@ -151,7 +188,7 @@ def test_per_ssrc_keyframes(settings, field_trials, increased_rids):
151
188
 
152
189
  # rid を指定すると、その rid の SSRC にのみ PLI が送られる
153
190
  response = request_key_frame_api(
154
- settings.api_url, sendonly.channel_id, sendonly.connection_id, rid="r2"
191
+ settings.api_url, sendonly.channel_id, sendonly.connection_id, rid="r0"
155
192
  )
156
193
 
157
194
  # Sora はキーフレームを受け取るまで 1 秒間隔で PLI を再送するため、再送が終わるまで待つ
@@ -162,6 +199,7 @@ def test_per_ssrc_keyframes(settings, field_trials, increased_rids):
162
199
  print("キーフレーム要求後のキーフレーム数:", key_frames_after)
163
200
 
164
201
  sendonly.disconnect()
202
+ recvonly.disconnect()
165
203
 
166
204
  # 接続を閉じてから確認する。ここで失敗しても接続が残らない
167
205
  assert response.status_code == 200, response.text
@@ -91,7 +91,25 @@ def test_intel_vpl_key_frame_request(settings, video_codec_type):
91
91
  )
92
92
  sendonly.connect(fake_video=True)
93
93
 
94
- time.sleep(3)
94
+ # RequestKeyFrame はキーフレームを受け取る相手がいる配信者にだけ PLI を送るため、
95
+ # 同じチャンネルに視聴者 (recvonly) を 1 本用意して受信を開始するまで待つ。
96
+ # ハードウェアエンコーダの映像はハードウェアデコーダを指定しないと受信できない
97
+ recvonly = SoraClient(
98
+ settings,
99
+ SoraRole.RECVONLY,
100
+ audio=False,
101
+ video=True,
102
+ video_codec_preference=SoraVideoCodecPreference(
103
+ codecs=[
104
+ SoraVideoCodecPreference.Codec(
105
+ type=codec_type_string_to_codec_type(video_codec_type),
106
+ decoder=SoraVideoCodecImplementation.INTEL_VPL,
107
+ ),
108
+ ]
109
+ ),
110
+ )
111
+ recvonly.connect()
112
+ recvonly.wait_video_received()
95
113
 
96
114
  assert sendonly.connection_id is not None
97
115
 
@@ -108,6 +126,7 @@ def test_intel_vpl_key_frame_request(settings, video_codec_type):
108
126
  sendonly_stats = sendonly.get_stats()
109
127
 
110
128
  sendonly.disconnect()
129
+ recvonly.disconnect()
111
130
 
112
131
  # outbound-rtp が無かったら StopIteration 例外が上がる
113
132
  outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp")
@@ -0,0 +1,131 @@
1
+ import time
2
+
3
+ import pytest
4
+ from api import request_key_frame_api
5
+ from client import (
6
+ SoraClient,
7
+ SoraRole,
8
+ codec_type_string_to_codec_type,
9
+ )
10
+
11
+ from sora_sdk import (
12
+ SoraVideoCodecImplementation,
13
+ SoraVideoCodecPreference,
14
+ )
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "video_codec_type",
19
+ [
20
+ "VP9",
21
+ "VP8",
22
+ "AV1",
23
+ ],
24
+ )
25
+ def test_key_frame_request(settings, video_codec_type):
26
+ sendonly = SoraClient(
27
+ settings,
28
+ SoraRole.SENDONLY,
29
+ audio=False,
30
+ video=True,
31
+ video_codec_type=video_codec_type,
32
+ video_codec_preference=SoraVideoCodecPreference(
33
+ codecs=[
34
+ SoraVideoCodecPreference.Codec(
35
+ type=codec_type_string_to_codec_type(video_codec_type),
36
+ encoder=SoraVideoCodecImplementation.INTERNAL,
37
+ ),
38
+ ]
39
+ ),
40
+ )
41
+ sendonly.connect(fake_video=True)
42
+
43
+ # RequestKeyFrame はキーフレームを受け取る相手がいる配信者にだけ PLI を送るため、
44
+ # 同じチャンネルに視聴者 (recvonly) を 1 本用意して受信を開始するまで待つ
45
+ recvonly = SoraClient(settings, SoraRole.RECVONLY, audio=False, video=True)
46
+ recvonly.connect()
47
+ recvonly.wait_video_received()
48
+
49
+ assert sendonly.connection_id is not None
50
+
51
+ # キーフレーム要求 API を 3 秒間隔で 3 回呼び出す
52
+ api_count = 3
53
+ for _ in range(api_count):
54
+ response = request_key_frame_api(
55
+ settings.api_url, sendonly.channel_id, sendonly.connection_id
56
+ )
57
+ assert response.status_code == 200
58
+ time.sleep(3)
59
+
60
+ # 統計を取得する
61
+ sendonly_stats = sendonly.get_stats()
62
+
63
+ sendonly.disconnect()
64
+ recvonly.disconnect()
65
+
66
+ # outbound-rtp が無かったら StopIteration 例外が上がる
67
+ outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp")
68
+
69
+ # 3 回以上
70
+ assert outbound_rtp_stats["keyFramesEncoded"] > api_count
71
+ assert outbound_rtp_stats["pliCount"] >= api_count
72
+ print("keyFramesEncoded:", outbound_rtp_stats["keyFramesEncoded"])
73
+ print("pliCount:", outbound_rtp_stats["pliCount"])
74
+
75
+ # PLI カウントの 50% 以上がキーフレームとしてエンコードされることを確認
76
+ assert outbound_rtp_stats["keyFramesEncoded"] >= outbound_rtp_stats["pliCount"] * 0.7
77
+ print(
78
+ "keyFramesEncoded >= pliCount * 0.7:",
79
+ outbound_rtp_stats["keyFramesEncoded"] >= outbound_rtp_stats["pliCount"] * 0.7,
80
+ )
81
+
82
+
83
+ def video_outbound_rtp(stats):
84
+ """outbound-rtp の映像統計を取り出す。無ければ StopIteration 例外が上がる。"""
85
+ return next(s for s in stats if s.get("type") == "outbound-rtp" and s.get("kind") == "video")
86
+
87
+
88
+ def test_key_frame_request_without_consumer(settings):
89
+ """
90
+ キーフレームを受け取る相手がいない配信者へのキーフレーム要求では PLI が送られないこと。
91
+
92
+ 前提:
93
+ - RequestKeyFrame API はキーフレームを受け取る相手 (視聴者、クラスターのリレー、
94
+ 録画、 RTP 転送) がいない配信者には PLI を送らない
95
+ - 送らない場合も API は成功を返す
96
+
97
+ 期待:
98
+ - 視聴者がいない状態で API を呼ぶと 200 が返る
99
+ - PLI が送られないため pliCount が増えない
100
+ """
101
+ sendonly = SoraClient(
102
+ settings,
103
+ SoraRole.SENDONLY,
104
+ audio=False,
105
+ video=True,
106
+ video_codec_type="VP8",
107
+ )
108
+ sendonly.connect(fake_video=True)
109
+
110
+ # 視聴者を用意せず、録画と RTP 転送も使わない状態にする
111
+ time.sleep(5)
112
+
113
+ assert sendonly.connection_id is not None
114
+
115
+ stats_before = video_outbound_rtp(sendonly.get_stats())
116
+
117
+ response = request_key_frame_api(settings.api_url, sendonly.channel_id, sendonly.connection_id)
118
+
119
+ # Sora はキーフレームを受け取るまで 1 秒間隔で最大 5 回 PLI を再送するため、その間待つ
120
+ time.sleep(5)
121
+
122
+ stats_after = video_outbound_rtp(sendonly.get_stats())
123
+
124
+ sendonly.disconnect()
125
+
126
+ # 相手がいない場合も API は成功を返す
127
+ assert response.status_code == 200, response.text
128
+ # キーフレームを受け取る相手がいないため PLI は送られない
129
+ assert stats_after["pliCount"] == stats_before["pliCount"], (
130
+ f"pliCount が増えている: before={stats_before['pliCount']}, after={stats_after['pliCount']}"
131
+ )
@@ -66,7 +66,7 @@ def test_nvidia_video_codec_sdk_available():
66
66
  "H265",
67
67
  ],
68
68
  )
69
- def test_intel_vpl_key_frame_request(settings, video_codec_type):
69
+ def test_nvidia_video_codec_key_frame_request(settings, video_codec_type):
70
70
  sendonly = SoraClient(
71
71
  settings,
72
72
  SoraRole.SENDONLY,
@@ -84,7 +84,25 @@ def test_intel_vpl_key_frame_request(settings, video_codec_type):
84
84
  )
85
85
  sendonly.connect(fake_video=True)
86
86
 
87
- time.sleep(3)
87
+ # RequestKeyFrame はキーフレームを受け取る相手がいる配信者にだけ PLI を送るため、
88
+ # 同じチャンネルに視聴者 (recvonly) を 1 本用意して受信を開始するまで待つ。
89
+ # ハードウェアエンコーダの映像はハードウェアデコーダを指定しないと受信できない
90
+ recvonly = SoraClient(
91
+ settings,
92
+ SoraRole.RECVONLY,
93
+ audio=False,
94
+ video=True,
95
+ video_codec_preference=SoraVideoCodecPreference(
96
+ codecs=[
97
+ SoraVideoCodecPreference.Codec(
98
+ type=codec_type_string_to_codec_type(video_codec_type),
99
+ decoder=SoraVideoCodecImplementation.NVIDIA_VIDEO_CODEC_SDK,
100
+ ),
101
+ ]
102
+ ),
103
+ )
104
+ recvonly.connect()
105
+ recvonly.wait_video_received()
88
106
 
89
107
  assert sendonly.connection_id is not None
90
108
 
@@ -101,6 +119,7 @@ def test_intel_vpl_key_frame_request(settings, video_codec_type):
101
119
  sendonly_stats = sendonly.get_stats()
102
120
 
103
121
  sendonly.disconnect()
122
+ recvonly.disconnect()
104
123
 
105
124
  # outbound-rtp が無かったら StopIteration 例外が上がる
106
125
  outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp")
@@ -0,0 +1,202 @@
1
+ """
2
+ Sora の RPC 機能 (JSON-RPC 2.0 over DataChannel) の E2E テスト。
3
+
4
+ SoraConnection.send_rpc() が rpc ラベルへ JSON-RPC 2.0 のリクエストを送信し、
5
+ 応答が on_rpc に届くことを確認する。rpc ラベルは Sora の RPC 機能が有効な場合に
6
+ だけ offer に含まれるため、rpc ラベルを必要とするテストは含まれない環境では
7
+ 送信できないことを確認して skip する。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from queue import Empty
13
+ from typing import Any
14
+
15
+ import pytest
16
+ from client import SoraClient, SoraRole
17
+
18
+ NOT_EXIST_METHOD = "9999.0.0/not_exist"
19
+
20
+
21
+ def _create_rpc_client(settings) -> SoraClient:
22
+ """
23
+ RPC のテストで使うクライアントを生成する (Sora への接続は with 文で行う)。
24
+
25
+ rpc ラベルは DataChannel 経由のシグナリングでのみ使うため data_channel_signaling を
26
+ 有効にする。RPC は音声・映像とは独立して使えるため両方無効にするが、この構成では
27
+ ユーザー定義の DataChannel が 1 つも無いと Sora が接続メッセージを 4490
28
+ INVALID-MESSAGE で拒否するため、ダミーの #test ラベルを 1 つ用意する。
29
+ """
30
+ return SoraClient(
31
+ settings,
32
+ SoraRole.SENDONLY,
33
+ data_channel_signaling=True,
34
+ audio=False,
35
+ video=False,
36
+ data_channels=[{"label": "#test", "direction": "sendonly"}],
37
+ )
38
+
39
+
40
+ def test_send_rpc_request_and_response(settings):
41
+ """
42
+ send_rpc() で送信した JSON-RPC 2.0 のリクエストに応答が返ることを確認する。
43
+
44
+ 前提:
45
+ - RPC が有効な Sora に data_channel_signaling を有効にして接続する
46
+ - 払い出される rpc_methods に依存しないよう、存在しないメソッドを指定する
47
+
48
+ 期待:
49
+ - send_rpc() が True を返す
50
+ - 応答が on_rpc に届き、送信した id と一致する
51
+ - 存在しないメソッドなので error を含む応答になる
52
+ - params に None / Array を指定した場合と、params を省略した場合も送信できる
53
+ """
54
+ with _create_rpc_client(settings) as client:
55
+ if not client.wait_rpc_ready(timeout=5):
56
+ # rpc ラベルが使えない環境では送信されず False が返る
57
+ assert client.send_rpc(1, NOT_EXIST_METHOD, {"key": "value"}) is False
58
+ pytest.skip("rpc ラベルが使えない環境のため送信できない")
59
+
60
+ # 送信した id がそのまま応答に含まれることを確認する (0 と 2^64-1 は境界値)
61
+ for request_id in [0, 1, 2**64 - 1]:
62
+ assert client.send_rpc(request_id, NOT_EXIST_METHOD, {"key": "value"})
63
+
64
+ response = client.recv_rpc(timeout=5)
65
+ assert response["id"] == request_id
66
+ assert "error" in response
67
+
68
+ # params に None を指定した場合は params を含めずに送信する
69
+ assert client.send_rpc(2, NOT_EXIST_METHOD, None)
70
+
71
+ response = client.recv_rpc(timeout=5)
72
+ assert response["id"] == 2
73
+
74
+ # params には Object だけでなく Array も指定できる
75
+ assert client.send_rpc(3, NOT_EXIST_METHOD, [])
76
+
77
+ response = client.recv_rpc(timeout=5)
78
+ assert response["id"] == 3
79
+
80
+ # params を省略した 2 引数呼び出しではバインディングの既定値 (None) が使われる。
81
+ # ヘルパは常に params を渡すため、ここでは SDK を直接呼ぶ
82
+ assert client._connection.send_rpc(4, NOT_EXIST_METHOD)
83
+
84
+ response = client.recv_rpc(timeout=5)
85
+ assert response["id"] == 4
86
+
87
+
88
+ def test_send_rpc_notification_has_no_response(settings):
89
+ """
90
+ id に None を指定した Notification には Sora が応答を返さないことを確認する。
91
+
92
+ 前提:
93
+ - RPC が有効な Sora に接続する
94
+ - id に None を指定してリクエストを送る
95
+
96
+ 期待:
97
+ - send_rpc() が True を返す
98
+ - 応答が返らないため recv_rpc() が Empty でタイムアウトする
99
+ """
100
+ with _create_rpc_client(settings) as client:
101
+ if not client.wait_rpc_ready(timeout=5):
102
+ pytest.skip("rpc ラベルが使えない環境のため送信できない")
103
+
104
+ assert client.send_rpc(None, NOT_EXIST_METHOD, {"key": "value"})
105
+
106
+ # Notification には Sora が応答を返さない
107
+ with pytest.raises(Empty):
108
+ client.recv_rpc(timeout=3)
109
+
110
+
111
+ def test_send_rpc_invalid_params(settings):
112
+ """
113
+ params に送信できない値を指定したときの挙動を確認する。
114
+
115
+ JSON-RPC 2.0 の params は Object か Array でなければならないため、それ以外の値に
116
+ 変換される場合は送信せず False が返る。JSON の値として扱えない型の場合は既存の
117
+ Sora::ConvertJsonValue() と同じく TypeError、int64 の範囲を超える整数とキーが
118
+ 文字列でない dict は同じく RuntimeError になる。TypeError と RuntimeError は引数の
119
+ 変換時に発生するため rpc ラベルの有無に依存しない。False はラベルが無い場合も
120
+ Object / Array 以外の場合も返る。
121
+ """
122
+ with _create_rpc_client(settings) as client:
123
+ # 文字列・整数・浮動小数点数・真偽値は JSON の値には変換できるが params の要件を満たさない
124
+ for params in ["not-structured", 1, 1.5, True]:
125
+ assert client.send_rpc(1, NOT_EXIST_METHOD, params) is False
126
+
127
+ # JSON の値として扱えない型は TypeError になる
128
+ # 0.1 は float32 で厳密に表現できないため (Sora::ConvertJsonValue() の既知の制限)
129
+ for invalid_params in [
130
+ {"key": set()},
131
+ {"key": (1, 2)},
132
+ {"key": b"bytes"},
133
+ {"key": 0.1},
134
+ ]:
135
+ with pytest.raises(TypeError, match="Invalid JSON value in params"):
136
+ client.send_rpc(1, NOT_EXIST_METHOD, invalid_params)
137
+
138
+ # int64 の範囲を超える整数は nb::cast_error 由来の RuntimeError になる
139
+ with pytest.raises(RuntimeError):
140
+ client.send_rpc(1, NOT_EXIST_METHOD, {"key": 2**70})
141
+
142
+ # キーが文字列でない dict も nb::cast_error 由来の RuntimeError になる
143
+ with pytest.raises(RuntimeError):
144
+ client.send_rpc(1, NOT_EXIST_METHOD, {1: "value"})
145
+
146
+
147
+ def test_send_rpc_invalid_id(settings):
148
+ """
149
+ id に None でも 0 以上 2^64-1 以下の整数でもない値を指定すると TypeError になることを確認する。
150
+
151
+ C++ SDK の SendRpc() の id は std::optional<uint64_t> のため、文字列・負の整数・
152
+ 2^64 以上の整数は指定できない。
153
+ """
154
+ with _create_rpc_client(settings) as client:
155
+ # 型注釈 (int | None) では通せない値も実行時に渡すため、Any のリストを経由する
156
+ invalid_ids: list[Any] = ["id", -1, 2**64]
157
+ for invalid_id in invalid_ids:
158
+ with pytest.raises(TypeError):
159
+ client.send_rpc(invalid_id, NOT_EXIST_METHOD, {"key": "value"})
160
+
161
+
162
+ def test_send_rpc_without_rpc_label_returns_false(settings):
163
+ """
164
+ rpc ラベルが使えない接続では send_rpc() が送信せず False を返すことを確認する。
165
+
166
+ 前提:
167
+ - DataChannel 経由のシグナリングを使わずに接続する (offer の data_channels に
168
+ rpc ラベルが含まれない)
169
+
170
+ 期待:
171
+ - send_rpc() が送信せず False を返す
172
+ - Sora の RPC 機能の有効無効に依存せず検証できる
173
+ """
174
+ with SoraClient(
175
+ settings,
176
+ SoraRole.RECVONLY,
177
+ audio=True,
178
+ video=True,
179
+ data_channel_signaling=False,
180
+ ignore_disconnect_websocket=False,
181
+ ) as client:
182
+ assert client.send_rpc(1, NOT_EXIST_METHOD, {"key": "value"}) is False
183
+
184
+
185
+ def test_send_rpc_after_disconnect_raises_runtime_error(settings):
186
+ """
187
+ disconnect() 後の send_rpc() が SEGV ではなく RuntimeError になることを確認する。
188
+
189
+ 前提:
190
+ - Sora に接続してから disconnect() する
191
+ - disconnect() 後の SoraConnection インスタンスをそのまま使う
192
+
193
+ 期待:
194
+ - RuntimeError が発生する (メッセージに Already disconnected を含む)
195
+ - プロセスが SEGV で落ちない
196
+ """
197
+ with _create_rpc_client(settings) as client:
198
+ connection = client._connection
199
+ client.disconnect()
200
+
201
+ with pytest.raises(RuntimeError, match="Already disconnected"):
202
+ connection.send_rpc(1, NOT_EXIST_METHOD, {"key": "value"})
@@ -27,7 +27,7 @@ def test_websocket_signaling_only_disconnect(settings):
27
27
 
28
28
  assert conn.disconnect_code == SoraSignalingErrorCode.CLOSE_SUCCEEDED
29
29
  assert (
30
- conn.disconnect_reason == "Succeeded to close WebSocket (DC signaling is not enabled)"
30
+ conn.disconnect_reason == "Succeeded to close WebSocket (DC signaling is not available)"
31
31
  )
32
32
 
33
33
 
@@ -8,6 +8,8 @@ def test_websocket_signaling_only_type_switched(settings):
8
8
  """
9
9
  - WebSocket シグナリングのみ
10
10
  - type: switched 送られてこない
11
+ - signaling の redirect で張り直した WebSocket のクローズ (SELF-CLOSED) は
12
+ 接続中の WebSocket のクローズではないため ws_close には記録されない
11
13
  """
12
14
 
13
15
  with SoraClient(
@@ -29,6 +31,8 @@ def test_hybrid_signaling_type_switched(settings):
29
31
  """
30
32
  - WebSocket シグナリング + DataChannel シグナリング
31
33
  - type: switched 送られてくる
34
+ - signaling の redirect で張り直した WebSocket のクローズ (SELF-CLOSED) は
35
+ 接続中の WebSocket のクローズではないため ws_close には記録されない
32
36
  """
33
37
  with SoraClient(
34
38
  settings,
@@ -49,7 +53,7 @@ def test_datachannel_signaling_only_type_switched(settings):
49
53
  """
50
54
  - DataChannel シグナリングのみ
51
55
  - type: switched 送られてくる
52
- - Python SDK は WebSocket を自分で切断する
56
+ - Python SDK は WebSocket を自分で切断する (switched によるクローズは記録される)
53
57
  """
54
58
  with SoraClient(
55
59
  settings,
@@ -1 +0,0 @@
1
- 2026.2.0.dev1
@@ -1,75 +0,0 @@
1
- import time
2
-
3
- import pytest
4
- from api import request_key_frame_api
5
- from client import (
6
- SoraClient,
7
- SoraRole,
8
- codec_type_string_to_codec_type,
9
- )
10
-
11
- from sora_sdk import (
12
- SoraVideoCodecImplementation,
13
- SoraVideoCodecPreference,
14
- )
15
-
16
-
17
- @pytest.mark.parametrize(
18
- "video_codec_type",
19
- [
20
- "VP9",
21
- "VP8",
22
- "AV1",
23
- ],
24
- )
25
- def test_key_frame_request(settings, video_codec_type):
26
- sendonly = SoraClient(
27
- settings,
28
- SoraRole.SENDONLY,
29
- audio=False,
30
- video=True,
31
- video_codec_type=video_codec_type,
32
- video_codec_preference=SoraVideoCodecPreference(
33
- codecs=[
34
- SoraVideoCodecPreference.Codec(
35
- type=codec_type_string_to_codec_type(video_codec_type),
36
- encoder=SoraVideoCodecImplementation.INTERNAL,
37
- ),
38
- ]
39
- ),
40
- )
41
- sendonly.connect(fake_video=True)
42
-
43
- time.sleep(3)
44
-
45
- assert sendonly.connection_id is not None
46
-
47
- # キーフレーム要求 API を 3 秒間隔で 3 回呼び出す
48
- api_count = 3
49
- for _ in range(api_count):
50
- response = request_key_frame_api(
51
- settings.api_url, sendonly.channel_id, sendonly.connection_id
52
- )
53
- assert response.status_code == 200
54
- time.sleep(3)
55
-
56
- # 統計を取得する
57
- sendonly_stats = sendonly.get_stats()
58
-
59
- sendonly.disconnect()
60
-
61
- # outbound-rtp が無かったら StopIteration 例外が上がる
62
- outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp")
63
-
64
- # 3 回以上
65
- assert outbound_rtp_stats["keyFramesEncoded"] > api_count
66
- assert outbound_rtp_stats["pliCount"] >= api_count
67
- print("keyFramesEncoded:", outbound_rtp_stats["keyFramesEncoded"])
68
- print("pliCount:", outbound_rtp_stats["pliCount"])
69
-
70
- # PLI カウントの 50% 以上がキーフレームとしてエンコードされることを確認
71
- assert outbound_rtp_stats["keyFramesEncoded"] >= outbound_rtp_stats["pliCount"] * 0.7
72
- print(
73
- "keyFramesEncoded >= pliCount * 0.7:",
74
- outbound_rtp_stats["keyFramesEncoded"] >= outbound_rtp_stats["pliCount"] * 0.7,
75
- )