353 lines
13 KiB
Python
Executable file
353 lines
13 KiB
Python
Executable file
#! /bin/env python3
|
|
# encoding: utf-8
|
|
|
|
import json
|
|
import os
|
|
import over
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
from aux import Command, parse_fps, to_Path
|
|
from version import _version
|
|
|
|
# --------------------------------------------------
|
|
|
|
prefix = over.core.textui.prefix
|
|
_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_generic = Command('ffmpeg', '-i', 'INFILE', 'VIDEO', 'AUDIO', '-sn', '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')
|
|
command.sub_copy_audio = Command('-codec:a', 'copy')
|
|
command.sub_copy_video = Command('-codec:v', 'copy')
|
|
|
|
# --------------------------------------------------
|
|
|
|
if __name__ == '__main__':
|
|
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.')
|
|
main.add_option('normalize-override', 'float', 0.0, 'Volume correction to use instead of computing the required value in a (lengthy) pre-pass.', short_name='N')
|
|
main.add_option('ffmpeg-vfilter', 'str', '', 'Raw ffmpeg -filter:v options, e.g. "scale=1280:trunc(ow/a/2)*2"', 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. 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()
|
|
|
|
# --------------------------------------------------
|
|
# 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_override != 0:
|
|
audio_words.append('§gadjust_volume§/=§m%.1f dB§/' %(main.cfg.normalize_override))
|
|
elif 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('settings', prefix.start, suffix=':\n')
|
|
_print('audio: %s' %(', '.join(audio_words)))
|
|
_print('video: %s' %(', '.join(video_words)))
|
|
_print('container: §gtype§/=§m%s§/' %(files.container))
|
|
|
|
if main.cfg.move_source:
|
|
_print('move source files to §B%s§//' %(main.cfg.move_source))
|
|
|
|
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)
|
|
|
|
for tgt in main.targets:
|
|
print()
|
|
|
|
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
|
|
|
|
if not os.path.exists(tgt) or os.path.isdir(tgt):
|
|
_print('target §y%s§/ §ris not a readable file§/, skipping' %(tgt), prefix.fail)
|
|
continue
|
|
|
|
_print('processing §B%s§/' %(tgt), prefix.start)
|
|
|
|
# --------------------------------------------------
|
|
# identify the input file
|
|
|
|
command.identify.reset()
|
|
command.identify.INFILE = 'file:' + str(files.infile)
|
|
command.identify.run()
|
|
identify_raw = command.identify.get_all_output()
|
|
identify_dict = json.loads(identify_raw.decode('utf-8'))
|
|
|
|
info = over.core.types.ndict()
|
|
|
|
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)
|
|
print(video_streams)
|
|
main.exit(1)
|
|
|
|
if amount_as > 1:
|
|
_print('detected §y%d§/ audio streams, picking the "best" one (see man 1 ffmpeg, section STREAM SELECTION)' %(amount_as), prefix.warn)
|
|
|
|
video = video_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??§/'
|
|
|
|
if audio_streams:
|
|
audio = audio_streams[0]
|
|
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:
|
|
_print('exception 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))
|
|
|
|
if audio_streams:
|
|
_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))
|
|
else:
|
|
_print('§caudio§/: §yNone§/', prefix.warn)
|
|
|
|
# --------------------------------------------------
|
|
# normalization pre-pass
|
|
|
|
if (main.cfg.armed or main.cfg.dump_commands) and main.cfg.normalize and (not main.cfg.audio == 'drop'):
|
|
if main.cfg.normalize_override == 0.0:
|
|
_print('running normalization pre-pass')
|
|
|
|
command.normalize_prepass.reset()
|
|
command.normalize_prepass.INFILE = 'file:' + str(files.infile)
|
|
command.normalize_prepass.run(stderr=True)
|
|
|
|
pb = over.core.textui.ProgressBar(60, int(info.video_fps.value * info.duration), 'frames')
|
|
|
|
output_buffer = []
|
|
|
|
while True:
|
|
time.sleep(.25)
|
|
|
|
out = command.normalize_prepass.get_output()
|
|
|
|
if out:
|
|
output_buffer.append(out)
|
|
|
|
if b'frame=' in out:
|
|
frame_id = re.findall(b'frame= *(\d+) ', out)[0]
|
|
pb.update(int(frame_id))
|
|
|
|
elif out is None:
|
|
break
|
|
|
|
output = b''.join(output_buffer)
|
|
|
|
if b'mean_volume: ' in output:
|
|
info.mean_volume = float(re.findall(b'mean_volume: (-\d+\.\d+) dB', output)[0])
|
|
info.volume_correction = main.cfg.normalize_target - info.mean_volume
|
|
else:
|
|
_print('§runexpected ffmpeg output§/, dump follows', prefix.fail, suffix=':\n')
|
|
print(output)
|
|
raise RuntimeError
|
|
|
|
pb.blank()
|
|
_print('detected volume %.1f dB, correction %.1f dB' %(info.mean_volume, info.volume_correction))
|
|
|
|
else:
|
|
info.volume_correction = main.cfg.normalize_override
|
|
_print('using user-supplied volume correction %.1f dB' %(info.volume_correction))
|
|
|
|
info.normalize_command = command.sub_normalize
|
|
info.normalize_command.reset()
|
|
info.normalize_command.VOLUME = 'volume=%.2fdB' %(info.volume_correction)
|
|
|
|
else:
|
|
info.normalize_command = None
|
|
|
|
# --------------------------------------------------
|
|
# main command assembly
|
|
|
|
encode_cmd = command.encode_generic
|
|
encode_cmd.reset()
|
|
|
|
encode_cmd.INFILE = 'file:' + str(files.infile)
|
|
encode_cmd.OUTFILE = files.tmpfile
|
|
|
|
if main.cfg.audio == 'copy':
|
|
encode_cmd.AUDIO = command.sub_copy_audio
|
|
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:
|
|
info.vfilter_command = command.sub_vfilter
|
|
info.vfilter_command.reset()
|
|
info.vfilter_command.ARGS = main.cfg.ffmpeg_vfilter
|
|
else:
|
|
info.vfilter_command = None
|
|
|
|
if main.cfg.video == 'copy':
|
|
encode_cmd.VIDEO = command.sub_copy_video
|
|
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 or main.cfg.armed:
|
|
cmd = ' '.join(encode_cmd.dump(pretty=True))
|
|
|
|
if main.cfg.armed:
|
|
_print('executing §B%s§/' %(cmd), prefix.start)
|
|
else:
|
|
_print('will execute §B%s§/' %(cmd))
|
|
else:
|
|
_print('will encode into §B%s§/' %(files.tmpfile))
|
|
|
|
if main.cfg.armed:
|
|
pb = over.core.textui.ProgressBar(60, int(info.video_fps.value * info.duration), 'frames')
|
|
|
|
encode_cmd.run(stderr=True)
|
|
|
|
while True:
|
|
time.sleep(.25)
|
|
|
|
out = encode_cmd.get_output()
|
|
|
|
if out:
|
|
if b'frame=' in out:
|
|
frame_id = re.findall(b'frame= *(\d+) ', out)[0]
|
|
pb.update(int(frame_id))
|
|
|
|
elif out is None:
|
|
break
|
|
|
|
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()
|
|
|
|
if encode_cmd.returncode == 0:
|
|
_print('encoding finished: %s -> %s' %(original_filesize, new_filesize), prefix.done)
|
|
else:
|
|
_print('§rencoding failed§/, ffmpeg returned §y%d§/' %(encode_cmd.returncode), prefix.fail)
|
|
raise RuntimeError
|
|
|
|
# --------------------------------------------------
|
|
# shuffle files around
|
|
|
|
if main.cfg.move_source:
|
|
move_to_dir = pathlib.Path(main.cfg.move_source)
|
|
|
|
if not move_to_dir.is_dir():
|
|
if main.cfg.armed:
|
|
_print('creating directory §B%s§/' %(move_to_dir), prefix.start)
|
|
move_to_dir.mkdir()
|
|
else:
|
|
_print('will create directory §B%s§/' %(move_to_dir))
|
|
|
|
if files.move_infile_to:
|
|
if main.cfg.armed:
|
|
_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§/' %(files.tmpfile, files.outfile), prefix.start)
|
|
files.tmpfile.rename(files.outfile)
|
|
else:
|
|
_print('will move §B%s§/ -> §B%s§/' %(files.tmpfile, files.outfile))
|