ffchopreview.py

命名は慎重に…。

ちゃんとするつもりなら、もちっと安全な命名を考えた方がいいんだけど、チマっとした作業に一時的に使うインチキ道具なので、てけとー:

ffchopreview.py
 1 # -*- coding: utf-8 -*-
 2 from __future__ import unicode_literals
 3 
 4 import sys
 5 import subprocess
 6 import os
 7 
 8 
 9 if hasattr("", "decode"):
10     _encode = lambda s: s.encode(sys.getfilesystemencoding())
11 else:
12     _encode = lambda s: s
13 
14     
15 def parse_time(s):
16     """
17     >>> print("%.3f" % parse_time(3.2))
18     3.200
19     >>> print("%.3f" % parse_time(3))
20     3.000
21     >>> print("%.3f" % parse_time("00:00:01"))
22     1.000
23     >>> print("%.3f" % parse_time("00:00:01.3"))
24     1.300
25     >>> print("%.3f" % parse_time("00:00:01.34"))
26     1.340
27     >>> print("%.3f" % parse_time("00:00:01.034"))
28     1.034
29     >>> print("%.3f" % parse_time("00:00:01.345"))
30     1.345
31     >>> print("%.3f" % parse_time("00:01:01.345"))
32     61.345
33     >>> print("%.3f" % parse_time("02:01:01.345"))
34     7261.345
35     >>> print("%.3f" % parse_time("01:01.345"))
36     61.345
37     """
38     try:
39         return float(s)
40     except ValueError:
41         if "." in s:
42             n, _, ss = s.rpartition(".")
43         else:
44             n, ss = s, "0"
45         n = n.split(":")
46         if len(n) > 3:
47             raise ValueError("'{}' is not valid time.".format(s))
48         result = sum([
49             p * 60**(len(n) - 1 - i)
50             for i, p in enumerate(list(map(int, n)))])
51         result += int(ss) / float((10**len(ss)))
52         return result
53 
54 
55 if __name__ == '__main__':
56     import argparse
57     import tempfile
58     import atexit
59 
60     ap = argparse.ArgumentParser()
61     ap.add_argument("video")
62     ap.add_argument("--ss", default="0")
63     ap.add_argument("--to")
64     ap.add_argument("--t", default="1")
65     ap.add_argument("--ffmpeg", default="ffmpeg")
66     args = ap.parse_args()
67     _ss = parse_time(args.ss)
68     if args.to:
69         _t = parse_time(args.to) - _ss
70     else:
71         _t = parse_time(args.t)
72     #
73     tmpout = tempfile.mktemp(suffix=".mkv")
74     atexit.register(os.remove, tmpout)
75     #
76     cmdl = [
77         args.ffmpeg,
78         "-hide_banner",
79         "-ss", "{}".format(_ss),
80         "-i", args.video,
81         "-vf", "scale=640:480,setsar=1",
82         "-af", "volume=0.25",
83         "-ss", "0",
84         "-t", "{}".format(_t),
85         tmpout
86     ]
87     subprocess.check_call(map(_encode, cmdl))
88     #
89     cmdl = [
90         "ffplay",
91         "-hide_banner",
92         tmpout
93     ]
94     subprocess.check_call(map(_encode, cmdl))

要は ffmpeg で特定範囲で千切るって作業を頻繁にやる場合に、簡単にその範囲をプレビューする術が欲しいんだけれど、「-ss」の妙な仕様のせいで、都度やろうとするとかなり鬱陶しいのよね。

一時ファイルを介さず直接 ffplay に渡すようにもできるし、渡す追加フィルターの部分の柔軟性についてはいくらでも考えられる。まぁ使いたくて色々やりたい人は頑張ってみてくれ。あと出来上がったビデオのプレビューについて昨日のにしようかとも思ったんだけれど、今の場合は atexit との兼ね合いもあるしで ffplay でいいかなと。


2020-10-16:
「version 2」なんて言い方で追記続けてると「rev103」みたいなことにもなりかねないんだけども。要するにこういう需要って、「分割位置をみたいのぢゃぁ」てことなわけで、しかも複数箇所を一気にみたいことも多くて、こんな使い方が出来たらいいなぁと:

1 [me@host: ~]$ ffchopreview.py --as_r='["00:02:19.3", "00:02:20.8", "00:02:23"]' hoge.mkv

ちぅわけで:

ffchopreview.py
  1 #! py -3
  2 # -*- coding: utf-8 -*-
  3 from __future__ import unicode_literals
  4 
  5 import sys
  6 import subprocess
  7 import os
  8 
  9 
 10 if hasattr("", "decode"):
 11     _encode = lambda s: s.encode(sys.getfilesystemencoding())
 12 else:
 13     _encode = lambda s: s
 14 
 15     
 16 def parse_time(s):
 17     """
 18     >>> print("%.3f" % parse_time(3.2))
 19     3.200
 20     >>> print("%.3f" % parse_time(3))
 21     3.000
 22     >>> print("%.3f" % parse_time("00:00:01"))
 23     1.000
 24     >>> print("%.3f" % parse_time("00:00:01.3"))
 25     1.300
 26     >>> print("%.3f" % parse_time("00:00:01.34"))
 27     1.340
 28     >>> print("%.3f" % parse_time("00:00:01.034"))
 29     1.034
 30     >>> print("%.3f" % parse_time("00:00:01.345"))
 31     1.345
 32     >>> print("%.3f" % parse_time("00:01:01.345"))
 33     61.345
 34     >>> print("%.3f" % parse_time("02:01:01.345"))
 35     7261.345
 36     >>> print("%.3f" % parse_time("01:01.345"))
 37     61.345
 38     """
 39     try:
 40         return float(s)
 41     except ValueError:
 42         if "." in s:
 43             n, _, ss = s.rpartition(".")
 44         else:
 45             n, ss = s, "0"
 46         n = n.split(":")
 47         if len(n) > 3:
 48             raise ValueError("'{}' is not valid time.".format(s))
 49         result = sum([
 50             p * 60**(len(n) - 1 - i)
 51             for i, p in enumerate(list(map(int, n)))])
 52         result += int(ss) / float((10**len(ss)))
 53         return result
 54 
 55 
 56 def _ts_to_tss(ts, frac=3):
 57     d, _, f = (("%%.%df" % frac) % ts).partition(".")
 58     d = abs(int(d))
 59     ss_h = int(d / 3600)
 60     d -= ss_h * 3600
 61     ss_m = int(d / 60)
 62     d -= ss_m * 60
 63     ss_s = int(d)
 64     return "%s%02d:%02d:%02d.%s" % (
 65         "" if ts >= 0 else "-",
 66         ss_h, ss_m, ss_s, f)
 67 
 68 
 69 if __name__ == '__main__':
 70     import argparse
 71     import json
 72     import tempfile
 73     import atexit
 74 
 75     ap = argparse.ArgumentParser()
 76     ap.add_argument("video")
 77     ap.add_argument("--ss", default="0")
 78     ap.add_argument("--to")
 79     ap.add_argument("--t", default="1")
 80     ap.add_argument("--as_range")  # as json's list
 81     ap.add_argument("--ffmpeg", default="ffmpeg")
 82     args = ap.parse_args()
 83     def _yr():
 84         if args.as_range:
 85             invalid = True
 86             try:
 87                 _aslst = json.loads(args.as_range)
 88                 if isinstance(_aslst, (list,)) and len(_aslst) == 1:
 89                     _aslst = _aslst[0]
 90                 if isinstance(_aslst, (list,)):
 91                     _aslst = list(map(parse_time, _aslst))
 92                     invalid = False
 93                 elif isinstance(_aslst, (str, float, int)):
 94                     t = parse_time(_aslst)
 95                     if t > 1:
 96                         _aslst = [t - 1, t, t + 1]
 97                     else:
 98                         _aslst = [t, t + 1]
 99                     invalid = False
100             except (json.decoder.JSONDecodeError, ValueError):
101                 pass
102             if invalid:
103                 ap.error("invalid ranges:\n    {!r}".format(args.as_range))
104             for _ss, _to in zip(_aslst[:-1], _aslst[1:]):
105                 _t = _to - _ss
106                 yield _ss, _t
107         else:
108             _ss = parse_time(args.ss)
109             if args.to:
110                 _t = parse_time(args.to) - _ss
111             else:
112                 _t = parse_time(args.t)
113             yield _ss, _t
114     #
115     for _ss, _t in _yr():
116         tmpout = tempfile.mktemp(
117             suffix=".mkv",
118             prefix=" {} ~ {}   ".format(
119                 _ts_to_tss(_ss).replace(":", "_"),
120                 _ts_to_tss(_t).replace(":", "_")))
121         atexit.register(os.remove, tmpout)
122         #
123         cmdl = [
124             args.ffmpeg,
125             "-hide_banner",
126             "-ss", "{}".format(_ss),
127             "-i", args.video,
128             "-vf", "scale=640:480,setsar=1",
129             "-af", "volume=0.5",
130             "-ss", "0",
131             "-t", "{}".format(_t),
132             tmpout
133         ]
134         subprocess.check_call(map(_encode, cmdl))
135         #
136         cmdl = [
137             "ffplay",
138             "-hide_banner",
139             tmpout
140         ]
141         subprocess.check_call(map(_encode, cmdl))

