外部映像入力を利用する

概要

Sora Flutter SDK では、SDK 管理のカメラ入力 ( MediaDevices.getUserMedia() 経由) の代わりにアプリケーション側で用意した任意の映像フレームを送信できます。 画面キャプチャ、動画ファイル再生、合成映像、テストパターンなど、カメラ以外の入力を扱うことができます。

アプリケーション自身の画面を送信する場合は、 ReplayKit などで取得したフレームを writeFrame() で外部映像入力として送信してください。 本ページでは、アプリケーション側で取得した画面映像を外部映像入力として送信する方法を説明します。

AdaptedVideoTrackSource とは

AdaptedVideoTrackSource は、WebRTC の映像トラックにフレームを渡すネイティブ側の映像ソースです。 MediaDevices.createExternalVideoTrack() が外部映像トラックごとに 1 つ生成し、生成した LocalVideoTrack に関連付けます。 ここでいう「適合」は、入力フレームを WebRTC の映像トラック側の条件に合わせ、必要に応じてフレームを破棄したり解像度を調整したりする処理です。 アプリケーションがこの型を直接生成・操作する必要はありません。

writeFrame() を呼び出すと、SDK は次の順にフレームを処理します。

ExternalVideoFrame
       │
       ▼
LocalVideoTrack.writeFrame()
       │
       ▼
AdaptFrame
(フレームを受け付けるか、適合後の解像度を決める)
       │
       ▼
ネイティブ側の I420 バッファーへコピー
(必要な場合は適合後の解像度へスケーリング)
       │
       ▼
AdaptedVideoTrackSource の OnFrame
       │
       ▼
WebRTC の映像トラック以降の処理

AdaptFrame が 0 を返す場合は、現在の WebRTC の映像処理がフレームを必要としていない、または WebRTC の適合処理がフレームを破棄すると判断した状態です。 この場合はフレームをコピーせず、 writeFrame() は正常に戻ります。

AdaptedVideoTrackSource は、アプリケーションの ExternalVideoFrame を WebRTC の映像トラックへつなぐ境界です。 Sora へのネットワーク送信をアプリケーションが直接呼び出すための API ではありません。

利用方法

外部映像入力を送信する基本的な流れは以下のようになります。

  1. MediaDevices.createMediaStream() で LocalMediaStream を生成
  2. 必要に応じて createAudioTrack() を作成し stream.addTrack() で追加する
  1. MediaDevices.createExternalVideoTrack() で映像トラックを作成し stream.addTrack() で追加する
  2. SoraConnection.connect(stream) で接続する
  3. 接続完了後に track.writeFrame(ExternalVideoFrame(...)) を一定間隔で呼ぶ
  4. 使い終わったらフレーム生成元を停止し、 conn.disconnect() → 各 track の dispose() → stream.dispose() → フレーム生成元の破棄 → conn.dispose() の順で解放する

以下の例では、後述の buildSolidColorFrame() で単色フレームを生成します。

import 'dart:async';
import 'dart:typed_data';

import 'package:sora_sdk/sora_sdk.dart';

Future<void> startExternalVideo() async {
  final conn = 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 = await MediaDevices.createAudioTrack();
  // 外部映像入力用の映像トラックを生成
  final video = MediaDevices.createExternalVideoTrack();
  // 音声・映像トラックを LocalMediaStream に関連付ける
  stream.addTrack(audio);
  stream.addTrack(video);

  await conn.connect(stream);

  // 接続完了後に一定間隔でフレームを投入する
  // 33ms は約 30 FPS 相当。フレームの生成負荷に応じて調整してください
  final timer = Timer.periodic(const Duration(milliseconds: 33), (_) {
    video.writeFrame(buildSolidColorFrame(
      width: 640,
      height: 480,
      y: 128,
      u: 128,
      v: 128,
    ));
  });

  try {
    // 例として 10 秒間フレームを送信する
    await Future<void>.delayed(const Duration(seconds: 10));
  } finally {
    // 使い終わったらタイマーを止めてから解放する
    timer.cancel();
    await conn.disconnect();
    await video.dispose();
    await audio.dispose();
    await stream.dispose();
    await conn.dispose();
  }
}

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

disconnect() と SoraConnection.dispose() は、アプリケーション側で作成した外部映像トラックや LocalMediaStream を自動では破棄しません。 Timer 、 camera パッケージ、動画デコーダーなどのフレーム生成元も SDK は管理しないため、先に停止してください。

