From 6093f90e4ac151939d51a9009228981424460636 Mon Sep 17 00:00:00 2001 From: voluntas Date: Wed, 10 Sep 2025 15:43:05 +0900 Subject: [PATCH 1/3] ruff format --- tests/test_intel_vpl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_intel_vpl.py b/tests/test_intel_vpl.py index 3cbf8a01..d6f9962e 100644 --- a/tests/test_intel_vpl.py +++ b/tests/test_intel_vpl.py @@ -602,6 +602,6 @@ def test_intel_vpl_av1_rtp_hdr_ext(settings): sendonly.disconnect() # AV1 の RTP ヘッダー拡張が送られてきていることを確認 - assert ( - stats["rtp_hdrext"]["total_received_rtp_hdrext_av1_rtp_sepc"] > 0 - ), "Dependency Descriptor RTP Header Extension が Python SDK から送られてきていません" + assert stats["rtp_hdrext"]["total_received_rtp_hdrext_av1_rtp_sepc"] > 0, ( + "Dependency Descriptor RTP Header Extension が Python SDK から送られてきていません" + ) From 3122d0a3fc1eb95da4869b71a74e260bfd20a544 Mon Sep 17 00:00:00 2001 From: voluntas Date: Wed, 10 Sep 2025 15:43:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?blend2d-py=20=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + uv.lock | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4f17f5a6..1bbb4bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev-dependencies = [ "pyjwt", "ruff", "ty", + "blend2d-py", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index 650483c2..5cab829f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -16,6 +16,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "blend2d-py" +version = "2025.1.0.dev8" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/30/795fb701468799229d640fed720e4775c77136b03ee1cee713cee4dd6a17/blend2d_py-2025.1.0.dev8-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:688c437b367bb06ed77a070a589858460b6210c42bafdacb3839448594b9233a", size = 701638, upload-time = "2025-09-10T05:39:01.42Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0d/200296be3fd406f60abbf1fb48a59e161458601f3cf6ecb40c5472be68f4/blend2d_py-2025.1.0.dev8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:bcaefa205a20de9c9652068d29f7cd408a75d17ccea623071bd085e3199d2218", size = 683386, upload-time = "2025-09-10T05:38:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/53/464e9099e8f99ca69eb38ce06bcae08f71fafa27363b3d633614a92da3be/blend2d_py-2025.1.0.dev8-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:fdb61d6b9f16abea0c654a5bd63935124b5fd149c59c8b7f41807999a425fe23", size = 986136, upload-time = "2025-09-10T05:38:58.577Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/f92bdf284350ba5fbaffea01f32f7594eb3f076051716d5955bbc3c17d03/blend2d_py-2025.1.0.dev8-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:fab0d9c8fd569ed428f63fe17102c1ccf783717e1d624f9ec78699f242727212", size = 1142273, upload-time = "2025-09-10T05:39:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cc/7da7b5d8460ade4126dd6e06a62b8e2f3eec3c34abaf80751265c65623c8/blend2d_py-2025.1.0.dev8-cp311-cp311-manylinux_2_39_aarch64.whl", hash = "sha256:7ff381f54b92e439cd8192e2a296b1f31b762076fd7f5f3672422489077bb7fc", size = 1010711, upload-time = "2025-09-10T05:42:07.71Z" }, + { url = "https://files.pythonhosted.org/packages/59/9a/cbdb005ada4f0b811cd9093b0aad64ea007a3feec23313dbeb375bd87c50/blend2d_py-2025.1.0.dev8-cp311-cp311-manylinux_2_39_x86_64.whl", hash = "sha256:daca8e5dd8cffe60fd6d617d3dac9b9176bacef0335856bbbffe35810a9cf8f6", size = 1217329, upload-time = "2025-09-10T05:38:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/339a98e0bbed9397ee2e38d862608faf2a5ed69fcef98675841e0a9ba451/blend2d_py-2025.1.0.dev8-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:93288518720f443930c2551930a8b9c9c55469d999306eaeafd219ebad6268d5", size = 701106, upload-time = "2025-09-10T05:38:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/72/d2/55d5857f9e8fc665431d1a888e42d64418d6777ae3e26fb6844f3c644dcc/blend2d_py-2025.1.0.dev8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:13ac1c0fb216ce719d09b72430c4702d632b928566b383b6790a5d7ea3d5b6ab", size = 682809, upload-time = "2025-09-10T05:38:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c1/39/0391014a537d44533e77e6e1f38fb9aac0362b20fa3d9d9f0b6c972d92fb/blend2d_py-2025.1.0.dev8-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:b8947256fc15652138ab3cfb232dfb083fed095afcebc42d050a6cac00f6b6b9", size = 985699, upload-time = "2025-09-10T05:39:00.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/70/3fc5d463eec102d65c7711e1fc32f6802ad3d3d12c0cee14c83b3262e855/blend2d_py-2025.1.0.dev8-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:fa1e63289a273421e490201114875f5757b941a40cd0bcf0296134dfbef9988b", size = 1141868, upload-time = "2025-09-10T05:42:10.061Z" }, + { url = "https://files.pythonhosted.org/packages/e5/dc/3b707ca50c0871870f75d6c8fbc6855f6d91dbe24fd77c6e3299a65e6760/blend2d_py-2025.1.0.dev8-cp312-cp312-manylinux_2_39_aarch64.whl", hash = "sha256:e464b6b7e2783c9d4925b5b4e3c259c3a7a0495f02c84e77587da0929840e6d3", size = 1009869, upload-time = "2025-09-10T05:39:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/76/43/398657c93cc110466ff74e0c869f129708a12b6d6a72f7f1a23c20a4e5ce/blend2d_py-2025.1.0.dev8-cp312-cp312-manylinux_2_39_x86_64.whl", hash = "sha256:3862724ebda2dcf2bd8d17fbb9ca5e6f24ba6d25a0d8b5bed144514179ffcb8c", size = 1216468, upload-time = "2025-09-10T05:38:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d5/d200b8c2cdb65fed5da66713795481277e9c92fbe0ec70e9023215a02c36/blend2d_py-2025.1.0.dev8-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f0f9a29d9edf413d090aaa620df1eb95a10c96cf9d950395acc7b791f47dcc17", size = 700963, upload-time = "2025-09-10T05:39:00.386Z" }, + { url = "https://files.pythonhosted.org/packages/65/9d/dcc1c7533f5ca1a4074fd9f72d6defd94b0941f243ebfdf2627da1450717/blend2d_py-2025.1.0.dev8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:0b88eebfba4dcab5a665c53b16dc6fc9fd851822955a52ea877f8f3262a855bb", size = 682784, upload-time = "2025-09-10T05:38:58.585Z" }, + { url = "https://files.pythonhosted.org/packages/38/00/1c0a5af613b57fa2b59aef4c3ef7b75c93ffba04c41e32ed5e1859d69839/blend2d_py-2025.1.0.dev8-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:26f458d2a648941edbc451e41f65ae03168bf4268c005fc7be6c0edaba2a8789", size = 985316, upload-time = "2025-09-10T05:38:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/25/2b/5faf2813e21f4f1c764819a8772f8e92a62ae03239fc71f092480ae29d15/blend2d_py-2025.1.0.dev8-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:84d991440194bcb4f364df244424921d782c4b94c6c266460582132aac4f9983", size = 1141863, upload-time = "2025-09-10T05:42:06.371Z" }, + { url = "https://files.pythonhosted.org/packages/48/4f/fcc0d12d0e3f285fc37bb9bed729e37e6eb1c7cf137f63a12e2a09cb272c/blend2d_py-2025.1.0.dev8-cp313-cp313-manylinux_2_39_aarch64.whl", hash = "sha256:c76b5dd349bd2b0d44634785a44dba8eb9490fa04764a96b64c2ab994e15be81", size = 1009953, upload-time = "2025-09-10T05:38:56.974Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/002c4d1ed18da332f01eef4ed6b0f015ad95df0fc177341e224fbb544d8f/blend2d_py-2025.1.0.dev8-cp313-cp313-manylinux_2_39_x86_64.whl", hash = "sha256:3b84136d550f73a95e49570b758854ac384f7f109dd4d0998db68061439cec09", size = 1216469, upload-time = "2025-09-10T05:38:59.988Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -315,6 +340,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "blend2d-py" }, { name = "httpx" }, { name = "nanobind" }, { name = "numpy" }, @@ -331,6 +357,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "blend2d-py" }, { name = "httpx" }, { name = "nanobind", specifier = "==2.8.0" }, { name = "numpy" }, From 183cfeaf76a88dd222a2aa28fccc2a9caa6b3c6c Mon Sep 17 00:00:00 2001 From: voluntas Date: Wed, 10 Sep 2025 15:47:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?CLI=20=E3=81=8B=E3=82=89=20SoraClient=20?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B=E3=81=9F=E3=82=81=E3=81=AE?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B=20Blend2D=20=E3=82=92=E4=BD=BF=E7=94=A8=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=83=95=E3=82=A7=E3=82=A4=E3=82=AF=E5=8B=95=E7=94=BB?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E3=81=99=E3=82=8B=20Settings=20=E3=82=AF=E3=83=A9=E3=82=B9?= =?UTF-8?q?=E3=81=AB=20channel=5Fid=20=E3=81=A8=20signaling=5Furls=20?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E6=9B=B8=E3=81=8D=E6=A9=9F=E8=83=BD=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/README.md | 22 ++++ tests/cli.py | 121 ++++++++++++++++++++++ tests/client.py | 94 +++++++++++++---- tests/conftest.py | 14 ++- tests/fake_video_blend2d.py | 198 ++++++++++++++++++++++++++++++++++++ 5 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 tests/cli.py create mode 100644 tests/fake_video_blend2d.py diff --git a/tests/README.md b/tests/README.md index ddbfe00d..47d61978 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,3 +10,25 @@ - `TEST_LIBWEBRTC_LOG` - デフォルトは `none` です - `none`, `verbose`, `info`, `warning`, `error` のいずれかを指定してください + +## 簡易 CLI 実行 + +`tests/cli.py` で `SoraClient` を手軽に実行できます。 + +### Blend2D で 120fps・解像度 960x540・厳密ペーシング + +```bash +uv run tests/cli.py --role sendonly --video --fake-video \ + --fake-video-type blend2d --video-codec-type VP9 \ + --video-width 960 --video-height 540 --framerate 120 --precise-timing +``` + +### 備考 + +- `--duration` を省略すると無制限で動作します(Ctrl-C で終了)。 +- フレームレートは `--framerate`(または `--fps`)で指定できます + - 未指定は 30 fps +- 解像度は `--video-width`/`--video-height` で指定 + - デフォルト 960x540 +- フレームペーシングをより厳密にするには `--precise-timing` を付与 + - CPU 使用率が上がります diff --git a/tests/cli.py b/tests/cli.py new file mode 100644 index 00000000..38e291d2 --- /dev/null +++ b/tests/cli.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import time + +from client import SoraClient, SoraRole +from conftest import Settings + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Run SoraClient quickly from CLI") + + p.add_argument( + "--role", + required=True, + choices=[r.value for r in SoraRole], + help="Connection role", + ) + + # メディア設定 + p.add_argument("--audio", action="store_true", help="Enable audio") + p.add_argument("--video", action="store_true", help="Enable video") + p.add_argument( + "--video-codec-type", choices=["VP8", "VP9", "H264", "H265", "AV1"], help="Video codec type" + ) + p.add_argument("--video-bit-rate", type=int, help="Video bitrate (bps)") + p.add_argument("--video-width", type=int, default=960, help="Video width (default 960)") + p.add_argument("--video-height", type=int, default=540, help="Video height (default 540)") + + # フェイクメディア + p.add_argument("--fake-audio", action="store_true", help="Send fake audio") + p.add_argument("--fake-video", action="store_true", help="Send fake video") + p.add_argument( + "--fake-video-type", + choices=["random", "blend2d"], + help="Fake video generator type. Default(None)=black frame", + ) + + # その他 + p.add_argument( + "--signaling-url", + action="append", + help="Signaling URL (repeatable or comma-separated)", + ) + p.add_argument("--channel-id", help="Override channel_id (settings)") + p.add_argument( + "--framerate", + "--fps", + dest="framerate", + type=int, + help="Fake video framerate (fps)", + ) + p.add_argument( + "--precise-timing", + action="store_true", + help="Use tighter frame pacing (may increase CPU)", + ) + p.add_argument( + "--duration", + type=float, + help="Run duration in seconds; omit for unlimited (CTRL-C to stop)", + ) + + return p.parse_args() + + +def main() -> int: + args = parse_args() + + # signaling-url は複数指定またはカンマ区切り両対応 + signaling_urls: list[str] | None = None + if args.signaling_url: + signaling_urls = [] + for ent in args.signaling_url: + signaling_urls.extend([x.strip() for x in ent.split(",") if x.strip()]) + + settings = Settings(channel_id=args.channel_id, signaling_urls=signaling_urls) + + client = SoraClient( + settings, + role=SoraRole(args.role), + audio=True if args.audio else False, + video=True if args.video else False, + video_codec_type=args.video_codec_type, + video_bit_rate=args.video_bit_rate, + video_width=args.video_width, + video_height=args.video_height, + fake_video_type=args.fake_video_type, # None の場合は黒フレーム + video_fps=args.framerate, + precise_timing=args.precise_timing, + ) + + print( + f"Connecting: role={args.role} channel_id={settings.channel_id} " + f"signaling_urls={settings.signaling_urls} audio={args.audio} video={args.video} " + f"fake_audio={args.fake_audio} fake_video={args.fake_video} fake_video_type={args.fake_video_type}" + ) + client.connect(fake_audio=args.fake_audio, fake_video=args.fake_video) + print(f"Connected: connection_id={client.connection_id}") + + start = time.perf_counter() + try: + while True: + if ( + args.duration is not None + and args.duration > 0 + and (time.perf_counter() - start) >= args.duration + ): + break + time.sleep(0.2) + except KeyboardInterrupt: + pass + finally: + client.disconnect() + print("Disconnected") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/client.py b/tests/client.py index 1cf7fbc5..b44d4671 100644 --- a/tests/client.py +++ b/tests/client.py @@ -68,15 +68,23 @@ def __init__( audio_output_frequency: int = 16000, video_width: int = 640, video_height: int = 480, + # fake video の描画タイプ: None | "random" | "blend2d" + fake_video_type: Optional[str] = None, + # 動画のフレームレート (None または <=0 の場合は 30fps) + video_fps: Optional[int] = None, + # フレームペーシングを厳密化(高CPUになる可能性あり) + precise_timing: bool = False, ): self._signaling_urls = settings.signaling_urls self._role = role.value self._channel_id = settings.channel_id + # JWT の channel_id を self._channel_id に合わせる + claims: dict[str, Any] = {} if jwt_private_claims is not None: - access_token = settings.access_token(**jwt_private_claims) - else: - access_token = settings.access_token() + claims.update(jwt_private_claims) + claims.update({"channel_id": self._channel_id}) + access_token = settings.access_token(**claims) # secret が設定されていない場合は access_token が存在しない if access_token is not None: @@ -105,6 +113,14 @@ def __init__( self._video_width: int = video_width self._video_height: int = video_height + # None の場合は真っ黒画面 + self._fake_video_type: Optional[str] = fake_video_type or None + # FPS は 30 をデフォルト + if video_fps is None or video_fps <= 0: + self._video_fps = 30 + else: + self._video_fps = int(video_fps) + self._precise_timing = precise_timing if settings.libwebrtc_log is not None: sora_sdk.enable_libwebrtc_log(settings.libwebrtc_log) @@ -228,9 +244,9 @@ def connect(self, fake_audio=False, fake_video=False) -> "SoraClient": self._connection.connect() try: - assert self._connected.wait( - self._default_connection_timeout_s - ), "Could not connect to Sora." + assert self._connected.wait(self._default_connection_timeout_s), ( + "Could not connect to Sora." + ) except Exception as e: self._connection.disconnect() raise e @@ -348,22 +364,64 @@ def _fake_audio_loop(self): self._audio_source.on_data(numpy.zeros((320, 1), dtype=numpy.int16)) def _fake_video_loop(self): + # フレームレート + fps = self._video_fps + + # Blend2D デモ指定時は専用のジェネレータを利用 + generator = None + if self._fake_video_type == "blend2d": + try: + from fake_video_blend2d import Blend2DFakeGenerator # type: ignore + + generator = Blend2DFakeGenerator(self._video_width, self._video_height, fps) + except Exception as e: + # 失敗時はランダム画像にフォールバック + print(f"Blend2D fake video init failed: {e}. Fallback to random frames.") + generator = None + + frame_interval = 1.0 / max(1, fps) + next_frame_at = time.perf_counter() + while not self._disconnected.is_set(): - time.sleep(1.0 / 30) - if self._video_source is not None: - # self._video_source.on_captured( - # numpy.zeros((self._video_height, self._video_width, 3), dtype=numpy.uint8) - # ) - - # お試し randint - def generate_random_image(): + if self._video_source is None: + time.sleep(frame_interval) + next_frame_at = time.perf_counter() + frame_interval + continue + + now = time.perf_counter() + if now < next_frame_at: + sleep_for = next_frame_at - now + if self._precise_timing and sleep_for > 0.0015: + # ざっくり寝てから短時間ビジーウェイト + time.sleep(sleep_for - 0.001) + while time.perf_counter() < next_frame_at: + pass + else: + time.sleep(sleep_for) + now = time.perf_counter() + + # フレーム生成と送出 + if generator is not None: + frame_bgr = generator.next_bgr() + self._video_source.on_captured(frame_bgr) + else: + if self._fake_video_type == "random": random_color = numpy.random.randint(0, 256, size=(3,), dtype=numpy.uint8) - return numpy.full( + frame = numpy.full( (self._video_height, self._video_width, 3), random_color, dtype=numpy.uint8 ) - - random_image = generate_random_image() - self._video_source.on_captured(random_image) + else: + frame = numpy.zeros( + (self._video_height, self._video_width, 3), dtype=numpy.uint8 + ) + self._video_source.on_captured(frame) + + # 次フレームの予定時刻を更新(ドリフト対策) + next_frame_at += frame_interval + late_by = time.perf_counter() - next_frame_at + if late_by > frame_interval: + # かなり遅延している場合はリセット(追いつけないときの暴走防止) + next_frame_at = time.perf_counter() + frame_interval def _on_signaling_message( self, diff --git a/tests/conftest.py b/tests/conftest.py index 93441872..5e5c52e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ class Settings: - def __init__(self): + def __init__(self, channel_id: str | None = None, signaling_urls: list[str] | None = None): # .env ファイルから環境変数を読み込む self._load_env_file(".env") @@ -27,6 +27,12 @@ def __init__(self): self.openh264_path = os.getenv("OPENH264_PATH") self.libwebrtc_log = self._parse_libwebrtc_log(os.getenv("TEST_LIBWEBRTC_LOG")) self.channel_id_suffix = str(uuid.uuid4()) + # CLI などから channel_id を明示指定するための上書き値 + self._channel_id_override = channel_id + # CLI などから signaling_urls を明示指定するための上書き値 + if signaling_urls is not None: + # 空文字などが混ざっていた場合に備えて軽くフィルタ + self.signaling_urls = [u.strip() for u in signaling_urls if u and u.strip()] def _load_env_file(self, env_file: str) -> None: """環境変数ファイルを読み込む""" @@ -85,7 +91,11 @@ def _parse_libwebrtc_log(self, value: str | None) -> SoraLoggingSeverity | None: @property def channel_id(self) -> str: - """TEST_CHANNEL_ID_PREFIX と TEST_CHANNEL_ID_SUFFIX を組み合わせて channel_id を生成する""" + """TEST_CHANNEL_ID_PREFIX と TEST_CHANNEL_ID_SUFFIX を組み合わせて channel_id を生成する。 + _channel_id_override が設定されている場合はそれを優先する。 + """ + if self._channel_id_override: + return self._channel_id_override return f"{self.channel_id_prefix}_{self.channel_id_suffix}" def access_token(self, **kwargs: Any) -> str | None: diff --git a/tests/fake_video_blend2d.py b/tests/fake_video_blend2d.py new file mode 100644 index 00000000..a1bacf3f --- /dev/null +++ b/tests/fake_video_blend2d.py @@ -0,0 +1,198 @@ +""" +Blend2D ベースのフェイク動画フレーム生成 + +テストの fake video 有効時に、環境変数 TEST_FAKE_VIDEO_TYPE=blend2d を指定すると +この描画デモで生成したフレームを SoraVideoSource に渡せます。 + +BGRA -> BGR は numpy で処理 +""" + +from __future__ import annotations + +import time +from math import pi, sin + +import numpy +from blend2d import CompOp, Context, Image, Path + + +def _draw_7segment(ctx: Context, digit: int, x: float, y: float, w: float, h: float) -> None: + if digit < 0 or digit > 9: + return + + thickness = w * 0.15 + gap = thickness * 0.2 + + segments = [ + # a, b, c, d, e, f, g + [True, True, True, True, True, True, False], # 0 + [False, True, True, False, False, False, False], # 1 + [True, True, False, True, True, False, True], # 2 + [True, True, True, True, False, False, True], # 3 + [False, True, True, False, False, True, True], # 4 + [True, False, True, True, False, True, True], # 5 + [True, False, True, True, True, True, True], # 6 + [True, True, True, False, False, False, False], # 7 + [True, True, True, True, True, True, True], # 8 + [True, True, True, True, False, True, True], # 9 + ] + + def draw_h(sx: float, sy: float) -> None: + p = Path() + p.move_to(sx + gap, sy) + p.line_to(sx + w - gap, sy) + p.line_to(sx + w - gap - thickness * 0.5, sy + thickness * 0.5) + p.line_to(sx + w - gap, sy + thickness) + p.line_to(sx + gap, sy + thickness) + p.line_to(sx + gap + thickness * 0.5, sy + thickness * 0.5) + p.close() + ctx.fill_path(p) + + def draw_v(sx: float, sy: float, sh: float) -> None: + p = Path() + p.move_to(sx, sy + gap) + p.line_to(sx + thickness * 0.5, sy + gap + thickness * 0.5) + p.line_to(sx + thickness, sy + gap) + p.line_to(sx + thickness, sy + sh - gap) + p.line_to(sx + thickness * 0.5, sy + sh - gap - thickness * 0.5) + p.line_to(sx, sy + sh - gap) + p.close() + ctx.fill_path(p) + + on = segments[digit] + if on[0]: + draw_h(x, y) + if on[1]: + draw_v(x + w - thickness, y, h * 0.5) + if on[2]: + draw_v(x + w - thickness, y + h * 0.5, h * 0.5) + if on[3]: + draw_h(x, y + h - thickness) + if on[4]: + draw_v(x, y + h * 0.5, h * 0.5) + if on[5]: + draw_v(x, y, h * 0.5) + if on[6]: + draw_h(x, y + h * 0.5 - thickness * 0.5) + + +def _draw_colon(ctx: Context, x: float, y: float, h: float) -> None: + dot = h * 0.1 + ctx.fill_circle(x + dot, y + h * 0.3, dot) + ctx.fill_circle(x + dot, y + h * 0.7, dot) + + +def _draw_digital_clock(ctx: Context, start_time: float, width: int, height: int) -> None: + ms = int((time.perf_counter() - start_time) * 1000) + hours = (ms // (60 * 60 * 1000)) % 10000 + minutes = (ms // (60 * 1000)) % 60 + seconds = (ms // 1000) % 60 + milliseconds = ms % 1000 + + clock_x = width * 0.02 + clock_y = height * 0.02 + digit_w = width * 0.018 + digit_h = height * 0.04 + spacing = digit_w * 0.3 + colon_w = digit_w * 0.3 + + x = clock_x + ctx.set_fill_style_rgba(0, 255, 255) + _draw_7segment(ctx, (hours // 1000) % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + _draw_7segment(ctx, (hours // 100) % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + _draw_7segment(ctx, (hours // 10) % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + _draw_7segment(ctx, hours % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + + _draw_colon(ctx, x, clock_y, digit_h) + x += colon_w + spacing + + _draw_7segment(ctx, (minutes // 10) % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + _draw_7segment(ctx, minutes % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + + _draw_colon(ctx, x, clock_y, digit_h) + x += colon_w + spacing + + _draw_7segment(ctx, (seconds // 10) % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + _draw_7segment(ctx, seconds % 10, x, clock_y, digit_w, digit_h) + x += digit_w + spacing + + ctx.fill_circle(x + colon_w * 0.3, clock_y + digit_h * 0.8, digit_h * 0.05) + x += colon_w + spacing + + ms_w = digit_w * 0.7 + ms_h = digit_h * 0.7 + ctx.set_fill_style_rgba(200, 200, 200) + y_off = (digit_h - ms_h) / 2 + _draw_7segment(ctx, (milliseconds // 100) % 10, x, clock_y + y_off, ms_w, ms_h) + x += ms_w + spacing * 0.8 + _draw_7segment(ctx, (milliseconds // 10) % 10, x, clock_y + y_off, ms_w, ms_h) + x += ms_w + spacing * 0.8 + _draw_7segment(ctx, milliseconds % 10, x, clock_y + y_off, ms_w, ms_h) + + +class Blend2DFakeGenerator: + def __init__(self, width: int, height: int, fps: int = 30) -> None: + self.width = width + self.height = height + self.fps = max(1, int(fps)) + self._frame = 0 + self._start = time.perf_counter() + self._img = Image(width, height) + + def next_bgr(self) -> numpy.ndarray: + ctx = Context(self._img) + ctx.set_comp_op(CompOp.SRC_COPY) + ctx.set_fill_style_rgba(0, 0, 0, 255) + ctx.fill_all() + + # デジタル時計 + ctx.save() + _draw_digital_clock(ctx, self._start, self.width, self.height) + ctx.restore() + + # 回転する円弧 + ctx.save() + ctx.translate(self.width * 0.5, self.height * 0.5) + ctx.rotate(-pi / 2) + ctx.set_fill_style_rgba(255, 255, 255) + ctx.fill_pie(0, 0, min(self.width, self.height) * 0.3, 0, 2 * pi) + ctx.set_fill_style_rgba(160, 160, 160) + # 実時間ベースで 1 秒に 1 回転 + elapsed = time.perf_counter() - self._start + sweep = (elapsed % 1.0) * 2 * pi + ctx.fill_pie(0, 0, min(self.width, self.height) * 0.3, 0, sweep) + ctx.restore() + + # 横に流れるボックス + box = 50 + colors = [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (255, 0, 255), + ] + for i in range(5): + # 実時間ベースで 1 秒周期 + phase = (elapsed + i * 0.2) % 1.0 + x = phase * (self.width - box) + y = self.height * 0.5 + sin(phase * 2 * pi) * self.height * 0.2 + r, g, b = colors[i % len(colors)] + ctx.set_fill_style_rgba(r, g, b) + ctx.fill_rect(x, y, box, box) + + ctx.end() + + rgba = self._img.asarray() # BGRA (premultiplied) + # BGRA -> BGR (alpha を捨てる)。copy() で連続領域にする。 + bgr = rgba[:, :, :3].copy() + + self._frame += 1 + return bgr