# Image Story Assembly Hardening

> Use when assembling a long (≥5 min / ≥40 shots) image-story or slideshow video with ffmpeg — push-in (Ken Burns / zoompan) motion, crossfades, burned-in or layered subtitles, and pre-delivery QC. Covers the four failure modes that only show up at this scale (multi-branch filtergraph OOM/throttle, crossfade subtitle ghosting, zoompan jitter, generation artifacts like scan-lines and baked watermarks) plus the quantitative criteria to catch them. 触发词：图片联播、静态图叙事、长片装配、推镜卡顿、转场字幕叠影、去水印、扫描线伪影、成片 QA。

- Skill: `pangpang888/image-story-assembly-hardening` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add pangpang888/image-story-assembly-hardening`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pangpang888/image-story-assembly-hardening/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pangpang888 (https://skillmd.com/u/pangpang888)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/pangpang888/image-story-assembly-hardening

---


# 长片图片叙事 · 装配与质检加固

面向「几十张图 → 10 分钟成片」的 ffmpeg 装配。前 10 镜都不会出问题，**问题全在规模上**。
本技能只讲四个规模化才暴露的失败模式与对应判据，通用流程仍走 `image-story-video-wizard`。

## 0. 先算帧数再动手

一切装配以**音频帧长**为准，不要以「每镜几秒」凑：

```
total_frames = round(audio_duration * fps)
第 i 镜 input_frames = base_frames_i + transition_frames     # 给 xfade 补时长，末镜不补
第 i 镜 xfade_offset_frames = Σ(前 i 镜 base_frames)
验收：Σ input_frames - (n-1)*transition_frames == total_frames
```

先把这张表算完、断言通过，再碰 ffmpeg。装配完用 `ffprobe -show_entries stream=nb_frames` 复核，
差 1 帧以内才算过。**不要靠 -shortest 兜底**，那会把误差悄悄堆在结尾。

---

## 1. 失败模式 A：多分支 filtergraph 会爆

**症状**：把 N 路 `scale + zoompan` 同时挂进一张 `filter_complex`，速度掉到 0.4x，
一小时只出一千多帧，输出还是残缺的。

**根因**：ffmpeg 要为**每条** zoompan 分支保留预放大后的整帧缓冲。
6x 预放大下每分支 9216×5184×3B ≈ 143MB，64 分支就是 9GB 级别 —— 内存换页，
不是 CPU 慢，所以加 `-threads` 没用。

**解法：两段式**

```bash
# 段1：每镜单独渲成中间片（每进程只持 1 路缓冲，可并行）
ffmpeg -loop 1 -framerate $FPS -t $DUR -i shot.png \
  -vf "scale=$((W*6)):$((H*6)):flags=lanczos,\
zoompan=z='min(1+0.006*on/$FPS,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
d=1:s=${W}x${H}:fps=$FPS,setsar=1,format=yuv420p" \
  -c:v libx264 -crf 12 -preset veryfast seg_$i.mp4

