- 投稿日:2021-12-05T23:15:02+09:00
C#でカノジョをつくる(5日目)
お疲れ様です。たなしょです。 このような駄文を垂れ流している記事にコメントありがとうございます。励みになります。 今日はC#で第一級市民化やラムダ式について勉強しました。 この辺から難易度が上がってきました。 第一級市民化 ユーザー定義型を組み込み型と区別しないように扱うこと?と書いてありますがよく理解できませんでした。 ユーザーが定義した方にも+,-などが使えるということなんでしょうかね? using System; class Counter { public int Count { get; private set; } public Counter() : this(0) { } private Counter(int count) { Count = count; } public static Counter operator++(Counter x) { return new Counter(x.Count + 1); } } class Sample { static void Main() { var c = new Counter(); var c1 = ++c; Console.WriteLine(c1.Count); var c2 = c++; Console.WriteLine(c2.Count); Console.WriteLine(c.Count); } } 上のコードの場合、宣言したcounterクラスを2回インクリメントしてるから最終的にConsole.WriteLine(c.Count); で出力される値は2になります。 public static Counter operator++(Counter x) { return new Counter(x.Count + 1); } 上記のCounterクラスのopratorを2回処理してるから値が2になるということですね。 いろいろな演算子で実行できる &や|などの演算子でも判定するようになってるみたいですね。 宣言した型名 operator 演算子の様に宣言するそうです。 using System; struct IntBool { private int i; public IntBool(int i) { this.i = i == 0 ? 0 : 1; } public static bool operator true(IntBool b) { Console.WriteLine("operator true"); return b.i != 0; } public static bool operator false(IntBool b) { Console.WriteLine("operator false"); return b.i == 0; } public static IntBool operator &(IntBool a, IntBool b) { Console.WriteLine("operator &"); return new IntBool(a.i & b.i); } public static IntBool operator | (IntBool a, IntBool b) { Console.WriteLine("operator |"); return new IntBool(a.i | b.i); } public override string ToString() { return i == 0 ? "false" : "true"; } } コレクション初期化子 JavaでいうところArrayListみたいなやつなんですかね? IEnumerableインターフェイスを実装していることとAddメソッドを持っていることがコレクション初期化子になる条件だそうです。 よくわかりません。 class CollectionInitializable : IEnumerable { public void Add(string item) { Console.WriteLine($"{item} Added"); } public IEnumerator GetEnumerator() { throw new NotImplementedException(); } } そもそもIEnumerableって何だろうと思って公式ドキュメントを読んでみたところ、反復処理をサポートしているそうです。 GetEnumeratorも反復処理返すIEnumerableのメソッドです。 インデクサー 配列みたくユーザー定義型に添え字アクセスできる文法という認識です。 using System; class Program { static void Main() { var x = new RangedArray<int>(1, 3); x[1] = 1; x[2] = 2; x[3] = 4; Console.WriteLine(x[3]); } } class RangedArray<T> { T[] array; int lower; public RangedArray(int lower, int length) { this.lower = lower; array = new T[length]; } public T this[int i] { set { this.array[i - lower] = value; } get { return this.array[i - lower] ; } } } 正直、第一級市民化をすることで可読性が高くなるのかなと疑問に思っています。 C#の文法を理解している人にとっては使いやすいのかもしれないですが、そうでない人は初見でソースコードを理解することが難しいのかなと思いました。大規模な開発なってくればありがたみがわかるのでしょうか。 デリゲート 間接的に関数を呼び出してくれる函?(関数?)的なものだと理解しました。 class Program { static void Main() { Func<int, int, int> f = Add; Console.WriteLine(f(1, 2)); } static int Add(int x, int y) { return x + y; } } 匿名関数、ラムダ式 デリゲートへ渡すための名前のないメソッドのことだそうです。 ラムダ式で書かれるイメージがあります。 class Smaple { static void Filter(List<int> input, Predicate<int> pred) { foreach (var x in input) { if (pred(x)) Console.WriteLine(x); } } static void Main() { var foo = new List<int>() { 1, 2, 3, 4, 5 }; Filter(foo, x => x > 3); } } x => x > 3と書かれている部分がラムダ式です。 (パッとみ関数型言語みたいですよね。いつかLispやHaskellを勉強してみたいです。) ローカル関数 匿名関数と似ているようで少し違うみたいです。 関数メンバーの直下にしか書けないのがローカル関数でどこでも書けるのが匿名関数らしいです。 下の例だとローカル関数と判別されます。 int fn(int n)で宣言されている関数の中でラムダ式を展開しています。 class Program { static void Main() { int fn(int n) => n >= 1 ? n * fn(n - 1) : 1; Console.WriteLine(fn(10)); } } 下の例だと匿名関数と判別されます。 一度関数fnを宣言した後に、ラムダ式を展開しています。 class Program { static void Main() { Func<int, int> fn = null; fn = n => n >= 1 ? n * fn(n - 1) : 1; Console.WriteLine(fn(10)); } } 拡張メソッド 静的メソッドに色々な機能を追加できるみたいなことですよね。 いまいち本の説明だと理解できませんでした...。 最後に ラムダ式は便利そうなと文法が関数型言語みたいでかっこいいので使っていきたいです。 だんだんと一回通して読んだだけだと理解できない部分が増えてきたので、 マイクロソフトのドキュメントと並行しながら読む機会が多くなりそうです。
- 投稿日:2021-12-05T21:46:14+09:00
pleasanter API接続用 Glue code生成プログラムを作ってみた
概要 pleasanterのサイトパッケージを読み込んでGlue codeを生成するプログラムを作りました。 SWIG,Swagger Codegenのpleasanter版みたいなものです。 pleasanterはAPIを使うことで簡単に外部連携する事ができます。 しかし、APIをプログラムから呼び出すときに、APIとpleasanterのテーブル定義のマッピングをするGlue codeが必要です。 特に要素A〜Zとなっている任意項目をプログラム側で触るときにはなんかしらの名前をつけて触りたいのですがそこが手動となってしまっています。 Glue codeの生成はなかなか面倒なのですが、これを半自動化する事を目指しました。 まだまだβ版なので、あんまり期待しないでね(ノД`)シクシク 構成 プログラムの構成を以下に示します。 プログラムは大きく分けて2この要素から構成されます。 No 区分 プログラム名 概要 URL 1 CLI HackPleasanterApiCli Glue codeを生成するプログラムです。 CLI 2 ライブラリ HackPleasanterApi.Csharp Glue code生成のためのテンプレート、Glue codeを実行するためのライブラリです。 LIB 使い方は、以下となります。 pleasanterから接続対象のサイトパッケージをエクスポートする。 CLIを使ってサイトパッケージから、Glue code生成対象定義用のCSVを生成 CSV上でGlue code用のテーブル名、カラム名を命名 CLIを使ってCSVからGlue codeを生成 Glue codeを使ってプログラムを書く CLIの使い方 CLIの使い方はGit Hubのreadmeを参照してください。 基本的には、dockerで稼働するイメージとなっています。 CLI リポジトリ ライブラリ C# からの使い方 生成されたGlue codeを使ってライブラリを経由してpleasanterを呼び出すコード例を示します。 コード自体はHackPleasanterApi.Csharpに存在します。 また、Nugetで配布しているので使うだけならばGitHubから落とす必要はありません。 Glue codeとして生成されるコードは大きく分けて以下2種類となります。 No 種別 概要 1 Model pleasanterにおいてテーブルの1要素と対応する項目となります。 2 Service pleasanterにおいてテーブルと対応する項目となります。CRUD操作はここから実施します。 ここから、説明に使用するコードはC#ライブラリのに含まれるテストコードとなります。 プログラム側からの詳細な使い方を知りたい場合はテストコードを参照してください。 想定するテーブル構造は以下となります。 (任意に指定できる項目にA、Bの要素にそれぞれ入力項目が指定されています。) 基本 基本的なパターンはを示します。 このテーブルに対して、Glue codeから生成されたコードは「RecordingTableService」と「RecordingTableModel」となります。 RecordingTableModelには、大きく分けて2種類のメンバーが存在します。 No 種別 概要 1 BasicItemData pleasanterのテーブルにおいて標準的に存在する項目を保持するメンバー。ID、タイトルなど...。 2 ExtensionElements 各型毎にA〜Zで存在しユーザーが独自設定した要素に対してユーザーが独自に命名した要素。 ExtensionElements側が特に重要かと思います。 基本的には、pleasanterのテーブル定義上で自分が作った独自要素のメンバーに対して、ExtensionElementsに生えているメンバーを使ってアクセスして行くこととなります。 // APIの動作設定を作成 var cfg = new ServiceConfig { uri = new Uri("http://localhost"), ApiKey = " *** ", ApiVersion = "1.1" }; // サイトパッケージから生成されたサービスクラス var s = new RecordingTableService(cfg); // サービスクラスには、itemを操作するapi関数が生成されます。 // この関数は全件削除関数です var del = await s.DeleteALL(true); // テーブル上の要素を操作する場合、CLIにて生成されたモデルを使います。 var data = new RecordingTableModel(); // モデルには通常のデータ領域と data.BasicItemData.Title = "タイトルてすと"; data.BasicItemData.Body = "本文"; data.BasicItemData.Comments = "コメント"; // 独自に命名した拡張領域があります。 data.ExtensionElements.CheckA = true; data.ExtensionElements.DateA = DateTime.Now; data.ExtensionElements.NumA = Int32.MaxValue; data.ExtensionElements.StringA = "StringA"; data.ExtensionElements.TypeA = "TypeA"; // モデルデータを指定してサービスの生成関数を使う事でpleasanter上に // itemが生成されます。 var x = await s.CreateItem(data); // 情報を更新する場合、モデルデータの各要素を変更します。 data.ExtensionElements.CheckA = false; data.ExtensionElements.DateA = DateTime.Now; data.ExtensionElements.NumA = Int32.MinValue; data.ExtensionElements.StringA = "StringA +1"; data.ExtensionElements.TypeA = "TypeA +1"; // Update関数を使う事で指定された要素を更新する事ができます。 var ts = await s.UpdateItem(x.Id, data); 検索系 基本パターン 検索キーを生成してpleasanterからitemを取得する事ができます。 RecordingTableServiceから検索に使用する各種検索キーを格納するオブジェクトを取得する事ができます。 検索キーはor条件で複数指定が可能ですので、それらを束ねるオブジェクトとして「HackPleasanterApi.Client.Api.Request.View.View」があります。 このオブジェクトに各種キーを指定してfindする事で検索を実行する事ができます。 //サービスを生成 var s = new RecordingTableService(MakeTestConfig()); // 検索条件を設定 var findString = "test10"; // 検索用のクエリオブジェクトを生成して検索キーを設定 var fa = RecordingTableService.FilterKeys.StringA; fa.SearchCondition = findString; // 検索キーの集合体を生成 var v = new HackPleasanterApi.Client.Api.Request.View.View<RecordingTableModel>(); v.Add(fa); // ★ 検索実行 var r = await s.FindItems(v); 範囲指定 項目が数値の場合、数値で範囲指定する事ができます。 var findString1 = 6; // 6より大きい場合 var fa = RecordingTableService.FilterKeys.NumA; fa.AddKeyOver(findString1); var findString1 = 6; // 6以下の場合場合 var fa = RecordingTableService.FilterKeys.NumA; fa.AddKeyUnder(findString1); // 3〜6の場合 var fa = RecordingTableService.FilterKeys.NumA; fa.AddKeyRange(3, 6); 選択肢で指定する場合 pleasanter上でプルダウンで選択するキーの場合、 条件選択別に選択肢が生成されます。 // 対象となる要素 var fa = RecordingTableService.FilterKeys.TypeA; // 選択肢を収める配列を生成 fa.SearchConditions = new List<ChoicesTextElement>(); // 指定する選択肢を設定 fa.SearchConditions.Add(RecordingTableService.FilterKeys.TypeA_Choices.ClassA); ソート系 ソートの指定の仕方も同様です。 RecordingTableServiceクラスに各カラム毎にソートを指定する条件を格納するオブジェクトが格納されています。 同オブジェクトにソート順序を指定し、「HackPleasanterApi.Client.Api.Request.View.View」に対して指定する事で検索時にソート条件を指定する事ができます。 //サービスを生成 var s = new RecordingTableService(MakeTestConfig()); // 検索条件を設定 var findString = "test10"; // 検索用のクエリオブジェクトを生成して検索キーを設定 var fa = RecordingTableService.FilterKeys.StringA; fa.SearchCondition = findString; // ソート条件を指定 var sortKey = RecordingTableService.ColumnSorterKeys.NumA; sortKey.ColumnSorterType = HackPleasanterApi.Client.Api.Request.View.ColumnSorterType.Desc; v.Add(sortKey); // 検索キーの集合体を生成 var v = new HackPleasanterApi.Client.Api.Request.View.View<RecordingTableModel>(); v.Add(fa); // 検索実行 var r = await s.FindItems(v); 添付ファイル系 添付ファイルはbase64形式添付することができます。 // 添付するファイルを読み込んで添付オブジェクトを生成する var a = AttachmentsHelper.MakeAttachmentsFromFile(Path.Combine(GetCurrentPass(), @"sky.jpg")); // 添付ファイルはリストで付加できるので、格納用のオブジェクトを生成して値を指定する data.ExtensionElements.AttachmentA = new System.Collections.Generic.List<HackPleasanterApi.Client.Api.Models.ItemModel.Hash.Attachments>(); data.ExtensionElements.AttachmentA.Add(a); 未完成 - 現状item系のapiだけに対応しています。 - Date系の日付条件指定がUTCになってしまっていて、挙動が怪しいので調整が必要です。 その他 思うところ - pleasanterをREST APIのバックエンドとして使えるかと思い今回のツールを作りました。 - 一般にバックエンド APIの場合、APIだけでは済まずに管理画面などを作るとそれなりに手間がかかるのですが、似たようなことはpleasanterで実現してしまうからです。 - 今回のツールを使うことでpleasanterがREST apiのバックエンドっぽく使えると思います。 - 現状、Glue codeを生成するときに、pleasanterの画面上では「説明」に書かれた文言を取り込んでいます。しかし、説明はユーザーに向けた説明に使いたいので、pleasanter側に「内部説明用」のカラムが存在するともっと便利なのですが...。(そこまで手をいれる元気がなかった...。) - あと、pleasanterをREST APIのバックエンドとして使用する場合、認証キーを取ってくるログインAPIだったり、リフレッシュトークンがないので、そのあたりも本体に改造が入ってくれるともっと本格的に使えるのではないかと思いました。
- 投稿日:2021-12-05T21:12:08+09:00
音楽に目覚めたので、楽しくオリジナル曲が作れるゲームを作ってみた
音楽に目覚める 以前から音楽に興味はがあり、ピアノなども触ってはみたのですが長続きせず、途中で挫折することを繰り返している北城です。 皆さんもそのような経験ありますよね?? そんな中であったLogic Pro。 もうね、音楽の知識とか必要なくすごい簡単に曲が作れちゃうんですよ。 楽しくなってしまって、曲作りについて学ぼうと『作りながら覚える 3日で作曲入門』。 この本の通りにやると簡単に1曲作れます。 多くの人に音楽作りを体験して欲しくて、ゲームを作成しました。 お時間のある方は、ぜひやってみてください。 今回は、ゲームを作成する方法の肝についてまとめました。 音楽に必要な3大要素 音楽が、リズム・ハーモニー・メロディーの3大要素によって構成されているそうです。 それぞれが3つあれば、3の3乗通り、つまり、27通りの音楽が作成されます。 上記のゲームは、この27通りから自分の好きな曲を自ら作り、楽しめるゲームになっております。 音を出すための設定 unityで音を出すためには、 1)音を出すスピーカーにあたるAudio Source 2)音の元となるAudio Clip を設置する必要があります。 Clipを設定するためのcodeは以下です。 DrumSoundManager.cs using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class DrumSoundManager : MonoBehaviour { public static DrumSoundManager instance; private void Awake() { if (instance == null) { instance = this; DontDestroyOnLoad(this.gameObject); } else { Destroy(this.gameObject); } } public AudioSource drumAudioSource; // BGMのスピーカー public AudioClip[] drumAudioClips; // BGMの素材( public Slider dslider; //音量調節用のスライダー private void Update() { drumAudioSource.volume = dslider.GetComponent<Slider>().normalizedValue; } public void PlayDrum(string clipName) { drumAudioSource.Stop(); switch (clipName) { default: case "Beach": drumAudioSource.clip = drumAudioClips[0]; break; case "Cafe": drumAudioSource.clip = drumAudioClips[1]; break; case "Night": drumAudioSource.clip = drumAudioClips[2]; break; } drumAudioSource.Play(); } public void DrumStop() { drumAudioSource.Stop(); } } 続いて、以下のように設定を行います。 ① ヒエラルキー上にDrumSoundManagerという空のオブジェクトを作成し、このオブジェクトにAudio Sourceコンポーネントをアタッチします。 ② DrumSoundManagerという空のオブジェクトにDrumSoundManager.CSもアタッチします。 ③ Audio SourceコンポーネントをDrumSoundManager(Script)のDrum Audio Sourceにアタッチします。 ④ Audio Clipsに音源となるMP3データを貼り付けます。 上記の工程をハーモニーとメロディーにも行います。 あとは、ボタンに音楽を開始する関数を貼り付ければ、完成です。 完成したゲームを再掲します。 ぜひ一度遊んでみてください!!!
- 投稿日:2021-12-05T18:52:41+09:00
C#で「ポリモーフィズム」を実現する手段と利点
前回:ポリモーフィズム完全に理解した(理解していない) 前回はポリモーフィズムの超基本的な手段で便利さをじっくり味わいました。 具体的にはクラスを継承してサブクラスごとに異なった処理を行うメソッドを実装しました。 しかし、ポリモーフィズムを実現する手段はほかにもあり、それを覚えつつ使い分けができるようになる必要があると考えました。 注意 ・「多相」を「ポリモーフィズム」に置き換えています ・私の考えが強く反映されているので注意してください ・指摘はコメントでお願いします なぜこの記事を書こうと思ったのか ・前回の記事だけでは内容が不十分であるから ・ポリモーフィズムを実現する手段や使い分けについて自分なりにまとめたかったから 使用言語 C# 10.0を使用します。 今回わざわざ言語とバージョンを書いたのは、ポリモーフィズムの記事というよりはC#をつかって実装してみよう!というノリの記事になると思ったからです。 記事の構成 概要 → 利点 → 実現する手段 → (実現する手段の考察) → まとめ という流れで書きます。(括弧の中身は省略する場合があります) 【復習】そもそも「ポリモーフィズム」とは何か。何ができるか。 ・メインルーチンを共通化する仕組みである ・サブセット(部分集合)をあたかもスーパーセットのように扱うことができるようになる (この説明は大雑把すぎて不適切かもしれない) 取りあえず今回はこの二つを前提にして手段について調べていきます。 「ポリモーフィズム」の分類 まず、実現する方法を考えるにはポリモーフィズムの概念について知る必要があります。 主にこの三つです。(実際はもっとあるらしい) ①サブタイピング ②アドホックポリモーフィズム ③パラメトリックポリモーフィズム 他にもローポリモーフィズム、ポリタイピズムという概念があるらしいです。 オブジェクト指向で一般に言われる「ポリモーフィズム」は「サブタイピング」の概念です。その他はオブジェクト指向とは少し離れた話になるので、先にサブタイピングについて掘り下げていきます。 ①「サブタイピング」について 「サブタイピング」は『犬は、動物である。』のように、『<サブタイプ>は、<スーパータイプ>である。』の関係を司る概念です。オブジェクト指向ではサブタイプをサブクラス、スーパータイプをスーパークラスという形で表現できます。動的なポリモーフィズムです。 スーパータイプはサブタイプで代入ないし代替することが可能です。この性質を「代入可能性」と言います。 リスコフの置換原則(LSP)はサブタイピングの代入可能性をコンセプトにした原則です。 ただし、継承関係があるからといって『スーパータイプ=スーパークラス、サブタイプ=サブクラス』が成立するわけではないので言葉の使い分けに気を付ける必要があります。 「サブタイピング」の利点 サブタイピングの利点は、複数種類の型を一つの型のように扱うことができることだと思います。 具体的には、アップキャストされても問題なく動作する処理を実装できます。スーパークラスの配列に サブクラスのオブジェクトを入れたり、引数にスーパークラス型を指定されているメソッドの引数に サブクラスのオブジェクトを入れても問題なく動作します。 これらを駆使すると、 サブクラスが増えても、サブクラスを利用する側の修正箇所がなくなるという恩恵を得ることができます。 次はC#でサブタイピングを実現する手段について考えていきます。 クラス継承とオーバーライドを用いる方法 鳴くメソッドのMakeSounds仮想メソッドを実装したMammalクラスを定義します。そしてMammalクラスのサブクラスであるCatクラス、Dogクラスを実装します。サブクラスではMakeSoundsメソッドをオーバーライドし、それぞれ別の振る舞いをさせます。 Mammalクラス継承とオーバーライドを用いる例 public class Mammal { public virtual void MakeSounds() => Console.WriteLine("何の動物か知らないけど鳴いたよ"); } public class Cat : Mammal { public override void MakeSounds() => Console.WriteLine("ぬお~~ん"); } public class Dog : Mammal { public override void MakeSounds() => Console.WriteLine("うおんうおん"); } class Program { public static void Main() { var mammals = new List<Mammal>() { new Cat(), new Dog() }; foreach(var mammal in mammals) { mammal.MakeSounds(); } } } /*出力結果*/ /*ぬお~~ん*/ /*うおんうおん*/ このコードではCat、Dog型のオブジェクトをあたかもMammal型のように扱っています。『Cat型、Dog型 は Mammal型』という関係が成立しているという事です。 このコードではMammalクラスでもMakeSoundsメソッドを実装していますが、哺乳類全般をターゲットにしているスーパークラスで具体的な実装をする必要はありません。そこで次の方法を使います。 「抽象基底クラス」の継承とオーバーライドを用いる方法 MammalクラスをMammalBaseクラスに改名し、次のように書き換えます Mammal抽象基底クラスを継承し、オーバーライドを用いる例 public abstract class MammalBase { public abstract void MakeSounds(); } public class Cat : MammalBase { public override void MakeSounds() => Console.WriteLine("ぬお~~ん"); } public class Dog : MammalBase { public override void MakeSounds() => Console.WriteLine("うおんうおん"); } class Program { public static void Main() { var mammals = new List<MammalBase>() { new Cat(), new Dog() }; foreach(var mammal in mammals) { mammal.MakeSounds(); } } } MammalクラスのMakeSoundsメソッドを宣言だけしています。このようなメソッドを抽象メソッドといい、抽象メソッドのあるクラスを抽象基底クラスといいます。抽象基底クラスを継承しているクラスを具象クラスといいます。 「インターフェイス」を実装する方法 MammalBaseクラスをIMammalインターフェイスにして、次のように書き換えます。 IMammalインターフェースを実装する例 interface IMammal { void MakeSounds(); } public class Cat : IMammal { public void MakeSounds() => Console.WriteLine("ぬお~~ん"); } public class Dog : IMammal { public void MakeSounds() => Console.WriteLine("うおんうおん"); } class Program { public static void Main() { var mammals = new List<IMammal>() { new Cat(), new Dog() }; foreach(var mammal in mammals) { mammal.MakeSounds(); } } } インターフェイスを用いても抽象基底クラスと同じような事が出来ます。 ではどのように使い分ければ良いのでしょうか。 「抽象基底クラス」と「インターフェイス」の使い分け方 まず、使い分けをするうえで二つの観点があると思います。 1.「抽象基底クラス」、「インターフェイス」が使われる理由や目的に着目する 2.機能に着目する 今回はこの二つの観点から使い分けを考えていきます。 『理由や目的』に着目して考える いろいろな人の説明を見て『インターフェイスは継承先でできることを宣言することが目的』であり『抽象基底クラスは一つ一つの型の大きな枠組みやひな形を作ることが目的』だと解釈しました。インターフェイスはインターフェイスを実装したクラスを安全に使えるようにする、抽象基底クラスは派生クラスの構造の大枠を作って派生クラスを実装しやすくするという役割が大きいと思います。 要するに、利用する側が対象であるのか実装する側が対象であるかという違いだと思います。 『機能』に着目して考える 使い分けについて考えるだけなので細かい言語の仕様は省きます。 結論から言うと、インターフェイスの方が柔軟性があるので基本的にインターフェイスを使えば問題なさそうです。インターフェイスは複数実装できますが、抽象基底クラスは一個だけしか継承できないというのが理由として大きいです。C#8.0以降インターフェイスもメソッドやプロパティの実装を持てるようになったので抽象基底クラスとは機能的な違いが減ってきています。 補足情報:C#8.0からデフォルトインターフェイスメソッドを実装できるようになったのでインターフェイスをトレイトのように扱うことができるようになりました。 では「抽象基底クラス」はいらない子なのか そんなことは無いと思います。抽象基底クラスもインターフェイスも派生クラスを抽象化することができますが、抽象化する範囲が異なると考えました。 この図は電化製品を例にしたものです。インターフェイスで「ボタン」、「スピーカー」など多くの種類の電化製品で使える部品を作っています。そしてこれらを実装した抽象基底クラス、今回は「音楽プレイヤー」と「動画プレイヤー」を作ります。そして抽象基底クラスを継承した派生クラスが、それぞれの製品になるイメージです。(高い音楽プレイヤー、酷い音楽プレイヤーなど) ここで抽象基底クラスとインターフェイスでは抽象化する範囲と抽象化した範囲に対する規模で違いが表れています。 インターフェイスで抽象化している範囲は広いですが、電化製品に対する規模は小さくなっています。例えば、電化製品インターフェイスのように抽象化した対象すべてをカバーする規模の大きなインターフェイスを作ってしまうと「冷蔵庫」でもスピーカーに関する振る舞いを実装しないといけなくなります。これはインターフェイス分離原則に違反しています。インターフェイスは複数実装できるので使いたいインターフェイスを自由に組み合わせて実装できることはとても魅力的です。 一方で抽象基底クラスは抽象化する範囲は狭いですが、抽象化する対象の必要なものはすべて定義するので抽象化した範囲に対しての規模が大きくなります。今回の例だと「音楽プレイヤー」、「動画プレイヤー」のように電化製品の中では限られた部分を抽象化していますが、抽象化した対象の共通している処理はすべて定義することができます。そして共通していない部分を含めて派生クラスで実装して一つ一つの製品になります。 このように抽象基底クラスとインターフェイスを組み合わせて使うことができる場合があります。 また、使い分けるというよりはどちらも使いこなせるようにするという考え方をする方が表現の幅は広がりそうです。 「サブタイピング」を実現できたからといって油断してはいけない Cat、Dogクラスに固有の特技(メソッド)を実装することになりました。そしてProgramクラスには引数に与えられた動物に、鳴いた後に特技をさせるという処理をするメソッドを実装します。 危険な例 interface IMammal { void MakeSounds(); } public class Cat : IMammal { public void MakeSounds() => Console.WriteLine("ぬお~~ん"); public void Sleep() => Console.WriteLine("グースカ"); } public class Dog : IMammal { public void MakeSounds() => Console.WriteLine("うおんうおん"); public void Paw() => Console.WriteLine("右手を差し出す"); public void OtherPaw() => Console.WriteLine("左手を差し出す"); } class Program { public static void Main() { var cat = new Cat(); var dog = new Dog(); ShowSpecialSkill(cat); ShowSpecialSkill(dog); } private static void ShowSpecialSkill(IMammal mammal) { mammal.MakeSounds(); if (mammal is Dog) { var dog = mammal as Dog; dog.Paw(); dog.OtherPaw(); } else if (mammal is Cat) { var cat = mammal as Cat; cat.Sleep(); } } } ShowSpecialSkillメソッドをみると、mammalに鳴かせてからmammalをダウンキャストして猫、犬の特技をさせています。 問題はダウンキャストする処理にあります。 このメソッドは動物を鳴かせた後に特技をさせるという役割がありますが、動物の種類が増えるたびにelse ifが量産されていきます。サブタイピングの利点を殺すコードの完成です。そもそも、鳴いてから特技をするのは各動物なので、各動物のクラスにメソッドを実装すべきです。 改善した結果 public class Dog : IMammal { public void MakeSounds() => Console.WriteLine("うおんうおん"); public void Paw() => Console.WriteLine("右手を差し出す"); public void OtherPaw() => Console.WriteLine("左手を差し出す"); public void ShowSpecialSkill() { MakeSounds(); Paw(); OtherPaw(); } } class Program { public static void Main() { var dog = new Dog(); dog.ShowSpecialSkill(); } } 「サブタイピング」のまとめ 【概要】 ・サブタイピングは『<サブタイプ>は、<スーパータイプ>である。』の関係を司る概念 ・スーパータイプをサブタイプで代入ないし代替できる性質を「代入可能性」という ・スーパータイプ=スーパークラス、サブタイプ=サブクラスの関係は 常に成り立たないので言葉に注意する必要がある ・動的なポリモーフィズム ・サブタイピングの利点はサブクラスの数が増えてもサブクラスを利用する側の修正をなくすこと 【実現方法】 ・継承してメソッドをオーバーライドする ・抽象基底クラスを継承してメソッドをオーバーライドする ・インターフェイスでメソッドを定義して派生クラスで実装する 【実現方法の考察】 ・抽象基底クラスとインターフェイスで実現できることはほぼ同じだが、役割や目的は異なる ・機能的にはインターフェイスの方が適している場面が多い ⇒だいたいはインターフェースを使えば問題ない ・インターフェイスと抽象クラスを両方つかうと良い場合がある ・サブタイピングの利点を上手く活かせないと感じた場合、コードの設計を見直したほうが良い ②「アドホックポリモーフィズム」について 「アドホックポリモーフィズム」は、同名であっても複数の実体を持ち、それぞれ別の振る舞いをする性質です。その性質を持つメソッドは、メソッドを利用する側からは実体が一つのメソッドに見えますが、実際は複数の実体を持ちます。静的なポリモーフィズムです。 普段使っている演算子に注目してみる。 +演算子について string str = "Hello" + " World!!"; int num = 1 + 1; 文字列の連結にも+演算子、加算にも+演算子を使っていることがわかります。 また、デリゲートの組み合わせにも+演算子を使います。 名前の『意味』について考えて分かる事 演算子の例に挙げた+演算子ですが、『足す』と言い換えることができます。『整数を足して下さい。』、『文字列を足してください。』という言い回しでも一応伝わります。引数にbool型の変数もstring型の変数も入るConsole.WriteLineメソッドの役割を簡単に説明しても『画面に文字を表示する』という説明で落ち着きます。 つまり、具体的な処理の中身に注目せずに同じ処理だと言えるものにアドホックポリモーフィズムは適用できると言えます。 アドホックポリモーフィズムの利点は身近過ぎて気づきにくい 普段は無意識的に文字列の連結も数値の加算も+演算子を使い、コンソールアプリケーションで画面に変数の中身を表示したいときはConsole.WriteLineメソッドを使っています。使うたびに『うっひょぉおアドホックポリモーフィズムを感じるぜよ~』とか思ってる変態は居ても90%程度だと思います。 利点はここに隠されています。 大雑把に見て同じ処理と言えるのに(加算、連結は足すと言い換えられるなど)、わざわざ処理内容の詳細を見て使い分けていたら面倒くさいです。もし+演算子で数値の加算しかできなくて『文字列の連結は+++演算子を使って、デリゲートの組み合わせは++++演算子をつかって…』なんてやっていたら慣れるまでが大変です。アドホックポリモーフィズムはプログラミングを人間の感覚に近づけることができる性質といえると思います。 C#ではどのようにアドホックポリモーフィズムを実現できるのでしょうか。 オーバーロードを利用する方法と例 C#ではアドホックポリモーフィズムをメソッドのオーバーロードで実現できます。 Addメソッドの例 class Program { public static void Main() { Console.WriteLine(Add(1,1)); Console.WriteLine(Add("Hello"," World!!")); } private static int Add(int x,int y) => x + y; private static string Add(string x,string y) => x + y; /*出力結果*/ /*2*/ /*Hello World!!*/ } このAddメソッドは二つの実体を持っています。 また、メソッド名の意味に着目すると、『Add』は、『整数の加算』、『文字列の連結』の二つの意味を持っています。 みんな大好きConsole.WriteLineメソッドもオーバーロードされていて複数の実体を持っています(17個もあります)。引数にbool型変数を入れてもstring型変数を入れても画面に出力してくれます。 コンストラクタもオーバーロードできる Color構造体の例 struct Color { private int _r; private int _g; private int _b; private float _a; /*R G B Aプロパティは省略*/ public Color(int red,int green,int blue) { /*実際に使う場合はRGBの引数が0~255、Aの引数が0.0~1.0かのチェックが必要だけど省略*/ this._r = red; this._g = green; this._b = blue; this._a = 1.0f; } public Color(int red,int green,int blue,float alpha) { this._r = red; this._g = green; this._b = blue; this._a = alpha; } } どちらも『初期化』の処理ですが、『RGB』の値を初期化するのと『RGBA』の値を初期化するのでは意味が異なります。(アルファ値を意識しないでColor構造体を扱うことができるようになります。) 「アドホックポリモーフィズム」のまとめ 【概要】 ・アドホックポリモーフィズムは、同名だが複数の実体を持ち、それぞれ別の振る舞いをする性質のこと ・アドホックポリモーフィズムの利点は具体的な処理にあわせて使い分ける必要がなくなること ・静的なポリモーフィズム 【実現方法】 ・メソッドをオーバーロードする ・コンストラクタをオーバーロードする 【実現方法の考察】 オーバーロードは、具体的な処理を考えないで処理が同じといえるメソッドで使うと良い ③「パラメトリックポリモーフィズム」について 「パラメトリックポリモーフィズム」は値の静的な型安全性を確保しつつ一様に値を扱うことができる性質のことです。 似た概念に「ポリタイピズム」というものがあるらしいです。静的なポリモーフィズムです。 (調べたけど違いがいまいちわからなかった) 「パラメトリックポリモーフィズム」の利点 パラメトリックポリモーフィズムの利点というよりはジェネリックプログラミングの利点という感じになってしまいました。 型に囚われない処理を作ることができる 一様に値を扱うことが出来るのですべての型に対応した処理を作ることができるので、型に関係ない振る舞いをする汎用性の高い処理を簡潔に書くことができます。その性質をもつクラスの事をジェネリッククラス、メソッドをジェネリックメソッドといいます。ちなみにジェネリックインターフェイス、デリゲートも作れます。 List<T>クラスのようなコンテナクラスには最適だと思います。 正直、汎用性の高い処理はほとんど標準ライブラリに入っているので、作る場面より使う場面の方が多そうです。 パフォーマンスが良い場合がある 値型を扱うとき、ArrayListクラスを使うよりList<T>クラスの方が良いです。(ソートなど) 何故ならArrayListクラスでは値型がobject型にキャスト(ボックス化)されてから格納されるからです。このようにパフォーマンスにパラメトリックポリモーフィズムの利点が現れることもあります。 「アドホックポリモーフィズム」との違い アドホックポリモーフィズムを活用した場合メソッドの引数によって別の振る舞いをしますが、パラメトリックポリモーフィズムでは引数の型がどの型でも同じ振る舞いをします。そのため、パラメトリックポリモーフィズムを活用する場合、型固有の動作は使うことができません。その対価として汎用性が与えられます。 次はジェネリックな処理を作る方法について調べます。 ジェネリッククラスを利用する 試しにList<T>をランダムソートするジェネリッククラスを作ってみます。 ジェネリッククラスを利用する例 class RandomSorter<T> { private IReadOnlyList<T> _list; public RandomSorter(IReadOnlyList<T> list) { _list = list; } public List<T> GetSortedList() { var sortedList = _list.OrderBy(x => Guid.NewGuid()).ToList(); return sortedList; } } ジェネリッククラスの本体はこのようになります。たったこれだけですべての型に対応した処理が書けました。刺さるところでは凄まじいパワーを発揮します。 ジェネリックメソッドを利用する ソートする機能しかないならジェネリックメソッドという形にしたほうが良さそうです。 ジェネリックメソッドを利用する例 public static List<T> GetRandomSortedList<T>(IReadOnlyList<T> list) { var sortedList = list.OrderBy(x => Guid.NewGuid()).ToList(); return sortedList; } 引数がIReadOnlyList型、戻り値がList<T>型のジェネリックメソッドです。 ジェネリックメソッドを呼び出す例 List<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, }; GetRandomSortedList(list); 使うときは型引数を省略できますが、ジェネリックメソッドもオーバーロードできるので同名の 非ジェネリックメソッドが存在したらそちらが呼び出されるので注意が必要です。 ジェネリックデリゲートを利用する このようにするとジェネリックメソッドをデリゲートに代入できます。 ジェネリックデリゲートを利用する例 delegate void SayDelegate<T>(T arg); public static void Main() { SayDelegate<int> sayInt = Say; sayInt(145432); } public static void Say<T>(T arg){} 型に制約を掛けることができる 上の例ではすべての型に対応した処理になっていますが、型引数に制約を掛けることができます。 型引数の制約の例 class GenericClass<T> where T : struct {} このようにwhere T : ○ ○で制約を追加できます。上の例ではTが値型でなければならないという制約が書かれています。 制約は複数適用できる class GenericClass<T> where T : List<T>, IComparable<T> また、制約は複数適用でき、制約自体をジェネリック型にすることもできます。 制約の一覧ついては公式ドキュメントをご覧ください 「パラメトリックポリモーフィズム」のまとめ 【概要】 ・パラメトリックポリモーフィズムは値の型安全性を確保しつつ値を一様に扱える性質 ・型に囚われない処理が作れる=汎用性の高い部品を作れる ・ジェネリックを活用するとパフォーマンスが良くなる場合がある ・型固有の処理は書けない ・静的なポリモーフィズム 【実現方法】 ・ジェネリッククラス、メソッド、デリゲートを作る ・型引数に制約を掛けることができる 感想 前回はオブジェクト指向におけるポリモーフィズムのみ扱っていたので広い部分をカバーする概念という事に 気づけませんでしたが、今回オーバーロードやジェネリックプログラミングの仕組みも ポリモーフィズムの概念を含んでいるという事を学べてよかったと思います。 ポリモーフィズムはどれも『抽象化』に関わってくる概念ですが、下手に使うと逆に複雑になりそうなので、 次は抽象化するテクニックや悪い例を学びたいと思います。 『○○は××するもの』という説明を見るだけではどのように使うか実感がわかないので、 全体的に難しい内容だと感じました。特に概念の話は抽象的すぎて難しいと感じました。 まだ理解できていない事があるので今後も更新していきたいです。
- 投稿日:2021-12-05T17:26:37+09:00
Blazorチートシート
Blazorで使われる記述を簡単にまとめてみました。 データバインド 変数のバインド <p>@value</P> inputタグへのバインド <input @bind-value="<変数名>" /> @bind-<バインド先の属性名> <input @bind="<変数名>" /> バインド先の属性名を省略した場合は、デフォルトの属性のバインドされる。 バインドタイミング <input @bind="<変数名>" @bind:event="oninput" /> 即時反映される。 <input @bind="<変数名>" @bind:event="onchange" /> 入力確定時、Enter押下かフォーカスが外れたとき スタイルへのバインド <div @bind-style="fontsize" @bind-style:event="onchange" >フォントサイズを変更</div> <div style="font-size: @(fontSize)pt" >フォントサイズを変更</div> 数値入力 <input type="number" @bind="<変数名>" /> 項目選択 <select @bind="<変数名>"> @foreach(var item in items) { <option value="@item.Id">@item.Name</option> } </select> チェックボックス <input type="checkbox" @bind="<変数名>" /> コンポーネント関連 パラメーター付きの子コンポーネントの呼び出し Razor <Component Name="Name" Year="2021" /> C# //コンポーネント側のプロパティに[Parameter]をつけておく [Parameter] public string Name { get; set; } [Parameter] public int Year { get; set; } 親コンポーネントからリストの要素として呼ばれる際は、 [Parameter] Public RednerFragment ChildContent {get; set;} //を作成しておく 属性スプラッティングでコンポーネントにデータを渡す @foreach (var item in Items) { Var attrs = new Dictionary<string, object>() { { "Name", item.Name }, { "Value", item.Value }, } <Component @attributes="attrs" /> //使わない場合 <Component Name=@item.Name Value=@item.Value /> } C#とJavascriptの連携 C#からJavascriptを呼び出す JSRuntimeをインジェクトする。 @page "/index" @inject IJSRuntime JSRuntime; Javascriptの関数を定義する <script> window.jsFunctions = { changeValue: function (value, id) { $(`#${id}`).text(value); } } </script> C#からJavascriptの関数を呼び出す private async void Method() { await JSRuntime.InvokeVoidAsync("jsFUnctions.changeValue", "value", "id"); } JavascriptからC#の処理を呼び出す C#の処理 @code { [JSInvokable] public static void SomeMethod(string value) //staticである必要がある。 { Console.WriteLine(value); return; } } JavascriptからC#のメソッドを呼び出す DotNet.invokeMethod("<アセンブリ名>", "SomeMethod", "value"); コンポーネント間のデータバインド 親から子へのデータバインド 子コンポーネントに [Parameter] public EventCallBack<string> XXXXChanged { get; set; } のような形でバインドするプロパティ名 + Changedのイベントを作成する。 親コンポーネントからの呼び出し <ChildComponent @bind-XXXX="XXXX" /> 子から親へのデータバインド [Parameter] public EventCallBack<double> XXXXChanged {get; set;} private async void clickSomething() { await resultChanged.InvokeAsync(XXXX); } 子のイベントを親に伝える 子コンポーネント実装 [Parameter] public EventCallback<string> OnResultChanged {get; set;} private async void SomeMethod() { await OnResultChanged.InvkeAsync(XXXX); } 親コンポーネントの実装 <ChildComponent OnResultChanged="@ChangeResult" /> @code { private void ChangeResult(string result) { this.XXX = result; } } イベント クリックイベント <button @onclick="onSubmit">登録</button> ラムダ式の場合 <button @onclick="@(() => Message = "Some Message…")">登録</button> KeyPressイベント <input type="text" @bind="Text" @onkeypress="OnKeyPress" /> マウスムーブイベント <rect @onmousemove="OnMouseMove"></rect> ルーティング ページパラメーター @page "/param/{param1}/{param2}" 初期化 初期化処理(同期) protected override void OnInitialized() { ... } 初期化処理(非同期) protected override async Task OnInitializedAsync() { await ... }
- 投稿日:2021-12-05T17:03:04+09:00
「ドメイン駆動設計入門」を読んで
この記事は エヴァンス本1を読んでいない、DDD素人が書いた記事 標題の本の内容と周辺知識をまとめて、備忘録とした記事 あつかう範囲は、本の序盤であるドメインオブジェクト(値オブジェクト、エンティティ)およびドメインサービスまで 「ドメイン駆動設計入門」について 初版は2020年3月に出版された、まだ比較的あたらしい本 ※エヴァンス本が翔泳社から出版されたのは2011年4月 本書はドメイン駆動設計という巨大な試練に立ち向かうための準備である ドメイン駆動設計は、用語が多く、前提知識も必要とされるため難しい 本書では概念の理解には、いったん棚に上げる(概念には触れない) 軽量DDDやらドメインモデル貧血症やらは、あとで調べる 本書は理解と実践がしやすい実装に関するパターンに集中してボトムアップで解説している はじめに出てくるのは値オブジェクト 例示のコードはC#で書かれている GitHubに実例のサンプルコードが置かれている 定価3,200円 ドメイン駆動設計とは ドメイン駆動設計はまさに知識をコードに埋め込むことを実現するのです(p.002) 具体的に知りたい場合は・・・Qiita検索窓に「title:ドメイン駆動設計」&LGTM降順ソート ドメインに関連する用語の説明 ドメイン ドメインモデル ドメインオブジェクト プログラムを適用する対象となる領域 ドメインの概念を取捨選択して抽象化したもの ドメインモデルを表現するためのモジュール 会計システムの金銭や帳票、物流システムの倉庫・貨物・輸送手段 道具としてのペン、贈答品としてのペン ドメインモデルの中から問題解決につながるものを実装したもの ドメインの世界の住人と、ソフトウェアの世界の住人が協力してドメインモデルをつくる ドメインモデルは、現場の人間とソフト開発者の共作である ドメイン・ドメインモデル・ドメインオブジェクトは互いに影響しあい、反復的に開発される これも共作の1つといえる。コードを書くうちに業務ルールが明確化される ドメイン駆動のパターン ここまではドメイン駆動設計の概念の話 ここからは実際にコードとして使えるテクニックの話 知識を表現する 値オブジェクト エンティティ ドメインサービス アプリケーションを実現する リポジトリ アプリケーションサービス ファクトリー 知識を表現する(発展系) 集約 仕様 値オブジェクト 値オブジェクトはドメインオブジェクトの基本である。以下の特徴を持つ 不変である 状態が変化することはバグの要因となりうるので、それを除去する デメリット:状態が変化しないので、オブジェクトの一部だけを変えたいときには、あらたにインスタンスを生成せねばならない 交換が可能である 値オブジェクトの変更は(値と同じように)、代入によって交換されることで表現(変更)される 等価性によって比較される 属性の値が同じであれば、異なるインスタンスも同じものであるとみなされる どうやって実装するか 先の特徴をクラスとして表現するためには、それなりのコード量が必要となる 標題の本においても、見開き半ページ(p.026)がコードで埋まっている それらをここに打ち込むのも大変なので、イメージはこんな感じですと有名サイト様のリンクでお茶を濁す 値オブジェクトをつかうモチベーション 表現力が増す 単なるstring型ではなく、stringを複数使ったコンストラクタ(string productCode,string lotNumber)のほうが意図が通じやすい 不正な値を存在させない コンストラクタやメソッドのガード節で不正な値を検知する 誤った代入を防ぐ 値オブジェクトの独自型をつくれば、異なる型はそこに代入できない ロジックの散在を防ぐ データとふるまいは近くに置く 「どの粒度までを値オブジェクトとして切り出すか」の判断が難しいときには 「そこにルールが存在しているか」「それ単体で扱いたいか」を自問するといいらしい 値オブジェクトをもっと詳しく知りたい人は クソコードを爆殺する御人の記事をどうぞ! 値ほか、値オブジェクトに関する関連記事 recordをDDDの値オブジェクトとして利用する際の注意 C# 9.0 から導入されたrecordを使えばよいものかと思っていたけど、そうではないということに気づかされました withをつかうと、属性値を変更した代入が可能となってしまい、コンストラクタの検証をすり抜けるのがダメそう 詳細は元記事をご覧ください システム固有の値を表現する「値オブジェクト」のまとめと感想 同じ本を読んだ方が値オブジェクトだけにフォーカスしてまとめていらっしゃいます 現時点での値オブジクトに対する勝手なイメージ 値オブジェクトは石ころのイメージ 1つ1つを区別する必要がない 別の形の石ころが必要になったら、その形の石ころを探してつかう(=交換可能) 大きさや形(=属性)が同じ石ころは、同じものとして扱っても支障がない 一方で、石材店の親父にとっての石ころは・・・ 石材店の親父にとっては、1つ1つの石ころには意味があり、判別可能であるかもしれない 同一の対象物(ここでの石ころ)であっても、それが属するドメインによって1つ1つが意味をもつ そういった類のものは、次節のエンティティとして扱うこともある 石材店の親父(=ドメインエキスパート)と共にドメインモデルを練り上げることで、その概念を表すにふさわしいドメインオブジェクトの形態が決まる(と理解している) エンティティ エンティティは値オブジェクトと対をなすドメインオブジェクトである 簡単にいえばSNSのアカウントのように、識別子があり、生成され、いずれ削除されうるもの 可変である 状態が変えられる。オブジェクトを観測するたびに違うふるまいをする可能性がある 属性が同じでも区別される 同姓同名の人間が2人以上いるとき、その人たちをすべて同一人間として扱ったりしない 同一性をもつ 識別子により同一性・連続性が担保される ドメインオブジェクトをつかうメリット 値オブジェクトとエンティティという、2つのドメインオブジェクトが登場した これらのドメインオブジェクトをつかうメリットは2つある メリット1:コード自体がドキュメントになる 値オブジェクトの「表現力が増す」と言いたいことは同じで 単なるプリミティブな型、たとえばstringをつかうのではなく 独自に型をつくることで、コードにルールが埋め込まれて、それがドキュメントとなる メリット2:ドメインルールの変更をコードに反映しやすくなる もう1つはオブジェクト指向の基本であるところのカプセル化である データとふるまい(メソッド)を1か所にまとめることで、変更箇所を極小化できる 逆にオブジェクトがデータを保持するだけで、ふるまいが外に漏れ出ることを、ドメインモデル貧血症というらしい コードの不自然さを解消する反面、この貧血症を起こす危険性をはらむのが、次節のドメインサービスである ドメインサービス ドメインサービスは不自然さを解消します (p.065) 値オブジェクトやエンティティに持たせると不自然になるふるまいはサービスが担う たとえばユーザーアカウント(=エンティティ=区別されるもの)を新規作成するためには、あたらしく作るエンティティの識別子が既存のものと衝突しないことを確認する必要がある その確認作業をエンティティ自身にもたせるのは不自然である class User { public bool Exists(User user) { // ユーザーがユーザーを引数にして、存在するか判断する。これは不自然 } } その場合は、サービスクラスclass UserServiceにメソッドExists(User user)を持たせればよい ドメインサービスは値オブジェクトやエンティティが担えないふるまいを代行する事務係であるため、ドメインサービス自身は状態をもたない (ある種ユーティリティクラスのようなものだと捉えている) ドメインサービスの乱用はドメインモデル貧血症をおこす ふるまいをドメインサービスに任せっきりになると、ドメインオブジェクトはgetter/setterだけのデータの入れ物になり下がる ドメインサービスを神ユーティリティクラスに進化させてしまわないように、まずは値オブジェクトやエンティティにふるまいを実装することを考える 不自然さを解消する目的においてのみ、ドメインサービスへふるまいを移行する おわりに 標題の本の1/4くらいまでについて触れた 実際のコード事例が少ない文字だけの記事になってしまったが、巷には実践例があふれているのでよしとする 標題の本の付録には、「Visual Stduioにおいてソリューション構成をどうすべきか」が書かれている 何の名前でどうフォルダ分けするが、まず初めに悩むところなので、この辺はまた後日読みたい おまけ ちょうどコレを書いているときに、zennに類似の投稿があったので、リンクさせていただく エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践) 大型本 – 2011/4/9 ↩
- 投稿日:2021-12-05T15:20:39+09:00
.NET6でHTTP/3を扱ってみる
AdventCalendarについて ✩本記事は Qiita AdventCalendar2021 / 祝 .NET 6 GA!.NET 6 での開発 Tips や試してみたことなど、あなたの「いち推し」ポイントを教えてください【PR】日本マイクロソフト Advent Calendar 2021 の 5日目の記事です きのうは 昨日 2021年12月04日 の本カレンダーの記事は @kaorumori さんの「Blazor WebAssemblyのAOTコンパイルによる高速化とAzure Static Web Appsへのデプロイ」でした Blazorでライフゲームを再現するのに少し驚きましたが、AOTコンパイルによる処理時間の高速化がハンパないことにも驚く内容でした .NET6すげー!(こなみかん) はじめに 本アドベントカレンダーに記事を書くことを決めた時、私は.NET6の新機能の一覧ページを眺めていました その際に最初に「これかなー」と思って決めた題材が System.Text.Json についてでしたが、その後 HTTP/3のサポート が新機能として載っていたのを見て 「あ、これは書かなくてはならないやつかなー」と本アドベントカレンダー2つ目の記事を書くことに決めたのでした そんな、何かビビッと来させるよう 「HTTP/3 (QUIC) を .NET6で実装してみた」 お話を記載します 実行環境 VisualStudio2022 Preview windows環境 (開発用) Windows11 Pro バージョン: 21H2 ビルド: 22000.348 .NET6 SDK 6.0.100 (x86_64) windows環境 (実行用 / Windowsサンドボックス) Windows11 Enterprise バージョン: 21H2 ビルド: 22000.348 .NET6 SDK 6.0.100 (x86_64) ASP.NET6 Core Runtime 6.0.0 (x86_64) macOS環境 ありません (現時点でHTTP/3の実行は実行時にExceptionが発生するため実行できません) サンプルコード こちらに本記事を書くに当たって作成したコードのリポジトリがございます よろしければご確認ください 参考 注意事項 本機能は現時点で プレビュー機能 としてリリースされています https://docs.microsoft.com/ja-jp/dotnet/core/extensions/httpclient-http3 実行環境欄にも記載しましたが、この記事が執筆された 2021年12月05日 の時点で、HTTP/3 を macOSで使用することはできません 将来的に実行できるようになるとのことです ※こんな感じでエラーになります ASP.NET Core 6 プロジェクトの用意 プロジェクトの作成を選択します ASP.NET Core Web App を選択します プロジェクト名やプロジェクトファイルを配置する場所を選択します 使用するフレームワークのバージョンなどを指定します (忘れずに .NET6 を選択しましょう / また、QUICはHTTPSが前提ですので、 Configure for HTTPS のチェックも入れておきます) .csproj の中身もこちらのように変更しておきます (超重要で、これやっておかないとHTTP/3のサーバとして起動できません) <!-- .csproj --> <Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> + <EnablePreviewFeatures>True</EnablePreviewFeatures> </PropertyGroup> </Project> コードの実装 たったこれだけ、簡単ではありませんか? 実装するだけなら簡単なのだと思います、本当に // Program.cs using Microsoft.AspNetCore.Server.Kestrel.Core; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorPages(); + builder.WebHost.ConfigureKestrel((context, options) => + { + options.ListenAnyIP(5001, listenOptions => + { + // Use HTTP/3 + listenOptions.Protocols = HttpProtocols.Http3; + // こっちだとHTTP/1.1,HTTP/2,HTTP/3が使えます + //listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + listenOptions.UseHttps(); + }); + }); var app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); app.Run(); 実行 問題はここからです macOSでは実行できない、LinuxやWindowsなら実行できるHTTP/3のASP.NETプロジェクト こいつをWindowsで HTTP/3を使用していることを検証 しようとする場合、 そのままデバッグ実行では動作しません というわけで、このASP.NET Core6のアプリを実行するには WSL2 Windowsサンドボックス のいずれかを利用する必要があります … Windowsサンドボックス!? Windowsサンドボックス 私はこの名前を初めて聞いたのですが、Windows10 Pro以降のエディションでバージョン18305以降で提供される機能のようです このページによると 新たにVHDを作ったりする必要ナシ 終了すると中のデータは綺麗さっぱり、起動するたびに常に新しいWindowsが実行される Windowsのハイパーバイザを使用して、ホストのカーネルと切り離している?のでセキュリティもバッチリ ほえぇ… る? (言いたかっただけ) 今回のこの記事の作成でこんな目新しい、飛び抜けている技術に突き当たるとは思いもしませんでした せっかくなので、今回はこのWindowsサンドボックスを使用していきましょう (HomeEdition勢を置き去りにしてしまいますが…) Win+S で検索ボックスを表示し、 機能の有効化 あたりで検索すると出てくる Windows の機能の有効化または無効化 を選択し、当該のウィンドウを表示させます Windows サンドボックス のチェックを入れ、OKボタンをクリックしましょう 再起動するかどうかを問われるので、迷わず再起動を選択し、再起動されるのを待ちます 再起動したら、同じく Win+S で検索ボックスを表示し、 Windowsサンドボックス で検索しましょう、きっとあなたを待っています 実行する.NET環境が必要ですので、あらかじめダウンロードしておきます ASP.NET Core6の実行環境をダウンロードし、インストールしましょう 実行しようとしているASP.NETアプリはHTTPSで動作しますが、こちらを認証させるために.NET6のSDKが必要となりますので、こちらについてもDLしておきます ここまでできたら、おもむろにビルドしたプロジェクトをWindowsサンドボックスの中にコピペで突っ込みます (本当にできます) そして、Powershellを開き、次のコマンドを打ちこみ、開発用のSSL証明書を有効にします > dotnet dev-certs https --trust 最後に、 作成したASP.NETアプリを実行してあげましょう 検証 このASP.NETのアプリケーションの検証には、HTTP/3で扱えるcurlを使います こちらの記事にて紹介されている方法ですが、これを実装している時間はありませんでした ので、同記事にて紹介されているdockerhubにアップロードされているコンテナイメージを使ってみましょう docker run --rm keioni/curl-http3 curl -v -L -s --http3 https://<WindowsサンドボックスのIPアドレス>:5001 こんな感じで、レスポンスを受け取ることができれば、うまくいっています!やったぜ!! HTTPClient プロジェクトの用意 これについてはもはや説明不要な気もしますが、やっておきます プロジェクトの作成から Console App を選択します 適当にプロジェクト名をつけます、配置場所も必要であれば変更しましょう フレームワークの指定はもちろん、 .NET6 で! .csproj を次のように変更して準備OKです <!-- .csproj --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net6.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> + <ItemGroup> + <RuntimeHostConfigurationOption Include="System.Net.SocketsHttpHandler.Http3Support" Value="true" /> + </ItemGroup> </Project> コードの実装 program.cs // See https://aka.ms/new-console-template for more information using System.Net; Console.WriteLine("input access address"); var address = Console.ReadLine(); var client = new HttpClient(); // 以下2行の指定でHTTP/3で通信することができるようになります client.DefaultRequestVersion = HttpVersion.Version30; client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; var response = await client.GetAsync(@"https://" + address); var html = await response.Content.ReadAsStringAsync(); Console.WriteLine(html); 検証 作成したexeファイル等一式を、先ほどの Windowsサンドボックス にコピペで持っていき、実行してみましょう うまく動作することが確認できました、やったぜ! さいごに つかれました このような感じで、 .NET6 では、HTTP/3が扱えるようになっていました Feature Previewな機能ではあるようなので、今後何か変更が加えられる可能性もありますが、ひとまず扱えそうです 余力があれば、dockerで動かして、nginxのコンテナと連携させて…みたいなこともやってみたいですね 今回は時間がなさすぎてムリでした、ごめんなさい また、実際にブラウザで開けるのを確認したかったのですが、多分証明書の関係などでブラウザでは開くことが叶いませんでした、かなしい このへんももしかしたらちゃんとデプロイすればうまく動作するかもしれませんね 何か誤っている個所がありましたらご指摘ください ここまで読んでくださり、ありがとうございました あしたは 明日 2021年12月06日 の本カレンダーの記事は @yakumomo さんが「MAUIの浅い部分で何か書きます」とのことです (こちら、公開されましたらリンクにしておきますね) MAUI、なぜか私の環境では試すことが叶いませんでしたので、どんな感じで使えるのかが気になります (試せなかったの、RyzenCPUの問題とかなのでしょうか…?) 引き続き、Qiita AdventCalendar2021をお楽しみください!
- 投稿日:2021-12-05T12:28:38+09:00
C# EntityFramework マイグレーションまとめ
はじめに Entity Frameworkを使う時のマイグレーションコマンドの使い方の備忘録。 ドキュメントや書籍を確認してもマイグレーションのRollbackの記事が上手に見つけられず苦労したので.... dotnet efのインストール dotnet tool install --global dotnet-ef マイグレーションファイルの作成 dotnet ef migrations add [マイグレーションファイル名] マイグレーションの実行 dotnet ef database update 全テーブルの削除 dotnet ef database drop ここにハマった。。。。。。 各コマンドを叩くと下記が表示された。 Build failed. Use dotnet build to see the errors. ビルドを再度実行しても同じエラー。 リビルドすることで解決出来ました。
- 投稿日:2021-12-05T06:17:33+09:00
Unityでルービックキューブ
はじめに 思い付きでルービックキューブを作ってみたのでその記録として残す。 実際に動く画面がこちら ルービックキューブ作ってみた 改善点は多い pic.twitter.com/qhP8xPnuFj— momantai (@momantai_jp) December 4, 2021 めんどくさくて平面での操作にしたがちゃんと3Dのモデルは作っている。 どんな流れで思考したかの記録。 プロジェクトファイルはここ スワイプ機能 まずスマホでタッチして操作できるようにしようと思い実装。 こちらを参考にした 【Unity】ドラッグ(スワイプ)でオブジェクトを移動させる ルービックキューブの作成 blenderを使ってCubeを26個(見える部分)、それぞれの色をPlaneで張り付けた。 Unityにimportする際に中央にCubeを用意し、rubic1~27の名前を付けている。 今回のやり方では別にUnityの方でCubeとPlaneで用意してもいいと思う。 回転の実装 回転させたいCubeを選択し、縦横を選択して動かせるように考えたのが以下の方法。 1. 3×3×3のstring型の3次元配列を用意し、rubic1~27をそれぞれ入れる 2. タッチしたCubeの名前を取得し、配列内を検索し、添字を求める。 3. 回転するCubeを取得し、その内1つのCubeを親としそれ以外を子にする。 4. 回転中心を回転面の真ん中のCubeにして90度回転させる。 5. 親子関係を解消させる 6. 3次元配列の中身を回転に応じて書き換える。 回転させるCubeの取得はRayを飛ばしてやっている。 長くなるので左右回転のみ記述 using System.Collections; using System.Collections.Generic; using UnityEngine; public class ScreenInput : MonoBehaviour { GameObject target; [SerializeField] float mouse_sensitivity = 0.2f; [SerializeField] float touch_sensitivity = 0.001f; GameObject[] childobjects= new GameObject[9]; //回転させるCubeを格納する配列 string[,,] rubic= new string[3,3,3]; //ルービックキューブを表す配列 string[,,] crubic= new string[3,3,3]; //回転させた後書き換える用の配列 int i,j,k,ti,tj,tk,n=1; void Start() { //1. 3×3×3のstring型の3次元配列を用意し、rubic1~27をそれぞれ入れる for(i=0;i<3;i++){ for(j=0;j<3;j++){ for(k=0;k<3;k++){ rubic[i,j,k] = "rubic"+n; crubic[i,j,k] = "rubic"+n; n++; } } } } void Update() { //2.タッチしたオブジェクトを取得する if(Input.GetMouseButtonDown(0)){ target = null; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit = new RaycastHit(); if(Physics.Raycast(ray, out hit)){ target = hit.collider.gameObject; //2.オブジェクトがどこにあるか探索 for(i=0;i<3;i++){ for(j=0;j<3;j++){ for(k=0;k<3;k++){ if(target.name == rubic[i,j,k]){ ti=i; tj=j; tk=k; } } } } } } //ドラッグ中もしくはスワイプ中 if (Input.GetMouseButtonUp(0)) { float dx,dy; Vector3 point; //マウスの場合 dx = Input.GetAxis("Mouse X") * mouse_sensitivity; dy = Input.GetAxis("Mouse Y") * mouse_sensitivity; //タッチの場合 if (Input.touchCount > 0) { dx = Input.touches[0].deltaPosition.x * touch_sensitivity; dy = Input.touches[0].deltaPosition.y * touch_sensitivity; } if(dx > 0.001f || dx < -0.001f){//左or右回転 //3.回転させるrubic[i,j,k]を取得する iはrayで取得したもので固定 i=0; for(j=0;j<3;j++){ for(k=0;k<3;k++){ childobjects[i] = GameObject.Find(rubic[ti,j,k]); i++; } } //4.実際に回転させる point = childobjects[4].transform.position; for(i=1;i<9;i++){ childobjects[i].transform.SetParent(childobjects[0].transform); } if(dx < -0.001f){//右回転 childobjects[0].transform.RotateAround(point,Vector3.up,90); for(j=0;j<3;j++){ for(k=0;k<3;k++){ rubic[ti,j,k] =crubic[ti,3-k-1,j]; } } } else if(dx > 0.001f){//左回転 childobjects[0].transform.RotateAround(point,Vector3.down,90); for(j=0;j<3;j++){ for(k=0;k<3;k++){ rubic[ti,j,k] =crubic[ti,k,3-1-j]; } } } //5. 親子関係を解消させる for(i=1;i<9;i++){ childobjects[i].transform.parent= null; } //6. 3次元配列を書き換える for(i=0;i<3;i++){ for(j=0;j<3;j++){ for(k=0;k<3;k++){ crubic[i,j,k] = rubic[i,j,k]; } } } } } } } 現在ある問題点 縦横の操作の区別が難しい。→xy方向にベクトル量で詳しく設定するとかしないとダメかな~という所。3Dに見えるような視点で操作させるならさらに色々必要? 最初の動画のように白面を正面にしている所からのスタートになる。 本来のルービックキューブを考えるとランダムに回してからのスタートや別の面を正面にした時の操作も可能にした方が理想。回転の実装方法から変えた方が良いかも。思いついたのはカメラをそれぞれの面で切り替えらえるようにして、その都度3次元配列を書き変える方法。 回転するとCubeがずれて見える。これはモデリングが下手なのが原因。 あと継ぎ足しで長くなったのでソースコードちゃんと分けたい。
- 投稿日:2021-12-05T05:35:19+09:00
【C#】FFmpeg APIで動画プレーヤーを1からつくってみる(後編)
この記事はHUITアドベントカレンダー5日目の記事です。 前編はこちら 動画表示UIの作成 動画がデコードできたので、画面に表示してみましょう。 今回はWPFアプリケーションを作成するので、WPFを利用して画面描画をします。 動画プレーヤーなので、毎秒60枚程度はフレームを描くことになりうるため、高速な手法が必要です。生半可な実装だと死にます。この場合、WriteableBitmap クラスを利用するのが適しています。 WriteableBitmap クラスでは低レベルにピクセルデータを設定すると、DirectX 9により画面に描画してくれます。低レベルはすべてを解決する 最終的に画面に表示させるには、xamlで Image コントロールを配置し、Source にバインディングします。 WriteableBitmapの作成 まず画面のDPIを取得します。MainWindow.xaml.csなどのUIに触れるコンテキストに以下のコードを書きます。 ffplay.exeはDPI無視するのでこの時点で私の勝ちです。 MainWindow.xaml.cs 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 を受け取るメソッドを用意し、安全にします。 FrameConverter.cs 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() を呼ぶと再生します。 VideoPlayController.cs 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の使用は一部のマニア以外にはおすすめしません
- 投稿日:2021-12-05T05:35:19+09:00
【C#】FFmepg APIで動画プレーヤーを1からつくってみる(後編)
この記事はHUITアドベントカレンダー5日目の記事です。 前編はこちら 動画表示UIの作成 動画がデコードできたので、画面に表示してみましょう。 今回はWPFアプリケーションを作成するので、WPFを利用して画面描画をします。 動画プレーヤーなので、毎秒60枚程度はフレームを描くことになりうるため、高速な手法が必要です。生半可な実装だと死にます。この場合、WriteableBitmap クラスを利用するのが適しています。 WriteableBitmap クラスでは低レベルにピクセルデータを設定すると、DirectX 9により画面に描画してくれます。低レベルはすべてを解決する 最終的に画面に表示させるには、xamlで Image コントロールを配置し、Source にバインディングします。 WriteableBitmapの作成 まず画面のDPIを取得します。MainWindow.xaml.csなどのUIに触れるコンテキストに以下のコードを書きます。 ffplay.exeはDPI無視するのでこの時点で私の勝ちです。 MainWindow.xaml.cs 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 を受け取るメソッドを用意し、安全にします。 FrameConverter.cs 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を選びます。これでやはりfflay.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() を呼ぶと再生します。 VideoPlayController.cs 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の使用は一部のマニア以外にはおすすめしません
- 投稿日:2021-12-05T03:52:01+09:00
【UnityC#】MonoBehaviourでもインターフェイスを利用して疎結合を実現する2つの方法
はじめに C#をはじめとするオブジェクト指向言語では、クラス同士が直接アクセスするのを避け、インターフェイスを介してアクセスさせることで疎結合が実現されることは周知の事実です。そのため、UnityC#でもMonoBehaviour同士をインターフェイスを介して依存させたい!でもMonoBehaviourの制約のせいでうまくいかない!って方は割と多いのではないでしょうか。 例えば、以下のようにFugaComponentがHogeComponentのインターフェイスIHogeComponentに依存したいとき。 public interface IHogeComponent { void SayHoge(); } public class HogeComponent : MonoBehaviour, IHogeComponent { public void SayHoge() { print("Hoge"); } } public class FugaComponent : MonoBehaviour { // 疎結合のためインターフェイスを通じてアクセスさせたい。 private IHogeComponent m_hoge; //←このm_hoge、どこから持ってくる!? void Start() { m_hoge.SayHoge(); } } FugaComponentはインターフェイスIHogeComponentをフィールドに持つことでHogeComponentとFugaComponentとの結合度を弱めているのですが、m_hogeの中身はどうやって代入するのかはなかなか難しい問題です。というのも、 SerializeField属性をつけてインスペクタから代入しては?→インターフェイスはシリアライズできません! コンストラクタから渡す?→MonoBehaviourなクラスの場合はコンストラクタ作れません! GetComponent<T>からインターフェイス取得しては?→同じGameObjectにアタッチされているコンポーネント同士ならOKだけど、違う場合はGameObjectへの参照が必要になって美しくないし安全性も落ちる! このような問題が発生するからです。 そこで本記事では、上記のような問題を解決するべく、MonoBehaviourなクラスにインターフェイスを引き渡す方法を2つ紹介します。 方法1. Odin Inspectorを使ってインターフェイスをシリアライズする 通常、次のようにインターフェイスをSerializeFieldしてもインスペクタ上には表示されません。 public class FugaComponent : MonoBehaviour { [SerializeField] private IHogeComponent m_hoge; } ▲インターフェイスをSerializeFieldしても… ▲インスペクタ上には表示されない ところが、Odin Inspectorというアセットに含まれているSerializedMonoBehaviourクラスを使うと、インスペクタ上でインターフェイスを引き渡すことができるようになります。 public class FugaComponent : SerialiedMonoBehaviour { [SerializeField] private IHogeComponent m_hoge; } ▲MonoBehaviourの代わりにSerializedMonoBehaviourを継承させると… ▲インターフェイスもインスペクタ上に現れる! この方法であれば、インスペクタ上でもIHogeComponentを実装したコンポーネントがアタッチされたGameObjectしかセットできないですし、ソースコード上にも余計なメンバが現れないため、安全かつ美しいです。 ただし、Odin Inspectorは60.50ドルの有料アセットです(たまに半額セールもある)。 お金をかけたくない場合は、次に紹介するまったく異なるアプローチを取ることもできます。 方法2. Zenjectを使ってインターフェイスを注入する Zenject(Extenject)は、UnityC#でDependency Injection(DI、依存性の注入)1を実現する無料アセットです。 DI(依存性の注入)は簡単に言うと、クラス同士の依存関係を設定しておくと、必要な時に必要なオブジェクトを引き渡してくれる(=注入してくれる)仕組みです。 Zenjectの使い方は複雑なので、順を追って説明します。 注入してほしいインターフェイスにInject属性をつける インスペクタからオブジェクトを引き渡す際はSerializeField属性を付けますが、Zenjectによってオブジェクトを注入する際はInject属性を付けます。 public class FugaComponent : MonoBehaviour { [Inject] private IHogeComponent m_hoge; } ちょうど、SerializeFieldの代わりにInjectを付けるイメージです。こうすることで、Zenjectに対してIHogeComponentを注入して欲しいです!という意思表示ができます。 依存関係定義ファイルMonoInstallerを作成する インターフェイスを注入すると言っても、実際に注入されるのは具体的なオブジェクトのインスタンスです。とあるインターフェイスの注入を要求されたとき、具体的にはどんなオブジェクトを注入すれば良いのかをZenjectに対して設定する必要があります。この設定ファイルをInstallerと言います。 Projectビューのプラスボタンをクリックして、Zenject→Mono Installerをクリックします。任意の名前をつけてファイルを保存します。 作成されたファイルには、次のようなコードが記載されています。 using UnityEngine; using Zenject; public class UntitledInstaller : MonoInstaller { public override void InstallBindings() { } } このInstallBindingsメソッド内に、「どのインターフェイスが要求されたらどのオブジェクトを注入するか」を定義していきます。 例えば、IHogeComponentインターフェイスが要求されたらHogeComponentを注入するように設定する場合は、次のようにします。※あくまで一例です。 using UnityEngine; using Zenject; public class UntitledInstaller : MonoInstaller { public override void InstallBindings() { Container .Bind<IHogeComponent>() .To<HogeComponent>() .FromComponentInHierarchy() .AsCached(); } } まずメソッド内1行目のContainerですが、これはDiContainerクラスのインスタンスです。DiContainerは設定通りにオブジェクトを注入してくれる核となる存在です。これからこのDiContainerに対してさまざまな依存関係の設定を行っていきます。 2行目のBind<T>メソッドは「このインターフェイスが要求されたら」という意味です。DiContainerに対して、IHogeComponentが要求されたら(Injectされたら)、なんらかのオブジェクトを注入してね、と設定します。 3行目のTo<T>メソッドはBind<T>で設定されたインターフェイスが要求されたときに、実際に注入するオブジェクトを設定します。この例では、IHogeComponentが要求されたときにHogeComponentを注入するように設定しています。 4行目のFromComponentInHierarchyメソッドは、「注入するオブジェクトはヒエラルキー上から持ってきてね」と設定しています。 もちろんこれは一例であり、注入するオブジェクトはさまざまな場所から持ってくることができます。詳しくはこちらのサイトが参考になります。 5行目のAsCachedメソッドは、注入オブジェクトをキャッシュして再利用するように設定しています。例えば、IHogeComponentが複数のクラスから要求された場合、2回目以降はヒエラルキー上から持ってくるのではなく、キャッシュされたオブジェクトをそのまま注入します。 他にもAsSingleやAsTransientなどがあります。詳しくはこちらのサイトが参考になります。 以上で、ひとまず依存関係の設定は完了しました。 Contextを用意してInstallerをセットする 最後に、作成したInstallerの影響範囲を設定します。これには、Contextというものを使用します。 Contextにも様々ありますが、ここではひとつのシーン全体に影響するSceneContextを使用します。 もし、Contextに対して詳しく知りたい場合は、以下のようなサイトが参考になります。 ヒエラルキービューのプラスボタンをクリックして、Zenject→Scene Contextをクリックします。 すると、シーン上にSceneContextコンポーネントがアタッチされたGameObjectが作成されると思います。 SceneContextコンポーネントのMonoInstallersの部分に、先ほど作成したInstallerをセットします。 Add Componentボタンを押して先ほどのInstallerをアタッチし、それをMonoInstallersにドラッグしてセットできます。 以上で、SceneContextに対してInstallerのセットが完了しました。 これで晴れて、このSceneContextの影響範囲に対して、セットしたInstallerの設定内容に従って、オブジェクトの注入が実行されるようになります。 Scene上に注入を要求するコンポーネントを配置する SceneContextの影響範囲内(同一Scene内)に、オブジェクト注入を要求するコンポーネントを配置します。また、今回の例ではFromComponentInHierarchyに設定しているため、ヒエラルキ上に注入されるオブジェクト(コンポーネント)も配置されている必要があります。 次のように、Scene上に注入されるHogeComponent、注入を要求するFugaComponentをそれぞれアタッチしたGameObjectを配置します。 動作確認用に、HogeComponentとIHogeComponentを以下のように実装しました。 public interface IHogeComponent { void SayHoge(); } public class HogeComponent : MonoBehaviour, IHogeComponent { public void SayHoge() { print("Hoge"); } } FugaComponentはIHogeComponentの注入を要求し、SayHoge()を実行します。 public class FugaComponent : SerializedMonoBehaviour { [Inject] private IHogeComponent m_hoge; void Start() { m_hoge.SayHoge(); } } この状態でPlayすると、コード上ではm_hogeに対してオブジェクトを代入していないのに、きちんとSayHoge()が呼べていることが分かります。Zenjectによるオブジェクトの注入が効いている証拠です。 OdinとZenjectの使い分け 以上、UnityC#においてインターフェイスを引き渡す方法を2つ紹介しました。 OdinとZenjectは、片方だけでもインターフェイスを引き渡すという目的は達成できるのですが、どちらにも得意な場面・不得意な場面があると考えています。 例えば、Odinの場合はインスペクタ上で視覚的にオブジェクトの注入ができるため、同じインターフェイスを実装していても実体が異なる複数のインスタンスを注入したい場合に向いています。 ▲異なる複数のインスタンスを視覚的に注入できる これをZenjectで行うには、Installerの設定時にIDを使った注入設定を細かく行う必要があるため、設定と管理が煩雑になります。 反対にゲームのManager的なクラスなど、ただひとつのインスタンスしか持たないオブジェクトを注入したい場合、インスペクタ上で手動で注入するよりも、依存関係を設定してZenjectにやってもらったほうが楽です。 このように、OdinとZenjectは得意不得意があると思いますので、結局は両方とも導入して、適材適所で使い分けるのが一番良いのではないかと考えています。 Odinはインターフェイス注入以外にもインスペクタを使いやすくする機能が揃ってますし、DIはUnityだけではなく様々な場面で使われるデザインパターンです。どちらも勉強しておいて損はないかと思います(自分も勉強中)。 番外編. どうしてもOdinを買わずにインスペクタでインターフェイスを渡したい場合 昔の自分がOdinを使わずに無理矢理インスペクタでインターフェイスを(擬似的に)渡す方法を考えてました。 ご興味ある方は下記参照ください。 この記事はZenject, DI(依存性の注入)について詳しく解説する記事ではありません!正確性に欠ける表現がある可能性があることをご了承ください。 ↩
- 投稿日:2021-12-05T03:31:58+09:00
【C#】FFmpeg APIで動画プレーヤーを1からつくってみる(前編)
この記事はHUITアドベントカレンダー5日目の記事です。 大変複雑なため、記事が長くなりすぎてしまい、前後編に分けることになりました。後編はこちら。 はじめに この記事で扱うのはFFmpeg API、つまりDLLを叩くもので、 ffmpeg -i input.mp4 -c:v copy -c:a copy output.avi みたいな 生ぬるい 話は一切でてきません。FFmpeg APIの雰囲気や動画プレーヤーのしくみを知りたい人向けです。 とりあえず動画を変換・解析してみたい! => ffmpeg.exe や ffprove.exe をコマンドラインで使いましょう。Process クラスが有用。 とりあえず動画を再生したい! => WPFには動画再生用のコントロールが標準であります。MediaElement で検索。 動画のフレームの画素データがほしい! => OpenCVがおすすめです。OpenCVSharpというC#ラッパーがあるので簡単に扱えます。 リアルタイムで変換・デコードしたい! => ffmpeg.exe の出力をコンソールの標準出力にリダイレクトしましょう。 pipe:1 を出力パスの代わりに書きます。 といった具合に、たいていの用途では FFmpeg API を使うのはおすすめしません。 OpenCVは動画を扱う方法としては少し意外かもしれませんが、OpenCVSharp という高レベルのC#ラッパーがあるのがミソです。 昔コンソールで「BadApple!」を再生するのに使いました(そのうち記事にするかもしれません)が、結構使いやすいです。おすすめ。 なぜこのようなことを書いたかといえば、FFmpeg APIは(C#では) かなり扱いにくい からです。 それでもFFmpeg APIが使いたい かなり複雑なことをしたい場合、もうFFmpeg APIしか選択肢がないかもしれません。 FFmpeg が非常に高機能かつ高パフォーマンスなのは事実で、動画プレーヤーを1から作りたい、といった変わったケースではかなり有力です。 覚悟を決めてHello Worldしましょう。 幸いにも、大量の [DLLImport] を書く必要はありません。FFmpeg.AutoGen というライブラリを使います。 このライブラリは薄いラッパーで、[DLLImport] 相当のコードを自動生成したもののようです。 現状C#向けのラッパーはほとんどなく、検索してヒットするのは ffmpeg.exeの方のラッパー(?) だったりします。 薄いラッパーなので大量の * からは逃げられませんが、情報源が本当に少ないFFmpeg APIを扱う上でメソッドのシグネチャが何も変わっていないのは逆に利点です。 今のうちにufcppでunsafeの復習をしておきましょう 公式のサンプルコードやごくわずかな情報を活用しやすく、c++のコードがほぼコピペで動く場合もあります。特にこちらの記事には大変お世話になりました。 準備 まず先程のライブラリをNuGetで取ってきます。使用するFFmpegのバージョンに合わせたNuGetパッケージのバージョンを選択します。この記事では4.4.1を利用しています。 次にFFmpegのバイナリを用意します。自分でビルドしてもよいですが、ビルド済みのものをダウンロードするのが楽でしょう。 今回の動画プレーヤーはWindows向けなので、Windowsでの話になってしまいますが、公式サイトにビルド済みバイナリを配布している有志サイトへのリンクがあります。今回は gyan.dev さんの方のビルド済みバイナリを利用しました。 「ffmpeg-release-full-shared.7z」のように「shared」が入るものをダウンロードすると今回必要なDLLが手に入ります。ライセンスがGPLなので注意。 普段「FFmpeg」と呼んでいたものはいくつかのライブラリの集合体なのでした。ちなみにffmpeg.dllみたいなものが必要なわけではないです。以下が個別にあればOK。 DLLの名前 APIでの主な接頭辞 内容 avutil av_ メモリ管理などの便利機能 avformat avformat_ 動画コンテナに関わる処理 avcodec avcodec_ 動画・音声コーデックのエンコード・デコード swscale sws_ 画像のスケーリング swresample swr_ 音声のリサンプリング となっております。今後全部使います。 ファイルの読み込み 初期化 予め using FFmpeg.AutoGen;しておいてください。以後省略します。 また、今後unsafeコードが大量に登場しますので予めunsafeを有効にしておいてください。 ffmpeg.RootPath = @"C:\Users\{ユーザー名}\Downloads\ffmpeg-4.4.1-full_build-shared\bin"; ffmpeg.av_register_all(); RootPath プロパティの値として、先程ダウンロードしたDLL群のあるディレクトリを指定します。 その上で、ffmpeg.av_register_all()を呼んでライブラリを初期化します。最初に1度だけ呼んであげる必要があります。 コンストラクタなどで呼んであげてください。 エラー処理定形コード 今後呼ぶ関数には、「失敗すると負のエラーコードが返り、成功すると0が返る」ものがたくさんあります。 例外に変換する拡張メソッドを予め書いておきましょう。今後当然のように登場します。 public static int OnError(this int n, Action act) { if (n < 0) { var buffer = Marshal.AllocHGlobal(1000); string str; unsafe { ffmpeg.av_make_error_string((byte*)buffer.ToPointer(), 1000, n); str = new string((sbyte*)buffer.ToPointer()); } Marshal.FreeHGlobal(buffer); Debug.WriteLine(str); act.Invoke(); } return n; } av_make_error_string 関数は、エラーコードからエラーメッセージを取得する関数です。こちらでバッファを用意してあげて呼びます。 第一引数にバッファのポインタ、第二引数にバッファの長さ、第三引数にエラーコードを投げます。 バッファの長さは1000byteで決め打ちしてますが、このへんは適当に調整してください。 ファイルを開く // クラスのフィールド string path = "{有効な動画のパス}"; AVFormatContext* formatContext; public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); } まず、avformat_open_input 関数を呼びます。第一引数にAVFormatContextのダブルポインタを、第二引数に動画のパスを渡します。 いきなりダブルポインタですが頑張って理解してください。 これでAVFormatContext が取れましたので、次に動画に含まれるストリームを解析します。 avformat_find_stream_info関数を呼びます。第一引数にAVFormatContextのポインタを渡すと、渡したAVFormatContextに情報が書き込まれます。 ストリームを探す private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } 今後->演算子が頻出します。普段のC#では見ないし使わないと思うので今のうちに復習しておいてください。 AVFormatContext のstreamフィールド(配列様)を漁ってストリームを探します。 ストリームには、動画ストリーム、音声ストリーム、字幕ストリームなどなど色々ありますので、目的のものが見つかるまでループを回します。 ストリームは複数ある可能性(例:日本語音声と英語音声)が考えられますが、今回は便宜上最初に見つかったストリームのみを有効とします。 上記のは動画ストリームを探すメソッドでしたが、音声ストリームを探すものも用意します。 private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } コーデックを開く ストリームを見つけたので、今度は各ストリームのコーデックに合わせたデコーダを初期化しましょう。 private AVStream* videoStream; private AVCodec* videoCodec; private AVCodecContext* videoCodecContext; // OpenFile関数の続き videoStream = GetFirstVideoStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } avcodec_find_decoder 関数でデコーダを取得します。今回はストリームのコーデック情報から必要なデコーダを自動検出しています。 例えばHWデコーダを使いたいなど、使用するデコーダをカスタマイズする場合はここでデコーダを変えます。 こちらの関数は戻り値としてデコーダが得られます。 この先は定形コードです。説明するよりコードを見てもらうほうが理解が早いと思います。 AVCodecContextを経由してAVCodecを初期化すれば準備完了です。 音声コーデックも開きます。やることは同じです。以下のコード全体像で把握してください。 完成形 ここまでの処理を1つのクラスにまとめたものを示します。 最初はコピペでも構いませんが何となく何をしているかは把握してください。 /// <summary> /// 動画デコーダを表現します。 /// </summary> public unsafe class Decoder : IDisposable { public Decoder() { ffmpeg.RootPath = @"{DLLがあるディレクトリのパス}"; ffmpeg.av_register_all(); } private AVFormatContext* formatContext; /// <summary> /// 現在の <see cref="AVFormatContext"/> を取得します。 /// </summary> public AVFormatContext FormatContext { get => *formatContext; } private AVStream* videoStream; /// <summary> /// 現在の動画ストリームを表す <see cref="AVStream"/> を取得します。 /// </summary> public AVStream VideoStream { get => *videoStream; } private AVStream* audioStream; private AVCodec* videoCodec; /// <summary> /// 現在の動画コーデックを表す <see cref="AVCodec"/> を取得します。 /// </summary> public AVCodec VideoCodec { get => *videoCodec; } private AVCodec* audioCodec; private AVCodecContext* videoCodecContext; /// <summary> /// 現在の動画コーデックの <see cref="AVCodecContext"/> を取得します。 /// </summary> public AVCodecContext VideoCodecContext { get => *videoCodecContext; } private AVCodecContext* audioCodecContext; /// <summary> /// ファイルを開き、デコーダを初期化します。 /// </summary> /// <param name="path">開くファイルのパス。</param> /// <exception cref="InvalidOperationException" /> public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); videoStream = GetFirstVideoStream(); audioStream = GetFirstAudioStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } if (audioStream is not null) { audioCodec = ffmpeg.avcodec_find_decoder(audioStream->codecpar->codec_id); if (audioCodec is null) { throw new InvalidOperationException("必要な音声デコーダを検出できませんでした。"); } audioCodecContext = ffmpeg.avcodec_alloc_context3(audioCodec); if (audioCodecContext is null) { throw new InvalidOperationException("音声コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(audioCodecContext, audioStream->codecpar) .OnError(() => throw new InvalidOperationException("音声コーデックのパラメータ設定に失敗しました。")); ffmpeg.avcodec_open2(audioCodecContext, audioCodec, null) .OnError(() => throw new InvalidOperationException("音声コーデックの初期化に失敗しました。")); } } private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } } お疲れさまでした。これでファイルを開く処理は完了です。一回休憩しましょう。 ファイルを開くだけでも結構たいへんです。「はじめに」のセッションでくどいことを書いた理由がおわかりいただけたと思います... FFmpeg APIを使ってみたくてこの記事にたどりついた方はこのあたりでスっとタブを閉じていることでしょう デコードする パケットの読み出しと配送 まずは、動画ファイルから「パケット」を取り出します。 パケットは、どれかのストリームに関連するデータです。目的のストリームではないかもしれません。 av_read_frame関数が0を返した場合、パケットが取得できています。0でない場合は、動画の末端に達しています。 AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); パケットのstream_indexをみて、正しいデコーダにパケットを配送します。 パケットは、参照カウンタ方式で管理されているので使い終わったら参照を外してください。 if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, &packet) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); } } } フレームの取得 パケットを送信したら、avcodec_receive_frame 関数を呼びフレームの読み取りを試みます。 パケットとフレームは対応していないので、一回のパケット送信操作で複数のフレームが得られたり、あるいは何も得られなかったりしますので、そのことを念頭に置いて設計してください(後ほど例を示します)。また、av_frame_allocしたら必ず責任を持ってav_frame_freeする必要があります。 このフレームがデコードの結果得られる(ほしい)ものです。サイズが大きいので配列などに突っ込むのはおすすめしません。 必要に応じて(メソッドを呼び出したら)返すように設計しましょう。 AVFrame* frame = ffmpeg.av_frame_alloc(); ffmpeg.avcodec_receive_frame(videoCodecContext, frame) 動画のフレームは非常にサイズが大きく、解放が漏れた場合大変なことになります(あっという間にメモリを使い果たす)。 解放漏れを起こさないよう注意するとともに、適宜マネージドラッパーを作成するなど保険をかけましょう。このあたりの実装例は後ほど示します。 実際解放漏れがあったときは、数十秒以内に私のPCの16GBのメモリを使い切り落ちました。本当に注意してください。 終端に達したとき 最後に、パケットが読み取れなくなり、動画の終端に達した場合は各デコーダにnull を送信して終端に達したことを通達します。 逆にプログラムのバグなどで誤ってnullを途中送信してしまうと、その次のパケット送信操作で「すでに動画の終端に達しています(筆者訳)」というエラーが起きます。 一見何が間違っているのかわかりにくいので注意しましょう(一敗)。 ffmpeg.avcodec_send_packet(videoCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); null を送信したあと、デコーダに残っているフレームを最後まで読み取りきるのを忘れないようにしてください。 解放 AVCodecContext と AVFormatContext は解放が必要です。avcodec_free_context あるいは avformat_close_input 関数で最後に解放してください。 デコードに必要な実装 パケットのキュー 動画プレーヤーのように動画と音声を同時に扱う場合、取得したパケットをキューする機構が必要です。 デコーダからフレームを読み出すことを試みる前に次のパケットを送信することは許されていないため、動画パケットを探している途中で音声パケットが出てくるなどした場合キューに入れます。 実は、パケットはユーザーコード側に所有権がありません。C#コード側で保持しておいても普通に解放されます。 したがって、パケットを自分で複製した上でそれを保持しなければなりません。(これに辿り着くのに数日かかりました。本当に情報が少ない。) C#ではポインタをジェネリック型引数にできないのでAVPacketPtr構造体でラップしています。 private struct AVPacketPtr { public AVPacket* Ptr; public AVPacketPtr(AVPacket* ptr) { Ptr = ptr; } } private Queue<AVPacketPtr> videoPackets = new(); private Queue<AVPacketPtr> audioPackets = new(); AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, & .OnError(() => throw new InvalidOperationExcept ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); videoPackets.Enqueue(new AVPacketPtr(_packet)); } } } フレームの管理 デコード処理の成果物はフレームで、今後はこれを扱いますが、前述の通り解放漏れを起こすと悲惨です。 よってこのフレームのラッパーを作り、上位のコードにはラップされたフレームを渡すことにします。 IDisposableインターフェイスを実装し、さらにファイナライザ(デストラクタ)を定義しそこから解放ロジックを呼びます。 これでusingなどの確実な解放を保証する言語機能が使えますし、最悪GCが解放してくれます。 /// <summary> /// ラップされた <see cref="AVFrame"/> を表現します。解放漏れを防止します。 /// </summary> public unsafe class ManagedFrame : IDisposable { public ManagedFrame(AVFrame* frame) { this.frame = frame; } private readonly AVFrame* frame; /// <summary> /// ラップされたフレームを取得します。このフレームを独自に解放しないでください。 /// </summary> public AVFrame* Frame { get => frame; } ~ManagedFrame() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVFrame* aVFrame = frame; ffmpeg.av_frame_free(&aVFrame); isDisposed = true; } } 完成したデコーダーのコード decoder.cs using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using FFmpeg.AutoGen; namespace FFmpegWraper { /// <summary> /// 動画デコーダを表現します。 /// </summary> public unsafe class Decoder : IDisposable { public Decoder() { ffmpeg.RootPath = @"{DLLのあるディレクトリのパス}"; ffmpeg.av_register_all(); } private AVFormatContext* formatContext; /// <summary> /// 現在の <see cref="AVFormatContext"/> を取得します。 /// </summary> public AVFormatContext FormatContext { get => *formatContext; } private AVStream* videoStream; /// <summary> /// 現在の動画ストリームを表す <see cref="AVStream"/> を取得します。 /// </summary> public AVStream VideoStream { get => *videoStream; } private AVStream* audioStream; private AVCodec* videoCodec; /// <summary> /// 現在の動画コーデックを表す <see cref="AVCodec"/> を取得します。 /// </summary> public AVCodec VideoCodec { get => *videoCodec; } private AVCodec* audioCodec; private AVCodecContext* videoCodecContext; /// <summary> /// 現在の動画コーデックの <see cref="AVCodecContext"/> を取得します。 /// </summary> public AVCodecContext VideoCodecContext { get => *videoCodecContext; } private AVCodecContext* audioCodecContext; /// <summary> /// ファイルを開き、デコーダを初期化します。 /// </summary> /// <param name="path">開くファイルのパス。</param> /// <exception cref="InvalidOperationException" /> public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); videoStream = GetFirstVideoStream(); audioStream = GetFirstAudioStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } if (audioStream is not null) { audioCodec = ffmpeg.avcodec_find_decoder(audioStream->codecpar->codec_id); if (audioCodec is null) { throw new InvalidOperationException("必要な音声デコーダを検出できませんでした。"); } audioCodecContext = ffmpeg.avcodec_alloc_context3(audioCodec); if (audioCodecContext is null) { throw new InvalidOperationException("音声コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(audioCodecContext, audioStream->codecpar) .OnError(() => throw new InvalidOperationException("音声コーデックのパラメータ設定に失敗しました。")); ffmpeg.avcodec_open2(audioCodecContext, audioCodec, null) .OnError(() => throw new InvalidOperationException("音声コーデックの初期化に失敗しました。")); } } private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } private struct AVPacketPtr { public AVPacket* Ptr; public AVPacketPtr(AVPacket* ptr) { Ptr = ptr; } } private object sendPackedSyncObject = new(); private Queue<AVPacketPtr> videoPackets = new(); private Queue<AVPacketPtr> audioPackets = new(); public int SendPacket(int index) { lock (sendPackedSyncObject) { if (index == videoStream->index) { if (videoPackets.TryDequeue(out var ptr)) { ffmpeg.avcodec_send_packet(videoCodecContext, ptr.Ptr) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(ptr.Ptr); return 0; } } if (index == audioStream->index) { if (audioPackets.TryDequeue(out var ptr)) { ffmpeg.avcodec_send_packet(audioCodecContext, ptr.Ptr) .OnError(() => throw new InvalidOperationException("音声デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(ptr.Ptr); return 0; } } while (true) { AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, &packet) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); videoPackets.Enqueue(new AVPacketPtr(_packet)); continue; } } if (packet.stream_index == audioStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(audioCodecContext, &packet) .OnError(() => throw new InvalidOperationException("音声デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); audioPackets.Enqueue(new AVPacketPtr(_packet)); continue; } } } else { return -1; } } } } /// <summary> /// 次のフレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> public unsafe ManagedFrame ReadFrame() { var frame = ReadUnsafeFrame(); if (frame is null) { return null; } return new ManagedFrame(frame); } private bool isVideoFrameEnded; /// <summary> /// 次のフレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> /// <remarks> /// 取得したフレームは <see cref="ffmpeg.av_frame_free(AVFrame**)"/> を呼び出して手動で解放する必要があることに注意してください。 /// </remarks> /// <returns></returns> public unsafe AVFrame* ReadUnsafeFrame() { AVFrame* frame = ffmpeg.av_frame_alloc(); if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } if (isVideoFrameEnded) { return null; } int n; while ((n = SendPacket(videoStream->index)) == 0) { if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } else { } } isVideoFrameEnded = true; ffmpeg.avcodec_send_packet(videoCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } return null; } /// <summary> /// 次の音声フレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> public unsafe ManagedFrame ReadAudioFrame() { var frame = ReadUnsafeAudioFrame(); if (frame is null) { return null; } return new ManagedFrame(frame); } private bool isAudioFrameEnded; /// <summary> /// 次の音声フレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> /// <remarks> /// 取得したフレームは <see cref="ffmpeg.av_frame_free(AVFrame**)"/> を呼び出して手動で解放する必要があることに注意してください。 /// </remarks> /// <returns></returns> public unsafe AVFrame* ReadUnsafeAudioFrame() { AVFrame* frame = ffmpeg.av_frame_alloc(); if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } if (isAudioFrameEnded) { return null; } while (SendPacket(audioStream->index) == 0) { if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } } isAudioFrameEnded = true; ffmpeg.avcodec_send_packet(audioCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } return null; } ~Decoder() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVCodecContext* codecContext = videoCodecContext; AVFormatContext* formatContext = this.formatContext; ffmpeg.avcodec_free_context(&codecContext); ffmpeg.avformat_close_input(&formatContext); isDisposed = true; } } internal static class WrapperHelper { public static int OnError(this int n, Action act) { if (n < 0) { var buffer = Marshal.AllocHGlobal(1000); string str; unsafe { ffmpeg.av_make_error_string((byte*)buffer.ToPointer(), 1000, n); str = new string((sbyte*)buffer.ToPointer()); } Marshal.FreeHGlobal(buffer); Debug.WriteLine(str); act.Invoke(); } return n; } } } ManagedFrame.cs using FFmpeg.AutoGen; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace FFmpegWraper { /// <summary> /// ラップされた <see cref="AVFrame"/> を表現します。解放漏れを防止します。 /// </summary> public unsafe class ManagedFrame : IDisposable { public ManagedFrame(AVFrame* frame) { this.frame = frame; } private readonly AVFrame* frame; /// <summary> /// ラップされたフレームを取得します。このフレームを独自に解放しないでください。 /// </summary> public AVFrame* Frame { get => frame; } ~ManagedFrame() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVFrame* aVFrame = frame; ffmpeg.av_frame_free(&aVFrame); isDisposed = true; } } } まだデコードするだけなのに合わせて実に430行です。大変。 長くなりすぎてしまい、またエディタが重いので前編・後編に分けることにします。 後編へ続く。
- 投稿日:2021-12-05T03:31:58+09:00
【C#】FFmepg APIで動画プレーヤーを1からつくってみる(前編)
この記事はHUITアドベントカレンダー5日目の記事です。 大変複雑なため、記事が長くなりすぎてしまい、前後編に分けることになりました。後編はこちら。 はじめに この記事で扱うのはFFmpeg API、つまりDLLを叩くもので、 ffmpeg -i input.mp4 -c:v copy -c:a copy output.avi みたいな 生ぬるい 話は一切でてきません。FFmpeg APIの雰囲気や動画プレーヤーのしくみを知りたい人向けです。 とりあえず動画を変換・解析してみたい! => ffmpeg.exe や ffprove.exe をコマンドラインで使いましょう。Process クラスが有用。 とりあえず動画を再生したい! => WPFには動画再生用のコントロールが標準であります。MediaElement で検索。 動画のフレームの画素データがほしい! => OpenCVがおすすめです。OpenCVSharpというC#ラッパーがあるので簡単に扱えます。 リアルタイムで変換・デコードしたい! => ffmpeg.exe の出力をコンソールの標準出力にリダイレクトしましょう。 pipe:1 を出力パスの代わりに書きます。 といった具合に、たいていの用途では FFmpeg API を使うのはおすすめしません。 OpenCVは動画を扱う方法としては少し意外かもしれませんが、OpenCVSharp という高レベルのC#ラッパーがあるのがミソです。 昔コンソールで「BadApple!」を再生するのに使いました(そのうち記事にするかもしれません)が、結構使いやすいです。おすすめ。 なぜこのようなことを書いたかといえば、FFmpeg APIは(C#では) かなり扱いにくい からです。 それでもFFmpeg APIが使いたい かなり複雑なことをしたい場合、もうFFmpeg APIしか選択肢がないかもしれません。 FFmpeg が非常に高機能かつ高パフォーマンスなのは事実で、動画プレーヤーを1から作りたい、といった変わったケースではかなり有力です。 覚悟を決めてHello Worldしましょう。 幸いにも、大量の [DLLImport] を書く必要はありません。FFmpeg.AutoGen というライブラリを使います。 このライブラリは薄いラッパーで、[DLLImport] 相当のコードを自動生成したもののようです。 現状C#向けのラッパーはほとんどなく、検索してヒットするのは ffmpeg.exeの方のラッパー(?) だったりします。 薄いラッパーなので大量の * からは逃げられませんが、情報源が本当に少ないFFmpeg APIを扱う上でメソッドのシグネチャが何も変わっていないのは逆に利点です。 今のうちにufcppでunsafeの復習をしておきましょう 公式のサンプルコードやごくわずかな情報を活用しやすく、c++のコードがほぼコピペで動く場合もあります。特にこちらの記事には大変お世話になりました。 準備 まず先程のライブラリをNuGetで取ってきます。使用するFFmpegのバージョンに合わせたNuGetパッケージのバージョンを選択します。この記事では4.4.1を利用しています。 次にFFmpegのバイナリを用意します。自分でビルドしてもよいですが、ビルド済みのものをダウンロードするのが楽でしょう。 今回の動画プレーヤーはWindows向けなので、Windowsでの話になってしまいますが、公式サイトにビルド済みバイナリを配布している有志サイトへのリンクがあります。今回は gyan.dev さんの方のビルド済みバイナリを利用しました。 「ffmpeg-release-full-shared.7z」のように「shared」が入るものをダウンロードすると今回必要なDLLが手に入ります。ライセンスがGPLなので注意。 普段「FFmpeg」と呼んでいたものはいくつかのライブラリの集合体なのでした。ちなみにffmpeg.dllみたいなものが必要なわけではないです。以下が個別にあればOK。 DLLの名前 APIでの主な接頭辞 内容 avutil av_ メモリ管理などの便利機能 avformat avformat_ 動画コンテナに関わる処理 avcodec avcodec_ 動画・音声コーデックのエンコード・デコード swscale sws_ 画像のスケーリング swresample swr_ 音声のリサンプリング となっております。今後全部使います。 ファイルの読み込み 初期化 予め using FFmpeg.AutoGen;しておいてください。以後省略します。 また、今後unsafeコードが大量に登場しますので予めunsafeを有効にしておいてください。 ffmpeg.RootPath = @"C:\Users\{ユーザー名}\Downloads\ffmpeg-4.4.1-full_build-shared\bin"; ffmpeg.av_register_all(); RootPath プロパティの値として、先程ダウンロードしたDLL群のあるディレクトリを指定します。 その上で、ffmpeg.av_register_all()を呼んでライブラリを初期化します。最初に1度だけ呼んであげる必要があります。 コンストラクタなどで呼んであげてください。 エラー処理定形コード 今後呼ぶ関数には、「失敗すると負のエラーコードが返り、成功すると0が返る」ものがたくさんあります。 例外に変換する拡張メソッドを予め書いておきましょう。今後当然のように登場します。 public static int OnError(this int n, Action act) { if (n < 0) { var buffer = Marshal.AllocHGlobal(1000); string str; unsafe { ffmpeg.av_make_error_string((byte*)buffer.ToPointer(), 1000, n); str = new string((sbyte*)buffer.ToPointer()); } Marshal.FreeHGlobal(buffer); Debug.WriteLine(str); act.Invoke(); } return n; } av_make_error_string 関数は、エラーコードからエラーメッセージを取得する関数です。こちらでバッファを用意してあげて呼びます。 第一引数にバッファのポインタ、第二引数にバッファの長さ、第三引数にエラーコードを投げます。 バッファの長さは1000byteで決め打ちしてますが、このへんは適当に調整してください。 ファイルを開く // クラスのフィールド string path = "{有効な動画のパス}"; AVFormatContext* formatContext; public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); } まず、avformat_open_input 関数を呼びます。第一引数にAVFormatContextのダブルポインタを、第二引数に動画のパスを渡します。 いきなりダブルポインタですが頑張って理解してください。 これでAVFormatContext が取れましたので、次に動画に含まれるストリームを解析します。 avformat_find_stream_info関数を呼びます。第一引数にAVFormatContextのポインタを渡すと、渡したAVFormatContextに情報が書き込まれます。 ストリームを探す private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } 今後->演算子が頻出します。普段のC#では見ないし使わないと思うので今のうちに復習しておいてください。 AVFormatContext のstreamフィールド(配列様)を漁ってストリームを探します。 ストリームには、動画ストリーム、音声ストリーム、字幕ストリームなどなど色々ありますので、目的のものが見つかるまでループを回します。 ストリームは複数ある可能性(例:日本語音声と英語音声)が考えられますが、今回は便宜上最初に見つかったストリームのみを有効とします。 上記のは動画ストリームを探すメソッドでしたが、音声ストリームを探すものも用意します。 private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } コーデックを開く ストリームを見つけたので、今度は各ストリームのコーデックに合わせたデコーダを初期化しましょう。 private AVStream* videoStream; private AVCodec* videoCodec; private AVCodecContext* videoCodecContext; // OpenFile関数の続き videoStream = GetFirstVideoStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } avcodec_find_decoder 関数でデコーダを取得します。今回はストリームのコーデック情報から必要なデコーダを自動検出しています。 例えばHWデコーダを使いたいなど、使用するデコーダをカスタマイズする場合はここでデコーダを変えます。 こちらの関数は戻り値としてデコーダが得られます。 この先は定形コードです。説明するよりコードを見てもらうほうが理解が早いと思います。 AVCodecContextを経由してAVCodecを初期化すれば準備完了です。 音声コーデックも開きます。やることは同じです。以下のコード全体像で把握してください。 完成形 ここまでの処理を1つのクラスにまとめたものを示します。 最初はコピペでも構いませんが何となく何をしているかは把握してください。 /// <summary> /// 動画デコーダを表現します。 /// </summary> public unsafe class Decoder : IDisposable { public Decoder() { ffmpeg.RootPath = @"{DLLがあるディレクトリのパス}"; ffmpeg.av_register_all(); } private AVFormatContext* formatContext; /// <summary> /// 現在の <see cref="AVFormatContext"/> を取得します。 /// </summary> public AVFormatContext FormatContext { get => *formatContext; } private AVStream* videoStream; /// <summary> /// 現在の動画ストリームを表す <see cref="AVStream"/> を取得します。 /// </summary> public AVStream VideoStream { get => *videoStream; } private AVStream* audioStream; private AVCodec* videoCodec; /// <summary> /// 現在の動画コーデックを表す <see cref="AVCodec"/> を取得します。 /// </summary> public AVCodec VideoCodec { get => *videoCodec; } private AVCodec* audioCodec; private AVCodecContext* videoCodecContext; /// <summary> /// 現在の動画コーデックの <see cref="AVCodecContext"/> を取得します。 /// </summary> public AVCodecContext VideoCodecContext { get => *videoCodecContext; } private AVCodecContext* audioCodecContext; /// <summary> /// ファイルを開き、デコーダを初期化します。 /// </summary> /// <param name="path">開くファイルのパス。</param> /// <exception cref="InvalidOperationException" /> public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); videoStream = GetFirstVideoStream(); audioStream = GetFirstAudioStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } if (audioStream is not null) { audioCodec = ffmpeg.avcodec_find_decoder(audioStream->codecpar->codec_id); if (audioCodec is null) { throw new InvalidOperationException("必要な音声デコーダを検出できませんでした。"); } audioCodecContext = ffmpeg.avcodec_alloc_context3(audioCodec); if (audioCodecContext is null) { throw new InvalidOperationException("音声コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(audioCodecContext, audioStream->codecpar) .OnError(() => throw new InvalidOperationException("音声コーデックのパラメータ設定に失敗しました。")); ffmpeg.avcodec_open2(audioCodecContext, audioCodec, null) .OnError(() => throw new InvalidOperationException("音声コーデックの初期化に失敗しました。")); } } private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } } お疲れさまでした。これでファイルを開く処理は完了です。一回休憩しましょう。 ファイルを開くだけでも結構たいへんです。「はじめに」のセッションでくどいことを書いた理由がおわかりいただけたと思います... FFmpeg APIを使ってみたくてこの記事にたどりついた方はこのあたりでスっとタブを閉じていることでしょう デコードする パケットの読み出しと配送 まずは、動画ファイルから「パケット」を取り出します。 パケットは、どれかのストリームに関連するデータです。目的のストリームではないかもしれません。 av_read_frame関数が0を返した場合、パケットが取得できています。0でない場合は、動画の末端に達しています。 AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); パケットのstream_indexをみて、正しいデコーダにパケットを配送します。 パケットは、参照カウンタ方式で管理されているので使い終わったら参照を外してください。 if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, &packet) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); } } } フレームの取得 パケットを送信したら、avcodec_receive_frame 関数を呼びフレームの読み取りを試みます。 パケットとフレームは対応していないので、一回のパケット送信操作で複数のフレームが得られたり、あるいは何も得られなかったりしますので、そのことを念頭に置いて設計してください(後ほど例を示します)。また、av_frame_allocしたら必ず責任を持ってav_frame_freeする必要があります。 このフレームがデコードの結果得られる(ほしい)ものです。サイズが大きいので配列などに突っ込むのはおすすめしません。 必要に応じて(メソッドを呼び出したら)返すように設計しましょう。 AVFrame* frame = ffmpeg.av_frame_alloc(); ffmpeg.avcodec_receive_frame(videoCodecContext, frame) 動画のフレームは非常にサイズが大きく、解放が漏れた場合大変なことになります(あっという間にメモリを使い果たす)。 解放漏れを起こさないよう注意するとともに、適宜マネージドラッパーを作成するなど保険をかけましょう。このあたりの実装例は後ほど示します。 実際解放漏れがあったときは、数十秒以内に私のPCの16GBのメモリを使い切り落ちました。本当に注意してください。 終端に達したとき 最後に、パケットが読み取れなくなり、動画の終端に達した場合は各デコーダにnull を送信して終端に達したことを通達します。 逆にプログラムのバグなどで誤ってnullを途中送信してしまうと、その次のパケット送信操作で「すでに動画の終端に達しています(筆者訳)」というエラーが起きます。 一見何が間違っているのかわかりにくいので注意しましょう(一敗)。 ffmpeg.avcodec_send_packet(videoCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); null を送信したあと、デコーダに残っているフレームを最後まで読み取りきるのを忘れないようにしてください。 解放 AVCodecContext と AVFormatContext は解放が必要です。avcodec_free_context あるいは avformat_close_input 関数で最後に解放してください。 デコードに必要な実装 パケットのキュー 動画プレーヤーのように動画と音声を同時に扱う場合、取得したパケットをキューする機構が必要です。 デコーダからフレームを読み出すことを試みる前に次のパケットを送信することは許されていないため、動画パケットを探している途中で音声パケットが出てくるなどした場合キューに入れます。 実は、パケットはユーザーコード側に所有権がありません。C#コード側で保持しておいても普通に解放されます。 したがって、パケットを自分で複製した上でそれを保持しなければなりません。(これに辿り着くのに数日かかりました。本当に情報が少ない。) C#ではポインタをジェネリック型引数にできないのでAVPacketPtr構造体でラップしています。 private struct AVPacketPtr { public AVPacket* Ptr; public AVPacketPtr(AVPacket* ptr) { Ptr = ptr; } } private Queue<AVPacketPtr> videoPackets = new(); private Queue<AVPacketPtr> audioPackets = new(); AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, & .OnError(() => throw new InvalidOperationExcept ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); videoPackets.Enqueue(new AVPacketPtr(_packet)); } } } フレームの管理 デコード処理の成果物はフレームで、今後はこれを扱いますが、前述の通り解放漏れを起こすと悲惨です。 よってこのフレームのラッパーを作り、上位のコードにはラップされたフレームを渡すことにします。 IDisposableインターフェイスを実装し、さらにファイナライザ(デストラクタ)を定義しそこから解放ロジックを呼びます。 これでusingなどの確実な解放を保証する言語機能が使えますし、最悪GCが解放してくれます。 /// <summary> /// ラップされた <see cref="AVFrame"/> を表現します。解放漏れを防止します。 /// </summary> public unsafe class ManagedFrame : IDisposable { public ManagedFrame(AVFrame* frame) { this.frame = frame; } private readonly AVFrame* frame; /// <summary> /// ラップされたフレームを取得します。このフレームを独自に解放しないでください。 /// </summary> public AVFrame* Frame { get => frame; } ~ManagedFrame() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVFrame* aVFrame = frame; ffmpeg.av_frame_free(&aVFrame); isDisposed = true; } } 完成したデコーダーのコード decoder.cs using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using FFmpeg.AutoGen; namespace FFmpegWraper { /// <summary> /// 動画デコーダを表現します。 /// </summary> public unsafe class Decoder : IDisposable { public Decoder() { ffmpeg.RootPath = @"{DLLのあるディレクトリのパス}"; ffmpeg.av_register_all(); } private AVFormatContext* formatContext; /// <summary> /// 現在の <see cref="AVFormatContext"/> を取得します。 /// </summary> public AVFormatContext FormatContext { get => *formatContext; } private AVStream* videoStream; /// <summary> /// 現在の動画ストリームを表す <see cref="AVStream"/> を取得します。 /// </summary> public AVStream VideoStream { get => *videoStream; } private AVStream* audioStream; private AVCodec* videoCodec; /// <summary> /// 現在の動画コーデックを表す <see cref="AVCodec"/> を取得します。 /// </summary> public AVCodec VideoCodec { get => *videoCodec; } private AVCodec* audioCodec; private AVCodecContext* videoCodecContext; /// <summary> /// 現在の動画コーデックの <see cref="AVCodecContext"/> を取得します。 /// </summary> public AVCodecContext VideoCodecContext { get => *videoCodecContext; } private AVCodecContext* audioCodecContext; /// <summary> /// ファイルを開き、デコーダを初期化します。 /// </summary> /// <param name="path">開くファイルのパス。</param> /// <exception cref="InvalidOperationException" /> public void OpenFile(string path) { AVFormatContext* _formatContext = null; ffmpeg.avformat_open_input(&_formatContext, path, null, null) .OnError(() => throw new InvalidOperationException("指定のファイルは開けませんでした。")); formatContext = _formatContext; ffmpeg.avformat_find_stream_info(formatContext, null) .OnError(() => throw new InvalidOperationException("ストリームを検出できませんでした。")); videoStream = GetFirstVideoStream(); audioStream = GetFirstAudioStream(); if (videoStream is not null) { videoCodec = ffmpeg.avcodec_find_decoder(videoStream->codecpar->codec_id); if (videoCodec is null) { throw new InvalidOperationException("必要な動画デコーダを検出できませんでした。"); } videoCodecContext = ffmpeg.avcodec_alloc_context3(videoCodec); if (videoCodecContext is null) { throw new InvalidOperationException("動画コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(videoCodecContext, videoStream->codecpar) .OnError(() => throw new InvalidOperationException("動画コーデックパラメータの設定に失敗しました。")); ffmpeg.avcodec_open2(videoCodecContext, videoCodec, null) .OnError(() => throw new InvalidOperationException("動画コーデックの初期化に失敗しました。")); } if (audioStream is not null) { audioCodec = ffmpeg.avcodec_find_decoder(audioStream->codecpar->codec_id); if (audioCodec is null) { throw new InvalidOperationException("必要な音声デコーダを検出できませんでした。"); } audioCodecContext = ffmpeg.avcodec_alloc_context3(audioCodec); if (audioCodecContext is null) { throw new InvalidOperationException("音声コーデックのCodecContextの確保に失敗しました。"); } ffmpeg.avcodec_parameters_to_context(audioCodecContext, audioStream->codecpar) .OnError(() => throw new InvalidOperationException("音声コーデックのパラメータ設定に失敗しました。")); ffmpeg.avcodec_open2(audioCodecContext, audioCodec, null) .OnError(() => throw new InvalidOperationException("音声コーデックの初期化に失敗しました。")); } } private AVStream* GetFirstVideoStream() { for (int i = 0; i < (int)formatContext->nb_streams; ++i) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO) { return stream; } } return null; } private AVStream* GetFirstAudioStream() { for (int i = 0; i < (int)formatContext->nb_streams; i++) { var stream = formatContext->streams[i]; if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_AUDIO) { return stream; } } return null; } private struct AVPacketPtr { public AVPacket* Ptr; public AVPacketPtr(AVPacket* ptr) { Ptr = ptr; } } private object sendPackedSyncObject = new(); private Queue<AVPacketPtr> videoPackets = new(); private Queue<AVPacketPtr> audioPackets = new(); public int SendPacket(int index) { lock (sendPackedSyncObject) { if (index == videoStream->index) { if (videoPackets.TryDequeue(out var ptr)) { ffmpeg.avcodec_send_packet(videoCodecContext, ptr.Ptr) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(ptr.Ptr); return 0; } } if (index == audioStream->index) { if (audioPackets.TryDequeue(out var ptr)) { ffmpeg.avcodec_send_packet(audioCodecContext, ptr.Ptr) .OnError(() => throw new InvalidOperationException("音声デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(ptr.Ptr); return 0; } } while (true) { AVPacket packet = new AVPacket(); var result = ffmpeg.av_read_frame(formatContext, &packet); if (result == 0) { if (packet.stream_index == videoStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(videoCodecContext, &packet) .OnError(() => throw new InvalidOperationException("動画デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); videoPackets.Enqueue(new AVPacketPtr(_packet)); continue; } } if (packet.stream_index == audioStream->index) { if (packet.stream_index == index) { ffmpeg.avcodec_send_packet(audioCodecContext, &packet) .OnError(() => throw new InvalidOperationException("音声デコーダへのパケットの送信に失敗しました。")); ffmpeg.av_packet_unref(&packet); return 0; } else { var _packet = ffmpeg.av_packet_clone(&packet); audioPackets.Enqueue(new AVPacketPtr(_packet)); continue; } } } else { return -1; } } } } /// <summary> /// 次のフレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> public unsafe ManagedFrame ReadFrame() { var frame = ReadUnsafeFrame(); if (frame is null) { return null; } return new ManagedFrame(frame); } private bool isVideoFrameEnded; /// <summary> /// 次のフレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> /// <remarks> /// 取得したフレームは <see cref="ffmpeg.av_frame_free(AVFrame**)"/> を呼び出して手動で解放する必要があることに注意してください。 /// </remarks> /// <returns></returns> public unsafe AVFrame* ReadUnsafeFrame() { AVFrame* frame = ffmpeg.av_frame_alloc(); if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } if (isVideoFrameEnded) { return null; } int n; while ((n = SendPacket(videoStream->index)) == 0) { if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } else { } } isVideoFrameEnded = true; ffmpeg.avcodec_send_packet(videoCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); if (ffmpeg.avcodec_receive_frame(videoCodecContext, frame) == 0) { return frame; } return null; } /// <summary> /// 次の音声フレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> public unsafe ManagedFrame ReadAudioFrame() { var frame = ReadUnsafeAudioFrame(); if (frame is null) { return null; } return new ManagedFrame(frame); } private bool isAudioFrameEnded; /// <summary> /// 次の音声フレームを読み取ります。動画の終端に達している場合は <c>null</c> が返されます。 /// </summary> /// <remarks> /// 取得したフレームは <see cref="ffmpeg.av_frame_free(AVFrame**)"/> を呼び出して手動で解放する必要があることに注意してください。 /// </remarks> /// <returns></returns> public unsafe AVFrame* ReadUnsafeAudioFrame() { AVFrame* frame = ffmpeg.av_frame_alloc(); if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } if (isAudioFrameEnded) { return null; } while (SendPacket(audioStream->index) == 0) { if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } } isAudioFrameEnded = true; ffmpeg.avcodec_send_packet(audioCodecContext, null) .OnError(() => throw new InvalidOperationException("デコーダへのnullパケットの送信に失敗しました。")); if (ffmpeg.avcodec_receive_frame(audioCodecContext, frame) == 0) { return frame; } return null; } ~Decoder() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVCodecContext* codecContext = videoCodecContext; AVFormatContext* formatContext = this.formatContext; ffmpeg.avcodec_free_context(&codecContext); ffmpeg.avformat_close_input(&formatContext); isDisposed = true; } } internal static class WrapperHelper { public static int OnError(this int n, Action act) { if (n < 0) { var buffer = Marshal.AllocHGlobal(1000); string str; unsafe { ffmpeg.av_make_error_string((byte*)buffer.ToPointer(), 1000, n); str = new string((sbyte*)buffer.ToPointer()); } Marshal.FreeHGlobal(buffer); Debug.WriteLine(str); act.Invoke(); } return n; } } } ManagedFrame.cs using FFmpeg.AutoGen; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace FFmpegWraper { /// <summary> /// ラップされた <see cref="AVFrame"/> を表現します。解放漏れを防止します。 /// </summary> public unsafe class ManagedFrame : IDisposable { public ManagedFrame(AVFrame* frame) { this.frame = frame; } private readonly AVFrame* frame; /// <summary> /// ラップされたフレームを取得します。このフレームを独自に解放しないでください。 /// </summary> public AVFrame* Frame { get => frame; } ~ManagedFrame() { DisposeUnManaged(); } /// <inheritdoc /> public void Dispose() { DisposeUnManaged(); GC.SuppressFinalize(this); } private bool isDisposed = false; private void DisposeUnManaged() { if (isDisposed) { return; } AVFrame* aVFrame = frame; ffmpeg.av_frame_free(&aVFrame); isDisposed = true; } } } まだデコードするだけなのに合わせて実に430行です。大変。 長くなりすぎてしまい、またエディタが重いので前編・後編に分けることにします。 後編へ続く。
- 投稿日:2021-12-05T02:57:32+09:00
似た顔のFANZA女優を顔写真から検索出来るサンプルを用意するのに苦労した
この記事は 驚異のFANZA女優検索 Advent Calendar 2021 の 5 日目の記事です。 URLを指定して顔検索出来るようにしたが、顔写真のURLを入力するのは手間がある 似た顔のFANZA女優をURLを指定して検索出来るようにした。ただ、この機能を使いたいというモチベーションがある人ならURLを入力して使ってくれると思うが、物見遊山で来てみた人はパッを顔検索したいが顔写真が思いつかない場合、わざわざタブをもう1個立ち上げて、誰か顔検索したい人の名前で顔写真を探して、そのURLをコピーして、戻ってそのURLを貼るというのはやらないかもしれない。ましてやそこまでやって元画像が何らかの原因で顔検索の対象とならなかった場合などは、もうその時点でやる気がなくなるのが大多数だと思う。 サンプルを用意すれば、とりあえず顔検索を1回は使ってもらえる可能性は上がる 自分はそれなりに面白いと思った機能で、それなりにはこだわって、それなりにコストをかけて作った。で、あるからには当然多くの人に使ってほしい。1度使ってもらって気に入らないのはしょうがないとしても、1度も使わずにトラブルで使ってもらえないのは避けたい。トラブルを避けるためにも先ずサンプルがあればとりあえず使ってもらうことは出来る。だからいくつかのサンプル画像を用意しておくのは必須だと思った。サンプルを使って顔検索すればそれ自体がチュートリアルになる。これでやり方が分かって使ってくれる可能性は上がると思う。 有名人をサンプルに使う さてサンプルとして顔写真を用意するのであれば、最初に考えるのは有名人を並べておくのだと思う。特に人気女優や自分の好きなアイドルと似た顔のFANZA女優を検索できるのは面白そうだと思ってもらえると思う。ただここで大きな問題がある。女優の顔写真を置くのは法律的にどうなんだろうということだ。正直そんなサイトは一杯あると思う。例えばTwitterのアイコンに本人でもないので顔写真を使っている例なんてそれこそめちゃありそうだし。そういう人らをわざわざ糾弾したいとは思わないし、法律を守っていることが非常に大事だとも思ってない。が、自分的にNGとなっていることに関して避けられるなら避けたいという気持ちはある。ということで有名人をサンプルに使うのは止めた。 フリー素材をサンプルに使う 次に考えたのは、フリー素材をサンプルに使うことだ。フリー素材のサイトは世の中に一杯あるが、顔写真が大きく映っている画像はそんなに多くない。その中でも人物の無料利用に関して、かなり良心的に公開してくれているサイトがある。それがぱくたそだ。自分は知らなかったが、モデルリリースを読むとフリー素材であってすら人物は気軽に使うと問題になるケースもあるらしい。そういう意味でもこのサイトの素材を使うのは色々安心なので、モデルのページから何人ものモデル画像の写真をサンプルとして作ってみた。で、念のため利用規約を読み直してみた。すると禁止事項に気になる一文があった。これ自分のサイトに使うなら完全にアウトのやつだった。 アダルト(R18)/風俗/性的描写/宗教/麻薬/ストライキ/デモ/反発団体/ドラッグ/ナイトサービス/出会い系サービス(婚活/マッチング/出合い系情報/インターネット異性紹介事業届出業者/その他、出会い系サービスと運営者が判断する場合)などの利用 今まで気づいていなかった、画像の利用に関して殆どのサイトでアダルトはNGだった。考えてみれば当然で利用規約の雛形を見てみても禁止事項にアダルトの表記は書いてある方が普通だ。そして多くの場合外すことにはデメリットしかない。わざわざそれを外すのは強い理由がいる。そうなると殆どのサイトの規約にアダルトNGて書いてあるのは当然のこととなる。つまり自分のサイトでは、ほどんどの無料画像は使えない。こうなってくると選択肢は激減する。 ディープラーニングでサンプルを作る 色々調べているとディープラーニングを応用したGAN(敵対的生成ネットワーク)という技術により、存在しない人物の顔写真を作成できるサイトGenerated Photosというのがあった。アイドル自動生成AIを開発とか、ThisPersonDoesNotExist.comというAIが架空の人物の写真を生成するサイトもある。こういう技術を使えばモデルリリースなんて考えなくて良いのでは?と思った。ただ、Generated Photosは無料で使えない。そして英語サイトだし、有料で購入した場合でもアダルトサイトで使えるはよく分かっていない。というかトラブった時とか日本のサイト以上に話がややこしくなるかなと思った。自分でゼロから作れないかなと思ったけれど結構ハードルは高めだ(少しずつ勉強はしているが)。仮に覚えたとしても時間はかかりそうだなと思った。 作品をサンプルに使う 他のサイトの画像をサンプルに使うのは現実的に厳しい。そうなると自分のサイトの画像をサンプルに使うしかない。DMM Webサービスで取ってきた作品の画像であれば問題はない。そこで作品のパッケージをサンプルとして置こうと思って、どの作品を選ぶか精査していた。ここでポイントとして驚異のFANZA女優検索はアダルトサイトではあるが、TOPページは年齢認証の対象外にしている。そして今回の顔検索も対象外にしている。つまり、このページはアダルト表現のある画像は基本置けない。そうなるサンプル画像として使える作品が極端に少なくなる。そもそも9割方の作品の画像は服を着ていない。たまに着ていてもタイトルがNGだったりする例も多い。そういう意味で適切なサンプル画像はかなり少なかった。 FANZA作品以外の動画のパッケージをサンプルに使う 少ないなりになんとか10作品を選んで、サンプルとして置いてみた時に突然思いついた。FANZAの作品はアダルト以外にもある。DMMにあるアイドルの作品であればDMM Webサービスで取得でき、それを自分のサイトに置くことは既にDMMに認められている。これなら何にも接触していないと思う。というわけで紆余曲折したが、最終的におすすめアイドル一覧からピックアップしたアイドルの作品をサンプルとして並べた。これは作っている自分の年齢もあると思うが、 話題の注目アイドルは知っている人が殆どいなかったが、おすすめアイドルは知っている人も結構居たので、おすすめアイドルから選んでいる。使うユーザーによっては殆ど知らないということもあるとは思うけれどまあここは自分の好みということで。
- 投稿日:2021-12-05T00:11:08+09:00
オープンワールド系のゲーム開発を経験したので色々話す#0
まずは挨拶 お久しぶりです。てんぺんです。 いつの間にか就活も終わり気づいたら12月になろうとしています。 この期間、何もしていなかったかというと、そういうわけでもなく... タイトルの通りオープンワールドのゲームの開発をし始めたので、そのお話を何回かに分けて話そうと思います。(毎週投稿予定) 下記リンクからそれぞれの記事に飛べるので、是非読んで行ってください。 #1 TerrainとDitail編 https://qiita.com/hemmiyuya/items/b9bc17d65cc629f07500 #2 街づくり編 12/12日投稿予定 #3 NPC編 12/19投稿予定 #4 クエスト編 12/26投稿予定 まとめ 読んでもらったらわかると思うのですが、バチバチにコードを書いていく時間よりも、Unityの機能を使って何かする。という時間が結構長いので、プログラムを書くのが好きな人だと結構やっていて苦痛な時間が多いのかなと思います。 それでも、自分が作りたいものがどんどんできていくというのはとても嬉しいしワクワクするものなのでこの記事を読んだ方も是非挑戦してみてください。(オープンワールドゲームに限った話じゃないけどね!) あと、自分も初心者だったりするので、もっとこーゆーことしたら良くなるよとか、他にはどんなことしたんですか?とか、アドバイス質問等あったらどんどんください! それではノシ
- 投稿日:2021-12-05T00:06:47+09:00
オープンワールド系のゲーム開発を経験したので色々話す#1 TerrainとDetail編
UnityのTerrainを使って地形を作る オープンワールドゲームということで、まずは広大な世界を作らなければいけないと思います。(ゼル伝しかり原神しかり Unityには、Terrainという便利で優秀()な機能があるので全力で使っていきます。 Terrainの基本的な使い方は他で調べて頂いて、今回はオープンワールドの地形を作っていく上で大切なことを話して行こうと思います。 ↑ 一応Terrainの学習に用いたサイトを貼っておきます。 まず最初にやること UnityでTerrainを使っていると、そのうち「処理が重すぎる」という問題に直面すると思います。 実はデフォルトの設定だとかなり高品質なTerrainを作るようになっていて、ステージ制の1場面の規模があまり大きくないゲームだと問題はないと思いますが、オープンワールドとなるとかなり広大な地形をたくさん作ることになるので、重くてゲームどころじゃないといったことになってきます。 というわけで設定を見直していきましょう。 ヒエラルキーのCreate/3D Object/TerrainからTerrainを作ります。 インスペクターのTerrainコンポーネントの歯車アイコンをクリックして設定を開きます。 すると、色々と項目が出てきて大変だとは思いますが、今回見ていくのは Pixel Error Base Map Dist Ditail Distance Ditail Density Tree Distance BillBoard Start Ditail Resolution Per Patch ◯◯ Resolution 系統 になります。そこそこ多いですが、かなり軽量化に繋がるので少しずつ説明していきます。 Pixel Error 簡単に言うと、「遠くの地形を正確に描画するかどうか」の設定になります。 数値を大きくすると、遠くの描画が雑になっていきますが、処理は早くなっていきます。 僕はこのプロジェクトでは30に設定しています。 Base Map Dist Pixel Errorのテクスチャバージョンになります。Terrainに設定しているテクスチャが指定した数値より遠くになると雑に描画されるようになります。 ゲームの規模に合わせて、数値を大きくしていくといいかなと思います。遠くを狙撃するようなゲームの場合はひと工夫必要かも? Ditail Distance 後述するDitail(草とかの小物)の描画範囲です。指定した数値までの範囲を描画します。 Ditail Density Ditailの密度設定です。ここをいじりながら設定したことはないですが、Ditailの設定からも配置する密度は変えられるので、ここの値はあまりいじらなくてもいいかも?一応小さくなればなるほど処理は軽くなります。 Tree Distance Ditail Distanceの木バージョンです。Ditailよりはおおきな値を設定してあげるといいと思います。 BillBoard Start 木をメッシュではなく、ハリボテ(2D)で表示するようになる距離です。木の量によっては、一気にメッシュ数が減るので軽量化に繋がると思います。Tree Distanceと合わせていい感じに設定していきましょう。 Ditail Resolution Per Patch 描画処理の勉強が足りなくて、よくわからなかったので引用させてもらうと 1回のドローコールで描画される範囲のサイズです。値は8~128の間で設定可能で、大きいほど描画コストが下がります。 ・・・らしいです。とりあえず見た目に影響が出ない程度におおきな値を設定してあげるといいと思います。 ◯◯ Resolution 系統 いわゆる解像度設定です。見た目が大きく変わるのでクオリティも大きく変動しますが、その分描画処理も大きく変わります。 また、途中で変えると見た目がおかしくなったりすることもあるので、この項目は一番最初に設定しておきましょう。 これらの項目を見直すことで、劇的にTerrainを軽量化することができると思います。 中には、設定を変えると見た目がおかしくなったり、地形データが吹き飛ぶものもあるので、できるだけ一番最初に設定して途中で変えるようなことがないようにしましょう。 まっ平らな地形を作らない 地形を作っていく上で、結構見落としがちですが簡単に工夫できるのがこれだと思います。 ↑まっ平らな地形 ↑軽く起伏をつけた地形 他にも道っぽい砂利があったり風景があったりで単純に比べるのはちょっとずるな感じですが、こんな感じで起伏をつけただけでそれっぽい感じが作れます。 これはクリエイターだからこそ感じてしまうことなのかもしれませんが、Unityでゲーム作ってみた的な初心者向けのサイトを漁っているとまっ平らな地形で作っているものが結構あるので、すごい「Unityじゃん」感が出てしまうんですよね。 それを解消するために、まずは起伏をつけて地形を作ってみるといいと思います。 ただし!! プレイヤーがいける場所で大きな起伏をつけるのはやめましょう!! せっかくの広いマップが見えにくくなる他、急な斜面はUnityの物理演算を使っていると壁のような判定になって登るのが困難になります。 独自で物理を実装していて、周りが見えにくくても良いという状況であれば問題無いとは思いますが...色々と手間になるので、地形を作るのに慣れるまでは避けたほうがいいと思います。 Ditailを設定する 風景を作っていく上で欠かせないのがDitailの配置です。 先程の比較画像の通り、草花を生やしたり、小石を並べていったりするだけでそれっぽい雰囲気を作り出すことができます。 感覚的なお話になってしまうのですが、このような感じで一種類の草だけではなく、黄色い花、赤い花、緑の草、木の枝、などなど、多くの種類のDitailを置いていくといいと思います。 ↑荒れ地のDitail例。荒れ地の瓦礫はテクスチャだけでも表現できますが、Ditailとしても配置すると立体感がでてより良く見えます。他にも木の枝や小石、枯れ草なども配置してもともとは自然があった感を出して行くといいのかなと思います。 こんな感じでたくさんのDitailを登録しておくと、簡単に置けるので楽になります。(背景がないやつは2Dテクスチャ、背景が黒いやつはメッシュ) 今回のまとめ というわけで、第一回TerrainとDetail編でした。 時間がかかる世界観構築部分ですが、やればやるほど見た目が良くなって遊ぶ側も探索したい!という気持ちが出てくるので、時間の許す限りこだわって作っていきましょう。 特にTerrainは本当にできることの多い機能です。アセットなんかを漁ってみると軽量化だったり、より複雑な地形を作れるようになったり、なんなら自動生成してくれるものまであります。 もしお金に余裕があるのであればそれらを使ってみるとまた違う世界が見えてくるのかなと思います。 おまけ 実際に使ってみたアセット ほんとに最後のおまけとして、こちらのNature Rendererアセットを紹介しておきます。 名前の通り、自然関係のレンダリングを見直してくれる(Terrainにスクリプトをつけると勝手にやってくれる)のですが.... まじでさいきょーwwwってなるぐらい優秀なアセットです。 ↑アセットなし ↓アセットあり 影の描画や、Ditailの描画処理を大きく変更するので、この同じ場面でも大きく雰囲気が変わってきます。 特に、メッシュを使ったDitailの描画はありえないぐらい自然になるので、是非とも使ってほしいアセットとなっています。 あと、これを入れたらびっくりするぐらいTerrainが軽くなります。上でやった軽量化は何だったんだって思うぐらい軽くなるので初めて入れたときはびっくりしました。 というわけで今度こそ本当に終わろうと思います。 ではノシ