4097b529 by 柴进

:wrench: 重构构建流程 + :white_check_mark: 为 refresh_history 加细粒度诊断日志

构建流程 (修复 macOS 26 上 libtiff.6.dylib 未打包闪退):
- ZB100ImageGenerator.spec 成为跨平台构建配置唯一真相源
- 显式枚举 PIL/.dylibs/.libs 下的原生库并平铺到 bundle 根目录,
  匹配 PyInstaller 把 _imaging.so 的 @rpath 改写成 @loader_path/..
  (= Contents/Frameworks/) 的预期位置
- build_mac_universal.sh / build_windows.bat 不再删除 *.spec,
  调用简化为 `pyinstaller ZB100ImageGenerator.spec`
- 旧的 --collect-all PIL 方案保留了 .dylibs/ 目录结构, 跟 rpath
  预期位置不匹配, 治标不治本

诊断日志 (定位 refresh_history 的 SIGKILL 位置):
- 新增 _flush_logs() 模块级工具, 在可疑阶段边界强制刷盘
- load_history_index: 每 20 条打一次路径修正进度
- refresh_history: clear/load/loop 三段独立计时, 每 20 条打渲染进度
- 下次 macOS 卡死被 SIGKILL 时能精确定位死亡行

昨天 138ec9fa 的 thumb.jpg 缓存挡住了内存压力型 SIGKILL, 但今天
115 条时 refresh_history 仍卡 8 秒后被 WindowServer 强杀 (日志
只打了 "开始刷新" 就再无输出). 真正的架构修复 (取消 clear+rebuild
全量重绘) 留待下次定位清楚后再做.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 138ec9fa
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec for ZB100ImageGenerator.
跨平台构建配置的唯一真相源 (macOS + Windows)。
build_mac_universal.sh / build_windows.bat 只负责环境准备,
实际构建调用 `pyinstaller ZB100ImageGenerator.spec`。
修复点:
- macOS 上 PIL/_imaging.so 依赖 @rpath/libtiff.6.dylib 等原生库,
PyInstaller 会把 @rpath 改写为 @loader_path/.. (= Contents/Frameworks/),
因此 .dylibs/*.dylib 必须平铺到 bundle 根目录,而不是保留
PIL/.dylibs/ 结构 —— 否则 dlopen 在启动时失败。
"""
import sys
from pathlib import Path
IS_MAC = sys.platform == 'darwin'
IS_WIN = sys.platform == 'win32'
# ----- 图标 -----
if IS_MAC:
ICON = 'zb100_mac.icns'
elif IS_WIN:
ICON = 'zb100_windows.ico'
else:
ICON = None
# ----- 数据文件 -----
datas = [('config.json', '.')]
if IS_WIN:
datas.append(('zb100_windows.ico', '.'))
# ----- Pillow 原生库:平铺到 bundle 根 -----
pil_native_libs = []
try:
import PIL
pil_dir = Path(PIL.__file__).parent
for sub in ('.dylibs', '.libs'):
d = pil_dir / sub
if d.is_dir():
for lib in d.iterdir():
if lib.suffix.lower() in ('.dylib', '.so', '.dll'):
pil_native_libs.append((str(lib), '.'))
except Exception as e:
print(f'[spec] WARN 枚举 PIL 原生库失败: {e}')
print(f'[spec] 将 {len(pil_native_libs)} 个 PIL 原生库平铺到 bundle 根目录')
a = Analysis(
['image_generator.py'],
pathex=[],
binaries=[],
datas=[('config.json', '.')],
binaries=pil_native_libs,
datas=datas,
hiddenimports=[],
hookspath=[],
hooksconfig={},
......@@ -32,7 +78,7 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['zb100_mac.icns'],
icon=[ICON] if ICON else None,
)
coll = COLLECT(
exe,
......@@ -43,9 +89,11 @@ coll = COLLECT(
upx_exclude=[],
name='ZB100ImageGenerator',
)
app = BUNDLE(
if IS_MAC:
app = BUNDLE(
coll,
name='ZB100ImageGenerator.app',
icon='zb100_mac.icns',
icon=ICON,
bundle_identifier=None,
)
)
......
......@@ -66,19 +66,13 @@ pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
# 清理旧构建
# 清理旧构建 (保留 spec 文件,它是构建配置的唯一真相源)
echo "Cleaning previous builds..."
rm -rf build dist *.spec
rm -rf build dist
# 构建
# 构建 (所有配置都在 ZB100ImageGenerator.spec 里)
echo "Building executable..."
pyinstaller --name="ZB100ImageGenerator" \
--onedir \
--windowed \
--add-data "config.json:." \
--icon=zb100_mac.icns \
--collect-all PIL \
image_generator.py
pyinstaller ZB100ImageGenerator.spec
# 验证构建结果
if [ -d "dist/ZB100ImageGenerator.app" ]; then
......
......@@ -20,22 +20,14 @@ echo Installing dependencies...
pip install --upgrade pip
pip install -r requirements.txt
REM Clean previous builds
REM Clean previous builds (keep spec file - it's the single source of truth)
echo Cleaning previous builds...
if exist "build" rd /s /q build
if exist "dist" rd /s /q dist
if exist "*.spec" del /q *.spec
REM Build executable
REM Build executable (all config lives in ZB100ImageGenerator.spec)
echo Building executable...
pyinstaller --name="ZB100ImageGenerator" ^
--onedir ^
--windowed ^
--icon=zb100_windows.ico ^
--add-data "config.json;." ^
--add-data "zb100_windows.ico;." ^
--collect-all PIL ^
image_generator.py
pyinstaller ZB100ImageGenerator.spec
REM Check if build was successful
if exist "dist\ZB100ImageGenerator\ZB100ImageGenerator.exe" (
......
......@@ -41,6 +41,22 @@ from dataclasses import dataclass, asdict
from typing import List, Optional, Dict, Any
def _flush_logs() -> None:
"""强制刷盘所有日志 handlers.
macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失,
下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。
"""
try:
for h in logging.getLogger().handlers:
try:
h.flush()
except Exception:
pass
except Exception:
pass
def _cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int:
"""清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
......@@ -746,14 +762,20 @@ class HistoryManager:
return []
try:
self.logger.info("[load_history_index] 开始读取 index.json")
_flush_logs()
with open(self.history_index_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.logger.info(f"[load_history_index] JSON 解析完成, {len(data)} 条原始数据")
_flush_logs()
history_items = [HistoryItem.from_dict(item) for item in data]
total = len(history_items)
# 修正可能过期的绝对路径(存储迁移后旧路径不再有效)
# 每条 item 对每个 path 都要 stat; 这是 UI 主线程上可能拖死的点
needs_save = False
for item in history_items:
for idx, item in enumerate(history_items):
fixed_gen = self._fix_history_path(item.generated_image_path, item.timestamp)
if fixed_gen != item.generated_image_path:
item.generated_image_path = fixed_gen
......@@ -767,16 +789,27 @@ class HistoryManager:
fixed_refs.append(fixed_ref)
item.reference_image_paths = fixed_refs
if (idx + 1) % 20 == 0:
self.logger.info(f"[load_history_index] 路径修正进度 {idx + 1}/{total}")
_flush_logs()
self.logger.info(f"[load_history_index] 路径修正完成, needs_save={needs_save}")
_flush_logs()
# 路径修正后持久化,避免每次都修正
if needs_save:
self.logger.info("检测到历史记录路径变更,已自动修正")
self._save_history_index(history_items)
_flush_logs()
# 按时间戳倒序排列
history_items.sort(key=lambda x: x.timestamp, reverse=True)
self.logger.info(f"[load_history_index] 返回 {total} 条")
_flush_logs()
return history_items
except Exception as e:
print(f"加载历史记录索引失败: {e}")
self.logger.error(f"加载历史记录索引失败: {e}", exc_info=True)
_flush_logs()
return []
def get_history_item(self, timestamp: str) -> Optional[HistoryItem]:
......@@ -2913,12 +2946,24 @@ class ImageGeneratorWindow(QMainWindow):
def refresh_history(self):
"""Refresh the history list"""
import time as _time
t_start = _time.monotonic()
self.logger.info("[refresh_history] 开始刷新历史记录列表...")
_flush_logs()
t_clear = _time.monotonic()
self.history_list.clear()
self.logger.info(f"[refresh_history] clear() 完成 ({int((_time.monotonic() - t_clear) * 1000)}ms)")
_flush_logs()
t_load = _time.monotonic()
history_items = self.history_manager.load_history_index()
self.logger.info(f"[refresh_history] 加载到 {len(history_items)} 条历史记录")
total = len(history_items)
self.logger.info(f"[refresh_history] 加载到 {total} 条历史记录 ({int((_time.monotonic() - t_load) * 1000)}ms)")
_flush_logs()
for item in history_items:
t_loop = _time.monotonic()
for idx, item in enumerate(history_items):
# Create list item with icon
list_item = QListWidgetItem()
......@@ -2963,14 +3008,19 @@ class ImageGeneratorWindow(QMainWindow):
# Add to list
self.history_list.addItem(list_item)
if (idx + 1) % 20 == 0:
self.logger.info(f"[refresh_history] 渲染进度 {idx + 1}/{total} ({int((_time.monotonic() - t_loop) * 1000)}ms)")
_flush_logs()
# Update count label
self.history_count_label.setText(f"共 {len(history_items)} 条历史记录")
self.history_count_label.setText(f"共 {total} 条历史记录")
# Clear details panel if no items
if not history_items:
self.clear_details_panel()
self.logger.info(f"[refresh_history] 刷新完成,共渲染 {len(history_items)} 条")
self.logger.info(f"[refresh_history] 刷新完成,共渲染 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)")
_flush_logs()
def create_placeholder_icon(self, text):
"""Create a placeholder icon with text"""
......