从一堆脚本到能维护的系统:视频处理管道的工程化

我们的视频处理代码是这样长出来的:

*本文由 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'
    ...

好处:

  1. 每个步骤可以单独测试;
  2. 流程由"步骤列表"组成,改顺序/加步骤不改步骤内部;
  3. 每个步骤的成功/失败/耗时都被记录。

三、配置外置

用 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

要点:

  1. 用列表不用字符串 (避免 shell 解析和空格问题,shell=False);
  2. 参数白名单校验(编码器、preset 这种有枚举的参数);
  3. 用户输入(文件名)永远作为独立参数,不拼进命令串;
  4. 数值参数显式转换 (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),这样测试跑得快(几秒钟),大家才愿意跑。

八、产物与临时文件管理

规则:

  1. 每个任务一个独立工作目录 (work/<task_id>/);
  2. 中间产物明确命名 (01_remux.mp4、02_transcode.mp4);
  3. 成功后清理中间产物(只保留最终产物);
  4. 失败时保留现场(方便排查),但要设过期清理(比如 7 天);
  5. 用 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}

十、坑清单

  1. 用 shell 字符串拼 ffmpeg 命令 → 空格/注入问题。用列表。
  2. 参数硬编码在脚本里 → 改一处漏十处。配置外置。
  3. 不校验配置 → 配置写错要等 ffmpeg 报错。用 schema 校验。
  4. 不记实际执行的命令 → 出问题没法复现。写进日志。
  5. 所有失败都重试 → 损坏文件重试 N 次浪费算力。分类。
  6. 重试从头开始 → 耗时步骤重跑。做检查点。
  7. 没有超时 → 一个任务卡几天。每个步骤设超时。
  8. 临时文件不清理 → 磁盘满。try/finally + 定期清理。
  9. 工作目录共用 → 并发任务互相覆盖。每任务独立目录。
  10. 测试要跑真素材几分钟 → 没人跑测试。用几秒的短视频。
  11. 没有契约测试 → 输出参数错了到客户才发现。自动断言。
  12. 没有回归测试 → 升级依赖后画质变了不知道。黄金素材集。
  13. 日志只有 stdout → 没法结构化分析。JSON 日志。
  14. 步骤之间用文件传参 → 路径硬编码。用 ctx 传递。
  15. 不做编排,一个脚本跑到底 → 失败不知道在哪一步。状态机。

最后说说这次重构带来的最大变化。

不是"代码更优雅了",而是"团队敢改了"。

重构之前的状态是:三十多个脚本,每个人都在复制,没人敢动老的------因为改动的后果不可预测。这导致:

  • 新需求只能"再加一个脚本"(继续恶化);
  • bug 修不了(怕影响别的);
  • 新人看不懂(只能问老人)。

重构之后:

  • 改参数 = 改 YAML(几乎零风险);
  • 加步骤 = 实现一个新的 Step 类(不影响其他步骤);
  • 改逻辑 = 有测试兜底(知道会不会破坏别的)。

"敢改"才是可维护性的真正含义。 代码好不好看是次要的,"能不能安全地改"才是关键。

还有一点:这类重构的时机很重要。太早做(只有三个脚本时)是过度设计------你会为了三个需求建一套框架,最后框架比脚本还复杂。太晚做(五十个脚本时)会很难------因为要理解所有脚本的行为才能抽象。

我的经验阈值:当你开始复制第三次的时候。也就是说,当你发现自己在做第三次"复制 + 改参数"时,就是抽象的信号。

"第三次重复"是个很好用的经验法则------第一次是需求,第二次是巧合,第三次就是模式了。这时抽出抽象,成本最低、收益最大。

相关推荐
William Dawson1 小时前
kkFileView 内网 ARM 服务器全链路部署:从「找不到 office」到全绿通关(超详细排障实录)
运维·服务器
轻口味2 小时前
HarmonyOS 7 新特性2:音频编创——轻音台里的降噪、环绕与格式转换
华为·音视频·harmonyos·鸿蒙·音频编创
箓维2 小时前
线程的优缺点,与进程的关联和差异
java·服务器·笔记
技灵AI2 小时前
Seedance 长剧生产实战:用首尾帧接戏解决角色崩脸与场景漂移(含 return_last_frame 用法与提示词模板)
人工智能·prompt·aigc·音视频
不会就选b2 小时前
Linux之http会话
服务器·网络协议·http
江屿风2 小时前
【Linux系统】【从【收尾】缓冲区到【新开】磁盘块:一节课打通文件系统底层原理】流食般投喂
linux·运维·服务器·人工智能·笔记
_upupup2 小时前
Linux中的权限解析
linux·服务器
曹牧2 小时前
Spring MVC : Controller 层URL划分
java·运维·服务器·前端
宵时待雨3 小时前
linux笔记归纳24:多路转接select
linux·服务器·笔记·高并发