import argparse
import json
import sys
import threading
import time
from typing import Optional, Dict, Any, List
import queue
import numpy as np
from sora_sdk import (
Sora,
SoraConnection,
SoraVideoSink,
SoraMediaTrack,
SoraVideoFrame,
)
START_TIME = time.time()
class SoraReceiver:
def __init__(self, channel_id: str, signaling_urls: list[str],
video_stream_names: List[str]):
self.channel_id = channel_id
self.signaling_urls = signaling_urls
self.video_stream_names = video_stream_names
self.sora: Optional[Sora] = None
self.connection: Optional[SoraConnection] = None
self.connected = False
self.stream_name_to_id: Dict[str, int] = {}
self.next_stream_id = 1
self.video_queue = queue.Queue()
self.finished_streams = set()
self.video_sinks: List[SoraVideoSink] = []
def initialize(self):
self.sora = Sora()
def connect(self):
if not self.sora:
self.initialize()
self.connection = self.sora.create_connection(
signaling_urls=self.signaling_urls,
role="recvonly",
channel_id=self.channel_id,
audio=False, video=bool(self.video_stream_names),
)
self.connection.on_notify = self._on_notify
self.connection.on_disconnect = self._on_disconnect
self.connection.on_track = self._on_track
self.connection.connect()
time.sleep(1)
self.connected = True
def disconnect(self):
if self.connection:
self.connection.disconnect()
self.connected = False
def _on_notify(self, raw_message: str):
message = json.loads(raw_message)
if (message.get("type") == "notify" and
message.get("event_type") == "connection.created"):
print(f"Sora に接続しました: {message}", file=sys.stderr)
def _on_disconnect(self, error_code, message: str):
print(f"Sora から切断されました: {error_code} - {message}", file=sys.stderr)
self.connected = False
for stream_name in self.video_stream_names:
if stream_name in self.stream_name_to_id:
stream_id = self.stream_name_to_id[stream_name]
self.finished_streams.add(stream_id)
def _on_track(self, track: SoraMediaTrack):
if track.kind == "video" and self.video_stream_names:
stream_name = self.video_stream_names[len(self.video_sinks) % len(self.video_stream_names)]
stream_id = self.next_stream_id
self.next_stream_id += 1
self.stream_name_to_id[stream_name] = stream_id
video_sink = SoraVideoSink(track)
video_sink.on_frame = lambda frame: self._on_video_frame(stream_id, stream_name, frame)
self.video_sinks.append(video_sink)
print(f"映像トラックを受信: {stream_name} (stream_id: {stream_id})", file=sys.stderr)
def _on_video_frame(self, stream_id: int, stream_name: str, frame: SoraVideoFrame):
if not self.connected:
return
bgr_data = frame.data()
height, width = bgr_data.shape[:2]
duration_us = int(1_000_000 / 30) timestamp_us = int((time.time() - START_TIME) * 1_000_000)
self.video_queue.put({
'stream_id': stream_id,
'stream_name': stream_name,
'width': width,
'height': height,
'timestamp_us': timestamp_us,
'duration_us': duration_us,
'data': bgr_data.flatten().tobytes() })
class HisuiSoraSourcePlugin:
def __init__(self, channel_id: str, signaling_urls: list[str] = None,
video_stream_names: List[str] = None):
if signaling_urls is None:
signaling_urls = ["ws://localhost:3000/signaling"]
if video_stream_names is None:
video_stream_names = []
self.receiver = SoraReceiver(channel_id, signaling_urls, video_stream_names)
self.running = True
def read_message(self):
headers = {}
while True:
line = sys.stdin.buffer.readline().decode('utf-8')
if not line:
return None
line = line.strip()
if not line: break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
content_length = int(headers.get('Content-Length', 0))
if content_length == 0:
return None
content_bytes = sys.stdin.buffer.read(content_length)
content = content_bytes.decode('utf-8')
return content
def send_response(self, response: dict):
response_json = json.dumps(response)
print(f"Content-Length: {len(response_json)}")
print("Content-Type: application/json")
print()
print(response_json, end='')
sys.stdout.flush()
def send_response_with_payload(self, response: dict, payload: bytes):
response_json = json.dumps(response)
print(f"Content-Length: {len(response_json)}")
print("Content-Type: application/json")
print()
print(response_json, end='')
print(f"Content-Length: {len(payload)}")
print("Content-Type: application/octet-stream")
print()
sys.stdout.flush()
sys.stdout.buffer.write(payload)
sys.stdout.flush()
def process_request(self, request_data: str):
try:
request = json.loads(request_data)
except json.JSONDecodeError:
return
method = request.get('method')
request_id = request.get('id')
if method == 'poll_output':
if not self.receiver.video_queue.empty():
video_data = self.receiver.video_queue.get_nowait()
if request_id is not None:
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"type": "video_frame",
"stream_name": video_data['stream_name'],
"width": video_data['width'],
"height": video_data['height'],
"timestamp_us": video_data['timestamp_us'],
"duration_us": video_data['duration_us']
}
}
self.send_response_with_payload(response, video_data['data'])
return
expected_stream_count = len(self.receiver.video_stream_names)
if len(self.receiver.finished_streams) >= expected_stream_count and expected_stream_count > 0:
if request_id is not None:
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {"type": "finished"}
}
self.send_response(response)
else:
if request_id is not None:
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {"type": "waiting_input_any"}
}
self.send_response(response)
def run(self):
try:
self.receiver.initialize()
self.receiver.connect()
while self.running:
try:
message = self.read_message()
if message is None:
break
self.process_request(message)
except Exception as e:
print(f"メッセージ処理エラー: {e}", file=sys.stderr)
finally:
self.receiver.disconnect()
def main():
parser = argparse.ArgumentParser(description="Sora から映像を受信するための Hisui プラグイン")
parser.add_argument("--channel-id", required=True, help="Sora チャンネル ID")
parser.add_argument("--signaling-url", required=True, action="append",
help="Sora シグナリング URL(複数回指定可能)")
parser.add_argument("--video-stream-name", action="append", default=[],
help="映像ストリーム名(複数回指定可能)")
args = parser.parse_args()
plugin = HisuiSoraSourcePlugin(
args.channel_id,
args.signaling_url,
args.video_stream_name
)
plugin.run()
if __name__ == "__main__":
main()