# Video Cut

> 從 macOS 照片圖庫自動找素材、用 GPS 與時間還原行程、剪成短片並上字幕配樂。當使用者說「幫我剪影片」「把某天的影片剪出來」「做一支旅遊短片」「這段影片幫我剪一下」「剪成 15 秒放脆」「剪個兩分鐘的」，或丟一個日期、地點、事件要成片時，務必使用此 Skill。涵蓋素材掃描與去重、畫中畫裁切、EDL 剪輯表、三種字幕、配樂、有人臉與無人臉雙版本。與 visual-gate 的分工：那支驗簡報與網頁，這支管影片，但同一條鐵則：看過畫面才算完成。

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

---


# 影片剪輯產線

從幾萬個檔案裡找出該剪哪些，剪成片，上字幕，配樂。

市面上的剪片工具都假設「你已經有一支片，告訴我怎麼剪」。這支 skill 補的是前面那段：
**素材在哪、哪些能用、怎麼分群**。

---

## 鐵則

1. **渲染完要抽格看過。** 程式沒報錯不等於畫面對。CLAUDE.md 第七原則在這裡沒有例外。
2. **每輪只修 1 到 3 個最大落差。** 看整支，講出最嚴重的那個，改完重新排序，不要照舊清單往下做。
3. **素材數字從圖庫實際查。** 「大概有幾百支」這種話會害你選錯版面和長度。
4. **合成前逐段驗證。** 渲染偶爾產出截斷的 mp4，`moov atom not found`，重跑該段就好。

---

## 五個步驟

| # | 做什麼 | 產出 |
|---|---|---|
| 1 | 掃圖庫、去重、分群、抓地名 | `assets.json`、分群表 |
| 2 | 抽格做檢視表，用眼睛選片 | `*_v.jpg`、`*_p.jpg` |
| 3 | 寫剪輯表 | `edl.json` |
| 4 | 渲染 → 抽格看 → 修 → 再看 | `clips/*.mp4` |
| 5 | 合成、配樂、輸出 | `out/*.mp4` |

步驟 2 和 4 是這支 skill 的重點。**選片和驗收都要靠眼睛**，不能靠檔名和時長猜。

---

## 開工前的環境檢查

第一次在一台機器上跑，先確認：

```bash
for f in drawtext subtitles ass zscale overlay zoompan gblur; do
  printf "%-12s " $f; ffmpeg -hide_banner -filters 2>/dev/null | grep -qw "$f" && echo 有 || echo 沒有
done
ffmpeg -hide_banner -version | grep -c -- --enable   # 官方完整版 60+，精簡版 15 左右
python3 -c "import PIL; print('Pillow', PIL.__version__)"
command -v exiftool sips
```

**如果沒有 `drawtext` 和 `subtitles`**：不要重裝 ffmpeg 才動工。改用 Pillow 產字幕 PNG 再 `overlay` 疊上去，中文排版反而更好控制。`scripts/render.py` 就是這樣寫的。

**如果沒有 `zscale`**：做不了正規的 HDR→SDR tone mapping。iPhone 的 HLG 素材直接轉 8-bit 通常可以接受（實測藍天飽和、雲有層次、亮部不過曝），先做出來看，不要為這件事卡住。要更準的顏色再 `brew reinstall ffmpeg`。

---

## 步驟 1：掃素材

```bash
python3 scripts/scan_library.py --from 2026-06-10 --to 2026-06-22
```

它會複製 `Photos.sqlite`、查出區間內的資產、去重、分群、解出地名，寫成 `assets.json` 並印出分群表。

### 四個必踩的坑

**`.mov` 的時間戳是 UTC，照片是當地時間。**
QuickTime 的 `CreateDate` 存 UTC，HEIC 的 `DateTimeOriginal` 存拍攝地當地時間。
混在一起排序，影片會全部跑到早上，順序整個亂掉。用 `--tz` 指定拍攝地時區。

**Live Photo 佔了影片檔的九成以上。**
`originals` 裡的 `.mov` 有 96% 是 Live Photo 的 2–3 秒動態部分（檔名以 `_3.mov` 結尾）。
按副檔名抓片會剪出一團碎片。查 `ZASSET.ZKIND=1` 才抓得到錄影。