切断後の再利用

disconnect() は外部映像トラックを接続から切り離しますが、 dispose() は呼び出しません。そのため、切断だけではトラックが破棄済みにならず、 writeFrame() が接続切断だけを理由に StateError になるとは限りません。

ただし、切断後に同じ外部映像トラックを新しい接続で再利用できることは保証しません。再接続する場合は、新しい LocalMediaStream と createExternalVideoTrack() で生成したトラックを用意してください。

LocalVideoTrack.dispose() を呼び出した後は、 writeFrame() を含むトラックの API を利用できません。 LocalMediaStream.dispose() もトラックを破棄しないため、各トラックを破棄してから stream.dispose() を呼び出してください。

SDK 内蔵のカメラ入力との違い

MediaDevices.getUserMedia() を利用する場合は内部で自動的に createCameraVideoTrack で映像トラックが生成されます。 createExternalVideoTrack との違いは以下のようになります。

観点

createCameraVideoTrack

createExternalVideoTrack

フレーム生成元

SDK 内蔵の SoraCameraCapturer

アプリケーション側

ローカルプレビュー

LocalVideoTrack.textureId で取得できる

SDK からは提供されない (アプリケーション側で描画)

フレーム投入

SDK が自動で実行

writeFrame() をアプリケーションから呼ぶ

connect() 時の動作

SDK 内蔵のカメラキャプチャを開始

カメラキャプチャを開始せず、アプリケーションから writeFrame() でフレームを投入

接続と送信の前提

  • role: recvonly では connect() に stream を渡せません
  • 外部映像入力は送信側のロールで、 video: true を指定するか video を未指定にして使います。 video: false の場合は映像トラックを渡せません
  • audio トラックを含める場合は audio: true を指定するか未指定にし、含めない場合は audio: false にします

writeFrame() を呼び出せるトラック

writeFrame() は MediaDevices.createExternalVideoTrack() で生成したトラック専用です。 MediaDevices.getUserMedia() などで生成したカメラのトラックで呼び出すと StateError になります。

エラーハンドリング

writeFrame() において、次のような場合に StateError が送出されます。

  • トラックが dispose() 済み
  • MediaDevices.createExternalVideoTrack() で生成したトラック以外で writeFrame() を呼び出した場合
  • 内部の映像ソース参照が null
  • rotation が 0 / 90 / 180 / 270 のいずれでもない
  • width / height が 0 以下
  • width / height が 8192 を超える
  • timestampUs が負の値
  • yStride が width 未満、または uStride / vStride が (width + 1) ~/ 2 未満
  • yPlane / uPlane / vPlane のバイト列が、ストライドと高さから求まる長さに満たない

writeFrame() は同期的な void メソッドです。SDK の Dart 層に送信キューはありません。 入力検証と AdaptFrame の判定を終えた後、受け付けたフレームの Y / U / V plane を呼び出し中にネイティブ側の I420 バッファー へコピーします。 呼び出しから戻った後は、元の Dart 側のバッファーを再利用できます。

writeFrame() の戻りは、受け付けられたフレームをネイティブ映像ソースへ同期的に引き渡したことだけを示します。 ネイティブ側の配送、エンコード、ネットワーク送信の完了や順序は保証しません。 複数のフレーム生成元から呼び出す場合に Dart 側の呼び出し順を制御したいときは、アプリケーション側で writeFrame() の呼び出しを直列化してください。 この直列化によって、ネイティブ側の配送、エンコード、ネットワーク送信の順序が保証されるわけではありません。

void onFrame(Uint8List y, Uint8List u, Uint8List v, int width, int height) {
  try {
    final frame = ExternalVideoFrame(
      width: width,
      height: height,
      yPlane: y,
      uPlane: u,
      vPlane: v,
      yStride: width,
      uStride: (width + 1) ~/ 2,
      vStride: (width + 1) ~/ 2,
    );
    videoTrack.writeFrame(frame);
  } catch (error, stackTrace) {
    // エラー時も後続フレームの処理を継続する
    debugPrint('writeFrame error: $error\n$stackTrace');
  }
}

外部映像入力トラックをローカルプレビューで描画する