# 段2：只做 xfade 拼接（纯解码 + 混帧，秒级）
```

段1 用 `ThreadPoolExecutor(max_workers=4)`；中间片用 **crf 12**（近似无损）避免二次压缩。
实测收益：单次调用 0.40x / 1h24m 未完成 → 两段式全片 **119s**。

**中间片的 `-t` 要用 input_frames/fps**，不是 base_frames/fps。

---

## 2. 失败模式 B：转场中点字幕叠影

**症状**：转场那 0.5 秒里，上下同时出现两句字幕 / 两个标题，像渲染 bug。

**根因**：字幕和画面同帧，xfade 混的是**整帧**，于是前镜字幕和后镜字幕被一起混。

**解法：字幕独立成轨，最后 overlay 一次**

```
画面轨 1536x864 ──lanczos──► 1920x1080 ─┐
字幕轨 1920x1080 RGBA (qtrle) ──────────┴─ overlay=format=auto ─► 母版
```

- 字幕层按 1920×1080 **原生分辨率**渲染成 RGBA PNG 序列，再 `-c:v qtrle` 拼成 `.mov`
  （qtrle 是无损 + 带 alpha，几十 GB 也别怕，只是中间产物）。
- 最后一条命令里做 `scale` + `overlay` + 混音。这样：
  ① 转场只作用于画面，字幕不参与混帧；
  ② 字幕不被 resample，锐度是原生的；
  ③ 想改字幕只需重跑字幕轨 + 最后一条命令，不用重渲画面。

**验收**：抽 4 处以上转场中点帧，确认只有一组字幕、一组标题。

---

## 3. 失败模式 C：zoompan 推镜抖动

**根因**：`zoompan` 的 `z/x/y` 计算后按**源像素整数**取整，锯齿幅度 ≈ ±0.5 源像素。
源图越小，这个 ±0.5 占输出像素的比例越大，看起来就是推镜时整幅在「跳」。

**解法：先 Lanczos 预放大再 zoompan**，倍数按验收指标定：

| 预放大 | 实测抖动残差 (25fps, 0.6%/s, 1536 基线) | 判定 |
|---|---|---|
| 2x | 0.401 px | 不合格 |
| 4x | 0.128 px | 不合格（临界） |
| **6x** | **0.075~0.089 px** | 合格 |

预放大倍数 = 1 / 可接受残差(px) × 系数，**验收线取 0.10px**。

**测量方法**：取推镜中的 30 连续帧 → 灰度 → `phaseCorrelate` 求逐帧位移 →
对位移序列做一次线性拟合取残差 std。整幅匀速推镜的位移是线性的，残差就是抖动。
配合缩放搜索法验「推镜是否真的发生」（对前后帧做 1.00~1.25 的尺度扫描找最大相关）。

---

## 4. 失败模式 D：生成图自带伪影

### 4a. API 水印（右下角网点白字）

**定位：跨图一致性响应**。水印在整批里位置/字形完全一致而背景各异：

```
每张取「相对自身局部中值背景的正向对比度」→ 跨 N 张取平均
⇒ 背景互相抵消，只剩水印
```

据此得到 bbox。**清理用整块外接矩形 + `cv2.inpaint(..., INPAINT_NS, r≈9)`**。

> ⚠️ **不要用「贴合字形的遮罩」**。半透明水印的对比度随底图明暗变化，
> 跨图平均只保得住压在暗底上的笔画，亮底上的整段笔画被漏掉 → inpaint 后留白色斑块。
> 外接矩形反而更干净。（实测残留：字形遮罩方案留斑块，矩形方案 98.0 → 2.1）

**写盘必须走 `PIL` 或 `cv2.imencode(...).tofile()`** —— 非 ASCII 路径下 `cv2.imwrite` 静默失败。

### 4b. 扫描线伪影（贯穿画面的细直线）

**不要**只用「该行与邻行差异 > 阈值」判废 —— 会把画面固有的直线结构
（屋脊、梁柱、桌沿、卷边）全部误报（实测首轮 20 张图 108 处误报）。

**必须加两个维度**：

| 维度 | 定义 | 伪影 | 画面结构 |
|---|---|---|---|
| profile | `mean_x max_c |P[r] - (P[r-4]+P[r+4])/2|` | 高（>100） | 中（30~50） |
| **贯穿度** | 残差 > 0.45×max 的宽度占比 | **≥0.85**（贯穿全幅） | <0.75 |
| **沿线恒定性** | `1 - std_x(D) / mean_x(D)` | **≥0.55**（强度几乎不变） | <0.55（沿线起伏） |

判废 = profile ≥ 35 **且** 贯穿度 ≥ 0.85 **且** 恒定性 ≥ 0.55。
加完这两维，误报从 108 处降到 1 处，与目视完全一致。

**修补：相邻干净行逐列线性插值，不要 inpaint**

```python
for r in range(a, b + 1):
    t = (r - up) / (dn - up)                     # up/dn 是紧邻的干净行
    out[r] = (1 - t) * im[up] + t * im[dn]
