diff --git a/gaming/compose.py b/gaming/compose.py index 8c566526..1ec5760d 100644 --- a/gaming/compose.py +++ b/gaming/compose.py @@ -23,6 +23,7 @@ from core.binaries import ffmpeg from gaming.layout import OUT_H, OUT_W, Element, Plan from video.encoding import audio_filter_args, final_movflags, video_encoder_args +from video.filters import subtitles_filter def _blur_fill(label: str, w: int, h: int) -> str: @@ -78,7 +79,7 @@ def filter_graph(p: Plan, vf_extra: str = "", ass_name: str | None = None) -> st if vf_extra: graph += f";[v]{vf_extra}[v]" if ass_name: - graph += f";[v]subtitles={ass_name}[v]" + graph += f";[v]{subtitles_filter(ass_name)}[v]" return graph diff --git a/multilingual/burn.py b/multilingual/burn.py index 35f5a839..a35a3075 100644 --- a/multilingual/burn.py +++ b/multilingual/burn.py @@ -76,6 +76,7 @@ def burn( from core.models import ClipCandidate from video.captions import build_captions from video.encoding import final_movflags, video_encoder_args + from video.filters import subtitles_filter canvas = _canvas_of(base_video) ass_path = out_path.with_suffix(f".{language}.ass") @@ -89,7 +90,7 @@ def burn( cmd = [ ffmpeg(), "-y", "-v", "error", "-i", str(base_video.resolve()), - "-vf", f"subtitles={built.name}", + "-vf", subtitles_filter(built.name), "-c:a", "copy", *video_encoder_args(config), # A language's own clip, posted as it is: first frame at 0 either way. diff --git a/tests/test_subtitles_filter.py b/tests/test_subtitles_filter.py new file mode 100644 index 00000000..6f8e8e28 --- /dev/null +++ b/tests/test_subtitles_filter.py @@ -0,0 +1,76 @@ +"""The captions file name inside FFmpeg's `subtitles=` filter. + +Every renderer hands the filter a bare file name (with cwd set to the +captions' folder, to dodge Windows drive-letter escaping). The name was put +into the filtergraph as it was, so a clip whose file name carried a comma, +a quote, a bracket or a semicolon broke the graph: a comma reads as the next +filter, and the burn failed with a parse error. Seen on a translated burn, +whose output is named after the clip's title. + +FFmpeg needs two levels of escaping (doc: "Notes on filtergraph escaping"): +the option value (\\ ' :) and then the filtergraph (\\ ' [ ] , ;). +""" + +import subprocess +from pathlib import Path + +import pytest + +from core.binaries import ffmpeg +from video.filters import subtitles_filter + +ASS = """[Script Info] +ScriptType: v4.00+ +PlayResX: 320 +PlayResY: 240 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, BackColour, Bold, Italic, Alignment, MarginL, MarginR, MarginV +Style: Default,Arial,20,&H00FFFFFF,&H00000000,0,0,2,10,10,10 + +[Events] +Format: Layer, Start, End, Style, Text +Dialogue: 0,0:00:00.00,0:00:01.00,Default,ola +""" + + +def test_escapes_both_filter_levels(): + assert subtitles_filter("Corte, do 'Paulinho' [GTA]; parte 1: final.ass") == \ + r"subtitles=Corte\, do \\\'Paulinho\\\' \[GTA\]\; parte 1\\: final.ass" + + +def test_escapes_an_equals_sign(): + # "a=b.ass" read as option a, value b.ass ("Option not found"). + assert subtitles_filter("GTA=top.ass") == r"subtitles=GTA\\=top.ass" + + +def test_plain_name_unchanged(): + assert subtitles_filter("clip_12.ass") == "subtitles=clip_12.ass" + + +def test_ffmpeg_burns_a_title_with_commas_quotes_and_brackets(tmp_path): + binary = ffmpeg() + if not Path(binary).is_file(): + pytest.skip("FFmpeg is not installed here") + name = "Corte, do 'Paulinho' [GTA]; parte 1.ass" + (tmp_path / name).write_text(ASS, encoding="utf-8") + result = subprocess.run( + [binary, "-v", "error", "-f", "lavfi", "-i", "color=black:s=320x240:d=1", + "-vf", subtitles_filter(name), "-f", "null", "-"], + capture_output=True, text=True, cwd=tmp_path, + ) + assert result.returncode == 0, result.stderr[-500:] + + +@pytest.mark.parametrize("name", ["a=b.ass", "GTA=top, do 'Paulinho' [x]; parte 1.ass"]) +def test_ffmpeg_burns_a_title_with_an_equals_sign(tmp_path, name): + binary = ffmpeg() + if not Path(binary).is_file(): + pytest.skip("FFmpeg is not installed here") + (tmp_path / name).write_text(ASS, encoding="utf-8") + result = subprocess.run( + [binary, "-v", "error", "-f", "lavfi", "-i", "color=black:s=320x240:d=1", + "-vf", subtitles_filter(name), "-f", "null", "-"], + capture_output=True, text=True, cwd=tmp_path, + ) + assert result.returncode == 0, result.stderr[-500:] diff --git a/video/cropper.py b/video/cropper.py index ccc596ec..49ceb02f 100644 --- a/video/cropper.py +++ b/video/cropper.py @@ -31,6 +31,7 @@ from core.binaries import ffmpeg from video.capture import video_capture from video.encoding import audio_filter_args, final_movflags, video_encoder_args +from video.filters import subtitles_filter CAM_H = 672 # webcam band height in the 1080x1920 split layout (35%) GAME_H = 1248 # gameplay band height (65%) @@ -117,7 +118,7 @@ def _render_fit_blur( ) filters = f"[0:v]split=2[a][b];[a]{bg}[bg];[b]{fg}[fg];[bg][fg]overlay=0:(H-h)/2,setsar=1[v]" if ass_path is not None: - filters += f";[v]subtitles={ass_path.name}[v2]" + filters += f";[v]{subtitles_filter(ass_path.name)}[v2]" vout = "[v2]" else: vout = "[v]" @@ -304,7 +305,7 @@ def emit_rest_on_cpu(frame_idx: int) -> None: f"[bg][fg]overlay=0:{top},setsar=1[v]" ) if ass_path is not None: - filters += f";[v]subtitles={ass_path.name}[v]" + filters += f";[v]{subtitles_filter(ass_path.name)}[v]" cmd = [ ffmpeg(), "-y", *pipe_in, # cropped frames on stdin @@ -333,7 +334,7 @@ def emit_rest_on_cpu(frame_idx: int) -> None: if vf_extra: vf += f",{vf_extra}" if ass_path is not None: - vf += f",subtitles={ass_path.name}" + vf += "," + subtitles_filter(ass_path.name) cmd = [ ffmpeg(), "-y", *pipe_in, # cropped frames on stdin @@ -526,7 +527,7 @@ def _render_split( if vf_extra: filters += f";[v]{vf_extra}[v]" if ass_path is not None: - filters += f";[v]subtitles={ass_path.name}[v]" + filters += f";[v]{subtitles_filter(ass_path.name)}[v]" cmd = [ ffmpeg(), "-y", diff --git a/video/cutter.py b/video/cutter.py index 19ce6451..9ea488ab 100644 --- a/video/cutter.py +++ b/video/cutter.py @@ -17,6 +17,7 @@ using_hardware_encoder, video_encoder_args, ) +from video.filters import subtitles_filter def cut_clip( @@ -57,7 +58,7 @@ def cut_clip( vf_parts = [p for p in (vf_extra,) if p] if ass_path is not None: # Bare filename + cwd avoids Windows path escaping in filter args. - vf_parts.append(f"subtitles={ass_path.name}") + vf_parts.append(subtitles_filter(ass_path.name)) if vf_parts: cmd.extend(["-vf", ",".join(vf_parts)]) cmd.append(str(output_path.resolve())) diff --git a/video/filters.py b/video/filters.py index faa85909..aa4de898 100644 --- a/video/filters.py +++ b/video/filters.py @@ -9,6 +9,22 @@ chat), so an unknown name can never inject filter syntax into FFmpeg. """ +import re + + +def subtitles_filter(name: str) -> str: + """`subtitles=` with the file name escaped for FFmpeg. + + Callers pass a bare file name and run FFmpeg from the captions' folder + (that avoids Windows drive-letter escaping). The name still needs FFmpeg's + two levels ("Notes on filtergraph escaping"): first as an option value + (\\ ' : =), then as part of the filtergraph (\\ ' [ ] , ;). Unescaped, a + comma in a clip's title reads as the start of the next filter, and + "a=b.ass" as option "a". + """ + value = re.sub(r"([\\':=])", r"\\\1", name) + return "subtitles=" + re.sub(r"([\\'\[\],;])", r"\\\1", value) + PRESETS: dict[str, str] = { "none": "", "vibrant": "eq=saturation=1.35:contrast=1.08", diff --git a/video_editor/export.py b/video_editor/export.py index a461287a..3a246dda 100644 --- a/video_editor/export.py +++ b/video_editor/export.py @@ -12,6 +12,7 @@ from core.binaries import ffmpeg from video.encoding import LOUDNORM, final_movflags, video_encoder_args +from video.filters import subtitles_filter from video_editor.audio import master_filter, mute_filter from video_editor.cuts import concat_graph from video_editor.timeline import EditList @@ -86,7 +87,7 @@ def apply_edits( if abs(edit.speed - 1.0) >= 0.01: video_chain.append(f"setpts=PTS/{edit.speed:.4f}") if ass_path is not None: - video_chain.append(f"subtitles={ass_path.name}") + video_chain.append(subtitles_filter(ass_path.name)) if video_chain: src = video_out if video_out.startswith("[") else f"[{video_out}]" graph_parts.append(f"{src}{','.join(video_chain)}[vout]")