修复画布参考图上传删除链路 (#164)
Project CI / Repository checks (push) Successful in 3m3s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 16m17s

主站规范生成支持多张参考图

修复普通生成参考图删除与提交状态不同步

优化触屏设备删除按钮可达性并补充回归测试

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/164
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
This commit was merged in pull request #164.
This commit is contained in:
2026-08-21 11:55:32 +08:00
committed by 段舒康
parent 7e3adc38b9
commit e4e3cab559
20 changed files with 698 additions and 72 deletions
@@ -862,8 +862,14 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
.expect("waiting main task exists");
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
.expect("wake same main run after art receipt");
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
.expect("cancel resumed fixture run");
if let Err(error) =
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
{
assert!(
error.contains("Agent Runtime 任务已结束,不能取消"),
"cancel resumed fixture run: {error}"
);
}
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
@@ -85,10 +85,10 @@
## 生成规范参考图
- `生成规范 -> 角色规范``图标规范``图标规范``自定义规范` 的设定面板支持上传 1 张参考图,并统一放在面板首行参考图区域。
- `生成规范 -> 角色规范``自定义规范` 的设定面板支持上传多张参考图,最多 5 张;`图标规范` 保持单张规范参考图。所有参考图统一放在面板首行参考图区域,并可逐张删除
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`便于后续在图片信息面板回看生成输入。
- 提交生成规范时,若存在参考图,前端必须按用户当前顺序把全部参考图提交到 `/api/editor/images/generations``referenceImageSrcs`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 生成结果的信息快照必须按当前顺序记录全部参考图,首张标题为 `参考图`,后续依次为 `参考图2``参考图3` 等,便于在图片信息面板回看生成输入。
## 新建生成图落点避让
@@ -156,6 +156,49 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(screen.queryByText('参考图A')).toBeNull();
});
it('removes only the selected reference when multiple references are present', () => {
const submitGeneration = vi.fn();
render(
<BasicGenerationHarness
initialDialog={createDialog({
generationReferences: [
{
id: 'ref-a',
label: '参考图A',
src: 'data:image/png;base64,cmVmQQ==',
},
{
id: 'ref-b',
label: '参考图B',
src: 'data:image/png;base64,cmVmQg==',
},
{
id: 'ref-c',
label: '参考图C',
src: 'data:image/png;base64,cmVmQw==',
},
],
})}
onSubmit={submitGeneration}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '删除参考图B' }));
expect(screen.queryByText('参考图B')).toBeNull();
expect(screen.getByText('参考图A')).toBeTruthy();
expect(screen.getByText('参考图C')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({
generationReferences: [
expect.objectContaining({ id: 'ref-a' }),
expect.objectContaining({ id: 'ref-c' }),
],
}),
);
});
it('shows only current image options and opens upward option panels', () => {
render(<BasicGenerationHarness />);
@@ -194,7 +194,12 @@ describe('ImageCanvasEditorView generation integration', () => {
candidate.getAttribute('title') === label ||
candidate.textContent?.includes(label),
);
expect(button).toBeTruthy();
const chip =
button ??
Array.from(panel.querySelectorAll('[aria-label]')).find(
(candidate) => candidate.getAttribute('aria-label') === label,
);
expect(chip).toBeTruthy();
};
const createGeneratedResource = ({
@@ -2361,10 +2366,12 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 120,
},
);
expectReferenceChip(
screen.getByRole('dialog', { name: '生成规范' }),
'拼图素材',
);
await waitFor(() => {
expectReferenceChip(
screen.getByRole('dialog', { name: '生成规范' }),
'拼图素材',
);
});
await userEvent.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
@@ -62,7 +62,10 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
createLayerGenerationDialogDraft,
resolveSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
import {
applyEditorGenerationPricingConfig,
@@ -1666,8 +1669,16 @@ export function ImageCanvasEditorView({
return generateDialog?.mode === 'spec'
? {
label: '规范参考图',
currentCount: 0,
limit: 1,
currentCount:
generateDialog.specType === 'icon'
? 0
: imageReferenceCount(
resolveSpecGenerationReferences(generateDialog),
),
limit:
generateDialog.specType === 'icon'
? 1
: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
@@ -1731,7 +1742,9 @@ export function ImageCanvasEditorView({
? {
label: '生成参考图',
currentCount: imageReferenceCount(
generateDialog.generationReferences,
generateDialog.mode === 'spec'
? resolveSpecGenerationReferences(generateDialog)
: generateDialog.generationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
@@ -33,6 +33,7 @@ import {
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
resolveSpecGenerationReferences,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
updateSpecFormDialogValue,
@@ -1554,6 +1555,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '',
status: 'failed',
errorMessage: '失败',
specType: 'icon',
},
sourceLayer,
),
@@ -1562,6 +1564,29 @@ describe('ImageCanvasGenerationDialogModel', () => {
errorMessage: undefined,
specReference: { label: '参考图' },
});
const legacySpecReference = {
id: 'legacy-spec-reference',
label: '历史参考图',
src: 'data:image/png;base64,legacy',
};
const migratedSpecDialog = appendGenerationReference(
{
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'character',
specReference: legacySpecReference,
},
sourceLayer,
);
expect(migratedSpecDialog).toMatchObject({
specReference: null,
generationReferences: [legacySpecReference, { label: '参考图' }],
});
expect(resolveSpecGenerationReferences(migratedSpecDialog)).toEqual(
migratedSpecDialog?.generationReferences,
);
expect(
appendGenerationReference(
{
@@ -1654,6 +1679,49 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores canonical non-icon spec references for same-source generation', () => {
const legacyReference = {
id: 'legacy-reference',
label: '历史参考',
src: 'data:image/png;base64,legacy',
};
const currentReference = {
id: 'current-reference',
label: '当前参考',
src: 'data:image/png;base64,current',
};
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'spec',
generationInputs: {
fields: [{ title: '规范类型', value: '角色规范' }],
references: [],
},
}),
sourceDialog: {
id: 'source-spec-dialog',
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'character',
specReference: legacyReference,
generationReferences: [currentReference],
},
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'spec',
specType: 'character',
specReference: null,
generationReferences: [currentReference],
});
});
it('limits Seedance canvas-picked references by media type', () => {
const videoDialog: GenerateDialogState = {
mode: 'video',
@@ -94,6 +94,41 @@ function resetFailedGenerationDialog(dialog: GenerateDialogState) {
};
}
export function resolveSpecGenerationReferences(
dialog: GenerateDialogState | null | undefined,
) {
if (!dialog || dialog.mode !== 'spec') {
return [];
}
if (dialog.specType === 'icon') {
return dialog.specReference ? [dialog.specReference] : [];
}
if (dialog.generationReferences?.length) {
return dialog.generationReferences;
}
return dialog.specReference ? [dialog.specReference] : [];
}
export function setSpecGenerationReferences(
dialog: GenerateDialogState,
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
if (dialog.mode !== 'spec') {
return dialog;
}
return dialog.specType === 'icon'
? {
...dialog,
specReference: references[0] ?? null,
generationReferences: [],
}
: {
...dialog,
specReference: null,
generationReferences: references,
};
}
function resetFailedGenerationPanel(panel: QuickEditPanelState) {
return {
...panel,
@@ -1017,12 +1052,22 @@ function createNormalizedGenerationDialogDraft({
characterView: getNormalizedString(sourceLayer, 'characterView'),
customPrompt: getNormalizedString(sourceLayer, 'customPrompt'),
},
specReference:
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
...(specType === 'icon'
? {
specReference:
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
}
: {
generationReferences: resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
),
}),
},
sourceLayer,
);
@@ -1776,14 +1821,25 @@ export function createSameSourceGenerationDialogDraft({
if (sourceMode === 'spec') {
const specType = resolveSpecTypeFromSourceLayer(sourceLayer, sourceDialog);
const sourceReferences =
sourceDialog?.mode === 'spec'
? resolveSpecGenerationReferences(sourceDialog)
: [];
return placeDraftBesideSourceLayer(
restoreSpecValuesFromLayer(
{
...createSpecDialogDraft({ canvasSize, viewport, specType }),
prompt,
sourceLayerId: sourceLayer.id,
specReference:
sourceDialog?.mode === 'spec' ? sourceDialog.specReference : null,
...(specType === 'icon'
? {
specReference: sourceReferences[0] ?? null,
generationReferences: [],
}
: {
specReference: null,
generationReferences: sourceReferences,
}),
},
sourceLayer,
specType,
@@ -2102,11 +2158,20 @@ export function appendGenerationReference(
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
const resetDialog = {
...resetFailedGenerationDialog(dialog),
specReference: createCanvasLayerReference(layer),
composerOpen: true,
};
return setSpecGenerationReferences(
resetDialog,
dialog.specType === 'icon'
? [createCanvasLayerReference(layer)]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
@@ -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 });
@@ -977,13 +998,14 @@ describe('ImageCanvasGenerationModel', () => {
});
it('adds reference image semantics and snapshots for spec generation references', () => {
expect(
buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
),
).toContain('生成一张怪兽规范图');
const prompt = buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
);
expect(prompt).toContain('严格参考全部参考图');
expect(prompt).not.toContain('严格参考图1');
expect(prompt).toContain('生成一张怪兽规范图');
expect(
buildSpecGenerationInputs(
'custom',
@@ -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) {
@@ -757,7 +761,7 @@ export function buildSpecPrompt(
return prompt;
}
return [
'参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
'参考图生成规范:严格参考全部参考图的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
prompt,
].join('\n');
}
@@ -1873,12 +1877,18 @@ export function buildSpecGenerationInputs(
specType: SpecGenerationType,
values: SpecFormValues,
reference?: CharacterReferenceImage | null,
extraReferences: CharacterReferenceImage[] = [],
): CanvasGenerationInputs {
const references = reference
? createGenerationInputReference('参考图', reference, {
id: 'reference',
})
: [];
const references = [
...(reference ? [reference] : []),
...extraReferences,
].flatMap((item, index) =>
createGenerationInputReference(
index === 0 ? '参考图' : `参考图${index + 1}`,
item,
{ id: index === 0 ? 'reference' : `reference-${index + 1}` },
),
);
if (specType === 'custom') {
return {
version: 2,
@@ -760,6 +760,11 @@ export function buildImageGenerationSubmissionPlan({
},
};
}
const specReferences = dialog.generationReferences?.length
? dialog.generationReferences
: dialog.specReference
? [dialog.specReference]
: [];
return {
kind: 'image',
normalizedPrompt,
@@ -767,18 +772,18 @@ export function buildImageGenerationSubmissionPlan({
prompt: buildSpecPrompt(
specType,
specValues,
Boolean(dialog.specReference?.src),
specReferences.some((reference) => Boolean(reference.src)),
),
size: SPEC_GENERATION_SIZE,
model: SPEC_GENERATION_MODEL,
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
kind: 'spec',
...(dialog.specReference?.src
...(specReferences.length
? {
referenceImageSrcs: [
resolveImageReferenceSubmissionSource(dialog.specReference),
],
referenceImageSrcs: specReferences.map(
resolveImageReferenceSubmissionSource,
),
}
: {}),
},
@@ -791,7 +796,8 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
specReferences[0],
specReferences.slice(1),
),
},
};
@@ -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="删除"
@@ -106,6 +106,30 @@ function UiDesignHarness({
) : null;
}
function CharacterSpecReferenceMenuHarness({
onRequestUpload = vi.fn(),
}: {
onRequestUpload?: (target: UploadTarget) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const referenceButtonRef = createRef<HTMLButtonElement>();
return (
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ generationReferences: [] })}
style={{ left: 10, top: 20 }}
generationReferenceButtonRef={referenceButtonRef}
isGenerationReferenceMenuOpen={isMenuOpen}
setIsGenerationReferenceMenuOpen={setIsMenuOpen}
setIsPickingGenerationReferenceFromCanvas={vi.fn()}
renderEditorPortal={(node) => node}
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
onUpdateSpecFormValue={vi.fn()}
onRequestUpload={onRequestUpload}
onSubmit={vi.fn()}
/>
);
}
function IconSpecHarness({
playSetting = '回合制占点',
artStyle = '低多边形',
@@ -278,6 +302,71 @@ 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('shows and removes a legacy non-icon spec reference', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
generationReferences: [],
specReference: {
id: 'legacy-reference',
label: '历史角色参考',
src: 'data:image/png;base64,legacy',
},
})}
/>,
);
expect(screen.getByLabelText('历史角色参考')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '删除历史角色参考' }));
expect(screen.queryByLabelText('历史角色参考')).toBeNull();
expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy();
});
it('hides a stale legacy spec reference when canonical references exist', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
specReference: {
id: 'legacy-reference',
label: '过期角色参考',
src: 'data:image/png;base64,legacy',
},
generationReferences: [
{
id: 'canonical-reference',
label: '当前角色参考',
src: 'data:image/png;base64,current',
},
],
})}
/>,
);
expect(screen.getByLabelText('当前角色参考')).toBeTruthy();
expect(screen.queryByLabelText('过期角色参考')).toBeNull();
});
it('renders character spec fields and forwards updates', () => {
const updateSpecFormValue = vi.fn();
renderPanel({
@@ -314,6 +403,17 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
);
});
it('keeps the character spec reference source menu open after clicking the add slot', () => {
render(<CharacterSpecReferenceMenuHarness />);
const addButton = screen.getByRole('button', { name: '参考图' });
fireEvent.click(addButton);
expect(screen.getByRole('menu', { name: '参考图来源' })).toBeTruthy();
fireEvent.click(addButton);
expect(screen.queryByRole('menu', { name: '参考图来源' })).toBeNull();
});
it('uses gameplay and art style hints for icon specs', () => {
renderPanel({
dialog: createSpecDialog({
@@ -41,6 +41,10 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
resolveSpecGenerationReferences,
setSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
@@ -144,7 +148,13 @@ export function ImageCanvasSpecGenerationPanelView({
: 'spec-reference';
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
: dialog.specType === 'icon'
? dialog.specReference
: null;
const extraSpecReferences =
!isUiDesignDialog && dialog.specType !== 'icon'
? resolveSpecGenerationReferences(dialog)
: [];
const isGenerating = dialog.status === 'generating';
const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon';
const [playSettingOptimization, setPlaySettingOptimization] =
@@ -278,7 +288,7 @@ export function ImageCanvasSpecGenerationPanelView({
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__reference-strip">
{isUiDesignDialog || dialog.specType ? (
{isUiDesignDialog || dialog.specType === 'icon' ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone={isUiDesignDialog ? 'ui' : 'spec'}
@@ -337,6 +347,59 @@ export function ImageCanvasSpecGenerationPanelView({
removeLabel={`删除${referenceLabel}`}
/>
) : null}
{extraSpecReferences.map((item, index) => (
<ImageCanvasReferenceSlot
key={`${item.id}-${index}`}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={item.src}
objectKey={item.objectKey}
label={`参考图${index + 1}`}
ariaLabel={item.label || `参考图${index + 1}`}
title={item.label}
disabled={isGenerating}
onRemove={
setGenerateDialog
? () =>
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode !== 'spec') {
return currentDialog;
}
return setSpecGenerationReferences(
{
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
},
resolveSpecGenerationReferences(currentDialog).filter(
(referenceItem) => referenceItem.id !== item.id,
),
);
})
: undefined
}
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
alwaysShowRemove
/>
))}
{!isUiDesignDialog && dialog.specType !== 'icon' ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel={referenceLabel}
disabled={isGenerating}
isAdd
onClick={openReferenceMenu}
/>
) : null}
{isUiDesignDialog
? (dialog.generationReferences ?? []).map((item, index) => (
<ImageCanvasReferenceSlot
@@ -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(
@@ -381,7 +401,7 @@ describe('ImageCanvasUploadModel', () => {
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'spec' }),
dialog: createDialog({ mode: 'spec', specType: 'icon' }),
target: 'spec-reference',
references: [reference],
}),
@@ -415,6 +435,71 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('migrates a legacy non-icon spec reference before appending uploads', () => {
const legacyReference = {
id: 'ref-legacy',
label: '历史参考图',
src: 'data:image/png;base64,legacy',
};
const uploadedReferences = Array.from({ length: 5 }, (_, index) => ({
id: `ref-new-${index + 1}`,
label: `新参考图 ${index + 1}`,
src: `data:image/png;base64,new-${index + 1}`,
}));
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'spec',
specType: 'character',
specReference: legacyReference,
generationReferences: [],
}),
target: 'spec-reference',
references: uploadedReferences,
}),
).toMatchObject({
specReference: null,
generationReferences: [
legacyReference,
...uploadedReferences.slice(0, 4),
],
});
});
it('prefers canonical spec references over a stale legacy reference on upload', () => {
const canonicalReference = {
id: 'ref-current',
label: '当前参考图',
src: 'data:image/png;base64,current',
};
const nextReference = {
id: 'ref-next',
label: '新增参考图',
src: 'data:image/png;base64,next',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'spec',
specType: 'custom',
specReference: {
id: 'ref-stale',
label: '过期参考图',
src: 'data:image/png;base64,stale',
},
generationReferences: [canonicalReference],
}),
target: 'generation-reference',
references: [nextReference],
}),
).toMatchObject({
specReference: null,
generationReferences: [canonicalReference, nextReference],
});
});
it('appends character reference uploads without changing unmatched dialogs', () => {
const previousReference = {
id: 'ref-old',
@@ -11,6 +11,10 @@ import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import {
resolveSpecGenerationReferences,
setSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
@@ -47,6 +51,8 @@ type GenerationReferenceUploadTarget =
| 'ui-design-icon-spec'
| 'publication-reference';
let uploadedGenerationReferenceSequence = 0;
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
@@ -128,11 +134,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,
@@ -168,12 +175,19 @@ export function applyGenerationReferenceUpload({
return dialog;
}
if (target === 'spec-reference') {
return dialog?.mode === 'spec'
? {
...setFailedGenerationIdle(dialog),
specReference: firstReference,
}
: dialog;
if (dialog?.mode !== 'spec') {
return dialog;
}
return setSpecGenerationReferences(
setFailedGenerationIdle(dialog),
dialog.specType === 'icon'
? [firstReference]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
if (target === 'character-spec') {
return dialog?.mode === 'character'
@@ -220,10 +234,21 @@ export function applyGenerationReferenceUpload({
: dialog;
}
if (target === 'generation-reference') {
if (dialog?.mode === 'spec') {
return setSpecGenerationReferences(
setFailedGenerationIdle(dialog),
dialog.specType === 'icon'
? [firstReference]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
references,
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
return dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
? {
@@ -3960,6 +3960,69 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('normalizes non-icon spec sources with canonical reference metadata', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,character-spec',
width: 2048,
height: 1152,
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character-spec-mixed-references',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'character',
specValues: {
playSetting: '动作冒险',
artStyle: '赛璐璐',
bodyRatio: '4',
characterView: '正面站姿',
customPrompt: '',
},
specReference: {
id: 'legacy-reference',
label: '历史参考',
src: '/generated/legacy-fallback.png',
objectKey: 'generated/legacy-object.png',
resourceId: 'resource-legacy',
},
generationReferences: [
{
id: 'canonical-reference',
label: '当前参考',
src: '/generated/current-fallback.png',
objectKey: 'generated/current-object.png',
resourceId: 'resource-current',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
referenceImageSrcs: ['generated/current-object.png'],
generationInputs: expect.objectContaining({
references: [expect.objectContaining({ label: '当前参考' })],
}),
}),
);
});
expect(
generateEditorImageMock.mock.calls[0]?.[0]?.referenceImageSrcs,
).not.toContain('generated/legacy-object.png');
});
it('validates icon submission before calling the API', async () => {
render(
<SubmissionWorkflowHarness
@@ -50,6 +50,7 @@ import type {
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { resolveSpecGenerationReferences } from './ImageCanvasGenerationDialogModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
@@ -411,7 +412,7 @@ function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
if (dialog.mode === 'spec') {
return dialog.specReference ? [dialog.specReference] : [];
return resolveSpecGenerationReferences(dialog);
}
if (dialog.mode === 'character') {
return [
@@ -528,24 +528,33 @@ export function useImageCanvasUploadWorkflow({
return;
}
const reservation = reserveImageReferenceUpload('spec-reference', 1);
const reservation = reserveImageReferenceUpload(
'spec-reference',
Array.from(files).filter(isImageFile).length,
);
if (!reservation) {
return;
}
try {
const imageSrc = await readImageFileAsDataUrl(imageFile);
const imageFiles = Array.from(files).filter(isImageFile);
const fileReferences = await Promise.all(
imageFiles.map(async (file, index) => ({
file,
index,
imageSrc: await readImageFileAsDataUrl(file),
})),
);
if (!validateImageReferenceUploadReservation(reservation)) {
return;
}
const references = await persistReferenceUploads(
[
{
idPrefix: 'upload-spec-reference',
file: imageFile,
fallbackLabel: '参考图',
imageSrc,
},
],
fileReferences.map(({ file, imageSrc, index }) => ({
idPrefix: 'upload-spec-reference',
index,
file,
fallbackLabel: `参考图${index + 1}`,
imageSrc,
})),
reservation,
);
if (
+14
View File
@@ -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
@@ -7033,6 +7039,14 @@ button.image-canvas-editor__reference-chip:disabled {
transform: scale(1);
}
@media (hover: none) {
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
.image-canvas-editor__reference-chip-remove:disabled {
cursor: not-allowed;
opacity: 0;