新增剪接點轉場: 畫面淡出淡入 + 聲音恆定功率交叉淡化
- 畫面用 FFmpeg xfade(預設 fadeblack 淡出到黑再淡入),逐段串接並正確計算 offset - 聲音用 acrossfade 等功率曲線(c1=qsin:c2=qsin)= 恆定功率,與畫面同步重疊 - 開啟轉場會自動重新編碼;偵測到 NVIDIA 顯卡時用 h264_nvenc 硬體加速 4K - 新增 config 參數: transition / transition_duration / video_transition - 轉場長度自動夾到比最短片段小,避免 xfade 出錯;單一片段時免轉場直接無損切出 - 已實測 3 段 4K 片段串接,輸出時長與音畫同步皆正確;同步更新 README 本次調整由 Claude Opus 4.8 (1M context) 協助開發與處理。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,10 +93,18 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV
|
|||||||
| `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 |
|
| `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 |
|
||||||
| `use_gpu` | `true` | 是否使用 GPU(無 CUDA 時自動退回 CPU) |
|
| `use_gpu` | `true` | 是否使用 GPU(無 CUDA 時自動退回 CPU) |
|
||||||
| `reencode` | `false` | `false` = 無損快剪;`true` = 重新編碼,切點精準到幀但較慢 |
|
| `reencode` | `false` | `false` = 無損快剪;`true` = 重新編碼,切點精準到幀但較慢 |
|
||||||
|
| `transition` | `true` | 剪接點是否加轉場(淡出淡入 + 恆定功率交叉淡化)。**開啟會強制重新編碼**,有 NVIDIA 顯卡時自動用 NVENC 加速 |
|
||||||
|
| `transition_duration` | `0.5` | 轉場長度(秒),畫面與聲音共用以維持同步 |
|
||||||
|
| `video_transition` | `fadeblack` | 畫面轉場類型:`fadeblack`=淡出到黑再淡入;`fade`=交叉溶接;其餘見 FFmpeg `xfade` 文件 |
|
||||||
| `base_dir` | `E:\\videos` | 影片根目錄 |
|
| `base_dir` | `E:\\videos` | 影片根目錄 |
|
||||||
|
|
||||||
> 參數優先順序:**命令列參數 > config.json > 程式內建預設**
|
> 參數優先順序:**命令列參數 > config.json > 程式內建預設**
|
||||||
|
|
||||||
|
### 關於轉場與無損
|
||||||
|
|
||||||
|
- `transition: false` → 使用 FFmpeg `-c copy` 串流複製,**畫質無損、速度快**,但剪接點是硬切。
|
||||||
|
- `transition: true` → 在每個剪接點加上**畫面淡出淡入**(`xfade`)與**聲音恆定功率交叉淡化**(`acrossfade`,`c1=qsin:c2=qsin`)。此模式必須重新編碼(非無損),4K 影片較耗時,建議搭配 NVIDIA 顯卡用 NVENC 加速。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 輸出結果
|
## 輸出結果
|
||||||
|
|||||||
+126
-3
@@ -117,6 +117,17 @@ REENCODE = False # False = 無損 -c copy(切點對齊關鍵
|
|||||||
PROGRESS_EVERY = 60 # 進度回報: 每處理幾秒影片印一行目前時速 (秒)。
|
PROGRESS_EVERY = 60 # 進度回報: 每處理幾秒影片印一行目前時速 (秒)。
|
||||||
# 純顯示用,改小只是印多幾行,不影響速度與偵測精度
|
# 純顯示用,改小只是印多幾行,不影響速度與偵測精度
|
||||||
|
|
||||||
|
# --- 剪接點轉場(淡出淡入 / 恆定功率交叉淡化)------------------
|
||||||
|
# 注意: 一旦開啟轉場,就「必須重新編碼」(濾鏡無法用 -c copy),非無損且較慢。
|
||||||
|
# 有 NVIDIA 顯卡時會自動用 NVENC 硬體編碼加速 4K。
|
||||||
|
TRANSITION = True # 是否在每個剪接點加轉場。False = 維持無損快剪
|
||||||
|
TRANSITION_DURATION = 0.5 # 轉場長度 (秒)。畫面與聲音共用同一長度,確保同步
|
||||||
|
VIDEO_TRANSITION = "fadeblack" # 畫面轉場 (FFmpeg xfade 類型):
|
||||||
|
# fadeblack = 淡出到黑再淡入(較接近「淡出淡入」)
|
||||||
|
# fade = 交叉溶接(兩段直接互溶)
|
||||||
|
# 其它如 dissolve, smoothleft... 見 ffmpeg xfade 文件
|
||||||
|
# 聲音固定用 acrossfade 等功率曲線(c1=qsin:c2=qsin)=恆定功率
|
||||||
|
|
||||||
# --- 批次模式 -----------------------------------------------
|
# --- 批次模式 -----------------------------------------------
|
||||||
# 會被當成影片來處理的副檔名(小寫比較)
|
# 會被當成影片來處理的副檔名(小寫比較)
|
||||||
VIDEO_EXTS = {".mov", ".mp4", ".m4v", ".avi", ".mkv", ".mts", ".m2ts", ".insv"}
|
VIDEO_EXTS = {".mov", ".mp4", ".m4v", ".avi", ".mkv", ".mts", ".m2ts", ".insv"}
|
||||||
@@ -465,6 +476,109 @@ def ffmpeg_cut_concat(input_path: str, keeps: List[Tuple[float, float]],
|
|||||||
shutil.rmtree(tmpdir, ignore_errors=True) # 清掉暫存檔
|
shutil.rmtree(tmpdir, ignore_errors=True) # 清掉暫存檔
|
||||||
|
|
||||||
|
|
||||||
|
def has_audio(path: str) -> bool:
|
||||||
|
"""用 ffprobe 判斷影片是否有音訊軌。出錯時保守假設『有』。"""
|
||||||
|
if shutil.which("ffprobe") is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-select_streams", "a",
|
||||||
|
"-show_entries", "stream=index", "-of", "csv=p=0", path],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return bool(out.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_cut_concat_xfade(input_path: str, keeps: List[Tuple[float, float]],
|
||||||
|
output_path: str, use_gpu: bool,
|
||||||
|
tmp_parent: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
把 keeps 各片段接起來,並在「每個剪接點」加上轉場:
|
||||||
|
- 畫面: xfade(VIDEO_TRANSITION,例如 fadeblack 淡出淡入)
|
||||||
|
- 聲音: acrossfade 等功率曲線(c1=qsin:c2=qsin)= 恆定功率交叉淡化
|
||||||
|
兩者重疊長度相同(TRANSITION_DURATION),因此畫面與聲音同步。
|
||||||
|
|
||||||
|
※ 轉場需要重新編碼(濾鏡無法 -c copy)。有 NVIDIA 顯卡時用 h264_nvenc 硬體加速。
|
||||||
|
"""
|
||||||
|
n = len(keeps)
|
||||||
|
durs = [e - s for (s, e) in keeps]
|
||||||
|
|
||||||
|
# 只有一段 → 沒有剪接點,不需轉場,直接無損切出
|
||||||
|
if n == 1:
|
||||||
|
s, e = keeps[0]
|
||||||
|
run_cmd(["ffmpeg", "-y", "-ss", f"{s:.3f}", "-t", f"{e - s:.3f}",
|
||||||
|
"-i", input_path, "-c", "copy",
|
||||||
|
"-avoid_negative_ts", "make_zero", output_path])
|
||||||
|
print(f"[INFO] 完成(單一片段,免轉場)! 輸出檔案: {output_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 轉場長度不能 >= 任何片段,否則 xfade/acrossfade 會出錯 → 夾到安全值
|
||||||
|
d = min(TRANSITION_DURATION, min(durs) - 0.1)
|
||||||
|
if d <= 0.05:
|
||||||
|
print("[WARN] 片段太短,改用無轉場拼接。")
|
||||||
|
ffmpeg_cut_concat(input_path, keeps, output_path, reencode=True,
|
||||||
|
tmp_parent=tmp_parent)
|
||||||
|
return
|
||||||
|
|
||||||
|
audio = has_audio(input_path)
|
||||||
|
|
||||||
|
# 每個保留片段當成一個輸入(同一來源檔多次 -ss/-t 取段)
|
||||||
|
cmd: List[str] = ["ffmpeg", "-y"]
|
||||||
|
for (s, e) in keeps:
|
||||||
|
cmd += ["-ss", f"{s:.3f}", "-t", f"{e - s:.3f}", "-i", input_path]
|
||||||
|
|
||||||
|
# --- 組 filter_complex ---
|
||||||
|
parts: List[str] = []
|
||||||
|
|
||||||
|
# 畫面: 先把每段正規化(統一時間基準/格式),再逐段 xfade
|
||||||
|
for i in range(n):
|
||||||
|
parts.append(f"[{i}:v]settb=AVTB,format=yuv420p[v{i}]")
|
||||||
|
prev = "v0"
|
||||||
|
run_len = durs[0]
|
||||||
|
for j in range(1, n):
|
||||||
|
offset = run_len - d # 轉場在「目前累積長度 - d」處開始
|
||||||
|
out = f"vx{j}"
|
||||||
|
parts.append(
|
||||||
|
f"[{prev}][v{j}]xfade=transition={VIDEO_TRANSITION}:"
|
||||||
|
f"duration={d:.3f}:offset={offset:.3f}[{out}]")
|
||||||
|
prev = out
|
||||||
|
run_len = run_len + durs[j] - d # xfade 後總長
|
||||||
|
vlabel = prev
|
||||||
|
|
||||||
|
# 聲音: 逐段 acrossfade(等功率 = 恆定功率)
|
||||||
|
alabel = None
|
||||||
|
if audio:
|
||||||
|
prev_a = "0:a"
|
||||||
|
for j in range(1, n):
|
||||||
|
out = f"ax{j}"
|
||||||
|
parts.append(
|
||||||
|
f"[{prev_a}][{j}:a]acrossfade=d={d:.3f}:c1=qsin:c2=qsin[{out}]")
|
||||||
|
prev_a = out
|
||||||
|
alabel = prev_a
|
||||||
|
|
||||||
|
filtergraph = ";".join(parts)
|
||||||
|
|
||||||
|
# --- 編碼器: 有 GPU 用 NVENC,否則 libx264 ---
|
||||||
|
if use_gpu:
|
||||||
|
venc = ["-c:v", "h264_nvenc", "-preset", "p5", "-cq", "19"]
|
||||||
|
else:
|
||||||
|
venc = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"]
|
||||||
|
|
||||||
|
cmd += ["-filter_complex", filtergraph, "-map", f"[{vlabel}]"]
|
||||||
|
if audio:
|
||||||
|
cmd += ["-map", f"[{alabel}]", "-c:a", "aac", "-b:a", "256k"]
|
||||||
|
else:
|
||||||
|
cmd += ["-an"]
|
||||||
|
cmd += venc + ["-pix_fmt", "yuv420p", output_path]
|
||||||
|
|
||||||
|
print(f" 轉場拼接 {n} 段(轉場 {d:.2f}s,畫面={VIDEO_TRANSITION},"
|
||||||
|
f"聲音=恆定功率,編碼={'NVENC' if use_gpu else 'x264'})...")
|
||||||
|
print(" ※ 4K 重新編碼較久,請耐心等候。")
|
||||||
|
run_cmd(cmd)
|
||||||
|
print(f"[INFO] 完成! 輸出檔案: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
# ---------- preview: 輸出 ROI 預覽圖 ----------
|
# ---------- preview: 輸出 ROI 預覽圖 ----------
|
||||||
|
|
||||||
def save_preview(video_path: str, roi: Tuple[int, int, int, int],
|
def save_preview(video_path: str, roi: Tuple[int, int, int, int],
|
||||||
@@ -761,9 +875,15 @@ def process_one(video_path: str, roi: Tuple[int, int, int, int],
|
|||||||
return {"file": stem, "status": "skipped_no_keep",
|
return {"file": stem, "status": "skipped_no_keep",
|
||||||
"removed": removed_total}
|
"removed": removed_total}
|
||||||
|
|
||||||
# 4. FFmpeg 無損切割 + 拼接(暫存放在輸出同顆硬碟)
|
# 4. FFmpeg 切割 + 拼接
|
||||||
ffmpeg_cut_concat(video_path, keeps, out_path, args.reencode,
|
if TRANSITION and len(keeps) >= 1:
|
||||||
tmp_parent=out_dir)
|
# 有轉場: xfade 淡出淡入 + acrossfade 恆定功率(需重新編碼)
|
||||||
|
ffmpeg_cut_concat_xfade(video_path, keeps, out_path,
|
||||||
|
use_gpu=args.gpu, tmp_parent=out_dir)
|
||||||
|
else:
|
||||||
|
# 無轉場: 維持無損快剪
|
||||||
|
ffmpeg_cut_concat(video_path, keeps, out_path, args.reencode,
|
||||||
|
tmp_parent=out_dir)
|
||||||
return {"file": stem, "status": "ok", "output": out_path,
|
return {"file": stem, "status": "ok", "output": out_path,
|
||||||
"removed": removed_total, "redlights": len(removals)}
|
"removed": removed_total, "redlights": len(removals)}
|
||||||
|
|
||||||
@@ -787,6 +907,9 @@ _CONFIG_KEYS = {
|
|||||||
"min_keep": "MIN_KEEP",
|
"min_keep": "MIN_KEEP",
|
||||||
"use_gpu": "USE_GPU",
|
"use_gpu": "USE_GPU",
|
||||||
"reencode": "REENCODE",
|
"reencode": "REENCODE",
|
||||||
|
"transition": "TRANSITION",
|
||||||
|
"transition_duration": "TRANSITION_DURATION",
|
||||||
|
"video_transition": "VIDEO_TRANSITION",
|
||||||
"base_dir": "BASE_DIR",
|
"base_dir": "BASE_DIR",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,5 +17,8 @@
|
|||||||
"min_keep": 0.8,
|
"min_keep": 0.8,
|
||||||
"use_gpu": true,
|
"use_gpu": true,
|
||||||
"reencode": false,
|
"reencode": false,
|
||||||
|
"transition": true,
|
||||||
|
"transition_duration": 0.5,
|
||||||
|
"video_transition": "fadeblack",
|
||||||
"base_dir": "E:\\videos"
|
"base_dir": "E:\\videos"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user