Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
10 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/CLAUDE.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions lib/features/canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/features/canvas/util/node_menu_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// 各项的作用范围:
// - 生成 / 存为角色:单张动作——目标恰好一张才可点(与画布 Ctrl+↵「恰好选中一张」同一个口径);
// - 复制一份 / 加入泳道 / 删除:整批——多选时对目标里的每一张;
// - 在序列中定位:目标里链序最靠前的那一镜(判据在 sequence/util/view_in_sequence.dart,与标签栏共用)。
// - 在序列中定位:目标里链序最靠前的那一镜(判据在 sequence/util/shot_index.dart,与标签栏共用)。
// 不可用的项置灰、不隐藏(菜单行的 enabled),判据全在这里。
//
// 【加入泳道要挪位置】见 [laneMoveDelta]:位置决定泳道归属是整个画布的规则,只改 lane_id 卡片会画在带外。
Expand Down
59 changes: 48 additions & 11 deletions lib/features/canvas/util/outline_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,7 +47,9 @@ Set<String> narrativeChainMembers(
return members;
}

/// 链上节点 → 1 起的链序号;顺序 = 叙事链序([orderByNarrativeChain])。不在链上的节点不在表里。
/// 链上节点 → 1 起的**链序**(叙事链 DFS 序,[orderByNarrativeChain],不折叠)。不在链上的节点不在表里。
///
/// 只给大纲排组内行序用;显示出来的镜号不是它,是 [sequenceShotNumbers](序列折叠后的镜序)。
Map<String, int> narrativeSequenceIndex(
List<CanvasNode> nodes,
List<CanvasEdge> edges,
Expand All @@ -52,7 +66,21 @@ Map<String, int> narrativeSequenceIndex(
};
}

/// 一行镜头:配置节点 + 链序号 + 名下最新产物。
/// 节点 → 它所在那一镜的镜号(1 起),序列的口径:大纲行首的镜号、⌘K 镜头组的「序列 NNN」都从这里取,
/// 与序列视图、画廊的序号是同一个数。
///
/// 镜 = 序列 lens 的镜([buildSequenceLens]:叙事链折叠后只留链上的镜——「借产物」的后继并进前一镜,
/// 既无产物也无备注的节点跳过);节点归哪一镜 = [shotIndexByNode](与「在序列中查看」同一判据:产物归
/// 来源配置节点,借出产物的配置节点归借走它的那一镜)。不在任何一镜里的节点不在表里。
Map<String, int> sequenceShotNumbers(List<CanvasNode> nodes, List<CanvasEdge> edges) {
final SequenceLens lens = buildSequenceLens(nodes: nodes, edges: edges);
return <String, int>{
for (final MapEntry<String, int> e in shotIndexByNode(lens, nodes: nodes).entries)
e.key: e.value + 1,
};
}

/// 一行镜头:配置节点 + 镜号 + 名下最新产物。
@immutable
class OutlineShot {
const OutlineShot({
Expand All @@ -64,7 +92,8 @@ class OutlineShot {

final CanvasNode node;

/// 全画布叙事链序(1 起);未入链为 null(不编号)。
/// 所在那一镜的镜号(1 起,序列口径,[sequenceShotNumbers]);未入链、或链上但不在任何一镜里为 null
/// (不编号)。
final int? sequence;

/// 名下最新的产物(result 节点,[resultsFor] 口径:按 created_at 取最新);没有为 null。
Expand Down Expand Up @@ -96,6 +125,12 @@ class OutlineGroup {

/// 至少一行(空组不产出)。
final List<OutlineShot> shots;

/// 组内不同镜号的个数(组头「N 镜」):同一镜的几行(镜头 + 借出产物的配置)只算一镜,没有镜号的行不算。
int get shotCount => <int>{
for (final OutlineShot s in shots)
if (s.sequence case final int n) n,
}.length;
}

/// 一次分组的结果。
Expand Down Expand Up @@ -123,7 +158,9 @@ CanvasOutline buildCanvasOutline({
for (final CanvasNode n in nodes)
if (n.role == NodeRole.config) n,
];
final Map<String, int> sequence = narrativeSequenceIndex(nodes, edges);
// 链序只管「谁在链上、组内怎么排」;行首显示的镜号走序列口径。
final Map<String, int> chainOrder = narrativeSequenceIndex(nodes, edges);
final Map<String, int> shotNumber = sequenceShotNumbers(nodes, edges);

final String q = query.trim().toLowerCase();
bool matches(CanvasNode n) {
Expand All @@ -148,21 +185,21 @@ CanvasOutline buildCanvasOutline({
);
return OutlineShot(
node: n,
sequence: sequence[n.id],
sequence: shotNumber[n.id],
latest: results.isEmpty ? null : results.first,
artifactCount: results.length,
);
}

final List<CanvasNode> chained = <CanvasNode>[
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<CanvasNode> unchained = orderByNarrativeChain(
nodes: <CanvasNode>[
for (final CanvasNode n in configs)
if (!sequence.containsKey(n.id)) n,
if (!chainOrder.containsKey(n.id)) n,
],
edges: const <CanvasEdge>[],
);
Expand Down
32 changes: 32 additions & 0 deletions lib/features/canvas/util/take_numbers.dart
Original file line number Diff line number Diff line change
@@ -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<String, int> takeNumbersOn(List<CanvasNode> nodes) {
// 按来源分桶,桶内保持节点列表原序(resultsFor 对缺时间戳的产物按原序定先后)。
final Map<String, List<CanvasNode>> bySource = <String, List<CanvasNode>>{};
for (final CanvasNode n in nodes) {
final String? source = n.sourceNodeId;
if (n.role != NodeRole.result || source == null) continue;
(bySource[source] ??= <CanvasNode>[]).add(n);
}
final Map<String, int> out = <String, int>{};
for (final MapEntry<String, List<CanvasNode>> e in bySource.entries) {
final List<CanvasNode> 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;
}
39 changes: 25 additions & 14 deletions lib/features/canvas/widgets/canvas_job_listener.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 只管画布这一侧的刷新。
Expand All @@ -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 {
Expand All @@ -22,20 +31,22 @@ class CanvasJobListener extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final canvasId = ref.watch(currentCanvasIdProvider);
ref.listen<List<JobState>>(jobsRegistryProvider, (prev, next) {
if (canvasId == null) return;
final effect = CanvasJobEffects.diff(
prev: prev ?? const <JobState>[],
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<JobState> before = prev ?? const <JobState>[];
final Set<String> canvases = <String>{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;
Expand Down
Loading
Loading