Files
yexuejc 8dc42a85c7 VideoSnapper — 视频截图工具
- 两种截图模式:平均截图 / 按间隔截图
- 预览支持:网格/列表模式切换
- 列表模式:表格视图,支持按名称/分辨率/大小排序
- 双击放大、右键上下文菜单
- 可视化界面,PyInstaller 打包
2026-07-27 09:13:54 +00:00

893 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()