From a2fcd6dbdc9e1e1738b02ea8aa15a351e908d491 Mon Sep 17 00:00:00 2001 From: Kerro Date: Thu, 20 Aug 2026 13:55:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(canvas):=20CH-2=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E2=80=94=E2=80=94=E8=A7=92=E8=89=B2=E5=8C=BA/?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E6=A1=86/=E8=A7=92=E8=89=B2=20chip=20?= =?UTF-8?q?=E6=8A=BD=E5=85=B1=E4=BA=AB=20characters=5Fsection.dart,image?= =?UTF-8?q?=20inspector=201033=E2=86=92672=20=E8=A1=8C=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E9=9B=B6=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../canvas/widgets/characters_section.dart | 394 ++++++++++++++++++ .../widgets/image_config_inspector.dart | 373 +---------------- 2 files changed, 400 insertions(+), 367 deletions(-) create mode 100644 lib/features/canvas/widgets/characters_section.dart diff --git a/lib/features/canvas/widgets/characters_section.dart b/lib/features/canvas/widgets/characters_section.dart new file mode 100644 index 0000000..7be43ea --- /dev/null +++ b/lib/features/canvas/widgets/characters_section.dart @@ -0,0 +1,394 @@ +// CharactersSection:config 节点角色区(CH-2 起 image/video inspector 共享)。 +// 把项目级角色挂到节点(写 type_config.character_ids)+ 存为角色/从文件导入。 +// ref 支持判定按调用方口径参数化:image 要求 maxRefImages>0 且 imageToImage; +// video 对齐 CH-1 注入门只要 maxRefImages>0(不检查 modes——r2v/omni 的参考图 +// 语义不落在 modes 里,见 generation_controller._injectCharacterRefs 拍板注释)。 +// InspectorNameDialog / CharacterChip 一并公开——_PresetsSection 亦复用。 + +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/di/character_assets.dart'; +import '../../../core/di/file_resolver.dart'; +import '../../../core/errors/ink_error.dart'; +import '../../../core/interfaces/character_asset_service.dart'; +import '../../../core/interfaces/file_resolver_service.dart'; +import '../../../core/models/provider_capabilities.dart'; +import '../../../l10n/l10n_x.dart'; +import '../../../theme/app_theme.dart'; +import '../../../theme/components/ink_error_banner.dart'; +import '../../../theme/components/ink_input.dart'; +import '../../../theme/primitives/ink_dashed_slot.dart'; +import '../../../theme/tokens.dart'; +import '../models/canvas_edge.dart'; +import '../models/canvas_node.dart'; +import '../models/character.dart'; +import '../providers/canvas_edges_controller.dart'; +import '../providers/canvas_nodes_controller.dart'; +import '../providers/characters_controller.dart'; +import '../providers/inspector_submit_controller.dart'; + +/// 项目级角色一致性:把可复用角色挂到本 config 节点(写 type_config.character_ids), +/// 并支持把已连的参考图「存为角色」。仅当 provider 支持参考图时真正生效(否则给提示)。 +class CharactersSection extends ConsumerStatefulWidget { + const CharactersSection({ + super.key, + required this.targetNode, + required this.selectedCaps, + required this.requireImageToImageMode, + }); + + final CanvasNode targetNode; + final ProviderCapabilities? selectedCaps; + + /// image inspector 传 true(维持原门);video inspector 传 false(CH-1 口径)。 + final bool requireImageToImageMode; + + @override + ConsumerState createState() => _CharactersSectionState(); +} + +class _CharactersSectionState extends ConsumerState { + late Set _attachedIds; + + @override + void initState() { + super.initState(); + _attachedIds = _readAttached(widget.targetNode.typeConfig); + } + + Set _readAttached(Map tc) { + final raw = tc['character_ids']; + if (raw is List) { + return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toSet(); + } + return {}; + } + + InspectorSubmitController get _submitCtrl => ref.read( + inspectorSubmitControllerProvider(widget.targetNode.id).notifier, + ); + + bool get _supportsRefs { + final caps = widget.selectedCaps; + if (caps == null || caps.maxRefImages <= 0) return false; + return !widget.requireImageToImageMode || + caps.modes.contains(GenerationMode.imageToImage); + } + + void _toggle(String id) { + setState(() { + if (!_attachedIds.add(id)) _attachedIds.remove(id); + }); + _submitCtrl.saveConfig({ + 'character_ids': _attachedIds.toList(growable: false), + }); + } + + /// 角色首张参考图缩略图绝对路径;无图 / 越权路径 → null 不渲染。 + String? _characterThumb(String projectId, Character c) { + if (c.referenceImagePaths.isEmpty) return null; + try { + return ref.read(characterAssetServiceProvider).absolutePathOf( + projectId: projectId, + relativePath: c.referenceImagePaths.first, + ); + } on CharacterAssetError { + return null; + } + } + + /// 本 config 节点第一条 reference data 边上、带 image_url 的源节点("存为角色"来源)。 + CanvasNode? _referenceSource() { + final canvasId = widget.targetNode.canvasId; + if (canvasId == null) return null; + final edges = + ref.watch(canvasEdgesControllerProvider(canvasId)).valueOrNull ?? + const []; + final nodes = + ref.watch(canvasNodesControllerProvider(canvasId)).valueOrNull ?? + const []; + final nodesById = {for (final n in nodes) n.id: n}; + for (final e in edges) { + if (e.targetNodeId != widget.targetNode.id || + e.edgeType != EdgeType.data || + e.role != EdgeRole.reference) { + continue; + } + final src = nodesById[e.sourceNodeId]; + if (src != null && (src.imageUrl?.isNotEmpty ?? false)) return src; + } + return null; + } + + @override + Widget build(BuildContext context) { + final projectId = widget.targetNode.projectId; + if (projectId == null) return const SizedBox.shrink(); + final colors = context.inkColors; + final typo = context.inkTypography; + final charactersAsync = ref.watch(charactersControllerProvider(projectId)); + final characters = charactersAsync.valueOrNull ?? const []; + final refSource = _referenceSource(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.inspectorCharactersLabel, + style: typo.caption.copyWith(color: colors.fg3), + ), + const SizedBox(height: InkSpacing.xs), + if (!_supportsRefs) + Padding( + padding: const EdgeInsets.only(bottom: InkSpacing.xs), + child: Text( + context.l10n.inspectorCharactersUnsupported, + style: typo.caption.copyWith(color: colors.warning), + ), + ), + // 加载失败 → 错误横幅(此前静默降级为空 = 误报"无角色")。 + if (charactersAsync.hasError) + InkErrorBanner( + message: l10nAsyncError(context, charactersAsync.error!), + ) + else if (characters.isEmpty) + InkDashedSlot( + onPressed: _importFromFile, + child: Text( + context.l10n.inspectorCharactersEmpty, + style: typo.caption.copyWith(color: colors.fg3), + ), + ) + else + Wrap( + spacing: InkSpacing.xs, + runSpacing: InkSpacing.xs, + children: [ + for (final c in characters) + CharacterChip( + label: c.name.isNotEmpty ? c.name : c.id, + selected: _attachedIds.contains(c.id), + onTap: () => _toggle(c.id), + thumbPath: _characterThumb(projectId, c), + ), + ], + ), + const SizedBox(height: InkSpacing.xs), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: refSource == null + ? null + : () => _createFromReference(refSource), + icon: const Icon(Icons.person_add_alt_1, size: 16), + label: Text(context.l10n.inspectorCharactersSaveFromReference), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _importFromFile, + icon: const Icon(Icons.upload_file, size: 16), + label: Text(context.l10n.inspectorCharactersImportFile), + ), + ), + ], + ); + } + + /// 从磁盘选图新建角色(file_selector,桌面)。不依赖已连参考图,补齐首个角色的种子路径。 + Future _importFromFile() async { + final projectId = widget.targetNode.projectId; + if (projectId == null) return; + const group = XTypeGroup( + label: 'images', + extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'], + ); + final file = await openFile(acceptedTypeGroups: [group]); + if (file == null || !mounted) return; + final name = await _promptName(context); + if (name == null || name.trim().isEmpty) return; + try { + final id = await ref + .read(charactersControllerProvider(projectId).notifier) + .createFromImage(name: name.trim(), sourceAbsolutePath: file.path); + if (mounted) _toggle(id); + } on InkError catch (_) { + // 导入失败不崩 UI,但要提示(此前静默吞错 = 假成功)。 + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(context.l10n.inspectorCharactersImportFailed)), + ); + } + } + + Future _createFromReference(CanvasNode source) async { + final canvasId = widget.targetNode.canvasId; + final projectId = widget.targetNode.projectId; + final rel = source.imageUrl; + if (canvasId == null || projectId == null || rel == null || rel.isEmpty) { + return; + } + final String abs; + try { + abs = ref + .read(fileResolverServiceProvider) + .resolve(projectId: projectId, canvasId: canvasId, relativePath: rel) + .path; + } on PathSecurityError catch (_) { + return; + } + final name = await _promptName(context); + if (name == null || name.trim().isEmpty) return; + try { + final id = await ref + .read(charactersControllerProvider(projectId).notifier) + .createFromImage(name: name.trim(), sourceAbsolutePath: abs); + if (mounted) _toggle(id); + } on InkError catch (_) { + // 导入/落库失败不崩 UI,但要提示(下次可重试)。 + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(context.l10n.inspectorCharactersImportFailed)), + ); + } + } + + Future _promptName(BuildContext context) => showDialog( + context: context, + builder: (ctx) => InspectorNameDialog( + title: ctx.l10n.inspectorCharactersDialogTitle, + hint: ctx.l10n.inspectorCharactersNameHint, + confirmLabel: ctx.l10n.inspectorCharactersSave, + cancelLabel: ctx.l10n.commonCancel, + ), + ); +} + +/// 命名对话框。TextEditingController 生命周期归 dialog 自身——退场动画期间 +/// TextField 仍引用 controller,调用方在 showDialog 返回后立即 dispose 会炸。 +class InspectorNameDialog extends StatefulWidget { + const InspectorNameDialog({ + super.key, + required this.title, + required this.hint, + required this.confirmLabel, + required this.cancelLabel, + }); + + final String title; + final String hint; + final String confirmLabel; + final String cancelLabel; + + @override + State createState() => _InspectorNameDialogState(); +} + +class _InspectorNameDialogState extends State { + final TextEditingController _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: InkInput(controller: _ctrl, hintText: widget.hint), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_ctrl.text), + child: Text(widget.confirmLabel), + ), + ], + ); + } +} + +/// 角色 chip 缩略图边长(正方形裁切)。 +const double _kCharacterThumbSize = 20; + +class CharacterChip extends StatelessWidget { + const CharacterChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.thumbPath, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + final String? thumbPath; + + @override + Widget build(BuildContext context) { + final colors = context.inkColors; + final typo = context.inkTypography; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(InkRadius.sm), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: InkSpacing.sm, + vertical: InkSpacing.xs, + ), + decoration: BoxDecoration( + color: selected ? colors.accent : colors.surface2, + borderRadius: BorderRadius.circular(InkRadius.sm), + border: Border.all(color: selected ? colors.accent : colors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (thumbPath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(InkRadius.sm), + child: Image.file( + File(thumbPath!), + width: _kCharacterThumbSize, + height: _kCharacterThumbSize, + fit: BoxFit.cover, + // LB-23:微缩略图按显示尺寸缩略解码。 + cacheWidth: (_kCharacterThumbSize * + MediaQuery.devicePixelRatioOf(context)) + .round(), + // 缺文件/坏图占位,不崩 UI。 + errorBuilder: (_, _, _) => Icon( + Icons.person_outline, + size: 14, + color: selected ? colors.onAccent : colors.fg3, + ), + ), + ), + const SizedBox(width: InkSpacing.xs), + ], + if (selected) ...[ + Icon(Icons.check, size: 14, color: colors.onAccent), + const SizedBox(width: InkSpacing.xs), + ], + Text( + label, + style: typo.caption.copyWith( + color: selected ? colors.onAccent : colors.fg1, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/canvas/widgets/image_config_inspector.dart b/lib/features/canvas/widgets/image_config_inspector.dart index d238ced..5ce713f 100644 --- a/lib/features/canvas/widgets/image_config_inspector.dart +++ b/lib/features/canvas/widgets/image_config_inspector.dart @@ -5,40 +5,32 @@ // InspectorSubmitController(nodeId);hasApiKey 经 inspectorHasApiKeyProvider // 缓存;四态渲染由 InspectorStatusBinding → InspectorStatusPanel 完成。 -import 'dart:io'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' hide AspectRatio; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/di/character_assets.dart'; -import '../../../core/di/file_resolver.dart'; import '../../../core/di/preferences.dart'; import '../../../core/di/providers.dart'; import '../../../core/errors/ink_error.dart'; -import '../../../core/interfaces/character_asset_service.dart'; -import '../../../core/interfaces/file_resolver_service.dart'; import '../../../core/models/provider_capabilities.dart'; import '../../../l10n/l10n_x.dart'; import '../../../theme/app_theme.dart'; import '../../../theme/components/ink_error_banner.dart'; import '../../../theme/components/ink_input.dart'; -import '../../../theme/primitives/ink_dashed_slot.dart'; import '../../../theme/tokens.dart'; import '../../generation/services/cost_estimator.dart'; import '../../generation/services/prompt_assembler.dart'; import '../models/canvas_edge.dart'; import '../models/canvas_node.dart'; -import '../models/character.dart'; import '../models/prompt_preset.dart'; import '../models/style_lane.dart'; import '../providers/canvas_base_style.dart'; import '../providers/canvas_edges_controller.dart'; import '../providers/canvas_lanes_controller.dart'; import '../providers/canvas_nodes_controller.dart'; -import '../providers/characters_controller.dart'; import '../providers/inspector_submit_controller.dart'; import '../providers/prompt_presets_controller.dart'; +import 'characters_section.dart'; import 'inspector_status_panel.dart'; import 'node_inputs_section.dart'; @@ -439,9 +431,11 @@ class _ImageConfigInspectorState extends ConsumerState { (prompt: _promptCtrl.text, negative: _negCtrl.text), ), const SizedBox(height: InkSpacing.md), - _CharactersSection( + CharactersSection( targetNode: widget.node, selectedCaps: selected, + // image 维持原门:maxRefImages>0 且 imageToImage(CH-2 抽共享后参数化)。 + requireImageToImageMode: true, ), const SizedBox(height: InkSpacing.md), Row( @@ -528,7 +522,7 @@ class _PresetsSectionState extends ConsumerState<_PresetsSection> { runSpacing: InkSpacing.xs, children: [ for (final p in presets) - _CharacterChip( + CharacterChip( label: p.name.isNotEmpty ? p.name : p.id, selected: false, onTap: () => widget.onApply(p), @@ -574,7 +568,7 @@ class _PresetsSectionState extends ConsumerState<_PresetsSection> { Future _promptPresetName(BuildContext context) => showDialog( context: context, - builder: (ctx) => _NameDialog( + builder: (ctx) => InspectorNameDialog( title: ctx.l10n.inspectorPresetsDialogTitle, hint: ctx.l10n.inspectorPresetsNameHint, confirmLabel: ctx.l10n.inspectorCharactersSave, @@ -583,361 +577,6 @@ class _PresetsSectionState extends ConsumerState<_PresetsSection> { ); } -/// 项目级角色一致性:把可复用角色挂到本 config 节点(写 type_config.character_ids), -/// 并支持把已连的参考图「存为角色」。仅当 provider 支持参考图时真正生效(否则给提示)。 -class _CharactersSection extends ConsumerStatefulWidget { - const _CharactersSection({ - required this.targetNode, - required this.selectedCaps, - }); - - final CanvasNode targetNode; - final ProviderCapabilities? selectedCaps; - - @override - ConsumerState<_CharactersSection> createState() => _CharactersSectionState(); -} - -class _CharactersSectionState extends ConsumerState<_CharactersSection> { - late Set _attachedIds; - - @override - void initState() { - super.initState(); - _attachedIds = _readAttached(widget.targetNode.typeConfig); - } - - Set _readAttached(Map tc) { - final raw = tc['character_ids']; - if (raw is List) { - return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toSet(); - } - return {}; - } - - InspectorSubmitController get _submitCtrl => ref.read( - inspectorSubmitControllerProvider(widget.targetNode.id).notifier, - ); - - bool get _supportsRefs { - final caps = widget.selectedCaps; - return caps != null && - caps.maxRefImages > 0 && - caps.modes.contains(GenerationMode.imageToImage); - } - - void _toggle(String id) { - setState(() { - if (!_attachedIds.add(id)) _attachedIds.remove(id); - }); - _submitCtrl.saveConfig({ - 'character_ids': _attachedIds.toList(growable: false), - }); - } - - /// 角色首张参考图缩略图绝对路径;无图 / 越权路径 → null 不渲染。 - String? _characterThumb(String projectId, Character c) { - if (c.referenceImagePaths.isEmpty) return null; - try { - return ref.read(characterAssetServiceProvider).absolutePathOf( - projectId: projectId, - relativePath: c.referenceImagePaths.first, - ); - } on CharacterAssetError { - return null; - } - } - - /// 本 config 节点第一条 reference data 边上、带 image_url 的源节点("存为角色"来源)。 - CanvasNode? _referenceSource() { - final canvasId = widget.targetNode.canvasId; - if (canvasId == null) return null; - final edges = - ref.watch(canvasEdgesControllerProvider(canvasId)).valueOrNull ?? - const []; - final nodes = - ref.watch(canvasNodesControllerProvider(canvasId)).valueOrNull ?? - const []; - final nodesById = {for (final n in nodes) n.id: n}; - for (final e in edges) { - if (e.targetNodeId != widget.targetNode.id || - e.edgeType != EdgeType.data || - e.role != EdgeRole.reference) { - continue; - } - final src = nodesById[e.sourceNodeId]; - if (src != null && (src.imageUrl?.isNotEmpty ?? false)) return src; - } - return null; - } - - @override - Widget build(BuildContext context) { - final projectId = widget.targetNode.projectId; - if (projectId == null) return const SizedBox.shrink(); - final colors = context.inkColors; - final typo = context.inkTypography; - final charactersAsync = ref.watch(charactersControllerProvider(projectId)); - final characters = charactersAsync.valueOrNull ?? const []; - final refSource = _referenceSource(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.inspectorCharactersLabel, - style: typo.caption.copyWith(color: colors.fg3), - ), - const SizedBox(height: InkSpacing.xs), - if (!_supportsRefs) - Padding( - padding: const EdgeInsets.only(bottom: InkSpacing.xs), - child: Text( - context.l10n.inspectorCharactersUnsupported, - style: typo.caption.copyWith(color: colors.warning), - ), - ), - // 加载失败 → 错误横幅(此前静默降级为空 = 误报"无角色")。 - if (charactersAsync.hasError) - InkErrorBanner( - message: l10nAsyncError(context, charactersAsync.error!), - ) - else if (characters.isEmpty) - InkDashedSlot( - onPressed: _importFromFile, - child: Text( - context.l10n.inspectorCharactersEmpty, - style: typo.caption.copyWith(color: colors.fg3), - ), - ) - else - Wrap( - spacing: InkSpacing.xs, - runSpacing: InkSpacing.xs, - children: [ - for (final c in characters) - _CharacterChip( - label: c.name.isNotEmpty ? c.name : c.id, - selected: _attachedIds.contains(c.id), - onTap: () => _toggle(c.id), - thumbPath: _characterThumb(projectId, c), - ), - ], - ), - const SizedBox(height: InkSpacing.xs), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: refSource == null - ? null - : () => _createFromReference(refSource), - icon: const Icon(Icons.person_add_alt_1, size: 16), - label: Text(context.l10n.inspectorCharactersSaveFromReference), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _importFromFile, - icon: const Icon(Icons.upload_file, size: 16), - label: Text(context.l10n.inspectorCharactersImportFile), - ), - ), - ], - ); - } - - /// 从磁盘选图新建角色(file_selector,桌面)。不依赖已连参考图,补齐首个角色的种子路径。 - Future _importFromFile() async { - final projectId = widget.targetNode.projectId; - if (projectId == null) return; - const group = XTypeGroup( - label: 'images', - extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'], - ); - final file = await openFile(acceptedTypeGroups: [group]); - if (file == null || !mounted) return; - final name = await _promptName(context); - if (name == null || name.trim().isEmpty) return; - try { - final id = await ref - .read(charactersControllerProvider(projectId).notifier) - .createFromImage(name: name.trim(), sourceAbsolutePath: file.path); - if (mounted) _toggle(id); - } on InkError catch (_) { - // 导入失败不崩 UI,但要提示(此前静默吞错 = 假成功)。 - if (!mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar(content: Text(context.l10n.inspectorCharactersImportFailed)), - ); - } - } - - Future _createFromReference(CanvasNode source) async { - final canvasId = widget.targetNode.canvasId; - final projectId = widget.targetNode.projectId; - final rel = source.imageUrl; - if (canvasId == null || projectId == null || rel == null || rel.isEmpty) { - return; - } - final String abs; - try { - abs = ref - .read(fileResolverServiceProvider) - .resolve(projectId: projectId, canvasId: canvasId, relativePath: rel) - .path; - } on PathSecurityError catch (_) { - return; - } - final name = await _promptName(context); - if (name == null || name.trim().isEmpty) return; - try { - final id = await ref - .read(charactersControllerProvider(projectId).notifier) - .createFromImage(name: name.trim(), sourceAbsolutePath: abs); - if (mounted) _toggle(id); - } on InkError catch (_) { - // 导入/落库失败不崩 UI,但要提示(下次可重试)。 - if (!mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar(content: Text(context.l10n.inspectorCharactersImportFailed)), - ); - } - } - - Future _promptName(BuildContext context) => showDialog( - context: context, - builder: (ctx) => _NameDialog( - title: ctx.l10n.inspectorCharactersDialogTitle, - hint: ctx.l10n.inspectorCharactersNameHint, - confirmLabel: ctx.l10n.inspectorCharactersSave, - cancelLabel: ctx.l10n.commonCancel, - ), - ); -} - -/// 命名对话框。TextEditingController 生命周期归 dialog 自身——退场动画期间 -/// TextField 仍引用 controller,调用方在 showDialog 返回后立即 dispose 会炸。 -class _NameDialog extends StatefulWidget { - const _NameDialog({ - required this.title, - required this.hint, - required this.confirmLabel, - required this.cancelLabel, - }); - - final String title; - final String hint; - final String confirmLabel; - final String cancelLabel; - - @override - State<_NameDialog> createState() => _NameDialogState(); -} - -class _NameDialogState extends State<_NameDialog> { - final TextEditingController _ctrl = TextEditingController(); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: InkInput(controller: _ctrl, hintText: widget.hint), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(widget.cancelLabel), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_ctrl.text), - child: Text(widget.confirmLabel), - ), - ], - ); - } -} - -/// 角色 chip 缩略图边长(正方形裁切)。 -const double _kCharacterThumbSize = 20; - -class _CharacterChip extends StatelessWidget { - const _CharacterChip({ - required this.label, - required this.selected, - required this.onTap, - this.thumbPath, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - final String? thumbPath; - - @override - Widget build(BuildContext context) { - final colors = context.inkColors; - final typo = context.inkTypography; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(InkRadius.sm), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: InkSpacing.sm, - vertical: InkSpacing.xs, - ), - decoration: BoxDecoration( - color: selected ? colors.accent : colors.surface2, - borderRadius: BorderRadius.circular(InkRadius.sm), - border: Border.all(color: selected ? colors.accent : colors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (thumbPath != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(InkRadius.sm), - child: Image.file( - File(thumbPath!), - width: _kCharacterThumbSize, - height: _kCharacterThumbSize, - fit: BoxFit.cover, - // LB-23:微缩略图按显示尺寸缩略解码。 - cacheWidth: (_kCharacterThumbSize * - MediaQuery.devicePixelRatioOf(context)) - .round(), - // 缺文件/坏图占位,不崩 UI。 - errorBuilder: (_, _, _) => Icon( - Icons.person_outline, - size: 14, - color: selected ? colors.onAccent : colors.fg3, - ), - ), - ), - const SizedBox(width: InkSpacing.xs), - ], - if (selected) ...[ - Icon(Icons.check, size: 14, color: colors.onAccent), - const SizedBox(width: InkSpacing.xs), - ], - Text( - label, - style: typo.caption.copyWith( - color: selected ? colors.onAccent : colors.fg1, - ), - ), - ], - ), - ), - ); - } -} - /// 最终 prompt 预览框:实时拼接 base 前缀 + 泳道风格 + 关联文本 + 用户 prompt。 class _PromptPreview extends ConsumerWidget { const _PromptPreview({ From ef515c9be1c66a6704e55bf7652e865328296640 Mon Sep 17 00:00:00 2001 From: Kerro Date: Thu, 20 Aug 2026 13:58:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(canvas):=20CH-2=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=20Inspector=20=E8=A7=92=E8=89=B2=E5=8C=BA=E2=80=94=E2=80=94CH-?= =?UTF-8?q?1=20=E6=B3=A8=E5=85=A5=E8=87=AA=E6=AD=A4=E6=9C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=85=A5=E5=8F=A3,=E9=97=A8=E6=8E=A7=20maxRefImages>0?= =?UTF-8?q?=20=E5=AF=B9=E9=BD=90=E6=B3=A8=E5=85=A5=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../widgets/video_config_inspector.dart | 12 ++ ...ideo_config_inspector_characters_test.dart | 160 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 test/features/canvas/widgets/video_config_inspector_characters_test.dart diff --git a/lib/features/canvas/widgets/video_config_inspector.dart b/lib/features/canvas/widgets/video_config_inspector.dart index 5912633..1bc2b09 100644 --- a/lib/features/canvas/widgets/video_config_inspector.dart +++ b/lib/features/canvas/widgets/video_config_inspector.dart @@ -22,6 +22,7 @@ import '../../generation/services/cost_estimator.dart'; import '../models/canvas_node.dart'; import '../providers/inspector_submit_controller.dart'; import '../util/camera_labels.dart'; +import 'characters_section.dart'; import 'inspector_status_panel.dart'; import 'node_inputs_section.dart'; @@ -300,6 +301,17 @@ class _VideoConfigInspectorState extends ConsumerState { context.l10n.inspectorVideoGenerateDisabledNoKey, onSubmit: _submit, ), + // CH-2:角色区(CH-1 视频注入的用户入口)。门控对齐注入门: + // 仅 maxRefImages>0 挂载——与 image 侧「常挂+警示文案」有意不同, + // 视频多数 provider 无 ref 能力,常挂=一屏死区。 + if (selected != null && selected.maxRefImages > 0) ...[ + const SizedBox(height: InkSpacing.lg), + CharactersSection( + targetNode: widget.node, + selectedCaps: selected, + requireImageToImageMode: false, + ), + ], // 入边(首/尾帧、参考图连线):i2v 的首尾帧语义由此处 role 切换驱动, // GenerationController 按 role 分流到 firstFramePath/lastFramePath。 if (widget.node.canvasId != null) ...[ diff --git a/test/features/canvas/widgets/video_config_inspector_characters_test.dart b/test/features/canvas/widgets/video_config_inspector_characters_test.dart new file mode 100644 index 0000000..4163041 --- /dev/null +++ b/test/features/canvas/widgets/video_config_inspector_characters_test.dart @@ -0,0 +1,160 @@ +// CH-2:video inspector 角色区——maxRefImages>0 挂载(不要求 imageToImage,CH-1 口径), +// =0 整段不出现;chip 点选写 type_config.character_ids。 +// 节点必须带 projectId/canvasId——角色区首行守卫 projectId==null 即 shrink +//(image 侧测试文件头注释踩过的坑)。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/di/character_assets.dart'; +import 'package:inkframe/core/di/providers.dart'; +import 'package:inkframe/core/di/repositories.dart'; +import 'package:inkframe/core/models/cost_model.dart'; +import 'package:inkframe/core/models/provider_capabilities.dart' as caps; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/widgets/characters_section.dart'; +import 'package:inkframe/features/canvas/widgets/video_config_inspector.dart'; + +import '../../../_harness/fake_character.dart'; +import '../../../_harness/fake_repositories.dart'; +import '../../../_harness/test_app.dart'; + +const _kCanvasId = 'cv-1'; +const _kProjectId = 'proj-1'; + +// 关键:modes 无 imageToImage——若共享 section 误用 image 门,挂载测试必红。 +const _refCapableVideoCaps = caps.ProviderCapabilities( + providerId: 'kling-v3-omni', + region: caps.ProviderRegion.cn, + modes: [caps.GenerationMode.textToVideo, caps.GenerationMode.imageToVideo], + supportedRatios: [caps.AspectRatio.r16x9], + supportedResolutions: [caps.Resolution.p1080], + supportedDurations: [5, 10], + supportedCameras: [], + maxBatchSize: 1, + maxRefImages: 4, + refImagesIncludeKeyframes: false, + supportsFirstFrame: false, + supportsLastFrame: false, + supportsNegativePrompt: false, + supportsSeed: false, + supportsSound: false, + supportsBatch: false, + supportsCancellation: true, + supportsPolling: true, + costModel: CostModel.perCall(usdPerCall: 0.1), + maxConcurrentJobs: 1, + qps: 1, + burst: 1, +); + +const _noRefVideoCaps = caps.ProviderCapabilities( + providerId: 'wanx-t2v', + region: caps.ProviderRegion.cn, + modes: [caps.GenerationMode.textToVideo], + supportedRatios: [caps.AspectRatio.r16x9], + supportedResolutions: [caps.Resolution.p1080], + supportedDurations: [5, 10], + supportedCameras: [], + maxBatchSize: 1, + maxRefImages: 0, + refImagesIncludeKeyframes: false, + supportsFirstFrame: false, + supportsLastFrame: false, + supportsNegativePrompt: false, + supportsSeed: false, + supportsSound: false, + supportsBatch: false, + supportsCancellation: true, + supportsPolling: true, + costModel: CostModel.perCall(usdPerCall: 0.1), + maxConcurrentJobs: 1, + qps: 1, + burst: 1, +); + +void main() { + late InMemoryNodeRepository nodeRepo; + late InMemoryEdgeRepository edgeRepo; + late FakeCharacterRepo charRepo; + + setUp(() { + nodeRepo = InMemoryNodeRepository(); + edgeRepo = InMemoryEdgeRepository(); + charRepo = FakeCharacterRepo(); + }); + + Future seedConfigNode() async { + final id = await nodeRepo.create( + canvasId: _kCanvasId, + type: 'video', + nodeRole: 'config', + label: 'cfg', + ); + return CanvasNode( + id: id, + label: 'cfg', + type: CanvasNodeType.video, + role: NodeRole.config, + canvasId: _kCanvasId, + projectId: _kProjectId, + ); + } + + Future pump( + WidgetTester tester, + CanvasNode node, { + required caps.ProviderCapabilities capabilities, + }) async { + await pumpInkApp( + tester, + Scaffold( + body: SingleChildScrollView(child: VideoConfigInspector(node: node)), + ), + locale: const Locale('en'), + overrides: [ + providerCapabilitiesListProvider.overrideWith( + (ref) => [capabilities], + ), + nodeRepositoryProvider.overrideWith((ref) async => nodeRepo), + edgeRepositoryProvider.overrideWith((ref) async => edgeRepo), + characterRepositoryProvider.overrideWith((ref) async => charRepo), + characterAssetServiceProvider.overrideWithValue( + FakeCharacterAssetService(), + ), + ], + ); + await tester.pumpAndSettle(); + } + + testWidgets('maxRefImages>0 → 角色区出现(无 imageToImage mode 也出现,CH-1 口径)', + (tester) async { + final node = await seedConfigNode(); + await pump(tester, node, capabilities: _refCapableVideoCaps); + + expect(find.byType(CharactersSection), findsOneWidget); + }); + + testWidgets('maxRefImages=0 → 角色区整段不挂载', (tester) async { + final node = await seedConfigNode(); + await pump(tester, node, capabilities: _noRefVideoCaps); + + expect(find.byType(CharactersSection), findsNothing); + }); + + testWidgets('点选角色 chip → type_config 写入 character_ids', (tester) async { + charRepo.rows['char-1'] = { + 'id': 'char-1', + 'project_id': _kProjectId, + 'name': 'Hero', + 'reference_image_paths': [], + }; + final node = await seedConfigNode(); + await pump(tester, node, capabilities: _refCapableVideoCaps); + + await tester.tap(find.text('Hero')); + await tester.pumpAndSettle(); + + final tc = nodeRepo.rows[node.id]!['type_config']! as Map; + expect(tc['character_ids'], ['char-1'], + reason: 'chip 点选须经 saveConfig 落到节点 type_config'); + }); +} From c720ed2168fa1db7878dfc694f2ec6cb2fec32bb Mon Sep 17 00:00:00 2001 From: Kerro Date: Thu, 20 Aug 2026 14:01:57 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20CH-2=20=E6=94=B6=E5=8F=A3=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E2=80=94=E2=80=94BOARD/MASTERPLAN/features=20?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=B8=89=E5=A4=84=E7=8A=B6=E6=80=81=20+=20?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/BOARD.md | 1 + docs/MASTERPLAN.md | 2 +- .../plans/2026-07-07-launch-features.md | 4 + ...6-08-20-ch-2-video-inspector-characters.md | 217 ++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-20-ch-2-video-inspector-characters.md diff --git a/docs/BOARD.md b/docs/BOARD.md index cf2848b..eba55d0 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -120,6 +120,7 @@ | **SB-2 脚本导入对话框 + 批量建链(M4 E1;E1 整条链自此贯通)**:`storyboard/providers/script_import_controller.dart` + `widgets/script_import_dialog.dart`——粘贴框/策略切换/实时预览 → 确认后 N 个 shot 节点 + N-1 条 narrative 边收进**单个事务**。**失败必须零残留**:半条链(几个建好的散镜 + 几条边)比一个错误提示难收拾得多——用户既不知道哪几镜落地了也没有一键撤销;测试用的 fake UoW **真回滚**(写入先落 staging,闭包整体成功才 commit),真事务语义仍由 `transaction_integration_test`(真 PG)兜底。两处坑:①**不走 CanvasNodesController**(它一次只建一个节点、不入事务)——直接经 scope 写库,落地后 invalidate 节点与边两个控制器 ②**provider 不加 autoDispose**——调用方只 `ref.read` 不订阅,autoDispose 会在 read 后立刻销毁 ref,事务 await 完再 invalidate 就撞上已销毁的 ref,画布刷不出新链。落点=第一镜落视口中心,后续每 `kShotChainSpacingX`(260) 沿 x 排开;入口=FAB 菜单「导入脚本…」+ 空态 CTA。ARB +13 键。+18 例 | #225 | | **XM-2 图片元数据(M4;补齐 XM-1 的图片侧)**:`core/media/png_dimensions.dart` 纯函数(PNG 签名 + IHDR,不引解码库)→ 四个落盘点(inline 单/批、remote 单/批)顺手解析宽高,连同 `task.seed` 写进 `node.type_config`(主图那一张,与 XM-1 视频侧同键)与 `batch_results.width/height/seed`(逐 slot 各记各的,schema 已建未用的列自此有值)。**判定刻意严格、拿不到就不写键**:认错了会把垃圾尺寸写进库再一路带到排版,而列停在 NULL 画廊才知道退回默认比例——每条正向断言都配一条「非 PNG 时这些键根本不出现」的反向断言。inline 路径 bytes 在手零额外 IO;remote 路径回读文件头 33 字节(完整 IHDR 含 CRC),**任何探针失败一律吞掉**——元数据是锦上添花,绝不能把一次成功的生成拖成失败。视频侧不动(宽高仍归 XM-1 抽帧探针)。+22 例 | #226 | | LB-23 内存基线:cacheWidth 缩略解码收口 ×4 文件 5 站点(gallery tile 图片+视频缩略图/batch grid/两微缩略图;lightbox InteractiveViewer 有意豁免)+ ImageCache 上限 100→256MB(`kImageCacheMaxBytes`,main bootstrap 设定)+ perf-baseline 内存水位节(方法论/阈值/mac 空载实测 140MB,画廊与生成场景按 SOP 待填,Windows 列待群内机器)+ keepAlive 盘点 8 文件无未管控大对象 | #227 | +| CH-2 视频 Inspector 角色区:角色区/命名框/角色 chip 抽共享 `characters_section.dart`(image inspector 1033→672 行,行为零变化以既有角色区测试一行未改全绿为证)+ video inspector 门控挂载(`maxRefImages>0` 对齐 CH-1 注入门,不检查 imageToImage——r2v/omni 语义;与 image 侧常挂+警示有意不同,视频多数 provider 无 ref 能力常挂=一屏死区)——CH-1 视频角色注入自此有用户可见入口 | #230 | ## M1 补遗(审计发现的悬空项) diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index fedea61..d604693 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -85,7 +85,7 @@ M6 「公开上线」……… 官网 + 示例项目 + 冷启动执行(HN/Reddit | E2 画廊 | 视频缩略/播/时长(**GA-1/2/7 ✅**);筛选(**GA-3 ✅**);存角色(**GA-4 ✅**——均 2026-08-05 Polish Wave 1);发送画布(GA-5,D-M4-2);删除(GA-6,D-M4-3,文件+DB 同步收敛) | GA-1~7 | | E3 聚合器 | 设置页可视化 CRUD+key 校验;≥2 协议模板;(二期)新增热生效 | AG-1~5 | | E4 导出 | narrative 自动排序(主体已随 PR #143 合入 main;剩 EX-1′)+转码归一+进度取消(**EX-3 ✅ 2026-08-05 Polish Wave 1**:ProcessStarter 流式通道+determinate 进度+取消,顺带债144 两件) | EX-1′~3 | -| E5 角色进阶 | 视频生成自动带角色图(**CH-1 注入链路 ✅ 2026-08-05 Polish Wave 1**——注:video inspector 尚无角色区,character_ids 当前仅 image inspector 可写/导入可带,用户可见入口随 CH-2);video inspector 角色区;角色库管理页 | CH-1~3 | +| E5 角色进阶 | 视频生成自动带角色图(**CH-1 注入链路 ✅ 2026-08-05 Polish Wave 1**——用户可见入口已随 CH-2 落地);video inspector 角色区(**CH-2 ✅ #230**);角色库管理页 | CH-1~3 | | E6 项目复制 | ⚠️ **裁决:排上线后**(BOARD 原判"单独立项";≡ backend BP-11,复用 LB-12 导入机器后 成本减半)。features 的 PD-1~3 保留为**实现细化参考**,不进 M4 波次;若用户要提前,依赖 LB-12 | PD-1~3(细化) | | E7 跨模块 metadata | duration/width/height/seed 回填 result 与 batch slot(**多 epic 阻塞点**) | XM-1~2 | diff --git a/docs/superpowers/plans/2026-07-07-launch-features.md b/docs/superpowers/plans/2026-07-07-launch-features.md index 117a60f..641031c 100644 --- a/docs/superpowers/plans/2026-07-07-launch-features.md +++ b/docs/superpowers/plans/2026-07-07-launch-features.md @@ -149,6 +149,10 @@ narrative 链可序列播放且与导出同序。 使用——需决策:留原文件共享 or 一并抽公共 widget;这不是零决策机械搬移**; video 侧挂 NodeInputsSection 前,门控 maxRefImages>0;顺带给 1019 行的 image inspector 减重 (勿与 P1-17 _PromptPreview 债同窗)。 + 状态:已随 #230 落地——三类抽共享 characters_section.dart(NameDialog/CharacterChip + 一并转公开,_PresetsSection 改 import;image inspector 1033→672 行零行为变化, + 既有角色区测试一行未改全绿);video 侧 maxRefImages>0 门控挂载, + requireImageToImageMode=false 对齐 CH-1 注入口径(不检查 modes)。 - **CH-3 角色库管理页**(M/L,可并行;入口形态 D-M4-8 默认 A 可先行):新 `features/characters/` 整屏(路由完全仿 gallery 的 currentGalleryProjectProvider 模式,优先级 canvas>gallery> characters>screen);列表卡(缩略图/名称/描述/图数);改名/删除用现有 controller 方法; diff --git a/docs/superpowers/plans/2026-08-20-ch-2-video-inspector-characters.md b/docs/superpowers/plans/2026-08-20-ch-2-video-inspector-characters.md new file mode 100644 index 0000000..e4fd3b6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-ch-2-video-inspector-characters.md @@ -0,0 +1,217 @@ +# CH-2 视频 Inspector 角色区 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 image inspector 的角色区抽成共享 widget,挂进 video inspector——给 CH-1 已落地的视频角色注入补上用户可见入口(video 节点自此可写 `type_config.character_ids`)。 + +**Architecture:** 机械抽取 + 参数化门控。`_CharactersSection`/`_NameDialog`/`_CharacterChip` 从 `image_config_inspector.dart`(1033 行)整体搬到新共享文件 `characters_section.dart` 并转公开;唯一行为参数是 ref 支持判定——image 保持 `maxRefImages>0 && imageToImage`,video 对齐 CH-1 注入门只要 `maxRefImages>0`(**不检查 modes**,r2v/omni 语义,见 generation_controller.dart:674 拍板注释)。video 侧按卡片要求以 `maxRefImages>0` 门控挂载(不支持的 provider 整段不出现,与 image 侧「挂载+警示文案」不同属有意差异)。 + +**决策(卡片留白处,本计划拍板):** `_NameDialog` 与 `_CharacterChip` 同时被 `_PresetsSection` 使用——**一并搬进共享文件转公开**(`InspectorNameDialog`/`CharacterChip`),image inspector 回头 import。理由:留私有副本=两份漂移源;`characters_section.dart` 是它们语义上的家(chip 渲染角色、dialog 命名角色,`_PresetsSection` 只是复用者)。image inspector 从 1033 行减 ~340 行,顺带完成卡片「给 image inspector 减重」目标。**不动 `_PromptPreview`**(P1-17 债明令勿同窗)。 + +**Tech Stack:** 纯 widget 搬移(零新依赖);InspectorSubmitController.saveConfig 写 `character_ids`(既有);测试复用 `test_app.dart` pumpInkApp + fake_repositories/fake_character harness。**零新 ARB key**(全部复用 inspectorCharacters* 既有键,本卡不碰 l10n)。 + +**行为零变化的证据面:** 既有 `image_config_inspector_characters_test.dart`(10 例)不改一行断言、搬移后必须全绿——这是「机械搬移未走样」的红线证据。 + +--- + +### Task 1: 抽取共享 `characters_section.dart`(image 侧行为零变化) + +**Files:** +- Create: `lib/features/canvas/widgets/characters_section.dart` +- Modify: `lib/features/canvas/widgets/image_config_inspector.dart`(删 L586-940 三个类,import 新文件,改 4 处调用点) +- Test: 既有 `test/features/canvas/widgets/image_config_inspector_characters_test.dart` **不改动**,作回归证据 + +- [ ] **Step 1: 新建 `characters_section.dart`,整体搬移三个类** + +从 `image_config_inspector.dart` 剪切(非复制)以下内容到新文件: +- `_CharactersSection` + `_CharactersSectionState`(现 L586-817)→ 改名 `CharactersSection`/`_CharactersSectionState` +- `_NameDialog` + `_NameDialogState`(现 L819-867 一带,含「controller 生命周期归 dialog」注释)→ 改名 `InspectorNameDialog`/`_InspectorNameDialogState` +- `_CharacterChip`(现 L869-940 一带,含 LB-23 cacheWidth 与 `_kCharacterThumbSize` 常量)→ 改名 `CharacterChip`;`_kCharacterThumbSize = 20` 常量一起搬 + +新文件头注释与公开签名(构造参数新增 `requireImageToImageMode`): + +```dart +// CharactersSection:config 节点角色区(CH-2 起 image/video inspector 共享)。 +// 把项目级角色挂到节点(写 type_config.character_ids)+ 存为角色/从文件导入。 +// ref 支持判定按调用方口径参数化:image 要求 maxRefImages>0 且 imageToImage; +// video 对齐 CH-1 注入门只要 maxRefImages>0(不检查 modes——r2v/omni 的参考图 +// 语义不落在 modes 里,见 generation_controller._injectCharacterRefs 拍板注释)。 + +class CharactersSection extends ConsumerStatefulWidget { + const CharactersSection({ + super.key, + required this.targetNode, + required this.selectedCaps, + required this.requireImageToImageMode, + }); + + final CanvasNode targetNode; + final ProviderCapabilities? selectedCaps; + + /// image inspector 传 true(维持原门);video inspector 传 false(CH-1 口径)。 + final bool requireImageToImageMode; + ... +} +``` + +`_supportsRefs` 改为: + +```dart + bool get _supportsRefs { + final caps = widget.selectedCaps; + if (caps == null || caps.maxRefImages <= 0) return false; + return !widget.requireImageToImageMode || + caps.modes.contains(GenerationMode.imageToImage); + } +``` + +其余方法体(`_readAttached`/`_toggle`/`_characterThumb`/`_referenceSource`/`build`/`_importFromFile`/`_createFromReference`/`_promptName`)**逐字搬移不改**。import 列表按 analyzer 报错补齐(来源即 image_config_inspector 现 import:canvas_node/provider_capabilities/inspector_submit_controller/characters_controller/canvas_edges/nodes controller/character_assets/file_resolver/ink_error/l10n_x/app_theme/tokens/ink_error_banner/ink_dashed_slot/file_selector 等)。 + +- [ ] **Step 2: image_config_inspector.dart 改调用点** + +```dart +import 'characters_section.dart'; +``` + +四处改名: +1. L442 挂载处:`_CharactersSection(` → `CharactersSection(targetNode: ..., selectedCaps: ..., requireImageToImageMode: true,)` +2. `_PresetsSection` 内 L531:`_CharacterChip(` → `CharacterChip(` +3. `_PresetsSection` 内 L577:`_NameDialog(` → `InspectorNameDialog(` +4. 文件头注释若列了区块清单,同步措辞(角色区已抽共享文件) + +- [ ] **Step 3: 回归验证(既有 10 例一行未改必须全绿)** + +```bash +flutter analyze lib test 2>&1 | tail -1 +flutter test test/features/canvas/widgets/image_config_inspector_characters_test.dart 2>&1 | tail -2 +``` + +Expected: No issues / All tests passed(若测试红=搬移走样,回 Step 1 对照原文逐字核对,禁止改测试迁就实现)。 + +- [ ] **Step 4: 提交** + +```bash +git add lib/features/canvas/widgets +git commit -m "refactor(canvas): CH-2 前置——角色区/命名框/角色 chip 抽共享 characters_section.dart,image inspector 减重 ~340 行行为零变化 + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 2: video inspector 挂载角色区(TDD) + +**Files:** +- Modify: `lib/features/canvas/widgets/video_config_inspector.dart`(NodeInputsSection 挂载点前,现 L305 一带) +- Test: `test/features/canvas/widgets/video_config_inspector_characters_test.dart`(新建) + +- [ ] **Step 1: 写失败测试** + +新建测试文件,harness 三来源拼装:caps 常量抄 `video_config_inspector_test.dart` 的 `_fakeVideoCaps`(maxRefImages=1,即 ref-capable)与 `_noCameraCaps`(maxRefImages=0);角色数据/仓储 override 抄 `image_config_inspector_characters_test.dart` 的 fake_character + fake_repositories 用法;节点必须带 `projectId`/`canvasId`(角色区首行守卫 projectId==null 即 shrink——image 侧测试文件头注释已踩过此坑)。 + +```dart +// CH-2:video inspector 角色区——maxRefImages>0 挂载(不要求 imageToImage,CH-1 口径), +// =0 整段不出现;chip 点选写 type_config.character_ids。 +void main() { + testWidgets('maxRefImages>0 → 角色区出现(无 imageToImage mode 也出现)', + (tester) async { + // _fakeVideoCaps.modes = [textToVideo, imageToVideo]——无 imageToImage, + // 若共享 section 误用 image 门此测必红。 + await _pumpVideoInspector(tester, capsList: [_fakeVideoCaps]); + expect(find.byType(CharactersSection), findsOneWidget); + }); + + testWidgets('maxRefImages=0 → 角色区整段不挂载', (tester) async { + await _pumpVideoInspector(tester, capsList: [_noCameraCaps]); + expect(find.byType(CharactersSection), findsNothing); + }); + + testWidgets('点选角色 chip → saveConfig 写 character_ids', (tester) async { + // 角色仓储种一条 Character(id: 'ch-1'),tap chip 后断言 + // fake canvas 仓储收到的 typeConfig 含 {'character_ids': ['ch-1']} + // (断言写法对照 image_config_inspector_characters_test.dart 既有 toggle 用例)。 + }); +} +``` + +(第三例的完整断言按 image 侧同名场景照抄改 caps;若 image 侧无现成 toggle 落库断言则经 fake_repositories 的 canvas 仓储 update 记录断。) + +- [ ] **Step 2: 跑测试确认红** + +```bash +flutter test test/features/canvas/widgets/video_config_inspector_characters_test.dart 2>&1 | tail -3 +``` + +Expected: FAIL(CharactersSection findsNothing——video inspector 尚未挂载)。 + +- [ ] **Step 3: 实现挂载(video_config_inspector.dart)** + +import 增 `'characters_section.dart'`;`build` 内 NodeInputsSection 块之前插入: + +```dart + // CH-2:角色区(CH-1 视频注入的用户入口)。门控对齐注入门: + // 仅 maxRefImages>0 挂载——与 image 侧「常挂+警示文案」有意不同, + // 视频多数 provider 无 ref 能力,常挂=一屏死区。 + if (selected != null && selected.maxRefImages > 0) ...[ + const SizedBox(height: InkSpacing.lg), + CharactersSection( + targetNode: widget.node, + selectedCaps: selected, + requireImageToImageMode: false, + ), + ], +``` + +- [ ] **Step 4: 测试绿 + 双 inspector 回归** + +```bash +flutter test test/features/canvas/widgets/ 2>&1 | tail -2 +flutter analyze lib test 2>&1 | tail -1 +``` + +Expected: 全过、no issues。 + +- [ ] **Step 5: 提交** + +```bash +git add lib/features/canvas/widgets test/features/canvas/widgets +git commit -m "feat(canvas): CH-2 视频 Inspector 角色区——CH-1 注入自此有用户入口,门控 maxRefImages>0 对齐注入口径 + +Co-Authored-By: Claude Fable 5 " +``` + +### Task 3: 状态回填 + 全量闸门 + PR + +**Files:** +- Modify: `docs/BOARD.md`(近期落地表 #228 行后加行) +- Modify: `docs/MASTERPLAN.md:88`(E5 行 CH-2 标 ✅) +- Modify: `docs/superpowers/plans/2026-07-07-launch-features.md:146`(CH-2 卡加状态行) + +- [ ] **Step 1: 三处回填**(PR 号预写 #229,建 PR 时核实) + +MASTERPLAN E5 行:`video inspector 角色区` → `video inspector 角色区(**CH-2 ✅ #229**)`,并把括号注里「用户可见入口随 CH-2」句改为「用户可见入口已随 CH-2 落地」。 + +features plan CH-2 卡后加: + +```markdown + 状态:已随 #229 落地——三类抽共享 characters_section.dart(NameDialog/CharacterChip + 一并转公开,_PresetsSection 改 import;image inspector 减重 ~340 行零行为变化, + 既有 10 例角色区测试一行未改全绿);video 侧 maxRefImages>0 门控挂载, + requireImageToImageMode=false 对齐 CH-1 注入口径。 +``` + +BOARD 近期落地表: + +```markdown +| CH-2 视频 Inspector 角色区:角色区/命名框/角色 chip 抽共享 `characters_section.dart`(image inspector 1033→约 690 行,行为零变化以既有 10 例测试一行未改全绿为证)+ video inspector 门控挂载(`maxRefImages>0` 对齐 CH-1 注入门,不检查 imageToImage——r2v/omni 语义;与 image 侧常挂+警示有意不同)——CH-1 视频角色注入自此有用户可见入口 | #229 | +``` + +- [ ] **Step 2: 全量闸门 + 提交 + push(pbcopy 交用户 `!` 执行)+ PR + CI 绿合并** + +```bash +flutter analyze lib test && flutter test --exclude-tags golden +git add -A docs && git commit -m "docs: CH-2 收口回填——BOARD/MASTERPLAN/features 计划三处状态 + +Co-Authored-By: Claude Fable 5 " +printf 'git push -u origin feat/ch-2-video-inspector-characters' | pbcopy # 用户 ! 执行 +gh pr create --title "feat: CH-2 视频 Inspector 角色区——抽共享 section + CH-1 注入的用户入口" --body "(正文技术记号一律反引号,禁裸 @)" +gh pr checks --watch && gh pr merge --squash --delete-branch +```