Skip to content

Instantly share code, notes, and snippets.

@fsLeg
Last active April 30, 2024 13:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fsLeg/5f98dcbac159d319ad57497bf6c30b65 to your computer and use it in GitHub Desktop.
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.
#!/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