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
1 # -*- mode: python ; coding: utf-8 -*- 1 # -*- mode: python ; coding: utf-8 -*-
2 """
3 PyInstaller spec for ZB100ImageGenerator.
2 4
5 跨平台构建配置的唯一真相源 (macOS + Windows)。
6 build_mac_universal.sh / build_windows.bat 只负责环境准备,
7 实际构建调用 `pyinstaller ZB100ImageGenerator.spec`。
8
9 修复点:
10 - macOS 上 PIL/_imaging.so 依赖 @rpath/libtiff.6.dylib 等原生库,
11 PyInstaller 会把 @rpath 改写为 @loader_path/.. (= Contents/Frameworks/),
12 因此 .dylibs/*.dylib 必须平铺到 bundle 根目录,而不是保留
13 PIL/.dylibs/ 结构 —— 否则 dlopen 在启动时失败。
14 """
15 import sys
16 from pathlib import Path
17
18 IS_MAC = sys.platform == 'darwin'
19 IS_WIN = sys.platform == 'win32'
20
21 # ----- 图标 -----
22 if IS_MAC:
23 ICON = 'zb100_mac.icns'
24 elif IS_WIN:
25 ICON = 'zb100_windows.ico'
26 else:
27 ICON = None
28
29 # ----- 数据文件 -----
30 datas = [('config.json', '.')]
31 if IS_WIN:
32 datas.append(('zb100_windows.ico', '.'))
33
34 # ----- Pillow 原生库:平铺到 bundle 根 -----
35 pil_native_libs = []
36 try:
37 import PIL
38 pil_dir = Path(PIL.__file__).parent
39 for sub in ('.dylibs', '.libs'):
40 d = pil_dir / sub
41 if d.is_dir():
42 for lib in d.iterdir():
43 if lib.suffix.lower() in ('.dylib', '.so', '.dll'):
44 pil_native_libs.append((str(lib), '.'))
45 except Exception as e:
46 print(f'[spec] WARN 枚举 PIL 原生库失败: {e}')
47
48 print(f'[spec] 将 {len(pil_native_libs)} 个 PIL 原生库平铺到 bundle 根目录')
3 49
4 a = Analysis( 50 a = Analysis(
5 ['image_generator.py'], 51 ['image_generator.py'],
6 pathex=[], 52 pathex=[],
7 binaries=[], 53 binaries=pil_native_libs,
8 datas=[('config.json', '.')], 54 datas=datas,
9 hiddenimports=[], 55 hiddenimports=[],
10 hookspath=[], 56 hookspath=[],
11 hooksconfig={}, 57 hooksconfig={},
...@@ -32,7 +78,7 @@ exe = EXE( ...@@ -32,7 +78,7 @@ exe = EXE(
32 target_arch=None, 78 target_arch=None,
33 codesign_identity=None, 79 codesign_identity=None,
34 entitlements_file=None, 80 entitlements_file=None,
35 icon=['zb100_mac.icns'], 81 icon=[ICON] if ICON else None,
36 ) 82 )
37 coll = COLLECT( 83 coll = COLLECT(
38 exe, 84 exe,
...@@ -43,9 +89,11 @@ coll = COLLECT( ...@@ -43,9 +89,11 @@ coll = COLLECT(
43 upx_exclude=[], 89 upx_exclude=[],
44 name='ZB100ImageGenerator', 90 name='ZB100ImageGenerator',
45 ) 91 )
46 app = BUNDLE( 92
93 if IS_MAC:
94 app = BUNDLE(
47 coll, 95 coll,
48 name='ZB100ImageGenerator.app', 96 name='ZB100ImageGenerator.app',
49 icon='zb100_mac.icns', 97 icon=ICON,
50 bundle_identifier=None, 98 bundle_identifier=None,
51 ) 99 )
......
...@@ -66,19 +66,13 @@ pip install --upgrade pip ...@@ -66,19 +66,13 @@ pip install --upgrade pip
66 pip install -r requirements.txt 66 pip install -r requirements.txt
67 pip install pyinstaller 67 pip install pyinstaller
68 68
69 # 清理旧构建 69 # 清理旧构建 (保留 spec 文件,它是构建配置的唯一真相源)
70 echo "Cleaning previous builds..." 70 echo "Cleaning previous builds..."
71 rm -rf build dist *.spec 71 rm -rf build dist
72 72
73 # 构建 73 # 构建 (所有配置都在 ZB100ImageGenerator.spec 里)
74 echo "Building executable..." 74 echo "Building executable..."
75 pyinstaller --name="ZB100ImageGenerator" \ 75 pyinstaller ZB100ImageGenerator.spec
76 --onedir \
77 --windowed \
78 --add-data "config.json:." \
79 --icon=zb100_mac.icns \
80 --collect-all PIL \
81 image_generator.py
82 76
83 # 验证构建结果 77 # 验证构建结果
84 if [ -d "dist/ZB100ImageGenerator.app" ]; then 78 if [ -d "dist/ZB100ImageGenerator.app" ]; then
......
...@@ -20,22 +20,14 @@ echo Installing dependencies... ...@@ -20,22 +20,14 @@ echo Installing dependencies...
20 pip install --upgrade pip 20 pip install --upgrade pip
21 pip install -r requirements.txt 21 pip install -r requirements.txt
22 22
23 REM Clean previous builds 23 REM Clean previous builds (keep spec file - it's the single source of truth)
24 echo Cleaning previous builds... 24 echo Cleaning previous builds...
25 if exist "build" rd /s /q build 25 if exist "build" rd /s /q build
26 if exist "dist" rd /s /q dist 26 if exist "dist" rd /s /q dist
27 if exist "*.spec" del /q *.spec
28 27
29 REM Build executable 28 REM Build executable (all config lives in ZB100ImageGenerator.spec)
30 echo Building executable... 29 echo Building executable...
31 pyinstaller --name="ZB100ImageGenerator" ^ 30 pyinstaller ZB100ImageGenerator.spec
32 --onedir ^
33 --windowed ^
34 --icon=zb100_windows.ico ^
35 --add-data "config.json;." ^
36 --add-data "zb100_windows.ico;." ^
37 --collect-all PIL ^
38 image_generator.py
39 31
40 REM Check if build was successful 32 REM Check if build was successful
41 if exist "dist\ZB100ImageGenerator\ZB100ImageGenerator.exe" ( 33 if exist "dist\ZB100ImageGenerator\ZB100ImageGenerator.exe" (
......
...@@ -41,6 +41,22 @@ from dataclasses import dataclass, asdict ...@@ -41,6 +41,22 @@ from dataclasses import dataclass, asdict
41 from typing import List, Optional, Dict, Any 41 from typing import List, Optional, Dict, Any
42 42
43 43
44 def _flush_logs() -> None:
45 """强制刷盘所有日志 handlers.
46
47 macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失,
48 下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。
49 """
50 try:
51 for h in logging.getLogger().handlers:
52 try:
53 h.flush()
54 except Exception:
55 pass
56 except Exception:
57 pass
58
59
44 def _cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int: 60 def _cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int:
45 """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。 61 """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
46 62
...@@ -746,14 +762,20 @@ class HistoryManager: ...@@ -746,14 +762,20 @@ class HistoryManager:
746 return [] 762 return []
747 763
748 try: 764 try:
765 self.logger.info("[load_history_index] 开始读取 index.json")
766 _flush_logs()
749 with open(self.history_index_file, 'r', encoding='utf-8') as f: 767 with open(self.history_index_file, 'r', encoding='utf-8') as f:
750 data = json.load(f) 768 data = json.load(f)
769 self.logger.info(f"[load_history_index] JSON 解析完成, {len(data)} 条原始数据")
770 _flush_logs()
751 771
752 history_items = [HistoryItem.from_dict(item) for item in data] 772 history_items = [HistoryItem.from_dict(item) for item in data]
773 total = len(history_items)
753 774
754 # 修正可能过期的绝对路径(存储迁移后旧路径不再有效) 775 # 修正可能过期的绝对路径(存储迁移后旧路径不再有效)
776 # 每条 item 对每个 path 都要 stat; 这是 UI 主线程上可能拖死的点
755 needs_save = False 777 needs_save = False
756 for item in history_items: 778 for idx, item in enumerate(history_items):
757 fixed_gen = self._fix_history_path(item.generated_image_path, item.timestamp) 779 fixed_gen = self._fix_history_path(item.generated_image_path, item.timestamp)
758 if fixed_gen != item.generated_image_path: 780 if fixed_gen != item.generated_image_path:
759 item.generated_image_path = fixed_gen 781 item.generated_image_path = fixed_gen
...@@ -767,16 +789,27 @@ class HistoryManager: ...@@ -767,16 +789,27 @@ class HistoryManager:
767 fixed_refs.append(fixed_ref) 789 fixed_refs.append(fixed_ref)
768 item.reference_image_paths = fixed_refs 790 item.reference_image_paths = fixed_refs
769 791
792 if (idx + 1) % 20 == 0:
793 self.logger.info(f"[load_history_index] 路径修正进度 {idx + 1}/{total}")
794 _flush_logs()
795
796 self.logger.info(f"[load_history_index] 路径修正完成, needs_save={needs_save}")
797 _flush_logs()
798
770 # 路径修正后持久化,避免每次都修正 799 # 路径修正后持久化,避免每次都修正
771 if needs_save: 800 if needs_save:
772 self.logger.info("检测到历史记录路径变更,已自动修正") 801 self.logger.info("检测到历史记录路径变更,已自动修正")
773 self._save_history_index(history_items) 802 self._save_history_index(history_items)
803 _flush_logs()
774 804
775 # 按时间戳倒序排列 805 # 按时间戳倒序排列
776 history_items.sort(key=lambda x: x.timestamp, reverse=True) 806 history_items.sort(key=lambda x: x.timestamp, reverse=True)
807 self.logger.info(f"[load_history_index] 返回 {total} 条")
808 _flush_logs()
777 return history_items 809 return history_items
778 except Exception as e: 810 except Exception as e:
779 print(f"加载历史记录索引失败: {e}") 811 self.logger.error(f"加载历史记录索引失败: {e}", exc_info=True)
812 _flush_logs()
780 return [] 813 return []
781 814
782 def get_history_item(self, timestamp: str) -> Optional[HistoryItem]: 815 def get_history_item(self, timestamp: str) -> Optional[HistoryItem]:
...@@ -2913,12 +2946,24 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2913,12 +2946,24 @@ class ImageGeneratorWindow(QMainWindow):
2913 2946
2914 def refresh_history(self): 2947 def refresh_history(self):
2915 """Refresh the history list""" 2948 """Refresh the history list"""
2949 import time as _time
2950 t_start = _time.monotonic()
2916 self.logger.info("[refresh_history] 开始刷新历史记录列表...") 2951 self.logger.info("[refresh_history] 开始刷新历史记录列表...")
2952 _flush_logs()
2953
2954 t_clear = _time.monotonic()
2917 self.history_list.clear() 2955 self.history_list.clear()
2956 self.logger.info(f"[refresh_history] clear() 完成 ({int((_time.monotonic() - t_clear) * 1000)}ms)")
2957 _flush_logs()
2958
2959 t_load = _time.monotonic()
2918 history_items = self.history_manager.load_history_index() 2960 history_items = self.history_manager.load_history_index()
2919 self.logger.info(f"[refresh_history] 加载到 {len(history_items)} 条历史记录") 2961 total = len(history_items)
2962 self.logger.info(f"[refresh_history] 加载到 {total} 条历史记录 ({int((_time.monotonic() - t_load) * 1000)}ms)")
2963 _flush_logs()
2920 2964
2921 for item in history_items: 2965 t_loop = _time.monotonic()
2966 for idx, item in enumerate(history_items):
2922 # Create list item with icon 2967 # Create list item with icon
2923 list_item = QListWidgetItem() 2968 list_item = QListWidgetItem()
2924 2969
...@@ -2963,14 +3008,19 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2963,14 +3008,19 @@ class ImageGeneratorWindow(QMainWindow):
2963 # Add to list 3008 # Add to list
2964 self.history_list.addItem(list_item) 3009 self.history_list.addItem(list_item)
2965 3010
3011 if (idx + 1) % 20 == 0:
3012 self.logger.info(f"[refresh_history] 渲染进度 {idx + 1}/{total} ({int((_time.monotonic() - t_loop) * 1000)}ms)")
3013 _flush_logs()
3014
2966 # Update count label 3015 # Update count label
2967 self.history_count_label.setText(f"共 {len(history_items)} 条历史记录") 3016 self.history_count_label.setText(f"共 {total} 条历史记录")
2968 3017
2969 # Clear details panel if no items 3018 # Clear details panel if no items
2970 if not history_items: 3019 if not history_items:
2971 self.clear_details_panel() 3020 self.clear_details_panel()
2972 3021
2973 self.logger.info(f"[refresh_history] 刷新完成,共渲染 {len(history_items)} 条") 3022 self.logger.info(f"[refresh_history] 刷新完成,共渲染 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)")
3023 _flush_logs()
2974 3024
2975 def create_placeholder_icon(self, text): 3025 def create_placeholder_icon(self, text):
2976 """Create a placeholder icon with text""" 3026 """Create a placeholder icon with text"""
......