Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gaming/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion multilingual/burn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions tests/test_subtitles_filter.py
Original file line number Diff line number Diff line change
@@ -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:]
9 changes: 5 additions & 4 deletions video/cropper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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%)
Expand Down Expand Up @@ -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]"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion video/cutter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using_hardware_encoder,
video_encoder_args,
)
from video.filters import subtitles_filter


def cut_clip(
Expand Down Expand Up @@ -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()))
Expand Down
16 changes: 16 additions & 0 deletions video/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>` 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",
Expand Down
3 changes: 2 additions & 1 deletion video_editor/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]")
Expand Down