#!/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("", self._drag_start) self._canvas.bind("", self._drag_move) self._canvas.bind("", self._on_wheel) # Windows self._canvas.bind("", self._on_wheel_linux) # Linux up self._canvas.bind("", 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("<>", 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("<>", 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("", self._on_container_configure) self._preview_canvas.bind("", 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("", 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("", lambda e, p=path: self._open_preview(p)) child.bind("", 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("", 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("", lambda e, p=row_data["path"]: self._open_preview(p)) child.bind("", 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()