修复角色规范多参考图删除
保证上传参考图身份唯一并按选中位置逐张删除 让规范参考图删除入口稳定可见并统一模型上限 补充多图删除、身份冲突和上限回归测试 同步更新画布角色规范参考图文档
This commit is contained in:
@@ -85,9 +85,9 @@
|
||||
|
||||
## 生成规范参考图
|
||||
|
||||
- `生成规范 -> 角色规范`、`图标规范`、`图标规范`、`自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。
|
||||
- `生成规范 -> 角色规范`、`自定义规范` 的设定面板支持上传多张参考图,最多 5 张;`图标规范` 保持单张规范参考图。所有参考图统一放在面板首行参考图区域,并可逐张删除。
|
||||
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
|
||||
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
|
||||
- 提交生成规范时,若存在参考图,前端必须按用户当前顺序把全部参考图提交到 `/api/editor/images/generations` 的 `referenceImageSrcs`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
|
||||
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
|
||||
|
||||
## 新建生成图落点避让
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
isNormalizedCanvasGenerationInputs,
|
||||
isQuickEditSupportedLayer,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveImageGenerationErrorMessage,
|
||||
VIDEO_MODEL_KLING_3,
|
||||
@@ -68,6 +69,26 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => {
|
||||
expect(
|
||||
resolveDialogExtraImageReferenceLimit({
|
||||
mode: 'spec',
|
||||
specType: 'character',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
}),
|
||||
).toBe(5);
|
||||
expect(
|
||||
resolveDialogExtraImageReferenceLimit({
|
||||
mode: 'spec',
|
||||
specType: 'custom',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('展示时长时收敛到两位小数且不改动其他字段', () => {
|
||||
const format = (title: string, value: string) =>
|
||||
formatEditorGenerationInputFieldValue({ title, value });
|
||||
|
||||
@@ -464,7 +464,8 @@ export function resolveExtraImageReferenceLimit(
|
||||
|
||||
export function resolveDialogExtraImageReferenceLimit(
|
||||
dialog: GenerateDialogState,
|
||||
model: string | null | undefined = dialog.imageModel,
|
||||
model: string | null | undefined = dialog.imageModel ??
|
||||
(dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined),
|
||||
) {
|
||||
if (dialog.mode === 'icon') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
@@ -494,7 +495,10 @@ export function resolveDialogExtraImageReferenceLimit(
|
||||
1,
|
||||
);
|
||||
}
|
||||
return IMAGE_GENERATION_REFERENCE_LIMIT;
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
IMAGE_GENERATION_REFERENCE_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
@@ -14,7 +14,10 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
describe('ImageCanvasReferenceSlot', () => {
|
||||
beforeEach(() => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
|
||||
(
|
||||
source: string | null | undefined,
|
||||
options?: { objectKey?: string | null },
|
||||
) => ({
|
||||
resolvedUrl: options?.objectKey
|
||||
? `https://oss.example.com/${options.objectKey}?signature=1`
|
||||
: (source ?? ''),
|
||||
@@ -49,4 +52,21 @@ describe('ImageCanvasReferenceSlot', () => {
|
||||
'https://oss.example.com/generated-character-drafts/editor/legacy-resources/editor-resource-1.png?signature=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an explicitly marked reference remove action visible', () => {
|
||||
render(
|
||||
<ImageCanvasReferenceSlot
|
||||
icon={<span />}
|
||||
imageSrc="/reference.png"
|
||||
label="参考图1"
|
||||
ariaLabel="参考图1"
|
||||
onRemove={vi.fn()}
|
||||
alwaysShowRemove
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '删除参考图1' }).className,
|
||||
).toContain('image-canvas-editor__reference-chip-remove--always-visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ type ImageCanvasReferenceSlotProps = {
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
alwaysShowRemove?: boolean;
|
||||
};
|
||||
|
||||
export function ImageCanvasReferenceSlot({
|
||||
@@ -45,6 +46,7 @@ export function ImageCanvasReferenceSlot({
|
||||
onClick,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
alwaysShowRemove = false,
|
||||
}: ImageCanvasReferenceSlotProps) {
|
||||
const hasPreviewImage = Boolean(imageSrc?.trim() || objectKey?.trim());
|
||||
const slotClassName = [
|
||||
@@ -106,7 +108,11 @@ export function ImageCanvasReferenceSlot({
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip-remove"
|
||||
className={`image-canvas-editor__reference-chip-remove${
|
||||
alwaysShowRemove
|
||||
? ' image-canvas-editor__reference-chip-remove--always-visible'
|
||||
: ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
aria-label={removeLabel ?? `删除${ariaLabel}`}
|
||||
title="删除"
|
||||
|
||||
@@ -302,6 +302,27 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
expect(screen.queryByLabelText('界面风格参考')).toBeNull();
|
||||
});
|
||||
|
||||
it('removes only the selected character spec reference and keeps its action visible', () => {
|
||||
render(
|
||||
<UiDesignHarness
|
||||
initialDialog={createSpecDialog({
|
||||
generationReferences: [
|
||||
{ id: 'ref-a', label: '参考一', src: 'data:image/png;base64,a' },
|
||||
{ id: 'ref-b', label: '参考二', src: 'data:image/png;base64,b' },
|
||||
{ id: 'ref-c', label: '参考三', src: 'data:image/png;base64,c' },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /删除参考/ })).toHaveLength(3);
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除参考二' }));
|
||||
|
||||
expect(screen.queryByLabelText('参考二')).toBeNull();
|
||||
expect(screen.getByLabelText('参考一')).toBeTruthy();
|
||||
expect(screen.getByLabelText('参考三')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders character spec fields and forwards updates', () => {
|
||||
const updateSpecFormValue = vi.fn();
|
||||
renderPanel({
|
||||
|
||||
@@ -345,7 +345,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
) : null}
|
||||
{extraSpecReferences.map((item, index) => (
|
||||
<ImageCanvasReferenceSlot
|
||||
key={item.id}
|
||||
key={`${item.id}-${index}`}
|
||||
tone="default"
|
||||
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={item.src}
|
||||
@@ -372,8 +372,8 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
generationReferences: (
|
||||
currentDialog.generationReferences ?? []
|
||||
).filter(
|
||||
(referenceItem) =>
|
||||
referenceItem.id !== item.id,
|
||||
(_referenceItem, referenceIndex) =>
|
||||
referenceIndex !== index,
|
||||
),
|
||||
}
|
||||
: currentDialog,
|
||||
@@ -381,6 +381,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
: undefined
|
||||
}
|
||||
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
|
||||
alwaysShowRemove
|
||||
/>
|
||||
))}
|
||||
{!isUiDesignDialog && dialog.specType !== 'icon' ? (
|
||||
|
||||
@@ -349,7 +349,7 @@ describe('ImageCanvasUploadModel', () => {
|
||||
imageSrc: 'data:image/png;base64,ref',
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'upload-character-reference-1781697600000-2',
|
||||
id: 'upload-character-reference-1781697600000-2-1',
|
||||
label: '参考图3',
|
||||
src: 'data:image/png;base64,ref',
|
||||
mediaType: 'image',
|
||||
@@ -361,6 +361,26 @@ describe('ImageCanvasUploadModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps uploaded reference identities unique across same-time batches', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
|
||||
|
||||
const first = createUploadedGenerationReference({
|
||||
idPrefix: 'upload-spec-reference',
|
||||
index: 0,
|
||||
fallbackLabel: '参考图1',
|
||||
imageSrc: 'data:image/png;base64,first',
|
||||
});
|
||||
const second = createUploadedGenerationReference({
|
||||
idPrefix: 'upload-spec-reference',
|
||||
index: 0,
|
||||
fallbackLabel: '参考图1',
|
||||
imageSrc: 'data:image/png;base64,second',
|
||||
});
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
});
|
||||
|
||||
it('resets failed generation dialogs when reference uploads are applied', () => {
|
||||
expect(
|
||||
setFailedGenerationIdle(
|
||||
|
||||
@@ -47,6 +47,8 @@ type GenerationReferenceUploadTarget =
|
||||
| 'ui-design-icon-spec'
|
||||
| 'publication-reference';
|
||||
|
||||
let uploadedGenerationReferenceSequence = 0;
|
||||
|
||||
const VIDEO_REFERENCE_LIMITS = {
|
||||
image: 9,
|
||||
video: 3,
|
||||
@@ -128,11 +130,12 @@ export function createUploadedGenerationReference({
|
||||
resourceId?: string | null;
|
||||
sourceAssetId?: string | null;
|
||||
}): CharacterReferenceImage {
|
||||
uploadedGenerationReferenceSequence += 1;
|
||||
return {
|
||||
id:
|
||||
typeof index === 'number'
|
||||
? `${idPrefix}-${Date.now()}-${index}`
|
||||
: `${idPrefix}-${Date.now()}`,
|
||||
? `${idPrefix}-${Date.now()}-${index}-${uploadedGenerationReferenceSequence}`
|
||||
: `${idPrefix}-${Date.now()}-${uploadedGenerationReferenceSequence}`,
|
||||
label: fileName || fallbackLabel,
|
||||
src: imageSrc,
|
||||
mediaType,
|
||||
|
||||
@@ -7024,6 +7024,12 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip-remove--always-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-slot:hover
|
||||
.image-canvas-editor__reference-chip-remove,
|
||||
.image-canvas-editor__reference-slot:focus-within
|
||||
|
||||
Reference in New Issue
Block a user