Artisan Build logo
Start a project

The Single Quote That Took Down 30 Subtitle Filters

Len Woodward

How a children's story taught us that FFmpeg's documentation lies about single-quote escaping.

The Single Quote That Took Down 30 Subtitle Filters

The Setup

We're building Fawnfox Fables, a platform that generates personalized children's stories. One of our features renders narrated video slideshows: a cover illustration, an audio narration from ElevenLabs, and animated subtitle overlays timed to the narration.

The video pipeline is built in Laravel with FFmpeg. Each sentence in the story gets its own drawtext filter with:

  • Timed enable expressions (fade in when the sentence is spoken)
  • Animated alpha for a lifecycle: entryactivedimexit
  • A slide-up y position tween on entry
  • Word-wrapped text computed from font metrics

A typical story produces 30 chained drawtext filters in a single -vf argument. The filter string is around 25KB of dense FFmpeg filter graph syntax.

Here's a simplified version of what a single filter looks like:

drawtext=fontfile=/path/to/Inter-Regular.ttf
  :text='Allison gazed out her window at the twinkling night sky.'
  :fontsize=36
  :fontcolor=white
  :[email protected]
  :shadowx=2
  :shadowy=2
  :alpha=if(lt(t\,15.569)\,<entry_tween>\,<dim_tween>)
  :x=(w-tw)/2
  :y=<slide_tween>
  :enable='between(t\,9.5320\,17.7990)'

Multiply that by 30, chain them with commas, and pass them as the -vf argument. What could go wrong?


The First Errors: Commas Everywhere

The first time we pressed "Generate Video," FFmpeg threw:

No such filter: '99.4990'

The enable='between(t,%s,%s)' expression had unescaped commas in the between() function call. FFmpeg's filter graph parser splits on commas to separate filters, so between(t,0,10) was being parsed as three separate tokens. Easy fix:

// Before (broken)
$enableExpr = sprintf("'between(t,%s,%s)'", $start, $end);

// After (fixed)
$enableExpr = sprintf("'between(t\\,%s\\,%s)'", $start, $end);

The second press gave us:

No such filter: 'keep exploring'

Now commas in the story text were breaking the filter graph. The sentence "She kept exploring, keep exploring..." had an unescaped comma that split the drawtext filter mid-sentence. We added comma escaping to our escapeDrawText() method:

$text = str_replace(',', '\\,', $text);

These were the easy ones. The real nightmare was about to begin.


The Impossible Error

After fixing commas, semicolons, brackets, and double-quotes, we hit an error that made no sense:

[Eval @ 0x16dd1f520] Invalid chars ',drawtext=fontfile=/path/to/Inter-Regular.ttf'
  at the end of expression
  'between(t,94.9480,106.6730),drawtext=fontfile=/path/to/Inter-Regular.ttf'

Read that carefully. The enable expression for one drawtext filter was consuming the entire next filter as part of its value. The comma between 106.6730) and drawtext= was being treated as content, not a filter separator.

This should be impossible. The enable expression was wrapped in single quotes:

enable='between(t\,94.9480\,106.6730)'

The closing ' should terminate the quoted section. The , after it should be a filter separator. But FFmpeg was acting as if the quoted section never closed.


The Diagnostic Journey

Step 1: Write an Artisan Command

First lesson: stop using the UI as your test harness. We wrote a dedicated artisan command that:

  1. Downloaded the cover image and audio from R2 to a temp directory
  2. Tested a bare video (no filters) — to verify FFmpeg and assets work
  3. Tested with 1 drawtext filter — to verify basic filter syntax
  4. Tested with 2 drawtext filters — to verify filter chaining
  5. Tested with all 30 filters — the real test
  6. On failure, ran a binary search to find the first problematic filter
// Binary search for the problematic filter
$lo = 2;
$hi = count($filters);
while ($lo < $hi) {
    $mid = (int) (($lo + $hi) / 2);
    $testVf = implode(',', array_slice($filters, 0, $mid));
    // ... run ffmpeg with $testVf ...
    if ($process->isSuccessful()) {
        $lo = $mid + 1;
    } else {
        $hi = $mid;
    }
}
$this->error("First problematic filter index: " . ($lo - 1));

Result: filters 0–12 worked, filter 13 broke it.

Step 2: Test Filter 13 in Isolation

Filter 13's text was: It wasn't sad, just a little bit shy and lost!

We tested filter 13 alone — it worked perfectly. We tested it with just filter 12 — also fine. The issue only appeared with all 14 filters together.

Step 3: Replace Texts, Keep Expressions

We replaced all real story text with simple placeholders (text='Text 0', text='Text 1', etc.) but kept the complex alpha expressions, y-position tweens, and enable expressions identical.

All 30 filters rendered successfully.

Then we put the real text back but with simple alpha expressions.

All 30 filters rendered successfully.

The problem was specifically in the combination of real text content with chained filters.

Step 4: Individual Text Testing

We tested each of the 30 real texts individually (one real text, 29 placeholders):

