AI视频送审前 先抽帧过一遍安全闸门

0 阅读10分钟

生成式视频的成本结构很反常识:钱花在生成上,但赔钱赔在审核上。一条片子渲染出来要几分钟,撞到平台风控被封号,要的是一两周。

我这套流水线的日更量不大,一天一条短片,走七八个平台。看起来是低频、低风险的小生意。但三件事叠在一起,风险就不小了:内容全由 AI 生成、跨平台重复投递、完全无人值守。第二件事尤其危险——同一个号一天里发同一题材的多个变体,平台侧看到的不是「创作者勤快」,而是「疑似批量投放」。

所以我在生成之后、发布之前插了一道闸门:先抽帧,再过模型,过不了就不出门。这篇写这道闸门怎么搭、为什么不能只查关键词、以及抽帧参数怎么定才既不漏也不烧钱。

平台风控看的是行为 不只是画面

先把问题定准。很多团队理解审核是「画面别越线」,于是只做 NSFW 检测。但真正让账号出事的是另外两层:

第一层是画面合规。色情、暴力、血腥、未成年人相关,这是红线,触碰即封。这一层最容易被注意到,也最容易做。

第二层是内容标识合规。国内从《人工智能生成合成内容标识办法》落地后,AI 生成内容必须带显式标识(画面角标或文字说明)和隐式元数据。缺标识不是「内容不好」,是「手续不全」,平台的处理往往是下架而非提醒。

第三层是行为特征。同一账号短时间高频发同类内容、多平台同步秒发、标题模板高度雷同,这些信号与画面无关,但同样会触发风控告警。这一层最隐蔽,因为你在本地看不到任何异常。

我这道闸门只解第一、第二层,第三层靠发布侧限速处理。认清边界很重要:闸门不是万能保险,它只负责「这条片子的画面和标识能不能过」,「这个号能不能这么发」是另一套机制。

抽帧不是越多越好

视频审核的第一个工程问题是成本。一条 8 秒、25fps 的片子有 200 帧,每帧跑一次多模态模型,成本是每帧几厘到几分钱。按 200 帧算,一条短片的审核费用可能超过生成费用。

但抽少了会漏。违规画面可能只出现两三帧——一秒钟的直视镜头,或是转场瞬间的擦边构图。

我的做法是两个抽帧通道并行:

  • 定时抽帧:固定 1 fps,保证时间轴上均匀覆盖,负责兜住持续存在的风险(比如人物穿着、场景性质)。
  • 场景切换抽帧:算相邻帧的直方图差异,超过阈值就认为发生了镜头切换,额外抽该切换点附近的一帧。

第二通道很关键。生成式视频的镜头切换不像实拍那样有明确的剪辑点,往往是模型自己「想」转场,切换处的画面最容易失控——人物突然变形、背景突兀替换、肢体畸变。这些帧如果只靠 1 fps 采样,大概率被跳过。

# 抽帧:1fps 均匀采样 + 直方图差分场景切换补采
import subprocess, os
import numpy as np
from PIL import Image

def sample_frames(video, outdir, fps=1.0, hist_thresh=0.32):
    os.makedirs(outdir, exist_ok=True)
    base = os.path.join(outdir, "t_%04d.jpg")
    subprocess.run(["ffmpeg", "-y", "-i", video, "-vf", f"fps={fps}",
                    "-q:v", "3", base], check=True, capture_output=True)
    frames = sorted(f for f in os.listdir(outdir) if f.endswith(".jpg"))
    picks = list(frames)                      # 通道一:均匀
    prev = None
    for name in frames:                       # 通道二:场景切换
        arr = np.asarray(Image.open(os.path.join(outdir, name)).convert("L"))
        hist = np.histogram(arr, bins=64, range=(0, 256))[0].astype(float)
        hist /= hist.sum() + 1e-9
        if prev is not None and 0.5 * np.abs(hist - prev).sum() > hist_thresh:
            picks.append(name)
        prev = hist
    return sorted(set(picks))

0.5 * Σ|h1 - h2| 是直方图交集的补,取值 0~1,等价于「两帧分布差了多少」。阈值 0.32 是我在几十条片子上调出来的:低于 0.25 会把运镜造成的亮度渐变也当切换,高于 0.4 会漏掉硬切。

单帧命中不等于违规

第二件事是判定策略。早期的版本我对每一帧单独判定,只要有一帧超阈值就整条挂掉。结果是误杀率很高:人物侧脸仰拍被判成异常姿势、暖色调夜景被判成血腥、白色床单被判成肤色区块。

不是模型不行,是单帧信息天生不足。人眼看一个画面,会自动带上下文:这是卧室还是手术室、这个动作是打闹还是殴打。分类器看到的只是一张图。

所以判定要从「帧级」改成「镜头级」:

VIOLATION = {"porn": 0.80, "gore": 0.80, "minor": 0.50}  # minor 阈值必须最低

def judge(frame_scores, shot_of):
    """frame_scores: {frame: {cat: score}};shot_of: {frame: shot_id}"""
    for cat, thr in VIOLATION.items():
        shots = {}
        for f, sc in frame_scores.items():
            if sc.get(cat, 0) >= thr:
                shots.setdefault(shot_of[f], []).append(f)
        for shot, hit in shots.items():
            if len(hit) >= 2:            # 同一镜头连续命中才算数
                return f"BLOCK:{cat}:{shot}", hit
    return "PASS", []

三处细节值得说明:

