"""
Video Manager Advanced (合规用途) - Optimized Version
- 功能:合规的视频美化(中间主视频 + 两侧填充)、多层去重检测、并行处理、HTML 报告。
- 优化点:
- 增强去重精度:动态阈值调整、多级去重校验。
- 性能优化:并行帧提取、内存流式计算哈希。
- 音频指纹:添加降级方案(波形哈希)。
- 宽幅合成:支持动态模糊、多层叠加(侧边内容)。
- 报告:动态多帧缩略图、交互按钮(模拟删除)。
- 清理:自动删除临时目录、错误日志记录。
- 严禁用于规避平台检测、传播未授权或侵权内容。
"""
import os
import sys
import shutil
import subprocess
import time
import csv
import tempfile
import math
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from multiprocessing import cpu_count
from PIL import Image
import imagehash
import hashlib
import base64
from tqdm import tqdm
import PySimpleGUI as sg
# ----------------------------
# 配置(可按需修改)
# ----------------------------
FFMPEG = "ffmpeg" # or full path to ffmpeg
FFPROBE = "ffprobe"
FPCALC = "fpcalc" # optional
SUPPORTED_EXT = [".mp4", ".mov", ".mkv", ".ts", ".webm"]
TMP_PREFIX = "vma_"
ERROR_LOG = "vma_error.log"
# ----------------------------
# 工具函数
# ----------------------------
def run_quiet(cmd):
"""Run subprocess and swallow output (returns CompletedProcess)"""
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def log_error(e, context=""):
"""记录错误到日志文件"""
with open(ERROR_LOG, "a", encoding="utf-8") as f:
f.write(f"{time.ctime()} [{context}]: {str(e)}\n")
def file_sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def ffprobe_duration(path):
try:
out = subprocess.check_output([FFPROBE, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
stderr=subprocess.DEVNULL).decode().strip()
return float(out)
except Exception as e:
log_error(e, "ffprobe_duration")
return None
def extract_frames(video_path, out_dir, fps=1, max_frames=None):
"""
Extract frames at given fps into out_dir as frame_0001.jpg ...
If fps is float, it's frames per second. Use -vf fps=...
"""
os.makedirs(out_dir, exist_ok=True)
if fps <= 0:
return []
vf = f"fps={fps}"
cmd = [FFMPEG, "-y", "-i", str(video_path), "-vf", vf, "-q:v", "2", os.path.join(out_dir, "frame_%05d.jpg")]
proc = run_quiet(cmd)
if proc.returncode != 0:
log_error(proc.stderr, "extract_frames")
return []
files = sorted(Path(out_dir).glob("frame_*.jpg"))
if max_frames and len(files) > max_frames:
files = files[:max_frames]
return [str(p) for p in files]
def extract_mid_frame(video_path, out_image):
dur = ffprobe_duration(video_path)
if dur is None:
ss = 1.0
else:
ss = max(0.5, dur / 2.0)
cmd = [FFMPEG, "-y", "-ss", str(ss), "-i", str(video_path), "-frames:v", "1", "-q:v", "2", str(out_image)]
proc = run_quiet(cmd)
return os.path.exists(out_image)
def compute_phash_from_image(img_path):
try:
with Image.open(img_path).convert("RGB") as img:
return str(imagehash.phash(img))
except Exception as e:
log_error(e, "compute_phash_from_image")
return None
def compute_phash_streaming(frame_paths):
"""流式计算哈希,避免内存过高"""
phashes = []
for f in frame_paths:
ph = compute_phash_from_image(f)
if ph:
phashes.append(ph)
return phashes
def hamming_distance(ph1, ph2):
try:
if isinstance(ph1, str): ph1 = imagehash.hex_to_hash(ph1)
if isinstance(ph2, str): ph2 = imagehash.hex_to_hash(ph2)
return ph1 - ph2
except Exception:
return 9999
def audio_fingerprint(video_path):
"""音频指纹:优先 fpcalc,降级到波形哈希"""
if shutil.which(FPCALC):
try:
tmpwav = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
tmpwav.close()
cmd1 = [FFMPEG, "-y", "-i", str(video_path), "-vn", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", tmpwav.name]
subprocess.run(cmd1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
res = subprocess.run([FPCALC, "-raw", tmpwav.name], capture_output=True, text=True)
os.unlink(tmpwav.name)
if res.returncode == 0:
return res.stdout.strip()
except Exception as e:
log_error(e, "audio_fingerprint_fpcalc")
# 降级:简单波形哈希
try:
cmd = [FFMPEG, "-i", str(video_path), "-vn", "-af", "volumedetect", "-f", "null", "-"]
out = subprocess.run(cmd, capture_output=True, text=True).stderr
if "mean_volume" in out:
return hashlib.md5(out.encode()).hexdigest()
except Exception as e:
log_error(e, "audio_fingerprint_fallback")
return None
def dynamic_blur(video_path, frames_per_sec=0.5, max_frames=2):
"""动态计算模糊值,根据帧差异"""
tmpdir = tempfile.mkdtemp(prefix=TMP_PREFIX)
frames = extract_frames(video_path, tmpdir, fps=frames_per_sec, max_frames=max_frames)
if len(frames) < 2:
shutil.rmtree(tmpdir, ignore_errors=True)
return 18 # 默认
phashes = compute_phash_streaming(frames)
diffs = [hamming_distance(phashes[i], phashes[i+1]) for i in range(len(phashes)-1)]
avg_diff = sum(diffs) / len(diffs) if diffs else 0
blur = min(30, max(10, int(avg_diff * 2)))
shutil.rmtree(tmpdir, ignore_errors=True)
return blur
def generate_wide_version(src, dest, out_w=1920, out_h=1080, blur=None, side_content=None):
"""宽幅合成:支持动态模糊、多层叠加"""
if blur is None:
blur = dynamic_blur(src)
fc = f"[0:v]scale={out_w}:{out_h},boxblur={blur}:1[bg];[0:v]scale=-1:{out_h}[fg]"
if side_content:
# 假设 side_content 是额外输入文件路径
fc = f"[0:v]scale={out_w}:{out_h},boxblur={blur}:1[bg];" \
f"[1:v]scale={out_w/4}:{out_h}[side];[bg][side]overlay=x=0:y=0[bg2];" \
f"[0:v]scale=-1:{out_h}[fg];[bg2][fg]overlay=(W-w)/2:(H-h)/2"
cmd = [FFMPEG, "-y", "-i", str(src), "-i", str(side_content), "-filter_complex", fc, "-c:a", "copy", str(dest)]
else:
cmd = [FFMPEG, "-y", "-i", str(src), "-filter_complex", fc, "-c:a", "copy", str(dest)]
proc = run_quiet(cmd)
return proc.returncode == 0
# ----------------------------
# 核心去重逻辑(并行)
# ----------------------------
def analyze_file(video_path, tempdir, frames_per_sec=1, max_frames=5, use_audio=False):
rec = {"path": str(video_path), "sha256": None, "frames": [], "phashes": [], "audio_fp": None}
try:
rec["sha256"] = file_sha256(video_path)
fdir = os.path.join(tempdir, Path(video_path).stem)
os.makedirs(fdir, exist_ok=True)
frames = extract_frames(video_path, fdir, fps=frames_per_sec, max_frames=max_frames)
rec["frames"] = frames
rec["phashes"] = compute_phash_streaming(frames)
if use_audio:
rec["audio_fp"] = audio_fingerprint(video_path)
except Exception as e:
log_error(e, f"analyze_file: {video_path}")
return rec
def build_index(folder, workers=None, frames_per_sec=1, max_frames=5, use_audio=False, progress_callback=None):
folder = Path(folder)
files = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED_EXT]
total = len(files)
tmpdir = tempfile.mkdtemp(prefix=TMP_PREFIX)
results = []
workers = workers or max(1, cpu_count()//2)
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(analyze_file, p, tmpdir, frames_per_sec, max_frames, use_audio): p for p in files}
for i, fut in enumerate(as_completed(futures)):
try:
r = fut.result()
results.append(r)
except Exception as e:
log_error(e, "build_index")
if progress_callback:
progress_callback(i+1, total)
return results, tmpdir
def dynamic_threshold(phashes):
"""动态阈值:基于帧间差异"""
if len(phashes) < 2:
return 8
diffs = [hamming_distance(phashes[i], phashes[i+1]) for i in range(len(phashes)-1)]
avg_diff = sum(diffs) / len(diffs) if diffs else 0
return max(8, int(avg_diff * 1.5))
def multi_level_dedup(rec_a, rec_b, phash_threshold=8, require_frame_ratio=0.6):
"""多级去重:SHA256 + 帧哈希 + 音频"""
if rec_a["sha256"] == rec_b["sha256"]:
return 1.0, True # exact match
audio_match = rec_a.get("audio_fp") == rec_b.get("audio_fp") if rec_a.get("audio_fp") and rec_b.get("audio_fp") else False
aps = rec_a.get("phashes", [])
bps = rec_b.get("phashes", [])
if not aps or not bps:
return 0.0, audio_match
thresh_a = dynamic_threshold(aps)
thresh_b = dynamic_threshold(bps)
thresh = max(thresh_a, thresh_b, phash_threshold)
match_count = sum(1 for ph_a in aps for ph_b in bps if hamming_distance(ph_a, ph_b) <= thresh)
total_frames = max(1, min(len(aps), len(bps)))
ratio = match_count / (total_frames * max(len(aps), len(bps)) / total_frames) # normalized
return ratio, audio_match or (ratio >= require_frame_ratio)
def compare_records(records, phash_threshold=8, require_frame_ratio=0.6):
out = []
n = len(records)
for i in range(n):
for j in range(i+1, n):
a = records[i]; b = records[j]
ratio, audio_match = multi_level_dedup(a, b, phash_threshold, require_frame_ratio)
if ratio >= require_frame_ratio or audio_match:
best_dist = min(hamming_distance(pa, pb) for pa in a["phashes"] for pb in b["phashes"]) if a["phashes"] and b["phashes"] else 999
out.append((a["path"], b["path"], best_dist, ratio, audio_match))
return out
# ----------------------------
# 报告生成(CSV + HTML preview)
# ----------------------------
def generate_csv_report(records, pairs, out_csv):
with open(out_csv, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["path", "sha256", "num_frames", "phashes", "audio_fp_present"])
for r in records:
w.writerow([r["path"], r["sha256"], len(r.get("frames", [])), ";".join(r.get("phashes", [])), bool(r.get("audio_fp"))])
w.writerow([])
w.writerow(["potential_duplicate_pairs"])
w.writerow(["path_a", "path_b", "best_phash_distance", "matching_ratio", "audio_match"])
for a,b,d,ratio,audio in pairs:
w.writerow([a,b,d,ratio,audio])
def encode_multi_thumbnail(frame_paths, maxw=360):
"""动态多帧缩略图:拼接前3帧"""
try:
imgs = []
for f in frame_paths[:3]:
with Image.open(f).convert("RGB") as img:
imgs.append(img.resize((120, 120)))
if not imgs:
return ""
result = Image.new("RGB", (120 * len(imgs), 120))
for i, img in enumerate(imgs):
result.paste(img, (120 * i, 0))
from io import BytesIO
buf = BytesIO()
result.save(buf, format="JPEG", quality=75)
return base64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
log_error(e, "encode_multi_thumbnail")
return ""
def generate_html_report(records, pairs, out_html, tmpdir):
html = ["<html><head><meta charset='utf-8'><title>Dedup Report</title></head><body>"]
html.append("<h1>Video Manager Advanced - Report</h1>")
html.append("<h2>Files</h2><div style='display:flex;flex-wrap:wrap;'>")
for r in records:
b64 = encode_multi_thumbnail(r["frames"]) if r["frames"] else ""
html.append("<div style='width:380px;border:1px solid #ddd;margin:6px;padding:6px;'>")
if b64:
html.append(f"<img src='data:image/jpeg;base64,{b64}' style='max-width:360px;display:block;margin-bottom:4px;'/>")
html.append(f"<div style='font-size:12px;word-break:break-all;'>Path: {r['path']}</div>")
html.append(f"<div style='font-size:12px;'>SHA256: {r['sha256'][:16]}...</div>")
html.append("</div>")
html.append("</div>")
html.append("<h2>Potential Duplicates</h2>")
html.append("<table border='1' cellpadding='6'><tr><th>File A</th><th>File B</th><th>best_dist</th><th>ratio</th><th>audio_match</th><th>Action</th></tr>")
for a,b,d,ratio,audio in pairs:
html.append(f"<tr><td style='max-width:300px'>{a}</td><td style='max-width:300px'>{b}</td><td>{d}</td><td>{ratio:.2f}</td><td>{audio}</td>"
f"<td><button onclick=\"if(confirm('Delete {a}?')) alert('Deleted (simulated)');\">Del A</button> "
f"<button onclick=\"if(confirm('Delete {b}?')) alert('Deleted (simulated)');\">Del B</button></td></tr>")
html.append("</table>")
html.append("<p>Note: This tool does NOT delete files automatically. Please review results manually.</p>")
html.append("</body></html>")
with open(out_html, "w", encoding="utf-8") as f:
f.write("\n".join(html))
# ----------------------------
# GUI 主窗口(带进度/日志)
# ----------------------------
def run_gui():
sg.theme("SystemDefault")
layout = [
[sg.Text("Video Manager Advanced(合规用途)", font=("Helvetica", 14))],
[sg.Text("1) 扫描与去重")],
[sg.Text("Folder:"), sg.Input(key="-FOLDER-"), sg.FolderBrowse()],
[sg.Text("Frames/sec (采样帧率):"), sg.Input("1", size=(6,1), key="-FPS-"),
sg.Text("Max frames per file:"), sg.Input("5", size=(6,1), key="-MAXF-"),
sg.Text("phash阈值:"), sg.Input("8", size=(6,1), key="-PH-")],
[sg.Checkbox("启用音频指纹(fpcalc)", key="-AF-", default=False),
sg.Text("并发线程:"), sg.Input(str(max(1, cpu_count()//2)), size=(6,1), key="-WORK-")],
[sg.Button("开始扫描", key="-SCAN-"), sg.Button("停止", key="-STOP-"), sg.Text("", key="-STATUS-", size=(40,1))],
[sg.ProgressBar(100, orientation='h', size=(50, 20), key='-PROG-')],
[sg.HorizontalSeparator()],
[sg.Text("2) 合成宽屏视频(中间主视频 + 两侧模糊)")],
[sg.Text("Input Video:"), sg.Input(key="-INVID-"), sg.FileBrowse(file_types=(("Video","*.mp4;*.mov;*.mkv"),))],
[sg.Text("Side Content (optional):"), sg.Input(key="-SIDE-",), sg.FileBrowse(file_types=(("Video/Image","*.mp4;*.jpg;*.png"),))],
[sg.Text("Output:"), sg.Input("out_wide.mp4", key="-OUTVID-"), sg.FileSaveAs(file_types=(("MP4","*.mp4"),))],
[sg.Text("Out W:"), sg.Input("1920", key="-W-", size=(6,1)), sg.Text("Out H:"), sg.Input("1080", key="-H-", size=(6,1)),
sg.Text("Blur (0=auto):"), sg.Input("0", key="-BLUR-", size=(6,1))],
[sg.Button("合成视频", key="-MAKE-")],
[sg.HorizontalSeparator()],
[sg.Text("日志:")],
[sg.Multiline(size=(100,10), key="-LOG-", autoscroll=True)],
[sg.Button("退出")]
]
window = sg.Window("VideoManagerAdv", layout, resizable=True, finalize=True)
scanning = False
tmpdir_global = None
while True:
event, values = window.read(timeout=100)
if event in (sg.WIN_CLOSED, "退出"):
if tmpdir_global and os.path.exists(tmpdir_global):
shutil.rmtree(tmpdir_global, ignore_errors=True)
break
if event == "-MAKE-":
invid = values["-INVID-"]
outvid = values["-OUTVID-"]
side = values["-SIDE-"] or None
if not invid or not outvid:
sg.popup("请指定输入与输出视频文件")
continue
try:
ow = int(values["-W-"]); oh = int(values["-H-"]); blur = int(values["-BLUR-"])
if blur == 0:
blur = None # auto
except Exception:
sg.popup("宽高或模糊参数错误")
continue
window['-LOG-'].print(f"开始合成 {invid} -> {outvid} ...")
ok = generate_wide_version(invid, outvid, out_w=ow, out_h=oh, blur=blur, side_content=side)
window['-LOG-'].print("合成完成" if ok else "合成失败,请检查 ffmpeg")
if event == "-SCAN-":
folder = values["-FOLDER-"]
if not folder or not os.path.isdir(folder):
sg.popup("请选择有效文件夹")
continue
try:
fps = float(values["-FPS-"])
maxf = int(values["-MAXF-"])
ph = int(values["-PH-"])
workers = int(values["-WORK-"])
use_audio = bool(values["-AF-"])
except Exception:
sg.popup("参数错误")
continue
window['-STATUS-'].update("Scanning...")
window['-LOG-'].print(f"开始索引目录: {folder}")
scanning = True
window['-PROG-'].update(0)
try:
records, tmpdir = build_index(folder, workers=workers, frames_per_sec=fps, max_frames=maxf, use_audio=use_audio,
progress_callback=lambda done, total: window['-PROG-'].update(int(done/total*100)))
tmpdir_global = tmpdir
window['-LOG-'].print(f"索引完成, 临时目录: {tmpdir}")
window['-LOG-'].print("开始相似度比对 ...")
pairs = compare_records(records, phash_threshold=ph)
out_csv = os.path.join(folder, "vma_report.csv")
out_html = os.path.join(folder, "vma_report.html")
generate_csv_report(records, pairs, out_csv)
generate_html_report(records, pairs, out_html, tmpdir)
window['-LOG-'].print(f"报告生成: {out_csv}, {out_html}")
window['-STATUS-'].update("完成")
window['-PROG-'].update(100)
except Exception as e:
log_error(e, "-SCAN-")
window['-LOG-'].print("错误: " + str(e))
window['-STATUS-'].update("错误")
scanning = False
if event == "-STOP-":
sg.popup("当前操作为短任务;若需中止正在运行的 ffmpeg,请在任务管理器中结束 ffmpeg 进程。")
window.close()
# ----------------------------
# CLI 支持(无 GUI 批量)
# ----------------------------
def run_cli(args):
import argparse
parser = argparse.ArgumentParser(description="Video Manager Advanced CLI")
parser.add_argument("folder", help="Folder to scan")
parser.add_argument("--fps", type=float, default=1.0, help="frames per second to sample")
parser.add_argument("--max-frames", type=int, default=5, help="max frames per file")
parser.add_argument("--workers", type=int, default=max(1, cpu_count()//2))
parser.add_argument("--phash-threshold", type=int, default=8)
parser.add_argument("--use-audio-fp", action="store_true")
parser.add_argument("--out-csv", default="vma_report.csv")
parser.add_argument("--out-html", default="vma_report.html")
ns = parser.parse_args(args)
print("Building index...")
records, tmp = build_index(ns.folder, workers=ns.workers, frames_per_sec=ns.fps, max_frames=ns.max_frames, use_audio=ns.use_audio_fp,
progress_callback=lambda done, total: print(f"Progress: {done}/{total}", end="\r"))
print("\nComparing records...")
pairs = compare_records(records, phash_threshold=ns.phash_threshold)
generate_csv_report(records, pairs, ns.out_csv)
generate_html_report(records, pairs, ns.out_html, tmp)
print("Reports:", ns.out_csv, ns.out_html)
print("Temporary frames in:", tmp)
print("Note: Tool does NOT delete files automatically. Please review reports manually.")
# CLI 模式下清理临时目录
if tmp and os.path.exists(tmp):
shutil.rmtree(tmp, ignore_errors=True)
# ----------------------------
# 入口
# ----------------------------
if __name__ == "__main__":
if len(sys.argv) > 1:
# CLI mode
run_cli(sys.argv[1:])
else:
run_gui()