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 目標
讀完本章你會知道:
- 一支本地 AI 短劇從劇本到 publish 經過哪幾個模型、幾條 pipeline
- 哪些環節必須本地跑、哪些可以丟雲端
- 整個 stack 的硬體、軟體、儲存最小可運作配置(minimum viable stack)
- 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 的三個正當理由
雖然本文最終建議轉雲端,但本地仍有不可取代的場景:
- 隱私需求:劇本內容涉及商業機密、未公開 IP、私人故事,不能丟到 OpenAI/Anthropic 服務端
- 客製需求:需要自己訓練的特定角色 LoRA(例如真人代言人、特定動漫角色),雲端不支援
- 學習需求:想理解整套 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(不存在):太新,沒二手市場
買二手三件事必做:
- 跑
nvidia-smi --query-gpu=temperature.gpu --format=csv -l 1滿載 30 分鐘看溫度(>80°C 表示散熱壞) - 跑 furmark 30 分鐘確認穩定不藍屏
- 檢查 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。
Step 6:models 走 symlink 省空間
# 把 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.safetensors11.9 GB - 已下載 T5 encoder
t5xxl_fp16.safetensors9.8 GB - 已下載 VAE
ae.safetensors335 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 會:
- OOM:latent 變大,VRAM 不夠
- 品質崩:超出訓練分布,畫面開始模糊
- 時序錯亂:第 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 流程:
- 上傳 30 sec reference wav
- 標註 reference 文本
- 訓練 SoVITS model(約 30 min 5060 Ti)
- 訓練 GPT model(約 1 hr)
- 用訓好的模型 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 分鐘。為什麼差距大:
- Wan22 5B retry:失敗率 ~15%,retry 一次又 6 分鐘
- MuseTalk preprocess:每個 shot 要 frame extract + face track,extra 60-90 sec
- GFPGAN restore video:每秒影片 restore 約 0.3 倍速
- scp 跨機:Mac orchestrator ↔ PC GPU worker scp 每次 2-5 sec
- 間隔冷卻: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:1attack=20:壓縮啟動 20msrelease=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/。
坑 12:WSL symlink 在 Windows 不被 follow
症狀:在 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 件事
- 不要重蹈 v1.7 GGUF 14B 的覆轍:5060 Ti 16GB 跑不動。
- 不要嘗試把 Wan22 length 改成 161:架構級限制改不了。
- 不要在 talking head 跑 LatentSync:複雜構圖嘴融化。
- 不要相信「跑完才看」:22 小時量產要先 smoke 10 sec。
- 不要把 Wav2Lip 當過渡:搭 GFPGAN 後比 LatentSync 還穩。
13.3 必做的 5 件事
- 必做 vmIdleTimeout=-1 + Mac SSH keeper:WSL 長任務的命脈。
- 必做 13 角色 connect 連跑 LoRA:cache reuse 救 23 小時。
- 必做 face_alignment 68 patch:MuseTalk 在 5060 Ti Blackwell 唯一路徑。
- 必做 BGM duck:沒有 ducking 的 BGM 直接報廢。
- 必做 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 集全做完前主機壞了
防範:
- 每 shot 即時 backup 到 R2/S3:成品不丟
- progress.db 每小時 backup:恢復可從 last checkpoint 接續
- ComfyUI workflows 走 git:版本可追溯
- 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 句話故事大綱(「50 歲女主被丈夫外遇後復仇」)
- 給 Claude 生 30 集大綱(每集 3-5 句)
- 給 Claude 生 ep1 32-shot 劇本(含 shot_type / dialogue / camera)
- 你人工修改 / 加 emotion / 加 SFX hint
- 輸出 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:對嘴影片可以做多語言版本嗎?
可以。流程:
- 中文 dialogue → Edge TTS / F5-TTS 生中文音檔
- 同樣 dialogue 翻譯成英 / 日 / 西 → 各自生音檔
- 同樣 base 影片 → MuseTalk 對嘴 N 次(每個語言一次)
- 輸出 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 過擬合了怎麼救?
幾個方法:
- 降 inference strength:strength_model 從 1.0 降到 0.6
- 重訓低 step:100 step → 50 step
- 降 rank:rank 4 → rank 2
- 加 caption_dropout:0.0 → 0.05(讓 trigger 偶爾被丟掉,學泛化)
- 加 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:訓練資料能不能用合成資料?
可以。流程:
- 用一個 base prompt 跑 Flux 100 張(不同 seed)
- 人工挑 30 張一致性好的
- 用這 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:可以結合真人實拍嗎?
可以。混合流程:
- 真人實拍主角(演員)
- AI 生背景 / 配角 / 特效
- 用 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 幀後臉漂移

ReActor face swap 在第 30 幀後五官位置開始偏移,左眼比右眼低 5px、嘴角往下歪。這是促使我們加 PoseControlNet + YOLOv5l head bbox 的契機。
v04 Flux keyframe(baseline)

第一版 Flux 生成的婚紗 anchor。構圖可用但細節不夠 cinematic,色彩偏 flat。
v05 加 PoseControlNet

加了 PoseControlNet 後 body lock 住,但臉部仍漂。Flux 對 control net 的整合度沒 SDXL 好。
v06 GFPGAN restore 真正過關

GFPGAN v1.4 only_center_face 後臉部終於穩定,肌膚紋理重建。這是 v6 拍板的成果,後續 v7-v17 都沿用這個 face restore 機制。
v06 Flux keyframe(修正後)

v06 stack 的最終 keyframe,比 v04 / v05 都有明顯品質提升。
v08 MuseTalk 嘴部模糊

MuseTalk 256² 原始輸出嘴部明顯模糊,需要後續 GFPGAN restore 才能可用。這是 5060 Ti 16GB 跑不動 512² 的物理代價。
v10 LatentSync 純特寫成功

LatentSync 1.5 在純臉特寫的成功案例:bride solo shot05/06/07 face-on 完美。但這只是「最佳場景」,複雜構圖會嘴融化。
v12 ep1 真人短劇 production

v12 階段的 production keyframe,可看到 Flux + LoRA 對 50 歲女主角的銀絲、皺紋特徵已穩定。但跨 shot 仍會漂。
v16 動漫 - 林夏(女主)

v16 轉動漫後 NoobAI XL Vpred 生成的女主 22yo f01_linxia。畫風 anime 後對「角色一致性」要求降低,IPAdapter Plus 反而比 LoRA 更靈活。
v16 動漫 - 木雪(反派)

反派女配角 muxue。IPAdapter 一張 reference 就能複用,比 LoRA 訓練省很多時間。但代價是 character lock 不如 LoRA 強。
v16 動漫 - 蘇妮(性感配角)

性感配角 suni,NoobAI XL 對人體比例掌握不錯。
v16 動漫 - 沈逸(男主 CEO)

男主 CEO 沈逸。動漫風格男角色 NoobAI XL 也能穩定生成。
v17 武俠 v4 戰鬥場景

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 坑指南)。同系列另有兩個視角:
- 🅰 本地 AI 短劇生成完全失敗實錄:30 天 17 版 5060 Ti 16GB 血淚教科書 — 真實派敘事,4.5 萬字按 v1.7-v17 時間軸講完整失敗史,理解為什麼會踩到這些坑。
- 🅱 從 0 到放棄:一張 16GB 顯卡能不能拍中國短劇? — 反差派,給還在猶豫值不值得砸時間的決策者讀。
本篇是行動派的 SOP;如果你已被卡在某個坑想知道整段歷史背景,走 A 版;如果你還在猶豫要不要開始這個項目,走 B 版。