mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
fix(subtitles): sort cues by start time when converting from SSA/ASS
Convert a time-sorted copy of the SSA/ASS source, so that SubtitleEdit and pysubs2 write SRT cues in order. SSA/ASS targets keep the file order.
This commit is contained in:
@@ -247,6 +247,11 @@ unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'
|
|||||||
Conversion only runs when the source format differs from the target, so
|
Conversion only runs when the source format differs from the target, so
|
||||||
`--sub-format srt` on a track that is already SRT is a no-op.
|
`--sub-format srt` on a track that is already SRT is a no-op.
|
||||||
|
|
||||||
|
When unshackle converts SSA/ASS to a format that is not SSA/ASS, it writes the cues in
|
||||||
|
start-time order. SSA/ASS files often list layered sign events out of time order. The
|
||||||
|
SSA/ASS source file keeps its order, because the order of events on one layer sets the
|
||||||
|
order in which they are drawn.
|
||||||
|
|
||||||
!!! warning "Down-converting styled subtitles is lossy"
|
!!! warning "Down-converting styled subtitles is lossy"
|
||||||
Converting styled SSA/ASS subtitles to SRT throws away positioning, colours, and
|
Converting styled SSA/ASS subtitles to SRT throws away positioning, colours, and
|
||||||
italics. unshackle never does this automatically. It only happens when you
|
italics. unshackle never does this automatically. It only happens when you
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,World
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
LAYERED_ASS_SAMPLE = ASS_SAMPLE.replace(
|
||||||
|
"Dialogue: 0,0:00:01.00", "Dialogue: 1,0:00:05.00,0:00:06.00,Default,,0,0,0,,Sign\nDialogue: 0,0:00:01.00"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read before the autouse fixture below hides it.
|
||||||
|
INSTALLED_SUBTITLEEDIT = binaries.SubtitleEdit
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def no_subtitleedit(monkeypatch):
|
def no_subtitleedit(monkeypatch):
|
||||||
"""Default every test to a SubtitleEdit-less environment; tests opt in when needed."""
|
"""Default every test to a SubtitleEdit-less environment; tests opt in when needed."""
|
||||||
@@ -147,6 +155,30 @@ def test_ass_to_srt_converts_when_forced(tmp_path, monkeypatch):
|
|||||||
assert "{\\" not in out.read_text("utf8") # override tags stripped
|
assert "{\\" not in out.read_text("utf8") # override tags stripped
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target", [Codec.SubRip, Codec.WebVTT])
|
||||||
|
@pytest.mark.parametrize("backend", ["pysubs2", "subtitleedit"])
|
||||||
|
def test_layered_ass_converts_in_start_time_order(tmp_path, monkeypatch, backend, target):
|
||||||
|
if backend == "subtitleedit":
|
||||||
|
if not INSTALLED_SUBTITLEEDIT:
|
||||||
|
pytest.skip("SubtitleEdit is not installed")
|
||||||
|
monkeypatch.setattr(binaries, "SubtitleEdit", INSTALLED_SUBTITLEEDIT)
|
||||||
|
monkeypatch.setattr("unshackle.core.config.config.subtitle", {"conversion_method": backend}, raising=False)
|
||||||
|
sub = make_sub(tmp_path, "x.ass", LAYERED_ASS_SAMPLE, Codec.SubStationAlphav4)
|
||||||
|
out = sub.convert(target, forced=True)
|
||||||
|
starts = re.findall(r"(\d+:\d\d:\d\d[.,]\d{3}) -->", out.read_text("utf-8-sig"))
|
||||||
|
assert len(starts) == 3
|
||||||
|
assert starts == sorted(starts)
|
||||||
|
assert [p.name for p in tmp_path.iterdir()] == [out.name] # sorted copy removed
|
||||||
|
|
||||||
|
|
||||||
|
def test_ass_to_ssa_keeps_file_order(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
||||||
|
sub = make_sub(tmp_path, "x.ass", LAYERED_ASS_SAMPLE, Codec.SubStationAlphav4)
|
||||||
|
out = sub.convert(Codec.SubStationAlpha, forced=True)
|
||||||
|
text = out.read_text("utf8")
|
||||||
|
assert text.index("Sign") < text.index("Hello")
|
||||||
|
|
||||||
|
|
||||||
def test_webvtt_to_srt_conversion(tmp_path, monkeypatch):
|
def test_webvtt_to_srt_conversion(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
monkeypatch.setattr("unshackle.core.config.config.subtitle", {}, raising=False)
|
||||||
sub = make_sub(tmp_path, "x.vtt", VTT_SAMPLE, Codec.WebVTT)
|
sub = make_sub(tmp_path, "x.vtt", VTT_SAMPLE, Codec.WebVTT)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from itertools import pairwise
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Protocol
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
@@ -114,6 +115,8 @@ def subtitleedit_args(
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
SUBSTATION: frozenset[Codec] = frozenset({Codec.SubStationAlpha, Codec.SubStationAlphav4})
|
||||||
|
|
||||||
# Styled SubStation formats flattened to SRT lose positioning/colours/italics.
|
# Styled SubStation formats flattened to SRT lose positioning/colours/italics.
|
||||||
# Never performed automatically, only when the user explicitly forces a target format.
|
# Never performed automatically, only when the user explicitly forces a target format.
|
||||||
LOSSY_DOWNCONVERTS: frozenset[tuple[Codec, Codec]] = frozenset(
|
LOSSY_DOWNCONVERTS: frozenset[tuple[Codec, Codec]] = frozenset(
|
||||||
@@ -155,10 +158,7 @@ class SubtitleEditBackend:
|
|||||||
return source in self.reads and target in self.writes
|
return source in self.reads and target in self.writes
|
||||||
|
|
||||||
def rank(self, source: Codec, target: Codec) -> int:
|
def rank(self, source: Codec, target: Codec) -> int:
|
||||||
if source in (Codec.SubStationAlpha, Codec.SubStationAlphav4) and target in (
|
if source in SUBSTATION and target in (Codec.TimedTextMarkupLang, Codec.WebVTT):
|
||||||
Codec.TimedTextMarkupLang,
|
|
||||||
Codec.WebVTT,
|
|
||||||
):
|
|
||||||
return 3
|
return 3
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ class SubtitleEditBackend:
|
|||||||
# SE5 names the output <input-stem>.<format-ext>, which may differ from our target
|
# SE5 names the output <input-stem>.<format-ext>, which may differ from our target
|
||||||
# suffix (e.g. timedtext1.0 -> .ttml). Normalise it onto `out`.
|
# suffix (e.g. timedtext1.0 -> .ttml). Normalise it onto `out`.
|
||||||
if not out.exists():
|
if not out.exists():
|
||||||
produced = next((p for p in src.parent.glob(f"{src.stem}.*") if p not in (src, out)), None)
|
produced = next((p for p in out.parent.glob(f"{src.stem}.*") if p not in (src, out)), None)
|
||||||
if produced is None:
|
if produced is None:
|
||||||
raise FileNotFoundError(f"SubtitleEdit produced no output for {src.name} -> {target.name}")
|
raise FileNotFoundError(f"SubtitleEdit produced no output for {src.name} -> {target.name}")
|
||||||
produced.replace(out)
|
produced.replace(out)
|
||||||
@@ -203,7 +203,7 @@ class Pysubs2Backend:
|
|||||||
|
|
||||||
def rank(self, source: Codec, target: Codec) -> int:
|
def rank(self, source: Codec, target: Codec) -> int:
|
||||||
# Preferred reader for styled SubStation sources; solid general fallback otherwise.
|
# Preferred reader for styled SubStation sources; solid general fallback otherwise.
|
||||||
return 1 if source in (Codec.SubStationAlpha, Codec.SubStationAlphav4) else 2
|
return 1 if source in SUBSTATION else 2
|
||||||
|
|
||||||
def convert(self, source: Codec, src: Path, target: Codec, out: Path) -> None:
|
def convert(self, source: Codec, src: Path, target: Codec, out: Path) -> None:
|
||||||
subs = pysubs2.load(str(src), encoding="utf-8-sig")
|
subs = pysubs2.load(str(src), encoding="utf-8-sig")
|
||||||
@@ -327,6 +327,26 @@ def finalize(sub: Subtitle, target: Codec, out: Path) -> Path:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def time_sorted_copy(src: Path) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Copy an SSA/ASS file with its events in start-time order, or return None when it is already in order.
|
||||||
|
|
||||||
|
Layered sign events are often out of time order, and SubtitleEdit and the pysubs2 SRT writer keep file order.
|
||||||
|
The source stays as it is, because ASS draws the events of one layer in file order.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subs = pysubs2.load(str(src), encoding="utf-8-sig")
|
||||||
|
except (pysubs2.exceptions.Pysubs2Error, UnicodeDecodeError, ValueError) as e:
|
||||||
|
log.debug(f"Cannot read {src.name} to sort it by start time, converting it unsorted: {e}")
|
||||||
|
return None
|
||||||
|
if all(a.start <= b.start for a, b in pairwise(subs.events)):
|
||||||
|
return None
|
||||||
|
subs.sort()
|
||||||
|
copy = src.with_name(f"{src.stem}.sorted{src.suffix}")
|
||||||
|
subs.save(str(copy), format_=subs.format, encoding="utf-8")
|
||||||
|
return copy
|
||||||
|
|
||||||
|
|
||||||
def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, forced: bool = False) -> Path:
|
def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, forced: bool = False) -> Path:
|
||||||
"""
|
"""
|
||||||
Convert ``sub`` to ``target`` using the best available backend, falling back through the
|
Convert ``sub`` to ``target`` using the best available backend, falling back through the
|
||||||
@@ -356,16 +376,21 @@ def run_conversion(sub: Subtitle, target: Codec, *, pin: Optional[str] = None, f
|
|||||||
raise NotImplementedError(f"Cannot convert {source.name} to {target.name}.")
|
raise NotImplementedError(f"Cannot convert {source.name} to {target.name}.")
|
||||||
|
|
||||||
out = src.with_suffix(f".{target.value.lower()}")
|
out = src.with_suffix(f".{target.value.lower()}")
|
||||||
|
sorted_src = time_sorted_copy(src) if source in SUBSTATION and target not in SUBSTATION else None
|
||||||
last_exc: Optional[Exception] = None
|
last_exc: Optional[Exception] = None
|
||||||
for backend in chain:
|
try:
|
||||||
try:
|
for backend in chain:
|
||||||
backend.convert(source, src, target, out)
|
try:
|
||||||
except Exception as e:
|
backend.convert(source, sorted_src or src, target, out)
|
||||||
last_exc = e
|
except Exception as e:
|
||||||
log.debug(f"Subtitle backend {backend.name} failed ({source.name}->{target.name}): {e}")
|
last_exc = e
|
||||||
continue
|
log.debug(f"Subtitle backend {backend.name} failed ({source.name}->{target.name}): {e}")
|
||||||
if target in PLAIN_TEXT_TARGETS:
|
continue
|
||||||
decode_entities(out)
|
if target in PLAIN_TEXT_TARGETS:
|
||||||
return finalize(sub, target, out)
|
decode_entities(out)
|
||||||
|
return finalize(sub, target, out)
|
||||||
|
finally:
|
||||||
|
if sorted_src:
|
||||||
|
sorted_src.unlink(missing_ok=True)
|
||||||
|
|
||||||
raise RuntimeError(f"All subtitle backends failed for {source.name}->{target.name}") from last_exc
|
raise RuntimeError(f"All subtitle backends failed for {source.name}->{target.name}") from last_exc
|
||||||
|
|||||||
Reference in New Issue
Block a user