Closed21

.NETで動画をGIFファイルに変換するアプリの開発

gotoooogotoooo

今回採用した開発環境、フレームワーク

  • VisualStudio 2022
  • WPF
  • .NET8
  • Community Toolkit
  • xUnit
gotoooogotoooo

当初は下記構成でクロスプラットフォームなアプリ開発を試みたがあまりにハードルが高く見送った

  • VisualStudio 2022
  • MAUI
  • .NET8
  • Community Toolkit
  • xUnit

MAUIを見送った理由

  • 筆者が長らくWPF+Prism7環境に慣れていた。WPFでできることをMAUIで実現するための方法をいちいち調べたり、場合によっては実現できないものがあることが判明した
  • 動画ファイルの加工部はFFMpegに頼ることにした。プラットフォームごとにFFMpegの実行ファイルを用意する必要があるが、自前でのビルドに非常に苦労しそうと感じた。
gotoooogotoooo

筆者が長らく慣れ親しんでいた構成

  • VisualStudio 2019
  • WPF
  • .NET Framework4.8
  • Prism7
  • MSTest
gotoooogotoooo

VisualStudio2019 -> 2022で感じた変化

  • コード補完が賢い  
    括弧閉じるを補完してくれるのが便利。Tabキー連打でコーディングが進む。
  • Xamlデザイナ周りのビルドには影響しない不可解なエラーが出る。
  • ビルド結果に不要な言語フォルダが生成される
    VisualStudio2022起因なのか.NET起因か切り分けできていないが大量に不要なフォルダが出力されてしまう。下記を参考に対処した。
    https://note.dokeep.jp/post/csharp-satellite-resource-languages/
gotoooogotoooo

.NET Framework4.8 -> .NET8で感じた変化

  • 対応していないOSSによるコンパイラ警告が鬱陶しい。
  • null参照の警告が厳しい。
gotoooogotoooo

Prism7 -> CommunityToolkitで感じた変化

  • ObservableProperty, RelayCommandでViewModelのコード記述量が減ってスッキリした

  • DIコンテナへのI/F, クラスの登録の作法が変わった。利用側での取り出し方も変わった。

  • CommunityToolkit側でコード生成されるのでViewModelをpartialなクラスとして定義しなければならない

  • EventToCommandBehaviorがない
    対応案1:View側のコードビハインドでイベントハンドラを記述し、VMのコマンドを呼ぶ。
    CommunityToolkitを使う場合、コードビハインドにロジックを一切実装しないというのは厳しい。
    対応案2:Xaml.Behavior.Wpfを使う。
    そういえばPrismを使っていたころも終盤はXaml.Behaviorでイベント->コマンド変換を実装していた。

  • PrismのIDialogServiceはどう実現する??
    ダイアログとしてWindowクラスを定義し、Showで表示する。
    より丁寧に実現するならばダイアログ表示部をサービスクラス(まさしくIDialogService)で担い、ダイアログを呼び出したいViewModelなりでサービスクラス経由で表示する。

  • PrismのIRegionManagerはどう実現する??

gotoooogotoooo

MSTest -> xUnitで感じた変化

  • xUnitでWPFアプリのプロジェクトを参照するとコンパイルエラーになる。テストプロジェクトの設定を変える必要あり。
gotoooogotoooo

開発作業の流れ

  1. 画面素案作成
  2. ドメイン層実装+単体テスト作成
  3. ダミーのリポジトリ層実装
  4. Viewのスケルトン実装
  5. ViewModelのスケルトン実装
  6. UseCaseごとにViewModelのテストコード実装+ViewModel実装
  7. View実装
  8. 本番環境用リポジトリ層実装
gotoooogotoooo

1.画面素案作成

  • ファイルさえ読み込んだら1クリックでGIF変換できる操作手番の少なさを優先
gotoooogotoooo

3.ダミーのリポジトリ層実装

外界との出し入れが発生するのは下記3つ

  • 加工元となる動画
  • 加工条件
  • 加工後のGIFファイル

まずはメモリ内のみで管理するダミーのリポジトリを用意。

ダミーリポジトリの例
public class FakeConvertConditionRepository : IConvertConditionRepository
{
    private readonly List<IConvertCondition> conditions = new List<IConvertCondition>();

    public FakeConvertConditionRepository()
    {
        this.conditions.Add(new ConvertCondition());
    }

    public Task AddConvertConditionAsync(IConvertCondition convertCondition)
    {
        convertCondition.Id = 0;
        this.conditions.Clear();
        this.conditions.Add(convertCondition);
        return Task.CompletedTask;
    }

