- 两种截图模式:平均截图 / 按间隔截图 - 预览支持:网格/列表模式切换 - 列表模式:表格视图,支持按名称/分辨率/大小排序 - 双击放大、右键上下文菜单 - 可视化界面,PyInstaller 打包
893 lines
37 KiB
Python
893 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
VideoSnapper — 视频截图工具
|
||
============================
|
||
- 选择视频 → 展示基本信息 → 截图 → 预览
|
||
- 两种截图模式:按平均张数 / 按时间间隔
|
||
- 截图以时间命名 (52s.png / 10m.png / 2m28s.png)
|
||
- 使用 OpenCV 处理视频 (零外部二进制依赖)
|
||
- 预览支持:网格/列表模式、小/中/大三档尺寸、双击放大、右键菜单
|
||
|
||
打包:PyInstaller 一键打包 (见 build.bat)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import subprocess
|
||
import tkinter as tk
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from tkinter import (
|
||
Tk, ttk, StringVar, IntVar, filedialog, messagebox, Menu, Toplevel,
|
||
)
|
||
from tkinter.scrolledtext import ScrolledText
|
||
|
||
import cv2
|
||
from PIL import Image, ImageTk
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 工具函数
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def sec2hms(seconds: float) -> str:
|
||
s = int(round(seconds))
|
||
h, r = divmod(s, 3600)
|
||
m, s_ = divmod(r, 60)
|
||
return f"{h:02d}:{m:02d}:{s_:02d}"
|
||
|
||
|
||
def fmt_size(b: int) -> str:
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if b < 1024:
|
||
return f"{b:.1f} {unit}"
|
||
b /= 1024
|
||
return f"{b:.1f} TB"
|
||
|
||
|
||
def format_ts(total_seconds: float) -> str:
|
||
ts = int(round(total_seconds))
|
||
m, s_ = divmod(ts, 60)
|
||
if m == 0:
|
||
return f"{s_}s"
|
||
if s_ == 0:
|
||
return f"{m}m"
|
||
return f"{m}m{s_}s"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 视频处理
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class VideoReader:
|
||
"""基于 OpenCV 的视频读取封装。"""
|
||
|
||
def __init__(self, path: str) -> None:
|
||
self.path = path
|
||
self.cap = cv2.VideoCapture(path)
|
||
if not self.cap.isOpened():
|
||
raise ValueError(f"无法打开视频: {path}")
|
||
|
||
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||
|
||
if self.fps > 0:
|
||
self.duration = self.total_frames / self.fps
|
||
else:
|
||
self.cap.set(cv2.CAP_PROP_POS_AVI_RATIO, 1)
|
||
ms = self.cap.get(cv2.CAP_PROP_POS_MSEC)
|
||
self.duration = ms / 1000.0 if ms > 0 else 0
|
||
self.cap.set(cv2.CAP_PROP_POS_AVI_RATIO, 0)
|
||
|
||
def meta(self) -> dict:
|
||
return {
|
||
"文件名": Path(self.path).name,
|
||
"完整路径": self.path,
|
||
"时长": sec2hms(self.duration),
|
||
"时长_秒": round(self.duration, 2),
|
||
"分辨率": f"{self.width}×{self.height}",
|
||
"帧率": f"{self.fps:.2f} fps",
|
||
"总帧数": self.total_frames,
|
||
"大小": fmt_size(os.path.getsize(self.path)),
|
||
}
|
||
|
||
def extract_frame(self, timestamp: float, out_path: str) -> bool:
|
||
frame_idx = int(timestamp * self.fps)
|
||
if frame_idx >= self.total_frames:
|
||
frame_idx = self.total_frames - 1
|
||
if frame_idx < 0:
|
||
frame_idx = 0
|
||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
||
ret, frame = self.cap.read()
|
||
if not ret:
|
||
return False
|
||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||
img = Image.fromarray(rgb)
|
||
img.save(out_path, "PNG")
|
||
return True
|
||
|
||
def close(self) -> None:
|
||
self.cap.release()
|
||
|
||
|
||
def calc_timestamps(duration_s: float, mode: str, param) -> list[float]:
|
||
if duration_s <= 0:
|
||
return []
|
||
if mode == "interval":
|
||
step = float(param)
|
||
return [i * step for i in range(int(duration_s // step) + 1)]
|
||
count = max(1, int(param))
|
||
if count == 1:
|
||
return [duration_s / 2]
|
||
return [duration_s * i / (count - 1) for i in range(count)]
|
||
|
||
|
||
def batch_extract(
|
||
video_reader: VideoReader,
|
||
timestamps: list[float],
|
||
out_dir: str,
|
||
on_progress=None,
|
||
) -> list[dict]:
|
||
results = []
|
||
total = len(timestamps)
|
||
for idx, ts in enumerate(timestamps):
|
||
name = format_ts(ts) + ".png"
|
||
dst = os.path.join(out_dir, name)
|
||
ok = video_reader.extract_frame(ts, dst)
|
||
results.append({"ts": ts, "name": name, "path": dst, "ok": ok})
|
||
if on_progress:
|
||
on_progress(idx + 1, total)
|
||
return results
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 大图预览窗口
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class PreviewWindow:
|
||
"""双击缩略图弹出的原图预览窗口。"""
|
||
|
||
def __init__(self, parent, image_path: str):
|
||
self.win = Toplevel(parent)
|
||
self.win.title(Path(image_path).name)
|
||
self.win.geometry("900x650")
|
||
self.win.minsize(400, 300)
|
||
|
||
self.img_path = image_path
|
||
self._zoom = 1.0
|
||
self._orig_size = None
|
||
|
||
# 菜单栏
|
||
bar = Menu(self.win)
|
||
self.win.config(menu=bar)
|
||
file_m = Menu(bar, tearoff=False)
|
||
file_m.add_command(label="用系统程序打开", command=self._open_system)
|
||
file_m.add_command(label="打开文件所在位置", command=self._open_location)
|
||
file_m.add_separator()
|
||
file_m.add_command(label="关闭", command=self.win.destroy)
|
||
bar.add_cascade(label="文件", menu=file_m)
|
||
|
||
# 缩放控件
|
||
zoom_frame = ttk.Frame(self.win)
|
||
zoom_frame.pack(fill="x", padx=8, pady=4)
|
||
ttk.Button(zoom_frame, text="─ 缩小", command=lambda: self._zoom_to(self._zoom * 0.8)).pack(side="left")
|
||
self._zoom_label = ttk.Label(zoom_frame, text="100%")
|
||
self._zoom_label.pack(side="left", padx=8)
|
||
ttk.Button(zoom_frame, text="+ 放大", command=lambda: self._zoom_to(self._zoom * 1.25)).pack(side="left")
|
||
ttk.Button(zoom_frame, text="适应窗口", command=self._fit_to_window).pack(side="left", padx=(12, 0))
|
||
ttk.Button(zoom_frame, text="原始大小", command=self._original_size).pack(side="left", padx=(4, 0))
|
||
|
||
# 图片展示区(可滚动)
|
||
canvas_frame = ttk.Frame(self.win)
|
||
canvas_frame.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
self._canvas = tk.Canvas(canvas_frame, bg="#2b2b2b", highlightthickness=0)
|
||
hbar = ttk.Scrollbar(canvas_frame, orient="horizontal", command=self._canvas.xview)
|
||
vbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=self._canvas.yview)
|
||
self._canvas.configure(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
|
||
|
||
hbar.pack(side="bottom", fill="x")
|
||
vbar.pack(side="right", fill="y")
|
||
self._canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
self._canvas.bind("<ButtonPress-1>", self._drag_start)
|
||
self._canvas.bind("<B1-Motion>", self._drag_move)
|
||
self._canvas.bind("<MouseWheel>", self._on_wheel) # Windows
|
||
self._canvas.bind("<Button-4>", self._on_wheel_linux) # Linux up
|
||
self._canvas.bind("<Button-5>", self._on_wheel_linux) # Linux down
|
||
|
||
self._img_on_canvas = None
|
||
self._pil_img = None
|
||
self._tk_img = None
|
||
self._drag_data = {"x": 0, "y": 0}
|
||
|
||
self._load_image()
|
||
self._fit_to_window()
|
||
|
||
def _load_image(self):
|
||
try:
|
||
self._pil_img = Image.open(self.img_path)
|
||
self._orig_size = self._pil_img.size
|
||
except Exception as e:
|
||
ttk.Label(self._canvas, text=f"加载失败: {e}").pack()
|
||
self._pil_img = None
|
||
|
||
def _render(self):
|
||
if self._pil_img is None:
|
||
return
|
||
w = max(1, int(self._orig_size[0] * self._zoom))
|
||
h = max(1, int(self._orig_size[1] * self._zoom))
|
||
resized = self._pil_img.resize((w, h), Image.LANCZOS)
|
||
self._tk_img = ImageTk.PhotoImage(resized)
|
||
self._canvas.delete("img")
|
||
self._img_on_canvas = self._canvas.create_image(0, 0, anchor="nw", image=self._tk_img, tags="img")
|
||
self._canvas.configure(scrollregion=self._canvas.bbox("all"))
|
||
self._zoom_label.config(text=f"{int(self._zoom * 100)}%")
|
||
|
||
def _zoom_to(self, factor):
|
||
if self._pil_img is None:
|
||
return
|
||
factor = max(0.05, min(factor, 20.0))
|
||
self._zoom = factor
|
||
self._render()
|
||
|
||
def _fit_to_window(self):
|
||
if self._pil_img is None:
|
||
return
|
||
cw = self._canvas.winfo_width() or 800
|
||
ch = self._canvas.winfo_height() or 600
|
||
iw, ih = self._orig_size
|
||
if iw == 0 or ih == 0:
|
||
return
|
||
sx = cw / iw
|
||
sy = ch / ih
|
||
self._zoom = min(sx, sy) * 0.95
|
||
self._render()
|
||
|
||
def _original_size(self):
|
||
self._zoom = 1.0
|
||
self._render()
|
||
|
||
def _open_system(self):
|
||
p = self.img_path
|
||
try:
|
||
os.startfile(p)
|
||
except AttributeError:
|
||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", p])
|
||
|
||
def _open_location(self):
|
||
p = os.path.abspath(self.img_path)
|
||
try:
|
||
subprocess.run(["explorer", "/select,", p])
|
||
except Exception:
|
||
try:
|
||
os.startfile(os.path.dirname(p))
|
||
except AttributeError:
|
||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", os.path.dirname(p)])
|
||
|
||
def _drag_start(self, e):
|
||
self._drag_data["x"] = e.x
|
||
self._drag_data["y"] = e.y
|
||
|
||
def _drag_move(self, e):
|
||
dx = e.x - self._drag_data["x"]
|
||
dy = e.y - self._drag_data["y"]
|
||
self._canvas.xview_scroll(-dx, "units")
|
||
self._canvas.yview_scroll(-dy, "units")
|
||
self._drag_data["x"] = e.x
|
||
self._drag_data["y"] = e.y
|
||
|
||
def _on_wheel(self, e):
|
||
self._zoom_to(self._zoom * (1.1 if e.delta > 0 else 0.9))
|
||
|
||
def _on_wheel_linux(self, e):
|
||
self._zoom_to(self._zoom * (1.1 if e.num == 4 else 0.9))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 主窗口
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class App:
|
||
# 缩略图尺寸三档
|
||
SIZES = {
|
||
"small": (180, 101),
|
||
"medium": (320, 180),
|
||
"large": (480, 270),
|
||
}
|
||
# 缩略图尺寸定义(列数改为动态计算)
|
||
|
||
def __init__(self) -> None:
|
||
self.root = Tk()
|
||
self.root.title("VideoSnapper — 视频截图工具")
|
||
self.root.geometry("1000x800")
|
||
self.root.minsize(800, 600)
|
||
|
||
# ── 状态 ──
|
||
self.meta: dict = {}
|
||
self._results: list[dict] = [] # 最近截图结果
|
||
self._thumb_cells: list[dict] = [] # 预览区单元格引用
|
||
self._last_outdir: str | None = None
|
||
self._busy = False
|
||
# 列表排序状态
|
||
self._list_sort_col = 'name'
|
||
self._list_sort_rev = False
|
||
|
||
# tk 变量
|
||
self.path_var = StringVar()
|
||
self.mode_var = StringVar(value="average")
|
||
self.iv_val = IntVar(value=10)
|
||
self.iv_unit = StringVar(value="秒")
|
||
self.av_count = IntVar(value=12)
|
||
self.view_mode = StringVar(value="grid") # "grid" | "list"
|
||
self.thumb_size = StringVar(value="small") # "small" | "medium" | "large"
|
||
|
||
self._build_ui()
|
||
self._bind_events()
|
||
|
||
# ── 构建界面 ──────────────────────────────────────────────
|
||
|
||
def _build_ui(self) -> None:
|
||
root = self.root
|
||
style = ttk.Style()
|
||
style.theme_use("vista" if "vista" in style.theme_names() else "clam")
|
||
|
||
# ───── 顶部:视频选择 ─────────────────────────────────
|
||
f_top = ttk.Frame(root, padding=(12, 8))
|
||
f_top.pack(fill="x")
|
||
|
||
ttk.Label(f_top, text="视频文件:").pack(side="left")
|
||
self._entry_path = ttk.Entry(f_top, textvariable=self.path_var)
|
||
self._entry_path.pack(side="left", fill="x", expand=True, padx=4)
|
||
ttk.Button(f_top, text="浏览…", command=self._pick_video).pack(side="left")
|
||
ttk.Button(f_top, text="加载", command=self._load_video).pack(side="left", padx=(4, 0))
|
||
|
||
# ───── 视频信息 ───────────────────────────────────────
|
||
self._info_frame = ttk.LabelFrame(root, text="视频信息", padding=(8, 4))
|
||
self._info_frame.pack(fill="x", padx=12, pady=(6, 0))
|
||
self._info_text = ScrolledText(
|
||
self._info_frame, height=5, font=("Consolas", 10),
|
||
wrap="word", state="disabled",
|
||
)
|
||
self._info_text.pack(fill="both", expand=True)
|
||
|
||
# ───── 截图配置 ───────────────────────────────────────
|
||
f_cfg = ttk.LabelFrame(root, text="截图配置", padding=(8, 4))
|
||
f_cfg.pack(fill="x", padx=12, pady=(6, 0))
|
||
|
||
fm = ttk.Frame(f_cfg)
|
||
fm.pack(fill="x", pady=2)
|
||
ttk.Radiobutton(fm, text="平均截图", variable=self.mode_var,
|
||
value="average", command=self._toggle_mode).pack(side="left", padx=(0, 12))
|
||
ttk.Radiobutton(fm, text="按间隔截图", variable=self.mode_var,
|
||
value="interval", command=self._toggle_mode).pack(side="left")
|
||
|
||
self._f_avg = ttk.Frame(f_cfg)
|
||
self._f_avg.pack(fill="x", pady=2)
|
||
ttk.Label(self._f_avg, text="平均截取").pack(side="left")
|
||
self._e_avg = ttk.Entry(self._f_avg, textvariable=self.av_count, width=6)
|
||
self._e_avg.pack(side="left", padx=4)
|
||
ttk.Label(self._f_avg, text="张(含首尾)").pack(side="left")
|
||
|
||
self._f_int = ttk.Frame(f_cfg)
|
||
ttk.Label(self._f_int, text="每隔").pack(side="left")
|
||
self._e_int = ttk.Entry(self._f_int, textvariable=self.iv_val, width=6)
|
||
self._e_int.pack(side="left", padx=4)
|
||
unit_combo = ttk.Combobox(self._f_int, textvariable=self.iv_unit,
|
||
values=["秒", "分", "小时"], state="readonly", width=6)
|
||
unit_combo.pack(side="left")
|
||
ttk.Label(self._f_int, text="截一张").pack(side="left", padx=(4, 0))
|
||
self._f_int.pack_forget()
|
||
|
||
# ───── 操作按钮 + 视图控制 ────────────────────────────
|
||
f_btn = ttk.Frame(root, padding=(12, 6))
|
||
f_btn.pack(fill="x")
|
||
|
||
self._btn_start = ttk.Button(f_btn, text="▶ 开始截图", command=self._start_screenshot)
|
||
self._btn_start.pack(side="left")
|
||
self._btn_open = ttk.Button(f_btn, text="📁 打开输出文件夹",
|
||
command=self._open_outdir, state="disabled")
|
||
self._btn_open.pack(side="left", padx=(8, 0))
|
||
ttk.Button(f_btn, text="清空预览", command=self._clear_preview).pack(side="left", padx=(8, 0))
|
||
|
||
# ─── 视图控制(右侧) ─────────────────────────────────
|
||
f_view = ttk.Frame(f_btn)
|
||
f_view.pack(side="right")
|
||
|
||
ttk.Label(f_view, text="视图:").pack(side="left")
|
||
view_combo = ttk.Combobox(f_view, textvariable=self.view_mode,
|
||
values=["grid", "list"], state="readonly", width=6)
|
||
view_combo.pack(side="left", padx=(0, 8))
|
||
view_combo.bind("<<ComboboxSelected>>", lambda _: self._rebuild_preview())
|
||
|
||
ttk.Label(f_view, text="大小:").pack(side="left")
|
||
size_combo = ttk.Combobox(f_view, textvariable=self.thumb_size,
|
||
values=["small", "medium", "large"],
|
||
state="readonly", width=8)
|
||
size_combo.pack(side="left")
|
||
size_combo.bind("<<ComboboxSelected>>", lambda _: self._rebuild_preview())
|
||
|
||
# ───── 进度 ───────────────────────────────────────────
|
||
self._progress = ttk.Progressbar(root, mode="determinate")
|
||
self._progress.pack(fill="x", padx=12)
|
||
self._status_var = StringVar(value="就绪")
|
||
ttk.Label(root, textvariable=self._status_var).pack(fill="x", padx=12, pady=(0, 4))
|
||
|
||
# ───── 截图预览 ───────────────────────────────────────
|
||
f_preview = ttk.LabelFrame(root, text="截图预览", padding=(6, 4))
|
||
f_preview.pack(fill="both", expand=True, padx=12, pady=(0, 8))
|
||
|
||
# 可滚动容器(仅垂直滚动)
|
||
self._preview_canvas = tk.Canvas(f_preview, highlightthickness=0, bg="#f0f0f0")
|
||
vbar = ttk.Scrollbar(f_preview, orient="vertical", command=self._preview_canvas.yview)
|
||
self._preview_canvas.configure(yscrollcommand=vbar.set)
|
||
|
||
vbar.pack(side="right", fill="y")
|
||
self._preview_canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
self._preview_container = ttk.Frame(self._preview_canvas)
|
||
self._preview_canvas.create_window(0, 0, window=self._preview_container, anchor="nw")
|
||
|
||
self._preview_container.bind("<Configure>", self._on_container_configure)
|
||
self._preview_canvas.bind("<Configure>", self._on_canvas_configure)
|
||
|
||
def _on_container_configure(self, _=None):
|
||
self._preview_canvas.configure(scrollregion=self._preview_canvas.bbox("all"))
|
||
|
||
def _on_canvas_configure(self, e):
|
||
# 网格模式下:容器宽度跟随 canvas
|
||
if self._preview_container.winfo_children():
|
||
old_w = self._preview_container.winfo_width()
|
||
new_w = e.width
|
||
self._preview_container.configure(width=new_w)
|
||
# 宽度变化超过 20px 才触发重排(防止无穷循环)
|
||
if self.view_mode.get() == "grid" and abs(new_w - old_w) > 20:
|
||
self._rebuild_preview()
|
||
|
||
def _bind_events(self) -> None:
|
||
self.path_var.trace_add("write", lambda *_: self._clear_preview())
|
||
self.root.bind("<Control-o>", lambda _: self._pick_video())
|
||
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
def _toggle_mode(self) -> None:
|
||
if self.mode_var.get() == "average":
|
||
self._f_int.pack_forget()
|
||
self._f_avg.pack(fill="x", pady=2)
|
||
else:
|
||
self._f_avg.pack_forget()
|
||
self._f_int.pack(fill="x", pady=2)
|
||
|
||
# ── 事件 ──────────────────────────────────────────────────
|
||
|
||
def _pick_video(self) -> None:
|
||
p = filedialog.askopenfilename(
|
||
title="选择视频文件",
|
||
filetypes=[
|
||
("视频文件", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm *.ts *.mts *.m4v"),
|
||
("所有文件", "*.*"),
|
||
],
|
||
)
|
||
if p:
|
||
self.path_var.set(p)
|
||
self._load_video()
|
||
|
||
def _load_video(self) -> None:
|
||
p = self.path_var.get().strip()
|
||
if not p or not os.path.isfile(p):
|
||
messagebox.showerror("错误", f"文件不存在:{p}")
|
||
return
|
||
self._set_info("正在分析视频…")
|
||
self.root.update()
|
||
try:
|
||
reader = VideoReader(p)
|
||
self.meta = reader.meta()
|
||
reader.close()
|
||
except Exception as e:
|
||
self._set_info(f"打开视频失败:{e}")
|
||
return
|
||
lines = [
|
||
f"文件名:{self.meta.get('文件名', '?')}",
|
||
f"时长:{self.meta.get('时长', '?')} ({self.meta.get('时长_秒', 0)} 秒)",
|
||
f"分辨率:{self.meta.get('分辨率', '?')}",
|
||
f"大小:{self.meta.get('大小', '?')}",
|
||
f"帧率:{self.meta.get('帧率', '?')} 总帧数:{self.meta.get('总帧数', '?')}",
|
||
]
|
||
self._set_info("\n".join(lines))
|
||
self._clear_preview()
|
||
self._status_var.set(f"已加载:{Path(p).name}")
|
||
self.meta["_path"] = p
|
||
|
||
def _set_info(self, text: str) -> None:
|
||
self._info_text.configure(state="normal")
|
||
self._info_text.delete("1.0", "end")
|
||
self._info_text.insert("1.0", text)
|
||
self._info_text.configure(state="disabled")
|
||
|
||
# ── 截图 ──────────────────────────────────────────────────
|
||
|
||
def _start_screenshot(self) -> None:
|
||
if self._busy:
|
||
return
|
||
path = self.meta.get("_path", "")
|
||
if not path or not os.path.isfile(path):
|
||
messagebox.showerror("错误", "请先加载有效的视频文件")
|
||
return
|
||
dur = self.meta.get("时长_秒", 0)
|
||
if dur <= 0:
|
||
messagebox.showerror("错误", "无法获取视频时长")
|
||
return
|
||
|
||
mode = self.mode_var.get()
|
||
if mode == "average":
|
||
count = self.av_count.get()
|
||
if count < 1:
|
||
messagebox.showerror("错误", "截图张数必须 ≥ 1")
|
||
return
|
||
param = count
|
||
mode_label = f"平均{count}张"
|
||
else:
|
||
val = self.iv_val.get()
|
||
if val < 1:
|
||
messagebox.showerror("错误", "间隔值必须 ≥ 1")
|
||
return
|
||
unit_map = {"秒": 1, "分": 60, "小时": 3600}
|
||
step = val * unit_map.get(self.iv_unit.get(), 1)
|
||
param = step
|
||
mode_label = f"每{val}{self.iv_unit.get()}"
|
||
|
||
timestamps = calc_timestamps(dur, mode, param)
|
||
if not timestamps:
|
||
messagebox.showinfo("提示", "未计算出截图时间点")
|
||
return
|
||
|
||
video_stem = Path(path).stem
|
||
out_dir = os.path.join(Path(path).parent, f"{video_stem}_截图")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
self._last_outdir = out_dir
|
||
|
||
self._busy = True
|
||
self._btn_start.configure(state="disabled")
|
||
self._progress["maximum"] = len(timestamps)
|
||
self._progress["value"] = 0
|
||
self._clear_preview()
|
||
|
||
def on_prog(done, total):
|
||
self.root.after(0, lambda: self._progress.configure(value=done))
|
||
self.root.after(0, lambda: self._status_var.set(f"截图中… {done}/{total} ({mode_label})"))
|
||
|
||
def worker():
|
||
try:
|
||
reader = VideoReader(path)
|
||
results = batch_extract(reader, timestamps, out_dir, on_progress=on_prog)
|
||
reader.close()
|
||
self._results = results
|
||
self.root.after(0, lambda: self._on_done(results, out_dir))
|
||
except Exception as e:
|
||
self.root.after(0, lambda: self._on_error(str(e)))
|
||
|
||
Thread(target=worker, daemon=True).start()
|
||
|
||
def _on_done(self, results: list[dict], out_dir: str) -> None:
|
||
self._busy = False
|
||
self._btn_start.configure(state="normal")
|
||
self._btn_open.configure(state="normal")
|
||
ok = sum(1 for r in results if r["ok"])
|
||
self._status_var.set(f"完成:成功 {ok}/{len(results)} 张 → {out_dir}")
|
||
self._results = results
|
||
self._show_thumbnails(results)
|
||
|
||
def _on_error(self, msg: str) -> None:
|
||
self._busy = False
|
||
self._btn_start.configure(state="normal")
|
||
messagebox.showerror("截图出错", msg)
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# 预览区(核心改动)
|
||
# ══════════════════════════════════════════════════════════
|
||
|
||
def _rebuild_preview(self):
|
||
"""在切换视图模式/尺寸后重建预览。"""
|
||
if self._results:
|
||
self._show_thumbnails(self._results)
|
||
|
||
def _get_thumb_size(self):
|
||
"""返回当前尺寸设置的 (宽, 高) 像素。"""
|
||
return self.SIZES.get(self.thumb_size.get(), self.SIZES["small"])
|
||
|
||
def _get_cols(self):
|
||
"""网格模式下列数,留作未来可能需要,现改为动态计算。"""
|
||
return 4 # fallback,实际由动态计算覆盖
|
||
|
||
# ── 缩略图展示 ────────────────────────────────────────────
|
||
|
||
def _show_thumbnails(self, results: list[dict]) -> None:
|
||
"""根据当前视图模式展示缩略图。"""
|
||
# 清空旧内容
|
||
for w in self._preview_container.winfo_children():
|
||
w.destroy()
|
||
self._thumb_cells.clear()
|
||
self._preview_container.configure(width=None)
|
||
|
||
ok_list = [r for r in results if r["ok"]]
|
||
if not ok_list:
|
||
ttk.Label(self._preview_container, text="(无成功的截图)").pack()
|
||
self._on_container_configure()
|
||
return
|
||
|
||
mode = self.view_mode.get()
|
||
if mode == "list":
|
||
self._render_list(ok_list)
|
||
else:
|
||
self._render_grid(ok_list)
|
||
|
||
self._on_container_configure()
|
||
|
||
def _render_grid(self, items: list[dict]):
|
||
"""网格模式:图片平铺填满整行 + 垂直滚动。"""
|
||
# 第一步:先显示出内容,等 canvas 布局完成再重新计算列数并刷新
|
||
# 直接使用 grid 布局,依据 canvas 可用宽度动态计算列数
|
||
cw = self._preview_canvas.winfo_width()
|
||
if cw < 100:
|
||
cw = 800 # fallback 宽
|
||
pad_outer = 6 # preview_container padx 总消耗
|
||
pad_cell_x = 8 # 单元格之间的水平间距
|
||
padding_inner = 4 # 单元格 border 左右各 1px
|
||
|
||
tw, th = self._get_thumb_size()
|
||
# 每单元格占宽 = 缩略图宽 + 额外 padding
|
||
cell_w = tw + pad_cell_x + 2
|
||
usable = cw - pad_outer
|
||
cols = max(1, usable // cell_w)
|
||
# 重新计算实际每个单元格宽度,使它们均匀撑满整行
|
||
actual_cell_w = usable // cols
|
||
# 缩略图在单元格内的水平居中偏移
|
||
img_w = actual_cell_w - pad_cell_x - 2
|
||
|
||
# 使用 grid 布局
|
||
for i, r in enumerate(items):
|
||
row_idx = i // cols
|
||
col_idx = i % cols
|
||
|
||
# 创建缩略图(用 img_w 宽度,高度保持比例缩放)
|
||
cell = self._make_thumb_cell_inner(
|
||
self._preview_container, r, img_w, th,
|
||
show_caption=True,
|
||
)
|
||
cell.grid(row=row_idx, column=col_idx, padx=1, pady=2, sticky="nsew")
|
||
|
||
# 绑定双击/右键
|
||
self._bind_cell_events(cell, r["path"])
|
||
|
||
# 让每列等宽扩展
|
||
for c in range(cols):
|
||
self._preview_container.columnconfigure(c, weight=1, uniform="thumb_col")
|
||
|
||
def _make_thumb_cell_inner(self, parent, result: dict, w: int, h: int,
|
||
show_caption: bool = True) -> ttk.Frame:
|
||
"""创建单个缩略图 frame,图片宽度撑满单元格,高度等比缩放。"""
|
||
cell = ttk.Frame(parent, relief="solid", borderwidth=1)
|
||
try:
|
||
img = Image.open(result["path"])
|
||
iw, ih = img.size
|
||
# 宽度撑满,高度等比(不限制高度)
|
||
ratio = w / iw if iw > 0 else 1.0
|
||
nw = max(1, int(iw * ratio))
|
||
nh = max(1, int(ih * ratio))
|
||
resized = img.resize((nw, nh), Image.LANCZOS)
|
||
photo = ImageTk.PhotoImage(resized)
|
||
lbl = ttk.Label(cell, image=photo)
|
||
lbl.image = photo
|
||
lbl.pack()
|
||
except Exception:
|
||
lbl = ttk.Label(cell, text="(加载失败)")
|
||
lbl.pack()
|
||
|
||
if show_caption:
|
||
cap = result["name"].replace(".png", "")
|
||
ttk.Label(cell, text=cap, font=("Consolas", 8)).pack()
|
||
|
||
return cell
|
||
|
||
def _bind_cell_events(self, cell, path: str):
|
||
"""为单元格及其所有子组件绑定交互事件。"""
|
||
for child in [cell] + list(cell.children.values()):
|
||
child.bind("<Double-Button-1>", lambda e, p=path: self._open_preview(p))
|
||
child.bind("<Button-3>", lambda e, p=path: self._show_context_menu(e, p))
|
||
|
||
def _render_list(self, items: list[dict]):
|
||
"""列表模式:表格展示,支持按列排序(名称/分辨率/大小)。"""
|
||
sort_col = self._list_sort_col
|
||
sort_rev = self._list_sort_rev
|
||
|
||
# ── 构建行数据 ──
|
||
rows = []
|
||
for r in items:
|
||
p = r["path"]
|
||
try:
|
||
img = Image.open(p)
|
||
res_text = f"{img.width} × {img.height}"
|
||
pixels = img.width * img.height
|
||
except Exception:
|
||
res_text = "?"
|
||
pixels = 0
|
||
try:
|
||
size_bytes = os.path.getsize(p)
|
||
except Exception:
|
||
size_bytes = 0
|
||
rows.append({
|
||
"name": r["name"],
|
||
"resolution": res_text,
|
||
"pixels": pixels,
|
||
"size_str": self._format_size(size_bytes),
|
||
"size_bytes": size_bytes,
|
||
"path": p,
|
||
})
|
||
|
||
# ── 排序 ──
|
||
if sort_col == "name":
|
||
rows.sort(key=lambda x: x["name"], reverse=sort_rev)
|
||
elif sort_col == "resolution":
|
||
rows.sort(key=lambda x: x["pixels"], reverse=sort_rev)
|
||
elif sort_col == "size":
|
||
rows.sort(key=lambda x: x["size_bytes"], reverse=sort_rev)
|
||
|
||
# ── 表头 ──
|
||
columns = [("name", "名称"), ("resolution", "分辨率"), ("size", "大小")]
|
||
header = ttk.Frame(self._preview_container)
|
||
header.pack(fill="x", pady=(0, 2))
|
||
|
||
for col_idx, (key, text) in enumerate(columns):
|
||
arrow = (" ▼" if sort_rev else " ▲") if sort_col == key else ""
|
||
lbl = ttk.Label(header, text=text + arrow,
|
||
font=("Consolas", 9, "bold"),
|
||
relief="raised", padding=(6, 3), anchor="center")
|
||
lbl.bind("<Button-1>", lambda e, k=key: self._sort_list(k))
|
||
lbl.grid(row=0, column=col_idx, sticky="ew", padx=1)
|
||
|
||
header.columnconfigure(0, weight=4) # 名称
|
||
header.columnconfigure(1, weight=3) # 分辨率
|
||
header.columnconfigure(2, weight=2) # 大小
|
||
|
||
# ── 分隔线 ──
|
||
ttk.Separator(self._preview_container, orient="horizontal").pack(fill="x", pady=(0, 2))
|
||
|
||
# ── 数据行 ──
|
||
for row_data in rows:
|
||
row = ttk.Frame(self._preview_container, relief="solid", borderwidth=1)
|
||
row.pack(fill="x", pady=1, padx=1)
|
||
|
||
# 名称
|
||
name_lbl = ttk.Label(row, text=row_data["name"], font=("Consolas", 9))
|
||
name_lbl.grid(row=0, column=0, sticky="w", padx=(6, 2), pady=4)
|
||
|
||
# 分辨率
|
||
ttk.Label(row, text=row_data["resolution"],
|
||
font=("Consolas", 9)).grid(row=0, column=1, sticky="w", padx=2, pady=4)
|
||
|
||
# 大小
|
||
ttk.Label(row, text=row_data["size_str"],
|
||
font=("Consolas", 9)).grid(row=0, column=2, sticky="w", padx=2, pady=4)
|
||
|
||
# 操作按钮
|
||
btn_frame = ttk.Frame(row)
|
||
btn_frame.grid(row=0, column=3, sticky="e", padx=(4, 2))
|
||
ttk.Button(btn_frame, text="📂",
|
||
command=lambda p=row_data["path"]: self._open_location_system(p)).pack(side="left", padx=1)
|
||
|
||
# 列权重
|
||
row.columnconfigure(0, weight=4)
|
||
row.columnconfigure(1, weight=3)
|
||
row.columnconfigure(2, weight=2)
|
||
row.columnconfigure(3, weight=0)
|
||
|
||
# 交互绑定(双击大图 / 右键菜单)
|
||
for child in [row] + list(row.children.values()):
|
||
child.bind("<Double-Button-1>",
|
||
lambda e, p=row_data["path"]: self._open_preview(p))
|
||
child.bind("<Button-3>",
|
||
lambda e, p=row_data["path"]: self._show_context_menu(e, p))
|
||
|
||
@staticmethod
|
||
def _format_size(size_bytes: int) -> str:
|
||
"""将字节数转为人类可读的字符串。"""
|
||
if size_bytes <= 0:
|
||
return "0 B"
|
||
s = float(size_bytes)
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if s < 1024:
|
||
return f"{s:.1f} {unit}"
|
||
s /= 1024
|
||
return f"{s:.1f} TB"
|
||
|
||
def _sort_list(self, col: str):
|
||
"""切换排序状态并重建列表视图。"""
|
||
if self._list_sort_col == col:
|
||
self._list_sort_rev = not self._list_sort_rev
|
||
else:
|
||
self._list_sort_col = col
|
||
self._list_sort_rev = False
|
||
self._rebuild_preview()
|
||
|
||
# ── 双击放大 ──────────────────────────────────────────────
|
||
|
||
def _open_preview(self, path: str):
|
||
"""双击打开大图预览窗口。"""
|
||
PreviewWindow(self.root, path)
|
||
|
||
# ── 右键菜单 ──────────────────────────────────────────────
|
||
|
||
def _show_context_menu(self, event, path: str):
|
||
"""右键弹出操作菜单。"""
|
||
menu = Menu(self.root, tearoff=False)
|
||
menu.add_command(label="打开 (系统默认)", command=lambda: self._open_file_system(path))
|
||
menu.add_command(label="打开文件所在位置", command=lambda: self._open_location_system(path))
|
||
menu.add_separator()
|
||
menu.add_command(label="复制文件路径", command=lambda: self._copy_path(path))
|
||
menu.add_separator()
|
||
menu.add_command(label="查看大图", command=lambda: self._open_preview(path))
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
|
||
@staticmethod
|
||
def _open_file_system(path: str):
|
||
"""用系统默认程序打开文件。"""
|
||
try:
|
||
os.startfile(path)
|
||
except AttributeError:
|
||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", path])
|
||
|
||
@staticmethod
|
||
def _open_location_system(path: str):
|
||
"""打开文件所在文件夹并选中文件。"""
|
||
p = os.path.abspath(path)
|
||
try:
|
||
subprocess.run(["explorer", "/select,", p])
|
||
except Exception:
|
||
try:
|
||
os.startfile(os.path.dirname(p))
|
||
except AttributeError:
|
||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", os.path.dirname(p)])
|
||
|
||
@staticmethod
|
||
def _copy_path(path: str) -> None:
|
||
"""复制文件路径到剪贴板。"""
|
||
cmd = path.replace("/", "\\") if sys.platform == "win32" else path
|
||
r = tk.Tk()
|
||
r.withdraw()
|
||
r.clipboard_clear()
|
||
r.clipboard_append(cmd)
|
||
r.update()
|
||
r.destroy()
|
||
|
||
# ── 打开输出文件夹 ───────────────────────────────────────
|
||
|
||
def _open_outdir(self) -> None:
|
||
if self._last_outdir and os.path.isdir(self._last_outdir):
|
||
try:
|
||
os.startfile(self._last_outdir)
|
||
except AttributeError:
|
||
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", self._last_outdir])
|
||
|
||
def _clear_preview(self) -> None:
|
||
"""清空预览区。"""
|
||
for w in self._preview_container.winfo_children():
|
||
w.destroy()
|
||
self._thumb_cells.clear()
|
||
self._on_container_configure()
|
||
|
||
# ── 关闭 ──────────────────────────────────────────────────
|
||
|
||
def _on_close(self) -> None:
|
||
self.root.destroy()
|
||
|
||
def run(self) -> None:
|
||
self.root.mainloop()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 入口
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
if __name__ == "__main__":
|
||
App().run() |