**重複檔案。**
同行的人 AirDrop 過來的素材可能被匯入兩次。用
`(拍攝時間秒, 時長×100, 原始檔案大小)` 三者相同判定重複。
實測一趟 13 天的行程裡有 80 個重複檔，沙丘那組 16 支影片有 5 組是同一支存兩份。

**地名不要打外部 API。**
照片圖庫本機的 `ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA` 是 NSKeyedArchiver plist，
裡面有 Apple 已經解好的地名。用 `plistlib` 讀，不用把使用者的 GPS 座標送出去。
鄉下地區可能只到「某某省」，這時候要問使用者實際地名。

---

## 步驟 2：選片

**做檢視表，看過再選。**

```bash
python3 scripts/sheets.py videos assets.json --out camp   # 影片，長片自動抽 3 格
python3 scripts/sheets.py photos assets.json --out camp   # 照片，可 --sample 3 抽樣
```

每格標了編號、時間、時長，編號跟 `assets.json` 的索引一致，直接拿來寫 EDL。

### 選片時要看的

- **主角是誰。** 常常不是風景。一隻被帶著走的玩偶、一台新車、一隻站在手臂上的鷹，
  這些重複出現的東西才是片子的骨架。
- **有沒有畫中畫。** iPhone 的雙擷取會在角落放一個自拍視窗，
  橫拍在左上（約寬 27%、高 38%），直拍在右上（約寬 35%、高 25%）。
  這會毀掉「無人臉版」，但**不必整支放棄**，用 `crop` 裁掉即可，見下面。
- **長鏡頭要掃時間軸。** 一支 96 秒的片，主體位置會變。
  掃 8 個時間點，挑主體在裁切線右邊（或下方）的那一段，就能零損失地裁掉畫中畫。
- **照片的 EXIF 方向。** `sips` 轉檔不會自動套用，縮圖會出現倒立和橫躺。
  一定要走 `PIL.ImageOps.exif_transpose`。

---

## 步驟 3：寫 EDL

`edl.json` 是一個陣列，一個物件一格。

| 欄位 | 意思 |
|---|---|
| `clip` / `photo` | 素材編號，對應 `assets.json` 的索引 |
| `ss` / `dur` | 從原片第幾秒開始、取幾秒（照片只要 `dur`） |
| `zoom` | 放大倍率，把直拍素材在橫式版面裡放大。1.0 不放大 |
| `crop` | `[左, 上, 右, 下]` 各裁掉的比例，用來去掉畫中畫 |
| `zdir` | 照片的 Ken Burns 方向，`1` 推近、`-1` 拉遠。相鄰兩張用相反方向 |
| `place` | 資訊卡，一到三行 `["06.13 05:47", "地點", "天氣"]` |
| `sub` | 動作字幕，底部置中 |
| `poem` | 詩詞。純 list 就是詩句；要署名用 `{"lines":[...], "by":"唐・王績〈野望〉"}` |
| `audio` | `true` 保留這格的原聲。**預設全部靜音**，只有需要聽現場聲音的段落才開 |

浮水印走環境變數，不必寫進每一格：

```bash
MARK="yoursite.com ; edited by Claude" VW=1920 VH=1080 ... python3 render.py
```

小字半透明，貼在畫面最下緣，跟動作字幕分層不會撞。
| `title` | 片頭標題 `["主標", "副標"]`，第三個元素給 `"top"` 可貼上方，避免壓到畫面正中的主體 |
| `end` | 片尾卡 `["主標", "副標", "說明"]`，會加半透明黑底 |

### 長度怎麼抓

| 用途 | 長度 | 每格 | 格數 |
|---|---|---|---|
| Threads／IG 循環 | 15 秒 | 1.8–2.2 秒 | 8 |
| 一般短片 | 60–90 秒 | 2.5–3.5 秒 | 25–30 |
| 完整記錄 | 100–130 秒 | 2.4–6 秒 | 35–40 |

**15 秒循環是另一種東西，不是把長片剪短。** 它沒有時間鋪陳，要靠一個重複的動作
當引擎（同一個主角、背景一直換），首尾用同一個視覺元素收口，接回去就看不出接縫。
字幕最多一行放在最後，loop 時它會變成節拍點。

---

## 步驟 4：渲染與驗收

