一份已经确认的 SRT,不等于用户能看到正确字幕。有人把字幕文件放在视频旁边,以为播放器会自动加载;有人把字幕硬烧进视频,却在部署机上缺少中文字体;还有人只检查 FFmpeg 退出码,没有发现成片时长变短或字幕根本没有出现。
本文解决的是"确认字幕怎样成为可交付视频"的工程问题。固定案例是一条 93 秒、1920×1080 的中文讲解视频,已有确认版 confirmed.srt。网站需要可开关、可切换语言的字幕轨;短视频平台需要字幕直接显示在画面中。系统必须据此选择软字幕或硬字幕,并留下可复核的输出记录。
示例环境为 Python 3 Worker 执行 FFmpeg,Java 17/Spring Boot 风格服务层管理任务和产物,MySQL 8.x 保存渲染记录。命令参数、字体文件和路径均为教学示例,应按实际环境配置。
目录
- 先判断该用硬字幕还是软字幕
- 固定案例和交付目标
- 字幕烧录的处理链路
- 数据模型:一次渲染必须可追溯
- Python实现:生成安全的FFmpeg任务
- Java实现:只登记验收通过的成片
- 预期输出和自动测试
- SQL验证:上线后怎样核对字幕成片
- 异常边界和交付验收
- 小结和延伸阅读
一、先判断该用硬字幕还是软字幕
"加字幕"至少有两种交付方式。软字幕把字幕轨封装进容器,播放器负责显示;硬字幕在渲染时写进每一帧画面。它们不是谁更高级,而是交付约束不同:
| 交付场景 | 推荐方式 | 原因 | 限制 |
|---|---|---|---|
| 自有网站、课程播放器 | 软字幕 | 用户可开关、换语言、调字号 | 播放器必须支持字幕轨和 UTF-8 |
| 短视频平台、社交媒体 | 硬字幕 | 上传后不依赖外部 SRT 文件 | 字体、换行和安全边距固定在画面中 |
| 留存母版 | 软字幕加原始 SRT | 保留可编辑文本和多语言空间 | 不能把母版误当最终发布文件 |
| 审核预览 | 硬字幕预览版 | 审核人打开即可核对画面 | 预览版不替代字幕资产 |
固定案例的网页版本输出 lesson-soft.mp4,内含中文字幕轨;平台版本输出 lesson-burned.mp4,字幕已写入画面。两份文件都来自同一个 CONFIRMED_SRT,不能各自手工修改。

图1:软字幕保留播放端控制能力;硬字幕换取跨平台的确定展示。
二、固定案例和交付目标
text
任务编号:VW-20260930-005
源视频:SOURCE_VIDEO / source/original.mp4
确认字幕:CONFIRMED_SRT / outputs/subtitle/confirmed.srt
视频参数:1920x1080,93.2 秒,25 fps
硬字幕输出:outputs/delivery/lesson-burned.mp4
软字幕输出:outputs/delivery/lesson-soft.mp4
字体:Noto Sans CJK SC,字号 42,底边距 72 像素
开发机上的预览曾正常,部署机却缺少同一套中文字体;FFmpeg 返回成功,但部分字符显示为方框。另一条任务把时间轴单位理解错,导出文件只剩一小段。结论是:字幕是否出现、文字是否能显示、时长是否接近源视频,都要成为交付条件。
三、字幕烧录的处理链路
无论硬字幕还是软字幕,都不应该直接覆盖最终文件。正确链路是读取确认输入、生成临时输出、探测媒体、检查交付规则、最后原子登记:
| 步骤 | 硬字幕处理 | 软字幕处理 | 关键证据 |
|---|---|---|---|
| 读取输入 | 视频与确认版 SRT 均为 READY |
同左 | 文件摘要、版本号 |
| 选择配置 | 字体、字号、边距、颜色 | 语言、标题、默认轨 | 配置版本 |
| FFmpeg 生成 | subtitles 滤镜写入画面 |
复制视频流并封装字幕轨 | 临时文件、stderr 摘要 |
| 媒体探测 | 检查时长、分辨率、视频流 | 额外检查 subtitle stream | ffprobe 结果 |
| 提交产物 | 验收通过后移动为最终文件 | 验收通过后移动为最终文件 | sha256、大小、状态 |

