一份已经确认的 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:软字幕保留播放端控制能力;硬字幕换取跨平台的确定展示。
二、固定案例和交付目标
任务编号: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判断,因为字幕已变成像素。生产中可保存实际字体与滤镜参数、抽取指定时间点预览帧供人工核对、并将原始字幕与渲染任务关联。软字幕则能通过媒体流检查语言与编码。
四、数据模型:一次渲染必须可追溯
不要只在任务表里留一个成片路径。至少记录该成片使用了哪一版字幕、哪套样式和哪种交付方式:
CREATETABLEsubtitle_render_job(idBIGINTPRIMARYKEYAUTO_INCREMENT,job_noVARCHAR(64)NOTNULL,source_video_idBIGINTNOTNULL,subtitle_version_idBIGINTNOTNULL,delivery_modeVARCHAR(16)NOTNULL,render_statusVARCHAR(24)NOTNULL,style_profileVARCHAR(64)NULL,font_nameVARCHAR(128)NULL,language_codeVARCHAR(16)NULL,output_pathVARCHAR(500)NULL,output_sha256CHAR(64)NULL,output_duration_msBIGINTNULL,output_widthINTNULL,output_heightINTNULL,error_codeVARCHAR(64)NULL,error_messageVARCHAR(1000)NULL,request_idVARCHAR(64)NOTNULL,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_render_request(request_id),KEYidx_render_video(source_video_id,render_status),CHECK(delivery_modeIN('BURNED','SOFT')),CHECK(render_statusIN('PENDING','RUNNING','READY','FAILED')));同一份确认字幕可以生成多个交付物,但每个交付物有独立渲染记录。READY只表示文件已通过当前交付规则的验收,不表示以后不能因样式变更重新生成新版本。
图3:渲染记录回答“这份成片用了什么字幕、什么样式,为什么可以交付”。
五、Python实现:生成安全的FFmpeg任务
Worker 不接受前端直接拼接的 FFmpeg 字符串,而是接收结构化请求,检查字幕版本、字体和样式配置后构造参数。下面是硬字幕任务的核心部分:
fromdataclassesimportdataclassfrompathlibimportPathimportsubprocess@dataclass(frozen=True)classBurnRequest:video:Path subtitle:Path output:Path font_name:str="Noto Sans CJK SC"font_size:int=42margin_v:int=72defbuild_burn_command(request:BurnRequest)->list[str]:ifnotrequest.video.is_file()ornotrequest.subtitle.is_file():raiseValueError("源视频或确认字幕不存在")ifrequest.font_size<20orrequest.font_size>96:raiseValueError("字幕字号不在允许范围")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)]defrender_burned(request:BurnRequest)->Path:completed=subprocess.run(build_burn_command(request),capture_output=True,text=True,timeout=900)ifcompleted.returncode!=0:raiseRuntimeError(completed.stderr[-1200:])returnrequest.output.with_suffix(request.output.suffix+".writing")软字幕不需要视频滤镜,通常复制视频和音频流并把字幕封装到 MP4:
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 后核验结构化媒体信息:
@Transactional(rollbackFor=Exception.class)publicRenderResultrender(RenderCommandcommand){SubtitleRenderJobjob=renderRepository.lockByRequestId(command.requestId()).orElseGet(()->renderRepository.createPending(command));if("READY".equals(job.status()))returnRenderResult.reused(job.outputPath());WorkflowFilevideo=fileRepository.findReady(command.videoId(),"SOURCE_VIDEO").orElseThrow(()->newBizException("源视频不可用"));SubtitleVersionsubtitle=subtitleRepository.findConfirmed(command.subtitleVersionId()).orElseThrow(()->newBizException("字幕尚未确认,不能渲染"));renderRepository.markRunning(job.id());WorkerRenderResultoutput=renderWorker.render(video.path(),subtitle.path(),command.profile());if(!output.success()||!output.mediaInfo().isPlayable()){renderRepository.markFailed(job.id(),output.errorCode(),output.errorMessage());returnRenderResult.failed(output.errorCode());}if(Math.abs(output.mediaInfo().durationMs()-video.durationMs())>1500){renderRepository.markFailed(job.id(),"DURATION_MISMATCH","成片时长偏差超过 1.5 秒");returnRenderResult.failed("DURATION_MISMATCH");}StringoutputPath=fileRepository.commitTemporary(output.temporaryPath(),command.deliveryRole());renderRepository.markReady(job.id(),outputPath,output.sha256(),output.mediaInfo());returnRenderResult.ready(outputPath);}“FFmpeg 返回 0”和“成片可交付”是两件事。后者还要满足时长、分辨率、文件大小、软字幕流或硬字幕预览等规则。
七、预期输出和自动测试
硬字幕:lesson-burned.mp4,1920x1080,时长约 93.2 秒,字幕在底部安全区域可见 软字幕:lesson-soft.mp4,包含 1 条 chi / mov_text 字幕轨,可由播放器开关 两份成片:均关联 CONFIRMED-V3,不关联草稿或提议字幕 失败任务:保留错误摘要和临时文件清理记录,不生成 READY 产物deftest_burn_command_rejects_missing_subtitle(tmp_path):request=BurnRequest(tmp_path/"source.mp4",tmp_path/"missing.srt",tmp_path/"out.mp4")withpytest.raises(ValueError,match="确认字幕不存在"):build_burn_command(request)deftest_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"))assertcommand[-1].endswith("lesson.mp4.writing")@TestvoidshouldRejectUnconfirmedSubtitle(){fixture.readyVideo(100L,93_200L);fixture.subtitleVersion(200L,"WAITING_CONFIRM");BizExceptionerror=assertThrows(BizException.class,()->service.render(newRenderCommand("REQ-005",100L,200L,"BURNED")));assertTrue(error.getMessage().contains("字幕尚未确认"));}@TestvoidshouldFailWhenDurationDriftsTooFar(){fixture.readyVideo(100L,93_200L);fixture.confirmedSubtitle(200L);worker.stubSuccess(90_000L,1920,1080);RenderResultresult=service.render(newRenderCommand("REQ-006",100L,200L,"BURNED"));assertEquals("DURATION_MISMATCH",result.errorCode());}八、SQL验证:上线后怎样核对字幕成片
-- 已交付但时长明显偏离源视频的任务,预期结果为空SELECTr.job_no,r.delivery_mode,r.output_duration_ms,v.duration_msASsource_duration_msFROMsubtitle_render_job rJOINworkflow_file vONv.id=r.source_video_idWHEREr.render_status='READY'ANDABS(r.output_duration_ms-v.duration_ms)>1500;-- 软字幕成片应带有语言信息,预期结果为空SELECTjob_no,output_pathFROMsubtitle_render_jobWHEREdelivery_mode='SOFT'ANDrender_status='READY'AND(language_codeISNULLORlanguage_code='');-- 同一请求只允许一条渲染记录,预期结果为空SELECTrequest_id,COUNT(*)AScntFROMsubtitle_render_jobGROUPBYrequest_idHAVINGCOUNT(*)>1;图5:没有通过媒体与交付规则核验的文件,只能保留为失败证据,不能被下游当作成片。
九、异常边界和交付验收
| 异常 | 应对方式 |
|---|---|
| 缺少中文字体 | 预检字体文件或字体族;缺失时阻断硬字幕渲染 |
| SRT 编码错误 | 入库时统一 UTF-8;解析失败不进入确认版本 |
| 字幕被画面裁切 | 使用分辨率对应的安全边距;保留预览帧抽检 |
| FFmpeg 超时或异常退出 | 保存 stderr 摘要,清理临时文件,允许重新发起 |
| 成片时长或分辨率异常 | 标记失败,不移动到正式交付目录 |
| 同一请求重复提交 | 以request_id复用结果或阻止并发执行 |
上线验收至少应完成:硬字幕在目标分辨率预览帧可读;软字幕在目标播放器可开关;成片时长、分辨率和文件大小符合规则;每份成片可追溯到确认字幕版本与样式配置;失败记录能定位字体、编码、命令或媒体探测问题。
十、小结和延伸阅读
字幕烧录的关键不是把一条 FFmpeg 命令跑起来,而是根据交付场景选择硬字幕或软字幕,让确认版字幕、样式配置、临时文件、媒体探测和最终成片形成一条可验收的链路。这样网页播放器保留字幕轨,短视频平台也能稳定展示文字,而失败文件不会混进正式交付物。
- FFmpeg Filters Documentation
- FFprobe Documentation
- Spring Framework: Transaction Management
- MySQL 8.0 Reference Manual: CREATE TABLE