```bash
VW=1920 VH=1080 CDIR=clips EDL=edl.json CONCAT=concat.txt CARDS=cards python3 scripts/render.py
VW=1920 VH=1080 CDIR=clips EDL=edl.json CONCAT=concat.txt CARDS=cards python3 scripts/render.py 3 12 21   # 只重跑指定段
```

全部的環境變數：

| 變數 | 預設 | 意思 |
|---|---|---|
| `VW` / `VH` | 1080 / 1920 | 輸出尺寸。16:9 用 `VW=1920 VH=1080` |
| `CDIR` | `clips` | 片段輸出目錄 |
| `EDL` | `edl.json` | 剪輯表檔名 |
| `CONCAT` | `concat.txt` | concat 清單檔名 |
| `CARDS` | `cards` | 照片卡片目錄 |
| `VIDEOS` | `videos.json` | 影片清單。全照片的片子可以沒有這個檔 |
| `WORKDIR` | 當前目錄 | 工作目錄，一般不用設 |
| `MARK` | 無 | 浮水印文字，設了就每格都疊在畫面最下緣 |

**所有輸出都相對於「執行時的當前目錄」**，不是腳本裝在哪裡。
所以可以把 skill 裝在 `~/.claude/skills/`，`cd` 到專案目錄再呼叫它。
片段落在 `./clips/`，字幕圖在 `./ovl/`。

```bash
```

出 9:16 直式版：`VW=1080 VH=1920`。同一份 EDL，兩個版面都出得來。

**合成前逐段驗證：**

```bash
cd clips && for f in *.mp4; do
  ffprobe -v error -show_entries format=duration -of csv=p=0 "$f" >/dev/null 2>&1 || echo "壞: $f"
done
```

**抽格看畫面**（這步不能跳）：

```bash
python3 scripts/sheets.py check out.mp4 edl.json --out chk
```

每格抽中點，拼成檢視表。看完講出最嚴重的 1–3 個問題，改完重新排序。

### 版面怎麼選

橫拍和直拍混在一起的時候，實際渲染兩版比一比，不要用猜的。判準是**哪一邊的主體損失小**：

- 主角特寫多半是直拍 → 9:16 滿版
- 風景和地標多半是橫拍 → 16:9 滿版
- 少數派用模糊背景填滿，或用 `zoom` 放大裁切

`zoom` 放太大會切掉主體。直拍在 16:9 裡放到 1.2–1.3 通常安全，超過 1.5 要看過再決定。

---

## 步驟 5：配樂與輸出

**消原聲配音樂**（原聲多半是風聲和雜訊，削弱效果）：

```bash
MUS="/Applications/iMovie.app/Contents/Resources/iMovie Theme Music/SIMPLE2.m4a"
ffmpeg -y -f concat -safe 0 -i concat.txt -c copy -an muted.mp4
D=$(ffprobe -v error -show_entries format=duration -of csv=p=0 muted.mp4)
FO=$(python3 -c "print(round(float('$D')-4,2))")
ffmpeg -y -i muted.mp4 -stream_loop -1 -i "$MUS" \
  -filter_complex "[1:a]atrim=0:$D,asetpts=PTS-STARTPTS,afade=t=in:d=2.5,afade=t=out:st=$FO:d=4,loudnorm=I=-19:TP=-2:LRA=11[a]" \
  -map 0:v -map "[a]" -c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p \
  -c:a aac -b:a 160k -ar 48000 -shortest -movflags +faststart out.mp4
```

### 只留幾段原聲（現場演奏、有人說話）

在那幾格的 EDL 加 `"audio": true`，其他格自動靜音。合成時讓音樂在那幾段降下去：

```bash
DUCK="between(t,74.5,79.0)+between(t,81.5,85.5)"   # 從 EDL 累加算出來的區間
ffmpeg -y -i raw.mp4 -stream_loop -1 -i "$MUS" -filter_complex "\
[1:a]atrim=0:$D,asetpts=PTS-STARTPTS,volume=0.50,\
volume=enable='$DUCK':volume=0.22,\
afade=t=in:d=2.5,afade=t=out:st=$FO:d=4[m];\
[0:a]volume=2.0[o];\
[m][o]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.92[a]" \
  -map "[a]" -c:a aac -b:a 160k -t $D audio.m4a
```

