Skip to content

Commit 7c48d96

Browse files
committed
fix(ffmpeg): share literal drawtext escaping
1 parent 49b0211 commit 7c48d96

18 files changed

Lines changed: 76 additions & 79 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ record also lives in the git commit messages.
145145
- `escape_drawtext` documents/requires `expansion=none`: under default
146146
drawtext expansion a literal `%` cannot be escaped at all ("Stray %") and
147147
caption text containing `%{...}` would be interpreted as an expression.
148+
- Caption styles, click/keystroke overlays, tickers, quiz cards, telemetry
149+
overlays, callouts, audiograms, brand watermarks, end screens, and guest
150+
lower-thirds now use the shared `escape_drawtext()` contract with
151+
`expansion=none`.
148152
- Audio-only export to `.aac` and `.ogg` no longer fails — those containers
149153
were handed the mp3 encoder; now mapped to `aac` / `libvorbis`.
150154
- Progress-parsing FFmpeg subprocess now decodes stdout/stderr as UTF-8 with

ROADMAP.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ history, not here.
2323
Why: six full :root token redefinitions (12, 4462, 5386, 13215, 15466, 17214), two divergent html.theme-light blocks (16701 vs 17628), three different :focus-visible rules, and a triplicated prefers-reduced-motion block — the effective theme is "whatever the last pass overrode" and ~⅓ of 18k lines is dead weight. Consolidate to one token block per theme; then retokenize the ~340 stray hex literals.
2424
Where: extension/com.opencut.panel/client/style.css
2525

26-
- [ ] P2 — Drawtext escaping is duplicated and wrong about apostrophes in ~10 modules
27-
Why: the local _escape_drawtext copies use \' inside single quotes (apostrophes silently dropped, graph can be mangled); helpers.escape_drawtext (ffmpeg-verified, expansion=none contract) should replace them.
28-
Where: opencut/core/caption_styles.py:365-372, click_overlay.py, news_ticker.py, quiz_overlay.py, telemetry_overlay.py, callout_gen.py, audiogram.py, brand_kit.py, end_screen.py, guest_compilation.py
29-
3026
## P3 — Lower-severity correctness, UX, packaging
3127

3228
- [ ] P3 — Inno installer writes HKCU/PlayerDebugMode in the elevated user's hive

opencut/core/audiogram.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import subprocess
1414
from typing import Callable, Optional
1515

16-
from opencut.helpers import get_ffmpeg_path, get_ffprobe_path, run_ffmpeg
16+
from opencut.helpers import escape_drawtext, get_ffmpeg_path, get_ffprobe_path, run_ffmpeg
1717

1818
logger = logging.getLogger("opencut")
1919

@@ -47,12 +47,7 @@ def _get_audio_duration(filepath: str) -> float:
4747

4848
def _escape_ffmpeg_text(text: str) -> str:
4949
"""Escape special characters for FFmpeg drawtext filter."""
50-
# FFmpeg drawtext requires escaping these characters
51-
text = text.replace("\\", "\\\\")
52-
text = text.replace(":", "\\:")
53-
text = text.replace("'", "\\'")
54-
text = text.replace("%", "%%")
55-
return text
50+
return escape_drawtext(text)
5651

5752

