外部映像入力ガイド

概要

sora_sdk の外部映像入力 API の使い方と振る舞いをまとめます。

外部映像入力 API とは

SDK 管理のカメラ入力 (MediaDevices.getUserMedia() 経由) の代わりに、アプリ側で用意した任意の映像フレームを送信する API です。画面キャプチャ、動画ファイル再生、 camera パッケージからの取り込み、合成映像、テストパターンなど、カメラ以外の入力を扱うために用意されています。

..note::

SDK 内蔵の SoraCameraCapturer を通さず、アプリ側が AdaptedVideoTrackSource に直接 I420 フレームを流し込む経路となります

API 一覧

  • MediaDevices.createMediaStream(): トラックを保持しない LocalMediaStream を生成します
  • MediaDevices.createAudioTrack(): LocalAudioTrack を生成します (音声入力が必要な場合)
  • MediaDevices.createExternalVideoTrack(): 外部映像入力用の LocalVideoTrack を生成します
  • LocalVideoTrack.writeFrame(ExternalVideoFrame): 生成した映像トラックに I420 フレームを投入します
  • LocalVideoTrack.captureType: VideoTrackCaptureType を返します (camera / external)
  • LocalVideoTrack.dispose(): トラックと内部の映像ソース参照を解放します
  • ExternalVideoFrame: 1 フレーム分の I420 データ構造体

カメラ入力との違い

観点

createCameraVideoTrack

createExternalVideoTrack

フレーム生成元

SDK 内蔵の SoraCameraCapturer

アプリ側

ローカルプレビュー

LocalVideoTrack.textureId で取得できる

SDK からは提供されない (アプリ側で描画)

フレーム投入

SDK が自動で実行

writeFrame() をアプリから呼ぶ

captureType

VideoTrackCaptureType.camera

VideoTrackCaptureType.external

connect() 時の動作

startCameraFeed を platform 経由で起動

platform のカメラキャプチャは起動しない

外部映像入力トラックを使っているあいだは SDK 側のカメラキャプチャが動かないため、 LocalVideoTrack.textureId からのローカルプレビューは提供されません。ローカルでプレビューしたい場合はアプリ側でフレームを別途描画してください。

基本の流れ

  1. MediaDevices.createMediaStream()LocalMediaStream を作る
  2. 必要に応じて createAudioTrack() を作成し stream.addTrack() で追加する
  3. MediaDevices.createExternalVideoTrack() で映像トラックを作成し stream.addTrack() で追加する
  4. SoraConnection.connect(stream) で接続する
  5. 任意のタイミングで track.writeFrame(ExternalVideoFrame(...)) を呼ぶ
  6. 使い終わったら client.disconnect()track.dispose() / stream.dispose()
import 'dart:typed_data';

import 'package:sora_sdk/sora_sdk.dart';

Future<void> startExternalVideo() async {
  final client = await Sora.createConnection(
    SoraConnectionConfig(
      signalingUrls: <String>['wss://sora.example.com/signaling'],
      channelId: 'example-channel',
      role: SoraRole.sendonly,
      audio: true,
      video: true,
    ),
  );

  // トラックを保持しない LocalMediaStream を生成
  final stream = MediaDevices.createMediaStream();
  // 音声入力が必要な場合は音声トラックを生成
  final audio = MediaDevices.createAudioTrack();
  // 外部映像入力用の映像トラックを生成
  final video = MediaDevices.createExternalVideoTrack();
  // 音声・映像トラックを LocalMediaStream に関連付ける
  stream.addTrack(audio);
  stream.addTrack(video);

  await client.connect(stream);

  // 以降、任意のタイミングで I420 フレームを流し込む
  video.writeFrame(buildFrame());

  // 終了時の解放
  await client.disconnect();
  await video.dispose();
  await audio.dispose();
  await stream.dispose();
  await client.dispose();
}

MediaStream.dispose() は中のトラックを自動解放しません。 LocalVideoTrack.dispose() を明示的に呼ぶと、ネイティブ側の AdaptedVideoTrackSource の参照もまとめて release されます。

ExternalVideoFrame のフィールド

フィールド

必須

デフォルト

意味

width

int

はい

-

フレームの幅 (px)

height

int

はい

-

フレームの高さ (px)

yPlane

Uint8List

はい

-

