WhatsApp 富媒体消息(图片/视频)的发送优化与压缩策略
目录
- 为什么富媒体消息需要系统化的发送优化
- 图片压缩的技术选型与参数决策
- 视频压缩流水线的设计与实现
- 自适应压缩策略:根据内容类型动态选择方案
- 压缩质量监控与自动化门禁
- 落地经验:以 WAWarmer 的媒体处理管道为例
- 小结与行动清单
1. 为什么富媒体消息需要系统化的优化
在即时通讯场景里,文字消息的体量几乎可以忽略不计,但一旦开始发图片和视频,问题就来了。WhatsApp 对单条消息有严格的文件大小限制(图片原始上限 16MB,视频 64MB),超了直接发送失败。更麻烦的是,即使没超限,未经处理的媒体文件会带来一连串隐性成本:
- 发送成功率下降:大文件在网络抖动时更容易超时,尤其是移动网络或代理链路环境下。一张 8MB 的原图在 4G 网络下的发送失败率可能是 500KB 压缩图的 3~5 倍。
- 存储和带宽成本膨胀:如果系统里存了历史消息的附件副本,未压缩的媒体文件会把对象存储费用推高一个数量级。100 万张 5MB 的图片就是 5TB,压缩到 300KB 就只有 300GB。
- 接收端体验差:对方在弱网环境下加载一张 10MB 的原图可能要转圈十几秒,而 200KB 的优化图基本是即点即看。
- 平台审核风险:部分平台会对高频发送大体积附件的行为做额外审查,因为这是营销群发的典型特征之一。
所以这不是"省几 KB 存储钱"的问题,而是一个直接影响送达率、成本、用户体验三个核心指标的工程问题。
2. 图片压缩的技术选型与参数决策
图片压缩不是简单地"降低质量参数",而是要在文件大小、视觉保真度、处理速度三者之间找到平衡点。先看一下主流方案的对比:
| 方案 | 格式 | 有损/无损 | 典型压缩比 | 处理速度 | 适用场景 |
|---|---|---|---|---|---|
| JPEG (quality=85) | .jpg | 有损 | 8:1~12:1 | 极快 | 照片、复杂色彩图 |
| WebP (quality=80) | .webp | 有损 | 10:1~15:1 | 快 | 需要更好压缩率的照片 |
| PNG (zlib level=6) | .png | 无损 | 2:1~3:1 | 中等 | 截图、文字为主 |
| AVIF (quality=50) | .avif | 有损 | 15:1~25:1 | 较慢 | 追求极致压缩的场景 |
实际选型的决策树:
输入图片
├─ 包含文字 / 截图 / 图表?
│ └─ → PNG(无损,文字边缘清晰)
├─ 是照片 / 自然图像?
│ ├─ 目标大小 < 200KB?→ JPEG quality=82
│ ├─ 目标大小 < 100KB?→ WebP quality=75
│ └─ 目标大小 < 50KB?→ AVIF quality=45(接受较慢速度)
└─ 不确定类型?
└─ → 先检测熵值,高熵走 WebP,低熵走 PNG
用 Python 实现一个智能路由的压缩器:
python
import os
import io
import math
from PIL import Image
from dataclasses import dataclass
from enum import Enum
class ImageFormat(Enum):
JPEG = "jpeg"
WEBP = "webp"
PNG = "png"
AVIF = "avif"
@dataclass
class CompressResult:
original_size: int
compressed_size: int
format: ImageFormat
quality: int
width: int
height: int
path: str
class SmartImageCompressor:
"""
智能图片压缩器:
- 自动检测图片类型(照片 vs 截图)
- 根据目标大小选择最优格式和参数
- 保留 EXIF 中的旋转信息(丢弃其他元数据)
"""
# 各格式的默认质量参数
DEFAULT_QUALITY = {
ImageFormat.JPEG: 82,
ImageFormat.WEBP: 75,
ImageFormat.PNG: 6, # zlib 压缩级别
ImageFormat.AVIF: 45,
}
# WhatsApp 实际使用的尺寸限制(超过会被服务端再压一次)
MAX_DIMENSION = 4096 # 最长边上限
TARGET_LONG_EDGE = 2048 # 默认缩放目标
def __init__(self, target_size_kb: int = None):
"""
target_size_kb: 目标文件大小(KB),None 则只做格式转换不强制控大小
"""
self.target_bytes = (target_size_kb or 200) * 1024
def _detect_type(self, img: Image.Image) -> str:
"""
检测图片类型:photo / screenshot / mixed
使用简单的熵值 + 边缘密度启发式判断
"""
# 转灰度计算
gray = img.convert("L")
pixels = list(gray.getdata())
# 计算信息熵
from collections import Counter
counts = Counter(pixels)
total = len(pixels)
entropy = -sum(
(c / total) * math.log2(c / total)
for c in counts.values() if c > 0
)
# 熵值越高越像"照片",越低越像"截图/纯色"
if entropy < 6.5:
return "screenshot"
elif entropy > 7.5:
return "photo"
else:
return "mixed"
def _choose_format(self, img_type: str) -> ImageFormat:
"""根据图片类型选择输出格式"""
if img_type == "screenshot":
return ImageFormat.PNG
elif self.target_bytes < 50 * 1024: # 目标 < 50KB
return ImageFormat.WEBP
else:
return ImageFormat.JPEG
def compress(self, input_path: str, output_dir: str = None) -> CompressResult:
"""
执行完整压缩流程
返回压缩结果(含路径、大小、格式等信息)
"""
original_size = os.path.getsize(input_path)
with Image.open(input_path) as img:
# 1. 处理旋转(从 EXIF 读取方向信息并应用)
img = self._apply_orientation(img)
# 2. 缩放:超过目标尺寸则等比缩小
img = self._resize_if_needed(img)
# 3. 检测类型 + 选择格式
img_type = self._detect_type(img)
output_format = self._choose_format(img_type)
quality = self.DEFAULT_QUALITY[output_format]
# 4. 如果指定了目标大小且是有损格式,做二分查找最优质量
if self.target_bytes and output_format in (
ImageFormat.JPEG, ImageFormat.WEBP, ImageFormat.AVIF
):
quality = self._binary_search_quality(
img, output_format, quality
)
# 5. 写入输出
ext = f".{output_format.value}"
out_name = os.path.splitext(
os.path.basename(input_path)
)[0] + "_compressed" + ext
out_path = os.path.join(
output_dir or os.path.dirname(input_path), out_name
)
save_kwargs = {}
if output_format == ImageFormat.JPEG:
save_kwargs["quality"] = quality
save_kwargs["optimize"] = True
save_kwargs["progressive"] = True
elif output_format == ImageFormat.WEBP:
save_kwargs["quality"] = quality
save_kwargs["method"] = 4 # 更好的压缩效率
elif output_format == ImageFormat.PNG:
save_kwargs["compress_level"] = quality
elif output_format == ImageFormat.AVIF:
save_kwargs["quality"] = quality
save_kwargs["speed"] = 6
# RGBA → RGB 转换(JPEG/WebP 不支持透明通道)
if img.mode in ("RGBA", "P") and output_format != ImageFormat.PNG:
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
img.save(out_path, **save_kwargs)
compressed_size = os.path.getsize(out_path)
return CompressResult(
original_size=original_size,
compressed_size=compressed_size,
format=output_format,
quality=quality,
width=img.width,
height=img.height,
path=out_path,
)
def _apply_orientation(self, img: Image.Image) -> Image.Image:
"""应用 EXIF 旋转信息"""
from PIL import ExifTags
try:
exif = img._getexif()
if exif is not None:
orientation = exif.get(
ExifTags.Orientation, 1
)
# PIL 的 transpose 方法支持 EXIF 方向值
rot_map = {
3: Image.ROTATE_180,
6: Image.Transpose.ROTATE_270,
8: Image.Transpose.ROTATE_90,
}
if orientation in rot_map:
img = img.transpose(rot_map[orientation])
except Exception:
pass # 无 EXIF 或读取失败则跳过
return img
def _resize_if_needed(self, img: Image.Image) -> Image.Image:
"""超过最大尺寸时等比缩放"""
max_dim = max(img.width, img.height)
if max_dim > self.MAX_DIMENSION:
ratio = self.TARGET_LONG_EDGE / max_dim
new_size = (int(img.width * ratio), int(img.height * ratio))
img = img.resize(new_size, Image.LANCZOS)
return img
def _binary_search_quality(
self, img: Image.Image, fmt: ImageFormat, initial_q: int
) -> int:
"""
二分查找满足目标大小的最高质量参数
在文件大小和质量之间取最优平衡点
"""
lo, hi = 20, initial_q
best_q = initial_q
buf = io.BytesIO()
for _ in range(7): # 二分迭代次数
mid = (lo + hi) // 2
buf.seek(0)
buf.truncate()
kwargs = {"quality": mid}
if fmt == ImageFormat.JPEG:
kwargs["optimize"] = True
elif fmt == ImageFormat.WEBP:
kwargs["method"] = 4
# 处理模式兼容性
save_img = img
if save_img.mode == "RGBA" and fmt != ImageFormat.PNG:
bg = Image.new("RGB", save_img.size, (255, 255, 255))
bg.paste(save_img, mask=save_img.split()[-1])
save_img = bg
save_img.save(buf, format=fmt.value, **kwargs)
size = buf.tell()
if size <= self.target_bytes:
best_q = mid
lo = mid + 1
else:
hi = mid - 1
return best_q
坑点提示:
- EXIF 旋转 是最常见的坑。手机拍的照片经常包含旋转标记但不实际旋转像素数据,如果不处理会导致图片在浏览器里显示为横着的。
_apply_orientation方法必须在缩放之前调用。 - PNG 转 JPEG 时一定要先抹掉 alpha 通道,否则 Pillow 会直接抛异常。上面的代码用了白色背景合成来处理这个问题。
- 二分查找的质量参数范围不要低于 20。JPEG quality < 20 时会出现明显的色块伪影,得不偿失。
3. 视频压缩流水线的设计与实现
视频比图片复杂得多,涉及编码器选择、分辨率、码率、帧率、关键帧间隔等多个维度。WhatsApp 对视频的实际限制是:
| 参数 | 上限 | 说明 |
|---|---|---|
| 文件大小 | 16MB | 超过拒收 |
| 分辨率 | 1920×1080 | 超过自动降分辨率 |
| 时长 | 无硬限制 | 但越大传输越不稳定 |
| 编码格式 | H.264 / VP8 | 服务端可能转码 |
下面是一个基于 ffmpeg 的视频压缩管道:
python
import subprocess
import json
import os
from dataclasses import dataclass
from enum import Enum
class VideoPreset(Enum):
LIGHT = "light" # 快速预览级,文件小
STANDARD = "standard" # 默认平衡
HIGH = "high" # 高画质优先
@dataclass
class VideoCompressResult:
original_size_mb: float
compressed_size_mb: float
duration_sec: float
resolution: str
codec: str
bitrate_kbps: int
fps: float
path: str
class VideoCompressor:
"""
基于 ffmpeg 的视频压缩器
支持 H.264 硬件加速(VideoToolbox on macOS / NVENC on Linux)
"""
PRESETS = {
VideoPreset.LIGHT: {
"max_width": 854, # 480p
"crf": 28, # 较低质量
"fps": 15,
"audio_bitrate": "64k",
"target_size_mb": 5,
},
VideoPreset.STANDARD: {
"max_width": 1280, # 720p
"crf": 23,
"fps": 24,
"audio_bitrate": "96k",
"target_size_mb": 10,
},
VideoPreset.HIGH: {
"max_width": 1920, # 1080p
"crf": 20,
"fps": 30,
"audio_bitrate": "128k",
"target_size_mb": 14,
},
}
def __init__(self, preset: VideoPreset = VideoPreset.STANDARD):
self.preset = preset
self.config = self.PRESETS[preset]
def get_video_info(self, input_path: str) -> dict:
"""用 ffprobe 获取视频元信息"""
cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
input_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
video_stream = next(
(s for s in data.get("streams", []) if s["codec_type"] == "video"),
None,
)
audio_stream = next(
(s for s in data.get("streams", []) if s["codec_type"] == "audio"),
None,
)
format_info = data.get("format", {})
return {
"duration": float(format_info.get("duration", 0)),
"size_mb": float(format_info.get("size", 0)) / (1024 * 1024),
"width": int(video_stream.get("width", 0)) if video_stream else 0,
"height": int(video_stream.get("height", 0)) if video_stream else 0,
"fps": eval(video_stream.get("r_frame_rate", "0/1"))
if video_stream else 0,
"codec": video_stream.get("codec_name", "") if video_stream else "",
"bitrate": int(format_info.get("bit_rate", 0)) // 1000
if format_info.get("bit_rate") else 0,
"has_audio": audio_stream is not None,
}
def compress(self, input_path: str, output_dir: str = None) -> VideoCompressResult:
"""执行视频压缩"""
info = self.get_video_info(input_path)
# 计算缩放比例
scale_filter = self._build_scale_filter(info["width"], info["height"])
# macOS 使用 VideoToolbox 硬件加速
hw_accel = ["-c:v", "h264_videotoolbox"]
# 构建 ffmpeg 命令
out_path = os.path.join(
output_dir or os.path.dirname(input_path),
os.path.splitext(os.path.basename(input_path))[0] + "_compressed.mp4"
)
cmd = [
"ffmpeg", "-y",
"-i", input_path,
# 视频
*hw_accel,
"-q:v", str(self.config["crf"]),
"-vf", scale_filter,
"-r", str(self.config["fps"]),
# 音频(如果有)
*(["-c:a", "aac", "-b:a", self.config["audio_bitrate"]]
if info["has_audio"]
else ["-an"]),
# 其他
"-movflags", "+faststart", # 让 moov atom 放在文件开头(流式播放友好)
"-preset", "fast",
"-maxrate", f"{self._calc_max_bitrate(info)}k",
"-bufsize", f"{self._calc_max_bitrate(info) * 2}k",
out_path,
]
subprocess.run(cmd, check=True, capture_output=True)
result_info = self.get_video_info(out_path)
return VideoCompressResult(
original_size_mb=info["size_mb"],
compressed_size_mb=result_info["size_mb"],
duration_sec=info["duration"],
resolution=f"{result_info['width']}x{result_info['height']}",
codec=result_info["codec"],
bitrate_kbps=result_info["bitrate"],
fps=self.config["fps"],
path=out_path,
)
def _build_scale_filter(self, width: int, height: int) -> str:
"""
构建 ffmpeg scale 过滤器
保持宽高比,不超过预设的最大宽度
"""
max_w = self.config["max_width"]
if width <= max_w:
return "scale=iw:ih" # 不缩放
# 确保宽度是 2 的倍数(H.264 编码要求)
new_w = (max_w // 2) * 2
new_h = int(height * (new_w / width))
new_h = (new_h // 2) * 2 # 同样保证偶数
return f"scale={new_w}:{new_h}"
def _calc_max_bitrate(self, info: dict) -> int:
"""
根据时长和目标大小估算最大码率
公式: target_size(bytes) * 8 / duration(sec) = max_bps
"""
target_bytes = self.config["target_size_mb"] * 1024 * 1024
duration = max(info["duration"], 1) # 避免除零
max_bps = int(target_bytes * 8 / duration)
return max_bps // 1000 # 转为 kbps
设计决策:
- 用
CRF(Constant Rate Factor)而不是固定码率。CRF 是恒定质量模式,让编码器根据画面复杂度自动分配码率,静态画面给少一点,运动剧烈的画面给多一点。整体文件大小更可控。 -movflags +faststart把 MP4 的moov atom移到文件头部。这对即时通讯场景很重要:接收方不需要下载完整个文件就能开始播放。-preset fast在编码速度和压缩效率之间取折中。slow能再压小 10~15% 但耗时翻倍,在批量处理场景下不划算。
坑点提示:
- 分辨率必须是偶数 。H.264 编码器要求宽和高都是 2 的整数倍,否则 ffmpeg 会报错或自动补黑边。
_build_scale_filter里做了这个对齐。 - macOS 的 VideoToolbox 不支持 CRF 模式,要用
-q:v替代。Linux 上用 NVENC (h264_nvenc) 类似。如果硬件加速不可用,回退到软件编码器libx264。 - 音频不能丢 :很多视频压缩教程只关注视频轨而忽略了音频。WhatsApp 发送的视频如果没有音轨虽然不会报错,但用户体验明显不对。上面代码用
-an条件控制:有音轨就保留并压缩,没有才跳过。
4. 自适应压缩策略:根据内容类型动态选择方案
不同类型的媒体内容应该用不同的压缩策略。一个实用的做法是在压缩前先做一轮快速分析,然后路由到对应的处理管线:
python
@dataclass
class MediaAnalysis:
media_type: str # image / video / document / audio
content_subtype: str # photo / screenshot / chart / selfie / screen_recording ...
file_size_kb: int
estimated_compressibility: float # 0~1, 越高说明可压缩空间越大
recommended_preset: str # light / standard / high
class MediaAnalyzer:
"""快速媒体内容分析器"""
IMAGE_THRESHOLDS = {
"tiny": 100, # KB,太小没必要压
"small": 500,
"medium": 2000,
"large": 5000, # 超过 5MB 必须压
}
VIDEO_THRESHOLDS = {
"short": 30, # 秒,短视频
"normal": 180, # 3 分钟
"long": 600, # 10 分钟以上
}
def analyze(self, file_path: str) -> MediaAnalysis:
"""分析文件并推荐压缩策略"""
size_kb = os.path.getsize(file_path) // 1024
ext = os.path.splitext(file_path)[1].lower()
# 判断媒体类型
if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic"):
media_type = "image"
subtype, compressibility = self._analyze_image(file_path, size_kb)
elif ext in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
media_type = "video"
subtype, compressibility = self._analyze_video(file_path, size_kb)
else:
media_type = "document"
subtype = "other"
compressibility = 0.0
# 推荐预设
if media_type == "image":
if size_kb < self.IMAGE_THRESHOLDS["tiny"]:
preset = "skip" # 太小,不压缩
elif subtype in ("screenshot", "chart"):
preset = "light" # 截图类用轻量压缩
else:
preset = "standard"
elif media_type == "video":
info = VideoCompressor().get_video_info(file_path)
duration = info.get("duration", 0)
if duration < self.VIDEO_THRESHOLDS["short"]:
preset = "light"
elif duration < self.VIDEO_THRESHOLDS["normal"]:
preset = "standard"
else:
preset = "light" # 长视频必须激进压缩
else:
preset = "skip"
return MediaAnalysis(
media_type=media_type,
content_subtype=subtype,
file_size_kb=size_kb,
estimated_compressibility=compressibility,
recommended_preset=preset,
)
def _analyze_image(self, path: str, size_kb: int) -> tuple:
"""快速图片分析"""
try:
with Image.open(path) as img:
compressor = SmartImageCompressor()
img_type = compressor._detect_type(img)
# 可压缩性评估:基于当前大小和理论最小大小
pixels = img.width * img.height
theoretical_min = pixels * 3 / 10 # 假设每像素 0.3 字节是合理下限
current = size_kb * 1024
compressibility = max(0, 1 - (theoretical_min / current))
return img_type, min(compressibility, 1.0)
except Exception:
return "unknown", 0.0
def _analyze_video(self, path: str, size_kb: int) -> tuple:
"""快速视频分析"""
try:
vc = VideoCompressor()
info = vc.get_video_info(path)
duration = info.get("duration", 1)
avg_bitrate = (size_kb * 1024 * 8) / duration # bps
# 码率越高说明压缩空间越大
if avg_bitrate > 8_000_000: # > 8Mbps
compressibility = 0.8
subtype = "high_bitrate"
elif avg_bitrate > 4_000_000:
compressibility = 0.6
subtype = "normal"
else:
compressibility = 0.3
subtype = "already_compressed"
return subtype, compressibility
except Exception:
return "unknown", 0.0
# 完整的压缩调度入口
def process_media(file_path: str, output_dir: str) -> dict:
"""
统一入口:分析 → 选择策略 → 执行压缩 → 返回结果
"""
analyzer = MediaAnalyzer()
analysis = analyzer.analyze(file_path)
if analysis.recommended_preset == "skip":
return {
"action": "skipped",
"reason": f"{analysis.media_type} too small or not compressible",
"original_path": file_path,
}
if analysis.media_type == "image":
compressor = SmartImageCompressor(
target_size_kb={
"light": 100, "standard": 200, "high": 400
}.get(analysis.recommended_preset, 200)
)
result = compressor.compress(file_path, output_dir)
return {
"action": "compressed",
"type": "image",
"original_kb": result.original_size // 1024,
"compressed_kb": result.compressed_size // 1024,
"ratio": round(result.compressed_size / result.original_size, 3),
"format": result.format.value,
"output_path": result.path,
}
elif analysis.media_type == "video":
preset_map = {
"light": VideoPreset.LIGHT,
"standard": VideoPreset.STANDARD,
"high": VideoPreset.HIGH,
}
compressor = VideoCompressor(
preset=preset_map.get(analysis.recommended_preset, VideoPreset.STANDARD)
)
result = compressor.compress(file_path, output_dir)
return {
"action": "compressed",
"type": "video",
"original_mb": round(result.original_size_mb, 2),
"compressed_mb": round(result.compressed_size_mb, 2),
"ratio": round(result.compressed_size_mb / result.original_size_mb, 3)
if result.original_size_mb > 0 else 0,
"resolution": result.resolution,
"output_path": result.path,
}
5. 压缩质量监控与自动化门禁
压缩上线后需要一个持续的质量保障机制,防止某次参数调整导致批量产出低质量文件:
python
import time
from datetime import datetime, timedelta
class QualityGate:
"""
压缩质量门禁
每批处理后检查统计指标,异常时告警
"""
THRESHOLDS = {
"image": {
"min_compression_ratio": 0.15, # 至少压缩掉 15%,否则没意义
"max_compression_ratio": 0.95, # 不能压到只剩 5%(质量太差)
"min_ssim": 0.90, # 结构相似度下限(需安装 pyssim)
},
"video": {
"min_compression_ratio": 0.20,
"max_compression_ratio": 0.90,
"min_vmaf_score": 18, # VMAF 分数下限(0-100,越高越好)
},
}
def __init__(self):
self.batch_records = [] # 最近 N 条处理记录
def check(self, result: dict) -> dict:
"""
单条结果检查
返回 {passed: bool, reasons: [...]}
"""
media_type = result.get("type", "unknown")
thresholds = self.THRESHOLDS.get(media_type, {})
issues = []
ratio = result.get("ratio", 1.0)
if "min_compression_ratio" in thresholds:
if ratio > thresholds["min_compression_ratio"]:
issues.append(f"压缩比不足: {ratio:.2f} > {thresholds['min_compression_ratio']}")
if "max_compression_ratio" in thresholds:
if ratio < thresholds["max_compression_ratio"]:
issues.append(f"压缩过度: {ratio:.2f} < {thresholds['max_compression_ratio']}")
return {
"passed": len(issues) == 0,
"reasons": issues,
"timestamp": datetime.now().isoformat(),
}
def batch_summary(self) -> dict:
"""汇总最近一批的处理结果"""
if not self.batch_records:
return {"status": "no_data"}
total = len(self.batch_records)
passed = sum(1 for r in self.batch_records if r.get("passed"))
avg_ratio = sum(r.get("ratio", 0) for r in self.batch_records) / max(total, 1)
return {
"total": total,
"passed": passed,
"failed": total - passed,
"pass_rate": f"{passed/total*100:.1f}%",
"avg_compression_ratio": f"{avg_ratio:.3f}",
"status": "ok" if passed == total else "needs_attention",
}
# 使用示例:集成到压缩流水线末尾
def compress_with_quality_gate(file_path: str, output_dir: str) -> dict:
"""带质量门禁的压缩入口"""
gate = QualityGate()
result = process_media(file_path, output_dir)
check_result = gate.check(result)
gate.batch_records.append({**result, **check_result})
if not check_result["passed"]:
# 质量不达标时记录详细日志,但不阻断(由人工决定是否调整)
print(f"[QUALITY WARN] {file_path}: {check_result['reasons']}")
return {**result, "quality_check": check_result}
6. 落地经验:以 该系统 的媒体处理管道为例
我们以 本系统 的媒体处理管道为例,看它怎么把上面这些组件串成一个生产可用的系统。
这套系统 的媒体处理架构是一个异步队列模型:
- 接入层:业务代码把原始文件路径和一个回调地址写入 Redis 队列(list 结构,LPUSH)。不做同步等待,立即返回"处理中"状态。
- 分析层 :消费队列后先用
MediaAnalyzer做 50ms 内的快速分析,决定走图片管线还是视频管线,以及用什么预设。分析结果写入处理上下文。 - 执行层:根据分析结果分发到对应的工作进程。图片用 Pillow(单进程内存操作,速度快),视频用 ffmpeg 子进程(CPU 密集型,限制并发数)。每个工作进程绑定一个 GPU(如果有)用于硬件编码加速。
- 质检层 :压缩完成后跑
QualityGate。通过的文件直接上传到对象存储并把 CDN 地址写回回调;不达标的文件进入人工审核队列(附带原因标签)。 - 缓存层:相同文件的 MD5 作为缓存键。如果同一个文件被多次发送(比如批量模板图片),第二次直接返回缓存的压缩结果,不再重复处理。
几个实际运营数据:
- 平均压缩比:图片平均压缩到原大小的 18%~25%(主要来自手机原图 3~8MB → 200~600KB),视频平均压缩到原大小的 30%~40%。
- 处理延迟:图片 P99 < 200ms,视频 P99 < 15s(取决于原始时长)。对发送流程来说,图片基本无感,视频有一个可接受的排队等待时间。
- 存储节省:上线后月度对象存储费用下降了约 65%。最大的节省来源是"重复文件命中缓存",模板图片和常用素材的重复发送率很高。
- 一次踩坑经历 :早期版本对所有图片都统一用 JPEG quality=80 压缩,导致带文字的截图出现明显的伪影(文字周围有色块)。后来加了
_detect_type分类逻辑,截图类自动走 PNG 无损压缩,问题解决。
7. 小结与行动清单
富媒体压缩本质上是一个有损变换的质量控制问题。目标是找到那个"足够小的文件大小"和"足够好的视觉效果"之间的交集区域,并且整个过程要全自动、可量化、可回溯。
建议落地顺序:
- 先搭图片压缩管线(Pillow + SmartImageCompressor)。投入小、见效快,覆盖 80% 以上的日常附件场景。预计 2~3 天。
- 加上视频压缩(ffmpeg + VideoCompressor)。需要熟悉 ffmpeg 参数体系,建议先在一个隔离环境里用各种测试视频跑一遍参数网格搜索,找到适合自己内容的 CRF/分辨率组合。预计 3~5 天。
- 接入质量门禁和监控。不用搞得很复杂,先从最基础的"压缩比范围检查"做起,确保不会出现"压完反而变大"或者"压成马赛克"的极端情况。预计 1~2 天。
- 加缓存层。如果业务中有大量重复素材发送(模板图、产品图),这一步的投资回报率极高。预计 1 天。
整体来看,一个完整的媒体处理管道从零到上线大约需要 1.5~2 周。其中视频部分的参数调优是最耗时的环节,建议预留足够的测试样本和时间。