「一時ファイルを介さず直接 ffplay に渡すようにもできるし、渡す追加フィルターの部分の柔軟性についてはいくらでも考えられる」云々のくだりに関することは変わらず。scaleとvolumeはある程度の「大きなお世話」が(「プレビュー」という目的の性質上)必要ではあるものの、この硬直っぷりはさすがにもう一声欲しいところではあるね。せめて固定オプションを設定ファイルからも読めるようにするとか、かなぁ、そうすりゃ少し使い勝手がいいかな、たぶん。


2020-10-17:
やっぱ gist のほうがえぇかのぉ? とまれ。成果がめっぽう少ないわりにえれー苦労しちゃったもんで。やぱーし Windows でな MSYS でばめっさダルわらし、ダルはだるざすーぱーはか。ごめん、ちと壊れてる。「ともあれ、これな」:

ffchopreview.py (version 3?)
  1 #! py -3
  2 # -*- coding: utf-8 -*-
  3 from __future__ import unicode_literals
  4 
  5 import io
  6 import sys
  7 import subprocess
  8 import os
  9 import re
 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 parse_time(s):
 19     """
 20     >>> print("%.3f" % parse_time(3.2))
 21     3.200
 22     >>> print("%.3f" % parse_time(3))
 23     3.000
 24     >>> print("%.3f" % parse_time("00:00:01"))
 25     1.000
 26     >>> print("%.3f" % parse_time("00:00:01.3"))
 27     1.300
 28     >>> print("%.3f" % parse_time("00:00:01.34"))
 29     1.340
 30     >>> print("%.3f" % parse_time("00:00:01.034"))
 31     1.034
 32     >>> print("%.3f" % parse_time("00:00:01.345"))
 33     1.345
 34     >>> print("%.3f" % parse_time("00:01:01.345"))
 35     61.345
 36     >>> print("%.3f" % parse_time("02:01:01.345"))
 37     7261.345
 38     >>> print("%.3f" % parse_time("01:01.345"))
 39     61.345
 40     """
 41     try:
 42         return float(s)
 43     except ValueError:
 44         if "." in s:
 45             n, _, ss = s.rpartition(".")
 46         else:
 47             n, ss = s, "0"
 48         n = n.split(":")
 49         if len(n) > 3:
 50             raise ValueError("'{}' is not valid time.".format(s))
 51         result = sum([
 52             p * 60**(len(n) - 1 - i)
 53             for i, p in enumerate(list(map(int, n)))])
 54         result += int(ss) / float((10**len(ss)))
 55         return result
 56 
 57 
 58 def _ts_to_tss(ts, frac=3):
 59     d, _, f = (("%%.%df" % frac) % ts).partition(".")
 60     d = abs(int(d))
 61     ss_h = int(d / 3600)
 62     d -= ss_h * 3600
 63     ss_m = int(d / 60)
 64     d -= ss_m * 60
 65     ss_s = int(d)
 66     return "%s%02d:%02d:%02d.%s" % (
 67         "" if ts >= 0 else "-",
 68         ss_h, ss_m, ss_s, f)
 69 
 70 
 71 if __name__ == '__main__':
 72     import argparse
 73     import json
 74     import tempfile
 75     import atexit
 76 
 77     ap = argparse.ArgumentParser()
 78     ap.add_argument("video")
 79     ap.add_argument("--ss", default="0")
 80     ap.add_argument("--to")
 81     ap.add_argument("--t", default="1")
 82     ap.add_argument("--as_range")  # as json's list
 83     ap.add_argument("--ffmpeg", default="ffmpeg")
 84     args = ap.parse_args()
 85 
 86     _mar = 1.5
 87     def _yr():
 88         if args.as_range:
 89             invalid = True
 90             try:
 91                 _aslst = json.loads(args.as_range)
 92                 if isinstance(_aslst, (list,)) and len(_aslst) == 1:
 93                     _aslst = _aslst[0]
 94                 if isinstance(_aslst, (list,)):
 95                     _aslst = list(map(parse_time, _aslst))
 96                     invalid = False
 97                 elif isinstance(_aslst, (str, float, int)):
 98                     t = parse_time(_aslst)
 99                     if t > _mar:
100                         _aslst = [t - _mar, t, t + _mar]
101                     else:
102                         _aslst = [t, t + _mar]
103                     invalid = False
104             except (json.decoder.JSONDecodeError, ValueError):
105                 pass
106             if invalid:
107                 ap.error("invalid ranges:\n    {!r}".format(args.as_range))
108             for _ss, _to in zip(_aslst[:-1], _aslst[1:]):
109                 _t = _to - _ss
110                 if _t <= 0:
111                     ap.error("invalid ranges:\n    {!r}".format(
112                         [_ts_to_tss(_ss), _ts_to_tss(_ss + _t)]))
113                 yield _ss, _t
114         else:
115             _ss = parse_time(args.ss)
116             if args.to:
117                 _t = parse_time(args.to) - _ss
118             else:
119                 _t = parse_time(args.t)
120             yield _ss, _t
121     #
122     for _ss, _t in _yr():
123         _ss1_s, _to1_s = _ts_to_tss(_ss), _ts_to_tss(_ss + _t)
124         _ss2_s, _to2_s = _ts_to_tss(0), _ts_to_tss(_t)
125         tmpoutb = tempfile.mktemp()
126         tmpout, tmpoutsrt = tmpoutb + ".mkv", (tmpoutb + ".srt").replace("\\", "/")
127         atexit.register(os.remove, tmpout)
128         atexit.register(os.remove, tmpoutsrt)
129         #
130         srttxt = """\
131 1
132 {} --> {}
133 {} ~ {}
134 
135 """.format(_ss2_s.replace(".", ","), _to2_s.replace(".", ","), _ss1_s, _to1_s)
136         with io.open(tmpoutsrt, "w") as fosrt:
137             fosrt.write(srttxt)
138         cmdl = [
139             args.ffmpeg,
140             "-hide_banner",
141             "-ss", "{}".format(_ss),
142             "-i", args.video,
143             "-filter_complex",
144             "[0:v]subtitles='{}',scale=640:-1,setsar=1[v];[0:a]volume=0.4[a]".format(
145                 tmpoutsrt.replace(":", "\\:")),
146             "-ss", "0",
147             "-t", "{}".format(_t),
148             "-map", "[v]", "-map", "[a]",
149             tmpout
150         ]
151         subprocess.check_call(map(_encode, cmdl))
152         #
153         cmdl = [
154             "ffplay",
155             "-hide_banner",
156             tmpout
157         ]
158         subprocess.check_call(map(_encode, cmdl))

「ちぎった範囲のプレビュー」なのであるからして、「どこよ?」が視覚的にすぐにわかればよかろうべ、てなノリであるのよ。そこはまぁよかろ。ffmpeg のエラー報告がさ、相変わらずバカでバカでバカなのでバカなのよなのだわよ。ファイル自体の存在証明とファイル内容の正当性証明の区別が簡単に付かんのよ、ええかげんにせい。(何言ってるのかわからんと思うけれど、この「苦労」では最低でも3つ以上の独立した問題と格闘した。どの3つかは言わない。コードから察してくれる手もあるし、自力てみればきっと何かわかると思う。)

昨日版でペンディングにしてた部分は変わってないよ。あんまりそこはまだワタシに需要ないんでな。

それより、「-vf」「-af」が複数フィルタのチェイン出来ないことに今更気づいた。確かに普段は -filter_complex ばかり使ってるけどね、今更だ…。


2020-10-17 (2):
いいかげん gist 日和なのかもなのかしらなのよ。ベティはこんなことしたくなるのよ:

1 [me@host: ~]$ ffchopreview.py --g='{"ss": "00:04:31", "to": "00:06:20", "step": 3}'
2    ...  (["00:04:31", "00:04:34"], ["00:04:34", "00:04:37"], ["00:04:37", "00:04:40"], ...)