Y plane のバイト列

uPlane

Uint8List

はい

-

U plane のバイト列

vPlane

Uint8List

はい

-

V plane のバイト列

yStride

int

はい

-

Y plane の 1 行あたりバイト数

uStride

int

はい

-

U plane の 1 行あたりバイト数

vStride

int

はい

-

V plane の 1 行あたりバイト数

rotation

int

いいえ

0

フレームの回転角度。WebRTC の VideoFrame に渡される

timestampUs

int?

いいえ

null

フレームの µs タイムスタンプ。 null のときは DateTime.now().microsecondsSinceEpoch が使われる

色空間は I420 (YUV 4:2:0 planar) 固定です。他のフォーマットを扱う場合はアプリ側で I420 に変換してください。

I420 の plane サイズ

  • Y plane: 少なくとも yStride * height バイト必要
  • chromaWidth = (width + 1) ~/ 2
  • chromaHeight = (height + 1) ~/ 2
  • U plane: 少なくとも uStride * chromaHeight バイト必要
  • V plane: 少なくとも vStride * chromaHeight バイト必要

width が奇数の場合でも chromaWidth は切り上げになるため、stride は (width + 1) ~/ 2 以上にしてください。

writeFrame() の振る舞い

事前チェック

次の条件で StateError が投げられます。

  • トラックが dispose() 済み: "Disposed LocalMediaStreamTrack cannot be used."
  • captureTypeexternal 以外 (カメラトラックで呼んだ): "writeFrame is available only for external video track."
  • 内部の映像ソース参照が null: "External video source is not available."

フレーム単位のバリデーション

以下を順番にチェックします。失敗時は StateError を投げます。

  • width <= 0 || height <= 0: "width and height must be positive"
  • yStride < width: "yStride is too small"
  • uStride < chromaWidth || vStride < chromaWidth: "chroma stride is too small"
  • yPlane.length < yStride * height: "yPlane is too short"
  • uPlane.length < uStride * chromaHeight: "uPlane is too short"
  • vPlane.length < vStride * chromaHeight: "vPlane is too short"

adapt (スキップと解像度調整)

adaptedVideoTrackSourceAdaptFrame を呼び、戻り値が 0 のときはフレームを投入せず return します。これは libwebrtc 側の AdaptedVideoTrackSource::AdaptFrame 相当の判定で、受信側の解像度要求やフレームレート制御などにより「このフレームは不要」と判断された場合に発生します。呼び出し側では writeFrame() の戻り値 (void) から成否を区別できないため、必要に応じてアプリ側でフレームレートを調整してください。

adapt 後の adaptedWidth / adaptedHeight が入力と異なる場合、内部で i420BufferScaleFrom による scale を行ってから投入します。入力側でリサイズを行わなくても libwebrtc の調整に追従できますが、毎フレーム scale を行うことになるためコストは発生します。

timestampUs と rotation

  • timestampUsnull のときは DateTime.now().microsecondsSinceEpoch が使われます
  • timestampUs を明示する場合はアプリ内で単調増加する値を渡してください
  • rotationsoraVideoFrameCreate(..., rotation, timestampUs, 0) の第 2 引数として libwebrtc の VideoFrame に渡されます。WebRTC の仕様 (0 / 90 / 180 / 270) に従ってください

内部でのコピー

以下の処理が行われます。

  1. 入力サイズで i420BufferCreate(width, height) を呼び、Y/U/V を行単位コピーする
  2. adapt 後サイズが違う場合はもう 1 段 buffer を作って i420BufferScaleFrom で scale する
  3. soraVideoFrameCreate で VideoFrame を作り adaptedVideoTrackSourceOnFrame で流し込む
  4. finally で全バッファと source 参照を release する

そのため毎フレーム Y/U/V の全プレーン相当のコピーが走ります。高解像度を扱うときは呼び出し頻度と、入力側での下処理の量を意識してください。

カメラと外部入力を切り替える

replaceVideoTrack(stream, track) で差し替えられます。前提条件として確認されるのはトラック種別 (LocalVideoTrack) と role / config.video であり、 captureType の違いは制限されません。

以下のように切り替わります。

  • camera → external: platform のカメラが止まる。以降は writeFrame() でフレームを供給する必要がある
  • external → camera: platform のカメラ入力が再開し、 LocalVideoTrack.textureId からのローカルプレビューも再開する