5853
def generate_audiogram(
@@ -211,7 +206,7 @@ def generate_audiogram(
211206
font_size = max(24, width // 25)
212207
title_y = height // 20
213208
fc_parts.append(
214-
f"{current_label}drawtext=text='{escaped}':"
209+
f"{current_label}drawtext=expansion=none:text='{escaped}':"
215210
f"fontsize={font_size}:fontcolor=white:x=(w-text_w)/2:y={title_y}:"
216211
f"shadowcolor=black@0.6:shadowx=2:shadowy=2[titled]"
217212
)

opencut/core/brand_kit.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from opencut.helpers import (
1515
FFmpegCmd,
16+
escape_drawtext,
1617
get_video_info,
1718
output_path,
1819
run_ffmpeg,
@@ -300,12 +301,12 @@ def auto_correct_brand(
300301
color = _hex_to_ffmpeg_color(brand_kit.primary_color)
301302
margin = int(info["width"] * brand_kit.safe_zone_margin)
302303
fontsize = max(12, int(info["height"] * 0.02))
303-
escaped_text = brand_kit.watermark_text.replace("'", "'\\''")
304+
escaped_text = escape_drawtext(brand_kit.watermark_text)
304305

305306
if filter_parts:
306307
# Chain onto previous filter output
307308
wm_filter = (
308-
f"drawtext=text='{escaped_text}'"
309+
f"drawtext=expansion=none:text='{escaped_text}'"
309310
f":fontsize={fontsize}"
310311
f":fontcolor={color}@0.3"
311312
f":x=w-tw-{margin}:y=h-th-{margin}"
@@ -314,7 +315,7 @@ def auto_correct_brand(
314315
filter_parts[-1] = last + f"[branded];[branded]{wm_filter}"
315316
else:
316317
filter_parts.append(
317-
f"[0:v]drawtext=text='{escaped_text}'"
318+
f"[0:v]drawtext=expansion=none:text='{escaped_text}'"
318319
f":fontsize={fontsize}"
319320
f":fontcolor={color}@0.3"
320321
f":x=w-tw-{margin}:y=h-th-{margin}"

opencut/core/callout_gen.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from opencut.helpers import (
1616
FFmpegCmd,
17+
escape_drawtext,
1718
get_video_info,
1819
output_path,
1920
run_ffmpeg,
@@ -109,11 +110,7 @@ def to_dict(self) -> dict:
109110

110111
def _escape_text(text: str) -> str:
111112
"""Escape special characters for FFmpeg drawtext."""
112-
text = text.replace("\\", "\\\\\\\\")
113-
text = text.replace(":", "\\:")
114-
text = text.replace("'", "\\'")
115-
text = text.replace("%", "%%")
116-
return text
113+
return escape_drawtext(text)
117114

118115

119116
# ---------------------------------------------------------------------------
@@ -183,7 +180,7 @@ def _build_callout_filter(ann: Annotation, video_w: int, video_h: int) -> List[s
183180
y_pos = "h-th-40"
184181

185182
filters.append(
186-
f"drawtext=text='{escaped}'"
183+
f"drawtext=expansion=none:text='{escaped}'"
187184
f":fontsize={ann.font_size}:fontcolor={ann.color}"
188185
f":x={x_pos}:y={y_pos}"
189186
f":box=1:boxcolor=black@0.6:boxborderw=6"
@@ -214,7 +211,7 @@ def _build_step_filter(ann: Annotation, video_w: int, video_h: int) -> List[str]
214211
f":color={ann.color}@0.9:t=fill:enable='{enable}'"
215212
)
216213
filters.append(
217-
f"drawtext=text='{num_text}'"
214+
f"drawtext=expansion=none:text='{num_text}'"
218215
f":fontsize=20:fontcolor=black"
219216
f":x={badge_x}+8:y={badge_y}+6"
220217
f":enable='{enable}'"
@@ -226,7 +223,7 @@ def _build_step_filter(ann: Annotation, video_w: int, video_h: int) -> List[str]
226223
text_x = f"{badge_x}+40"
227224
text_y = f"{badge_y}+4"
228225
filters.append(
229-
f"drawtext=text='{escaped}'"
226+
f"drawtext=expansion=none:text='{escaped}'"
230227
f":fontsize={ann.font_size}:fontcolor={ann.color}"
231228
f":x={text_x}:y={text_y}"
232229
f":box=1:boxcolor=black@0.6:boxborderw=4"

opencut/core/caption_styles.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from dataclasses import asdict, dataclass, field
1313
from typing import Callable, Dict, List, Optional
1414

15-
from opencut.helpers import get_ffmpeg_path, output_path, run_ffmpeg
15+
from opencut.helpers import escape_drawtext, get_ffmpeg_path, output_path, run_ffmpeg
1616

1717
logger = logging.getLogger("opencut")
1818

@@ -364,12 +364,7 @@ def _position_y(position: str, height: int, font_size: int) -> int:
364364

365365
def _escape_drawtext(text: str) -> str:
366366
"""Escape text for FFmpeg drawtext filter."""
367-
return (
368-
text.replace("\\", "\\\\")
369-
.replace("'", "\\'")
370-
.replace(":", "\\:")
371-
.replace("%", "%%")
372-
)
367+
return escape_drawtext(text)
373368

374369

375370
def _build_drawtext_filter(
@@ -388,7 +383,7 @@ def _build_drawtext_filter(
388383
bg_color = style.colors.get("background", "")
389384

390385
parts = [
391-
f"drawtext=text='{escaped}'",
386+
f"drawtext=expansion=none:text='{escaped}'",
392387
"fontfile=''",
393388
f"fontsize={style.font_size}",
394389
f"fontcolor={text_color}",
@@ -497,7 +492,7 @@ def generate_style_preview(
497492
escaped = _escape_drawtext(sample_text)
498493

499494
dt_parts = [
500-
f"drawtext=text='{escaped}'",
495+
f"drawtext=expansion=none:text='{escaped}'",
501496
f"fontsize={min(style.font_size, 36)}",
502497
f"fontcolor={text_color}",
503498
f"shadowcolor={shadow_color}",

opencut/core/click_overlay.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from opencut.helpers import (
1717
FFmpegCmd,
18+
escape_drawtext,
1819
get_video_info,
1920
output_path,
2021
run_ffmpeg,
@@ -364,12 +365,7 @@ def render_click_overlay(
364365

365366
def _escape_drawtext(text: str) -> str:
366367
"""Escape special characters for FFmpeg drawtext filter."""
367-
# FFmpeg drawtext requires escaping of : ; ' \ and %
368-
text = text.replace("\\", "\\\\\\\\")
369-
text = text.replace(":", "\\:")
370-
text = text.replace("'", "\\'")
371-
text = text.replace("%", "%%")
372-
return text
368+
return escape_drawtext(text)
373369

374370

375371
def render_keystroke_overlay(
@@ -451,7 +447,7 @@ def render_keystroke_overlay(
451447

452448
# Background box + text
453449
dt_filter = (
454-
f"drawtext=text='{escaped_keys}'"
450+
f"drawtext=expansion=none:text='{escaped_keys}'"
455451
f":fontsize={font_size}"
456452
f":fontcolor={font_color}"
457453
f":x={x_expr}:y={y_expr}"

opencut/core/end_screen.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from opencut.helpers import (
2020
FFmpegCmd,
21+
escape_drawtext,
2122
run_ffmpeg,
2223
)
2324

@@ -178,7 +179,7 @@ def _build_end_screen_filter(
178179

179180
# Get custom text from data
180181
custom_text = data.get(f"element_{i}_text", elem.label)
181-
safe_text = custom_text.replace("'", "\\'").replace(":", "\\:")
182+
safe_text = escape_drawtext(custom_text)
182183

183184
out_label = f"e{i}"
184185

@@ -188,7 +189,7 @@ def _build_end_screen_filter(
188189
parts.append(
189190
f"[{prev_label}]drawbox=x={ex}:y={ey}:w={ew}:h={eh}"
190191
f":color=0x{accent}@0.9:t=fill,"
191-
f"drawtext=text='{safe_text}'"
192+
f"drawtext=expansion=none:text='{safe_text}'"
192193
f":fontsize={font_size}:fontcolor=0x{text_color}"
193194
f":x={ex}+({ew}-text_w)/2:y={ey}+({eh}-text_h)/2"
194195
f"[{out_label}]"
@@ -201,7 +202,7 @@ def _build_end_screen_filter(
201202
f":color=0x333333@0.8:t=fill,"
202203
f"drawbox=x={ex}:y={ey}:w={ew}:h={eh}"
203204
f":color=0x{accent}@0.6:t=3,"
204-
f"drawtext=text='{safe_text}'"
205+
f"drawtext=expansion=none:text='{safe_text}'"
205206
f":fontsize={font_size}:fontcolor=0x{text_color}@0.8"
206207
f":x={ex}+({ew}-text_w)/2:y={ey}+({eh}-text_h)/2"
207208
f"[{out_label}]"
@@ -212,7 +213,7 @@ def _build_end_screen_filter(
212213
parts.append(
213214
f"[{prev_label}]drawbox=x={ex}:y={ey}:w={ew}:h={eh}"
214215
f":color=0x{accent}@0.4:t=fill,"
215-
f"drawtext=text='{safe_text}'"
216+
f"drawtext=expansion=none:text='{safe_text}'"
216217
f":fontsize={font_size}:fontcolor=0x{text_color}"
217218
f":x={ex}+({ew}-text_w)/2:y={ey}+({eh}-text_h)/2"
218219
f"[{out_label}]"
@@ -221,7 +222,7 @@ def _build_end_screen_filter(
221222
# Text element
222223
font_size = max(16, eh // 2)
223224
parts.append(
224-
f"[{prev_label}]drawtext=text='{safe_text}'"
225+
f"[{prev_label}]drawtext=expansion=none:text='{safe_text}'"
225226
f":fontsize={font_size}:fontcolor=0x{text_color}"
226227
f":x={ex}+({ew}-text_w)/2:y={ey}+({eh}-text_h)/2"
227228
f"[{out_label}]"

opencut/core/guest_compilation.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from opencut.helpers import (
1616
FFmpegCmd,
17+
escape_drawtext,
1718
get_ffmpeg_path,
1819
get_video_info,
1920
output_path,
@@ -209,7 +210,7 @@ def generate_name_card(
209210
x_expr = str(margin)
210211
y_expr = f"h-{margin}-th"
211212

212-
escaped_name = name.replace("'", "'\\''").replace(":", "\\:")
213+
escaped_name = escape_drawtext(name)
213214

214215
# Build drawtext with background box and fade
215216
fade_in_expr = f"if(lt(t,{style.fade_in}),t/{style.fade_in},1)"
@@ -218,7 +219,7 @@ def generate_name_card(
218219
alpha_expr = f"min({fade_in_expr},{fade_out_expr})"
219220

220221
drawtext = (
221-
f"drawtext=text='{escaped_name}'"
222+
f"drawtext=expansion=none:text='{escaped_name}'"
222223
f":fontsize={fontsize}"
223224
f":fontcolor={style.font_color}"
224225
f":box=1:boxcolor={style.bg_color}:boxborderw={padding}"
@@ -327,7 +328,7 @@ def process_single_message(
327328
card_dur = name_style.display_duration
328329
fade_in = name_style.fade_in
329330
fade_out = name_style.fade_out
330-
escaped_name = guest_name.replace("'", "'\\''").replace(":", "\\:")
331+
escaped_name = escape_drawtext(guest_name)
331332

332333
# Show name card for first N seconds with fade
333334
enable_expr = f"between(t,0,{card_dur})"
@@ -343,7 +344,7 @@ def process_single_message(
343344
y_expr = f"h-{margin}-th"
344345

345346
vf_parts.append(
346-
f"drawtext=text='{escaped_name}'"
347+
f"drawtext=expansion=none:text='{escaped_name}'"
347348
f":fontsize={fontsize}"
348349
f":fontcolor={name_style.font_color}"
349350
f":box=1:boxcolor={name_style.bg_color}:boxborderw={padding}"

opencut/core/news_ticker.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from opencut.helpers import (
1919
FFmpegCmd,
20+
escape_drawtext,
2021
get_video_info,
2122
output_path,
2223
run_ffmpeg,
@@ -127,13 +128,7 @@ def _load_ticker_text(
127128

128129
def _escape_drawtext(text: str) -> str:
129130
"""Escape text for FFmpeg drawtext filter."""
130-
# FFmpeg drawtext special characters
131-
text = text.replace("\\", "\\\\")
132-
text = text.replace("'", "\\'")
133-
text = text.replace(":", "\\:")
134-
text = text.replace("%", "%%")
135-
text = text.replace("\n", " ")
136-
return text
131+
return escape_drawtext(str(text).replace("\n", " "))
137132

138133

139134
# ---------------------------------------------------------------------------
@@ -235,7 +230,7 @@ def create_ticker(
235230
# Background bar + scrolling text
236231
vf = (
237232
f"drawbox=x=0:y={bar_y}:w=iw:h={bar_h}:color={bg_color}:t=fill,"
238-
f"drawtext=text='{safe_text}'"
233+
f"drawtext=expansion=none:text='{safe_text}'"
239234
f":fontsize={font_size}:fontcolor={font_color}"
240235
f":x='{x_expr}':y={text_y}"
241236
)
@@ -341,7 +336,7 @@ def create_ticker_overlay(
341336
# Generate with color source + drawtext
342337
fc = (
343338
f"color=c=0x{bg_color}:s={width}x{height}:d={duration},"
344-
f"drawtext=text='{safe_text}'"
339+
f"drawtext=expansion=none:text='{safe_text}'"
345340
f":fontsize={font_size}:fontcolor={font_color}"
346341
f":x='{x_expr}':y={text_y}"
347342
f"[out]"

0 commit comments

Comments
 (0)