diff --git a/aux.py b/aux.py index e111f5c..329847e 100644 --- a/aux.py +++ b/aux.py @@ -1,6 +1,8 @@ #! /bin/env python3 # encoding: utf-8 +import os +import pathlib import queue import subprocess import sys @@ -147,3 +149,17 @@ def parse_fps(raw): return float(num) / float(den) return float(raw) + +# -------------------------------------------------- + +def to_Path(raw_path): + ''' + Returns pathlib.Path pointing to raw_path, handling "~/" correctly. + + To be removed after python:3.5 move. + ''' + + if raw_path.startswith('~'): + raw_path = os.path.expanduser(raw_path) + + return pathlib.Path(raw_path) diff --git a/doc/command_assembler.png b/doc/command_assembler.png new file mode 100644 index 0000000..7da9b39 Binary files /dev/null and b/doc/command_assembler.png differ diff --git a/over-video.py b/over-video.py index 3f9868b..36de28f 100755 --- a/over-video.py +++ b/over-video.py @@ -10,7 +10,7 @@ import subprocess import tempfile import time -from aux import Command, parse_fps +from aux import Command, parse_fps, to_Path from version import _version # -------------------------------------------------- @@ -20,36 +20,87 @@ _print = over.core.textui.Output('over.video') # -------------------------------------------------- +# see doc/command_assembler.png command = over.core.types.ndict() command.identify = Command('ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', 'INFILE') command.normalize_prepass = Command('ffmpeg', '-i', 'INFILE', '-af', 'volumedetect', '-f', 'null', '/dev/null') -command.encode_theora = Command('ffmpeg', '-i', 'INFILE', '-codec:v', 'libtheora', '-qscale:v', 'VQ', 'VFILTER', '-codec:a', 'libvorbis', '-qscale:a', 'AQ', 'NORMALIZE', 'OUTFILE') -command.encode_x264 = Command('ffmpeg', '-i', 'INFILE', '-c:v', 'libx264', '-preset', 'slow', '-crf', 'VQ', '-profile:v', 'high', '-level', '4.2', 'VFILTER', '-codec:a', 'libvorbis', '-qscale:a', 'AQ', 'NORMALIZE', 'OUTFILE') -command.encode_wav = Command('ffmpeg', '-i', 'INFILE', '-codec:a', 'pcm_s16le', 'NORMALIZE', 'OUTFILE') -command.normalize = Command('-filter:a', 'VOLUME') -command.vfilter = Command('-filter:v', 'ARGS') +command.encode_generic = Command('ffmpeg', '-i', 'INFILE', 'VIDEO', 'AUDIO', 'OUTFILE') +command.sub_vorbis = Command('-codec:a', 'libvorbis', '-qscale:a', 'QUALITY', 'NORMALIZE') +command.sub_pcm = Command('-codec:a', 'pcm_s16le', 'NORMALIZE') +command.sub_theora = Command('-codec:v', 'libtheora', '-qscale:v', 'QUALITY', 'VFILTER') +command.sub_x264 = Command('-codec:v', 'libx264', '-preset', 'slow', '-crf', 'QUALITY', '-profile:v', 'high', '-level', '4.2', 'VFILTER') +command.sub_normalize = Command('-filter:a', 'VOLUME') +command.sub_vfilter = Command('-filter:v', 'ARGS') # -------------------------------------------------- if __name__ == '__main__': - main = over.core.app.Main('Over-Video', "%d (%s)" %_version, 'AWARE-Overwatch Joint Software License', '~/.over/video.cfg') - main.add_option('profile', 'str', 'x264', 'Encoding profile to use. Available are either §mtheora§/ for Theora and Vorbis in an Ogg container, §mx264§/ for H.264 and Vorbis in a Matroska container, or §mwav§/ which just extracts audio into a wav for further processing.') - main.add_option('video-quality', 'float', 22, 'Video encoding quality. Use 0-10 for Theora (0 being the lowest, 5-7 is generally watchable) and 0-51 for x264 (0 being lossless, 18-28 is reasonable).', short_name='v') - main.add_option('audio-quality', 'float', 2, 'Audio encoding quality with -1 being the worst and 10 being the best.', short_name='a') - main.add_option('normalize', 'bool', True, 'Normalize the audio track.', short_name='N') - main.add_option('normalize-target', 'float', -20.0, 'Target mean volume to target.', short_name='n') - main.add_option('ffmpeg-vfilter', 'str', "", 'Raw ffmpeg -filter:v options, e.g. "scale=1280:-1"', short_name='f') + main = over.core.app.Main('Over-Video', '%d (%s)' %_version, 'AWARE-Overwatch Joint Software License', '~/.over/video.cfg') + main.add_option('audio', 'str', 'vorbis', 'Audio codec to use, either §mvorbis§/, §mpcm§/, §mcopy§/ or §mdrop§/.', short_name='a') + main.add_option('audio-quality', 'float', 2, 'Audio encoding quality with -1 being the worst and 10 being the best.', short_name='q') + main.add_option('video', 'str', 'x264', 'Video codec to use, either §mx264§/, §mtheora§/, §mcopy§/ or §mdrop§/.', short_name='v') + main.add_option('video-quality', 'float', 22, 'Video encoding quality. Use 0-10 for Theora (0 being the lowest, 5-7 is generally watchable) and 0-51 for x264 (0 being lossless, 18-28 is reasonable).', short_name='Q') + main.add_option('normalize', 'bool', True, 'Normalize the audio track.', short_name='n') + main.add_option('normalize-target', 'float', -20.0, 'Target mean volume to target.', short_name='N') + main.add_option('ffmpeg-vfilter', 'str', '', 'Raw ffmpeg -filter:v options, e.g. "scale=1280:-1"', short_name='f') main.add_option('move-source', 'str', '', 'Move source file to this directory after conversion.', short_name='m') - main.add_option('dump-commands', 'bool', False, 'Print ffmpeg commands that would be executed. Implies §B--no-§garmed§/. If §B--§gnormalize§/ is in effect, the normalization pre-pass will still be performed so that the proper volume correction can be computed.', short_name='D') + main.add_option('dump-commands', 'bool', False, 'Print ffmpeg commands that would be executed. If §B--§gnormalize§/ is in effect, the normalization pre-pass will still be performed so that the proper volume correction can be computed.', short_name='D') main.add_option('armed', 'bool', False, 'Perform the suggested action.', short_name='A', use_cfg_file=False) main.enable_help('h') main.add_help('Description', ['Over-Video is a simple video converter.']) main.parse() - _print('using profile §b%s§/: video_quality=%.1f, audio_quality=%.1f, normalize=%s' %(main.cfg.profile, main.cfg.video_quality, main.cfg.audio_quality, '%.1f dB' %(main.cfg.normalize_target) if main.cfg.normalize else 'None')) +# -------------------------------------------------- +# cfg checks + + files = over.core.types.ndict() + audio_words = [] + video_words = [] + files.container = 'mkv' + + if main.cfg.audio in ('copy', 'drop'): + audio_words.append('§c%s§/' %(main.cfg.audio)) + else: + audio_words.append('§gcodec§/=§m%s§/' %(main.cfg.audio)) + + if main.cfg.audio == 'vorbis': + audio_words.append('§gquality§/=§m%.1f§/' %(main.cfg.audio_quality)) + + if main.cfg.normalize: + audio_words.append('§gnormalize§/=§m%.1f dB§/' %(main.cfg.normalize_target)) + + if main.cfg.video in ('copy', 'drop'): + video_words.append('§c%s§/' %(main.cfg.video)) + + else: + video_words.append('§gcodec§/=§m%s§/' %(main.cfg.video)) + + video_words.append('§gquality§/=§m%.1f§/' %(main.cfg.video_quality)) + + if main.cfg.ffmpeg_vfilter: + video_words.append('§gvfilter§/=§m%s§/' %(main.cfg.ffmpeg_vfilter)) + + if main.cfg.video == 'drop': + if main.cfg.audio == 'pcm': + files.container = 'wav' + elif main.cfg.audio == 'vorbis': + files.container = 'ogg' + + _print('input parameters:', prefix.start) + _print('audio: %s' %(', '.join(audio_words))) + _print('video: %s' %(', '.join(video_words))) + _print('container: §gtype§/=§m%s§/' %(files.container)) + + if main.cfg.audio not in ('drop', 'copy', 'pcm', 'vorbis'): + _print('unknown audio codec', prefix.fail) + raise RuntimeError + + if main.cfg.video not in ('drop', 'copy', 'theora', 'x264'): + _print('unknown video codec', prefix.fail) + raise RuntimeError if not main.targets: - _print("no files specified", prefix.warn) + _print('no files specified', prefix.warn) for tgt in main.targets: print() @@ -60,6 +111,9 @@ if __name__ == '__main__': _print('processing §B%s§/' %(tgt), prefix.start) + # -------------------------------------------------- + # identify the input file + command.identify.reset() command.identify.INFILE = tgt command.identify.run() @@ -67,40 +121,51 @@ if __name__ == '__main__': identify_dict = json.loads(identify_raw.decode('utf-8')) info = over.core.types.ndict() - info.duration = float(identify_dict['format']['duration']) - video_streams = [s for s in identify_dict['streams'] if s['codec_type'] == 'video'] - audio_streams = [s for s in identify_dict['streams'] if s['codec_type'] == 'audio'] + try: + info.duration = float(identify_dict['format']['duration']) + + video_streams = [s for s in identify_dict['streams'] if s['codec_type'] == 'video'] + audio_streams = [s for s in identify_dict['streams'] if s['codec_type'] == 'audio'] + + amount_vs = len(video_streams) + amount_as = len(audio_streams) + + if amount_vs != 1: + _print('detected §r%d§/ video streams' %(amount_vs), prefix.fail) + main.exit(1) + + if amount_as != 1: + _print('detected §r%d§/ audio streams' %(amount_as), prefix.fail) + main.exit(1) + + video = video_streams[0] + audio = audio_streams[0] + + info.video_codec = video['codec_name'] + info.video_size_x = video['width'] + info.video_size_y = video['height'] + info.video_fps = over.core.textui.Unit(parse_fps(video['r_frame_rate']), 'Hz') + info.video_bitrate = over.core.textui.Unit(video['bit_rate'], 'b/s') if 'bit_rate' in video else '§r??§/' + + info.audio_codec = audio['codec_name'] + info.audio_channels = audio['channels'] + info.audio_samplerate = over.core.textui.Unit(audio['sample_rate'], 'Hz') + info.audio_language = audio['tags']['language'] if 'tags' in audio and 'language' in audio['tags'] else 'und' + info.audio_bitrate = over.core.textui.Unit(audio['bit_rate'], 'b/s') if 'bit_rate' in audio else '§r??§/' - amount_vs = len(video_streams) - amount_as = len(audio_streams) - - if amount_vs != 1: - _print('detected §r%d§/ video streams' %(amount_vs), prefix.fail) - main.exit(1) - - if amount_as != 1: - _print('detected §r%d§/ audio streams' %(amount_as), prefix.fail) - main.exit(1) - - video = video_streams[0] - audio = audio_streams[0] - - info.video_codec = video['codec_name'] - info.video_size_x = video['width'] - info.video_size_y = video['height'] - info.video_fps = over.core.textui.Unit(parse_fps(video['r_frame_rate']), 'Hz') - info.video_bitrate = over.core.textui.Unit(video['bit_rate'], 'b/s') if 'bit_rate' in video else "§r??§/" - - info.audio_codec = audio['codec_name'] - info.audio_channels = audio['channels'] - info.audio_samplerate = over.core.textui.Unit(audio['sample_rate'], 'Hz') - info.audio_language = audio['tags']['language'] if 'tags' in audio and 'language' in audio['tags'] else 'und' - info.audio_bitrate = over.core.textui.Unit(audio['bit_rate'], 'b/s') if 'bit_rate' in audio else "§r??§/" + except KeyError: + _print('KeyError while reading identify_dict, dump follows', prefix.fail) + print(identify_dict) + raise + # TODO wordify :-) _print('§mvideo§/: size=§b%d§/x§b%d§/ px, framerate=%s, codec=%s, bitrate=%s' %(info.video_size_x, info.video_size_y, info.video_fps, info.video_codec, info.video_bitrate)) _print('§caudio§/: channels=§b%d§/, samplerate=%s, codec=%s, bitrate=%s, language=%s' %(info.audio_channels, info.audio_samplerate, info.audio_codec, info.audio_bitrate, info.audio_language)) + # -------------------------------------------------- + # normalization pre-pass + if (main.cfg.armed or main.cfg.dump_commands) and main.cfg.normalize: _print('running normalization pre-pass') @@ -138,42 +203,71 @@ if __name__ == '__main__': raise RuntimeError pb.blank() - _print('detected volume %.2f dB, correction %.2f dB' %(info.mean_volume, info.volume_correction)) + _print('detected volume %.1f dB, correction %.1f dB' %(info.mean_volume, info.volume_correction)) + + info.normalize_command = command.sub_normalize + info.normalize_command.reset() + info.normalize_command.VOLUME = 'volume=%.2fdB' %(info.volume_correction) - # select encoding command - if main.cfg.profile == 'theora': - encode_cmd = command.encode_theora - info.tmp_file = tempfile.mktemp(suffix='.ogv', dir='.') - elif main.cfg.profile == 'x264': - encode_cmd = command.encode_x264 - info.tmp_file = tempfile.mktemp(suffix='.mkv', dir='.') - elif main.cfg.profile == 'wav': - encode_cmd = command.encode_wav - info.tmp_file = tempfile.mktemp(suffix='.wav', dir='.') else: - _print('§runknown profile selected§/: §r%s§/' %(main.cfg.profile), prefix.fail) - raise RuntimeError + info.normalize_command = None - # populate its arguments + # -------------------------------------------------- + # main command assembly + + files.infile = to_Path(tgt) + files.tmpfile = to_Path(tempfile.mktemp(suffix='.' + files.container, dir='.')) + files.outfile = files.infile.parent / (str(files.infile.stem) + '.' + files.container) + files.move_infile_to = to_Path(main.cfg.move_source) / files.infile.name if main.cfg.move_source else None + + encode_cmd = command.encode_generic encode_cmd.reset() - encode_cmd.INFILE = tgt - encode_cmd.OUTFILE = info.tmp_file - encode_cmd.AQ = main.cfg.audio_quality - encode_cmd.VQ = main.cfg.video_quality - if (main.cfg.armed or main.cfg.dump_commands) and main.cfg.normalize: - command.normalize.reset() - command.normalize.VOLUME = 'volume=%.2fdB' %(info.volume_correction) - encode_cmd.NORMALIZE = command.normalize - else: - encode_cmd.NORMALIZE = None + encode_cmd.INFILE = files.infile + encode_cmd.OUTFILE = files.tmpfile + + if main.cfg.audio == 'copy': + encode_cmd.AUDIO = None + elif main.cfg.audio == 'drop': + encode_cmd.AUDIO = '-an' + elif main.cfg.audio == 'pcm': + command.sub_pcm.reset() + command.sub_pcm.NORMALIZE = info.normalize_command + + encode_cmd.AUDIO = command.sub_pcm + elif main.cfg.audio == 'vorbis': + command.sub_vorbis.reset() + command.sub_vorbis.QUALITY = main.cfg.audio_quality + command.sub_vorbis.NORMALIZE = info.normalize_command + + encode_cmd.AUDIO = command.sub_vorbis if main.cfg.ffmpeg_vfilter: - command.vfilter.reset() - command.vfilter.ARGS = main.cfg.ffmpeg_vfilter.split() - encode_cmd.VFILTER = command.vfilter + info.vfilter_command = command.sub_vfilter + info.vfilter_command.reset() + info.vfilter_command.ARGS = main.cfg.ffmpeg_vfilter else: - encode_cmd.VFILTER = None + info.vfilter_command = None + + if main.cfg.video == 'copy': + encode_cmd.VIDEO = None + elif main.cfg.video == 'drop': + encode_cmd.VIDEO = '-vn' + elif main.cfg.video == 'theora': + command.sub_theora.reset() + command.sub_theora.QUALITY = main.cfg.video_quality + command.sub_theora.VFILTER = info.vfilter_command + + encode_cmd.VIDEO = command.sub_theora + elif main.cfg.video == 'x264': + command.sub_x264.reset() + command.sub_x264.QUALITY = main.cfg.video_quality + command.sub_x264.VFILTER = info.vfilter_command + + encode_cmd.VIDEO = command.sub_x264 + + # -------------------------------------------------- + # run the command if armed if main.cfg.dump_commands: _print('will execute §B%s§/' %(' '.join(encode_cmd.dump())), prefix.start) @@ -196,30 +290,30 @@ if __name__ == '__main__': elif out is None: break - original_filesize = over.core.textui.Unit(pathlib.Path(tgt).stat().st_size, 'B') - new_filesize = over.core.textui.Unit(pathlib.Path(info.tmp_file).stat().st_size, 'B') + original_filesize = over.core.textui.Unit(files.infile.stat().st_size, 'B') + new_filesize = over.core.textui.Unit(files.tmpfile.stat().st_size, 'B') pb.blank() _print('encoding finished: %s -> %s' %(original_filesize, new_filesize), prefix.done) - new_filename = pathlib.Path(tgt).stem + pathlib.Path(info.tmp_file).suffix + # -------------------------------------------------- + # shuffle files around - path_tgt_dir = pathlib.Path(main.cfg.move_source) - path_tgt = pathlib.Path(tgt) - path_tgt_new = path_tgt_dir / path_tgt + if main.cfg.move_source and main.cfg.armed: + move_to_dir = pathlib.Path(main.cfg.move_source) + + if not move_to_dir.is_dir(): + move_to_dir.mkdir() - if path_tgt != path_tgt_new: + if files.move_infile_to: if main.cfg.armed: - _print("moving §B%s§/ -> §B%s§/" %(path_tgt, path_tgt_new), prefix.start) - path_tgt.rename(path_tgt_new) - elif main.cfg.dump_commands: - _print('will execute §Bmv "%s" "%s"§/' %(path_tgt, path_tgt_new)) - - old_name = pathlib.Path(info.tmp_file) - new_name = pathlib.Path(new_filename) + _print('moving §B%s§/ -> §B%s§/' %(files.infile, files.move_infile_to), prefix.start) + files.infile.rename(files.move_infile_to) + else: + _print('will move §B%s§/ -> §B%s§/' %(files.infile, files.move_infile_to)) if main.cfg.armed: - _print("moving §B%s§/ -> §B%s§/" %(old_name, new_name), prefix.start) - old_name.rename(new_name) - elif main.cfg.dump_commands: - _print('will execute §Bmv "%s" "%s"§/' %(old_name, new_name)) + _print('moving §B%s§/ -> §B%s§/' %(files.tmpfile, files.outfile), prefix.start) + files.tmpfile.rename(files.outfile) + else: + _print('will move §B%s§/ -> §B%s§/' %(files.tmpfile, files.outfile))