避坑指南:彻底解决 FFmpeg drawtext 烧录多行文本出现"方块(□)"乱码的终极方案
📝 背景:那个阴魂不散的"豆腐块"
在短视频矩阵自动化生成、影视解说等业务中,我们经常需要使用 FFmpeg 的 drawtext 滤镜将营销文案、标题硬烧录(Hardsub)到视频画面上。
当我们需要渲染多行文本 时,最直觉的做法就是在命令行传入带有换行符 \n 的字符串,例如:
bash
ffmpeg -i input.mp4 -vf "drawtext=fontfile='simhei.ttf':text='第一行文案\n第二行文案':x=(w-text_w)/2:y=(h-text_h)/2" output.mp4
灾难发生了:
视频虽然成功渲染出了多行文字,但在每一行的末尾,都会出现一个极其碍眼的方块(□)(俗称"豆腐块"或"垃圾字符")。
🕵️ 为什么会出现这个方块?
经过深入底层排查,这其实是 FFmpeg 命令行解析器与底层字体引擎(FreeType)之间的配合缺陷:
- 控制符未剥离 :在命令行传参模式下,无论你传的是
\n(换行)、\f(换页)还是潜伏在 Windows 系统里的\r(回车),FFmpeg 在切分完行之后,并没有干净地把这些"不可见控制符"剥离。 - 字体库背锅 :字体引擎拿到了包含
\n或\r的字符串,跑去字体文件(尤其是比较老旧的simhei.ttf黑体)里查找这个字的字形(Glyph)。由于字体库里根本没有"换行符"的长相,渲染引擎只能无奈地画一个代表"未知字符"的默认占位符------方块(□)。
❌ 传统的妥协方案
网上常见的解决方案通常有两种,但都伴随着阵痛:
- 方案 A(转义/替换符) :尝试把
\n换成各种诡异的转义字符。缺点 :极度不稳定,受宿主操作系统和 Java/Python 的ProcessBuilder转义机制影响很大。 - 方案 B(改用 textfile) :放弃
text=参数,改用textfile=temp.txt,让 FFmpeg 去读 UTF-8 文件。缺点 :需要引入繁琐的磁盘 I/O 操作,每次渲染都要创建临时文件、捕获异常、并在finally里打扫战场,对于追求极速的纯内存运算引擎来说不够优雅。
🚀 终极解决方案:Java 物理切割 + line_h 动态自适应
既然 FFmpeg 命令行解析多行文本这么坑,那我们就彻底不让它解析多行!
我们采用"降维打击"的架构思路:在业务代码(Java/Python)层,将多行文本彻底"物理超度",化整为零。
核心解题思路:
- 物理切片 :在 Java 层直接用
split("\n")将一整段话切成 N 句独立的单行文本。此时,所有的换行符已被彻底抹杀。 - 多层渲染 :原本的一个
drawtext滤镜,我们动态生成 N 个drawtext滤镜,用逗号,串联。 - 动态行高自适应(灵魂所在) :切分成多行后,第二行的 Y 轴坐标怎么算?写死绝对像素(如
y=100)会导致换个分辨率或改个字号就发生重叠。我们利用 FFmpeg 的原生内部变量line_h(当前字体的高度)来动态计算偏移量。
💻 Java 代码实战
下面这段代码展示了如何优雅地将一个多行文本转换为完美排版的 FFmpeg 滤镜链:
java
import java.util.ArrayList;
import java.util.List;
public class VideoTextEngine {
public static void main(String[] args) {
// 带有 \n 的原始营销文案
String rawText = "We're not good at making videos.\nSo we're giving discounts to everyone!\nIf you can make better content --- DM us!";
// 生成的 FFmpeg Filtergraph
String filterGraph = buildMultilineDrawtext(rawText, "(w-text_w)/2", "h*0.14", 50, "yellow");
System.out.println("-vf \"" + filterGraph + "\"");
}
/**
* 构建多行自适应 drawtext 滤镜链
* @param text 原始多行文本
* @param baseX 基础 X 坐标表达式 (如居中: (w-text_w)/2)
* @param baseY 基础 Y 坐标表达式 (第一行的起始高度)
* @param fontSize 字体大小
* @param color 字体颜色
* @return 拼装好的滤镜字符串
*/
public static String buildMultilineDrawtext(String text, String baseX, String baseY, int fontSize, String color) {
// 1. 彻底抹杀隐藏的 \r,并按 \n 物理切分
String[] textLines = text.replace("\r", "").split("\n");
List<String> filters = new ArrayList<>();
// 设定行间距(像素)
int lineSpacing = 15;
for (int i = 0; i < textLines.length; i++) {
String currentLine = textLines[i].trim();
if (currentLine.isEmpty()) continue;
StringBuilder drawText = new StringBuilder("drawtext=");
drawText.append("fontfile='C\\:/Windows/Fonts/msyh.ttf':"); // 推荐使用微软雅黑
// 对单引号进行转义,防止 FFmpeg 命令行截断
String safeText = currentLine.replace("'", "\\'");
drawText.append("text='").append(safeText).append("':");
drawText.append("fontsize=").append(fontSize).append(":");
drawText.append("fontcolor=").append(color).append(":");
drawText.append("x=").append(baseX).append(":");
// 🌟 核心魔法:使用 FFmpeg 原生变量 line_h 动态计算 Y 轴
if (i == 0) {
// 第一行,使用基础坐标
drawText.append("y=").append(baseY);
} else {
// 后续行:基础坐标 + (字体高度 + 行间距) * 行号
// 注意:让 FFmpeg 底层去计算 line_h,完美自适应任何字号缩放!
drawText.append("y=").append(baseY).append("+").append("(line_h+").append(lineSpacing).append(")*").append(i);
}
filters.add(drawText.toString());
}
// 用逗号将多个 drawtext 滤镜优雅串联
return String.join(", ", filters);
}
}
💡 方案优势总结
- 100% 免疫乱码:底层渲染引擎根本见不到任何控制字符,方块字被从源头上物理消灭。
- 极佳的扩展性 :因为每一行都被拆成了独立的
drawtext层,你甚至可以在循环里加上if判断,实现**"第一行字号大、黄色;第二行字号小、白色"**的复杂高级排版(这是单滤镜很难做到的)。 - 完美响应式 :
line_h变量把计算行高的工作交还给了 FFmpeg 的字体引擎。无论你是 720p 还是 4K,字号是 40 还是 120,行距永远丝滑准确,绝对不会发生重叠。 - 代码极度清爽 :无需申请磁盘权限,无需创建/清理临时
.txt文件,纯内存字符串拼接,在大规模自动化视频矩阵生成中,性能和稳定性拉满。