    public Task DeleteConvertConditionAsync(int id)
    {
        var toDelete = this.conditions.FirstOrDefault(f => f.Id == id);
        if (toDelete is not null)
        {
            this.conditions.Remove(toDelete);
        }
        return Task.CompletedTask;
    }

    public Task<IConvertCondition?> GetConvertConditionAsync(int id)
    {
        return Task.FromResult<IConvertCondition?>(this.conditions.FirstOrDefault(f => f.Id == id));
    }

    public Task UpdateConvertConditionAsync(int id, IConvertCondition convertCondition)
    {
        var toUpdate = this.conditions.FirstOrDefault(f => f.Id == id);
        if (toUpdate is not null)
        {
            toUpdate.UpdateFrom(convertCondition);
        }

        return Task.CompletedTask;
    }
}

gotoooogotoooo

4.Viewのスケルトン実装

View-ViewModelのペアを洗い出すため、ざっくりと画面レイアウトを決める。

gotoooogotoooo

5.ViewModelのスケルトン実装

View-ViewModelのペアとなるよう各ViewModelクラスを用意する。

CommunityToolkitでのコードジェネレーションに備えてpartialなクラスとして定義する

internal partial class ConvertControlViewModel : ObservableObject
gotoooogotoooo

6.UseCaseごとにViewModelのテストコード実装+ViewModel実装

見通しを良くするためユースケースに番号を振ってテストクラスを作成する。
期待結果->そのための処理 の順番で実装を進める。いわゆるTDD開発である。

UseCaseのテストコード例
using NSubstitute;
using SimpleGIFMaker.Domains;
using SimpleGIFMaker.Domains.Repositories;
using SimpleGIFMaker.ViewModels;
using System;
using Xunit;

namespace SimpleGIFMaker.Tests.UseCases
{
    public class uc000_SelectMovieFileTests : IDisposable
    {
        private bool disposedValue;
        private ConvertControlViewModel vm;
        private IMediaPlayer mediaPlayer;
        private IConvertConditionRepository convertConditionRepository;
        private IGifFileRepository gifFileRepository;
        private IMovieRepository movieRepository;

        public uc000_SelectMovieFileTests()
        {
            this.mediaPlayer = Substitute.For<IMediaPlayer>();
            this.convertConditionRepository = Substitute.For<IConvertConditionRepository>();
            this.gifFileRepository = Substitute.For<IGifFileRepository>();
            this.movieRepository = Substitute.For<IMovieRepository>();

            this.vm = new ConvertControlViewModel(this.mediaPlayer, this.movieRepository, this.convertConditionRepository, this.gifFileRepository);
        }

        [Fact]
        public async void FileSelect()
        {
            //
            var movieMock = Substitute.For<IMovie>();
            this.vm.selectMovieFileFunc = () => movieMock;

            //
            await this.vm.SelectFile();

            //
            await this.movieRepository.Received().AddMovieAsync(movieMock);
            await this.convertConditionRepository.Received().AddConvertConditionAsync(Arg.Any<IConvertCondition>());
            this.mediaPlayer.Received().SetMovie(movieMock);
        }

        [Fact]
        public async void FileSelectCancel()
        {
            //
            this.vm.selectMovieFileFunc = () => null;

            //
            await this.vm.SelectFile();

            //
            await this.movieRepository.DidNotReceive().AddMovieAsync(Arg.Any<IMovie>());
            this.mediaPlayer.DidNotReceive().SetMovie(Arg.Any<IMovie>());
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            // このコードを変更しないでください。クリーンアップ コードを 'Dispose(bool disposing)' メソッドに記述します
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}
gotoooogotoooo

クロップ範囲をドラッグ操作で決める機能の実装でハマる

  • 表示対象の動画の解像度の違いを吸収するためViewBoxの子要素としてMediaElementを配置する
    撮影時のカメラの回転角度に基づきViewBoxごと回転させる。
  • MediaElementに重なるようにBorderを配置する
  • Borderの四隅にクロップ範囲ドラッグ用のシンボルを配置する <= これがViewBoxで縮小され、見づらい
    ViewBoxによって縮小されてしまったドラッグ用のシンボルを再度拡大する。そのためにViewBoxのサイズ、動画の解像度からそれぞれのアスペクト比の大小に応じて縮小率を求める。そしてScaleTransformでドラッグ用シンボルだけ拡大する。
gotoooogotoooo

7.View実装

クロップ範囲指定機能

シンプルな機能だが想像以上に手こずった。
マウス操作などインタラクティブな操作はどうしてもXaml, コードビハインド側の記述量が多くなり、苦手である。

時間範囲指定機能

こちらもマウスドラッグで範囲を指定できるようにした。

gotoooogotoooo

8.本番環境用リポジトリ層実装

ローカルのファイルシステムに出し入れする機能を実装した。

ハマりポイント

