停止向用户泄露抠图模型
Project CI / Repository checks (pull_request) Successful in 1m3s
Project CI / Native shell tests (pull_request) Successful in 2m34s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / Backend tests (pull_request) Successful in 3m33s

This commit is contained in:
2026-07-30 04:28:53 +00:00
parent 2cb40f246a
commit 31f7d5ea1b
9 changed files with 155 additions and 32 deletions
@@ -169,6 +169,8 @@
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
- 用户可见的 `generationInputs.fields` 不得写入或展示“处理模型”;角色抠图、图标 / UI 图集抠图及自动拆分形成的派生资产,其 `Model` 继续继承并展示源生图模型,不得改写为 `birefnet``anime-seg``connected-components`、阿里云抠图或本地键色等内部后处理模型。前端同时过滤历史项目中已持久化的“处理模型”字段;历史资源若只剩内部处理模型而无法恢复源生图模型,保留 `Model` 行并显示 `-`。画布 ZIP 的用户可见导出元数据使用同一过滤口径且保持既有结构。
## 后续扩展点
- 接入图片生成 / 修改计费、队列进度状态、OSS 落盘和更完整失败审计。
@@ -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<Value> {
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::<Vec<_>>(),
}))
@@ -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(
@@ -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('骑士');
@@ -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<string, CanvasLayerResourceMetadata>,
) {
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<string>();
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,
): {
@@ -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', () => {
@@ -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 ?? '-',
};
@@ -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,
) {
@@ -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(
<ImageCanvasMetadataModalView
layer={createLayer({
model: 'gpt-image-2',
generationInputs: {
fields: [
{ title: '处理阶段', value: '角色抠图' },
{ title: '处理模型', value: 'birefnet' },
],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
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(
<ImageCanvasMetadataModalView
layer={createLayer({
model: 'birefnet',
generationInputs: {
fields: [{ title: '处理阶段', value: '角色抠图' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
expect(within(dialog).getByText('Model')).toBeTruthy();
expect(within(dialog).queryByText('birefnet')).toBeNull();
});
it('renders nanobanana2 instead of the raw Gemini image model id', () => {
render(
<ImageCanvasMetadataModalView
@@ -3,7 +3,8 @@ import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
import {
formatLayerImageType,
getEditorImageModelDisplayName,
getEditorLayerModelDisplayName,
isEditorUserVisibleGenerationInputField,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
@@ -27,6 +28,11 @@ export function ImageCanvasMetadataModalView({
layer,
onClose,
}: ImageCanvasMetadataModalViewProps) {
const visibleGenerationInputFields =
layer?.generationInputs?.fields.filter(
isEditorUserVisibleGenerationInputField,
) ?? [];
const generationInputReferences = layer?.generationInputs?.references ?? [];
const mediaLabel =
layer?.mediaType === 'audio'
? '音频'
@@ -52,10 +58,10 @@ export function ImageCanvasMetadataModalView({
<dd>{formatLayerImageType(layer)}</dd>
<dt></dt>
<dd className="image-canvas-editor__metadata-inputs">
{layer.generationInputs?.fields.length ||
layer.generationInputs?.references.length ? (
{visibleGenerationInputFields.length ||
generationInputReferences.length ? (
<>
{layer.generationInputs.fields.map((field) => (
{visibleGenerationInputFields.map((field) => (
<div
key={`${field.title}-${field.value}`}
className="image-canvas-editor__metadata-input-field"
@@ -66,9 +72,9 @@ export function ImageCanvasMetadataModalView({
<span>{field.value}</span>
</div>
))}
{layer.generationInputs.references.length ? (
{generationInputReferences.length ? (
<div className="image-canvas-editor__metadata-reference-list">
{layer.generationInputs.references.map((reference) => (
{generationInputReferences.map((reference) => (
<div
key={`${reference.title}-${reference.label}-${reference.refType}-${reference.refId}`}
className="image-canvas-editor__metadata-reference-card"
@@ -93,7 +99,7 @@ export function ImageCanvasMetadataModalView({
)}
</dd>
<dt>Model</dt>
<dd>{layer.model ? getEditorImageModelDisplayName(layer.model) : '-'}</dd>
<dd>{getEditorLayerModelDisplayName(layer.model)}</dd>
{layer.mediaType === 'audio' ? (
<>
<dt></dt>