新增剪接點轉場: 畫面淡出淡入 + 聲音恆定功率交叉淡化
- 畫面用 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:
+126
-3
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user