阈值按类目分级。 色情、血腥用 0.80,宁可漏一点也不乱杀;涉未成年人单独压到 0.50——这一类风险不对称,误杀的代价是废一条片子,漏放的代价是账号没了。风险不对称时,阈值就该不对称。

同一镜头两次命中才升级。 单帧孤立命中可能是噪声,同镜头多帧命中说明风险是「持续存在」的。这一条把误杀率压下去了大半。

返回命中帧而不是布尔值。 人要复核时,看那一帧比看「审核未通过」这五个字有用得多。我把它画成一张缩略图联系表(contact sheet),一次看完。

提示词也要审 主要防绕词

画面审完,还有一层:提示词。这一层不是防「写了脏话」,而是防绕字。

现在的模型对违禁词有基本抵抗,但对手法很低级:全角半角混写、拼音缩写、形近字替换、加零宽字符、用「那个词」的同义描述。这些手法对规则引擎是噪音,对语义模型却几乎不构成障碍。

我的做法是先把提示词做一遍规范化,再交给分类器,而不是反过来调模型:

import re, unicodedata

def normalize(prompt):
    s = unicodedata.normalize("NFKC", prompt)      # 全角→半角、兼容字符归一
    s = s.replace("\u200b", "").replace("\u200c", "")  # 剥零宽字符
    s = re.sub(r"(?<=[一-龥])\s+(?=[一-龥])", "", s)   # 中文词内空格干扰
    return s.lower()

NFKC 这一步很关键,它顺手解决了三个问题:全角字母编号、装饰性花体、罗马数字变体。零宽字符是另一类常见绕过手段,肉眼看不见但会打断关键词匹配,直接剥掉。

规范化之后再做两级判定:高风险直接拦,中风险转人工队列。中风险不自动放行,但也别自动拒绝——中间态的意义是攒样本,人工复核的结论回灌,下一版阈值就有依据了。

标识检查 最容易被漏的一步

《人工智能生成合成内容标识办法》要求 AI 生成内容带显式标识。很多团队做了画面审核、做了关键词过滤,唯独漏了这一条,最后片子被下架还不知道为什么。

我这套流水线在 2026-10-07 做过一次纠正:此前为了规避平台限流,把平台的「AI 生成声明」字段从 AI 生成改成了虚构作品;当天恢复为 AI 生成,因为合规优先于流量。改的位置不止一处——发布脚本、job 生成模板、GUI 侧默认值共七八个地方,漏改任何一处都会造成「以为标了其实没标」。

工程上的教训是:这类开关必须有单一真源,且要有校验。我在闸门里加了一条硬检查——送审的 job 里 creativeStatement 不等于 ai_generated,直接拒发:

def check_declaration(job):
    if job.get("creativeStatement") != "ai_generated":
        return "BLOCK:declaration_missing"
    return "PASS"

注意这是代码层拦截,不是靠人记。同样是这套流水线,2026-09-26 曾因为「改配置但执行路径不读配置」导致停发指令两小时未生效——那次教训的结论就是:约束必须落在执行路径上,写在文档和任务描述里都不算数。

把闸门放在生成和发布之间

完整链路长这样:

AI视频发布前安全闸门 抽帧双通道到镜头级判定与标识校验流程图

四个环节里,真正花钱的只有第二环。所以我给它加了两条省钱规则:

先跑便宜的先决条件。 标识检查是纯字段比对,零成本,放在最前面。标识都不对的片子根本不用送去抽帧。

抽帧结果按内容哈希缓存。 同一条片子重投多个平台时(我的流水线要发七八个平台),审核结论只算一次。缓存键用视频文件的 SHA256 前 16 位 + 审核器版本号,避免换了模型还用旧结论。

import hashlib, json

def cache_key(path, model_ver):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return f"{h.hexdigest()[:16]}:{model_ver}"

这里有个我吃过亏的点:不要用文件的 mtime 或 ctime 当缓存键。同一份内容被转码、被原子替换、被同步工具碰一下,时间戳就变了,缓存全失效,审核费重算一遍。内容哈希慢几秒,但它是唯一靠得住的判据。

闸门挡不住的部分

最后说清楚这道闸门做不到什么,避免产生虚假的安全感。

它挡不住语义违规。画面干净、提示词干净,但整条片子的叙事在宣扬什么,模型判不了,人也常常判不了。这类问题只能靠选题侧的判断——我的流水线选题固定在四个技术方向上,天然规避了这个风险,但这不叫解决了,叫没碰到。

它挡不住跨平台差异。同一条片子,A 平台过、B 平台不放过,实际发生过不止一次。闸门给出的是「普遍合规」的判断,具体平台的红线还得单独维护。

它更挡不住账号级风控。账号如果已经被打了自动化标签,发什么都会中招。这时候该做的是停发、换环境、人工试水,而不是优化内容——这个判断跟内容质量完全无关,闸门再准也没用。

所以闸门的定位要说准:它是降低返工率的工程手段,不是合规责任的外包。 一条片子该不该发、这个号还能不能发,仍然要人来定。

收尾

这道闸门上线后,我这边的变化很具体:抽帧审核通过就走,不通过的直接进复核队列,平均每条片子多花 20 秒机器时间和几分钱的审核费。换回来的是发布后零惊动——不用盯着平台后台等审核结果,也不用半夜起来处理下架通知。

比起「内容做得更好」,这种「少出错」的改进收益更稳定。内容质量是波动量,工程闸门是常量。