# -*- coding: utf-8 -*- """ HEIF 转文件格式 - 批量转换工具 三松强哥出品,必属精品。2027年5月前可用,过期后静默禁用,不提示。 """import os import sys from datetime import date import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext from pathlib import Path from typing import List, Tuple# 过期日期:2027年5月1日起不可用(不提示,全部禁用) EXPIRY_DATE = date(2027, 5, 1)def _get_app_base_dir() -> str:"""程序所在目录:脚本运行时为脚本目录,打包 exe 后为 exe 所在目录。"""if getattr(sys, "frozen", False):base = os.path.dirname(sys.executable)else:base = os.path.dirname(os.path.abspath(__file__))return base if base else os.getcwd()def _get_watermark_path() -> Path:"""用于防改时间的记录文件路径(AppData)。"""appdata = os.environ.get("APPDATA", "")if not appdata:appdata = os.path.expanduser("~")folder = Path(appdata) / "HEIFtoPIC_SSQG"folder.mkdir(parents=True, exist_ok=True)return folder / "dt"def _is_expired() -> bool:"""是否已过期。2027年5月及之后视为过期;若检测到本地时间被回拨(曾记录过更大日期),也视为过期。过期不弹窗、不提示,仅返回 True,由调用方禁用界面。"""today = date.today()if today >= EXPIRY_DATE:return Truepath = _get_watermark_path()max_seen = Nonetry:if path.exists():raw = path.read_text().strip()if raw:max_seen = date.fromisoformat(raw)except Exception:passif max_seen is not None and today < max_seen:return Truenew_max = max(today, max_seen) if max_seen else todaytry:path.write_text(new_max.isoformat())except Exception:passreturn False# 延迟导入,便于在无 GUI 时做模块检查 try:from PIL import Imagefrom pillow_heif import register_heif_opener except ImportError as e:print("请先安装依赖: pip install -r requirements.txt")raise SystemExit(1) from e# 注册 HEIF 解码器,使 Pillow 能打开 .heic/.heif register_heif_opener()# 支持的 HEIF 输入扩展名 HEIF_EXTENSIONS = (".heic", ".heif") # 目标格式及对应扩展名 TARGET_FORMATS = [("JPEG", ".jpg"),("PNG", ".png"), ]def find_heif_files(folder: str) -> List[Path]:"""递归扫描文件夹及所有子目录中的 HEIF/HEIC 文件。"""folder_path = Path(folder).resolve()if not folder_path.is_dir():return []files = []for f in folder_path.rglob("*"):if f.is_file() and f.suffix.lower() in HEIF_EXTENSIONS:files.append(f)return sorted(files)def convert_one(src_path: Path,out_folder: str,target_ext: str,target_format: str,input_root: str | Path | None = None, ) -> Tuple[bool, str]:"""转换单个 HEIF 文件。若提供 input_root,则按相对路径在 out_folder 下保持相同目录结构;否则输出到 out_folder 根目录。返回 (是否成功, 消息)。"""try:out_base = Path(out_folder).resolve()if input_root is not None:input_root = Path(input_root).resolve()rel = src_path.resolve().relative_to(input_root)out_path = out_base / rel.parent / (rel.stem + target_ext)out_path.parent.mkdir(parents=True, exist_ok=True)else:out_path = out_base / (src_path.stem + target_ext)img = Image.open(src_path)if img.mode in ("RGBA", "P") and target_format == "JPEG":img = img.convert("RGB")img.save(out_path, format=target_format, quality=95)if input_root is not None:return True, f"成功: {rel} -> {out_path.relative_to(out_base)}"return True, f"成功: {src_path.name} -> {out_path.name}"except Exception as e:return False, f"失败: {src_path} - {type(e).__name__}: {e}"class HeifConverterApp:"""主窗口:三松强哥出品,必属精品。过期后静默禁用,不提示。"""def __init__(self):self.root = tk.Tk()self.root.title("HEIF 转文件格式 批量转换工具 - 三松强哥出品,必属精品")self.root.geometry("620x460")self.root.resizable(True, True)self.root.configure(bg="#f0f0f0")self.expired = _is_expired()self.failed_list: List[Path] = []self._last_input_folder: str | None = Noneself._default_dir = _get_app_base_dir()self._build_ui()if self.expired:self._disable_all()def _build_ui(self):main = ttk.Frame(self.root, padding="12 8")main.pack(fill=tk.BOTH, expand=True)# ---------- 品牌字样 ----------brand = ttk.Label(main, text="三松强哥出品,必属精品", font=("Microsoft YaHei", 10, "bold"))brand.pack(anchor=tk.W, pady=(0, 8))# ---------- 选择要转换的图片文件夹(默认当前/程序所在目录) ----------row1 = ttk.Frame(main)row1.pack(fill=tk.X, pady=(0, 6))ttk.Label(row1, text="选择要转换的图片文件夹:").pack(side=tk.LEFT, padx=(0, 8))self.input_var = tk.StringVar(value=self._default_dir)self.entry_input = ttk.Entry(row1, textvariable=self.input_var, width=50)self.entry_input.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))self.btn_browse_in = ttk.Button(row1, text="浏览", command=self._browse_input)self.btn_browse_in.pack(side=tk.LEFT)# ---------- 选择转换后的图片文件夹(默认当前/程序所在目录) ----------row2 = ttk.Frame(main)row2.pack(fill=tk.X, pady=(0, 6))ttk.Label(row2, text="选择转换后的图片文件夹:").pack(side=tk.LEFT, padx=(0, 8))self.output_var = tk.StringVar(value=self._default_dir)self.entry_output = ttk.Entry(row2, textvariable=self.output_var, width=50)self.entry_output.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))self.btn_browse_out = ttk.Button(row2, text="浏览", command=self._browse_output)self.btn_browse_out.pack(side=tk.LEFT)# ---------- 目标格式 ----------row3 = ttk.Frame(main)row3.pack(fill=tk.X, pady=(0, 8))ttk.Label(row3, text="目标格式:").pack(side=tk.LEFT, padx=(0, 8))self.format_var = tk.StringVar(value="JPEG")self.combo = ttk.Combobox(row3,textvariable=self.format_var,values=[f[0] for f in TARGET_FORMATS],state="readonly",width=12,)self.combo.pack(side=tk.LEFT)# ---------- 状态/日志区域 ----------log_frame = ttk.LabelFrame(main, text="转换状态与日志", padding=4)log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 8))self.log_text = scrolledtext.ScrolledText(log_frame,height=12,wrap=tk.WORD,font=("Consolas", 9),state=tk.DISABLED,)self.log_text.pack(fill=tk.BOTH, expand=True)# ---------- 按钮 ----------btn_frame = ttk.Frame(main)btn_frame.pack(fill=tk.X)self.btn_start = ttk.Button(btn_frame, text="开始转换", command=self._start_convert)self.btn_start.pack(side=tk.LEFT, padx=(0, 12))self.btn_retry = ttk.Button(btn_frame, text="重试转换失败文件", command=self._retry_failed)self.btn_retry.pack(side=tk.LEFT)def _disable_all(self):"""过期时禁用所有可操作控件,不弹任何提示。"""self.entry_input.configure(state=tk.DISABLED)self.entry_output.configure(state=tk.DISABLED)self.combo.configure(state=tk.DISABLED)self.btn_browse_in.configure(state=tk.DISABLED)self.btn_browse_out.configure(state=tk.DISABLED)self.btn_start.configure(state=tk.DISABLED)self.btn_retry.configure(state=tk.DISABLED)def _log(self, msg: str):"""在日志区追加一行并滚动到底。"""self.log_text.configure(state=tk.NORMAL)self.log_text.insert(tk.END, msg + "\n")self.log_text.see(tk.END)self.log_text.configure(state=tk.DISABLED)self.root.update_idletasks()def _browse_input(self):path = filedialog.askdirectory(title="选择要转换的图片文件夹")if path:self.input_var.set(path)def _browse_output(self):path = filedialog.askdirectory(title="选择转换后的图片文件夹")if path:self.output_var.set(path)def _get_target_format_and_ext(self) -> Tuple[str, str]:name = self.format_var.get().strip().upper()for fmt, ext in TARGET_FORMATS:if fmt == name:return fmt, extreturn "JPEG", ".jpg"def _start_convert(self):inp = self.input_var.get().strip()out = self.output_var.get().strip()if not inp:messagebox.showwarning("提示", "请先选择要转换的图片文件夹。")returnif not out:messagebox.showwarning("提示", "请先选择转换后的图片保存文件夹。")returnif not os.path.isdir(inp):messagebox.showerror("错误", f"输入文件夹不存在:{inp}")returnif not os.path.isdir(out):messagebox.showerror("错误", f"输出文件夹不存在:{out}")returntarget_format, target_ext = self._get_target_format_and_ext()files = find_heif_files(inp)if not files:self._log("未在输入文件夹中发现 HEIF/HEIC 文件。")messagebox.showinfo("提示", "该文件夹中没有找到 .heic 或 .heif 文件。")returnself.failed_list.clear()self._last_input_folder = inpself._log(f"开始转换(含子目录),共 {len(files)} 个文件,目标格式: {target_format}")ok_count = 0for src in files:success, msg = convert_one(src, out, target_ext, target_format, input_root=inp)self._log(msg)if success:ok_count += 1else:self.failed_list.append(src)self._log(f"完成。成功: {ok_count},失败: {len(self.failed_list)}")if self.failed_list:messagebox.showinfo("转换结束",f"成功 {ok_count} 个,失败 {len(self.failed_list)} 个。可点击「重试转换失败文件」重试。",)else:messagebox.showinfo("转换结束", f"全部完成,共转换 {ok_count} 个文件。")def _retry_failed(self):if not self.failed_list:self._log("当前没有失败记录,请先执行一次「开始转换」。")messagebox.showinfo("提示", "没有需要重试的失败文件。")returnout = self.output_var.get().strip()if not out or not os.path.isdir(out):messagebox.showwarning("提示", "请先选择有效的「转换后的图片文件夹」。")returntarget_format, target_ext = self._get_target_format_and_ext()input_root = getattr(self, "_last_input_folder", None)self._log("--- 重试转换失败文件 ---")still_failed = []for src in self.failed_list:success, msg = convert_one(src, out, target_ext, target_format, input_root=input_root)self._log(msg)if not success:still_failed.append(src)self.failed_list = still_failedself._log(f"重试完成。仍失败: {len(still_failed)} 个")if not still_failed:messagebox.showinfo("重试完成", "失败文件已全部转换成功。")def run(self):self.root.mainloop()def main():app = HeifConverterApp()app.run()if __name__ == "__main__":main()
