Skip to content
Open
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
15 changes: 15 additions & 0 deletions packages/tests-shared/compile/test_compile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,18 @@ def test_parse_ffmpeg_commands(snapshot: SnapshotAssertion, command: str) -> Non
assert snapshot(
name="build-ffmpeg-commands", extension_class=VersionedAmberExtension
) == compile(parsed)


def test_vf_single_quoted_params_with_commas() -> None:
"""Single-quoted filter option values containing commas must not split the filterchain (issue #593)."""
cmd = (
"ffmpeg -i input.mkv"
" -vf \"scale=-1:-1,pad=1920:1080:-1:-1,subtitles='subtitles.srt':force_style='Fontname=Arial,Fontsize=17'\""
" output.mp4"
)
compiled = compile(parse(cmd))
# All three filters must appear in the output; commas inside the
# single-quoted force_style value must not split the filterchain.
assert "scale" in compiled
assert "pad" in compiled
assert "subtitles" in compiled
23 changes: 23 additions & 0 deletions packages/ts-core/src/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ beforeAll(() => {
]),
makeFilter("split", ["video"], ["video", "video"]),
makeFilter("amix", ["audio", "audio"], ["audio"]),
makeFilter("pad", ["video"], ["video"], [
{ name: "width", default: null },
{ name: "height", default: null },
{ name: "x", default: null },
{ name: "y", default: null },
]),
makeFilter("subtitles", ["video"], ["video"], [
{ name: "filename", default: null },
{ name: "stream_index", default: null },
{ name: "force_style", default: null },
]),
];
filtersMap = new Map(filters.map(f => [f.name, f]));
optionsMap = new Map(baseOptions.map(o => [o.name, o]));
Expand Down Expand Up @@ -387,4 +398,16 @@ describe("parse()", () => {
expect(compiled).toMatch(/1920/);
expect(compiled).toMatch(/1080/);
});

it("-vf filterchain with single-quoted option value containing commas (issue #593)", () => {
// Single-quoted force_style contains commas that must not split the filterchain
const cmd =
`ffmpeg -i input.mkv -vf "scale=-1:-1:flags=lanczos,pad=1920:1080:-1:-1,subtitles='subtitles.srt':force_style='Fontname=Arial,Fontsize=17'" output.mp4`;
// Should not throw; the three filters are scale → pad → subtitles
const stream = parse(cmd, filtersMap, optionsMap);
const compiled = compile(stream);
expect(compiled).toContain("scale");
expect(compiled).toContain("pad");
expect(compiled).toContain("subtitles");
});
});
33 changes: 30 additions & 3 deletions packages/ts-core/src/compile/compileCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,37 @@ function shlexSplit(cli: string): string[] {
return tokens;
}

/** Split text on an unescaped separator character. */
/**
* Split text on an unescaped separator character.
* Respects single-quoted sections (FFmpeg filter-option escaping) and
* backslash-escaped characters — neither terminates a token.
*/
function splitUnescaped(text: string, sep: string): string[] {
const escaped = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text.split(new RegExp(`(?<!\\\\)${escaped}`));
const parts: string[] = [];
let current = "";
let inSingleQuote = false;

for (let i = 0; i < text.length; i++) {
const ch = text[i];

if (ch === "\\" && !inSingleQuote) {
// Backslash escapes the next character; keep both in the current token.
current += ch;
if (i + 1 < text.length) current += text[++i];
} else if (ch === "'") {
inSingleQuote = !inSingleQuote;
current += ch;
} else if (!inSingleQuote && text.startsWith(sep, i)) {
parts.push(current);
current = "";
i += sep.length - 1;
} else {
current += ch;
}
}

parts.push(current);
return parts;
}

/**
Expand Down
36 changes: 29 additions & 7 deletions packages/v5/src/ffmpeg/compile/compile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
36 changes: 29 additions & 7 deletions packages/v6/src/ffmpeg/compile/compile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
36 changes: 29 additions & 7 deletions packages/v7/src/ffmpeg/compile/compile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
36 changes: 29 additions & 7 deletions packages/v8/src/ffmpeg/compile/compile_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
36 changes: 29 additions & 7 deletions src/scripts/code_gen/templates/compile/compile_cli.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -365,16 +365,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,38 @@ def parse_input(


_UNESCAPED_EQ = re.compile(r"(?<!\\)=")
_SEP_PATTERNS: dict[str, re.Pattern[str]] = {}


def _split_unescaped(text: str, sep: str) -> list[str]:
"""Split text on unescaped separator character."""
pattern = _SEP_PATTERNS.get(sep)
if pattern is None:
pattern = re.compile(r"(?<!\\)" + re.escape(sep))
_SEP_PATTERNS[sep] = pattern
return pattern.split(text)
"""Split *text* on *sep*, respecting single-quoted sections and backslash escapes.

Single-quoted strings (FFmpeg filter-option escaping) protect their content
from being split, matching the behaviour of the TypeScript implementation.
"""
parts: list[str] = []
current: list[str] = []
in_single_quote = False
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and not in_single_quote:
# Backslash escapes the next character; keep both.
current.append(ch)
i += 1
if i < len(text):
current.append(text[i])
elif ch == "'":
in_single_quote = not in_single_quote
current.append(ch)
elif not in_single_quote and text[i : i + len(sep)] == sep:
parts.append("".join(current))
current = []
i += len(sep) - 1
else:
current.append(ch)
i += 1
parts.append("".join(current))
return parts


def _parse_filter_params(
Expand Down
Loading