2026 AI 短劇本地化完整指南:避開我踩過的 47 個坑

寫給:要動手把家裡 5060 Ti / 4090 / Mac M-series 變成本地 AI 短劇工廠的工程師。這不是「五分鐘上手」的入門文,是 18 天 17 個版本血淚換來的步驟、程式碼、避坑清單。

本文不講願景、不講商業模式、不寫心路歷程。要那些的請去看本系列 A 版(產業綜述)跟 B 版(敘事篇)。

這篇只做一件事:把你從零帶到能在自己 PC 上跑出一支 17.7 分鐘的本地 AI 短劇,並告訴你 47 個我已經幫你踩好的坑。

讀者要的不是「我做出來了」,是「複製這個流程到我自己的 PC,會在哪一步炸掉」。所以本文每章都是 5 段制:目標 → 前置條件 → 步驟 → 驗證 → 常見錯誤。

附錄裡 47 個坑全部編號連續、每個 50-200 字、症狀 / 根因 / 解法 / 預防四段制,配合一張 cheatsheet 表格。出事的時候 Ctrl-F 搜症狀就能定位。


第 1 章:概覽:本地 AI 短劇 stack 全景

1.1 目標

讀完本章你會知道:

  1. 一支本地 AI 短劇從劇本到 publish 經過哪幾個模型、幾條 pipeline
  2. 哪些環節必須本地跑、哪些可以丟雲端
  3. 整個 stack 的硬體、軟體、儲存最小可運作配置(minimum viable stack)
  4. 18 天 17 版迭代教給我們的「本地天花板」在哪裡

1.2 前置條件

不需要任何前置條件。本章是地圖,後面九章是各區域的細節。

1.3 步驟:認識八層 stack

本地 AI 短劇 stack 由八層組成,從下到上:

┌─────────────────────────────────────────────────┐
│ Layer 8 │ Publish        │ YouTube / TikTok    │
│ Layer 7 │ Post-Production│ ffmpeg / CapCut     │
│ Layer 6 │ Subtitle / BGM │ SRT / .ass / 音樂   │
│ Layer 5 │ Lip-sync       │ MuseTalk / LatentSync│
│ Layer 4 │ Voice          │ Edge TTS / F5-TTS   │
│ Layer 3 │ Video          │ Wan22 5B / LTX       │
│ Layer 2 │ Image / Anchor │ Flux + LoRA / SDXL  │
│ Layer 1 │ Script         │ Ollama qwen3:14b    │
└─────────────────────────────────────────────────┘
   底層:WSL2 + ComfyUI portable + NVIDIA Driver

每一層都有可替換的競品,每一層都有自己的 VRAM / 時間 / 品質曲線。本文重點在 Layer 1 到 Layer 7,Layer 8 屬於發布領域,不在本文範圍。

1.4 全景 mermaid 架構圖

graph TD
    A[劇本 JSON] --> B{shot_type 分類}
    B -->|cutaway| C1[Flux 單圖]
    B -->|static_pose| C2[Flux + LoRA + Ken Burns]
    B -->|talking_head| C3[Flux + LoRA]
    B -->|action| C4[Flux + LoRA → Anchor]

    C1 --> D1[ffmpeg loop 2-3 sec]
    C2 --> D2[ffmpeg zoompan 2-3 sec]
    C3 --> E1[Edge TTS / F5-TTS]
    C4 --> F1[Wan22 5B i2v 81 frame]

    E1 --> G1[MuseTalk 256 對嘴]
    G1 --> H1[GFPGAN restore]

    D1 --> I[shot mp4]
    D2 --> I
    H1 --> I
    F1 --> I

    I --> J[ffmpeg concat]
    J --> K[字幕燒入 .ass]
    K --> L[BGM 混音 -22dB]
    L --> M[final ep mp4]

1.5 18 天 17 版的迭代里程碑

階段 版本 學到的事
試水 v1.7 GGUF Wan22 14B 量化到 Q4_K_M 仍在 5060 Ti 16GB OOM
探路 v4-v6 Sniper Flux+ReActor+GFPGAN+YOLOv5l 才能鎖住臉
對嘴 v7 LivePortrait LP 不適合戲劇強表情
baseline v8 Wav2Lip 96² 對嘴可用但 base 重煉
翻修 v9.1 GFPGAN 局部修復層救回 Wav2Lip 品質
替代 v10 LatentSync face-on 純特寫可用、複雜構圖嘴融化
走錯 v11 PuLID 跟最新 ComfyUI 不兼容
失敗 v12 五版 Wan22 5B 沒有真 i2v 鎖首幀 node
突破 v13 MuseTalk face_alignment 68 patch 跑通 256²
量產 v14 450 shot 21 hr 出 17.7 分鐘,用戶 7 項批評
修補 v15 SSML+vf smoke only 就轉動漫
轉向 v16 NoobAI XL 50 角色批 460 shot 翻車
認輸 v17 10 sec 5060 Ti 16GB 已天花板

1.6 驗證

讀完本章你應該能畫出下面這張流程圖,不能畫的話請回到 1.4 重看:

  • 一個 cutaway shot 只需要哪些 layer?(答:1, 2, 7)
  • 一個 talking_head shot 需要哪些 layer?(答:1, 2, 4, 5, 6, 7)
  • 一個 action shot 需要哪些 layer?(答:1, 2, 3, 6, 7)

1.7 常見錯誤

  • 錯把 Layer 3(Video)當所有 shot 必須:80% 短劇 shot 是 cutaway / static / talking head,不需要 i2v。Wan22 5B 一支 81 frame 跑 6-7 分鐘,全部 shot 都用 i2v 一集要 30+ 小時。
  • 忽略 Layer 1(劇本)的時間成本:Ollama qwen3:14b 生 32-shot 劇本要 8-15 分鐘,加上人工修改可能 1 小時。預期就好。
  • 跳過 Layer 5(對嘴)直接配音:聲音對不上嘴會立刻被觀眾辨識為「AI 仿冒」,跳這層等於白做。

1.8 八層 stack 各自的雲端替代品

如果某一層你不想本地跑,下面是雲端替代品對照:

Layer 本地工具 雲端替代 替代後省的時間 / 成本
Layer 1 劇本 Ollama qwen3:14b Claude / Gemini API 8 min → 30 sec
Layer 2 圖像 Flux + LoRA Dreamina / Midjourney 38 sec → 8 sec
Layer 3 影片 Wan22 5B Kling / Runway 6 min → 1 min
Layer 4 配音 F5-TTS ElevenLabs / Azure premium 18 sec → 3 sec
Layer 5 對嘴 MuseTalk HeyGen / Synthesia 28 sec → 即時
Layer 6 字幕 ffmpeg CapCut auto-caption 手寫 → 自動
Layer 7 後製 ffmpeg CapCut / Premiere 同等
Layer 8 publish 手動 Postiz / Buffer 同等

混合策略最務實:劇本 + 圖像 + 字幕本地,影片 + 對嘴上雲端。這樣硬體門檻只需 8GB VRAM 就能玩。

1.9 為什麼選擇本地 stack 的三個正當理由

雖然本文最終建議轉雲端,但本地仍有不可取代的場景:

  1. 隱私需求:劇本內容涉及商業機密、未公開 IP、私人故事,不能丟到 OpenAI/Anthropic 服務端
  2. 客製需求:需要自己訓練的特定角色 LoRA(例如真人代言人、特定動漫角色),雲端不支援
  3. 學習需求:想理解整套 AI stack 的內部機制,本地是最好的學習場域

如果以上三個都不沾邊,沒必要折磨自己,直接上 Dreamina / Kling。

1.10 各層的時間 / 成本 / 風險矩陣

每一層 stack 跑一次的時間與失敗成本:

Layer 平均時間 失敗成本 風險點
1 劇本 8 min 低(重跑快) LLM 偶爾跑掉 JSON 格式
2 圖像 38 sec 低 角色臉漂、構圖偏
3 影片 6 min 高(要重跑 6 min) 第 30 幀後 drift、整體抖動
4 配音 2 sec 低 rate 超出範圍卡頓
5 對嘴 28 sec 中(重跑要 28 sec) bbox 對不準、嘴不動
6 字幕 5 sec 低 字幕時間軸錯
7 後製 30 sec 低(重跑快) ffmpeg filter 語法錯
8 publish 手動 中 平台規格不符被拒

設計優先級:時間長 + 失敗成本高的 Layer 要先驗證(Layer 3 跟 Layer 5)。

1.11 端到端 pipeline 跑一次的時間軸

下面是一個 talking_head shot 從劇本到 mp4 的完整時間軸(5060 Ti 16GB 真實量測):

時間軸 →
0     30s   60s   90s   120s  150s  180s  210s
│     │     │     │     │     │     │     │
├─ Layer 1 (劇本,假設已有)            ─┤
│                                       │
│  ├─ Layer 2 Flux + LoRA anchor 38s ──┤
│                                       │
│      ├─ ffmpeg base mp4 5s ──┤        │
│                              │        │
│         ├─ Edge TTS 2s ──┤   │        │
│                          │   │        │
│            ├─ MuseTalk + face_align 35s ──────┤
│                                                │
│               ├─ GFPGAN restore 15s ──┤       │
│                                       │       │
│                  ├─ 字幕 + BGM mix 8s ──┤    │
│                                              │
│                     ├─ final mux 5s ──┤      │
│                                              │
└─────────────────────────── total 113s ──────┘

一個 talking_head shot 約 113 sec ≈ 2 分鐘。但量產時實際 3 min(含 retry / wait / scp)。

1.12 本文章節依賴關係

第 1 章(概覽)
    │
    ├── 第 2 章(硬體)─────┐
    │                       │
    │                       ↓
    └── 第 3 章(WSL+ComfyUI)
            │
            ├──→ 第 4 章(Flux LoRA 訓練)
            │
            ├──→ 第 5 章(對嘴三選一)
            │
            ├──→ 第 6 章(Wan22 影片)
            │
            └──→ 第 7 章(TTS 配音)
                    │
                    ↓
                第 8 章(dispatcher 量產)
                    │
                    ↓
                第 9 章(後製運鏡)
                    │
                    ↓
                第 10 章(v15 修補)
                    │
                    ↓
                第 11 章(47 坑附錄)

可以按順序讀,也可以遇到問題時跳到對應章節。


第 2 章:硬體選型:5060 Ti 16GB / 24GB / Mac M-series 取捨指南

2.1 目標

幫你在動工前選對 GPU。錯選硬體會讓你卡在某一層永遠跨不過去(例如 16GB 永遠跑不動 Wan22 14B)。

2.2 前置條件

對「VRAM、unified memory、Blackwell / Ada Lovelace / Hopper」這幾個詞有基本認識。沒有的話 wiki 一下 NVIDIA architecture roadmap。

2.3 步驟:根據目標選 GPU

Step 1:確定你要的最終品質檔次

檔次 範例 對應品質 必須跑的最重模型
入門試水 個人 demo / vlog 60 分 Flux fp8 + Wav2Lip
自媒體量產 30 集連續劇 70 分 Flux fp8 + MuseTalk + Wan22 5B
半商業級 直播間/帶貨/短劇平台 85 分 Flux fp16 + MuseTalk 512 + Wan22 14B
商業級 廣告/院線預告 95 分 雲端,不要本地跑

Step 2:對照下面表格選 GPU

GPU VRAM 入門 自媒體 半商業 月電費*
RTX 3060 12GB 12GB ✓ △ ✗ NT$400
RTX 4060 Ti 16GB 16GB ✓ ✓ ✗ NT$500
RTX 5060 Ti 16GB 16GB ✓ ✓ ✗ NT$650
RTX 4070 Ti Super 16GB 16GB ✓ ✓ △ NT$800
RTX 4090 24GB 24GB ✓ ✓ ✓ NT$1,200
RTX 5090 32GB 32GB ✓ ✓ ✓✓ NT$1,500
Mac M2 Pro 16GB 16GB unified ✓ △ ✗ NT$200
Mac M3 Max 64GB 64GB unified ✓ ✓ △ NT$300
Mac M4 Max 128GB 128GB unified ✓ ✓ △ NT$400

*月電費:以 12 小時/天運轉、台電住宅電價 NT$4.5/度估算

2.4 benchmark:5060 Ti 16GB 真實實測數字

下面是同一塊 5060 Ti 16GB(Blackwell sm_120, CUDA 12.8)的真實 wall-clock:

任務 模型 VRAM 占用 一次耗時
Flux fp8 720x1280 30 step flux1-dev fp8 11.2 GB 32 sec
Flux fp8 + LoRA 720x1280 + soxw_zh50 11.8 GB 38 sec
Wan22 5B ti2v 720x1280 81 frame wan2.2_ti2v_5B_fp16 14.9 GB 6 min 12 sec
Wan22 14B GGUF Q4_K_M 試跑 OOM 第 1 步 失敗
MuseTalk V1.5 256² 60 frame musetalk v15 6.5 GB 28 sec
LatentSync 1.5 60 frame latentsync 7.8 GB 42 sec
Wav2Lip 96² 60 frame wav2lip_gan 2.1 GB 9 sec
GFPGAN v1.4 only_center_face GFPGANv1.4 1.8 GB 6 sec
ReActor face swap inswapper_128 1.2 GB 4 sec
Edge TTS 30 sec audio online 0 GB 2 sec
F5-TTS voice clone 10 sec f5-tts 4.6 GB 18 sec

這張表的重點:5060 Ti 16GB 跑單一最重的 Wan22 5B 已經占 14.9 GB,沒有同時跑兩個重模型的空間。你的 dispatcher 必須序列化,不能並行。

2.5 為什麼選 5060 Ti 16GB 而不選 4090 24GB

  • 價格差距:5060 Ti 16GB ≈ NT$15,000,4090 24GB ≈ NT$70,000,相差 4.6 倍
  • VRAM 差距 50%:跑 Wan22 5B 兩者都行;跑 Wan22 14B 兩者都不行
  • 電費差距 2 倍:4090 滿載 450W,5060 Ti 滿載 180W
  • 時間差距 2 倍:4090 跑 Wan22 5B 約 3 分鐘,5060 Ti 約 6 分鐘

結論:自媒體量產等級的關鍵 GPU 是 4070 Ti Super 16GB 或 4090 24GB。5060 Ti 16GB 是「最便宜的能玩」的入場券,但會在很多細節上撞牆(後文 47 坑會反覆出現)。

2.6 Mac M-series unified memory 的真實表現

Mac 的 unified memory 跟 NVIDIA 的 VRAM 不能 1:1 對比:

  • 優點:M3 Max 64GB 可以跑 Wan22 14B fp16(因為 unified memory 對 LLM 友善)
  • 缺點:MPS backend 對 ComfyUI 很多自訂 node 不支援(例如 MuseTalk 完全不能跑、Wan22 跑不了 ti2v)
  • 適用場景:Mac 適合當 orchestrator(劇本生成 + 後製 ffmpeg + scp 派工),不適合當 GPU worker

我們最終的部署:Mac M4 16GB 當 orchestrator + PC 5060 Ti 16GB 當 GPU worker,兩台用 SSH + scp 互通。

2.7 推薦組合

預算 配置 適用
NT$20,000 二手 4060 16GB + 32GB RAM 入門試水
NT$40,000 全新 5060 Ti 16GB + 64GB RAM 自媒體連續劇(本文設定)
NT$80,000 全新 4090 24GB + 128GB RAM 半商業級
NT$200,000+ 5090 32GB + 256GB RAM + 多卡 真正接案

2.8 驗證

裝完硬體後跑這三個指令確認:

# 1. CUDA 版本與驅動
nvidia-smi
# 應該看到 Driver 13.x, CUDA 12.8+

# 2. PyTorch 認得 GPU
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
# 應該看到 True NVIDIA GeForce RTX xxxx

# 3. VRAM 真實可用容量
nvidia-smi --query-gpu=memory.total,memory.free --format=csv
# total 應該 >= 16384 MiB, free 應該 >= 15000 MiB

2.9 常見錯誤

  • 5060 Ti 跑 Blackwell sm_120,PyTorch 必須 >= 2.6:早期版本沒有 sm_120 編譯目標,會 fallback CPU。坑 #3 詳述。
  • 以為 fp8 model 永遠占一半 VRAM:fp8 只省 weight,activation 跟 KV cache 仍是 fp16,省的不多。
  • 以為買 24GB 就能跑 Wan22 14B:14B fp16 weight 是 28 GB,要 32GB+ 才有空間。

2.10 CPU / RAM / Storage 的搭配建議

GPU 不是全部。本地短劇 pipeline 對其他元件也有要求:

CPU 要求:

CPU 適用 為什麼
Intel i5-12400 / Ryzen 5 5600X 入門 ffmpeg / ComfyUI 主執行緒夠用
Intel i7-13700K / Ryzen 7 7700X 自媒體量產 並行跑 LLM + ffmpeg + GPU dispatcher
Intel i9-13900K / Ryzen 9 7950X 半商業 LoRA 訓練 + 大量並行 ffmpeg
Threadripper / Xeon 商業 多卡 + 多項目並行

RAM 要求:

RAM 適用
16 GB 勉強跑 ComfyUI 單一 workflow
32 GB 入門量產,WSL2 可分 24 GB
64 GB 自媒體連續劇,WSL2 配 48 GB + Windows 16 GB
128 GB 半商業,可同時跑訓練 + inference

Storage:

用途 建議容量 速度
系統碟 256 GB NVMe SSD 3000 MB/s+
Models / Datasets 2 TB NVMe SSD 3000 MB/s+
影片產出 / 備份 4-8 TB HDD 任意

為什麼 models 要 NVMe:Flux fp8 11.9 GB 載入時,SATA SSD 要 50 sec,NVMe 只要 4 sec。一天跑 100 次 inference 差距很大。

2.11 5060 Ti 16GB 的工程取捨清單

如果你決定買 5060 Ti 16GB(本文設定),這些事情你必須接受:

  • 無法跑 Wan22 14B:永遠別想
  • 無法跑 MuseTalk 512²:永遠 256²
  • 無法並行兩個重模型:dispatcher 必須序列化
  • 第 30 幀後 character drift:5B 限制
  • Wan22 cfg 不能拉太高:6.5 是上限
  • LoRA rank 不能太大:rank 4 是平衡點

如果以上任何一點是 dealbreaker,請預算升級到 4090 24GB(NT$70,000)或 5090 32GB(NT$100,000+)。

2.12 二手 GPU 採購注意事項

很多人問「能買二手 3090 24GB 嗎?」答案是:

  • 二手 3090 24GB(NT$25,000):CP 值最高,但風險:
  • 礦卡風險(散熱片膏乾、風扇磨損)
  • 沒保固
  • PCIe 4.0 / 缺 DLSS 3
  • 二手 4090 24GB(NT$50,000):保固期內二手最划算
  • 二手 5060 Ti(不存在):太新,沒二手市場

買二手三件事必做:

  1. 跑 nvidia-smi --query-gpu=temperature.gpu --format=csv -l 1 滿載 30 分鐘看溫度(>80°C 表示散熱壞)
  2. 跑 furmark 30 分鐘確認穩定不藍屏
  3. 檢查 fan RPM(>2500 表示風扇老化)

第 3 章:WSL2 + ComfyUI portable 安裝完整 SOP

3.1 目標

把 Windows 11 PC 配成可以從 Mac SSH 進 WSL Ubuntu、跑 ComfyUI portable、Mac 從 28189 port 連到 ComfyUI 8188 的完整工作環境。

3.2 前置條件

  • Windows 11 22H2+
  • 已啟用 WSL2 + 安裝 Ubuntu 22.04
  • PC IP 在 LAN 固定(例如 192.168.0.15)
  • Mac 已產生 ssh key 並可以推到 PC

3.3 步驟

Step 1:配置 WSL2 .wslconfig(防 idle terminate)

在 Windows %USERPROFILE%\.wslconfig 寫:

[wsl2]
memory=112GB
processors=16
swap=16GB
localhostForwarding=true
vmIdleTimeout=-1
nestedVirtualization=false

重點是 vmIdleTimeout=-1。預設 WSL2 每 28 秒 idle 會把 VM terminate 掉,跑 30 小時的 LoRA 訓練會直接斷。設 -1 是永不 idle。詳見坑 #1。

寫完後在 PowerShell(管理員)執行:

wsl --shutdown

讓設定生效。

Step 2:WSL2 內部 IP 取得

WSL 啟動後在 WSL Ubuntu 內:

ip addr show eth0 | grep "inet " | awk '{print $2}' | cut -d/ -f1
# 範例輸出:172.27.230.199

記下這個 IP(每次 WSL 重啟可能變,所以後面要設動態抓)。

Step 3:在 Windows 設定 portproxy(NAT → WSL)

PowerShell(管理員)執行:

# 抓 WSL IP
$wslip = wsl -d Ubuntu -- bash -c "ip addr show eth0 | grep 'inet ' | awk '{print `$2}' | cut -d/ -f1"
echo "WSL IP: $wslip"

# 把 LAN 8188 轉發到 WSL 8188
netsh interface portproxy add v4tov4 listenport=8188 listenaddress=0.0.0.0 connectport=8188 connectaddress=$wslip

# 確認規則
netsh interface portproxy show v4tov4

注意:不要用 8189。8189 在 PC 上被 iphlpsvc(Windows IP Helper service)的 portproxy 自己占住,跟 8188 衝突。詳見坑 #10。

第二個雷:portproxy 的 connectaddress 不能填 192.168.0.15(PC 自己的 LAN IP),會 self-loop empty reply。必須填 WSL 內部 IP 172.27.230.199。詳見坑 #11。

Step 4:Windows 防火牆放行 8188

New-NetFirewallRule -DisplayName "WSL ComfyUI 8188" -Direction Inbound -LocalPort 8188 -Protocol TCP -Action Allow

Step 5:在 WSL Ubuntu 裝 ComfyUI portable

# 別放在 /mnt/c/,9P 檔案系統慢 10 倍且 symlink 不被 Windows 認
cd /home/user
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI

# 用 portable Python 3.11,不要用 system Python
curl -L https://github.com/indygreg/python-build-standalone/releases/download/20250612/cpython-3.11.13+20250612-x86_64-unknown-linux-gnu-install_only.tar.gz | tar xz
export PATH=$PWD/python/bin:$PATH

# 裝依賴
python -m pip install --upgrade pip
python -m pip install torch==2.6.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
python -m pip install -r requirements.txt

為什麼裝在 /home/user/ 不裝在 /mnt/c/:詳見坑 #15。

# 把 models 目錄 symlink 到 Windows 共用磁碟(174GB 才不會 WSL 撐爆)
rm -rf /home/user/ComfyUI/models
ln -s /mnt/c/AI/models /home/user/ComfyUI/models

# 但 ComfyUI 本身執行檔絕對不要在 /mnt/c/
ls -lh /home/user/ComfyUI/models/checkpoints/

陷阱:WSL symlink 在 Windows 不被 follow。如果你想讓 ComfyUI 從 extra_model_paths.yaml 抓 Windows 路徑下的 models,必須走 yaml 配置,不能依賴 symlink。

# /home/user/ComfyUI/extra_model_paths.yaml
windows_models:
    base_path: /mnt/c/Users/user/ComfyUI/models/
    checkpoints: checkpoints/
    clip: clip/
    clip_vision: clip_vision/
    configs: configs/
    controlnet: controlnet/
    embeddings: embeddings/
    loras: loras/
    upscale_models: upscale_models/
    vae: vae/
    diffusion_models: diffusion_models/

Step 7:UTF-8 啟動腳本(防 cp950 殺訓練)

如果你打算從 Windows 那邊直接啟動 ComfyUI(非 SSH 模式),必須建一個 UTF-8 batch:

@echo off
chcp 65001 > nul
set PYTHONIOENCODING=utf-8
set PYTHONLEGACYWINDOWSSTDIO=utf-8
cd /d "C:\Users\user\ComfyUI_windows_portable"
python_embeded\python.exe -s ComfyUI\main.py --listen 0.0.0.0 --port 8188

存成 start_comfyui_utf8.bat。沒有 chcp 65001 跑訓練會在中文 caption 那一刻爆 UnicodeDecodeError。詳見坑 #29。

Step 8:從 WSL 真正 detach 啟動 ComfyUI(背景常駐)

nohup python /home/user/ComfyUI/main.py \
    --listen 0.0.0.0 \
    --port 8188 \
    --preview-method none \
    > /home/user/comfyui.log 2>&1 &

# 確認 listening
sleep 5
ss -tlnp | grep 8188

Step 9:Mac 端 SSH tunnel

從 Mac 把 PC 8188 mapping 到 Mac 28189:

# Mac terminal
ssh -L 28189:127.0.0.1:8188 -N -f user@192.168.0.15

-N 不執行命令、-f 背景。然後 Mac 瀏覽器打開 http://localhost:28189 就是 ComfyUI 介面。

Step 10:WSL keeper(防 28 秒 idle terminate 的最後保險)

即使設了 vmIdleTimeout=-1,某些 Windows 更新後會重置。永久解是 Mac 端開一條 SSH 跑 sleep infinity:

# Mac
nohup bash -c '
    echo "sleep 108000" | ssh -o ServerAliveInterval=15 user@192.168.0.15 "wsl -d Ubuntu -- bash"
' > /tmp/wsl_keeper.log 2>&1 &

這條會跑 30 小時,過程中 WSL 一定不會 terminate。

3.4 驗證

# 1. 從 Mac 直接打 ComfyUI 看版本
curl -s http://localhost:28189/system_stats | python -m json.tool | head -20
# 應該看到 comfyui_version, python_version, devices 列表

# 2. 從 Mac 列出 queue 狀態
curl -s http://localhost:28189/queue | python -m json.tool
# 應該看到 {"queue_running": [], "queue_pending": []}

# 3. WSL 還活著
ssh user@192.168.0.15 "wsl -d Ubuntu -- ps aux | grep ComfyUI | grep -v grep"
# 應該看到一條 python ComfyUI/main.py 進程

3.5 網路拓撲

┌─────────────┐         ┌─────────────────────────────────────┐
│   Mac M4    │         │              Windows 11 PC          │
│  (macOS)    │         │  ┌───────────────────────────────┐  │
│             │         │  │       WSL2 Ubuntu              │  │
│  localhost  │         │  │   IP: 172.27.230.199          │  │
│   :28189    │  SSH    │  │                                │  │
│      │      │ tunnel  │  │   ComfyUI :8188 (LISTEN)      │  │
│      └──────┼─────────┼──┼──────────┐                    │  │
│             │  port   │  │          │                    │  │
│             │ forward │  └──────────┼────────────────────┘  │
│             │         │             │                       │
│             │         │   ┌─────────▼──────────┐            │
│             │         │   │ Windows netsh      │            │
│             │         │   │ portproxy :8188    │            │
│             │         │   │ → 172.27.230.199   │            │
│             │         │   │       :8188        │            │
│             │         │   └────────────────────┘            │
└─────────────┘         └─────────────────────────────────────┘
   192.168.0.5            192.168.0.15 (LAN IP)

3.6 常見錯誤

  • 用 mirrored networkingMode:完全無法對外曝露 port,連 Windows 本機都打不到 WSL service。坑 #2 詳述,絕對不要用。
  • portproxy 沒設成 0.0.0.0:listenaddress 預設不是萬用,要明寫 0.0.0.0 才能 LAN 進來。
  • /mnt/c/ 安裝 ComfyUI:執行檔在 9P 上 import 速度慢 10 倍,啟動要 5+ 分鐘。
  • 沒裝 portable Python:用 Ubuntu apt 內建 Python 3.10 跑 PyTorch 2.6 會缺很多 C++ 依賴,編譯地獄。

3.7 ComfyUI custom_nodes 推薦清單

裝完 ComfyUI base 後,這些 custom_nodes 是本地短劇必裝:

cd /home/user/ComfyUI/custom_nodes

# 必裝
git clone https://github.com/ltdrdata/ComfyUI-Manager.git
git clone https://github.com/cubiq/ComfyUI_IPAdapter_plus.git
git clone https://github.com/kijai/ComfyUI-FluxTrainer.git
git clone https://github.com/kijai/ComfyUI-KJNodes.git
git clone https://github.com/kijai/ComfyUI-LivePortraitKJ.git
git clone https://github.com/Gourieff/comfyui-reactor-node.git
git clone https://github.com/Fannovel16/comfyui_controlnet_aux.git
git clone https://github.com/WASasquatch/was-node-suite-comfyui.git

# 影片生成
git clone https://github.com/kijai/ComfyUI-WanVideoWrapper.git

# 對嘴
git clone https://github.com/chflame163/ComfyUI_MuseTalk.git

# 上採樣 / restore
git clone https://github.com/ssitu/ComfyUI_UltimateSDUpscale.git
git clone https://github.com/Nourepide/ComfyUI-Allor.git

裝完每個 custom_node 都要:

cd <node_dir>
pip install -r requirements.txt 2>/dev/null

注意:FluxTrainer 跟 IPAdapter 的依賴會打架(前者要 transformers 4.57.x,後者偶爾要 4.58+)。鎖 transformers==4.57.6 兩者都跑得起來。

3.8 ComfyUI 的 8 個 launch flag 詳解

python main.py \
    --listen 0.0.0.0 \              # 對外監聽(不只 localhost)
    --port 8188 \                    # 監聽 port
    --preview-method none \          # 不存中間預覽圖(省 IO)
    --use-pytorch-cross-attention \  # 用 PyTorch 內建 attention(穩)
    --disable-cuda-malloc \          # 某些 RTX 30/40 系列需要(5060 Ti 可省)
    --highvram \                     # 強制把 model 留 VRAM 不 swap
    --bf16-vae \                     # VAE 用 bf16(5060 Ti 支援)
    --output-directory /home/user/comfyui_out

各 flag 的取捨:

Flag 開 關
--highvram 模型不 swap,速度快 占滿 VRAM,不能跑大 batch
--bf16-vae bf16 VAE 省 30% VRAM Pascal / Turing 卡不支援
--preview-method none 省 IO + 加速 沒中間預覽
--disable-xformers 不用 xformers 速度減 20%

5060 Ti 推薦:開 --highvram --bf16-vae --preview-method none。

3.9 ComfyUI HTTP API 速覽

ComfyUI 不只能用 GUI,全部功能都有 HTTP API。dispatcher 主要用這幾個:

import requests
import json

COMFY = "http://localhost:8188"

# 提交 workflow
def queue_prompt(workflow):
    payload = {"prompt": workflow, "client_id": "drama_factory"}
    r = requests.post(f"{COMFY}/prompt", json=payload)
    r.raise_for_status()
    return r.json()["prompt_id"]

# 查 queue 狀態
def get_queue():
    return requests.get(f"{COMFY}/queue").json()

# 查 history(含 output 路徑)
def get_history(prompt_id):
    return requests.get(f"{COMFY}/history/{prompt_id}").json()

# 等待完成
def wait_for_completion(prompt_id, timeout=1800):
    import time
    start = time.time()
    while time.time() - start < timeout:
        history = get_history(prompt_id)
        if prompt_id in history:
            return history[prompt_id]
        time.sleep(2)
    raise TimeoutError(f"Prompt {prompt_id} timeout after {timeout}s")

# 取得 output 圖片
def get_image(filename, subfolder="", folder_type="output"):
    params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
    return requests.get(f"{COMFY}/view", params=params).content

# 中斷正在跑的 task
def interrupt():
    requests.post(f"{COMFY}/interrupt")

# 清空 queue
def clear_queue():
    requests.post(f"{COMFY}/queue", json={"clear": True})

dispatcher 主迴圈基本就是這幾個 function 的組合。

3.10 SCP 跨機檔案傳輸 SOP

Mac orchestrator + PC GPU worker 模式下,每個 shot 要 scp 來回:

# Mac → PC(上傳 anchor / config)
scp /Users/jiahaolu/drama-factory/data/control/shot01.png \
    user@192.168.0.15:/mnt/c/Users/user/ComfyUI/input/

# PC → Mac(下載成品)
scp user@192.168.0.15:/mnt/c/Users/user/ComfyUI/output/shot01_00001_.png \
    /Users/jiahaolu/drama-factory/data/final/v14_ep1/

# 批次 rsync(大量檔案)
rsync -avz --progress \
    user@192.168.0.15:/mnt/c/Users/user/ComfyUI/output/ \
    /Users/jiahaolu/drama-factory/data/final/v14_ep1/

包成 Python helper:

import subprocess

PC_HOST = "user@192.168.0.15"
PC_INPUT = "/mnt/c/Users/user/ComfyUI/input"
PC_OUTPUT = "/mnt/c/Users/user/ComfyUI/output"

def scp_to_pc(local_path, remote_subdir=""):
    remote = f"{PC_HOST}:{PC_INPUT}/{remote_subdir}/"
    subprocess.run(["scp", str(local_path), remote], check=True)

def scp_from_pc(remote_filename, local_path):
    remote = f"{PC_HOST}:{PC_OUTPUT}/{remote_filename}"
    subprocess.run(["scp", remote, str(local_path)], check=True)

每次 scp 約 2-5 sec(取決於檔案大小 + 網路)。30 shot 量產光 scp 就要 10 分鐘,所以批次 rsync 一次完成比較好。


第 4 章:Flux LoRA 訓練:13 角色 30 hr 量產實戰

4.1 目標

訓練 13 個角色的 Flux LoRA,每人 30 張 anchor,rank 4,100 step,全套 30 小時內跑完。角色 trigger 命名要規範。

4.2 前置條件

  • ComfyUI portable 已裝(第 3 章完成)
  • 已下載 Flux fp8 single-file flux1-dev-fp8.safetensors 11.9 GB
  • 已下載 T5 encoder t5xxl_fp16.safetensors 9.8 GB
  • 已下載 VAE ae.safetensors 335 MB
  • 已裝 ComfyUI-FluxTrainer custom node(kijai/ComfyUI-FluxTrainer)

4.3 步驟

Step 1:裝 ComfyUI-FluxTrainer(5 連坑解法)

cd /home/user/ComfyUI/custom_nodes
git clone https://github.com/kijai/ComfyUI-FluxTrainer.git
cd ComfyUI-FluxTrainer

# 坑:requirements.txt 寫死 numpy<=1.26.4,跟 portable Py3.13 衝突
# 解:先拿掉
sed -i '/numpy/d' requirements.txt

# 安裝
python -m pip install -r requirements.txt

# 坑:transformers 5.x 刪了 sd-scripts 要的 CLIPFeatureExtractor
# 解:降版到 4.57.6
python -m pip install transformers==4.57.6

# 坑:cp950 console 殺訓練(如果在 Windows native 跑)
# 解:強制 UTF-8 環境變數
export PYTHONIOENCODING=utf-8
export PYTHONLEGACYWINDOWSSTDIO=utf-8

Step 2:準備訓練資料(30 張 anchor)

每個角色準備 30 張 1024x1024 的 anchor 圖:

# 訓練資料目錄結構
/home/user/lora_train/
├── soxw_zh50/                    # 角色 trigger 名
│   ├── img/                       # 30 張 PNG
│   │   ├── soxw_001.png
│   │   ├── soxw_001.txt           # 對應 caption
│   │   ├── soxw_002.png
│   │   ├── soxw_002.txt
│   │   └── ...
│   └── log/

每張圖的 .txt caption 範例:

soxw_zh50, 50 years old elegant Taiwanese woman, salt-and-pepper hair with subtle silver streaks, mature features with fine eye wrinkles, looking at camera, sitting at office desk, warm office lighting

Trigger 必須是第一個 token(soxw_zh50,)。Flux 對 trigger 在句首敏感。

Step 3:13 角色 trigger 命名規則

角色 trigger 年齡碼 拼音碼
蘇曉雯 soxw_zh50 50 蘇曉雯
傅景行 fujx_zh48 48 傅景行
陳俊豪 chenjh_zh53 53 陳俊豪
林雅淇 linyq_zh28 28 林雅淇
陳怡蘭 chenyl_zh23 23 陳怡蘭
林明 lim_zh35 35 林明
... + 7 配角

命名規則:<姓拼音首字+名拼音首字>_<語言><年齡>,全小寫,全部用底線。

為什麼這樣命名:

  • 唯一性:避開 Flux 既有 token(例如別取 mary、tom 這種常見名)
  • 可讀性:開發者一眼看出年齡與性別
  • 長度:保持在 12 字以內,避免 tokenizer 切碎

Step 4:FluxTrainer workflow(rank 4 / 100 step)

ComfyUI workflow 摘要:

{
  "10": {
    "class_type": "InitFluxLoRATraining",
    "inputs": {
      "transformer": "flux1-dev-fp8.safetensors",
      "clip_l": "clip_l.safetensors",
      "t5xxl": "t5xxl_fp16.safetensors",
      "vae": "ae.safetensors",
      "output_name": "soxw_zh50_v1_100step_rank4_fp16",
      "output_dir": "/mnt/c/Users/user/ComfyUI/models/loras",
      "dataset_path": "/home/user/lora_train/soxw_zh50",
      "max_train_steps": 100,
      "learning_rate": 1e-4,
      "network_dim": 4,
      "network_alpha": 4,
      "save_every_n_steps": 50,
      "mixed_precision": "fp16",
      "cache_latents": true,
      "cache_text_encoder_outputs": true,
      "caption_dropout_rate": 0.0
    }
  },
  "11": {
    "class_type": "FluxTrainLoop",
    "inputs": {
      "network_trainer": ["10", 0],
      "steps": 100
    }
  },
  "12": {
    "class_type": "FluxTrainEnd",
    "inputs": {
      "network_trainer": ["11", 0],
      "save_state": false
    }
  }
}

caption_dropout_rate 必須是 0.0。預設 0.1 會在 10% 機率丟掉 trigger,導致 trigger 沒鎖住。詳見坑 #20。

Step 5:cache miss vs cache reuse 速度差距

場景 速度 原因
第一個角色 100 step 197s/step latent 沒 cache,每 step 重算
第二個角色 100 step 70s/step latent cache 已建,可重用
13 角色全跑(連跑) 70s/step 全部 cache reuse

13 角色 100 step 總時間:

第 1 角色:197 × 100 = 19,700 sec ≈ 5.5 hr
第 2-13 角色:70 × 100 × 12 = 84,000 sec ≈ 23.3 hr
合計:28.8 hr ≈ 29-30 hr

所以宣傳「13 角色 30 hr 量產」是真的,但前提是連跑讓 cache 不釋放。中間關機就要重算。

Step 6:sequential 排隊 script

#!/usr/bin/env python3
"""train_13_loras.py — 13 角色 LoRA 順序訓練,progress 寫檔,可中斷 resume"""
import json
import subprocess
import time
from pathlib import Path

CHARS = [
    "soxw_zh50", "fujx_zh48", "chenjh_zh53", "linyq_zh28",
    "chenyl_zh23", "lim_zh35", "huangzy_zh40", "linwr_zh32",
    "zhouqiang_zh38", "zhanglt_zh45", "wujm_zh29", "liyl_zh33", "wanghc_zh51",
]

PROGRESS_FILE = Path("/home/user/lora_train_progress.json")
COMFY_URL = "http://localhost:8188"

def load_progress():
    if PROGRESS_FILE.exists():
        return json.loads(PROGRESS_FILE.read_text())
    return {"done": [], "failed": []}

def save_progress(p):
    PROGRESS_FILE.write_text(json.dumps(p, indent=2))

def train_one(char):
    """submit workflow 並等待完成"""
    wf_path = Path(f"/home/user/lora_train/{char}/workflow.json")
    wf = json.loads(wf_path.read_text())
    # ... 略:submit + poll
    return True  # or False if failed

def main():
    p = load_progress()
    for char in CHARS:
        if char in p["done"]:
            print(f"[skip] {char} already done")
            continue
        print(f"[start] {char}")
        ok = train_one(char)
        if ok:
            p["done"].append(char)
        else:
            p["failed"].append(char)
        save_progress(p)
        time.sleep(60)  # 給 GPU 冷卻

if __name__ == "__main__":
    main()

進度檔長這樣:

{
  "done": ["soxw_zh50", "fujx_zh48", "chenjh_zh53"],
  "failed": [],
  "last_update": "2026-05-19T03:45:12"
}

中斷後 resume 直接重跑 script,已 done 的會 skip。

4.4 驗證

# 1. LoRA 檔案大小 ~38 MB (rank 4 fp16)
ls -lh /mnt/c/Users/user/ComfyUI/models/loras/soxw_zh50_v1_100step_rank4_fp16-step00100.safetensors
# 應該看到 ~38M

# 2. 跑一張 anchor 確認 trigger 鎖住
curl -X POST http://localhost:28189/prompt -H "Content-Type: application/json" -d @test_lora.json
# 等 40 秒後檢查 output,臉部特徵要對得上

