手机弹出“存储空间不足”,点进去一看,视频占了 42GB——这两年我遇到太多次了。安卓用户的存储焦虑,八成不是照片造成的,而是视频。一段 4K 60 帧的随手拍,一分钟就能吃掉 400MB;游戏录屏、会议回放、孩子的成长记录,几个月下来轻松破百 G。视频压缩这件事说起来简单,就是“把文件变小”,但真正动手做过的人都知道,里面全是取舍:码率给低了画面糊成一团,给高了等于没压;分辨率降了字幕看不清,不降又省不了空间;音频砍狠了人声发闷。这篇我把折腾 VideoCompressor 这类安卓端压缩工具的完整过程写下来,从体积到底由什么决定、四条实现路径怎么选、参数怎么配、代码怎么写、坑怎么填,一直到怎么验收效果。适合两类人看:想给自己的应用加一个压缩能力的开发者,以及单纯想把手里的视频压小一点的安卓用户。看完你至少能做到两件事:自己算出该给多少码率,以及判断那些“一键瘦身”到底有没有偷偷牺牲你的画质。
1. 先从体积这件事算明白
1.1 存储告警的真实结构
很多人以为手机存储是被照片吃掉的,实际拆开看完全不是这么回事。我用文件管理器按类型统计过自己那台用了两年的中端机:照片 8.6GB,应用数据 22GB,视频 41GB。照片单张 3 到 5MB,堆到 8GB 需要两千多张;而视频一分钟就是几百兆,几十个文件轻松超过照片总量。原因很直接,照片是单帧压缩,视频是每秒 30 帧连续压缩,还得加上音轨,体积自然不在一个量级。
更麻烦的是,安卓上产生视频的入口太多了:系统相机、微信保存、抖音缓存、录屏、通话录音转视频、无人机导出、行车记录仪。这些来源的编码参数五花八门,有的用 H.264 高码率,有的用 HEVC,有的甚至是 MJPEG 这种古老格式,体积能大到离谱。也就是说,用户手机里的视频并不是均质的,真正值得压缩的往往是少数几个“体积异常”的大文件。搞清楚这一点很重要,它决定了压缩工具应该做成“批量全部处理”还是“按体积排序挑大的处理”,我后来选了后者,理由是用户的时间和电量都有限。
1.2 视频体积的计算公式
所有压缩参数的选择,底层都绕不开这一个公式:
文件大小(字节) = (视频码率 + 音频码率) × 时长(秒) / 8注意单位:码率通常写作 bps(比特每秒),转换成字节要除以 8。以 Mbps 为单位的码率,算出来的结果是 MB。举个真实例子,一段 10 分钟的 1080p 视频,文件 1.2GB:
1200 MB × 8 = 9600 Mb 9600 Mb / 600 s = 16 Mbps也就是说这段视频的总码率是 16Mbps,其中视频大概 15.7Mbps,音频 128kbps。这个数字偏高,正常 1080p30 的 H.264 内容 5Mbps 左右就足够好了,15Mbps 属于手机默认给了很大的余量。这就解释了为什么手机拍的视频特别“肥”:厂商为了保证任何场景都不糊,默认码率往往开得远超必要值。
反过来用这个公式做规划就更清楚了。想把 1.2GB 压到 180MB 左右,时长不变还是 600 秒:
180 MB × 8 = 1440 Mb 1440 Mb / 600 s = 2.4 Mbps2.4Mbps 总码率,扣掉 128kbps 音频,视频码率给 2.27Mbps。这个值对 1080p 来说是“能看但经不起细看”的水平,对 720p 则相当宽裕。所以先算再调,比凭感觉拖进度条靠谱得多。
| 目标体积 | 时长 1 分钟 | 时长 5 分钟 | 时长 10 分钟 | 时长 30 分钟 |
|---|---|---|---|---|
| 20MB | 2.67 Mbps | 0.53 Mbps | 0.27 Mbps | 0.09 Mbps |
| 50MB | 6.67 Mbps | 1.33 Mbps | 0.67 Mbps | 0.22 Mbps |
| 100MB | 13.3 Mbps | 2.67 Mbps | 1.33 Mbps | 0.44 Mbps |
| 300MB | 40 Mbps | 8 Mbps | 4 Mbps | 1.33 Mbps |
这张表可以直接当速查用。它暴露了一个残酷事实:短视频压起来很轻松,长视频想压到小体积就必须牺牲分辨率,因为码率是有物理下限的。一条 30 分钟的视频想塞进 50MB,平均码率只有 0.22Mbps,那是连 480p 都撑不住的水平。这时候正确的做法不是硬压,而是提示用户“要么接受更低分辨率,要么别压”。
1.3 压缩不是单纯变小,是三条曲线的交汇
真正做过才知道,压缩从来不是单变量问题。你手上其实握着四根旋钮:分辨率、帧率、码率、编码器效率,每调一根都会牵动画质、体积、耗时三条曲线。
分辨率是第一优先级。人眼对空间细节的敏感度远高于时间细节,把 4K 降到 1080p,体积直接砍到四分之一,观感上的损失在手机屏幕上几乎察觉不到;反过来把帧率从 60 降到 30,体积只省一部分,但运动画面会有明显顿感。所以我的默认策略永远是“先降分辨率,再考虑降帧率”。
编码器效率是“免费的午餐”。同样的画质,HEVC(H.265)比 H.264 省 30% 到 50% 的码率,代价是编码耗时翻倍、老设备可能解不动。我的判断标准很简单:目标设备是近五年的安卓机,就用 HEVC;要发给别人或者上传到不确定的平台,老老实实用 H.264,兼容性值那点体积。
还有一条容易被忽略的曲线是内容复杂度。同一个码率,拍静止的墙面和拍满屏飘落的树叶,效果天差地别。这就是为什么固定码率编码器(CBR)在复杂场景下会糊,恒定质量模式(CRF)会自适应地给复杂帧更多比特。理解了这一点,你就明白为什么有些工具压缩后“有的片段很清晰,有的糊成一片”——它用的是固定码率,简单但粗暴。
2. 四条实现路径,我为什么选这条
2.1 几种方案的横向对比
安卓上做视频压缩,能走的路其实就那么几条,我把它们摆在一起比过一轮:
| 方案 | 速度 | 功耗发热 | 画质可控性 | 兼容性 | 包体积影响 |
|---|---|---|---|---|---|
| 系统 MediaCodec 硬编 | 极快,接近实时 | 低 | 中等,参数受限 | 依赖芯片,有坑 | 几乎为零 |
| 软件编码(FFmpeg 集成) | 慢,0.2 到 0.5 倍速 | 高,明显发热 | 高,参数全开放 | 极好 | 增加 5 到 20MB |
| 云端转码 | 取决于网络 | 无 | 高 | 依赖网络 | 小 |
| 直接换封装(不重编码) | 极快 | 无 | 无(码率不变) | 好 | 零 |
先说最后一个,很多人不知道还有这条路。视频文件其实是“容器 + 轨道”,MP4 只是个盒子,把盒子换成更省空间的格式或者去掉多余的元数据,能省一点,但码率没变,省得非常有限,通常只有 3% 到 8%。这招适合“实在懒得重编码但又想榨一点空间”的场景,不能当主力。
云端转码速度和质量都不错,但它绕不开一个根本问题:用户的私人视频要上传到别人的服务器。这个取舍不是技术问题而是信任问题,我个人的项目里直接排除了这条路径,本地能算完的事情不要在本地算完。
2.2 硬编为主、软编兜底的组合拳
最终的架构是这样的:默认走系统 MediaCodec 硬件编码,速度快、省电、包体积零增加;遇到硬编不支持的格式(比如某些老机器的 HEVC 编码器缺失)、或者用户明确要求“最高画质”,再降级到软件编码。
这个选择背后的逻辑值得说清楚。硬件编码器的本质是芯片里的一块专用电路,它只干编码这一件事,所以快且省电。但它也“笨”:参数调节粒度粗,很多高级选项(比如自定义量化矩阵、精细的码率控制)根本不开放;不同厂商的实现差异巨大,同一份配置在 A 机型上跑得好好的,B 机型可能直接抛异常。
我做过一次粗略的耗时对比:一段 5 分钟 1080p 视频,硬编大约 40 秒完成,机身温热;软编用中速预设跑了将近 4 分钟,机身明显发烫,电量掉了 6%。对普通用户来说,为了那一点点画质提升等四分钟是不划算的,所以默认值必须是硬编。但软编不能砍掉,它是兜底:用户愿意等,就给他更好的结果。
2.3 硬编码的兼容性雷区
这块是真正会让人栽跟头的地方,我列几个实测遇到的:
分辨率的对齐要求。不少编码器要求宽高是 16 的整数倍,你传 1080×1920 没问题,但传 1078×1918 就可能直接报错。解决办法是向下取整到 16 的倍数再编码,编码完成后如果需要保持原始宽高比,再在播放端做微调,或者干脆把画面缩放到标准尺寸。
Profile 和 Level 支持不全。同样是 H.264,有些芯片只支持 Baseline 和 Main,你配 High Profile 就失败。做法是先查MediaCodecInfo.CodecCapabilities里支持的 profileLevels,挑一个交集,别硬写死。
码率上限。有的编码器对单路码率设了硬上限(尤其是 4K 场景),你给 80Mbps 它默默截断到 40Mbps,这种“静默失败”最难查,只能压完看实际码率对不对。
还有一点,编码器实例数量有限。有些机型同时只能开一个硬件编码器,如果你的应用里还在后台录屏或者播放,压缩任务就会拿不到资源。这一点在应用里必须做优雅降级和排队。
3. 参数怎么配,给一份能直接抄的表
3.1 分辨率、帧率、码率的联动
这三个参数不是独立的,它们共同决定“每帧能分到多少比特”。业内有个经验值:H.264 编码下,每像素每帧大约给 0.05 到 0.1 比特就能得到不错的观感。按这个算 1920×1080×30fps:
1920 × 1080 × 30 × 0.07 ≈ 4.35 Mbps和我前面说的“1080p30 给 5Mbps 就够好”基本吻合。同样的公式套 1280×720×30:
1280 × 720 × 30 × 0.07 ≈ 1.9 Mbps套 640×360×30:
640 × 360 × 30 × 0.07 ≈ 0.48 Mbps有了这个系数,你可以从任意分辨率帧率推出一个合理的起始码率,再根据实际效果上下浮动 30%。HEVC 的话把系数乘 0.6 左右。
帧率的选择我一般按内容类型来:普通拍摄用 30 够了,60 帧的意义主要在高速运动和游戏录屏;文档、屏幕录制、幻灯片这类内容,15 到 20 帧就完全够用,压出来体积能再省三分之一;而如果是慢动作视频,降帧率会毁掉整个效果,宁可不压。
3.2 CRF 与固定码率,两套思路分别适合谁
恒定质量模式(CRF)的思路是“我要这个画质,你看着给码率”,复杂场景自动多给比特,简单场景自动少给,最终体积是不可预测的。固定码率(或目标码率 + 峰值上限)的思路是“我就要这个体积”,画面质量随内容浮动。
我的用法是:用户显式输入“压到 200MB”时用目标码率模式,因为体积是可承诺的;用户选择“画质优先/均衡/极限压缩”时用 CRF,因为质量是可承诺的。这两个承诺不能同时给,非要说“又小又清晰”那就是骗人。
H.264 的 CRF 取值参考:18 到 20 视觉上基本无损,普通人不做对比看不出;23 是默认值,画质和体积平衡点;26 到 28 开始能看出细节丢失,但手机屏上看还行;30 以上明显发糊,只适合“能看清内容就行”的场合。还有个关键规律要记住:CRF 每加 6,码率大约减半。这个规律在做参数预设时特别好用,你想让体积减半,直接把 CRF 加 6 试试。
3.3 四套场景配方
这些是我反复调试后固定下来的预设,可以直接拿去用:
| 场景 | 分辨率 | 帧率 | 视频码率/CRF | 音频 | 编码器 | 预估压缩比 |
|---|---|---|---|---|---|---|
| 聊天软件发送 | 720p | 30 | CRF 28 | AAC 64kbps 单声道 | H.264 | 8 到 15 倍 |
| 本地归档保存 | 1080p | 30 | CRF 23 | AAC 128kbps | H.265 | 3 到 6 倍 |
| 屏幕录制/文档 | 720p(保原宽高比) | 15 | CRF 30 | AAC 64kbps | H.264 | 10 到 20 倍 |
| 游戏录屏分享 | 1080p | 60 | CRF 25 | AAC 128kbps | H.265 | 4 到 8 倍 |
“聊天软件发送”那一档要特别说明:绝大多数即时通讯软件收到视频后还会再压一次,你压得太狠就是二次伤害,压得太轻又传不动。720p + CRF 28 是个甜点,传过去对方看着清楚,你自己这边体积也降下来了。另外这一档我建议加faststart(把索引挪到文件头),否则对方点开要等半天才开始播。
3.4 一个完整的取舍手算
拿一段真实素材走一遍,你就明白参数怎么定了。假设有一段 4K 60fps、时长 3 分钟、体积 1.5GB 的旅行视频,目标是压到 150MB 以内并且要发朋友圈。
先算原码率:1500 × 8 / 180 = 66.7 Mbps。确实很肥。
目标码率:150 × 8 / 180 = 6.67 Mbps,减去 128kbps 音频,视频给 6.5Mbps。
现在问自己:6.5Mbps 能撑住 4K 吗?按系数反推,4K30 需要 3840×2160×30×0.07 ≈ 17.4 Mbps,明显不够。1080p30 需要 4.35Mbps,6.5Mbps 有富余。所以答案是:降到 1080p,还能把帧率保留在 30。
最终配置:1080p、30fps、目标码率 6.5Mbps(或 CRF 21 左右),H.265,音频 AAC 128kbps。预计耗时 1 到 2 分钟,画质在手机屏上基本无损。如果换成 H.264,同样画质需要 9Mbps 左右,那就得提到 CRF 24 并接受一定损失。
这套推理过程比背参数表有用得多,因为它能推广到任何素材。
4. 动手实现,把流程跑通
4.1 工程准备与数据结构
核心思路是“解码 → 编码 → 封装”三段式,中间不落地成图片序列。这是最关键的设计决定:把视频拆成 PNG 帧再编码,中间要写几百 GB 的临时文件,速度和存储都受不了。正确做法是让解码器的输出直接送到编码器的输入,全程在内存/显存里流转。
安卓上最省事的连接方式是 Surface 直连:创建编码器的输入 Surface,把它作为解码器的输出目标。数据流在图形子系统里直接传递,连 CPU 拷贝都省了,实测比走 ByteBuffer 中转快 2 到 3 倍。
权限方面,读取视频要READ_MEDIA_VIDEO(新版本)或旧的存储权限,写入到应用私有目录不需要额外权限。压缩这种耗时任务建议放在前台服务里跑,否则用户切到后台系统可能把进程回收,压到一半的视频直接废掉。
4.2 解封装与轨道选择
拿到文件先解封装,把视频轨和音频轨分离出来:
val extractor = MediaExtractor() extractor.setDataSource(inputPath) var videoTrack = -1 var audioTrack = -1 for (i in 0 until extractor.trackCount) { val format = extractor.getTrackFormat(i) val mime = format.getString(MediaFormat.KEY_MIME) ?: continue when { mime.startsWith("video/") && videoTrack < 0 -> videoTrack = i mime.startsWith("audio/") && audioTrack < 0 -> audioTrack = i } }这里有个细节值得说:不要假设轨道顺序。有些文件音频在前、视频在后,硬编码索引 0 取视频就会拿到音频轨,然后报一堆莫名其妙的错。我第一次踩这个坑花了大半天才定位到,因为异常信息完全指向别处。
读轨道格式的时候顺便把KEY_ROTATION、KEY_WIDTH、KEY_HEIGHT、KEY_DURATION都取出来。旋转角度特别容易丢,后面会专门讲。
4.3 编码循环的核心骨架
这是整个流程的心脏部分:
val outFormat = MediaFormat.createVideoFormat( MediaFormat.MIMETYPE_VIDEO_AVC, targetWidth, targetHeight ).apply { setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) setInteger(MediaFormat.KEY_BIT_RATE, targetBitrate) setInteger(MediaFormat.KEY_FRAME_RATE, targetFps) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) } val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) encoder.configure(outFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) val inputSurface = encoder.createInputSurface() encoder.start() val decoder = MediaCodec.createDecoderByType(srcMime) decoder.configure(srcFormat, inputSurface, null, 0) decoder.start()KEY_I_FRAME_INTERVAL设成 2 表示每 2 秒一个关键帧(GOP 长度 = 帧率 × 2)。这个值的取舍是:GOP 短,随机拖动定位快、抗丢包强,但体积大;GOP 长,体积小,但拖动和剪辑时定位精度差。存档用 2 到 5 秒,网络传输用 1 到 2 秒,够了。
主循环的逻辑是:给解码器喂输入、取解码输出、把输出渲染到 Surface、从编码器取编码输出、写进封装器。四个动作交替进行,用一个循环驱动。这里必须用非阻塞模式(dequeueInputBuffer(0)加超时),别用无限等待的阻塞调用,否则用户在界面上点取消,你得等它跑完才能响应。
进度计算用解码器输出的 presentationTimeUs 除以总时长,比按帧数估算准,因为可变帧率的素材按帧数算会跑偏。
4.4 封装与时间戳对齐
封装器本身不难用,难在时间戳:
val muxer = MediaMuxer(outPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) // 编码器输出的 format changed 事件里拿到真实格式后加轨 val videoIndex = muxer.addTrack(encoderOutputFormat) val audioIndex = muxer.addTrack(audioOutputFormat) muxer.start() // 写视频样本 buffer.position(0) buffer.size = info.size muxer.writeSampleData(videoIndex, buffer, info)三个坑必须提前处理。
第一,时间戳要归零。原始视频的第一帧时间戳不一定是 0,可能是几千微秒甚至几十万微秒,直接写进去会导致文件开头有一段“空白”。做法是记下第一个样本的 pts,之后每个都减掉它。
第二,编码器输出的BUFFER_FLAG_CODEC_CONFIG帧(就是 SPS/PPS 参数集)不要写进封装器。MediaMuxer在addTrack时已经通过 format 拿到这些信息了,你再写一遍会导致部分播放器解析出错。判断方式是检查 flag 位。
第三,音视频两条轨的时间戳必须独立归零、独立计算,不能混用一个基准。如果音频从 0 开始、视频从 0.03 秒开始,播放器会以为视频晚开始,结果就是声音快半拍。
4.5 命令行兜底方案
用户选了“最高画质”或者硬编失败时,走软件编码。如果集成了命令行工具,参数模板可以直接用:
ffmpeg -i input.mp4 \ -c:v libx264 -preset medium -crf 23 \ -vf "scale=-2:1080,fps=30" \ -c:a aac -b:a 128k -ac 2 \ -movflags +faststart \ -y output.mp4几个参数解释一下:scale=-2:1080里的 -2 表示宽度自动按比例算并保持偶数(编码器要求偶数);-movflags +faststart把 moov 索引移到文件头部,边下边播靠它;-preset从 ultrafast 到 veryslow 一共九档,手机上建议用 medium 到 slow,再慢就真的等不起了。
如果想让命令行也走硬件加速(速度会快很多但兼容性差一些):
ffmpeg -i input.mp4 -c:v h264_mediacodec -b:v 4M \ -c:a copy -y output.mp4注意-c:a copy是“音频直接复制不重编码”,能省一截时间,而且音质不会二次损失。前提是音频格式目标容器能接受,AAC 进 MP4 是可以的,别的格式要小心。
4.6 进度回调与取消机制
压缩是个长任务,用户必须能看到进度、必须能取消。进度用回调往外抛,别直接更新界面,用消息机制或者协程切回来。取消的实现要点是设置一个 volatile 标志位,主循环每轮检查一次,为真就停止喂数据、发 EOS、清理资源。清理顺序有讲究:先stop()再release(),先停编码器再停解码器,先关 muxer 再删临时文件。顺序错了轻则抛异常,重则留下一个零字节的坏文件被用户看到。
我还会在写文件时先写到一个.tmp后缀的临时文件,全部成功后再重命名。这样即使中途崩了,用户目录里也不会出现一批“看起来正常但打不开”的视频。
5. 踩过的坑,值得一条条记下来
5.1 音画不同步的三个成因
这是最高频的问题,我遇到过的原因有三类。
第一类是音频直接复制没有重编码,但原音频的时间戳基准和视频不同,封装后整体偏移。解决办法是要么重编码音频统一基准,要么在写音频样本时手动减去它自己的第一个 pts。
第二类是编码器缓冲导致视频某一帧延迟输出,而音频是实时的,累计下来差了几十毫秒。这种情况肉眼可见“口型对不上”。做法是记录编码器实际输出的第一帧 pts 作为视频基准,而不是用解码器的第一帧 pts。
第三类是可变帧率素材。有些手机录制的是可变帧率(VFR),视频里每帧的实际间隔不固定,你如果按固定帧率重算时间戳,整条时间轴就会漂移。正确做法是原样保留每帧的 pts 相对间隔,只做整体平移。
5.2 旋转角度丢失与色彩异常
手机拍的竖屏视频,实际存储的可能是横向的 1920×1080 加一个 90 度的旋转元数据。你如果只读宽高不读旋转,压出来的视频会“躺着播”。处理方式有两种:把旋转角度透传给输出文件(简单,但部分播放器识别不了),或者在编码时先把画面旋转过来再写入(稳妥,但要重设宽高)。
色彩异常主要出现在硬件编码路径上,表现为画面偏绿、偏紫或者上下颠倒。成因通常是颜色格式不匹配,比如编码器要 NV12,你给了 I420。用 Surface 直连一般不会遇到,走 ByteBuffer 中转就很容易踩。另外有些设备在 4K 分辨率下颜色格式支持和 1080p 不一样,同一个配置在低分辨率下正常,切到高分辨率就出问题。
还有一种情况是 HDR 视频被当成 SDR 压,画面会发灰。这类素材建议直接检测 HDR 元数据然后跳过压缩,或者提示用户“该视频为 HDR,压缩会损失效果”。
5.3 内存、发热与分片策略
大文件处理最怕两件事:OOM 和烫手。一个 4K 视频如果一次性把解码数据全读进内存,几百兆起步,中低端机直接崩。正确做法是流式处理,每次只持有一个缓冲区,输入一点、输出一点。缓冲区大小从编码器的 format 里读KEY_MAX_INPUT_SIZE,别自己拍脑袋设个大数字。
发热方面,连续压 10 分钟以上机身温度能到 40 度以上,系统可能触发降频,速度越来越慢。我的处理是把长视频切成 30 秒一段分别编码,中间插入短暂的让出时间,最后再拼接。听起来麻烦,但实测下来总耗时反而更短,因为避免了持续降频。
后台任务一定要加锁,避免用户连点两次触发两个并行的压缩任务。
5.4 常见问题速查
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 压缩后文件 0 字节 | muxer 未 start 或提前 stop | 检查 addTrack 后是否调用 start,异常分支里保留日志 |
| 有画面无声音 | 音频轨未添加或音轨编码失败 | 单独打印音频轨的 addTrack 返回值 |
| 播放到一半卡住 | 时间戳非单调递增 | 写样本前校验 pts 必须大于上一条 |
| 画面比例变形 | 只改了宽未按比例改高 | 用原宽高比重新计算,强制偶数值 |
| 压缩比远低于预期 | 码率被硬件静默截断 | 压完读实际文件码率对比目标值 |
| 部分设备直接崩溃 | profile/level 不支持 | 遍历 codec 支持的 profileLevels 取交集 |
| 进度条卡在 99% | 未处理 EOS 后的收尾输出 | EOS 后继续循环直到输出 flag 带 EOS |
| 视频开头黑屏一秒 | 首帧时间戳未归零 | 记录首帧 pts 并在写入时全部减去 |
6. 压完了怎么验收,别只看体积
6.1 客观指标:三个数字就够
压完不看质量就是耍流氓。我一般看三个数。
实际码率。用算出来的文件大小和时长反算,和你设定的目标值对比。偏差超过 20% 就说明码率控制没生效,可能是被硬件截断,也可能是 CRF 模式吃了太多比特。
关键帧间隔。如果设了 2 秒,实际却出现 10 秒一个关键帧,说明I_FRAME_INTERVAL没被采纳,某些编码器只认自己的默认值。这会影响拖动定位体验。
音频参数。确认采样率、声道数、码率符合预期。曾经遇到过音频被强制降成 32kHz 单声道,人声听起来像隔了一层布,查下来是编码器不支持 48kHz 双声道,静默降级了。
至于 PSNR 和 SSIM 这类画质相似度指标,说实话在手机场景里参考价值有限,它们和主观观感的关联度不高,算起来还慢。我一般只在前面对比不同 CRF 值时用一次,做个横向排序,日常不跑。
6.2 主观验收清单
真正决定“能不能用”的还是眼睛。我固定看这几个地方:
人脸和字幕。人脸是最容易被压糊的区域,眼睛和嘴唇的细节丢失最明显;字幕边缘如果有明显的锯齿和振铃,说明码率不够。这两处过关,基本就过关。
暗部噪点。低码率下暗部容易出现块状色斑,尤其是夜景视频。全屏看一遍,如果干净就说明编码器分配比特比较合理。
快速运动段落。找一个镜头快速平移的片段,如果画面出现明显的“拖影”和块状,是码率不够或者 GOP 太长。
声音。听 20 秒人声,有没有变闷、有没有忽大忽小。忽大忽小通常是音频重采样出了问题。
这套验收流程走下来 2 分钟,比什么指标都靠谱。
6.3 批量处理的省事写法
单个文件处理完,接下来就是批量。我的做法是扫描目录、按体积倒序排列、只处理超过阈值的文件、处理完再校验:
# 找出体积超过 200MB 的视频,逐个压缩 find /sdcard/DCIM -type f -name "*.mp4" -size +200M | while read f; do out="${f%.mp4}_min.mp4" ffmpeg -i "$f" -c:v libx265 -crf 24 -preset medium \ -vf "scale=-2:1080" -c:a aac -b:a 128k \ -movflags +faststart -y "$out" # 只有输出确实更小才替换,避免越压越大 if [ -s "$out" ] && [ $(stat -c%s "$out") -lt $(stat -c%s "$f") ]; then mv "$out" "$f" else rm -f "$out" fi done那个“只有更小才替换”的判断很重要。我遇到过原视频是低码率低分辨率、压完反而更大的情况,因为没有这个判断,用户的文件被“优化”得更占地方了。
批量处理还要考虑排队。同时跑三个压缩任务,三个都变慢,用户体验反而更差。串行跑,一次一个,进度明确,发热也可控。
提示:压缩前一定先确认目标文件是完整的。如果原始视频本身就有损坏,解码器读到一半会报错,这时不要强行输出,直接标记失败并跳过,否则会生成一个时长不对的残缺文件。
最后再分享两个实操里的心得。一是压缩参数别做成“专业模式”让用户自己填,绝大多数人不懂码率是什么,给了也是一顿乱调然后怪你效果差。做成三档预设加一个“目标体积”输入框就够了,前者覆盖 90% 的需求,后者覆盖那些有明确体积要求的场合。二是永远保留原文件,或者至少在一次压缩后提供“恢复原始”的入口。我在自己项目里见过不止一次用户后悔压得太狠,原文件又没了的窘境——那种情况下除了道歉没有任何补救办法。压缩这事本质上是不可逆的信息丢失,工具能做的就是把选择权交清楚,并且给一条退路。