# -*- 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 True
path = _get_watermark_path()
max_seen = None
try:
if path.exists():
raw = path.read_text().strip()
if raw:
max_seen = date.fromisoformat(raw)
except Exception:
pass
if max_seen is not None and today < max_seen:
return True
new_max = max(today, max_seen) if max_seen else today
try:
path.write_text(new_max.isoformat())
except Exception:
pass
return False
# 延迟导入,便于在无 GUI 时做模块检查
try:
from PIL import Image
from 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 = None
self._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, ext
return "JPEG", ".jpg"
def _start_convert(self):
inp = self.input_var.get().strip()
out = self.output_var.get().strip()
if not inp:
messagebox.showwarning("提示", "请先选择要转换的图片文件夹。")
return
if not out:
messagebox.showwarning("提示", "请先选择转换后的图片保存文件夹。")
return
if not os.path.isdir(inp):
messagebox.showerror("错误", f"输入文件夹不存在:{inp}")
return
if not os.path.isdir(out):
messagebox.showerror("错误", f"输出文件夹不存在:{out}")
return
target_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 文件。")
return
self.failed_list.clear()
self._last_input_folder = inp
self._log(f"开始转换(含子目录),共 {len(files)} 个文件,目标格式: {target_format}")
ok_count = 0
for src in files:
success, msg = convert_one(src, out, target_ext, target_format, input_root=inp)
self._log(msg)
if success:
ok_count += 1
else:
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("提示", "没有需要重试的失败文件。")
return
out = self.output_var.get().strip()
if not out or not os.path.isdir(out):
messagebox.showwarning("提示", "请先选择有效的「转换后的图片文件夹」。")
return
target_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_failed
self._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()