Ctrl K
ComunidadGuías…

backend/subtitle_timing.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Does a subtitle's timing fit the video it is for?

Prod 2026-11 (Mushoku Tensei S01): three English sidecars passed every check
and sat next to the video for months. One ran 6 s late; two matched no line
of the episode at all (another cut), one of them ending 81 s after the video.
Name, episode number, language, length and kind were all right — only the
timing was wrong, and nothing looked at it.

The check compares when lines start. Two subtitles of the same video start
their lines at the same moments whatever their language, because both follow
the speech: on the reference library 85-100 % of a German track's lines found
an English line within 0.3 s. A track for another cut or episode finds 3-28 %
at any offset, and a delayed one finds the 85 % at a constant offset, which
can simply be undone.

A reference is needed for that, and a reference can be wrong too. An embedded
text track is part of the release and trusted; a sidecar of another language
is used only when no embedded track exists, and its verdict alone never
rejects every candidate (see ``providers.download_manager``).
"""

from __future__ import annotations

import bisect
import logging
from collections import Counter
from dataclasses import dataclass

logger = logging.getLogger(__name__)

#: Two lines "start together" within this many seconds.
TOLERANCE_S = 0.3
#: Offsets searched, in each direction.
MAX_OFFSET_S = 120.0
#: Histogram bucket for the offset search.
_BUCKET_S = 0.1
#: Share of lines that must start together for the timing to fit.
FIT_MATCH = 0.6
#: Below this share at every offset the subtitle belongs to another cut.
MISMATCH_MATCH = 0.35
#: A shift smaller than this is jitter between releases, not a delay.
SHIFT_MIN_S = 0.5
#: Fewer dialogue lines than this on either side give no verdict.
MIN_CUES = 40
#: More than this gives no verdict either: a feature film holds ~2,500 lines,
#: and the content comes from providers, so the work must stay bounded.
MAX_CUES = 6000
#: Line pairs the offset histogram may count before it stops early.
MAX_PAIRS = 2_000_000

FIT = "fit"
SHIFT = "shift"
MISMATCH = "mismatch"
UNCERTAIN = "uncertain"


@dataclass(frozen=True)
class Alignment:
    offset_s: float
    match: float
    verdict: str


def _matches_at(reference: list[float], candidate: list[float], offset: float) -> int:
    """Reference lines with a candidate line starting at ``t + offset`` (± tolerance)."""
    hits = 0
    for t in reference:
        i = bisect.bisect_left(candidate, t + offset - TOLERANCE_S)
        if i < len(candidate) and candidate[i] <= t + offset + TOLERANCE_S:
            hits += 1
    return hits


def align(reference: list[float], candidate: list[float]) -> Alignment | None:
    """Best constant offset of ``candidate`` against ``reference``; None without a verdict.

    ``offset_s`` is how late the candidate runs: shifting it by ``-offset_s``
    lines it up. ``match`` is the share of the shorter track's lines that start
    together at that offset.
    """
    if not (MIN_CUES <= len(reference) <= MAX_CUES and MIN_CUES <= len(candidate) <= MAX_CUES):
        return None
    # Histogram of pairwise start differences within the search window: the
    # true offset collects one vote per line pair, any other offset only
    # chance pairs. Linear in lines x lines-per-window.
    votes: Counter[int] = Counter()
    lo = pairs = 0
    for t in reference:
        if pairs > MAX_PAIRS:
            return None
        while lo < len(candidate) and candidate[lo] < t - MAX_OFFSET_S:
            lo += 1
        j = lo
        while j < len(candidate) and candidate[j] <= t + MAX_OFFSET_S:
            votes[round((candidate[j] - t) / _BUCKET_S)] += 1
            j += 1
        pairs += j - lo
    if not votes:
        return Alignment(0.0, 0.0, MISMATCH)
    # Score the strongest buckets exactly (a bucket edge can split a peak).
    best_offset, best_hits = 0.0, -1
    for bucket, _count in votes.most_common(5):
        for delta in (-1, 0, 1):
            offset = (bucket + delta) * _BUCKET_S
            hits = _matches_at(reference, candidate, offset)
            if hits > best_hits or (hits == best_hits and abs(offset) < abs(best_offset)):
                best_offset, best_hits = offset, hits
    match = best_hits / min(len(reference), len(candidate))
    offset = round(best_offset, 1)
    if match >= FIT_MATCH:
        verdict = FIT if abs(offset) <= SHIFT_MIN_S else SHIFT
    elif match < MISMATCH_MATCH:
        verdict = MISMATCH
    else:
        verdict = UNCERTAIN
    return Alignment(offset, round(match, 2), verdict)


def shift_content(content: bytes, offset_s: float) -> bytes | None:
    """``content`` moved ``offset_s`` seconds earlier, format kept; None if unreadable."""
    try:
        import pysubs2

        from subtitle_encoding import ensure_utf8, is_wide_unicode

        if is_wide_unicode(content):
            content = ensure_utf8(content)
        subs = pysubs2.SSAFile.from_string(content.decode("utf-8-sig", errors="replace"))
        if not subs.events:
            return None
        subs.shift(s=-offset_s)
        fmt = subs.format if subs.format in ("ass", "ssa", "srt") else "srt"
        return subs.to_string(fmt).encode("utf-8")
    except Exception as exc:  # noqa: BLE001 — keep the original rather than fail the download
        logger.debug("Subtitle shift skipped: %s", exc)
        return None


@dataclass(frozen=True)
class Reference:
    starts: list[float]
    #: An embedded track is part of the release; a sidecar may itself be wrong.
    trusted: bool
    source: str


# Codecs ffmpeg can turn into SRT for a timing reference (mov_text included:
# MP4 releases carry their only text track that way).
_TEXT_CODECS = frozenset({"subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text"})
_SIDECAR_SKIP_MODIFIERS = frozenset({"forced", "signs"})


def _embedded_starts(video_path: str) -> tuple[list[float], str] | None:
    """Dialogue starts of the first full embedded text track, or None. Never raises."""
    import os
    import subprocess
    import tempfile

    try:
        from remux import _safe_arg_path
        from services.subtitle_health.raw_io import list_subtitle_streams
        from utils.io_timeout import compute_io_timeout

        streams = [s for s in list_subtitle_streams(video_path) if s["codec"] in _TEXT_CODECS]
        streams.sort(key=lambda s: s["forced"])  # full tracks first
        if not streams or streams[0]["forced"]:
            return None
        stream = streams[0]
        fd, tmp_path = tempfile.mkstemp(suffix=".srt")
        os.close(fd)
        try:
            cmd = [
                "ffmpeg", "-v", "error", "-y", "-i", _safe_arg_path(video_path),
                "-map", f"0:s:{stream['sub_index']}", "-c:s", "srt", _safe_arg_path(tmp_path),
            ]  # fmt: skip
            done = subprocess.run(
                cmd, capture_output=True, timeout=compute_io_timeout(video_path, floor=300)
            )
            if done.returncode != 0:
                return None
            with open(tmp_path, "rb") as fh:
                starts = _dialogue_starts(fh.read())
        finally:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
        if starts is None:
            return None
        return starts, f"embedded track {stream['sub_index']} ({stream['lang']})"
    except Exception as exc:  # noqa: BLE001 — no reference rather than a failed download
        logger.debug("Embedded timing reference skipped for %s: %s", video_path, exc)
        return None


def _dialogue_starts(content: bytes) -> list[float] | None:
    from subtitle_dialogue import dialogue_starts

    return dialogue_starts(content)


def _sidecar_starts(video_path: str, exclude_language: str) -> tuple[list[float], str] | None:
    """Dialogue starts of the fullest sidecar in another language, or None. Never raises."""
    try:
        from config_language_data import normalize_language_code
        from services.sidecar_scan import scan_subtitle_sidecars

        exclude = normalize_language_code(exclude_language or "")
        best: tuple[list[float], str] | None = None
        for entry in scan_subtitle_sidecars(video_path):
            if entry.get("combined") or entry.get("modifier") in _SIDECAR_SKIP_MODIFIERS:
                continue
            if normalize_language_code(entry.get("language") or "") == exclude:
                continue
            with open(entry["path"], "rb") as fh:
                starts = _dialogue_starts(fh.read())
            if starts and (best is None or len(starts) > len(best[0])):
                best = (starts, f"sidecar {entry['path']}")
        return best
    except Exception as exc:  # noqa: BLE001 — no reference rather than a failed download
        logger.debug("Sidecar timing reference skipped for %s: %s", video_path, exc)
        return None


def find_reference(video_path: str, language: str) -> Reference | None:
    """A timing reference for a ``language`` subtitle of ``video_path``, or None.

    Never the same language: the file being replaced may be the wrong one.
    """
    if not video_path:
        return None
    embedded = _embedded_starts(video_path)
    if embedded and len(embedded[0]) >= MIN_CUES:
        return Reference(embedded[0], True, embedded[1])
    sidecar = _sidecar_starts(video_path, language)
    if sidecar and len(sidecar[0]) >= MIN_CUES:
        return Reference(sidecar[0], False, sidecar[1])
    return None