图2:两种成片共享同一份确认字幕,但使用不同的渲染和验收规则。
硬字幕的"字幕是否可见"不能只靠 ffprobe 判断,因为字幕已变成像素。生产中可保存实际字体与滤镜参数、抽取指定时间点预览帧供人工核对、并将原始字幕与渲染任务关联。软字幕则能通过媒体流检查语言与编码。
四、数据模型:一次渲染必须可追溯
不要只在任务表里留一个成片路径。至少记录该成片使用了哪一版字幕、哪套样式和哪种交付方式:
sql
CREATE TABLE subtitle_render_job (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_no VARCHAR(64) NOT NULL,
source_video_id BIGINT NOT NULL,
subtitle_version_id BIGINT NOT NULL,
delivery_mode VARCHAR(16) NOT NULL,
render_status VARCHAR(24) NOT NULL,
style_profile VARCHAR(64) NULL,
font_name VARCHAR(128) NULL,
language_code VARCHAR(16) NULL,
output_path VARCHAR(500) NULL,
output_sha256 CHAR(64) NULL,
output_duration_ms BIGINT NULL,
output_width INT NULL,
output_height INT NULL,
error_code VARCHAR(64) NULL,
error_message VARCHAR(1000) NULL,
request_id VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NULL,
UNIQUE KEY uk_render_request (request_id),
KEY idx_render_video (source_video_id, render_status),
CHECK (delivery_mode IN ('BURNED', 'SOFT')),
CHECK (render_status IN ('PENDING', 'RUNNING', 'READY', 'FAILED'))
);
同一份确认字幕可以生成多个交付物,但每个交付物有独立渲染记录。READY 只表示文件已通过当前交付规则的验收,不表示以后不能因样式变更重新生成新版本。

图3:渲染记录回答"这份成片用了什么字幕、什么样式,为什么可以交付"。
五、Python实现:生成安全的FFmpeg任务
Worker 不接受前端直接拼接的 FFmpeg 字符串,而是接收结构化请求,检查字幕版本、字体和样式配置后构造参数。下面是硬字幕任务的核心部分:
python
from dataclasses import dataclass
from pathlib import Path
import subprocess
@dataclass(frozen=True)
class BurnRequest:
video: Path
subtitle: Path
output: Path
font_name: str = "Noto Sans CJK SC"
font_size: int = 42
margin_v: int = 72
def build_burn_command(request: BurnRequest) -> list[str]:
if not request.video.is_file() or not request.subtitle.is_file():
raise ValueError("源视频或确认字幕不存在")
if request.font_size < 20 or request.font_size > 96:
raise ValueError("字幕字号不在允许范围")
subtitle_path = request.subtitle.as_posix().replace("'", r"\\'")
style = f"FontName={request.font_name},FontSize={request.font_size},MarginV={request.margin_v}"
filter_arg = f"subtitles=filename='{subtitle_path}':force_style='{style}'"
temporary = request.output.with_suffix(request.output.suffix + ".writing")
return ["ffmpeg", "-y", "-i", str(request.video), "-vf", filter_arg,
"-c:v", "libx264", "-crf", "20", "-preset", "medium",
"-c:a", "aac", "-movflags", "+faststart", str(temporary)]
def render_burned(request: BurnRequest) -> Path:
completed = subprocess.run(build_burn_command(request), capture_output=True, text=True, timeout=900)
if completed.returncode != 0:
raise RuntimeError(completed.stderr[-1200:])
return request.output.with_suffix(request.output.suffix + ".writing")
软字幕不需要视频滤镜,通常复制视频和音频流并把字幕封装到 MP4:
text
ffmpeg -i source.mp4 -i confirmed.srt \
-map 0:v -map 0:a? -map 1:0 \
-c:v copy -c:a copy -c:s mov_text \
-metadata:s:s:0 language=chi -metadata:s:s:0 title="中文" \
lesson-soft.mp4.writing
.writing 很关键:没有完成探测和验收的文件不能使用正式扩展名,更不能登记为 READY。

