h265-nise するのはいいがエクスプローラがサムネイれないのを「NTFS#Alternate data stream (ADS)」でどうにかしてみようと考えたけれども果てた話。

正確には、「果てた」というか、当面は不毛だと理解した、て話。

265-nize の、昨日(2021-04-06)の追記に書いた内容が、ここ最近やってた「ビデオを H.265 にするとすっげー小さくなる、が、Windows 生活では少々不都合」に対する「満足できる解」、なのだけれど、その正解に辿り着く前に考えてた一つについての話。

要は「H.265 に変換してしまったが最後、そのビデオは、コンテナが .mp4 だろうがなんだろうがエクスプローラでサムネイルが見れなくなる」ことに対して、ほかに対案はないか、ということなわけなんだけれど、MP3 タグ・カバーイメージなんかがそうであるように、「補助データ埋め込み + エクスプローラのなにがしか能力」という組み合わせで参照可能なもの、というのはある。あるいは「そういうエクスプローラの alternative」があるかもしれない。あるいは、そういう「エクスプローラもどき」を作ってしまう手もあるかもしれない、と。

その思考の過程で考えていたのが「NTFS#Alternate data stream (ADS)」で、ワタシが何度か紹介している「SysInternalsSuite」の「streams」がターゲットにする、いわゆる補助データ。ワタシがこれについて知ったのはまさに SysInternalSuite の元となった Mark Russinovich が書いた記事から。ソースコードがあったはずなんだけどねぇ、今書籍を買う以外の手段てないのかな。ワタシはどうやって参照してたんだろうか? 持ってたのだよ、間違いなく不正のない手段で入手したソースコード。とにかく今はダウンロード可能な状態では手に入らないっぽい。私が持っていたのはこのこれではないとは思うんだけど、おそらく購入可能なこれに近しい本に、今でもソースコードが掲載されてるんじゃないかと思う。

ADS について初めて知った際にワタシが何を思ったかと言えば、「あぁ、Mac OS がやってたヤツの真似か」と。当時も今もワタシは本当の Mac OS に触れている時間はほぼ皆無(人生全体でも数時間以内)という知識のなさなんだけれど、知識としてそういうものがあることを聞きかじりで知ってた。記憶違いかしら、と思って改めて調べてみたが、間違ってなかったみたい

「補助データ」というと理解が雑過ぎるのかもしれない。ともあれエンドユーザからの見え方としては、一つのファイルに付属する「隠しデータ」に見える。

これがさ。前にNTFS のワイド文字拡張の話をしたけどさ、あれと同じで、システムレベルでシームレスに統合されている機能というのは、「思わぬものが自動的に恩恵に預かっている」ことがあって、実はこれもその一つ。なんせこれだけなんだからな:

ADSデータの操作に関して、とりわけ難しいことは、ひとつしかない。ひとつ、は後述
 1 import io
 2 
 3 # ビデオから一枚絵を抜き出す方法として ffmpeg を使う場合は、たとえば:
 4 #     $ ffmpeg -y -i somevideo.mkv -r 1/1 -t 1 thumb.png
 5 #     $ ffmpeg -y -ss 10 -i somevideo.mkv -r 1/1 -t 1 thumb.png
 6 # など。この thumb.png が、「somevideo.mkv」と完全に一体となってたらハッピー
 7 # なのでは、という発想、なわけよ。
 8 
 9 # ビデオ「somevideo.mkv」に、「thumb.png」を ADS データとしてぶち込む。
10 with io.open("somevideo.mkv:thumb.png", "wb") as fo:
11     fo.write(io.open("thumb.png", "rb").read())
12 
13 # ビデオ「somevideo.mkv」に含まれる「thumb.png」ADS データを取り出す。
14 with io.open("somevideo_extracted.png", "wb") as fo:
15     fo.write(io.open("somevideo.mkv:thumb.png", "rb").read())

そう、「ファイル名:~」という特殊記述がそのまま ADS に対するインターフェイスになっている。それだけ、それだけ。「後述」とした「難しいこと」というのはこれは、「ADS として何がくっついてんの?」を知る術。この問い合わせ方法に関しては python は完全に無知なままである。

ADS の列挙は、kernel32.dll の「FindFirstStreamFindNextStream」で行える。うーむ…。ポインタを使うやつなので、正直 C/C++ から使うのが一番楽で、はっきりいって python ctypes から扱うのはかなりダルい。誰かやってるよね、と探すと、pyads が見つかった。ただ、一番根底の C API を呼び出す部分はいいが、作りはよくない。必要部分だけコピーして使うか…:

pyADS からオイシイとこだけ拾ってくる…
 1 # -*- coding: utf-8 -*-
 2 def enum_streams(filename):
 3     #
 4     import ctypes
 5     kernel32 = ctypes.windll.kernel32
 6     LPSTR     = ctypes.c_wchar_p
 7     DWORD     = ctypes.c_ulong
 8     LONG      = ctypes.c_ulong
 9     WCHAR     = ctypes.c_wchar * 296
10     LONGLONG  = ctypes.c_longlong
11 
12     class LARGE_INTEGER_UNION(ctypes.Structure):
13         _fields_ = [
14             ("LowPart", DWORD),
15             ("HighPart", LONG),]
16 
17     class LARGE_INTEGER(ctypes.Union):
18         _fields_ = [
19             ("large1", LARGE_INTEGER_UNION),
20             ("large2", LARGE_INTEGER_UNION),
21             ("QuadPart", LONGLONG),
22         ]
23 
24     class WIN32_FIND_STREAM_DATA(ctypes.Structure):
25         _fields_ = [
26             ("StreamSize", LARGE_INTEGER),
27             ("cStreamName", WCHAR),
28         ]
29     #
30     wfsd = WIN32_FIND_STREAM_DATA()
31     findFirstStreamW = kernel32.FindFirstStreamW
32     findFirstStreamW.restype = ctypes.c_void_p
33     #https://msdn.microsoft.com/en-us/library/aa364424(v=vs.85).aspx
34     hnd = kernel32.FindFirstStreamW(LPSTR(filename), 0, ctypes.byref(wfsd), 0)
35     phnd = ctypes.c_void_p(hnd)
36     try:
37         if wfsd.cStreamName:
38             streamname = wfsd.cStreamName.split(":")[1]
39             if streamname:
40                 yield streamname
41             while kernel32.FindNextStreamW(phnd, ctypes.byref(wfsd)):
42                 yield wfsd.cStreamName.split(":")[1]
43     finally:
44         kernel32.FindClose(phnd)
45 
46 
47 if __name__ == '__main__':
48     import sys
49     for fn in sys.argv[-1:]:
50         print(fn, list(enum_streams(fn)))

先に挙げたストリームデータを書き込んだやつを処理してみれば、うまく列挙出来たことがわかる。

うん、まぁ「簡単、は簡単だね」。まではいいとして。問題はこの先。まず「この ADS について熟知してるのはどこのどいつだ?」てことがまず大問題。どうやら今でもエクスプローラはこれを黙殺してるらしい。見れるようにする手段は見つけることが出来てない。

一番手軽なのは「みんな大好き MS DOS」の内部コマンド dir で、「/R」を付けることで、ストリームデータの存在を知ることが出来る。あとは SysInternalSuite の streams コマンド。そしてもう一つは、.net …というか PowerShell の「Add-Content, Clear-Content, Get-Content, Get-Item, Remove-Item, Set-Content」が使える、とのこと。てわけで少なくとも2~3つの「Microsoft におけるメインストリーム」から使えるのだから、さぞかし「広まっててハッピー」なのかと言えば、無論「誰一人知らんのではねーのか、これについて」という現状から理解できる通りで、「そんな幸せなことにはなってない」。もちろんこれこそエクスプローラの罪だし、まぁいつもながら Microsoft は「自分発信のものを広めるのが下手」だよなぁと思う。

そして、まぁワタシ的にはまたしてもなんだけれど、「エクスプローラもどき」で ADS を可視化出来る能力を持ってるようなのがあれば、もしかしたら「ビデオには ADS の形でサムネイルイメージをくっつけておく管理を日常しとく」ことも現実解かもしれんなぁ、と、懲りずにAlternative File Managers for Windows 探し。例によって、めぼしいものは見つからない上に、前回調査時に却下してるヤツに延々再会し続ける、という苦行。

てなわけで、「ADS をつけはずしするのがいくら自由自在だからといって、システムが全然関与してくれないので、結果不自由」。うーんこれはもうどうしようもないね、現状。

現状、てことは、将来? なさそう、だよね。今ここまで黙殺されてて、ある日突然日の目を見る、なんてことは、きっとない。

