Last active
April 30, 2024 13:15
-
-
Save fsLeg/5f98dcbac159d319ad57497bf6c30b65 to your computer and use it in GitHub Desktop.
A script to automate video conversion for a few devices: Cowon A3, Cowon X7, PSP, Nokia 5730 XM, New Nintendo 3DS. I started writing this script around 2010. This is the 3rd revision, a complete rewrite.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
# -*- coding: utf-8 -*- | |
from sys import exit | |
from subprocess import run, Popen, PIPE | |
from os import remove | |
from os.path import isfile, isdir, abspath | |
from shutil import which | |
FFMPEG=which("ffmpeg") | |
FFPROBE=which("ffprobe") | |
FDKAAC=which("fdkaac") | |
MENCODER=which("mencoder") | |
MP4BOX=which("MP4Box") | |
KID3=which("kid3-cli") | |
def doc(): | |
print(''' | |
Скрипт представляет собой обертку для видео- и аудиокодировщиков и предназначен для автоматической конвертации видеофайлов для различных устройств. Работает скрипт как с одиночными файлами, так и со списком видеофайлов. | |
Использование: | |
python3 autoconv.py --file FILE --output OUTPUT --profile pmp|x7|psp|symbian|3ds-v|3ds-a [--sub SUBTITLES] [--mode MODE] | |
Опции: | |
-f, --file\tФайл для конвертации. Это может быть видеофайл или их список. Формат списка - ниже. | |
-o, --output\tИмя выходной папки. | |
-s, --subtitles\tФайл субтитров для вшития в видеопоток. Не работает со списком и некоторыми профилями. Необязательный параметр. Для вшития субтитров из самого контейнера используйте аргумент self. | |
-p, --profile\tИмя профиля. О профилях - ниже. | |
-m, --mode\tПринимает значения: file (видеофайл), list (список) и genlist (генерация списка). Значение по умолчанию: file. | |
-h, --help\tПолучить список возможных параметров. | |
-i, --info\tЭтот экран. | |
Список представляет собой JSON следующего формата: | |
[ | |
\t{ | |
\t\t"file": FILE, | |
\t\t"status": STATUS, | |
\t\t"subtitles": SUBTITLES, | |
\t\t"profile": PROFILE | |
\t} | |
] | |
Порядок ключей неважен. Наличие ключей file и status обязательно, ключи profile и subtitles указываются при необходимости. При отсутствии PROFILE используется профиль из опции --profile. SUBTITLES может быть как путём к файлу субтитров, так и словом self (для вшития субтитров из контейнера видеофайла) Возможные статусы (регистр неважен): Wait - видео готово к конвертации, Conv - видео конвертируется, Good - видео сконвертировано. | |
Список можно сгенерировать с --mode genlist. В этом случае --file становится директорией, которая обходится рекурсивно, при этом добавляются только видео- и аудиофайлы (список расширений находится в функции jsonGen). Допустимо указать --subtitles self и профиль. Скрипт возвращает готовый список в stdout, который можно перенаправить в файл и отредактировать на своё усмотрение. | |
Доступные профили: | |
pmp\tДля плеера COWON A3. Формат файла - avi; видео - XviD @ Q 2.5 480p; аудио - MP3 @ 160 kbps. | |
x7\tДля плеера COWON X7. Формат файла - avi; видео - XviD @ Q 3 272p; аудио - MP3 @ 128 kbps. | |
psp\tДля PlayStation Portable. Формат файла - mp4; видео - H.264 BL @ CRF 23; аудио - AAC @ 96 kbps. | |
symbian\tДля Nokia 5730 XM (и телефонов на Symbian S60). Формат файла - mp4; видео - x264 BL @ CRF 23; аудио - AAC @ 96 kbps. | |
3ds-v\tДля New Nintendo 3DS. Формат файла - mkv; видео - H.264 @ CRF 17 240p; аудио - AAC @ 128 kbps. | |
3ds-a\tДля New Nintendo 3DS. Формат файла - mp4; аудио - AAC @ 128 kbps, теги переносятся. | |
''') | |
def collision(file): | |
""" | |
Let the user decide whether to overwrite the file that already exists. | |
""" | |
from random import choice | |
# Insults if the user can't type a single Y or N | |
phrases = [ | |
"I don't think so.", | |
"Try again, buddy.", | |
"Wrong!", | |
"Are you kidding me?", | |
"You made the wrong choice...", | |
"Nope.", | |
"Can't you read?", | |
"Consider your options." | |
] | |
decision = False | |
decided = False | |
print("File %s already exists! Overwrite?" % file) | |
while not decided: | |
decision = input("Y/[N] ") | |
if decision.upper() == "Y": | |
decided = True | |
return True | |
elif decision.upper() == "N" or decision == "": | |
print("Skipping file.") | |
decided = True | |
return False | |
else: | |
print(choice(phrases)) | |
def encode3DSVideo(file, output, subtitles): | |
filters = "scale='trunc(min(400, 240*dar)/2)*2:trunc(min(240, 400/dar)/2)*2',hqdn3d,pad=400:240:-1:-1:black" | |
audio_filters = "loudnorm" | |
if subtitles != 'no': | |
if subtitles != 'self': | |
filters = filters + ",subtitles=%s" % abspath(subtitles.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,")) # ffmpeg is a bitch about escaping characters | |
else: | |
filters = filters + ",subtitles=%s" % file.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,") | |
# Apparently, using `-ac 2` is better than this strange formula I found somewhere | |
#channels = run([FFPROBE, "-v", "error", "-show_entries", "stream=channels", "-of", "default=noprint_wrappers=1:nokey=1", file], stdout=PIPE, text=True).stdout.strip() | |
#if channels == "6": | |
# audio_filters = "pan=stereo|FL=FC+0.30*FL+0.30*BL|FR=FC+0.30*FR+0.30*BR," + audio_filters | |
sample_rate = run([FFPROBE, "-v", "error", "-show_entries", "stream=sample_rate", "-of", "default=noprint_wrappers=1:nokey=1", file], stdout=PIPE, text=True).stdout.strip() | |
if int(sample_rate) > 48000: | |
ar = "48000" | |
else: | |
ar = sample_rate | |
command = [FFMPEG, "-i", file, "-map", "0:v:0", "-map", "0:a:0", "-map_metadata", "-1", "-c:v", "libx264", "-crf", "18", "-profile:v", "baseline", "-vf", filters, "-c:a", "libfdk_aac", "-b:a", "128k", "-ac", "2", "-af", audio_filters, "-ar", ar, "-f", "matroska", "-pix_fmt", "yuv420p", output] | |
print(" ".join(command)) | |
run(command) | |
return | |
def encode3DSAudio(file, output): | |
tagfilePath = output[:output.rfind('.')] + ".json" | |
if isfile(tagfilePath): | |
if collision(tagfilePath): | |
remove(tagfilePath) | |
else: | |
return | |
tagfile = open(tagfilePath, 'w') | |
tagfile.write(run([FFPROBE, "-v", "0", "-of", "json", "-show_format", file], stdout=PIPE, text=True).stdout) | |
tagfile.close() | |
ffmpeg_pipe = Popen([FFMPEG, "-i", file, "-f", "caf", "-"], stdout=PIPE) | |
fdkaac_pipe = run([FDKAAC, "-b", "128", "-o", output, "--tag-from-json=%s?format.tags" % tagfilePath, "-"], stdin=ffmpeg_pipe.stdout) | |
ffmpeg_pipe.stdout.close() # Allow ffmpeg_pipe to receive a SIGPIPE if fdkaac_pipe exits. | |
remove(tagfilePath) | |
if KID3: | |
run([KID3, "-c", "select \"%s\"" % file, "-c", "copy", "-c", "select \"%s\"" % output, "-c", "paste"]) | |
return | |
def encodePMP(file, output, subtitles): | |
command = [MENCODER, "-ovc", "xvid", "-xvidencopts", "fixed_quant=2.5:cartoon:quant_type=mpeg:lumi_mask:threads=2:chroma_opt", "-oac", "mp3lame", "-lameopts", "cbr:br=128:aq=2", "-vf", "scale=-11:480,hqdn3d", "-af", "volnorm", "-o", output, file, "-demuxer", "lavf"] | |
if subtitles != 'no': | |
if subtitles != 'self': | |
for item in ['-ass', '-utf8', '-sub', abspath(subtitles)]: | |
command.append(item) | |
else: | |
for item in ['-ass', '-utf8']: | |
command.append(item) | |
else: | |
command.append('-nosub') | |
run(command) | |
return | |
def encodePMPf(file, output, subtitles): | |
filters = "scale=-16:480" | |
if subtitles != 'no': | |
if subtitles != 'self': | |
filters = filters + ",subtitles='%s'" % subtitles | |
else: | |
filters = filters + ",subtitles='%s'" % file | |
command = [FFMPEG, "-i", file, "-map", "0:v:0", "-map", "0:a:0", "-map_metadata", "-1", "-c:v", "mpeg4", "-q:v", "2.5", "-maxrate:v", "3M", "-bufsize:v", "3M", "-mpeg_quant", "1", "-vf", filters, "-c:a", "libmp3lame", "-b:a", "128k", "-af", "loudnorm", output] | |
run(command) | |
return | |
def encodeX7(file, output, subtitles): | |
command = [MENCODER, "-ovc", "xvid", "-xvidencopts", "fixed_quant=3:cartoon:lumi_mask:chroma_opt", "-oac", "mp3lame", "-lameopts", "cbr:br=128:aq=2", "-vf", "scale=-11:272,hqdn3d,expand=480:272", "-af", "volnorm", "-o", output, file] | |
if subtitles != 'no': | |
if subtitles != 'self': | |
for item in ['-ass', '-utf8', '-sub', abspath(subtitles)]: | |
command.append(item) | |
else: | |
for item in ['-ass', '-utf8']: | |
command.append(item) | |
else: | |
command.append('-nosub') | |
run(command) | |
return | |
def encodePSP(file, output, subtitles): | |
filters = "scale='trunc(min(480, 272*dar)/2)*2:trunc(min(272, 480/dar)/2)*2',hqdn3d,pad=480:272:-1:-1:black" | |
if subtitles != 'no': | |
if subtitles != 'self': | |
filters = filters + ",subtitles='%s'" % abspath(subtitles.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,")) | |
else: | |
filters = filters + ",subtitles='%s'" % file.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,") | |
run([FFMPEG, "-i", file, "-c:v", "libx264", "-crf", "23", "-profile:v", "baseline", "-vf", filters, "-c:a", "libfdk_aac", "-b:a", "128k", "-ac", "2", "-af", "loudnorm", "-f", "mp4", "-pix_fmt", "yuv420p", output + ".tmp"]) | |
run([MP4BOX, "-new", output, "-add", output + ".tmp"]) | |
remove(output + ".tmp") | |
return | |
def encodeSymbian(file, output, subtitles): | |
filters = "scale='trunc(min(320, 240*dar)/2)*2:trunc(min(240, 320/dar)/2)*2',hqdn3d,pad=320:240:-1:-1:black" | |
if subtitles != 'no': | |
if subtitles != 'self': | |
filters = filters + ",subtitles='%s'" % abspath(subtitles.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,")) | |
else: | |
filters = filters + ",subtitles='%s'" % file.replace("'", r"\\\'").replace("[", r"\[").replace("]", r"\]").replace(",", r"\,") | |
run([FFMPEG, "-i", file, "-c:v", "libx264", "-crf", "23", "-maxrate", "900k", "-bufsize", "1800k", "-profile:v", "baseline", "-vf", filters, "-c:a", "libfdk_aac", "-b:a", "96k", "-ac", "2", "-af", "loudnorm", "-f", "mp4", "-pix_fmt", "yuv420p", output + ".tmp"]) | |
run([MP4BOX, "-new", output, "-add", output + ".tmp"]) | |
remove(output + ".tmp") | |
return | |
def encode(file, outpath, profile, subtitles): | |
filename = file[(file.rfind("/") + 1):][:file[(file.rfind("/") + 1):].rfind(".")] | |
output = outpath + "/" + filename | |
if profile == "3ds-v": | |
output = output + ".mkv" | |
elif profile == "3ds-a": | |
output = output + ".m4a" | |
elif profile == "pmp": | |
output = output + ".avi" | |
elif profile == "pmpf": | |
output = output + ".avi" | |
elif profile == "x7": | |
output = output + ".avi" | |
elif profile == "psp": | |
output = output + ".mp4" | |
elif profile == "symbian": | |
output = output + ".mp4" | |
if isfile(output): | |
if collision(output): | |
remove(output) | |
else: | |
return | |
if profile == "3ds-v": | |
encode3DSVideo(file, output, subtitles) | |
elif profile == "3ds-a": | |
encode3DSAudio(file, output) | |
elif profile == "pmp": | |
encodePMP(file, output, subtitles) | |
elif profile == "pmpf": | |
encodePMPf(file, output, subtitles) | |
elif profile == "x7": | |
encodeX7(file, output, subtitles) | |
elif profile == "psp": | |
encodePSP(file, output, subtitles) | |
elif profile == "symbian": | |
encodeSymbian(file, output, subtitles) | |
return | |
def jsonConv(listFile, outpath, defaultProfile): | |
import json | |
i = 0 | |
loglist = [] | |
listFileOpened = open(listFile, "r") | |
list = json.load(listFileOpened) | |
listFileOpened.close() | |
for line in list: | |
file = line["file"] | |
loglist.append(file) | |
status = line["status"] | |
if "subtitles" in line: | |
if line["subtitles"]: | |
subtitles = line["subtitles"] | |
if not subtitles: subtitles = "no" # in case the key is present, but the value is empty | |
else: | |
subtitles = "no" | |
if "profile" in line: | |
profile = line["profile"] | |
else: | |
profile = defaultProfile | |
if status.upper() == "GOOD": continue | |
if status.upper() == "CONV": status = "Wait" | |
if status.upper() == "WAIT": | |
line["status"] = "Conv" | |
listFileOpened = open(listFile, "w") | |
listFileOpened.write(json.dumps(list, indent=4, ensure_ascii=False)) | |
listFileOpened.close() | |
try: | |
encode(file, outpath, profile, subtitles) | |
line["status"] = "Good" | |
listFileOpened = open(listFile, "w") | |
listFileOpened.write(json.dumps(list, indent=4, ensure_ascii=False)) | |
listFileOpened.close() | |
except IOError as err: | |
print(err) | |
i += 1 | |
print("\nEnd of list reached. The job is done. Files converted:\n") | |
for count in loglist: | |
print(count) | |
print("\nOutput folder: %s\nTotal files converted: %s" % (outpath, i)) | |
return | |
def jsonGen(inpath, defaultProfile="", defaultSubtitles=""): | |
import json | |
from os import walk | |
from os.path import join | |
supported_types = ["avi", "mp4", "mkv", "webm", "mpg", "mpeg", "ts", "mov", "mp3", "m4a", "ogg", "flac", "wav"] | |
list = [] | |
for root, dirs, files in walk(abspath(inpath)): | |
for name in files: | |
extention = name[name.rfind(".") + 1:] | |
if not extention in supported_types: continue | |
entry = { | |
"file": join(root, name), | |
"status": "wait" | |
} | |
if defaultProfile: entry["profile"] = defaultProfile | |
if defaultSubtitles == "self": entry["subtitles"] = defaultSubtitles | |
list.append(entry) | |
# sort the resulting list just because | |
list = sorted(list, key=lambda compare_using: compare_using["file"]) | |
return json.dumps(list, indent=4, ensure_ascii=False) | |
if __name__ == "__main__": | |
from optparse import OptionParser | |
from datetime import datetime | |
startTime = datetime.now() | |
parser = OptionParser() | |
parser.add_option("-f", "--file", dest="file", help="File or list of files to convert; path to generate a list from", metavar="FILE") | |
parser.add_option("-o", "--output", dest="output", help="Output folder.", metavar="OUTPUT") | |
parser.add_option("-p", "--profile", dest="profile", help="Encoding profile: pmp, x7, psp, symbian, 3ds-v or 3ds-a.", metavar="PROFILE") | |
parser.add_option("-m", "--mode", dest="mode", default="file", help="Operation mode: file (default) , list or genlist.", metavar="PROFILE") | |
parser.add_option("-s", "--subtitles", dest="subs", help="Subtitles to hardsub (file mode only). Default: no subtitles.", default="no", metavar="SUBTITLES") | |
parser.add_option("-i", "--info", dest="info", default=False, help="Full help.", action="store_true") | |
(options, args) = parser.parse_args() | |
if (options.info and not (options.file and options.output and options.profile)): | |
doc() | |
exit(0) | |
if not options.file: | |
parser.error("See --help for more information.") | |
exit(1) | |
if options.mode == "file" or options.mode == "list": | |
if not (options.mode and options.output and options.profile): parser.error("See --help for more information.") | |
if options.output: | |
if not isdir(options.output): | |
parser.error("%s is not a directory." % options.output) | |
if options.mode not in ["file", "list", "genlist"]: parser.error("Invalid mode! Possible modes: file, list, genlist.") | |
if (options.mode == "list" and options.subs != "no") and (options.mode == "list" and options.subs != "self"): parser.error("You can't use external subtitles in list mode.") | |
if options.mode == "list": | |
try: | |
jsonConv(abspath(options.file), abspath(options.output), options.profile) | |
except: | |
print("Something is wrong.") | |
exit(1) | |
finally: | |
print("Running time:", datetime.now() - startTime) | |
exit(0) | |
elif options.mode == "file": | |
try: | |
encode(abspath(options.file), abspath(options.output), options.profile, options.subs) | |
except: | |
print("Something is wrong.") | |
exit(1) | |
finally: | |
print("Running time:", datetime.now() - startTime) | |
exit(0) | |
elif options.mode == "genlist": | |
try: | |
print(jsonGen(options.file, options.profile, options.subs)) | |
except: | |
print("Something is wrong.") | |
exit(1) | |
finally: | |
exit(0) | |
else: | |
print("Wha...? How did you get here?") | |
exit(2) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment