265-nize

メモちゃぁメモ。

前に「自分が既に十分に知っていることを情報公開としてお披露目するのって、疲れる」みたいなことを言った。情報格差ってのは実はそういうところから生まれるのだ、てことも。「やったぜ」とエキサイトできるのってさ、自分にとって新しいこと、じゃんか。そのモチベーションなく伝えるのはこれは「教育」という意識がないと出来ないことで、それをする人、出来る人ってのは結構限られるてことだ。(のみならず「教育」を目的とするコンテンツの何割かはビジネスに関係しているので、詐欺のようなインチキ情報も混ざりがち。)今日のこれもまさにそれで、自分的には「今更だから書くようなもんかねぇ」つぅ葛藤がある内容。

なんだけども。それは「やり方は知ってた」て意味で。やってみて驚いた、が今日の話の本題。

いやぁ、この「絶賛圧縮中、の効果を観察して喜びたい」ネタって、実は「H265」の話だったのよ。ビデオファイルって、たとえば zip で圧縮しても圧縮率が 3% にしかならない、てほどにもともとが圧縮率が高いもんなので、「ファイルサイズがデカいのは諦める」と思ってたんだけど、h265、かなりスゴイ。高画質で長時間のものほど効果抜群で、たとえば H264 で 512MB だったものを H265 に変換したら 250MB になった、とか。無論「(たぶん)劣化なしで」(劣化があるにしても少なくともワタシはまったく気づけないレベル)。

こうまで違うと、たとえば USB メモリスティックに突っ込んでおきたい、なんてときにそのデバイスに入れておける量も格段に違ってくるわけね。一括で変換したくなって、こんなん:

 1 #! /bin/sh
 2 # このスクリプトの第一引数が入力ビデオ、(任意の)第二引数は最初に元ビデオを移動する
 3 # /tmp/vidcnvcmp/ の下のサブフォルダ名。
 4 tmpd="/tmp/vidcnvcmp/${2:-$$}"
 5 ifn="$1"
 6 ffprobe -hide_banner "${ifn}" 2>&1 | grep 'Video: hevc ' && exit 0
 7 test -d ${tmpd} || mkdir -p ${tmpd}
 8 #
 9 ofn="${ifn}"
10 for ext in .mp4 .mkv .webm ; do
11     ofn="`basename \"${ofn}\" ${ext}`"
12 done
13 ofn="${ofn}".mkv
14 mv -fv "${ifn}" ${tmpd} && ffmpeg -y -i ${tmpd}/"${ifn}" -c:v libx265 -c:a copy "${ofn}"

本質は「-c:v libx265」のみで、以外は一時ファイルの扱いとかそんな些末の扱いをちょっとだけ楽にするためにコマゴマやってるだけ。

「なんで今さら?」の話をちょっと詳細に。

当然こういうのって、「相手がいてこそ」なわけね。出来たてほやほやのものってのは、サポートしてくれるものがないとどうしようもない。Windows に添付のメディアプレイヤーやフリーの代替は、今やほとんど問題ないだろう、「Windows 7 死亡に合わせて世代交代出来た人なら」。ワタシが個人的に「全部変換しちまえ」と決断するに至った理由は、昨年購入の SONY BRAVIA なアンドロイドが H265 をサポートしてたことね。USB に突っ込んでテレビに差し込んで、そのまま再生出来るの。

USB メモリの容量が 500TB あります、なんてことはないわけで、大きくても 128GB くらいだろう。これは結構大きいけれど、あれやこれや入れてればすぐに溢れる。これに「倍つっこめるで」って、そら大きいっすわ。


2021-03-17追記:
2つ追記しないといけないが、一つは訂正。「Windows に添付のメディアプレイヤーやフリーの代替は、今やほとんど問題ないだろう」は、本日時点だとかなりガセ。codecs はデフォルトには含まれてなくて、なおかつ情報では Microsoft Store で商用で有償で入手可能、とある。ワタシは MPC-HCVLC Media Player を使っていて全然困ってないので、Windows 添付のプレイヤーでどうにかしようとしてない。ので、エクスプローラの「プレビュー」が機能しない、など些末な不自由はないではない。

もうひとつは効果を観察して喜びたいに関して「グラフで」:

スクリプトはこんなである:

