diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d7422fe6f..d906ed40a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -169,6 +169,8 @@ - 工程刷新后能从后端恢复资源、图层布局和 viewport。 - “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=` 并按 query 加载该工程。 +- 用户可见的 `generationInputs.fields` 不得写入或展示“处理模型”;角色抠图、图标 / UI 图集抠图及自动拆分形成的派生资产,其 `Model` 继续继承并展示源生图模型,不得改写为 `birefnet`、`anime-seg`、`connected-components`、阿里云抠图或本地键色等内部后处理模型。前端同时过滤历史项目中已持久化的“处理模型”字段;历史资源若只剩内部处理模型而无法恢复源生图模型,保留 `Model` 行并显示 `-`。画布 ZIP 的用户可见导出元数据使用同一过滤口径且保持既有结构。 + ## 后续扩展点 - 接入图片生成 / 修改计费、队列进度状态、OSS 落盘和更完整失败审计。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 993dbe364..400aa5ebf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1908,7 +1908,7 @@ pub(crate) async fn generate_editor_image_for_owner( let storage_profile = editor_generated_image_storage_profile(normalized_kind); let mut output_prompt = submitted_prompt.clone(); let mut output_actual_prompt = generated.actual_prompt.clone(); - let mut output_model = generation_options.model.to_string(); + let output_model = generation_options.model.to_string(); let mut output_provider = "VectorEngine".to_string(); let mut output_generation_inputs = generation_inputs.clone(); let source_record = if is_character_generation { @@ -2110,10 +2110,9 @@ pub(crate) async fn generate_editor_image_for_owner( image = restored_removal_image; output_prompt = "去除纯色背景".to_string(); output_actual_prompt = None; - output_model = removal.model; output_provider = removal_provider.to_string(); output_generation_inputs = - build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record); + build_editor_derived_asset_generation_inputs("角色抠图", &source_record); Some(source_record) } else { None @@ -2268,7 +2267,6 @@ pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs( fn build_editor_derived_asset_generation_inputs( stage: &str, - model: &str, source: &EditorGeneratedAssetRecord, ) -> Option { let reference = source @@ -2299,7 +2297,6 @@ fn build_editor_derived_asset_generation_inputs( Some(json!({ "fields": [ { "title": "处理阶段", "value": stage }, - { "title": "处理模型", "value": model }, ], "references": reference.into_iter().collect::>(), })) @@ -4157,7 +4154,6 @@ async fn request_editor_background_removal_image_with_bgfilter_worker( pub(crate) struct EditorScreenBackgroundRemovalOutput { pub(crate) image: DownloadedOpenAiImage, pub(crate) provider: &'static str, - pub(crate) model: String, } pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( @@ -4192,7 +4188,6 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( mime_type: image.mime_type, }, provider: "BgFilter", - model: seg_model.to_string(), }); } Err(error) if !error.allows_flat_fallback() => { @@ -4247,7 +4242,6 @@ async fn fallback_editor_screen_background_removal( Ok(image) => Ok(EditorScreenBackgroundRemovalOutput { image, provider: "Aliyun Matting", - model: "segment-common-image".to_string(), }), Err(error) => { tracing::warn!( @@ -4285,7 +4279,6 @@ async fn fallback_editor_screen_background_removal( output.map(|image| EditorScreenBackgroundRemovalOutput { image, provider: "Genarrative Local", - model: "screen-color-keying".to_string(), }) } } @@ -4825,11 +4818,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(editor_pixel_art_fallback_warning()), ); } - let matting_generation_inputs = build_editor_derived_asset_generation_inputs( - "图标图集抠图", - &removal.model, - &source_record, - ); + let matting_generation_inputs = + build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record); let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); @@ -4864,7 +4854,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( height: spritesheet_height, prompt: "去除纯色背景".to_string(), actual_prompt: None, - model: removal.model.clone(), + model: generation_options.model.to_string(), provider: removal.provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, @@ -4902,11 +4892,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( group_task_id: None, prompt: "自动拆分图集".to_string(), actual_prompt: None, - model: "connected-components".to_string(), + model: generation_options.model.to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", - "connected-components", &spritesheet_record, ), path_kind: "icon-spritesheet-assets", @@ -5588,7 +5577,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ); } let matting_generation_inputs = - build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record); + build_editor_derived_asset_generation_inputs("UI图集抠图", &source_record); let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); @@ -5623,7 +5612,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( height: spritesheet_height, prompt: "去除纯色背景".to_string(), actual_prompt: None, - model: removal.model.clone(), + model: generation_options.model.to_string(), provider: removal.provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, @@ -5662,11 +5651,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( group_task_id: None, prompt: "自动拆分图集".to_string(), actual_prompt: None, - model: "connected-components".to_string(), + model: generation_options.model.to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", - "connected-components", &spritesheet_record, ), path_kind: "ui-design-assets", @@ -11891,6 +11879,7 @@ mod tests { "persist_editor_provider_source_resource", "build_multi_asset_canvas_layer_items", "if is_character_generation", + "let output_model = generation_options.model.to_string();", ], ); assert_function_contains_in_order( @@ -11916,6 +11905,7 @@ mod tests { "persist_editor_provider_source_resource", "slice_editor_icon_spritesheet_all", "persist_editor_spritesheet_slices", + "model: generation_options.model.to_string(),", ], ); assert_function_contains_in_order( @@ -11951,6 +11941,7 @@ mod tests { "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "persist_editor_provider_source_resource", "slice_generated_icon_spritesheet_all_by_connected_components", + "model: generation_options.model.to_string(),", ], ); assert_function_contains_in_order( diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index c613b81d7..1df6588a9 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -282,6 +282,8 @@ describe('ImageCanvasEditorModel', () => { zIndex: 9, sourceType: 'generated', objectKey: 'generated/object.png', + model: 'birefnet', + sourceResourceId: 'resource-provider-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '骑士' }], @@ -307,6 +309,15 @@ describe('ImageCanvasEditorModel', () => { fields: [{ title: '角色设定', value: '骑士' }], references: [], }, + model: 'birefnet', + sourceResourceId: 'resource-provider-source', + }, + ], + [ + 'resource-provider-source', + { + imageSrc: '/read/provider-source.png', + model: 'gpt-image-2', }, ], ]), @@ -318,6 +329,7 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', assetKind: 'character', objectKey: 'generated/object.png', + model: 'gpt-image-2', locked: true, }); expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index bbc1db6b7..c906545ec 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -43,6 +43,24 @@ export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; export const MINIMAP_DRAG_SENSITIVITY = 0.3; export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; +const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([ + 'anime-seg', + 'bgfilter complex', + 'birefnet', + 'connected-components', + 'screen-color-keying', + 'segment-common-image', +]); + +export function isEditorInternalProcessingModel( + model: string | null | undefined, +) { + const normalizedModel = model?.trim().toLowerCase(); + return Boolean( + normalizedModel && INTERNAL_EDITOR_PROCESSING_MODELS.has(normalizedModel), + ); +} + export const MAX_HISTORY_STEPS = 60; export const CONTEXT_MENU_VIEWPORT_MARGIN = 8; export const CONTEXT_MENU_SIZE = { @@ -734,7 +752,7 @@ export function hydrateLayer( prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), actualPrompt: stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt), - model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model), + model: resolveHydratedLayerModel(snapshot, resource, resourcesById), provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), @@ -761,6 +779,33 @@ export function hydrateLayer( }; } +function resolveHydratedLayerModel( + snapshot: EditorProjectLayerSnapshot, + resource: CanvasLayerResourceMetadata | undefined, + resourcesById: Map, +) { + const persistedModel = + stringOrNull(snapshot.model) ?? stringOrNull(resource?.model); + if (!isEditorInternalProcessingModel(persistedModel)) { + return persistedModel; + } + + let sourceResourceId = + stringOrNull(snapshot.sourceResourceId) ?? + stringOrNull(resource?.sourceResourceId); + const visitedResourceIds = new Set(); + while (sourceResourceId && !visitedResourceIds.has(sourceResourceId)) { + visitedResourceIds.add(sourceResourceId); + const sourceResource = resourcesById.get(sourceResourceId); + const sourceModel = stringOrNull(sourceResource?.model); + if (sourceModel && !isEditorInternalProcessingModel(sourceModel)) { + return sourceModel; + } + sourceResourceId = stringOrNull(sourceResource?.sourceResourceId); + } + return persistedModel; +} + export function mapAssetLibrarySnapshot( library: EditorAssetLibrarySnapshot, ): { diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6a3eeeb06..c01a5481f 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -175,6 +175,7 @@ describe('ImageCanvasExportModel', () => { it('filters built-in prompts from exported visible generation inputs', () => { const metadata = buildLayerExportMetadata( buildLayer({ + model: 'gpt-image-2', generationInputs: { fields: [ { title: '生成提示词', value: 'AI 生成图片' }, @@ -195,6 +196,7 @@ describe('ImageCanvasExportModel', () => { { title: '用户输入', value: '用户填写的视觉要求' }, { title: '游戏分类', value: '游戏音效' }, { title: 'duration', value: '5秒' }, + { title: '处理模型', value: 'birefnet' }, ], references: [], }, @@ -210,6 +212,7 @@ describe('ImageCanvasExportModel', () => { ], references: [], }); + expect(metadata.visible.model).toBe('gpt-image-2'); }); it('builds a Spine JSON attachment timeline from image sequence frames', () => { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 3c064986d..521ca5467 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -8,7 +8,8 @@ import type { import { DEFAULT_ICON_DESCRIPTIONS, formatLayerImageType, - getEditorImageModelDisplayName, + getEditorLayerModelDisplayName, + isEditorUserVisibleGenerationInputField, UI_DESIGN_ASSET_EXTRACTION_PROMPT, } from './ImageCanvasGenerationModel'; import { formatCanvasDurationMetric } from './ImageCanvasMediaModel'; @@ -543,6 +544,7 @@ function isBuiltInGenerationInputField(field: CanvasGenerationInputField) { const builtInValues = BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle); return ( + !isEditorUserVisibleGenerationInputField(field) || BUILT_IN_GENERATION_INPUT_TITLES.has(normalizedTitle) || normalizedValue === UI_DESIGN_ASSET_EXTRACTION_PROMPT || Boolean(builtInValues?.has(normalizedValue)) @@ -572,7 +574,7 @@ export function buildLayerVisibleExportMetadata(layer: CanvasLayer) { const base = { type: formatLayerImageType(layer), generationInputs: buildVisibleGenerationInputs(layer), - model: layer.model ? getEditorImageModelDisplayName(layer.model) : '-', + model: getEditorLayerModelDisplayName(layer.model), task: formatTaskIdForDisplay(layer.taskId), object: layer.objectKey ?? layer.assetObjectId ?? '-', }; diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 463def180..396d10029 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -4,7 +4,10 @@ import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, } from '../../services/image-editor/editorProjectClient'; -import { isGeneratedLayer } from './ImageCanvasEditorModel'; +import { + isEditorInternalProcessingModel, + isGeneratedLayer, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputField, @@ -548,6 +551,21 @@ export function getEditorImageModelDisplayName( ); } +export function isEditorUserVisibleGenerationInputField( + field: CanvasGenerationInputField, +) { + return field.title.trim() !== '处理模型'; +} + +export function getEditorLayerModelDisplayName( + model: string | null | undefined, +) { + if (!model?.trim() || isEditorInternalProcessingModel(model)) { + return '-'; + } + return getEditorImageModelDisplayName(model); +} + export function getEditorGenerationModelDisplayName( model: string | null | undefined, ) { diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index 41ab7a9aa..d48df2de0 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -58,6 +58,7 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('参考图')).toBeTruthy(); expect(within(dialog).getByText('角色立绘')).toBeTruthy(); expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy(); + expect(within(dialog).getByText('Model')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); expect(within(dialog).queryByText('Provider')).toBeNull(); @@ -66,6 +67,49 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('123')).toBeTruthy(); }); + it('keeps the generation model while hiding internal processing models', () => { + const { rerender } = render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '图片信息' }); + + expect(within(dialog).getByText('处理阶段')).toBeTruthy(); + expect(within(dialog).getByText('角色抠图')).toBeTruthy(); + expect(within(dialog).queryByText('处理模型')).toBeNull(); + expect(within(dialog).getByText('Model')).toBeTruthy(); + expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(dialog).queryByText('birefnet')).toBeNull(); + + rerender( + , + ); + + expect(within(dialog).getByText('Model')).toBeTruthy(); + expect(within(dialog).queryByText('birefnet')).toBeNull(); + }); + it('renders nanobanana2 instead of the raw Gemini image model id', () => { render( {formatLayerImageType(layer)}
生成输入
- {layer.generationInputs?.fields.length || - layer.generationInputs?.references.length ? ( + {visibleGenerationInputFields.length || + generationInputReferences.length ? ( <> - {layer.generationInputs.fields.map((field) => ( + {visibleGenerationInputFields.map((field) => (
{field.value}
))} - {layer.generationInputs.references.length ? ( + {generationInputReferences.length ? (
- {layer.generationInputs.references.map((reference) => ( + {generationInputReferences.map((reference) => (
Model
-
{layer.model ? getEditorImageModelDisplayName(layer.model) : '-'}
+
{getEditorLayerModelDisplayName(layer.model)}
{layer.mediaType === 'audio' ? ( <>
时长