统一画布快速编辑白名单与入口行为 (#140)
Project CI / Repository checks (push) Successful in 56s
Project CI / Frontend tests (push) Successful in 4m1s
Project CI / Backend tests (push) Successful in 4m26s
Project CI / Native shell tests (push) Successful in 15m12s

- 使用白名单控制快速编辑功能的显示

- 同步浮动工具栏、右键菜单、打开入口和提交门禁
- 禁用角色动作序列帧(整体)、音频和单个图标(explain: backend reject)的快速编辑

- 把快速编辑api改为只接受resource Id | asset Id,删除 image_src 参数, 以保证校验

其中包含生产数据迁移。迁移用于清理历史普通图片错误持久化的 assetKind="image",覆盖 asset → project-resource → showcase → canvas 四个作用域,并包含 operator 鉴权、分批 dry-run、批次 SHA-256 绑定 apply、active 结构化画
  > 布迁移摘要同步及最终零残留复核。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/140
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #140.
This commit is contained in:
2026-08-12 14:46:24 +08:00
committed by 段舒康
parent 4c3c0e9cd4
commit 8abeb333bb
61 changed files with 11979 additions and 688 deletions
@@ -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={() => {
@@ -168,7 +168,7 @@ function createTestImageAsset({
width: 320,
height: 240,
sourceType,
assetKind: 'image' as const,
assetKind: null,
sourceResourceId,
};
}
@@ -6,6 +6,7 @@ import type {
} from '../../services/image-editor/editorProjectClient';
import {
CANVAS_WORLD_ORIGIN,
canvasAssetKindOrNull,
canvasDisplayScaleToViewportScale,
canvasDisplayViewportToViewport,
collectExpiredInlineGenerationDialogIds,
@@ -548,7 +549,7 @@ describe('ImageCanvasEditorModel', () => {
width: 640,
height: 640,
sourceType: 'generated',
assetKind: 'image',
assetKind: null,
sourceResourceId: 'resource-generated',
},
],
@@ -557,6 +558,7 @@ describe('ImageCanvasEditorModel', () => {
expect(library.assets[0]).toMatchObject({
id: 'asset-generated',
sourceResourceId: 'resource-generated',
assetKind: null,
});
});
@@ -1118,7 +1120,7 @@ describe('ImageCanvasEditorModel', () => {
assetKindOverride: 'character-animation',
},
new Map([
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
['resource-image', { imageSrc: '/read/image.png', assetKind: null }],
]),
{ onAssetKindOverrideFallback },
);
@@ -1127,29 +1129,28 @@ describe('ImageCanvasEditorModel', () => {
id: 'layer-image-with-action-label',
src: '/read/image.png',
mediaType: 'image',
resourceAssetKind: 'image',
resourceAssetKind: null,
assetKindOverride: null,
assetKind: 'image',
assetKind: null,
});
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
resourceAssetKind: 'image',
resourceAssetKind: null,
rejectedAssetKindOverride: 'character-animation',
});
});
it('uses the same media-family compatibility matrix for layer labels', () => {
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
true,
);
expect(canvasAssetKindOrNull('image')).toBeNull();
expect(isCanvasAssetKindOverrideCompatible(null, 'character')).toBe(true);
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
expect(
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
expect(isCanvasAssetKindOverrideCompatible(null, 'video')).toBe(false);
expect(
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
isCanvasAssetKindOverrideCompatible(null, 'character-animation'),
).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
false,
@@ -1806,7 +1806,7 @@ function inferEditorAssetKind(
objectKey?: string | null,
assetKind?: string | null,
mediaType?: CanvasMediaType,
): CanvasAssetKind {
): CanvasAssetKind | null {
const normalizedAssetKind = canvasAssetKindOrNull(assetKind);
if (normalizedAssetKind) {
return normalizedAssetKind;
@@ -1822,7 +1822,7 @@ function inferEditorAssetKind(
if (inferredMediaType === 'audio') {
return inferAudioAssetKindFromLabel(label);
}
return 'image';
return null;
}
function hasMediaFileExtension(
@@ -2143,8 +2143,7 @@ export function generationInputsOrNull(
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
return value === 'image' ||
value === 'audio' ||
return value === 'audio' ||
value === 'spec' ||
value === 'character' ||
value === 'character-animation' ||
@@ -22,7 +22,6 @@ import type {
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind =
| 'image'
| 'audio'
| 'spec'
| 'character'
@@ -50,7 +50,7 @@ import {
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
isNormalizedCanvasGenerationInputs,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -530,16 +530,6 @@ describe('ImageCanvasGenerationModel', () => {
},
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'image',
generationInputs: {
fields: [{ title: '画面内容', value: '非场景展示字段' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
@@ -931,17 +921,52 @@ describe('ImageCanvasGenerationModel', () => {
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditUnsupportedAssetKind({
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon',
mediaType: 'image',
assetKind: null,
}),
).toBe(true);
expect(
isQuickEditUnsupportedAssetKind({
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon-spritesheet',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'scene',
resourceAssetKind: 'icon',
}),
).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', () => {
@@ -709,8 +709,30 @@ 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',
'scene',
'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) {
@@ -10,6 +10,7 @@ import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -31,6 +32,33 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
}
describe('ImageCanvasGenerationSubmissionModel', () => {
it('resolves registered image-edit sources by resource ID first', () => {
expect(
resolveRegisteredEditorReferenceId({
resourceId: ' resource-source ',
sourceAssetId: 'asset-source',
}),
).toBe('resource-source');
});
it('falls back to a registered asset ID for image edits', () => {
expect(
resolveRegisteredEditorReferenceId({
resourceId: 'local-resource-source',
sourceAssetId: ' asset-source ',
}),
).toBe('asset-source');
});
it('rejects unregistered image-edit sources', () => {
expect(() =>
resolveRegisteredEditorReferenceId({
resourceId: 'generation-dialog:source',
sourceAssetId: 'upload-source',
}),
).toThrow('原图尚未登记为项目资源或素材');
});
it('builds normal image generation submission plans', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -133,10 +133,13 @@ export function resolveImageReferenceSubmissionSource(reference: {
return reference.objectKey?.trim() || reference.src;
}
export function resolveRegisteredEditorReferenceId(reference: {
resourceId?: string | null;
sourceAssetId?: string | null;
}) {
export function resolveRegisteredEditorReferenceId(
reference: {
resourceId?: string | null;
sourceAssetId?: string | null;
},
referenceLabel: '原图' | '参考图' = '原图',
) {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
@@ -146,10 +149,12 @@ export function resolveRegisteredEditorReferenceId(reference: {
return resourceId;
}
const assetId = reference.sourceAssetId?.trim();
if (assetId) {
if (assetId && !assetId.startsWith('upload-')) {
return assetId;
}
throw new Error('参考图尚未登记为项目资源或素材,请重新选择或上传后再试');
throw new Error(
`${referenceLabel}尚未登记为项目资源或素材,请重新选择或上传后再试`,
);
}
function buildSeedanceVideoReferenceInput(
@@ -735,6 +740,7 @@ export function buildImageGenerationSubmissionPlan({
? {
referenceId: resolveRegisteredEditorReferenceId(
dialog.specReference,
'参考图',
),
}
: {}),
@@ -1090,7 +1096,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
}
let referenceId: string;
try {
referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference);
referenceId = resolveRegisteredEditorReferenceId(
dialog.iconSpecReference,
'参考图',
);
} catch (error) {
return {
ok: false,
@@ -476,7 +476,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
).toEqual(['改造', '下载按钮']);
});
it('keeps redraw visible for supported actions with a missing non-replaceable source', () => {
@@ -16,7 +16,7 @@ import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
canOpenRedrawPanel,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
} from './ImageCanvasGenerationModel';
type ImageCanvasSelectedLayerToolbarViewProps = {
@@ -102,7 +102,7 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
{isQuickEditSupportedLayer(selectedLayer) ? (
<>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -4,7 +4,7 @@ import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils';
describe('ImageCanvasUploadUtils', () => {
it('maps supported upload media types to persisted asset kinds', () => {
expect(resolveUploadedAssetKind('image')).toBe('image');
expect(resolveUploadedAssetKind('image')).toBeUndefined();
expect(resolveUploadedAssetKind('video')).toBe('video');
expect(resolveUploadedAssetKind('audio')).toBe('audio');
});
@@ -5,7 +5,7 @@ import type {
export function resolveUploadedAssetKind(
mediaType: CanvasMediaType,
): CanvasAssetKind {
): CanvasAssetKind | undefined {
switch (mediaType) {
case 'video':
return 'video';
@@ -14,6 +14,6 @@ export function resolveUploadedAssetKind(
case 'image-sequence':
throw new Error('序列帧素材不支持按单个媒体文件上传');
case 'image':
return 'image';
return undefined;
}
}
@@ -1088,7 +1088,7 @@ describe('ImageCanvasWorldView', () => {
it('disables labels from other media families', () => {
const imageLayer = createLayer({
resourceAssetKind: 'image',
resourceAssetKind: null,
assetKind: 'character',
});
renderWorldView({ layers: [imageLayer] });
@@ -1108,6 +1108,28 @@ describe('ImageCanvasWorldView', () => {
}
});
it('offers scene as a user-selectable label for image layers', () => {
const imageLayer = createLayer({
resourceAssetKind: null,
assetKind: 'character',
});
const { props } = renderWorldView({ layers: [imageLayer] });
fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' }));
const sceneItem = within(
screen.getByRole('menu', { name: '选择素材标签' }),
).getByRole('menuitem', { name: '场景' });
expect(sceneItem.hasAttribute('disabled')).toBe(false);
fireEvent.click(sceneItem);
expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith(
imageLayer.id,
'scene',
);
});
it('hides resolution and duration text for focused audio generation frames', () => {
const dialog = createGenerationDialog({
id: 'dialog-audio',
@@ -73,7 +73,7 @@ function createAssetSnapshot(
height: 240,
folderId: 'project',
sourceType: 'uploaded',
assetKind: 'image',
assetKind: null,
...overrides,
};
}
@@ -869,7 +869,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'resource-source',
sourceReferenceId: 'resource-source',
size: '2048x1365',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '3:2',
@@ -978,10 +978,100 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改图标:图标类素材不支持快速编辑',
'failed:修改图标:当前素材类型不支持快速编辑',
);
});
it.each(['scene', 'spec'] as const)(
'submits quick edits for icon resources overridden to %s',
async (assetKind) => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: `修改${assetKind}` }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
resourceAssetKind: 'icon',
assetKind,
}),
]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: `修改${assetKind}`,
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceReferenceId: 'resource-source',
targetLayerId: 'layer-source',
}),
);
});
},
);
it('rejects unregistered quick-edit sources before uploading marked references', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
resourceId: 'local-resource-source',
sourceAssetId: 'upload-source',
}),
]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '修改未登记图片',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
size: '1024x1024',
status: 'idle',
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gpt-image-2',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改未登记图片:原图尚未登记为项目资源或素材',
);
});
expect(renderUiDesignAssetExtractionMarkedImageMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
expect(editEditorImageMock).not.toHaveBeenCalled();
});
it('allows direct quick edit submission for icon spritesheets', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '调整图集布局' }),
@@ -1088,10 +1178,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把当前图改成雨天',
sourceImageSrc: 'resource-source',
sourceReferenceId: 'resource-source',
size: '1024x768',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
targetLayerId: 'layer-source',
assetLabel: '源图 快速编辑',
}),
@@ -1281,8 +1370,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc:
sourceReferenceId: 'resource-source',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
targetLayerId: 'layer-source',
}),
);
@@ -1290,9 +1381,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(
uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0],
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'referenceImageSrcs',
);
expect(generateEditorImageMock).not.toHaveBeenCalled();
});
@@ -1371,8 +1459,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc:
sourceReferenceId: 'resource-source',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
model: 'gpt-image-2',
targetLayerId: 'layer-source',
}),
@@ -1581,13 +1671,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={[
@@ -1624,25 +1708,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 () => {
@@ -1677,7 +1749,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '修改当前图片',
sourceImageSrc: 'resource-source',
sourceReferenceId: 'resource-source',
canvasCompletion: expect.objectContaining({
placeholder: {
x: 472,
@@ -72,7 +72,7 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -84,6 +84,7 @@ import {
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveImageGenerationDialogPrompt,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
import type {
@@ -1421,11 +1422,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return;
}
if (isQuickEditUnsupportedAssetKind(quickEditSourceLayer)) {
if (!isQuickEditSupportedLayer(quickEditSourceLayer)) {
setQuickEditPanel({
...quickEditPanel,
status: 'failed',
errorMessage: '图标类素材不支持快速编辑',
errorMessage: '当前素材类型不支持快速编辑',
});
return;
}
@@ -1617,7 +1618,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
? quickEditSelectionState.marks
: [];
let sourceReferenceImageSrc: string;
const sourceReferenceId =
resolveRegisteredEditorReferenceId(quickEditSourceLayer);
let markedReferenceImageSrc: string | null = null;
if (quickEditSelectionMarks.length > 0) {
const renderSourceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
@@ -1628,17 +1631,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
sourceReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
} else {
sourceReferenceImageSrc = await resolveEditorGenerationMediaReference(
quickEditSourceLayer,
'image',
projectId,
);
}
const imageEditReferenceSrcs = [
...extraReferenceImageSrcs,
...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []),
];
const generationInputs = buildQuickEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
@@ -1653,20 +1654,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: normalizedPrompt,
sourceImageSrc: sourceReferenceImageSrc,
sourceReferenceId,
size: quickEditSize,
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
...(imageEditReferenceSrcs.length
? { referenceImageSrcs: imageEditReferenceSrcs }
: {}),
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: quickEditResultTitle,
sourceResourceId: quickEditSourceLayer.resourceId,
targetLayerId: quickEditSourceLayer.id,
}),
onWalletBalanceMayHaveChanged,
@@ -1887,10 +1886,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt,
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorGenerationMediaReference(
const sourceReferenceId = resolveRegisteredEditorReferenceId(
submissionPlan.sourceLayer,
'image',
projectId,
);
const editPlaceholderSize =
getCanvasCompletionPlaceholderSizeFromPlan({
@@ -1913,14 +1910,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
sourceReferenceId,
...submissionPlan.editInput,
projectId,
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: submissionPlan.resultTitle,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
...(projectId && editCanvasCompletionPlaceholder
? {
canvasCompletion: {
@@ -1954,12 +1949,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'quick-edit') {
const sourceReferenceId = resolveRegisteredEditorReferenceId(
submissionPlan.sourceLayer,
);
const quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId ===
submissionPlan.sourceLayer.id
? quickEditSelectionState.marks
: [];
let referenceImageSrc: string;
let markedReferenceImageSrc: string | null = null;
if (quickEditSelectionMarks.length > 0) {
const renderSourceImageSrc =
await resolveEditorImageReferenceDataUrl(
@@ -1972,16 +1970,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
referenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedReferenceImageSrc = await uploadEditorGenerationImageDataUrl(
markedImageDataUrl,
projectId,
);
} else {
referenceImageSrc = await resolveEditorGenerationMediaReference(
submissionPlan.sourceLayer,
'image',
projectId,
);
}
const normalizedReferenceImageSrcs = await Promise.all(
(submissionPlan.editInput.referenceImageSrcs ?? []).map(
@@ -1998,20 +1990,20 @@ export function useImageCanvasGenerationSubmissionWorkflow({
),
),
);
const referenceImageSrcs = [
...normalizedReferenceImageSrcs,
...(markedReferenceImageSrc ? [markedReferenceImageSrc] : []),
];
const generated = await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
sourceReferenceId,
...submissionPlan.editInput,
...(normalizedReferenceImageSrcs.length
? { referenceImageSrcs: normalizedReferenceImageSrcs }
: {}),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
targetLayerId: submissionPlan.sourceLayer.id,
}),
onWalletBalanceMayHaveChanged,
@@ -2463,9 +2463,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'resource-source',
sourceReferenceId: 'resource-source',
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: expect.objectContaining({
version: 2,
action: 'image.edit',
@@ -2485,6 +2484,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
}),
);
});
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'sourceImageSrc',
);
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'sourceResourceId',
);
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
@@ -101,7 +101,7 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
@@ -1760,7 +1760,7 @@ export function useImageCanvasGenerationWorkflow({
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
if (!isQuickEditSupportedLayer(sourceLayer)) {
return;
}
setImageContextMenu(null);
@@ -1044,7 +1044,7 @@ describe('useImageCanvasProjectPersistence', () => {
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'image',
assetKind: null,
},
],
updatedAt: '2026-08-05T00:00:00.000Z',
@@ -1054,7 +1054,7 @@ describe('useImageCanvasProjectPersistence', () => {
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-image:resource-image:image',
'layer-image:resource-image:-',
);
});
expect(
@@ -784,7 +784,6 @@ describe('useImageCanvasUploadWorkflow', () => {
width: 420,
height: 315,
sourceType: 'uploaded',
assetKind: 'image',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
@@ -1118,7 +1118,7 @@ describe('editorProjectClient', () => {
await expect(
editEditorImage({
prompt: '修改图片',
sourceImageSrc: '/generated-images/editor/source.png',
sourceReferenceId: 'resource-source',
referenceImageSrcs: references.slice(0, 5),
model: 'gpt-image-2',
}),
@@ -2445,17 +2445,15 @@ describe('editorProjectClient', () => {
const result = await editEditorImage({
prompt: '把画面改成黄昏光线',
sourceImageSrc: '/generated-images/editor/source.png',
sourceReferenceId: 'resource-character-1',
size: '1024x1024',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: ['/generated-images/editor/style.png'],
projectId: 'editor-project-1',
assetKind: 'character',
assetFolderId: 'project',
assetLabel: '角色形象 修改结果',
sourceResourceId: 'resource-character-1',
targetLayerId: 'layer-character-1',
generationInputs: {
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
@@ -2471,21 +2469,19 @@ describe('editorProjectClient', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '把画面改成黄昏光线',
sourceImageSrc: '/generated-images/editor/source.png',
sourceReferenceId: 'resource-character-1',
size: '1024x1024',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: ['/generated-images/editor/style.png'],
projectId: 'editor-project-1',
assetKind: 'character',
generationInputs: {
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
references: [],
},
assetFolderId: 'project',
assetLabel: '角色形象 修改结果',
sourceResourceId: 'resource-character-1',
targetLayerId: 'layer-character-1',
}),
}),
@@ -412,18 +412,16 @@ export type EditorUiDesignAssetExtractionInput = {
export type EditorImageEditInput = {
prompt: string;
sourceImageSrc: string;
sourceReferenceId: string;
size?: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
referenceImageSrcs?: string[];
projectId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
sourceResourceId?: string | null;
targetLayerId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
@@ -1383,7 +1381,6 @@ export async function extractEditorUiDesignAssets(
}
export async function editEditorImage(input: EditorImageEditInput) {
assertStableEditorMediaReference(input.sourceImageSrc, '待修改图片');
assertStableEditorMediaReferences(input.referenceImageSrcs, '修改参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
@@ -1397,7 +1394,7 @@ export async function editEditorImage(input: EditorImageEditInput) {
EDITOR_IMAGE_EDIT_API,
jsonRequest('POST', {
prompt: input.prompt,
sourceImageSrc: input.sourceImageSrc,
sourceReferenceId: input.sourceReferenceId,
...(input.size ? { size: input.size } : {}),
...(input.model ? { model: input.model } : {}),
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
@@ -1406,15 +1403,11 @@ export async function editEditorImage(input: EditorImageEditInput) {
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.targetLayerId ? { targetLayerId: input.targetLayerId } : {}),
...(input.canvasCompletion
? { canvasCompletion: input.canvasCompletion }