合并远端 master(直连 Codex 改造)
冲突 19 处 / 6 个文件,逐处判定归属而不是二选一:
· commands.rs —— 保留本分支的 hydrate_game_creator_plan_gdd_state 输入解析,
丢掉与 master 重复的项目根定义(master 把它挪到了后面)。
· types.ts / useHomeProjectCreation.ts —— 取 master 把 mode/prompt/attachments
重构成 HomeDraft 的形状,保留本分支新增的 startMode。
· App.tsx —— directGameChatRuntime(master)与 !planningStartMode(本分支)是两个
互不相干的条件,合并保留;refreshDirectProjectSurface 在 merge base 里就带预览
逻辑,是 master 主动删掉并改名成 refreshDirectProjectManifest 的,取 master;
capturePendingGameChatStageManifestBeforeNextRun 是分支新增且仍被调用,保留。
· SupervisorChatOnlyView.tsx —— master 的 directCodex 短路与本分支的
projectedRuntime / descendantsStillActive 正交,逐处合成。
· project-development.suite.ts —— 那段专业 Agent 断言 base 里就有、master 主动
删除(直连 Codex 之后不再适用),取 master。
两处语义缺口一并补上:master 新增的 codex_app_server 构造
AgentRuntimeProviderRequestSnapshot 时缺本分支新增的 planning_session_binding
字段(首页直连对话不属于任何立项 session,填 None);首页提交按钮的 aria-label
还引用着已被删除的 homeAgentMode,取 master 的固定文案。
**做方案 UI 入口暂时缺失,待定。** master 在 bacd7a9da 里把 HomeAgentMode 整个删了,
首页的「做游戏 / 做素材 / 做方案」模式选择不再存在,而 startMode 在前端只有那一个
来源(homeDraftStartMode(mode))。立项策划链路的代码全部保留——后端、prompt、
澄清卡、审批卡、E2E 都在——但首页现在统一按 direct-build 进入
(HOME_DRAFT_START_MODE 常量,已在原处留注释)。入口放哪由产品侧决定后另做一笔。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
? {
|
||||
|
||||
@@ -287,6 +287,69 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('only rerenders the moved image layer when other layer objects stay stable', () => {
|
||||
const firstLayer = createLayer({
|
||||
id: 'layer-first',
|
||||
resourceId: 'resource-first',
|
||||
title: '第一张图片',
|
||||
zIndex: 1,
|
||||
});
|
||||
const movedLayer = createLayer({
|
||||
id: 'layer-moved',
|
||||
resourceId: 'resource-moved',
|
||||
title: '移动图片',
|
||||
zIndex: 2,
|
||||
});
|
||||
const lastLayer = createLayer({
|
||||
id: 'layer-last',
|
||||
resourceId: 'resource-last',
|
||||
title: '最后一张图片',
|
||||
zIndex: 3,
|
||||
});
|
||||
const { props, rerender } = renderWorldView({
|
||||
layers: [firstLayer, movedLayer, lastLayer],
|
||||
});
|
||||
const initialReadUrlCallCount =
|
||||
useResolvedAssetReadUrlMock.mock.calls.length;
|
||||
|
||||
rerender(
|
||||
<ImageCanvasWorldView
|
||||
{...props}
|
||||
layers={[
|
||||
firstLayer,
|
||||
{ ...movedLayer, x: movedLayer.x + 24, y: movedLayer.y + 16 },
|
||||
lastLayer,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledTimes(
|
||||
initialReadUrlCallCount + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls the latest layer callback without rerendering a stable layer', () => {
|
||||
const layer = createLayer();
|
||||
const firstOnLayerClick = vi.fn();
|
||||
const latestOnLayerClick = vi.fn();
|
||||
const { props, rerender } = renderWorldView({
|
||||
layers: [layer],
|
||||
onLayerClick: firstOnLayerClick,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ImageCanvasWorldView
|
||||
{...props}
|
||||
layers={[layer]}
|
||||
onLayerClick={latestOnLayerClick}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择角色主图' }));
|
||||
|
||||
expect(latestOnLayerClick).toHaveBeenCalledWith(expect.any(Object), layer);
|
||||
expect(firstOnLayerClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows editable kind tags for tagged and untagged layers', () => {
|
||||
const layer = createLayer({ assetKind: null });
|
||||
renderWorldView({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 [
|
||||
|
||||
@@ -110,6 +110,7 @@ function asCanvasGenerationDialog(
|
||||
}
|
||||
|
||||
function StageInteractionsHarness({
|
||||
includeConcurrentLayerFixture = false,
|
||||
pickCharacterSpecFromLayer = vi.fn(),
|
||||
pickGenerationReferenceFromLayer = vi.fn(),
|
||||
pickIconSpecFromLayer = vi.fn(),
|
||||
@@ -122,6 +123,7 @@ function StageInteractionsHarness({
|
||||
onViewportInteractionEnd = vi.fn(),
|
||||
captureCanvasHistory = vi.fn(),
|
||||
}: {
|
||||
includeConcurrentLayerFixture?: boolean;
|
||||
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
|
||||
pickIconSpecFromLayer?: (layer: CanvasLayer) => void;
|
||||
@@ -150,9 +152,12 @@ function StageInteractionsHarness({
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const worldRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(() => [
|
||||
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
|
||||
createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }),
|
||||
...(includeConcurrentLayerFixture
|
||||
? [createLayer({ id: 'removed', x: 900, y: 900, zIndex: 3 })]
|
||||
: []),
|
||||
]);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
@@ -331,6 +336,14 @@ function StageInteractionsHarness({
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="layer-details">
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.title}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="viewport-state">
|
||||
{viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale}
|
||||
</span>
|
||||
@@ -351,6 +364,17 @@ function StageInteractionsHarness({
|
||||
? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="dialog-details">
|
||||
{generateDialog
|
||||
? [
|
||||
generateDialog.status,
|
||||
generateDialog.prompt,
|
||||
generateDialog.errorMessage ?? '-',
|
||||
generateDialog.placeholder?.width ?? '-',
|
||||
generateDialog.placeholder?.height ?? '-',
|
||||
].join(':')
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="dialog-selection-id">
|
||||
{getCanvasGenerationSelectionId('dialog-1')}
|
||||
</span>
|
||||
@@ -440,6 +464,48 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接移动图层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
[
|
||||
{ clientX: 230, clientY: 70 },
|
||||
{ clientX: 250, clientY: 90 },
|
||||
{ clientX: 280, clientY: 120 },
|
||||
].forEach((pointer) => {
|
||||
interaction.handlePointerMove(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 2,
|
||||
...pointer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
连续移动图层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLayers((currentLayers) => [
|
||||
...currentLayers
|
||||
.filter((layer) => layer.id !== 'removed')
|
||||
.map((layer) =>
|
||||
layer.id === 'first' || layer.id === 'second'
|
||||
? { ...layer, title: `${layer.id}-concurrent` }
|
||||
: layer,
|
||||
),
|
||||
createLayer({
|
||||
id: 'added',
|
||||
title: 'added-concurrent',
|
||||
x: 640,
|
||||
y: 360,
|
||||
zIndex: 4,
|
||||
}),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
并发更新图层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -589,6 +655,79 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接拖生成占位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const frameElement = document.createElement('div');
|
||||
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
interaction.handleGenerationFramePointerDown(
|
||||
createPointerEvent(frameElement, {
|
||||
pointerId: 24,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
}),
|
||||
dialog,
|
||||
);
|
||||
[
|
||||
{ clientX: 320, clientY: 215 },
|
||||
{ clientX: 340, clientY: 230 },
|
||||
].forEach((pointer) => {
|
||||
interaction.handlePointerMove(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 24,
|
||||
...pointer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
连续拖生成占位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...currentDialog,
|
||||
status: 'failed',
|
||||
prompt: '并发提示',
|
||||
errorMessage: '并发失败',
|
||||
placeholder: currentDialog.placeholder
|
||||
? {
|
||||
...currentDialog.placeholder,
|
||||
width: 222,
|
||||
height: 111,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
}}
|
||||
>
|
||||
并发更新生成占位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...currentDialog,
|
||||
status: 'failed',
|
||||
prompt: '并发移除占位',
|
||||
errorMessage: '占位已移除',
|
||||
placeholder: undefined,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
}}
|
||||
>
|
||||
并发移除生成占位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -809,9 +948,7 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
it('selects and drags multiple layers from stage pointer events', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
render(
|
||||
<StageInteractionsHarness
|
||||
captureCanvasHistory={captureCanvasHistory}
|
||||
/>,
|
||||
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -871,9 +1008,7 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
it('keeps sub-threshold pointer jitter from moving layers or creating history', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
render(
|
||||
<StageInteractionsHarness
|
||||
captureCanvasHistory={captureCanvasHistory}
|
||||
/>,
|
||||
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -887,6 +1022,171 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
expect(captureCanvasHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coalesces high-frequency layer moves to the latest animation frame', () => {
|
||||
let animationFrameCallback: FrameRequestCallback | null = null;
|
||||
const captureCanvasHistory = vi.fn();
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 41;
|
||||
});
|
||||
render(
|
||||
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
screen.getByRole('button', { name: '连续移动图层' }).click();
|
||||
});
|
||||
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'second:280.0,120.0',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
animationFrameCallback?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'second:280.0,120.0',
|
||||
);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves concurrent layer updates while applying a pending animation-frame move', () => {
|
||||
let animationFrameCallback: FrameRequestCallback | null = null;
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 44;
|
||||
});
|
||||
render(<StageInteractionsHarness includeConcurrentLayerFixture />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
screen.getByRole('button', { name: '连续移动图层' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '并发更新图层' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layer-details').textContent).toContain(
|
||||
'second:second-concurrent:',
|
||||
);
|
||||
expect(screen.getByTestId('layer-details').textContent).not.toContain(
|
||||
'second:second-concurrent:280.0,120.0',
|
||||
);
|
||||
expect(screen.getByTestId('layer-details').textContent).not.toContain(
|
||||
'removed:',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
animationFrameCallback?.(16);
|
||||
});
|
||||
|
||||
const layerDetails = screen.getByTestId('layer-details').textContent;
|
||||
expect(layerDetails).toContain('first:first-concurrent:40.0,40.0');
|
||||
expect(layerDetails).toContain('second:second-concurrent:280.0,120.0');
|
||||
expect(layerDetails).toContain('added:added-concurrent:640.0,360.0');
|
||||
expect(layerDetails).not.toContain('removed:');
|
||||
});
|
||||
|
||||
it('preserves concurrent generation fields while applying only pending placeholder coordinates', () => {
|
||||
let animationFrameCallback: FrameRequestCallback | null = null;
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 45;
|
||||
});
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '连续拖生成占位' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '并发更新生成占位' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('dialog-position').textContent).toBe(
|
||||
'320.0,215.0',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-details').textContent).toBe(
|
||||
'failed:并发提示:并发失败:222:111',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
animationFrameCallback?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('dialog-position').textContent).toBe(
|
||||
'340.0,230.0',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-details').textContent).toBe(
|
||||
'failed:并发提示:并发失败:222:111',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not revive a generation placeholder removed before a pending animation-frame move', () => {
|
||||
let animationFrameCallback: FrameRequestCallback | null = null;
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 46;
|
||||
});
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '连续拖生成占位' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '并发移除生成占位' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
|
||||
expect(screen.queryByTestId('generation-frame')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
animationFrameCallback?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog-details').textContent).toBe(
|
||||
'failed:并发移除占位:占位已移除:-:-',
|
||||
);
|
||||
expect(screen.queryByTestId('generation-frame')).toBeNull();
|
||||
});
|
||||
|
||||
it('flushes the latest coalesced layer move before pointer release', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 42);
|
||||
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
screen.getByRole('button', { name: '连续移动图层' }).click();
|
||||
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
|
||||
});
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'second:280.0,120.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels a pending layer move frame when the stage unmounts', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 43);
|
||||
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
const view = render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
screen.getByRole('button', { name: '连续移动图层' }).click();
|
||||
});
|
||||
view.unmount();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(43);
|
||||
});
|
||||
|
||||
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
|
||||
const activateCanvasGenerationDialog = vi.fn();
|
||||
render(
|
||||
@@ -1028,7 +1328,7 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks viewport interactions for pan and minimap drags', () => {
|
||||
it('marks layout interactions for layer, generation, pan, and minimap drags', () => {
|
||||
const onViewportInteractionStart = vi.fn();
|
||||
const onViewportInteractionEnd = vi.fn();
|
||||
const flushMinimapViewportDrag = vi.fn();
|
||||
@@ -1040,28 +1340,46 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
screen.getByRole('button', { name: '直接移动图层' }).click();
|
||||
});
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
|
||||
});
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接拖生成占位' }).click();
|
||||
});
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '清理交互' }).click();
|
||||
});
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '切抓手' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接开始平移' }).click();
|
||||
});
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(3);
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '清理交互' }).click();
|
||||
});
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(3);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接拖小地图' }).click();
|
||||
});
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
|
||||
expect(onViewportInteractionStart).toHaveBeenCalledTimes(4);
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接结束小地图拖拽' }).click();
|
||||
});
|
||||
|
||||
expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1);
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
|
||||
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user