Filter 0: OK   (Allison's Starry Helper...)
Filter 1: OK   (Allison, a curious little girl...)
Filter 2: OK   (She loved to explore.)
...
Filter 13: OK  (It wasn't sad, just a little bit shy and lost!)
...
Filter 29: OK  (Until next time, keep exploring...)

Every single text worked individually. No single text was "the problem."

Step 5: Progressive Combination

We added real texts one at a time from the start:

Real texts 0-7:  OK
Real texts 0-11: OK
Real texts 0-12: OK   ← 13 filters, works
Real texts 0-13: FAIL ← 14 filters, breaks

Then we tried removing any single filter from the failing set of 14:

Removing filter 0 from 0-13:  fixes it!
Removing filter 1 from 0-13:  fixes it!
Removing filter 2 from 0-13:  fixes it!
...
Removing filter 12 from 0-13: fixes it!

Removing any filter made it work. This wasn't about a specific text — it was about cumulative parser state corruption.

Step 6: Escape Type Testing

We created 30 filters with specific escape patterns:

tests = [
    "30 filters with \\' in text",           # OK
    "30 filters with \\, in text",           # OK
    "30 filters with \\' and \\, in text",   # OK
    "30 filters with \\n in text",           # OK
    "30 filters with all escape types",      # OK
]

All passed. The escapes in isolation were fine. It was the real story text that triggered the bug.

Step 7: The Minimal Reproduction

We stripped it down to the absolute minimum:

# Write this exact content to a file (no shell interpretation):
drawtext=text='It\'s test':fontsize=36:fontcolor=white,drawtext=text='World':fontsize=36:fontcolor=red:y=100
ffmpeg -y -loop 1 -i cover.png -t 3 \
  -filter_script:v /tmp/test.txt \
  output.mp4
[Parsed_drawtext_0] Cannot find color 'white,drawtext=text=World'

Two filters. One apostrophe. That's all it takes.


The Root Cause

FFmpeg's documentation says this about single-quoted sections in the filter graph:

The content between the pair of ' is taken literally, except for \' which produces a literal ' and \\ which produces a literal \.

This is wrong. Or at least, it doesn't match FFmpeg 8's actual behavior.

In FFmpeg 8's av_get_token() implementation, the single-quote parsing loop is approximately:

} else if (c == '\'') {
    while (*p && *p != '\'')
        *out++ = *p++;
    if (*p) {
        p++;
    }
}

Inside a single-quoted section, the parser copies every character literally until it hits the next '. There is no backslash escape handling inside the loop. The \ before ' is just a literal backslash, and the ' closes the section.

So when FFmpeg parses text='It\'s test':

Step Character Parser State Action
1 ' Normal → Quoted Opens quoted section
2 I, t Quoted Copied literally
3 \ Quoted Copied literally (NOT an escape)
4 ' Quoted → Normal Closes the quoted section
5 s Normal Continues parsing in normal mode
6 Normal Content continues...

The closing ' on step 4 is not escaped. It terminates the quoted section. Everything after — s test':fontsize=36:fontcolor=white,drawtext=... — is now parsed in normal mode, and the ' before fontsize opens a new quoted section that swallows the comma separator and the entire next filter.

With 13+ filters containing apostrophes, the cascading misalignment of quoted sections eventually corrupts a later filter's enable expression, producing the error we saw.


The Solution: Close-Reopen

The fix is the same technique used in shell scripting to include a literal single quote inside a single-quoted string:

'text up to quote' \' 'rest of text'

Which concatenates:

  1. 'text up to quote' — a quoted section
  2. \' — an escaped literal ' in unquoted context (where \ escaping works)
  3. 'rest of text' — another quoted section

In practice, for the text It's test:

- text='It\'s test'
+ text='It'\''s test'

The '\'' pattern means: close quote ('), escaped literal quote (\'), open quote (').

The PHP Implementation

Here's the complete escapeDrawText() method:

private function escapeDrawText(string $text): string
{
    // Escape backslashes first (so later escapes don't get double-escaped)
    $text = str_replace('\\', '\\\\', $text);

    // Escape single quotes using the close-reopen technique: '\''
    // FFmpeg 8's filter graph parser does NOT treat \' as an escape
    // inside single-quoted sections. The ' always closes the section.
    $text = str_replace("'", "'\\''" , $text);

    // Escape colons for the filter option parser (splits key=value on :)
    $text = str_replace(':', '\\:', $text);

    // Escape characters special to the FFmpeg filter graph parser
    $text = str_replace('"', '\\"', $text);
    $text = str_replace(',', '\\,', $text);
    $text = str_replace(';', '\\;', $text);
    $text = str_replace('[', '\\[', $text);
    $text = str_replace(']', '\\]', $text);

    // Escape % to prevent drawtext text expansion
    $text = str_replace('%', '%%', $text);

    // Replace actual newlines with drawtext line break sequence
    $text = str_replace(["\r\n", "\r", "\n"], '\\n', $text);

    return $text;
}

For Laravel FFmpeg Tools Users

If you're using projektgopher/laravel-ffmpeg-tools, the DrawText filter's text() method accepts pre-escaped text. The Filter::build() method wraps values containing spaces in single quotes automatically, so the close-reopen pattern integrates seamlessly:

use ProjektGopher\FFMpegTools\Filters\Video\DrawText;

$drawText = DrawText::make()
    ->fontfile($fontPath)
    ->text($this->escapeDrawText($wrappedText))
    ->fontsize('36')
    ->fontcolor('white')
    ->enable("'between(t\\,0\\,10)'");

$filterString = $drawText->build();
// Result: drawtext=fontfile=/path:text='It'\''s a story':fontsize=36:...

For Protonemedia Users

If you're using protonemedia/laravel-ffmpeg and building drawtext filters manually, apply the same escaping to any text that will appear in a -vf or -filter_complex argument:

use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;

FFMpeg::fromDisk('local')
    ->open('input.mp4')
    ->addFilter(function ($filters) {
        $filters->custom(
            "drawtext=text='" . $this->escapeDrawText("It's a story") . "'"
            . ":fontsize=36:fontcolor=white"
        );
    })
    ->export()
    ->toDisk('local')
    ->save('output.mp4');

FFmpeg's Two-Level Escaping (The Part Nobody Explains Well)

FFmpeg filter strings have two levels of parsing, and understanding both is essential:

Level 2: Filter Graph Parser

Splits the entire filter string into individual filters.

Separator Meaning
, Chain filters (output of one → input of next)
; Separate parallel filter chains
[label] Named inputs/outputs between chains
'...' Quoted section (protects , ; [ ])
\x Escape next character (in unquoted context only)

Level 1: Filter Option Parser

For each individual filter, splits options into key=value pairs.

Separator Meaning
: Separates key=value pairs
= Separates key from value
'...' Quoted section (protects :)
\x Escape next character (in unquoted context only)

The Critical Detail

In both levels, \ is only an escape character in unquoted context. Inside '...', the backslash is a literal character. The ' always closes the section.

This is contrary to what the FFmpeg documentation implies and contrary to how most developers (us included) expect quoting to work.


Quick Reference: Characters That Need Escaping in drawtext text

Character Escape Sequence Reason
\ \\ Literal backslash (must be escaped first)
' '\'' Close-reopen for single quotes
: \: Option parser key=value separator
" \" Filter graph double-quote delimiter
, \, Filter graph filter separator
; \; Filter graph chain separator
[ \[ Filter graph link label
] \] Filter graph link label
% %% Drawtext text expansion (%{expr})
newline (0x0A) \n (two chars) Drawtext line break

Bonus: FFmpeg 8 Requires Harfbuzz for drawtext

If you're compiling FFmpeg 8 from source, the drawtext filter now requires both libfreetype and libharfbuzz. Previous versions only required freetype. Without harfbuzz, the drawtext filter simply won't be available:

./configure \
  --enable-libfreetype \
  --enable-libfontconfig \
  --enable-libharfbuzz \   # NEW in FFmpeg 8 — required for drawtext
  --enable-libfribidi \
  # ... other options

Or save yourself the trouble and use a static build that includes everything.


Diagnostic Techniques Worth Stealing

If you're debugging FFmpeg filter graph issues, here's our toolkit:

1. Binary Search for Problematic Filters

When you have N chained filters and the combination fails, binary search for the first one that triggers the issue. It takes O(log N) FFmpeg invocations instead of N.

2. Write a Dedicated Debug Command

Don't use your queue/broadcast/UI stack as a test harness. Write an artisan command that:

  • Downloads assets to a temp directory
  • Tests incrementally (bare → 1 filter → 2 filters → all)
  • Dumps the filter string to a file for inspection

3. Use -filter_script:v Instead of -vf

Write your filter string to a file and use -filter_script:v /path/to/file. This eliminates any shell interpretation and lets you inspect the exact bytes FFmpeg receives.

4. Test Components in Isolation

When a combination fails but components work individually, systematically swap real content for placeholders to identify which dimension (text content, expressions, filter count) triggers the issue.

5. Trace the Parser State

Write a script that simulates the parser's quoting state character by character. This helps identify where the parser's understanding diverges from your intention. Just remember: your model might be wrong — as ours was about \' inside single quotes.


TL;DR

  • FFmpeg 8's filter graph parser does not support \' as an escape inside single-quoted sections
  • The ' always closes the quoted section, regardless of the preceding \
  • Use the close-reopen pattern '\'' instead: close the quote, add an escaped literal quote in unquoted context, reopen the quote
  • This affects any PHP FFmpeg library (protonemedia, laravel-ffmpeg-tools, or raw Process calls) that builds drawtext filters with user-generated text containing apostrophes
  • The bug manifests as cascading parser corruption — later filters fail because earlier ones threw off the quoting state
  • The symptoms are misleading: the error points to a filter far from the actual cause

Let’s talk

Tell us about your product, timeline, and what success looks like. We’ll reply with a concise plan of attack.

  • Calm, predictable cadence
  • Accessible, testable components
  • Transparent reporting & demos

Ready to start the conversation?

You can book a quick intro call or send us an email. No pressure, no forms — just a friendly hello.