Fletでチャプター検出できる動画編集アプリを作りました

# 作ったもの

動画内の黒/白のフェードイン/アウトからチャプターを検出して、好きなチャプターだけを選んで結合できるアプリ「ChapterTrimmer」を作りました。

![demo](https://github.com/oddgai/ChapterTrimmer/assets/45445604/508e0a0c-eb95-4a15-9464-c5f04617d4cc)

こちらからダウンロードできます（現時点ではWindowsのみ）。

https://github.com/oddgai/ChapterTrimmer/releases/tag/v0.1.0

※自分で使うために勢いで作ったので、正常系の簡単なテストしかやってません。ちゃんとしたテストやリファクタリングはあとでやります（フラグ）。

# なぜ作ったか

黒や白のフェードイン/アウトで区切られた動画を観ることってよくありますよね。僕はあります。そのなかで必要なチャプターだけ抽出できたらいいなーと思って調べてみたんですが、そのようなことができるアプリが見当たらなかったので作りました。

# なぜFletで作ったか

今回はFletという、フロントエンド言語を書かずにPythonだけでFlutterアプリを作れるフレームワークを使いました。選定の理由はこちらです。

- GB単位の動画ファイルを扱う想定なので、サーバーへのアップロード/ダウンロードが必要なWEBアプリよりもデスクトップアプリがいい
- デスクトップアプリを作るにはいろんなやり方があるが、Fletが一番ちょうど良さそう
    - [Electron](https://www.electronjs.org/)：今回のような小規模なアプリには too much 感がある
    - [Flutter](https://flutter.dev/)：一からDartを触るほどのモチベはない
    - [Tauri](https://tauri.app/)：一からRustを（以下同文）
    - [Flet](https://flet.dev/)：現状だとE2Eテストをする方法が（PyAutoGUIくらいしか）なかったり開発途上感はあるが、Pythonは使い慣れてるしTkinterやKivyよりいい感じにUIが作れそう

# ざっくり説明

動作の流れは以下の通りです。順にざっくり説明していきます。

- 動画ファイルを読み込む
- 動画のチャプターを検出する
- 各チャプターの動画を表示する
- 選択されたチャプターを結合して保存する

なお、ソースコードはGitHubにあるので詳しくはそちらを見てください。

https://github.com/oddgai/ChapterTrimmer

## 動画ファイルを読み込む

Fletでファイルを読み込むには以下のように書きます。

```Python
def main(page: ft.Page):
    def load_video(e: ft.FilePickerResultEvent):
        picked_video = ft.VideoMedia(e.files[0].path)

        # 何かしらの処理
        # たとえば動画を再生するならこう書く
        video = ft.Video(
                    aspect_ratio=16 / 9,
                    autoplay=False,
                    filter_quality=ft.FilterQuality.HIGH,
                    playlist=[picked_video],
                    playlist_mode=ft.PlaylistMode.NONE,
                )
        page.add(video)
        page.update()

    file_picker = ft.FilePicker(on_result=load_video)
    page.overlay.append(file_picker)

    file_pick_button = ft.Container(
        ft.ElevatedButton(
            "Pick Video File",
            icon=ft.icons.VIDEO_FILE_OUTLINED,
            on_click=lambda _: file_picker.pick_files(allow_multiple=False, file_type=ft.FilePickerFileType.VIDEO),
        ),
    )
    page.add(file_pick_button)
    page.update()


ft.app(target=main)
```

Fletは公式ドキュメントが充実しているので、それを参考にすればだいたいの処理は書けると思います。

https://flet.dev/docs/controls/filepicker

https://flet.dev/docs/controls/video

## 動画のチャプターを検出する

チャプター検出にはPySceneDetectを使いました。

https://www.scenedetect.com/

PySceneDetectには以下の3つのDetectorが搭載されています。

- `Content-Aware Detector`
    - 動画内のフレームがパッと切り替わったタイミングを検出できる
- `Adaptive Content Detector`
    - `Content-Aware Detector`と同様にパッと切り替わったのを検出できるが、誤検出が少ない
- `Threshold Detector`
    - フレームの輝度が一定以上/以下になったタイミングを検出できる

これらの複数を組み合わせて使うこともできますが、手持ちの動画だと `Content-Aware Detector` と `Adaptive Content Detector` では意図しないチャプターが大量に検出されてしまったため、今回は `Threshold Detector` のみを使いました。

さて、PySceneDetectでチャプター検出をするにはこのように書きます。

```python
def detect_chapter(video_file_path: str) -> list[tuple[FrameTimecode, FrameTimecode]]:
    video = open_video(video_file_path)
    scene_manager = SceneManager()
    scene_manager.auto_downscale = True

    # detect White
    scene_manager.add_detector(ThresholdDetector(threshold=243, method=ThresholdDetector.Method.CEILING))

    # detect Black
    scene_manager.add_detector(ThresholdDetector(threshold=12, method=ThresholdDetector.Method.FLOOR))

    scene_manager.detect_scenes(frame_source=video, show_progress=True, frame_skip=1)
    chapter_list = scene_manager.get_scene_list()
    return chapter_list
```

上記の `ThresholdDetector` の2つの引数のうち、`threshold` はチャプターの区切りとみなす輝度のしきい値、`method` は `threshold` で指定したしきい値の 以上(CEILING) or 以下(FLOOR) のどちらを区切りとみなすかを表しています。
上記のように書くことで、黒のフェードと白のフェードをどちらもチャプターの区切りとして検出しています。

## 各チャプターの動画を表示する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119757/3a07f636-630c-c130-3dfd-b3cd24cd9451.png)


いくつかやり方はあると思いますが、今回は以下のような方法で実現しました。

- Pythonから `ffmpeg` コマンドを叩いて、元の動画ファイルから各チャプターの動画を切り出して保存する
    - 5つのチャプターが検出されたら5つの動画ファイルができる
    - PySceneDetectに `split_video_ffmpeg()` というこれ用の関数があったのですが、動作が遅かったので自分で書きました
- 各チャプターの動画ファイルを読み込んで表示する
    - 少し上に書いたやり方と同様

今回は各動画と合わせてチェックボックスも表示させたかったので、以下のように書きました。

```python
splitted_video_grid = ft.GridView(
            runs_count=3, spacing=20, run_spacing=20, child_aspect_ratio=16 / 12, padding=ft.padding.only(right=20)
        )
check_box_list = [ft.Checkbox(value=False) for _ in range(len(splitted_video_list))]
for check_box, splitted_video in zip(check_box_list, splitted_video_list):
    splitted_video_grid.controls.append(
        ft.Container(
            ft.Column([check_box, splitted_video]),
            bgcolor=ft.colors.GREY_200,
            border_radius=10,
            padding=10,
        ),
    )
```

ちなみに、デスクトップアプリ上だと動画コンポーネントの見た目がとてもYoutubeっぽいのでクリックして再生/一時停止ができそうですが、[現時点だとできません](https://github.com/media-kit/media-kit/issues/683)。

## 選択されたチャプターを結合して保存する

これもいくつかやり方があると思いますが、上記と同様に `ffmpeg` を叩くようにしました。具体的には以下のような流れになっています。

- `Merge` ボタンが押されたタイミングで各チェックボックスの状態（True/False）をみる
- 一時ファイルを作成し、Trueになっている動画のファイルパスをすべて書き込む
- `ffmpeg concat` コマンドでその一時ファイルを参照し、結合した動画を保存する

たとえば以下のようなテキストファイルを `ffmpeg concat` コマンドで読み込ませると、ファイル内の3つのファイルを結合して保存できます。

```txt
hoge/chapter_1.mp4
hoge/chapter_2.mp4
hoge/chapter_3.mp4
```

`ffmpeg concat` コマンドの注意点として動画の解像度を揃える必要があるのですが、今回は同じ動画ファイルから切り出してるので解像度はすべて同じです。

# まとめ

以上を組み合わせると、好きな動画から好きなチャプターだけ保存できます。やったね！