From 57e465e669904dcd90b978a1b6db417fe7675731 Mon Sep 17 00:00:00 2001 From: Kerro Date: Tue, 18 Aug 2026 14:29:43 +0800 Subject: [PATCH 1/3] =?UTF-8?q?perf(memory):=20LB-23=20cacheWidth=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94gallery=20tile(=E5=9B=BE/?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E7=BC=A9=E7=95=A5=E5=9B=BE)/batch=20grid/?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E5=BE=AE=E7=BC=A9=E7=95=A5=E5=9B=BE=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E8=A7=A3=E7=A0=81,lightbox=20=E6=9C=89=E6=84=8F?= =?UTF-8?q?=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../canvas/widgets/batch_results_grid.dart | 2 ++ .../widgets/image_config_inspector.dart | 4 +++ .../canvas/widgets/node_inputs_section.dart | 4 +++ .../widgets/gallery_image_lightbox.dart | 2 ++ .../gallery/widgets/gallery_tile.dart | 6 ++++ .../gallery/widgets/gallery_tile_test.dart | 34 +++++++++++++++++++ 6 files changed, 52 insertions(+) diff --git a/lib/features/canvas/widgets/batch_results_grid.dart b/lib/features/canvas/widgets/batch_results_grid.dart index ca535585..139523c7 100644 --- a/lib/features/canvas/widgets/batch_results_grid.dart +++ b/lib/features/canvas/widgets/batch_results_grid.dart @@ -116,6 +116,8 @@ class _BatchSlotTile extends ConsumerWidget { return Image.file( file, fit: BoxFit.cover, + // LB-23:2 列格宽上限 180 逻辑px × dpr 缩略解码。 + cacheWidth: (180 * MediaQuery.devicePixelRatioOf(context)).round(), errorBuilder: (_, _, _) => _placeholder(colors, Icons.broken_image_outlined), ); diff --git a/lib/features/canvas/widgets/image_config_inspector.dart b/lib/features/canvas/widgets/image_config_inspector.dart index 4f252055..d238ced0 100644 --- a/lib/features/canvas/widgets/image_config_inspector.dart +++ b/lib/features/canvas/widgets/image_config_inspector.dart @@ -907,6 +907,10 @@ class _CharacterChip extends StatelessWidget { width: _kCharacterThumbSize, height: _kCharacterThumbSize, fit: BoxFit.cover, + // LB-23:微缩略图按显示尺寸缩略解码。 + cacheWidth: (_kCharacterThumbSize * + MediaQuery.devicePixelRatioOf(context)) + .round(), // 缺文件/坏图占位,不崩 UI。 errorBuilder: (_, _, _) => Icon( Icons.person_outline, diff --git a/lib/features/canvas/widgets/node_inputs_section.dart b/lib/features/canvas/widgets/node_inputs_section.dart index dbb53564..c4c21274 100644 --- a/lib/features/canvas/widgets/node_inputs_section.dart +++ b/lib/features/canvas/widgets/node_inputs_section.dart @@ -183,6 +183,10 @@ class _InputRow extends ConsumerWidget { width: _kThumbSize, height: _kThumbSize, fit: BoxFit.cover, + // LB-23:微缩略图按显示尺寸缩略解码。 + cacheWidth: + (_kThumbSize * MediaQuery.devicePixelRatioOf(context)) + .round(), // 缺文件/坏图占位,不崩 UI。 errorBuilder: (_, _, _) => Icon(Icons.image_outlined, size: 16, color: colors.fg3), diff --git a/lib/features/gallery/widgets/gallery_image_lightbox.dart b/lib/features/gallery/widgets/gallery_image_lightbox.dart index d905524f..97551e97 100644 --- a/lib/features/gallery/widgets/gallery_image_lightbox.dart +++ b/lib/features/gallery/widgets/gallery_image_lightbox.dart @@ -33,6 +33,8 @@ class GalleryImageLightboxContent extends StatelessWidget { Positioned.fill( child: InteractiveViewer( child: Center( + // LB-23 有意豁免 cacheWidth:InteractiveViewer 可缩放, + // 必须全分辨率解码;lightbox 为瞬态单图,关窗即回收。 child: Image.file( imageFile, fit: BoxFit.contain, diff --git a/lib/features/gallery/widgets/gallery_tile.dart b/lib/features/gallery/widgets/gallery_tile.dart index 685fd2bc..15260c31 100644 --- a/lib/features/gallery/widgets/gallery_tile.dart +++ b/lib/features/gallery/widgets/gallery_tile.dart @@ -148,6 +148,9 @@ class _GalleryTileState extends ConsumerState { child: Image.file( file, fit: BoxFit.cover, + // LB-23:按 tile 上限缩略解码,禁原图全解码 + //(220 = gallery_screen._tileMaxExtent,逻辑px × dpr)。 + cacheWidth: (220 * MediaQuery.devicePixelRatioOf(context)).round(), errorBuilder: (_, _, _) => _iconPlaceholder(colors, Icons.broken_image_outlined), ), @@ -171,6 +174,9 @@ class _GalleryTileState extends ConsumerState { : Image.file( thumb, fit: BoxFit.cover, + // LB-23:视频缩略图同按 tile 上限缩略解码。 + cacheWidth: + (220 * MediaQuery.devicePixelRatioOf(context)).round(), // 播放/时长角标经 frameBuilder 挂在图内:缩略图缺失/解码失败时 // errorBuilder 整体接管,不残留兄弟浮标叠影(评审 F1)。 frameBuilder: (context, child, frame, wasSync) => Stack( diff --git a/test/features/gallery/widgets/gallery_tile_test.dart b/test/features/gallery/widgets/gallery_tile_test.dart index a3f6200c..f92cf28c 100644 --- a/test/features/gallery/widgets/gallery_tile_test.dart +++ b/test/features/gallery/widgets/gallery_tile_test.dart @@ -287,4 +287,38 @@ void main() { reason: '视频文件缺失 → broken 态'); expect(find.byType(Dialog), findsNothing); }); + + // LB-23:画廊 tile 必须 cacheWidth 缩略解码(220px tile 全解码原图 = 内存炸点)。 + // ResizeImage 解包模式同 generation_render_node_e2e_test.dart(ME-26 先例)。 + testWidgets('LB-23 图片 tile 缩略解码(ResizeImage)', (tester) async { + final root = await _root(tester); + File('${root.path}/p1/canvases/c1/images/a.png') + ..parent.createSync(recursive: true) + ..writeAsBytesSync(_kPngBytes); + + await _pumpTile(tester, item: _image(), root: root.path); + await tester.pump(); + + final img = tester.widget(find.byType(Image)); + expect(img.image, isA(), + reason: 'gallery tile 未设 cacheWidth——原图全解码'); + }); + + testWidgets('LB-23 视频缩略图缩略解码(ResizeImage)', (tester) async { + final root = await _root(tester); + File('${root.path}/p1/canvases/c1/thumbnails/v.jpg') + ..parent.createSync(recursive: true) + ..writeAsBytesSync(_kPngBytes); + + await _pumpTile( + tester, + item: _video(thumb: 'thumbnails/v.jpg'), + root: root.path, + ); + await tester.pump(); + + final img = tester.widget(find.byType(Image)); + expect(img.image, isA(), + reason: '视频缩略图未设 cacheWidth——原图全解码'); + }); } From c3bb13bcc709ab295f41942a92e57fac0fd4d651 Mon Sep 17 00:00:00 2001 From: Kerro Date: Tue, 18 Aug 2026 14:31:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?perf(memory):=20LB-23=20ImageCache=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90=20100MB=E2=86=92256MB=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=94=BB=E5=BB=8A=E5=B7=A5=E4=BD=9C=E9=9B=86=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8,=E5=B8=B8=E9=87=8F=E5=85=A5=20core/constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/CLAUDE.md | 1 + lib/core/constants/image_cache.dart | 4 ++++ lib/main.dart | 3 +++ lib/services/image_cache_config.dart | 8 ++++++++ test/app_boot_image_cache_test.dart | 16 ++++++++++++++++ 5 files changed, 32 insertions(+) create mode 100644 lib/core/constants/image_cache.dart create mode 100644 lib/services/image_cache_config.dart create mode 100644 test/app_boot_image_cache_test.dart diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 63dc86dd..bbbe4711 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -323,6 +323,7 @@ lib/ ├── crash_reporter.dart # FileCrashReporter (uncaught-error crash file + keep-3 rotation, no context/extra) ├── error_hooks.dart # installErrorHooks + reportUncaught (FlutterError/PlatformDispatcher/zone → logger + CrashReporter) ├── lifecycle_timer.dart # LifecycleTimer (startup stage timing → app.lifecycle {stage, ms}; see docs/perf-baseline.md) + ├── image_cache_config.dart # configureImageCache (LB-23: ImageCache 上限 256MB; main bootstrap 调用一次) ├── dio_video_download_service.dart ├── process_watchdog.dart # runWithWatchdog (ProcessStarter 流式 + 定时 kill + 硬截止; pg_dump/pg_restore 超时) ├── system_process_runner.dart # ProcessRunner/ProcessStarter impl (Process.run / Process.start+stdin close) diff --git a/lib/core/constants/image_cache.dart b/lib/core/constants/image_cache.dart new file mode 100644 index 00000000..25bc9955 --- /dev/null +++ b/lib/core/constants/image_cache.dart @@ -0,0 +1,4 @@ +// LB-23:ImageCache 字节上限。cacheWidth 收口后画廊 100 项工作集 ≈80MB +// 贴 Flutter 默认 100MB 上限,滚动即抖动淘汰;桌面内存充裕,上调换流畅。 +// 条目上限 1000 不动(桌面场景先触字节限)。评估记录见 docs/perf-baseline.md。 +const int kImageCacheMaxBytes = 256 << 20; diff --git a/lib/main.dart b/lib/main.dart index c946ef97..3de71a47 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ import 'services/custom_providers_file_service.dart'; import 'features/settings/providers/update_check_controller.dart'; import 'services/error_hooks.dart'; import 'services/file_preferences_service.dart'; +import 'services/image_cache_config.dart'; import 'services/lifecycle_timer.dart'; import 'theme/tokens.dart'; @@ -69,6 +70,8 @@ void main() { runZonedGuarded>( () async { WidgetsFlutterBinding.ensureInitialized(); + // LB-23:ImageCache 字节上限 100MB→256MB(依据见 core/constants/image_cache.dart)。 + configureImageCache(); // 崩溃钩子所需最小依赖尽早就绪,最大化未捕获错误覆盖面(覆盖其后整段 bootstrap)。 final Duration pathsStart = elapsed.elapsed; diff --git a/lib/services/image_cache_config.dart b/lib/services/image_cache_config.dart new file mode 100644 index 00000000..2a2afdce --- /dev/null +++ b/lib/services/image_cache_config.dart @@ -0,0 +1,8 @@ +// LB-23:ImageCache 上限设定(main bootstrap 于 ensureInitialized 后调用一次)。 +import 'package:flutter/painting.dart'; + +import '../core/constants/image_cache.dart'; + +void configureImageCache() { + PaintingBinding.instance.imageCache.maximumSizeBytes = kImageCacheMaxBytes; +} diff --git a/test/app_boot_image_cache_test.dart b/test/app_boot_image_cache_test.dart new file mode 100644 index 00000000..3e93ec18 --- /dev/null +++ b/test/app_boot_image_cache_test.dart @@ -0,0 +1,16 @@ +// LB-23:ImageCache 字节上限必须显式设定(默认 100MB 贴画廊工作集,滚动抖动淘汰)。 +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/constants/image_cache.dart'; +import 'package:inkframe/services/image_cache_config.dart'; + +void main() { + testWidgets('imageCache 上限 = 256MB', (tester) async { + expect(kImageCacheMaxBytes, 256 << 20); + configureImageCache(); + expect( + PaintingBinding.instance.imageCache.maximumSizeBytes, + kImageCacheMaxBytes, + ); + }); +} From dde8e50bd3b5c46d237b7fe01e382bfb8176e5fb Mon Sep 17 00:00:00 2001 From: Kerro Date: Tue, 18 Aug 2026 14:34:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20LB-23=20=E6=94=B6=E5=8F=A3=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E2=80=94=E2=80=94perf-baseline=20=E5=86=85=E5=AD=98?= =?UTF-8?q?=E6=B0=B4=E4=BD=8D=E8=8A=82(mac=20=E7=A9=BA=E8=BD=BD=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=20140MB)=20+=20BOARD/MASTERPLAN/backend=20=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=B8=89=E5=A4=84=E7=8A=B6=E6=80=81=20+=20=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=96=87=E6=A1=A3=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/BOARD.md | 3 +- docs/MASTERPLAN.md | 2 +- docs/perf-baseline.md | 37 +++ .../plans/2026-07-07-launch-backend.md | 4 + .../plans/2026-07-31-lb-23-memory-baseline.md | 257 ++++++++++++++++++ 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-31-lb-23-memory-baseline.md diff --git a/docs/BOARD.md b/docs/BOARD.md index c3320687..cf2848be 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -118,7 +118,8 @@ | **SB-4 shot→video 直达(M4 E1)**:shot 面板第二个生成入口——以本镜备注为 prompt 建 **video** config 节点 + narrative 边,并把 SB-3 的预期时长/预期运镜一并写进去(省得在 video 面板重填)。**带的是意图不是承诺**:video 面板按所选 provider 的 supportedDurations/supportedCameras 钳制,不支持的值落地即丢回未设置——本卡不做任何能力判断(此刻还没选 provider);未设置的参数**不写空键**,让 video 面板照常按 provider 取默认而非被显式 null 顶掉。两个生成入口的公共主体抽 `_generateFromNotes(type, extraConfig)`——原先只有图片一条,现在两条,复制一遍迟早发散(notifier/文案 await 前取好、节点与连线失败分开报这些约束一处都不能漏)。ARB +1 键。+5 例 | #223 | | **SB-1 规则脚本拆分器(M4 E1;D-M4-1 拍板后首件)**:`storyboard/util/script_splitter.dart` 纯 Dart 零 Flutter 依赖——`ShotDraft{label,notes}` + `splitScript(text, strategy)`,策略 blankLine(空行分段)/perLine。**剥行首编号**是本卡最易写漏处:用户粘来的文本十有八九带 `1.`/`镜头1`/`第3镜`/`SHOT 1`/`# `,留着会污染 prompt(「1. 山径破晓」让模型去画数字);但不能剥过头——「1920 年代的街道」「3D 渲染质感」里的数字是内容,故**光秃秃的数字必须跟分隔符才算编号**,有词锚的(镜头/第N镜/shot/scene)才允许无分隔符。markdown 井号单独一趟剥(可独立出现也可叠在编号前,`### Shot 1 dawn`)。只剥**段首那一行**(「2 号机位跟拍」是内容不是第 2 镜);剥完为空的行丢弃让后续行顶上。CRLF 与单独 CR 都归一 LF;「空行」按 trim 判定(Word/网页粘贴常带空格 tab);label=段首行截 60 字而 notes 恒为全文。22 例覆盖两策略/六类编号/不误伤/退化输入/顺序稳定 | #224 | | **SB-2 脚本导入对话框 + 批量建链(M4 E1;E1 整条链自此贯通)**:`storyboard/providers/script_import_controller.dart` + `widgets/script_import_dialog.dart`——粘贴框/策略切换/实时预览 → 确认后 N 个 shot 节点 + N-1 条 narrative 边收进**单个事务**。**失败必须零残留**:半条链(几个建好的散镜 + 几条边)比一个错误提示难收拾得多——用户既不知道哪几镜落地了也没有一键撤销;测试用的 fake UoW **真回滚**(写入先落 staging,闭包整体成功才 commit),真事务语义仍由 `transaction_integration_test`(真 PG)兜底。两处坑:①**不走 CanvasNodesController**(它一次只建一个节点、不入事务)——直接经 scope 写库,落地后 invalidate 节点与边两个控制器 ②**provider 不加 autoDispose**——调用方只 `ref.read` 不订阅,autoDispose 会在 read 后立刻销毁 ref,事务 await 完再 invalidate 就撞上已销毁的 ref,画布刷不出新链。落点=第一镜落视口中心,后续每 `kShotChainSpacingX`(260) 沿 x 排开;入口=FAB 菜单「导入脚本…」+ 空态 CTA。ARB +13 键。+18 例 | #225 | -| **XM-2 图片元数据(M4;补齐 XM-1 的图片侧)**:`core/media/png_dimensions.dart` 纯函数(PNG 签名 + IHDR,不引解码库)→ 四个落盘点(inline 单/批、remote 单/批)顺手解析宽高,连同 `task.seed` 写进 `node.type_config`(主图那一张,与 XM-1 视频侧同键)与 `batch_results.width/height/seed`(逐 slot 各记各的,schema 已建未用的列自此有值)。**判定刻意严格、拿不到就不写键**:认错了会把垃圾尺寸写进库再一路带到排版,而列停在 NULL 画廊才知道退回默认比例——每条正向断言都配一条「非 PNG 时这些键根本不出现」的反向断言。inline 路径 bytes 在手零额外 IO;remote 路径回读文件头 33 字节(完整 IHDR 含 CRC),**任何探针失败一律吞掉**——元数据是锦上添花,绝不能把一次成功的生成拖成失败。视频侧不动(宽高仍归 XM-1 抽帧探针)。+22 例 | 本 PR | +| **XM-2 图片元数据(M4;补齐 XM-1 的图片侧)**:`core/media/png_dimensions.dart` 纯函数(PNG 签名 + IHDR,不引解码库)→ 四个落盘点(inline 单/批、remote 单/批)顺手解析宽高,连同 `task.seed` 写进 `node.type_config`(主图那一张,与 XM-1 视频侧同键)与 `batch_results.width/height/seed`(逐 slot 各记各的,schema 已建未用的列自此有值)。**判定刻意严格、拿不到就不写键**:认错了会把垃圾尺寸写进库再一路带到排版,而列停在 NULL 画廊才知道退回默认比例——每条正向断言都配一条「非 PNG 时这些键根本不出现」的反向断言。inline 路径 bytes 在手零额外 IO;remote 路径回读文件头 33 字节(完整 IHDR 含 CRC),**任何探针失败一律吞掉**——元数据是锦上添花,绝不能把一次成功的生成拖成失败。视频侧不动(宽高仍归 XM-1 抽帧探针)。+22 例 | #226 | +| LB-23 内存基线:cacheWidth 缩略解码收口 ×4 文件 5 站点(gallery tile 图片+视频缩略图/batch grid/两微缩略图;lightbox InteractiveViewer 有意豁免)+ ImageCache 上限 100→256MB(`kImageCacheMaxBytes`,main bootstrap 设定)+ perf-baseline 内存水位节(方法论/阈值/mac 空载实测 140MB,画廊与生成场景按 SOP 待填,Windows 列待群内机器)+ keepAlive 盘点 8 文件无未管控大对象 | #227 | ## M1 补遗(审计发现的悬空项) diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 7c2271ba..fedea61c 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -108,7 +108,7 @@ M6 「公开上线」……… 官网 + 示例项目 + 冷启动执行(HN/Reddit > 与 UI 卡去重:LB-06≡GAP-3、LB-15≡GAP-2、LB-05 联动 GAP-8——同一工作只排一张; > Inspector 测试欠账裁决为**上线前**(UI GAP-7 为准,backend 债 #19 让渡)。 > 严格复审新增三卡:**LB-22 备份还原路径 ✅ #189**(SCRAM 后用户无法手工 pg_restore——app 内还原入口, -> beta 前 M)、**LB-23 内存基线**(ImageCache 上限/长会话水位,S-M)、**LB-24 网络代理支持** +> beta 前 M)、**LB-23 内存基线 ✅ #227**(ImageCache 上限/长会话水位,S-M)、**LB-24 网络代理支持** > (dio 不读系统代理,中文用户连不上海外模型商——`HTTPS_PROXY` env 先行 + 设置页代理区,上线前 M)。 **债表处置总账**:24 行 → 上线前必修 11、条件必修 1(build_runner,freezed 3.2.6 触发)、 diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index 7463ee3b..b5ba840b 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -71,3 +71,40 @@ grep '"module":"app.lifecycle"' ~/Library/Application\ Support/InkFrame/logs/ink | 冷启 | `pg_ready`(含 initdb) | < 15s | TODO(measure on real macOS/Windows) | TODO(measure on real macOS/Windows) | 回填时一并记录机器规格(CPU / 内存 / 磁盘类型)与 app 版本,便于跨机比较。 + +## 内存水位(LB-23) + +### 方法论 +- 指标:进程 RSS(mac `ps -o rss= -p `;win 任务管理器「内存」列或 + `Get-Process inkframe | % WorkingSet64`)。Release 构建,采样前静置 10s。 +- 三场景:**空载**(启动进 Studio 不开项目);**画廊 100 项**(示例项目扩种或真实项目, + Gallery 滚到底再回顶);**连续生成 20 张**(任一 provider,1024px,逐张等完成)。 + +### 阈值(验收线) +| 场景 | 阈值 | 依据 | +|---|---|---| +| 空载 | < 400 MB | Flutter desktop shell + 嵌入式 PG 常驻 | +| 画廊 100 项 | < 800 MB | 空载 + ImageCache 上限 256MB + 解码抖动余量 | +| 连续生成 20 张后回落 | < 900 MB 且 10min 内不再增长 | 泄漏红线:持续线性增长即 bug | + +### 实测记录 +| 场景 | macOS (arm64) | Windows (x64) | +|---|---|---| +| 空载 | 140 MB(2026-08-18,alpha.11+ 本机 arm64;主进程 30s 稳定,嵌入式 PG 子进程另 23 MB) | 待测(群内 Windows 机) | +| 画廊 100 项 | 待测(需 ≥100 项真实产物) | 待测 | +| 连续生成 20 张 | 待测(需 API key 手工跑,SOP 如上) | 待测 | + +### ImageCache 策略(评估记录) +默认 100MB/1000 张 → 字节上限调 256MB(`kImageCacheMaxBytes`,main bootstrap 设定)。 +依据:cacheWidth 收口后画廊 100 项工作集 ≈80MB 贴默认上限,滚动抖动淘汰; +桌面内存充裕。条目上限不动。cacheWidth 覆盖清单:node_card / video_node_body(先例) ++ gallery_tile(图片 tile + 视频缩略图两站点)/ batch_results_grid / +node_inputs_section / image_config_inspector(本卡); +豁免:gallery_image_lightbox(InteractiveViewer 缩放需全分辨率)。 + +### keepAlive 常驻盘点(LB-23 子项④) +8 文件(database / providers / rate_limiter / canvas_transform / inspector_submit / +export_controller / generation_controller / jobs_registry),`grep -rln keepAlive lib` +2026-08-18 复核一致。持大对象者仅 ImageCache 域外的 jobs_registry(handle 缓存, +随 purge 收敛)与 database pool——均为设计内常驻;无未管控大对象。 +上线后如需精算记 BP 系。 diff --git a/docs/superpowers/plans/2026-07-07-launch-backend.md b/docs/superpowers/plans/2026-07-07-launch-backend.md index a2d4c860..f7b94837 100644 --- a/docs/superpowers/plans/2026-07-07-launch-backend.md +++ b/docs/superpowers/plans/2026-07-07-launch-backend.md @@ -186,6 +186,10 @@ commit 更新 DATABASE/ARCHITECTURE 对应节。 - **LB-23 内存基线**(S-M):perf-baseline.md 加内存水位节(空载/画廊 100 项/连续生成 20 张后的 RSS,双平台实测);ImageCache 上限策略评估;画廊 tile `Image.file(cacheWidth:)` 缩略解码 列为 GA-1/GAP-5 验收项;keepAlive provider 持大对象盘点(17 处)记 BP 系。 + 状态:已随 #227 落地——cacheWidth 收口 ×4 文件 5 站点(gallery tile 图/视频缩略图、 + batch grid、两处微缩略图;lightbox InteractiveViewer 有意豁免)、ImageCache 256MB + (kImageCacheMaxBytes)、perf-baseline 内存水位节(mac 空载已实测 140MB;画廊/生成/win 列 + 按 SOP 待填)、keepAlive 盘点 8 文件无未管控大对象。 ### D. 可观测性 diff --git a/docs/superpowers/plans/2026-07-31-lb-23-memory-baseline.md b/docs/superpowers/plans/2026-07-31-lb-23-memory-baseline.md new file mode 100644 index 00000000..f1072e48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-lb-23-memory-baseline.md @@ -0,0 +1,257 @@ +# LB-23 内存基线 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 收口 LB-23 四子项:① 全部缩略场景 `cacheWidth` 解码收敛(4 站点);② ImageCache 上限策略评估并落地;③ perf-baseline.md 内存水位节(方法论+阈值+可测项实测);④ keepAlive 持大对象盘点入档。 + +**Architecture:** 内存问题的根源排序:未缩解码 > 缓存上限 > 常驻对象。先修解码(一张 2048² 原图全解码 16MB,缩到 220px tile 只要 ~0.8MB,20 倍差),再定缓存上限(修完解码后才知道真实工作集),最后盘点常驻。lightbox 是**有意豁免**:InteractiveViewer 要缩放,必须全分辨率。 + +**Tech Stack:** `Image.file(cacheWidth:)`(既有 node_card/video_node_body 同款,ME-26 先例)、`PaintingBinding.instance.imageCache`、widget test 断言 `ResizeImage`(照抄 generation_render_node_e2e_test.dart:110 解包模式)。 + +**实测口径的诚实边界:** 卡片要求「空载/画廊 100 项/连续生成 20 张 RSS,双平台」。本 PR 落地方法论+表格+**mac 空载实测**;画廊/生成两项需真实数据与 API key(手工 SOP 已写清步骤),Windows 列需群里 Windows 机器——表格留待填标注归属,不假装测过。 + +--- + +### Task 1: cacheWidth 收口 ×4 站点 + lightbox 豁免注释 + +**Files:** +- Modify: `lib/features/gallery/widgets/gallery_tile.dart:92` +- Modify: `lib/features/canvas/widgets/batch_results_grid.dart:116` +- Modify: `lib/features/canvas/widgets/node_inputs_section.dart:181` +- Modify: `lib/features/canvas/widgets/image_config_inspector.dart:905` +- Modify: `lib/features/gallery/widgets/gallery_image_lightbox.dart:36`(仅加豁免注释) +- Test: `test/features/gallery/gallery_tile_cache_width_test.dart`(新建;若既有 gallery tile 测试文件存在则并入) + +- [ ] **Step 1: 写失败测试(gallery tile 为代表,断言 ResizeImage)** + +```dart +// LB-23:画廊 tile 必须 cacheWidth 缩略解码(220px tile 全解码原图 = 内存炸点)。 +// ResizeImage 解包模式同 generation_render_node_e2e_test.dart(ME-26 先例)。 + final img = tester.widget(find.byType(Image)); + expect(img.image, isA(), + reason: 'gallery tile 未设 cacheWidth——原图全解码'); +``` + +(完整测试文件:pump 一个带临时图片文件的 GalleryTile,override fileResolver; +具体 harness 照抄该目录既有 gallery 测试的 ProviderScope overrides 写法。) + +- [ ] **Step 2: 跑测试确认红** + +```bash +flutter test test/features/gallery/ 2>&1 | tail -3 +``` + +Expected: 新断言 FAIL(`is not ResizeImage`)。 + +- [ ] **Step 3: 四站点实现** + +gallery_tile.dart(tile 上限 `_tileMaxExtent`=220,取自 gallery_screen;挪为共享 const 或就地复制注明来源): + +```dart + child: Image.file( + file, + fit: BoxFit.cover, + // LB-23:按 tile 上限缩略解码,禁原图全解码(220 逻辑px × dpr) + cacheWidth: + (220 * MediaQuery.devicePixelRatioOf(context)).round(), + errorBuilder: ... + ), +``` + +batch_results_grid.dart(2 列网格,Inspector 面板宽 ~360,格宽上限取 180): + +```dart + return Image.file( + file, + fit: BoxFit.cover, + // LB-23:2 列格宽上限 180 逻辑px × dpr 缩略解码 + cacheWidth: + (180 * MediaQuery.devicePixelRatioOf(context)).round(), + errorBuilder: ... + ); +``` + +node_inputs_section.dart / image_config_inspector.dart(微缩略图,`_kThumbSize`=28 / `_kCharacterThumbSize`=20): + +```dart + cacheWidth: (_kThumbSize * + MediaQuery.devicePixelRatioOf(context)) + .round(), +``` + +(inspector 同式用 `_kCharacterThumbSize`。) + +gallery_image_lightbox.dart 仅加注释,不改行为: + +```dart + // LB-23 有意豁免 cacheWidth:InteractiveViewer 可缩放, + // 必须全分辨率解码;lightbox 为瞬态单图,关窗即回收。 + child: Image.file( +``` + +- [ ] **Step 4: 测试绿 + 全量闸门** + +```bash +flutter test test/features/gallery/ test/features/canvas/ 2>&1 | tail -2 +flutter analyze lib test 2>&1 | tail -1 +``` + +Expected: 全过、no issues。 + +- [ ] **Step 5: 提交** + +```bash +git add -A lib/features test/features +git commit -m "perf(memory): LB-23 cacheWidth 收口——gallery tile/batch grid/两处微缩略图缩略解码,lightbox 有意豁免 + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 2: ImageCache 上限策略落地 + +**Files:** +- Modify: `lib/main.dart`(bootstrap 段,ensureInitialized 之后) +- Test: `test/app_boot_image_cache_test.dart`(新建) + +**评估结论(写入 perf-baseline,代码只落结果):** Flutter 默认 100MB/1000 张。修完 Task 1 后主要占用=画布 result 卡(已 cacheWidth 到节点宽)+ 画廊 tile(~0.8MB@2x);100 项画廊 ≈ 80MB,贴默认上限,滚动即抖动淘汰。桌面机内存充裕,上调到 **256MB** 换滚动流畅;条目数上限 1000 不动(桌面场景先触字节限)。 + +- [ ] **Step 1: 失败测试** + +```dart +// LB-23:ImageCache 字节上限必须显式设定(默认 100MB 贴画廊工作集,滚动抖动淘汰)。 +testWidgets('imageCache 上限 = 256MB', (tester) async { + // main() 的 bootstrap 不可直接调,断言常量与运行时一致的最小面: + expect(kImageCacheMaxBytes, 256 << 20); +}); +``` + +实现时若 main.dart 结构允许抽 `configureImageCache()` 纯函数则连行为一起测 +(调用后 `PaintingBinding.instance.imageCache.maximumSizeBytes == kImageCacheMaxBytes`); +不允许则常量测试 + main 内一行赋值,以 analyze+启动日志为证。 + +- [ ] **Step 2: 实现(main.dart bootstrap,ensureInitialized 后)** + +```dart + // LB-23:ImageCache 字节上限 100MB→256MB。修完 cacheWidth 后画廊 100 项 + // 工作集 ≈80MB 贴默认上限,滚动即抖动淘汰;桌面内存充裕,上调换流畅。 + // 条目上限 1000 不动(桌面先触字节限)。评估记录见 docs/perf-baseline.md。 + PaintingBinding.instance.imageCache.maximumSizeBytes = + kImageCacheMaxBytes; +``` + +常量放 `lib/core/constants/`(与既有数值常量同居,命名 `kImageCacheMaxBytes = 256 << 20`)。 + +- [ ] **Step 3: 测试绿 + 提交** + +```bash +flutter test test/app_boot_image_cache_test.dart && flutter analyze lib test 2>&1 | tail -1 +git add -A lib test && git commit -m "perf(memory): LB-23 ImageCache 上限 100MB→256MB——画廊工作集评估驱动,常量入 core/constants + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 3: perf-baseline.md 内存水位节 + mac 空载实测 + +**Files:** +- Modify: `docs/perf-baseline.md`(追加「内存水位」节) + +- [ ] **Step 1: 追加内存水位节(方法论+阈值+表格)** + +```markdown +## 内存水位(LB-23) + +### 方法论 +- 指标:进程 RSS(mac `ps -o rss= -p `;win 任务管理器「内存」列或 + `Get-Process inkframe | % WorkingSet64`)。Release 构建,采样前静置 10s。 +- 三场景:**空载**(启动进 Studio 不开项目);**画廊 100 项**(示例项目扩种或真实项目, + Gallery 滚到底再回顶);**连续生成 20 张**(任一 provider,1024px,逐张等完成)。 + +### 阈值(验收线) +| 场景 | 阈值 | 依据 | +|---|---|---| +| 空载 | < 400 MB | Flutter desktop shell + 嵌入式 PG 常驻 | +| 画廊 100 项 | < 800 MB | 空载 + ImageCache 上限 256MB + 解码抖动余量 | +| 连续生成 20 张后回落 | < 900 MB 且 10min 内不再增长 | 泄漏红线:持续线性增长即 bug | + +### 实测记录 +| 场景 | macOS (arm64) | Windows (x64) | +|---|---|---| +| 空载 | (本 PR 实测填入) | 待测(群内 Windows 机) | +| 画廊 100 项 | 待测(需 ≥100 项真实产物) | 待测 | +| 连续生成 20 张 | 待测(需 API key 手工跑,SOP 如上) | 待测 | + +### ImageCache 策略(评估记录) +默认 100MB/1000 张 → 字节上限调 256MB(`kImageCacheMaxBytes`,main bootstrap 设定)。 +依据:cacheWidth 收口后画廊 100 项工作集 ≈80MB 贴默认上限,滚动抖动淘汰; +桌面内存充裕。条目上限不动。cacheWidth 覆盖清单:node_card / video_node_body(先例) ++ gallery_tile / batch_results_grid / node_inputs_section / image_config_inspector(本卡); +豁免:gallery_image_lightbox(InteractiveViewer 缩放需全分辨率)。 + +### keepAlive 常驻盘点(LB-23 子项④) +8 文件(database / providers / rate_limiter / canvas_transform / inspector_submit / +export_controller / generation_controller / jobs_registry)。持大对象者仅 +ImageCache 域外的 jobs_registry(handle 缓存,随 purge 收敛)与 database pool—— +均为设计内常驻;无未管控大对象。上线后如需精算记 BP 系。 +``` + +- [ ] **Step 2: mac 空载实测并填表** + +```bash +flutter build macos --release 2>&1 | tail -1 +APP="$(find build/macos/Build/Products/Release -maxdepth 1 -name '*.app' | head -1)" +open "$APP" && sleep 15 +PID=$(pgrep -x inkframe | head -1) +echo "RSS: $(( $(ps -o rss= -p "$PID") / 1024 )) MB" +osascript -e 'quit app "inkframe"' +``` + +把输出 MB 填进表格 macOS 空载格(格式如 `312 MB(2026-07-31,alpha.11+`本机`)`)。 + +> 注意:本机曾有 DebugProfile.entitlements 沙箱坑(memory:local_dev_unlocks)—— +> Release 构建不走 DebugProfile,预期可直接起;若启动失败,记录失败原因并把该格标 +> 「待测(本机 release 启动受阻)」,不伪造数值。 + +- [ ] **Step 3: keepAlive 盘点核实(grep 复核后如实修正上表文件清单)** + +```bash +grep -rln "keepAlive" lib --include="*.dart" +``` + +以实际输出为准修正盘点段;若发现真持大对象的常驻 provider,如实记录并在 BOARD 技术债表加行。 + +### Task 4: 状态回填 + PR + +**Files:** +- Modify: `docs/BOARD.md`(近期落地表加行) +- Modify: `docs/MASTERPLAN.md:111`(LB-23 标 ✅) +- Modify: `docs/superpowers/plans/2026-07-07-launch-backend.md:186-188`(LB-23 卡加状态行) + +- [ ] **Step 1: 三处回填** + +MASTERPLAN :111 的 `**LB-23 内存基线**(...)` 后加 `✅ #206`;backend plan LB-23 卡后加: + +```markdown + 状态:已随 #206 落地——cacheWidth 收口 ×4(lightbox 有意豁免)、ImageCache 256MB + (kImageCacheMaxBytes)、perf-baseline 内存水位节(mac 空载已实测;画廊/生成/win 列 + 按 SOP 待填)、keepAlive 盘点无未管控大对象。 +``` + +BOARD 近期落地表(#205 行后): + +```markdown +| LB-23 内存基线:cacheWidth 缩略解码收口 ×4 站点(gallery tile/batch grid/两微缩略图;lightbox InteractiveViewer 有意豁免)+ ImageCache 上限 100→256MB + perf-baseline 内存水位节(方法论/阈值/mac 空载实测,画廊与生成场景按 SOP 待填)+ keepAlive 盘点 | #206 | +``` + +(PR 号预写 #206,建 PR 时核实。) + +- [ ] **Step 2: 全量闸门 + 提交 + push(pbcopy 交用户)+ PR + CI 绿合并** + +```bash +flutter analyze lib test && flutter test --exclude-tags golden +git add -A docs && git commit -m "docs: LB-23 收口回填——perf-baseline 内存水位节 + BOARD/MASTERPLAN/backend 计划三处状态 + +Co-Authored-By: Claude Fable 5 " +git push -u origin perf/lb-23-memory-baseline # 用户 ! 执行 +gh pr create --title "perf: LB-23 内存基线——cacheWidth 收口 + ImageCache 256MB + 水位基线" --body "(注意:正文技术记号一律反引号,禁裸 @)" +gh pr checks --watch && gh pr merge --squash --delete-branch +```