あともう一つ注意しておきたいのが、これ、特にセキュリティの専門家からは非常に問題視されている。エンドユーザの目に触れない形で隠せるんだからね。こういうマルウェアの形は当然あって、アンチウィルス系のアプリケーションが監視する対象になっている。…という知識を聞きかじっている人は、たぶん「ADS」というキーワードを聞いただけで警戒するんではないか、と思う。

「セキュリティ」という観点だけから言うと、挙げた「enum_streams」だけは今回の検証の成果、ではあるね。特にワタシは普段 cmd.exe 組み込みの dir なんて使わないからね、使いたくもないし。それを python から実行できるようにしとくのは、多少発展性のある話題とは言える。


21:25追記:
すまん、二点。

一つ目は「手が滑った、ごめん」。"__main__" ブロック内の「for fn in sys.argv[-1:]:」は、当たり前だが「for fn in sys.argv[1:]:」のつもりだった。

二つ目はひょっとしたら書かれてなくて悶々とする人もいるかも。「つけた ADS データを取り除く」のに関しても:

ADSデータの操作に関して、とりわけ難しいことは、ひとつしかない。ひとつ、は後述
 1 import io
 2 
 3 # ビデオから一枚絵を抜き出す方法として ffmpeg を使う場合は、たとえば:
 4 #     $ ffmpeg -y -i somevideo.mkv -r 1/1 -t 1 thumb.png
 5 #     $ ffmpeg -y -ss 10 -i somevideo.mkv -r 1/1 -t 1 thumb.png
 6 # など。この thumb.png が、「somevideo.mkv」と完全に一体となってたらハッピー
 7 # なのでは、という発想、なわけよ。
 8 
 9 # ビデオ「somevideo.mkv」に、「thumb.png」を ADS データとしてぶち込む。
10 with io.open("somevideo.mkv:thumb.png", "wb") as fo:
11     fo.write(io.open("thumb.png", "rb").read())
12 
13 # ビデオ「somevideo.mkv」に含まれる「thumb.png」ADS データを取り出す。
14 with io.open("somevideo_extracted.png", "wb") as fo:
15     fo.write(io.open("somevideo.mkv:thumb.png", "rb").read())
16 
17 # 消すのだってごくごく通常運行。
18 import os
19 os.remove("somevideo.mkv:thumb.png")

翌日追記:
上で python からのアクセスだけ例にしているのを理由に「python が特別」だと思いこむ人はあんましいないとは思うけれど、「どっちだ?」と疑う人はいるだろう。ケースとしてはどっちも考えられるからね、「python が頑張ったのか、それとも OS が頑張ったのか」。さらっと答えは書いたつもりだった。「シームレスに統合」「知らずに恩恵」と。事実、MSYS コマンドでやっても:

 1 [me@host: tmp]$ ls -1
 2 test.txt
 3 test2.txt
 4 [me@host: tmp]$ cat test.txt
 5 this is text.
 6 [me@host: tmp]$ cat test2.txt
 7 This is text, too.
 8 [me@host: tmp]$ cat test2.txt > test.txt:test2.txt
 9 [me@host: tmp]$ py -3 mypyads.py *.txt  # mypyads.py は上で作った enum_streams。
10 test.txt ['test2.txt']
11 test2.txt []
12 [me@host: tmp]$ cat test.txt:test2.txt
13 This is text, too.
14 [me@host: tmp]$ cmd /c "dir /R *.txt"
15  ドライブ C のボリューム ラベルは Windows です
16  ボリューム シリアル番号は 7E0F-6218 です
17 
18  c:\Users\hit_o\AppData\Roaming\_wk のディレクトリ
19 
20 2021/04/08  12:06                15 test.txt
21                                  20 test.txt:test2.txt:$DATA
22 2021/04/08  11:53                20 test2.txt
23                2 個のファイル                  35 バイト
24                0 個のディレクトリ  785,972,240,384 バイトの空き領域

一応注意。MSYS bash から起動できる cmd.exe は Windows ネイティブの cmd.exe とは厳密にいえば違うものだが、ただのプロキシなので、振る舞いは 99.9% 同じ。

