统一画布快速编辑白名单与入口行为
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 8s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Native shell tests (pull_request) Failing after 4m21s

新增快速编辑正向媒体与素材类型白名单
同步浮动工具栏、右键菜单、打开入口和提交门禁
禁用角色动作、序列帧、音频和单个图标的快速编辑
补齐白名单回归测试并更新编辑器文档
This commit is contained in:
2026-08-04 19:39:43 +08:00
parent 0cc257ce6d
commit 33d557a78b
12 changed files with 126 additions and 47 deletions
@@ -6027,6 +6027,14 @@
- 身份与 telemetry 扫描只消费可渲染文本、可执行 inline JavaScript 和已链接外部 unit,排除 inert/raw-text、HTML 注释、带 `src` body 与非 JavaScript script。资产路径比较保持大小写,Linux 文件身份不得经 ASCII 小写副本合并。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-04 图片画布快速编辑改用统一正向白名单(2026-08-05 修订)
- 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、单个拆分图标、UI 设计图、宣发图和视频。角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单。
- 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标开放、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标能够提交以及绕过入口时仍拒绝角色动作快速编辑。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``ImageCanvasSelectedLayerToolbarView.tsx``ImageCanvasContextMenusView.tsx``useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSubmissionWorkflow.ts`
## 2026-08-04 静态视觉状态流与可见证据收口
- Canvas、2D context 与图片变量统一按 semantic symbol 记录声明和整体赋值事件;重新指向离屏 Canvas、无效 context 或新图片时立即失效旧视觉身份,只有绘制点可证明的当前状态才作证。
File diff suppressed because one or more lines are too long
@@ -110,7 +110,7 @@
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,提交层也必须拒绝绕过入口的调用。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效和背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器。
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
@@ -159,7 +165,7 @@ describe('ImageCanvasContextMenusView', () => {
fireEvent.click(
screen.getByRole('menuitem', { name: 'Spine 导出(zip' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
@@ -178,10 +184,10 @@ describe('ImageCanvasContextMenusView', () => {
2,
expect.objectContaining({ mode: 'spine-json' }),
);
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenQuickEditPanel).not.toHaveBeenCalled();
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
@@ -349,6 +355,46 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
it.each([
{
title: '角色动作',
mediaType: 'image-sequence' as const,
assetKind: 'character-animation' as const,
},
{
title: '点击音效',
mediaType: 'audio' as const,
assetKind: 'sound-effect' as const,
},
{
title: '背景音乐',
mediaType: 'audio' as const,
assetKind: 'background-music' as const,
},
])('hides quick edit from both menus for $title', (overrides) => {
const layer = createLayer(overrides);
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
imageContextMenuLayer: layer,
contextMenuLayer: layer,
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
cleanup();
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
});
it('hides quick edit from the standalone menu for individual icons', () => {
const layer = createLayer({ assetKind: 'icon' });
renderContextMenus({
@@ -18,7 +18,7 @@ import type {
CanvasViewport,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import { isQuickEditSupportedLayer } from './ImageCanvasGenerationModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
type ImageCanvasContextMenusViewProps = {
@@ -501,7 +501,7 @@ export function ImageCanvasContextMenusView({
<hr />
{imageContextMenuLayer ? (
<>
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
{isQuickEditSupportedLayer(imageContextMenuLayer) ? (
<button
type="button"
role="menuitem"
@@ -565,7 +565,7 @@ export function ImageCanvasContextMenusView({
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
{isQuickEditSupportedLayer(imageContextMenuLayer) ? (
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
@@ -43,7 +43,7 @@ import {
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -424,17 +424,38 @@ describe('ImageCanvasGenerationModel', () => {
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditUnsupportedAssetKind({
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon',
}),
).toBe(true);
).toBe(false);
expect(
isQuickEditUnsupportedAssetKind({
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon-spritesheet',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'image-sequence',
assetKind: 'character-animation',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'audio',
assetKind: 'sound-effect',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'video',
assetKind: 'video',
}),
).toBe(true);
});
it('adds reference image semantics and snapshots for spec generation references', () => {
@@ -594,8 +594,29 @@ export function buildQuickEditModelOptions(currentModel: string) {
return options;
}
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
return layer.assetKind === 'icon';
const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set<CanvasLayer['mediaType']>([
undefined,
'image',
'video',
]);
const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
undefined,
null,
'spec',
'character',
'icon-spritesheet',
'icon-spec',
'publication-material',
'ui-design',
'video',
]);
export function isQuickEditSupportedLayer(layer: CanvasLayer) {
return (
QUICK_EDIT_SUPPORTED_MEDIA_TYPES.has(layer.mediaType) &&
QUICK_EDIT_SUPPORTED_ASSET_KINDS.has(layer.assetKind)
);
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
@@ -282,7 +282,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
).toEqual(['改造', '下载按钮']);
});
it('renders character animation only for character layers', () => {
@@ -13,7 +13,7 @@ import type { CSSProperties } from 'react';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import { isQuickEditSupportedLayer } from './ImageCanvasGenerationModel';
type ImageCanvasSelectedLayerToolbarViewProps = {
selectedLayer: CanvasLayer | null;
@@ -89,7 +89,7 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
{isQuickEditSupportedLayer(selectedLayer) ? (
<>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -728,7 +728,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改图标:图标类素材不支持快速编辑',
'failed:修改图标:当前素材类型不支持快速编辑',
);
});
@@ -1348,13 +1348,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits character action quick edits through image edits', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '让当前图跳得更高',
imageSrc: 'data:image/png;base64,action-frame-edited',
}),
);
it('rejects character action quick edits', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
@@ -1393,25 +1387,13 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '让当前图跳得更高',
sourceImageSrc: 'resource-source',
model: 'gpt-image-2',
assetLabel: '角色动作 快速编辑',
targetLayerId: 'layer-source',
}),
);
});
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited',
);
});
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:让当前图跳得更高:当前素材类型不支持快速编辑',
);
});
it('captures a local edit fallback when the project response has no snapshot', async () => {
@@ -65,7 +65,7 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -1344,11 +1344,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return;
}
if (isQuickEditUnsupportedAssetKind(quickEditSourceLayer)) {
if (!isQuickEditSupportedLayer(quickEditSourceLayer)) {
setQuickEditPanel({
...quickEditPanel,
status: 'failed',
errorMessage: '图标类素材不支持快速编辑',
errorMessage: '当前素材类型不支持快速编辑',
});
return;
}
@@ -79,7 +79,7 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
@@ -1196,7 +1196,7 @@ export function useImageCanvasGenerationWorkflow({
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
if (!isQuickEditSupportedLayer(sourceLayer)) {
return;
}
setImageContextMenu(null);