334 lines
12 KiB
Python
334 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# coding: utf-8
|
||
|
||
import os
|
||
import json
|
||
import sys
|
||
from functools import partial
|
||
|
||
from PySide6 import QtCore, QtGui, QtWidgets
|
||
from PIL import Image, PngImagePlugin, ImageQt
|
||
import piexif
|
||
|
||
# ============ 配置区 ============
|
||
PROJECT_COLUMNS = ["Runway", "Hailuo", "Kling", "Dreamina", "Vidu", "Pixverse"] # 自定义列名
|
||
THUMBNAIL_SIZE = (320, 240) # 缩略图大小(宽, 高)
|
||
# ==============================
|
||
|
||
|
||
def load_notes_from_image(path):
|
||
"""从图片中读取 JSON 格式的备注(如果存在),返回 dict;否则返回 {}"""
|
||
try:
|
||
ext = path.lower().split(".")[-1]
|
||
img = Image.open(path)
|
||
if ext in ("jpg", "jpeg"):
|
||
exif_bytes = img.info.get("exif")
|
||
if exif_bytes:
|
||
try:
|
||
exif = piexif.load(exif_bytes)
|
||
raw = exif.get("Exif", {}).get(piexif.ExifIFD.UserComment)
|
||
if raw:
|
||
# piexif UserComment 通常有两个前缀字节,照处理
|
||
if isinstance(raw, bytes) and raw.startswith(b'\x00\x00'):
|
||
raw = raw[2:]
|
||
if isinstance(raw, bytes):
|
||
text = raw.decode("utf-8", errors="ignore")
|
||
else:
|
||
text = str(raw)
|
||
return json.loads(text) if text else {}
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
elif ext == "png":
|
||
# Pillow 将 text chunks 放在 img.info
|
||
note = img.info.get("notes") or img.info.get("Notes") or img.info.get("text") or img.info.get("TEXT")
|
||
if note:
|
||
try:
|
||
return json.loads(note)
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
else:
|
||
return {}
|
||
except Exception as e:
|
||
print(f"[load_notes_from_image] 读取失败 {path}: {e}")
|
||
return {}
|
||
|
||
|
||
def save_notes_to_image(path, note_dict):
|
||
"""把 note_dict (python dict) 写入图片文件:
|
||
JPEG -> Exif.UserComment
|
||
PNG -> tEXt 'notes'
|
||
"""
|
||
try:
|
||
ext = path.lower().split(".")[-1]
|
||
note_json = json.dumps(note_dict, ensure_ascii=False)
|
||
if ext in ("jpg", "jpeg"):
|
||
img = Image.open(path)
|
||
# 获取已有 exif,如果没有就创建结构
|
||
try:
|
||
exif_dict = piexif.load(img.info.get("exif", b""))
|
||
except Exception:
|
||
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "Interop": {}, "thumbnail": None}
|
||
# 写入 UserComment(前两个 0 字节用来代表编码,保持之前的写法)
|
||
exif_dict.setdefault("Exif", {})
|
||
exif_dict["Exif"][piexif.ExifIFD.UserComment] = b'\x00\x00' + note_json.encode("utf-8")
|
||
exif_bytes = piexif.dump(exif_dict)
|
||
# 覆盖保存 jpeg(注意:会重新压缩图片)
|
||
img_rgb = img.convert("RGB") # 确保jpeg格式
|
||
img_rgb.save(path, "jpeg", exif=exif_bytes)
|
||
elif ext == "png":
|
||
img = Image.open(path)
|
||
meta = PngImagePlugin.PngInfo()
|
||
# 添加文本 chunk
|
||
meta.add_text("notes", note_json)
|
||
# 保存时保留原模式(注意:这也会重新写文件)
|
||
img.save(path, "png", pnginfo=meta)
|
||
else:
|
||
raise ValueError("不支持的图片格式")
|
||
return True, None
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
class NoteEditor(QtWidgets.QPlainTextEdit):
|
||
"""
|
||
自定义编辑器:
|
||
- Shift+Enter 插入换行
|
||
- Enter (无 Shift) 触发保存(emit save_requested 信号)
|
||
"""
|
||
save_requested = QtCore.Signal()
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
# 可选:设置合适的样式/行高
|
||
self.setTabChangesFocus(False)
|
||
self.setPlaceholderText("按 Enter 保存(Shift+Enter 换行)")
|
||
|
||
def keyPressEvent(self, event: QtGui.QKeyEvent):
|
||
if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
|
||
# 如果按下 Shift,则插入换行
|
||
if event.modifiers() & QtCore.Qt.ShiftModifier:
|
||
super().keyPressEvent(event)
|
||
return
|
||
# 否则触发保存信号(并阻止默认换行)
|
||
# 但先让文档内容同步(光标位置等)
|
||
event.accept()
|
||
# 发出保存信号(外部会连接并执行保存操作)
|
||
self.save_requested.emit()
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
|
||
class ImageRowWidget(QtWidgets.QWidget):
|
||
"""每一行的组合组件:缩略图 + 多个 NoteEditor"""
|
||
def __init__(self, folder_path, filename, projects, thumbnail_size):
|
||
super().__init__()
|
||
self.folder_path = folder_path
|
||
self.filename = filename
|
||
self.path = os.path.join(folder_path, filename)
|
||
self.projects = projects
|
||
|
||
h = QtWidgets.QHBoxLayout()
|
||
h.setContentsMargins(4, 4, 4, 4)
|
||
h.setSpacing(8)
|
||
|
||
# 缩略图
|
||
self.thumb_label = QtWidgets.QLabel()
|
||
self.thumb_label.setFixedSize(thumbnail_size[0], thumbnail_size[1])
|
||
self.thumb_label.setAlignment(QtCore.Qt.AlignCenter)
|
||
pix = self._load_thumbnail(self.path, thumbnail_size)
|
||
if pix:
|
||
self.thumb_label.setPixmap(pix)
|
||
else:
|
||
self.thumb_label.setText("无法加载")
|
||
h.addWidget(self.thumb_label)
|
||
|
||
# 读取已有元数据
|
||
self.note_dict = load_notes_from_image(self.path)
|
||
|
||
# editors 按项目顺序
|
||
self.editors = {}
|
||
for proj in projects:
|
||
editor = NoteEditor()
|
||
# 初始化内容(如果没有该项目,则为空)
|
||
editor.setPlainText(self.note_dict.get(proj, ""))
|
||
editor.setMinimumHeight(80)
|
||
# 当触发保存信号时,调用保存回调(只保存当前图片)
|
||
editor.save_requested.connect(partial(self._on_save_requested))
|
||
self.editors[proj] = editor
|
||
h.addWidget(editor, 1)
|
||
|
||
self.setLayout(h)
|
||
|
||
def _load_thumbnail(self, image_path, size):
|
||
"""用 PIL 创建缩略并转为 QPixmap"""
|
||
try:
|
||
img = Image.open(image_path)
|
||
img.thumbnail(size, Image.LANCZOS)
|
||
# 转为Qt pixmap
|
||
qim = ImageQt.ImageQt(img.convert("RGBA"))
|
||
pix = QtGui.QPixmap.fromImage(qim)
|
||
return pix
|
||
except Exception as e:
|
||
print(f"[thumbnail] {image_path} load fail: {e}")
|
||
return None
|
||
|
||
def collect_notes(self):
|
||
"""收集当前编辑器里所有项目字段,返回 dict"""
|
||
d = {}
|
||
for proj, editor in self.editors.items():
|
||
text = editor.toPlainText().strip()
|
||
d[proj] = text
|
||
return d
|
||
|
||
@QtCore.Slot()
|
||
def _on_save_requested(self):
|
||
"""当某个编辑器触发保存时,写入该图片的元数据"""
|
||
notes = self.collect_notes()
|
||
success, err = save_notes_to_image(self.path, notes)
|
||
if success:
|
||
# 简单的视觉反馈:临时背景闪烁或状态提示
|
||
for editor in self.editors.values():
|
||
# 设置短暂背景色
|
||
editor.setStyleSheet("background-color: #e6ffe6;")
|
||
QtCore.QTimer.singleShot(350, self._clear_editor_styles)
|
||
else:
|
||
# 弹出错误提示框
|
||
QtWidgets.QMessageBox.warning(self, "保存失败", f"图片 {self.filename} 写入失败:\n{err}")
|
||
|
||
def _clear_editor_styles(self):
|
||
for editor in self.editors.values():
|
||
editor.setStyleSheet("")
|
||
|
||
|
||
class MainWindow(QtWidgets.QMainWindow):
|
||
def __init__(self, folder, projects):
|
||
super().__init__()
|
||
# ...existing code...
|
||
self.folder = folder
|
||
self.projects = projects
|
||
self.setWindowTitle("简易记录提示词")
|
||
self.resize(1500, 800)
|
||
|
||
central = QtWidgets.QWidget()
|
||
vbox = QtWidgets.QVBoxLayout()
|
||
central.setLayout(vbox)
|
||
self.setCentralWidget(central)
|
||
|
||
# ======= 修改:图片目录选择栏 =======
|
||
dir_widget = QtWidgets.QWidget()
|
||
dir_layout = QtWidgets.QHBoxLayout()
|
||
dir_layout.setContentsMargins(4, 4, 4, 4)
|
||
dir_widget.setLayout(dir_layout)
|
||
|
||
self.dir_edit = QtWidgets.QLineEdit(self.folder)
|
||
self.dir_edit.setPlaceholderText("输入或选择图片目录路径")
|
||
dir_layout.addWidget(QtWidgets.QLabel("图片目录:"))
|
||
dir_layout.addWidget(self.dir_edit, 1)
|
||
|
||
select_btn = QtWidgets.QPushButton("选择目录")
|
||
select_btn.clicked.connect(self._on_select_folder)
|
||
dir_layout.addWidget(select_btn)
|
||
|
||
vbox.addWidget(dir_widget)
|
||
# ======= 修改结束 =======
|
||
|
||
# 顶部说明栏
|
||
instr = QtWidgets.QLabel(
|
||
"说明:在任意单元格中编辑,按 Enter(不按 Shift)保存该图片的所有字段;"
|
||
"Shift+Enter 插入换行。JPEG 写入 Exif.UserComment;PNG 写入 tEXt 'notes'。"
|
||
)
|
||
instr.setWordWrap(True)
|
||
vbox.addWidget(instr)
|
||
|
||
# 标题行(缩略 + 每个项目列名)
|
||
header = QtWidgets.QWidget()
|
||
header_h = QtWidgets.QHBoxLayout()
|
||
header_h.setContentsMargins(4, 4, 4, 4)
|
||
header.setLayout(header_h)
|
||
thumb_title = QtWidgets.QLabel("缩略")
|
||
thumb_title.setFixedSize(THUMBNAIL_SIZE[0], 30)
|
||
thumb_title.setAlignment(QtCore.Qt.AlignCenter)
|
||
header_h.addWidget(thumb_title)
|
||
for proj in projects:
|
||
lbl = QtWidgets.QLabel(proj)
|
||
lbl.setMinimumHeight(30)
|
||
lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
header_h.addWidget(lbl)
|
||
vbox.addWidget(header)
|
||
|
||
# Scroll area for rows
|
||
scroll = QtWidgets.QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
container = QtWidgets.QWidget()
|
||
self.rows_layout = QtWidgets.QVBoxLayout()
|
||
container.setLayout(self.rows_layout)
|
||
scroll.setWidget(container)
|
||
vbox.addWidget(scroll)
|
||
|
||
# Load images and populate
|
||
if self.folder and os.path.isdir(self.folder):
|
||
self._load_images()
|
||
|
||
# 状态栏
|
||
self.status = QtWidgets.QStatusBar()
|
||
self.setStatusBar(self.status)
|
||
|
||
def _load_images(self):
|
||
# 清空
|
||
for i in reversed(range(self.rows_layout.count())):
|
||
w = self.rows_layout.itemAt(i).widget()
|
||
if w:
|
||
w.setParent(None)
|
||
|
||
# 修复:仅在目录有效时加载图片,否则直接返回
|
||
if not self.folder or not os.path.isdir(self.folder):
|
||
return
|
||
|
||
# 获取图片列表(按文件名排序)
|
||
try:
|
||
files = [f for f in os.listdir(self.folder)
|
||
if f.lower().endswith((".jpg", ".jpeg", ".png"))]
|
||
files.sort()
|
||
except Exception as e:
|
||
QtWidgets.QMessageBox.critical(self, "错误", f"无法读取文件夹 {self.folder}:\n{e}")
|
||
return
|
||
|
||
if not files:
|
||
lbl = QtWidgets.QLabel("文件夹中没有 JPEG/PNG 图片")
|
||
self.rows_layout.addWidget(lbl)
|
||
return
|
||
|
||
for fname in files:
|
||
row = ImageRowWidget(self.folder, fname, self.projects, THUMBNAIL_SIZE)
|
||
self.rows_layout.addWidget(row)
|
||
|
||
# 填充底部伸缩项
|
||
self.rows_layout.addStretch(1)
|
||
|
||
def _on_select_folder(self):
|
||
"""弹出目录选择对话框,选择后填充并加载图片"""
|
||
dlg = QtWidgets.QFileDialog(self, "选择图片目录")
|
||
dlg.setFileMode(QtWidgets.QFileDialog.Directory)
|
||
dlg.setOption(QtWidgets.QFileDialog.ShowDirsOnly, True)
|
||
if dlg.exec():
|
||
selected = dlg.selectedFiles()
|
||
if selected:
|
||
folder = selected[0]
|
||
self.dir_edit.setText(folder)
|
||
self.folder = folder
|
||
self._load_images()
|
||
|
||
|
||
def main():
|
||
app = QtWidgets.QApplication(sys.argv)
|
||
# 默认目录可设为空字符串或当前目录
|
||
win = MainWindow("", PROJECT_COLUMNS)
|
||
win.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|