Python 3用で、要 psutil と pillow、matplotlib。
 1 # -*- coding: utf-8 -*-
 2 from datetime import datetime
 3 import tkinter
 4 import multiprocessing
 5 import psutil
 6 import matplotlib.pyplot as plt
 7 from PIL import Image, ImageTk
 8 
 9 
10 def _plt(fshist):
11     fig, ax1 = plt.subplots(tight_layout=True)
12     fig.set_size_inches(16.53 / 4 * 4, 11.69 / 4 * 2)
13     ax1.plot([dt for dt, fs, pct in fshist], [fs for dt, fs, pct in fshist])
14     ax1.grid(True)
15     last = fshist[-1]
16     ax1.set_title("free: {:.1f} % (at {})".format(
17         last[2], last[0].strftime("%I:%M%p")))
18     fig.autofmt_xdate()
19     fig.savefig(".visfs.png")
20     plt.close(fig)
21 
22 
23 class App(object):
24     def __init__(self, inidir):
25         self._inidir = inidir
26         self._root = tkinter.Tk()
27         self._fshist = [self._nowfs()]
28         self._imglbl = tkinter.Label(self._root)
29         self._imglbl.pack()
30         self._root.after(100, self._upd)
31         #
32         self._root.mainloop()
33 
34     def _nowfs(self):
35         dt, r = datetime.now(), psutil.disk_usage(self._inidir)
36         return dt, r.free / (1024.**3), 100 - r.percent
37 
38     def _upd(self, *args, **kwargs):
39         self._fshist.append(self._nowfs())
40         while len(self._fshist) > 10000:
41             self._fshist.pop(0)
42         p = multiprocessing.Process(target=_plt, args=(self._fshist,))
43         p.start()
44         p.join()
45         img = Image.open(".visfs.png")
46         self._imgobj = ImageTk.PhotoImage(img)
47         self._imglbl.config(image=self._imgobj)
48         self._root.after(1000, self._upd)
49 
50 
51 if __name__ == '__main__':
52     import sys, os
53     app = App(os.path.dirname(os.path.abspath(sys.argv[-1])))

全然苦労しなかったならこうやってネタとして挙げる価値もあんましなかったんだけれど、ちょっと問題があって苦労した。コードが「multiprocessing」してるのは、これは「Tk の mainloop と matplotlib の相性が悪かったから」。具体的には、たぶんだけど plt.close(figure) などの matplotlib 後片付けが Tk の mainloop に何らか割り込んでしまって、無限ループ出来なくなってしまう。Windows 版でしか確認してないが Windows 版の問題かもしれない。ゆえに、こういうときのほぼ定石の「プロセス空間を分離」で措置した、てこと。

同じ multiprocessing を使うのでも、今回はシンプルに「都度プロセスを起こす」ようにしたけど、もちろんこれはコストが嵩むので、性能を問題にしたいならばサブプロセスの方を常駐サービス的にして IPC で通信するやり方(Queue などを使って)を採ると良い。が、「ワタシの今日」にはこれで十分だったので。「明日のワシ」が必要と思ったらそのうち考える。


2021-03-21「明日のワタシ」:

 1 # -*- coding: utf-8 -*-
 2 # require: python 3.x, pillow, matplotlib, psutil
 3 import sys, os
 4 import time
 5 from datetime import datetime
 6 import tkinter
 7 import tkinter.ttk as ttk
 8 from tkinter import E, W, N, S
 9 import multiprocessing