  • 当初非同期メソッドでファイルのWriteAsync, ReadAsyncしていたが、UIのイベント起点でファイルの更新が頻発するとファイル更新の順序の整合性が崩れることがあった。しかたなく同期メソッドに置き換えることで対応した。
例:FsConvertConditionRepository.cs

namespace SimpleGIFMaker.DataSource.FileSystem
{
    public class FsConvertConditionRepository : IConvertConditionRepository
    {
        private string jsonDirPath = string.Empty;

        public FsConvertConditionRepository()
        {
            this.jsonDirPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "output");
            if (Directory.Exists(jsonDirPath) == false)
            {
                Directory.CreateDirectory(jsonDirPath);
            }
        }

        public Task AddConvertConditionAsync(IConvertCondition convertCondition)
        {
            convertCondition.Id = 0;
            var dto = ConvertConditionDto.CreateFrom(convertCondition);

            var jsonFileName = $"condition_{convertCondition.Id}.json";
            var jsonPath = Path.Combine(this.jsonDirPath, jsonFileName);
            JsonFile.Save(dto, jsonPath);
            return Task.CompletedTask;
        }

        public Task DeleteConvertConditionAsync(int id)
        {
            var jsonFileName = $"condition_{id}.json";
            var jsonPath = Path.Combine(this.jsonDirPath, jsonFileName);
            if (File.Exists(jsonPath) == false)
            {
                return Task.CompletedTask;
            }

            File.Delete(jsonPath);
            return Task.CompletedTask;
        }

        public Task<IConvertCondition?> GetConvertConditionAsync(int id)
        {
            var jsonFileName = $"condition_{id}.json";
            var jsonPath = Path.Combine(this.jsonDirPath, jsonFileName);
            var dto = JsonFile.Load<ConvertConditionDto>(jsonPath);
            if (dto == null)
            {
                return Task.FromResult<IConvertCondition?>(null);
            }

            var condition = dto.Create();
            return Task.FromResult<IConvertCondition?>(condition);
        }

        public Task UpdateConvertConditionAsync(int id, IConvertCondition convertCondition)
        {
            var toUpdate = GetConvertConditionAsync(id).GetAwaiter().GetResult();
            if (toUpdate is not null)
            {
                toUpdate.UpdateFrom(convertCondition);

                var dto = ConvertConditionDto.CreateFrom(convertCondition);

                var jsonFileName = $"condition_{convertCondition.Id}.json";
                var jsonPath = Path.Combine(this.jsonDirPath, jsonFileName);
                JsonFile.Save(dto, jsonPath);
            }
            return Task.CompletedTask;
        }
    }
}

gotoooogotoooo

雑感

開発環境

  • VisualStudio2022のコード補完が優秀なので継続的に使っていきたい。
  • .NET Framework4.8->.NET8の悪い意味での変化はあまり感じることはなく、自然に実装を進められた。
  • 今回は出番がなかったがPrismで重宝していたIDialogService、IRegionManagerをCommunityToolkitで代替する方法は調べておきたい。

制作物

  • 動画→GIF変換ツールは巷にたくさんあるが、Webへの動画アップロードは状況が限られる上にスタンドアロンのソフトは無償版だと機能制限が伴ったりする。さくっとGIFを作れるようになって快適である。
  • FFMpegの使い方をマスターできておらず、GIFの画質のチューニングは改善の余地を残している。今後深掘りしていきたい。

開発の流れ

  • View/ViewModelの責務配分が実装最中にコロコロ変わるのでTDDのつもりで作ったテストコードに頻繁に修正が入ってしまう。とはいえ初回のテストコードでおおよそのプロパティ、メソッドを確定できる恩恵は感じられたので避けられない手戻りコストとして受容するべきか。
  • 動画の処理などマルチメディア系のアプリはViewに機能が寄りがちでありカスタムUserControlの自作でかなりの労力を要した。(慣れていないだけでもある。)
gotoooogotoooo

完成品

動画をGIFファイルに変換する様子を画面キャプチャしてさらにその動画をGIFに変換した。

このスクラップは2ヶ月前にクローズされました