ready to use

This commit is contained in:
Julian Freeman
2025-08-11 00:35:05 -04:00
parent 5c63767dc1
commit 276ff9e227
3 changed files with 514 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@@ -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

333
main.py Normal file
View File

@@ -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.UserCommentPNG 写入 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()

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
pyside6-essentials
Pillow
piexif