10 import psutil
11 import matplotlib.pyplot as plt
12 from PIL import Image, ImageTk
13 
14 
15 _GRAPH_IMGFN = ".visfs.png"
16 
17 
18 def _nowfs(inidir):
19     dt, r = datetime.now(), psutil.disk_usage(inidir)
20     return dt, r.free / (1024.**3), 100 - r.percent
21 
22 
23 def _plt(inidir, q):
24     fshist = [_nowfs(inidir)]
25     time.sleep(0.25)
26     while True:
27         fshist.append(_nowfs(inidir))
28         while len(fshist) > 10000:
29             fshist.pop(0)
30 
31         fig, ax1 = plt.subplots(tight_layout=True)
32         fig.set_size_inches(16.53 / 4 * 4, 11.69 / 4 * 2)
33         ax1.plot([dt for dt, fs, pct in fshist], [fs for dt, fs, pct in fshist])
34         ax1.grid(True)
35         last = fshist[-1]
36         ax1.set_title("[{}] free: {:.1f} % (at {})".format(
37             inidir, last[2], last[0].strftime("%I:%M%p")))
38         fig.autofmt_xdate()
39         fig.savefig(_GRAPH_IMGFN)
40         plt.close(fig)
41         qv = q.get()
42         if not qv:
43             break
44 
45 
46 class App(object):
47     def __init__(self, inidir):
48         self._inidir = inidir
49         self._root = tkinter.Tk()
50 
51         self._pctl = ttk.Progressbar(orient="vertical", maximum=100.)
52         self._pctl.grid(row=0, column=0, sticky=N + S)
53         self._pctl.config(value=_nowfs(self._inidir)[-1])
54         self._imglbl = tkinter.Label(self._root)
55         self._imglbl.grid(row=0, column=1)
56         self._pctr = ttk.Progressbar(orient="vertical", maximum=100.)
57         self._pctr.grid(row=0, column=2, sticky=N + S)
58         #
59         self._q = multiprocessing.Queue()
60         self._sp = multiprocessing.Process(target=_plt, args=(self._inidir, self._q))
61         self._sp.start()
62         #
63         self._root.protocol("WM_DELETE_WINDOW", self._cleanup)
64         #
65         self._root.after(500, self._upd)
66         #
67         self._root.mainloop()
68 
69     def _upd(self, *args, **kwargs):
70         self._q.put_nowait("_upd")
71         self._pctr.config(value=_nowfs(self._inidir)[-1])
72         try:
73             img = Image.open(_GRAPH_IMGFN)
74             self._imgobj = ImageTk.PhotoImage(img)
75             self._imglbl.config(image=self._imgobj)
76         except OSError:
77             pass
78         self._root.after(5000, self._upd)
79 
80     def _cleanup(self):
81         self._q.put(None)
82         self._root.destroy()
83         os.remove(_GRAPH_IMGFN)
84 
85 
86 if __name__ == '__main__':
87     app = App(os.path.dirname(os.path.abspath(sys.argv[-1])))

最初の版を動かしてみればすぐにわかるんだけど、「コストが嵩む」こともなんだが、むしろ「都度フォアグラウンドになろうとする(ゆえに他のプロセスのフォーカスを奪う)」ことで他の作業の妨げになってしまうことの方が問題だった。監視を 1000ms でなんてやってると、たとえばブラウザで「右クリックして「新しいタブで開く」を選択」の操作すらままならなくなる。matplotlib、というかそれのバックエンドの問題、で、おそらく Windows 版固有の問題と思う。

もちろんこの問題に関してワタシ自身は matplotlib を日常的に使ってるのでお馴染みなんだけれど、さすがに「毎秒フォーカスを奪われる」のはお馴染みとまでは言わない。して、その措置こそが「サブプロセスの方を常駐サービス的にして IPC で通信するやり方(Queue などを使って)」。スクリプトの変更は主としてそれ。あと「free: ~%」って文字だけだと寂しいので、グラフの横に「free % なプログレスバー」もつけといた。


2021-03-23追記:
本題の x265-nize スクリプトなのだが、ファイル名で問題起こしたりとうっといので、python に書き換えた:

video_to_x265.py
  1 # -*- coding: utf-8 -*-
  2 from __future__ import unicode_literals
  3 
  4 import os
  5 import sys
  6 import re
  7 import subprocess
  8 import shutil
  9 import tempfile
 10 
 11 
 12 if hasattr("", "decode"):
 13     _encode = lambda s: s.encode(sys.getfilesystemencoding())
 14 else:
 15     _encode = lambda s: s
 16 
 17 
 18 def _filter_args(*cmd):
 19     """
 20     do filtering None, and do encoding items to bytes
 21     (in Python 2).
 22     """
 23     return list(map(_encode, filter(None, *cmd)))
 24 
 25 
 26 def check_call(*popenargs, **kwargs):
 27     """
 28     Basically do simply forward args to subprocess#check_call, but this
 29     does two things:
 30     * It does encoding these to bytes in Python 2.
 31     * It does omitting `None` in *cmd.
 32 
 33     """
 34     cmd = kwargs.get("args")
 35     if cmd is None:
 36         cmd = popenargs[0]
 37     subprocess.check_call(
 38         _filter_args(cmd), **kwargs)
 39 
 40 
 41 def check_stderroutput(*popenargs, **kwargs):
 42     """
 43     Unfortunately, ffmpeg and ffprobe throw out the information
 44     we want into the standard error output, and subprocess.check_output
 45     discards the standard error output. This function is obtained by
 46     rewriting subprocess.check_output for standard error output.
 47     And this does two things:
 48     * It does encoding these to bytes in Python 2.
 49     * It does omitting `None` in *cmd.
 50     """
 51     if 'stderr' in kwargs:
 52         raise ValueError(
 53             'stderr argument not allowed, it will be overridden.')
 54     cmd = kwargs.get("args")
 55     if cmd is None:
 56         cmd = popenargs[0]
 57     #
 58     process = subprocess.Popen(
 59         _filter_args(cmd),
 60         stderr=subprocess.PIPE,
 61         **kwargs)
 62     stdout_output, stderr_output = process.communicate()
 63     retcode = process.poll()
 64     if retcode:
 65         raise subprocess.CalledProcessError(
 66             retcode, list(cmd), output=stderr_output)
 67     return stderr_output
 68 
 69 
 70 def _ffmpeg_for_conv(ifn, ofn):
 71     probed = check_stderroutput(["ffprobe", "-hide_banner", ifn])
 72     venc = "copy" if re.search(br"Video: hevc ", probed) else "libx265"
 73     ffmcmdl = [
 74         "ffmpeg", "-y",
 75         "-i", ifn,
 76         "-c:v", venc,
 77         "-c:a", "copy",
 78         "-map_metadata", "-1",
 79         ofn
 80         ]
 81     check_call(ffmcmdl)
 82 
 83 
 84 def _main():
 85     import argparse
 86     ap = argparse.ArgumentParser()
 87     ap.add_argument("video", nargs="+")
 88     ap.add_argument("--remove_original", action="store_true")
 89     args = ap.parse_args()
 90     curdir = os.path.abspath(os.curdir)
 91     tmpdir = tempfile.mkdtemp()
 92     try:
 93         for media in args.video:
 94             ifn = os.path.join(tmpdir, media)
 95             os.rename(media, ifn)
 96             mkv = os.path.splitext(media)[0] + ".mkv"
 97             ofn = os.path.join(curdir, mkv)
 98             _ffmpeg_for_conv(ifn, ofn)
 99     finally:
100         if args.remove_original:
101             shutil.rmtree(tmpdir, ignore_errors=True)
102         else:
103             print("originals: {}".format(tmpdir.replace("\\", "/")))
104 
105 
106 if __name__ == '__main__':
107     _main()

色々欲が出てきそうだが…。

相変わらず ffmpeg にはイラつく。これを読んでる人のなかで「最初から python にしときゃええやん」と感じる人もいるかとは思うんだけど、コード中の「check_stderroutput」なのよ、いつも ffmpeg 関係で python で作るのを臆してしまう理由の一番大きな理由は。ffmpeg 本体については少し理解できるんだけれど、ffprobe が「欲しい情報が stderr に行く」のは致命的に「阿呆」で、言うなればこれは「コップに白飯、カレー皿に味噌汁」で給仕されてるようなもん。bash シェルならリダイレクトを簡単に変更できるわけで、「あぁ python だとめんどい」てなる。

実はこの「subprocess」周りの便利パッケージを検討することも多いんだわ。python 2.x と 3.x での差異もウザくてね。けど、依存パッケージがあると、こういうページにぺろっと単体で貼り付けるのに向かなくなってくるのが悩みのタネで。弱るよな。


2021-03-23 14時追記:
「ファイル名にまつわるゴタゴタ」が上にあげたのだけでは解決しないことがあって、少し大きめに書き換えた。ついでに gist に置くことに:

