hisui 2025.2.0

Recording Composition Tool Hisui
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

        # ストリーム名とID のマッピング
        self.stream_name_to_id: Dict[str, int] = {}
        self.next_stream_id = 1

        # 受信データのキュー(映像のみ)
        self.video_queue = queue.Queue()
        self.finished_streams = set()

        # video sinks
        self.video_sinks: List[SoraVideoSink] = []

    def initialize(self):
        """Sora SDK を初期化し、接続を作成"""
        self.sora = Sora()

    def connect(self):
        """Sora に接続"""
        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):
        """Sora から切断"""
        if self.connection:
            self.connection.disconnect()
            self.connected = False

    def _on_notify(self, raw_message: str):
        """Sora の notify メッセージを処理"""
        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):
        """Sora の切断を処理"""
        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形式のnumpy配列として取得
        bgr_data = frame.data()

        # numpy配列の形状から幅と高さを取得 (H x W x BGR)
        height, width = bgr_data.shape[:2]

        # タイムスタンプとデュレーション(簡易実装)
        duration_us = int(1_000_000 / 30)  # 30 FPS と仮定
        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):
        """標準入力から JSON-RPC メッセージを読み取り"""
        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):
        """標準出力に JSON-RPC レスポンスを送信"""
        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):
        """標準出力に JSON-RPC レスポンス(ペイロード付き)を送信"""
        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):
        """JSON-RPC リクエストを処理"""
        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:
            # Sora を初期化して接続
            self.receiver.initialize()
            self.receiver.connect()

            # Hisui からのメッセージを処理
            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()