文件上传完成,后端准备抽音轨、转写。此时一个 .mp4 文件名,还回答不了几个问题:它有没有视频流,是否包含音轨,时长是否满足入口要求。
带封面的音频文件还会带来另一个判断:探测结果里出现了 video,这条流是不是业务需要的视频?
本文以 Vimorph 当前的 Spring Boot 视频预处理代码为依据,另用 Java 21 + Jackson 2.17.3 教学工程验证 JSON 解析与业务规则。分离输出通道、精确 token 匹配、排除封面和严格数值处理属于独立示例,没有修改业务实现;下面代码均为节选。
先看处理入口需要什么
当前 LocalFfmpegVideoSourceValidator.validate() 的顺序是:取得本地缓存路径,检查文件大小,用 ffprobe 读取视频宽高、容器及总时长,再探测首条音轨的编码名称。
| 项目中的检查 | 当前规则 |
|---|---|
| 文件大小 | 非空,不超过 1 GiB |
| 视频流 | 使用 v:0,要求正宽高 |
| 时长 | 转为毫秒后检查非正值及 60 分钟上限 |
| 容器 | 对 format_name 使用 mov/mp4/webm 子串判断 |
| 音轨 | a:0 探测成功后,编码名称为空则报缺少音轨 |
PreprocessVideoJobHandler.execute() 在校验后记录时长、尝试生成预览资源,再把已经探测到的 audioCodec 传给抽音轨入口。预览生成有自己的异常捕获,不表示预览失败一定阻断抽音轨。
1 GiB 和 60 分钟是当前业务限制,并非 ffprobe 的最大处理能力。大小检查发生在下载取得本地文件之后,也不能当作下载阶段已经具备同样的流量限制。
容器名称提供了哪些信息
先分清三个字段层次:format_name 描述探测到的格式家族,streams 保存媒体流信息,codec_name 标识流的编码名称。
本次生成的有声 MP4 和 M4A 都返回了 mov,mp4,m4a,3gp,3g2,mj2。这组解复用器别名不能唯一鉴定文件就是 MP4。教学代码用逗号切成 token,与 mov、mp4、webm 的允许集合求交,避免把 notmp4 这样的字符串误当命中;它仍是一条格式家族策略,不是完整文件类型鉴定。ffprobe 官方说明
是否要求音轨也由业务决定。本例面向视频翻译,后续需要提取音频,因此无音轨视频会被拒绝;这不表示该视频文件本身不合法。