まぁ MSYS にもちょい責任はあるんだけどね、コマンドラインから入ってくる文字列と python 内部で自力でお取り寄せる文字列が同じであるとは限らない、てこと。テクニカルには「いつどこでバイト列をデコードして文字列にするかの経路依存」てこと。もっとコード寄りに具体的に言えば、「sys.argv に入ってくるやーつ」と「os.listdir()で自前リスティングしたやーつ」の違いね。「sys.argv に入ってくるやーつ」は、ワタシの作業の仕方だと MSYS bash を経由するので、つまりは「ワタシはコントロール出来ない」の。だから「glob」で自家発電できるようにした、てことね。

ほかにもいくつか機能拡張してるけど、まぁ説明はいらん…よね、きっと。


2021-03-25追記:
H.265 化は、「ワタシの日常にはおk」なのよ、「使いたいデバイスで再生出来る上にコンパクト」なのだから、ばんばんざい、てわけだ。

なんだけど、そもそも Windows 機に置いとくのにはエクスプローラの「プレビュー」などでサムネイルが見れないのは結構不便に感じるし、たとえば以下では chrome は絶対に再生出来ない:

 1 <html>
 2 <body>
 3 
 4 H.265 でエンコードしたビデオ:
 5 <video id="vid1" width="480" height="270" controls>
 6   <source src="x265test.mkv" type="video/webm">
 7 </video>
 8 
 9 </body>
10 </html>

もろもろの理由により「H.265 の未来は明るくない」と言ってる人がいる。そうなのかも。chrome、
というか google が後ろ向きらしいのだが、例によってライセンスの問題みたいね。(Microsoft Edge は一応再生出来るんだけれど、ハードウェアデコーディングが利用可能な場合に限る、とのこと。)

最初にも言った通り「ワタシの日々の暮らし」には別に H.265 はいいんだけど、ただ例えば Youtube にアップロードしたいなどの「外向け」には、どうも今は H.265 が置かれている状況はよろしくない、て結論で良さそうだ。で、代替は何か、てことになるんだけど、二つの候補「VP9」と「AV1」の、これはともに ffmpeg がサポートしている。どうなの?

やってみたのだが、まず av1 (-c:v libaom-av1 -strict -2)は「終わる気がしない」というほどにエンコードにかかる時間が尋常ではなく、とてもではないが日常使いする気にはなれなかった。libx265 だって別に速くはない(というか遅い)が、その何百倍も遅い。10秒の高画質ビデオを、おそらく2時間ほどかけないとエンコード出来ない、てくらいに。ゆえに、ワタシ的には VP9 (-c:v libvpx-vp9)一択、少なくとも AV1 は「今デショ、ではない」。将来性はとてもあると思うんだけどね、AV1。

VP9 については、大問題が一つ。少なくともワタシが手持ちの「ffmpeg-4.2.1 の vp9 はコントロール出来ない」てこと。ワタシが「H.265 かっけー」と思ったのは、「エンコーダを指定するだけで他に何もすることなく「限りなくロスレスでなおかつ凄まじくサイズが小さくなる」」から。しかしながら「ffmpeg-4.2.1 の vp9 はとても低画質にしやがる上にコントロール不能」。ほんとか、と思うのだが、以下を ffmpeg 4.2 とワタシが持ってる最新の 4.3 とで実際に動かして比較してみて欲しい:

 1 #! /bin/sh
 2 #ffmpeg="/c/Program Files/ffmpeg-4.2.1-win64-shared/bin/ffmpeg"
 3 ffmpeg="/c/Program Files/ffmpeg-4.3.2-win64-shared/bin/ffmpeg"
 4 bn="testvideo"
 5 #"${ffmpeg}" -y -i "${bn}".mkv -c:v libaom-av1 -strict -2 -t 10 "${bn}"_av1.mkv
 6 for q in -1 0 32 40 48 56 63 ; do
 7     # 小さいほど高画質(サイズがデカくなる)、大きいほど低画質(サイズが小さくなる)。
 8     # -1 は「デフォルト」て意味かしら? -crf -1 と -crf 32 が同じになったっぽい。
 9     "${ffmpeg}" -y -i "${bn}".mkv -c:v libvpx-vp9 -crf ${q} -strict -2 -t 10 "${bn}"_vp9_crf${q}.mkv
10 done

もう一つ小さいがワタシには問題なのが、「エンコーダを指定するだけで他に何もすることなく」が libvpx-vp9 では成立しなくて、crf でクオリティを指定しないと「不満なビデオ」が出来上がってしまう点。まぁこればっかりは仕方ないか、とは思うが、この場合「どのくらいの crf なら不満がないか」を試行錯誤で調べないといけない、てことになるわけな。