脚本はゴミなのよ:

  1 #! py -3
  2 # -*- coding: utf-8 -*-
  3 from __future__ import unicode_literals
  4 
  5 import io
  6 import sys
  7 import subprocess
  8 import os
  9 import re
 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 parse_time(s):
 19     """
 20     >>> print("%.3f" % parse_time(3.2))
 21     3.200
 22     >>> print("%.3f" % parse_time(3))
 23     3.000
 24     >>> print("%.3f" % parse_time("00:00:01"))
 25     1.000
 26     >>> print("%.3f" % parse_time("00:00:01.3"))
 27     1.300
 28     >>> print("%.3f" % parse_time("00:00:01.34"))
 29     1.340
 30     >>> print("%.3f" % parse_time("00:00:01.034"))
 31     1.034
 32     >>> print("%.3f" % parse_time("00:00:01.345"))
 33     1.345
 34     >>> print("%.3f" % parse_time("00:01:01.345"))
 35     61.345
 36     >>> print("%.3f" % parse_time("02:01:01.345"))
 37     7261.345
 38     >>> print("%.3f" % parse_time("01:01.345"))
 39     61.345
 40     """
 41     try:
 42         return float(s)
 43     except ValueError:
 44         if "." in s:
 45             n, _, ss = s.rpartition(".")
 46         else:
 47             n, ss = s, "0"
 48         n = n.split(":")
 49         if len(n) > 3:
 50             raise ValueError("'{}' is not valid time.".format(s))
 51         result = sum([
 52             p * 60**(len(n) - 1 - i)
 53             for i, p in enumerate(list(map(int, n)))])
 54         result += int(ss) / float((10**len(ss)))
 55         return result
 56 
 57 
 58 def _ts_to_tss(ts, frac=3):
 59     d, _, f = (("%%.%df" % frac) % ts).partition(".")
 60     d = abs(int(d))
 61     ss_h = int(d / 3600)
 62     d -= ss_h * 3600
 63     ss_m = int(d / 60)
 64     d -= ss_m * 60
 65     ss_s = int(d)
 66     return "%s%02d:%02d:%02d.%s" % (
 67         "" if ts >= 0 else "-",
 68         ss_h, ss_m, ss_s, f)
 69 
 70 
 71 def _yr(ap, args):
 72     _mar = 1.5
 73     if args.gen_ranges:
 74         try:
 75             _sl = json.loads(args.gen_ranges)
 76             _times = 5
 77             _t = 2.0
 78             if isinstance(_sl, (dict,)):
 79                 _ss, _t = _sl.get("ss", 0.0), _sl.get("step", _t)
 80                 _ss = parse_time(_ss)
 81                 _to = _sl.get("to", _ss + _t * _times)
 82             elif not isinstance(_sl, (list,)) or not len(_sl):
 83                 raise ValueError("--gen_ranges: invalid")
 84             elif len(_sl) == 1:
 85                 _ss = parse_time(_sl[0])
 86                 _to = _ss + _t * _times
 87             elif len(_sl) == 2:
 88                 _ss, _to = _sl
 89             elif len(_sl) >= 2:
 90                 _ss, _to, _t = _sl[:3]
 91             _ss, _to = parse_time(_ss), parse_time(_to)
 92         except Exception:
 93             ap.error("cannot generate ranges:\n    {!r}".format(args.gen_ranges))
 94         if _ss >= _to or _t <= 0:
 95             ap.error("cannot generate ranges:\n    {!r}".format(args.gen_ranges))
 96         _t = float(_t)
 97         while _ss < _to:
 98             yield _ss, _t
 99             _ss += _t