图4:外部命令成功只是中间结果;媒体探测和文件提交共同决定成片是否可用。
六、Java实现:只登记验收通过的成片
Java 服务层锁定请求、读取已确认字幕,调用 Worker 后核验结构化媒体信息:
java
@Transactional(rollbackFor = Exception.class)
public RenderResult render(RenderCommand command) {
SubtitleRenderJob job = renderRepository.lockByRequestId(command.requestId())
.orElseGet(() -> renderRepository.createPending(command));
if ("READY".equals(job.status())) return RenderResult.reused(job.outputPath());
WorkflowFile video = fileRepository.findReady(command.videoId(), "SOURCE_VIDEO")
.orElseThrow(() -> new BizException("源视频不可用"));
SubtitleVersion subtitle = subtitleRepository.findConfirmed(command.subtitleVersionId())
.orElseThrow(() -> new BizException("字幕尚未确认,不能渲染"));
renderRepository.markRunning(job.id());
WorkerRenderResult output = renderWorker.render(video.path(), subtitle.path(), command.profile());
if (!output.success() || !output.mediaInfo().isPlayable()) {
renderRepository.markFailed(job.id(), output.errorCode(), output.errorMessage());
return RenderResult.failed(output.errorCode());
}
if (Math.abs(output.mediaInfo().durationMs() - video.durationMs()) > 1500) {
renderRepository.markFailed(job.id(), "DURATION_MISMATCH", "成片时长偏差超过 1.5 秒");
return RenderResult.failed("DURATION_MISMATCH");
}
String outputPath = fileRepository.commitTemporary(output.temporaryPath(), command.deliveryRole());
renderRepository.markReady(job.id(), outputPath, output.sha256(), output.mediaInfo());
return RenderResult.ready(outputPath);
}
"FFmpeg 返回 0"和"成片可交付"是两件事。后者还要满足时长、分辨率、文件大小、软字幕流或硬字幕预览等规则。
七、预期输出和自动测试
text
硬字幕:lesson-burned.mp4,1920x1080,时长约 93.2 秒,字幕在底部安全区域可见
软字幕:lesson-soft.mp4,包含 1 条 chi / mov_text 字幕轨,可由播放器开关
两份成片:均关联 CONFIRMED-V3,不关联草稿或提议字幕
失败任务:保留错误摘要和临时文件清理记录,不生成 READY 产物
python
def test_burn_command_rejects_missing_subtitle(tmp_path):
request = BurnRequest(tmp_path / "source.mp4", tmp_path / "missing.srt", tmp_path / "out.mp4")
with pytest.raises(ValueError, match="确认字幕不存在"):
build_burn_command(request)
def test_burn_command_writes_to_temporary_file(sample_video, sample_srt, tmp_path):
command = build_burn_command(BurnRequest(sample_video, sample_srt, tmp_path / "lesson.mp4"))
assert command[-1].endswith("lesson.mp4.writing")
java
@Test
void shouldRejectUnconfirmedSubtitle() {
fixture.readyVideo(100L, 93_200L);
fixture.subtitleVersion(200L, "WAITING_CONFIRM");
BizException error = assertThrows(BizException.class,
() -> service.render(new RenderCommand("REQ-005", 100L, 200L, "BURNED")));
assertTrue(error.getMessage().contains("字幕尚未确认"));
}
@Test
void shouldFailWhenDurationDriftsTooFar() {
fixture.readyVideo(100L, 93_200L);
fixture.confirmedSubtitle(200L);
worker.stubSuccess(90_000L, 1920, 1080);
RenderResult result = service.render(new RenderCommand("REQ-006", 100L, 200L, "BURNED"));
assertEquals("DURATION_MISMATCH", result.errorCode());
}
八、SQL验证:上线后怎样核对字幕成片
sql
-- 已交付但时长明显偏离源视频的任务,预期结果为空
SELECT r.job_no, r.delivery_mode, r.output_duration_ms, v.duration_ms AS source_duration_ms
FROM subtitle_render_job r
JOIN workflow_file v ON v.id = r.source_video_id
WHERE r.render_status = 'READY'
AND ABS(r.output_duration_ms - v.duration_ms) > 1500;
-- 软字幕成片应带有语言信息,预期结果为空
SELECT job_no, output_path
FROM subtitle_render_job
WHERE delivery_mode = 'SOFT' AND render_status = 'READY'
AND (language_code IS NULL OR language_code = '');
-- 同一请求只允许一条渲染记录,预期结果为空
SELECT request_id, COUNT(*) AS cnt
FROM subtitle_render_job GROUP BY request_id HAVING COUNT(*) > 1;

图5:没有通过媒体与交付规则核验的文件,只能保留为失败证据,不能被下游当作成片。
九、异常边界和交付验收
| 异常 | 应对方式 |
|---|---|
| 缺少中文字体 | 预检字体文件或字体族;缺失时阻断硬字幕渲染 |
| SRT 编码错误 | 入库时统一 UTF-8;解析失败不进入确认版本 |
| 字幕被画面裁切 | 使用分辨率对应的安全边距;保留预览帧抽检 |
| FFmpeg 超时或异常退出 | 保存 stderr 摘要,清理临时文件,允许重新发起 |
| 成片时长或分辨率异常 | 标记失败,不移动到正式交付目录 |
| 同一请求重复提交 | 以 request_id 复用结果或阻止并发执行 |
上线验收至少应完成:硬字幕在目标分辨率预览帧可读;软字幕在目标播放器可开关;成片时长、分辨率和文件大小符合规则;每份成片可追溯到确认字幕版本与样式配置;失败记录能定位字体、编码、命令或媒体探测问题。
十、小结和延伸阅读
字幕烧录的关键不是把一条 FFmpeg 命令跑起来,而是根据交付场景选择硬字幕或软字幕,让确认版字幕、样式配置、临时文件、媒体探测和最终成片形成一条可验收的链路。这样网页播放器保留字幕轨,短视频平台也能稳定展示文字,而失败文件不会混进正式交付物。