やってみたんだけど、「-crf 40」付近が libx265 に近い圧縮が出来つつ画質も問題ない、て感じかなと思った。(当たり前だが「-crf 0」は超絶に高画質(まさにロスレス)だけど、サイズはべらぼーにデカくなる。)

あともう一つ付け加えておくと、「H.265」にしちゃった後で「H.264」に戻すのはこれも大変だとわかった。とても時間がかかる。対して、H.265 → VP9 にかかる時間は普通。てわけで、事情により H.264 をキープする必要性が少しでもあるビデオについては、安易に H.265 にはしない方がいい。そうでない場合は、「H.265 がダメなら VP9」でいい。Youtube がその事情ならそれでいいだろうね。

なお、上に貼り付けた gist のコードは vp9 へのエンコードも出来るようにしといた。


2021-04-06追記:
「Windows エクスプローラでサムネイルが参照出来ない」「Windows 10 純正機に同梱されているプレイヤーでは再生出来ない」という「些末な問題」、が解決できた。これはかなり嬉しい、些末なのに。

結局「主食」(普段使いの MPC-HC と BRAVIA)で問題ないなら「悔しいけどまぁいっか」てことだったんだけれど、もちろん「Windows エクスプローラでサムネイルが参照出来る」「Windows 10 純正機に同梱されているプレイヤーでも再生出来る」方が便利なのは当然の話。ファイル名だけで中身が自明とは限らないからね。

個人的に面白かったのは、「探すべきもの」がワタシははっきり理解していてそれでも見つからず、ふと「変化球の検索」を試みたら「ワタシが最初から理解していた「探すべきもの」」そのものが見つかった、ということ。なおかつ、見つかったそれは、個人的にかなり嬉しいものだった。

具体的に。

まず、「ワタシが最初から理解していた「探すべきもの」」とは、種類として二つで、

  • ffmpeg 系の OSS に由来する DirectShow フィルター (これが libx265 を取り込んでるはずであろう、と)
  • その DirectShow フィルターに依存してくれる、シェル拡張

後者は「不可欠になるのかどうかまではわからんが、必要かもなぁ」くらいに思ってた。問題は前者で、なんせワタシの知識はかなり古くて、まぁ見つからんのよ、全然。

救世主となってくれた記事は、「embed thumbnail image into matroska video」なんて人によっては「なんで?」と言いたくなるであろう意味不明な検索で見つかった。これね、MP3 にサムネイルイメージを埋め込むとエクスプローラが反応してくれるのと同じノリで、mkv にサムネイルイメージをぶっ込むのが答えになるであろうか、と思ってこういう検索をしたの。

見つけたその救世主:

その検索ワードから狙うものとは違うことが書いてあって、そしてそれこそが「ffmpeg 系由来 DirectShow フィルタとシェル拡張」についてだったわけね。

ワタシの知識がまぁ古くて、なので、ffdshow はご存知だったんだが、知らなかったんだわ、LAV Filters。まさしく ffmpeg 由来の DirectShow フィルタ。とりあえずこれをインストールすれば、Windows に標準添付されているプレイヤーでも再生できるようになる。

そして LAV Filters だけでは「エクスプローラからサムネイルがみれるようにはならない」→「シェル拡張」、これが shark007 の Icaros。なかなかにちょっと感動するプロジェクトになってて、是非「鑑賞」はしてみて欲しい。Configure の UI がかっちょ良くてびっくりする。相当な力作。サムネイルも「ビデオの何秒くらいの位置から取るか」をコントロール出来たりと、柔軟。この Icaros についてだけ独立して解説したくなるくらいだ。ともあれ、これをインストールしてアクティベートすれば、H.265 のビデオであろうが、あるいはそもそも .mkv はエクスプローラは「未知のもの」としてサムネイルは見せてくれないのだが、Icaros によってサムネイルが表示されるようになる。

うん、めでたい。

ちなみに迷走の途中では「NTFS5 streams」の活用まで考えてた。そっちの迷走の方のネタもそっちはそっちで面白かったりもするので、気が向いたら改めて書くかも。