diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 7c87a08..0ccfbc5 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] + python-version: [3.8] steps: - uses: actions/checkout@v2 @@ -31,6 +31,9 @@ jobs: cat requirements-dev.txt | xargs -L 1 pip install cat requirements-site.txt | xargs -L 1 pip install pip install -e . --ignore-installed + - name: Type checking + run: | + python -m mypy --follow-imports=skip subaligner - name: Linting run: | pycodestyle subaligner tests examples misc bin/subaligner bin/subaligner_1pass bin/subaligner_2pass bin/subaligner_batch bin/subaligner_convert bin/subaligner_train bin/subaligner_tune setup.py --ignore=E203,E501,W503 --exclude="subaligner/lib" diff --git a/Pipfile b/Pipfile index 56bfbb9..427193f 100644 --- a/Pipfile +++ b/Pipfile @@ -36,7 +36,7 @@ click = "==5.1" cloudpickle = "==0.5.3" cycler = "==0.10.0" Cython = "~=0.29.22" -dask = "==0.15.0" +dask = ">=2021.10.0" decorator = "==4.3.0" distributed = "==1.13.0" filelock = "==3.0.12" diff --git a/README.md b/README.md index 8e1df1b..b3c2534 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,12 @@ $ subaligner -m script -v test.mp4 -s subtitle.txt -o subtitle_aligned.srt $ subaligner -m script -v https://example.com/video.mp4 -s https://example.com/subtitle.txt -o subtitle_aligned.srt ``` ``` +# Alignment on multiple subtitles against the single media file + +$ subaligner -m script -v test.mp4 -s subtitle_lang_1.txt -s subtitle_lang_2.txt +$ subaligner -m script -v test.mp4 -s subtitle_lang_1.txt subtitle_lang_2.txt +``` +``` # Translative alignment with the ISO 639-3 language code pair (src,tgt) $ subaligner_1pass --languages @@ -135,6 +141,12 @@ $ subaligner -m dual -v video.mp4 -s subtitle.srt -t src,tgt $ subaligner -m script -v test.mp4 -s subtitle.txt -o subtitle_aligned.srt -t src,tgt ``` ``` +# Shift subtitle manually by offset in seconds + +$ subaligner -m shift --subtitle_path subtitle.srt -os 5.5 +$ subaligner -m shift --subtitle_path subtitle.srt -os -5.5 -o subtitle_shifted.srt +``` +``` # Run batch alignment against directories $ subaligner_batch -m single -vd videos/ -sd subtitles/ -od aligned_subtitles/ diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..b19d7b0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,8 @@ +# Global options: + +[mypy] +ignore_missing_imports = True +no_implicit_optional = True +allow_redefinition = True + +# Per-module options: \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 4dbfbb3..d3cc8e9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,9 @@ line-profiler==3.1.0 scikit-build==0.11.1 radish-bdd~=0.13.3 pex==2.1.34 -mypy==0.910 +mypy==0.931 +types-requests==2.27.9 +types-setuptools==57.4.9 parameterized==0.8.1 pylint~=2.8.2 pygments==2.7.4 \ No newline at end of file diff --git a/site/source/usage.rst b/site/source/usage.rst index b8f9a5c..c637d94 100644 --- a/site/source/usage.rst +++ b/site/source/usage.rst @@ -31,6 +31,11 @@ Make sure you have got the virtual environment activated upfront. (.venv) $ subaligner -m script -v test.mp4 -s subtitle.txt -o subtitle_aligned.srt (.venv) $ subaligner -m script -v https://example.com/video.mp4 -s https://example.com/subtitle.txt -o subtitle_aligned.srt +**Alignment on multiple subtitles against the single media file**:: + + (.venv) $ subaligner -m script -v test.mp4 -s subtitle_lang_1.txt -s subtitle_lang_2.txt + (.venv) $ subaligner -m script -v test.mp4 -s subtitle_lang_1.txt subtitle_lang_2.txt + **Translative alignment with the ISO 639-3 language code pair (src,tgt)**:: (.venv) $ subaligner_1pass --languages @@ -42,6 +47,11 @@ Make sure you have got the virtual environment activated upfront. (.venv) $ subaligner -m dual -v video.mp4 -s subtitle.srt -t src,tgt (.venv) $ subaligner -m script -v test.mp4 -s subtitle.txt -o subtitle_aligned.srt -t src,tgt +**Shift subtitle manually by offset in seconds**:: + + (.venv) $ subaligner -m shift --subtitle_path subtitle.srt -os 5.5 + (.venv) $ subaligner -m shift --subtitle_path subtitle.srt -os -5.5 -o subtitle_shifted.srt + **Run batch alignment against directories**:: (.venv) $ subaligner_batch -m single -vd videos/ -sd subtitles/ -od aligned_subtitles/ diff --git a/subaligner/__main__.py b/subaligner/__main__.py index 3c7f508..fc41bd9 100755 --- a/subaligner/__main__.py +++ b/subaligner/__main__.py @@ -1,8 +1,8 @@ #!/usr/bin/env python """ -usage: subaligner [-h] [-m {single,dual}] [-v VIDEO_PATH] [-s SUBTITLE_PATH] [-l MAX_LOGLOSS] [-so] +usage: subaligner [-h] [-m {single,dual,script,shift}] [-v VIDEO_PATH] [-s SUBTITLE_PATH [SUBTITLE_PATH ...]] [-l MAX_LOGLOSS] [-so] [-sil {afr,amh,ara,arg,asm,aze,ben,bos,bul,cat,ces,cmn,cym,dan,deu,ell,eng,epo,est,eus,fas,fin,fra,gla,gle,glg,grc,grn,guj,heb,hin,hrv,hun,hye,ina,ind,isl,ita,jbo,jpn,kal,kan,kat,kir,kor,kur,lat,lav,lfn,lit,mal,mar,mkd,mlt,msa,mya,nah,nep,nld,nor,ori,orm,pan,pap,pol,por,ron,rus,sin,slk,slv,spa,sqi,srp,swa,swe,tam,tat,tel,tha,tsn,tur,ukr,urd,vie,yue,zho}] - [-fos] [-tod TRAINING_OUTPUT_DIRECTORY] [-o OUTPUT] [-t TRANSLATE] [-lan] [-d] [-q] [-ver] + [-fos] [-tod TRAINING_OUTPUT_DIRECTORY] [-o OUTPUT] [-t TRANSLATE] [-os OFFSET_SECONDS] [-lgs] [-d] [-q] [-ver] Subaligner command line interface @@ -10,7 +10,7 @@ -h, --help show this help message and exit -l MAX_LOGLOSS, --max_logloss MAX_LOGLOSS Max global log loss for alignment - -so, --stretch_on Switch on stretch on subtitles + -so, --stretch_on Switch on stretch on subtitles) -sil {afr,amh,ara,arg,asm,aze,ben,bos,bul,cat,ces,cmn,cym,dan,deu,ell,eng,epo,est,eus,fas,fin,fra,gla,gle,glg,grc,grn,guj,heb,hin,hrv,hun,hye,ina,ind,isl,ita,jbo,jpn,kal,kan,kat,kir,kor,kur,lat,lav,lfn,lit,mal,mar,mkd,mlt,msa,mya,nah,nep,nld,nor,ori,orm,pan,pap,pol,por,ron,rus,sin,slk,slv,spa,sqi,srp,swa,swe,tam,tat,tel,tha,tsn,tur,ukr,urd,vie,yue,zho}, --stretch_in_language {afr,amh,ara,arg,asm,aze,ben,bos,bul,cat,ces,cmn,cym,dan,deu,ell,eng,epo,est,eus,fas,fin,fra,gla,gle,glg,grc,grn,guj,heb,hin,hrv,hun,hye,ina,ind,isl,ita,jbo,jpn,kal,kan,kat,kir,kor,kur,lat,lav,lfn,lit,mal,mar,mkd,mlt,msa,mya,nah,nep,nld,nor,ori,orm,pan,pap,pol,por,ron,rus,sin,slk,slv,spa,sqi,srp,swa,swe,tam,tat,tel,tha,tsn,tur,ukr,urd,vie,yue,zho} Stretch the subtitle with the supported ISO 639-3 language code [https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes]. NB: This will be ignored if neither -so nor --stretch_on is present @@ -21,18 +21,20 @@ Path to the output subtitle file -t TRANSLATE, --translate TRANSLATE Source and target ISO 639-3 language codes separated by a comma (e.g., eng,zho) + -os OFFSET_SECONDS, --offset_seconds OFFSET_SECONDS + Offset by which the subtitle will be shifted -lgs, --languages Print out language codes used for stretch and translation -d, --debug Print out debugging information -q, --quiet Switch off logging information -ver, --version show program's version number and exit required arguments: - -m {single,dual, script}, --mode {single,dual,script} - Alignment mode: either single or dual or script + -m {single,dual,script,shift}, --mode {single,dual,script,shift} + Alignment mode: either single or dual -v VIDEO_PATH, --video_path VIDEO_PATH File path or URL to the video file - -s SUBTITLE_PATH, --subtitle_path SUBTITLE_PATH - File path or URL to the subtitle file (Extensions of supported subtitles: .ttml, .vtt, .tmp, .dfxp, .xml, .sami, .scc, .sub, .txt, .stl, .ssa, .ytt, .srt, .sbv, .ass, .smi) or selector for the embedded subtitle (e.g., embedded:page_num=888 or embedded:stream_index=0) + -s SUBTITLE_PATH [SUBTITLE_PATH ...], --subtitle_path SUBTITLE_PATH [SUBTITLE_PATH ...] + File path or URL to the subtitle file (Extensions of supported subtitles: .sami, .ssa, .vtt, .xml, .sub, .smi, .ass, .srt, .tmp, .dfxp, .stl, .ttml, .sbv, .txt, .ytt, .scc) or selector for the embedded subtitle (e.g., embedded:page_num=888 or embedded:stream_index=0) """ import argparse @@ -61,7 +63,7 @@ def main(): "--mode", type=str, default="", - choices=["single", "dual", "script"], + choices=["single", "dual", "script", "shift"], help="Alignment mode: either single or dual", ) required_args.add_argument( @@ -76,7 +78,9 @@ def main(): "-s", "--subtitle_path", type=str, - default="", + default=[], + action="append", + nargs="+", help="File path or URL to the subtitle file (Extensions of supported subtitles: {}) or selector for the embedded subtitle (e.g., embedded:page_num=888 or embedded:stream_index=0)".format(", ".join(Subtitle.subtitle_extensions())), ) parser.add_argument( @@ -127,6 +131,12 @@ def main(): type=str, help="Source and target ISO 639-3 language codes separated by a comma (e.g., eng,zho)", ) + parser.add_argument( + "-os", + "--offset_seconds", + type=float, + help="Offset by which the subtitle will be shifted" + ) parser.add_argument("-lgs", "--languages", action="store_true", help="Print out language codes used for stretch and translation") parser.add_argument("-d", "--debug", action="store_true", @@ -143,156 +153,176 @@ def main(): print("ERROR: --mode was not passed in") parser.print_usage() sys.exit(21) - if FLAGS.video_path == "": - print("ERROR: --video_path was not passed in") - parser.print_usage() - sys.exit(21) - if FLAGS.subtitle_path == "": + FLAGS.subtitle_path = [path for paths in FLAGS.subtitle_path for path in paths] + + if not FLAGS.subtitle_path: print("ERROR: --subtitle_path was not passed in") parser.print_usage() sys.exit(21) - if FLAGS.subtitle_path.lower().startswith("http") and FLAGS.output == "": - print("ERROR: --output was not passed in for alignment on a remote subtitle file") - parser.print_usage() - sys.exit(21) - if FLAGS.subtitle_path.lower().startswith("teletext:") and FLAGS.output == "": - print("ERROR: --output was not passed in for alignment on embedded subtitles") - parser.print_usage() - sys.exit(21) - if FLAGS.mode == "script" and FLAGS.output == "": - print("ERROR: --output was not passed in for alignment on plain texts") - parser.print_usage() - sys.exit(21) - if FLAGS.translate is not None: - if "transformers" not in {pkg.key for pkg in pkg_resources.working_set}: - print('ERROR: Alignment has been configured to perform translation. Please install "subaligner[translation]" and run your command again.') - sys.exit(21) - if FLAGS.stretch_on or FLAGS.mode == "script": - if "aeneas" not in {pkg.key for pkg in pkg_resources.working_set}: - print('ERROR: Alignment has been configured to use extra features. Please install "subaligner[stretch]" and run your command again.') - sys.exit(21) + if FLAGS.mode != "shift": + for subtitle_path in FLAGS.subtitle_path: + if FLAGS.video_path == "": + print("ERROR: --video_path was not passed in") + parser.print_usage() + sys.exit(21) + if subtitle_path.lower().startswith("http") and FLAGS.output == "": + print("ERROR: --output was not passed in for alignment on a remote subtitle file") + parser.print_usage() + sys.exit(21) + if subtitle_path.lower().startswith("teletext:") and FLAGS.output == "": + print("ERROR: --output was not passed in for alignment on embedded subtitles") + parser.print_usage() + sys.exit(21) + if FLAGS.mode == "script" and FLAGS.output == "": + print("ERROR: --output was not passed in for alignment on plain texts") + parser.print_usage() + sys.exit(21) + if FLAGS.translate is not None: + if "transformers" not in {pkg.key for pkg in pkg_resources.working_set}: + print('ERROR: Alignment has been configured to perform translation. Please install "subaligner[translation]" and run your command again.') + sys.exit(21) + if FLAGS.stretch_on or FLAGS.mode == "script": + if "aeneas" not in {pkg.key for pkg in pkg_resources.working_set}: + print('ERROR: Alignment has been configured to use extra features. Please install "subaligner[stretch]" and run your command again.') + sys.exit(21) - local_video_path = FLAGS.video_path - local_subtitle_path = FLAGS.subtitle_path - exit_segfail = FLAGS.exit_segfail - stretch = FLAGS.stretch_on - stretch_in_lang = FLAGS.stretch_in_language + local_video_path = FLAGS.video_path + local_subtitle_path = subtitle_path + exit_segfail = FLAGS.exit_segfail + stretch = FLAGS.stretch_on + stretch_in_lang = FLAGS.stretch_in_language - from subaligner.logger import Logger - Logger.VERBOSE = FLAGS.debug - Logger.QUIET = FLAGS.quiet - from subaligner.predictor import Predictor - from subaligner.exception import UnsupportedFormatException - from subaligner.exception import TerminalException + from subaligner.logger import Logger + Logger.VERBOSE = FLAGS.debug + Logger.QUIET = FLAGS.quiet + from subaligner.predictor import Predictor + from subaligner.exception import UnsupportedFormatException + from subaligner.exception import TerminalException - try: - if FLAGS.video_path.lower().startswith("http"): - _, local_video_path = tempfile.mkstemp() - _, video_file_extension = os.path.splitext(FLAGS.video_path.lower()) - local_video_path = "{}{}".format(local_video_path, video_file_extension) - Utils.download_file(FLAGS.video_path, local_video_path) + try: + if FLAGS.video_path.lower().startswith("http"): + _, local_video_path = tempfile.mkstemp() + _, video_file_extension = os.path.splitext(FLAGS.video_path.lower()) + local_video_path = "{}{}".format(local_video_path, video_file_extension) + Utils.download_file(FLAGS.video_path, local_video_path) - if FLAGS.subtitle_path.lower().startswith("http"): - _, local_subtitle_path = tempfile.mkstemp() - _, subtitle_file_extension = os.path.splitext(FLAGS.subtitle_path.lower()) - local_subtitle_path = "{}{}".format(local_subtitle_path, subtitle_file_extension) - Utils.download_file(FLAGS.subtitle_path, local_subtitle_path) + if subtitle_path.lower().startswith("http"): + _, local_subtitle_path = tempfile.mkstemp() + _, subtitle_file_extension = os.path.splitext(subtitle_path.lower()) + local_subtitle_path = "{}{}".format(local_subtitle_path, subtitle_file_extension) + Utils.download_file(subtitle_path, local_subtitle_path) - if FLAGS.subtitle_path.lower().startswith("embedded:"): - _, local_subtitle_path = tempfile.mkstemp() - _, subtitle_file_extension = os.path.splitext(FLAGS.output) - local_subtitle_path = "{}{}".format(local_subtitle_path, subtitle_file_extension) - params = FLAGS.subtitle_path.lower().split(":")[1].split(",") - if params and "=" in params[0]: - params = {param.split("=")[0]: param.split("=")[1] for param in params} - if "page_num" in params: - Utils.extract_teletext_as_subtitle(local_video_path, int(params["page_num"]), local_subtitle_path) - elif "stream_index" in params: - Utils.extract_matroska_subtitle(local_video_path, int(params["stream_index"]), local_subtitle_path) - else: - print("ERROR: Embedded subtitle selector cannot be empty") - parser.print_usage() - sys.exit(21) + if subtitle_path.lower().startswith("embedded:"): + _, local_subtitle_path = tempfile.mkstemp() + _, subtitle_file_extension = os.path.splitext(FLAGS.output) + local_subtitle_path = "{}{}".format(local_subtitle_path, subtitle_file_extension) + params = subtitle_path.lower().split(":")[1].split(",") + if params and "=" in params[0]: + params = {param.split("=")[0]: param.split("=")[1] for param in params} + if "page_num" in params: + Utils.extract_teletext_as_subtitle(local_video_path, int(params["page_num"]), + local_subtitle_path) + elif "stream_index" in params: + Utils.extract_matroska_subtitle(local_video_path, int(params["stream_index"]), + local_subtitle_path) + else: + print("ERROR: Embedded subtitle selector cannot be empty") + parser.print_usage() + sys.exit(21) - predictor = Predictor() - if FLAGS.mode == "single": - aligned_subs, audio_file_path, voice_probabilities, frame_rate = predictor.predict_single_pass( - video_file_path=local_video_path, - subtitle_file_path=local_subtitle_path, - weights_dir=os.path.join(FLAGS.training_output_directory, "models/training/weights") - ) - elif FLAGS.mode == "dual": - aligned_subs, subs, voice_probabilities, frame_rate = predictor.predict_dual_pass( - video_file_path=local_video_path, - subtitle_file_path=local_subtitle_path, - weights_dir=os.path.join(FLAGS.training_output_directory, "models/training/weights"), - stretch=stretch, - stretch_in_lang=stretch_in_lang, - exit_segfail=exit_segfail, - ) - elif FLAGS.mode == "script": - aligned_subs, _, voice_probabilities, frame_rate = predictor.predict_plain_text( - video_file_path=local_video_path, - subtitle_file_path=local_subtitle_path, - stretch_in_lang=stretch_in_lang, - ) - else: - print("ERROR: Unknown mode {}".format(FLAGS.mode)) - parser.print_usage() - sys.exit(21) + predictor = Predictor() + if FLAGS.mode == "single": + aligned_subs, audio_file_path, voice_probabilities, frame_rate = predictor.predict_single_pass( + video_file_path=local_video_path, + subtitle_file_path=local_subtitle_path, + weights_dir=os.path.join(FLAGS.training_output_directory, "models/training/weights") + ) + elif FLAGS.mode == "dual": + aligned_subs, subs, voice_probabilities, frame_rate = predictor.predict_dual_pass( + video_file_path=local_video_path, + subtitle_file_path=local_subtitle_path, + weights_dir=os.path.join(FLAGS.training_output_directory, "models/training/weights"), + stretch=stretch, + stretch_in_lang=stretch_in_lang, + exit_segfail=exit_segfail, + ) + elif FLAGS.mode == "script": + aligned_subs, _, voice_probabilities, frame_rate = predictor.predict_plain_text( + video_file_path=local_video_path, + subtitle_file_path=local_subtitle_path, + stretch_in_lang=stretch_in_lang, + ) + else: + print("ERROR: Unknown mode {}".format(FLAGS.mode)) + parser.print_usage() + sys.exit(21) + + aligned_subtitle_path = "_aligned.".join( + subtitle_path.rsplit(".", 1)).replace(".stl", ".srt") if FLAGS.output == "" else FLAGS.output - aligned_subtitle_path = "_aligned.".join( - FLAGS.subtitle_path.rsplit(".", 1)).replace(".stl", ".srt") if FLAGS.output == "" else FLAGS.output + if FLAGS.translate is not None: + from subaligner.translator import Translator + source, target = FLAGS.translate.split(",") + translator = Translator(source, target) + aligned_subs = translator.translate(aligned_subs) + Subtitle.save_subs_as_target_format(aligned_subs, local_subtitle_path, aligned_subtitle_path, + frame_rate, "utf-8") + else: + Subtitle.save_subs_as_target_format(aligned_subs, local_subtitle_path, aligned_subtitle_path, + frame_rate) - if FLAGS.translate is not None: - from subaligner.translator import Translator - source, target = FLAGS.translate.split(",") - translator = Translator(source, target) - aligned_subs = translator.translate(aligned_subs) - Subtitle.save_subs_as_target_format(aligned_subs, local_subtitle_path, aligned_subtitle_path, frame_rate, "utf-8") - else: - Subtitle.save_subs_as_target_format(aligned_subs, local_subtitle_path, aligned_subtitle_path, frame_rate) + if voice_probabilities is not None: + log_loss = predictor.get_log_loss(voice_probabilities, aligned_subs) + if log_loss is None or log_loss > FLAGS.max_logloss: + print( + "ERROR: Alignment failed with a too high loss value: {}".format(log_loss) + ) + _remove_tmp_files(FLAGS.video_path, subtitle_path, local_video_path, local_subtitle_path) + sys.exit(22) - if voice_probabilities is not None: - log_loss = predictor.get_log_loss(voice_probabilities, aligned_subs) - if log_loss is None or log_loss > FLAGS.max_logloss: + print("Aligned subtitle saved to: {}".format(aligned_subtitle_path)) + except UnsupportedFormatException as e: print( - "ERROR: Alignment failed with a too high loss value: {}".format(log_loss) + "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") ) - _remove_tmp_files(FLAGS, local_video_path, local_subtitle_path) - sys.exit(22) - - print("Aligned subtitle saved to: {}".format(aligned_subtitle_path)) - except UnsupportedFormatException as e: - print( - "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") - ) - traceback.print_tb(e.__traceback__) - _remove_tmp_files(FLAGS, local_video_path, local_subtitle_path) - sys.exit(23) - except TerminalException as e: - print( - "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") - ) - traceback.print_tb(e.__traceback__) - _remove_tmp_files(FLAGS, local_video_path, local_subtitle_path) - sys.exit(24) - except Exception as e: - print( - "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") - ) - traceback.print_tb(e.__traceback__) - _remove_tmp_files(FLAGS, local_video_path, local_subtitle_path) - sys.exit(1) + traceback.print_tb(e.__traceback__) + _remove_tmp_files(FLAGS.video_path, subtitle_path, local_video_path, local_subtitle_path) + sys.exit(23) + except TerminalException as e: + print( + "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") + ) + traceback.print_tb(e.__traceback__) + _remove_tmp_files(FLAGS.video_path, subtitle_path, local_video_path, local_subtitle_path) + sys.exit(24) + except Exception as e: + print( + "ERROR: {}\n{}".format(str(e), "".join(traceback.format_stack()) if FLAGS.debug else "") + ) + traceback.print_tb(e.__traceback__) + _remove_tmp_files(FLAGS.video_path, subtitle_path, local_video_path, local_subtitle_path) + sys.exit(1) + else: + _remove_tmp_files(FLAGS.video_path, subtitle_path, local_video_path, local_subtitle_path) + sys.exit(0) else: - _remove_tmp_files(FLAGS, local_video_path, local_subtitle_path) + if FLAGS.offset_seconds is None: + print("ERROR: --offset_seconds was not passed in during subtitle shifting") + sys.exit(21) + from subaligner.subtitle import Subtitle + + for subtitle_path in FLAGS.subtitle_path: + shifted_subtitle_file_path = Subtitle.shift_subtitle(subtitle_file_path=subtitle_path, + seconds=FLAGS.offset_seconds, + shifted_subtitle_file_path=FLAGS.output or None) + print("Shifted subtitle saved to: {}".format(shifted_subtitle_file_path)) sys.exit(0) -def _remove_tmp_files(flags, local_video_path, local_subtitle_path): - if flags.video_path.lower().startswith("http") and os.path.exists(local_video_path): +def _remove_tmp_files(video_path, subtitle_path, local_video_path, local_subtitle_path): + if video_path.lower().startswith("http") and os.path.exists(local_video_path): os.remove(local_video_path) - if flags.subtitle_path.lower().startswith("http") and os.path.exists(local_subtitle_path): + if subtitle_path.lower().startswith("http") and os.path.exists(local_subtitle_path): os.remove(local_subtitle_path) diff --git a/subaligner/lib/to_srt.py b/subaligner/lib/to_srt.py index 4355a6c..290a19a 100644 --- a/subaligner/lib/to_srt.py +++ b/subaligner/lib/to_srt.py @@ -598,32 +598,3 @@ def parseChildTree(element_list): def __iter__(self): return iter(self.subs) - -if __name__ == '__main__': - - from optparse import OptionParser - import sys - - parser = OptionParser(usage = 'usage: %prog [options] input output') - parser.set_defaults(reader_class=STL) - parser.add_option('-d', '--debug', dest='debug_level', action='store_const', const=logging.DEBUG, default=logging.ERROR) - parser.add_option('-r', '--rich', dest='rich_formatting', action='store_true', default=False, help='Output text with some formatting, the following HTML tags are used: b i u font(color)') - parser.add_option("-s", "--stl", dest="reader_class", action="store_const", const=STL, - help="Set input file format as STL (default)") - parser.add_option("-t", "--tt", dest="reader_class", action="store_const", const=TT, - help="Set input file format as TT, handles both the EBU and SMPTE variants") - - (options, args) = parser.parse_args() - - if len(args) != 2: - parser.print_help() - sys.exit(1) - - logging.basicConfig(level=options.debug_level) - - input = options.reader_class(args[0], options.rich_formatting) - c = SRT(args[1]) - for sub in input: - (tci, tco, txt) = sub - c.write(tci, tco, txt) - c.file.close() diff --git a/subaligner/logger.py b/subaligner/logger.py index 2a89579..2dcc452 100644 --- a/subaligner/logger.py +++ b/subaligner/logger.py @@ -6,7 +6,7 @@ absl_logging._warn_preinit_stderr = 0 -class Logger(Singleton): +class Logger(metaclass=Singleton): """Common logging.""" VERBOSE = False diff --git a/subaligner/predictor.py b/subaligner/predictor.py index 1d8f5bd..1a4d1a7 100644 --- a/subaligner/predictor.py +++ b/subaligner/predictor.py @@ -8,7 +8,7 @@ import logging import numpy as np import multiprocessing as mp -from typing import Tuple, List, Optional, Dict, Any +from typing import Tuple, List, Optional, Dict, Any, Iterable from pysrt import SubRipTime, SubRipItem, SubRipFile from sklearn.metrics import log_loss from copy import deepcopy @@ -23,7 +23,7 @@ from .logger import Logger -class Predictor(Singleton): +class Predictor(metaclass=Singleton): """ Predictor for working out the time to shift subtitles """ __MAX_SHIFT_IN_SECS = ( @@ -456,8 +456,8 @@ def _predict_in_multithreads( os.remove(segment_path) @staticmethod - def __minibatch(total, batch_size): - batch = [] + def __minibatch(total: int, batch_size: int) -> Iterable[List[int]]: + batch: List = [] for i in range(total): if len(batch) == batch_size: yield batch @@ -708,8 +708,8 @@ def __predict( subtitles: Optional[SubRipFile] = None, max_shift_secs: Optional[float] = None, previous_gap: Optional[float] = None, - lock: threading.RLock = None, - network: Network = None + lock: Optional[threading.RLock] = None, + network: Optional[Network] = None ) -> Tuple[List[SubRipItem], str, "np.ndarray[float]"]: """Shift out-of-sync subtitle cues by sending the audio track of an video to the trained network. diff --git a/subaligner/singleton.py b/subaligner/singleton.py index 70671ca..7f1aaa9 100644 --- a/subaligner/singleton.py +++ b/subaligner/singleton.py @@ -1,18 +1,14 @@ from typing import Dict, Any -class _Singleton(type): # type: ignore +class Singleton(type): # type: ignore """ A metaclass that creates a Singleton base class when called. """ _instances: Dict[Any, Any] = {} def __call__(cls, *args, **kwargs) -> Any: if cls not in cls._instances: - cls._instances[cls] = super(_Singleton, cls).__call__( + cls._instances[cls] = super(Singleton, cls).__call__( *args, **kwargs ) return cls._instances[cls] - - -class Singleton(_Singleton("SingletonMeta", (object,), {})): # type: ignore - pass diff --git a/subaligner/subaligner_batch/__main__.py b/subaligner/subaligner_batch/__main__.py index efb53cc..597baac 100755 --- a/subaligner/subaligner_batch/__main__.py +++ b/subaligner/subaligner_batch/__main__.py @@ -4,7 +4,7 @@ [-sil {afr,amh,ara,arg,asm,aze,ben,bos,bul,cat,ces,cmn,cym,dan,deu,ell,eng,epo,est,eus,fas,fin,fra,gla,gle,glg,grc,grn,guj,heb,hin,hrv,hun,hye,ina,ind,isl,ita,jbo,jpn,kal,kan,kat,kir,kor,kur,lat,lav,lfn,lit,mal,mar,mkd,mlt,msa,mya,nah,nep,nld,nor,ori,orm,pan,pap,pol,por,ron,rus,sin,slk,slv,spa,sqi,srp,swa,swe,tam,tat,tel,tha,tsn,tur,ukr,urd,vie,yue,zho}] [-fos] [-tod TRAINING_OUTPUT_DIRECTORY] [-od OUTPUT_DIRECTORY] [-t TRANSLATE] [-lgs] [-d] [-q] [-ver] -Batch align multiple subtitle files and audiovisual files (v0.1.4) +Batch align multiple subtitle files and audiovisual files Subtitle files and their companion audiovisual files need to be stored in two separate directories. Each file pair needs to share the same base filename, the part before the extension. diff --git a/subaligner/subtitle.py b/subaligner/subtitle.py index ea2ce9b..81541c7 100644 --- a/subaligner/subtitle.py +++ b/subaligner/subtitle.py @@ -324,6 +324,10 @@ def shift_subtitle( string -- The path to the shifted subtitle file. """ _, file_extension = os.path.splitext(subtitle_file_path) + if shifted_subtitle_file_path is None: + shifted_subtitle_file_path = subtitle_file_path.replace( + file_extension, "{}{}".format(suffix, file_extension) + ) if file_extension.lower() in cls.TTML_EXTENSIONS: subs = cls(cls.__secret, subtitle_file_path, "ttml").subs subs.shift(seconds=seconds) @@ -333,10 +337,6 @@ def shift_subtitle( for index, cue in enumerate(cues): cue.attrib["begin"] = str(subs[index].start).replace(",", ".") cue.attrib["end"] = str(subs[index].end).replace(",", ".") - if shifted_subtitle_file_path is None: - shifted_subtitle_file_path = subtitle_file_path.replace( - file_extension, "{}{}".format(suffix, file_extension) - ) encoding = Utils.detect_encoding(subtitle_file_path) tree.write(shifted_subtitle_file_path, encoding=encoding) elif file_extension.lower() in cls.STL_EXTENSIONS: @@ -741,7 +741,7 @@ def __save_subtitle_by_extension(file_extension: str, # Change single quotes in the XML header to double quotes with open(target_file_path, "w", encoding=encoding) as target: if "xml_declaration" in inspect.getfullargspec(ElementTree.tostring).kwonlyargs: # for >= python 3.8 - encoded = ElementTree.tostring(tt, encoding=encoding, method="xml", xml_declaration=True) + encoded = ElementTree.tostring(tt, encoding=encoding, method="xml", xml_declaration=True) # type: ignore else: encoded = ElementTree.tostring(tt, encoding=encoding, method="xml") normalised = encoded.decode(encoding) \ diff --git a/subaligner/translator.py b/subaligner/translator.py index 8697a2f..78c548c 100644 --- a/subaligner/translator.py +++ b/subaligner/translator.py @@ -10,7 +10,7 @@ from .logger import Logger -class Translator(Singleton): +class Translator(metaclass=Singleton): """Translate subtitles. """ diff --git a/tests/integration/feature/subaligner.feature b/tests/integration/feature/subaligner.feature index 4b16421..acefc16 100644 --- a/tests/integration/feature/subaligner.feature +++ b/tests/integration/feature/subaligner.feature @@ -175,7 +175,7 @@ Feature: Subaligner CLI | subaligner_2pass | | | subaligner | dual | - @quality-management + @quality-control Scenario Outline: Test exit when alignment log loss is too high Given I have a video file "test.mp4" And I have a subtitle file "test.srt" @@ -302,3 +302,25 @@ Feature: Subaligner CLI | subaligner_1pass | | subaligner_2pass | | subaligner | + + @manual_shift + Scenario Outline: Shift the subtitle by offset in seconds + Given I have a subtitle file + When I run the manual shift with offset of in seconds + Then a new subtitle file is generated + Examples: + | subtitle-in | subtitle-out | offset | + | "test.srt" | "test_shifted.srt" | 1.1 | + | "test.ttml" | "test_shifted.ttml" | 2.2 | + | "test.xml" | "test_shifted.xml" | 3 | + | "test.dfxp" | "test_shifted.dfxp" | 4.25 | + | "test.vtt" | "test_shifted.vtt" | +0 | + | "test.sami" | "test_shifted.sami" | 0 | + | "test.ssa" | "test_shifted.ssa" | -0 | + | "test.ass" | "test_shifted.ass" | -1.1 | + | "test.sub" | "test_shifted.sub" | -2.2 | + | "test.tmp" | "test_shifted.tmp" | -3 | + | "test.smi" | "test_shifted.smi" | -4.25 | + | "test.scc" | "test_shifted.scc" | 1.1 | + | "test.sbv" | "test_shifted.sbv" | 2.2 | + | "test.ytt" | "test_shifted.ytt" | 3 | diff --git a/tests/integration/radish/step.py b/tests/integration/radish/step.py index a9f8a46..cdf8ea2 100644 --- a/tests/integration/radish/step.py +++ b/tests/integration/radish/step.py @@ -26,6 +26,29 @@ def subtitle_file(step, file_name): step.context.subtitle_path_or_selector = os.path.join(PWD, "..", "..", "subaligner", "resource", file_name).replace("[]", " ") +@given('I have a list of subtitle files "{file_names:S}"') +def subtitle_file_list(step, file_names): + step.context.subtitle_path_or_selector = [os.path.join(PWD, "..", "..", "subaligner", "resource", file_name).replace("[]", " ") for file_name in file_names.split(",")] + + +@when('I run the alignment with subaligner on all of them') +def run_subaligner_on_multi_subtitles(step): + process = subprocess.Popen([ + os.path.join(PWD, "..", "..", "..", "bin", "subaligner"), + "-m", "single", + "-v", step.context.video_file_path, + "-q"] + [["-s", path] for path in step.context.subtitle_path_or_selector], shell=False) + step.context.exit_code = process.wait(timeout=WAIT_TIMEOUT_IN_SECONDS) + + +@then('a list of subtitle files "{file_names:S}" are generated') +def expect_result_list(step, file_names): + for file_name in file_names.split(","): + output_file_path = os.path.join(step.context.aligning_output, file_name) + assert os.path.isfile(output_file_path) is True + assert step.context.exit_code == 0 + + @given('I have selector "{selector:S}" for the embedded subtitle') def subtitle_selector(step, selector): step.context.subtitle_path_or_selector = selector @@ -49,6 +72,17 @@ def run_subaligner(step, aligner, mode): step.context.exit_code = process.wait(timeout=WAIT_TIMEOUT_IN_SECONDS) +@when("I run the manual shift with offset of {offset_seconds:g} in seconds") +def run_subaligner_manual_shift(step, offset_seconds): + process = subprocess.Popen([ + os.path.join(PWD, "..", "..", "..", "bin", "subaligner"), + "-m", "shift", + "-s", step.context.subtitle_path_or_selector, + "-os", str(offset_seconds), + "-q"], shell=False) + step.context.exit_code = process.wait(timeout=WAIT_TIMEOUT_IN_SECONDS) + + @when("I run the alignment with {aligner:S} on them with {mode:S} stage and {language_pair:S} for translation") def run_subaligner_with_translation(step, aligner, mode, language_pair): if mode == "": diff --git a/tests/subaligner/test_singleton.py b/tests/subaligner/test_singleton.py index db8e187..a8c2e13 100644 --- a/tests/subaligner/test_singleton.py +++ b/tests/subaligner/test_singleton.py @@ -4,7 +4,7 @@ class SingletonTests(unittest.TestCase): def test_singleton(self): - class Single(Singleton): + class Single(metaclass=Singleton): pass a = Single()