で、せっかくなので、powershell でも試してみようと思ったのだが、うーん…、こちら(つまり Microsoft ネイティブ側)は逆に「ADS 対応をキチント」行っていて、「ADS 専用のアクセス方法を取らないといけない」、python や MSYS コマンドと同じノリで「test.txt:test2.txt」ではアクセス出来ない。なんか妙な気分だ:

 1 Windows PowerShell
 2 Copyright (C) Microsoft Corporation. All rights reserved.
 3 
 4 新しいクロスプラットフォームの PowerShell をお試しください https://aka.ms/pscore6
 5 
 6 PS C:\Users\hhsprings\AppData\Roaming\_wk> Get-Item "test.txt" -Stream "test2.txt"
 7 
 8 
 9 PSPath        : Microsoft.PowerShell.Core\FileSystem::C:\Users\hhsprings\AppData\Roaming\_wk\test.txt:test2.txt
10 PSParentPath  : Microsoft.PowerShell.Core\FileSystem::C:\Users\hhsprings\AppData\Roaming\_wk
11 PSChildName   : test.txt:test2.txt
12 PSDrive       : C
13 PSProvider    : Microsoft.PowerShell.Core\FileSystem
14 PSIsContainer : False
15 FileName      : C:\Users\hhsprings\AppData\Roaming\_wk\test.txt
16 Stream        : test2.txt
17 Length        : 20
18 
19 
20 
21 PS C:\Users\hhsprings\AppData\Roaming\_wk> Get-Content "test.txt" -Stream "test2.txt"
22 This is text, too.
23 PS C:\Users\hhsprings\AppData\Roaming\_wk> Get-Content "test.txt:test2.txt"
24 Get-Content : ドライブが見つかりません。名前 'test.txt' のドライブが存在しません。
25 発生場所 行:1 文字:1
26 + Get-Content "test.txt:test2.txt"
27 + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
28     + CategoryInfo          : ObjectNotFound: (test.txt:String) [Get-Content], DriveNotFoundException
29     + FullyQualifiedErrorId : DriveNotFound,Microsoft.PowerShell.Commands.GetContentCommand

そして問題は「みんな大好き MS DOS」で、なんなんだこりゃ:

 1 Microsoft Windows [Version 10.0.19041.867]
 2 (c) 2020 Microsoft Corporation. All rights reserved.
 3 
 4 c:\Users\hhsprings\AppData\Roaming\_wk>dir /R test.txt
 5  ドライブ C のボリューム ラベルは Windows です
 6  ボリューム シリアル番号は 7E0F-6218 です
 7 
 8  c:\Users\hhsprings\AppData\Roaming\_wk のディレクトリ
 9 
10 2021/04/08  12:06                15 test.txt
11                                  20 test.txt:test2.txt:$DATA
12                1 個のファイル                  15 バイト
13                0 個のディレクトリ  785,653,202,944 バイトの空き領域
14 
15 c:\Users\hhsprings\AppData\Roaming\_wk>type test.txt
16 this is text.
17 
18 c:\Users\hhsprings\AppData\Roaming\_wk>type test.txt:test2.txt
19 ファイル名、ディレクトリ名、またはボリューム ラベルの構文が間違っています。
20 
21 c:\Users\hhsprings\AppData\Roaming\_wk>type test.txt:test2.txt:$DATA
22 ファイル名、ディレクトリ名、またはボリューム ラベルの構文が間違っています。
23 
24 c:\Users\hhsprings\AppData\Roaming\_wk>help type
25 テキスト ファイルまたはファイルの内容を表示します。
26 
27 TYPE  [ドライブ:][パス]ファイル名

やっぱ Microsoft は ADS を普及させようとしてないのかもしらんなぁ。というかいつも思うんだが、コア部分の開発チームとエクスプローラなどのシェル周縁部分の開発チームって、仲悪いの? エクスプローラが、頑なに Windows 核心部の進化を拒絶し続けてるような気が…。mountpoint、junction がその代表例。まぁマウントポイントについては、ユーザが知らないうちにこっそり活躍してはいるんだけど、それでもなお「いまだに c:、d:、なのね」と。この見せ方(ドライブレター)を「隠そう」とする動きが皆無なのが驚き。

みんな大好き MS DOS からの ADS アクセス手段について何かわかったらあとで追記する、かも。(予定は未定。)


2021-04-27追記:
今回のこのネタの本質っていくつかあって、当然見出しに選んだ「ADS」が一応主食ではあったんだけれど、「サムネイろーぜ」も本質ちゃぁ本質で。

で「H.265 相手にサムネイろーぜ/マトリョーシカ相手にサムネイろーぜ」が 265-nize の 2021-04-06 追記に書いた内容(LAV Filters と Icaros)なわけだけれど、そもそもの「サムネイル画像の自由度」のなさってのも、まぁ結構不愉快なことも多いわけでしょ。自動で選ばれた場所の画像が、ビデオ全体を一言で表現してしまう、なんてのは「奇跡」であって、ビデオによっては内容をまったく思い出せない一枚を選んでしまうこともあるよね、と。そこをちょっと突き詰めて考えようかと。

やってはみたんだけどさ。コメントに書いた通りで、「attached_pic」って、MP4 でしか使えない、のかな。WEBM ではそもそも「埋め込めない」、MKV の場合は「期待したのと違う埋め込み方」をしちゃって、そしてプレイヤーはそれを正しく解釈出来ない。duration がおかしくなるプレイヤーがあるのが一番最悪(MPC-HC や Windows Media Player)だが、それだけでなくて「デフォルトストリーム」の扱いがプレイヤーによってマチマチなのよね。先頭のストリームしか扱えない(切り替え出来ない)やつ、「default」属性を無視して先頭のストリームをデフォルトとして扱うやつ、「default」を正しく扱うやつ。

てわけで、「attached_pic で自由自在なサムネイれるぜ」は MP4 にしか通用せず、いまいち嬉しかない。なんせ普段は MKV が一番便利なんだからなぁ…。(MKV は名前が示す通り「マトリョーシカ」ってるのが便利で、ffmpeg を強制終了して出来た中途なビデオでも、プレイヤーがちゃんと処理出来るようになってる。)

なお、この add_embthumb_to_video.py はこれは「ffmpeg な例」を載せてるページなのでこれで終わらせてるけれど、そう、今あなたがみてるこのブログ記事の「ADS」ネタにも出来ることは出来る、ので、そうしようか、とも少し思った。無論、上で結論付けた通りで、「誰も相手にしてくれないので、やっても嬉しくない」、のでやらない。


2021-05-16(本題とは無関係の)追記:
ADSの件とは全然関係ないんだけれど、「Mark Russinovichが書いた記事」に関することで個人的に一番困ってたのが「junction.exe のソースコード」だったんだけれど、別件で遊んでるさなかに思わぬところで見つけた、「reparse point」の仕様。

どこにあったかって、py7zr のソース。抜粋:

py7zr/win32compat.py
29     class SymbolicLinkReparseBuffer(ctypes.Structure):
30         """Implementing the below in Python:
31 
32         typedef struct _REPARSE_DATA_BUFFER {
33             ULONG  ReparseTag;
34             USHORT ReparseDataLength;
35             USHORT Reserved;
36             union {
37                 struct {
38                     USHORT SubstituteNameOffset;
39                     USHORT SubstituteNameLength;
40                     USHORT PrintNameOffset;
41                     USHORT PrintNameLength;
42                     ULONG Flags;
43                     WCHAR PathBuffer[1];
44                 } SymbolicLinkReparseBuffer;
45                 struct {
46                     USHORT SubstituteNameOffset;
47                     USHORT SubstituteNameLength;
48                     USHORT PrintNameOffset;
49                     USHORT PrintNameLength;
50                     WCHAR PathBuffer[1];
51                 } MountPointReparseBuffer;
52                 struct {
53                     UCHAR  DataBuffer[1];
54                 } GenericReparseBuffer;
55             } DUMMYUNIONNAME;
56         } REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;
57         """
58         # ↑これは無論「C ではこうだ」というドキュメント。
59         # 以下で Python としての実現が続く…

そうなのよ、junction.exe のソースコードがやってることの実態はワタシは結構正確に憶えてて、「当時「struct なんちゃら」が公式に公開されてなかったので自分でそれを用意して、それを使って DeviceIoControl するだけよん」だけどさその「struct なんちゃら」がね、ソースコードなしにはもう見つからんよ、どうしたもんかなぁ、てことで苦しんでたわけ。それが py7zr に書かれてる。

junction を使う/使わせるにあたって「SysinternalsSuiteを入れろよ」だけが選択肢ってのがそもそも不健康なことなわけね。でも今後は py7zr そのものを使うんでもいいし、これに基づいて何か別のインターフェイスを用意するんでもいい。ただ前にも言ったけど「エクスプローラが識別出来ないし、Windows で動く Unix もどきのほとんどもこれを識別しないので」が理由の危険性については熟知の上で使うこと。(Windows 10 は一応視覚的には識別できるようになったので、少し日常使いに近づいた。)