100     elif args.as_range:
101         invalid = True
102         try:
103             _aslst = json.loads(args.as_range)
104             if isinstance(_aslst, (list,)) and len(_aslst) == 1:
105                 _aslst = _aslst[0]
106             if isinstance(_aslst, (list,)):
107                 _aslst = list(map(parse_time, _aslst))
108                 invalid = False
109             elif isinstance(_aslst, (str, float, int)):
110                 t = parse_time(_aslst)
111                 if t > _mar:
112                     _aslst = [t - _mar, t, t + _mar]
113                 else:
114                     _aslst = [t, t + _mar]
115                 invalid = False
116         except (json.decoder.JSONDecodeError, ValueError):
117             pass
118         if invalid:
119             ap.error("invalid ranges:\n    {!r}".format(args.as_range))
120         for _ss, _to in zip(_aslst[:-1], _aslst[1:]):
121             _t = _to - _ss
122             if _t <= 0:
123                 ap.error("invalid ranges:\n    {!r}".format(
124                     [_ts_to_tss(_ss), _ts_to_tss(_ss + _t)]))
125             yield _ss, _t
126     else:
127         _ss = parse_time(args.ss)
128         if args.to:
129             _t = parse_time(args.to) - _ss
130         else:
131             _t = parse_time(args.t)
132         yield _ss, _t
133 
134 
135 if __name__ == '__main__':
136     import argparse
137     import json
138     import tempfile
139     import atexit
140 
141     ap = argparse.ArgumentParser()
142     ap.add_argument("video")
143     ap.add_argument("--ss", default="0")
144     ap.add_argument("--to")
145     ap.add_argument("--t", default="1")
146     ap.add_argument("--as_range")  # as json's list
147     ap.add_argument("--gen_ranges")  # [start, end, step]
148     ap.add_argument("--ffmpeg", default="ffmpeg")
149     args = ap.parse_args()
150 
151     #
152     for _ss, _t in _yr(ap, args):
153         _ss1_s, _to1_s = _ts_to_tss(_ss), _ts_to_tss(_ss + _t)
154         _ss2_s, _to2_s = _ts_to_tss(0), _ts_to_tss(_t)
155         tmpoutb = tempfile.mktemp()
156         tmpout, tmpoutsrt = tmpoutb + ".mkv", (tmpoutb + ".srt").replace("\\", "/")
157         atexit.register(os.remove, tmpout)
158         atexit.register(os.remove, tmpoutsrt)
159         #
160         srttxt = """\
161 1
162 {} --> {}
163 {} <font color=0x7F7FFF3F>~</font> {}
164 
165 """.format(
166     _ss2_s.replace(".", ","),
167     _to2_s.replace(".", ","),
168     re.sub(r"([\d:]+\.)(\d+)", r"<font size=30>\1</font><font size=20>\2</font>", _ss1_s),
169     re.sub(r"([\d:]+\.)(\d+)", r"<font size=30>\1</font><font size=20>\2</font>", _to1_s))
170         with io.open(tmpoutsrt, "w") as fosrt:
171             fosrt.write(srttxt)
172         cmdl = [
173             args.ffmpeg,
174             "-hide_banner",
175             "-ss", "{}".format(_ss),
176             "-i", args.video,
177             "-filter_complex",
178             "[0:v]subtitles='{}',scale=640:-1,setsar=1[v];[0:a]volume=0.4[a]".format(
179                 tmpoutsrt.replace(":", "\\:")),
180             "-ss", "0",
181             "-t", "{}".format(_t),
182             "-map", "[v]", "-map", "[a]",
183             tmpout
184         ]
185         subprocess.check_call(map(_encode, cmdl))
186         #
187         cmdl = [
188             "ffplay",
189             "-hide_banner",
190             tmpout
191         ]
192         subprocess.check_call(map(_encode, cmdl))

なんか今回の拡張でいつもより多めに汚れたのかしら。

雑にいい加減に作ったもんだけど、想像してたよりずっと使いやすくて、お気に入りの道具になりそうだ。あまりにもこれを育てるようならやはり gist に持ってくだろう。そんときゃここに貼り付けると思われ。楽しまずに待っとけ。


2020-10-18:
一つ前からの変更点は些末だが、さすがに gist た:

にしても、一つ前の際に書こうか迷って書かなかったことなんだけれど、これさ、「二律背反的な指定オプション」問題にハマっちゃってるのよね。まぁわりかし良く起こることだとはいえ、「雑なプロジェクト」でないならもっと頭を痛くしてるとこだ。つまり「オプション A を使う場合は同時に B を使うことは論理的に無意味」みたいなものってさ、あまりよろしくないわけよ、わかりにくいし、場合によっては非常に使いにくいこともある。今の場合は「–ssと–to、–as_range、–gen_ranges」はこれらは同時に指定するのは意味がなく、プログラム的には単に優先順位をつけて参照してるだけ、つまりこの優先順位という「隠れ仕様」がね、製品品質のものなら「ドキュメントすべし」なものであるし、あるいは「そういうわかりにくいデザインをするなかれ」てことね。こやつ、結構自分で気に入っちゃったもんで、余計に気になっちゃってな。どうにかすべきならどうしてくれようか? わからん、いまのとこ。


