# 工单 L2：渲染产物 QC 哨兵 render_qc.py

**任务性质**：管线能力实施批（新 QC 模块）。交付 = `scripts/render_qc.py` + 单测 + 交付文档。
**派发定位**：flash 司机（glm-5.3-flash，机械逻辑批）。

## 0. 基线与环境

- worktree `/data/workspace/kais-hermes-skills-wt/render-qc`，分支 `wt/render-qc`，基线 `b7f5fe01`。
- 你只提交到 `wt/render-qc`，禁 push、禁碰其他 worktree。
- 开工第一步 `git log --oneline -3` 认环境。

## 1. 背景事实（operator 实测锚点，直接采用勿重新推导）

1. **动机实录（0912）**：12move 批首版驱动硬编码 `frame_end=48`，把 slow 档 144f 运镜截断渲染——声称时长与产物时长不符，靠人工 ffprobe 才发现。本批把它做成自动哨兵。
2. **camera_motion.py** `move_duration_frames(move, fps)` = 时长真值换算（speed 档表 SPEED_DURATION_SECONDS，static 恒 0）。
3. 产物格式：本仓渲染产物 = h264 mp4（Blender FFMPEG/MPEG4），ffprobe 可读 duration。
4. 本机 ffmpeg/ffprobe 可用（operator 实测过 `/tmp/cm_v4/` 12 个 mp4 全部 probe 成功）。
5. 无 Blender 依赖：本模块纯 ffprobe + json，**禁 import bpy**。

## 2. 任务

### C1. `scripts/render_qc.py` — 三个检查器

```python
def check_duration(mp4_path: str, expected_frames: int, fps: int = 24,
                   tol_frames: int = 1) -> dict:
    """ffprobe 实测时长 vs 期望帧数。返回 {"check":"duration","ok":bool,
    "expected_frames":N,"actual_frames":round(dur*fps), "delta":..., "path":...}
    允差 ±1 帧(h264 封装取整)。"""

def check_black_frames(mp4_path: str, threshold_luma: int = 8,
                       max_ratio: float = 0.02) -> dict:
    """黑帧扫描: ffmpeg blackdetect(d=0.05:pix_th=threshold_luma/255)。
    返回 {"check":"black","ok":bool,"black_ratio":...,"segments":[...]}。
    ok = 黑帧占比 ≤ max_ratio。"""

def check_motion(mp4_path: str, min_delta: float = 2.0, sample_frames: int = 3) -> dict:
    """动画发生证明: 抽首/中/尾 3 帧落临时 png,逐对像素差均值(绝对差灰度)。
    min_delta 以下判 "疑似静帧/运镜未生效"。static move 调用方应跳过本检查。"""
```

### C2. 聚合入口

```python
def qc_clip(mp4_path: str, *, expected_frames: int | None = None,
            fps: int = 24, skip_motion: bool = False) -> dict:
    """跑适用检查器,返回 {"path","ok","checks":[...]}。任一 check 不 ok 则 ok=False。
    expected_frames=None 时跳过时长检查(只黑帧+运动)。"""

def qc_batch(mp4s: list[str], **kw) -> dict:
    """逐个 qc_clip,返回 {"results":[...],"ok":all,"failed":[path...]}。"""
```

### C3. CLI

`python3 render_qc.py <mp4...> [--expect-frames N --fps 24 --skip-motion --json]`——人读表格（path/check/ok/delta）默认输出，`--json` 机器可读。退出码：全 ok=0，任一 fail=1（哨兵可直用）。

### C4. 单测 `tests/test_render_qc.py`（新目录）

用 ffmpeg 现造小测试件（48f 纯黑 mp4 / 48f testsrc mp4，fixture 里 subprocess 生成到 tmp_path，module scope 缓存）：
1. check_duration 期望值命中 ok；
2. check_duration 期望偏差 >1 判 fail；
3. check_black_frames 纯黑件 fail、testsrc 件 ok；
4. check_motion testsrc ok、纯黑件 fail（静帧）；
5. qc_batch 聚合与 failed 列表正确；
6. CLI 退出码 0/1。

### C5. 实弹自验 + 交付文档

对 operator 预置的真值对照件自验：`/tmp/cm_v4/cmv4_dolly_in.mp4`（144f 慢推，motion 应 ok）与 `/tmp/cm_v4/cmv4_static.mp4`（48f 静持，须 skip_motion 才 ok）。结果写进 `docs/render-qc-DELIVERY.md`（改动清单/检查器语义/已知边界如 crossfade 会误报黑帧）。

## 3. 红线

1. **逐文件白名单**：只可新增 `scripts/render_qc.py`、`tests/test_render_qc.py`、`docs/render-qc-DELIVERY.md`；其余任何文件一律不动。
2. 禁 import bpy / 禁调 Blender / 禁 GPU 任务；ffmpeg 只读消费 mp4，禁改任何既有产物。
3. `/tmp/cm_v4/` 对照件只读；临时帧 png 落 tmp_path 且用完清理。

## 4. 验证命令（operator 将原样复跑）

```bash
cd /data/workspace/kais-hermes-skills-wt/render-qc
python3 -m pytest skills/kais-blender/kais-blender-layout/tests/test_render_qc.py -q
python3 skills/kais-blender/kais-blender-layout/scripts/render_qc.py /tmp/cm_v4/cmv4_dolly_in.mp4 --expect-frames 144 --json | tail -3   # ok:true
python3 skills/kais-blender/kais-blender-layout/scripts/render_qc.py /tmp/cm_v4/cmv4_static.mp4 --skip-motion --json | tail -3          # ok:true
python3 skills/kais-blender/kais-blender-layout/scripts/render_qc.py /tmp/cm_v4/cmv4_static.mp4 --json | tail -3                        # ok:false(motion 静帧)
git diff b7f5fe01..HEAD --stat   # 恰好 3 个新文件，零越界
```