final next = MediaDevices.createExternalVideoTrack();
await client.replaceVideoTrack(stream, next);
await previous.dispose();

差し替え前のトラックは replaceVideoTrack() 内で stream から removeTrack() されますが、 dispose() は呼ばれません。使わなくなったトラックはアプリ側で dispose() してください。

接続と送信の前提

  • SoraConnectionConfig.videofalse のとき、または role: recvonly のときは connect() が stream を受け付けません。外部映像入力も video: true の sendonly / sendrecv でのみ使えます
  • audio トラックを含める場合は config.audio = true、含めない場合は config.audio = false にしてください (同じ検証が走ります)
  • ローカルプレビューを表示したい場合は、 writeFrame() 用のフレームをアプリ側で別ウィジェット (たとえば CustomPaint) に描画してください。SDK からのローカルプレビュー (LocalVideoTrack.textureId) は外部入力時には提供されません

エラーハンドリングの指針

writeFrame()StateError を投げるのは API 使用ミス (dispose 済み、captureType 誤り、入力バイト列不足) があったことを表します。フレームの欠落やネットワーク調整を理由にスキップされる場合は例外にならず、そのまま返ります。アプリ側では以下を目安に設計すると整理しやすくなります。

  • フレーム生成側でバッファ長と stride を検証し、不整合があれば assert で早期検出する
  • 送信レートを制御する layer (タイマー / producer) をアプリ側に置き、SDK から解像度要求を通知する API は使わない (公開されていないため)
  • 状態遷移は client.events (SoraConnectionStateChangedEvent) と組み合わせて、接続完了前のフレーム投入を避ける

コード例

テストパターン (単色塗りつぶし)

ExternalVideoFrame buildSolidColorFrame({
  required int width,
  required int height,
  required int y,
  required int u,
  required int v,
}) {
  final chromaWidth = (width + 1) ~/ 2;
  final chromaHeight = (height + 1) ~/ 2;
  final yPlane = Uint8List(width * height)..fillRange(0, width * height, y);
  final uPlane = Uint8List(chromaWidth * chromaHeight)
    ..fillRange(0, chromaWidth * chromaHeight, u);
  final vPlane = Uint8List(chromaWidth * chromaHeight)
    ..fillRange(0, chromaWidth * chromaHeight, v);
  return ExternalVideoFrame(
    width: width,
    height: height,
    yPlane: yPlane,
    uPlane: uPlane,
    vPlane: vPlane,
    yStride: width,
    uStride: chromaWidth,
    vStride: chromaWidth,
  );
}

一定フレームレートで書き込む

writeFrame() はアプリ側で呼ぶ時刻がそのままフレーム間隔になります。 Timer.periodic や独立アイソレートのループで制御する構成が扱いやすくなります。

Timer? timer;

void startPublishing(LocalVideoTrack track) {
  timer = Timer.periodic(const Duration(milliseconds: 33), (_) {
    track.writeFrame(buildSolidColorFrame(
      width: 640,
      height: 480,
      y: 128,
      u: 128,
      v: 128,
    ));
  });
}

Future<void> stopPublishing() async {
  timer?.cancel();
  timer = null;
}

フレームの生成がメインアイソレートを阻害する場合は、 compute() や独立 isolate での生成を検討してください。 writeFrame() 自体は Dart 呼び出しスレッドでネイティブに直接渡り、同期的にコピーを行います。

注意点

  • createExternalVideoTrack() はネイティブ側に映像ソースを確保します。作成したトラックは必ず dispose() してください。 dispose() 内で adaptedVideoTrackSourceRelease が呼ばれます
  • 外部入力トラック 1 本に対する writeFrame() は送信キューに 1 フレーム投入する操作です。同じトラックで並列に呼んだ場合の順序は保証されないため、アプリ側で排他制御してください
  • 解像度や fps の上限は libwebrtc 側の判断に従います。送信側の品質調整 (videoBitRate / simulcast / spotlight) と組み合わせた挙動は Sora サーバー設定にも依存します
  • カメラトラックで writeFrame() を呼ぶと必ず StateError になります。誤用を防ぐため、 track.captureType == VideoTrackCaptureType.external のガードを書いてから呼ぶと安全です

あわせて読むとよいページ