视频字幕如何烧录到成片?硬字幕、软字幕、样式控制与播放验收

一份已经确认的 SRT,不等于用户能看到正确字幕。有人把字幕文件放在视频旁边,以为播放器会自动加载;有人把字幕硬烧进视频,却在部署机上缺少中文字体;还有人只检查 FFmpeg 退出码,没有发现成片时长变短或字幕根本没有出现。

本文解决的是"确认字幕怎样成为可交付视频"的工程问题。固定案例是一条 93 秒、1920×1080 的中文讲解视频,已有确认版 confirmed.srt。网站需要可开关、可切换语言的字幕轨;短视频平台需要字幕直接显示在画面中。系统必须据此选择软字幕或硬字幕,并留下可复核的输出记录。

示例环境为 Python 3 Worker 执行 FFmpeg,Java 17/Spring Boot 风格服务层管理任务和产物,MySQL 8.x 保存渲染记录。命令参数、字体文件和路径均为教学示例,应按实际环境配置。

目录

  1. 先判断该用硬字幕还是软字幕
  2. 固定案例和交付目标
  3. 字幕烧录的处理链路
  4. 数据模型:一次渲染必须可追溯
  5. Python实现:生成安全的FFmpeg任务
  6. Java实现:只登记验收通过的成片
  7. 预期输出和自动测试
  8. SQL验证:上线后怎样核对字幕成片
  9. 异常边界和交付验收
  10. 小结和延伸阅读

一、先判断该用硬字幕还是软字幕

"加字幕"至少有两种交付方式。软字幕把字幕轨封装进容器,播放器负责显示;硬字幕在渲染时写进每一帧画面。它们不是谁更高级,而是交付约束不同:

交付场景 推荐方式 原因 限制
自有网站、课程播放器 软字幕 用户可开关、换语言、调字号 播放器必须支持字幕轨和 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 命令跑起来,而是根据交付场景选择硬字幕或软字幕,让确认版字幕、样式配置、临时文件、媒体探测和最终成片形成一条可验收的链路。这样网页播放器保留字幕轨,短视频平台也能稳定展示文字,而失败文件不会混进正式交付物。

  1. FFmpeg Filters Documentation
  2. FFprobe Documentation
  3. Spring Framework: Transaction Management
  4. MySQL 8.0 Reference Manual: CREATE TABLE
相关推荐
小朱爱编程1231 小时前
我用 Jev 做了三个实用工具:整理标签页、分诊飞书反馈、找回 GitHub 收藏
java·开发语言·人工智能·后端·python·架构·ai编程
酷虎软件1 小时前
视频加标题字幕 API 接口文档
java·数据库·mysql
谢亮_vipxieliang2 小时前
Java 8/11/17/21/25 怎么选?一篇讲清 LTS 升级路线
java·开发语言
Cindy_cd2 小时前
IDEA2021 配置 JDK+Maven
java·开发语言·maven
我要神龙摆尾2 小时前
IDEA 阅读插件
java·ide·intellij-idea·idea-plugs·plugs
涉密IT资质笔记3 小时前
涉密人员脱密期管理规范:期限分级模型、就业限制边界与违规认定标准
java·服务器·前端·网络·数据库
mmsx3 小时前
Android Dialog 插槽模式:用一套壳统一全应用的弹窗风格
android·java·dialog·开发语言
u0111026753 小时前
工具页案例图如何编写 alt让图片说明与处理场景对应
java·前端·javascript·图像处理·人工智能·算法·ai作画
码上有光3 小时前
Linux:进程间通信——匿名管道通信、命名管道通信
android·java·linux·linux通信·匿名通信·命名通信