diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 16126e21..fe07e292 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -237,8 +237,8 @@ lib/ │ ├── canvas/ # Node canvas │ │ ├── models/ │ │ ├── providers/ # Riverpod ViewModels; canvas_compact_cards.dart(缩放 < 0.6 时节点卡只画类型图标——只在跨阈值时通知,缩放时不逐帧重建卡片);left_rail_state.dart(PR-4 画布左栏:停在哪一页〔大纲 / 素材 / 角色〕+ 面板展开 / 收起,非 autoDispose——「管理」链接与 Ctrl+\ / ⌘\ 都是跨子树来开合 / 切页;取代 P4 的 project_panel_tab.dart)+ character_usage.dart(角色 id → 引用它的 config 节点,只数 config、跨全项目画布、按节点 id 去重;数据源复用 galleryGraphProvider,挂/摘角色后由 CharactersSection 定点 invalidate)+ canvas_bootstrap_controller.dart(短剧示例:单事务建项目/画布/泳道/config 节点,并把 assets/samples/ 的两张打包成片落盘 + 种成 result 节点——P7 把内置示例页并进来了;成片 best-effort,读不出来就少种两个节点)+ inspector_submit_controller.dart(提交四态 + type_config 单写入链 + 自动保存失败态;并实现 `PendingWriteFlushable`、在 build 里注册进 `pendingWriteRegistryProvider`——退出序列在关连接池之前冲一次挂起的防抖 patch 与 `_unsaved`,BOARD 第 201 行)+ canvas_tool_mode.dart(浮动工具条的工具态:选择 / 平移 + 按住空格临时平移,两个来源分开记、按画布 autoDispose;实际是否平移 = 二者取或,舞台指针闸 / 手形光标 / 工具条高亮共读 canvasPanActiveProvider)+ inspector_tab.dart(PR-6 检查器停在「属性」还是「历史」;autoDispose,换选中节点不重置) -│ │ ├── util/ # incl. narrative_order.dart (SB-5 chain ordering) + node_artifacts.dart (node → latest result) + camera_labels.dart (运镜 / 景别 / 机位角度 / 焦段 → ARB 文案 + parseCameraMovement + shotLanguageSummary) + batch_slot_view.dart (P4: slot 行 → 四种呈现态 promoted/success/error/generating 的唯一判据 + canPromote/aspectRatio) + ref_image_budget.dart (CH-2②: 参考图配额纯判据——边连 reference 入边 + 挂载角色图合计 vs maxRefImages,算出"谁被截掉几张";检查器提交前点名、生成侧落 warn 用同一口径。首/尾帧不占配额) + edge_style.dart(连线外观的唯一判据:叙事 / 首帧 / 尾帧 / 参考 / 产出五种线型、颜色槽、标签,选中只换色)+ lineage_edges.dart(产出线由 source_node_id 推导、不入库不可点不可删;端口占用判据)+ result_placement.dart(成片落点:config 右侧一列、第 k 张往下错开;与 config 同道) + prompt_bar_width.dart(提示词条宽 = 画布宽 × 0.6,夹在 480–880;窄于 512 贴边留 16)+ canvas_tool_actions.dart(浮动工具条与单键快捷键 V/I/G/T/F/L 共用的唯一实现:视口中心建节点 / 建泳道 / 切连线 / 选择工具;PR-8a 起还有新建菜单的动作:在菜单锚点建节点并连好线〔先建节点再连线,边写失败回滚删节点 + 错误卡,成功挂定位请求单选〕、端口拖到卡上连线〔走 linkTo〕、全选;PR-8b 起还有节点右键菜单与 Ctrl+↵ / Ctrl+D / Shift+1 / Shift+2 的动作:生成〔先冲挂起的防抖写入,再交空补丁走 InspectorSubmitController.submit——提示词不从可能过期的内存节点里取〕、复制一份〔同类型 / typeConfig / 泳道,右下偏一个 lg,不复制边与产物〕、适应全部 / 适应选中〔缩放条「适应」与空白处「适应视图」都是适应全部;⌘0 仍是缩放复位〕、存为角色〔InkNameDialog + createFromImage,与角色库同一条路〕、加入 / 移出泳道〔moveNode 整批,位置一并挪进目标道的带里〕)+ viewport_fit.dart(PR-8b 适应视口纯函数:舞台矩形框进「视口扣掉留白」并居中,缩放夹在最小缩放与 min(1, 最大缩放) 之间——只缩小不放大;泳道里的卡片包围盒随缩放变,按缩放二分,卡片矩形走 nodeStageRect)+ node_menu_model.dart(PR-8b 节点右键菜单判据:目标集〔右键的卡在选中集里 = 整个选中集〕、生成 / 复制一份 / 存为角色的可用条件、泳道子菜单的打勾与「移出泳道」、加入泳道时的位置换算 laneMoveDelta)+ create_menu_model.dart(PR-8a 新建菜单项判据:带连线上下文时源带图像产物出首帧 / 尾帧 / 参考三项、配置节点〔shot / image / video〕出「下一镜」;不带上下文出图像配置 / 视频配置 / 文本 / 镜头 / 泳道;搜索过滤)+ drop_placement.dart(落点换算:拖卡片落点与新建菜单落点共用的泳道归属判定 resolveLaneDrop、视口点 → 世界坐标、舞台上一点落在哪张卡上)+ outline_model.dart(PR-4 左栏大纲的分组纯函数:链上配置节点按泳道分组〔组序 = 泳道 sortOrder、组内 = 叙事链序〕→ 未分泳道 → 未入链〔按位置、不编号〕,名称 / 提示词筛选;narrativeChainMembers / narrativeSequenceIndex 是「在链上」与链序号的唯一判据,⌘K 镜头组共用)+ viewport_focus.dart(保持缩放、把节点渲染矩形〔世界 + 分道位移〕移到视口中心的纯函数) -│ │ └── widgets/ # canvas_screen.dart(左栏 | 舞台 + 浮层 | 检查器;PR-6 页签去重撤掉了舞台上方的画布头与底部的渲染队列——画布名在面包屑、缩放比例在缩放条、任务进状态栏胶囊,舞台上沿贴外壳标签栏、下沿贴状态栏)+ canvas_toolbar.dart(舞台顶部居中的浮动工具条:选择 · 平移 | 图像 · 视频 · 文本 · 分镜 · 泳道 · 连线 | 更多〔导入脚本〕;取代原左侧 37px 工具条与右下角加号)+ canvas_zoom_bar.dart(舞台左下的浮动缩放条;「适应」= 适应全部)+ canvas_inspector_panel.dart(右侧检查器,页签「属性 / 历史」,原「状态」页签已删)+ inspector_history_panel.dart(检查器「历史」页:选中节点的 take 列表——配置节点列名下全部产物、产物节点列同源兄弟并高亮自己;take 按时间正序编号;有 data 出边的标「在用」;点行 = 单选 + 居中)+ canvas_shortcuts.dart(删除 / Esc / 全选 / 缩放 + 工具单键 V/H/I/G/T/F/L,键位常量 CanvasToolKeys 与工具条 tooltip 同源;按住空格临时平移走 Focus.onKeyEvent,只在画布本身持焦时接管;Ctrl+\ / ⌘\ 开合左栏;Tab = 新建…,同样只在画布本身持主焦点时接管、别处的 Tab 放行给焦点遍历;PR-8b 加了 Ctrl+↵ / ⌘↵ 生成选中、Ctrl+D / ⌘D 复制一份、Shift+1 适应全部、Shift+2 适应选中,四个都对文本编辑让位——提示词条里的 ⌘↵ 归提示词条自己)+ create_node_menu.dart(PR-8a 新建菜单:从出端口拖线到空白处 / 双击空白 / Tab / 空白处右键「新建…」开同一个带搜索的菜单;canvasMenuRequestProvider〔开着哪个菜单、锚点、源节点 / 右键的卡〕+ canvasWireDragProvider〔拖线实时状态,Esc 取消〕+ CanvasMenuHost〔OverlayPortal 浮层,点外面 / 失焦关〕+ CanvasMenuPanel〔↑↓ / ↵ / Esc;项可置灰〔↑↓ 跳过〕/ 危险色 / 打勾 / 带子菜单——子菜单经第二个 OverlayPortal 贴在父行旁,与主菜单同一个点击区组〕;空白处右键菜单只有新建… / 导入脚本… / 全选 / 适应视图;PR-8b 节点右键菜单〔卡片上右键:没选中的先单选它,多选保持〕:生成 · 复制一份 | 存为角色 · 加入泳道 › · 在序列中定位 | 删除,不可用的置灰不隐藏)。PR-4 左栏 left_rail/:canvas_left_rail.dart(43px 图标导航条:大纲 / 素材 / 角色三页键 + 快捷键〔开设置的快捷键页〕+ 收起;剧本 / 帮助无后端不画)/ left_rail_panel.dart(241 面板宿主,收起时不占宽)/ outline_panel.dart(泳道分组的镜头行 + 产物子行 + 生成中子行,状态走 node_card 的 nodeStatusOf;点行 = 单选 + 移到视口中心)/ canvas_switcher.dart(大纲面板头的画布切换胶囊,接替旧画布树;同项目 openCanvas 不带 withProject)/ assets_panel.dart + assets_model.dart(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊 galleryGraphProvider / galleryController;点格子 = 当前画布定位或走画廊「在画布中定位」)/ left_panel_parts.dart(面板头 + 筛选框 + 说明行)/ left_rail_actions.dart(focusNodeOnCanvas:单选 + 居中)。P4 批量:batch_results_grid.dart(检查器内联 2×N 网格,宿主 image_result_inspector.dart 只剩一层 s12 内边距——宽度跟面板走)/ batch_compare_overlay.dart(全屏并排对比;「叠加对比」是禁用标签,不做清单)/ batch_slot_parts.dart(BatchSlotImage + BatchTappable + runBatchSlotAction——slot 动作的统一执行口,只捕具体失败类型并落 toast)。P4 角色:character_library_panel.dart(左栏角色页,PR-4 起顶上是面板头 + 「搜索角色…」筛选;行 = 缩略图 + 名 + 「N 张参考图 · M 处引用」+ ⋯ 菜单)/ character_edit_dialog.dart(改名 + 描述 + 参考图增删拖排 + 「被引用」) +│ │ ├── util/ # incl. narrative_order.dart (SB-5 chain ordering) + node_artifacts.dart (node → latest result) + camera_labels.dart (运镜 / 景别 / 机位角度 / 焦段 → ARB 文案 + parseCameraMovement + shotLanguageSummary) + batch_slot_view.dart (P4: slot 行 → 四种呈现态 promoted/success/error/generating 的唯一判据 + canPromote/aspectRatio) + ref_image_budget.dart (CH-2②: 参考图配额纯判据——边连 reference 入边 + 挂载角色图合计 vs maxRefImages,算出"谁被截掉几张";检查器提交前点名、生成侧落 warn 用同一口径。首/尾帧不占配额) + edge_style.dart(连线外观的唯一判据:叙事 / 首帧 / 尾帧 / 参考 / 产出五种线型、颜色槽、标签,选中只换色)+ lineage_edges.dart(产出线由 source_node_id 推导、不入库不可点不可删;端口占用判据)+ result_placement.dart(成片落点:config 右侧一列、第 k 张往下错开;与 config 同道) + prompt_bar_width.dart(提示词条宽 = 画布宽 × 0.6,夹在 480–880;窄于 512 贴边留 16)+ canvas_tool_actions.dart(浮动工具条与单键快捷键 V/I/G/T/F/L 共用的唯一实现:视口中心建节点 / 建泳道 / 切连线 / 选择工具;PR-8a 起还有新建菜单的动作:在菜单锚点建节点并连好线〔先建节点再连线,边写失败回滚删节点 + 错误卡,成功挂定位请求单选〕、端口拖到卡上连线〔走 linkTo〕、全选;PR-8b 起还有节点右键菜单与 Ctrl+↵ / Ctrl+D / Shift+1 / Shift+2 的动作:生成〔先冲挂起的防抖写入,再交空补丁走 InspectorSubmitController.submit——提示词不从可能过期的内存节点里取〕、复制一份〔同类型 / typeConfig / 泳道,右下偏一个 lg,不复制边与产物〕、适应全部 / 适应选中〔缩放条「适应」与空白处「适应视图」都是适应全部;⌘0 仍是缩放复位〕、存为角色〔InkNameDialog + createFromImage,与角色库同一条路〕、加入 / 移出泳道〔moveNode 整批,位置一并挪进目标道的带里〕)+ viewport_fit.dart(PR-8b 适应视口纯函数:舞台矩形框进「视口扣掉留白」并居中,缩放夹在最小缩放与 min(1, 最大缩放) 之间——只缩小不放大;泳道里的卡片包围盒随缩放变,按缩放二分,卡片矩形走 nodeStageRect)+ node_menu_model.dart(PR-8b 节点右键菜单判据:目标集〔右键的卡在选中集里 = 整个选中集〕、生成 / 复制一份 / 存为角色的可用条件、泳道子菜单的打勾与「移出泳道」、加入泳道时的位置换算 laneMoveDelta)+ create_menu_model.dart(PR-8a 新建菜单项判据:带连线上下文时源带图像产物出首帧 / 尾帧 / 参考三项、配置节点〔shot / image / video〕出「下一镜」;不带上下文出图像配置 / 视频配置 / 文本 / 镜头 / 泳道;搜索过滤)+ drop_placement.dart(落点换算:拖卡片落点与新建菜单落点共用的泳道归属判定 resolveLaneDrop、视口点 → 世界坐标、舞台上一点落在哪张卡上)+ outline_model.dart(PR-4 左栏大纲的分组纯函数:链上配置节点按泳道分组〔组序 = 泳道 sortOrder、组内 = 叙事链序〕→ 未分泳道 → 未入链〔按位置、不编号;列全部不在链上的配置节点,与序列状态栏「未入链 · N」只数有产物的口径不同〕,名称 / 提示词筛选;narrativeChainMembers 是「在链上」的唯一判据,narrativeSequenceIndex 只管组内行序;显示的镜号走 sequenceShotNumbers〔序列折叠后的镜序,节点归哪一镜用 sequence/util/shot_index.dart 的判据——与序列视图 / 画廊 / ⌘K 镜头组同一个号;组头「N 镜」= 组内不同镜号的个数〕)+ take_numbers.dart(take 号的唯一编号函数 takeNumbersOn:每个配置节点名下有产物的 result 节点按时间正序编号,名下只有一个不编号〔写「成片」〕,批量 slot 不参与;左栏素材页格子与检查器历史页共用)+ viewport_focus.dart(保持缩放、把节点渲染矩形〔世界 + 分道位移〕移到视口中心的纯函数) +│ │ └── widgets/ # canvas_screen.dart(左栏 | 舞台 + 浮层 | 检查器;PR-6 页签去重撤掉了舞台上方的画布头与底部的渲染队列——画布名在面包屑、缩放比例在缩放条、任务进状态栏胶囊,舞台上沿贴外壳标签栏、下沿贴状态栏)+ canvas_toolbar.dart(舞台顶部居中的浮动工具条:选择 · 平移 | 图像 · 视频 · 文本 · 分镜 · 泳道 · 连线 | 更多〔导入脚本〕;取代原左侧 37px 工具条与右下角加号)+ canvas_zoom_bar.dart(舞台左下的浮动缩放条;「适应」= 适应全部)+ canvas_inspector_panel.dart(右侧检查器,页签「属性 / 历史」,原「状态」页签已删)+ inspector_history_panel.dart(检查器「历史」页:选中节点的 take 列表——配置节点列名下全部产物、产物节点列同源兄弟并高亮自己;take 号走 util/take_numbers.dart〔与左栏素材页同一个号,名下只有一个写「成片」〕;有 data 出边的标「在用」;点行 = 单选 + 居中)+ canvas_shortcuts.dart(删除 / Esc / 全选 / 缩放 + 工具单键 V/H/I/G/T/F/L,键位常量 CanvasToolKeys 与工具条 tooltip 同源;按住空格临时平移走 Focus.onKeyEvent,只在画布本身持焦时接管;Ctrl+\ / ⌘\ 开合左栏;Tab = 新建…,同样只在画布本身持主焦点时接管、别处的 Tab 放行给焦点遍历;PR-8b 加了 Ctrl+↵ / ⌘↵ 生成选中、Ctrl+D / ⌘D 复制一份、Shift+1 适应全部、Shift+2 适应选中,四个都对文本编辑让位——提示词条里的 ⌘↵ 归提示词条自己)+ create_node_menu.dart(PR-8a 新建菜单:从出端口拖线到空白处 / 双击空白 / Tab / 空白处右键「新建…」开同一个带搜索的菜单;canvasMenuRequestProvider〔开着哪个菜单、锚点、源节点 / 右键的卡〕+ canvasWireDragProvider〔拖线实时状态,Esc 取消〕+ CanvasMenuHost〔OverlayPortal 浮层,点外面 / 失焦关〕+ CanvasMenuPanel〔↑↓ / ↵ / Esc;项可置灰〔↑↓ 跳过〕/ 危险色 / 打勾 / 带子菜单——子菜单经第二个 OverlayPortal 贴在父行旁,与主菜单同一个点击区组〕;空白处右键菜单只有新建… / 导入脚本… / 全选 / 适应视图;PR-8b 节点右键菜单〔卡片上右键:没选中的先单选它,多选保持〕:生成 · 复制一份 | 存为角色 · 加入泳道 › · 在序列中定位 | 删除,不可用的置灰不隐藏)。PR-4 左栏 left_rail/:canvas_left_rail.dart(43px 图标导航条:大纲 / 素材 / 角色三页键 + 快捷键〔开设置的快捷键页〕+ 收起;剧本 / 帮助无后端不画)/ left_rail_panel.dart(241 面板宿主,收起时不占宽)/ outline_panel.dart(泳道分组的镜头行 + 产物子行 + 生成中子行,状态走 node_card 的 nodeStatusOf;点行 = 单选 + 移到视口中心)/ canvas_switcher.dart(大纲面板头的画布切换胶囊,接替旧画布树;同项目 openCanvas 不带 withProject)/ assets_panel.dart + assets_model.dart(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊 galleryGraphProvider / galleryController;每次挂载都让画廊图失效一次〔它可能被画廊标签 / 角色页保活而过期〕、开着时 job 成功再刷新;当前画布的格子按画布控制器的实时节点表过滤,删掉立即消失,点到已不存在的节点不选中;标签「源配置名 · take N / 成片」,批量 slot 写「#n」;点格子 = 当前画布定位或走画廊「在画布中定位」)/ left_panel_parts.dart(面板头 + 筛选框 + 说明行)/ left_rail_actions.dart(focusNodeOnCanvas:单选 + 居中)。P4 批量:batch_results_grid.dart(检查器内联 2×N 网格,宿主 image_result_inspector.dart 只剩一层 s12 内边距——宽度跟面板走)/ batch_compare_overlay.dart(全屏并排对比;「叠加对比」是禁用标签,不做清单)/ batch_slot_parts.dart(BatchSlotImage + BatchTappable + runBatchSlotAction——slot 动作的统一执行口,只捕具体失败类型并落 toast)。P4 角色:character_library_panel.dart(左栏角色页,PR-4 起顶上是面板头 + 「搜索角色…」筛选;行 = 缩略图 + 名 + 「N 张参考图 · M 处引用」+ ⋯ 菜单)/ character_edit_dialog.dart(改名 + 描述 + 参考图增删拖排 + 「被引用」) │ ├── command_palette/ # ⌘K/Ctrl+K command palette (PL-1; app-level, wraps InkShell). Screens 稿第 4 屏右:跨实体搜索,三组「镜头 · 当前画布 / 产物 / 动作」 │ │ ├── palette_entry.dart # PaletteEntry / PaletteChoice(hand-written value objects; run = ↵ 打开, locate = ⌘↵ 在画布中定位) │ │ ├── palette_search.dart # buildPaletteEntries — 镜头组只搜当前画布已加载节点(不为搜索读库)、产物组复用 galleryController、动作组 = buildCommandActions @@ -272,7 +272,7 @@ lib/ │ │ ├── models/ # shell_state.dart (ShellTab / ShellOverlay / ProjectRef / ShellState — hand-written value object, 8 named transitions incl. closeCanvas / closeProject context-invalidation, no copyWith) │ │ ├── providers/ # shell_controller.dart (ShellNavigator Notifier + shellControllerProvider — sole write entry point) + active_project.dart (activeProjectProvider — read-only projection of ShellState.project) + gallery_dirty.dart (galleryDirtyProvider — any job reaching JobSucceeded marks *its own project's* gallery dirty, keyed by JobState.projectId; the gallery tab refreshes once on the invisible→visible edge **if the active project is dirty**, then clears that project's mark. State is a `Set` where `null` is the "project unknown" wildcard — unreachable in production, falls back to the old global semantics. NOT live refresh: a gallery the user is already looking at stays put until they switch away and back) │ │ ├── util/ # tab_availability.dart (hasNarrativeEdges / canExportVideo — pure predicates moved verbatim out of the deleted canvas_top_chrome.dart) -│ │ └── widgets/ # ink_shell.dart (sole root Scaffold + chrome + tab bar) / shell_content_stack.dart (outer Stack: tab host under an ExcludeFocus + overlay on top; inner five-slot IndexedStack; fallback focus) / shell_keep_alive_host.dart (five lazily-materialized, then permanent tab slots) / shell_chrome.dart (the tree's only InkWindowChrome; trailing = ⌘K chip + notification bell + ⚙) / shell_breadcrumb.dart / shell_empty_state.dart / shell_overlay_layer.dart (ModalBarrier scrim + the settings overlay — ShellOverlay 只剩 settings(P7 删掉内置示例页); NOT kept alive; the barrier is what stops click-through) / shell_tab_bar.dart (wires ShellState into the theme-layer InkShellTabBar — that component knows nothing about ShellTab, see test/quality/no_reverse_layer_import_test.dart;画布标签右侧 = 「在序列中查看」〔切到序列标签 + 播放头跳到选中节点所在的镜,走 sequence_playhead 的 selectShot;本体 PR-8b 起在 sequence/util/view_in_sequence.dart,与节点右键菜单共用〕+「导出视频」,PR-6 撤掉了「导入脚本」) / shell_status_bar.dart (壳级状态栏:左侧随标签的计数 + 任务胶囊 + 右侧存储路径 / 版本) / shell_task_capsule.dart (PR-6 状态栏任务胶囊,取代画布底栏的渲染队列:全部 job、所有标签下都在;活动态「生成中 N · 排队 M」/ 终态「任务 N · 失败 K」/ 注册表空不画;点开任务弹层,再点或 Esc 关) / shell_task_popover.dart (任务弹层:数据与动作照搬原渲染队列——活动在前终态在后、源节点名回退 provider 名、取消 / 定位〔跨画布先 openCanvas 再单选 + 居中〕/ 清除已完成、「并发 N」) + tabs/ (studio / canvas / sequence / gallery / export bodies) +│ │ └── widgets/ # ink_shell.dart (sole root Scaffold + chrome + tab bar; also hosts CanvasJobListener, so a job reaching a terminal state invalidates *its own canvas's* node controller even when no canvas page is mounted) / shell_content_stack.dart (outer Stack: tab host under an ExcludeFocus + overlay on top; inner five-slot IndexedStack; fallback focus) / shell_keep_alive_host.dart (five lazily-materialized, then permanent tab slots) / shell_chrome.dart (the tree's only InkWindowChrome; trailing = ⌘K chip + notification bell + ⚙) / shell_breadcrumb.dart / shell_empty_state.dart / shell_overlay_layer.dart (ModalBarrier scrim + the settings overlay — ShellOverlay 只剩 settings(P7 删掉内置示例页); NOT kept alive; the barrier is what stops click-through) / shell_tab_bar.dart (wires ShellState into the theme-layer InkShellTabBar — that component knows nothing about ShellTab, see test/quality/no_reverse_layer_import_test.dart;画布标签右侧 = 「在序列中查看」〔切到序列标签 + 播放头跳到选中节点所在的镜,走 sequence_playhead 的 selectShot;本体 PR-8b 起在 sequence/util/view_in_sequence.dart,与节点右键菜单共用〕+「导出视频」,PR-6 撤掉了「导入脚本」) / shell_status_bar.dart (壳级状态栏:左侧随标签的计数 + 任务胶囊 + 右侧存储路径 / 版本) / shell_task_capsule.dart (PR-6 状态栏任务胶囊,取代画布底栏的渲染队列:全部 job、所有标签下都在;活动态「生成中 N · 排队 M」/ 终态「任务 N · 失败 K」/ 注册表空不画;点开任务弹层,再点或 Esc 关) / shell_task_popover.dart (任务弹层:数据与动作照搬原渲染队列——活动在前终态在后、源节点名回退 provider 名、取消 / 定位〔跨画布先 openCanvas 再单选 + 居中〕/ 清除已完成、「并发 N」) + tabs/ (studio / canvas / sequence / gallery / export bodies) │ ├── startup/ # Startup failure surface (DB-ready gate; LB-09) │ │ └── widgets/ # StartupErrorView (full-screen error + retry + open-log-dir) │ ├── storyboard/ # Script in, sequence out — paste a script into a shot chain (SB-1/SB-2), play the chain end to end (SB-6) @@ -283,7 +283,7 @@ lib/ │ ├── sequence/ # 序列视图(P2,Timeline 稿):只读序列 lens 常驻在序列标签——叙事链 320 | 节目监视器 | 交付占位 320;序列区 300(时间尺 / 场次标记 / V1 只读轨 / 播放头) │ │ ├── models/ # sequence_lens.dart (buildSequenceLens: shots + startsMs + totalMs + 场次标记由链上 shot 节点推 + 未入链计数;locate / globalMs 纯函数) │ │ ├── providers/ # sequence_lens_provider (watch 节点 + 边控制器) / sequence_playhead (index + offsetMs + seekToken:监视器 report 不动 token,用户 seek 才自增;不落库) / sequence_zoom (px/s,Ctrl+滚轮,8..160) -│ │ ├── util/ # timecode.dart (HH:MM:SS:FF / 短格式,24fps 常量——帧率无字段) + view_in_sequence.dart (PR-8b 从 shell_tab_bar 原样搬出:viewInSequence 切到序列标签并把播放头跳到节点所在那一镜〔临时订阅撑过一帧〕+ shotIndexForSelection 判据;标签栏「在序列中查看」与节点右键菜单「在序列中定位」共用) +│ │ ├── util/ # timecode.dart (HH:MM:SS:FF / 短格式,24fps 常量——帧率无字段) + view_in_sequence.dart (PR-8b 从 shell_tab_bar 原样搬出:viewInSequence 切到序列标签并把播放头跳到节点所在那一镜〔临时订阅撑过一帧〕;标签栏「在序列中查看」与节点右键菜单「在序列中定位」共用) + shot_index.dart (节点在序列哪一镜的唯一判据,纯函数:shotIndexForSelection〔跳哪一镜、「在序列中定位」可不可点〕+ shotIndexByNode〔每个节点所在那一镜;左栏大纲与 ⌘K 的「序列 NNN」经 outline_model.sequenceShotNumbers 用它〕) │ │ └── widgets/ # sequence_screen.dart (三栏 + 序列区;点 / 拖轨道 = seekGlobal,只改 Player 位置) + sequence_monitor.dart (media_kit Player:不自动播放;paused = !isTabVisible ⇒ 立即 pause、回来不续播) │ └── studio/ # Project / workspace shell (Screens 稿第 1 屏:库 220 | 工具行 + 恢复条 + 4 列项目网格;first-run onboarding dialog; ON-1/ON-2) │ ├── studio_home_screen.dart # + showStudioNewProjectDialog / createStudioSampleProject(标签栏「新建项目」与网格虚线格共用) diff --git a/lib/features/canvas/README.md b/lib/features/canvas/README.md index c048d693..650ff21e 100644 --- a/lib/features/canvas/README.md +++ b/lib/features/canvas/README.md @@ -37,18 +37,18 @@ util/ 无副作用的几何/命中/预设工具 - `canvas_screen` — 整屏骨架:左栏(43px 导航条 + 可收起的 241 面板)| 画布区(`canvas_view` 舞台 + 浮层:顶部居中 `canvas_toolbar` / 底部居中提示词条 / 左下 `canvas_zoom_bar`)| 检查器;外层包 `canvas_shortcuts`(删除/Esc/全选/缩放 + 工具单键 V/H/I/G/T/F/L + 按住空格平移 + Ctrl+\ / ⌘\ 开合左栏;`isActive` 由外壳按标签可见性下传)。PR-6 页签去重撤掉了舞台上方的画布头(画布名在面包屑、泳道名写在泳道上、缩放比例在缩放条)和底部的渲染队列(任务进度挪到外壳状态栏的任务胶囊 + 任务弹层,见 `features/shell/widgets/shell_task_capsule.dart`),舞台上沿贴外壳标签栏、下沿贴状态栏。窗口 chrome 与面包屑在外壳 `ShellChrome` - `canvas_zoom_bar` — 舞台左下的浮动缩放条(缩小 / % / 放大 / 适应),四格都走 `InkActivatable` - `canvas_toolbar` — 舞台顶部居中的浮动工具条:选择 · 平移 | 图像 · 视频 · 文本 · 分镜 · 泳道 · 连线 | 更多(导入脚本)。取代原左侧 37px 工具条与右下角加号 -- `left_rail/` — 画布左栏(PR-4):`canvas_left_rail`(43px 图标导航条:大纲 / 素材 / 角色 + 快捷键 / 收起)· `left_rail_panel`(241 面板宿主)· `outline_panel`(泳道分组的镜头行 + 产物子行,点行 = 单选 + 移到视口中心)· `canvas_switcher`(大纲面板头的画布切换胶囊)· `assets_panel` / `assets_model`(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊)· `left_panel_parts`(面板头 / 筛选框)· `left_rail_actions`(定位:单选 + 居中);角色页仍是 `character_library_panel`(顶上加了面板头 + 筛选) +- `left_rail/` — 画布左栏(PR-4):`canvas_left_rail`(43px 图标导航条:大纲 / 素材 / 角色 + 快捷键 / 收起)· `left_rail_panel`(241 面板宿主)· `outline_panel`(泳道分组的镜头行 + 产物子行,点行 = 单选 + 移到视口中心)· `canvas_switcher`(大纲面板头的画布切换胶囊)· `assets_panel` / `assets_model`(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊;开页即让画廊图失效一次,删掉的节点格子立刻消失)· `left_panel_parts`(面板头 / 筛选框)· `left_rail_actions`(定位:单选 + 居中);角色页仍是 `character_library_panel`(顶上加了面板头 + 筛选) - `canvas_view` / `edge_painter` — 画布视口与连线绘制 - `node_card` / `video_node_body` — 节点卡片各形态 - `canvas_inspector_panel` — 右侧检查器:页签「属性 / 历史」(原「状态」页签只画不挂交互,已删——状态内嵌在各配置检查器的 `inspector_status_panel` 里) -- `inspector_history_panel` — 检查器「历史」页:选中配置节点 = 名下全部 take(`node_artifacts.resultsFor`,新 → 旧),选中产物节点 = 同一来源的兄弟、当前那张高亮;「take N」按时间正序编号;有 data 出边(首帧 / 尾帧 / 参考)的 take 标「在用」;点一行 = 单选 + 居中(`left_rail_actions.focusNodeOnCanvas`) +- `inspector_history_panel` — 检查器「历史」页:选中配置节点 = 名下全部 take(`node_artifacts.resultsFor`,新 → 旧),选中产物节点 = 同一来源的兄弟、当前那张高亮;「take N」按时间正序编号(`util/take_numbers`,与素材页同一个号;名下只有一个写「成片」);有 data 出边(首帧 / 尾帧 / 参考)的 take 标「在用」;点一行 = 单选 + 居中(`left_rail_actions.focusNodeOnCanvas`) - `node_inspector_router` — 按 (role, type) 分发:config → `image_config_inspector` / `video_config_inspector` / `shot_config_inspector`(前二者共用 `inspector_status_panel`);result+image → `image_result_inspector` - `node_inputs_section` — config 节点入边列表 + role 切换(reference/first_frame/last_frame,按能力位门控),被 image/video config inspector 挂载 - `batch_results_grid` — 结果节点的批量/变体槽位网格(挂载于 `image_result_inspector`) -- `lane_*`(background/title_bar/toolbar/edit_dialog)/ `base_style_editor_dialog` / `canvas_empty_state` / `video_lightbox` / `canvas_job_listener` +- `lane_*`(background/title_bar/toolbar/edit_dialog)/ `base_style_editor_dialog` / `canvas_empty_state` / `video_lightbox` / `canvas_job_listener`(挂在外壳根 `InkShell`,不随画布页挂载) ## util -`node_position` · `edge_hit_test` · `lane_geometry` · `lane_tint` · `base_style_presets` · `canvas_job_effects` · `canvas_zoom` · `canvas_node_delete` · `canvas_snackbars` · `canvas_tool_actions`(工具条与单键快捷键共用:视口中心建节点 / 建泳道 / 切连线 / 选择工具)· `outline_model`(左栏大纲分组 + 「在链上」/ 链序号的唯一判据,⌘K 共用)· `viewport_focus`(保持缩放、把节点移到视口中心) +`node_position` · `edge_hit_test` · `lane_geometry` · `lane_tint` · `base_style_presets` · `canvas_job_effects` · `canvas_zoom` · `canvas_node_delete` · `canvas_snackbars` · `canvas_tool_actions`(工具条与单键快捷键共用:视口中心建节点 / 建泳道 / 切连线 / 选择工具)· `outline_model`(左栏大纲分组 + 「在链上」的唯一判据;镜号走序列口径 `sequenceShotNumbers`,⌘K 共用)· `take_numbers`(take 号,素材页与检查器历史页共用)· `viewport_focus`(保持缩放、把节点移到视口中心) ## 数据流(生成一次图/视频) 1. 选中 config 节点 → `node_inspector_router` 渲染对应 inspector diff --git a/lib/features/canvas/util/node_menu_model.dart b/lib/features/canvas/util/node_menu_model.dart index 4f1c4a56..d631aed9 100644 --- a/lib/features/canvas/util/node_menu_model.dart +++ b/lib/features/canvas/util/node_menu_model.dart @@ -5,7 +5,7 @@ // 各项的作用范围: // - 生成 / 存为角色:单张动作——目标恰好一张才可点(与画布 Ctrl+↵「恰好选中一张」同一个口径); // - 复制一份 / 加入泳道 / 删除:整批——多选时对目标里的每一张; -// - 在序列中定位:目标里链序最靠前的那一镜(判据在 sequence/util/view_in_sequence.dart,与标签栏共用)。 +// - 在序列中定位:目标里链序最靠前的那一镜(判据在 sequence/util/shot_index.dart,与标签栏共用)。 // 不可用的项置灰、不隐藏(菜单行的 enabled),判据全在这里。 // // 【加入泳道要挪位置】见 [laneMoveDelta]:位置决定泳道归属是整个画布的规则,只改 lane_id 卡片会画在带外。 diff --git a/lib/features/canvas/util/outline_model.dart b/lib/features/canvas/util/outline_model.dart index c252e3ef..322fbc97 100644 --- a/lib/features/canvas/util/outline_model.dart +++ b/lib/features/canvas/util/outline_model.dart @@ -6,12 +6,24 @@ // b. 不在链上的配置节点进「未入链」,放在最后,按位置 (dx, dy, id) 排、不编号。 // 空组不出:没有镜头的泳道不画组头,筛选后一个都不匹配的组整组不画。 // -// 「在链上」的判据只有一份:[narrativeChainMembers]——narrative 边两端都在节点集里。 -// ⌘K 镜头组的「序列 003」与这里的「01」都从 [narrativeSequenceIndex] 来,两处不会走岔。 +// 「在链上」的判据只有一份:[narrativeChainMembers]——narrative 边两端都在节点集里。组内行序是叙事链序 +// ([narrativeSequenceIndex],不折叠:链上每个节点都排得上位置)。 // -// 链序号是**全画布**的叙事链序(1 起),不是组内序号:同一镜在大纲、⌘K、画廊里是同一个号。 +// 镜号(行首的「01」、⌘K 镜头组的「序列 003」)用**序列的口径**:[sequenceShotNumbers]——序列 lens 折叠后的 +// 镜序(全画布的,1 起,不是组内序号),节点归哪一镜复用「在序列中查看」的判据 +// (sequence/util/shot_index.dart)。同一张图在大纲、⌘K、序列视图、画廊(「序列 002」)里因此是同一个号。 +// 几种节点由此共号或无号: +// - 借出产物的配置节点与借走它产物的那一镜同号(「导入剧本 + 用本镜备注生成图像」那条主路径:镜头 → +// 图像配置,图挂在配置名下,序列里放在镜头那一镜); +// - 链上但不在任何一镜里的节点(折叠时被跳过:自己没产物没备注,也没借到、没被借)不编号,仍留在泳道组里。 +// 组头「N 镜」是组内不同镜号的个数([OutlineGroup.shotCount]),不是行数。 +// +// 「未入链」组列的是不在链上的**全部**配置节点(有没有产物都列);序列标签状态栏的「未入链 · N」只数其中 +// 有产物的(SequenceLens.unchainedCount)——两处口径不同,数字对不上是正常的。 import 'package:flutter/foundation.dart'; +import '../../sequence/models/sequence_lens.dart'; +import '../../sequence/util/shot_index.dart'; import '../models/canvas_edge.dart'; import '../models/canvas_node.dart'; import '../models/style_lane.dart'; @@ -35,7 +47,9 @@ Set narrativeChainMembers( return members; } -/// 链上节点 → 1 起的链序号;顺序 = 叙事链序([orderByNarrativeChain])。不在链上的节点不在表里。 +/// 链上节点 → 1 起的**链序**(叙事链 DFS 序,[orderByNarrativeChain],不折叠)。不在链上的节点不在表里。 +/// +/// 只给大纲排组内行序用;显示出来的镜号不是它,是 [sequenceShotNumbers](序列折叠后的镜序)。 Map narrativeSequenceIndex( List nodes, List edges, @@ -52,7 +66,21 @@ Map narrativeSequenceIndex( }; } -/// 一行镜头:配置节点 + 链序号 + 名下最新产物。 +/// 节点 → 它所在那一镜的镜号(1 起),序列的口径:大纲行首的镜号、⌘K 镜头组的「序列 NNN」都从这里取, +/// 与序列视图、画廊的序号是同一个数。 +/// +/// 镜 = 序列 lens 的镜([buildSequenceLens]:叙事链折叠后只留链上的镜——「借产物」的后继并进前一镜, +/// 既无产物也无备注的节点跳过);节点归哪一镜 = [shotIndexByNode](与「在序列中查看」同一判据:产物归 +/// 来源配置节点,借出产物的配置节点归借走它的那一镜)。不在任何一镜里的节点不在表里。 +Map sequenceShotNumbers(List nodes, List edges) { + final SequenceLens lens = buildSequenceLens(nodes: nodes, edges: edges); + return { + for (final MapEntry e in shotIndexByNode(lens, nodes: nodes).entries) + e.key: e.value + 1, + }; +} + +/// 一行镜头:配置节点 + 镜号 + 名下最新产物。 @immutable class OutlineShot { const OutlineShot({ @@ -64,7 +92,8 @@ class OutlineShot { final CanvasNode node; - /// 全画布叙事链序(1 起);未入链为 null(不编号)。 + /// 所在那一镜的镜号(1 起,序列口径,[sequenceShotNumbers]);未入链、或链上但不在任何一镜里为 null + /// (不编号)。 final int? sequence; /// 名下最新的产物(result 节点,[resultsFor] 口径:按 created_at 取最新);没有为 null。 @@ -96,6 +125,12 @@ class OutlineGroup { /// 至少一行(空组不产出)。 final List shots; + + /// 组内不同镜号的个数(组头「N 镜」):同一镜的几行(镜头 + 借出产物的配置)只算一镜,没有镜号的行不算。 + int get shotCount => { + for (final OutlineShot s in shots) + if (s.sequence case final int n) n, + }.length; } /// 一次分组的结果。 @@ -123,7 +158,9 @@ CanvasOutline buildCanvasOutline({ for (final CanvasNode n in nodes) if (n.role == NodeRole.config) n, ]; - final Map sequence = narrativeSequenceIndex(nodes, edges); + // 链序只管「谁在链上、组内怎么排」;行首显示的镜号走序列口径。 + final Map chainOrder = narrativeSequenceIndex(nodes, edges); + final Map shotNumber = sequenceShotNumbers(nodes, edges); final String q = query.trim().toLowerCase(); bool matches(CanvasNode n) { @@ -148,7 +185,7 @@ CanvasOutline buildCanvasOutline({ ); return OutlineShot( node: n, - sequence: sequence[n.id], + sequence: shotNumber[n.id], latest: results.isEmpty ? null : results.first, artifactCount: results.length, ); @@ -156,13 +193,13 @@ CanvasOutline buildCanvasOutline({ final List chained = [ for (final CanvasNode n in configs) - if (sequence.containsKey(n.id)) n, - ]..sort((CanvasNode a, CanvasNode b) => sequence[a.id]!.compareTo(sequence[b.id]!)); + if (chainOrder.containsKey(n.id)) n, + ]..sort((CanvasNode a, CanvasNode b) => chainOrder[a.id]!.compareTo(chainOrder[b.id]!)); // 没有边时 orderByNarrativeChain 退化成按 (dx, dy, id) 排——「按位置」借它的口径,不另写比较器。 final List unchained = orderByNarrativeChain( nodes: [ for (final CanvasNode n in configs) - if (!sequence.containsKey(n.id)) n, + if (!chainOrder.containsKey(n.id)) n, ], edges: const [], ); diff --git a/lib/features/canvas/util/take_numbers.dart b/lib/features/canvas/util/take_numbers.dart new file mode 100644 index 00000000..13587723 --- /dev/null +++ b/lib/features/canvas/util/take_numbers.dart @@ -0,0 +1,32 @@ +// take 号:素材页(widgets/left_rail/assets_model.dart 的格子标签)与检查器历史页(widgets/inspector_history_panel.dart) +// 共用的那一个编号函数,两处的号不会走岔。 +// +// 放在 util 而不是素材页的 model 里:检查器不该为了一个编号函数去 import 左栏素材页的文件。 +import '../models/canvas_node.dart'; +import 'node_artifacts.dart'; + +/// take 号——素材页的格子标签与检查器历史页共用这一个函数,两处的号不会走岔。 +/// +/// 同一来源(result 节点的 sourceNodeId,即它的配置节点)名下有产物的 result 节点按生成时间正序编号, +/// 最早的是 take 1;「有产物」「谁先谁后」都照 [resultsFor](产物 url 非空;按 created_at,缺戳的垫底)。 +/// 名下只有一个的不进表——它是「成片」,两处都不写号;没有来源的孤儿产物也不进表(各自成片)。 +/// 批量 slot 不是 result 节点,不参与;批量的 result 容器转正之前没有产物 url,同样不参与。 +/// [nodes] 是同一张画布的节点。 +Map takeNumbersOn(List nodes) { + // 按来源分桶,桶内保持节点列表原序(resultsFor 对缺时间戳的产物按原序定先后)。 + final Map> bySource = >{}; + for (final CanvasNode n in nodes) { + final String? source = n.sourceNodeId; + if (n.role != NodeRole.result || source == null) continue; + (bySource[source] ??= []).add(n); + } + final Map out = {}; + for (final MapEntry> e in bySource.entries) { + final List newestFirst = resultsFor(sourceNodeId: e.key, nodes: e.value); + if (newestFirst.length < 2) continue; + for (int i = 0; i < newestFirst.length; i++) { + out[newestFirst[i].id] = newestFirst.length - i; + } + } + return out; +} diff --git a/lib/features/canvas/widgets/canvas_job_listener.dart b/lib/features/canvas/widgets/canvas_job_listener.dart index 2afefc42..05db9b1d 100644 --- a/lib/features/canvas/widgets/canvas_job_listener.dart +++ b/lib/features/canvas/widgets/canvas_job_listener.dart @@ -2,6 +2,16 @@ // 纯决策在 CanvasJobEffects.diff;本 widget 只负责 ref.listen + 执行副作用, // 可在 widget test 中独立 pump(无 DB 依赖)。 // +// 挂在外壳根(InkShell),不随画布页挂载 / 卸载:没开画布、画布页还没物化时落的产物,同样要让 job 所在画布的 +// 控制器失效(理由见 shell/widgets/ink_shell.dart 头注)。 +// +// 【按 job 所在画布刷新,不只当前画布】节点控制器按画布分族,当前画布之外的那几份也可能活着: +// 状态栏的任务弹层开着时,为每个 job 去 job 所在画布的控制器里找源节点(任务名、「定位」都靠它), +// 别的画布的控制器因此一直被保活。那张画布上的 job 落了产物却没人让它的控制器失效,「定位」过去 +// 看到的就是旧节点表:产物卡还在「等待中」、大纲没有子行、历史页是空的。所以注册表里出现过 job 的 +// 每一张画布都单独过一遍 diff(判据与当前画布同一份)。没人 watch 的画布 invalidate 是空操作 +// (Riverpod:对还没初始化的 provider 调 invalidate 不生效),不用先判断。 +// // 【这里不弹通知】生成完成 / 失败的通知卡在应用级的 JobNotificationListener // (features/notifications,挂在 ToastLayer 里):它不随当前画布走,切走画布、换了项目 // 照样通知。这里再弹一次,失败就会出两张卡。本 widget 只管画布这一侧的刷新。 @@ -12,7 +22,6 @@ import '../../generation/models/job_state.dart'; import '../../generation/providers/batch_results_controller.dart'; import '../../generation/providers/jobs_registry.dart'; import '../providers/canvas_nodes_controller.dart'; -import '../providers/current_canvas_id.dart'; import '../util/canvas_job_effects.dart'; class CanvasJobListener extends ConsumerWidget { @@ -22,20 +31,22 @@ class CanvasJobListener extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canvasId = ref.watch(currentCanvasIdProvider); ref.listen>(jobsRegistryProvider, (prev, next) { - if (canvasId == null) return; - final effect = CanvasJobEffects.diff( - prev: prev ?? const [], - next: next, - canvasId: canvasId, - ); - if (effect.shouldReloadNodes) { - ref.invalidate(canvasNodesControllerProvider(canvasId)); - } - // 终态定点刷新批量网格(autoDispose family:未被 watch 时 invalidate 为 no-op)。 - for (final nodeId in effect.invalidateBatchNodes) { - ref.invalidate(batchResultsControllerProvider(nodeId)); + final List before = prev ?? const []; + final Set canvases = {for (final JobState j in next) j.canvasId}; + for (final String canvasId in canvases) { + final CanvasJobEffect effect = CanvasJobEffects.diff( + prev: before, + next: next, + canvasId: canvasId, + ); + if (effect.shouldReloadNodes) { + ref.invalidate(canvasNodesControllerProvider(canvasId)); + } + // 终态定点刷新批量网格(autoDispose family:未被 watch 时 invalidate 为 no-op)。 + for (final String nodeId in effect.invalidateBatchNodes) { + ref.invalidate(batchResultsControllerProvider(nodeId)); + } } }); return child; diff --git a/lib/features/canvas/widgets/canvas_screen.dart b/lib/features/canvas/widgets/canvas_screen.dart index 36df3a91..0dc02b8b 100644 --- a/lib/features/canvas/widgets/canvas_screen.dart +++ b/lib/features/canvas/widgets/canvas_screen.dart @@ -21,7 +21,6 @@ import '../../../theme/app_theme.dart'; import '../../../theme/tokens.dart'; import '../providers/current_canvas_id.dart'; import 'canvas_inspector_panel.dart'; -import 'canvas_job_listener.dart'; import 'canvas_prompt_bar.dart'; import 'canvas_shortcuts.dart'; import 'canvas_toolbar.dart'; @@ -41,51 +40,49 @@ class CanvasScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colors = context.inkColors; final String? canvasId = ref.watch(currentCanvasIdProvider); - return CanvasJobListener( - child: Scaffold( - backgroundColor: colors.surface2, - // PL-2:画布快捷键层包裹整行(含 Inspector),autofocus 使按键即时生效; - // 焦点在 Inspector 文本框时删除/全选让位文本编辑(见 CanvasShortcuts)。 - body: CanvasShortcuts( - isActive: isVisible, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (canvasId != null) ...[ - const CanvasLeftRail(), - CanvasLeftRailPanel(canvasId: canvasId), - ], - Expanded( - child: Stack( - children: [ - const Positioned.fill(child: CanvasView()), - if (canvasId != null) ...[ - Positioned( - left: InkSpacing.s12, - bottom: InkSpacing.s12, - child: CanvasZoomBar(canvasId: canvasId), - ), - Positioned( - left: 0, - right: 0, - bottom: 16, - child: CanvasPromptBar(canvasId: canvasId), - ), - // 最后画:压在舞台浮层(连线提示等)之上,那些浮层的 top 已让出 - // 工具条的高度(InkToolbarSize.stageTopInset)。 - Positioned( - top: InkSpacing.s12, - left: 0, - right: 0, - child: Center(child: CanvasToolbar(canvasId: canvasId)), - ), - ], + return Scaffold( + backgroundColor: colors.surface2, + // PL-2:画布快捷键层包裹整行(含 Inspector),autofocus 使按键即时生效; + // 焦点在 Inspector 文本框时删除/全选让位文本编辑(见 CanvasShortcuts)。 + body: CanvasShortcuts( + isActive: isVisible, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (canvasId != null) ...[ + const CanvasLeftRail(), + CanvasLeftRailPanel(canvasId: canvasId), + ], + Expanded( + child: Stack( + children: [ + const Positioned.fill(child: CanvasView()), + if (canvasId != null) ...[ + Positioned( + left: InkSpacing.s12, + bottom: InkSpacing.s12, + child: CanvasZoomBar(canvasId: canvasId), + ), + Positioned( + left: 0, + right: 0, + bottom: 16, + child: CanvasPromptBar(canvasId: canvasId), + ), + // 最后画:压在舞台浮层(连线提示等)之上,那些浮层的 top 已让出 + // 工具条的高度(InkToolbarSize.stageTopInset)。 + Positioned( + top: InkSpacing.s12, + left: 0, + right: 0, + child: Center(child: CanvasToolbar(canvasId: canvasId)), + ), ], - ), + ], ), - if (canvasId != null) CanvasInspectorPanel(canvasId: canvasId), - ], - ), + ), + if (canvasId != null) CanvasInspectorPanel(canvasId: canvasId), + ], ), ), ); diff --git a/lib/features/canvas/widgets/create_node_menu.dart b/lib/features/canvas/widgets/create_node_menu.dart index be0e1484..fdbd0974 100644 --- a/lib/features/canvas/widgets/create_node_menu.dart +++ b/lib/features/canvas/widgets/create_node_menu.dart @@ -45,6 +45,7 @@ import '../../../theme/tokens.dart'; import '../../../theme/typography.dart'; import '../../sequence/models/sequence_lens.dart'; import '../../sequence/providers/sequence_lens_provider.dart'; +import '../../sequence/util/shot_index.dart'; import '../../sequence/util/view_in_sequence.dart'; import '../../settings/util/shortcut_catalog.dart'; import '../../shell/models/shell_state.dart'; diff --git a/lib/features/canvas/widgets/inspector_history_panel.dart b/lib/features/canvas/widgets/inspector_history_panel.dart index 1c46e131..a6ffb6ba 100644 --- a/lib/features/canvas/widgets/inspector_history_panel.dart +++ b/lib/features/canvas/widgets/inspector_history_panel.dart @@ -5,7 +5,8 @@ // - 选中产物节点 ⇒ 它的兄弟(同一个 sourceNodeId,含它自己),当前这张高亮(surface5 底); // 没有来源的孤儿产物只列它自己。 // 顶部说明「<节点名> 的全部 take」:配置节点用它自己的引用名(nodeRefName);产物节点用它来源的那个。 -// 行:72×41 缩略(cover、圆角 3、1px outline)|「take N」(N 按时间正序,最早的是 take 1) +// 行:72×41 缩略(cover、圆角 3、1px outline)|「take N」(N 按时间正序,最早的是 take 1;名下只有一个写 +// 「成片」——号由 util/take_numbers.dart 的 takeNumbersOn 给,与左栏素材页的格子同一个函数) // + meta(10 号 fg5):相对时间(复用画廊的 galleryTimeAgo)· 种子(type_config 里有 seed 才写)。 // 「在用」= 这个 take 有 data 出边(首帧 / 尾帧 / 参考,往下游流):缩略描边换 accent,右侧写「在用」。 // 稿上的图钉「固定」没有数据模型,不画。 @@ -31,6 +32,7 @@ import '../models/canvas_node.dart'; import '../providers/canvas_edges_controller.dart'; import '../providers/canvas_nodes_controller.dart'; import '../util/node_artifacts.dart'; +import '../util/take_numbers.dart'; import 'left_rail/left_rail_actions.dart'; import 'node_card.dart' show nodeRefName; @@ -74,6 +76,8 @@ class InspectorHistoryPanel extends ConsumerWidget { final String? projectId = ref.watch(activeProjectProvider)?.id; final DateTime now = ref.watch(clockProvider).nowUtc(); final CanvasNode owner = _ownerOf(node, nodes); + // take 号与左栏素材页同一个函数:按时间正序、最早的是 take 1;名下只有一个 ⇒ 不在表里 =「成片」。 + final Map takeNumbers = takeNumbersOn(nodes); return ListView( padding: EdgeInsets.zero, @@ -92,8 +96,7 @@ class InspectorHistoryPanel extends ConsumerWidget { canvasId: canvasId, projectId: projectId, take: takes[i], - // takes 是新 → 旧;编号按时间正序,最早的是 take 1。 - number: takes.length - i, + number: takeNumbers[takes[i].id], meta: _metaOf(l, takes[i], now), current: takes[i].id == node.id, inUse: flowing.contains(takes[i].id), @@ -149,7 +152,9 @@ class _TakeRow extends ConsumerWidget { final String canvasId; final String? projectId; final CanvasNode take; - final int number; + + /// take 号;null =「成片」(名下只有这一个)。 + final int? number; final String meta; final bool current; final bool inUse; @@ -166,6 +171,7 @@ class _TakeRow extends ConsumerWidget { final File? file = (rel == null || pid == null) ? null : galleryResolveFile(ref, projectId: pid, canvasId: canvasId, relativePath: rel); + final int? n = number; return InkActivatable( key: InspectorHistoryPanel.takeKey(take.id), onTap: onTap, @@ -205,7 +211,7 @@ class _TakeRow extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - l.inspectorHistoryTake(number), + n == null ? l.inspectorHistoryFinal : l.inspectorHistoryTake(n), maxLines: 1, overflow: TextOverflow.ellipsis, style: t.body.copyWith(color: c.fg2), diff --git a/lib/features/canvas/widgets/left_rail/assets_model.dart b/lib/features/canvas/widgets/left_rail/assets_model.dart index fa8361e9..8eaf21b5 100644 --- a/lib/features/canvas/widgets/left_rail/assets_model.dart +++ b/lib/features/canvas/widgets/left_rail/assets_model.dart @@ -1,14 +1,20 @@ -// 左栏「素材」页的纯函数:分段(本画布 / 项目全部)、类型计数与过滤、网格标签。 +// 左栏「素材」页的纯函数:分段(本画布 / 项目全部)、删掉的不显示、类型计数与过滤、网格标签。 // // 数据是画廊那一份(galleryGraphProvider → galleryControllerProvider 的项目级产物),这里只做派生, -// 不读库。网格标签的「源配置名」取画廊的 galleryConfigNodeOf(result 节点走 sourceNodeId,批量 slot -// 先经它挂的 result 容器),与画廊信息栏同一口径。 +// 不读库;当前画布另有画布控制器里的实时节点表,删除靠它立即生效。网格标签的「源配置名」取画廊的 +// galleryConfigNodeOf(result 节点走 sourceNodeId,批量 slot 先经它挂的 result 容器),与画廊信息栏 +// 同一口径。 +// +// take 号:[takeNumbersOn](canvas/util/take_numbers.dart)是素材页格子与检查器历史页共用的那一个编号函数—— +// 每个配置节点名下有产物的 result 节点按时间正序编号,名下只有一个时两处都写「成片」;批量 slot 不参与 take +// 编号,照画廊写「#n」。 import 'package:flutter/foundation.dart'; import '../../../gallery/models/gallery_graph.dart'; import '../../../gallery/models/gallery_item.dart'; import '../../../gallery/util/gallery_meta.dart'; import '../../models/canvas_node.dart'; +import '../../util/take_numbers.dart'; /// 分段:本画布 / 项目全部。 enum AssetScope { canvas, project } @@ -33,6 +39,22 @@ List assetsInScope( ], }; +/// 删掉的不显示:[canvasId](当前画布)的条目,节点已不在 [liveNodes](画布控制器里的实时节点表)里的 +/// 去掉——删除立即生效,不等画廊图重读。批量 slot 的 nodeId 是它挂的 result 容器,容器删了 slot 跟着走。 +/// 别的画布的条目原样(手里没有它们的实时节点表);[liveNodes] 为 null(还没载入)时不过滤。保持传入的序。 +List assetsWithoutDeleted( + List items, { + required String canvasId, + required List? liveNodes, +}) { + if (liveNodes == null) return items; + final Set alive = {for (final CanvasNode n in liveNodes) n.id}; + return [ + for (final GalleryItem i in items) + if (i.canvasId != canvasId || alive.contains(i.nodeId)) i, + ]; +} + AssetCounts countAssets(List items) { int image = 0; int video = 0; @@ -61,57 +83,44 @@ List assetsOfKind(List items, AssetKindFilter kind) => ], }; -/// 网格标签的两段:源配置节点 + take 序号。界面拼成「源配置名 · 成片」或「源配置名 · take N」。 +/// 网格标签的几段:源配置节点 + take 号 / slot 号。界面拼成「源配置名 · 成片」「源配置名 · take N」, +/// 批量 slot 照画廊写「源配置名 #n」。 @immutable class AssetCaption { - const AssetCaption({this.config, this.take}); + const AssetCaption({this.config, this.take, this.slot}); /// 源 config 节点;来源节点已经不在(孤儿产物)时为 null,界面回落到类型名。 final CanvasNode? config; - /// 同一 config 名下不止一个产物时,本产物按生成时间从早到晚的序号(1 起);只有一个时为 null =「成片」。 + /// 产物节点的 take 号([takeNumbersOn],与检查器历史页同一个号,1 起);名下只有它一个、没有来源、 + /// 或者是批量 slot 时为 null——节点主产物写「成片」。 final int? take; + + /// 批量 slot 的槽位号(1 起):不参与 take 编号,界面照画廊写「源配置名 #n」;节点主产物为 null。 + final int? slot; } /// 每个产物(按 [galleryItemKey])的标签。 /// -/// take 在**传入的全部产物**里编号(调用方给的是整个项目的产物)——切分段 / 类型只改「显示哪些格」, -/// 同一格的号不会跟着变。孤儿产物(找不到源 config)各自成片,不互相编号。 +/// take 号不在产物列表上数,在**节点**上数([takeNumbersOn],检查器历史页用的是同一个函数);批量 slot 不参与, +/// 写 slot 号。号只看节点,与传进来的是哪些产物无关——切分段 / 类型只改「显示哪些格」,同一格的号不会跟着变。 +/// 节点表:[canvasId] 这张画布用 [canvasNodes](画布控制器里的实时节点表——历史页读的也是它,删掉一个 take, +/// 两处的号一起变),其余画布(或 [canvasNodes] 为 null 时)用画廊图。 Map assetCaptions( GalleryGraph graph, - List items, -) { - final Map configOf = {}; - final Map> byConfig = >{}; - for (final GalleryItem item in items) { - final CanvasNode? config = galleryConfigNodeOf(graph, item); - configOf[galleryItemKey(item)] = config; - if (config != null) { - (byConfig['${item.canvasId}|${config.id}'] ??= []).add(item); - } - } - final Map takeOf = {}; - for (final List group in byConfig.values) { - if (group.length < 2) continue; - final List oldestFirst = [...group]..sort(_byCreation); - for (int i = 0; i < oldestFirst.length; i++) { - takeOf[galleryItemKey(oldestFirst[i])] = i + 1; - } - } + List items, { + String? canvasId, + List? canvasNodes, +}) { + final Map> takesByCanvas = >{}; + Map takesOn(String id) => takesByCanvas[id] ??= + takeNumbersOn((id == canvasId ? canvasNodes : null) ?? graph.nodesOf(id)); return { for (final GalleryItem item in items) galleryItemKey(item): AssetCaption( - config: configOf[galleryItemKey(item)], - take: takeOf[galleryItemKey(item)], + config: galleryConfigNodeOf(graph, item), + take: item.slotIndex == null ? takesOn(item.canvasId)[item.nodeId] : null, + slot: item.slotIndex == null ? null : item.slotIndex! + 1, ), }; } - -/// 生成时间从早到晚;同刻(同一批的几格)按 slot 序,再按路径——全序稳定。 -int _byCreation(GalleryItem a, GalleryItem b) { - final int byTime = a.createdAt.compareTo(b.createdAt); - if (byTime != 0) return byTime; - final int bySlot = (a.slotIndex ?? -1).compareTo(b.slotIndex ?? -1); - if (bySlot != 0) return bySlot; - return a.relativePath.compareTo(b.relativePath); -} diff --git a/lib/features/canvas/widgets/left_rail/assets_panel.dart b/lib/features/canvas/widgets/left_rail/assets_panel.dart index aa22b8aa..a8cf6fcb 100644 --- a/lib/features/canvas/widgets/left_rail/assets_panel.dart +++ b/lib/features/canvas/widgets/left_rail/assets_panel.dart @@ -9,9 +9,16 @@ // 点格子:产物在当前画布 = 单选它并移到视口中心(与大纲同一条 focusNodeOnCanvas);在别的画布 = // 走画廊「在画布中定位」那条路(gallery_actions.galleryLocateInCanvas:切过去再选中)。 // -// 新产物:本页开着时,本项目有 job 转成功 ⇒ 帧后失效一次画廊图(与画廊标签脏刷新的入口相同)。 -// 不这样做,画廊图被本页 watch 着一直不重读,刚生成的图在素材里看不到。按 jobId 记账,一条只刷一次 -// (成功的 job 会一直留在 registry 里)。 +// 新鲜度:画廊图不只本页 watch——画廊标签物化后常驻、角色页的引用计数(characterUsageProvider)也读它, +// 被它们保活着时,本页打开看到的可能是旧图(job 成功发生在本页没开的时候)。所以: +// - 每次挂载(展开左栏、从别的页切过来)时图若已经活着,帧后让它失效一次——开页即是新的;没活着的话 +// 本页这次 build 本来就现读,不再多读一遍。 +// - 本页开着时,本项目有 job 转成功(上一刻还不是成功、这一刻是)⇒ 帧后再失效一次(与画廊标签脏刷新 +// 的入口相同)。成功的 job 会一直留在注册表里、别的 job 每跳一次进度整张表都会再发一遍,所以按前后 +// 两刻比,不记账。 +// 删除:当前画布的条目按画布控制器里的实时节点表过一遍(assets_model.assetsWithoutDeleted)——节点删了格子 +// 立刻消失,不等画廊图重读;点格子时节点表里找不到就不选中(选中一个不存在的 id,状态栏写「选中 1」, +// 检查器却是空的)。 import 'dart:io'; import 'package:flutter/material.dart'; @@ -37,7 +44,6 @@ import '../../../shell/providers/active_project.dart'; import '../../../shell/providers/shell_controller.dart'; import '../../models/canvas_node.dart'; import '../../providers/canvas_nodes_controller.dart'; -import '../../providers/canvas_selection_controller.dart'; import '../node_card.dart' show nodeTypeLabel; import 'assets_model.dart'; import 'left_panel_parts.dart'; @@ -68,24 +74,31 @@ class _AssetsPanelState extends ConsumerState { AssetScope _scope = AssetScope.canvas; AssetKindFilter _kind = AssetKindFilter.all; - /// 已经算过的成功 job(见头注:按 jobId 记账,一条只刷一次)。 - final Set _seenSucceeded = {}; - @override void initState() { super.initState(); - // 本页打开之前就成功了的 job 不算「新产物」:本页第一次读画廊图时它们已经在里面了。 - for (final JobState j in ref.read(jobsRegistryProvider)) { - if (j is JobSucceeded) _seenSucceeded.add(j.jobId); + // 开页即是新的(见头注):图此刻已经活着 ⇒ 是被别处保活着的,可能早于刚成功的 job,帧后让它失效一次。 + // 推到帧后:initState 跑在 build 阶段里,同步改 provider 图会被 Riverpod 断言掉。 + final ProjectRef? project = ref.read(activeProjectProvider); + if (project != null && ref.exists(galleryGraphProvider(project.id))) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.invalidate(galleryGraphProvider(project.id)); + }); } } void _onJobs(List? previous, List next) { final ProjectRef? project = ref.read(activeProjectProvider); if (project == null) return; + // 上一刻就已经是成功的不算新产物(成功的 job 会一直留在注册表里)。 + final Set succeededBefore = { + for (final JobState j in previous ?? const []) + if (j is JobSucceeded) j.jobId, + }; bool fresh = false; for (final JobState j in next) { - if (j is! JobSucceeded || !_seenSucceeded.add(j.jobId)) continue; + if (j is! JobSucceeded || succeededBefore.contains(j.jobId)) continue; // 拿不到 projectId(生产里不会发生)就当作可能是本项目:多刷一次远比漏刷便宜。 if (j.projectId == null || j.projectId == project.id) fresh = true; } @@ -109,13 +122,9 @@ class _AssetsPanelState extends ConsumerState { const []; final CanvasNode? node = nodes.where((CanvasNode n) => n.id == item.nodeId).firstOrNull; - if (node == null) { - // 画布节点还没载入(或画廊图比画布新):位置未知,至少选中它。 - ref - .read(canvasSelectionControllerProvider(canvasId).notifier) - .select(item.nodeId); - return; - } + // 节点表里没有它(刚删掉,或画布节点还没载入):不选中——选中一个找不到的 id,状态栏写「选中 1」, + // 检查器却是空的。 + if (node == null) return; focusNodeOnCanvas(ref, canvasId: canvasId, node: node); } @@ -139,13 +148,25 @@ class _AssetsPanelState extends ConsumerState { final GalleryGraph graph = ref.watch(galleryGraphProvider(project.id)).valueOrNull ?? GalleryGraph.empty; - final List all = - itemsAsync.valueOrNull ?? const []; + // 本画布的实时节点表:删掉的节点,格子立刻消失(不等画廊图重读)。 + final List? liveNodes = + ref.watch(canvasNodesControllerProvider(widget.canvasId)).valueOrNull; + final List all = assetsWithoutDeleted( + itemsAsync.valueOrNull ?? const [], + canvasId: widget.canvasId, + liveNodes: liveNodes, + ); final List scoped = assetsInScope(all, scope: _scope, canvasId: widget.canvasId); final AssetCounts counts = countAssets(scoped); final List shown = assetsOfKind(scoped, _kind); - final Map captions = assetCaptions(graph, all); + // take 号在本画布的实时节点表上数(与检查器历史页同一份、同一个函数),别的画布用画廊图。 + final Map captions = assetCaptions( + graph, + all, + canvasId: widget.canvasId, + canvasNodes: liveNodes, + ); final Widget body; if (!itemsAsync.hasValue && itemsAsync.hasError) { @@ -212,8 +233,9 @@ class _AssetsPanelState extends ConsumerState { ); } - /// 「源配置名 · 成片」/「源配置名 · take N」。源配置名空着(或找不到源节点)时回落到类型名, - /// 与画廊 tile 的回落同一口径。 + /// 「源配置名 · 成片」/「源配置名 · take N」;批量 slot 照画廊对 slot 的写法「源配置名 #n」(不参与 take + /// 编号,见 util/take_numbers.dart 的 takeNumbersOn)。源配置名空着(或找不到源节点)时回落到类型名,与画廊 tile 的回落 + /// 同一口径。 static String _captionOf( BuildContext context, GalleryItem item, @@ -229,6 +251,8 @@ class _AssetsPanelState extends ConsumerState { ? CanvasNodeType.video : CanvasNodeType.image, ); + final int? slot = caption?.slot; + if (slot != null) return '$name #$slot'; final int? take = caption?.take; return take == null ? l.leftRailAssetFinal(name) : l.leftRailAssetTake(name, take); } diff --git a/lib/features/canvas/widgets/left_rail/outline_panel.dart b/lib/features/canvas/widgets/left_rail/outline_panel.dart index bc03451d..a431a14c 100644 --- a/lib/features/canvas/widgets/left_rail/outline_panel.dart +++ b/lib/features/canvas/widgets/left_rail/outline_panel.dart @@ -3,10 +3,11 @@ // 面板头「大纲」+ 画布切换胶囊(有活动项目才画)→ 筛选框(名称或提示词,不区分大小写)→ 分组列表。 // 分组由 util/outline_model.dart 的 buildCanvasOutline 产出,这里只渲染: // - 组头 26(上外边距 6):泳道色点 8×8 圆角 2(手动 tint → 风格词推断 → control,与泳道底色同一 -// 口径)+ 泳道名 + 等宽计数「N 镜」;「未分泳道」「未入链」没有色点,未入链的计数只写数字。 -// - 镜头行 40:序号(等宽 10 号、宽 16,链序两位补零,未入链不画)| 44×25 缩略(最新产物;没有产物 = -// thumb 底 + 类型图标)| 名称 / 类型 | 状态(node_card.dart 的 nodeStatusOf,与节点卡同一判据)。 -// 选中行 surface5 底。 +// 口径)+ 泳道名 + 等宽计数「N 镜」(N = 组内不同镜号的个数,不是行数:镜头和借出产物的配置同一镜); +// 「未分泳道」「未入链」没有色点,未入链的计数只写数字(行数)。 +// - 镜头行 40:镜号(等宽 10 号、宽 16,两位补零;序列的口径,与序列视图 / 画廊 / ⌘K 同一个号,见 +// outline_model.dart;没有镜号的不画)| 44×25 缩略(最新产物;没有产物 = thumb 底 + 类型图标)| 名称 / +// 类型 | 状态(node_card.dart 的 nodeStatusOf,与节点卡同一判据)。选中行 surface5 底。 // - 产物子行 26:「└」+ 28×16 缩略 + 「成片 · 图像 / 视频」,名下多于 1 个时「 · 共 N 个」; // 有活动 job 时再加一行「生成中…」(缩略位是 thumb 色空块)。 // 点镜头行 / 产物子行 = 在画布上单选该节点并把它移到视口中心(left_rail_actions.dart)。 @@ -175,11 +176,10 @@ class _GroupHeader extends StatelessWidget { OutlineGroupKind.unassigned => l.leftRailGroupNoLane, OutlineGroupKind.unchained => l.leftRailGroupNotInSequence, }; - final int n = group.shots.length; - // 未入链的不是序列里的镜,计数只写数字(稿)。 + // 未入链的不是序列里的镜,计数只写数字(稿:行数);其余组数的是组内不同镜号(几行同一镜只算一镜)。 final String count = group.kind == OutlineGroupKind.unchained - ? n.toString() - : l.leftRailGroupShotCount(n); + ? group.shots.length.toString() + : l.leftRailGroupShotCount(group.shotCount); return Padding( padding: const EdgeInsets.only(top: InkSpacing.s6), child: Container( diff --git a/lib/features/command_palette/palette_search.dart b/lib/features/command_palette/palette_search.dart index d34bafab..355816f6 100644 --- a/lib/features/command_palette/palette_search.dart +++ b/lib/features/command_palette/palette_search.dart @@ -18,7 +18,7 @@ import '../canvas/providers/canvas_nodes_controller.dart'; import '../canvas/providers/canvas_focus_request.dart'; import '../canvas/providers/current_canvas_name.dart'; import '../canvas/util/node_artifacts.dart'; -import '../canvas/util/outline_model.dart' show narrativeSequenceIndex; +import '../canvas/util/outline_model.dart' show sequenceShotNumbers; import '../canvas/widgets/node_card.dart' show nodeDisplayName, nodeTypeLabel; import '../gallery/models/gallery_item.dart'; import '../gallery/models/gallery_selection.dart'; @@ -83,11 +83,12 @@ List _shotEntries( if (nodes == null) return const []; final String canvasName = ref.watch(currentCanvasNameProvider).valueOrNull ?? l.canvasDefaultName; final String project = s.project?.name ?? l.shellBreadcrumbNoProject; - // 路径第三段:在叙事链上的镜写「序列 003」(与画廊「已入序列」同一判据:narrative 边两端 - // 都在场的节点才算链上),其余写节点类型。边也是画布已加载的,只遍历内存,不读库。 - // 链序号与左栏大纲的「01」同出 narrativeSequenceIndex(canvas/util/outline_model.dart)。 + // 路径第三段:在序列某一镜里的节点写那一镜的「序列 003」,其余(不在链上,或在链上但折叠时被跳过)写 + // 节点类型。镜号是序列的口径——与左栏大纲的「01」、序列视图、画廊同出 sequenceShotNumbers + // (canvas/util/outline_model.dart):借出产物的配置节点与借走它的镜头同号。边也是画布已加载的,只遍历 + // 内存,不读库。 final List edges = ref.watch(canvasEdgesControllerProvider(canvasId)).valueOrNull ?? const []; - final Map sequenceIndex = narrativeSequenceIndex(nodes, edges); + final Map sequenceIndex = sequenceShotNumbers(nodes, edges); final List out = []; for (final CanvasNode n in nodes) { if (n.role != NodeRole.config) continue; diff --git a/lib/features/sequence/util/shot_index.dart b/lib/features/sequence/util/shot_index.dart new file mode 100644 index 00000000..02146df3 --- /dev/null +++ b/lib/features/sequence/util/shot_index.dart @@ -0,0 +1,60 @@ +// 一个节点在序列的哪一镜:纯函数,不碰 ref、不碰外壳。 +// +// 判据只有一份([_shotIndexOfNode],与 sequence_builder 的折叠同一口径),几处共用: +// - 「在序列中查看 / 定位」跳到哪一镜、节点右键菜单的「在序列中定位」可不可点:[shotIndexForSelection] +// (view_in_sequence.dart 与 canvas/widgets/create_node_menu.dart); +// - 镜号:[shotIndexByNode] 一次给出每个节点所在那一镜——左栏大纲行首的镜号、⌘K 镜头组的「序列 NNN」由它来 +// (经 canvas/util/outline_model.dart 的 sequenceShotNumbers),与序列视图、画廊的序号是同一个数。 +// +// 从 view_in_sequence.dart 拆出来:那份要 ref、要切外壳标签,canvas/util 的纯函数不该为了一个判据把外壳 +// 控制器连带 import 进来。 +import '../../canvas/models/canvas_node.dart'; +import '../../storyboard/models/sequence_shot.dart'; +import '../models/sequence_lens.dart'; + +/// 「在序列中查看」该跳到哪一镜:选中节点里链序最靠前的那一镜;没选中 / 都不在链上 ⇒ -1。 +/// +/// 一个节点「在」哪一镜(与 sequence_builder 的折叠同一口径): +/// - 它自己就是那一镜(shot.nodeId); +/// - 那一镜放的就是它(shot.artifactNodeId——选中的正是那张成片); +/// - 产物节点归到它的来源配置节点;配置节点若把产物借给了前面的 shot(折叠,不单独成镜), +/// 就落在借走它产物的那一镜上。 +int shotIndexForSelection( + SequenceLens lens, { + required Set selection, + required List nodes, +}) { + if (selection.isEmpty || lens.shots.isEmpty) return -1; + final Map byId = {for (final CanvasNode n in nodes) n.id: n}; + int best = -1; + for (final String id in selection) { + final int i = _shotIndexOfNode(lens, id, byId); + if (i >= 0 && (best < 0 || i < best)) best = i; + } + return best; +} + +/// 每个节点所在那一镜的下标(0 起):逐个节点问 [shotIndexForSelection] 用的同一个判据([_shotIndexOfNode]), +/// 不另写折叠规则。不在任何一镜里的节点(不在叙事链上,或折叠时被跳过的,见 sequence_builder)不在表里。 +Map shotIndexByNode(SequenceLens lens, {required List nodes}) { + if (lens.shots.isEmpty) return const {}; + final Map byId = {for (final CanvasNode n in nodes) n.id: n}; + final Map out = {}; + for (final CanvasNode n in nodes) { + final int i = _shotIndexOfNode(lens, n.id, byId); + if (i >= 0) out[n.id] = i; + } + return out; +} + +int _shotIndexOfNode(SequenceLens lens, String id, Map byId) { + final CanvasNode? node = byId[id]; + final String owner = node != null && node.role == NodeRole.result ? (node.sourceNodeId ?? id) : id; + for (int i = 0; i < lens.shots.length; i++) { + final SequenceShot shot = lens.shots[i]; + if (shot.nodeId == owner || shot.artifactNodeId == id) return i; + final String? artifact = shot.artifactNodeId; + if (artifact != null && byId[artifact]?.sourceNodeId == owner) return i; + } + return -1; +} diff --git a/lib/features/sequence/util/view_in_sequence.dart b/lib/features/sequence/util/view_in_sequence.dart index 98761223..716962e7 100644 --- a/lib/features/sequence/util/view_in_sequence.dart +++ b/lib/features/sequence/util/view_in_sequence.dart @@ -4,6 +4,9 @@ // 「在序列中定位」(看菜单的目标集)。PR-8b 从 shell_tab_bar.dart 原样搬出来(_viewInSequence / // shotIndexForSelection),逻辑与临时保活订阅一行没改;只多了一个可选的 [nodeIds]——不给就是选中集, // 与原来逐字相同。 +// +// 跳哪一镜的判据(shotIndexForSelection)在 shot_index.dart:纯函数,大纲 / ⌘K 的镜号也用同一个判据, +// 用它的地方不必为此 import 这份要 ref、要切外壳标签的文件。 import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,10 +15,9 @@ import '../../canvas/providers/canvas_nodes_controller.dart'; import '../../canvas/providers/canvas_selection_controller.dart'; import '../../shell/models/shell_state.dart'; import '../../shell/providers/shell_controller.dart'; -import '../../storyboard/models/sequence_shot.dart'; -import '../models/sequence_lens.dart'; import '../providers/sequence_lens_provider.dart'; import '../providers/sequence_playhead.dart'; +import 'shot_index.dart'; /// 切到序列标签;[nodeIds](不给 = [canvasId] 的选中集)里有节点在叙事链上 ⇒ 播放头跳到链序最靠前的 /// 那一镜。跳镜头走 selectShot——seekToken 自增,与点链表行同一个「用户 seek」入口,监视器据此换镜。 @@ -41,37 +43,3 @@ void viewInSequence( ref.read(sequencePlayheadProvider(canvasId).notifier).selectShot(index); WidgetsBinding.instance.addPostFrameCallback((_) => hold.close()); } - -/// 「在序列中查看」该跳到哪一镜:选中节点里链序最靠前的那一镜;没选中 / 都不在链上 ⇒ -1。 -/// -/// 一个节点「在」哪一镜(与 sequence_builder 的折叠同一口径): -/// - 它自己就是那一镜(shot.nodeId); -/// - 那一镜放的就是它(shot.artifactNodeId——选中的正是那张成片); -/// - 产物节点归到它的来源配置节点;配置节点若把产物借给了前面的 shot(折叠,不单独成镜), -/// 就落在借走它产物的那一镜上。 -int shotIndexForSelection( - SequenceLens lens, { - required Set selection, - required List nodes, -}) { - if (selection.isEmpty || lens.shots.isEmpty) return -1; - final Map byId = {for (final CanvasNode n in nodes) n.id: n}; - int best = -1; - for (final String id in selection) { - final int i = _shotIndexOfNode(lens, id, byId); - if (i >= 0 && (best < 0 || i < best)) best = i; - } - return best; -} - -int _shotIndexOfNode(SequenceLens lens, String id, Map byId) { - final CanvasNode? node = byId[id]; - final String owner = node != null && node.role == NodeRole.result ? (node.sourceNodeId ?? id) : id; - for (int i = 0; i < lens.shots.length; i++) { - final SequenceShot shot = lens.shots[i]; - if (shot.nodeId == owner || shot.artifactNodeId == id) return i; - final String? artifact = shot.artifactNodeId; - if (artifact != null && byId[artifact]?.sourceNodeId == owner) return i; - } - return -1; -} diff --git a/lib/features/shell/README.md b/lib/features/shell/README.md index 8496f67e..36a4c763 100644 --- a/lib/features/shell/README.md +++ b/lib/features/shell/README.md @@ -52,16 +52,13 @@ InkFrameApp (MaterialApp) # 全树唯一 MaterialApp | canvas 域的读路径(`nodes` / `edges` / `style_lanes`)全部 `JOIN canvases … AND c.deleted_at IS NULL` | 画布软删**不级联**到子表,只过滤子行自身 `deleted_at` 会把已删画布的内容照常读出来——画面上完全看不出它已被删。反向:孤儿回收的两个「引用集」(`listAllMediaUrls` / `listAllOutputUrls`) 与归档导出的 9 条查询**绝不能**加这个过滤 | `test/storage/repositories/repository_sql_templates_test.dart`「canvas 域读路径必带画布软删过滤」(表驱动 11 条 + 1 条反向)+ `postgres_repositories_integration_test.dart`(pg-tagged,真 PG 钉"级联不存在"这个前提事实) | | **同一时刻只保活一个 `canvasId`**:五槽保活是按 `ShellTab` 分的,不是按 `canvasId` 分的。`canvas` 那一槽从头到尾只有一个 `KeyedSubtree('shellTabBody-canvas')`,`CanvasTab` 内部 `watch(currentCanvasIdProvider)` 变了就在同一个槽位里换内容 | 从画布 A 切到画布 B 不是"多开一个保活槽",是把槽位里的树换成 B;A 的滚动位置/本地 UI 态随之丢弃——这是当前设计的边界,不是 bug | `shell_keep_alive_host_test.dart`(五槽 == 五个 `ShellTab`,与 `canvasId` 无关) | | **任务胶囊挂在状态栏、看的是全部 job**(`shell_task_capsule.dart` / `shell_task_popover.dart`),不只当前画布 | 状态栏是全局的,job 也是全局的:在画廊或序列标签上也要看得到还在跑的生成。弹层「定位」跨画布时走挂起定位请求(`canvas_focus_request.dart`),不在切画布的同一刻写目标画布的选中 | `shell_task_capsule_test.dart` / `shell_task_capsule_shell_test.dart` | +| **画布侧的 job 监听挂在外壳根**:`InkShell` 包着 `CanvasJobListener`,不随画布页挂载;job 到终态时让 **job 所在画布**的节点控制器失效(没人 watch 的画布 invalidate 是空操作) | job 是全局的:没开画布、开着别的画布时照样落产物;任务弹层在任何标签下都能开,开着时保活 job 所在画布的节点控制器。监听跟着画布页走的话,画布页不在的那段时间落的产物没人让控制器失效,「定位」过去看到旧节点表。生成完成 / 失败的通知不在这里:应用级的 `JobNotificationListener`(挂在 `ToastLayer` 里)只要 job 到终态就弹 | `shell_canvas_job_listener_test.dart`(没开画布时)+ `test/features/canvas/widgets/canvas_screen_job_listener_test.dart`(判据;弹层保活别的画布) | ## 已知且接受的副作用(隐藏标签仍会 build / layout) `RenderIndexedStack` 未覆写 `performLayout`,隐藏子照常 build / layout / 跑 `LayoutBuilder` 与 post-frame,只是不 paint、不 hitTest。于是: -- `CanvasJobListener` 在任何标签下都会 invalidate 节点控制器——这是**改善**(切回画布时 - 产物已经在了),不是回归。生成完成 / 失败的通知不在这里:应用级的 - `JobNotificationListener`(`features/notifications`,挂在 `ToastLayer` 里)只要 job 到终态就弹, - 与哪个标签在台上、当前开的是哪张画布都无关。 - 画廊的 `GridView` 在离台时仍 build 首屏 tile 并解码图片。懒物化把"首启即 N+2 次查询 + 一屏解码"挡在门外:不点画廊标签就零画廊查询。 diff --git a/lib/features/shell/widgets/ink_shell.dart b/lib/features/shell/widgets/ink_shell.dart index 435e07a3..265729ee 100644 --- a/lib/features/shell/widgets/ink_shell.dart +++ b/lib/features/shell/widgets/ink_shell.dart @@ -5,10 +5,16 @@ // lib 里也已经没有直接弹 SnackBar 的地方(test/quality/no_direct_snackbar_test.dart 守着)。 // 画布自带的 Scaffold 原样保留;当初为 SnackBar 广播加的 V3c 用例还在 // (test/features/shell/shell_window_chrome_test.dart),只当根 Scaffold 在场的回归闸。 +// +// 【画布侧的 job 监听挂在这里】CanvasJobListener(features/canvas)在 job 到终态时让 job 所在画布的节点控制器 +// 重新读库。挂在外壳根、不挂在画布页里:job 是全局的,没开画布、开着别的画布时照样会落产物;任务弹层在任何 +// 标签下都能开,开着时会保活 job 所在画布的节点控制器。监听要是跟着画布页挂载,画布页不在的那段时间落的产物 +// 就没人让控制器失效,「定位」过去看到的还是旧节点表(test/features/shell/shell_canvas_job_listener_test.dart)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../theme/app_theme.dart'; +import '../../canvas/widgets/canvas_job_listener.dart'; import '../models/shell_state.dart'; import '../providers/shell_controller.dart'; import 'shell_chrome.dart'; @@ -22,15 +28,17 @@ class InkShell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ShellState s = ref.watch(shellControllerProvider); - return Scaffold( - backgroundColor: context.inkColors.surface0, - body: Column( - children: [ - const ShellChrome(), // 31:全树唯一 InkWindowChrome(菜单栏) - const ShellTabBar(), // 35:在 DragToMoveArea 之外(见该文件头注);面包屑在这一行 - Expanded(child: ShellContentStack(tab: s.tab, overlay: s.overlay)), - const ShellStatusBar(), // 23:壳级状态栏,内容随当前标签 - ], + return CanvasJobListener( + child: Scaffold( + backgroundColor: context.inkColors.surface0, + body: Column( + children: [ + const ShellChrome(), // 31:全树唯一 InkWindowChrome(菜单栏) + const ShellTabBar(), // 35:在 DragToMoveArea 之外(见该文件头注);面包屑在这一行 + Expanded(child: ShellContentStack(tab: s.tab, overlay: s.overlay)), + const ShellStatusBar(), // 23:壳级状态栏,内容随当前标签 + ], + ), ), ); } diff --git a/lib/features/shell/widgets/shell_tab_bar.dart b/lib/features/shell/widgets/shell_tab_bar.dart index 41991186..e12e06cd 100644 --- a/lib/features/shell/widgets/shell_tab_bar.dart +++ b/lib/features/shell/widgets/shell_tab_bar.dart @@ -8,8 +8,9 @@ // 并把播放头跳到当前选中节点所在的那一镜)/ 导出视频(= 导出标签,主按钮)。序列与导出在本仓库 // 已升格为标签,按钮只是稿上的快捷入口。原来的「导入脚本」随 PR-6 页签去重撤掉——浮动工具条的 // 「更多」里有同一个入口;「序列预览」改名「在序列中查看」,说的就是它真正做的事。 -// 「在序列中查看」的本体(含跳镜头判据 shotIndexForSelection 与临时保活订阅)PR-8b 搬到了 -// sequence/util/view_in_sequence.dart,与画布节点右键菜单的「在序列中定位」共用;本文件只调它。 +// 「在序列中查看」的本体(切标签 + 临时保活订阅)PR-8b 搬到了 sequence/util/view_in_sequence.dart, +// 与画布节点右键菜单的「在序列中定位」共用(跳镜头判据 shotIndexForSelection 在同目录的 shot_index.dart); +// 本文件只调它。 // // 【标签条绝不进 chrome 的槽位】整条 InkWindowChrome 包在 DragToMoveArea 里, // 其 onDoubleTap 让其中任何单击等满 kDoubleTapTimeout(300ms)。标签是全应用最高 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9467307..e48ae98e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -982,6 +982,8 @@ "placeholders": { "name": { "type": "String" } } }, "inspectorHistoryTake": "Take {number}", + "inspectorHistoryFinal": "Final", + "@inspectorHistoryFinal": {"description": "Inspector History tab: label of the only take when a config has exactly one result (same rule as the asset tiles' 'final')."}, "@inspectorHistoryTake": { "description": "Inspector History tab — one generated result; numbered oldest first, so the first one ever generated is Take 1", "placeholders": { "number": { "type": "int" } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c8df673a..5e9f59fa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -737,6 +737,7 @@ "inspectorHistoryHeading": "{name} 的全部 take", "@inspectorHistoryHeading": { "placeholders": { "name": { "type": "String" } } }, "inspectorHistoryTake": "take {number}", + "inspectorHistoryFinal": "成片", "@inspectorHistoryTake": { "placeholders": { "number": { "type": "int" } } }, "inspectorHistorySeed": "种子 {seed}", "@inspectorHistorySeed": { "placeholders": { "seed": { "type": "int" } } }, diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ae6fd4f8..bb6c67a4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3452,6 +3452,12 @@ abstract class AppLocalizations { /// **'Take {number}'** String inspectorHistoryTake(int number); + /// Inspector History tab: label of the only take when a config has exactly one result (same rule as the asset tiles' 'final'). + /// + /// In en, this message translates to: + /// **'Final'** + String get inspectorHistoryFinal; + /// Inspector History tab — the seed a take was generated with (only shown when it was recorded) /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index d45c8061..c0c5e615 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1915,6 +1915,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Take $number'; } + @override + String get inspectorHistoryFinal => 'Final'; + @override String inspectorHistorySeed(int seed) { return 'Seed $seed'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 29cb1d0b..08be402f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1835,6 +1835,9 @@ class AppLocalizationsZh extends AppLocalizations { return 'take $number'; } + @override + String get inspectorHistoryFinal => '成片'; + @override String inspectorHistorySeed(int seed) { return '种子 $seed'; diff --git a/test/features/canvas/util/outline_model_test.dart b/test/features/canvas/util/outline_model_test.dart index 0d19a847..8896b74f 100644 --- a/test/features/canvas/util/outline_model_test.dart +++ b/test/features/canvas/util/outline_model_test.dart @@ -1,13 +1,19 @@ // 左栏大纲分组(PR-4):buildCanvasOutline 与「在链上」判据的纯函数测试。 // // 每一条都对着派工单 §3 的一句口径:组序跟泳道 sortOrder、组内按叙事链序、链上无泳道进 -// 「未分泳道」、不在链上进「未入链」(最后、按位置、不编号)、编号是全画布链序、筛选按名称或 -// 提示词不区分大小写且整组都不匹配就不出、产物取最新一个并给出总数。 +// 「未分泳道」、不在链上进「未入链」(最后、按位置、不编号)、筛选按名称或提示词不区分大小写且 +// 整组都不匹配就不出、产物取最新一个并给出总数。 +// 镜号是序列的口径(不是链上 DFS 序):「镜号」组拿审查给的两个场景交叉核对——大纲每一行的号 == +// 序列 lens 里它所在那一镜的号(「在序列中查看」的判据)== 画廊写的「序列 NNN」。 import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/features/canvas/models/canvas_edge.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/models/style_lane.dart'; import 'package:inkframe/features/canvas/util/outline_model.dart'; +import 'package:inkframe/features/gallery/models/gallery_graph.dart'; +import 'package:inkframe/features/gallery/util/gallery_meta.dart'; +import 'package:inkframe/features/sequence/models/sequence_lens.dart'; +import 'package:inkframe/features/sequence/util/shot_index.dart'; CanvasNode _config( String id, { @@ -25,6 +31,15 @@ CanvasNode _config( typeConfig: {'prompt': prompt}, ); +/// 镜头节点(剧本拆出来的那种):正文是 shot_notes。 +CanvasNode _shot(String id, {required String notes, String? lane}) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.shot, + laneId: lane, + typeConfig: {'shot_notes': notes}, + ); + CanvasNode _result(String id, {required String source, required DateTime at, bool video = false}) => CanvasNode( id: id, @@ -67,6 +82,31 @@ CanvasOutline _build( List _ids(OutlineGroup g) => [for (final OutlineShot s in g.shots) s.node.id]; +List _numbers(OutlineGroup g) => [for (final OutlineShot s in g.shots) s.sequence]; + +/// 交叉核对:大纲每一行的镜号 == 序列 lens 里它所在那一镜的序号(「在序列中查看」的判据,1 起; +/// 不在任何一镜里 = 不编号)。 +void _expectSameAsSequence(CanvasOutline o, List nodes, List edges) { + final SequenceLens lens = buildSequenceLens(nodes: nodes, edges: edges); + for (final OutlineGroup g in o.groups) { + for (final OutlineShot s in g.shots) { + final int i = shotIndexForSelection(lens, selection: {s.node.id}, nodes: nodes); + expect(s.sequence, i < 0 ? null : i + 1, reason: '${s.node.id} 的镜号与序列视图不一致'); + } + } +} + +/// 画廊给这张产物写的「序列 NNN」(只有一张画布 c1)。 +int? _gallerySequenceOf(List nodes, List edges, String relativePath) => + GalleryIndex.build( + GalleryGraph( + canvases: const [GalleryCanvasInfo(id: 'c1', name: 'c1')], + nodesByCanvas: >{'c1': nodes}, + edgesByCanvas: >{'c1': edges}, + slotsByNode: const >{}, + ), + ).sequenceIndexOf('c1', relativePath); + void main() { group('narrativeChainMembers / narrativeSequenceIndex', () { test('两端都在节点集里的 narrative 边才算;悬空边与 data 边不算', () { @@ -181,12 +221,13 @@ void main() { }); group('编号', () { - test('链序是全画布的,从 1 起:跨泳道交错时组内号不连续', () { + test('镜号是全画布的,从 1 起:跨泳道交错时组内号不连续', () { + // 三个都有提示词 ⇒ 各自成一镜(既无产物也无备注的节点在序列里会被跳过,见「镜号」组)。 final CanvasOutline o = _build( [ - _config('a', lane: 'L1'), - _config('b', lane: 'L2'), - _config('c', lane: 'L1'), + _config('a', lane: 'L1', prompt: 'harbor'), + _config('b', lane: 'L2', prompt: 'boat'), + _config('c', lane: 'L1', prompt: 'peaks'), ], edges: [_narrative('a', 'b'), _narrative('b', 'c')], lanes: [_lane('L1', 0), _lane('L2', 1)], @@ -199,6 +240,61 @@ void main() { }); }); + group('镜号 = 序列的口径(与 buildSequence / lens、画廊交叉核对)', () { + test('场景 A:S1→S2→S3 的镜头链,S2 上「用本镜备注生成图像」接出的 C2 有图 ⇒ C2 与 S2 同号,S3 是 03', () { + // 边:S1→S2(sortOrder 0)、S2→S3(sortOrder 1)、S2→C2(sortOrder 0);C2 名下有图。 + // 链上 DFS 序是 S1、S2、C2、S3——按它编号 C2 写 03、S3 写 04,而序列把 C2 的图借给了 S2。 + final List nodes = [ + _shot('S1', notes: 'harbor at dawn'), + _shot('S2', notes: 'a lone boat'), + _shot('S3', notes: 'far peaks'), + _config('C2', prompt: 'a lone boat'), + _result('R2', source: 'C2', at: DateTime.utc(2026, 5, 1)), + ]; + final List edges = [ + _narrative('S1', 'S2'), + _narrative('S2', 'S3', sortOrder: 1), + _narrative('S2', 'C2'), + ]; + final CanvasOutline o = _build(nodes, edges: edges); + + expect(_ids(o.groups.single), ['S1', 'S2', 'C2', 'S3'], reason: '行序仍是叙事链序'); + expect(_numbers(o.groups.single), [1, 2, 2, 3]); + _expectSameAsSequence(o, nodes, edges); + expect(_gallerySequenceOf(nodes, edges, 'images/R2.png'), 2, reason: '画廊给这张图写「序列 002」'); + }); + + test('场景 B:C1→C2(C2 没提示词、没产物)→C3(有图)⇒ C2 借走 C3 的图成一镜,C3 与 C2 同号', () { + final List nodes = [ + _config('C1', prompt: 'harbor at dawn'), + _config('C2'), + _config('C3', prompt: 'far peaks'), + _result('R3', source: 'C3', at: DateTime.utc(2026, 5, 1)), + ]; + final List edges = [_narrative('C1', 'C2'), _narrative('C2', 'C3')]; + final CanvasOutline o = _build(nodes, edges: edges); + + expect(_ids(o.groups.single), ['C1', 'C2', 'C3']); + expect(_numbers(o.groups.single), [1, 2, 2]); + _expectSameAsSequence(o, nodes, edges); + expect(_gallerySequenceOf(nodes, edges, 'images/R3.png'), 2, reason: '画廊给这张图写「序列 002」'); + }); + + test('链上但不在任何一镜里(既无产物也无备注、也没借到):不编号,仍留在泳道组里', () { + final List nodes = [ + _config('a', lane: 'L1', prompt: 'harbor'), + _config('b', lane: 'L1'), + ]; + final List edges = [_narrative('a', 'b')]; + final CanvasOutline o = _build(nodes, edges: edges, lanes: [_lane('L1', 0)]); + + expect(o.groups.single.kind, OutlineGroupKind.lane); + expect(_ids(o.groups.single), ['a', 'b']); + expect(_numbers(o.groups.single), [1, null]); + _expectSameAsSequence(o, nodes, edges); + }); + }); + group('筛选', () { final List nodes = [ _config('Harbor dawn', lane: 'L1', prompt: 'misty river'), @@ -226,7 +322,7 @@ void main() { test('一组里一个都不匹配,整组不画;筛选不改编号;configCount 仍是筛选前的数', () { final CanvasOutline o = _build(nodes, edges: edges, lanes: lanes, query: 'peaks'); expect(o.groups.single.lane?.id, 'L2', reason: 'L1 一镜都没命中 → 整组不出,未入链同理'); - expect(o.groups.single.shots.single.sequence, 3, reason: '编号还是全画布链序'); + expect(o.groups.single.shots.single.sequence, 3, reason: '筛选不改镜号:还是全画布的那个号'); expect(o.configCount, 4); }); diff --git a/test/features/canvas/util/outline_shot_numbers_test.dart b/test/features/canvas/util/outline_shot_numbers_test.dart new file mode 100644 index 00000000..b46bdcbf --- /dev/null +++ b/test/features/canvas/util/outline_shot_numbers_test.dart @@ -0,0 +1,93 @@ +// 大纲 / ⌘K 的镜号(canvas/util/outline_model.dart):sequenceShotNumbers 与组头计数 OutlineGroup.shotCount。 +// +// 镜号是序列的口径:序列 lens 折叠后的镜序,节点归哪一镜复用「在序列中查看」的判据(shotIndexByNode)。 +// 大纲行号与序列视图 / 画廊的交叉核对在 outline_model_test.dart 的「镜号」组;这里钉函数本身的输出。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_edge.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/util/outline_model.dart'; + +CanvasNode _shot(String id, String notes) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.shot, + typeConfig: {'shot_notes': notes}, + ); + +CanvasNode _image(String id, {String prompt = ''}) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.image, + typeConfig: {'prompt': prompt}, + ); + +CanvasNode _result(String id, String source) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.image, + role: NodeRole.result, + sourceNodeId: source, + createdAt: DateTime.utc(2026, 5, 1), + typeConfig: {'image_url': 'images/$id.png'}, + ); + +CanvasEdge _narrative(String from, String to, {int sortOrder = 0}) => CanvasEdge( + id: 'e-$from-$to', + canvasId: 'c1', + sourceNodeId: from, + targetNodeId: to, + edgeType: EdgeType.narrative, + sortOrder: sortOrder, + ); + +void main() { + group('sequenceShotNumbers', () { + test('镜头链 S1→S2→S3、S2 接出的 C2 有图:C2 和它的图都归 S2 那一镜,S3 是第 3 镜', () { + final List nodes = [ + _shot('S1', 'harbor at dawn'), + _shot('S2', 'a lone boat'), + _shot('S3', 'far peaks'), + _image('C2', prompt: 'a lone boat'), + _result('R2', 'C2'), + ]; + final List edges = [ + _narrative('S1', 'S2'), + _narrative('S2', 'S3', sortOrder: 1), + _narrative('S2', 'C2'), + ]; + expect(sequenceShotNumbers(nodes, edges), { + 'S1': 1, + 'S2': 2, + 'C2': 2, + 'R2': 2, + 'S3': 3, + }); + }); + + test('链上但折叠时被跳过的(自己没产物没备注,也没借到)、不在链上的:都不在表里', () { + final List nodes = [ + _image('a', prompt: 'harbor'), + _image('b'), + _image('loose', prompt: 'peaks'), + ]; + expect(sequenceShotNumbers(nodes, [_narrative('a', 'b')]), {'a': 1}); + }); + + test('没有叙事链 → 空表', () { + expect(sequenceShotNumbers([_image('a', prompt: 'x')], const []), isEmpty); + }); + }); + + test('OutlineGroup.shotCount:组内不同镜号的个数——同一镜的几行只算一次,没有镜号的行不算', () { + final OutlineGroup group = OutlineGroup( + kind: OutlineGroupKind.unassigned, + shots: [ + OutlineShot(node: _shot('S1', 'x'), sequence: 1), + OutlineShot(node: _shot('S2', 'y'), sequence: 2), + OutlineShot(node: _image('C2'), sequence: 2), + OutlineShot(node: _image('b')), + ], + ); + expect(group.shotCount, 2); + }); +} diff --git a/test/features/canvas/util/take_numbers_test.dart b/test/features/canvas/util/take_numbers_test.dart new file mode 100644 index 00000000..06c7e865 --- /dev/null +++ b/test/features/canvas/util/take_numbers_test.dart @@ -0,0 +1,58 @@ +// take 号(canvas/util/take_numbers.dart 的 takeNumbersOn):素材页格子与检查器历史页共用的那一个编号函数。 +// 素材页标签怎么跟着它变见 left_rail/asset_captions_test.dart;两处界面上的号对得上,由 +// assets_panel_test.dart 的外壳级用例(素材页 + 历史页同屏)钉。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/util/take_numbers.dart'; + +CanvasNode _config(String id) => CanvasNode(id: id, label: id, type: CanvasNodeType.image); + +/// result 节点;[url] 为 null = 没有产物(生成中 / 失败的占位,或没转正的批量容器)。 +CanvasNode _result(String id, {String? source, String? url, DateTime? at}) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.image, + role: NodeRole.result, + sourceNodeId: source, + createdAt: at, + typeConfig: {'image_url': ?url}, + ); + +void main() { + group('takeNumbersOn', () { + test('每个来源名下有产物的 result 节点按时间正序编号(最早的是 take 1),与节点列表顺序无关', () { + final Map numbers = takeNumbersOn([ + _config('cfg1'), + _result('a', source: 'cfg1', url: 'images/a.png', at: DateTime.utc(2026, 1, 2)), + _result('b', source: 'cfg1', url: 'images/b.png', at: DateTime.utc(2026, 1, 1)), + _result('c', source: 'cfg1', url: 'images/c.png', at: DateTime.utc(2026, 1, 3)), + _config('cfg2'), + _result('d', source: 'cfg2', url: 'images/d.png', at: DateTime.utc(2026, 1, 1)), + _result('e', source: 'cfg2', url: 'images/e.png', at: DateTime.utc(2026, 1, 5)), + ]); + expect(numbers, {'b': 1, 'a': 2, 'c': 3, 'd': 1, 'e': 2}); + }); + + test('名下只有一个的(成片)、没有来源的孤儿、没有产物 url 的(占位 / 没转正的批量容器):都不在表里', () { + final Map numbers = takeNumbersOn([ + _config('solo'), + _result('only', source: 'solo', url: 'images/only.png', at: DateTime.utc(2026, 1, 1)), + _result('orphan', url: 'images/orphan.png', at: DateTime.utc(2026, 1, 1)), + _config('cfg'), + _result('t1', source: 'cfg', url: 'images/t1.png', at: DateTime.utc(2026, 1, 1)), + _result('container', source: 'cfg', at: DateTime.utc(2026, 1, 2)), + _result('t2', source: 'cfg', url: 'images/t2.png', at: DateTime.utc(2026, 1, 3)), + ]); + expect(numbers, {'t1': 1, 't2': 2}); + }); + + test('都没有时间戳(乐观新增、还没落库):按节点列表原序,越靠后越新(resultsFor 口径)', () { + final Map numbers = takeNumbersOn([ + _config('cfg'), + _result('first', source: 'cfg', url: 'images/first.png'), + _result('second', source: 'cfg', url: 'images/second.png'), + ]); + expect(numbers, {'first': 1, 'second': 2}); + }); + }); +} diff --git a/test/features/canvas/widgets/canvas_screen_job_listener_test.dart b/test/features/canvas/widgets/canvas_screen_job_listener_test.dart index 045ec7fd..1e697992 100644 --- a/test/features/canvas/widgets/canvas_screen_job_listener_test.dart +++ b/test/features/canvas/widgets/canvas_screen_job_listener_test.dart @@ -5,32 +5,48 @@ // 这里再弹一次,失败就会出两张卡。ToastService 仍换成记录用的假实现,就是为了 // 钉住「一张都没弹」。 // 纯决策矩阵由 canvas_job_effects_test.dart 覆盖。 +// +// 刷新按 job 所在画布走,不只当前画布:状态栏任务弹层开着时会 watch 别的画布的节点控制器(找源节点), +// 那张画布上的 job 成功,它的控制器也得重新读库——否则「定位」过去看到的是旧节点表。最后一条用例 +// 把弹层本体和监听器一起 pump,钉的就是这件事。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/db/columns.dart'; import 'package:inkframe/core/di/repositories.dart'; import 'package:inkframe/core/errors/ink_error.dart'; import 'package:inkframe/core/interfaces/node_repository.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; import 'package:inkframe/features/canvas/widgets/canvas_job_listener.dart'; import 'package:inkframe/features/canvas/providers/current_canvas_id.dart'; import 'package:inkframe/features/generation/models/job_state.dart'; import 'package:inkframe/features/generation/providers/jobs_registry.dart'; import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; +import 'package:inkframe/features/shell/widgets/shell_task_popover.dart'; import 'package:inkframe/l10n/generated/app_localizations.dart'; import 'package:inkframe/theme/app_theme.dart'; import '../../../_harness/fake_toast_service.dart'; // ── fakes ────────────────────────────────────────────────────────────────── -/// 只计数 listByCanvas 的 NodeRepository——invalidate → rebuild → 计数 +1。 +/// 计数 listByCanvas 的 NodeRepository——invalidate → rebuild → 计数 +1。 +/// 按画布存一份「库」(没种过的画布读出来是空表),并按画布记读了几次。 class _CountingNodeRepository implements NodeRepository { int listByCanvasCalls = 0; + /// 画布 id → 库里的节点行(listByCanvas 原样返回)。 + final Map>> rowsByCanvas = + >>{}; + + /// 画布 id → 被读了几次。 + final Map readsByCanvas = {}; + @override Future>> listByCanvas(String canvasId) async { listByCanvasCalls++; - return const >[]; + readsByCanvas[canvasId] = (readsByCanvas[canvasId] ?? 0) + 1; + return rowsByCanvas[canvasId] ?? const >[]; } @override @@ -84,6 +100,25 @@ class _CountingNodeRepository implements NodeRepository { const _err = NetworkError(code: InkErrorCode.networkOffline); +/// 一行节点(列名走 NodeCol,与真仓储读出来的行同形)。 +Map _nodeRow( + String id, { + required String canvasId, + required NodeRole role, + String label = '', + String? source, + String? imageUrl, +}) => + { + NodeCol.id: id, + NodeCol.canvasId: canvasId, + NodeCol.type: CanvasNodeType.image.name, + NodeCol.nodeRole: role.name, + NodeCol.label: label, + NodeCol.sourceNodeId: source, + NodeCol.typeConfig: {'image_url': ?imageUrl}, + }; + void main() { late RecordingToastService toasts; late _CountingNodeRepository fakeRepo; @@ -94,6 +129,7 @@ void main() { fakeRepo = _CountingNodeRepository(); container = ProviderContainer( overrides: [ + // 当前画布 = c1:下面「别的画布」都是相对它说的。 currentCanvasIdProvider.overrideWith((ref) => 'c1'), toastServiceProvider.overrideWithValue(toasts), nodeRepositoryProvider.overrideWith((ref) async => fakeRepo), @@ -123,6 +159,27 @@ void main() { await tester.pumpAndSettle(); } + /// 监听器 + 任务弹层本体(状态栏胶囊点开后挂的就是它):弹层为每个 job 去 job 所在画布的节点控制器里 + /// 找源节点,别的画布的控制器就是这样被保活的。 + Future pumpWithTaskPopover(WidgetTester tester) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildAppTheme(variant: InkThemeVariant.dark, textScale: 1), + home: const Scaffold( + body: CanvasJobListener( + child: Align(alignment: Alignment.bottomLeft, child: ShellTaskPopover()), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + testWidgets('JobFailed 推入 → 节点重拉,但这里不弹通知(归应用级监听)', (tester) async { await pump(tester); final baseline = fakeRepo.listByCanvasCalls; @@ -164,7 +221,7 @@ void main() { expect(fakeRepo.listByCanvasCalls, greaterThan(baseline)); }); - testWidgets('别的画布 JobFailed → 无通知无重拉', (tester) async { + testWidgets('别的画布 JobFailed、那张画布的控制器没人 watch → 无通知;invalidate 落空,谁都不重拉', (tester) async { await pump(tester); final baseline = fakeRepo.listByCanvasCalls; @@ -181,4 +238,55 @@ void main() { expect(toasts.shown, isEmpty); expect(fakeRepo.listByCanvasCalls, baseline); }); + + testWidgets('任务弹层 watch 着别的画布:那张画布上的 job 成功 → 它的控制器重新读库,读到带产物的新状态', + (tester) async { + // 当前画布是 c1;c2 上有一个还在跑的 job:库里是配置节点 + 还没有产物的 result 占位。 + fakeRepo.rowsByCanvas['c2'] = >[ + _nodeRow('cfg-b', canvasId: 'c2', role: NodeRole.config, label: 'Harbor dawn'), + _nodeRow('r-b', canvasId: 'c2', role: NodeRole.result, source: 'cfg-b'), + ]; + container.read(jobsRegistryProvider.notifier).upsert( + const JobState.running( + jobId: 'jb', + providerId: 'p', + canvasId: 'c2', + sourceNodeId: 'cfg-b', + resultNodeId: 'r-b', + progress: 0.5, + ), + ); + await pumpWithTaskPopover(tester); + + // 前置:弹层正 watch c2 的控制器——任务名就是从那里找到的源节点名。 + expect(find.text('Harbor dawn'), findsOneWidget); + expect(container.exists(canvasNodesControllerProvider('c2')), isTrue); + final int readsBefore = fakeRepo.readsByCanvas['c2'] ?? 0; + expect(readsBefore, greaterThan(0), reason: '前置:c2 已经读过一次库'); + + // 产物落库(result 有了 image_url),然后 job 转成功。 + fakeRepo.rowsByCanvas['c2'] = >[ + _nodeRow('cfg-b', canvasId: 'c2', role: NodeRole.config, label: 'Harbor dawn'), + _nodeRow('r-b', canvasId: 'c2', role: NodeRole.result, source: 'cfg-b', imageUrl: 'images/b.png'), + ]; + container.read(jobsRegistryProvider.notifier).upsert( + const JobState.succeeded( + jobId: 'jb', + providerId: 'p', + canvasId: 'c2', + sourceNodeId: 'cfg-b', + resultNodeId: 'r-b', + artifactPath: 'images/b.png', + ), + ); + await tester.pumpAndSettle(); + + expect(fakeRepo.readsByCanvas['c2'], greaterThan(readsBefore), + reason: 'job 所在画布(不是当前画布)的控制器要失效、重新读库'); + final CanvasNode result = container + .read(canvasNodesControllerProvider('c2')) + .requireValue + .firstWhere((CanvasNode n) => n.id == 'r-b'); + expect(result.imageUrl, 'images/b.png', reason: '「定位」过去要看到产物,不是旧的占位'); + }); } diff --git a/test/features/canvas/widgets/inspector_history_panel_test.dart b/test/features/canvas/widgets/inspector_history_panel_test.dart index 176d36c4..da459327 100644 --- a/test/features/canvas/widgets/inspector_history_panel_test.dart +++ b/test/features/canvas/widgets/inspector_history_panel_test.dart @@ -1,7 +1,8 @@ // 检查器「历史」页(PR-6):选中节点的 take 列表。 // -// 面板本体:配置节点列名下全部 take(新 → 旧,「take N」按时间正序编号)、产物节点列同源兄弟并高亮 -// 自己、「在用」(有 data 出边:首帧 / 尾帧 / 参考)、meta = 相对时间 · 种子、空态。 +// 面板本体:配置节点列名下全部 take(新 → 旧,「take N」按时间正序编号;名下只有一个写「成片」,与左栏 +// 素材页同一个编号函数)、产物节点列同源兄弟并高亮自己、「在用」(有 data 出边:首帧 / 尾帧 / 参考)、 +// meta = 相对时间 · 种子、空态。 // 外壳级:点一行 = 单选那个产物 + 移到视口中心,页签留在历史页、那一行变成高亮。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -69,6 +70,16 @@ final CanvasNode _other = _take('o1', at: DateTime.utc(2026, 5, 2, 9), source: ' /// 名下什么都没有的配置节点。 const CanvasNode _blank = CanvasNode(id: 'blank', label: 'Blank', type: CanvasNodeType.image, canvasId: 'c1'); +/// 名下只有一个产物的配置节点:那一个是「成片」。 +const CanvasNode _soloConfig = CanvasNode( + id: 'solo', + label: 'Lone boat', + type: CanvasNodeType.image, + canvasId: 'c1', + typeConfig: {'prompt': 'a lone boat'}, +); +final CanvasNode _soloTake = _take('soloTake', at: DateTime.utc(2026, 5, 2, 8), source: 'solo'); + /// r2 当了下游视频的首帧 ⇒ 在用。 const List _edges = [ CanvasEdge( @@ -81,7 +92,7 @@ const List _edges = [ ), ]; -List get _all => [_config, _r1, _r2, _r3, _other, _blank]; +List get _all => [_config, _r1, _r2, _r3, _other, _blank, _soloConfig, _soloTake]; Future _pumpPanel(WidgetTester tester, CanvasNode selected) async { await pumpInkApp( @@ -189,6 +200,15 @@ void main() { expect(_inRow('r3', find.text(l.galleryTimeHoursAgo(1))), findsOneWidget, reason: '没有 seed 就只写时间'); }); + testWidgets('名下只有一个产物:那一行写「成片」,不写 take 1(与左栏素材页同一口径)', (tester) async { + await _pumpPanel(tester, _soloConfig); + final AppLocalizations l = _l(tester); + + expect(_row('soloTake'), findsOneWidget); + expect(_inRow('soloTake', find.text(l.inspectorHistoryFinal)), findsOneWidget); + expect(find.text(l.inspectorHistoryTake(1)), findsNothing); + }); + testWidgets('名下没有产物:一行「还没有生成过」,没有 take 行', (tester) async { await _pumpPanel(tester, _blank); final AppLocalizations l = _l(tester); diff --git a/test/features/canvas/widgets/left_rail/asset_captions_test.dart b/test/features/canvas/widgets/left_rail/asset_captions_test.dart new file mode 100644 index 00000000..906d2fb5 --- /dev/null +++ b/test/features/canvas/widgets/left_rail/asset_captions_test.dart @@ -0,0 +1,95 @@ +// 素材页网格标签(left_rail/assets_model.dart 的 assetCaptions)跟着 take 号变的两件事:批量 slot 写 slot 号、 +// 不占 take 号;当前画布的号在实时节点表上数。take 号本身(canvas/util/take_numbers.dart)的用例在 +// test/features/canvas/util/take_numbers_test.dart;两处界面上的号对得上,由 assets_panel_test.dart 的外壳级用例 +// (素材页 + 历史页同屏)钉。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/widgets/left_rail/assets_model.dart'; +import 'package:inkframe/features/gallery/models/gallery_graph.dart'; +import 'package:inkframe/features/gallery/models/gallery_item.dart'; +import 'package:inkframe/features/gallery/util/gallery_meta.dart'; + +CanvasNode _config(String id) => CanvasNode(id: id, label: id, type: CanvasNodeType.image); + +/// result 节点;[url] 为 null = 没有产物(生成中 / 失败的占位,或没转正的批量容器)。 +CanvasNode _result(String id, {String? source, String? url, DateTime? at}) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.image, + role: NodeRole.result, + sourceNodeId: source, + createdAt: at, + typeConfig: {'image_url': ?url}, + ); + +GalleryItem _item(String canvasId, String nodeId, String path, {int? slot}) => GalleryItem( + kind: GalleryItemKind.image, + relativePath: path, + canvasId: canvasId, + canvasName: canvasId, + nodeId: nodeId, + createdAt: DateTime.utc(2026, 1, 1), + slotIndex: slot, + ); + +void main() { + group('assetCaptions', () { + // cvA:cfg1 名下 r1 / r2 / r3 三个 take,r2 上还挂着批量容器 rb 的两格 slot;cvB:cfg2 名下 s1 / s2。 + final GalleryGraph graph = GalleryGraph( + canvases: const [ + GalleryCanvasInfo(id: 'cvA', name: 'A'), + GalleryCanvasInfo(id: 'cvB', name: 'B'), + ], + nodesByCanvas: >{ + 'cvA': [ + _config('cfg1'), + _result('r1', source: 'cfg1', url: 'images/r1.png', at: DateTime.utc(2026, 1, 1)), + _result('r2', source: 'cfg1', url: 'images/r2.png', at: DateTime.utc(2026, 1, 2)), + _result('r3', source: 'cfg1', url: 'images/r3.png', at: DateTime.utc(2026, 1, 3)), + _result('rb', source: 'cfg1', at: DateTime.utc(2026, 1, 4)), + ], + 'cvB': [ + _config('cfg2'), + _result('s1', source: 'cfg2', url: 'images/s1.png', at: DateTime.utc(2026, 1, 1)), + _result('s2', source: 'cfg2', url: 'images/s2.png', at: DateTime.utc(2026, 1, 2)), + ], + }, + edgesByCanvas: const >{}, + slotsByNode: const >{}, + ); + final GalleryItem r2 = _item('cvA', 'r2', 'images/r2.png'); + final GalleryItem r3 = _item('cvA', 'r3', 'images/r3.png'); + final GalleryItem slot0 = _item('cvA', 'rb', 'images/rb0.png', slot: 0); + final GalleryItem slot1 = _item('cvA', 'rb', 'images/rb1.png', slot: 1); + final GalleryItem s2 = _item('cvB', 's2', 'images/s2.png'); + final List items = [r2, r3, slot0, slot1, s2]; + + test('批量 slot 写 slot 号(1 起)、不写 take 号;节点主产物没有 slot 号', () { + final Map captions = assetCaptions(graph, items); + expect(captions[galleryItemKey(slot0)]!.slot, 1); + expect(captions[galleryItemKey(slot1)]!.slot, 2); + expect(captions[galleryItemKey(slot1)]!.take, isNull); + expect(captions[galleryItemKey(slot1)]!.config?.id, 'cfg1'); + expect(captions[galleryItemKey(r2)]!.slot, isNull); + expect(captions[galleryItemKey(r2)]!.take, 2); + expect(captions[galleryItemKey(r3)]!.take, 3); + }); + + test('当前画布的号在实时节点表上数:删掉 r1,r2 / r3 跟着变成 take 1 / 2;别的画布照画廊图', () { + final Map captions = assetCaptions( + graph, + items, + canvasId: 'cvA', + canvasNodes: [ + _config('cfg1'), + _result('r2', source: 'cfg1', url: 'images/r2.png', at: DateTime.utc(2026, 1, 2)), + _result('r3', source: 'cfg1', url: 'images/r3.png', at: DateTime.utc(2026, 1, 3)), + _result('rb', source: 'cfg1', at: DateTime.utc(2026, 1, 4)), + ], + ); + expect(captions[galleryItemKey(r2)]!.take, 1); + expect(captions[galleryItemKey(r3)]!.take, 2); + expect(captions[galleryItemKey(s2)]!.take, 2, reason: 'cvB 没有实时节点表,照画廊图数'); + }); + }); +} diff --git a/test/features/canvas/widgets/left_rail/assets_model_test.dart b/test/features/canvas/widgets/left_rail/assets_model_test.dart index 90223a97..86daf795 100644 --- a/test/features/canvas/widgets/left_rail/assets_model_test.dart +++ b/test/features/canvas/widgets/left_rail/assets_model_test.dart @@ -1,4 +1,5 @@ -// 左栏素材页的纯函数(PR-4):分段过滤、类型计数、网格标签(源配置名 + take 序号)。 +// 左栏素材页的纯函数(PR-4):分段过滤、删掉的不显示、类型计数、网格标签(源配置名 + take 序号)。 +// take 号在节点上数(与检查器历史页同一个函数):result 节点按时间正序编号,批量 slot 不参与。 import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/widgets/left_rail/assets_model.dart'; @@ -25,12 +26,17 @@ GalleryItem _item( CanvasNode _config(String id, String label) => CanvasNode(id: id, label: label, type: CanvasNodeType.image); -CanvasNode _result(String id, String source) => CanvasNode( +/// result 节点;[url] 是它的产物(videos/ 开头记成 video_url),没有 = 生成中 / 失败的占位或没转正的批量容器。 +CanvasNode _result(String id, String source, {String? url, DateTime? at}) => CanvasNode( id: id, label: id, type: CanvasNodeType.image, role: NodeRole.result, sourceNodeId: source, + createdAt: at, + typeConfig: { + (url?.startsWith('videos/') ?? false) ? 'video_url' : 'image_url': ?url, + }, ); void main() { @@ -52,15 +58,15 @@ void main() { 'cvA': [ _config('cfg1', 'Harbor'), _config('cfg2', 'Boat'), - _result('r1a', 'cfg1'), - _result('r1b', 'cfg1'), - _result('r1c', 'cfg1'), - _result('r2', 'cfg2'), - _result('rOrphan', 'deleted'), + _result('r1a', 'cfg1', url: 'images/1a.png', at: DateTime.utc(2026, 1, 2)), + _result('r1b', 'cfg1', url: 'images/1b.png', at: DateTime.utc(2026, 1, 1)), + _result('r1c', 'cfg1', url: 'images/1c.png', at: DateTime.utc(2026, 1, 3)), + _result('r2', 'cfg2', url: 'videos/2.mp4', at: DateTime.utc(2026, 2, 1)), + _result('rOrphan', 'deleted', url: 'images/o.png', at: DateTime.utc(2026, 3, 1)), ], 'cvB': [ _config('cfg3', 'Peaks'), - _result('r3', 'cfg3'), + _result('r3', 'cfg3', url: 'images/3.png', at: DateTime.utc(2026, 4, 1)), ], }, edgesByCanvas: const >{}, @@ -72,6 +78,34 @@ void main() { expect(assetsInScope(all, scope: AssetScope.project, canvasId: 'cvA'), all); }); + test('删掉的不显示:本画布节点已不在实时节点表里的去掉(批量 slot 跟着容器走),别的画布原样;节点表没载入不过滤', + () { + // 挂在 r1a 这个 result 容器上的批量 slot。 + final GalleryItem slot = GalleryItem( + kind: GalleryItemKind.image, + relativePath: 'images/s0.png', + canvasId: 'cvA', + canvasName: 'cvA', + nodeId: 'r1a', + createdAt: DateTime.utc(2026, 1, 4), + slotIndex: 0, + ); + final List items = [b1, orphan, v1, a3, a1, a2, slot]; + // cvA 上 r1a、rOrphan 删了;cvB 的 r3 不在这份节点表里,但它在别的画布,原样留着。 + final List live = [ + _config('cfg1', 'Harbor'), + _config('cfg2', 'Boat'), + _result('r1b', 'cfg1'), + _result('r1c', 'cfg1'), + _result('r2', 'cfg2'), + ]; + expect( + assetsWithoutDeleted(items, canvasId: 'cvA', liveNodes: live), + [b1, v1, a3, a2], + ); + expect(assetsWithoutDeleted(items, canvasId: 'cvA', liveNodes: null), items, reason: '节点表还没载入'); + }); + test('类型计数随分段变', () { expect(countAssets(assetsInScope(all, scope: AssetScope.canvas, canvasId: 'cvA')), (all: 5, image: 4, video: 1)); expect(countAssets(all), (all: 6, image: 5, video: 1)); @@ -107,6 +141,43 @@ void main() { expect(of(orphan).take, isNull); }); + test('批量 slot 不参与 take 编号:名下的 result 节点按时间正序编 take 1..N,slot 没有 take 号', () { + // cfgG 名下:rg1(1 月 1 日)、批量容器 rgB(1 月 2 日,没转正 ⇒ 没有产物 url)挂三格 slot、rg2(1 月 3 日)。 + final GalleryGraph batchGraph = GalleryGraph( + canvases: const [GalleryCanvasInfo(id: 'cvC', name: 'C')], + nodesByCanvas: >{ + 'cvC': [ + _config('cfgG', 'Gull'), + _result('rg1', 'cfgG', url: 'images/g1.png', at: DateTime.utc(2026, 1, 1)), + _result('rgB', 'cfgG', at: DateTime.utc(2026, 1, 2)), + _result('rg2', 'cfgG', url: 'images/g2.png', at: DateTime.utc(2026, 1, 3)), + ], + }, + edgesByCanvas: const >{}, + slotsByNode: const >{}, + ); + GalleryItem slot(int i) => GalleryItem( + kind: GalleryItemKind.image, + relativePath: 'images/gb$i.png', + canvasId: 'cvC', + canvasName: 'C', + nodeId: 'rgB', + createdAt: DateTime.utc(2026, 1, 2), + slotIndex: i, + ); + final GalleryItem g1 = _item('cvC', 'rg1', 'images/g1.png', at: DateTime.utc(2026, 1, 1)); + final GalleryItem g2 = _item('cvC', 'rg2', 'images/g2.png', at: DateTime.utc(2026, 1, 3)); + final Map batch = + assetCaptions(batchGraph, [g2, slot(0), slot(1), slot(2), g1]); + + expect(batch[galleryItemKey(g1)]!.take, 1); + expect(batch[galleryItemKey(g2)]!.take, 2, reason: '检查器历史页只数 result 节点:它是 take 2,不是 take 5'); + for (int i = 0; i < 3; i++) { + expect(batch[galleryItemKey(slot(i))]!.take, isNull, reason: 'slot 不参与 take 编号'); + expect(batch[galleryItemKey(slot(i))]!.config?.label, 'Gull', reason: 'slot 经它挂的 result 容器找到源配置'); + } + }); + test('take 按整个项目编号:只传本画布的产物算出来的号与全量一致(切分段不换号)', () { final Map canvasOnly = assetCaptions( graph, diff --git a/test/features/canvas/widgets/left_rail/assets_panel_test.dart b/test/features/canvas/widgets/left_rail/assets_panel_test.dart index 5518e18d..73775f2a 100644 --- a/test/features/canvas/widgets/left_rail/assets_panel_test.dart +++ b/test/features/canvas/widgets/left_rail/assets_panel_test.dart @@ -2,35 +2,56 @@ // // 钉:分段与类型 chip 的计数、网格标签(源配置名 · take N / 成片)、点格子定位(当前画布 = // 单选 + 移到视口中心;别的画布 = 切过去)、「在画廊中打开全部」切到画廊标签。 +// 新鲜度:画廊图被别处保活着时 job 成功,之后再打开本页也能看到新图(开页即重读);本页开着时 job 成功, +// 新图跟着出来(本页对注册表的监听)。删除:当前画布删掉的节点,格子立刻消失、剩下的 take 号跟着变;点一个 +// 在节点表里找不到的格子,不改选中集。 +// take 号:普通生成和批量混在一起时,素材页格子与检查器历史页的号一致(批量 slot 不参与编号,照画廊写 +// 「#n」);名下只有一个产物时两处都写成片。 +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/core/di/file_resolver.dart'; import 'package:inkframe/core/di/repositories.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; import 'package:inkframe/features/canvas/providers/canvas_selection_controller.dart'; +import 'package:inkframe/features/canvas/providers/inspector_tab.dart'; import 'package:inkframe/features/canvas/providers/left_rail_state.dart'; +import 'package:inkframe/features/canvas/widgets/canvas_inspector_panel.dart'; import 'package:inkframe/features/canvas/widgets/canvas_view.dart'; +import 'package:inkframe/features/canvas/widgets/inspector_history_panel.dart'; import 'package:inkframe/features/canvas/widgets/left_rail/assets_model.dart'; import 'package:inkframe/features/canvas/widgets/left_rail/assets_panel.dart'; +import 'package:inkframe/features/gallery/providers/gallery_graph_provider.dart'; +import 'package:inkframe/features/generation/models/job_state.dart'; +import 'package:inkframe/features/generation/providers/jobs_registry.dart'; +import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; import 'package:inkframe/features/shell/models/shell_state.dart'; import 'package:inkframe/features/shell/providers/shell_controller.dart'; import 'package:inkframe/l10n/generated/app_localizations.dart'; +import '../../../../_harness/fake_batch_result.dart'; import '../../../../_harness/fake_canvas.dart'; import '../../../../_harness/fake_repositories.dart'; +import '../../../../_harness/fake_toast_service.dart'; import '../../../../_harness/shell_app.dart'; const ProjectRef _alpha = ProjectRef(id: 'p1', name: 'Alpha'); /// 种好的两张画布:A(当前)= Harbor 名下两个图片 take + Boat 一个视频;B = Peaks 一张图。 class _Seed { - _Seed(this.canvases, this.nodes, this.cvA, this.cvB, this.take2Id, this.peaksTakeId); + _Seed(this.canvases, this.nodes, this.cvA, this.cvB, this.harborId, this.take2Id, this.peaksTakeId); final InMemoryCanvasRepository canvases; final InMemoryNodeRepository nodes; final String cvA; final String cvB; + /// A 画布上的配置节点 Harbor。 + final String harborId; + /// Harbor 的第二个(较新的)take。 final String take2Id; @@ -107,23 +128,98 @@ Future<_Seed> _seed() async { sourceNodeId: peaks, typeConfig: {'image_url': 'images/p.png'}, ); - return _Seed(canvases, nodes, cvA, cvB, take2, peaksTake); + return _Seed(canvases, nodes, cvA, cvB, harbor, take2, peaksTake); +} + +/// 节点表照常从内存仓储读(与画廊图同一份数据);删除只改内存——仓储里还在、画廊图也不重读,格子还在不在 +/// 全看本画布的实时节点表。(真控制器的删除走 UnitOfWork,外壳测试里连不上库。) +class _MemoryDeleteNodes extends CanvasNodesController { + @override + Future removeNode(String id) async { + final List previous = state.valueOrNull ?? const []; + state = AsyncData>([ + for (final CanvasNode n in previous) + if (n.id != id) n, + ]); + return null; + } +} + +/// 画布节点一直在载入:节点表拿不到,画廊图却照样读得出来——格子画着,点了在节点表里找不到。 +class _NeverLoadedNodes extends CanvasNodesController { + @override + Future> build(String canvasId) => Completer>().future; +} + +/// 素材格子的锚点:'leftRail-asset-' + galleryItemKey('画布|相对路径|slot 序号',节点主产物的 slot 是 null)。 +Key _tileKey(String canvasId, String path, [int? slot]) => + ValueKey('leftRail-asset-$canvasId|$path|$slot'); + +/// Harbor 又生成了一张:result 落库,然后 job 转成功。返回新产物的相对路径。 +Future _harborGeneratesAgain(ProviderContainer c, _Seed s) async { + const String path = 'images/h3.png'; + final String id = await s.nodes.create( + canvasId: s.cvA, + type: 'image', + nodeRole: 'result', + label: 'Harbor take', + sourceNodeId: s.harborId, + positionX: 600, + typeConfig: {'image_url': path}, + ); + c.read(jobsRegistryProvider.notifier).upsert( + JobState.succeeded( + jobId: 'job-h3', + providerId: 'p', + canvasId: s.cvA, + projectId: 'p1', + sourceNodeId: s.harborId, + resultNodeId: id, + artifactPath: path, + ), + ); + return path; } -Future _pump(WidgetTester tester, _Seed s) async { +Future _pump( + WidgetTester tester, + _Seed s, { + LeftRailPage page = LeftRailPage.assets, + List extra = const [], +}) => + _pumpShell( + tester, + canvases: s.canvases, + nodes: s.nodes, + canvasId: s.cvA, + page: page, + extra: extra, + ); + +Future _pumpShell( + WidgetTester tester, { + required InMemoryCanvasRepository canvases, + required InMemoryNodeRepository nodes, + required String canvasId, + LeftRailPage page = LeftRailPage.assets, + List extra = const [], +}) async { final paths = await setupTempPaths(tester, 'ink_left_assets_'); await pumpInkShell( tester, paths: paths, - initial: ShellState(tab: ShellTab.canvas, canvasId: s.cvA, project: _alpha), + initial: ShellState(tab: ShellTab.canvas, canvasId: canvasId, project: _alpha), extraOverrides: [ - canvasRepositoryProvider.overrideWith((_) async => s.canvases), - nodeRepositoryProvider.overrideWith((_) async => s.nodes), + canvasRepositoryProvider.overrideWith((_) async => canvases), + nodeRepositoryProvider.overrideWith((_) async => nodes), fileResolverServiceProvider.overrideWithValue(StubFileResolver()), + // job 成功会弹通知卡(卡片带计时器):换成只记录的假实现。 + toastServiceProvider.overrideWithValue(RecordingToastService()), + ...extra, ], ); final ProviderContainer c = readShellContainer(tester); - c.read(leftRailProvider.notifier).open(LeftRailPage.assets); + c.read(leftRailProvider.notifier).open(page); // 画布节点载入 + 画廊图聚合 + 视口尺寸帧后上报:多给几帧。 for (int i = 0; i < 8; i++) { await tester.pump(); @@ -229,4 +325,216 @@ void main() { expect(shell.tab, ShellTab.gallery); expect(shell.project, _alpha); }, timeout: const Timeout(Duration(seconds: 30))); + + group('新鲜度', () { + testWidgets('画廊图被别处保活着时 job 成功,之后再切到素材页:新图在(开页即重读)', (tester) async { + final _Seed s = await _seed(); + final ProviderContainer c = await _pump(tester, s, page: LeftRailPage.outline); + // 别处(常驻的画廊标签、角色页的引用计数)保活着画廊图:此刻读出来的是还没有新图的那一版。 + c.listen(galleryGraphProvider('p1'), (_, _) {}); + for (int i = 0; i < 4; i++) { + await tester.pump(); + } + expect(c.read(galleryGraphProvider('p1')).hasValue, isTrue, reason: '前置:画廊图已读出'); + + // 素材页没开的时候,Harbor 又生成了一张。 + final String path = await _harborGeneratesAgain(c, s); + for (int i = 0; i < 4; i++) { + await tester.pump(); + } + + // 再切到素材页。 + c.read(leftRailProvider.notifier).open(LeftRailPage.assets); + for (int i = 0; i < 8; i++) { + await tester.pump(); + } + expect(find.byKey(_tileKey(s.cvA, path)), findsOneWidget, + reason: '画廊图被别处保活着、是旧的;开页时要让它重读,不能把开页前成功的 job 当成「已见」'); + }, timeout: const Timeout(Duration(seconds: 30))); + + testWidgets('素材页开着时 job 成功:新图跟着出来', (tester) async { + final _Seed s = await _seed(); + final ProviderContainer c = await _pump(tester, s); + expect(find.byKey(_tileKey(s.cvA, 'images/h3.png')), findsNothing, reason: '前置:还没有这张'); + + final String path = await _harborGeneratesAgain(c, s); + for (int i = 0; i < 8; i++) { + await tester.pump(); + } + expect(find.byKey(_tileKey(s.cvA, path)), findsOneWidget, + reason: '本页 watch 着画廊图,没人让它失效就一直不重读——要靠本页对注册表的监听'); + }, timeout: const Timeout(Duration(seconds: 30))); + }); + + group('删除', () { + testWidgets('删掉当前画布上的一个产物节点:它的格子立刻消失、剩下的 take 号跟着变,不等画廊图重读', (tester) async { + final _Seed s = await _seed(); + final ProviderContainer c = await _pump( + tester, + s, + extra: [canvasNodesControllerProvider.overrideWith(_MemoryDeleteNodes.new)], + ); + final AppLocalizations l = _l(tester); + final Finder tile = find.byKey(_tileKey(s.cvA, 'images/h2.png')); + expect(tile, findsOneWidget, reason: '前置:格子在'); + expect(find.text(l.leftRailAssetsKindAll(3)), findsOneWidget); + expect(find.text(l.leftRailAssetTake('Harbor', 1)), findsOneWidget, reason: '前置:Harbor 名下两个 take'); + + await c.read(canvasNodesControllerProvider(s.cvA).notifier).removeNode(s.take2Id); + await tester.pump(); + await tester.pump(); + + expect(tile, findsNothing, reason: '节点已经不在画布上(画廊图里还有,它没重读)'); + expect(find.text(l.leftRailAssetsKindAll(2)), findsOneWidget, reason: '计数跟着少一个'); + expect(find.text(l.leftRailAssetFinal('Harbor')), findsOneWidget, + reason: '剩下那张名下只有它一个:号在本画布的实时节点表上数,立刻改写成片(与检查器历史页同一份)'); + expect(find.text(l.leftRailAssetTake('Harbor', 1)), findsNothing); + }, timeout: const Timeout(Duration(seconds: 30))); + + testWidgets('点一个在节点表里找不到的格子:不改选中集', (tester) async { + final _Seed s = await _seed(); + final ProviderContainer c = await _pump( + tester, + s, + extra: [canvasNodesControllerProvider.overrideWith(_NeverLoadedNodes.new)], + ); + final Finder tile = find.byKey(_tileKey(s.cvA, 'images/h2.png')); + expect(tile, findsOneWidget, reason: '前置:画廊图读得到,格子画着'); + + await _tap(tester, tile); + + expect(c.read(canvasSelectionControllerProvider(s.cvA)), isEmpty, + reason: '选中一个找不到的 id:状态栏写「选中 1」,检查器却是空的'); + }, timeout: const Timeout(Duration(seconds: 30))); + }); + + group('take 号:素材页与检查器历史页同一口径', () { + testWidgets('普通生成 + 批量混在一起:两处的号一致,批量 slot 写「#n」不占号;名下只有一个时两处都写成片', (tester) async { + final InMemoryCanvasRepository canvases = InMemoryCanvasRepository(); + final InMemoryNodeRepository nodes = InMemoryNodeRepository(); + final String cv = await canvases.create(projectId: 'p1', name: 'A'); + Future at(String id, DateTime t) async { + await nodes.update(id, {'created_at': t}); + return id; + } + + // Harbor:1 月 1 日一张、1 月 2 日一次三格批量(没转正:result 容器没有产物 url)、1 月 3 日又一张。 + final String harbor = await nodes.create( + canvasId: cv, + type: 'text', + nodeRole: 'config', + label: 'Harbor', + typeConfig: {'text': 'dawn'}, + ); + final String first = await at( + await nodes.create( + canvasId: cv, + type: 'image', + nodeRole: 'result', + label: 'Harbor take', + sourceNodeId: harbor, + positionX: 300, + typeConfig: {'image_url': 'images/h1.png'}, + ), + DateTime.utc(2026, 1, 1), + ); + final String batch = await at( + await nodes.create( + canvasId: cv, + type: 'image', + nodeRole: 'result', + label: 'Harbor batch', + sourceNodeId: harbor, + positionX: 300, + positionY: 300, + ), + DateTime.utc(2026, 1, 2), + ); + final String latest = await at( + await nodes.create( + canvasId: cv, + type: 'image', + nodeRole: 'result', + label: 'Harbor take', + sourceNodeId: harbor, + positionX: 300, + positionY: 600, + typeConfig: {'image_url': 'images/h2.png'}, + ), + DateTime.utc(2026, 1, 3), + ); + // Boat:只生成过一次。 + final String boat = await nodes.create( + canvasId: cv, + type: 'text', + nodeRole: 'config', + label: 'Boat', + positionY: 900, + typeConfig: {'text': 'drift'}, + ); + final String boatTake = await nodes.create( + canvasId: cv, + type: 'image', + nodeRole: 'result', + label: 'Boat take', + sourceNodeId: boat, + positionX: 300, + positionY: 900, + typeConfig: {'image_url': 'images/b.png'}, + ); + final FakeBatchResultRepo slots = FakeBatchResultRepo(>{ + for (int i = 0; i < 3; i++) + 'slot-$i': { + 'id': 'slot-$i', + 'node_id': batch, + 'job_id': 'job-batch', + 'slot_index': i, + 'status': 'success', + 'output_url': 'images/hb$i.png', + 'canvas_id': cv, + 'project_id': 'p1', + 'created_at': DateTime.utc(2026, 1, 2), + }, + }); + + final ProviderContainer c = await _pumpShell( + tester, + canvases: canvases, + nodes: nodes, + canvasId: cv, + extra: [batchResultRepositoryProvider.overrideWith((_) async => slots)], + ); + final AppLocalizations l = _l(tester); + Finder tileText(String path, String text, [int? slot]) => + find.descendant(of: find.byKey(_tileKey(cv, path, slot)), matching: find.text(text)); + Finder rowText(String nodeId, String text) => find.descendant( + of: find.byKey(InspectorHistoryPanel.takeKey(nodeId)), + matching: find.text(text), + ); + + // 检查器切到历史页,选中 Harbor。 + await tester.tap(find.byKey(CanvasInspectorPanel.tabKey(InspectorTab.history))); + await tester.pump(); + c.read(canvasSelectionControllerProvider(cv).notifier).select(harbor); + await tester.pump(); + await tester.pump(); + + // 历史页只数 result 节点:1 月 3 日那张是 take 2。素材页同一张也是 take 2(不是把三格 slot 也数进去的 take 5)。 + expect(rowText(first, l.inspectorHistoryTake(1)), findsOneWidget); + expect(rowText(latest, l.inspectorHistoryTake(2)), findsOneWidget); + expect(tileText('images/h1.png', l.leftRailAssetTake('Harbor', 1)), findsOneWidget); + expect(tileText('images/h2.png', l.leftRailAssetTake('Harbor', 2)), findsOneWidget); + // 批量 slot 不参与 take 编号,照画廊对 slot 的写法。 + for (int i = 0; i < 3; i++) { + expect(tileText('images/hb$i.png', 'Harbor #${i + 1}', i), findsOneWidget); + } + + // Boat 名下只有一个产物:两处都写成片。 + c.read(canvasSelectionControllerProvider(cv).notifier).select(boat); + await tester.pump(); + await tester.pump(); + expect(rowText(boatTake, l.inspectorHistoryFinal), findsOneWidget); + expect(tileText('images/b.png', l.leftRailAssetFinal('Boat')), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 30))); + }); } diff --git a/test/features/canvas/widgets/left_rail/outline_panel_test.dart b/test/features/canvas/widgets/left_rail/outline_panel_test.dart index 09954c86..f877c7f7 100644 --- a/test/features/canvas/widgets/left_rail/outline_panel_test.dart +++ b/test/features/canvas/widgets/left_rail/outline_panel_test.dart @@ -1,4 +1,5 @@ -// 左栏大纲(PR-4):点行 = 单选 + 移到视口中心;行尾五种状态;产物子行;筛选;画布切换胶囊。 +// 左栏大纲(PR-4):点行 = 单选 + 移到视口中心;行尾五种状态;产物子行;筛选;画布切换胶囊; +// 镜号与组头「N 镜」走序列的口径(镜头和借出产物的配置同号、同一镜只数一次)。 // // 定位与胶囊走外壳级 pump(视口尺寸、相机、外壳状态都要真的);状态 / 子行 / 筛选只要面板本体, // 直接 pump OutlinePanel。进行中的图标在转圈——那几条只用固定次数的 pump,不 pumpAndSettle。 @@ -6,7 +7,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/core/di/file_resolver.dart'; +import 'package:inkframe/features/canvas/models/canvas_edge.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/models/style_lane.dart'; import 'package:inkframe/features/canvas/providers/canvas_edges_controller.dart'; import 'package:inkframe/features/canvas/providers/canvas_lanes_controller.dart'; import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; @@ -63,10 +66,35 @@ final CanvasNode _videoResult = CanvasNode( typeConfig: const {'video_url': 'videos/v.mp4'}, ); -List _canvasOverrides(List nodes) => [ +class _EdgesOf extends CanvasEdgesController { + _EdgesOf(this._edges); + final List _edges; + + @override + Future> build(String canvasId) async => _edges; +} + +class _LanesOf extends CanvasLanesController { + _LanesOf(this._lanes); + final List _lanes; + + @override + Future> build(String canvasId) async => _lanes; +} + +List _canvasOverrides( + List nodes, { + List edges = const [], + List lanes = const [], +}) => + [ canvasNodesControllerProvider.overrideWith(() => FakeNodesController(nodes)), - canvasEdgesControllerProvider.overrideWith(() => FakeEdgesController()), - canvasLanesControllerProvider.overrideWith(() => EmptyLanesController()), + canvasEdgesControllerProvider.overrideWith( + () => edges.isEmpty ? FakeEdgesController() : _EdgesOf(edges), + ), + canvasLanesControllerProvider.overrideWith( + () => lanes.isEmpty ? EmptyLanesController() : _LanesOf(lanes), + ), fileResolverServiceProvider.overrideWithValue(StubFileResolver()), ]; @@ -95,7 +123,12 @@ Offset _cardCenter(WidgetTester tester, String nodeId) => Offset _stageCenter(WidgetTester tester) => tester.getCenter(find.byType(CanvasView)); /// 直接 pump 面板本体(241 宽,与左栏面板同宽)。 -Future _pumpPanel(WidgetTester tester, List nodes) async { +Future _pumpPanel( + WidgetTester tester, + List nodes, { + List edges = const [], + List lanes = const [], +}) async { await pumpInkApp( tester, const Scaffold( @@ -104,7 +137,7 @@ Future _pumpPanel(WidgetTester tester, List nodes child: SizedBox(width: 241, height: 700, child: OutlinePanel(canvasId: 'c1')), ), ), - overrides: _canvasOverrides(nodes), + overrides: _canvasOverrides(nodes, edges: edges, lanes: lanes), surfaceSize: const Size(400, 800), ); await tester.pump(); @@ -310,6 +343,64 @@ void main() { expect(find.text(_l(tester).leftRailOutlineEmpty), findsNothing, reason: '只是没命中,不是空画布'); }); + testWidgets('镜号与组头「N 镜」走序列的口径:借出产物的配置与镜头同号,同一镜只数一次', (tester) async { + // 「导入剧本 + 用本镜备注生成图像」的主路径:镜头链 S1→S2→S3,S2 上生成图像接出的 C2 有图,序列把这张图 + // 放在 S2 那一镜。四行都在泳道 L1 里。 + CanvasNode shot(String id, String notes) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.shot, + canvasId: 'c1', + laneId: 'L1', + typeConfig: {'shot_notes': notes}, + ); + CanvasEdge narrative(String from, String to, {int sortOrder = 0}) => CanvasEdge( + id: 'e-$from-$to', + canvasId: 'c1', + sourceNodeId: from, + targetNodeId: to, + edgeType: EdgeType.narrative, + sortOrder: sortOrder, + ); + await _pumpPanel( + tester, + [ + shot('S1', 'harbor at dawn'), + shot('S2', 'a lone boat'), + shot('S3', 'far peaks'), + const CanvasNode( + id: 'C2', + label: 'C2', + type: CanvasNodeType.image, + canvasId: 'c1', + laneId: 'L1', + typeConfig: {'prompt': 'a lone boat'}, + ), + _imageResult('R2', source: 'C2', at: DateTime.utc(2026, 5, 1)), + ], + edges: [ + narrative('S1', 'S2'), + narrative('S2', 'S3', sortOrder: 1), + narrative('S2', 'C2'), + ], + lanes: const [StyleLane(id: 'L1', canvasId: 'c1', label: 'Harbor', sortOrder: 0)], + ); + final AppLocalizations l = _l(tester); + + expect(_inRow('S1', find.text('01')), findsOneWidget); + expect(_inRow('S2', find.text('02')), findsOneWidget); + expect(_inRow('C2', find.text('02')), findsOneWidget, reason: '序列里这张图放在 S2 那一镜'); + expect(_inRow('S3', find.text('03')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(OutlinePanel.groupKey(OutlineGroupKind.lane, laneId: 'L1')), + matching: find.text(l.leftRailGroupShotCount(3)), + ), + findsOneWidget, + reason: '四行三镜:组头数的是不同镜号,不是行数', + ); + }); + testWidgets('画布上没有配置节点 → 居中一行空态说明', (tester) async { await _pumpPanel(tester, const []); expect(find.text(_l(tester).leftRailOutlineEmpty), findsOneWidget); diff --git a/test/features/command_palette/command_palette_test.dart b/test/features/command_palette/command_palette_test.dart index b897e3e0..d29f3c02 100644 --- a/test/features/command_palette/command_palette_test.dart +++ b/test/features/command_palette/command_palette_test.dart @@ -109,11 +109,24 @@ class _ChainOrderNodesController extends CanvasNodesController { } /// 三个图像 config 节点:cfg-a / cfg-b 在链上(边见 _ChainEdgesController),cfg-lone 孤立。 +/// 链上两个都有提示词:既无产物也无备注的节点在序列里会被跳过、不编号(镜号是序列的口径)。 class _ChainShotsController extends CanvasNodesController { @override Future> build(String canvasId) async => [ - CanvasNode(id: 'cfg-a', label: 'cfg-a', type: CanvasNodeType.image, canvasId: canvasId), - CanvasNode(id: 'cfg-b', label: 'cfg-b', type: CanvasNodeType.image, canvasId: canvasId), + CanvasNode( + id: 'cfg-a', + label: 'cfg-a', + type: CanvasNodeType.image, + canvasId: canvasId, + typeConfig: const {'prompt': 'harbor at dawn'}, + ), + CanvasNode( + id: 'cfg-b', + label: 'cfg-b', + type: CanvasNodeType.image, + canvasId: canvasId, + typeConfig: const {'prompt': 'a lone boat'}, + ), CanvasNode(id: 'cfg-lone', label: 'cfg-lone', type: CanvasNodeType.image, canvasId: canvasId), ]; } @@ -132,6 +145,60 @@ class _ChainEdgesController extends CanvasEdgesController { ]; } +/// 「导入剧本 + 用本镜备注生成图像」的主路径:镜头 Scene 1 → Scene 2 → Scene 3,Scene 2 上生成图像接出的 +/// 图像配置 Scene 2 image 也接在 Scene 2 后面(排在 Scene 3 前),它名下有图——序列把这张图放在 Scene 2 +/// 那一镜(边见 _SceneEdgesController)。 +class _SceneShotsController extends CanvasNodesController { + @override + Future> build(String canvasId) async { + CanvasNode shot(String id, String label, String notes) => CanvasNode( + id: id, + label: label, + type: CanvasNodeType.shot, + canvasId: canvasId, + typeConfig: {'shot_notes': notes}, + ); + return [ + shot('s1', 'Scene 1', 'harbor at dawn'), + shot('s2', 'Scene 2', 'a lone boat'), + shot('s3', 'Scene 3', 'far peaks'), + CanvasNode( + id: 'c2', + label: 'Scene 2 image', + type: CanvasNodeType.image, + canvasId: canvasId, + typeConfig: const {'prompt': 'a lone boat'}, + ), + CanvasNode( + id: 'r2', + label: 'r2', + type: CanvasNodeType.image, + role: NodeRole.result, + canvasId: canvasId, + sourceNodeId: 'c2', + createdAt: DateTime.utc(2026, 5, 1), + typeConfig: const {'image_url': 'images/r2.png'}, + ), + ]; + } +} + +/// s1 → s2(sortOrder 0)、s2 → s3(sortOrder 1)、s2 → c2(sortOrder 0)。 +class _SceneEdgesController extends CanvasEdgesController { + @override + Future> build(String canvasId) async { + CanvasEdge narrative(String from, String to, int sortOrder) => CanvasEdge( + id: 'e-$from-$to', + canvasId: canvasId, + sourceNodeId: from, + targetNodeId: to, + edgeType: EdgeType.narrative, + sortOrder: sortOrder, + ); + return [narrative('s1', 's2', 0), narrative('s2', 's3', 1), narrative('s2', 'c2', 0)]; + } +} + /// 带一个可搜到的图像 config 节点(「镜头 · 当前画布」组的数据源)。 class _ShotNodesController extends CanvasNodesController { @override @@ -570,6 +637,26 @@ void main() { expect(find.textContaining('Image'), findsWidgets, reason: 'cfg-lone 不在链上 ⇒ 第三段是类型名'); }); + testWidgets('镜头路径的「序列 NNN」是序列的口径:借出产物的图像配置与镜头同号,后面的镜不往后挪', (tester) async { + final container = await _pumpShell(tester, overrides: [ + canvasNodesControllerProvider.overrideWith(_SceneShotsController.new), + canvasEdgesControllerProvider.overrideWith(_SceneEdgesController.new), + ]); + container.read(shellControllerProvider.notifier).openCanvas('c1'); + await _warmNodes(container, 'c1'); + await _warmEdges(container, 'c1'); + await _pressCtrlK(tester); + await tester.enterText(find.byType(TextField), 'scene'); + await tester.pumpAndSettle(); + + // 链上 DFS 序是 Scene 1、Scene 2、Scene 2 image、Scene 3;序列把 Scene 2 image 的图放进 Scene 2 那一镜, + // 所以只有三镜——与序列视图、画廊(「序列 002」)、左栏大纲同一个号。 + expect(find.textContaining('Sequence 001'), findsOneWidget); + expect(find.textContaining('Sequence 002'), findsNWidgets(2), reason: 'Scene 2 与 Scene 2 image 同一镜'); + expect(find.textContaining('Sequence 003'), findsOneWidget, reason: 'Scene 3 是第三镜'); + expect(find.textContaining('Sequence 004'), findsNothing); + }); + testWidgets('「设置项」组不存在;Studio 上下文有查询词也只出动作组', (tester) async { await _pumpShell(tester); await _pressCtrlK(tester); diff --git a/test/features/sequence/util/shot_index_by_node_test.dart b/test/features/sequence/util/shot_index_by_node_test.dart new file mode 100644 index 00000000..1fae7c6f --- /dev/null +++ b/test/features/sequence/util/shot_index_by_node_test.dart @@ -0,0 +1,85 @@ +// shotIndexByNode(sequence/util/shot_index.dart):一次给出每个节点所在那一镜。 +// +// 它不另写折叠规则,逐个节点问的就是 shotIndexForSelection 用的那个判据——这里钉两件事:结果与逐个节点 +// 调 shotIndexForSelection 完全一致;不在任何一镜里的节点不在表里。左栏大纲的镜号、⌘K 的「序列 NNN」 +// 由它而来(canvas/util/outline_model.dart 的 sequenceShotNumbers)。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_edge.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/sequence/models/sequence_lens.dart'; +import 'package:inkframe/features/sequence/util/shot_index.dart'; + +CanvasNode _shot(String id, String notes) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.shot, + canvasId: 'c1', + typeConfig: {'shot_notes': notes}, + ); + +CanvasNode _imageResult(String id, {required String source, required DateTime at}) => CanvasNode( + id: id, + label: id, + type: CanvasNodeType.image, + role: NodeRole.result, + canvasId: 'c1', + sourceNodeId: source, + createdAt: at, + typeConfig: {'image_url': 'images/$id.png'}, + ); + +CanvasEdge _narrative(String from, String to) => CanvasEdge( + id: '$from-$to', + canvasId: 'c1', + sourceNodeId: from, + targetNodeId: to, + edgeType: EdgeType.narrative, + ); + +void main() { + // s1 ──narrative──> ic1 ──narrative──> s2:ic1 把它的最新产物 r1 借给了 s1(折叠,不单独成镜), + // 所以序列只有两镜:0 = s1(放 r1),1 = s2(备注占位)。r0 是 ic1 更早的一张;loose 不在链上。 + final List nodes = [ + _shot('s1', 'harbor'), + const CanvasNode( + id: 'ic1', + label: 'ic1', + type: CanvasNodeType.image, + canvasId: 'c1', + typeConfig: {'prompt': 'boat at dawn'}, + ), + _imageResult('r0', source: 'ic1', at: DateTime.utc(2026, 5, 1)), + _imageResult('r1', source: 'ic1', at: DateTime.utc(2026, 5, 2)), + _shot('s2', 'mountain'), + const CanvasNode(id: 'loose', label: 'loose', type: CanvasNodeType.text, canvasId: 'c1'), + ]; + final SequenceLens lens = buildSequenceLens( + nodes: nodes, + edges: [_narrative('s1', 'ic1'), _narrative('ic1', 's2')], + ); + + test('每个节点所在那一镜:镜头自己、借出产物的配置和它名下的产物都归借走产物的那一镜;不在任何一镜里的不在表里', () { + expect(shotIndexByNode(lens, nodes: nodes), { + 's1': 0, + 'ic1': 0, + 'r0': 0, + 'r1': 0, + 's2': 1, + }); + }); + + test('与逐个节点调 shotIndexForSelection 完全一致(同一个判据,不是第二份规则)', () { + final Map byNode = shotIndexByNode(lens, nodes: nodes); + for (final CanvasNode n in nodes) { + expect( + byNode[n.id] ?? -1, + shotIndexForSelection(lens, selection: {n.id}, nodes: nodes), + reason: n.id, + ); + } + }); + + test('序列是空的 → 空表', () { + expect(shotIndexByNode(SequenceLens.empty, nodes: nodes), isEmpty); + }); +} diff --git a/test/features/sequence/util/view_in_sequence_test.dart b/test/features/sequence/util/shot_index_test.dart similarity index 93% rename from test/features/sequence/util/view_in_sequence_test.dart rename to test/features/sequence/util/shot_index_test.dart index a22dfec2..00625e07 100644 --- a/test/features/sequence/util/view_in_sequence_test.dart +++ b/test/features/sequence/util/shot_index_test.dart @@ -1,13 +1,13 @@ -// 「在序列中查看 / 定位」的跳镜头判据 shotIndexForSelection(sequence/util/view_in_sequence.dart)。 +// 「在序列中查看 / 定位」的跳镜头判据 shotIndexForSelection(sequence/util/shot_index.dart)。 // -// PR-8b 把它从 shell_tab_bar.dart 原样搬到这里(标签栏「在序列中查看」与画布节点右键菜单「在序列中定位」 +// PR-8b 把它从 shell_tab_bar.dart 原样搬出来(标签栏「在序列中查看」与画布节点右键菜单「在序列中定位」 // 共用),用例随之从 shell_tab_bar_view_in_sequence_test.dart 原样搬过来:产物归到来源那一镜,把产物借给 // 前一镜的配置节点(折叠)落在借走它的那一镜,多选取链序最靠前的。外壳级的切标签 + 跳播放头仍在那个文件里。 import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/features/canvas/models/canvas_edge.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/sequence/models/sequence_lens.dart'; -import 'package:inkframe/features/sequence/util/view_in_sequence.dart'; +import 'package:inkframe/features/sequence/util/shot_index.dart'; CanvasNode _shot(String id, {Map config = const {}}) => CanvasNode( id: id, diff --git a/test/features/shell/shell_canvas_job_listener_test.dart b/test/features/shell/shell_canvas_job_listener_test.dart new file mode 100644 index 00000000..025f4d13 --- /dev/null +++ b/test/features/shell/shell_canvas_job_listener_test.dart @@ -0,0 +1,106 @@ +// 画布侧的 job 监听(CanvasJobListener)挂在外壳根(InkShell),不随画布页挂载。 +// +// 没开画布时(画布页从没物化)job 照样在跑;任务弹层这类全局界面会保活 job 所在画布的节点控制器(任务名、 +// 「定位」都靠它找源节点)。那张画布上的 job 成功 ⇒ 它的控制器照样重新读库,读到落了产物的那一行。监听要是 +// 跟着画布页挂载,这段时间落的产物就没人让控制器失效,「定位」过去看到的还是没有产物的占位。 +// 监听本身的判据(哪些 job、哪张画布)见 test/features/canvas/widgets/canvas_screen_job_listener_test.dart。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/di/repositories.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; +import 'package:inkframe/features/canvas/widgets/canvas_screen.dart'; +import 'package:inkframe/features/generation/models/job_state.dart'; +import 'package:inkframe/features/generation/providers/jobs_registry.dart'; +import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; +import 'package:inkframe/features/shell/models/shell_state.dart'; + +import '../../_harness/fake_repositories.dart'; +import '../../_harness/fake_toast_service.dart'; +import '../../_harness/shell_app.dart'; + +/// 按画布记读了几次库的内存节点仓储。 +class _CountingNodeRepository extends InMemoryNodeRepository { + final Map reads = {}; + + @override + Future>> listByCanvas(String canvasId) { + reads[canvasId] = (reads[canvasId] ?? 0) + 1; + return super.listByCanvas(canvasId); + } +} + +void main() { + testWidgets('没开画布时 job 成功:job 所在画布被保活的节点控制器照样重新读库,读到带产物的新状态', (tester) async { + final _CountingNodeRepository nodes = _CountingNodeRepository(); + // cvB 上一张图像配置 + 一个还在生成的产物占位(还没有 image_url)。 + final String cfg = await nodes.create(canvasId: 'cvB', type: 'image', nodeRole: 'config', label: 'Peaks'); + final String result = await nodes.create( + canvasId: 'cvB', + type: 'image', + nodeRole: 'result', + sourceNodeId: cfg, + ); + final paths = await setupTempPaths(tester, 'ink_shell_job_listener_'); + await pumpInkShell( + tester, + paths: paths, + initial: const ShellState(project: ProjectRef(id: 'p1', name: 'Alpha')), + extraOverrides: [ + nodeRepositoryProvider.overrideWith((_) async => nodes), + // job 成功会弹通知卡(卡片带计时器):换成只记录的假实现。 + toastServiceProvider.overrideWithValue(RecordingToastService()), + ], + ); + expect(find.byType(CanvasScreen), findsNothing, reason: '前提:没开画布,画布页从没物化'); + + // 任务弹层这类全局界面保活着 cvB 的节点控制器。 + final ProviderContainer c = readShellContainer(tester); + final ProviderSubscription>> hold = + c.listen(canvasNodesControllerProvider('cvB'), (_, _) {}); + addTearDown(hold.close); + c.read(jobsRegistryProvider.notifier).upsert( + JobState.running( + jobId: 'jb', + providerId: 'p', + canvasId: 'cvB', + sourceNodeId: cfg, + resultNodeId: result, + progress: 0.5, + ), + ); + for (int i = 0; i < 4; i++) { + await tester.pump(); + } + final int readsBefore = nodes.reads['cvB'] ?? 0; + expect(readsBefore, greaterThan(0), reason: '前提:cvB 的控制器已经读过一次库'); + expect( + hold.read().requireValue.firstWhere((CanvasNode n) => n.id == result).imageUrl, + isNull, + reason: '前提:读到的是还没有产物的占位', + ); + + // 产物落库(result 有了 image_url),然后 job 转成功。 + await nodes.patchTypeConfig(result, {'image_url': 'images/peaks.png'}); + c.read(jobsRegistryProvider.notifier).upsert( + JobState.succeeded( + jobId: 'jb', + providerId: 'p', + canvasId: 'cvB', + sourceNodeId: cfg, + resultNodeId: result, + artifactPath: 'images/peaks.png', + ), + ); + for (int i = 0; i < 4; i++) { + await tester.pump(); + } + + expect(nodes.reads['cvB'], greaterThan(readsBefore), reason: '画布页不在,外壳根的监听照样让 cvB 的控制器失效'); + expect( + hold.read().requireValue.firstWhere((CanvasNode n) => n.id == result).imageUrl, + 'images/peaks.png', + reason: '「定位」过去要看到产物,不是旧的占位', + ); + }); +} diff --git a/test/features/shell/shell_tab_bar_view_in_sequence_test.dart b/test/features/shell/shell_tab_bar_view_in_sequence_test.dart index 2df05917..735720d1 100644 --- a/test/features/shell/shell_tab_bar_view_in_sequence_test.dart +++ b/test/features/shell/shell_tab_bar_view_in_sequence_test.dart @@ -4,9 +4,9 @@ // - 选中叙事链上的节点再点:切到序列标签,播放头跳到那一镜——走 selectShot(seekToken +1, // 「用户 seek」语义); // - 没选中 / 选中的不在链上:只切标签,播放头不动。 -// 跳镜头的判据 shotIndexForSelection 与「切标签 + 跳播放头」本体 PR-8b 搬到了 -// sequence/util/view_in_sequence.dart(画布节点右键菜单「在序列中定位」共用),判据的用例随之搬到 -// test/features/sequence/util/view_in_sequence_test.dart;这里留外壳级的那几条。 +// 「切标签 + 跳播放头」本体 PR-8b 搬到了 sequence/util/view_in_sequence.dart(画布节点右键菜单「在序列中 +// 定位」共用),跳镜头的判据 shotIndexForSelection 在同目录的 shot_index.dart,判据的用例在 +// test/features/sequence/util/shot_index_test.dart;这里留外壳级的那几条。 // // 外壳级的那几条从画布标签起步:序列标签此前从没物化过,播放头(autoDispose)没有订阅者—— // 正是「临时订阅撑过这一帧」要防的那种情形,裸 read 写进去的跳转会在下一帧被回收。 diff --git a/test/features/shell/shell_task_capsule_shell_test.dart b/test/features/shell/shell_task_capsule_shell_test.dart index ae3b4d30..832a926d 100644 --- a/test/features/shell/shell_task_capsule_shell_test.dart +++ b/test/features/shell/shell_task_capsule_shell_test.dart @@ -1,9 +1,10 @@ // 任务胶囊 / 任务弹层的外壳级行为(PR-6): // - 胶囊挂在外壳状态栏上,所有标签下都在(job 是全局的); // - 弹层左沿对齐胶囊、底边离状态栏上沿 8; -// - 「定位」同一画布 = 单选源节点 + 移到视口中心;别的画布 = 先切过去(同项目带 ProjectRef)。 -// 跨画布之后的「再选中」已知会丢(目标画布的选中 provider 是 autoDispose,切过去那一帧没人订阅, -// 画廊 / ⌘K 同病,另行修),这里只断言切过去了。 +// - 「定位」同一画布 = 单选源节点 + 移到视口中心;别的画布 = 先切过去(同项目带 ProjectRef),切过去之后 +// 同样单选源节点 + 移到视口中心。跨画布那一下走挂起的定位请求(canvas_focus_request.dart):目标画布的 +// 选中 provider 是 autoDispose,切过去那一刻还没人订阅,同一刻直接写会被回收;所以要等目标画布挂上来、 +// 节点载入、视口上报之后才落地。最后一条用例断言的就是这三件事:切过去了、选中了、居中了。 // // 注册表一律用 override 播种:外壳里的 JobNotificationListener 只认「这一次变成终态」的 job, // 播种进来的初始态不弹通知卡(upsert 进来的终态会弹,卡片还带计时器)。