外部映像トラックでは LocalVideoTrack.textureId が使えないため、アプリケーション側でフレームを描画する必要があります。 writeFrame() で投入した I420 フレームを CustomPaint と CustomPainter で描画する例です。

import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:sora_sdk/sora_sdk.dart';

/// 外部映像トラックに投入した I420 フレームをローカルでプレビューする例です。
///
/// ``MediaDevices.createExternalVideoTrack()`` で生成したトラックでは
/// ``LocalVideoTrack.textureId`` が使えないため、
/// ``CustomPaint`` と ``CustomPainter`` でアプリケーション側が描画します。
///
/// 全体の流れ:
/// 1. ``writeFrame()`` で投入するフレームと同じ I420 データを保持する
/// 2. ``I420Painter`` で I420 → RGBA に変換して Canvas に描画する
/// 3. 新しいフレームが来るたびに ``setState`` で再描画する
class ExternalVideoPreview extends StatefulWidget {
  const ExternalVideoPreview({super.key});

  @override
  ExternalVideoPreviewState createState() => ExternalVideoPreviewState();
}

/// ``ExternalVideoPreview`` の状態を保持し、I420 フレームを更新します。
class ExternalVideoPreviewState extends State<ExternalVideoPreview> {
  // 最新の I420 フレームデータを保持します。
  // 各プレーンのバイト列とサイズ情報です。
  Uint8List? _yPlane;
  Uint8List? _uPlane;
  Uint8List? _vPlane;
  int _width = 0;
  int _height = 0;

  /// ``writeFrame`` で投入するフレームをプレビューにも反映します。
  ///
  /// [frame]: ``writeFrame`` に渡した ``ExternalVideoFrame`` と同じデータ
  void updateFrame(ExternalVideoFrame frame) {
    if (!mounted) {
      return;
    }
    setState(() {
      _yPlane = frame.yPlane;
      _uPlane = frame.uPlane;
      _vPlane = frame.vPlane;
      _width = frame.width;
      _height = frame.height;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_yPlane == null || _uPlane == null || _vPlane == null) {
      return const Center(child: Text('No frame'));
    }

    return CustomPaint(
      // 元映像のアスペクト比を維持して描画領域いっぱいに表示します。
      size: Size.infinite,
      painter: I420Painter(
        yPlane: _yPlane!,
        uPlane: _uPlane!,
        vPlane: _vPlane!,
        width: _width,
        height: _height,
      ),
    );
  }
}

/// I420 (YUV 4:2:0 planar) データを Canvas に描画する CustomPainter です。
///
/// I420 の各プレーンから RGBA ピクセルを計算し、
/// アスペクト比を保って描画領域にフィットさせます。
class I420Painter extends CustomPainter {
  I420Painter({
    required this.yPlane,
    required this.uPlane,
    required this.vPlane,
    required this.width,
    required this.height,
  });

  final Uint8List yPlane;
  final Uint8List uPlane;
  final Uint8List vPlane;
  final int width;
  final int height;

  // BT.601 フルスイング整数近似による YUV → RGB 変換の係数。
  // Y = 0.257*R + 0.504*G + 0.098*B + 16
  // U = -0.148*R - 0.291*G + 0.439*B + 128
  // V = 0.439*R - 0.368*G - 0.071*B + 128
  // 上記を整数演算で近似: `((a * c + 128) >> 8) + offset` の形。
  static const _yScale = 298;
  static const _vRed = 409;
  static const _uGreen = 100;
  static const _vGreen = 208;
  static const _uBlue = 516;

