この記事はHUITアドベントカレンダー5日目の記事です。
前編はこちら
動画表示UIの作成
動画がデコードできたので、画面に表示してみましょう。
今回はWPFアプリケーションを作成するので、WPFを利用して画面描画をします。
動画プレーヤーなので、毎秒60枚程度はフレームを描くことになりうるため、高速な手法が必要です。生半可な実装だと死にます。この場合、WriteableBitmap
クラスを利用するのが適しています。
WriteableBitmap
クラスでは低レベルにピクセルデータを設定すると、DirectX 9により画面に描画してくれます。低レベルはすべてを解決する
最終的に画面に表示させるには、xamlで Image
コントロールを配置し、Source
にバインディングします。
WriteableBitmapの作成
まず画面のDPIを取得します。MainWindow.xaml.csなどのUIに触れるコンテキストに以下のコードを書きます。
ffplay.exeはDPI無視するのでこの時点で私の勝ちです。
var presentationSource = PresentationSource.FromVisual(this);
Matrix matrix = presentationSource.CompositionTarget.TransformFromDevice;
var dpiX = (int)Math.Round(96 * (1 / matrix.M11));
var dpiY = (int)Math.Round(96 * (1 / matrix.M22));
次に、今取得したDPIと前編で作成した Decoder
クラスから WriteableBitmap
クラスを初期化してみましょう。
初期化に画像の幅・高さ・画面のDPI・色空間が必要です。色空間はBGR24(各色8byte、緑が先頭)を指定しています。
多分このフォーマットが一番高パフォーマンスです。試した限りでは。
private static readonly PixelFormat wpfPixelFormat = PixelFormats.Bgr24;
private Decoder decoder;
public WriteableBitmap CreateBitmap(int dpiX, int dpiY)
{
if (decoder is null)
{
throw new InvalidOperationException("描画先を作成する前に動画を開く必要があります。");
}
var context = decoder.VideoCodecContext;
int width = context.width;
int height = context.height;
WriteableBitmap writeableBitmap = new WriteableBitmap(width, height, dpiX, dpiY, wpfPixelFormat, null);
return writeableBitmap;
}
ピクセルデータの書き込み
インデクサなども使えますが、パフォーマンス上ポインタを介した書き込みをおすすめします。C#上で書き込む場合、Buffer.MemoryCopy
関数などを利用します。
この関数は可能ならばCPUの拡張命令によりコピーする実装で、非常に高速なのが特徴です。
ただし、今回はポインタを直接FFmpegに投げますので関係ないです。
まずLock
メソッドを呼んでバックバッファをロックします。バックバッファに書き込んだあと、再描画する必要のある領域(この場合全部)をAddDirtyRect
メソッドを呼んで通知します。このコードスレッド安全性に若干の問題がある気がしますが気にしない。
public class ImageWriter
{
private readonly Int32Rect rect;
private readonly WriteableBitmap writeableBitmap;
public ImageWriter(int width, int height, WriteableBitmap writeableBitmap)
{
this.rect = new Int32Rect(0, 0, width, height);
this.writeableBitmap = writeableBitmap;
}
public void WriteFrame()
{
var bitmap = writeableBitmap;
bitmap.Lock();
#warning ここアトミック保証ないのでは
try
{
IntPtr ptr = bitmap.BackBuffer;
// ここでptrに書き込む
bitmap.AddDirtyRect(rect);
}
finally
{
bitmap.Unlock();
}
}
}
フレームからピクセルデータ
それでは、前編で作成したデコーダからフレームを取り、それをピクセルデータにしましょう。
しかし、動画のフレームとUIでは色情報の表現が異なるため、変換が必要です。この変換には、FFmpegのswsというライブラリを使用します。
swsは(名前の通り)フレームのスケーリングを担うライブラリですが、色変換もできます。
SwsContextの取得
まず、変換のパラメータを設定します。以下のようにsws_getContext
関数を呼び出し、変換のパラメータが設定されたSwsContext
を取得します。
private SwsContext* swsContext;
/// <summary>
/// フレームの変換を設定します。
/// </summary>
/// <param name="srcFormat">変換元のフォーマット。</param>
/// <param name="srcWidth">変換元の幅。</param>
/// <param name="srcHeight">変換元の高さ。</param>
/// <param name="distFormat">変換先のフォーマット。</param>
/// <param name="distWidth">変換先の幅。</param>
/// <param name="distHeight">変換先の高さ。</param>
public void Configure(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat distFormat, int distWidth, int distHeight)
{
this.srcFormat = srcFormat;
this.srcWidth = srcWidth;
this.srcHeight = srcHeight;
this.distFormat = distFormat;
if (this.distWidth == distWidth || this.distHeight == distHeight)
{
return;
}
this.distWidth = distWidth;
this.distHeight = distHeight;
ffmpeg.sws_freeContext(swsContext);
swsContext = ffmpeg.sws_getContext(srcWidth, srcHeight, srcFormat, distWidth, distHeight, distFormat, 0, null, null, null);
}
スケーリングの実行
次にsws_scale
を呼び出しますが、バッファの確保が煩雑なので注意して下さい。
また、前編同様Context系は最後に解放が必要です。
/// <summary>
/// フレームを変換します。変換したフレームを指定したバッファに直接書き込みます。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe void ConvertFrameDirect(AVFrame* frame, byte* buffer)
{
byte_ptrArray4 data = default;
int_array4 lizesize = default;
ffmpeg.av_image_fill_arrays(ref data, ref lizesize, buffer, distFormat, srcWidth, srcHeight, 1)
.OnError(() => throw new InvalidOperationException("フレームスケーリング用バッファの確保に失敗しました。"));
ffmpeg.sws_scale(swsContext, frame->data, frame->linesize, 0, srcHeight, data, lizesize)
.OnError(() => throw new InvalidOperationException("フレームのスケーリングに失敗しました。"));
}
FrameConverter
クラスの作成
以上の機能をクラスにまとめます。前編で作成したManagedFrame
を受け取るメソッドを用意し、安全にします。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using FFmpeg.AutoGen;
namespace FFmpegWraper
{
/// <summary>
/// フレームを変換する機能を提供します。
/// </summary>
public unsafe class FrameConveter : IDisposable
{
public FrameConveter() { }
private AVPixelFormat srcFormat;
private int srcWidth;
private int srcHeight;
private AVPixelFormat distFormat;
private int distWidth;
private int distHeight;
private SwsContext* swsContext;
/// <summary>
/// フレームの変換を設定します。
/// </summary>
/// <param name="srcFormat">変換元のフォーマット。</param>
/// <param name="srcWidth">変換元の幅。</param>
/// <param name="srcHeight">変換元の高さ。</param>
/// <param name="distFormat">変換先のフォーマット。</param>
/// <param name="distWidth">変換先の幅。</param>
/// <param name="distHeight">変換先の高さ。</param>
public void Configure(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat distFormat, int distWidth, int distHeight)
{
this.srcFormat = srcFormat;
this.srcWidth = srcWidth;
this.srcHeight = srcHeight;
this.distFormat = distFormat;
if (this.distWidth == distWidth || this.distHeight == distHeight)
{
return;
}
this.distWidth = distWidth;
this.distHeight = distHeight;
ffmpeg.sws_freeContext(swsContext);
swsContext = ffmpeg.sws_getContext(srcWidth, srcHeight, srcFormat, distWidth, distHeight, distFormat, 0, null, null, null);
}
/// <summary>
/// フレームを変換します。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe byte* ConvertFrame(ManagedFrame frame)
{
return ConvertFrame(frame.Frame);
}
/// <summary>
/// フレームを変換します。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe byte* ConvertFrame(AVFrame* frame)
{
byte_ptrArray4 data = default;
int_array4 lizesize = default;
byte* buffer = (byte*)ffmpeg.av_malloc((ulong)ffmpeg.av_image_get_buffer_size(distFormat, srcWidth, srcHeight, 1));
ffmpeg.av_image_fill_arrays(ref data, ref lizesize, buffer, distFormat, srcWidth, srcHeight, 1)
.OnError(() => throw new InvalidOperationException("フレームスケーリング用バッファの確保に失敗しました。"));
ffmpeg.sws_scale(swsContext, frame->data, frame->linesize, 0, srcHeight, data, lizesize)
.OnError(() => throw new InvalidOperationException("フレームのスケーリングに失敗しました。"));
return buffer;
}
/// <summary>
/// フレームを変換します。変換したフレームを指定したバッファに直接書き込みます。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe void ConvertFrameDirect(ManagedFrame frame, IntPtr buffer)
{
ConvertFrameDirect(frame.Frame, (byte*)buffer.ToPointer());
}
/// <summary>
/// フレームを変換します。変換したフレームを指定したバッファに直接書き込みます。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe void ConvertFrameDirect(ManagedFrame frame, byte* buffer)
{
ConvertFrameDirect(frame.Frame, buffer);
}
/// <summary>
/// フレームを変換します。変換したフレームを指定したバッファに直接書き込みます。
/// </summary>
/// <param name="frame"></param>
/// <returns></returns>
public unsafe void ConvertFrameDirect(AVFrame* frame, byte* buffer)
{
byte_ptrArray4 data = default;
int_array4 lizesize = default;
ffmpeg.av_image_fill_arrays(ref data, ref lizesize, buffer, distFormat, srcWidth, srcHeight, 1)
.OnError(() => throw new InvalidOperationException("フレームスケーリング用バッファの確保に失敗しました。"));
ffmpeg.sws_scale(swsContext, frame->data, frame->linesize, 0, srcHeight, data, lizesize)
.OnError(() => throw new InvalidOperationException("フレームのスケーリングに失敗しました。"));
}
/// <inheritdoc />
public void Dispose()
{
DisposeUnManaged();
GC.SuppressFinalize(this);
}
~FrameConveter()
{
DisposeUnManaged();
}
private bool isDisposed = false;
private void DisposeUnManaged()
{
if (isDisposed) { return; }
ffmpeg.sws_freeContext(swsContext);
isDisposed = true;
}
}
}
フレームを書き込む
ImageWriter
クラスの作成
作成したFrameConverter
クラスを用いて、「ピクセルデータを書き込む」セクションで示した ImageWriter
クラスを完成させます。
これでWriteFrame
関数を呼び出して、前編の ManagedFrame
オブジェクトを WriteableBitmap
に書き込めます。
public class ImageWriter
{
private readonly Int32Rect rect;
private readonly WriteableBitmap writeableBitmap;
public ImageWriter(int width, int height, WriteableBitmap writeableBitmap)
{
this.rect = new Int32Rect(0, 0, width, height);
this.writeableBitmap = writeableBitmap;
}
public void WriteFrame(ManagedFrame frame, FrameConveter frameConveter)
{
var bitmap = writeableBitmap;
bitmap.Lock();
try
{
IntPtr ptr = bitmap.BackBuffer;
frameConveter.ConvertFrameDirect(frame, ptr);
bitmap.AddDirtyRect(rect);
}
finally
{
bitmap.Unlock();
}
}
}
機能をまとめる
ここまでに作成した機能をまとめて、より上位のAPIを作成します。
名前は VideoPlayController
です。後のセクションで再生機能を追加するのでこの名前です。
public class VideoPlayController
{
private static readonly AVPixelFormat ffPixelFormat = AVPixelFormat.AV_PIX_FMT_BGR24;
private static readonly PixelFormat wpfPixelFormat = PixelFormats.Bgr24;
private Decoder decoder;
private ImageWriter imageWriter;
private FrameConveter frameConveter;
public VideoPlayController()
{
}
public void OpenFile(string path)
{
decoder = new Decoder();
decoder.OpenFile(path);
}
public WriteableBitmap CreateBitmap(int dpiX, int dpiY)
{
if (decoder is null)
{
throw new InvalidOperationException("描画先を作成する前に動画を開く必要があります。");
}
var context = decoder.VideoCodecContext;
int width = context.width;
int height = context.height;
WriteableBitmap writeableBitmap = new WriteableBitmap(width, height, dpiX, dpiY, wpfPixelFormat, null);
this.imageWriter = new ImageWriter(width, height, writeableBitmap);
this.frameConveter = new FrameConveter();
frameConveter.Configure(context.pix_fmt, context.width, context.height, ffPixelFormat, width, height);
return writeableBitmap;
}
}
音声の再生
APIの選択
次に音声を再生する機能を実装します。C#で音声を再生するには、NAudio というライブラリが便利です。
ドキュメントが非常によくまとまっており、チュートリアル完備、APIもシンプルと、ここまでFfmpeg APIと悪戦苦闘してきたことを思えば天国です。本当に。
NAudioではいくつかのWindows側のオーディオAPIから何を使うか選べますが、私が音質厨なので 排他モード以外で音楽聞くとかありえないし 高機能なWASAPIを選びます。これでやはりffplay.exeに勝ちました。
WASAPIは最も新しいオーディオAPIで、排他モードにすることでシステムのサウンドミキサーをバイパスしてビットパーフェクトな再生が可能となっているなど、音声の再生が目的のプログラムならば必須級のAPIです。
NAudioは洗練されていますが、カスタマイズ性をもたせるため一応以下のようにラップしました。こんなに完結に書けるほど、NAudioは素晴らしいライブラリです。
using NAudio.Wave;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace VideoPlayer
{
class AudioPlayer
{
public AudioPlayer() { }
private IWavePlayer output;
public async Task Play(IWaveProvider waveProvider, int latency, int delay)
{
output = new WasapiOut(NAudio.CoreAudioApi.AudioClientShareMode.Exclusive, latency);
output.Init(waveProvider);
await Task.Delay(delay);
output.Play();
}
public IWaveProvider FromInt16(Stream stream, int sampleRate, int channel)
{
var provider = new RawSourceWaveStream(stream, new WaveFormat(sampleRate, 16, channel));
return provider;
}
public void Dispose()
{
output.Dispose();
}
}
}
swrの利用
例のごとく、デコーダーから取得したフレームを変換して再生側に流します。昨今の動画では、音声は単精度浮動小数点数(float)として得られることが多く
そのままだと再生側が受け付けなかったりするためです。今回は16bitのPCMに変換します。
変換にはFFmpegのswrというライブラリを利用します。今までのに比べるとシンプルです。これをシンプルですと言っている自分が怖い
今回はC#側でAllocHGlobal
によりバッファを確保しています。もちろん解放しないといけないので結果をAudioData
クラスでラップしています。
そろそろGCが全部やってくれる世界に帰りたい
using FFmpeg.AutoGen;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace FFmpegWraper
{
public class AudioFrameConveter
{
public static unsafe AudioData ConvertTo<TOut>(ManagedFrame frame) where TOut : OutputFormat, new()
{
return ConvertTo<TOut>(frame.Frame);
}
public static unsafe AudioData ConvertTo<TOut>(AVFrame* frame) where TOut : OutputFormat, new()
{
var output = new TOut();
var context = ffmpeg.swr_alloc();
ffmpeg.av_opt_set_int(context, "in_channel_layout", (long)frame->channel_layout, 0);
ffmpeg.av_opt_set_int(context, "out_channel_layout", (long)frame->channel_layout, 0);
ffmpeg.av_opt_set_int(context, "in_sample_rate", frame->sample_rate, 0);
ffmpeg.av_opt_set_int(context, "out_sample_rate", frame->sample_rate, 0);
ffmpeg.av_opt_set_sample_fmt(context, "in_sample_fmt", (AVSampleFormat)frame->format, 0);
ffmpeg.av_opt_set_sample_fmt(context, "out_sample_fmt", output.AVSampleFormat, 0);
ffmpeg.swr_init(context);
int size = output.SizeOf;
int bufferSize = frame->nb_samples * frame->channels * size;
var buffer = Marshal.AllocHGlobal(bufferSize);
byte* ptr = (byte*)buffer.ToPointer();
ffmpeg.swr_convert(context, &ptr, frame->nb_samples, frame->extended_data, frame->nb_samples);
return new AudioData()
{
Samples = frame->nb_samples,
SampleRate = frame->sample_rate,
Channel = frame->channels,
SizeOf = size,
Data = buffer,
};
}
}
public class AudioData : IDisposable
{
public int Samples { get; set; }
public int SampleRate { get; set; }
public int Channel { get; set; }
public int SizeOf { get; set; }
public IntPtr Data { get; set; }
public unsafe ReadOnlySpan<byte> AsSpan()
{
return new ReadOnlySpan<byte>(Data.ToPointer(), Samples * Channel * SizeOf);
}
public void Dispose()
{
Marshal.FreeHGlobal(Data);
}
}
public abstract class OutputFormat
{
public abstract AVSampleFormat AVSampleFormat { get; }
public abstract int SizeOf { get; }
}
public class PCMInt16Format : OutputFormat
{
public PCMInt16Format() { }
public override AVSampleFormat AVSampleFormat => AVSampleFormat.AV_SAMPLE_FMT_S16;
public override int SizeOf => sizeof(ushort);
}
}
プレーヤーの実装
長過ぎる本記事もついにクライマックスです。
いよいよプレーヤーを完成させます。
タイマーの処理
内部でタイマーを持ち、動画のフレームレートに合わせたタイミングで音声の再生開始指示とフレームの描画を行います。
C#で普通に扱えるものだと StopWatch
クラス( System.Diagnostics
名前空間)が一番高精度です。
本当はWindowsのAPIを叩けばより高精度のタイマーがありますが今回は StopWatch
でいきます。
また、正確な時間に描画できるよう、フレームを数フレームだけ先読みしておきます。動画フレームのデコードは重いので時間がかかりますが、一方先読みするとメモリ使用量が増えますので色々相談して先読みするフレーム数を決めましょう。今回は最大で4フレーム先読みします。
オーディオのストリーミング
今のところ、オーディオの逐次再生ができていません。どうやらNAudioのAPIは Stream
を引数に取っていてもストリーミングできないようで、そのへんの匙加減は独自実装する必要があるっぽいです。そこまで難しいわけではないのですが、FFmpegとの戦いでMPが尽きました。 今の所はオーディオを全部先読みしています。メモリ使用量がひどいです。
音ズレ
最後に、動画と音声のタイミングを合わせる必要があります。全く同じタイミングに描画/再生APIを呼んでも平気でズレます。まずWPF内部のレンダリングプロセスにより遅延があり、さらにディスプレイのリフレッシュレートに起因する遅延があります。これをちゃんと合わせるためには、自前でDirectXを直接握って描画するとか、垂直同期をとるとかするしかありません。どちらもWPFではできないので茨の道です。やはりMPが足りないので 音声に500msのディレイを入れてごまかします。
音ズレの分析と調整には音MAD動画を再生するといいです。笑われたんですがこれは本質情報です。
VideoPlayController
クラスの作成
本記事で登場したすべてのコードとクラスをあわせた最終形態を作ります。
OpenFile(string path)
で動画を開き、 CreateBitmap
でxamlにバインディングするための WriteableBitmap
を取得し、Play()
を呼ぶと再生します。
public class VideoPlayController
{
private static readonly AVPixelFormat ffPixelFormat = AVPixelFormat.AV_PIX_FMT_BGR24;
private static readonly PixelFormat wpfPixelFormat = PixelFormats.Bgr24;
private Decoder decoder;
private ImageWriter imageWriter;
private FrameConveter frameConveter;
public VideoPlayController()
{
}
public void OpenFile(string path)
{
decoder = new Decoder();
decoder.OpenFile(path);
}
public WriteableBitmap CreateBitmap(int dpiX, int dpiY)
{
if (decoder is null)
{
throw new InvalidOperationException("描画先を作成する前に動画を開く必要があります。");
}
var context = decoder.VideoCodecContext;
int width = context.width;
int height = context.height;
WriteableBitmap writeableBitmap = new WriteableBitmap(width, height, dpiX, dpiY, wpfPixelFormat, null);
this.imageWriter = new ImageWriter(width, height, writeableBitmap);
this.frameConveter = new FrameConveter();
frameConveter.Configure(context.pix_fmt, context.width, context.height, ffPixelFormat, width, height);
return writeableBitmap;
}
public async Task Play()
{
await PlayInternal();
}
const int frameCap = 4;
const int waitTime = 10;
private bool isFrameEnded;
private ConcurrentQueue<ManagedFrame> frames = new ConcurrentQueue<ManagedFrame>();
private async Task PlayInternal()
{
Task.Run(() => ReadFrames());
// init audio(仮実装、メモリ上に全展開するクソコード)
AudioData firstData;
using (var _frame = decoder.ReadAudioFrame())
{
firstData = AudioFrameConveter.ConvertTo<PCMInt16Format>(_frame);
}
MemoryStream stream = new();
AudioPlayer audioPlayer = new();
stream.Write(firstData.AsSpan());
while (true)
{
using (var frame2 = decoder.ReadAudioFrame())
{
if (frame2 is null)
{
break;
}
using (var audioData2 = AudioFrameConveter.ConvertTo<PCMInt16Format>(frame2))
{
stream.Write(audioData2.AsSpan());
}
}
}
stream.Position = 0;
var source = audioPlayer.FromInt16(stream, firstData.SampleRate, firstData.Channel);
firstData.Dispose();
// end of init audio
await WaitForBuffer();
var fps = decoder.VideoStream.r_frame_rate;
Stopwatch stopwatch = Stopwatch.StartNew();
int skipped = 0;
List<double> delays = new();
for (int i = 0; ; i++)
{
TimeSpan time = TimeSpan.FromMilliseconds(fps.den * i * 1000L / (double)fps.num);
if (stopwatch.Elapsed < time)
{
var rem = time - stopwatch.Elapsed;
await Task.Delay(rem);
}
if (frames.TryDequeue(out var frame))
{
imageWriter.WriteFrame(frame, frameConveter);
if (i == 0)
{
await audioPlayer.Play(source, 50, 500);
}
frame.Dispose();
}
else
{
if (isFrameEnded)
{
audioPlayer.Dispose();
stream.Dispose();
return;
}
skipped++;
Debug.WriteLine($"frame skipped(frame={i},total={skipped}/{i})");
}
}
}
private async Task WaitForBuffer()
{
while (true)
{
if (frames.Count == frameCap)
{
return;
}
await Task.Delay(waitTime);
}
}
private async Task ReadFrames()
{
while (true)
{
if (frames.Count < frameCap)
{
var frame = decoder.ReadFrame();
if (frame is null)
{
isFrameEnded = true;
return;
}
frames.Enqueue(frame);
}
else
{
await Task.Delay(waitTime);
}
}
}
}
ついに動作の時
ゲーム「原神」のサウンドトラックのMVを流してみました。元データはここ
画像では伝わりませんが、FHDの動画もなめらかに再生できており、また動画と音声の同期も取れています。画質・音質も問題ありません。
感想・最後に
一時停止もシークもできない、ただ再生するだけのプレーヤーですが、Visual Studioのコードメトリクス読みで(空行などを含まないで)1227行のコードになりました。
これから毎日既存の動画プレーヤーに感謝して生活したいと思います。
冗談はさておき、FFmpeg APIの使い方や動画プレーヤーのしくみが理解できたので、開発としては目標達成です。今後は本格的なプレーヤーを開発してみたいと思っています。
最後に一言... FFmpeg APIの使用は一部のマニア以外にはおすすめしません