```

inpaint 在**整幅长条**上会从左右边界向内传播，产生横向涂抹；
线性插值两端 t=0/1 天然无缝（实测跨带行差 2.1 vs 邻域 2.0）。
写脚本时带**幂等保护**（修前先复核该带是否仍是目标颜色）+ **原图备份**。

---

## 5. 字幕/标题层的硬规则

### 5a. 时间轴必须从音频实测，禁止按字数推算

**最容易出事、也最容易被忽略的一条。** 用「句字数 × 平均语速」分配时长，
前几秒看着没问题，**误差逐句累积**，到片尾就是「音频早讲完了，字幕还在往前滚」。

**解法：静音间隙强制对齐**（连续语流也适用，只要停顿够多）

```python
# 1. 解成 16k 单声道，取 10ms RMS 包络
# 2. 找所有 >=0.25s 的静音间隙 → 候选锚点 = 间隙起点 ∪ 间隙终点
#    ⚠️ 只取起点会退化成畸形解（末句被塞进超长区间，s/字 明显离群）
# 3. DP 选 N 个句界，代价 = Σ|句时长 − 字数 × 均速|
# 4. local_snap：DP 只是全局最优，短句会坐进停顿里
#    对「有声占比 <0.55」的句，在 ±0.8s 内步进搜索语音最密处
```

**验收指标**（缺一不可）：
- 每字时长的 **min/中位/max 落在物理合理带**（实测 0.094 / 0.196 / 0.373 s/字）；
- 有声占比 <0.45 的句数 ≈ 0（修前 15 句 → 修后 1~2 句）；
- **音频结束后仍有字幕的帧数 == 0**；
- **有声却无字幕的帧数 == 0**。

> ⚠️ **核验时基陷阱**：别拿 100Hz 包络直接比 25fps 视频的帧号，会算出 −445s 这种假结论。
> 先把语音按 1/(fps) 窗口重采样到同一时基，再逐帧比对。
> ⚠️ 「字幕落在静音的帧占比」30% 左右是**正常**的 —— 连续字幕本来就跨句内停顿。
> 不要把它当缺陷指标，真正的门禁是上面那两条「== 0」。

### 5b. 强制单行：缩字号，不要折行

折行必然出现两行。改为**逐句按需缩字号**：

```python
def fit_single_line(text, base_px, max_w, min_scale=0.62):
    # 从 base_px 逐级下调（下限 max(30, base_px*min_scale)）
    # 仍放不下再逐级收紧字距 (1.0 → 0.5 → 0.0)
    # 都不行才退回折行（实务上不会触发）
