外部映像入力ガイド¶
概要¶
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 データ構造体
カメラ入力との違い¶
観点 |
|
|
|---|---|---|
フレーム生成元 |
SDK 内蔵の |
アプリ側 |
ローカルプレビュー |
|
SDK からは提供されない (アプリ側で描画) |
フレーム投入 |
SDK が自動で実行 |
|
|
|
|
|
|
platform のカメラキャプチャは起動しない |
外部映像入力トラックを使っているあいだは SDK 側のカメラキャプチャが動かないため、 LocalVideoTrack.textureId からのローカルプレビューは提供されません。ローカルでプレビューしたい場合はアプリ側でフレームを別途描画してください。
基本の流れ¶
MediaDevices.createMediaStream()でLocalMediaStreamを作る- 必要に応じて
createAudioTrack()を作成しstream.addTrack()で追加する MediaDevices.createExternalVideoTrack()で映像トラックを作成しstream.addTrack()で追加するSoraConnection.connect(stream)で接続する- 任意のタイミングで
track.writeFrame(ExternalVideoFrame(...))を呼ぶ - 使い終わったら
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 のフィールド¶
フィールド |
型 |
必須 |
デフォルト |
意味 |
|---|---|---|---|---|
|
|
はい |
- |
フレームの幅 (px) |
|
|
はい |
- |
フレームの高さ (px) |
|
|
はい |
- |
Y plane のバイト列 |
|
|
はい |
- |
U plane のバイト列 |
|
|
はい |
- |
V plane のバイト列 |
|
|
はい |
- |
Y plane の 1 行あたりバイト数 |
|
|
はい |
- |
U plane の 1 行あたりバイト数 |
|
|
はい |
- |
V plane の 1 行あたりバイト数 |
|
|
いいえ |
|
フレームの回転角度。WebRTC の VideoFrame に渡される |
|
|
いいえ |
|
フレームの µs タイムスタンプ。 |
色空間は 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." captureTypeがexternal以外 (カメラトラックで呼んだ):"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¶
timestampUsがnullのときはDateTime.now().microsecondsSinceEpochが使われますtimestampUsを明示する場合はアプリ内で単調増加する値を渡してくださいrotationはsoraVideoFrameCreate(..., rotation, timestampUs, 0)の第 2 引数として libwebrtc の VideoFrame に渡されます。WebRTC の仕様 (0 / 90 / 180 / 270) に従ってください
内部でのコピー¶
以下の処理が行われます。
- 入力サイズで
i420BufferCreate(width, height)を呼び、Y/U/V を行単位コピーする - adapt 後サイズが違う場合はもう 1 段 buffer を作って
i420BufferScaleFromで scale する soraVideoFrameCreateで VideoFrame を作りadaptedVideoTrackSourceOnFrameで流し込む- 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.videoがfalseのとき、または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のガードを書いてから呼ぶと安全です
あわせて読むとよいページ¶
- メディアデバイスガイド
- 外部カメラ利用ガイド
- 映像レンダリングガイド
- シグナリング設定
- simulcast
- イベント