# 3. loss 曲線確認沒過擬合
grep "loss" /home/user/lora_train/soxw_zh50/log/*.log | tail -20
# 應該看到 loss 從 0.36 降到 0.26 左右,不應該降到 0.05(過擬合)

4.5 常見錯誤

  • caption 沒寫 trigger:trigger 學不到,模型完全不認得。
  • 30 步就停:銀絲、皺紋等細節學不到,臉看起來年輕 10 歲。30 步只是 smoke test,正式要 100 步。
  • 100 步用 rank 8+:rank 4 已經夠用,rank 8 容易過擬合到背景,跨場景時人物會帶舊背景。
  • 訓練中途關 ComfyUI:latent cache 會丟,下次訓練要重算 197s/step。

4.6 訓練資料蒐集 SOP

每個角色 30 張 anchor 怎麼來?三個來源:

來源 1:請 Flux 生(推薦)

用一個 base prompt 跑 30 個不同 seed:

import requests, json, random

BASE_PROMPT = (
    "professional portrait photography, "
    "50 years old elegant Taiwanese woman, "
    "salt-and-pepper hair with subtle silver streaks, "
    "fine eye wrinkles, mature features, "
    "looking at camera, neutral expression, "
    "studio lighting, 4k, photorealistic"
)

VARIATIONS = [
    "sitting at office desk",
    "standing in park",
    "in kitchen cooking",
    "on couch reading",
    "walking on street",
    # ... 30 個場景變化
]

for i, scene in enumerate(VARIATIONS):
    prompt = f"{BASE_PROMPT}, {scene}"
    seed = random.randint(0, 2**32)
    workflow = build_flux_workflow(prompt, seed, f"soxw_anchor_{i:03d}")
    queue_prompt(workflow)

跑出 30 張後人工挑 30 張最像目標的(眼睛、五官、年齡)。可以多生 60-90 張再篩。

來源 2:真人照片(有版權注意)

如果是訓練自己 / 同意的真人:

  • 30 張不同角度(正面 10 / 側面 30° 8 / 45° 6 / 90° 3 / 抬頭低頭 3)
  • 不同光源(晨光 / 室內 / 夕陽 / 室內燈)
  • 不同表情(中性 15 / 微笑 8 / 嚴肅 4 / 驚訝 3)
  • 解析度至少 1024x1024

來源 3:影視截圖(風險高)

從電視劇/電影截圖 30 張同一角色。法律風險高,僅供學習,商業使用會被告。

4.7 caption 標註自動化

30 張一張一張寫 caption 很累。用 Florence-2 / BLIP-2 自動標註:

from transformers import AutoProcessor, AutoModelForCausalLM
import torch
from PIL import Image
from pathlib import Path

device = "cuda"
model_id = "microsoft/Florence-2-large"
model = AutoModelForCausalLM.from_pretrained(model_id, trust_remote_code=True).to(device)
processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)

TRIGGER = "soxw_zh50"
AGE_DESC = "50 years old elegant Taiwanese woman, salt-and-pepper hair, fine eye wrinkles"

def caption_one(image_path):
    image = Image.open(image_path).convert("RGB")
    inputs = processor(text="<MORE_DETAILED_CAPTION>", images=image, return_tensors="pt").to(device)
    with torch.no_grad():
        generated_ids = model.generate(
            input_ids=inputs["input_ids"],
            pixel_values=inputs["pixel_values"],
            max_new_tokens=128,
            num_beams=3,
        )
    raw = processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
    parsed = processor.post_process_generation(raw, task="<MORE_DETAILED_CAPTION>", image_size=image.size)
    return parsed["<MORE_DETAILED_CAPTION>"]

img_dir = Path("/home/user/lora_train/soxw_zh50/img")
for img in sorted(img_dir.glob("*.png")):
    caption = caption_one(img)
    # 強制把 trigger 跟年齡描述放在最前
    final = f"{TRIGGER}, {AGE_DESC}, {caption}"
    txt = img.with_suffix(".txt")
    txt.write_text(final)
    print(f"{img.name}: {final[:80]}...")

注意:自動 caption 要人工 review,因為 Florence-2 可能誤把銀絲識別成「grey hair, elderly」,這會讓 LoRA 學成 80 歲。

4.8 訓練 hyperparameter 調整經驗

下面是 13 角色實戰調出來的 rank 4 / 100 step 標準參數:

network_module: networks.lora_flux
network_dim: 4
network_alpha: 4
learning_rate: 1.0e-4
unet_lr: 1.0e-4
text_encoder_lr: 1.0e-5
lr_scheduler: cosine
lr_warmup_steps: 0
optimizer_type: AdamW8bit
optimizer_args:
  - "weight_decay=0.01"
  - "betas=0.9,0.99"
mixed_precision: fp16
gradient_checkpointing: true
gradient_accumulation_steps: 1
max_train_steps: 100
save_every_n_steps: 50
caption_dropout_rate: 0.0
shuffle_caption: false
keep_tokens: 1                    # 第一個 token (trigger) 永不打亂
clip_skip: 1
noise_offset: 0.0

為什麼 keep_tokens=1:第一個 token 是 trigger,shuffle 會把它亂排,LoRA 學不牢。設 1 強制保留前 1 個 token 原位。

為什麼 AdamW8bit:省 VRAM 約 1.5 GB(5060 Ti 邊緣機型很重要)。品質跟 AdamW fp32 幾乎一樣。

4.9 訓練過程監控

ComfyUI 預設不顯示 loss curve,要自己看 log:

# 即時看 loss
tail -f /home/user/ComfyUI/log_train/soxw_zh50.log | grep -E "step|loss"

健康的 loss curve:

step  0: loss 0.42
step 10: loss 0.38
step 20: loss 0.35
step 30: loss 0.32
step 40: loss 0.29
step 50: loss 0.27 → 存 checkpoint
step 60: loss 0.26
step 70: loss 0.26
step 80: loss 0.25
step 90: loss 0.25
step 100: loss 0.24 → 存 final

不健康的:

step  0: loss 0.42
step 50: loss 0.10  ← 過擬合(loss 下太快)
step 100: loss 0.03 ← 嚴重過擬合,跑 inference 會看到 anchor 直接出現

過擬合 fix:rank 從 4 降到 2,或 learning_rate 從 1e-4 降到 5e-5。

4.10 13 角色 LoRA 訓練全紀錄

實戰:13 角色從零訓完的時間軸:

順序 角色 trigger 開始時間 完成時間 耗時 step/sec 備註
1 soxw_zh50 13:00 18:30 5.5 hr 197s cache miss
2 fujx_zh48 18:35 19:50 1.25 hr 75s cache reuse
3 chenjh_zh53 19:55 21:05 1.16 hr 70s
4 linyq_zh28 21:10 22:20 1.16 hr 70s
5 chenyl_zh23 22:25 23:38 1.21 hr 73s
6 lim_zh35 23:43 隔日 00:53 1.17 hr 70s
7 huangzy_zh40 隔日 00:58 02:13 1.25 hr 75s
8 linwr_zh32 02:18 03:30 1.20 hr 72s
9 zhouqiang_zh38 03:35 04:45 1.16 hr 70s
10 zhanglt_zh45 04:50 06:00 1.16 hr 70s
11 wujm_zh29 06:05 07:18 1.21 hr 73s
12 liyl_zh33 07:23 08:32 1.15 hr 69s
13 wanghc_zh51 08:37 09:48 1.18 hr 71s

合計 20.8 hr,平均 1.6 hr/角色(除第一個 5.5 hr)。

13 角色全部跑完是 20.8 hr,比預估的 30 hr 短,因為 cache reuse 比預期穩定。如果第一個角色不算 cache miss,11 個角色平均 1.18 hr/角色 = 13 hr 跑完 11 個角色。

實際進度 progress 檔每完成一個就寫一次,中斷不會丟。

4.11 LoRA 品質驗收 4 個 metric

訓完後跑這 4 個驗證:

Metric 1:trigger fidelity(特徵忠實度)

用 trigger 跑一張 anchor,跟 reference set 對比關鍵特徵:

def trigger_fidelity_check(lora_path, trigger, reference_images):
    """跑 5 張 anchor,人工評分特徵相似度 1-10"""
    samples = []
    for i in range(5):
        seed = 12345 + i
        img = generate_with_lora(lora_path, trigger, seed)
        samples.append(img)
    # 人工 review samples vs reference_images,給 1-10 分
    score = manual_review(samples, reference_images)
    return score  # 期望 >= 7

Metric 2:scene generalization(場景泛化)

把 trigger 放進 5 個全新場景:

NEW_SCENES = [
    "in spaceship cockpit",
    "underwater swimming",
    "on snowy mountain",
    "in medieval castle",
    "in cyberpunk city",
]

for scene in NEW_SCENES:
    prompt = f"{trigger}, {age_desc}, {scene}, cinematic"
    img = generate_with_lora(lora_path, prompt, seed=99)
    # 確認:1) 場景對 2) 人物仍像

Metric 3:multi-character compatibility(多角色相容)

兩個 LoRA 一起載:

prompt = f"{trigger_a} and {trigger_b} sitting at table talking"
# strength_a + strength_b 各 0.7
img = generate_with_multi_lora(
    [lora_a, lora_b],
    [0.7, 0.7],
    prompt
)
# 確認:兩個人物都認得出來

Metric 4:overfit detection(過擬合偵測)

用一個極端 prompt(例如「上太空」)測試:

prompt = f"{trigger}, astronaut in space, helmet"
img = generate_with_lora(lora_path, prompt, seed=999)
# 如果跑出來臉是對的、但背景是訓練資料的 office,就過擬合

4 個 metric 都 >= 7 分才算成功 LoRA。

4.12 LoRA 命名與管理

13+ LoRA 怎麼管不亂?建議命名規則:

<trigger>_<dataset_version>_<train_steps>_rank<N>_<precision>.safetensors

範例:
soxw_zh50_v1_100step_rank4_fp16.safetensors
soxw_zh50_v2_200step_rank8_fp16.safetensors    ← 試驗版
fujx_zh48_v1_100step_rank4_fp16.safetensors

進階:用 sqlite 紀錄 LoRA metadata:

CREATE TABLE loras (
    id INTEGER PRIMARY KEY,
    trigger TEXT NOT NULL,
    file_path TEXT NOT NULL,
    dataset_version TEXT,
    train_steps INTEGER,
    rank INTEGER,
    precision TEXT,
    train_started_at DATETIME,
    train_completed_at DATETIME,
    final_loss REAL,
    quality_score INTEGER,
    notes TEXT
);

每跑完一個 LoRA insert 一筆。後續想知道「哪個 LoRA 效果最好」就 query。

4.13 LoRA strength 調整指南

LoRA 訓完後,inference 時的 strength 參數要調整:

# 在 ComfyUI workflow 裡
{
    "class_type": "LoraLoader",
    "inputs": {
        "model": model_ref,
        "clip": clip_ref,
        "lora_name": "soxw_zh50_v1_100step_rank4_fp16.safetensors",
        "strength_model": 1.0,    # 1.0 = 完整效果
        "strength_clip": 1.0,
    }
}

strength_model 推薦範圍:

場景 strength
完整套用(特寫鏡頭) 1.0
中景,臉佔比中等 0.8
遠景,臉佔比小 0.6
過擬合修正 0.6
多角色混合(兩個 LoRA) 0.5-0.7

跨 shot 統一用 1.0 最簡單;如果 LoRA 訓得過頭,就降到 0.8。


第 5 章:對嘴技術三選一:MuseTalk vs LatentSync vs Wav2Lip

5.1 目標

了解三套主流對嘴方案的差異,在不同場景選對工具,並完整跑通 MuseTalk V1.5 256² 在 5060 Ti Blackwell sm_120 上的 face_alignment 68 patch。

5.2 前置條件

  • ComfyUI 已跑通
  • 已準備好對嘴用的 anchor 影片(base mp4,含臉)
  • 已準備好對應的音訊(wav)

5.3 三套方案總覽對比

維度 MuseTalk V1.5 LatentSync 1.5 Wav2Lip 96² + GFPGAN
解析度 256² 512² 96² + restore
VRAM 6.5 GB 7.8 GB 2.1 + 1.8 GB
一段 60 frame 耗時 28 sec 42 sec 9 + 6 sec
中文支援 ✓(V1.5) ✓(Whisper) ✓
嘴形精度 中 高 低
對複雜構圖 中等 易融化 穩定
對快速表情 中等 中等 穩定(但模糊)
Licence MIT Apache-2.0 非商用
商業使用 ✓ ✓ 需授權
安裝難度 高(face_alignment patch) 中 低

5.4 為什麼選 MuseTalk 而不選 LatentSync

LatentSync 在 face-on 純特寫超強,但凡是「手 + 物 + 臉」同框的構圖會 100% 嘴部融化(5/28 晚 v12 確認)。

MuseTalk 的優勢:

  • 對複雜構圖穩(手在臉旁、臉在側面 30-45 度都可)
  • 在 5060 Ti 16GB 跑通有完整教學
  • 256² 解析度搭 GFPGAN restore 可以接近 512² 效果

MuseTalk 的代價:

  • 256² 上限是硬天花板(再上升要 24GB+)
  • 安裝有 face_alignment 68 patch 地雷(後面詳述)

5.5 為什麼還保留 Wav2Lip

Wav2Lip 96² 嘴形最爛,但最快 + 最穩。在以下場景仍是 best baseline:

  • 大女主獨白工廠(v9.1):8 個 shot 連跑要穩
  • corruption filter + auto rerun:失敗了重跑成本低
  • 後製加 GFPGAN restore 蓋過去:visual 可接受

實測 v9.1 的 8 shot 比 v10 LatentSync 還好一點(用戶 5/28 確認)。

5.6 步驟:MuseTalk V1.5 安裝(face_alignment 68 patch)

Step 1:clone MuseTalk

cd /home/user
git clone https://github.com/TMElyralab/MuseTalk.git
cd MuseTalk
git checkout v1.5  # 一定要 V1.5

Step 2:下載 checkpoints

# musetalk
huggingface-cli download TMElyralab/MuseTalk \
    --local-dir ./models/musetalkV15

# 額外需要:sd-vae、whisper
huggingface-cli download stabilityai/sd-vae-ft-mse --local-dir ./models/sd-vae-ft-mse
huggingface-cli download openai/whisper-tiny --local-dir ./models/whisper

# face-parse-bisent
wget -O ./models/face-parse-bisent/79999_iter.pth \
    https://github.com/zllrunning/face-makeup.PyTorch/raw/master/cp/79999_iter.pth

總計 ~8.7 GB checkpoints。

Step 3:5 連 patch(最重要的部分)

Patch 1:preprocessing.py 用 face_alignment 取代 dwpose

預設 MuseTalk 用 mmcv 的 dwpose,編譯地獄裝不起來(坑 #34 mmcv build hell)。改用 standalone face_alignment lib:

# musetalk/utils/preprocessing.py
import face_alignment
import numpy as np

class FaceAlignmentModel:
    def __init__(self, device="cuda"):
        self.fa = face_alignment.FaceAlignment(
            face_alignment.LandmarksType.TWO_D,
            flip_input=False,
            device=device,
        )

    def get_landmarks(self, frame_bgr):
        """回傳 dlib 68 點格式 landmarks"""
        # face_alignment 吃 RGB
        frame_rgb = frame_bgr[:, :, ::-1]
        landmarks_list = self.fa.get_landmarks(frame_rgb)
        if landmarks_list is None or len(landmarks_list) == 0:
            return None
        # 取第一張臉
        return landmarks_list[0]  # shape (68, 2)

為什麼不用 mediapipe:mediapipe FaceMesh 是 478 點,索引跟 dlib 68 點不對應。MuseTalk 內部用 dlib 68 點的特定索引(嘴角、下巴等)來定位嘴部 ROI。用 mediapipe 套上去,MuseTalk 在錯位置生嘴,看起來嘴完全不動。詳見坑 #35。

Patch 2:7 個檔案加 weights_only=False

PyTorch 2.6+ 預設 torch.load(weights_only=True),但 MuseTalk 的 checkpoint 包含 pickle 物件,會炸:

cd /home/user/MuseTalk
# 找出所有 torch.load 然後加 weights_only=False
grep -rln "torch.load(" musetalk/ | while read f; do
    sed -i 's/torch\.load(\([^)]*\))/torch.load(\1, weights_only=False)/g' "$f"
done

# 確認
grep -rn "torch.load" musetalk/ | grep -v "weights_only=False"
# 應該沒有 output

詳見坑 #36。

Patch 3:V15 config path

MuseTalk V1.5 checkpoint 跟 V1.0 在不同路徑,inference 預設讀 V1.0:

# scripts/inference.py
- MODEL_PATH = "./models/musetalk"
+ MODEL_PATH = "./models/musetalkV15"

- UNET_CONFIG = "./models/musetalk/musetalk.json"
+ UNET_CONFIG = "./models/musetalkV15/musetalk.json"

Patch 4:bbox_shift 鎖 [-0, 0]

bbox_shift 是 MuseTalk 的 ROI 對齊偏移。預設值對 256² 模型有時對不準:

# scripts/inference.py
- BBOX_SHIFT = [-7, 7]
+ BBOX_SHIFT = [0, 0]   # V1.5 256² 鎖死

不鎖死的話會看到嘴漂出框,或者下唇被切。詳見坑 #37。

Patch 5:face_alignment lib 版本鎖

pip install face-alignment==1.3.5

face-alignment 1.4.0 開始改了 API,會炸。

Step 4:跑通 inference smoke test

cd /home/user/MuseTalk
python -m scripts.inference \
    --inference_config configs/inference/test.yaml

test.yaml 範例:

test:
  video_path: "/home/user/MuseTalk/data/video/avatar_v1.mp4"
  audio_path: "/home/user/MuseTalk/data/audio/test_zh.wav"
  bbox_shift: 0
  result_dir: "/home/user/MuseTalk/results"

跑 30-60 秒後 results/ 應該出現 avatar_v1_test_zh.mp4,臉部嘴部 sync 對得上。

5.7 步驟:LatentSync 1.5 安裝

Step 1:clone

cd /home/user
git clone https://github.com/bytedance/LatentSync.git
cd LatentSync

Step 2:下載 checkpoint

huggingface-cli download ByteDance/LatentSync-1.5 --local-dir ./checkpoints

Step 3:跑

python -m scripts.inference \
    --unet_config_path "configs/unet/stage2.yaml" \
    --inference_ckpt_path "checkpoints/latentsync_unet.pt" \
    --inference_steps 20 \
    --guidance_scale 1.0 \
    --video_path "data/avatar.mp4" \
    --audio_path "data/voice.wav" \
    --video_out_path "results/out.mp4"

LatentSync 沒有 face_alignment patch 地雷,安裝簡單,但對複雜構圖嘴會融化(前面說過)。

5.8 步驟:Wav2Lip 96² + GFPGAN restore(v9.1 path)

Step 1:Wav2Lip 對嘴

cd /home/user/Wav2Lip
python inference.py \
    --checkpoint_path checkpoints/wav2lip_gan.pth \
    --face data/face_video.mp4 \
    --audio data/voice.wav \
    --resize_factor 1 \
    --outfile results/synced.mp4

輸出是 96² 嘴部,paste 回原 video 後嘴會明顯模糊。

Step 2:GFPGAN v1.4 restore(only_center_face)

python inference_gfpgan.py \
    -i results/synced.mp4 \
    -o results/synced_restored.mp4 \
    -v 1.4 \
    --only_center_face \
    --bg_upsampler none

--only_center_face 是關鍵:只 restore 嘴部所在的中心臉,不動背景人臉(避免群眾鏡頭被亂修)。

5.9 驗證

# 1. ffprobe 確認影片有 sync
ffprobe -v error -show_entries stream=duration,nb_frames \
    -of default=noprint_wrappers=1 results/synced.mp4
# 應該看到 duration 跟 audio 一樣

# 2. 用瀏覽器或 VLC 開來看,嘴部 sync 應該對得上
mpv results/synced.mp4

# 3. 嘴部清晰度(GFPGAN 後)
# 用肉眼看,嘴邊不應該有模糊區塊

5.10 三種方案的決策樹

                     ┌─────────────────┐
                     │  shot 需要對嘴?  │
                     └────────┬────────┘
                              │ Yes
                ┌─────────────┴──────────────┐
                │   構圖是 face-on 純特寫?     │
                └─────────────┬──────────────┘
            Yes               │              No
        ┌───────┴───────┐    │       ┌──────┴──────┐
        │  LatentSync   │    │       │ 臉佔畫面 > 30% │
        │   (高品質)     │    │       └──────┬──────┘
        └───────────────┘    │           Yes │  No
                             │       ┌───────┴─────┐
                             │       │  MuseTalk    │ → Wav2Lip+GFPGAN
                             │       │  V1.5 256²   │   (穩定 baseline)
                             │       └─────────────┘

5.11 常見錯誤

  • mediapipe FaceMesh 套 MuseTalk:嘴不動,前面講過。
  • bbox_shift 沒鎖:嘴漂出框。
  • 沒裝 face-alignment 1.3.5:API 變了會炸。
  • LatentSync 拿來跑「手+物+臉」shot:嘴部融化。
  • Wav2Lip 沒接 GFPGAN restore:嘴明顯模糊,觀眾秒辨。

5.12 MuseTalk 三模型工作流時序圖

時間軸 →
0ms     500ms    1000ms   1500ms   2000ms   2500ms   3000ms
│       │        │        │        │        │        │
├── frame extract (60 frame)  ─────┤
│                                  │
│   ├── face detection (per frame) ──────────────┤
│                                                │
│      ├── face_alignment 68 點 landmark ───────────────┤
│                                                       │
│         ├── audio whisper feature ─────────┤          │
│                                            │          │
│            ├── MuseTalk inference (gen mouth latent) ───┤
│                                                         │
│               ├── VAE decode (latent → image) ──┤      │
│                                                  │     │
│                  ├── paste back mouth → frame ──┤     │
│                                                  │    │
│                     ├── ffmpeg encode → mp4 ────┤    │
│                                                  │   │
└─────────────────────────────── 28 sec total ─────┘   │

對 60-frame(2.5 sec)影片,MuseTalk 端到端約 28 秒。主要時間在 face detection(每 frame 都跑)。

5.13 face_alignment vs mediapipe vs dlib 比較

對嘴 pipeline 用的 face landmark 系統三家比較:

維度 face_alignment mediapipe FaceMesh dlib (shape_predictor_68)
點數 68 (dlib 格式) 478 68
精度 高 極高 中
速度 (CPU) 慢 (~50ms/frame) 快 (~10ms/frame) 中 (~30ms/frame)
速度 (GPU) 快 (~10ms) n/a n/a
MuseTalk 相容 ✓ 完美 ✗ 索引不對應 ✓
安裝 pip install pip install 需編譯 C++
維護 活躍 Google 官方 停滯

結論:MuseTalk 配 face_alignment(GPU),不要試 mediapipe。

5.14 GFPGAN vs CodeFormer vs RestoreFormer

對嘴後嘴部模糊,face restore 三家比較:

維度 GFPGAN v1.4 CodeFormer RestoreFormer
速度 (1024² 一張) 200ms 350ms 450ms
嘴部精度 高 中 高
五官對齊 中 高 高
視覺風格 自然 偏 smooth 偏 sharp
對動漫 ✗ △ ✓
only_center_face ✓ ✗ ✗

推薦:真人短劇用 GFPGAN v1.4 + --only_center_face,動漫用 RestoreFormer。CodeFormer 折衷但不突出。

5.15 對嘴成品 quality gates

每 shot 對嘴完要過這 3 個 quality gate 才繼續:

def check_lipsync_quality(video_path):
    """3 個 gate"""
    # Gate 1: 影片長度 == 音訊長度(誤差 < 100ms)
    v_dur = get_duration(video_path)
    a_dur = get_duration(video_path.with_suffix(".wav"))
    if abs(v_dur - a_dur) > 0.1:
        return False, "duration mismatch"

    # Gate 2: 影片完整(不是 0 frame)
    fc = get_frame_count(video_path)
    if fc < 24:
        return False, f"only {fc} frames"

    # Gate 3: 嘴部 ROI 真的有變化(不是靜止圖)
    diff = compute_mouth_motion(video_path)
    if diff < 0.05:
        return False, "mouth not moving"

    return True, "OK"

Gate 3 的 mouth motion 計算可以用 PSNR / SSIM 或簡單的 pixel diff:

import cv2
import numpy as np

def compute_mouth_motion(video_path, roi_bottom_frac=0.6):
    cap = cv2.VideoCapture(str(video_path))
    prev_mouth = None
    diffs = []
    while True:
        ret, frame = cap.read()
        if not ret:
            break
        h, w = frame.shape[:2]
        # 嘴部大約在影片下 1/4 中央
        mouth = frame[int(h*roi_bottom_frac):, w//3:2*w//3]
        if prev_mouth is not None:
            d = np.mean(np.abs(mouth.astype(int) - prev_mouth.astype(int)))
            diffs.append(d)
        prev_mouth = mouth
    cap.release()
    return np.mean(diffs) if diffs else 0

5.16 對嘴失敗 retry 策略

如果 Gate 沒過,retry 之前先檢查:

def retry_lipsync(shot, attempt):
    fixes = [
        # Attempt 1: 改 bbox_shift
        {"bbox_shift": [-2, 2]},
        # Attempt 2: 改用 LatentSync
        {"backend": "latentsync"},
        # Attempt 3: 改用 Wav2Lip + GFPGAN
        {"backend": "wav2lip_gfpgan"},
    ]
    if attempt < len(fixes):
        return run_lipsync(shot, **fixes[attempt])
    return None  # 全失敗,跳過這 shot

第 6 章:i2v 影片:Wan22 5B 完整 workflow + 81 frame 限制突破

6.1 目標

跑通 Wan2.2 5B ti2v workflow,了解 81 frame 限制怎麼來、為什麼改不掉、商用化怎麼接受 3.4 秒上限的 shot。

6.2 前置條件

  • ComfyUI 已跑通
  • 已下載 Wan22 5B fp16 checkpoint
  • 已下載 umt5 fp8 encoder
  • 已下載 wan22 vae

6.3 Wan22 5B 規格速覽

項目 數值
模型大小 wan2.2_ti2v_5B_fp16: 14.9 GB
CLIP umt5_xxl_fp8_e4m3fn_scaled: 5.2 GB
VAE wan2.2_vae: 254 MB
最大 frame 81(架構級限制,改不了)
預設 fps 24
影片長度 3.375 sec
解析度 720x1280 / 1280x720 / 768x768
一次推論 5060 Ti 16GB ~6 min
VRAM 占用 ~14.9 GB(剛好邊緣)

6.4 步驟:Wan22 5B ComfyUI workflow(完整 JSON)

def build_wan22_workflow(prompt, anchor_image_path, seed, output_prefix):
    """Wan22 5B ti2v workflow - i2v with anchor image"""
    return {
        "1": {
            "class_type": "UNETLoader",
            "inputs": {
                "unet_name": "wan2.2_ti2v_5B_fp16.safetensors",
                "weight_dtype": "default"
            }
        },
        "2": {
            "class_type": "CLIPLoader",
            "inputs": {
                "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
                "type": "wan",
                "device": "default"
            }
        },
        "3": {
            "class_type": "VAELoader",
            "inputs": {
                "vae_name": "wan2.2_vae.safetensors"
            }
        },
        "4": {
            "class_type": "CLIPTextEncode",
            "inputs": {
                "text": prompt,
                "clip": ["2", 0]
            }
        },
        "5": {
            "class_type": "CLIPTextEncode",
            "inputs": {
                "text": (
                    "text, watermark, blurry, deformed limbs, "
                    "two persons, motion graphics, frozen, static, "
                    "character change, color shift, clothing change"
                ),
                "clip": ["2", 0]
            }
        },
        "6": {
            "class_type": "LoadImage",
            "inputs": {"image": anchor_image_path}
        },
        "8": {
            "class_type": "Wan22FunControlToVideo",
            "inputs": {
                "positive": ["4", 0],
                "negative": ["5", 0],
                "vae": ["3", 0],
                "width": 720,
                "height": 1280,
                "length": 81,             # 鎖死 81
                "batch_size": 1,
                "ref_image": ["6", 0]
            }
        },
        "9": {
            "class_type": "ModelSamplingSD3",
            "inputs": {"shift": 8.0, "model": ["1", 0]}
        },
        "10": {
            "class_type": "KSampler",
            "inputs": {
                "model": ["9", 0],
                "positive": ["8", 0],
                "negative": ["8", 1],
                "latent_image": ["8", 2],
                "seed": seed,
                "steps": 30,
                "cfg": 6.5,                  # v4 從 8.0 降到 6.5 避 timeout
                "sampler_name": "dpmpp_2m",
                "scheduler": "karras",
                "denoise": 1.0,
                "control_after_generate": "fixed"
            }
        },
        "11": {
            "class_type": "VAEDecode",
            "inputs": {"samples": ["10", 0], "vae": ["3", 0]}
        },
        "12": {
            "class_type": "SaveAnimatedWEBP",
            "inputs": {
                "images": ["11", 0],
                "filename_prefix": output_prefix,
                "fps": 24,
                "lossless": False,
                "quality": 95
            }
        }
    }

6.5 為什麼 81 frame 改不掉

Wan22 5B 的 temporal attention 在 81 frame 切一個 latent slice。改成 121 / 161 frame 會:

  1. OOM:latent 變大,VRAM 不夠
  2. 品質崩:超出訓練分布,畫面開始模糊
  3. 時序錯亂:第 82 frame 之後人物會跳

這是架構級限制,不是 bug,不要試圖改 length 參數。要更長影片只能拼接(後文 Step 6)。

6.6 推薦參數

參數 v1(初版) v2(修穩) v4 拍板
steps 25 30 30
cfg 8.0 7.0 6.5
sampler euler dpmpp_2m dpmpp_2m
scheduler normal karras karras
shift 5.0 8.0 8.0
timeout default 1200 1800

為什麼 cfg 從 8.0 降到 6.5:cfg=8.0 在某些 prompt 會 timeout(1800 sec 跑不完),降到 6.5 穩定。詳見坑 #41。

6.7 anti-shake negative prompt

Wan22 5B 容易在後半段抖動。負向 prompt 必含:

text, watermark, blurry, deformed limbs, two persons,
motion graphics, frozen, static,
character change, color shift, clothing change, hair change,
close-up portrait, talking head

character change / color shift / clothing change 三件套是同源 seed 必加。詳見坑 #43。

6.8 同源 seed 鎖跨 shot 一致性

跨 shot 角色一致性最簡單的方法是同源 seed:

# 同一個角色的 shots 用同一個 base seed + shot id 偏移
def get_seed(char_id, shot_id):
    BASE_SEEDS = {
        "female_lead": 42_000_000,
        "male_lead":   42_001_000,
        "ex_husband":  42_002_000,
    }
    return BASE_SEEDS[char_id] + shot_id

不是 silver bullet,仍會跨 shot 漂移,但比每 shot random 好 20-30%。

6.9 81 frame 限制的三種突破策略

策略 1:接受限制,把 shot 切短

最務實。一集 30 shot × 3.4 sec = 102 sec,加上 cutaway / talking head 可以拉到 3-5 min/集。

策略 2:拼接(last frame as next anchor)

把第 81 frame 抽出來當下一段的 anchor:

# 抽最後一幀
ffmpeg -sseof -0.04 -i shot01.mp4 -vframes 1 last_frame.png

# 用 last_frame.png 當下一段 anchor 跑 Wan22
# 結果可能跨段有跳動,需後製 crossfade
ffmpeg -i shot01_part1.mp4 -i shot01_part2.mp4 \
    -filter_complex "[0:v][1:v]xfade=transition=fade:duration=0.3:offset=3.0" \
    shot01_full.mp4

策略 3:RIFE 補幀延長

24fps → 48fps,加上 ffmpeg setpts 慢動作:

# RIFE 補幀
python inference_video.py \
    --multi 2 \
    --video shot01.mp4 \
    --output shot01_48fps.mp4

# 變慢動作(時長翻倍)
ffmpeg -i shot01_48fps.mp4 \
    -filter:v "setpts=2.0*PTS" \
    -r 24 \
    shot01_slow.mp4

從 3.4 sec 變 6.8 sec。代價是動作變慢一倍,只適合「文藝鏡頭」、不適合動作戲。

6.10 驗證

# 1. 確認 frame 數正確
ffprobe -v error -count_frames \
    -select_streams v:0 \
    -show_entries stream=nb_read_frames \
    -of csv=p=0 shot01.mp4
# 應該回傳 81

# 2. 確認時長 3.375 秒
ffprobe -v error -show_entries format=duration \
    -of default=noprint_wrappers=1:nokey=1 shot01.mp4
# 應該回傳 3.375 或 3.38

# 3. 確認沒嚴重 character drift(眼測,肉眼確認首末幀人物像同一個人)
ffmpeg -i shot01.mp4 -vf "select=eq(n\,0)+eq(n\,80)" -vsync vfr first_last.png

6.11 常見錯誤

  • 試圖把 length 改成 161:OOM 或品質崩。
  • anchor 解析度跟 output 不一致:Wan22 會 stretch 變形。anchor 必須是 720x1280(或 output 一樣)。
  • cfg 拉到 9.0 求精度:timeout 機率大增,且結果 over-saturated。
  • 沒設 timeout=1800:ComfyUI 預設 timeout 太短,某些 shot 會在 1800 sec 後被砍。
  • 第 30 幀後人臉漂移:5B 模型訓練的固有限制,要更穩只能上 14B(不行就上雲端)。

6.12 motion prompt 寫法心法

Wan22 的 motion prompt 不同於 anchor prompt。anchor 重「靜態描述」(什麼樣的人在什麼場景),motion 重「動作描述」(如何移動)。

結構化 motion prompt 模板:

<構圖>, <鏡頭運動>, <主體動作>, <次要動作>, <氛圍>, <一致性鎖>

範例對照:

場景 bad motion prompt good motion prompt
戰士拔劍 "warrior pulling sword" "low angle medium shot, slight dolly forward, warrior slowly drawing jade sword from sheath with right hand, robe gently swaying, golden hour light, same warrior same red robe"
女主轉身 "woman turns around" "medium portrait shot, locked camera, woman turning her head from looking down to looking at camera, hair swaying slightly with motion, soft natural light, same character same hairstyle"
角色奔跑 "running" "wide tracking shot following from side, character running through forest path, robe flowing behind, leaves swirling, dappled sunlight, same character same outfit"

6.13 Wan22 vs LTX-Video 對比

LTX-Video 是另一個本地 i2v 模型,跟 Wan22 5B 對比:

維度 Wan22 5B LTX-Video 2B distilled
模型大小 14.9 GB 5.2 GB
VRAM ~15 GB ~8 GB
5060 Ti 是否可跑 邊緣 寬鬆
一次推論 6 min 3 min
最大 frame 81 257
解析度 720x1280 768x512
構圖控制 中(FunControl) 強(內建 first frame lock)
人物一致性 中 中
動作流暢度 高 中

何時選 LTX-Video:

  • 需要 4+ 秒的 shot
  • VRAM 緊張,要省 7GB 給其他 model
  • 構圖必須鎖死首幀

何時選 Wan22 5B:

  • 動作戲(武打 / 跑步 / 戰鬥)
  • 接受 3.4 sec 上限
  • 解析度要 720x1280(豎屏)

實戰:v14 量產用 Wan22 5B,LTX 用在 ep01 中介測試(17MB demo)。

6.14 Wan22 失敗 retry 機制

15% 失敗率不能讓單一失敗整批死。retry 邏輯:

def run_wan22_with_retry(anchor, motion_prompt, seed, max_retry=3):
    for attempt in range(max_retry):
        try:
            # 不同 attempt 用不同 seed
            actual_seed = seed + attempt * 1000
            output = run_wan22(anchor, motion_prompt, actual_seed)

            # 驗證
            if not is_valid_video(output):
                print(f"[retry] attempt {attempt+1}: invalid output, retrying")
                continue

            # 驗證 frame 完整性
            fc = get_frame_count(output)
            if fc != 81:
                print(f"[retry] attempt {attempt+1}: got {fc} frames, expected 81")
                continue

            return output
        except subprocess.TimeoutExpired:
            print(f"[retry] attempt {attempt+1}: timeout")
        except Exception as e:
            print(f"[retry] attempt {attempt+1}: {e}")

    raise RuntimeError(f"Wan22 failed after {max_retry} retries")

每次 retry 加 1000 到 seed,避開「就是這個 seed 不穩」的特殊 case。

6.15 Wan22 與 RIFE 補幀的搭配

RIFE 補幀可以把 24fps 變 48fps,視覺更流暢。但對 Wan22 5B:

  • 24fps + RIFE 2x = 48fps:動作更滑順,3.4 sec 不變
  • 24fps + RIFE 2x + 0.5x speed = 24fps + 6.8 sec:慢動作

RIFE 跟 Wan22 整合:

# Step 1: Wan22 跑出 24fps mp4
python wan22_inference.py --output shot01_24fps.mp4

# Step 2: RIFE 補幀到 48fps
cd /home/user/RIFE
python inference_video.py \
    --multi 2 \
    --video shot01_24fps.mp4 \
    --output shot01_48fps.mp4 \
    --scale 1.0

# Step 3: 如果要慢動作,setpts
ffmpeg -i shot01_48fps.mp4 \
    -filter:v "setpts=2.0*PTS" \
    -r 24 \
    shot01_slow_6.8sec.mp4

RIFE 的限制:

  • ghosting 在快動作下嚴重
  • 對「靜止 → 突然動」最差
  • 對「平滑運動」最好

實戰建議:action shot 用 RIFE 補幀,talking head 不用。

6.16 Wan22 不同 sampler 對比

KSampler 的 sampler_name 對 Wan22 5B 結果影響:

sampler 結果 速度
euler 偏 sharp,細節多 快
euler_a 偏 noisy 快
dpmpp_2m 平衡,推薦 中
dpmpp_2m_sde 偏 smooth 慢
dpmpp_3m_sde 最 smooth 最慢
heun 銳利,但偶爾抖 中

scheduler 對比:

scheduler 結果
normal 標準
karras 較好的 noise schedule,推薦
exponential 偏 dark
sgm_uniform 給 SD3 / Flux 用

推薦組合:dpmpp_2m + karras(穩定)或 euler + normal(快)。

6.17 Wan22 anchor 圖品質對成品的影響

實測同一段 motion prompt,不同 anchor 質量出來的成品差很多:

anchor 品質 描述 成品評分
A: cinematic 銳利高細節 Flux 30 step + 1024 解析度 75 分
B: 標準 quality Flux 20 step + 768 解析度 60 分
C: 低品質 noisy Flux 10 step + 512 解析度 35 分
D: 過 sharp + overexposed 後製過頭 40 分(Wan22 學壞了)

結論:anchor 一定要 cinematic 高品質。Flux 跑 30+ step、解析度跟 output 對齊(720x1280)、不過度後製。

6.18 Wan22 motion prompt 與 anchor prompt 的微妙關係

兩者必須「相關但不同」:

Bad(兩者完全相同):

anchor: "soxw_zh50 standing at vanity, sweeping cosmetics"
motion: "soxw_zh50 standing at vanity, sweeping cosmetics"
→ Wan22 不知道要動什麼,輸出靜止

Bad(兩者衝突):

anchor: "soxw_zh50 standing still at vanity"
motion: "soxw_zh50 running across forest"
→ Wan22 困惑,臉跟身體脫節

Good(漸進式延伸):

anchor: "soxw_zh50 standing at vanity, arm raised, about to sweep"
motion: "soxw_zh50 violently sweeping arm across vanity,
        cosmetics flying through air, hair whipping with motion"
→ Wan22 順著 anchor 預設的動作展開

心法:anchor 是「動作前一刻」,motion 是「動作中」。

6.19 Wan22 多 shot 連續性技巧

如果你要做「同一場景 5 個連續 shot」,怎麼讓場景一致:

def build_scene_anchors(scene_desc, shots):
    """同場景 multiple shot 用共同 environmental prompt"""
    ENV = (
        "in vintage 1980s Hong Kong cha chaan teng restaurant, "
        "warm tungsten lighting, faded yellow walls, "
        "wooden tables and chairs, neon sign outside window"
    )

    anchors = []
    for shot in shots:
        # 同樣 ENV 描述,只變人物動作
        prompt = f"{shot['character_prompt']}, {ENV}, {shot['action']}"
        # 同樣 base seed + shot id
        seed = SEED_BASE["scene_1"] + shot["shot_id"]
        anchor = generate_flux(prompt, seed=seed)
        anchors.append(anchor)
    return anchors

關鍵:ENV 描述完全相同,只改人物動作部分。配合同源 seed,場景一致性可達 70%。

6.20 Wan22 推論監控 dashboard

長任務跑著看 nvidia-smi 累。寫個簡單 dashboard:

"""wan22_monitor.py - 即時看 GPU 狀態 + queue 進度"""
import subprocess
import time
import requests

while True:
    # GPU 狀態
    gpu = subprocess.check_output(
        "nvidia-smi --query-gpu=temperature.gpu,utilization.gpu,memory.used,memory.total "
        "--format=csv,noheader,nounits", shell=True
    ).decode().strip()
    temp, util, mem_used, mem_total = gpu.split(", ")

    # ComfyUI queue
    q = requests.get("http://localhost:8188/queue").json()
    running = len(q.get("queue_running", []))
    pending = len(q.get("queue_pending", []))

    print(f"\r[GPU] {temp}°C {util}% util  VRAM {mem_used}/{mem_total}MB  "
          f"[Queue] running={running} pending={pending}", end="", flush=True)
    time.sleep(2)

跑這個就一直看到 GPU 滿載 + queue 在消化。溫度超過 80°C 要警告。


第 7 章:配音方案:Edge TTS / F5-TTS / GPT-SoVITS 三路線

7.1 目標

了解三套 TTS 的差異,知道在哪個場景該用哪一套,並掌握 Edge TTS SSML 22 emotion 設定 + F5-TTS voice clone 流程。

7.2 前置條件

  • 已有 Python 3.10+
  • Edge TTS 不需 GPU
  • F5-TTS / GPT-SoVITS 需要 5GB+ VRAM

7.3 三路線對比

維度 Edge TTS F5-TTS GPT-SoVITS
部署 online API 本地 本地
費用 免費 免費 免費
VRAM 0 4.6 GB 6 GB
一段 10 sec 耗時 2 sec 18 sec 25 sec
中文支援 ✓ 多 voice ✓ ✓
情感表達 SSML 受限 文本控 強
voice cloning ✗ ✓ 10 sec ref ✓ 30 sec ref
SSML 完整 ✗ n/a n/a
商業使用 注意條款 ✓ ✓
推薦場景 旁白 / 大量量產 角色配音 高情感角色

7.4 為什麼選 Edge TTS 主力

  • 0 VRAM 占用,跟 ComfyUI 並行不衝突
  • 22 個中文 voice 可選(Xiaoxiao / Yunjian / Yunyang ...)
  • 一段 10 秒生成 2 秒,量產極快
  • 免費

代價:免費版 SSML rate 限制 ±20%,超過會卡頓。商業級配音建議:

  • 上 Azure premium(付費版 SSML 完整)
  • 上 ElevenLabs(情感最強,$5/月起)

7.5 步驟:Edge TTS 完整使用

Step 1:裝

pip install edge-tts

Step 2:列出中文 voice

edge-tts --list-voices | grep "^zh-CN"

主要 voice:

voice id 性別 風格
zh-CN-XiaoxiaoNeural 女 標準甜美
zh-CN-XiaoyiNeural 女 年輕
zh-CN-XiaomengNeural 女 嬌嗲
zh-CN-YunjianNeural 男 沉穩
zh-CN-YunyangNeural 男 中年
zh-CN-YunxiNeural 男 年輕

Step 3:CLI 跑

edge-tts --voice "zh-CN-XiaoxiaoNeural" \
    --text "我不想再被你騙了" \
    --write-media output.wav

Step 4:SSML 22 emotion map

完整 SSML 寫法:

import edge_tts
import asyncio

EMOTION_MAP = {
    "neutral":   {"rate": "+0%",  "pitch": "+0Hz",  "volume": "+0%"},
    "happy":     {"rate": "+10%", "pitch": "+15Hz", "volume": "+15%"},
    "sad":       {"rate": "-15%", "pitch": "-20Hz", "volume": "-10%"},
    "angry":     {"rate": "+5%",  "pitch": "+10Hz", "volume": "+25%"},
    "shocked":   {"rate": "-10%", "pitch": "+25Hz", "volume": "+20%"},
    "fearful":   {"rate": "-5%",  "pitch": "+15Hz", "volume": "-5%"},
    "calm":      {"rate": "-10%", "pitch": "-5Hz",  "volume": "-5%"},
    "excited":   {"rate": "+15%", "pitch": "+20Hz", "volume": "+20%"},
    "whisper":   {"rate": "-20%", "pitch": "-10Hz", "volume": "-30%"},
    "shout":     {"rate": "+5%",  "pitch": "+15Hz", "volume": "+30%"},
    "tender":    {"rate": "-10%", "pitch": "-5Hz",  "volume": "+5%"},
    "serious":   {"rate": "-5%",  "pitch": "-10Hz", "volume": "+0%"},
    "confident": {"rate": "+5%",  "pitch": "-5Hz",  "volume": "+15%"},
    "doubt":     {"rate": "-10%", "pitch": "+10Hz", "volume": "-5%"},
    "scornful":  {"rate": "-5%",  "pitch": "-5Hz",  "volume": "+10%"},
    "narration": {"rate": "-5%",  "pitch": "+0Hz",  "volume": "+10%"},
    "warm":      {"rate": "-5%",  "pitch": "+5Hz",  "volume": "+5%"},
    "cold":      {"rate": "+0%",  "pitch": "-10Hz", "volume": "+0%"},
    "playful":   {"rate": "+15%", "pitch": "+15Hz", "volume": "+10%"},
    "serene":    {"rate": "-10%", "pitch": "+0Hz",  "volume": "-5%"},
    "determined":{"rate": "+5%",  "pitch": "+0Hz",  "volume": "+20%"},
    "broken":    {"rate": "-15%", "pitch": "-15Hz", "volume": "-15%"},
}

async def synthesize(text, voice, emotion, output):
    params = EMOTION_MAP[emotion]
    communicate = edge_tts.Communicate(
        text=text,
        voice=voice,
        rate=params["rate"],
        pitch=params["pitch"],
        volume=params["volume"],
    )
    await communicate.save(output)

# 範例
asyncio.run(synthesize(
    text="我不想再被你騙了!",
    voice="zh-CN-XiaoxiaoNeural",
    emotion="angry",
    output="line_001.wav",
))

注意:免費版 rate 限制 ±20%。超過範圍會被 server reject。

Step 5:批次跑(30 段一次)

import asyncio

async def batch_tts(lines):
    tasks = []
    for i, line in enumerate(lines):
        tasks.append(synthesize(
            text=line["text"],
            voice=line["voice"],
            emotion=line["emotion"],
            output=f"line_{i:03d}.wav",
        ))
    await asyncio.gather(*tasks)

lines = [
    {"text": "我不想再被你騙了!", "voice": "zh-CN-XiaoxiaoNeural", "emotion": "angry"},
    {"text": "你怎麼可以這樣對我?", "voice": "zh-CN-XiaoxiaoNeural", "emotion": "sad"},
    # ... 30 段
]
asyncio.run(batch_tts(lines))

30 段平行跑大約 8-12 秒(受 Edge TTS rate limit 影響)。

7.6 步驟:F5-TTS voice clone

Step 1:裝

git clone https://github.com/SWivid/F5-TTS.git
cd F5-TTS
pip install -e .

Step 2:準備 reference

需要 10-30 秒的乾淨人聲樣本(沒有 BGM、沒有雜音)。

# 從 YouTube 抓 ref(注意版權)
yt-dlp -x --audio-format wav --audio-quality 0 \
    "https://www.youtube.com/watch?v=XXXXXX" -o ref.wav

# 裁出 10-30 秒
ffmpeg -i ref.wav -ss 00:01:23 -t 20 ref_clean.wav

# 降噪(可選)
ffmpeg -i ref_clean.wav -af "anlmdn=s=0.001:p=0.002:r=0.01" ref_clean_dn.wav

Step 3:clone

f5-tts_infer-cli \
    --model F5TTS_v1_Base \
    --ref_audio "ref_clean.wav" \
    --ref_text "這段是參考音訊的逐字稿,盡量精確" \
    --gen_text "我不想再被你騙了,這就是我要說的話。" \
    --output_file "cloned.wav"

Step 4:30 角色 voice clone 收集 SOP

# 步驟 1:從 brief 角色卡找對應藝人 / podcaster
# 步驟 2:YouTube 抓 2-3 個候選樣本
# 步驟 3:每個樣本裁 20 秒
# 步驟 4:用同一段 gen_text 跑三個樣本
# 步驟 5:人工聽選最像的當 final ref

音質要求:

  • 取樣率 24kHz+
  • 沒有 BGM
  • 沒有混響
  • 同一人講話、沒有打斷

7.7 商業級配音建議

如果你想做帶貨 / 直播間品質:

等級 建議
低 Edge TTS + SSML 變化
中 F5-TTS 自己 clone 真人聲
高 Azure Premium SSML 完整版
高+ ElevenLabs Multilingual v2
商用 真人配音

別在 Edge TTS 上死磕。免費版受限,超過範圍的情感(憤怒怒吼、極度悲傷)需要付費 API。

7.8 驗證

# 1. 音訊時長正確
ffprobe -v error -show_entries format=duration \
    -of default=noprint_wrappers=1:nokey=1 line_001.wav

# 2. 取樣率 24000
ffprobe -v error -show_entries stream=sample_rate \
    -of default=noprint_wrappers=1:nokey=1 line_001.wav

# 3. 用人耳聽,情感對得上文本
# 開耳機聽 line_001.wav,"angry" 應該真的怒

7.9 常見錯誤

  • Edge TTS rate 超過 ±20%:直接卡頓或被 reject。
  • F5-TTS ref 帶 BGM:clone 出來會帶 BGM-like 雜音。
  • F5-TTS ref < 10 sec:clone 品質崩。
  • 批次跑 Edge TTS 100+ 段:被 rate limit,要加 throttle(每 30 段 sleep 5 sec)。

7.10 GPT-SoVITS 安裝補充

如果你想試 GPT-SoVITS(情感最強的本地 voice clone):

git clone https://github.com/RVC-Boss/GPT-SoVITS.git
cd GPT-SoVITS
pip install -r requirements.txt

# 下載預訓練模型
mkdir -p GPT_SoVITS/pretrained_models
huggingface-cli download lj1995/GPT-SoVITS \
    --local-dir GPT_SoVITS/pretrained_models

跑:

python webui.py --port 9874

webui 流程:

  1. 上傳 30 sec reference wav
  2. 標註 reference 文本
  3. 訓練 SoVITS model(約 30 min 5060 Ti)
  4. 訓練 GPT model(約 1 hr)
  5. 用訓好的模型 inference

跟 F5-TTS 比:

  • GPT-SoVITS:要訓練,但訓完情感極強
  • F5-TTS:不訓練,instant clone,情感中等

商業級短劇推薦 GPT-SoVITS 訓 13 個角色 voice model(一週連跑)。自媒體量產用 F5-TTS instant 即可。

7.11 voice + emotion + character 對照表

13 角色 × emotion 配置範例:

角色 base voice 主要 emotion 次要 emotion
女主蘇曉雯 50 XiaoxiaoNeural sad / determined shocked / broken
男主傅景行 48 YunjianNeural confident / warm tender
前夫陳俊豪 53 YunyangNeural scornful / cold angry
秘書林雅淇 28 XiaomengNeural playful / scornful jealous
女兒陳怡蘭 23 XiaoyiNeural warm / sad confused
助理林明 35 YunxiNeural serious / neutral concerned

腳本裡每段 dialogue 對應一個 emotion,dispatcher 自動套:

{
  "shot_id": 3,
  "character": "female_lead",
  "dialogue": "你跟她在一起多久了?",
  "emotion": "shocked"
}

7.12 配音品質檢驗

每段配音 Generate 完要過這 4 個 check:

def verify_tts(audio_path, expected_text, expected_duration_range=(1, 30)):
    # Check 1: 檔案存在 + 大小 > 1KB
    if not audio_path.exists() or audio_path.stat().st_size < 1000:
        return False, "file too small"

    # Check 2: 時長在合理範圍
    dur = get_duration(audio_path)
    if not (expected_duration_range[0] <= dur <= expected_duration_range[1]):
        return False, f"duration {dur} out of range"

    # Check 3: 取樣率 24000 / 16000
    sr = get_sample_rate(audio_path)
    if sr not in (16000, 24000, 48000):
        return False, f"sample rate {sr} unexpected"

    # Check 4: 用 whisper 反向 STT 對照原文(可選,慢)
    # text = whisper_transcribe(audio_path)
    # if not text_match(text, expected_text, threshold=0.8):
    #     return False, "text mismatch"

    return True, "OK"

Check 4 用 whisper 對照,可以確認 TTS 真的講對台詞。但每段加 5 sec,量產時可省。

7.13 中文字幕跟 TTS 時間軸對齊

字幕的時間軸要跟 TTS 完全對齊。手寫容易出錯,自動算:

import wave
import contextlib

def get_wav_duration(wav_path):
    with contextlib.closing(wave.open(str(wav_path), 'r')) as f:
        frames = f.getnframes()
        rate = f.getframerate()
        return frames / float(rate)

def generate_ass_subtitle(shots, output_path):
    """根據每 shot 的 TTS 音檔時長自動算字幕時間軸"""
    cursor = 0.0
    lines = []
    for shot in shots:
        if shot.get("dialogue"):
            duration = get_wav_duration(shot["audio_path"])
            start = cursor
            end = cursor + duration
            lines.append(format_ass_line(start, end, shot["subtitle"]))
            cursor = end
        else:
            cursor += shot["duration_sec"]

    write_ass_file(output_path, lines)

def format_ass_line(start, end, text):
    return f"Dialogue: 0,{format_time(start)},{format_time(end)},Default,,0,0,0,,{text}"

def format_time(sec):
    h = int(sec // 3600)
    m = int((sec % 3600) // 60)
    s = int(sec % 60)
    cs = int((sec - int(sec)) * 100)
    return f"{h}:{m:02d}:{s:02d}.{cs:02d}"

這樣字幕時間軸不會跟音訊脫節。


第 8 章:量產 SOP:cutaway/static/talking/action 4 type dispatcher

8.1 目標

設計一個可以處理 32-shot 劇本的 dispatcher,根據 shot_type 路由到不同 pipeline,能在 21 小時內跑完一集 17.7 分鐘短劇。

8.2 前置條件

  • LoRA 13 角色已訓練完
  • ComfyUI workflows 已準備好(Flux / Wan22 / MuseTalk)
  • 劇本 JSON 已寫完(每 shot 有 shot_type 標籤)

8.3 劇本 JSON 格式

{
  "episode": "ep1",
  "total_shots": 32,
  "shots": [
    {
      "shot_id": 1,
      "shot_type": "cutaway",
      "duration_sec": 2.5,
      "scene": "清晨6:00, 主臥, 窗外鳥叫",
      "anchor_prompt": "early morning 6am, bedroom window, sunlight",
      "dialogue": null,
      "subtitle": null
    },
    {
      "shot_id": 2,
      "shot_type": "static_pose",
      "duration_sec": 3.0,
      "character": "female_lead",
      "scene": "梳妝台前, 看著鏡中自己",
      "anchor_prompt": "soxw_zh50 sitting at vanity, looking in mirror, sad expression",
      "dialogue": null,
      "subtitle": "(女主沉默地看著鏡中的自己)",
      "camera": "push_in"
    },
    {
      "shot_id": 3,
      "shot_type": "talking_head",
      "duration_sec": 4.5,
      "character": "female_lead",
      "scene": "主臥, 對著電話",
      "anchor_prompt": "soxw_zh50 holding phone, devastated expression",
      "dialogue": "你跟她在一起多久了?",
      "voice": "zh-CN-XiaoxiaoNeural",
      "emotion": "shocked",
      "subtitle": "你跟她在一起多久了?"
    },
    {
      "shot_id": 4,
      "shot_type": "action",
      "duration_sec": 3.4,
      "character": "female_lead",
      "scene": "梳妝台前, 摔掉化妝品",
      "anchor_prompt": "soxw_zh50 sweeping cosmetics off vanity in anger",
      "motion_prompt": "violent angry motion sweeping objects, cosmetics flying, glass breaking",
      "dialogue": null,
      "subtitle": "(女主憤怒地掃落所有化妝品)"
    }
  ]
}

8.4 4 type dispatcher 決策樹

shot_type:
│
├── cutaway       (~1 min)
│   └── Flux 1 圖(無 LoRA)→ ffmpeg loop → mux
│
├── static_pose   (~1.5 min)
│   └── Flux + LoRA → ffmpeg Ken Burns zoompan → mux
│
├── talking_head  (~3 min)
│   └── Flux + LoRA → Edge TTS → MuseTalk 256² → 字幕燒入 → mux
│
└── action        (~7 min)
    └── Flux + LoRA → Wan22 5B i2v → 字幕燒入 → mux

8.5 dispatcher Python 主體

"""episode_v14_runner.py - 4 type dispatcher
按 shot_type 路由到 4 種 pipeline
"""
import json
import subprocess
from pathlib import Path

EPISODE = "v14_ep1"
RESOLUTION = (720, 1280)
FPS = 24

CHAR_LORA = {
    "female_lead": ("soxw_zh50_v1_100step_rank4_fp16.safetensors", "soxw_zh50"),
    "male_lead":   ("fujx_zh48_v1_100step_rank4_fp16.safetensors", "fujx_zh48"),
    # ... 13 角色
}

CHAR_VOICE = {
    "female_lead": "zh-CN-XiaoxiaoNeural",
    "male_lead":   "zh-CN-YunjianNeural",
    # ...
}

AGE_PRESERVE = {
    "female_lead": "50 years old elegant Taiwanese woman, salt-and-pepper hair",
    "male_lead":   "48 years old refined Asian businessman",
    # ...
}

def run_cutaway(shot):
    """cutaway: Flux 1 圖 + ffmpeg loop"""
    img = generate_flux(shot["anchor_prompt"], lora=None,
                        seed=42_000_000 + shot["shot_id"])
    duration = shot["duration_sec"]
    output = OUT_DIR / f"shot_{shot['shot_id']:03d}.mp4"
    subprocess.run([
        "ffmpeg", "-y",
        "-loop", "1", "-i", str(img),
        "-c:v", "libx264", "-t", str(duration),
        "-pix_fmt", "yuv420p", "-r", str(FPS),
        "-vf", f"scale={RESOLUTION[0]}:{RESOLUTION[1]}",
        str(output)
    ], check=True)
    return output

def run_static_pose(shot):
    """static_pose: Flux + LoRA + ffmpeg Ken Burns"""
    char = shot["character"]
    lora_name, trigger = CHAR_LORA[char]
    prompt = f"{trigger}, {AGE_PRESERVE[char]}, {shot['anchor_prompt']}"
    img = generate_flux(prompt, lora=lora_name,
                        seed=42_000_000 + shot["shot_id"])

    camera = shot.get("camera", "push_in")
    duration = shot["duration_sec"]
    vf = build_camera_vf(camera, duration, FPS)

    output = OUT_DIR / f"shot_{shot['shot_id']:03d}.mp4"
    subprocess.run([
        "ffmpeg", "-y",
        "-loop", "1", "-i", str(img),
        "-vf", vf,
        "-c:v", "libx264", "-t", str(duration),
        "-pix_fmt", "yuv420p", "-r", str(FPS),
        str(output)
    ], check=True)
    return output

def run_talking_head(shot):
    """talking_head: Flux + LoRA + Edge TTS + MuseTalk"""
    char = shot["character"]
    lora_name, trigger = CHAR_LORA[char]
    prompt = f"{trigger}, {AGE_PRESERVE[char]}, {shot['anchor_prompt']}"

    # Step 1: anchor
    anchor_img = generate_flux(prompt, lora=lora_name,
                               seed=42_000_000 + shot["shot_id"])

    # Step 2: anchor → base mp4 (talking head 用 Ken Burns 微推鏡)
    base_mp4 = OUT_DIR / f"shot_{shot['shot_id']:03d}_base.mp4"
    subprocess.run([
        "ffmpeg", "-y",
        "-loop", "1", "-i", str(anchor_img),
        "-c:v", "libx264", "-t", str(shot["duration_sec"]),
        "-pix_fmt", "yuv420p", "-r", str(FPS),
        str(base_mp4)
    ], check=True)

    # Step 3: Edge TTS
    audio = OUT_DIR / f"shot_{shot['shot_id']:03d}.wav"
    voice = CHAR_VOICE[char]
    emotion = shot.get("emotion", "neutral")
    synthesize_with_emotion(shot["dialogue"], voice, emotion, audio)

    # Step 4: MuseTalk 對嘴
    synced_mp4 = OUT_DIR / f"shot_{shot['shot_id']:03d}_sync.mp4"
    run_musetalk(base_mp4, audio, synced_mp4)

    # Step 5: GFPGAN restore
    restored_mp4 = OUT_DIR / f"shot_{shot['shot_id']:03d}_restored.mp4"
    run_gfpgan_video(synced_mp4, restored_mp4)

    # Step 6: 字幕燒入 + mux audio
    final = OUT_DIR / f"shot_{shot['shot_id']:03d}.mp4"
    burn_subtitle_and_mux(restored_mp4, audio, shot["subtitle"], final)
    return final

def run_action(shot):
    """action: Flux + LoRA + Wan22 5B i2v"""
    char = shot["character"]
    lora_name, trigger = CHAR_LORA[char]
    prompt = f"{trigger}, {AGE_PRESERVE[char]}, {shot['anchor_prompt']}"

    # Step 1: anchor
    anchor_img = generate_flux(prompt, lora=lora_name,
                               seed=42_000_000 + shot["shot_id"])

    # Step 2: Wan22 5B i2v
    motion_prompt = shot.get("motion_prompt", shot["anchor_prompt"])
    video_mp4 = run_wan22(anchor_img, motion_prompt,
                          seed=42_000_000 + shot["shot_id"])

    # Step 3: 字幕燒入
    final = OUT_DIR / f"shot_{shot['shot_id']:03d}.mp4"
    burn_subtitle(video_mp4, shot.get("subtitle", ""), final)
    return final

DISPATCHER = {
    "cutaway":      run_cutaway,
    "static_pose":  run_static_pose,
    "talking_head": run_talking_head,
    "action":       run_action,
}

def main(script_path):
    script = json.loads(Path(script_path).read_text())

    results = []
    failed = []
    for shot in script["shots"]:
        shot_type = shot["shot_type"]
        if shot_type not in DISPATCHER:
            print(f"[skip] unknown shot_type {shot_type}")
            continue

        print(f"[start] shot {shot['shot_id']} type={shot_type}")
        try:
            out = DISPATCHER[shot_type](shot)
            results.append(out)
            print(f"[done]  shot {shot['shot_id']} → {out}")
        except Exception as e:
            print(f"[FAIL]  shot {shot['shot_id']}: {e}")
            failed.append(shot["shot_id"])

    # concat 全部 shot
    if results:
        concat_all(results, OUT_DIR / "ep1_FULL.mp4")
    print(f"\nDone. Success: {len(results)}, Failed: {len(failed)}")
    if failed:
        print(f"Failed shots: {failed}")

if __name__ == "__main__":
    main("data/scripts/ep1_script_FINAL.json")

8.6 concat 邏輯

def concat_all(shot_mp4s, output):
    """ffmpeg concat demuxer - 同編碼可無損"""
    list_file = output.with_suffix(".txt")
    list_file.write_text("\n".join([
        f"file '{p.absolute()}'" for p in shot_mp4s
    ]))
    subprocess.run([
        "ffmpeg", "-y",
        "-f", "concat", "-safe", "0",
        "-i", str(list_file),
        "-c", "copy",
        str(output)
    ], check=True)
    list_file.unlink()

為什麼用 concat demuxer 不用 filter:concat filter 會重編碼,21 hr 量產的成品再重編成本太高。要無損請所有 shot 用同 codec + 同解析度 + 同 fps。

8.7 time budget 估算

一集 32 shot 典型分佈:

type 數量 單支時間 小計
cutaway 8 1 min 8 min
static_pose 10 1.5 min 15 min
talking_head 10 3 min 30 min
action 4 7 min 28 min
合計 32 — 81 min

但這是理論值。實際 v14 量產 450 sub-shot 跑了 21 小時,平均一個 shot 2.8 分鐘。為什麼差距大:

  1. Wan22 5B retry:失敗率 ~15%,retry 一次又 6 分鐘
  2. MuseTalk preprocess:每個 shot 要 frame extract + face track,extra 60-90 sec
  3. GFPGAN restore video:每秒影片 restore 約 0.3 倍速
  4. scp 跨機:Mac orchestrator ↔ PC GPU worker scp 每次 2-5 sec
  5. 間隔冷卻:GPU 滿載後 cool down 必要

8.8 GPU lock 機制(避免並行衝突)

5060 Ti 只有 16GB VRAM,跑兩個 workflow 一定 OOM。dispatcher 必須序列化。最簡單的鎖:

from pathlib import Path
import fcntl
import time

GPU_LOCK = Path("/tmp/comfyui_gpu.lock")

class GPULock:
    def __enter__(self):
        self.f = open(GPU_LOCK, "w")
        while True:
            try:
                fcntl.flock(self.f, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except IOError:
                time.sleep(1)
        return self

    def __exit__(self, *args):
        fcntl.flock(self.f, fcntl.LOCK_UN)
        self.f.close()

# 用法
with GPULock():
    img = generate_flux(...)

8.9 corruption filter + auto rerun(v9.1 經驗)

每個 shot 生完要驗:

def is_valid_video(path):
    """檢查 mp4 是否完整"""
    result = subprocess.run([
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1", str(path)
    ], capture_output=True, text=True)
    if result.returncode != 0:
        return False
    try:
        duration = float(result.stdout.strip())
        return duration > 0.5
    except ValueError:
        return False

def run_with_retry(shot, max_retry=3):
    for attempt in range(max_retry):
        try:
            out = DISPATCHER[shot["shot_type"]](shot)
            if is_valid_video(out):
                return out
            print(f"[retry] shot {shot['shot_id']} corrupted, attempt {attempt+1}")
        except Exception as e:
            print(f"[retry] shot {shot['shot_id']} error: {e}")
    return None

8.10 用戶 v14 七項批評(量產前要檢查的清單)

v14 跑完 17.7 分鐘 ep1 後用戶提的 7 項批評,量產前先 self-check:

# 批評 根因 解法
1 嘴模糊 MuseTalk 256² 上限 + GFPGAN restore
2 配音單調 Edge TTS 沒 emotion + SSML map
3 沒 BGM 沒做 量產後加 BGM 軌
4 角色不一致 seed 各 shot 不同 同源 seed
5 沒多角色 cutaway 全女主 增加 character field
6 運鏡失敗 無 ffmpeg vf + camera vf
7 沒動作 cutaway 全 static static_pose + push_in vf

先跑 1 集驗 7 項,再開 30 集量產。別重蹈 21 小時跑完才發現的覆轍。

8.11 驗證

# 1. 跑 sub0001 smoke test(一個 shot 4 型各跑一個)
python episode_v14_runner.py --shot_ids 1,2,3,4

# 2. 用 mpv / VLC 開 final mp4 過一遍
mpv data/final/v14_ep1/ep1_FULL.mp4

# 3. 統計成功率
ls data/final/v14_ep1/shot_*.mp4 | wc -l
# 應該 = 32

# 4. 總時長
ffprobe -v error -show_entries format=duration \
    -of default=noprint_wrappers=1:nokey=1 data/final/v14_ep1/ep1_FULL.mp4
# 應該接近 sum(shot durations)

8.12 常見錯誤

  • 沒做 smoke test 直接量產:21 hr 跑完發現整體不行(v14 痛點)。
  • 不序列化 GPU 任務:OOM 後段 shot 全廢。
  • 不存 progress:中斷重跑全部重新生。
  • concat 時 codec 不一致:必須重編,多花 2-3 hr。

8.13 progress 檔設計

長任務必須能 resume。progress 檔結構:

{
  "episode": "v14_ep1",
  "total_shots": 32,
  "started_at": "2026-05-31T08:00:00",
  "last_update": "2026-05-31T19:23:45",
  "completed": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
  "failed": [],
  "in_progress": [19],
  "pending": [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
  "per_shot_stats": {
    "1": {"type": "cutaway", "duration": 65, "attempts": 1},
    "2": {"type": "static_pose", "duration": 88, "attempts": 1},
    "3": {"type": "talking_head", "duration": 195, "attempts": 2}
  }
}

resume 邏輯:

def resume_episode(episode_name):
    progress = load_progress(episode_name)
    pending = progress["pending"] + progress["in_progress"]
    print(f"Resuming {episode_name}: {len(pending)} shots remaining")

    for shot_id in pending:
        shot = get_shot_by_id(shot_id)
        try:
            output = run_shot(shot)
            progress["completed"].append(shot_id)
            progress["in_progress"] = []
        except Exception as e:
            progress["failed"].append(shot_id)
            print(f"FAIL shot {shot_id}: {e}")
        save_progress(progress)

每完成一個 shot 就 save 一次,斷電也只丟最後 1 shot。

8.14 batch shot 並行策略(高階)

5060 Ti 序列化已說過,但如果你有 2 張卡 / 雲端 GPU pool:

from multiprocessing import Pool

def run_shot_on_gpu(args):
    shot, gpu_id = args
    import os
    os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
    return run_shot(shot)

def parallel_run(shots, num_gpus=2):
    args = []
    for i, shot in enumerate(shots):
        gpu_id = i % num_gpus
        args.append((shot, gpu_id))

    with Pool(num_gpus) as p:
        results = p.map(run_shot_on_gpu, args)
    return results

注意:要把 ComfyUI 也跑兩個 instance(不同 port),不能共用一個 instance。

8.15 dispatcher logging 規範

每個 shot 都要 log:

import logging
from datetime import datetime

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(f"/tmp/dispatcher_{datetime.now():%Y%m%d_%H%M%S}.log"),
        logging.StreamHandler(),
    ],
)
log = logging.getLogger("dispatcher")

def run_shot(shot):
    log.info(f"START shot {shot['shot_id']} type={shot['shot_type']}")
    t0 = time.time()
    try:
        output = DISPATCHER[shot["shot_type"]](shot)
        dt = time.time() - t0
        log.info(f"DONE  shot {shot['shot_id']} in {dt:.1f}s → {output}")
        return output
    except Exception as e:
        dt = time.time() - t0
        log.error(f"FAIL  shot {shot['shot_id']} after {dt:.1f}s: {e}")
        raise

跑完後可以從 log grep 統計:

grep "DONE" /tmp/dispatcher_*.log | wc -l    # 成功幾個
grep "FAIL" /tmp/dispatcher_*.log | wc -l    # 失敗幾個
grep "DONE" /tmp/dispatcher_*.log | \
    awk '{print $NF}' | sort -n | tail -5    # 最慢 5 個

8.16 量產 ETA 估算函數

跑到一半想知道還要多久:

def estimate_eta(progress):
    """根據已完成 shot 平均時間估算剩餘"""
    completed = progress["completed"]
    if len(completed) < 3:
        return "計算中..."

    stats = progress["per_shot_stats"]
    durations = [stats[str(s)]["duration"] for s in completed]
    avg = sum(durations) / len(durations)

    remaining = len(progress["pending"]) + len(progress["in_progress"])
    eta_sec = avg * remaining
    eta_hr = eta_sec / 3600

    return f"~{eta_hr:.1f} hr ({remaining} shots × {avg:.0f} sec avg)"

8.17 跨平台部署:Mac + PC 協作模式

實戰部署:

Mac M4 16GB(orchestrator)
├── 劇本生成 (Ollama / Claude)
├── 後製 ffmpeg
├── progress 管理
└── 派工 SSH → PC

PC RTX 5060 Ti 16GB(GPU worker)
├── ComfyUI :8188
├── Flux + LoRA 跑 anchor
├── Wan22 5B 跑 i2v
├── MuseTalk 對嘴
└── 結果回傳 → Mac

為什麼這樣分:

  • Mac 跑長任務的 orchestrator(不會 idle terminate)
  • Mac 跑 ffmpeg(M4 NEON 指令很快)
  • PC 跑 CUDA 模型(5060 Ti 是唯一 GPU)

SSH config 範例:

# ~/.ssh/config (Mac)
Host pc
    HostName 192.168.0.15
    User user
    Port 22
    IdentityFile ~/.ssh/id_ed25519
    ServerAliveInterval 15
    ServerAliveCountMax 4

設好後 ssh pc 直接連,不用打 IP。

8.18 dispatcher CLI 設計

dispatcher 主程式應該有完整的 CLI:

import argparse

def main():
    p = argparse.ArgumentParser(description="Drama Factory dispatcher")
    p.add_argument("--script", required=True, help="劇本 JSON 路徑")
    p.add_argument("--episode", required=True, help="episode 名 (用於 output dir)")
    p.add_argument("--shot_ids", help="只跑特定 shot, 逗號分隔 (e.g. 1,2,3)")
    p.add_argument("--resume", action="store_true", help="從 progress 恢復")
    p.add_argument("--smoke", action="store_true", help="smoke test 只跑前 4 shot")
    p.add_argument("--dry_run", action="store_true", help="不真跑,只 print 計畫")
    p.add_argument("--concurrency", type=int, default=1, help="並行 worker 數")
    p.add_argument("--gpu_id", type=int, default=0, help="用哪張 GPU")
    p.add_argument("--log_level", default="INFO")
    args = p.parse_args()

    if args.dry_run:
        plan_only(args)
    elif args.smoke:
        smoke_test(args)
    elif args.resume:
        resume(args)
    elif args.shot_ids:
        run_specific(args)
    else:
        run_all(args)

範例用法:

# 完整量產
python dispatcher.py --script ep1.json --episode v14_ep1

# 只跑 smoke
python dispatcher.py --script ep1.json --episode v14_ep1 --smoke

# 只跑 shot 5, 8, 12
python dispatcher.py --script ep1.json --episode v14_ep1 --shot_ids 5,8,12

# 從上次中斷恢復
python dispatcher.py --script ep1.json --episode v14_ep1 --resume

# dry run 看 plan
python dispatcher.py --script ep1.json --episode v14_ep1 --dry_run

8.19 dispatcher 異常恢復策略

跑 21 hr 過程中各種異常都會碰到。對應策略:

異常 偵測 恢復
ComfyUI crash HTTP 連不到 自動重啟 ComfyUI + retry shot
GPU OOM log 含 "out of memory" pkill ComfyUI + 等 30 sec + 重啟
SSH 斷線 ssh exit code != 0 重連 5 次,仍失敗就 abort
Disk 滿 df -h 出 100% 立即 abort + email 通知
溫度過高 sensors > 85°C pause 5 分鐘等冷卻
def health_check_loop():
    """背景跑,每 30 sec check 一次"""
    while True:
        if not is_comfyui_alive():
            log.warning("ComfyUI dead, restarting...")
            restart_comfyui()
        if get_gpu_temp() > 85:
            log.warning("GPU too hot, pausing 5 min")
            pause_dispatcher()
            time.sleep(300)
        if get_disk_free_pct() < 5:
            log.error("Disk almost full, aborting")
            abort_dispatcher()
        time.sleep(30)

第 9 章:後製:運鏡 vf / BGM / 字幕 / 補幀

9.1 目標

把生出來的純畫面變成有運鏡、有 BGM、有字幕、有補幀的成品。掌握 ffmpeg 一行運鏡公式、.ass 字幕燒入、BGM duck mix。

9.2 前置條件

  • shot mp4 已生好
  • 字幕 SRT / ASS 已準備
  • BGM mp3 / wav 已選好

9.3 步驟:camera-aware ffmpeg vf

七種運鏡的 ffmpeg vf 公式:

# 1. push_in(推鏡,由遠拉近)
ffmpeg -i input.mp4 -vf "
  zoompan=z='min(zoom+0.0015,1.5)':d=72:s=720x1280:fps=24
" output.mp4

# 2. pull_back(拉鏡,由近退遠)
ffmpeg -i input.mp4 -vf "
  zoompan=z='if(eq(on,0),1.5,max(zoom-0.0015,1.0))':d=72:s=720x1280:fps=24
" output.mp4

# 3. whip_pan(甩鏡,快速橫向掃過)
ffmpeg -i input.mp4 -vf "
  crop=720:1280:'min(in_w-720,t*200)':0
" output.mp4

# 4. pan_left(向左平移)
ffmpeg -i input.mp4 -vf "
  crop=720:1280:'max(0,(in_w-720)-t*30)':0
" output.mp4

# 5. pan_right(向右平移)
ffmpeg -i input.mp4 -vf "
  crop=720:1280:'min(in_w-720,t*30)':0
" output.mp4

# 6. low_angle(低角度仰拍 - 簡化版用 perspective)
ffmpeg -i input.mp4 -vf "
  perspective=x0=0:y0=H*0.1:x1=W:y1=H*0.1:x2=0:y2=H:x3=W:y3=H
" output.mp4

# 7. over_shoulder(過肩鏡 - 用 vignette 模擬肩膀虛影)
ffmpeg -i input.mp4 -vf "
  vignette=PI/4:x0='0.1*w':y0='0.7*h',
  curves=preset=darker
" output.mp4

9.4 Python 包裝:build_camera_vf

def build_camera_vf(camera, duration_sec, fps):
    """根據 camera 名稱回傳 ffmpeg vf 字串"""
    total_frames = int(duration_sec * fps)
    if camera == "push_in":
        return f"zoompan=z='min(zoom+0.0015,1.5)':d={total_frames}:s=720x1280:fps={fps}"
    elif camera == "pull_back":
        return f"zoompan=z='if(eq(on,0),1.5,max(zoom-0.0015,1.0))':d={total_frames}:s=720x1280:fps={fps}"
    elif camera == "whip_pan":
        return f"crop=720:1280:'min(in_w-720,t*200)':0"
    elif camera == "pan_left":
        return f"crop=720:1280:'max(0,(in_w-720)-t*30)':0"
    elif camera == "pan_right":
        return f"crop=720:1280:'min(in_w-720,t*30)':0"
    elif camera == "low_angle":
        return "perspective=x0=0:y0=H*0.1:x1=W:y1=H*0.1:x2=0:y2=H:x3=W:y3=H"
    elif camera == "over_shoulder":
        return "vignette=PI/4:x0='0.1*w':y0='0.7*h',curves=preset=darker"
    else:
        return "null"  # 無運鏡

9.5 步驟:字幕燒入(.ass 格式)

繁體中文字幕推薦用 .ass,可控字體大小、位置、顏色:

[Script Info]
Title: Drama Factory ep1
ScriptType: v4.00+

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Noto Sans CJK TC,26,&H00FFFFFF,&H00000000,&H80000000,1,1,2,1,2,30,30,50,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.30,0:00:03.40,Default,,0,0,0,,你跟她在一起多久了?
Dialogue: 0,0:00:03.70,0:00:06.80,Default,,0,0,0,,你怎麼可以這樣對我?

字幕參數:

欄位 推薦值 說明
Fontname Noto Sans CJK TC 繁中要這個
Fontsize 26 v14 32 太大,v17 v4 改 26
PrimaryColour &H00FFFFFF 白色
OutlineColour &H00000000 黑邊
Outline 2 邊框寬度
Alignment 2 底部置中
MarginV 50 距底邊距
# 字幕燒入
ffmpeg -i input.mp4 -vf "subtitles=subs.ass" -c:a copy output.mp4

9.6 步驟:BGM 混音 duck(自動側鏈壓縮)

BGM 音量在台詞時自動壓低 6 dB:

ffmpeg -i video.mp4 -i bgm.mp3 \
    -filter_complex "
        [1:a]volume=-22dB[bgm];
        [0:a][bgm]sidechaincompress=
            threshold=0.05:ratio=8:attack=20:release=200,
        amix=inputs=2:duration=first
    " \
    -c:v copy \
    output_bgm.mp4

參數說明:

  • volume=-22dB:BGM 基礎音量(背景樂)
  • threshold=0.05:人聲音量超過此值就觸發壓縮
  • ratio=8:壓縮比 8:1
  • attack=20:壓縮啟動 20ms
  • release=200:人聲停止後 200ms 釋放

BGM 推薦 dB 範圍:

場景 BGM 音量
旁白為主 -28 dB
對話為主 -22 dB
動作高潮 -16 dB
純畫面(無對白) -10 dB

9.7 步驟:RIFE 補幀 24→48fps

cd /home/user/RIFE
python inference_video.py \
    --multi 2 \
    --video input_24fps.mp4 \
    --output output_48fps.mp4 \
    --scale 1.0

--multi 2 是 2 倍補幀,24 → 48fps。要 4 倍補幀(24 → 96)用 --multi 4。

注意:補幀對「靜止 → 突然動」會 ghosting,建議只對 action shot 補幀,static / talking head 不用。

9.8 步驟:unsharp 雙層銳化

# v15 加 - 銳化兩次(先強後弱,避免破壞質感)
ffmpeg -i input.mp4 -vf "
    unsharp=luma_msize_x=5:luma_msize_y=5:luma_amount=0.8,
    unsharp=luma_msize_x=3:luma_msize_y=3:luma_amount=0.5
" -c:a copy output.mp4

對 256² MuseTalk 輸出補銳很有用。

9.9 步驟:最終 mux(合視訊+音訊+字幕+BGM)

ffmpeg -y \
    -i shot_video.mp4 \
    -i shot_voice.wav \
    -i bgm.mp3 \
    -filter_complex "
        [2:a]volume=-22dB[bgm];
        [1:a][bgm]sidechaincompress=
            threshold=0.05:ratio=8:attack=20:release=200,
        amix=inputs=2:duration=first[mixed];
        [0:v]subtitles=shot.ass,unsharp=5:5:0.8[v]
    " \
    -map "[v]" -map "[mixed]" \
    -c:v libx264 -preset medium -crf 18 \
    -c:a aac -b:a 192k \
    -pix_fmt yuv420p \
    shot_final.mp4

9.10 驗證

# 1. 影片資訊
ffprobe -v error -show_streams shot_final.mp4 | grep -E "codec_name|width|height|sample_rate"

# 2. 字幕真的燒進去(不是外掛)
ffmpeg -i shot_final.mp4 -map 0:s? 2>&1 | grep "no subtitle"

# 3. 音訊 BGM 跟人聲音量差
ffmpeg -i shot_final.mp4 -af "volumedetect" -f null - 2>&1 | grep "mean_volume"

# 4. 用人耳確認 BGM 在說話時有壓下去
mpv shot_final.mp4

9.11 常見錯誤

  • 字幕沒燒入直接用 SRT:YouTube/TikTok 上傳可能轉碼丟字幕。
  • BGM 沒 duck:BGM 蓋掉人聲,觀眾退出。
  • 補幀補在 talking head:嘴部 ghosting,比不補還糟。
  • unsharp 過度:細節變雜訊。
  • 字體沒裝:ffmpeg 找不到 Noto Sans CJK TC,字幕顯示為「□□□」。
  • MarginV 太大:字幕被裁掉,YouTube Shorts 9:16 比例特別容易。

9.12 SFX 音效設計

短劇沒 SFX 像沒鹽的湯。基本 SFX 庫:

場景 SFX 時長
巴掌 slap_001.wav 0.5 sec
摔東西 glass_break.wav 1.5 sec
開門 door_open.wav 1.0 sec
腳步聲 footstep_loop.wav loop
手機鈴 phone_ring.wav 3.0 sec
心跳加速 heartbeat_fast.wav loop
武打劍鳴 sword_swing.wav 0.8 sec
拔劍 sword_draw.wav 1.2 sec
衣物摩擦 cloth_swish.wav 0.5 sec
風聲 wind_loop.wav loop

SFX 加進 mix:

ffmpeg -y \
    -i shot_video.mp4 \
    -i shot_voice.wav \
    -i bgm.mp3 \
    -i sfx_slap.wav \
    -filter_complex "
        [3:a]adelay=2500|2500[sfx];
        [2:a]volume=-22dB[bgm];
        [1:a][bgm]sidechaincompress=threshold=0.05:ratio=8[mix1];
        [mix1][sfx]amix=inputs=2:duration=longest[final]
    " \
    -map "0:v" -map "[final]" \
    -c:v copy -c:a aac \
    output.mp4

adelay=2500|2500 表示 SFX 在 2500ms 開始播(兩個 channel 同步)。

9.13 ffmpeg 轉場效果 cookbook

不同轉場的 ffmpeg 一行:

# 1. cross-fade (淡入淡出)
ffmpeg -i a.mp4 -i b.mp4 \
    -filter_complex "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=2.5" \
    out.mp4

# 2. wipe-left
ffmpeg -i a.mp4 -i b.mp4 \
    -filter_complex "[0:v][1:v]xfade=transition=wipeleft:duration=0.5:offset=2.5" \
    out.mp4

# 3. radial
ffmpeg -i a.mp4 -i b.mp4 \
    -filter_complex "[0:v][1:v]xfade=transition=radial:duration=0.5:offset=2.5" \
    out.mp4

# 4. zoom-in fade
ffmpeg -i a.mp4 -i b.mp4 \
    -filter_complex "
        [0:v]scale=720:1280,setpts=PTS-STARTPTS[v0];
        [1:v]scale=720:1280,setpts=PTS-STARTPTS+2.5/TB[v1];
        [v0][v1]xfade=transition=fadeblack:duration=0.3:offset=2.7
    " \
    out.mp4

# 5. flash white
ffmpeg -i a.mp4 -i b.mp4 \
    -filter_complex "[0:v][1:v]xfade=transition=fadewhite:duration=0.2:offset=2.8" \
    out.mp4

9.14 LUT / color grading

統一色調讓所有 shot 看起來像同一集:

# 套 cinematic LUT
ffmpeg -i input.mp4 -vf "lut3d=cinematic.cube" output.mp4

# 自己調色(暖色調 + 增加對比)
ffmpeg -i input.mp4 -vf "
    eq=contrast=1.1:saturation=1.1:brightness=0.02,
    curves=preset=warmer
" output.mp4

# 暗黑風(暗部更暗、亮部更亮)
ffmpeg -i input.mp4 -vf "
    curves=preset=darker,
    eq=contrast=1.2:saturation=0.9
" output.mp4

LUT 檔可以從 freeluts.com 下載,或用 DaVinci Resolve 自己調出來 export .cube。

9.15 字幕高階:雙語對照

如果要同時放繁中跟英文:

[V4+ Styles]
Style: ZH,Noto Sans CJK TC,28,&H00FFFFFF,&H00000000,&H80000000,1,1,2,1,2,30,30,80,1
Style: EN,Arial,18,&H00CCCCCC,&H00000000,&H80000000,0,1,1,1,2,30,30,40,1

[Events]
Dialogue: 0,0:00:00.30,0:00:03.40,ZH,,0,0,0,,你跟她在一起多久了?
Dialogue: 0,0:00:00.30,0:00:03.40,EN,,0,0,0,,How long have you been with her?

MarginV 80 / 40 讓中文在底部、英文在更底部。

9.16 後製成品 quality gates

每集成品要過:

def final_quality_check(ep_mp4):
    checks = []

    # 1. 時長
    dur = get_duration(ep_mp4)
    checks.append(("duration", 180 < dur < 1200, f"{dur:.1f}s"))

    # 2. 解析度
    w, h = get_resolution(ep_mp4)
    checks.append(("resolution", (w, h) in [(720, 1280), (1280, 720)], f"{w}x{h}"))

    # 3. 影音同步
    streams = get_streams(ep_mp4)
    checks.append(("has_video", any(s["codec_type"] == "video" for s in streams), ""))
    checks.append(("has_audio", any(s["codec_type"] == "audio" for s in streams), ""))

    # 4. 字幕燒進去(不是 stream)
    has_sub_stream = any(s["codec_type"] == "subtitle" for s in streams)
    checks.append(("subtitle_burned", not has_sub_stream, ""))

    # 5. 平均位元率 > 1 Mbps(品質下限)
    bitrate = get_bitrate(ep_mp4)
    checks.append(("bitrate", bitrate > 1_000_000, f"{bitrate/1e6:.1f}Mbps"))

    for name, ok, val in checks:
        status = "OK" if ok else "FAIL"
        print(f"  [{status}] {name}: {val}")

    return all(ok for _, ok, _ in checks)

不過 quality gates 就不發布,自我把關。


第 10 章:v15 修補方案:SSML + 運鏡 + BGM + 同源 seed

10.1 目標

把 v14 量產 17.7 分鐘 ep1 後用戶提的 7 項批評全部修掉。

10.2 修補對照表

# v14 缺陷 v15 解法 驗證
1 嘴模糊 + GFPGAN restore only_center_face 肉眼看嘴
2 配音單調 SSML 22 emotion map 角色心情變化
3 沒 BGM 加 BGM 軌 -22 to -28 dB + duck 用耳機聽
4 角色不一致 同源 seed(base + shot_id) 跨 shot 看臉
5 沒多角色 cutaway 增加 character field 統計多角色出現
6 運鏡失敗 7 種 camera-aware ffmpeg vf mpv 看運鏡
7 沒動作 static_pose 改 zoompan + push_in 確認有動

10.3 v15 完整 dispatcher 增量

def run_talking_head_v15(shot):
    """v15 talking_head with SSML + GFPGAN restore"""
    char = shot["character"]
    lora_name, trigger = CHAR_LORA[char]
    prompt = f"{trigger}, {AGE_PRESERVE[char]}, {shot['anchor_prompt']}"

    # v15: 同源 seed
    base_seed = SEED_BASE.get(char, 42_000_000)
    seed = base_seed + shot["shot_id"]

    anchor_img = generate_flux(prompt, lora=lora_name, seed=seed)

    base_mp4 = make_base_video(anchor_img, shot["duration_sec"])

    # v15: SSML emotion TTS
    audio = OUT_DIR / f"shot_{shot['shot_id']:03d}.wav"
    voice = CHAR_VOICE[char]
    emotion = shot.get("emotion", "neutral")
    synthesize_with_emotion(shot["dialogue"], voice, emotion, audio)

    synced_mp4 = run_musetalk(base_mp4, audio)

    # v15: GFPGAN restore only_center_face
    restored_mp4 = OUT_DIR / f"shot_{shot['shot_id']:03d}_restored.mp4"
    subprocess.run([
        "python", "inference_gfpgan.py",
        "-i", str(synced_mp4),
        "-o", str(restored_mp4),
        "-v", "1.4",
        "--only_center_face",
        "--bg_upsampler", "none",
    ], check=True)

    # v15: 字幕燒入 + BGM duck mix
    final = mux_subtitle_bgm(
        video=restored_mp4,
        voice=audio,
        bgm=BGM_PATH,
        subtitle_text=shot["subtitle"],
        output=OUT_DIR / f"shot_{shot['shot_id']:03d}.mp4",
    )
    return final


SEED_BASE = {
    "female_lead":            42_000_000,
    "male_lead":              42_001_000,
    "antagonist_ex_husband":  42_002_000,
    "daughter":               42_003_000,
    "antagonist_secretary":   42_004_000,
    "assistant":              42_005_000,
    # ...
}

10.4 為什麼 v15 還是 smoke only

v15 修完 7 項缺陷,但只跑了 sub0001 確認就轉動漫了。原因:

  • 真人短劇即使全修補,5060 Ti 16GB 仍跑不到「商業級」(85 分)
  • 動漫對「角色一致性」要求低 + IPAdapter 替代 LoRA
  • 動漫對「對嘴」要求低,可以做漫剧無對白

但動漫 460-shot batch 翻車後(v16),最後結論:5060 Ti 16GB 已天花板,轉雲端 Dreamina / Kling。

10.5 v17 武俠 4 連版的最後嘗試

# v17_wuxia_v4.py - 強化版鎖角色 prompt
HERO_LOCK = (
    "(same handsome wuxia warrior:1.5), "
    "(same long black hair tied with red ribbon:1.4), "
    "(same crimson red silk robe with gold dragon embroidery:1.5), "
    "(same young 25 years old strong man), "
    "(same sharp piercing eyes:1.2), "
    "(holding same jade-hilted sword:1.4), "
    "masculine warrior, Asian male, dark hair, red clothing"
)

CONSISTENCY_LOCK = (
    "same character, same red robe, same long black hair, "
    "same red ribbon, same jade sword, "
    "consistent appearance throughout"
)

把 (...:1.5) weight 拉到 1.5+,並在 anchor 跟 motion 都重複關鍵字。仍無法解決:

  • 武打太慢(Wan22 5B 81 frame 物理上限)
  • 臉扭曲(5B 模型訓練的固有限制)
  • 配音慢(Edge TTS 免費版受限)

最終 honest admit:這三個問題本地解不了。

10.6 雲端 vs 本地最終比較

維度 本地 5060 Ti Dreamina Kling 1.6 Runway Gen-3
一次性硬體 NT$15,000 0 0 0
每月電費 NT$650 0 0 0
每 clip 成本 "免費" 免費(225 點/天) NT$10 $1
品質 60 分 90 分 95 分 98 分
一致性 差 好 極好 極好
30 集成本 "免費" + 1 個月血淚 免費(受配額) NT$3,000 $300
上手時間 30+ 天 30 分鐘 30 分鐘 1 小時

對自媒體量產:Dreamina 配額內免費 + IPAdapter 內建一致性 = 30 分鐘上手 + 90 分品質。本地 stack 適合學習 / 客製 / 隱私需求,不適合純效率。

10.7 v15 同源 seed 的數學

為什麼同源 seed 能改善跨 shot 一致性?

Flux + LoRA 的 noise 起點 ≈ seed → Gaussian noise → 經 LoRA 引導收斂到角色臉。同源 seed = 同樣的 noise 起點 = 同樣的收斂路徑 = 同樣的細節(眼間距、鼻型、嘴角)。

seed 42000001 → noise A → 引導 → soxw 臉 v1
seed 42000002 → noise B → 引導 → soxw 臉 v1' (細微不同)
seed 42000003 → noise C → 引導 → soxw 臉 v1'' (細微不同)

雖然每個 shot 略有差異,但因為起點接近(base_seed + shot_id 是連續整數),差異比完全 random seed 小很多。

實測:v14 random seed 跨 shot 一致性主觀分 60,v15 同源 seed 跨 shot 一致性主觀分 75。仍不是 90,但有明顯改善。

10.8 v15 全套 7 件套 ffmpeg 一行

把 v15 修補的 7 件全部整合進一個 ffmpeg 指令:

ffmpeg -y \
    -i shot_video.mp4 \
    -i shot_voice.wav \
    -i bgm.mp3 \
    -filter_complex "
        [0:v]
            zoompan=z='min(zoom+0.0015,1.3)':d=72:s=720x1280:fps=24,
            unsharp=5:5:0.8,
            unsharp=3:3:0.5,
            subtitles=shot.ass
        [v];
        [2:a]volume=-22dB[bgm];
        [1:a][bgm]sidechaincompress=
            threshold=0.05:ratio=8:attack=20:release=200,
        amix=inputs=2:duration=first
        [a]
    " \
    -map "[v]" -map "[a]" \
    -c:v libx264 -preset medium -crf 18 \
    -c:a aac -b:a 192k \
    -pix_fmt yuv420p \
    -movflags +faststart \
    final.mp4

-movflags +faststart 把 moov atom 放檔頭,YouTube/TikTok 邊下載邊播放友善。

10.9 動漫路線(v16)為什麼也翻車

v16 轉動漫的判斷:

  • 動漫對「角色一致性」要求低(任何畫風都可接受 stylization drift)
  • IPAdapter Plus 取代 LoRA(reference 角色一張圖就可)
  • 無對嘴(漫剧用旁白 + 心理獨白)

理論上應該成功,實際 460-shot batch 翻車原因:

  • NoobAI XL Vpred 對長 prompt 反應差:超過 200 token 後忽略後半
  • IPAdapter weight 1.5+ ghosting:要鎖角色 weight 拉高,畫面就出現殘影
  • 50 角色 batch 顯卡溫度過熱:跑 8 小時後 GPU 80°C+ throttling,後段品質崩
  • 動漫對「鏡頭運動」需求更高:但 IPAdapter 不支援 i2v

結論:動漫不是萬靈丹,本地 stack 還是受 5060 Ti 16GB 物理限制。

10.10 武俠 v17 4 連版的具體失敗

每版改了什麼、為什麼還是失敗:

v17 v1:office 愛情劇 10 sec demo - 改:沈逸 + 林夏 兩角色 office 場景 - 失敗:雙人合照角色臉漂

v17 v2:武俠拔劍 10 sec - 改:單角色武俠戲,避雙人問題 - 失敗:SFX 太弱、旁白不夠大聲、台詞爛、鏡頭近、動作慢

v17 v3:v2 修 - 改:SFX +6dB、旁白 +25% volume、改寫台詞、鏡頭拉遠 - 失敗:前 3.35 sec 白屏(fade alpha bug)+ Wan22 cfg 8.0 timeout 1800s + ComfyUI VRAM zombie 15.8 GB

v17 v4:v3 修 - 改:拿掉 fade、cfg 降 6.5、字幕 26 + MarginV 50、HERO_LOCK weight 1.5、IPAdapter weight 0.95 - 失敗:武打太慢 / 臉扭曲 / 配音慢

v4 三項 honest admit 失敗原因:

項目 根因 本地解 雲端解
武打太慢 Wan22 5B 81 frame 物理上限 無 Kling 1.6 動作戲很強
臉扭曲 5B 訓練 capacity 無 Runway Gen-3
配音慢 Edge TTS rate ±20% F5-TTS 自己 clone ElevenLabs 完整 SSML

3 個都需要更強的工具,不是 prompt 工程能解。

10.11 後續轉雲端的整合架構

如果你決定 v15 之後轉雲端 Dreamina:

本地(保留):
├── Layer 1 劇本:Ollama qwen3:14b
├── Layer 2 圖像:Flux + LoRA(角色 anchor)
├── Layer 4 配音:Edge TTS(旁白)
└── Layer 7 後製:ffmpeg

雲端(新加):
├── Layer 3 影片:Dreamina i2v(吃 Layer 2 的 anchor)
└── Layer 5 對嘴:HeyGen / Synthesia

混合架構優勢:

  • LoRA 角色一致性保留(自己訓的最好用)
  • 雲端解決 i2v 動作 + 對嘴品質
  • 成本可控(Dreamina 每日 225 點配額)

實測一集成本(混合):

  • 本地 GPU 時間:3 hr × 電費 = NT$15
  • 雲端 Dreamina:免費(配額內)
  • 雲端 HeyGen:1 hr × $1 = NT$30
  • 合計:NT$45/集

vs 全本地 NT$80/集,但品質從 60 分跳到 80 分。


第 11 章:47 個坑完整清單

下面是 18 天 17 版踩過的 47 個坑,按分類排序、編號連續。每個坑四段制:症狀 / 根因 / 解法 / 預防。

A. 硬體 / 環境(10 個)

坑 1:WSL2 每 28 秒 idle terminate 殺長任務

症狀:跑 30 小時 LoRA 訓練,跑 10 小時後 WSL 整個消失,所有 process 死光。

根因:WSL2 預設 vmIdleTimeout=28(秒),idle 超過就 terminate VM。

解法:在 %USERPROFILE%\.wslconfig 設 vmIdleTimeout=-1(永不 idle),然後 wsl --shutdown 後重啟。

預防:Mac 端開一條 SSH 跑 sleep infinity 當 keeper:nohup ssh user@PC "wsl -d Ubuntu -- sleep 108000" &,確保 WSL 整個訓練週期內活著。

坑 2:WSL2 networkingMode=mirrored 完全無法對外曝露 port

症狀:ComfyUI listen 0.0.0.0:8188,但連 Windows 本機 curl localhost:8188 都 connection refused。

根因:mirrored 模式跟 Windows 防火牆/portproxy 有未解的衝突,2026 年中以前不可用。

解法:改用預設 NAT 模式 + netsh portproxy 轉發到 WSL 內部 IP(如 172.27.230.199)。.wslconfig 拿掉 networkingMode=mirrored。

預防:絕對不要再用 mirrored。Windows 11 Build 25xxx 之前 mirrored 都不穩。

坑 3:PyTorch < 2.6 不支援 Blackwell sm_120

症狀:5060 Ti 跑 ComfyUI,CUDA 部分全 fallback CPU,inference 速度比 CPU only 還慢。

根因:5060 Ti 是 Blackwell 架構 sm_120,PyTorch 2.5 以下沒編譯 sm_120 wheel。

解法:pip install torch==2.6.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128,必須 cu128 + 2.6+。

預防:買 5060 Ti / 5090 之前先確認你的 framework 支援 sm_120。

坑 4:duix.avatar 3 個 container 偷 6.5GB GPU

症狀:明明沒跑任何 AI 任務,nvidia-smi 顯示 VRAM 已用 6.5GB。

根因:之前裝過 duix.avatar 數位人 demo,3 個 docker container 開機自動啟動,永久占 GPU。

解法:docker ps 找到 duix-avatar-* container,全部 docker stop + docker update --restart=no。

預防:每次開工前跑 docker ps | grep duix 確認沒有殘留 container 偷 GPU。

坑 5:ComfyUI VRAM zombie(crash 後 VRAM 不釋放)

症狀:ComfyUI crash 後重啟,nvidia-smi 顯示 15.8GB VRAM 占用但 GPU util 0%。

根因:CUDA process 沒清乾淨,殘留 memory 未釋放。

解法:

pkill -9 -f "ComfyUI"
fuser -k /dev/nvidia*
sleep 3
# 重啟 ComfyUI

預防:ComfyUI 加 --gpu-only 或定期重啟。長期跑加 systemd watchdog 自動重啟。

坑 6:Windows cp950 console 殺 ComfyUI training

症狀:FluxTrainer 跑訓練,到第 5-10 step 突然 UnicodeDecodeError: 'cp950' codec can't decode byte。

根因:Windows 預設 console 編碼是 cp950(繁中),訓練時的 caption / log 含 UTF-8 字元時炸。

解法:寫一個 UTF-8 .bat:

@echo off
chcp 65001 > nul
set PYTHONIOENCODING=utf-8
python_embeded\python.exe -s ComfyUI\main.py

預防:所有 Windows native 啟動腳本一律寫 chcp 65001 + PYTHONIOENCODING=utf-8。

坑 7:Port 8189 被 iphlpsvc portproxy 占住

症狀:把 ComfyUI 啟在 :8189,Windows 那邊根本連不到。

根因:Windows 11 的 IP Helper service(iphlpsvc)內部 portproxy 機制占用 8189。

解法:改用 8188(或其他 port)。不要動 iphlpsvc 服務(會壞掉其他 LAN 功能)。

預防:選 port 之前用 netstat -ano | findstr LISTEN 看占用情況。

坑 8:netsh portproxy connectaddress 不能是 PC 自己 LAN IP

症狀:從 LAN 連 PC 8188,永遠 empty reply。

根因:portproxy 規則 listenaddress=0.0.0.0 connectaddress=192.168.0.15 變成 self-loop。

解法:connectaddress 必須是 WSL 內部 IP(如 172.27.230.199),不是 PC 自己的 LAN IP。

預防:寫 PowerShell script 自動抓 WSL IP 後設 portproxy。

坑 9:PC SSH key-based auth 被 PubkeyAuthentication 優先擋

症狀:sshpass 用密碼方式連 PC 永遠 connection refused,或一直跳 "Permission denied (publickey)"。

根因:openssh 預設 PubkeyAuthentication=yes 優先,sshpass 走不到 password 階段。Mac 自動把 ~/.ssh/id_* 都試一遍,先試完 publickey 才到 password;如果有任何 key 是 PC 不認的,PC 早就把連線斷了。

解法:

# 短期:強制走 password
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password user@192.168.0.15

# 長期:設好 key-based auth
ssh-keygen -t ed25519 -C "drama-factory"
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@192.168.0.15

預防:首次設定就跑 ssh-copy-id user@PC,後續永遠 key-based。並在 ~/.ssh/config 寫 Host alias 簡化操作。如果同一台 PC 多個 user 共用 key,要把 IdentityFile 明確指定 path,避免互相覆蓋。

坑 10:HF BFL Flux 全 gated(schnell 也要 token)

症狀:下載 flux1-schnell 或 flux1-dev,403 Forbidden。

根因:2026-05 之後 BFL 把所有 Flux model 設為 gated,需要 HF token + 接受條款。

解法:走 ComfyUI portable 自帶的 fp8 single-file(不在 HF gated 範圍)。或申請 HF token 後 huggingface-cli login。

預防:先在 HF 網頁打開 https://huggingface.co/black-forest-labs/FLUX.1-dev 點 Accept,再 download。

B. ComfyUI / 模型載入(10 個)

坑 11:ComfyUI 安裝在 /mnt/c/ 啟動慢 10 倍

症狀:ComfyUI 啟動要 5+ 分鐘,import 期間 disk IO 滿。

根因:WSL2 的 /mnt/c/ 走 9P 協議,檔案系統慢 10 倍。

解法:ComfyUI 本體裝到 /home/user/(ext4 native),只把 models 走 symlink 到 /mnt/c/AI/models 省空間。

預防:所有 WSL Python project 都裝在 ext4,永遠不裝在 /mnt/c/。

症狀:在 WSL 建 symlink 指 Windows 路徑,ComfyUI 找不到 model。

根因:WSL symlink 對 Windows 不透明。

解法:用 ComfyUI 的 extra_model_paths.yaml 配置 Windows 路徑,不依賴 symlink。

預防:跨 OS 模型路徑一律走 yaml 配置,不走 symlink。

坑 13:ComfyUI custom_nodes 依賴版本互卡

症狀:裝完 IPAdapter,ReActor 突然不能用;裝完 FluxTrainer,整個 ComfyUI 起不來。

根因:custom_nodes 各自要求不同 transformers / numpy 版本,相互覆蓋。

解法:建立統一 requirements.lock,所有 custom_nodes 用同版本 transformers==4.57.6 / numpy==1.26.4 / accelerate==0.34。

預防:每裝一個 custom_node 跑 pip check 確認依賴一致。

坑 14:ClipVision model not found(IPAdapter 載不到)

症狀:IPAdapter UnifiedLoader 跑就報 "ClipVision model not found"。

根因:IPAdapter 預期的 clip vision 檔名是 CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors,但你下載的可能叫 clip_vision_h.safetensors。

解法:

cd /home/user/ComfyUI/models/clip_vision
cp clip_vision_h.safetensors CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors

預防:IPAdapter 安裝完先讀 README 的命名規則。

坑 15:IPAdapter 缺 weight_type input → HTTP 400

症狀:IPAdapter node submit workflow,回 400 Bad Request。

根因:新版 IPAdapter 增加 weight_type 必填欄位,舊 workflow 沒帶。

解法:workflow JSON 加 "weight_type": "standard"。

預防:每次升 IPAdapter 後 export 一個最小 sample workflow 對照欄位。

坑 16:Wan22 anchor 解析度跟 output 不一致 → stretch 變形

症狀:Wan22 i2v 輸出影片,人臉變寬或變長。

根因:anchor 是 1024x1024,output 設 720x1280,Wan22 強制 stretch。

解法:anchor 跟 output 必須同解析度。先把 Flux 生 1024 anchor crop / resize 成 720x1280 再餵 Wan22。

預防:dispatcher 統一 RESOLUTION = (720, 1280),所有 Flux / Wan22 共用。

坑 17:Wan22 5B 第 30 幀後人臉漂移

症狀:3.4 sec 影片前半段臉對,後半段臉變另一個人。

根因:5B 模型 capacity 不足,temporal attention 在 30 幀後失準。

解法:本地無解。要更穩只能上 Wan22 14B(需 24GB+ VRAM)。或接受限制,把 action shot 切更短。

預防:avoid 4 sec+ 純人臉特寫的 i2v shot。複雜表情戲交給 MuseTalk + GFPGAN。

坑 18:Wan22 cfg=8.0 timeout 1800s

症狀:某些 shot 跑 30 分鐘還沒完,ComfyUI 把 task 殺掉。

根因:cfg=8.0 + 30 step 在 5060 Ti 跑某些 prompt 會 hang。

解法:cfg 從 8.0 降到 6.5,timeout 從 1200s 拉到 1800s。

預防:先跑 5-10 個 shot 找穩定的 cfg 範圍。

坑 19:ComfyUI workflow JSON 缺 widgets_values 跑不起來

症狀:自己寫的 workflow.json 通過 /prompt 但回 "invalid prompt"。

根因:ComfyUI 內部需要 widgets_values 對應 inputs 順序。手寫 JSON 容易漏。

解法:先在 GUI 拉一遍 workflow,然後 Save (API Format) 匯出當 template,再改字串。

預防:永遠從 API Format export 開始,不要手寫整個 workflow。

坑 20:FluxTrainer caption_dropout_rate=0.1 → trigger 沒鎖住

症狀:訓完 LoRA,跑 inference 時 trigger 似乎沒效果,臉是隨機的。

根因:FluxTrainer 預設 caption_dropout_rate=0.1,每 10 step 就丟掉 trigger 一次,導致學不牢。

解法:訓練 config 明寫 caption_dropout_rate=0.0。

預防:每次訓練前 grep config 確認 dropout 都是 0。

C. 訓練 / LoRA(8 個)

坑 21:ComfyUI-FluxTrainer numpy 衝突

症狀:裝 FluxTrainer 後 ComfyUI 啟動報 numpy version incompatible。

根因:FluxTrainer requirements 寫死 numpy<=1.26.4,跟 portable Python 3.13 衝突。

解法:sed -i '/numpy/d' requirements.txt 把 numpy 行拿掉再裝。

預防:所有 requirements.txt 先 review,遇到 <=1.26 強制鎖版的就拿掉。

坑 22:transformers 5.x 刪了 CLIPFeatureExtractor

症狀:FluxTrainer 跑訓練,第 1 step 就 ImportError: CLIPFeatureExtractor。

根因:transformers 5.0+ 重命名 CLIPFeatureExtractor 為 CLIPImageProcessor,sd-scripts 沒跟上。

解法:pip install transformers==4.57.6。

預防:訓練 stack 鎖 transformers 4.57.x,不要升 5。

坑 23:FluxTrainer 沒 detach 跑 ComfyUI 整個 hang

症狀:FluxTrainer 跑訓練,ComfyUI WebUI 整個卡住,其他 workflow 不能 submit。

根因:FluxTrainer 預設不 detach training thread,blocking ComfyUI 主 event loop。

解法:FluxTrainer 啟動參數加 detach,或者 training 期間就不要再用 ComfyUI 做其他事。

預防:訓練機跟 inference 機分開,訓練不要在 production GPU 上跑。

坑 24:30 step LoRA 銀絲過度強化

症狀:30 step 訓出的 LoRA,跑 inference 時銀絲被當作 dominant feature,到處都是銀色頭髮。

根因:30 step 是 smoke test,太少 step 模型只學到 most salient features(這裡是銀絲)。

解法:正式訓練必須 100 step+。30 step 只能確認 pipeline 通了。

預防:訓練 SOP 寫死「30 step smoke → 100 step production」兩階段。

坑 25:cache miss vs cache reuse 速度差 3 倍

症狀:第一個角色訓練 100 step 跑 5.5 小時,後面 12 個角色加起來 23 小時。

根因:FluxTrainer 第一次跑要建 latent cache(每 step 197s),後續複用(70s/step)。

解法:13 角色一定要連跑,中間不能關 ComfyUI / 不能換 dataset path。

預防:寫 sequential script + progress 檔,中斷可 resume。詳見第 4.5 節。

坑 26:rank 8 LoRA 過擬合背景

症狀:跨場景跑同一角色,臉是對的但背景帶舊場景殘影。

根因:rank 8 capacity 太大,學進了 anchor 背景。

解法:用 rank 4 + alpha 4。一般人物 LoRA rank 4 已足夠。

預防:只有風格 LoRA / multi-concept LoRA 才考慮 rank 8+。

坑 27:HF Flux schnell 也是 gated 上不去

症狀:以為 schnell 是 open,去 HF 下載 403。

根因:BFL 把全系列 gated,schnell 也要 HF token。

解法:走 ComfyUI portable 自帶 fp8 single-file 或 ComfyOrg 鏡像。

預防:先確認 model 的 license 跟 gating 狀態。

坑 28:LoRA 命名衝突 token

症狀:用 mary 當 trigger,generate 出來臉部混雜「真實 mary 訓練資料」+「base model 對 mary 的先驗」,特徵看起來「半像半不像」。換成 john / tom / lisa 等英文 common name 也有同樣問題。

根因:Flux base model 訓練資料中已有大量「mary」、「john」這類 common name 的人物照片,模型對這些 token 已有強先驗。你的 LoRA 學的特徵會跟 base model 的既有特徵「打架」,結果是兩邊的平均,誰都不像。

解法:trigger 用拼音縮寫 + 年齡碼,例如:

  • soxw_zh50(蘇曉雯,中文 50 歲)
  • fujx_zh48(傅景行,中文 48 歲)

關鍵:trigger 必須是 base model 沒見過的字串。zh + 數字組合幾乎不可能在訓練資料中出現。

預防:trigger 命名規則文件化,13 角色統一風格。新加角色之前先跑「裸 prompt」測試(不載任何 LoRA,只用 trigger 字串),如果生出任何具體影像就表示 trigger 已被 base model 學過,需換名。

D. 影片生成 Wan22(8 個)

坑 29:Wan22 5B 81 frame 限制不可改

症狀:把 length 改成 161 想跑 6.7 sec,OOM 或 garbage output。

根因:Wan22 5B 訓練時固定 81 frame,temporal attention 在 81 切 slice。

解法:接受限制,跨段拼接(last frame as next anchor)或 RIFE 補幀延長。

預防:劇本設計階段就把 action shot 切 3 sec 以內。

坑 30:Wan22 anti-shake negative prompt 不夠導致整個 shot 抖動

症狀:影片整體像「手持鏡頭 + 帕金森」一直抖。

根因:negative prompt 沒寫 frozen, static,導致 sampler 過度補運動。

解法:negative 加 frozen, static 平衡 motion vs stability。

預防:所有 Wan22 workflow negative 都帶這六個關鍵字:text, watermark, blurry, deformed limbs, frozen, static。

坑 31:Wan22 GGUF 量化版仍 OOM

症狀:用 Q4_K_M 14B GGUF(10GB),16GB VRAM 應該夠,但第一步 VAE decode 就 OOM。

根因:GGUF 只省 model weight,但 latent space / KV cache 全是 fp16,加總超過 16GB。

解法:消費卡跑 14B i2v 是夢,回 5B fp16 或上 24GB+ 卡。

預防:算 VRAM 不要只算 weight,要算 weight + latent + KV cache + workspace。

坑 32:v17 v3 前幾秒白屏(fade alpha 溢出)

症狀:成品前 3.35 sec 全白,之後才有畫面。

根因:ffmpeg 加 fade=t=in:st=3.35:d=0.1:color=white:alpha=0,但 alpha=0 + start time 3.35 邏輯被誤解,整個 frame 都填白。

解法:fade 不要用 white + alpha=0 寫法,改用 fade=t=in:st=0:d=0.5(從黑淡入)。

預防:fade 效果先在 5 sec test mp4 驗,不要在 final 直接套。

坑 33:Wan22 character drift 跨 shot

症狀:女主在 shot 1 是黑髮,shot 2 變棕髮。

根因:每 shot random seed,Wan22 5B 對人物先驗弱。

解法:同源 seed(base_seed[char] + shot_id),加 negative character change, color shift, hair change。

預防:dispatcher 強制 seed 機制,不可 random。

坑 34:Wan22 ModelSamplingSD3 shift 8.0 必加

症狀:忘記加 ModelSamplingSD3 node,輸出是低品質糊圖。

根因:Wan22 5B 需要 shift=8.0 的 sigma 調整才正常 sample。

解法:所有 Wan22 workflow 都加 ModelSamplingSD3 shift=8.0。

預防:把 ModelSamplingSD3 寫進 workflow template,每次 deep copy 而不是手刻。

坑 35:Wan22 FunControl 構圖失控

症狀:anchor 是肩部以上特寫,i2v 結果變全身鏡頭。

根因:Wan22FunControlToVideo 對 ref_image 構圖 hint 弱,需要 prompt 明寫構圖。

解法:motion prompt 加 close-up portrait composition,並把構圖描述放在最前面。

預防:Wan22 motion prompt 結構固定為「構圖 → 動作 → 風格 → 一致性」。

坑 36:Wan22 length=81 對應實際 fps 算錯

症狀:以為 81 frame @ 30fps = 2.7 sec,結果輸出 3.375 sec。後製按 2.7 sec 配音、字幕,全部對不上。

根因:Wan22 5B output 預設 24fps,不是 30fps。Wan22 訓練時的 temporal stride 是按 24fps 設計,改成 30fps 會讓動作節奏不自然(快 25%)。

解法:

# 全 pipeline 統一 fps
FPS = 24
WAN_FRAMES = 81  # 81 / 24 = 3.375 sec

# 計算 shot 時長
shot_duration = WAN_FRAMES / FPS  # 3.375 sec

# 字幕 / TTS 都按 3.375 sec 對齊

如果非要 30fps:

# 在 SaveAnimatedWEBP 或 VideoCombine 設 fps
{"class_type": "VHS_VideoCombine", "inputs": {"frame_rate": 30, ...}}

但動作會快 25%,建議不要改。

預防:fps 預設 24 寫進 dispatcher 常數,全 pipeline 共用。所有 ffmpeg 後製也用 -r 24 顯式指定。

E. 對嘴 MuseTalk / LatentSync / Wav2Lip(6 個)

坑 37:MuseTalk 用 mediapipe FaceMesh 嘴不動

症狀:MuseTalk 跑通了,輸出影片人臉位置正確,但嘴巴完全不動。

根因:mediapipe FaceMesh 478 點索引跟 MuseTalk 訓練用的 dlib 68 點不對應,MuseTalk 在錯位置生嘴。

解法:preprocessing.py 改用 standalone face_alignment lib 的 fa.get_landmarks() 取代 mediapipe,得到 dlib 68 點格式。

預防:MuseTalk-only 走 face_alignment lib,避開 mediapipe / mmcv 路線。

坑 38:MuseTalk bbox_shift 沒鎖嘴漂出框

症狀:成品看到嘴部 ROI 不對位置,下唇被切。

根因:bbox_shift 預設 [-7, 7] 範圍,V1.5 256² 模型對這個範圍不穩。

解法:bbox_shift 鎖死 [0, 0],永遠不偏移。

預防:每個 MuseTalk inference 跑前 grep config 確認 bbox_shift = 0。

坑 39:MuseTalk torch.load weights_only=True 載不到 checkpoint

症狀:跑 inference 第一行 _pickle.UnpicklingError: Weights only load failed。

根因:PyTorch 2.6+ 預設 weights_only=True,但 MuseTalk checkpoint 含 pickle 物件。

解法:7 個檔案的 torch.load(...) 全部加 weights_only=False。詳見第 5.6 Patch 2。

預防:每次 patch checkpoint 載入相關的 lib 都檢查 weights_only 設定。

坑 40:mmcv build hell(裝不起來)

症狀:pip install mmcv 編譯 30 分鐘後 fail,缺 nvcc / 缺 C++ symbol。

根因:mmcv 2.x 需要對應 CUDA 11.x,5060 Ti 是 CUDA 12.8,不相容。

解法:放棄 mmcv 路線。MuseTalk 用 face_alignment 取代 dwpose(不需 mmcv)。

預防:所有 require mmcv 的 lib 都先確認你的 CUDA 版本。

坑 41:LatentSync 對「手+物+臉」嘴部融化

症狀:face-on 純臉特寫 LatentSync 完美,但鏡頭裡有手或道具同框就嘴部 melt。

根因:LatentSync 對 face region 的 attention 在複雜構圖容易被干擾。

解法:複雜構圖改用 MuseTalk + GFPGAN restore。LatentSync 只用在 face-on 純特寫。

預防:dispatcher 把 shot 構圖 metadata 寫進 JSON,自動路由:純特寫→LatentSync,複雜→MuseTalk。

坑 42:Wav2Lip 沒 GFPGAN restore 嘴模糊到觀眾秒辨

症狀:Wav2Lip 96² 對嘴後嘴部有明顯模糊區塊,觀眾一看就知道是 AI 後製。

根因:96² → 1280 解析度放大 13 倍,嘴部細節完全沒。

解法:Wav2Lip 輸出後立刻 GFPGAN v1.4 --only_center_face restore。

預防:把 GFPGAN restore 寫進 Wav2Lip wrapper function,不要當選配。

F. 後製 ffmpeg / TTS(5 個)

坑 43:Edge TTS rate 超過 ±20% 卡頓

症狀:SSML rate 設 +30%,輸出音訊有 gap / glitch。

根因:Edge TTS 免費版 rate 限制 ±20%。

解法:rate 永遠 clamp 在 [-20%, +20%] 範圍。超過的情感(極度興奮 / 極度緩慢)改用 F5-TTS / 商業 API。

預防:SSML emotion map 設計時就 clamp 範圍。

坑 44:F5-TTS reference 有 BGM clone 出來雜音

症狀:clone 完音檔有奇怪的低頻雜音,像是底層的音樂被一起 clone。

根因:F5-TTS 學的是整個音訊特徵,含 BGM 噪音。

解法:reference audio 必須先除 BGM。用 demucs 或 Adobe Podcast Enhance 去 BGM 再 clone。

預防:所有 voice clone reference 都跑 ffmpeg -af anlmdn 降噪 + 人耳聽過確認乾淨。

坑 45:ffmpeg subtitles filter 找不到字體

症狀:burn subtitles 後字幕顯示為「□□□」。

根因:ffmpeg 找不到 Noto Sans CJK TC 字體檔。

解法:

# 確認字體已裝
fc-list :lang=zh-tw | grep Noto

# 沒裝就裝
sudo apt install fonts-noto-cjk

# ass 檔的 Fontname 必須跟系統字體完全對得上

預防:dispatcher 啟動時跑 fc-list 檢查必要字體都有。

坑 46:ffmpeg concat demuxer codec 不一致重編成本暴增

症狀:concat 32 個 shot mp4,跑 2-3 小時才完。

根因:32 個 shot 各自的 codec / bitrate / pix_fmt 不完全一致,concat 觸發重編。

解法:所有 shot 用同 ffmpeg 參數產生:-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p。concat 時用 -c copy 無損。

預防:dispatcher 寫死一組統一的 ffmpeg encode params,所有 type 共用。

坑 47:BGM 沒 duck 蓋掉人聲觀眾退出

症狀:成品有 BGM 但聽不清楚對白,YouTube analytics 顯示 retention 5 秒就掉光,留言滿是「聽不清楚講什麼」。

根因:BGM 用 ffmpeg amix 直接跟人聲混音,沒有 sidechain compression。人聲音量約 -10 dB,BGM 也 -10 dB,兩個競爭頻段(中頻),人聲被淹沒。

解法:BGM 走 sidechaincompress:

ffmpeg -i video.mp4 -i bgm.mp3 \
    -filter_complex "
        [1:a]volume=-22dB[bgm];
        [0:a][bgm]sidechaincompress=
            threshold=0.05:
            ratio=8:
            attack=20:
            release=200,
        amix=inputs=2:duration=first
    " \
    -c:v copy \
    output_bgm.mp4

人聲一超過 threshold=0.05,BGM 自動壓 8:1 ratio。人聲停止 200ms 後 BGM 恢復原音量。詳見第 9.6 節。

預防:所有 BGM mix 預設啟用 ducking,不是可選功能。把這段 filter 包成 helper function,dispatcher 永遠呼叫 helper 而非自己寫 ffmpeg。


第 12 章:47 坑 cheatsheet(快速 ref)

# 分類 一句話症狀 一句話解
1 環境 WSL 28s idle 死 .wslconfig vmIdleTimeout=-1
2 環境 mirrored 連不到 改 NAT + portproxy
3 環境 5060 Ti CPU fallback PyTorch 2.6 + cu128
4 環境 duix 偷 GPU docker stop + restart=no
5 環境 VRAM zombie pkill + fuser -k
6 環境 cp950 殺訓練 chcp 65001 + PYTHONIOENCODING
7 環境 8189 占用 改 8188
8 環境 portproxy self-loop connectaddress = WSL 內 IP
9 環境 sshpass 被擋 -o PubkeyAuthentication=no
10 環境 HF Flux gated portable fp8 single-file
11 ComfyUI /mnt/c/ 啟動慢 裝在 /home/ ext4
12 ComfyUI symlink 不認 用 extra_model_paths.yaml
13 ComfyUI custom_nodes 版本卡 鎖統一 transformers 4.57.6
14 ComfyUI ClipVision 找不到 cp 改檔名
15 ComfyUI IPAdapter HTTP 400 加 weight_type
16 ComfyUI anchor stretch 跟 output 同解析度
17 ComfyUI Wan22 face drift 5B 上限,接受 / 上 14B
18 ComfyUI Wan22 timeout cfg 8.0 → 6.5
19 ComfyUI workflow JSON 缺欄 從 API Format export
20 ComfyUI LoRA trigger 沒鎖 caption_dropout_rate=0.0
21 訓練 numpy 衝突 拿掉 requirements numpy 行
22 訓練 transformers 5.x 鎖 4.57.6
23 訓練 FluxTrainer hang 訓練機分離
24 訓練 銀絲過度 30 step → 100 step
25 訓練 cache miss 慢 sequential 連跑 13 角色
26 訓練 rank 8 過擬合 rank 4 + alpha 4
27 訓練 schnell gated portable fp8
28 訓練 trigger 衝突 拼音 + 年齡碼
29 Wan22 81 frame 改不掉 接受 / 拼接 / RIFE
30 Wan22 整體抖動 neg 加 frozen, static
31 Wan22 14B GGUF OOM 回 5B 或 24GB+
32 Wan22 fade 白屏 不要 white + alpha=0
33 Wan22 跨 shot drift 同源 seed + neg lock
34 Wan22 低品質糊圖 加 ModelSamplingSD3 shift=8.0
35 Wan22 構圖失控 motion prompt 寫構圖
36 Wan22 fps 算錯 統一 24fps
37 對嘴 mediapipe 嘴不動 face_alignment 68 點
38 對嘴 bbox_shift 漂 鎖 [0, 0]
39 對嘴 weights_only 載不到 7 個檔加 =False
40 對嘴 mmcv build 地獄 改 face_alignment
41 對嘴 LatentSync 融化 複雜構圖改 MuseTalk
42 對嘴 Wav2Lip 模糊 + GFPGAN only_center_face
43 後製 Edge TTS rate 限制 clamp ±20%
44 後製 F5-TTS BGM 雜 demucs 去 BGM
45 後製 字幕 □□□ 裝 Noto CJK + Fontname 對齊
46 後製 concat 重編慢 統一 encode params + -c copy
47 後製 BGM 蓋人聲 sidechaincompress duck

第 13 章:總結 + 接手 checklist

13.1 30 天 quick start 路線圖

如果你今天才入坑,按這個順序走:

Week 1:環境 + LoRA 一個角色

  • Day 1-2:硬體採購 + Windows 11 / WSL2 安裝
  • Day 3:ComfyUI portable 跑通(第 3 章)
  • Day 4-5:Flux fp8 smoke 跑一張 anchor
  • Day 6-7:第一個 LoRA 30 step smoke + 100 step production(第 4 章)

Week 2:對嘴 + 一個 shot 全綠

  • Day 8-9:MuseTalk V1.5 安裝 + face_alignment patch(第 5 章)
  • Day 10:跑通一個 talking_head shot
  • Day 11-12:跑通一個 action shot(Wan22 5B,第 6 章)
  • Day 13:跑通一個 static_pose + cutaway
  • Day 14:dispatcher 雛形(第 8 章)

Week 3:劇本 + 後製 + 1 集 smoke

  • Day 15-16:劇本 JSON 寫一集 32 shot
  • Day 17:Edge TTS SSML 22 emotion map 串好(第 7 章)
  • Day 18:ffmpeg 7 種運鏡 + .ass 字幕 + BGM duck(第 9 章)
  • Day 19-20:跑通 1 集 smoke(10 shot)
  • Day 21:人工 review 7 項缺陷(v14 七項清單,第 8.10 節)

Week 4:30 集量產決策

  • Day 22-23:修補 7 項缺陷的優先順序(第 10 章)
  • Day 24-25:跑通 1 集完整 32 shot 量產
  • Day 26-27:成本實算 vs 雲端比較(第 10.6 節)
  • Day 28-30:決策——本地繼續 vs 轉 Dreamina / Kling

13.2 不要的 5 件事

  1. 不要重蹈 v1.7 GGUF 14B 的覆轍:5060 Ti 16GB 跑不動。
  2. 不要嘗試把 Wan22 length 改成 161:架構級限制改不了。
  3. 不要在 talking head 跑 LatentSync:複雜構圖嘴融化。
  4. 不要相信「跑完才看」:22 小時量產要先 smoke 10 sec。
  5. 不要把 Wav2Lip 當過渡:搭 GFPGAN 後比 LatentSync 還穩。

13.3 必做的 5 件事

  1. 必做 vmIdleTimeout=-1 + Mac SSH keeper:WSL 長任務的命脈。
  2. 必做 13 角色 connect 連跑 LoRA:cache reuse 救 23 小時。
  3. 必做 face_alignment 68 patch:MuseTalk 在 5060 Ti Blackwell 唯一路徑。
  4. 必做 BGM duck:沒有 ducking 的 BGM 直接報廢。
  5. 必做 GPU lock:序列化,避免 OOM 後段全廢。

13.4 認輸時機判斷表

信號 含意
一集量產 >24 hr 已超商業可接受範圍
跑 3 個 shot 失敗 ≥ 2 個 pipeline 不穩
用戶說「臉扭曲」「動作慢」 模型 capacity 已上限
修 7 項缺陷後仍 60 分 模型本身上限,不是 prompt 問題
算上電費 + 時間 vs 雲端便宜 經濟判斷已輸

碰到 3+ 個信號,認輸,轉 Dreamina / Kling / Runway。這不是失敗,是工程判斷。

13.5 一行精華

本地 AI 短劇可行,但天花板在 60 分。商業級量產走 Dreamina + IPAdapter 內建一致性,30 分鐘上手、90 分品質、配額內免費。本地 stack 適合學習、客製、隱私,不適合純效率。


第 14 章:進階技巧:多 LoRA 融合 / IPAdapter / ControlNet 組合

14.1 多 LoRA 融合場景與技巧

當需要同時呈現多個訓練角色(例如雙人對話)時,多 LoRA 載入會踩到幾個常見問題:

問題 1:兩個 LoRA strength 加起來 = 1.4,臉部融合

# Bad
lora_a = {"strength_model": 0.8}
lora_b = {"strength_model": 0.8}
# 兩個 trigger 同時出現時,臉部特徵混合,會出現「四不像」

解法:兩個 LoRA strength 各降到 0.5-0.6,配合精確 trigger 定位:

prompt: "soxw_zh50 woman on the LEFT side of frame,
fujx_zh48 man on the RIGHT side of frame,
both sitting at restaurant table, looking at each other"

問題 2:兩個 LoRA 風格不協調

如果 LoRA A 是用 portrait studio 訓練,LoRA B 是用 outdoor 訓練,兩個合用會風格衝突。

解法:訓練資料統一風格(例如都用 cinematic outdoor)或者只取人物 LoRA + 後製統一色調。

14.2 IPAdapter Plus 與 LoRA 的取捨

IPAdapter 提供「reference image → style transfer」能力,跟 LoRA 各有適用:

維度 LoRA IPAdapter Plus
準備時間 5.5 hr 訓練 0(直接給 ref 圖)
角色相似度 90% 60-70%
跨場景泛化 強 中
多角色組合 兩個 LoRA 容易融合 多 ref 衝突
VRAM +30 MB +500 MB
改變風格 不可(風格已 baked in) 可

實戰選擇:

  • 重要主角 → LoRA(值得訓 5.5 hr)
  • 50 個配角 → IPAdapter(一張 ref 圖就夠)
  • 風格快速試驗 → IPAdapter
  • 商業 production → LoRA + IPAdapter 並用

14.3 IPAdapter 推薦 weight 與 weight_type

weight weight_type 效果
0.3 standard 微微 reference,幾乎察覺不出
0.5 standard 中等影響,畫面有 ref 風格
0.7 standard 強影響,主體類似 ref(v16 默認)
0.95 standard 極強,主體幾乎等於 ref(v17 v4 用)
1.5 standard ghosting,畫面出現 ref 殘影
0.7 linear 整體 blend
0.7 ease in 漸進影響
0.7 weak input 弱化 ref 細節

v17 經驗:weight 拉到 0.95 鎖角色,但仍會 character drift。1.5+ ghosting 嚴重,不可用。

14.4 ControlNet 在短劇中的應用

5 個 ControlNet 在短劇 pipeline 的用途:

Canny ControlNet:鎖構圖 - 場景:要保留 anchor 的構圖框架 - 範例:head/shoulder bbox lock,五官位置不亂跳

Depth ControlNet:鎖空間結構 - 場景:跨 shot 同場景不同角度 - 範例:餐廳場景多 shot 共用景深圖

OpenPose ControlNet:鎖人物 pose - 場景:武打 / 舞蹈 / 特定 pose - 範例:v05 加 PoseControlNet 鎖 body

Tile ControlNet:upscale 不失真 - 場景:低解析度 → 高解析度 - 範例:MuseTalk 256² → 720² 升頻

InpaintControl Net:局部重繪 - 場景:背景修改、衣服換色、表情調整 - 範例:女主在不同場景換衣服

14.5 ControlNet + LoRA + IPAdapter 三合一架構

最強的 anchor 生成 stack:

def super_anchor_workflow(prompt, char_ref, pose_image, depth_image, seed):
    """LoRA + IPAdapter + Canny + Depth 四重控制"""
    wf = {
        # Base
        "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux1-dev-fp8.safetensors"}},
        "2": {"class_type": "DualCLIPLoader", "inputs": {...}},
        "3": {"class_type": "VAELoader", "inputs": {...}},

        # LoRA layer
        "4": {"class_type": "LoraLoader", "inputs": {
            "model": ["1", 0], "clip": ["2", 0],
            "lora_name": "soxw_zh50.safetensors",
            "strength_model": 1.0, "strength_clip": 1.0,
        }},

        # IPAdapter layer
        "10": {"class_type": "LoadImage", "inputs": {"image": char_ref}},
        "11": {"class_type": "IPAdapterUnifiedLoader", "inputs": {
            "model": ["4", 0], "preset": "PLUS (high strength)",
        }},
        "12": {"class_type": "IPAdapter", "inputs": {
            "model": ["11", 0], "ipadapter": ["11", 1],
            "image": ["10", 0], "weight": 0.5,  # 跟 LoRA 平衡
            "weight_type": "standard",
        }},

        # ControlNet Canny
        "20": {"class_type": "LoadImage", "inputs": {"image": pose_image}},
        "21": {"class_type": "CannyEdgePreprocessor", "inputs": {"image": ["20", 0]}},
        "22": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": "flux-canny.safetensors"}},
        "23": {"class_type": "ControlNetApplyAdvanced", "inputs": {
            "positive": ["5", 0], "negative": ["6", 0],
            "control_net": ["22", 0], "image": ["21", 0],
            "strength": 0.5, "start_percent": 0.0, "end_percent": 0.8,
        }},

        # KSampler 最終 generate
        "30": {"class_type": "KSampler", "inputs": {
            "model": ["12", 0],
            "positive": ["23", 0], "negative": ["23", 1],
            "seed": seed, "steps": 30, "cfg": 3.5,
        }},
        ...
    }
    return wf

VRAM 占用:

  • Flux fp8 base: 11.2 GB
  • LoRA: +30 MB
  • IPAdapter PLUS: +500 MB
  • ControlNet Canny: +2 GB
  • 合計:~14 GB(5060 Ti 16GB 邊緣)

跑一次約 60 sec(比純 Flux 38 sec 慢 60%)。品質提升明顯,跨 shot 一致性接近 90%。

14.6 進階:character LoRA + style LoRA 疊加

如果你還想加風格(例如「賽博龐克風」):

# 載 character LoRA
char_lora = LoraLoader(lora_name="soxw_zh50.safetensors", strength=1.0)

# 載 style LoRA
style_lora = LoraLoader(lora_name="cyberpunk_style.safetensors",
                        strength=0.6,  # style 不能太強,會蓋過 character
                        model=char_lora.MODEL)

# 用 style 之後的 model 跑 sampler

注意:style LoRA strength 不能超過 0.6-0.7,否則蓋過 character feature。

14.7 動畫風格 vs 寫實風格的技術差異

維度 動漫風格 (v16) 寫實風格 (v1-v15)
Base model NoobAI XL Vpred 1.0 Flux fp8
ControlNet 不必要 推薦
LoRA 必要性 低(IPAdapter 替代) 高
角色一致性 中(藝術風格容忍 drift) 嚴格
對嘴 漫剧無對嘴 必須
字幕大小 大(動漫慣例) 中
BGM 風格 J-pop / synth 鋼琴 / strings
場景數量 多(節奏快) 少(鏡頭穩)

動漫風格的優勢是「快」(無對嘴 -10 min/shot),劣勢是「市場小」(中文動漫短劇受眾不大)。

14.8 多模態:影片 + 文字浮層 + 互動元素

YouTube Shorts / TikTok 的進階做法是在影片上加互動元素:

# 加倒數計時器
ffmpeg -i input.mp4 -vf "
    drawtext=fontfile=/path/to/font.ttc:
    text='%{eif\:trunc(5-t)\:d}':
    x=10:y=10:fontsize=60:fontcolor=red
" output.mp4

# 加進度條
ffmpeg -i input.mp4 -vf "
    drawbox=x=0:y=ih-10:w='iw*t/5':h=10:color=red@0.8:t=fill
" output.mp4

# 加 emoji reactions
ffmpeg -i input.mp4 -i emoji.png -filter_complex "
    [1:v]scale=80:80[emoji];
    [0:v][emoji]overlay=W-w-20:H-h-20:enable='between(t,2,4)'
" output.mp4

實戰:在女主摔東西 shot 加「驚」emoji + 進度條,retention 提升 8%。

14.9 跨 episode 的角色檔案管理

30 集連續劇,角色 13 個,怎麼讓每集都引用同樣的設定?

建立 characters.yaml:

characters:
  female_lead:
    name: 蘇曉雯
    age: 50
    lora: soxw_zh50_v1_100step_rank4_fp16.safetensors
    trigger: soxw_zh50
    voice: zh-CN-XiaoxiaoNeural
    voice_emotion_default: neutral
    age_preserve: "50 years old elegant Taiwanese woman, salt-and-pepper hair with subtle silver streaks, fine eye wrinkles"
    seed_base: 42000000
    wardrobe_default: "black blazer, white shirt, pearl necklace"

  male_lead:
    name: 傅景行
    age: 48
    lora: fujx_zh48_v1_100step_rank4_fp16.safetensors
    trigger: fujx_zh48
    voice: zh-CN-YunjianNeural
    voice_emotion_default: warm
    age_preserve: "48 years old refined Asian businessman, slight gray at temples, sharp jawline"
    seed_base: 42001000
    wardrobe_default: "navy business suit, white shirt, silver tie"

每集劇本 import 這個 file,避免重複定義。

14.10 自動產出 subtitle SRT / ASS

字幕跟 dialogue 同步是手寫容易出錯的環節。自動產出:

def auto_generate_subtitles(script, output_dir):
    """從 script JSON 自動產 .ass 字幕,時間軸對齊 TTS"""
    cursor = 0.0
    ass_lines = []

    for shot in script["shots"]:
        if not shot.get("subtitle"):
            cursor += shot["duration_sec"]
            continue

        if shot.get("dialogue"):
            audio_path = output_dir / f"shot_{shot['shot_id']:03d}.wav"
            duration = get_wav_duration(audio_path)
        else:
            duration = shot["duration_sec"]

        start = cursor
        end = cursor + duration
        ass_lines.append(format_ass_dialogue(start, end, shot["subtitle"]))
        cursor = end

    ass_content = ASS_HEADER + "\n".join(ass_lines)
    output_dir.joinpath("subtitles.ass").write_text(ass_content)

這樣 dispatcher 跑完後字幕已經寫好,後製只要 burn 進去即可。


第 15 章:實戰案例:ep1 17.7 min 完整拆解

14.1 ep1「離婚那一天」結構

ep1 32 個 shot,分佈:

Act shot 範圍 主題 時長
Act 1 shot 01-08 起:清晨醒來 → 接到電話 → 證實丈夫外遇 ~3 min
Act 2 shot 09-18 承:摔東西 → 質問 → 對方坦承 ~5 min
Act 3 shot 19-26 轉:女主決定離開 → 收拾 → 離家 ~5 min
Act 4 shot 27-32 合:街上行走 → 偶遇 → 第一集結束 ~3 min
合計 32 shot 16 min

實際成品 17.7 min(含片頭片尾 1.7 min)。

14.2 32 shot 的 type 分佈

shot_type 數量 占比 跑單支耗時 小計
cutaway 8 25% 1 min 8 min
static_pose 10 31% 1.5 min 15 min
talking_head 10 31% 3 min 30 min
action 4 13% 7 min 28 min
合計 32 100% 平均 2.5 min 81 min

實際跑了 21 hr(1260 min),是理論值的 15.5 倍。原因:

  • Wan22 5B retry 失敗率 15-20%(每次重跑 6 min)
  • MuseTalk preprocess 每 shot 額外 1-2 min
  • GFPGAN restore 每秒影片 0.3 倍速
  • Mac ↔ PC scp 每 shot 2-5 sec
  • GPU 冷卻間隔 30-60 sec

14.3 32 shot 的 character 分佈

character shot 數 平均時長
female_lead 22 4 sec
male_lead (傅景行) 4 3 sec
antagonist_ex_husband 6 5 sec
daughter 3 3 sec
配角 2 2 sec
純場景 (cutaway) 8 2 sec

女主 22 shot 占比高,是 v14 「角色不一致」批評的根因。22 個 shot 雖然同源 seed,跨 shot 仍有 hair color / outline 微差。

14.4 ep1 中 5 個關鍵 shot 拆解

shot 03(女主接電話)— talking_head 範例

劇本 JSON:

{
  "shot_id": 3,
  "shot_type": "talking_head",
  "duration_sec": 4.5,
  "character": "female_lead",
  "scene_desc": "主臥, 電話響起, 接起電話",
  "anchor_prompt": "soxw_zh50 sitting on bed edge, holding phone to ear, devastated expression, cold morning light from window",
  "dialogue": "你跟她在一起多久了?",
  "subtitle": "你跟她在一起多久了?",
  "voice": "zh-CN-XiaoxiaoNeural",
  "emotion": "shocked",
  "camera": "slow_push_in",
  "sfx": [],
  "bgm_hint": "tense strings"
}

執行 timeline:

T+0:00  start
T+0:38  Flux + LoRA anchor 完成 (38 sec)
T+0:42  ffmpeg base mp4 (4 sec)
T+0:44  Edge TTS shocked emotion (2 sec)
T+1:25  MuseTalk 對嘴 (45 sec, 含 preprocess)
T+1:40  GFPGAN restore (15 sec)
T+1:48  字幕 + BGM mix (8 sec)
T+1:50  final mux (2 sec)
T+1:50  DONE

shot 13(女主摔化妝品)— action 範例

劇本 JSON:

{
  "shot_id": 13,
  "shot_type": "action",
  "duration_sec": 3.4,
  "character": "female_lead",
  "scene_desc": "梳妝台前, 摔化妝品",
  "anchor_prompt": "soxw_zh50 standing at vanity, arm raised in fury, hand about to sweep cosmetics",
  "motion_prompt": "violently sweeping arm across vanity in extreme anger, cosmetics bottles flying through air, perfume bottles shattering on floor, hair whipping with motion, dramatic side lighting",
  "subtitle": "(女主憤怒地掃落所有化妝品)",
  "camera": null,
  "sfx": ["glass_break", "objects_falling"],
  "bgm_hint": "climax drums"
}

執行 timeline:

T+0:00  start
T+0:42  Flux + LoRA anchor (42 sec)
T+6:48  Wan22 5B i2v 81 frame (6 min)
T+7:05  字幕 + SFX mix (17 sec)
T+7:08  final mux (3 sec)
T+7:08  DONE (7 min)

但 attempt 1 失敗(cfg 8.0 timeout),attempt 2 (cfg 6.5) 過。
總時間:13 min

action shot 重跑成本高,所以失敗 1 次就會大幅拖慢進度。

shot 27(街上偶遇男主)— talking_head 雙角色

{
  "shot_id": 27,
  "shot_type": "talking_head",
  "duration_sec": 5.0,
  "character": "male_lead",
  "secondary_character": "female_lead",
  "scene_desc": "街道, 偶遇撞上",
  "anchor_prompt": "fujx_zh48 in business suit, looking surprised, soxw_zh50 in casual outfit visible in foreground, street background bokeh",
  "dialogue": "對不起,您沒事吧?",
  "subtitle": "對不起,您沒事吧?",
  "voice": "zh-CN-YunjianNeural",
  "emotion": "warm",
  "camera": "static"
}

雙人合照的問題:兩個 LoRA 一起載,臉容易融合。實際成品女主臉部漂到 60% 像,男主像 70%。所以 v14 後改成:

  • 雙人合照用 ffmpeg 拼接(兩個單人 anchor + split-screen)
  • 或者只露一人的特寫(另一人只露身體 / 後腦勺)

14.5 ep1 量產失敗的 5 個 shot

21 hr 跑下來實際失敗(重跑 3 次仍敗)5 個 shot:

shot type 失敗原因 補救
07 action Wan22 anchor 構圖偏 → motion 跑掉 改靜態 cutaway
13 action cfg=8.0 timeout 3 次 改 cfg 6.5 後過
19 talking_head LatentSync 嘴融化 改 MuseTalk 後過
22 action 第 40 幀後 character drift 接受 + 截斷前 1.7 sec
27 talking_head 雙人合照臉融合 改 split-screen

5/32 = 15.6% 失敗率,符合預期。

14.6 ep1 用戶 7 項批評的具體 shot 對應

用戶提的 7 項批評各對應到哪些 shot:

批評 影響 shot 嚴重度
嘴模糊 shot 03, 05, 11, 15, 19, 24, 27, 31 (8 talking_head) 高
配音單調 所有 talking_head (10) 高
沒 BGM 所有 shot (32) 高
角色不一致 跨 shot 比對 中
沒多角色 shot 01, 02, 04 等 cutaway 全女主 中
運鏡失敗 static_pose 全 (10) 中
沒動作 cutaway 全 (8) 低

7 項中 4 項是「整集都有」,3 項是「特定 shot」。修補時前 4 項是 systemic fix(dispatcher 級),後 3 項是 per-shot fix。

14.7 ep1 從 60 分到 80 分需要的工作

如果你問「ep1 怎麼從 60 分變 80 分」,下面是估算:

修補項目 額外時間 額外分數
所有 talking_head 加 GFPGAN restore +5 min/shot × 10 = 50 min +5
所有 talking_head 加 SSML emotion +0 min(automate) +5
全集加 BGM duck +30 min(後製) +5
同源 seed 重生所有 shot +6 hr(部分重跑) +3
cutaway 加 character + push_in +30 min +2

合計 +9 hr 工作 = +20 分。一支 80 分 ep1 需要 30 hr 量產(vs 21 hr 60 分版本)。

商業判斷:

  • 自媒體 60 分 ep × 30 集(630 hr 量產)= 留住 5% 觀眾
  • 商業級 80 分 ep × 30 集(900 hr 量產)= 留住 15% 觀眾

商業級多 270 hr 工作但 retention 3x,CP 值很高。但 5060 Ti 16GB 跑 80 分本來就吃力,不如直上雲端。


第 16 章:架構演進:從單機到 worker pool

16.1 階段 1:單機 PoC(本文設定)

最簡單的部署:1 台 PC 跑 ComfyUI,dispatcher 跟它說話。

┌────────────────────────────┐
│ Mac M4 (orchestrator)      │
│  └─ dispatcher.py          │
│      │                     │
│      └── SSH tunnel ───────┼─┐
│                            │ │
└────────────────────────────┘ │
                               │
                               ↓
┌────────────────────────────┐
│ PC RTX 5060 Ti 16GB        │
│  └─ ComfyUI :8188          │
└────────────────────────────┘

優點:簡單、上手快。 缺點:序列化跑、單點故障。

16.2 階段 2:雙機並行(中等量產)

當你做到 50+ 集連續劇,想加速到 1 集 / 天:

┌────────────────────────────┐
│ Mac M4 (orchestrator)      │
│  └─ dispatcher.py          │
│      ├──→ pc-1             │
│      └──→ pc-2             │
└────────────────────────────┘
       │              │
       ↓              ↓
┌──────────────┐ ┌──────────────┐
│ PC RTX 4090  │ │ PC RTX 4090  │
│ ComfyUI 8188 │ │ ComfyUI 8188 │
└──────────────┘ └──────────────┘

dispatcher 改成 worker pool:

from concurrent.futures import ThreadPoolExecutor

WORKERS = [
    {"host": "pc-1", "port": 8188, "gpu": "4090-1"},
    {"host": "pc-2", "port": 8188, "gpu": "4090-2"},
]

def run_shot_on_worker(shot, worker):
    # ssh + scp + comfy http
    return result

def parallel_run(shots):
    with ThreadPoolExecutor(max_workers=len(WORKERS)) as ex:
        futures = []
        for i, shot in enumerate(shots):
            worker = WORKERS[i % len(WORKERS)]
            futures.append(ex.submit(run_shot_on_worker, shot, worker))

        results = [f.result() for f in futures]
    return results

雙機並行可以把 21 hr → 11 hr,但要注意:

  • shot 順序變亂,concat 時要按 shot_id 排序
  • 失敗處理要記錄哪台 worker 跑哪個 shot
  • 兩個 ComfyUI instance 各自 model load 要時間

16.3 階段 3:worker pool + 任務佇列(商業級)

當你要規模化到 50+ 集 / 月,需要 worker pool + 訊息佇列:

┌────────────────────────────────────────┐
│           Producer (orchestrator)       │
│   ┌──────────────────────────────────┐ │
│   │ 把 shot 任務塞進 Redis queue     │ │
│   └──────────────────────────────────┘ │
└──────────────────┬─────────────────────┘
                   │
                   ↓
        ┌──────────────────────┐
        │ Redis (task queue)   │
        └──────────────────────┘
                   │
       ┌───────────┼───────────┐
       ↓           ↓           ↓
   ┌────────┐ ┌────────┐ ┌────────┐
   │worker-1│ │worker-2│ │worker-3│
   │ GPU    │ │ GPU    │ │ GPU    │
   │ pop &  │ │ pop &  │ │ pop &  │
   │ run    │ │ run    │ │ run    │
   └────────┘ └────────┘ └────────┘

技術選型:

  • 訊息佇列:Redis (簡單) / RabbitMQ (進階)
  • worker 框架:Celery / RQ
  • 結果儲存:S3 / R2 / Cloudflare R2

範例 worker:

from rq import Queue, Worker
import redis

redis_conn = redis.Redis(host="redis.host", port=6379)
q = Queue("drama_shots", connection=redis_conn)

def run_shot(shot_payload):
    # 跑單個 shot
    ...
    return output_url

# Worker process
if __name__ == "__main__":
    worker = Worker([q], connection=redis_conn)
    worker.work()

orchestrator 推送:

for shot in script["shots"]:
    job = q.enqueue("run_shot", shot, timeout=1800)
    print(f"Enqueued shot {shot['shot_id']}: job {job.id}")

16.4 階段 4:cloud-burst 混合架構

當你需要「平常本地、忙時上雲」:

平常時:
┌──────────────────────────┐
│ 本地 2 台 GPU            │
│ 日均 1-2 集              │
└──────────────────────────┘

爆量時(活動 / 季節 / 大 IP 上線):
┌──────────────────────────┐
│ 本地 2 台 + RunPod 10 台 │
│ 日均 30 集               │
└──────────────────────────┘

實作:

def get_available_workers():
    """每分鐘檢查 queue 長度,自動 spin up RunPod"""
    queue_size = q.count
    if queue_size > 50:
        spin_up_runpod_workers(num=10)
    elif queue_size < 5:
        terminate_runpod_workers(keep=2)

成本控制:RunPod 用完即關,不會持續燒錢。

16.5 階段 5:跨地區 CDN 分發

當你的影片要面向全球:

影片產出(台北)→ 上傳 Cloudflare R2
                 ↓
            全球邊緣節點 cache
                 ↓
            用戶就近下載

Cloudflare R2 + Stream:

  • 免費 10 GB egress / 月(每集 ~100MB,可以 100 集)
  • 全球 CDN 加速
  • 沒有 egress fee(vs AWS S3)

16.6 監控 dashboard

當你跑 30+ 集 production,必須有 dashboard 看狀況:

# 簡單版:把 progress 寫進 SQLite,用 streamlit 顯示
import streamlit as st
import sqlite3
import pandas as pd

conn = sqlite3.connect("/tmp/drama_progress.db")
df = pd.read_sql("""
    SELECT episode, shot_id, shot_type, status, duration_sec, attempts
    FROM shots
    ORDER BY started_at DESC
    LIMIT 100
""", conn)

st.title("Drama Factory Dashboard")
st.metric("總 shot 數", len(df))
st.metric("成功率", f"{(df.status=='success').mean()*100:.1f}%")
st.metric("平均耗時", f"{df.duration_sec.mean():.1f} sec")
st.bar_chart(df.groupby("shot_type")["duration_sec"].mean())
st.dataframe(df)
streamlit run dashboard.py --server.port 8501

打開瀏覽器看即時進度。

16.7 災備:當你 30 集全做完前主機壞了

防範:

  1. 每 shot 即時 backup 到 R2/S3:成品不丟
  2. progress.db 每小時 backup:恢復可從 last checkpoint 接續
  3. ComfyUI workflows 走 git:版本可追溯
  4. LoRA / models 走 git-annex:可恢復到任意 commit

當主機壞時:

# 換新 PC,從 git clone
git clone --recursive git@github.com:user/drama-factory.git
cd drama-factory
git-annex get --all  # 抓回所有 LoRA / models

# 從 R2 抓最新 progress
aws s3 cp s3://drama-factory/progress.db .

# resume
python dispatcher.py --resume

整個救援過程 1-2 小時,不會丟超過 1 小時的工作。

16.8 多人協作:分工流程

如果你有團隊(編劇 + 美術 + 工程):

角色 工作 輸出
編劇 寫 30 集大綱 + 32-shot 劇本 JSON script JSON
美術 訓 LoRA + 調 anchor prompt LoRA 檔 + prompt 模板
配音 收 voice ref + F5-TTS 微調 voice model
工程 dispatcher / orchestrator runner / monitor
後製 BGM / SFX / 字幕 / 上字 mp4 final
發行 上 YouTube / TikTok / 等 觀眾

每個角色獨立 PR 流程:

  • 編劇 PR:scripts/ep01.json
  • 美術 PR:loras/soxw_zh50_v2.safetensors(git-annex)
  • 工程 PR:dispatcher.py
  • 後製 PR:final/ep01.mp4(R2 link)

每集走完整 5 個 PR,3-5 天 / 集。30 集 = 90-150 天,3-5 人團隊每天產 1 集。

16.9 KPI / 量化指標

商業級短劇要追蹤的 KPI:

KPI 計算 目標
量產速度 集數 / 天 >= 1
失敗率 失敗 shot / 總 shot < 10%
平均成本 電費 + 雲端 / 集 < NT$100
觀眾留存(YouTube) avg view duration / video length > 50%
點擊率 CTR 點擊 / 曝光 > 5%
訂閱轉換 新訂閱 / 觀看 > 0.5%

每月 review KPI,調整 stack 配置。

16.10 從 PoC 到 production 的 mind shift

從本文 PoC 到 production,要轉換的思維:

PoC Production
跑通就好 失敗自動 retry
一次跑完 可中斷 / 可 resume
不寫測試 每個 shot 有 quality gate
不寫 log 完整 logging + monitoring
不管成本 成本追蹤每 shot
一個人跑 多人協作 / git flow
不發布 自動 publish to platforms

PoC 你 1 個禮拜就能搞定,production 需要 3-6 個月慢慢補。


第 17 章:從失敗中學的 10 個教訓

18 天 17 版血淚換來這 10 個教訓,比任何技術細節都重要。

17.1 教訓 1:本地天花板真實存在

消費卡 16GB VRAM 跑不動產業級短劇生成。Wan22 14B / MuseTalk 512² / LatentSync 高解析度 全部 OOM。如果你的「成功」標準是「商業級」(85+ 分),本地是錯路。

啟示:先定義「夠好」的標準,再選工具。不要倒過來「先選工具再湊合」。

17.2 教訓 2:單一模型沒有 silver bullet

MuseTalk 對複雜構圖穩,但 256²;LatentSync 解析度高,但複雜構圖嘴融化;Wav2Lip 96² 最爛但最穩。每個工具都有適用場景,混搭才是正解。

啟示:建立「工具決策樹」(第 5.10 節),按 shot 特性自動 route。不要押注單一工具。

17.3 教訓 3:face_alignment 68 點 vs mediapipe 是地雷

MuseTalk 必走 dlib 68 點。用 mediapipe 478 點看起來「點更多應該更好」,實際嘴部完全錯位。技術選型時「規格大」不等於「適用」。

啟示:細讀模型的訓練細節(什麼 landmark format)。Reddit / GitHub issues 比 README 更有用。

17.4 教訓 4:WSL idle 必須關

vmIdleTimeout=-1 是 PC 長任務的命脈。沒設這個,跑 5 hr 訓練後突然消失。

啟示:所有「假設背景任務會跑到完」的場景,都要主動驗證「跑得完」的環境條件。

17.5 教訓 5:Edge TTS 免費版 SSML 限縮

商業級配音必上雲(Azure premium 或 ElevenLabs)。Edge TTS rate ±20% 是物理上限,再快卡頓。

啟示:免費工具一定有「夠用」的範圍。超出範圍就要付費,別硬撐。

17.6 教訓 6:量產前 smoke test 必跑 10 sec

別再砸 21 hr 量產才發現整體不行。v14 的痛點就是「一次量產」沒中途檢查。

啟示:把 production 拆「smoke → 1 集 → 30 集」三階段。每階段過 quality gate 才往下。

17.7 教訓 7:品質基準是文章先決

不知道「好」長什麼樣,做出來只會「不夠好」。研究 5 個標桿作品再動工(research-analyst agent 流程)。

啟示:競品分析不是 marketing 工作,是工程必要前置。

17.8 教訓 8:雲端 API 不是 cheating

算對成本電費 + 人力後,雲端常常更便宜。NT$45/集(混合)vs NT$80/集(全本地),雲端贏。

啟示:「自己做」的執念要打掉。專注做你「不可替代」的部分(劇本 / 角色 / 故事),其他外包。

17.9 教訓 9:失敗版本要保留

v8 / v9.1 / v12B2 都被「過時」但仍是 best baseline。新版本翻車時可以回退。

啟示:每個 milestone tag git,失敗實驗保留 branch,不要 force push 蓋掉。

17.10 教訓 10:手動驗證 > agent 報「完成」

21 hr 量產 = 沒中途 sanity check 的代價。AI agent 說「完成」不等於真的完成。

啟示:所有「自動化」任務都要有「人工 spot check」介面(dashboard / log / sample 抽查),不要 100% 信任 automation。

17.11 額外教訓:別跟自己賭

每個版本失敗時,內心 OS 都是「再試一次就會成功」。實際上:

  • v4-v7 LivePortrait 失敗 5 次 才放棄
  • v8 MuseTalk 失敗 N 次 才轉 Wav2Lip
  • v17 v1-v4 失敗 4 次 才認輸

每次失敗應該問:「這是 prompt 問題還是模型限制?」如果是模型限制,再試 100 次也沒用。對「不可解」要快放棄。

17.12 走過這條路的真實時間成本

如果你跟著本文走 30 天,預期投入:

階段 天數 每天小時 累計 hr
Week 1 環境 7 4 28
Week 2 對嘴 7 4 56
Week 3 劇本 + 後製 7 4 84
Week 4 量產 7 8 140
Week 5+ 修補 7 8 196

5 週共 196 hr,扣掉 weekend 約 25-30 個工作日。如果你週末也撩,5 週全職 = 25 天 × 8 hr = 200 hr。

這 200 hr 換來什麼:

  • 一整套本地 AI 短劇 stack
  • 13 角色 LoRA + voice clone(可重用)
  • 47 個坑的解法(不會再踩)
  • 對自己「能做到 60 分」的認知
  • 對「為什麼上限是 60 分」的理解

值不值:

  • 自媒體創業者:值(自己 stack 可長期演化)
  • 學生 / 學習者:值(learn by doing 最深)
  • 純效率追求者:不值(雲端 30 分鐘上手)

第 18 章:30 個常見問題 FAQ

Q1:我只有 RTX 3060 12GB,能跑這套嗎?

可以跑大部分,但 Wan22 5B i2v 可能會 OOM。建議:

  • Flux fp8 可跑(占 11.2 GB,邊緣)
  • LoRA 訓練可跑(rank 4 占約 12 GB)
  • MuseTalk 可跑(6.5 GB)
  • Wav2Lip + GFPGAN 可跑
  • Wan22 5B i2v 大概率 OOM(占 14.9 GB)
  • LTX-Video 2B 可跑(8 GB)

推薦你走 Flux + LoRA + MuseTalk + LTX-Video 路線,跳過 Wan22。

Q2:可以用 Apple Silicon Mac 跑嗎?

可以跑 Flux + LoRA + 部分 ComfyUI workflow,但:

  • MuseTalk 完全不能(需要 CUDA)
  • Wan22 完全不能(需要 CUDA)
  • LatentSync 完全不能(需要 CUDA)
  • 只能跑 Layer 1 / 2 / 4 / 6 / 7

Mac 適合當 orchestrator + ffmpeg + Edge TTS,不適合當 GPU worker。

Q3:我不會 Python,能玩嗎?

ComfyUI 本身是 web UI,不需要寫 code。可以:

  • 用 ComfyUI 拉 workflow 跑單 shot
  • 學 .bat / .sh 寫批次腳本
  • 用 ChatGPT / Claude 幫你寫 dispatcher

但量產 30 集還是需要寫 Python,建議花 1 週學基礎。

Q4:劇本可以丟給 Claude / GPT 寫嗎?

可以。實戰流程:

  1. 你寫 1 句話故事大綱(「50 歲女主被丈夫外遇後復仇」)
  2. 給 Claude 生 30 集大綱(每集 3-5 句)
  3. 給 Claude 生 ep1 32-shot 劇本(含 shot_type / dialogue / camera)
  4. 你人工修改 / 加 emotion / 加 SFX hint
  5. 輸出 JSON 給 dispatcher

LLM 不會幫你寫好,但會幫你省 80% 時間。

Q5:商業使用要注意什麼?

四個層面:

  • 角色 LoRA:訓自己 / 取得授權 / 公開角色 fork。不要訓「特定明星」(侵權)
  • 配音:Edge TTS 注意 Microsoft 商業條款(個人 OK,商業帶貨可能要付費)。F5-TTS Apache-2.0 商業 OK,但 voice ref 要授權
  • BGM:用 royalty-free(YouTube Audio Library / Epidemic Sound)
  • 發布:YouTube / TikTok 對 AI 生成內容要標註

Q6:5060 Ti 16GB 跑滿載多久不會壞?

連跑 30 hr 滿載沒問題(實測 13 角色 LoRA + 21 hr 量產都沒事)。但:

  • 溫度要監控 < 85°C
  • 機箱風扇要 3-4 顆
  • 顯卡風扇要清灰(每 3 個月)

5060 Ti 是 Blackwell 新架構,散熱比 4060 Ti 改進不少。

Q7:能跟 LM Studio / Oobabooga 一起跑嗎?

可以但要注意 VRAM。LM Studio 跑 14B 模型約占 9 GB,配 ComfyUI 跑 Flux fp8 11 GB,總和 20 GB 超過 16 GB 會 swap。

建議:

  • LM Studio 用 7B 模型(占 4 GB)
  • 或者只在 ComfyUI 不跑時用 LM Studio
  • 或者把 LM Studio 跑 Mac,ComfyUI 跑 PC

Q8:能不能用 Stable Diffusion 1.5 取代 Flux?

可以但品質差很多:

  • SD 1.5:512x512 強,720x1280 變形
  • SDXL 1.0:1024 強,但細節輸 Flux
  • Flux:12B 參數,2024 最強 open source

如果你只要動漫風格,SDXL(NoobAI XL Vpred)反而比 Flux 適合。寫實人物還是用 Flux。

Q9:能不能本地跑 Sora / Veo 2 / Kling?

不能,這些是 closed weights,只能透過官方 API 用。

本地能跑的 i2v 模型:

  • Wan22 5B / 14B
  • LTX-Video 2B / 13B
  • CogVideoX 5B / 13B
  • HunyuanVideo 13B
  • Mochi-1 10B

5060 Ti 16GB 只能跑前 3 個小版本。

Q10:可以接 ComfyUI 的 webhook 嗎?

可以,ComfyUI 在 task 完成時會發 websocket 訊息。subscribe ws://localhost:8188/ws?clientId=xxx 就能收到。

但實務上 polling /history/{prompt_id} 更簡單,每 2 sec 查一次即可。

Q11:可以跑 18+ 內容嗎?

技術上 Flux / SDXL 都有 NSFW LoRA / checkpoint 可用,但:

  • YouTube / TikTok 一律拒絕
  • 商業帶貨平台拒絕
  • Patreon 部分允許
  • Civitai / Reddit 完全 OK

如果是給成人平台用,可行;其他平台不要碰。

Q12:對嘴影片可以做多語言版本嗎?

可以。流程:

  1. 中文 dialogue → Edge TTS / F5-TTS 生中文音檔
  2. 同樣 dialogue 翻譯成英 / 日 / 西 → 各自生音檔
  3. 同樣 base 影片 → MuseTalk 對嘴 N 次(每個語言一次)
  4. 輸出 N 個版本

實測一支 ep1 跑 4 語言,多花 3 hr(MuseTalk 重新對嘴)。

Q13:可以做交互式 AI 短劇(觀眾選結局)嗎?

可以。架構:

  • 寫 N 個結局劇本(例如 3 個)
  • dispatcher 跑 N 個版本,每個結局獨立 mp4
  • 前端用 video.js / Plyr 做選項按鈕
  • 觀眾選 → 切換到對應 mp4

時間成本:3 個結局 ≈ 1.5 倍工作量(共用前 70%)。

Q14:可以跟其他人協作嗎?

可以,但要注意:

  • LoRA / Models 檔案大(幾 GB),git LFS 必要
  • ComfyUI workflow 用 git tracked,每個 commit 一個版本
  • 劇本 JSON 用 git 管,PR 流程
  • 影片成品太大不要進 git,用 cloud storage(R2 / S3)

實戰:drama-factory 專案 32 GB models 用 git-annex 管。

Q15:跑壞了 GPU 怎麼辦?

5060 Ti 全新有 NVIDIA 3 年保固(部分品牌 3+1 年)。常見故障:

  • 風扇壞:保固免費換
  • 顯存壞(藍屏 / 雪花):保固換新
  • 過電燒毀:保固不修(但極罕見)

平時保養:

  • 機箱溫度監控(HWiNFO 免費)
  • 風扇定期清灰
  • 不超頻
  • 電源 80+ Gold 以上

Q16:跑到一半斷電了怎麼辦?

如果有 progress 檔(第 8.13 節),重啟後 python dispatcher.py --resume 即可。最多丟最後 1 shot(被中斷的那個)。

預防:

  • 上 UPS(NT$3000 的就夠跑 5 分鐘讓你優雅關機)
  • progress 寫頻繁(每 shot 完成就 save)

Q17:訓 LoRA 過擬合了怎麼救?

幾個方法:

  1. 降 inference strength:strength_model 從 1.0 降到 0.6
  2. 重訓低 step:100 step → 50 step
  3. 降 rank:rank 4 → rank 2
  4. 加 caption_dropout:0.0 → 0.05(讓 trigger 偶爾被丟掉,學泛化)
  5. 加 noise_offset:0.0 → 0.05(learn 更多 variation)

優先試 1,再試 2。極端情況才重訓。

Q18:跑 24 hr 量產過程怎麼省電?

幾個技巧:

  • ComfyUI --lowvram 模式可省 30% VRAM(速度減 20%)
  • GPU power limit nvidia-smi -pl 150 強制 150W(5060 Ti TDP 180W)
  • 不跑時自動關電腦(cron + shutdown)

實測 21 hr 量產耗電約 4-5 度(NT$20-30)。

Q19:可以跑在雲端 GPU 上嗎?

可以。推薦平台:

  • RunPod:5060 Ti $0.39/hr,4090 $0.69/hr
  • Vast.ai:4090 $0.40/hr(社區)
  • CoreWeave:H100 $4/hr(商業)

21 hr 量產 一集 = $8-15 USD ≈ NT$240-450。比本地 31 天電費(NT$670 / 31 ≈ NT$22)貴 10-20 倍,但省 30 天時間。

Q20:能不能用 Docker 部署?

可以但 ComfyUI 在 Docker 對 GPU 配置麻煩。推薦:

  • Linux 直接裝(最簡單)
  • WSL2 + Linux 環境(次選)
  • Docker(不推薦給新手)

如果要 Docker:

FROM nvidia/cuda:12.8.0-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y python3 python3-pip git
WORKDIR /app
RUN git clone https://github.com/comfyanonymous/ComfyUI.git
WORKDIR /app/ComfyUI
RUN pip install torch==2.6.0 --index-url https://download.pytorch.org/whl/cu128
RUN pip install -r requirements.txt
EXPOSE 8188
CMD ["python3", "main.py", "--listen", "0.0.0.0", "--port", "8188"]

docker run 加 --gpus all 才能用 GPU。

Q21:可以加自己的 ControlNet 嗎?

可以。Flux 的 ControlNet 已有官方 release:

  • flux1-canny-dev:邊緣控制
  • flux1-depth-dev:深度控制
  • flux1-redux-dev:reference 影像

在 workflow 加 ControlNetLoader + ControlNetApply node。重點:ControlNet 占 4-6 GB VRAM,5060 Ti 跑 Flux + ControlNet 邊緣 OOM。

Q22:可以做動漫 sticker / 動圖 GIF 嗎?

可以。短的(< 3 sec)用 Wan22 5B + 轉 GIF:

ffmpeg -i input.mp4 \
    -vf "fps=15,scale=480:-1:flags=lanczos" \
    -loop 0 output.gif

或保留 mp4,現代瀏覽器都支援 mp4 inline 播放。

Q23:訓練資料能不能用合成資料?

可以。流程:

  1. 用一個 base prompt 跑 Flux 100 張(不同 seed)
  2. 人工挑 30 張一致性好的
  3. 用這 30 張當訓練資料

優點:完全自己擁有版權。 缺點:合成資料偶爾有「Flux 的 stylistic bias」,可能讓 LoRA 學到 Flux 風格而非真實人物。

Q24:可以做 IP 衍生角色嗎?

技術上可以,法律上不行。例如「訓練哈利波特 LoRA」會被 Warner Bros 告。

可行的做法:

  • 訓「中性魔法師女孩」(不指名)
  • 訓「公共領域人物」(古人、古代神話)
  • 訓「自己設計的原創角色」

法律安全 = 商業可發。

Q25:可以做即時 AI 主播嗎?

可以但難度高很多:

  • TTS 改用 ChatTTS / Azure neural(low latency)
  • 對嘴改用 SadTalker / Wav2Lip realtime
  • ComfyUI 改用 stream mode

5060 Ti 跑即時 AI 主播勉強可(~5 fps),但畫質會 sacrifice。商業級即時 AI 主播建議用 HeyGen / Synthesia 雲端方案。

Q26:訓完 LoRA 可以分享給朋友嗎?

可以,LoRA 檔案 30-100 MB 用 Google Drive / WeTransfer 都能傳。

但注意:

  • LoRA 學了你的訓練資料的 stylistic bias
  • 朋友用你的 LoRA + 不同 base model 可能效果差很多
  • 商業 LoRA 應該帶 license(CC-BY-NC 或商業授權)

Q27:可以做多角色合照嗎?

可以但難度高(v8 教訓)。技巧:

prompt: "<trigger_a> on the left and <trigger_b> on the right,
both sitting at restaurant table, talking to each other,
front view, professional photography"

# 兩個 LoRA 都載
lora_a: strength=0.7
lora_b: strength=0.7

仍會出現「臉融合」(兩人的特徵混在一起)。雙人合照建議:

  • 兩個 LoRA 各跑單人 anchor
  • ffmpeg 拼接 split-screen
  • 或用 ControlNet pose 鎖兩人位置

Q28:跑 30 集需要多少 storage?

實測:

  • 13 LoRA:30 GB
  • ComfyUI models:174 GB
  • 訓練 cache:50 GB
  • 30 集成品 mp4:~3 GB(720x1280, 5 min, h264)
  • 30 集中間檔(每 shot mp4):~60 GB

合計 ~320 GB。建議 1 TB NVMe SSD。

Q29:可以做廣告嗎?

可以但要評估:

  • 30 秒廣告 ≈ 6-8 shot
  • 5060 Ti 一支廣告 ≈ 1 hr 量產
  • 品質 60-70 分(可接受個人 / 小品牌)

商業廣告(KOL / 大品牌)建議:

  • 上 Runway Gen-3 / Kling 1.6(品質 90+ 分)
  • 或者本地生 anchor + 雲端跑 i2v(混合)

Q30:可以結合真人實拍嗎?

可以。混合流程:

  1. 真人實拍主角(演員)
  2. AI 生背景 / 配角 / 特效
  3. 用 ffmpeg 合成

例如:

# 真人實拍綠幕 → key out 綠色
ffmpeg -i actor_greenscreen.mp4 -vf "
    chromakey=0x00ff00:0.1:0.1
" actor_alpha.mov

# AI 生背景 + 疊上演員
ffmpeg -i ai_background.mp4 -i actor_alpha.mov \
    -filter_complex "[0:v][1:v]overlay=W-w-10:H-h-10" \
    final.mp4

這是當前最務實的「半 AI 短劇」路線:演員費用低 + AI 補背景。


附錄 A:完整指令 cheatsheet

# === WSL2 設定 ===
notepad %USERPROFILE%\.wslconfig
# 加入:[wsl2] memory=112GB vmIdleTimeout=-1

# === portproxy ===
$wslip = wsl -d Ubuntu -- bash -c "ip addr show eth0 | grep 'inet ' | awk '{print `$2}' | cut -d/ -f1"
netsh interface portproxy add v4tov4 listenport=8188 listenaddress=0.0.0.0 connectport=8188 connectaddress=$wslip
netsh interface portproxy show v4tov4

# === Windows 防火牆 ===
New-NetFirewallRule -DisplayName "WSL ComfyUI 8188" -Direction Inbound -LocalPort 8188 -Protocol TCP -Action Allow

# === Mac SSH keeper ===
nohup bash -c 'echo "sleep 108000" | ssh -o ServerAliveInterval=15 user@192.168.0.15 "wsl -d Ubuntu -- bash"' > /tmp/wsl_keeper.log 2>&1 &

# === Mac SSH tunnel ===
ssh -L 28189:127.0.0.1:8188 -N -f user@192.168.0.15

# === ComfyUI 啟動(WSL)===
cd /home/user/ComfyUI
nohup python main.py --listen 0.0.0.0 --port 8188 > /home/user/comfyui.log 2>&1 &

# === ComfyUI 啟動(Windows native UTF-8)===
@echo off
chcp 65001 > nul
set PYTHONIOENCODING=utf-8
cd /d "C:\Users\user\ComfyUI_windows_portable"
python_embeded\python.exe -s ComfyUI\main.py --listen 0.0.0.0 --port 8188

# === ComfyUI VRAM zombie 清理 ===
pkill -9 -f "ComfyUI"
fuser -k /dev/nvidia*
sleep 3

# === LoRA 訓練(裝依賴)===
cd /home/user/ComfyUI/custom_nodes/ComfyUI-FluxTrainer
sed -i '/numpy/d' requirements.txt
pip install -r requirements.txt
pip install transformers==4.57.6 face-alignment==1.3.5

# === MuseTalk 安裝 ===
cd /home/user
git clone https://github.com/TMElyralab/MuseTalk.git
cd MuseTalk && git checkout v1.5
grep -rln "torch.load(" musetalk/ | while read f; do
    sed -i 's/torch\.load(\([^)]*\))/torch.load(\1, weights_only=False)/g' "$f"
done

# === ffmpeg 一行運鏡 ===
# push_in
ffmpeg -y -loop 1 -i a.png -vf "zoompan=z='min(zoom+0.0015,1.5)':d=72:s=720x1280:fps=24" -t 3 -c:v libx264 -pix_fmt yuv420p out.mp4

# === ffmpeg BGM duck ===
ffmpeg -y -i v.mp4 -i bgm.mp3 -filter_complex "[1:a]volume=-22dB[bgm];[0:a][bgm]sidechaincompress=threshold=0.05:ratio=8:attack=20:release=200,amix=inputs=2:duration=first" -c:v copy out.mp4

# === ffmpeg 字幕燒入 ===
ffmpeg -y -i v.mp4 -vf "subtitles=subs.ass" -c:a copy out.mp4

# === ffmpeg concat 無損 ===
ffmpeg -y -f concat -safe 0 -i list.txt -c copy concat.mp4

# === Wan22 frame 驗證 ===
ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 shot01.mp4

# === Edge TTS ===
pip install edge-tts
edge-tts --voice "zh-CN-XiaoxiaoNeural" --text "你跟她在一起多久了?" --write-media line.wav

# === F5-TTS ===
f5-tts_infer-cli --model F5TTS_v1_Base --ref_audio ref.wav --ref_text "參考逐字稿" --gen_text "目標台詞" --output_file out.wav

附錄 B:47 坑 → 章節對照

坑編號 章節
1-10 第 3 章 / 第 2 章
11-20 第 3 章 / 第 6 章
21-28 第 4 章
29-36 第 6 章
37-42 第 5 章
43-47 第 7 章 / 第 9 章

附錄 C:18 天 17 版時間軸(決策樹)

2026-05-17 立項 ─┐
                │
2026-05-18 ─────┼──→ Phase 0: LoRA 13 角色(9/13 完成,30 hr 連跑)
                │
2026-05-19 ─────┼──→ Voice 候選 30 個 YouTube URL
                │
2026-05-22 ─────┼──→ v1.7 GGUF 14B OOM ✗ → 棄 GGUF
                │
2026-05-24-25 ──┼──→ v8 Wav2Lip baseline ✓ (shot06/07 過)
                │
2026-05-26 ─────┼──→ LTX 中介 ep01 17MB ✓ (但 i2v 鎖首幀差)
                │
2026-05-27 ─────┼──→ v4-v6 Sniper Shot06 三代 → v6 GFPGAN 過關
                │     v8 MuseTalk mmcv 編譯地獄 ✗ → 棄 mmcv
                │
2026-05-28 早 ──┼──→ v9.1 GFPGAN 大女主獨白 8 shot 穩 ✓
                │     v10 LatentSync 1.5 face-on OK / 複雜融化 △
                │
2026-05-28 晚 ──┼──→ v11 PuLID-Flux transformer_options ✗
                │     v12 五版全失敗 → handoff
                │
2026-05-29 ─────┼──→ v13 MuseTalk V1.5 face_alignment patch 跑通 ✓
                │     v14 4 type sniper 全綠
                │
2026-05-31-6-1 ─┼──→ v14 450 shot 21 hr ep1 → 17.7 min mp4
                │     用戶 7 項批評
                │
2026-06-01-03 ──┼──→ v15 SSML + vf + BGM + 同源 seed → smoke only
                │
2026-06-03 ─────┼──→ v16 NoobAI XL 動漫轉向 460 shot 翻車
                │
2026-06-03-04 ──┼──→ v17 10 sec demo 4 連版(愛情 + 武俠)
                │     v4 仍嘴模糊 + 臉扭曲 + 語速慢
                │
2026-06-04 ─────┴──→ honest admit: 5060 Ti 16GB 已天花板
                     轉雲端 Dreamina / Kling
                     寫教科書 ← (本文)

附錄 D:5060 Ti 16GB VRAM 分配地圖

┌─────────────────────────────────────────────────────────┐
│                  RTX 5060 Ti 16,384 MiB                  │
├──────────────────────────────────┬──────────────────────┤
│                                  │                       │
│    Workflow: Flux fp8 720x1280   │    System reserved    │
│    ─────────────────────────     │    ─────────────      │
│    flux1-dev-fp8     11,200 MiB  │    Display      512   │
│    LoRA           +     30 MiB   │    Driver       512   │
│    KV cache       +    640 MiB   │    OS overhead  256   │
│    ─────────────                 │                       │
│    Total         ~11,870 MiB     │    Total      1,280   │
│                                  │                       │
└──────────────────────────────────┴──────────────────────┘
        ↓
┌─────────────────────────────────────────────────────────┐
│    Workflow: Wan22 5B ti2v 720x1280 81 frame             │
│    ─────────────────────────────                         │
│    wan2.2_ti2v_5B_fp16          14,900 MiB               │
│    umt5_xxl_fp8_e4m3fn          +5,200 MiB (offload OK)  │
│    wan2.2_vae                   +  254 MiB                │
│    latent (81 × 90 × 160 × 16)  +1,860 MiB               │
│    ─────────────                                          │
│    Peak (encoder offloaded)    ~16,900 MiB → 邊緣 OOM     │
└─────────────────────────────────────────────────────────┘

→ 結論:5060 Ti 16GB 跑 Wan22 5B 已經邊緣,不能再加。
        14B / 24B 模型物理上跑不動。

附錄 E:自媒體 30 集 ROI 試算

假設:

  • 自己一個人做(人力 0 計)
  • 30 集 × 5 分鐘 = 150 分鐘 content
  • 一集 32 shot
  • 一集量產時間 = 25 小時(含 retry / 後製)
  • 30 集 = 750 小時 ≈ 31 天 24/7

本地路線:

項目 金額
硬體一次性 NT$15,000(5060 Ti 16GB)
31 天電費 NT$650 / 30 × 31 ≈ NT$670
雲端 LLM / TTS NT$0(Edge TTS 免費)
合計 NT$15,670

雲端 Dreamina 路線:

項目 金額
硬體 NT$0(用筆電)
30 集影片 NT$0(每天 225 點 ≈ 11 clip / 天,配額內)
LLM NT$0(用免費 Claude / Gemini)
TTS NT$0(Edge TTS)
合計 NT$0

時間成本:

項目 本地 雲端
學習時間 30 天 0.5 天
製作時間 750 hr 100 hr
總計 30 天學 + 31 天做 0.5 天學 + 12 天做

結論:除非你有「本地隱私 / 客製需求 / 學習 AI stack」等明確理由,否則 30 集自媒體量產應該選雲端。



附錄 F:18 天 17 版的視覺證據

下面是各版本的代表 keyframe / 對嘴成品 / 量產截圖,按時間順序排:

v04 Wedding Veil ReActor 第 30 幀後臉漂移

v04 face drift

ReActor face swap 在第 30 幀後五官位置開始偏移,左眼比右眼低 5px、嘴角往下歪。這是促使我們加 PoseControlNet + YOLOv5l head bbox 的契機。

v04 Flux keyframe(baseline)

v04 flux keyframe

第一版 Flux 生成的婚紗 anchor。構圖可用但細節不夠 cinematic,色彩偏 flat。

v05 加 PoseControlNet

v05 flux keyframe

加了 PoseControlNet 後 body lock 住,但臉部仍漂。Flux 對 control net 的整合度沒 SDXL 好。

v06 GFPGAN restore 真正過關

v06 gfpgan fix

GFPGAN v1.4 only_center_face 後臉部終於穩定,肌膚紋理重建。這是 v6 拍板的成果,後續 v7-v17 都沿用這個 face restore 機制。

v06 Flux keyframe(修正後)

v06 flux keyframe

v06 stack 的最終 keyframe,比 v04 / v05 都有明顯品質提升。

v08 MuseTalk 嘴部模糊

v08 musetalk mouth blur

MuseTalk 256² 原始輸出嘴部明顯模糊,需要後續 GFPGAN restore 才能可用。這是 5060 Ti 16GB 跑不動 512² 的物理代價。

v10 LatentSync 純特寫成功

v10 latentsync success

LatentSync 1.5 在純臉特寫的成功案例:bride solo shot05/06/07 face-on 完美。但這只是「最佳場景」,複雜構圖會嘴融化。

v12 ep1 真人短劇 production

v12 ep1 realhuman drama

v12 階段的 production keyframe,可看到 Flux + LoRA 對 50 歲女主角的銀絲、皺紋特徵已穩定。但跨 shot 仍會漂。

v16 動漫 - 林夏(女主)

v16 anime linxia

v16 轉動漫後 NoobAI XL Vpred 生成的女主 22yo f01_linxia。畫風 anime 後對「角色一致性」要求降低,IPAdapter Plus 反而比 LoRA 更靈活。

v16 動漫 - 木雪(反派)

v16 anime muxue villain

反派女配角 muxue。IPAdapter 一張 reference 就能複用,比 LoRA 訓練省很多時間。但代價是 character lock 不如 LoRA 強。

v16 動漫 - 蘇妮(性感配角)

v16 anime suni sexy

性感配角 suni,NoobAI XL 對人體比例掌握不錯。

v16 動漫 - 沈逸(男主 CEO)

v16 anime shenyi ceo

男主 CEO 沈逸。動漫風格男角色 NoobAI XL 也能穩定生成。

v17 武俠 v4 戰鬥場景

v17 wuxia combat

v17 武俠 v4 最終嘗試的戰鬥場景。HERO_LOCK weight 1.5 + IPAdapter weight 0.95 把角色鎖到極限,但武打速度仍受 Wan22 5B 81 frame 限制無解。


附錄 G:劇本 JSON 完整 schema

{
  "$schema": "drama-factory-script-v15",
  "episode_id": "ep1",
  "episode_title": "離婚那一天",
  "duration_target_sec": 300,
  "resolution": [720, 1280],
  "fps": 24,
  "characters": [
    {
      "id": "female_lead",
      "name": "蘇曉雯",
      "age": 50,
      "lora": "soxw_zh50_v1_100step_rank4_fp16.safetensors",
      "trigger": "soxw_zh50",
      "voice": "zh-CN-XiaoxiaoNeural",
      "voice_emotion_default": "neutral"
    }
  ],
  "shots": [
    {
      "shot_id": 1,
      "shot_type": "cutaway",
      "duration_sec": 2.5,
      "character": null,
      "scene_desc": "清晨6:00, 主臥, 窗外鳥叫",
      "anchor_prompt": "early morning 6am, bedroom window, sunlight",
      "negative_prompt": null,
      "dialogue": null,
      "subtitle": null,
      "voice": null,
      "emotion": null,
      "camera": "static",
      "sfx": ["bird_chirp"],
      "bgm_hint": "calm morning"
    },
    {
      "shot_id": 2,
      "shot_type": "static_pose",
      "duration_sec": 3.0,
      "character": "female_lead",
      "scene_desc": "梳妝台前, 看著鏡中自己",
      "anchor_prompt": "soxw_zh50 sitting at vanity, looking in mirror, sad expression, morning light",
      "dialogue": null,
      "subtitle": "(女主沉默地看著鏡中的自己)",
      "voice": null,
      "emotion": null,
      "camera": "push_in",
      "sfx": [],
      "bgm_hint": "melancholy piano"
    },
    {
      "shot_id": 3,
      "shot_type": "talking_head",
      "duration_sec": 4.5,
      "character": "female_lead",
      "scene_desc": "主臥, 對著電話",
      "anchor_prompt": "soxw_zh50 holding phone to ear, devastated expression, hand trembling",
      "dialogue": "你跟她在一起多久了?",
      "subtitle": "你跟她在一起多久了?",
      "voice": "zh-CN-XiaoxiaoNeural",
      "emotion": "shocked",
      "camera": "slow_push_in",
      "sfx": [],
      "bgm_hint": "tense strings"
    },
    {
      "shot_id": 4,
      "shot_type": "action",
      "duration_sec": 3.4,
      "character": "female_lead",
      "scene_desc": "梳妝台前, 摔掉化妝品",
      "anchor_prompt": "soxw_zh50 standing at vanity, arm raised in anger, about to sweep things off",
      "motion_prompt": "violent angry motion sweeping objects off vanity, cosmetics flying through air, glass breaking, hair whipping",
      "dialogue": null,
      "subtitle": "(女主憤怒地掃落所有化妝品)",
      "voice": null,
      "emotion": null,
      "camera": null,
      "sfx": ["glass_break", "objects_falling"],
      "bgm_hint": "climax drums"
    }
  ]
}

每個 shot 必填 7 個欄位:shot_id / shot_type / duration_sec / scene_desc / anchor_prompt / subtitle / bgm_hint。其他根據 shot_type 選填。


附錄 H:常用 prompt 模板庫

H.1 anchor prompt 模板

talking head 模板:

<TRIGGER>, <AGE_DESC>, <ACTION>, <EMOTION>, <ENVIRONMENT>,
<LIGHTING>, close-up portrait composition, depth of field f/2.8,
shot on Arri Alexa, anamorphic 2.39:1, cinematic color grading, 4k

範例填入:

soxw_zh50, 50 years old elegant Taiwanese woman with salt-and-pepper hair,
holding phone to ear with trembling hand, devastated shocked expression
with eyes wide open, in master bedroom, cold blue dawn light from window,
close-up portrait composition, depth of field f/2.8, shot on Arri Alexa,
anamorphic 2.39:1, cinematic color grading, 4k

action shot 模板:

<TRIGGER>, <AGE_DESC>, <DYNAMIC_POSE>, <ACTION_HINT>, <ENVIRONMENT>,
<LIGHTING>, medium full body shot, sense of motion, motion blur on extremities,
shot on Arri Alexa, anamorphic 2.39:1, cinematic, 8k

cutaway 模板(無人物):

<SCENE_DESCRIPTION>, <TIME_OF_DAY>, <ATMOSPHERIC_DETAIL>,
<LIGHTING>, no human, environmental shot, atmospheric, cinematic,
shot on Arri Alexa, anamorphic 2.39:1, 4k

H.2 motion prompt 模板

talking_head motion(微推鏡):

<COMPOSITION>, very slow dolly push-in toward subject's face,
<SUBJECT> <ACTION>, <SECONDARY_MOTION>, <CONSISTENCY_LOCK>

action motion(武打):

<COMPOSITION>, fast camera tracking around action,
<SUBJECT> <PRIMARY_ACTION>, <PHYSICS_EFFECTS>,
<CONSISTENCY_LOCK>, moderate pace combat choreography

slow reveal motion:

<COMPOSITION>, slow dolly out reveal,
camera slowly pulling back to reveal landscape,
<SUBJECT> standing still in <POSE>, <SECONDARY_MOTION>,
<CONSISTENCY_LOCK>, ultra-slow majestic motion

H.3 NEGATIVE prompt 通用模板

Flux NEGATIVE:

ugly, deformed, distorted, low quality, blurry, watermark, text,
multiple faces, two persons, abstract, neon, dark background,
motion graphics, numerical digits, melted face, distorted eyes,
extra fingers, missing limbs, bad anatomy

Wan22 NEGATIVE(anti-shake + anti-drift):

text, watermark, blurry, deformed limbs, two persons, motion graphics,
frozen, static, character change, color shift, clothing change,
hair change, close-up portrait, talking head

SDXL(動漫)NEGATIVE:

worst quality, low quality, lowres, blurry, jpeg artifacts,
bad anatomy, bad hands, missing fingers, modern clothing,
suit, tie, t-shirt, jeans, glasses, watermark, signature, text,
ugly, deformed, asymmetrical face, ugly face, plain face,
flat shading, line art

附錄 I:常見錯誤訊息對照表

錯誤訊息 章節 一句話解
CUDA out of memory 第 6 章 降 batch / 用 fp8 / 換更小模型
weight_type required 第 11 章 坑 #15 IPAdapter 加 "weight_type": "standard"
ClipVision model not found 第 11 章 坑 #14 cp clip_vision_h.safetensors CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors
CLIPFeatureExtractor not found 第 11 章 坑 #22 pip install transformers==4.57.6
Weights only load failed 第 11 章 坑 #39 torch.load 加 weights_only=False
Connection refused localhost:8188 第 3 章 確認 ComfyUI 啟動了且 listen 0.0.0.0
empty reply from server 第 11 章 坑 #8 portproxy 別填 PC 自己 LAN IP
UnicodeDecodeError 'cp950' 第 11 章 坑 #6 chcp 65001 + PYTHONIOENCODING=utf-8
mmcv build failed 第 11 章 坑 #40 改用 face_alignment
403 Forbidden HF 第 11 章 坑 #10 HF login + accept terms 或用 portable single-file
numpy version incompatible 第 11 章 坑 #21 拿掉 requirements numpy 行
port 8189 in use 第 11 章 坑 #7 改 8188
subtitles font not found 第 11 章 坑 #45 apt install fonts-noto-cjk
concat re-encoding 第 11 章 坑 #46 所有 shot 統一 encode params

附錄 J:本地 vs 雲端決策矩陣

如果你還在猶豫本地還是雲端,按下表自己打分:

因素 本地 +1 雲端 +1
你已有 16GB+ GPU ✓
你會 Python + Linux ✓
內容含商業機密 ✓
需要訓練自己角色 LoRA ✓
你享受 AI stack 折磨 ✓
你只想快速量產 ✓
你預算 < NT$5,000 ✓
你沒時間 debug 30 天 ✓
你做 30 集以下 ✓
你要 85+ 分品質 ✓

本地 ≥ 4 票:留本地。雲端 ≥ 4 票:上雲端。4-4 平手:上雲端(雲端是 default 選項,本地要有強理由)。


附錄 K:給接手者的 30 分鐘 quick start

如果你今天才看到這篇文章,下面是 30 分鐘內可以做的事,讓你判斷要不要繼續:

0-5 min:讀第 1 章 + 第 2 章硬體選型。確認你的 GPU 是否 8GB+ VRAM、是否 sm_120+(5060 Ti / 5090)或 sm_89+(4090 / 4060 Ti)。

5-15 min:跑這條指令確認 PyTorch 認 GPU:

python -c "
import torch
print(f'PyTorch: {torch.__version__}')
print(f'CUDA: {torch.cuda.is_available()}')
print(f'Device: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"CPU only\"}')
print(f'VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB')
"

15-25 min:安裝 ComfyUI portable:

cd /home/user
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
pip install torch==2.6.0 --index-url https://download.pytorch.org/whl/cu128
pip install -r requirements.txt
python main.py --listen 127.0.0.1 --port 8188

打開瀏覽器 http://127.0.0.1:8188 看到 ComfyUI 介面就成功。

25-30 min:跑一個 default workflow 看看能不能出圖:

  • 在 ComfyUI 點 Load → 選 default workflow
  • 改 prompt 成 a cat sitting on table
  • 點 Queue
  • 等 30 sec 看到圖

如果這 30 分鐘都過了,你的 stack 就 ready 了。繼續看第 4 章開始正式工作。如果在任何一步卡住,去附錄 I 對照錯誤訊息。


本文到此結束。47 個坑你已經知道在哪。剩下的,就是你自己上場。

如果你跟著走 30 天還是做不出一集 80 分的短劇,那不是你的問題,是物理問題:5060 Ti 16GB 已天花板。換 4090 或上雲端,省 30 天血淚。

如果你做到 60 分量產 30 集,恭喜你已經達到我們的水準。剩下的 20 分需要更大的卡 / 雲端 / 或者真人配音 + 真人剪輯。

工程從來不是萬能的。知道哪裡是天花板,比硬撞天花板更重要。


本系列其他兩篇

這篇是 C 版(教學派,47 坑指南)。同系列另有兩個視角:

本篇是行動派的 SOP;如果你已被卡在某個坑想知道整段歷史背景,走 A 版;如果你還在猶豫要不要開始這個項目,走 B 版。