```

实测：166 句全单行，仅 12 句缩了字号（62%~96%），最长 46 字仍单行；
行高 49~65px，左右边距 ≥97px（1920 画布，安全线 1720）。
**并且缩字号后要按实际字体重算行高**，否则垂直定位会漂。

### 5c. 去重口径：判「逐字相同」，不要判「包含」

若标题是从**本镜旁白里提炼的关键短语**（「孤证」「语多微文」这类），
它与完整句同屏是**设计意图**，不是 bug。

**真正要抑制的只有两种**：
1. 标题核心字串 == 同镜某一句字幕的核心字串；
2. 标题核心字串 == 同镜**连续 2 句及以上**字幕核心字串的拼接
   （例：「真相不在正史里。在缝里。」= 末两句拼接 —— 只判精确相等会漏掉这种）。

> 用「包含关系」会误伤十几镜：标题题「孤证」，字幕里出现「孤证」就被抑制，把设计意图当缺陷。

比对前先剥掉所有标点与空白（`，。、；：？！「」（）“”—…`），只留汉字/字母/数字。

**验收**：抑制镜的标题区高频能量应回落到背景水平，对照镜应明显更高。

### 5d. 分区：标题块与底部字幕的净空要量化

标题块左上、主字幕底部居中时，先算两者 bbox：
`标题块 max_y` 与 `主字幕 min_y` 之差就是净空。**要求 > 0 且留足余量**（实测取 611px）。
只靠目视「看着不重叠」在长片上不可靠 —— 某一镜多折一行就会撞。

折行用**动态规划**而非贪心：贪心会把「起居注」断成「起居 / 注」。
DP 的代价项：断在标点后加分、行填充均衡、禁止行首/行尾标点、禁止 <4 字的孤行。

---

## 6. 出片前 QA 清单

**机器可判（写脚本，必须全过）**
- [ ] 时长 == 音频时长 ±0.05s
- [ ] 分辨率 / 帧率 / 编码（v: h264, a: aac + 采样率 + 声道）
- [ ] `nb_frames` == 计划帧数 ±1
- [ ] 字幕事件数、覆盖帧数 == 总帧数（无空隙、无重叠）
- [ ] **音频结束后残留字幕帧 == 0**（防「音频讲完字幕还在滚」）
- [ ] **有声却无字幕的帧 == 0**
- [ ] **每字时长 min/中位/max 落在物理合理带**
- [ ] **折两行 == 0**（逐句量字幕区行带数；注意标题块自带一行，须按 y 区间裁掉）
- [ ] 最宽字幕左右边距 ≥ 60px（防贴边/裁切）
- [ ] 推镜生效（缩放搜索相关系数 > 无缩放基线）
- [ ] 抖动残差 std < 0.10px
- [ ] 水印响应残留低于阈值
- [ ] 扫描线伪影：判废命中数 == 0（或已全部修补）

**人眼必看（机器不可替代）**
- [ ] 转场中点抽帧 ≥4 处：字幕/标题不叠影
- [ ] 标题区与字幕区零重叠（≥8 镜抽样）
- [ ] 全片等距抽 12 帧通看：色调连贯、无水印、无新伪影
- [ ] 首帧、末帧完整
- [ ] 完整看一遍

## 7. 环境注意（Windows）

### 7a. `cv2.imwrite` 静默失败 —— 一律改用 imencode

**本机 Python 环境下 `cv2.imwrite()` 会返回 `False` 且文件不落盘**，
且**不限于非 ASCII 路径** —— 同样是 `review/` 这个纯 ASCII 相对路径，
`np.full((470,1800,3),16,np.uint8)` 能写成功，而绘制过的同尺寸数组就写失败。
`cv2.imencode('.png', img)[1].tobytes()` + `write_bytes()` 则**始终正常**。

```python
def save_png(img, path):
    path.parent.mkdir(parents=True, exist_ok=True)
    ok, buf = cv2.imencode(".png", img)
    if not ok:
        raise RuntimeError(f"PNG 编码失败: {path}")
    path.write_bytes(buf.tobytes())
    if not path.exists() or path.stat().st_size == 0:   # 落盘断言，别信返回值
        raise RuntimeError(f"PNG 落盘失败: {path}")
```

> **教训**：写图后一定要 `assert path.exists()`。`imwrite` 的返回值在这台机器上不可信，
> 曾因此浪费多轮排查 —— 脚本一路打印「成功」，磁盘上什么都没有。

### 7b. `Path(__file__).resolve().parent` 不是项目根

脚本放 `tools/qc/` 时，`.parent` 解析到 `tools/qc/`，出图会悄悄落到
`tools/qc/review/…`。项目根用 `Path(__file__).resolve().parents[2]`。
**出图后立刻打印绝对路径 + exists + 文件大小**，别只打印相对路径。

### 7c. `cv2.putText` 不能渲染中文

Hershey 字体只有 ASCII。传中文会画出乱码方块/问号。
核验图（核验板、波形图、泳道图）上的**所有标注一律用英文**；
真需要中文就换成 PIL + 中文字体（本机无宋体，只有仿宋 `simfang` / 楷体 `simkai`）。

### 7d. OpenCV 颜色是 BGR

想要橙色写 `(72, 140, 235)`；写成 `(200, 120, 72)` 出来是**蓝色**。

### 7e. 其他

- ffmpeg 路径参数加引号即可，但中间文件尽量放 ASCII 目录。
- **中文/长滤镜图**：拆到 `-filter_complex_script` 文件里（用 `;\n` 连接），
  别塞命令行（Windows 命令行长度上限会截断）。
- 中间产物目录（段文件、字幕 PNG、字幕轨 mov）**体积可达数十 GB**，
  出片后确认再清；母版和报告另存。

