bazarr/libs/ffsubsync/ffsubsync.py

732 lines
26 KiB
Python
Executable File

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import argparse
from datetime import datetime
import logging
import os
import shutil
import subprocess
import sys
from typing import cast, Any, Callable, Dict, List, Optional, Tuple, Union
import numpy as np
from ffsubsync.aligners import (
FFTAligner,
MaxScoreAligner,
FailedToFindAlignmentException,
)
from ffsubsync.constants import (
DEFAULT_APPLY_OFFSET_SECONDS,
DEFAULT_FRAME_RATE,
DEFAULT_MAX_OFFSET_SECONDS,
DEFAULT_MAX_SUBTITLE_SECONDS,
DEFAULT_NON_SPEECH_LABEL,
DEFAULT_START_SECONDS,
DEFAULT_VAD,
DEFAULT_ENCODING,
FRAMERATE_RATIOS,
SAMPLE_RATE,
SUBTITLE_EXTENSIONS,
)
from ffsubsync.ffmpeg_utils import ffmpeg_bin_path
from ffsubsync.sklearn_shim import Pipeline, TransformerMixin
from ffsubsync.speech_transformers import (
VideoSpeechTransformer,
DeserializeSpeechTransformer,
make_subtitle_speech_pipeline,
)
from ffsubsync.subtitle_parser import make_subtitle_parser
from ffsubsync.subtitle_transformers import SubtitleMerger, SubtitleShifter
from ffsubsync.version import get_version
logger: logging.Logger = logging.getLogger(__name__)
def override(args: argparse.Namespace, **kwargs: Any) -> Dict[str, Any]:
args_dict = dict(args.__dict__)
args_dict.update(kwargs)
return args_dict
def _ref_format(ref_fname: Optional[str]) -> Optional[str]:
if ref_fname is None:
return None
return ref_fname[-3:]
def make_test_case(
args: argparse.Namespace, npy_savename: Optional[str], sync_was_successful: bool
) -> int:
if npy_savename is None:
raise ValueError("need non-null npy_savename")
tar_dir = "{}.{}".format(
args.reference, datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
)
logger.info("creating test archive {}.tar.gz...".format(tar_dir))
os.mkdir(tar_dir)
try:
log_path = "ffsubsync.log"
if args.log_dir_path is not None and os.path.isdir(args.log_dir_path):
log_path = os.path.join(args.log_dir_path, log_path)
shutil.copy(log_path, tar_dir)
shutil.copy(args.srtin[0], tar_dir)
if sync_was_successful:
shutil.move(args.srtout, tar_dir)
if _ref_format(args.reference) in SUBTITLE_EXTENSIONS:
shutil.copy(args.reference, tar_dir)
elif args.serialize_speech or args.reference == npy_savename:
shutil.copy(npy_savename, tar_dir)
else:
shutil.move(npy_savename, tar_dir)
supported_formats = set(list(zip(*shutil.get_archive_formats()))[0])
preferred_formats = ["gztar", "bztar", "xztar", "zip", "tar"]
for archive_format in preferred_formats:
if archive_format in supported_formats:
shutil.make_archive(tar_dir, archive_format, os.curdir, tar_dir)
break
else:
logger.error(
"failed to create test archive; no formats supported "
"(this should not happen)"
)
return 1
logger.info("...done")
finally:
shutil.rmtree(tar_dir)
return 0
def get_srt_pipe_maker(
args: argparse.Namespace, srtin: Optional[str]
) -> Callable[[Optional[float]], Union[Pipeline, Callable[[float], Pipeline]]]:
if srtin is None:
srtin_format = "srt"
else:
srtin_format = os.path.splitext(srtin)[-1][1:]
parser = make_subtitle_parser(fmt=srtin_format, caching=True, **args.__dict__)
return lambda scale_factor: make_subtitle_speech_pipeline(
**override(args, scale_factor=scale_factor, parser=parser)
)
def get_framerate_ratios_to_try(args: argparse.Namespace) -> List[Optional[float]]:
if args.no_fix_framerate:
return []
else:
framerate_ratios = list(
np.concatenate(
[np.array(FRAMERATE_RATIOS), 1.0 / np.array(FRAMERATE_RATIOS)]
)
)
if args.gss:
framerate_ratios.append(None)
return framerate_ratios
def try_sync(
args: argparse.Namespace, reference_pipe: Optional[Pipeline], result: Dict[str, Any]
) -> bool:
sync_was_successful = True
exc = None
try:
logger.info(
"extracting speech segments from %s...",
"stdin" if not args.srtin else "subtitles file(s) {}".format(args.srtin),
)
if not args.srtin:
args.srtin = [None]
for srtin in args.srtin:
skip_sync = args.skip_sync or reference_pipe is None
skip_infer_framerate_ratio = (
args.skip_infer_framerate_ratio or reference_pipe is None
)
srtout = srtin if args.overwrite_input else args.srtout
srt_pipe_maker = get_srt_pipe_maker(args, srtin)
framerate_ratios = get_framerate_ratios_to_try(args)
srt_pipes = [srt_pipe_maker(1.0)] + [
srt_pipe_maker(rat) for rat in framerate_ratios
]
for srt_pipe in srt_pipes:
if callable(srt_pipe):
continue
else:
srt_pipe.fit(srtin)
if not skip_infer_framerate_ratio and hasattr(
reference_pipe[-1], "num_frames"
):
inferred_framerate_ratio_from_length = (
float(reference_pipe[-1].num_frames)
/ cast(Pipeline, srt_pipes[0])[-1].num_frames
)
logger.info(
"inferred frameratio ratio: %.3f"
% inferred_framerate_ratio_from_length
)
srt_pipes.append(
cast(
Pipeline, srt_pipe_maker(inferred_framerate_ratio_from_length)
).fit(srtin)
)
logger.info("...done")
logger.info("computing alignments...")
if skip_sync:
best_score = 0.0
best_srt_pipe = cast(Pipeline, srt_pipes[0])
offset_samples = 0
else:
(best_score, offset_samples), best_srt_pipe = MaxScoreAligner(
FFTAligner, srtin, SAMPLE_RATE, args.max_offset_seconds
).fit_transform(
reference_pipe.transform(args.reference),
srt_pipes,
)
logger.info("...done")
offset_seconds = (
offset_samples / float(SAMPLE_RATE) + args.apply_offset_seconds
)
scale_step = best_srt_pipe.named_steps["scale"]
logger.info("score: %.3f", best_score)
logger.info("offset seconds: %.3f", offset_seconds)
logger.info("framerate scale factor: %.3f", scale_step.scale_factor)
output_steps: List[Tuple[str, TransformerMixin]] = [
("shift", SubtitleShifter(offset_seconds))
]
if args.merge_with_reference:
output_steps.append(
("merge", SubtitleMerger(reference_pipe.named_steps["parse"].subs_))
)
output_pipe = Pipeline(output_steps)
out_subs = output_pipe.fit_transform(scale_step.subs_)
if args.output_encoding != "same":
out_subs = out_subs.set_encoding(args.output_encoding)
suppress_output_thresh = args.suppress_output_if_offset_less_than
if offset_seconds >= (suppress_output_thresh or float("-inf")):
logger.info("writing output to {}".format(srtout or "stdout"))
out_subs.write_file(srtout)
else:
logger.warning(
"suppressing output because offset %s was less than suppression threshold %s",
offset_seconds,
args.suppress_output_if_offset_less_than,
)
except FailedToFindAlignmentException as e:
sync_was_successful = False
logger.error(str(e))
except Exception as e:
exc = e
sync_was_successful = False
else:
result["offset_seconds"] = offset_seconds
result["framerate_scale_factor"] = scale_step.scale_factor
finally:
if exc is not None:
raise exc
result["sync_was_successful"] = sync_was_successful
return sync_was_successful
def make_reference_pipe(args: argparse.Namespace) -> Pipeline:
ref_format = _ref_format(args.reference)
if ref_format in SUBTITLE_EXTENSIONS:
if args.vad is not None:
logger.warning("Vad specified, but reference was not a movie")
return cast(
Pipeline,
make_subtitle_speech_pipeline(
fmt=ref_format,
**override(args, encoding=args.reference_encoding or DEFAULT_ENCODING),
),
)
elif ref_format in ("npy", "npz"):
if args.vad is not None:
logger.warning("Vad specified, but reference was not a movie")
return Pipeline(
[("deserialize", DeserializeSpeechTransformer(args.non_speech_label))]
)
else:
vad = args.vad or DEFAULT_VAD
if args.reference_encoding is not None:
logger.warning(
"Reference srt encoding specified, but reference was a video file"
)
ref_stream = args.reference_stream
if ref_stream is not None and not ref_stream.startswith("0:"):
ref_stream = "0:" + ref_stream
return Pipeline(
[
(
"speech_extract",
VideoSpeechTransformer(
vad=vad,
sample_rate=SAMPLE_RATE,
frame_rate=args.frame_rate,
non_speech_label=args.non_speech_label,
start_seconds=args.start_seconds,
ffmpeg_path=args.ffmpeg_path,
ref_stream=ref_stream,
vlc_mode=args.vlc_mode,
gui_mode=args.gui_mode,
),
),
]
)
def extract_subtitles_from_reference(args: argparse.Namespace) -> int:
stream = args.extract_subs_from_stream
if not stream.startswith("0:s:"):
stream = "0:s:{}".format(stream)
elif not stream.startswith("0:") and stream.startswith("s:"):
stream = "0:{}".format(stream)
if not stream.startswith("0:s:"):
logger.error(
"invalid stream for subtitle extraction: %s", args.extract_subs_from_stream
)
ffmpeg_args = [
ffmpeg_bin_path("ffmpeg", args.gui_mode, ffmpeg_resources_path=args.ffmpeg_path)
]
ffmpeg_args.extend(
[
"-y",
"-nostdin",
"-loglevel",
"fatal",
"-i",
args.reference,
"-map",
"{}".format(stream),
"-f",
"srt",
]
)
if args.srtout is None:
ffmpeg_args.append("-")
else:
ffmpeg_args.append(args.srtout)
logger.info(
"attempting to extract subtitles to {} ...".format(
"stdout" if args.srtout is None else args.srtout
)
)
retcode = subprocess.call(ffmpeg_args)
if retcode == 0:
logger.info("...done")
else:
logger.error(
"ffmpeg unable to extract subtitles from reference; return code %d", retcode
)
return retcode
def validate_args(args: argparse.Namespace) -> None:
if args.vlc_mode:
logger.setLevel(logging.CRITICAL)
if args.reference is None:
if args.apply_offset_seconds == 0 or not args.srtin:
raise ValueError(
"`reference` required unless `--apply-offset-seconds` specified"
)
if args.apply_offset_seconds != 0:
if not args.srtin:
args.srtin = [args.reference]
if not args.srtin:
raise ValueError(
"at least one of `srtin` or `reference` must be specified to apply offset seconds"
)
if args.srtin:
if len(args.srtin) > 1 and not args.overwrite_input:
raise ValueError(
"cannot specify multiple input srt files without overwriting"
)
if len(args.srtin) > 1 and args.make_test_case:
raise ValueError("cannot specify multiple input srt files for test cases")
if len(args.srtin) > 1 and args.gui_mode:
raise ValueError("cannot specify multiple input srt files in GUI mode")
if (
args.make_test_case and not args.gui_mode
): # this validation not necessary for gui mode
if not args.srtin or args.srtout is None:
raise ValueError(
"need to specify input and output srt files for test cases"
)
if args.overwrite_input:
if args.extract_subs_from_stream is not None:
raise ValueError(
"input overwriting not allowed for extracting subtitles from reference"
)
if not args.srtin:
raise ValueError(
"need to specify input srt if --overwrite-input "
"is specified since we cannot overwrite stdin"
)
if args.srtout is not None:
raise ValueError(
"overwrite input set but output file specified; "
"refusing to run in case this was not intended"
)
if args.extract_subs_from_stream is not None:
if args.make_test_case:
raise ValueError("test case is for sync and not subtitle extraction")
if args.srtin:
raise ValueError(
"stream specified for reference subtitle extraction; "
"-i flag for sync input not allowed"
)
def validate_file_permissions(args: argparse.Namespace) -> None:
error_string_template = (
"unable to {action} {file}; "
"try ensuring file exists and has correct permissions"
)
if args.reference is not None and not os.access(args.reference, os.R_OK):
raise ValueError(
error_string_template.format(action="read reference", file=args.reference)
)
if args.srtin:
for srtin in args.srtin:
if srtin is not None and not os.access(srtin, os.R_OK):
raise ValueError(
error_string_template.format(
action="read input subtitles", file=srtin
)
)
if (
args.srtout is not None
and os.path.exists(args.srtout)
and not os.access(args.srtout, os.W_OK)
):
raise ValueError(
error_string_template.format(
action="write output subtitles", file=args.srtout
)
)
if args.make_test_case or args.serialize_speech:
npy_savename = os.path.splitext(args.reference)[0] + ".npz"
if os.path.exists(npy_savename) and not os.access(npy_savename, os.W_OK):
raise ValueError(
"unable to write test case file archive %s (try checking permissions)"
% npy_savename
)
def _setup_logging(
args: argparse.Namespace,
) -> Tuple[Optional[str], Optional[logging.FileHandler]]:
log_handler = None
log_path = None
if args.make_test_case or args.log_dir_path is not None:
log_path = "ffsubsync.log"
if args.log_dir_path is not None and os.path.isdir(args.log_dir_path):
log_path = os.path.join(args.log_dir_path, log_path)
log_handler = logging.FileHandler(log_path)
logger.addHandler(log_handler)
logger.info("this log will be written to %s", os.path.abspath(log_path))
return log_path, log_handler
def _npy_savename(args: argparse.Namespace) -> str:
return os.path.splitext(args.reference)[0] + ".npz"
def _run_impl(args: argparse.Namespace, result: Dict[str, Any]) -> bool:
if args.extract_subs_from_stream is not None:
result["retval"] = extract_subtitles_from_reference(args)
return True
if args.srtin is not None and (
args.reference is None
or (len(args.srtin) == 1 and args.srtin[0] == args.reference)
):
return try_sync(args, None, result)
reference_pipe = make_reference_pipe(args)
logger.info("extracting speech segments from reference '%s'...", args.reference)
reference_pipe.fit(args.reference)
logger.info("...done")
if args.make_test_case or args.serialize_speech:
logger.info("serializing speech...")
np.savez_compressed(
_npy_savename(args), speech=reference_pipe.transform(args.reference)
)
logger.info("...done")
if not args.srtin:
logger.info(
"unsynchronized subtitle file not specified; skipping synchronization"
)
return False
return try_sync(args, reference_pipe, result)
def validate_and_transform_args(
parser_or_args: Union[argparse.ArgumentParser, argparse.Namespace]
) -> Optional[argparse.Namespace]:
if isinstance(parser_or_args, argparse.Namespace):
parser = None
args = parser_or_args
else:
parser = parser_or_args
args = parser.parse_args()
try:
validate_args(args)
except ValueError as e:
logger.error(e)
if parser is not None:
parser.print_usage()
return None
if args.gui_mode and args.srtout is None:
args.srtout = "{}.synced.srt".format(os.path.splitext(args.srtin[0])[0])
try:
validate_file_permissions(args)
except ValueError as e:
logger.error(e)
return None
ref_format = _ref_format(args.reference)
if args.merge_with_reference and ref_format not in SUBTITLE_EXTENSIONS:
logger.error(
"merging synced output with reference only valid "
"when reference composed of subtitles"
)
return None
return args
def run(
parser_or_args: Union[argparse.ArgumentParser, argparse.Namespace]
) -> Dict[str, Any]:
sync_was_successful = False
result = {
"retval": 0,
"offset_seconds": None,
"framerate_scale_factor": None,
}
args = validate_and_transform_args(parser_or_args)
if args is None:
result["retval"] = 1
return result
log_path, log_handler = _setup_logging(args)
try:
sync_was_successful = _run_impl(args, result)
result["sync_was_successful"] = sync_was_successful
return result
finally:
if log_handler is not None and log_path is not None:
log_handler.close()
logger.removeHandler(log_handler)
if args.make_test_case:
result["retval"] += make_test_case(
args, _npy_savename(args), sync_was_successful
)
if args.log_dir_path is None or not os.path.isdir(args.log_dir_path):
os.remove(log_path)
def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"reference",
nargs="?",
help=(
"Reference (video, subtitles, or a numpy array with VAD speech) "
"to which to synchronize input subtitles."
),
)
parser.add_argument(
"-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)."
)
parser.add_argument(
"-o", "--srtout", help="Output subtitles file (default=stdout)."
)
parser.add_argument(
"--merge-with-reference",
"--merge",
action="store_true",
help="Merge reference subtitles with synced output subtitles.",
)
parser.add_argument(
"--make-test-case",
"--create-test-case",
action="store_true",
help="If specified, serialize reference speech to a numpy array, "
"and create an archive with input/output subtitles "
"and serialized speech.",
)
parser.add_argument(
"--reference-stream",
"--refstream",
"--reference-track",
"--reftrack",
default=None,
help=(
"Which stream/track in the video file to use as reference, "
"formatted according to ffmpeg conventions. For example, 0:s:0 "
"uses the first subtitle track; 0:a:3 would use the third audio track. "
"You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. "
"Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`"
),
)
def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-v",
"--version",
action="version",
version="{package} {version}".format(
package=__package__, version=get_version()
),
)
parser.add_argument(
"--overwrite-input",
action="store_true",
help=(
"If specified, will overwrite the input srt "
"instead of writing the output to a new file."
),
)
parser.add_argument(
"--encoding",
default=DEFAULT_ENCODING,
help="What encoding to use for reading input subtitles "
"(default=%s)." % DEFAULT_ENCODING,
)
parser.add_argument(
"--max-subtitle-seconds",
type=float,
default=DEFAULT_MAX_SUBTITLE_SECONDS,
help="Maximum duration for a subtitle to appear on-screen "
"(default=%.3f seconds)." % DEFAULT_MAX_SUBTITLE_SECONDS,
)
parser.add_argument(
"--start-seconds",
type=int,
default=DEFAULT_START_SECONDS,
help="Start time for processing "
"(default=%d seconds)." % DEFAULT_START_SECONDS,
)
parser.add_argument(
"--max-offset-seconds",
type=float,
default=DEFAULT_MAX_OFFSET_SECONDS,
help="The max allowed offset seconds for any subtitle segment "
"(default=%d seconds)." % DEFAULT_MAX_OFFSET_SECONDS,
)
parser.add_argument(
"--apply-offset-seconds",
type=float,
default=DEFAULT_APPLY_OFFSET_SECONDS,
help="Apply a predefined offset in seconds to all subtitle segments "
"(default=%d seconds)." % DEFAULT_APPLY_OFFSET_SECONDS,
)
parser.add_argument(
"--frame-rate",
type=int,
default=DEFAULT_FRAME_RATE,
help="Frame rate for audio extraction (default=%d)." % DEFAULT_FRAME_RATE,
)
parser.add_argument(
"--skip-infer-framerate-ratio",
action="store_true",
help="If set, do not try to infer framerate ratio based on duration ratio.",
)
parser.add_argument(
"--non-speech-label",
type=float,
default=DEFAULT_NON_SPEECH_LABEL,
help="Label to use for frames detected as non-speech (default=%f)"
% DEFAULT_NON_SPEECH_LABEL,
)
parser.add_argument(
"--output-encoding",
default="utf-8",
help="What encoding to use for writing output subtitles "
'(default=utf-8). Can indicate "same" to use same '
"encoding as that of the input.",
)
parser.add_argument(
"--reference-encoding",
help="What encoding to use for reading / writing reference subtitles "
"(if applicable, default=infer).",
)
parser.add_argument(
"--vad",
choices=[
"subs_then_webrtc",
"webrtc",
"subs_then_auditok",
"auditok",
"subs_then_silero",
"silero",
],
default=None,
help="Which voice activity detector to use for speech extraction "
"(if using video / audio as a reference, default={}).".format(DEFAULT_VAD),
)
parser.add_argument(
"--no-fix-framerate",
action="store_true",
help="If specified, subsync will not attempt to correct a framerate "
"mismatch between reference and subtitles.",
)
parser.add_argument(
"--serialize-speech",
action="store_true",
help="If specified, serialize reference speech to a numpy array.",
)
parser.add_argument(
"--extract-subs-from-stream",
"--extract-subtitles-from-stream",
default=None,
help="If specified, do not attempt sync; instead, just extract subtitles"
" from the specified stream using the reference.",
)
parser.add_argument(
"--suppress-output-if-offset-less-than",
type=float,
default=None,
help="If specified, do not produce output if offset below provided threshold.",
)
parser.add_argument(
"--ffmpeg-path",
"--ffmpegpath",
default=None,
help="Where to look for ffmpeg and ffprobe. Uses the system PATH by default.",
)
parser.add_argument(
"--log-dir-path",
default=None,
help=(
"If provided, will save log file ffsubsync.log to this path "
"(must be an existing directory)."
),
)
parser.add_argument(
"--gss",
action="store_true",
help="If specified, use golden-section search to try to find"
"the optimal framerate ratio between video and subtitles.",
)
parser.add_argument(
"--strict",
action="store_true",
help="If specified, refuse to parse srt files with formatting issues.",
)
parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS)
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Synchronize subtitles with video.")
add_main_args_for_cli(parser)
add_cli_only_args(parser)
return parser
def main() -> int:
parser = make_parser()
return run(parser)["retval"]
if __name__ == "__main__":
sys.exit(main())