ffmpegのfilter_complexをGraphviz(dot)で見える化する Pythonスクリプト


[ffmpeg-python](https://kkroening.github.io/ffmpeg-python/)を使うと、各種動画生成が簡単に行えます。
このモジュールは動画処理を外部のffmpegプログラムに任せており、ffmpeg向けの命令群を構築して、`ffmpeg.run`でffmpegに投げています。

`ffmpeg.get_args()`でffmpegに渡す引数をダンプできます。
このうち`-filter_complex`で渡しているのが、中核処理のコマンド(`filter`)です。

数百種類あります。
[FFmpeg Filters Documentation](https://ffmpeg.org/ffmpeg-filters.html)

これらのコマンドは、「入力、処理、結果」というシンプルな形式になっています。

引数ダンプを見てもデータの流れが分かりづらかったので、Graphvizのdot言語に変換する Pythonスクリプトを書きました。
入力と出力先を指定するシンプルなI/Fです。


```python:filter2dot.py
#!/usr/bin/env python3
import argparse
import re
import sys


def main(argv: list[str]) -> int:
    # parse argument
    parser = argparse.ArgumentParser(description="")
    parser.add_argument("file")
    parser.add_argument("outfile")
    args = parser.parse_args(argv)

    src2node = {}
    node2dst = {}
    node2cmd = {}

    with open(args.file, encoding="utf8") as f:
        for line in f:
            if m := re.fullmatch(
                r"(.+\])([^\[\]]+)(\[.+)", line.strip().rstrip(";")
            ):
                srctxt, cmd, dsttxt = m.group(1, 2, 3)
                srcs = [t.strip("[]") for t in srctxt.split("][")]
                dsts = [t.strip("[]") for t in dsttxt.split("][")]
                nodename = f"{''.join(srcs)}-{''.join(dsts)}"
                for src in srcs:
                    src0 = src.split(":")[0]
                    src2node[src0] = nodename
                    if src0.isdecimal():
                        node2dst[src0] = [src0]
                        node2cmd[src0] = src
                node2dst[nodename] = dsts
                node2cmd[nodename] = (
                    cmd.replace(":", "\\n").replace("=", "\\n", 1)
                )

    with open(args.outfile, "w", encoding="utf8") as f:
        print("digraph {", file=f)
        print("    rankdir=TB;", file=f)
        print("    // node", file=f)
        for node, cmd in node2cmd.items():
            print(f'    "{node}"[label="{cmd}"];', file=f)
        print("    // edge", file=f)
        for node, dsts in node2dst.items():
            for dst in dsts:
                if dst in src2node:
                    print(
                        f'    "{node}" -> "{src2node[dst]}" [label="{dst}"];',
                        file=f,
                    )
                else:
                    print(f'    // "{node}" -> "??" [label="{dst}"]', file=f)
        print("}", file=f)

    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
```

以下は入出力例です。なお長いコマンド群の頭を抜き出しているので、１つに合流しきれていません。

```txt:入力例
[0]geq=a=255:b=255:g=200:r=200[s0];
[s0]split=1[s1];
[1]geq=a=255:b=255:g=200:r=200[s2];
[s2]split=1[s3];
[s3]trim=end=305.893922[s4];
[2:v]split=1[s5];
[s5]setsar=1/1[s6];
[s6]scale=768:-1[s7];
[s7]loop=loop=-1:size=32767:start=0[s8];
[s4][s8]overlay=eof_action=pass:shortest=False:x=-268:y=302[s9];
[3:v]split=1[s10];
[s10]setsar=1/1[s11];
[s11]scale=633:-1[s12];
[s12]loop=loop=-1:size=32767:start=0[s13];
[s9][s13]overlay=eof_action=pass:shortest=False:x=1478:y=324[s14];
[4]geq=a=0:b=0:g=0:r=0[s15];
[s15]split=2[s16][s17];
[s17]split=2[s18][s19];
[5]geq=a=0:b=0:g=0:r=0[s20];
[s20]split=2[s21][s22];
[s21]trim=end=3.897333[s23];
[6]geq=a=178:b=0:g=0:r=0[s24];
[s24]split=2[s25][s26];
```

```dot:出力例
digraph {
    rankdir=TB;
    // node
    "0"[label="0"];
    "0-s0"[label="geq\na=255\nb=255\ng=200\nr=200"];
    "s0-s1"[label="split\n1"];
    "1"[label="1"];
    "1-s2"[label="geq\na=255\nb=255\ng=200\nr=200"];
    "s2-s3"[label="split\n1"];
    "s3-s4"[label="trim\nend=305.893922"];
    "2"[label="2:v"];
    "2:v-s5"[label="split\n1"];
    "s5-s6"[label="setsar\n1/1"];
    "s6-s7"[label="scale\n768\n-1"];
    "s7-s8"[label="loop\nloop=-1\nsize=32767\nstart=0"];
    "s4s8-s9"[label="overlay\neof_action=pass\nshortest=False\nx=-268\ny=302"];
    "3"[label="3:v"];
    "3:v-s10"[label="split\n1"];
    "s10-s11"[label="setsar\n1/1"];
    "s11-s12"[label="scale\n633\n-1"];
    "s12-s13"[label="loop\nloop=-1\nsize=32767\nstart=0"];
    "s9s13-s14"[label="overlay\neof_action=pass\nshortest=False\nx=1478\ny=324"];
    "4"[label="4"];
    "4-s15"[label="geq\na=0\nb=0\ng=0\nr=0"];
    "s15-s16s17"[label="split\n2"];
    "s17-s18s19"[label="split\n2"];
    "5"[label="5"];
    "5-s20"[label="geq\na=0\nb=0\ng=0\nr=0"];
    "s20-s21s22"[label="split\n2"];
    "s21-s23"[label="trim\nend=3.897333"];
    "6"[label="6"];
    "6-s24"[label="geq\na=178\nb=0\ng=0\nr=0"];
    "s24-s25s26"[label="split\n2"];
    // edge
    "0" -> "0-s0" [label="0"];
    "0-s0" -> "s0-s1" [label="s0"];
    // "s0-s1" -> "??" [label="s1"]
    "1" -> "1-s2" [label="1"];
    "1-s2" -> "s2-s3" [label="s2"];
    "s2-s3" -> "s3-s4" [label="s3"];
    "s3-s4" -> "s4s8-s9" [label="s4"];
    "2" -> "2:v-s5" [label="2"];
    "2:v-s5" -> "s5-s6" [label="s5"];
    "s5-s6" -> "s6-s7" [label="s6"];
    "s6-s7" -> "s7-s8" [label="s7"];
    "s7-s8" -> "s4s8-s9" [label="s8"];
    "s4s8-s9" -> "s9s13-s14" [label="s9"];
    "3" -> "3:v-s10" [label="3"];
    "3:v-s10" -> "s10-s11" [label="s10"];
    "s10-s11" -> "s11-s12" [label="s11"];
    "s11-s12" -> "s12-s13" [label="s12"];
    "s12-s13" -> "s9s13-s14" [label="s13"];
    // "s9s13-s14" -> "??" [label="s14"]
    "4" -> "4-s15" [label="4"];
    "4-s15" -> "s15-s16s17" [label="s15"];
    // "s15-s16s17" -> "??" [label="s16"]
    "s15-s16s17" -> "s17-s18s19" [label="s17"];
    // "s17-s18s19" -> "??" [label="s18"]
    // "s17-s18s19" -> "??" [label="s19"]
    "5" -> "5-s20" [label="5"];
    "5-s20" -> "s20-s21s22" [label="s20"];
    "s20-s21s22" -> "s21-s23" [label="s21"];
    // "s20-s21s22" -> "??" [label="s22"]
    // "s21-s23" -> "??" [label="s23"]
    "6" -> "6-s24" [label="6"];
    "6-s24" -> "s24-s25s26" [label="s24"];
    // "s24-s25s26" -> "??" [label="s25"]
    // "s24-s25s26" -> "??" [label="s26"]
}
```

![x.dot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142637/28f0eba7-072b-3c1f-0e80-ddefb21aed00.png)