修复图片画布内部模型搜索泄漏

素材与图层搜索排除内部处理模型和 Provider。
补充模型层与侧栏回归测试,确认审计元数据不被删除。
同步图片画布搜索契约与项目决策记录。
This commit is contained in:
2026-07-31 02:43:05 +00:00
parent 6170a91992
commit 915eb4b739
5 changed files with 193 additions and 6 deletions
@@ -26,6 +26,16 @@
---
## 2026-07-30 图片画布搜索不得索引内部模型和 Provider
- 背景:素材和图层详情虽已把内部处理模型显示为 `-`,搜索仍索引原始 `model` 与 `provider`,导致 `BgFilter complex`、`segment-common-image`、`BgFilter` 或 `Aliyun Matting` 等隐藏信息可被查询命中,并出现“命中但无可见匹配字段”的异常体验。
- 决策:素材与图层搜索只索引用户可见生成模型;统一复用 `isEditorInternalProcessingModel(...)` 排除内部处理模型,并完全排除 `provider`。该规则只约束前端临时搜索值,不删除或改写素材、图层及后端保存的原始审计字段;`gpt-image-2`、`audio1.0` 等正常模型继续支持搜索。
- 影响范围:`ImageCanvasAssetLibraryModel.ts` 的素材与图层搜索值、图片画布素材 / 图层侧栏搜索和对应前端架构文档;不改变持久化、详情展示、删除、移动或画布保存行为。
- 验证方式:模型单测覆盖内部模型和 Provider 不命中、正常模型继续命中且原对象元数据不变;侧栏交互测试覆盖素材与图层两类入口。运行对应 Vitest、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding` 和 `git diff --check`。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
---
## 2026-07-29 图集切片必须受前置容量和有界 CPU 保护
- 背景:图标与 UI 图集的 alpha 连通域识别会在 async handler 上同步执行;原始连通域合并采用全量两两比较,`64` 个输出限制又晚于排序、裁剪和 PNG 编码。碎块或噪点图会放大 CPU 与内存成本,手动拆分、图标自动拆分和 UI 提取都受影响。另一方面,图标与 UI 的 Alpha 尺寸恢复、provider 原图回读或透明图解码失败此前只记日志,仍会把不可信透明图持久化并拆分。
@@ -164,8 +164,8 @@
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId`、`layerId`、`resourceId`、`sourceAssetId`、`sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果按序列帧素材处理:图层主 `src` 使用 `frames[0].imageSrc`,`mediaType` 固定为 `image-sequence`,`assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。下载和 ZIP 导出必须因此得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;停止索引不删除素材上供 owner / 后台审计使用的原始元数据。
- 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
@@ -9,6 +9,8 @@ import {
filterGroupedAssetsBySearch,
getSelectableAssets,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
removeAssetById,
@@ -28,6 +30,7 @@ import {
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasLayer,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
@@ -60,6 +63,24 @@ function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
};
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '图层A',
src: 'data:image/png;base64,YQ==',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
...overrides,
};
}
describe('ImageCanvasAssetLibraryModel', () => {
it('groups assets by folder and resolves selectable uploaded assets', () => {
const folders = [
@@ -184,6 +205,60 @@ describe('ImageCanvasAssetLibraryModel', () => {
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
});
it('excludes internal processing models and providers from asset and layer search without deleting metadata', () => {
const internalAsset = createAsset({
id: 'asset-internal',
label: '透明角色',
sourceType: 'generated',
model: 'BgFilter complex',
provider: 'BgFilter',
});
const visibleAsset = createAsset({
id: 'asset-visible',
label: '生成角色',
sourceType: 'generated',
model: 'gpt-image-2',
provider: 'VectorEngine',
});
const groupedAssets = groupAssetsByFolder(
[createFolder()],
[internalAsset, visibleAsset],
);
const internalLayer = createLayer({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
const visibleLayer = createLayer({
id: 'layer-visible',
model: 'audio1.0',
provider: 'VectorEngine',
});
expect(filterGroupedAssetsBySearch(groupedAssets, 'bgfilter')).toEqual([]);
expect(filterGroupedAssetsBySearch(groupedAssets, 'vectorengine')).toEqual(
[],
);
expect(
filterGroupedAssetsBySearch(groupedAssets, 'gpt-image-2')[0]?.assets.map(
(asset) => asset.id,
),
).toEqual(['asset-visible']);
expect(layerMatchesSearch(internalLayer, 'segment-common-image')).toBe(
false,
);
expect(layerStartsWithSearch(internalLayer, 'aliyun')).toBe(false);
expect(layerMatchesSearch(visibleLayer, 'audio1.0')).toBe(true);
expect(layerMatchesSearch(visibleLayer, 'vectorengine')).toBe(false);
expect(internalAsset).toMatchObject({
model: 'BgFilter complex',
provider: 'BgFilter',
});
expect(internalLayer).toMatchObject({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
});
it('renames assets, toggles folders and creates local folders', () => {
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
{ label: '新名字' },
@@ -1,3 +1,4 @@
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
CanvasLayer,
@@ -204,13 +205,19 @@ function startsWithSearchText(query: string, values: Array<string | null | undef
return values.some((value) => value?.trim().toLowerCase().startsWith(query));
}
function resolveSearchableEditorModel(model: string | null | undefined) {
const normalizedModel = model?.trim();
return normalizedModel && !isEditorInternalProcessingModel(normalizedModel)
? normalizedModel
: undefined;
}
function buildAssetSearchValues(asset: EditorAsset) {
return [
asset.label,
asset.prompt,
asset.actualPrompt,
asset.model,
asset.provider,
resolveSearchableEditorModel(asset.model),
asset.taskId,
asset.assetKind,
asset.mediaType,
@@ -222,8 +229,7 @@ function buildLayerSearchValues(layer: CanvasLayer) {
layer.title,
layer.prompt,
layer.actualPrompt,
layer.model,
layer.provider,
resolveSearchableEditorModel(layer.model),
layer.taskId,
layer.assetKind,
layer.mediaType,
@@ -269,6 +269,52 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
});
it('does not expose internal models or providers through asset search', () => {
const folder = createFolder();
const internalAsset = createAsset({
id: 'asset-internal',
label: '透明角色',
sourceType: 'generated',
model: 'BgFilter complex',
provider: 'BgFilter',
});
const visibleAsset = createAsset({
id: 'asset-visible',
label: '生成角色',
sourceType: 'generated',
model: 'gpt-image-2',
provider: 'VectorEngine',
});
render(
<ImageCanvasSidebarView
{...createSidebarProps({
assets: [internalAsset, visibleAsset],
groupedAssets: [
{ ...folder, assets: [internalAsset, visibleAsset] },
],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const search = within(sidebar).getByLabelText('搜索素材');
fireEvent.change(search, { target: { value: 'BgFilter' } });
expect(
within(sidebar).queryByRole('button', { name: '添加透明角色' }),
).toBeNull();
fireEvent.change(search, { target: { value: 'VectorEngine' } });
expect(
within(sidebar).queryByRole('button', { name: '添加生成角色' }),
).toBeNull();
fireEvent.change(search, { target: { value: 'gpt-image-2' } });
expect(
within(sidebar).getByRole('button', { name: '添加生成角色' }),
).toBeTruthy();
});
it('renders the layer panel with grouped and locked layer details', () => {
render(
<ImageCanvasSidebarView
@@ -346,6 +392,56 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
});
it('does not expose internal models or providers through layer search', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({
id: 'layer-internal',
title: '透明角色图层',
model: 'segment-common-image',
provider: 'Aliyun Matting',
zIndex: 2,
}),
createLayer({
id: 'layer-visible',
title: '生成角色图层',
model: 'gpt-image-2',
provider: 'VectorEngine',
zIndex: 1,
}),
],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const search = within(sidebar).getByLabelText('搜索画布图层');
fireEvent.change(search, { target: { value: 'segment-common-image' } });
expect(
within(sidebar).queryByRole('button', {
name: '选择图层透明角色图层',
}),
).toBeNull();
fireEvent.change(search, { target: { value: 'VectorEngine' } });
expect(
within(sidebar).queryByRole('button', {
name: '选择图层生成角色图层',
}),
).toBeNull();
fireEvent.change(search, { target: { value: 'gpt-image-2' } });
expect(
within(sidebar).getByRole('button', {
name: '选择图层生成角色图层',
}),
).toBeTruthy();
});
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
render(<SidebarTabsHarness />);