外部カメラパッケージを利用する¶
概要¶
Flutter 公式 camera パッケージで取得したフレームを Sora Flutter SDK の外部映像入力 API に流し込む方法を説明します。
camera パッケージが対応する全プラットフォーム(Android / iOS)で利用できますが、I420 への変換方法が異なります。パフォーマンスが出ない場合は libyuv の導入を検討してください。libyuv は FFI で呼び出す必要があります。
SDK の外部映像入力 API については 外部映像入力を利用する を参照してください。本ページは camera パッケージ統合に特化した補足です。
基本の流れ¶
pubspec.yamlにcameraパッケージを追加するCameraControllerを初期化し、プレビュー用にCameraPreviewを表示するMediaDevices.createExternalVideoTrack()で映像トラックを生成するSoraConnection.connect()で接続を確立する(フレーム投入より先に接続を完了させる)CameraController.startImageStream()でフレームを取得し、I420 に変換してwriteFrame()で投入する- 切断時は
stopImageStream→disconnect→ 各 track のdispose→stream.dispose→controller.dispose→conn.disposeの順で解放する
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:sora_sdk/sora_sdk.dart';
import 'external_camera_android_converter.dart';
import 'external_camera_ios_converter.dart';
/// ``CameraImage`` をプラットフォームに応じて I420 へ変換します。
///
/// Android では ``ImageFormatGroup.yuv420`` または
/// ``ImageFormatGroup.nv21``、iOS では single-planar の
/// ``ImageFormatGroup.bgra8888`` を使用します。
/// iOS の bi-planar ``yuv420`` は ``UnsupportedError`` とします。
ExternalVideoFrame cameraImageToExternalVideoFrame(
CameraImage image, {
required int sensorOrientation,
}) {
if (Platform.isAndroid) {
if (image.format.group != ImageFormatGroup.yuv420 &&
image.format.group != ImageFormatGroup.nv21) {
throw UnsupportedError(
'Android camera image format ${image.format.group} is not supported. '
'Use ImageFormatGroup.yuv420 or ImageFormatGroup.nv21.',
);
}
return yuv420ToI420(image, sensorOrientation);
}
if (Platform.isIOS) {
if (image.format.group != ImageFormatGroup.bgra8888) {
throw UnsupportedError(
'iOS camera image format ${image.format.group} is not supported. '
'Use ImageFormatGroup.bgra8888.',
);
}
return bgra8888ToI420(image, sensorOrientation);
}
throw UnsupportedError(
'Camera image conversion is supported only on Android and iOS.',
);
}
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:sora_sdk/sora_sdk.dart';
import 'external_camera_error_handler.dart';
/// ``camera`` パッケージのフレームを Sora Flutter SDK の外部映像入力として
/// 送信するサンプルです。
///
/// 全体の流れは次のとおりです。
/// 1. ``CameraController`` でカメラを初期化する
/// 2. ``MediaDevices.createExternalVideoTrack()`` で外部映像トラックを生成する
/// 3. ``SoraConnection`` を確立する
/// 4. ``startImageStream`` でフレームを取得し、I420 に変換して送信する
///
/// ``camera`` パッケージが出力するフレーム形式はプラットフォームによって
/// 異なります。各形式から I420 への変換方法については、
/// ``external_camera_converter.dart``、
/// ``external_camera_android_converter.dart`` および
/// ``external_camera_ios_converter.dart`` を参照してください。
Future<void> startExternalCamera({
void Function(ExternalVideoFrame frame)? onFrame,
}) async {
// 利用可能なカメラの一覧を取得します。
// ここでは最初のカメラ(通常は前面または背面のいずれか)を使用します。
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw StateError('利用可能なカメラがありません');
}
// CameraController を初期化します。
// - ResolutionPreset.medium: 480p 相当。高すぎる解像度を避け、
// 変換負荷とのバランスを取ります
// - enableAudio: false: 音声は ``camera`` パッケージでは扱わず、
// ``sora_sdk`` の ``MediaDevices.getUserMedia()`` で別途取得します
// - imageFormatGroup: プラットフォームに応じた形式を指定します
// iOS では bgra8888 を要求します(devtools サンプルの既定動作)
// Android では yuv420 を要求します(YUV_420_888 が取得されます)
final controller = CameraController(
cameras.first,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: Platform.isIOS
? ImageFormatGroup.bgra8888
: ImageFormatGroup.yuv420,
);
// カメラの初期化が完了するのを待ちます。
// 初期化前に ``startImageStream`` を呼ぶとエラーになります。
try {
await controller.initialize();
} catch (_) {
await controller.dispose();
rethrow;
}
// 空の LocalMediaStream を生成します。
// createMediaStream() はトラックを一切持たない stream を返すため、
// 後続の addTrack() で必要なトラックを追加します。
final stream = MediaDevices.createMediaStream();
// 外部映像入力用の LocalVideoTrack を生成します。
// このトラックは SDK 内蔵のカメラキャプチャではなく、
// アプリケーション側で writeFrame() を呼んでフレームを供給します。
// 注意: LocalVideoTrack.textureId は使えません。
// ローカルプレビューには ``camera`` パッケージの
// CameraPreview ウィジェットを利用してください。
final video = MediaDevices.createExternalVideoTrack();
// 映像トラックを stream に関連付けます。
// 音声も送信する場合は createAudioTrack() で生成し、同様に追加します。
stream.addTrack(video);
// SoraConnection を生成し、Sora に接続します。
// - role: SoraRole.sendonly: 配信のみ。視聴しない場合は sendonly を指定
// 送受信する場合は SoraRole.sendrecv を指定してください
// - video: true を指定するか未指定にする: 映像を送信する
// createExternalVideoTrack は video: true を指定するか未指定で、
// role が sendonly / sendrecv の場合に使用できます
final conn = await Sora.createConnection(
SoraConnectionConfig(
signalingUrls: <String>['wss://sora.example.com/signaling'],
channelId: 'example-channel',
role: SoraRole.sendonly,
video: true,
),
);
// Sora に接続します。
// connect() の完了を待ってからフレーム投入を開始してください。
// 接続完了前に writeFrame() を呼ぶと、
// AdaptFrame がフレームをドロップする可能性があります。
await conn.connect(stream);
// カメラからのフレーム取得を開始します。
// startImageStream のコールバックはカメラが新しいフレームを
// キャプチャするたびに呼ばれます。
// - CameraImage: フレームの生データとメタデータを含みます
// - sensorOrientation: カメラセンサーの取り付け向きです。
// 縦持ち端末では 90° または 270° になります。
// この値を ExternalVideoFrame.rotation に渡すことで
// 配信映像の向きを補正します。
await controller.startImageStream((CameraImage image) {
onImage(image, controller, video, onFrame: onFrame);
});
}
プレビューについて¶
外部映像トラックでは LocalVideoTrack.textureId が使えないため、 camera パッケージの CameraPreview ウィジェットでプレビューします。
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
/// 外部映像トラック利用時のプレビューウィジェットです。
///
/// ``MediaDevices.createExternalVideoTrack()`` で生成したトラックでは
/// ``LocalVideoTrack.textureId`` が使えません。そのため、ローカルプレビューには
/// ``camera`` パッケージの ``CameraPreview`` ウィジェットを使用します。
///
/// ``CameraPreview`` と ``startImageStream()`` は同時に使用できます。
/// プレビューはプラットフォーム側のテクスチャ上で描画され、
/// 画像ストリームとは独立して動作します。
///
/// 使用例:
/// ```dart
/// ExternalCameraPreview(controller: _controller)
/// ```
class ExternalCameraPreview extends StatelessWidget {
const ExternalCameraPreview({super.key, required this.controller});
/// ``camera`` パッケージの CameraController。
/// あらかじめ ``controller.initialize()`` を呼んで
/// 初期化を完了させておく必要があります。
final CameraController controller;
@override
Widget build(BuildContext context) {
// カメラが初期化されていない間はインジケーターを表示します。
// controller.value.isInitialized が false の状態で
// CameraPreview を表示しようとするとエラーになります。
if (!controller.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
// プラットフォーム側のテクスチャを用いてカメラのプレビューを描画します。
// SDK の Texture ウィジェットとは異なり、
// ``camera`` パッケージが独自にプラットフォーム側のテクスチャを管理します。
return CameraPreview(controller);
}
}
CameraPreview と startImageStream() は同時に使用できます。プレビューはプラットフォーム側のテクスチャを用いて描画され、画像ストリームとは独立して動作します。
I420 への変換¶
camera パッケージが出力するフレーム形式はプラットフォームごとに異なります。いずれも ExternalVideoFrame が要求する I420 (tri-planar YUV 4:2:0) ではないため、変換が必要です。
プラットフォーム |
要求フォーマット |
実際の形式 |
変換内容 |
|---|---|---|---|
Android |
|
|
プレーン抽出、VU の U / V 分離、ピクセルストライド正規化。純粋 Dart で実用的 |
iOS |
|
single-planar BGRA 32bit |
色空間変換 + 4:2:0 クロマサブサンプリング。純粋 Dart では高負荷 |
iOS |
|
bi-planar YUV 4:2:0 |
|
以下、各形式の変換方法を示します。devtools サンプルでは Android に YUV420、iOS に BGRA を要求しています。
Android: YUV_420_888 / NV21¶
ImageFormatGroup.yuv420 または ImageFormatGroup.nv21 でフレームが到着します。 yuv420 は 3 プレーンから I420 の各プレーンを抽出します。 nv21 は VU interleaved の順序を U / V に入れ替えて I420 に変換します。いずれも行ストライドとピクセルストライドを検証し、パディングを除いて連続したプレーンへコピーします。
import 'dart:typed_data';
import 'package:camera/camera.dart';
import 'package:sora_sdk/sora_sdk.dart';
/// Android の ``camera`` パッケージが出力する YUV 4:2:0 を
/// ``ExternalVideoFrame`` が要求する I420 形式に変換します。
///
/// ``ImageFormatGroup.yuv420`` は YUV_420_888 の 3 プレーン(Y, U, V)として
/// 扱います。``ImageFormatGroup.nv21`` はカメラ実装によって、Y と VU の
/// 2 プレーン、または Y + VU を連結した 1 プレーンとして到着します。
/// いずれも I420 の U / V プレーンへ正規化します。
ExternalVideoFrame yuv420ToI420(CameraImage image, int sensorOrientation) {
_validateDimensions(image);
switch (image.format.group) {
case ImageFormatGroup.yuv420:
return _convertYuv420(image, sensorOrientation);
case ImageFormatGroup.nv21:
return _convertNv21(image, sensorOrientation);
default:
throw UnsupportedError(
'Android camera image format ${image.format.group} is not supported. '
'Use ImageFormatGroup.yuv420 or ImageFormatGroup.nv21.',
);
}
}
ExternalVideoFrame _convertYuv420(CameraImage image, int sensorOrientation) {
_requirePlaneCount(image, expected: 3);
final width = image.width;
final height = image.height;
final yPlane = image.planes[0];
final uPlane = image.planes[1];
final vPlane = image.planes[2];
final yPixelStride = _pixelStride(
image,
yPlane,
defaultValue: 1,
label: 'plane 0 (Y)',
);
if (yPixelStride != 1) {
_unsupportedLayout(
image,
'plane 0 (Y) pixel stride must be 1, received $yPixelStride',
);
}
final uPixelStride = _pixelStride(
image,
uPlane,
defaultValue: 1,
label: 'plane 1 (U)',
);
final vPixelStride = _pixelStride(
image,
vPlane,
defaultValue: 1,
label: 'plane 2 (V)',
);
if ((uPixelStride != 1 && uPixelStride != 2) ||
vPixelStride != uPixelStride) {
_unsupportedLayout(
image,
'U / V pixel stride must be the same value of 1 or 2, '
'received U=$uPixelStride and V=$vPixelStride',
);
}
final chromaWidth = (width + 1) ~/ 2;
final chromaHeight = (height + 1) ~/ 2;
final yData = _copyPlane(
image,
yPlane,
width,
height,
pixelStride: yPixelStride,
label: 'plane 0 (Y)',
);
final uData = _copyPlane(
image,
uPlane,
chromaWidth,
chromaHeight,
pixelStride: uPixelStride,
label: 'plane 1 (U)',
);
final vData = _copyPlane(
image,
vPlane,
chromaWidth,
chromaHeight,
pixelStride: vPixelStride,
label: 'plane 2 (V)',
);
return _buildFrame(
image,
sensorOrientation,
yData,
uData,
vData,
yStride: width,
uStride: chromaWidth,
vStride: chromaWidth,
);
}
ExternalVideoFrame _convertNv21(CameraImage image, int sensorOrientation) {
switch (image.planes.length) {
case 1:
return _convertPackedNv21(image, sensorOrientation);
case 2:
return _convertTwoPlaneNv21(image, sensorOrientation);
default:
_unsupportedLayout(
image,
'NV21 requires 1 packed plane or 2 planes (Y and VU), '
'received ${image.planes.length}',
);
}
}
ExternalVideoFrame _convertTwoPlaneNv21(
CameraImage image,
int sensorOrientation,
) {
final width = image.width;
final height = image.height;
final chromaWidth = (width + 1) ~/ 2;
final chromaHeight = (height + 1) ~/ 2;
final yPlane = image.planes[0];
final vuPlane = image.planes[1];
final yPixelStride = _pixelStride(
image,
yPlane,
defaultValue: 1,
label: 'plane 0 (Y)',
);
if (yPixelStride != 1) {
_unsupportedLayout(
image,
'plane 0 (Y) pixel stride must be 1, received $yPixelStride',
);
}
final vuPixelStride = _pixelStride(
image,
vuPlane,
defaultValue: 1,
label: 'plane 1 (VU)',
);
if (vuPixelStride != 2) {
_unsupportedLayout(
image,
'plane 1 (VU) pixel stride must be 2, received $vuPixelStride',
);
}
final yData = _copyPlane(
image,
yPlane,
width,
height,
pixelStride: yPixelStride,
label: 'plane 0 (Y)',
);
final uData = Uint8List(chromaWidth * chromaHeight);
final vData = Uint8List(chromaWidth * chromaHeight);
_validatePlane(
image,
vuPlane,
chromaWidth,
chromaHeight,
pixelStride: vuPixelStride,
sampleByteLength: 2,
label: 'plane 1 (VU)',
);
for (var row = 0; row < chromaHeight; row++) {
final srcRowStart = row * vuPlane.bytesPerRow;
final dstRowStart = row * chromaWidth;
for (var col = 0; col < chromaWidth; col++) {
final srcIndex = srcRowStart + col * vuPixelStride;
final dstIndex = dstRowStart + col;
// NV21 は VU の順序、I420 は U / V の順序です。
vData[dstIndex] = vuPlane.bytes[srcIndex];
uData[dstIndex] = vuPlane.bytes[srcIndex + 1];
}
}
return _buildFrame(
image,
sensorOrientation,
yData,
uData,
vData,
yStride: width,
uStride: chromaWidth,
vStride: chromaWidth,
);
}
ExternalVideoFrame _convertPackedNv21(
CameraImage image,
int sensorOrientation,
) {
final width = image.width;
final height = image.height;
final plane = image.planes[0];
final pixelStride = _pixelStride(
image,
plane,
defaultValue: 1,
label: 'packed plane',
);
if (pixelStride != 1) {
_unsupportedLayout(
image,
'packed NV21 pixel stride must be 1, received $pixelStride',
);
}
if (plane.bytesPerRow != width) {
_unsupportedLayout(
image,
'packed NV21 bytes per row must equal image width ($width), '
'received ${plane.bytesPerRow}',
);
}
final chromaWidth = (width + 1) ~/ 2;
final chromaHeight = (height + 1) ~/ 2;
final yByteLength = width * height;
final chromaRowLength = chromaWidth * 2;
final requiredLength = yByteLength + chromaRowLength * chromaHeight;
if (plane.bytes.length < requiredLength) {
_unsupportedLayout(
image,
'packed NV21 buffer is too short: required $requiredLength bytes, '
'received ${plane.bytes.length}',
);
}
final yData = _copyPlane(
image,
plane,
width,
height,
pixelStride: pixelStride,
label: 'packed plane (Y)',
);
final uData = Uint8List(chromaWidth * chromaHeight);
final vData = Uint8List(chromaWidth * chromaHeight);
final chromaOffset = yByteLength;
for (var row = 0; row < chromaHeight; row++) {
final srcRowStart = chromaOffset + row * chromaRowLength;
final dstRowStart = row * chromaWidth;
for (var col = 0; col < chromaWidth; col++) {
final srcIndex = srcRowStart + col * 2;
final dstIndex = dstRowStart + col;
// NV21 は VU の順序、I420 は U / V の順序です。
vData[dstIndex] = plane.bytes[srcIndex];
uData[dstIndex] = plane.bytes[srcIndex + 1];
}
}
return _buildFrame(
image,
sensorOrientation,
yData,
uData,
vData,
yStride: width,
uStride: chromaWidth,
vStride: chromaWidth,
);
}
ExternalVideoFrame _buildFrame(
CameraImage image,
int sensorOrientation,
Uint8List yData,
Uint8List uData,
Uint8List vData, {
required int yStride,
required int uStride,
required int vStride,
}) {
return ExternalVideoFrame(
width: image.width,
height: image.height,
yPlane: yData,
uPlane: uData,
vPlane: vData,
yStride: yStride,
uStride: uStride,
vStride: vStride,
rotation: sensorOrientation,
timestampUs: DateTime.now().microsecondsSinceEpoch,
);
}
int _pixelStride(
CameraImage image,
Plane plane, {
required int defaultValue,
required String label,
}) {
final pixelStride = plane.bytesPerPixel ?? defaultValue;
if (pixelStride <= 0) {
_unsupportedLayout(
image,
'$label pixel stride must be positive, received $pixelStride',
);
}
return pixelStride;
}
Uint8List _copyPlane(
CameraImage image,
Plane plane,
int width,
int height, {
required int pixelStride,
required String label,
}) {
_validatePlane(
image,
plane,
width,
height,
pixelStride: pixelStride,
label: label,
);
final src = plane.bytes;
final srcRowStride = plane.bytesPerRow;
final dst = Uint8List(width * height);
for (var row = 0; row < height; row++) {
final srcRowStart = row * srcRowStride;
final dstRowStart = row * width;
for (var col = 0; col < width; col++) {
dst[dstRowStart + col] = src[srcRowStart + col * pixelStride];
}
}
return dst;
}
void _validateDimensions(CameraImage image) {
if (image.width <= 0 || image.height <= 0) {
_unsupportedLayout(
image,
'image dimensions must be positive, received '
'${image.width}x${image.height}',
);
}
}
void _requirePlaneCount(CameraImage image, {required int expected}) {
if (image.planes.length != expected) {
_unsupportedLayout(
image,
'YUV_420_888 requires $expected planes, '
'received ${image.planes.length}',
);
}
}
void _validatePlane(
CameraImage image,
Plane plane,
int width,
int height, {
required int pixelStride,
int sampleByteLength = 1,
required String label,
}) {
final minimumRowLength = (width - 1) * pixelStride + sampleByteLength;
if (plane.bytesPerRow < minimumRowLength) {
_unsupportedLayout(
image,
'$label bytes per row must be at least $minimumRowLength, '
'received ${plane.bytesPerRow}',
);
}
final lastByteIndex =
(height - 1) * plane.bytesPerRow +
(width - 1) * pixelStride +
sampleByteLength -
1;
if (plane.bytes.length <= lastByteIndex) {
_unsupportedLayout(
image,
'$label buffer is too short: requires byte index $lastByteIndex, '
'received ${plane.bytes.length} bytes',
);
}
}
Never _unsupportedLayout(CameraImage image, String detail) {
throw UnsupportedError(
'Android camera image format ${image.format.group} has unsupported '
'layout: $detail.',
);
}
ImageFormatGroup.nv21 の 2 プレーン経路では planes[0] を Y、 planes[1] を VU として扱い、VU の各ペアを I420 の V / U へ分離します。 1 プレーンの packed buffer では、Y の直後にある VU を同じ順序で分離します。プレーン数、行ストライド、ピクセルストライド、バッファ長が変換条件を満たさない場合は UnsupportedError を投げます。
iOS: bgra8888¶
iOS では ImageFormatGroup.bgra8888 を要求し、I420 へ変換します。
bgra8888 形式¶
ImageFormatGroup.bgra8888 でフレームが到着します。single-planar BGRA 32bit であり、I420 への変換には BGRA → YUV 色空間変換と 4:2:0 クロマサブサンプリングが必要です。純粋 Dart では高解像度・高フレームレートの処理に耐えられません。
devtools サンプルでは iOS に対して ImageFormatGroup.bgra8888 を明示的に要求しています。これは学習・検証用の簡易実装であり、パフォーマンスが出ない場合は libyuv の導入を検討してください。
変換コード¶
bgra8888ToI420 の変換コードを以下に示します。
import 'dart:typed_data';
import 'package:camera/camera.dart';
import 'package:sora_sdk/sora_sdk.dart';
/// iOS の ``camera`` パッケージが出力する BGRA 32bit を
/// ``ExternalVideoFrame`` が要求する I420 (YUV 4:2:0 planar) 形式に変換します。
///
/// iOS で ``ImageFormatGroup.bgra8888`` を要求すると、
/// single-planar BGRA 32bit が到着します。
/// I420 への変換には以下の処理が必要です:
/// 1. BGRA → YUV 色空間変換
/// 2. 4:2:0 クロマサブサンプリング(U/V を 2×2 ブロックごとに 1 つに間引く)
///
/// **重要**: この実装は純粋 Dart であり、高解像度・高フレームレートでは
/// パフォーマンスが不足します。本格的な利用には libyuv の導入を検討してください。
/// libyuv の BGRA を I420 に変換する C 関数を FFI で呼び出すことで、
/// 大幅な高速化が期待できます。
///
/// ``ImageFormatGroup.bgra8888`` を使用する理由:
/// - devtools サンプルでは iOS に対して明示的に bgra8888 を要求しています
/// - これは学習・検証用の簡易実装です
/// - bi-planar の ``ImageFormatGroup.yuv420`` はこのサンプルでは扱いません
ExternalVideoFrame bgra8888ToI420(CameraImage image, int sensorOrientation) {
final width = image.width;
final height = image.height;
// BGRA データは単一プレーンのみです。
// 各ピクセルは B, G, R, A の 4 バイトで構成され、
// 行のバイト数は bytesPerRow (= width * 4 + パディング) です。
final src = image.planes[0].bytes;
final srcStride = image.planes[0].bytesPerRow;
// Y プレーン用のバッファー: width × height の full resolution
final yPlane = Uint8List(width * height);
// クロマプレーンのサイズを計算します。
// 4:2:0 サブサンプリングにより、U/V はそれぞれ 1/4 のサイズです。
final chromaWidth = (width + 1) ~/ 2;
final chromaHeight = (height + 1) ~/ 2;
// U/V プレーン用のバッファー: chromaWidth × chromaHeight
final uPlane = Uint8List(chromaWidth * chromaHeight);
final vPlane = Uint8List(chromaWidth * chromaHeight);
// 2×2 ブロック単位で処理します。
// これは 4:2:0 クロマサブサンプリングに対応するためです。
// 各ブロックから:
// - Y 値を 4 つ(各ピクセルから 1 つずつ)
// - U 値を 1 つ(4 ピクセルの平均から計算)
// - V 値を 1 つ(4 ピクセルの平均から計算)
// を生成します。
for (var y = 0; y < height; y += 2) {
for (var x = 0; x < width; x += 2) {
// 2×2 ブロック内の 4 ピクセルを BGRA データから取得します。
// 画像端のピクセルは複製して扱います(境界処理)。
//
// tl: top-left tr: top-right
// bl: bottom-left br: bottom-right
final tl = _bgraAt(src, srcStride, x, y);
final tr = (x + 1 < width) ? _bgraAt(src, srcStride, x + 1, y) : tl;
final bl = (y + 1 < height) ? _bgraAt(src, srcStride, x, y + 1) : tl;
final br = (x + 1 < width && y + 1 < height)
? _bgraAt(src, srcStride, x + 1, y + 1)
: tl;
// 各ピクセルの Y 値を計算して Y プレーンに書き込みます。
// _yFromBgra は BT.601 の係数を用いて BGRA → Y 変換を行います。
yPlane[y * width + x] = _yFromBgra(tl);
if (x + 1 < width) {
yPlane[y * width + x + 1] = _yFromBgra(tr);
}
if (y + 1 < height) {
yPlane[(y + 1) * width + x] = _yFromBgra(bl);
if (x + 1 < width) {
yPlane[(y + 1) * width + x + 1] = _yFromBgra(br);
}
}
// 4 ピクセルの RGB 平均を計算してから U/V 値を算出します。
// これにより 4:2:0 クロマサブサンプリングを実現します。
final (r, g, b) = _avgPixel(tl, tr, bl, br);
final cx = x ~/ 2;
final cy = y ~/ 2;
// _uFromRgb / _vFromRgb は BT.601 の係数を用いて
// RGB → U/Cb および RGB → V/Cr 変換を行います。
uPlane[cy * chromaWidth + cx] = _uFromRgb(r, g, b);
vPlane[cy * chromaWidth + cx] = _vFromRgb(r, g, b);
}
}
return ExternalVideoFrame(
width: width,
height: height,
yPlane: yPlane,
uPlane: uPlane,
vPlane: vPlane,
yStride: width,
uStride: chromaWidth,
vStride: chromaWidth,
rotation: sensorOrientation,
timestampUs: DateTime.now().microsecondsSinceEpoch,
);
}
/// BGRA バイト列から指定位置 (x, y) のピクセル値を (B, G, R) として取得します。
///
/// BGRA は各ピクセルが 4 バイト(B, G, R, A の順)で構成されます。
/// Alpha チャンネルは I420 変換では使用しないため、返しません。
///
/// [bytes]: BGRA 形式のバイト列
/// [stride]: 1 行あたりのバイト数(パディングを含む)
/// [x], [y]: 取得するピクセルの座標
(int, int, int) _bgraAt(Uint8List bytes, int stride, int x, int y) {
// 各行の先頭からのオフセット: y * stride
// ピクセルの先頭からのオフセット: x * 4 (1 ピクセル = 4 バイト)
final idx = y * stride + x * 4;
// bytes[idx] = B (Blue)
// bytes[idx+1] = G (Green)
// bytes[idx+2] = R (Red)
// bytes[idx+3] = A (Alpha) ← 使用しません
return (bytes[idx], bytes[idx + 1], bytes[idx + 2]);
}
/// BT.601 の係数を用いて BGRA ピクセルから Y (輝度) 値を計算します。
///
/// 変換式(BT.601 近似):
/// Y = 0.257 * R + 0.504 * G + 0.098 * B + 16
///
/// 整数演算で近似しています:
/// Y = ((66 * R + 129 * G + 25 * B + 128) >> 8) + 16
///
/// 各係数は 66/256 ≈ 0.2578, 129/256 ≈ 0.5039, 25/256 ≈ 0.0977 です。
int _yFromBgra((int, int, int) bgr) {
final (b, g, r) = bgr;
return ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
}
/// BT.601 の係数を用いて RGB 値から U (Cb) 値を計算します。
///
/// 変換式(BT.601 近似):
/// U = -0.148 * R - 0.291 * G + 0.439 * B + 128
///
/// 整数演算で近似:
/// U = ((-38 * R - 74 * G + 112 * B + 128) >> 8) + 128
///
/// -38/256 ≈ -0.1484, -74/256 ≈ -0.2891, 112/256 ≈ 0.4375
int _uFromRgb(int r, int g, int b) =>
((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
/// BT.601 の係数を用いて RGB 値から V (Cr) 値を計算します。
///
/// 変換式(BT.601 近似):
/// V = 0.439 * R - 0.368 * G - 0.071 * B + 128
///
/// 整数演算で近似:
/// V = ((112 * R - 94 * G - 18 * B + 128) >> 8) + 128
///
/// 112/256 ≈ 0.4375, -94/256 ≈ -0.3672, -18/256 ≈ -0.0703
int _vFromRgb(int r, int g, int b) =>
((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
/// 4 ピクセルの RGB 平均を計算します。
///
/// 4:2:0 クロマサブサンプリングでは、
/// 2×2 ブロック内の 4 ピクセルから 1 つの U/V 値を生成します。
/// ここでは単純平均を用いています。
(int, int, int) _avgPixel(
(int, int, int) tl,
(int, int, int) tr,
(int, int, int) bl,
(int, int, int) br,
) {
int avg(int a, int b, int c, int d) => (a + b + c + d) ~/ 4;
final (b1, g1, r1) = tl;
final (b2, g2, r2) = tr;
final (b3, g3, r3) = bl;
final (b4, g4, r4) = br;
return (avg(r1, r2, r3, r4), avg(g1, g2, g3, g4), avg(b1, b2, b3, b4));
}
パフォーマンスが出ない場合は libyuv の導入を検討し、 BGRA を I420 に変換する C 関数を FFI で呼び出してください。
回転への対応¶
CameraDescription.sensorOrientation はカメラセンサーの取り付け向きを表します。多くの端末ではセンサーが横向きに取り付けられているため、縦持ち時に 90° や 270° の値が返ります。この値を ExternalVideoFrame.rotation に渡すことで、配信映像の向きを補正できます。
final frame = cameraImageToExternalVideoFrame(
image,
sensorOrientation: controller.description.sensorOrientation,
);
ライフサイクル管理¶
フレーム取得の開始と停止、接続の切断、各トラックと LocalMediaStream の破棄、カメラの破棄は以下の順序で行います。
connectAndStartCamera() は、カメラ初期化、Sora 接続、画像ストリーム開始のいずれかに失敗した場合も、生成済みのリソースを disconnectAndDisposeCamera() で解放してから元のエラーを再送出します。
Widget の破棄時やユーザー操作による停止時は、画像ストリームを停止してから同じ disconnectAndDisposeCamera() を呼び出してください。cleanup は複数回呼び出されても、完了済みの処理をスキップして後続の解放を続けます。
import 'package:camera/camera.dart';
import 'package:sora_sdk/sora_sdk.dart';
import 'external_camera_error_handler.dart';
/// カメラの初期化から接続、フレーム取得開始までの一連の流れを実行します。
///
/// 呼び出し順序には依存関係があります:
/// 1. ``controller.initialize()`` → カメラの使用準備
/// 2. ``conn.connect(stream)`` → Sora への接続(先に接続を確立する)
/// 3. ``controller.startImageStream()`` → フレーム取得開始
///
/// ``startImageStream`` は ``connect`` より後に呼んでください。
/// 接続完了前に ``writeFrame`` が呼ばれると
/// AdaptFrame がフレームをドロップする可能性があるため、
/// 先に ``connect()`` の完了を待ってからフレーム投入を開始します。
/// [videoTrack] は ``stream`` に追加した外部映像トラックです。
/// [onFrame] を指定すると、``writeFrame`` に渡した同じフレームを
/// プレビューなどへ通知します。
Future<void> connectAndStartCamera({
required CameraController controller,
required LocalMediaStream stream,
required SoraConnection conn,
required LocalVideoTrack videoTrack,
void Function(ExternalVideoFrame frame)? onFrame,
}) async {
try {
// カメラを初期化します。
// initialize() が完了するまで CameraPreview は表示できず、
// startImageStream も呼べません。
await controller.initialize();
// Sora に接続します。
// 先に接続を確立してからフレーム投入を開始します。
// 切断と破棄は必ず下記の disconnectAndDisposeCamera の順序で行ってください。
await conn.connect(stream);
// フレーム取得を開始します。
// コールバック関数内で I420 変換と writeFrame() を呼び出します。
await controller.startImageStream((image) {
onImage(image, controller, videoTrack, onFrame: onFrame);
});
} catch (error, stackTrace) {
// 初期化、接続、画像ストリーム開始のどこで失敗しても、
// 生成済みのリソースを同じ cleanup 経路で解放します。
try {
await disconnectAndDisposeCamera(
controller: controller,
conn: conn,
stream: stream,
);
} catch (_) {
// 元の失敗原因を保持するため、cleanup の失敗はここでは隠します。
}
Error.throwWithStackTrace(error, stackTrace);
}
}
/// フレーム取得の停止、切断、破棄を正しい順序で実行します。
///
/// **順序が重要です。**
/// 順序を誤ると ``StateError`` が発生したり、
/// リソースリークの原因になります。
///
/// 画像ストリームが未開始の場合や、cleanup が複数回呼ばれた場合も、
/// 既に完了した処理をスキップして後続の解放を続けます。
/// 各ステップで失敗しても残りのリソースを解放し、最後に最初のエラーを再送出します。
Future<void> disconnectAndDisposeCamera({
required CameraController controller,
required SoraConnection conn,
required LocalMediaStream stream,
}) async {
Object? firstError;
StackTrace? firstStackTrace;
var hasError = false;
void rememberError(Object error, StackTrace stackTrace) {
if (!hasError) {
hasError = true;
firstError = error;
firstStackTrace = stackTrace;
}
}
Future<void> attempt(Future<void> Function() action) async {
try {
await action();
} catch (error, stackTrace) {
rememberError(error, stackTrace);
}
}
// 1. フレーム取得を停止します。
// 切断後に writeFrame が呼ばれないよう、入力元を先に停止します。
// 未開始の場合は stopImageStream() を呼ばず、二重 cleanup も許容します。
await attempt(() async {
if (controller.value.isInitialized && controller.value.isStreamingImages) {
try {
await controller.stopImageStream();
} on CameraException {
// stop と別の cleanup が競合して既に停止している場合は許容します。
if (controller.value.isStreamingImages) {
rethrow;
}
}
}
});
// 2. Sora との接続を切断します。
// disconnect は接続からトラックを切り離しますが、
// アプリケーション側で作成したトラックや stream は破棄しません。
await attempt(conn.disconnect);
// 切断前に track の一覧を取得します。stream.dispose() 後は一覧を取得できません。
List<LocalMediaStreamTrack> tracks = const <LocalMediaStreamTrack>[];
try {
tracks = stream.getTracks();
} catch (error, stackTrace) {
rememberError(error, stackTrace);
}
// 3. stream に含まれる各トラックを破棄します。
// LocalMediaStream.dispose() はトラックを自動では破棄しません。
for (final track in tracks) {
await attempt(track.dispose);
}
// 4. stream 自身を破棄します。
await attempt(stream.dispose);
// 5. カメラを破棄します。
// フレーム取得を停止し、トラックを破棄した後に呼んでください。
await attempt(controller.dispose);
// 6. SoraConnection を破棄します。
// SoraConnection.dispose() も stream / track を自動では破棄しません。
// 破棄後は同じインスタンスで再接続できません。
await attempt(conn.dispose);
// disconnect() が失敗して track が接続中のままになった場合に備えて、
// SoraConnection.dispose() 後にもう一度 track の破棄を試みます。
for (final track in tracks) {
if (!track.isDisposed) {
await attempt(track.dispose);
}
}
if (hasError) {
Error.throwWithStackTrace(firstError!, firstStackTrace ?? StackTrace.current);
}
}
フレーム取得の停止( stopImageStream )は disconnect より先に行ってください。入力元を停止せずに切断すると、切断処理とフレーム投入が競合する可能性があります。 disconnect は接続からトラックを切り離しますが、アプリケーション側で作成したトラックや LocalMediaStream は自動では破棄しません。切断後に各トラック、 LocalMediaStream 、 CameraController 、 SoraConnection を明示的に破棄してください。
エラーハンドリング¶
cameraImageToExternalVideoFrame が UnsupportedError を投げるのは、Android の yuv420 / nv21 、 iOS の bgra8888 以外のフレーム形式が来た場合、または対応形式でもプレーン構造が検証条件を満たさない場合です。 iOS の bi-planar yuv420 は未対応です。 writeFrame が StateError を投げる条件は 外部映像入力を利用する を参照してください。
フレーム変換や writeFrame の例外を放置すると後続のフレームが処理されなくなるため、アプリケーション側で例外ハンドリングし、エラー時はログ出力のうえ後続フレームの処理を継続する構成を推奨します。
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:sora_sdk/sora_sdk.dart';
import 'external_camera_converter.dart';
/// ``startImageStream`` から呼ばれるフレーム受信コールバックです。
///
/// フレーム変換や ``writeFrame`` の例外を放置すると
/// 後続のフレームが処理されなくなるため、
/// 必ず例外をキャッチしてログ出力し、
/// 後続フレームの処理を継続するようにしてください。
///
/// 発生しうる例外:
/// - ``UnsupportedError``: 未対応のフレーム形式が到着した場合
/// (``cameraImageToExternalVideoFrame`` 内部でスロー)
/// - ``StateError``: ``writeFrame`` が失敗した場合
/// 主な原因:
/// - トラックが dispose 済み
/// - ``createExternalVideoTrack`` で生成したトラック以外で
/// ``writeFrame`` を呼び出した場合
/// - 内部の映像ソース参照が null
/// - rotation が 0 / 90 / 180 / 270 のいずれでもない
/// - width/height が 0 以下、または 8192 を超える
/// - timestampUs が負の値
/// - stride が不足
/// - プレーンのバイト列が短すぎる
///
/// [image]: カメラから取得したフレームデータ
/// [controller]: カメラの制御インスタンス。
/// sensorOrientation の取得に使用します
/// [videoTrack]: フレームの投入先となる外部映像トラック
/// [onFrame]: ``writeFrame`` に渡したフレームを受け取る callback
void onImage(
CameraImage image,
CameraController controller,
LocalVideoTrack videoTrack, {
void Function(ExternalVideoFrame frame)? onFrame,
}) {
try {
// プラットフォームに応じた I420 変換を行います。
// cameraImageToExternalVideoFrame 内で Platform 判定を行い、
// 適切な変換関数を呼び出してください。
final frame = cameraImageToExternalVideoFrame(
image,
sensorOrientation: controller.description.sensorOrientation,
);
// 変換したフレームを映像トラックに投入します。
// writeFrame は void を返すため、成否を戻り値で判別できません。
// 失敗時は StateError がスローされるため、
// この try-catch で捕捉します。
videoTrack.writeFrame(frame);
onFrame?.call(frame);
} catch (error, stackTrace) {
// debugPrint でエラーをログに出力します。
// リリースビルドではdebugPrint の出力は抑制されるため、
// 必要に応じて Crashlytics 等のクラッシュレポートツールへの
// 送信を追加してください。
debugPrint('Frame conversion/writeFrame error: $error\n$stackTrace');
// ここで rethrow しないことが重要です。
// rethrow すると startImageStream のコールバックループが停止し、
// 後続のフレームが処理されなくなります。
}
}
接続前の注意¶
writeFrameは接続完了前でも呼び出せますが、AdaptFrameがフレームをドロップする可能性があります。conn.connect()の完了を待ってからフレーム投入を開始してください。- 切断後も、破棄前のトラックと
LocalMediaStreamはアプリケーション側に残ります。切断後に同じ外部映像トラックを新しい接続で再利用できることは保証されないため、再接続時は新しいLocalMediaStreamとcreateExternalVideoTrack()で生成したトラックを用意してください。 dispose()済みのトラックは再利用できません。dispose()後にwriteFrame()を呼び出すとStateErrorになります。createExternalVideoTrack()はrole: sendonly / sendrecvで使用できますvideoはtrueを指定するか、未指定にします
制限事項¶
- iOS の
bgra8888→I420変換は純粋 Dart では高負荷です。パフォーマンスが出ない場合はlibyuvの導入を検討してください。 - フレーム変換に毎フレーム Y/U/V プレーン相当のメモリ確保が発生します。高解像度時はヒープ負荷に注意してください。
CameraPreviewとstartImageStreamの同時使用は Android / iOS のcameraパッケージでサポートされていますが、一部の古い端末では制限がある場合があります。sensorOrientationはデバイスの回転に動的追従しません。縦横切替が必要な場合はアプリケーション側で検出して補正してください。
サンプル実装¶
リポジトリ内の devtools アプリケーションに camera パッケージを使った external video track のサンプルが含まれています。 devtools の「External Video Track」トグルをオンにして接続すると、 camera パッケージのフレームを writeFrame() 経由で送信します。
- 変換ユーティリティ:
devtools/lib/src/devtools_external_video_source.dart - カメラライフサイクル管理:
devtools/lib/src/devtools_external_camera_manager.dart - 接続フローとの統合:
devtools/lib/main.dart