2020-10-20:
やはりというかなんというか。排他的なオプションというのは、プログラムの保守にも悪影響がありえて、かなり鬱陶しくなってきて、さっき「–ss、–to、–t」を消した。100%完全互換とはいかないが、–as_range が 97.23975411165513% くらい互換になりうる、絶対にだ。

「ビデオから単一範囲を切り取る」のためだけの「–ss、–to、–t」だったわけだが、「ビデオから複数範囲を切る取る」のための「–as_range」、代替できないはずがないわけである。ただ、「終了時刻は知らん、duration だけ意識したいんぢゃぁ(あるいは to だけ意識したい)」の向きには「–as_range」は劣っていて、これが 2.760245888344869% ね。

いつも思うんだが、こういう UI 設計の「あるあるネタ」って、どっかにまとまったものがあったりしないだろうか? いわゆるソフトウェア構造設計のデザインパターン・アンチパターンみたいに、真面目にやればちゃんとした工学だと思うんで、ちゃんと書いたちゃんとした本とかサイト、ないかなぁ?


2020-10-19:
gist 内容は勝手に本体 gist に追従しちゃうんで、昨日追記の文章は辻褄合わなくなっておるが、まぁわかるよね、「変更は些末」、ではない、もはや。動かしてみればわかることだし、まだ誰が読んでも最長10分もあれば全貌理解できるはずの分量のスクリプトだとは思うんで、今日あえて「ほにゃららしましたー」って言うのも大げさではある。インチキ GUI を追加した、てことである。

「インチキ GUI を追加した」ことで、実は「ちゃんとした動画編集アプリ」の道筋が出来上がってしまっていることについては、まぁそのうち考える。今すぐにどうこうしようとは思ってない。

で。なにゆえブログの編集画面を開いて今追記してるかというと、「GUIりました」の報告よりは、そもそも「使い方の想定」について、なんにも説明してないなぁ、と思ったもんでね。ちょっとだけ。

ちゃんとした、というか、本格的な動画編集ソフトがあればこんなもんいらんわい、というノリでここまで言ってきたけれど、実はそうした重量級アプリケーションよりもワタシのコレの方が優れる局面が少なからずある。

たとえば、「機械的に区切り位置を見つけることが出来ない結構長いビデオについて、目視で区切り位置を探したい」とする。こうした作業を、一般的な動画編集ソフトの UI でやろうとすると、「再生しっぱなし、凝視し続けながら」か、「テキトーにシークバーをごにょごにょ動かしてどうにか探す」しかない。「機械的にはわからない」となれば人の目を使うしかないわけだが、このタイプの作業方法を取った場合の問題は、「集中力が続かない」ことだったりする。つまり(動画の切れ目が見つからないのと同時に)「作業の切れ目も見つからない」ということが起こるのだ。

ワタシのこのスクリプトは、「インチキかつ雑で単純(でおバカ)」であるがゆえに、「作業の切れ目」を作りやすい形で作業することが出来る:

1 [me@host: ~]$ ffchopreview.py --gen='["00:01:00", "00:23:49", 30]' myvideo.mp4
2    ... "00:01:00" から "00:23:49" までの間を 30 秒ずつ区切る ...

これであれば、「区切り位置探し」の作業を「30秒ずつ」区切って行うことが出来る。かなり些細なことなのだが、直面している問題(作業)の性質によっては、精神疲労の量がかなり違ってくる可能性がある。そもそも「30分ずっと凝視しっぱなし」は、作業開始してからよりも始める前の方が精神的ダメージが大きい、かもしれない。


2020-10-21:
なんの責任もないインチキプロジェクトだから許してね、って前置きから始まる異世界生活。

スマソ。オプションの命名もテキトーにやってたもんで、変更したくなった。「–gen」だの「–as_range」だので説明してるけど、「–from_splitpoints」「–from_slice」に変えた。あと gui がかなり自分的に満足できるものになれた。なんか作業のテンポが格段に上がるのよね。「ffchopreview.py起動→確認→気に食わない→ffchopreview.py起動しなおし→…」のサイクルだったのが、起動して動かしっぱなしに出来るようになったんで。ストレスがかなり減る。こいつぁいいや。

ま…、実際に動かしてみた人にしかこの感じってわからんと思うのよね。なんせ見た目が地味な上に「ダサ」くて、初見で使いやすいに違いないと「思うはずがない」見た目なのですもの。

