文件名是 MP4 就能处理?用 ffprobe 检查视频流、音轨和时长

文件上传完成,后端准备抽音轨、转写。此时一个 .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 项不包含进程启动失败、超时、中断、终止或清理失败的故障注入;上述异常路径本轮只作源码审查。生产端到端、跨平台、全部编解码器、多流择优、完整解码、安全扫描和大文件/资源峰值也未验证;预检通过不保证后续解码成功。

给后续处理明确的输入

一个处理入口应把工具结果转成明确的输入条件:允许什么格式家族,选择哪类流,是否需要音轨,如何解释缺失和异常字段。

这些条件确定后,再将选流结果和预检摘要交给后续抽音轨、转写与渲染。探测失败、元数据不可靠和不满足业务规则分别保留原因,也更容易安排验证与排错。

相关推荐
snpgroupcn1 小时前
SAP 系统退役的归档架构怎么设计:可检索、可理解、可追溯、可证明
java·数据库·架构
文丑颜不良啊1 小时前
LeetCode476~500题目解析
java·leetcode
雨落在了我的手上1 小时前
JavaEE初级(1):计算机是如何⼯作的
java·java-ee
惊讶的猫1 小时前
Spring AOP
java·开发语言
用户3126874877201 小时前
Java 异常体系全链路:受检异常到底该不该用
java
学到老活到老2 小时前
集合换成线程安全的,就不用加锁了吗?
java·面试
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 今日佳人:需求分析
java·开发语言·交友
余额瞒着我当琳2 小时前
C++11,发展历史浅谈,初始化列表和对象初始化,右值引用和移动语义
java·开发语言·c++·算法