我们的视频处理代码是这样长出来的:
*本文由 VidDown(https://www.viddown.cn)支持
- 第一个需求:写了个
transcode.sh;- 第二个需求:复制一份改成
transcode_hls.sh;- 第三个需求:又复制一份
transcode_watermark.sh;- 半年后:三十多个脚本,每个都硬编码参数,没有一个有错误处理,没人敢改(改一处不知道会破坏什么)。
典型症状:
- 失败不知道在哪一步(脚本就是一串命令,中途挂了就挂了);
- 参数散落在各个脚本里(改 CRF 要改十几个文件);
- 没有日志(出问题只能重跑一遍看输出);
- 无法测试(只能拿真素材跑)。
后来我们做了一次重构,把它变成一个"有结构"的系统。这篇写这次重构的核心设计。
TL;DR :五个核心改动:① 统一任务抽象 (输入 + 参数 + 输出 + 状态 + 产物);② 配置外置 (YAML 描述流程,参数不写死在代码里);③ 命令构造器 (统一生成 ffmpeg 命令,参数白名单校验,避免拼接注入);④ 结构化日志 (每任务一个日志文件,记录命令、耗时、产物,可追溯);⑤ 分层测试 (短视频单测 + 输出契约测试 + 黄金素材回归)。另外两个容易被忽略的:临时文件的生命周期管理 (不然磁盘会被填满);可重试错误的分类(网络错误可重试、源文件损坏不可重试)。
目录
一、脚本是怎么腐烂的
阶段 1:一个脚本,跑通需求,很开心。
阶段 2:需求变了,复制一份改参数 → 两份脚本,90% 重复。
阶段 3:又来需求,再复制 → 三份脚本。此时开始出现"改了一个忘了另一个"。
阶段 4 :有人写了 common.sh 想抽公共部分 → 但公共部分被硬编码参数污染,抽不动。
阶段 5:三十多个脚本,没人敢改,只能继续复制。
根本原因 :脚本里混杂了三样本该分开的东西:
| 混在一起的东西 | 应该在哪 |
|---|---|
| 处理逻辑(怎么调 ffmpeg) | 代码 |
| 参数(CRF、分辨率、路径) | 配置 |
| 编排(先做什么后做什么) | 流程定义 |
重构的核心就是把这三样分开。
二、核心抽象:一个任务是什么
python
@dataclass
class TaskResult:
ok: bool
outputs: dict # {'video': '/path/out.mp4', 'cover': '/path/cover.jpg'}
metrics: dict # {'duration_s': 42.1, 'input_bytes': ..., 'output_bytes': ...}
error: str = ''
retryable: bool = False
每个处理步骤实现同一个接口:
python
class Step(ABC):
name: str
@abstractmethod
def run(self, ctx: dict) -> TaskResult:
"""ctx: 上下文(输入路径、输出目录、配置)"""
...
具体步骤:
python
class ProbeStep(Step):
name = 'probe'
def run(self, ctx):
info = probe_full(ctx['input'])
return TaskResult(True, {}, {'technical': normalize(info)})
class TranscodeStep(Step):
name = 'transcode'
def run(self, ctx):
cfg = ctx['config']['transcode']
out = ctx['workdir'] / 'out.mp4'
cmd = build_transcode_cmd(ctx['input'], out, cfg)
run_ffmpeg(cmd, timeout=cfg.get('timeout', 3600))
return TaskResult(True, {'video': str(out)}, {})
class HlsStep(Step):
name = 'package'
...
class ThumbnailStep(Step):
name = 'thumbnail'
...
好处:
- 每个步骤可以单独测试;
- 流程由"步骤列表"组成,改顺序/加步骤不改步骤内部;
- 每个步骤的成功/失败/耗时都被记录。
三、配置外置
用 YAML 描述一次处理要做什么:
yaml
# pipelines/web_delivery.yaml
name: web_delivery
steps:
- name: probe
- name: remux
enabled: true
- name: transcode
config:
codec: libx264
crf: 20
preset: slow
pix_fmt: yuv420p
maxrate_ratio: 1.3
bufsize_ratio: 2.0
params: ["psy-rd=0", "deblock=-1:-1"] # 屏录预设
- name: package
config:
hls_time: 4
segment_type: fmp4
ladder: [1080p, 720p, 480p]
- name: thumbnail
config:
count: 40
skip_head_ratio: 0.03
- name: upload
config:
bucket: vod-prod
prefix: "vod/{asset_id}"
代码里不再有硬编码的 CRF 和分辨率。改参数 = 改 YAML(甚至可以按客户存多份)。
加载与校验(用 pydantic 做 schema 校验,避免配置写错):
python
from pydantic import BaseModel
class TranscodeConfig(BaseModel):
codec: str = 'libx264'
crf: int = 20
preset: str = 'slow'
pix_fmt: str = 'yuv420p'
params: list[str] = []
class PipelineConfig(BaseModel):
name: str
steps: list[dict]
配置校验很重要------配置写错(比如 crf 写成字符串)不应该等到 ffmpeg 报错才发现。
四、命令构造器:别拼字符串
错误做法:
python
cmd = f'ffmpeg -i {src} -c:v libx264 -crf {crf} {out}' # 危险:src 含空格就崩
subprocess.run(cmd, shell=True) # 危险:shell 注入
正确做法:
python
ALLOWED_CODECS = {'libx264', 'libx265', 'libsvtav1'}
ALLOWED_PRESETS = {
'libx264': {'ultrafast', 'veryfast', 'fast', 'medium', 'slow', 'slower', 'veryslow'},
'libx265': {'ultrafast', 'veryfast', 'fast', 'medium', 'slow', 'slower', 'veryslow'},
'libsvtav1': {str(i) for i in range(14)},
}
def build_transcode_cmd(src: Path, out: Path, cfg: dict) -> list:
codec = cfg['codec']
if codec not in ALLOWED_CODECS:
raise ValueError(f'不支持的编码器: {codec}')
preset = str(cfg.get('preset', 'medium'))
if preset not in ALLOWED_PRESETS[codec]:
raise ValueError(f'{codec} 不支持 preset={preset}')
cmd = ['ffmpeg', '-hide_banner', '-v', 'error', '-y', '-i', str(src)]
# 滤镜链(从配置拼,不拼接用户输入)
filters = []
if cfg.get('scale'):
filters.append(f"scale={cfg['scale']}:flags=lanczos")
if cfg.get('denoise'):
filters.append(cfg['denoise'])
if filters:
cmd += ['-vf', ','.join(filters)]
cmd += ['-c:v', codec, '-preset', preset, '-crf', str(int(cfg['crf']))]
cmd += ['-pix_fmt', cfg.get('pix_fmt', 'yuv420p')]
for p in cfg.get('params', []):
cmd += ['-x264-params' if codec == 'libx264' else '-x265-params', p]
# 音频:默认复制,除非要重编码
cmd += ['-c:a', cfg.get('audio_codec', 'copy')]
cmd += ['-movflags', '+faststart']
cmd += [str(out)]
return cmd
要点:
- 用列表不用字符串 (避免 shell 解析和空格问题,
shell=False); - 参数白名单校验(编码器、preset 这种有枚举的参数);
- 用户输入(文件名)永远作为独立参数,不拼进命令串;
- 数值参数显式转换 (
str(int(crf))避免注入非数字)。
五、结构化日志
每个任务一个日志文件:
logs/
2026-03-11/
task-14201/
00_probe.log
01_remux.log
02_transcode.log
03_package.log
summary.json
summary.json:
json
{
"task_id": "14201",
"pipeline": "web_delivery",
"input": "/data/in/lecture_0311.mp4",
"input_sha": "xxh3:abc...",
"steps": [
{"name": "probe", "ok": true, "seconds": 1.2},
{"name": "remux", "ok": true, "seconds": 8.4},
{"name": "transcode", "ok": true, "seconds": 243.7,
"cmd": "ffmpeg -i ... -crf 20 ..."},
{"name": "package", "ok": true, "seconds": 12.1},
{"name": "thumbnail", "ok": true, "seconds": 4.3}
],
"outputs": {"video": "...", "master": "...", "cover": "..."},
"total_seconds": 271.4,
"status": "DONE"
}
关键:把实际执行的命令记下来。出问题时能直接复制出来重跑一次,而不用猜"当时到底跑了什么"。
日志用 Python logging + JSON 格式化(前面监控那篇讲过),每个步骤打 start/end 和关键指标。
六、错误处理与重试分类
不是所有失败都该重试:
| 错误 | 可重试? | 处理 |
|---|---|---|
| 网络超时(上传/下载) | ✅ | 指数退避重试 |
| 磁盘满 | ⚠️ | 清理后重试(但要先告警) |
| 源文件损坏 | ❌ | 直接标记失败 + 隔离 |
| 参数错误 | ❌ | 代码 bug,不该重试 |
| ffmpeg 崩溃(偶发) | ✅ | 重试一次 |
| 超时 | ⚠️ | 增大超时后重试,但要记录 |
python
class TransientError(Exception):
"""可重试错误"""
pass
class PermanentError(Exception):
"""不可重试"""
pass
def classify_ffmpeg_error(stderr: str) -> Exception:
if 'Invalid data found' in stderr or 'moov atom not found' in stderr:
return PermanentError('源文件损坏或格式不支持')
if 'No space left on device' in stderr:
return TransientError('磁盘空间不足')
if 'Connection timed out' in stderr or 'Connection reset' in stderr:
return TransientError('网络错误')
return TransientError(f'未知错误(可重试一次): {stderr[:200]}')
检查点 :重试时不要从头开始------记录已完成步骤,retry 时跳过它们。
七、分层测试
| 层级 | 测什么 | 怎么做 |
|---|---|---|
| 单元测试 | 命令构造、参数校验、时间解析 | 不需要真跑 ffmpeg,快 |
| 契约测试 | 输出文件是否符合预期(编码/分辨率/时长/faststart) | 用几秒的短视频跑一遍 |
| 回归测试 | 画质有没有变化 | 黄金素材集 + VMAF(第 37 篇) |
契约测试示例:
python
def test_transcode_output_contract():
src = TESTDATA / 'clip_5s.mp4'
out = tmp / 'out.mp4'
cmd = build_transcode_cmd(src, out, {'codec': 'libx264', 'crf': 23, 'preset': 'ultrafast'})
run_ffmpeg(cmd)
info = normalize(probe_full(str(out)))
assert info['video']['codec'] == 'h264'
assert info['video']['width'] == 1920
assert abs(info['duration'] - 5.0) < 0.3
assert info['has_audio']
assert moov_at_front(out) # faststart
测试素材要小(几秒、几 MB),这样测试跑得快(几秒钟),大家才愿意跑。
八、产物与临时文件管理
规则:
- 每个任务一个独立工作目录 (
work/<task_id>/); - 中间产物明确命名 (
01_remux.mp4、02_transcode.mp4); - 成功后清理中间产物(只保留最终产物);
- 失败时保留现场(方便排查),但要设过期清理(比如 7 天);
- 用
try/finally保证清理(异常路径也要清理)。
python
@contextmanager
def task_workdir(task_id: str, base: Path):
d = base / task_id
d.mkdir(parents=True, exist_ok=True)
try:
yield d
finally:
# 失败时保留(由清理任务处理),成功时立即删
pass
def cleanup_old_workdirs(base: Path, days=7):
cutoff = time.time() - days * 86400
for d in base.iterdir():
if d.is_dir() and d.stat().st_mtime < cutoff:
shutil.rmtree(d, ignore_errors=True)
磁盘监控:工作目录的磁盘使用率要有告警------前面磁盘那篇讲过,跑批量的话几小时就能塞满。
九、代码骨架
video_pipeline/
├── __init__.py
├── config.py # 配置加载与校验(pydantic)
├── probe.py # ffprobe 封装
├── cmd.py # ffmpeg 命令构造器(白名单校验)
├── errors.py # 错误分类
├── steps/
│ ├── __init__.py
│ ├── probe.py
│ ├── remux.py
│ ├── transcode.py
│ ├── package.py
│ └── thumbnail.py
├── runner.py # 流程执行器(编排 + 状态 + 重试)
├── logging_setup.py
└── pipelines/
├── web_delivery.yaml
├── archive.yaml
└── social_vertical.yaml
执行器:
python
class PipelineRunner:
def __init__(self, config: dict, workdir: Path):
self.config = config
self.workdir = workdir
self.steps = [STEP_REGISTRY[s['name']]() for s in config['steps'] if s.get('enabled', True)]
def run(self, input_path: Path) -> dict:
ctx = {'input': str(input_path), 'workdir': self.workdir, 'config': self.config}
results = []
outputs = {}
for step in self.steps:
t0 = time.time()
try:
r = step.run(ctx)
except TransientError as e:
# 重试一次
r = step.run(ctx)
if not r.ok:
return self._fail(results, step, str(e))
except PermanentError as e:
return self._fail(results, step, str(e))
secs = time.time() - t0
results.append({'name': step.name, 'ok': r.ok, 'seconds': round(secs, 1)})
outputs.update(r.outputs)
if not r.ok:
return self._fail(results, step, r.error)
return {'ok': True, 'steps': results, 'outputs': outputs}
十、坑清单
- 用 shell 字符串拼 ffmpeg 命令 → 空格/注入问题。用列表。
- 参数硬编码在脚本里 → 改一处漏十处。配置外置。
- 不校验配置 → 配置写错要等 ffmpeg 报错。用 schema 校验。
- 不记实际执行的命令 → 出问题没法复现。写进日志。
- 所有失败都重试 → 损坏文件重试 N 次浪费算力。分类。
- 重试从头开始 → 耗时步骤重跑。做检查点。
- 没有超时 → 一个任务卡几天。每个步骤设超时。
- 临时文件不清理 → 磁盘满。try/finally + 定期清理。
- 工作目录共用 → 并发任务互相覆盖。每任务独立目录。
- 测试要跑真素材几分钟 → 没人跑测试。用几秒的短视频。
- 没有契约测试 → 输出参数错了到客户才发现。自动断言。
- 没有回归测试 → 升级依赖后画质变了不知道。黄金素材集。
- 日志只有 stdout → 没法结构化分析。JSON 日志。
- 步骤之间用文件传参 → 路径硬编码。用 ctx 传递。
- 不做编排,一个脚本跑到底 → 失败不知道在哪一步。状态机。
最后说说这次重构带来的最大变化。
不是"代码更优雅了",而是"团队敢改了"。
重构之前的状态是:三十多个脚本,每个人都在复制,没人敢动老的------因为改动的后果不可预测。这导致:
- 新需求只能"再加一个脚本"(继续恶化);
- bug 修不了(怕影响别的);
- 新人看不懂(只能问老人)。
重构之后:
- 改参数 = 改 YAML(几乎零风险);
- 加步骤 = 实现一个新的 Step 类(不影响其他步骤);
- 改逻辑 = 有测试兜底(知道会不会破坏别的)。
"敢改"才是可维护性的真正含义。 代码好不好看是次要的,"能不能安全地改"才是关键。
还有一点:这类重构的时机很重要。太早做(只有三个脚本时)是过度设计------你会为了三个需求建一套框架,最后框架比脚本还复杂。太晚做(五十个脚本时)会很难------因为要理解所有脚本的行为才能抽象。
我的经验阈值:当你开始复制第三次的时候。也就是说,当你发现自己在做第三次"复制 + 改参数"时,就是抽象的信号。
"第三次重复"是个很好用的经验法则------第一次是需求,第二次是巧合,第三次就是模式了。这时抽出抽象,成本最低、收益最大。