あ、そうそう。言い忘れてたけど Python 2.x では正しく動作してないです。見かけ上あたかも 2.x 対応してそうだけどしてない。

一応このスクリプトはもう少しだけ拡張する。今 Rev34 時点で gui が「from_sliceし直し」にまで対応できてるけど、from_splitpoints の方もやりたいわけよね。前者が「固定幅での区切り」、後者が「任意幅での区切り」に対応するんで、どっちも個人的にないがしろにしたくないんで。

で、問題はその先。ちょっと上で言ったようにこれ、実際は「本格的な動画編集ソフト事始め」になってたりするんよね。すぐに出来ることとしては、またしてもMako利用して「色んなもの吐き出し機」にしちゃえる。ffmpeg を使うシェルスクリプトを生成するとか、あるいは何か別のツールのための設定ファイルを吐き出す道具に仕立て上げてみたりとか。あるいは「サムネイル抽出道具ぅ」なんか即効で作れたりもする。ただその場合もはや「chopreview」ではないんだよね。「tiny_videoedit」とかね、結構大げさな名前を付けてもバチがあたらんもんになる。どーすっかなぁ? 「軽量動画編集」ってどんだけ需要あるかね? (というかちゃんと探せば結構見つかると思うしねぇ?)


2020-10-21続き:
文章だけだと伝わらないと思うので、現状の Rev41 時点を使ってみてるの図:

当然ながらデスクトップを録画した録画ファイルから Youtube にアップロードするためのビデオにするための「必要部分切り取り」にこの ffchopreview.py 自身を使った。というかまさにこの作業のために欲しくなった道具なんだけどね。


2020-10-25:
文章だけだと伝わらないと思うので、現状の Rev77 時点を使ってみてるの図:

かなり雑なままガシガシ拡張したので、スクリプトはかなりムゴいことになってる、けど、まぁワタシ的日常使いモノとして、わりかしええもんかなと。無論なにも知識のない人がこれをみて「すげー」と思うようなシロモノではなくて、「なんだよ、こいつで直接動画編集できねーんでやんの」と言われる類のもんではありまするが。そういった「ちゃんとした完成品」もすぐに目指せるところにはいるけれど、これはこのままにしときたい。テンプレートエンジンを組み込んでコンソール出力をカスタマイズ出来るようにしたことで、少なくとも「ffmpeg を使うシェルスクリプトを生成出来るぜ」というものにはなってて、「ワタシは当面これだけでも存分にありがたい」ので。


2020-10-31:
上の動画で「input has no video stream」をやってみせてるけれど、Rev95 までのものはあんまし使い物にならんかった、すまん。映像をひとコマ取り出す、ってシンプルなタスクと違って、これは「音声の統計(など)処理のサマライズを可視化…、した映像をひとコマ取り出す」わけなので、つまりは「切り取る前」が結構大事なわけよ。ゼロ秒音声の統計可視化、ではなくて、「ある程度時間の音声を観測してそれを可視化した結果」てことね。

ちょっとスナップショットをとるのにややコストがかかるようにはなったけど、Rev96の改造で、ワタシが期待するものになった。何秒余計に観測するかは微妙なんだけど、showcqt のスクロールを基準に3秒くらいがいいかと思う。欲しい時刻の3秒前からの音声可視化を静止画にする、てことよ。一応 audio_visualize_duration_if_no_input_vstream でコントロール出来るようにしといたが、あまり大きくしちゃうとサムネイル取得のたんびに結構時間かかっちゃうのでほどほどに。とはいえ「ahistogram」で可視化したい場合は、多少大きめの方が便利かとは思う。

じっくり考えればわかるんだけれど、これ、「どの範囲の音声を可視化したか」によって変わるわけね、なので「trim前の全体を可視化してから切り取る」のと、この道具での切り取り部分の絵は違う。そりゃそうだよね、要は「履歴の可視化」みたいなもんだから。trim で切り取っちゃったらその「履歴がない」ことになるわけ。

なお、この機能は元は「ビデオストリームのない音声メディア(MP3とかWAV)」を想定して作り始めたものなんだけれど、実際映像+音声の普通のビデオで使っても大いに役に立つのよ。ワタシの「video_split_by_blank.py」が「いまいち上手に区切り位置を見つけられない」ってときに補助的に使いたいんであれば、audio_visualize_if_no_input_vstream を使って「音の切れ目」を探すのに使えなくはない。会話の切れ目とかね、たとえば。