新增剪接點轉場: 畫面淡出淡入 + 聲音恆定功率交叉淡化

- 畫面用 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:
2026-06-02 18:59:35 +08:00
parent 8f5c01b56c
commit cebe6e3bd5
3 changed files with 137 additions and 3 deletions
+126 -3
View File
@@ -117,6 +117,17 @@ REENCODE = False # False = 無損 -c copy(切點對齊關鍵
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"}
@@ -465,6 +476,109 @@ def ffmpeg_cut_concat(input_path: str, keeps: List[Tuple[float, float]],
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 預覽圖 ----------
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",
"removed": removed_total}
# 4. FFmpeg 無損切割 + 拼接(暫存放在輸出同顆硬碟)
ffmpeg_cut_concat(video_path, keeps, out_path, args.reencode,
tmp_parent=out_dir)
# 4. FFmpeg 切割 + 拼接
if TRANSITION and len(keeps) >= 1:
# 有轉場: 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,
"removed": removed_total, "redlights": len(removals)}
@@ -787,6 +907,9 @@ _CONFIG_KEYS = {
"min_keep": "MIN_KEEP",
"use_gpu": "USE_GPU",
"reencode": "REENCODE",
"transition": "TRANSITION",
"transition_duration": "TRANSITION_DURATION",
"video_transition": "VIDEO_TRANSITION",
"base_dir": "BASE_DIR",
}