  @override
  void paint(Canvas canvas, Size size) {
    if (width == 0 || height == 0) return;

    // この実装は学習・検証用の簡易例です。
    // 毎フレームのバッファー確保、UI スレッドでの同期的なピクセル変換、
    // decodeImageFromPixels による非同期 GPU アップロードはいずれも高負荷です。
    // 本番利用では、変換済み画像のキャッシュや
    // バックグラウンド Isolate への変換処理のオフロードを推奨します。
    //
    // アスペクト比を保って描画領域にフィットさせる。
    final scaleX = size.width / width;
    final scaleY = size.height / height;
    final scale = scaleX < scaleY ? scaleX : scaleY;
    final destW = width * scale;
    final destH = height * scale;
    final offsetX = (size.width - destW) / 2;
    final offsetY = (size.height - destH) / 2;

    // I420 → RGBA の変換結果を保持するバッファー。
    final pixels = Uint8List(width * height * 4);

    final chromaWidth = (width + 1) ~/ 2;
    final yStride = width;
    final uStride = chromaWidth;
    final vStride = chromaWidth;

    for (var row = 0; row < height; row++) {
      for (var col = 0; col < width; col++) {
        final y = yPlane[row * yStride + col];
        final cx = col ~/ 2;
        final cy = row ~/ 2;
        final u = uPlane[cy * uStride + cx];
        final v = vPlane[cy * vStride + cx];

        // BT.601 の整数近似で YUV → RGB 変換。
        // Y 成分から 16 を引き、U/V 成分から 128 を引いてから計算する。
        final yy = (y - 16) * _yScale;
        final r = ((yy + (v - 128) * _vRed + 128) >> 8).clamp(0, 255);
        final g =
            ((yy - (u - 128) * _uGreen - (v - 128) * _vGreen + 128) >> 8)
                .clamp(0, 255);
        final b = ((yy + (u - 128) * _uBlue + 128) >> 8).clamp(0, 255);

        final dstIdx = (row * width + col) * 4;
        pixels[dstIdx] = r;
        pixels[dstIdx + 1] = g;
        pixels[dstIdx + 2] = b;
        pixels[dstIdx + 3] = 255; // alpha
      }
    }

    // RGBA ピクセルを Canvas に描画する。
    ui.decodeImageFromPixels(
      pixels,
      width,
      height,
      ui.PixelFormat.rgba8888,
      (image) {
        canvas.drawImageRect(
          image,
          Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
          Rect.fromLTWH(offsetX, offsetY, destW, destH),
          Paint(),
        );
        image.dispose();
      },
    );
  }

  @override
  bool shouldRepaint(covariant I420Painter oldDelegate) {
    return yPlane != oldDelegate.yPlane ||
        uPlane != oldDelegate.uPlane ||
        vPlane != oldDelegate.vPlane ||
        width != oldDelegate.width ||
        height != oldDelegate.height;
  }
}

ExternalVideoPreviewState.updateFrame() に writeFrame() と同じ ExternalVideoFrame を渡すと、送信中のフレームをプレビューできます。 GlobalKey 経由で現在の State を取得する場合は、Widget が破棄されている可能性があるため、 currentState?.updateFrame(frame) のように呼び出してください。

final previewKey = GlobalKey<ExternalVideoPreviewState>();

await connectAndStartCamera(
  controller: controller,
  stream: stream,
  conn: conn,
  videoTrack: videoTrack,
  onFrame: (frame) {
    previewKey.currentState?.updateFrame(frame);
  },
);

// Widget ツリーの中に配置します。
ExternalVideoPreview(key: previewKey);

この例は学習・検証用の簡易実装です。本番利用では次の点に注意してください。

I420 の取り扱い

色空間は I420 (YUV 4:2:0 planar) 固定です。他のフォーマットを扱う場合はアプリケーション側で 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 以上にしてください。

カメラと外部映像トラックを切り替える

MediaDevices.getUserMedia() などで生成したカメラの映像トラックで配信中に、 MediaDevices.createExternalVideoTrack() の映像トラックに差し替えることができます。また逆も可能です。

  • カメラから外部映像トラックへの切り替え: プラットフォーム側のカメラが止まる。以降は writeFrame() でフレームを供給する必要がある
  • 外部映像トラックからカメラへの切り替え: プラットフォーム側のカメラ入力が再開し、 LocalVideoTrack.textureId からのローカルプレビューも再開する
// 現在の映像トラックを取得する
final previous = stream.getVideoTracks().first;
// 新しい外部映像トラックを生成して差し替える
final next = MediaDevices.createExternalVideoTrack();
await conn.replaceVideoTrack(stream, next);
// 使わなくなったトラックは破棄する
await previous.dispose();

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

コード例

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

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,
    // 省略すると DateTime.now().microsecondsSinceEpoch が使われる
    timestampUs: DateTime.now().microsecondsSinceEpoch,
    // カメラセンサーの取り付け向きに応じて 0 / 90 / 180 / 270 を指定
    rotation: 0,
  );
}

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

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 の呼び出し中に入力バッファーをネイティブ側の I420 バッファー へ同期的にコピーしてから戻るため、戻り後は入力バッファーを再利用できます。

関連ページ