以前の記事ではアプリ内で動画を再生する方法をご紹介しましたが、今回は Xamarin.Forms でカメラを使用して動画を撮影し、プレビュー画面を表示しながら録画し、ファイルとして保存する方法をご紹介いたします。Android と iOS でカメラを操作するネイティブライブラリが異なるため、DependencyServiceで実装します。動画を撮影して保存するだけなのですが、結構なソースコード量が必要となりますので、実装には時間がかかりました。
前提条件
・Windows10 Pro 64Bit 1709
・Visual Studio 2015 Community Update3
・Xamarin 4.8.0.760 (NuGet Xamarin.Forms 2.4.0.282)
・macOS Sierra 10.12.6 / Xcode 9 / Xamarin.iOS 11.6.1.4
1.PCLの記述方法
PCL プロジェクト内に DependencyService で呼び出すためのインターフェースを配置します。
IVideoService.cs
namespace AppName.Services
{
//DependencyServiceから利用する
public interface IVideoService
{
void PrepareRecord(string saveFilePath);
void StartRecord();
void StopRecord();
}
}
2.Androidの実装方法
(1)AndroidManifest.xml に以下のパーミッションとハードウェアアクセラレーションを有効にする設定を追加します。
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application android:hardwareAccelerated="true" />
※application のタグは2つ以上含めてはいけません。既に AndroidManifest.xml に application タグが存在する場合は、android:hardwareAccelerated="true" のみ追記してください。
(2)Android プロジェクト内に以下のクラス群を実装します。
※Android5.0 以降で Camera API2 を使用する必要がある為、Android5 よりも前のバージョンの動作は対象外です。
VideoService.cs
using System;
using System.Threading.Tasks;
using Android.App;
using Android.Views;
using Android.Widget;
using Android.Media;
using Xamarin.Forms;
using AppName.Droid.Services;
using AppName.Services;
[assembly: Dependency(typeof(VideoService))]
namespace AppName.Droid.Services
{
public class VideoService : IVideoService
{
#region "録画"
private static MediaRecorder _recorder = null;
private LinearLayout _linearLayout = null;
private TextureView _textureView = null;
private SurfaceTextureListener _listener = null;
private bool _isRecording = false;
public void PrepareRecord(string saveFilePath)
{
//MediaRecorderを設定します。
this.SetUpMediaRecorder(saveFilePath);
// MediaRecorderのプレビュー用のSurfaceViewを作成する
var context = Forms.Context;
//入力項目を格納するレイアウト
_linearLayout = new LinearLayout(context);
_linearLayout.LayoutParameters = new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MatchParent,
ViewGroup.LayoutParams.MatchParent);
_linearLayout.SetBackgroundColor(Android.Graphics.Color.White);
((MainActivity)context).AddContentView(_linearLayout,
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WrapContent,
ViewGroup.LayoutParams.WrapContent));
_textureView = new TextureView(context);
_textureView.SurfaceTextureListener = new SurfaceTextureListener(_recorder);
_linearLayout.AddView(_textureView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WrapContent, ViewGroup.LayoutParams.WrapContent));
}
private void SetUpMediaRecorder(string saveFilePath)
{
if (_recorder == null)
{
//MediaRecorderの設定
_recorder = new MediaRecorder();
// 入力ソースの設定
_recorder.SetVideoSource(VideoSource.Surface); // 録画の入力ソースを指定
_recorder.SetAudioSource(AudioSource.Mic); // 音声の入力ソースを指定
// ファイルフォーマットの設定
_recorder.SetOutputFormat(OutputFormat.ThreeGpp); // ファイルフォーマットを指定
// エンコーダーの設定
//_recorder.SetVideoEncoder(VideoEncoder.Mpeg4Sp); // ビデオエンコーダを指定
//_recorder.SetAudioEncoder(AudioEncoder.AmrNb); // オーディオエンコーダを指定
_recorder.SetVideoEncoder(VideoEncoder.H264); // ビデオエンコーダを指定
_recorder.SetAudioEncoder(AudioEncoder.Aac); // オーディオエンコーダを指定
// 各種設定
_recorder.SetOutputFile(saveFilePath); // 動画の出力先となるファイルパスを指定
_recorder.SetVideoEncodingBitRate(10000000);
_recorder.SetVideoFrameRate(29); //信号機の点滅レートも30 動画のフレームレートを指定
_recorder.SetVideoSize(320, 240); // 動画のサイズを指定
_recorder.Prepare(); // 録画準備
}
}
public void StartRecord()
{
if (_recorder != null)
{
_textureView.Visibility = ViewStates.Visible;
// 録画開始
_recorder.Start();
_isRecording = true;
}
}
public void StopRecord()
{
if (_isRecording)
{
if (_textureView != null)
{
_textureView.Visibility = ViewStates.Invisible;
}
// 録画終了
_recorder.Stop();
_recorder.Reset();
_recorder.Release();
_recorder = null;
if (_linearLayout != null)
{
((ViewGroup)_linearLayout.Parent).RemoveView(_linearLayout);
_linearLayout.Dispose();
_linearLayout = null;
}
if (_listener != null)
{
_listener.StopCamera2();
_listener.Dispose();
_listener = null;
}
_isRecording = false;
}
}
#endregion
}
public class SurfaceTextureListener : Java.Lang.Object, TextureView.ISurfaceTextureListener
{
#region "TextureView"
private MediaRecorder _recorder = null;
private CameraManager _manager = null;
private CameraCallBack _callback = null;
/// <summary>
/// カメラ2デバイスを開始する
/// </summary>
/// <param name="surfaceTexture"></param>
public SurfaceTextureListener(MediaRecorder recorder)
{
_recorder = recorder;
}
public void OpenCamera2(SurfaceTexture surfaceTexture)
{
//Camera2
CameraManager manager = (CameraManager)Android.App.Application.Context.GetSystemService(Context.CameraService);
//string cameraId = manager.GetCameraIdList().Where(r => manager.GetCameraCharacteristics(r).Get(CameraCharacteristics.LensFacing).ToString() == "").FirstOrDefault();
string cameraId = manager.GetCameraIdList().FirstOrDefault();
CameraCharacteristics cameraCharacteristics = manager.GetCameraCharacteristics(cameraId);
Android.Hardware.Camera2.Params.StreamConfigurationMap scm = (Android.Hardware.Camera2.Params.StreamConfigurationMap)cameraCharacteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap);
var previewSize = scm.GetOutputSizes((int)ImageFormatType.Jpeg)[0];
manager.OpenCamera(cameraId, new CameraCallBack(_recorder, surfaceTexture, previewSize), null);
}
/// <summary>
/// カメラ2デバイスを停止する
/// </summary>
public void StopCamera2()
{
if (_callback != null)
{
_callback.Disconnect();
_callback.Dispose();
_callback = null;
}
if (_manager != null)
{
_manager.Dispose();
_manager = null;
}
}
public void OnSurfaceTextureAvailable(SurfaceTexture surface, int width, int height)
{
this.OpenCamera2(surface);
}
public bool OnSurfaceTextureDestroyed(SurfaceTexture surface)
{
return true;
}
public void OnSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height)
{
//throw new NotImplementedException();
}
public void OnSurfaceTextureUpdated(SurfaceTexture surface)
{
//throw new NotImplementedException();
}
#endregion
}
public class CameraCallBack : CameraDevice.StateCallback
{
private MediaRecorder _recorder = null;
private CameraDevice _cameraDevice = null;
private SurfaceTexture _surfaceTexture = null;
private CaptureRequest _captureRequest = null;
private Android.Util.Size _previewSize = null;
public CameraCallBack(MediaRecorder recorder, SurfaceTexture surfaceTexture, Android.Util.Size previewSize)
{
_recorder = recorder;
_surfaceTexture = surfaceTexture;
_previewSize = previewSize;
}
/// <summary>
/// カメラを開放する
/// </summary>
public void Disconnect()
{
if (_cameraDevice != null)
{
_cameraDevice.Close();
_cameraDevice = null;
}
}
public override void OnDisconnected(CameraDevice camera)
{
//_cameraDevice.Close();
//_cameraDevice = null;
}
public override void OnError(CameraDevice camera, Android.Hardware.Camera2.CameraError error)
{
LogUtility.OutPutError(error.ToString());
_cameraDevice.Close();
_cameraDevice = null;
}
public override void OnOpened(CameraDevice camera)
{
_cameraDevice = camera;
this.CreateCaptureSession();
}
private void CreateCaptureSession()
{
//SurfaceTexture texture = _textureView.SurfaceTexture; //エラーになる
//バッファのサイズをプレビューサイズに設定(画面サイズ等適当な値を入れる)
_surfaceTexture.SetDefaultBufferSize(_previewSize.Width, _previewSize.Height);
Surface surface = new Surface(_surfaceTexture);
List<Surface> list = new List<Surface>();
list.Add(surface);
list.Add(_recorder.Surface);
CaptureRequest.Builder captureRequest = _cameraDevice.CreateCaptureRequest(CameraTemplate.Record);
captureRequest.AddTarget(surface);
captureRequest.AddTarget(_recorder.Surface);
_captureRequest = captureRequest.Build();
_cameraDevice.CreateCaptureSession(list, new CameraCaputureSessionCallBack(_captureRequest), null);
}
}
//キャプチャセッションの状態取得
public class CameraCaputureSessionCallBack : CameraCaptureSession.StateCallback
{
private CaptureRequest _captureRequest = null;
public CameraCaputureSessionCallBack(CaptureRequest captureRequest)
{
_captureRequest = captureRequest;
}
public override void OnConfigured(CameraCaptureSession session)
{
session.SetRepeatingRequest(_captureRequest, new CameraCaptureSessionCallBack(), null);
//session.StopRepeating();
session.Capture(_captureRequest, new CameraCaptureSessionCallBack(), null);
}
public override void OnConfigureFailed(CameraCaptureSession session)
{
//throw new NotImplementedException();
}
}
//キャプチャー開始
public class CameraCaptureSessionCallBack : CameraCaptureSession.CaptureCallback
{
public override void OnCaptureStarted(CameraCaptureSession session, CaptureRequest request, long timestamp, long frameNumber)
{
base.OnCaptureStarted(session, request, timestamp, frameNumber);
}
public override void OnCaptureCompleted(CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result)
{
base.OnCaptureCompleted(session, request, result);
}
}
}
※一時停止やボリューム・停止・再生などの操作ボタンは表示されません。
必要な操作があればソースを追加で記述してください。
※撮影できる拡張子には制限があるようです。
撮影可能拡張子:3gp, mp4
撮影不可拡張子:wmv, flv
2019/04/21追記
他のカメラアプリの動作を阻害しないようにカメラデバイスを開放するコードを追加しました。
3.iOSの実装方法
(1)Info.plist ファイルに以下のパーミッションを追加します。
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>ビデオ撮影のためにカメラを使用します。</string>
<key>NSMicrophoneUsageDescription</key>
<string>ビデオ撮影のためにマイクを使用します。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>ビデオ撮影を保存するためにフォトライブラリを利用します。</string>
</dict>
</plist>
(2)iOS プロジェクト内に以下のクラスを配置します。
VideoService.cs
using System;
using System.IO;
using Foundation;
using UIKit;
using AVFoundation;
using AVKit;
using CoreGraphics;
using Xamarin.Forms;
using AppName.iOS.Services;
using AppName.Services;
[assembly: Dependency(typeof(VideoService))]
namespace AppName.iOS.Services
{
public class VideoService : IVideoService
{
private CaptureController _captureController = null;
private UIViewController _viewController = null;
public void PrepareRecord(string saveFilePath)
{
_captureController = new CaptureController(saveFilePath);
_viewController = UIApplication.SharedApplication.KeyWindow.RootViewController;
while (viewController.PresentedViewController != null)
{
_viewController = viewController.PresentedViewController;
}
_viewController.PresentViewController(_captureController, true, () =>
{
});
}
public void StartRecord()
{
_captureController.Start();
}
public void StopRecord()
{
_captureController.Stop();
_viewController.DismissViewController(true, null);
_captureController.Dispose();
_captureController = null;
}
}
public class CaptureController : UIViewController, IAVCaptureFileOutputRecordingDelegate, IAVCapturePhotoCaptureDelegate
{
private AVCaptureSession _captureSession = null;
private AVCaptureMovieFileOutput _movieOutput = null;
private AVCaptureVideoPreviewLayer _videoLayer = null;
private AVCaptureDeviceInput _videoInput = null;
private AVCaptureDeviceInput _audioInput = null;
private bool _isRecording = false;
private NSUrl _url = null;
public CaptureController(string saveFilePath) : base()
{
_url = NSUrl.FromFilename(saveFilePath);
}
public override void ViewDidLoad()
{
base.ViewDidLoad();
// セッションのインスタンス化
_captureSession = new AVCaptureSession();
// デバイスの初期化
//AVCaptureDevice videoDevice = AVCaptureDevice.GetDefaultDevice(AVMediaType.Video); // カメラ
AVCaptureDevice videoDevice = AVCaptureDevice.GetDefaultDevice(AVCaptureDeviceType.BuiltInWideAngleCamera, AVMediaType.Video, AVCaptureDevicePosition.Back); // カメラ
//AVCaptureDevice audioDevice = AVCaptureDevice.GetDefaultDevice(AVMediaType.Audio); // マイク
AVCaptureDevice audioDevice = AVCaptureDevice.GetDefaultDevice(AVCaptureDeviceType.BuiltInMicrophone, AVMediaType.Audio, AVCaptureDevicePosition.Unspecified); // マイク
// デバイスの接続
_videoInput = AVCaptureDeviceInput.FromDevice(videoDevice);
_audioInput = AVCaptureDeviceInput.FromDevice(audioDevice);
_captureSession.AddInput(videoInput);
_captureSession.AddInput(audioInput);
// 出力の初期化
_movieOutput = new AVCaptureMovieFileOutput(); // 映像ファイル
// 出力の接続
if (_captureSession.CanAddOutput(_movieOutput))
{
_captureSession.AddOutput(_movieOutput);
}
// キャプチャの品質レベル、ビットレートなどのクオリティを設定
this.ConfigureSession();
_videoLayer = new AVCaptureVideoPreviewLayer(_captureSession);
_videoLayer.Frame = this.View.Bounds;
_videoLayer.VideoGravity = AVLayerVideoGravity.ResizeAspectFill;
this.View.Layer.AddSublayer(_videoLayer);
}
public override bool ShouldAutorotateToInterfaceOrientation(UIInterfaceOrientation toInterfaceOrientation)
{
// Return true for supported orientations
return (toInterfaceOrientation != UIInterfaceOrientation.PortraitUpsideDown);
}
public void Start()
{
if (_captureSession == null)
{
return;
}
var task = new System.Threading.Tasks.Task(() =>
{
// セッション開始
_captureSession.StartRunning();
//長い動画の場合に音が録音されない問題を解消する
_movieOutput.MovieFragmentInterval = CoreMedia.CMTime.Invalid;
// 録画開始
_movieOutput.StartRecordingToOutputFile(_url, this);
});
task.Start();
_isRecording = true;
}
public void Stop()
{
if (!_isRecording)
{
return;
}
//レイヤーを取り除く
if (_videoLayer != null)
{
_videoLayer.RemoveFromSuperLayer();
}
// 録画終了
_movieOutput.StopRecording();
// セッション終了
if (_captureSession != null)
{
_captureSession.RemoveInput(_videoInput);
_captureSession.RemoveInput(_audioInput);
_captureSession.StopRunning();
_captureSession.Dispose();
_captureSession = null;
}
if (_videoInput != null)
{
_videoInput.Dispose();
_videoInput = null;
}
if (_audioInput != null)
{
_audioInput.Dispose();
_audioInput = null;
}
_isRecording = false;
}
public void ConfigureSession()
{
if (_captureSession == null)
{
return;
}
if (_isRecording)
{
_captureSession.BeginConfiguration();
}
// キャプチャの品質レベル、ビットレートなどのクオリティを設定
if (_captureSession.CanSetSessionPreset(AVCaptureSession.PresetHigh))
{
_captureSession.SessionPreset = AVCaptureSession.PresetHigh;
}
else
{
_captureSession.SessionPreset = AVCaptureSession.PresetMedium;
}
if (_isRecording)
{
_captureSession.CommitConfiguration();
}
}
public void FinishedRecording(AVCaptureFileOutput captureOutput, NSUrl outputFileUrl, NSObject[] connections, NSError error)
{
//iOS9
//var assetsLib = new ALAssetsLibrary();
//assetsLib.WriteVideoToSavedPhotosAlbum(_url, null);
//iOS10移行
if (UIDevice.CurrentDevice.CheckSystemVersion(10, 0))
{
if (Photos.PHPhotoLibrary.AuthorizationStatus == Photos.PHAuthorizationStatus.NotDetermined ||
Photos.PHPhotoLibrary.AuthorizationStatus == Photos.PHAuthorizationStatus.Authorized)
{
Photos.PHPhotoLibrary.SharedPhotoLibrary.PerformChanges(() =>
{
//「Cocoa Error -1 操作を完了できませんでした」というエラーになる場合がある。
//Photos.PHAssetChangeRequest.FromVideo(outputFileUrl);
//以下の方法の方が望ましい。
var creationRequest = Photos.PHAssetCreationRequest.CreationRequestForAsset();
var options = new Photos.PHAssetResourceCreationOptions
{
OriginalFilename = System.IO.Path.GetFileName(url.ToString()),
ShouldMoveFile = true
};
creationRequest.AddResource(Photos.PHAssetResourceType.Video, url, options);
}, (success, err) =>
{
if (!success)
{
Debug.WriteLine(err.LocalizedDescription + System.Environment.NewLine + err.LocalizedFailureReason);
}
});
}
}
}
}
}
※撮影できる拡張子には制限があるようです。
撮影可能拡張子:mp4
撮影不可拡張子:wmv, flv
※2019/05/01追記
前面/背面のカメラ切り替え時に音声が録画できない問題があり、AVCaptureDevice の設定方法及び、AVCaptureDeviceInput の取扱方法を変更しました。
※2019/05/12追記
長い動画の場合に音が録音されない問題を解消するコードを追加しました。
AVCaptureMovieFileOutput の変数名が異なっていましたので修正しました。
※2019/10/26追記
動画を保存する際に、「Cocoa Error -1 : 操作を完了できませんでした」 のエラーが発生する不具合に対応しました。
4.使用方法
PCLプロジェクトの中の任意のページに記述します。
TestPage.xaml.cs
using AppName.Services;
using Xamarin.Forms;
public class TestPage : ContentPage
{
void StartRecordTest(object sender, EventArgs e)
{
string extension = ".mp4"; //".3gp";
//if (Device.RuntimePlatform == Device.iOS)
//{
// extension = ".mp4";
//}
DependencyService.Get<IVideoService>().PrepareRecord(Common.GetDocumentPath() + "/Record_" + DateTimeOffset.Now.LocalDateTime.ToString("yyyyMMddHHmmss") + extension);
DependencyService.Get<IVideoService>().StartRecord();
var task = new Task(() =>
{
//20秒撮影している間待機
Task.Delay(20000).Wait();
//停止時にUIを操作する為、Device.BeginInvokeOnMainThreadで囲みます
Device.BeginInvokeOnMainThread(() =>
{
//撮影を停止します。
DependencyService.Get<IVideoService>().StopRecord();
});
});
task.Start();
}
}
※ PrepareRecord の引数にはディレクトリを含めたファイルパス(フルパス)を渡します。
※ Androidで録画できる拡張子をiOSと同じmp4に統一しました。
最後までお読みいただきありがとうございます。
当ブログの内容をまとめた Xamarin逆引きメニュー は以下のURLからご覧になれます。
https://itblog.dynaspo.com/blog-entry-81.html