重构构建流程 +
为 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>
Showing
4 changed files
with
117 additions
and
33 deletions
| 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""" | ... | ... |
-
Please register or sign in to post a comment