Ctrl K
ComunidadGuías…

scripts/verify-swallowed-against-embedded.py

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

Archivo original ↗Preguntar a KiBot
"""Verify swallowed-dialogue candidates against the embedded subtitle track.

The quality-sidecar pre-filter (audit-swallowed-via-quality.py) is cheap but
noisy: on a production library it flagged files whose German sidecar actually
held English text, and files whose style classification simply disagreed with
a name-based guess. Neither is the sanitizer's doing.

This settles each candidate the only way that is exact — pull the ASS track
out of the video and compare it with the translation event for event, the
same rule the sibling audit uses:

    events in the embedded source  -  events in the translation  =  lines lost

and cross-check the number against what the old spanning pattern predicts for
that source. A candidate is only called damaged when both agree.

Usage (inside the container):
    python3 verify-swallowed-against-embedded.py <bases-file>

<bases-file> holds one media-root-relative base path per line (the path of the
translation without its ``.de.ass`` suffix).
"""

import os
import re
import subprocess
import sys
import tempfile

MEDIA_ROOT = os.environ.get("AUDIT_MEDIA_ROOT", "/media")
VIDEO_EXTS = (".mkv", ".mp4", ".m4v", ".avi", ".ts")

TAG_RE = re.compile(r"\{[^}]*\}")
DRAW_ON_RE = re.compile(r"\\p[1-9]", re.IGNORECASE)
DRAW_OFF_RE = re.compile(r"\\p0", re.IGNORECASE)


def swallowed_newlines(text):
    """Lines the old spanning pattern would have deleted. Linear, one pass."""
    lost = spans = 0
    start = None
    for tag in TAG_RE.finditer(text):
        body = tag.group()
        if start is None:
            if DRAW_ON_RE.search(body):
                start = tag.start()
        elif DRAW_OFF_RE.search(body):
            inner = text.count("\n", start, tag.end())
            if inner:
                lost += inner
                spans += 1
            start = None
    return lost, spans


def dialogue_lines(text):
    return text.count("\nDialogue:") + (1 if text.startswith("Dialogue:") else 0)


def find_video(base_abs):
    for ext in VIDEO_EXTS:
        if os.path.exists(base_abs + ext):
            return base_abs + ext
    return None


def ass_stream_indexes(video):
    out = subprocess.run(
        [
            "ffprobe", "-v", "error", "-select_streams", "s",
            "-show_entries", "stream=index,codec_name",
            "-of", "csv=p=0", video,
        ],
        capture_output=True, text=True, timeout=180,
    ).stdout
    idx = []
    for i, line in enumerate(out.strip().split("\n")):
        if not line:
            continue
        parts = line.split(",")
        if len(parts) >= 2 and parts[1].strip() in ("ass", "ssa"):
            idx.append(i)
    return idx


def extract_ass(video, sub_index):
    with tempfile.NamedTemporaryFile(suffix=".ass", delete=False) as tmp:
        path = tmp.name
    r = subprocess.run(
        ["ffmpeg", "-y", "-v", "error", "-i", video, "-map", f"0:s:{sub_index}", "-c:s", "ass", path],
        capture_output=True, text=True, timeout=600,
    )
    if r.returncode != 0:
        os.unlink(path)
        return None
    return path


def main():
    with open(sys.argv[1], encoding="utf-8") as fh:
        bases = [ln.strip() for ln in fh if ln.strip()]

    print(f"candidates: {len(bases)}\n")
    damaged = clean = no_video = no_track = 0

    for base in bases:
        base_abs = os.path.join(MEDIA_ROOT, base)
        de_path = base_abs + ".de.ass"
        video = find_video(base_abs)
        name = base.rsplit("/", 1)[-1][:70]

        if not video:
            no_video += 1
            print(f"  [no video ] {name}")
            continue

        indexes = ass_stream_indexes(video)
        if not indexes:
            no_track += 1
            print(f"  [no track ] {name}")
            continue

        with open(de_path, "rb") as fh:
            de_text = fh.read().decode("utf-8", errors="replace")
        de_n = dialogue_lines(de_text)

        best = None
        for sub_index in indexes:
            tmp = extract_ass(video, sub_index)
            if not tmp:
                continue
            with open(tmp, "rb") as fh:
                src_text = fh.read().decode("utf-8", errors="replace")
            os.unlink(tmp)
            src_n = dialogue_lines(src_text)
            predicted, spans = swallowed_newlines(src_text)
            # The track the translation came from is the one whose event count
            # is closest from above -- a foreign track of a different length
            # would otherwise masquerade as loss.
            if best is None or abs(src_n - de_n) < abs(best[0] - de_n):
                best = (src_n, predicted, spans)

        if best is None:
            no_track += 1
            print(f"  [unreadable] {name}")
            continue

        src_n, predicted, spans = best
        delta = src_n - de_n
        if delta > 0 and predicted > 0:
            damaged += 1
            print(f"  -{delta:6d} events (predicted {predicted} over {spans} span(s))  {name}")
        else:
            clean += 1
            print(f"  [clean    ] src={src_n} de={de_n} predicted={predicted}  {name}")

    print()
    print(f"damaged: {damaged}   clean: {clean}   no video: {no_video}   no usable track: {no_track}")


if __name__ == "__main__":
    main()