From c61e79630ff0d61bd56be5ff39eb0622528ef6ba Mon Sep 17 00:00:00 2001 From: Pcecil21 Date: Fri, 19 Jun 2026 12:41:32 -0500 Subject: [PATCH] fix(render): escape Windows path separators in the subtitles filter The subtitles= filter was built from an absolute path that escaped ':' and "'" but left '\' separators raw. On Windows the path is C:\Users\...\master.srt, so ffmpeg's filtergraph parser mangled the backslashes and subtitle burning failed. Fix: forward-slash the separators (ffmpeg accepts them on Windows) and escape the drive colon via escape_subtitles_path / _escape_filter_value. Adds tests/test_render_subtitles.py (cross-platform; passes on Windows and Linux). Verified on Windows: burning subtitles with an escaped path runs ffmpeg clean. No behavior change on macOS/Linux. --- helpers/render.py | 23 ++++++++++++++++++- tests/test_render_subtitles.py | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/test_render_subtitles.py diff --git a/helpers/render.py b/helpers/render.py index 0d02cffa..03a66cab 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -92,6 +92,27 @@ def resolve_path(maybe_path: str, base: Path) -> Path: return (base / p).resolve() +def _escape_filter_value(s: str) -> str: + r"""Make a path safe inside an ffmpeg filtergraph option value. + + In a filtergraph, ':' separates options and '\' is special, so a Windows + path (C:\Users\me\master.srt) breaks parsing. Forward-slash the separators + (ffmpeg accepts them on Windows), then escape the drive colon and any quote. + Pure string transform — host-independent, so it's the unit the tests pin. + """ + return s.replace("\\", "/").replace(":", r"\:").replace("'", r"\'") + + +def escape_subtitles_path(path: Path) -> str: + r"""Resolve `path` and escape it for ffmpeg's `subtitles=` filter option. + + No-op-shaped on POSIX (paths there contain neither '\' nor a drive ':'); + on Windows it turns C:\Users\me\master.srt into C\:/Users/me/master.srt so + the subtitles filter parses instead of erroring. + """ + return _escape_filter_value(str(path.resolve())) + + # -------- HDR → SDR tone mapping (HLG / PQ sources) -------------------------- # # iPhone defaults to HLG HDR in Rec.2020 (and many mirrorless cameras ship PQ). @@ -537,7 +558,7 @@ def build_final_composite( # Subtitles LAST — Rule 1 if has_subs: - subs_abs = str(subtitles_path.resolve()).replace(":", r"\:").replace("'", r"\'") + subs_abs = escape_subtitles_path(subtitles_path) filter_parts.append( f"{current}subtitles='{subs_abs}':force_style='{SUB_FORCE_STYLE}'[outv]" ) diff --git a/tests/test_render_subtitles.py b/tests/test_render_subtitles.py new file mode 100644 index 00000000..db84b101 --- /dev/null +++ b/tests/test_render_subtitles.py @@ -0,0 +1,41 @@ +r"""Regression test for cross-platform subtitles-path escaping in render.py. + +Bug: render.py built the ffmpeg `subtitles=` filter from an absolute path that, +on Windows, looks like C:\Users\me\master.srt. The filter escaped ':' and "'" +but left '\' separators raw, so ffmpeg's filtergraph parser mangled the path and +subtitle burning failed on Windows. Fix: _escape_filter_value forward-slashes the +separators and escapes the drive colon; escape_subtitles_path applies it to the +resolved path. These tests pin the pure transform for Windows and POSIX inputs +and pass on any host. +""" +import sys +import unittest +from pathlib import Path + +HELPERS_DIR = Path(__file__).resolve().parent.parent / "helpers" +sys.path.insert(0, str(HELPERS_DIR)) +import render # noqa: E402 + + +class EscapeFilterValueTest(unittest.TestCase): + def test_windows_path_forward_slashed_and_colon_escaped(self): + out = render._escape_filter_value(r"C:\Users\me\AppData\Local\Temp\master.srt") + # Drive colon escaped, all path separators forward-slashed. The only + # backslash that survives is the one escaping the colon. + self.assertEqual(out, r"C\:/Users/me/AppData/Local/Temp/master.srt") + for i, ch in enumerate(out): # every ':' is escaped (preceded by a backslash) + if ch == ":": + self.assertTrue(i > 0 and out[i - 1] == "\\") + + def test_posix_path_unchanged(self): + p = "/home/me/edit/master.srt" + self.assertEqual(render._escape_filter_value(p), p) + + def test_single_quote_escaped(self): + out = render._escape_filter_value("/tmp/it's/master.srt") + self.assertIn(r"\'", out) + self.assertNotIn("'", out.replace(r"\'", "")) # no unescaped quote remains + + +if __name__ == "__main__": + unittest.main()