diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fc4307 --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + + +category_names.json diff --git a/main.py b/main.py new file mode 100644 index 0000000..c7618d2 --- /dev/null +++ b/main.py @@ -0,0 +1,333 @@ +#!/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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..736e910 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pyside6-essentials +Pillow +piexif