diff --git a/README.md b/README.md index f8bef0e..9deac40 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,18 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV | `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 | | `use_gpu` | `true` | 是否使用 GPU(無 CUDA 時自動退回 CPU) | | `reencode` | `false` | `false` = 無損快剪;`true` = 重新編碼,切點精準到幀但較慢 | +| `transition` | `true` | 剪接點是否加轉場(淡出淡入 + 恆定功率交叉淡化)。**開啟會強制重新編碼**,有 NVIDIA 顯卡時自動用 NVENC 加速 | +| `transition_duration` | `0.5` | 轉場長度(秒),畫面與聲音共用以維持同步 | +| `video_transition` | `fadeblack` | 畫面轉場類型:`fadeblack`=淡出到黑再淡入;`fade`=交叉溶接;其餘見 FFmpeg `xfade` 文件 | | `base_dir` | `E:\\videos` | 影片根目錄 | > 參數優先順序:**命令列參數 > config.json > 程式內建預設** +### 關於轉場與無損 + +- `transition: false` → 使用 FFmpeg `-c copy` 串流複製,**畫質無損、速度快**,但剪接點是硬切。 +- `transition: true` → 在每個剪接點加上**畫面淡出淡入**(`xfade`)與**聲音恆定功率交叉淡化**(`acrossfade`,`c1=qsin:c2=qsin`)。此模式必須重新編碼(非無損),4K 影片較耗時,建議搭配 NVIDIA 顯卡用 NVENC 加速。 + --- ## 輸出結果 diff --git a/auto_remove_redlight.py b/auto_remove_redlight.py index 140eee7..9143776 100644 --- a/auto_remove_redlight.py +++ b/auto_remove_redlight.py @@ -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", } diff --git a/config.json b/config.json index e40169c..2c8a282 100644 --- a/config.json +++ b/config.json @@ -17,5 +17,8 @@ "min_keep": 0.8, "use_gpu": true, "reencode": false, + "transition": true, + "transition_duration": 0.5, + "video_transition": "fadeblack", "base_dir": "E:\\videos" } \ No newline at end of file