把 JSON 结果和诊断分开
项目共享的 FfmpegService 合并 stderr/stdout,当前校验器再解析 key=value 文本。独立示例改为 JSON 后,需要把诊断和结果分开,避免错误信息混进 JSON。
下面是教学 probe() 中的命令代码块节选:
java
process = new ProcessBuilder(executable, "-v", "error", "-show_format", "-show_streams",
"-of", "json", "-i", input.toAbsolutePath().toString())
.redirectOutput(stdout.toFile()).redirectError(stderr.toFile()).start();
executable 是构造器指定的 ffprobe 路径,input 是本地文件;stdout、stderr 是这次调用创建的临时目录里的两个文件。process 保存启动后的直接子进程。参数逐项传给 ProcessBuilder,没有经过 shell。
命令成功退出后才读取 JSON。启动失败、等待超时、非零退出和输入不满足条件分别报告;非零退出不能统一解释成"没有音轨"。
示例最多读取 1 MiB JSON,失败诊断只取 stderr 末尾 4096 字节。这些上限限制读取量,不是命令运行时的磁盘配额。 finally 尝试收尾直接子进程并删除自有临时文件,输入保留;命令等待预算不覆盖启动、文件 I/O 和清理的总耗时。
清理会先暂时清除中断标记,进行 300ms 的终止等待,必要时强制终止再等待 1000ms,退出前恢复已记录的中断。若仍无法确认退出,会记录 I/O 错误并继续尝试删除临时文件;这不保证进程已退出或文件已删净。已捕获的清理 IOException 会附到主要异常,正常结果遇到清理失败也会改为抛错;未捕获的运行时清理异常不具有同样的保留保证。
类型检查放在读取字段之前
JSON 能成功解析,还需要检查对象、数组和字段类型。这里先确认根、format 和 streams 的形状,再在各字段使用前做类型检查,不把缺失值默认为零。
Jackson 配置节选如下:
java
private static final ObjectMapper MAPPER = new ObjectMapper(JsonFactory.builder()
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
重复键和尾随的第二份 JSON 会被拒绝。USE_BIG_DECIMAL_FOR_FLOATS 则让数值节点保留十进制值,避免 3600.0000000000001 先被 double 舍入成 3600 后误过秒级上限。
示例的 requiredText() 要求非空字符串,positiveInt() 要求可转换为 int 的正整数;字符串 "160" 不会被静默当作数字宽度。错误提示保留具体字段或业务原因,便于区分 JSON 基本结构错误与视频、音轨、时长等检查失败。
封面也会出现在视频流列表里
官方流选择规则中,v 包含视频封面等附带图片,V 排除这类图片。本次自行生成的 M4A 包含 AAC 音轨和 PNG 封面;封面流的宽高是 160×90,attached_pic=1。实际命令中,v:0 能看到它,V:0 返回空流列表。流选择规则
教学示例直接读取全部流的 JSON,检查封面标志,选择代码块节选如下。streams 是已确认的数组,video、audio 初始为 null,Kind 和 error() 用于构造带类别的 ProbeException:
java
for (JsonNode stream : streams) {
if (!stream.isObject()) throw error(Kind.METADATA, "streams 每一项必须是对象");
String type = requiredText(stream, "codec_type");
if (type.equals("video")) {
JsonNode disposition = stream.get("disposition");
if (disposition == null || !disposition.isObject()) throw error(Kind.METADATA, "视频流缺少 disposition 对象");
JsonNode attached = disposition.get("attached_pic");
if (attached == null || !attached.isIntegralNumber() || !attached.canConvertToInt()
|| (attached.intValue() != 0 && attached.intValue() != 1)) {
throw error(Kind.METADATA, "attached_pic 必须是整数 0 或 1");
}
if (attached.intValue() == 0 && video == null) video = stream;
} else if (type.equals("audio") && audio == null) audio = stream;
}
这是有意采用的严格元数据契约:每条视频流都必须提供 disposition 对象,attached_pic 必须是整数 0 或 1;缺失、null、字符串 "0"、布尔值或小数节点都不按 0 猜测。字段不足时拒绝的是"不满足本例契约的元数据",不能据此认定媒体本身损坏。
选择策略是返回顺序中的第一条非封面视频流、第一条音轨,然后检查所选视频的宽高以及两条流的编码名称。它没有自动择优、遍历所有编码测试可用性或检测画面是否运动。
预检选中的视频流与音轨,还需要和后续 FFmpeg 转码、抽音轨保持一致。示例的 VideoInfo 只返回摘要,没有保存所选流的索引;接入时应传递 stream.index 并显式映射,或核对后续规则与预检等价,避免检查首条非封面视频,却让 FFmpeg 自动选择另一条流。本轮未完整核查业务转码链路,这里是接入要求,不是现有业务已统一选流的结论。FFmpeg 流选择说明
这个样本核对了封面标志与独立选择策略。
时长的比较与舍入顺序
时长既有字段契约,也有业务范围。教学示例接受字符串或数字,按十进制秒检查 (0, 3600],再用 HALF_UP 换算毫秒;换算结果为零也拒绝。
下面是 durationMillis() 方法节选。参数 value 是 format.duration 的 JsonNode,MAX_DURATION_SECONDS 是值为 3600 的 BigDecimal:
java
private static long durationMillis(JsonNode value) throws ProbeException {
if (value == null || !(value.isTextual() || value.isNumber())) throw error(Kind.DURATION, "缺少可靠的时长值");
String raw = value.isTextual() ? value.textValue() : value.toString();
if (raw.length() > 128) throw error(Kind.DURATION, "时长值过长");
try {
BigDecimal seconds = new BigDecimal(raw);
// ffprobe normally emits 6 decimal places; this teaching contract accepts up to 9.
if (seconds.precision() > 32 || seconds.scale() < -9 || seconds.scale() > 9) {
throw error(Kind.DURATION, "时长数值超出本示例支持的精度或指数范围");
}
if (seconds.signum() <= 0 || seconds.compareTo(MAX_DURATION_SECONDS) > 0) {
throw error(Kind.DURATION, "时长必须在 (0, 3600] 秒内");
}
long millis = seconds.multiply(BigDecimal.valueOf(1000)).setScale(0, RoundingMode.HALF_UP).longValueExact();
if (millis <= 0) throw error(Kind.DURATION, "时长精度不足 1 毫秒");
return millis;
} catch (NumberFormatException | ArithmeticException invalid) {
throw new ProbeException(Kind.DURATION, "时长不是有效有限数值", invalid);
}
}
文本长度、precision 和 scale 都有限制。先约束精度与指数,再进行乘法和舍入,避免极端指数引起不必要的大数运算。这些精度要求属于本示例契约。BigDecimal API
在其余元数据有效、下列时长以字符串提供时,结果如下:
format.duration(秒) |
结果 | 判断顺序 |
|---|---|---|
"1.2344" |
1234ms | 1234.4ms 按 HALF_UP 舍入 |
"1.2345" |
1235ms | 恰好半毫秒,向上舍入 |
"0.0004" |
拒绝 | 舍入后为 0ms |
"0.0005" |
1ms | 舍入后为正数,可以接受 |
"3600" |
3600000ms | 秒级上限包含 3600 |
"3600.0001" |
拒绝 | 先比较秒值,超限即拒绝,不等舍入 |
因此,拒绝条件是舍入后为 0ms,不是所有不足 1ms 的正时长。上限则在舍入前比较,不能先舍成 3600000ms 再放行。
项目现有代码使用 double 换算并舍入为毫秒后检查范围,示例则先比较十进制秒,两者的边界舍入契约不同。当前项目的 NaN、无穷也会经转换和外层范围检查被拒绝,不能把严格示例说成修复了已经证实的非有限值放行漏洞。
用实际样本核对判断
本轮复测沿用 macOS aarch64、Temurin 21.0.9、Jackson 2.17.3,FFmpeg 与 ffprobe 均为 6.0。
最终通过 47 个用例:40 项 JSON 契约检查,7 项本地媒体与调用场景。空文件在启动前被拒绝,不能把这 7 项写成全部都启动了 ffprobe。最终输出汇总为:
text
RESULT fixtures=40 real_media=7 total=47 failed=0
契约检查覆盖类型、缺字段、重复键、尾随 JSON、格式 token、封面选择、缺少音轨、时长边界、异常指数和 JSON 字节长度上限。实际小媒体场景如下:
| 自行生成的输入 | 教学结果 |
|---|---|
| 1 秒有声 MP4,mpeg4/aac | 返回 160×90、1000 毫秒的 VideoInfo |
| 无音轨 MP4 | 因本入口需要音轨而拒绝 |
| 纯音频 M4A | 缺少非封面视频流 |
| 带 PNG 封面的 M4A | 封面标志为 1,不计作所需视频流 |
自造的非空无效 .mp4 |
ffprobe 非零退出,归为命令执行失败 |
| 空文件 | 输入大小检查拒绝,不启动命令 |
| 拒绝输入后再次处理正常样本 | 仍能正常返回结果 |
本轮原套件及更新后 ZIP 解压的同一套 47 项均通过,不累计为 94 项;Java 实现和原测试源码未改。另以 15 项定向断言核对了严格字段契约和上面的时长示例,不混入原 47 项套件。执行记录及源码指纹保留在发布备注中。
这 47 项不包含进程启动失败、超时、中断、终止或清理失败的故障注入;上述异常路径本轮只作源码审查。生产端到端、跨平台、全部编解码器、多流择优、完整解码、安全扫描和大文件/资源峰值也未验证;预检通过不保证后续解码成功。
给后续处理明确的输入
一个处理入口应把工具结果转成明确的输入条件:允许什么格式家族,选择哪类流,是否需要音轨,如何解释缺失和异常字段。
这些条件确定后,再将选流结果和预检摘要交给后续抽音轨、转写与渲染。探测失败、元数据不可靠和不满足业务规则分别保留原因,也更容易安排验证与排错。