mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-07-31 10:07:37 -04:00
1044 lines
39 KiB
Python
1044 lines
39 KiB
Python
#!/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
|
|
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,
|
|
DEFAULT_MIN_SCORE,
|
|
DEFAULT_QUALITY_MAX_OFFSET_SECONDS,
|
|
DEFAULT_MAX_FRAMERATE_DEVIATION,
|
|
is_remote_url,
|
|
)
|
|
from ffsubsync.ffmpeg_utils import ffmpeg_bin_path
|
|
from ffsubsync.sklearn_shim import Pipeline, TransformerMixin
|
|
from ffsubsync.speech_transformers import (
|
|
VideoSpeechTransformer,
|
|
MultiSegmentVideoSpeechTransformer,
|
|
DeserializeSpeechTransformer,
|
|
PGSSpeechTransformer,
|
|
ProgressInfo,
|
|
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 _resolve_srtout(args: argparse.Namespace, srtin: Optional[str]) -> Optional[str]:
|
|
"""Decide where the synced subtitles for ``srtin`` should be written.
|
|
|
|
Overwriting the input takes precedence; otherwise, when subtitles were
|
|
auto-detected from the reference, write a `<name>.synced.srt` next to each
|
|
input; otherwise fall back to the explicit (possibly ``None``) output.
|
|
"""
|
|
if args.overwrite_input:
|
|
return srtin
|
|
if getattr(args, "auto_srtout", False) and srtin is not None:
|
|
return "{}.synced.srt".format(os.path.splitext(srtin)[0])
|
|
return args.srtout
|
|
|
|
|
|
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 assess_alignment_quality(
|
|
best_score: float,
|
|
offset_seconds: float,
|
|
scale_factor: float,
|
|
*,
|
|
min_score: float,
|
|
max_offset_seconds: float,
|
|
max_framerate_deviation: float,
|
|
) -> List[str]:
|
|
"""Return reasons an alignment looks too low-quality to trust (empty = trust it).
|
|
|
|
Used by --skip-sync-on-low-quality to decide whether to leave the subtitles
|
|
unmodified rather than apply a probably-wrong shift. A negative score means the
|
|
best alignment is anti-correlated; an implausibly large offset or framerate
|
|
scale suggests a spurious match.
|
|
"""
|
|
reasons: List[str] = []
|
|
if best_score < min_score:
|
|
reasons.append("score %.1f < %.1f" % (best_score, min_score))
|
|
if abs(offset_seconds) > max_offset_seconds:
|
|
reasons.append(
|
|
"|offset| %.1fs > %.1fs" % (abs(offset_seconds), max_offset_seconds)
|
|
)
|
|
framerate_deviation = abs(scale_factor - 1.0)
|
|
if framerate_deviation > max_framerate_deviation:
|
|
reasons.append(
|
|
"framerate deviation %.3f > %.3f"
|
|
% (framerate_deviation, max_framerate_deviation)
|
|
)
|
|
return reasons
|
|
|
|
|
|
def try_sync(
|
|
args: argparse.Namespace, reference_pipe: Optional[Pipeline], result: Dict[str, Any]
|
|
) -> bool:
|
|
result["sync_was_successful"] = False
|
|
sync_was_successful = True
|
|
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:
|
|
try:
|
|
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 = _resolve_srtout(args, srtin)
|
|
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")
|
|
and reference_pipe[-1].num_frames is not None
|
|
):
|
|
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,
|
|
)
|
|
if best_score < 0:
|
|
sync_was_successful = False
|
|
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)
|
|
low_quality_reasons: List[str] = []
|
|
if getattr(args, "skip_sync_on_low_quality", False):
|
|
low_quality_reasons = assess_alignment_quality(
|
|
best_score,
|
|
offset_seconds,
|
|
scale_step.scale_factor,
|
|
min_score=args.min_score,
|
|
max_offset_seconds=args.quality_max_offset_seconds,
|
|
max_framerate_deviation=args.max_framerate_deviation,
|
|
)
|
|
if low_quality_reasons:
|
|
logger.warning(
|
|
"low-quality alignment (%s); leaving subtitles unmodified",
|
|
"; ".join(low_quality_reasons),
|
|
)
|
|
sync_was_successful = False
|
|
# write the original (unscaled, unshifted) subtitles unchanged
|
|
original_subs = best_srt_pipe.named_steps["parse"].subs_
|
|
out_subs = original_subs.clone_props_for_subs(list(original_subs))
|
|
if args.output_encoding != "same":
|
|
out_subs = out_subs.set_encoding(args.output_encoding)
|
|
logger.info(
|
|
"writing original (unsynced) output to {}".format(srtout or "stdout")
|
|
)
|
|
out_subs.write_file(srtout)
|
|
continue
|
|
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 Exception:
|
|
sync_was_successful = False
|
|
logger.exception("failed to sync %s", srtin)
|
|
else:
|
|
result["offset_seconds"] = offset_seconds
|
|
result["framerate_scale_factor"] = scale_step.scale_factor
|
|
result["sync_was_successful"] = sync_was_successful
|
|
return sync_was_successful
|
|
|
|
|
|
def make_reference_pipe(
|
|
args: argparse.Namespace,
|
|
progress_handler: Optional[Callable[[ProgressInfo], None]] = None,
|
|
) -> Pipeline:
|
|
pgs_stream = getattr(args, "pgs_ref_stream", None)
|
|
if pgs_stream is not None:
|
|
# "auto" (bare --pgs-ref-stream flag) → let PGSSpeechTransformer auto-detect
|
|
resolved_stream: Optional[str] = None if pgs_stream == "auto" else pgs_stream
|
|
if resolved_stream is not None and not resolved_stream.startswith("0:"):
|
|
resolved_stream = "0:" + resolved_stream
|
|
return Pipeline(
|
|
[
|
|
(
|
|
"speech_extract",
|
|
PGSSpeechTransformer(
|
|
sample_rate=SAMPLE_RATE,
|
|
start_seconds=args.start_seconds,
|
|
ffmpeg_path=args.ffmpeg_path,
|
|
ref_stream=resolved_stream,
|
|
gui_mode=args.gui_mode,
|
|
),
|
|
),
|
|
]
|
|
)
|
|
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
|
|
if getattr(args, "multi_segment_sync", False):
|
|
return Pipeline(
|
|
[
|
|
(
|
|
"speech_extract",
|
|
MultiSegmentVideoSpeechTransformer(
|
|
vad=vad,
|
|
sample_rate=SAMPLE_RATE,
|
|
frame_rate=args.frame_rate,
|
|
non_speech_label=args.non_speech_label,
|
|
segment_count=getattr(args, "segment_count", 8),
|
|
skip_intro_outro=getattr(
|
|
args, "skip_intro_outro", False
|
|
),
|
|
parallel_workers=getattr(args, "parallel_workers", 4),
|
|
ffmpeg_path=args.ffmpeg_path,
|
|
ref_stream=ref_stream,
|
|
vlc_mode=args.vlc_mode,
|
|
gui_mode=args.gui_mode,
|
|
),
|
|
),
|
|
]
|
|
)
|
|
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,
|
|
max_duration_seconds=getattr(
|
|
args, "max_duration_seconds", None
|
|
),
|
|
extract_audio_first=getattr(
|
|
args, "extract_audio_first", False
|
|
),
|
|
progress_handler=progress_handler,
|
|
),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
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 _detect_srtin_from_reference(reference: str) -> List[str]:
|
|
"""Find subtitle files that sit next to the reference and share its name.
|
|
|
|
Matches `<reference-stem>.srt` and `<reference-stem>.<suffix>.srt` (e.g.
|
|
`movie.srt`, `movie.en.srt` for a `movie.mkv` reference) in the reference's
|
|
own directory, so detection works regardless of the process's current
|
|
working directory. The reference itself is never returned, so this is safe
|
|
even when the reference is already a subtitle file.
|
|
"""
|
|
reference_dir = os.path.dirname(reference) or "."
|
|
reference_stem = os.path.splitext(os.path.basename(reference))[0]
|
|
reference_abspath = os.path.abspath(reference)
|
|
matches = []
|
|
for name in sorted(os.listdir(reference_dir)):
|
|
stem, ext = os.path.splitext(name)
|
|
if ext.lower() != ".srt":
|
|
continue
|
|
if name.endswith(".synced.srt"):
|
|
continue # skip our own previously-synced outputs so re-runs are idempotent
|
|
if stem != reference_stem and not stem.startswith(reference_stem + "."):
|
|
continue
|
|
path = os.path.join(reference_dir, name)
|
|
if os.path.abspath(path) == reference_abspath:
|
|
continue
|
|
matches.append(path)
|
|
return matches
|
|
|
|
|
|
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")
|
|
elif (
|
|
args.reference is not None
|
|
and not is_remote_url(args.reference) # can't list a remote dir for sibling srts
|
|
and args.extract_subs_from_stream is None
|
|
and not args.gui_mode
|
|
and not args.make_test_case
|
|
and sys.stdin.isatty() # don't hijack subtitles piped in on stdin
|
|
):
|
|
# No input subtitles were given (and nothing is piped in), so look for
|
|
# subtitle files alongside the reference that share its name.
|
|
logger.info("no input srt specified; detecting input srt from reference")
|
|
detected = _detect_srtin_from_reference(args.reference)
|
|
if detected:
|
|
for path in detected:
|
|
logger.info("detected input srt: %s", path)
|
|
args.srtin = detected
|
|
if len(detected) > 1 and args.srtout is not None:
|
|
raise ValueError(
|
|
"detected multiple input srt files but an output file was "
|
|
"specified; re-run with --overwrite-input or a single input"
|
|
)
|
|
if args.srtout is None and not args.overwrite_input:
|
|
args.auto_srtout = True
|
|
logger.info(
|
|
"writing synced output alongside each input as "
|
|
"<name>.synced.srt; pass --overwrite-input to overwrite the "
|
|
"input file(s) in place instead"
|
|
)
|
|
else:
|
|
logger.info("no input srt detected from reference")
|
|
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 is_remote_url(args.reference) # ffmpeg streams URLs directly; no local file to check
|
|
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],
|
|
progress_handler: Optional[Callable[[ProgressInfo], None]] = None,
|
|
) -> 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, progress_handler=progress_handler)
|
|
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],
|
|
progress_handler: Optional[Callable[[ProgressInfo], None]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Synchronize subtitles.
|
|
|
|
``progress_handler``, if given, is called repeatedly during reference speech
|
|
extraction with a :class:`~ffsubsync.speech_transformers.ProgressInfo`
|
|
describing how much of the reference audio has been processed. This lets a
|
|
host application (e.g. a web UI) display sync progress. The handler is only
|
|
invoked for the video-reference path; exceptions it raises are logged and
|
|
swallowed so a buggy handler cannot abort syncing.
|
|
"""
|
|
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, progress_handler=progress_handler
|
|
)
|
|
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). If omitted (and nothing is "
|
|
"piped in), subtitles sharing the reference's name in its directory "
|
|
"are auto-detected (e.g. `movie.srt`, `movie.en.srt` for `movie.mkv`) "
|
|
"and each is synced to a `<name>.synced.srt` next to it; pass "
|
|
"--overwrite-input to overwrite the detected file(s) in place."
|
|
),
|
|
)
|
|
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`"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--pgs-ref-stream",
|
|
"--pgsstream",
|
|
nargs="?",
|
|
const="auto",
|
|
default=None,
|
|
help=(
|
|
"Use a PGS (Presentation Graphic Stream) image-based subtitle track from "
|
|
"the reference MKV as the sync reference instead of audio VAD. "
|
|
"Optionally specify the stream (leading `0:` is optional, e.g. `s:0` or `3`). "
|
|
"Omit the value to auto-detect the first hdmv_pgs_subtitle track. "
|
|
"Example: `ffs ref.mkv -i in.srt -o out.srt --pgs-ref-stream` (auto) "
|
|
"or `ffs ref.mkv -i in.srt -o out.srt --pgs-ref-stream s:2` (explicit)."
|
|
),
|
|
)
|
|
|
|
|
|
def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"-v",
|
|
"--version",
|
|
action="version",
|
|
version="{package} {version}".format(
|
|
package=__package__ or "ffsubsync", 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(
|
|
"--max-duration-seconds",
|
|
type=float,
|
|
default=None,
|
|
help="If specified, only process the first this-many seconds of the "
|
|
"reference (measured from --start-seconds). Useful for speeding up "
|
|
"long or remote references, since ffmpeg stops reading/downloading "
|
|
"once this duration is reached.",
|
|
)
|
|
parser.add_argument(
|
|
"--extract-audio-first",
|
|
action="store_true",
|
|
help="For remote URL references, first copy the audio track to a local "
|
|
"temp file (no re-encode) and run speech detection on that, instead of "
|
|
"streaming the full container over the network during detection. Can be "
|
|
"more stable on flaky connections; ignored for local references.",
|
|
)
|
|
parser.add_argument(
|
|
"--multi-segment-sync",
|
|
action="store_true",
|
|
help="Sample a few short segments spread across the reference and run "
|
|
"speech detection only on those, instead of the whole reference. Speeds "
|
|
"up long or remote references; the usual framerate and offset search is "
|
|
"unchanged. Only applies to video / audio references.",
|
|
)
|
|
parser.add_argument(
|
|
"--segment-count",
|
|
type=int,
|
|
default=8,
|
|
help="Number of segments to sample for --multi-segment-sync (default=8).",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-intro-outro",
|
|
action="store_true",
|
|
help="With --multi-segment-sync, skip the first 30s and last 60s of the "
|
|
"reference when placing segments (intros/credits often lack dialogue).",
|
|
)
|
|
parser.add_argument(
|
|
"--parallel-workers",
|
|
type=int,
|
|
default=4,
|
|
help="How many segments to extract in parallel for --multi-segment-sync "
|
|
"(default=4); useful for overlapping downloads of remote references.",
|
|
)
|
|
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(
|
|
"--skip-sync-on-low-quality",
|
|
action="store_true",
|
|
help="If the alignment looks untrustworthy (see the thresholds below), "
|
|
"leave the subtitles unmodified instead of applying a probably-wrong "
|
|
"sync. Useful for batch jobs where a bad sync is worse than none.",
|
|
)
|
|
parser.add_argument(
|
|
"--min-score",
|
|
type=float,
|
|
default=DEFAULT_MIN_SCORE,
|
|
help="With --skip-sync-on-low-quality, reject alignments scoring below "
|
|
"this. The score's magnitude is not normalized, but its sign is "
|
|
"meaningful, so the default of %.1f rejects only anti-correlated "
|
|
"(clearly wrong) alignments." % DEFAULT_MIN_SCORE,
|
|
)
|
|
parser.add_argument(
|
|
"--quality-max-offset-seconds",
|
|
type=float,
|
|
default=DEFAULT_QUALITY_MAX_OFFSET_SECONDS,
|
|
help="With --skip-sync-on-low-quality, reject alignments whose offset "
|
|
"exceeds this many seconds (default=%.1f)."
|
|
% DEFAULT_QUALITY_MAX_OFFSET_SECONDS,
|
|
)
|
|
parser.add_argument(
|
|
"--max-framerate-deviation",
|
|
type=float,
|
|
default=DEFAULT_MAX_FRAMERATE_DEVIATION,
|
|
help="With --skip-sync-on-low-quality, reject alignments whose framerate "
|
|
"scale deviates from 1.0 by more than this. The default of %.2f permits "
|
|
"every framerate correction ffsubsync can make (so it never rejects a "
|
|
"legitimate one); tighten it only when you know the framerate should not "
|
|
"change." % DEFAULT_MAX_FRAMERATE_DEVIATION,
|
|
)
|
|
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",
|
|
"fused",
|
|
"fused:weighted",
|
|
"fused:intersection",
|
|
"fused:union",
|
|
],
|
|
default=None,
|
|
help="Which voice activity detector to use for speech extraction "
|
|
"(if using video / audio as a reference, default={}). The `fused` "
|
|
"options combine webrtc and silero and require the optional silero "
|
|
"dependency (torch).".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())
|