實測原聲段會比周圍音樂大 4–5 dB，聽得很清楚。

**分三步做，不要一次到底。** 118 秒的 1080p 影片用 `preset medium` 壓縮要五分鐘，
一次做完常常撞到逾時而產出截斷的檔案：

1. `-an` 只壓影片
2. 單獨混音訊成 `.m4a`
3. `-c copy` 合併

這樣任何一步失敗都能單獨重跑，不用整支重來。

**保留全部原聲**（現場演奏、有人說話的片子）時，各段音量落差常有 20 dB，
用 `loudnorm` 會把安靜的段落硬抬成噪音。改用 `dynaudnorm` 做動態正規化：

```bash
-af "dynaudnorm=f=250:g=7:p=0.75:m=8,afade=t=in:d=1.0,afade=t=out:st=$FO:d=2.5,alimiter=limit=0.92"
```

實測七段 -23 到 -43 dB 的原聲，這樣拉平到 -16 到 -17 dB。

**保留原聲就不能切太碎。** 現場音樂被剁成 3 秒一段會很難聽。
這種片子改成只切 2 到 3 段、每段 8 到 9 秒，接點靠淡入淡出軟化。

### 音樂哪裡來

`/Applications/iMovie.app/Contents/Resources/iMovie Theme Music/` 有七首，
Apple 授權可用於自己的作品。`/Library/Audio/Apple Loops/` 有上千個 loop，同樣授權。
**不要從網路抓來路不明的音樂。**

沒辦法聽的時候用數據選：

```bash
ffmpeg -hide_banner -i "$f" -af "highpass=f=4000,volumedetect" -f null - 2>&1 | grep mean_volume
ffmpeg -y -i "$f" -filter_complex "showwavespic=s=900x120" -frames:v 1 wave.png
```

高頻能量越低音色越柔和，波形起伏越平穩越不搶戲。有規律脈衝的是舞曲，背景音樂不要選。

---

## 三種字幕，不要只有一種

只有日期地點會很乾。混著用：

| 類型 | 放哪 | 寫什麼 |
|---|---|---|
| 資訊卡 `place` | 段落開頭，左下 | 時間、地點、天氣 |
| 動作字幕 `sub` | 有動作的鏡頭，底部置中 | 白話、有畫面感、帶一點情緒 |
| 詩詞 `poem` | 遠景空鏡，底部偏上 | 見 `references/poems.md` |

**動作字幕怎麼寫**：講畫面上正在發生的事，不要抒情。
「牧民騎馬而來，一整排」「駱駝站起來的瞬間，會先往前傾」「奶茶是鹹的」。

**詩詞位置**：試過畫面上方三分之一，會壓在地平線和主體上。放底部，
前景多半是草地沙地，最不擋事。逗號改用全形空格，中文詩排起來才像詩。

**天氣**：陰晴可以從畫面確認，敢寫。溫度查得到的多半是月份氣候平均，不是當天實測，
**不要把月平均寫成當天氣溫**。

---

## 片頭與片尾

**片頭可以是一格，也可以是「一個 cut 加二到三張關鍵照片」的快切**：
第一格帶標題（2 秒），後面三張各 1.2 秒無字，像預告。選能代表全片的三張。

挑主鏡頭時**選最有衝擊力的那格**，標題疊上去。
挑之前先渲染出來看：遠景的主體在 16:9 裡會變得很小，
直拍縮進橫式版面只剩中間一條，這種當片頭撐不住。

**片尾**用一個「離開」感的鏡頭（人物遠去、鏡頭上搖），疊半透明黑底加三行卡：
地點、日期、這支片有什麼。

---

## 有人臉與無人臉雙版本

需要對外發布時常要兩版。無人臉版不是把有人的鏡頭刪掉就好：

1. **畫中畫**：`crop` 裁掉，見步驟 2
2. **主畫面裡的人**：整段排除，裁切救不回來
3. **照片也要逐張看**：縮圖上看起來是駱駝，放大才發現上面坐著人

做完無人臉版，把**用到的每一張照片並排重看一次**再交件。實測這步抓到兩張漏網的。

「不要人臉」通常指的是使用者自己和同行的人，當地人的臉多半是可以留的（那是內容本身）。
**先問清楚**，這決定可用素材是差一倍還是差一半。

---

## 純照片也能撐完一支敘事片

條件是**素材本身有時間軸**。實測一支 44 秒的馬拉松紀錄，一支影片都沒用到
（賽事當天的錄影全是 0 到 7 秒的碎片），十六格全是照片，但完全成立，因為：

- **里程牌是天然的節奏器**。6 MILE、21 MILE、35 KM、38 KM 一格一個，
  推進感自己會出來，不需要旁白解釋「跑了很久」。
- **表情變化是天然的弧線**。天亮前的笑臉到 38 公里的自拍，中間什麼都不用說。
- **時間戳當字幕**。`04:21` `15:15` `17:51` 直接寫進句子開頭，
  十一個小時的跨度用兩個數字就講完了。

判斷素材夠不夠：找得出**五個以上有時間標記的節點**（里程牌、路標、時鐘、
日出日落、餐點）就可以，找不出來就別硬做，那會變成配樂幻燈片。

## 節奏要快慢交替，不是整體加快

想加快不要全片壓短，字幕格壓短會讓長句讀不完。改成兩種格分開處理：

| 類型 | 每格 | 說明 |
|---|---|---|
| 字幕格 | 2.2–5.0 秒 | 依字數給時間。中文約每秒 5 字，30 字就要 5 秒 |
| 快切格 | 1.2–1.4 秒 | 無字幕的路人、空景、連拍。眼睛掃過就好 |

快切段插在敘事段之間，四到八格一組。慢—快—慢—快—慢的起伏比等速好看，
而且快切段最好落在故事本身也緊湊的地方。

橫拍素材混進 9:16 的快切段要加 `zoom`（2.0 以上），
不然一半滿版一半細條，眼睛會一直被打斷。

## 結尾的順序：先回答，再留餘韻

如果片中出現過問句或懸念，**結尾要先回答它，再收在餘韻上**，順序不能反。

實測那支馬拉松：倒數第四格是「結束了，真的嗎？」，緊接著給終點拱門和獎牌
（回答「真的」），最後才收在曬傷加上「隔年春天都還有印記」。

如果收在獎牌，那就跟所有完賽影片一樣。收在曬傷，時間被拉到半年後，
才是這支片獨有的東西。**但沒有前面的回答，餘韻會變成沒交代。**

## 封面圖另外出一張

社群平台自己抓的縮圖多半是第一格，而第一格常常是鋪陳用的暗畫面，滑過去不會停。
從 `cards/` 挑一張最有識別度的，用 Pillow 疊上大標題另存 JPG：

- 主標放比賽或地點名，副標放日期
- **最有力的數字放最大**（成績、天數、距離），用金色
- 底部保留浮水印

標題保持資訊型就好。如果字幕已經是自嘲或抒情的口氣，標題再跟著走會變成同一個調，
外層資訊、內層情緒，兩層才有對比。

## 從長片切系列短片

一支長片剪好之後，同一批素材可以再切成幾支 15–20 秒的主題短片。
一支一個主題（早晨、某個活動、某個地點），各自獨立成立，適合連續發。

每支給一張資訊卡開場，全程掛浮水印，不要片頭片尾卡（太短，撐不起來）。

**配樂取同一首的不同段落**，四支就 0s / 15s / 30s / 45s，淡入 1.5 秒。
這樣連著看不會覺得是同一段音樂重播。

**這裡有個會咬人的坑**：`-ss 45` 之後音樂只剩 15 秒，配上 `-shortest`
會把 20 秒的影片砍成 15 秒，而且不報錯。實測中招過一次。
音樂起點靠後時一定要加 `-stream_loop -1`，並用 `-t $D` 明確指定長度。
輸出後逐支比對「EDL 總長」和「實際長度」，差超過 0.5 秒就是被截了。

## 交付

輸出兩個檔：母帶（CRF 19）和上傳版（CRF 23 + faststart）。
超過 30 MB 的檔案傳不進對話，要告訴使用者只能在桌面版看，並附上 `open` 指令。

留下 `edl.json` 和 `README.md`，說明改哪個檔、怎麼重跑、這台機器有什麼坑。
下次要剪同一趟的另一支，複製整個資料夾改日期就好。

