统一画布生成资源命名

新增所有生成面板的资源名称输入并统一80字符边界
贯通画布标题、项目资源、素材库与生成请求的名称传播
为图标图集和角色动作补齐命名契约及中间产物后缀
补充前后端回归测试、OpenAPI与编辑器文档
This commit is contained in:
2026-07-14 10:22:59 +08:00
parent f7f799df81
commit ed8be38287
32 changed files with 671 additions and 60 deletions
@@ -58,6 +58,7 @@ function BasicGenerationHarness({
onSubmit={onSubmit}
/>
<output aria-label="当前提示词">{dialog.prompt}</output>
<output aria-label="当前资源名称">{dialog.assetLabel ?? '-'}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前模型">{dialog.imageModel}</output>
@@ -86,17 +87,27 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的提示' },
});
fireEvent.change(screen.getByLabelText('资源名称'), {
target: { value: '自定义主视觉' },
});
const panel = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
expect(screen.getByLabelText('当前资源名称').textContent).toBe(
'自定义主视觉',
);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
expect.objectContaining({
prompt: '新的提示',
assetLabel: '自定义主视觉',
status: 'idle',
}),
);
});
@@ -18,6 +18,7 @@ import type {
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
@@ -267,6 +268,20 @@ export function ImageCanvasBasicGenerationComposerView({
)
}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
<div className={finalFooterClassName}>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
@@ -24,6 +24,7 @@ import type {
CanvasLayer,
CharacterAnimationPanelState,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import {
CHARACTER_ANIMATION_ACTION_PROMPTS,
CHARACTER_ANIMATION_DURATION_OPTIONS,
@@ -211,6 +212,11 @@ export function ImageCanvasCharacterAnimationPanelView({
updatePanel({ promptText: event.target.value.slice(0, 4000) })
}
/>
<ImageCanvasGenerationAssetNameField
value={panel.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updatePanel({ assetLabel })}
/>
<div className="image-canvas-editor__character-animation-presets">
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
<button
@@ -20,6 +20,7 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -275,6 +276,20 @@ export function ImageCanvasCharacterGenerationComposerView({
}
/>
</label>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -198,6 +198,7 @@ export type GenerateDialogState = {
| 'audio-sound-effect'
| 'audio-background-music';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
@@ -305,6 +306,7 @@ export type QuickEditPanelState = {
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
assetLabel?: string;
size: string;
aspectRatio?: string;
imageSize?: string;
@@ -348,6 +350,7 @@ export type CropExpandResizeHandle =
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
assetLabel?: string;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH,
ImageCanvasGenerationAssetNameField,
} from './ImageCanvasGenerationAssetNameField';
describe('ImageCanvasGenerationAssetNameField', () => {
it('renders as a controlled optional name input', () => {
const onChange = vi.fn();
const { rerender } = render(
<ImageCanvasGenerationAssetNameField
value="角色立绘"
onChange={onChange}
/>,
);
const input = screen.getByRole('textbox', {
name: '资源名称',
}) as HTMLInputElement;
expect(input.value).toBe('角色立绘');
expect(input.getAttribute('maxlength')).toBe(
String(IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH),
);
fireEvent.change(input, { target: { value: '新名称' } });
expect(onChange).toHaveBeenCalledWith('新名称');
expect(input.value).toBe('角色立绘');
rerender(
<ImageCanvasGenerationAssetNameField
value="新名称"
onChange={onChange}
/>,
);
expect(input.value).toBe('新名称');
});
it('limits callback values to 80 characters and supports disabled state', () => {
const onChange = vi.fn();
render(
<ImageCanvasGenerationAssetNameField
value=""
disabled
onChange={onChange}
/>,
);
const input = screen.getByRole('textbox', {
name: '资源名称',
}) as HTMLInputElement;
expect(input.disabled).toBe(true);
fireEvent.change(input, { target: { value: '名'.repeat(90) } });
expect(onChange).toHaveBeenCalledWith('名'.repeat(80));
fireEvent.change(input, { target: { value: '🎨'.repeat(90) } });
expect(onChange).toHaveBeenLastCalledWith('🎨'.repeat(80));
});
});
@@ -0,0 +1,46 @@
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformTextField } from '../common/PlatformTextField';
import { EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS } from './ImageCanvasGenerationSubmissionModel';
export const IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH =
EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS;
type ImageCanvasGenerationAssetNameFieldProps = {
value?: string | null;
disabled?: boolean;
onChange: (value: string) => void;
};
export function ImageCanvasGenerationAssetNameField({
value,
disabled = false,
onChange,
}: ImageCanvasGenerationAssetNameFieldProps) {
return (
<label className="image-canvas-editor__generation-asset-name-field">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
资源名称
</PlatformFieldLabel>
<PlatformTextField
aria-label="资源名称"
value={value ?? ''}
maxLength={IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH}
disabled={disabled}
placeholder="例如:勇者立绘"
size="sm"
density="compact"
className="image-canvas-editor__generation-asset-name-input"
onChange={(event) =>
onChange(
Array.from(event.target.value)
.slice(0, IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH)
.join(''),
)
}
/>
</label>
);
}
@@ -53,6 +53,7 @@ import {
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -401,6 +402,11 @@ function ImageCanvasVideoGenerationComposerView({
updateVideoDialog({ prompt: event.target.value })
}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updateVideoDialog({ assetLabel })}
/>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
@@ -787,6 +793,11 @@ function ImageCanvasAudioGenerationComposerView({
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) => updateAudioDialog({ assetLabel })}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -22,6 +22,7 @@ import {
createIconGenerationDialogDraft,
createLayerGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditGenerationDialogDraft,
createQuickEditPanelDraft,
createRedrawPanelDraft,
createSameSourceGenerationDialogDraft,
@@ -415,6 +416,20 @@ describe('ImageCanvasGenerationDialogModel', () => {
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('preserves the resolved asset label in quick-edit dialog snapshots', () => {
expect(
createQuickEditGenerationDialogDraft({
sourceLayer: createLayer(),
prompt: '让视频下雨',
assetLabel: '雨天版本',
}),
).toMatchObject({
mode: 'quick-edit',
prompt: '让视频下雨',
assetLabel: '雨天版本',
});
});
it('restores remodel prompts from user-visible generation input snapshots only', () => {
const cases: Array<{
fields: NonNullable<CanvasLayer['generationInputs']>['fields'];
@@ -1232,6 +1232,7 @@ export function createQuickEditPanelDraft(
export function createQuickEditGenerationDialogDraft({
sourceLayer,
prompt,
assetLabel,
status = 'idle',
references = [],
aspectRatio,
@@ -1240,6 +1241,7 @@ export function createQuickEditGenerationDialogDraft({
}: {
sourceLayer: CanvasLayer;
prompt: string;
assetLabel?: string;
status?: CanvasGenerationDialogState['status'];
references?: NonNullable<QuickEditPanelState['quickEditReferences']>;
model?: string;
@@ -1252,6 +1254,7 @@ export function createQuickEditGenerationDialogDraft({
return {
mode: 'quick-edit',
prompt,
assetLabel,
status,
composerOpen: false,
sourceLayerId: sourceLayer.id,
@@ -39,6 +39,7 @@ type QuickEditResultLayerOptions = {
generationInputs: CanvasGenerationInputs;
mode?: 'quick-edit' | 'redraw';
frame?: GenerateDialogState['placeholder'];
title?: string;
};
type IconSpritesheetResultLayerOptions = {
@@ -190,6 +191,7 @@ export function createQuickEditResultLayer({
generationInputs,
mode = 'quick-edit',
frame,
title,
}: QuickEditResultLayerOptions): CanvasLayer {
const originalWidth =
frame?.originalWidth ||
@@ -222,7 +224,9 @@ export function createQuickEditResultLayer({
{
id: `layer-quick-edit-${generatedIndex}`,
resourceId: `local-resource-quick-edit-${generatedIndex}`,
title: `${sourceLayer.title} ${mode === 'redraw' ? '重绘' : '快速编辑'}`,
title:
title ??
`${sourceLayer.title} ${mode === 'redraw' ? '重绘' : '快速编辑'}`,
src: generated.imageSrc,
x: frameX ?? sourceLayer.x + sourceLayer.width + 32,
y: frameY ?? sourceLayer.y,
@@ -61,6 +61,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('trims custom asset names and limits them to 80 characters', () => {
const customName = ` ${'名'.repeat(81)} `;
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: '一张发光主视觉',
assetLabel: customName,
status: 'idle',
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toMatchObject({
kind: 'image',
result: { title: '名'.repeat(80) },
});
});
it('normalizes legacy nanobanana aliases before submitting image generation', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -646,6 +665,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
imageModel: 'gpt-image-2',
aspectRatio: '3:2',
imageSize: '2K',
assetLabel: ' 冒险游戏图标 ',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
@@ -694,6 +714,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
],
},
rememberImageModel: 'gpt-image-2',
resultTitle: '冒险游戏图标',
});
});
@@ -711,6 +732,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
panel: {
sourceLayerId: 'character-layer',
promptText: ' 循环奔跑动作 ',
assetLabel: ' 勇者奔跑 ',
resolution: '720p',
ratio: 'same',
frameCount: 48,
@@ -722,6 +744,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
expect(plan).toEqual({
promptText: '循环奔跑动作',
resultTitle: '勇者奔跑',
input: {
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
@@ -734,6 +757,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
assetLabel: '勇者奔跑',
},
});
});
@@ -58,6 +58,20 @@ type ImageGenerationSubmissionOptions = {
nextGeneratedIndex: number;
};
export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80;
export function resolveGenerationAssetLabel(
assetLabel: string | null | undefined,
fallback: string,
) {
const normalized = assetLabel?.trim();
return normalized
? Array.from(normalized)
.slice(0, EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS)
.join('')
: fallback;
}
function isSeedanceVideoModel(model: string) {
return model === 'seedance2.0' || model === 'seedance2.0-fast';
}
@@ -147,6 +161,7 @@ export type ImageGenerationSubmissionPlan =
kind: 'edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
resultTitle: string;
generationInputs: CanvasGenerationInputs;
}
| {
@@ -213,10 +228,12 @@ export type IconSpritesheetGenerationSubmissionPlan =
input: EditorIconSpritesheetGenerationInput;
generationInputs: CanvasGenerationInputs;
rememberImageModel: string;
resultTitle: string;
};
export type CharacterAnimationSubmissionPlan = {
promptText: string;
resultTitle: string;
input: EditorCharacterAnimationGenerationInput;
};
@@ -239,6 +256,10 @@ export function buildImageGenerationSubmissionPlan({
kind: 'edit',
normalizedPrompt,
sourceLayer,
resultTitle: resolveGenerationAssetLabel(
dialog.assetLabel,
`${sourceLayer.title} 修改结果`,
),
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
@@ -266,7 +287,10 @@ export function buildImageGenerationSubmissionPlan({
model: imageModel,
},
result: {
title: `${sourceLayer.title} 快速编辑`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${sourceLayer.title} 快速编辑`,
),
assetKind: sourceLayer.assetKind,
generationInputs: buildQuickEditGenerationInputs(
'快速编辑提示词',
@@ -308,7 +332,10 @@ export function buildImageGenerationSubmissionPlan({
// 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。
assetKind:
specType === 'ui' || specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
),
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
@@ -345,7 +372,10 @@ export function buildImageGenerationSubmissionPlan({
},
result: {
assetKind: 'character',
title: `角色形象 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`角色形象 ${nextGeneratedIndex}`,
),
generationInputs: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
@@ -379,7 +409,10 @@ export function buildImageGenerationSubmissionPlan({
},
result: {
assetKind: 'ui-design',
title: `UI设计图 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`UI设计图 ${nextGeneratedIndex}`,
),
generationInputs: buildUiDesignGenerationInputs(
normalizedPrompt,
dialog.uiDesignSpecReference,
@@ -421,7 +454,10 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
title: `${nextGeneratedIndex} 宣发素材`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${nextGeneratedIndex} 宣发素材`,
),
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
@@ -471,7 +507,10 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
title: `生成视频 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`生成视频 ${nextGeneratedIndex}`,
),
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
@@ -496,7 +535,10 @@ export function buildImageGenerationSubmissionPlan({
duration: durationSeconds,
},
result: {
title: `游戏音效 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏音效 ${nextGeneratedIndex}`,
),
generationInputs: buildSoundEffectGenerationInputs(
normalizedPrompt,
soundModel,
@@ -516,7 +558,10 @@ export function buildImageGenerationSubmissionPlan({
makeInstrumental: true,
},
result: {
title: `游戏背景音乐 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏背景音乐 ${nextGeneratedIndex}`,
),
generationInputs:
buildBackgroundMusicGenerationInputs(normalizedPrompt),
},
@@ -541,7 +586,10 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
title: `生成图片 ${nextGeneratedIndex}`,
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`生成图片 ${nextGeneratedIndex}`,
),
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
@@ -553,6 +601,7 @@ export function buildImageGenerationSubmissionPlan({
export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
nextGeneratedIndex = 1,
): IconSpritesheetGenerationSubmissionPlan {
const iconDescriptionSource = dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
@@ -610,6 +659,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
dialog.generationReferences,
),
rememberImageModel,
resultTitle: resolveGenerationAssetLabel(
dialog.assetLabel,
`图标素材图集 ${nextGeneratedIndex}`,
),
};
}
@@ -621,8 +674,10 @@ export function buildCharacterAnimationSubmissionPlan({
sourceLayer: CanvasLayer;
}): CharacterAnimationSubmissionPlan {
const promptText = panel.promptText.trim();
const resultTitle = resolveGenerationAssetLabel(panel.assetLabel, '角色动作');
return {
promptText,
resultTitle,
input: {
sourceLayerId: sourceLayer.id,
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(sourceLayer),
@@ -636,6 +691,7 @@ export function buildCharacterAnimationSubmissionPlan({
frameCount: panel.frameCount,
durationSeconds: panel.durationSeconds,
model: CHARACTER_ANIMATION_MODEL,
assetLabel: resultTitle,
},
};
}
@@ -18,6 +18,7 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorIconSpritesheetPrice,
@@ -223,6 +224,28 @@ export function ImageCanvasIconSpritesheetComposerView({
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
/>
</label>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -21,6 +21,7 @@ import type {
PublicationMaterialsGameInfo,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
buildPublicationMaterialsPrompt,
@@ -286,6 +287,21 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
</label>
</div>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={dialog.status === 'generating'}
onChange={(assetLabel) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'publication'
? {
...resetFailedDialogStatus(currentDialog),
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -55,6 +55,7 @@ function createQuickEditDialog(
status: panel.status,
sourceLayerId: panel.sourceLayerId,
generationReferences: panel.quickEditReferences,
assetLabel: panel.assetLabel,
imageModel: panel.model,
aspectRatio: panel.aspectRatio,
imageSize: panel.imageSize,
@@ -76,6 +77,7 @@ function syncPanelFromDialogUpdate(
imageSize: normalizedDialog.imageSize ?? normalizedPanel.imageSize ?? '1K',
model: normalizedDialog.imageModel ?? normalizedPanel.model,
quickEditReferences: normalizedDialog.generationReferences,
assetLabel: normalizedDialog.assetLabel,
status: normalizedDialog.status,
errorMessage: normalizedDialog.errorMessage,
};
@@ -25,6 +25,7 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
@@ -415,6 +416,30 @@ export function ImageCanvasSpecGenerationPanelView({
</>
)}
</div>
<ImageCanvasGenerationAssetNameField
value={dialog.assetLabel}
disabled={isGenerating}
onChange={(assetLabel) =>
setGenerateDialog?.((currentDialog) =>
currentDialog &&
(currentDialog.mode === 'spec' ||
currentDialog.mode === 'ui-design')
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
assetLabel,
}
: currentDialog,
)
}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
@@ -574,7 +574,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
'layer-source:源图 快速编辑:resource-source:-:-:data:image/png;base64,generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
@@ -584,6 +584,36 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('uses one custom name for quick-edit requests and local layer results', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '改成雨天' }),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '改成雨天',
assetLabel: ' 雨天勇者 ',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ assetLabel: '雨天勇者' }),
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:雨天勇者:resource-source',
);
});
});
it('rejects direct quick edit submission for icon assets', async () => {
render(
<SubmissionWorkflowHarness
@@ -699,7 +729,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source',
'layer-source:源图 快速编辑:resource-source',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
@@ -756,7 +786,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
'layer-source:源图 快速编辑:resource-source:-:-:data:image/png;base64,generated',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
@@ -972,6 +1002,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
assetKind: 'character',
@@ -982,6 +1013,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
mode: 'redraw',
sourceLayerId: 'layer-source',
prompt: ' 角色换成蓝色披风 ',
assetLabel: ' 蓝披风勇者 ',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
@@ -1002,12 +1034,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
referenceImageSrcs: ['resource-source'],
assetKind: 'character',
assetLabel: '蓝披风勇者',
canvasCompletion: expect.objectContaining({
title: '蓝披风勇者',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 重绘:resource-source:character',
'layer-quick-edit-1:蓝披风勇者:resource-source:character',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe(
@@ -1239,7 +1275,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(generateEditorVideoMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited',
'layer-source:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited',
);
});
});
@@ -2125,10 +2161,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮 \n\n 设置按钮 ',
assetLabel: ' 复古操作图标 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
@@ -2160,12 +2198,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'generated-character-drafts/editor/generation-references/reference.png',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
canvasCompletion: expect.objectContaining({
title: '复古操作图标',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet',
'layer-icon-spritesheet-1:复古操作图标:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
@@ -2611,6 +2653,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
@@ -2624,6 +2667,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
mode: 'character-animation',
sourceLayerId: 'character-layer',
prompt: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
@@ -2642,6 +2686,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
@@ -2661,12 +2706,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
sourceImageSrc: 'generated/character.png',
promptText: '挥手动作',
model: 'seedance2.0-fast',
assetLabel: '勇者挥手',
canvasCompletion: expect.objectContaining({
title: '勇者挥手',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4',
'layer-character-animation-1:勇者挥手:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
@@ -77,6 +77,7 @@ import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
@@ -680,6 +681,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
options: {
frame?: GenerateDialogState['placeholder'];
dialogId?: string;
title?: string;
} = {},
) => {
if (
@@ -697,6 +699,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs,
mode,
frame: options.frame,
title: options.title,
});
addGeneratedLayersToCanvas([nextLayer]);
@@ -752,15 +755,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
dialogId?: string,
title?: string,
) => {
updateSourceLayer(
sourceLayer.id,
(layer) =>
applyImageEditResultToSourceLayer({
(layer) => ({
...applyImageEditResultToSourceLayer({
generated,
sourceLayer: layer,
generationInputs,
}),
...(title ? { title } : {}),
}),
{ fit: false, persist: false },
);
setQuickEditPanel(null);
@@ -1156,8 +1162,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
) => {
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
};
const submissionPlan =
buildIconSpritesheetGenerationSubmissionPlan(dialog);
const submissionPlan = buildIconSpritesheetGenerationSubmissionPlan(
dialog,
layerCounterRef.current + 1,
);
if (submissionPlan.ok === false) {
if (canvasDialog) {
setSubmittingIconDialog({
@@ -1211,11 +1219,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: submissionPlan.resultTitle,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title: '图标素材图集',
title: submissionPlan.resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
@@ -1250,6 +1259,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.generationInputs,
canvasCompletionPlaceholder,
canvasDialog.id,
{ spritesheetTitle: submissionPlan.resultTitle },
);
} catch (error) {
setSubmittingIconDialog({
@@ -1265,6 +1275,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addIconSpritesheetResultLayers,
applyProjectSnapshot,
getGeneratingDialogPlaceholder,
layerCounterRef,
rememberImageModel,
projectId,
assetFolderId,
@@ -1289,6 +1300,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
const panelMode = quickEditPanel.mode ?? 'quick-edit';
const quickEditResultTitle = resolveGenerationAssetLabel(
quickEditPanel.assetLabel,
`${quickEditSourceLayer.title} ${panelMode === 'redraw' ? '重绘' : '快速编辑'}`,
);
const quickEditReferences =
panelMode === 'redraw' || panelMode === 'quick-edit'
? []
@@ -1340,6 +1355,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
width: quickEditOutputSize.width,
height: quickEditOutputSize.height,
},
assetLabel: quickEditResultTitle,
});
const quickEditPlacement = quickEditDraft.placeholder
? chooseGenerationPlacement({
@@ -1436,12 +1452,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
sourceResourceId: quickEditSourceLayer.resourceId,
assetKind: quickEditSourceLayer.assetKind,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 快速编辑`,
assetLabel: quickEditResultTitle,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: `${quickEditSourceLayer.title} 快速编辑`,
title: quickEditResultTitle,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
@@ -1469,7 +1485,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
addVideoResultLayer(
generated,
`${quickEditSourceLayer.title} 快速编辑`,
quickEditResultTitle,
generationInputs,
quickEditFrame,
quickEditDialogId,
@@ -1538,7 +1554,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 快速编辑`,
assetLabel: quickEditResultTitle,
sourceResourceId: quickEditSourceLayer.resourceId,
targetLayerId: quickEditSourceLayer.id,
}),
@@ -1560,13 +1576,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 重绘`,
assetLabel: quickEditResultTitle,
sourceResourceId: quickEditSourceLayer.resourceId,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: `${quickEditSourceLayer.title} 重绘`,
title: quickEditResultTitle,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
@@ -1605,6 +1621,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
quickEditSourceLayer,
generationInputs,
quickEditDialogId,
quickEditResultTitle,
);
} else {
addQuickEditResultLayer(
@@ -1616,8 +1633,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
? {
frame: quickEditFrame,
dialogId: quickEditDialogId,
title: quickEditResultTitle,
}
: {},
: { title: quickEditResultTitle },
);
}
} catch (error) {
@@ -1728,13 +1746,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`,
assetLabel: submissionPlan.resultTitle,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
...(projectId && editCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: `${submissionPlan.sourceLayer.title} 修改结果`,
title: submissionPlan.resultTitle,
placeholder: editCanvasCompletionPlaceholder,
},
}
@@ -1759,6 +1777,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
title: submissionPlan.resultTitle,
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'quick-edit') {
@@ -1829,6 +1848,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.sourceLayer,
submissionPlan.result.generationInputs,
canvasDialog?.id,
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'video') {
const canvasCompletionPlaceholder =
@@ -2087,6 +2107,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
characterAnimationFrameCount: characterAnimationPanel.frameCount,
characterAnimationDurationSeconds:
characterAnimationPanel.durationSeconds,
assetLabel: submissionPlan.resultTitle,
characterAnimationResult: undefined,
status: 'generating',
composerOpen: false,
@@ -2096,6 +2117,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const nextPanel = {
...characterAnimationPanel,
promptText: submissionPlan.promptText,
assetLabel: submissionPlan.resultTitle,
status: 'generating' as const,
errorMessage: undefined,
result: undefined,
@@ -2133,7 +2155,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title: '角色动作',
title: submissionPlan.resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
@@ -2159,7 +2181,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
if (canvasDialog) {
addCharacterAnimationResultLayer(
result,
'角色动作',
submissionPlan.resultTitle,
generationInputs,
canvasCompletionPlaceholder,
canvasDialog.id,
@@ -282,6 +282,9 @@ function GenerationWorkflowHarness({
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
: '-'}
</span>
<span data-testid="character-animation-name">
{`${workflow.characterAnimationPanel?.assetLabel ?? '-'}:${activeDialog?.assetLabel ?? '-'}`}
</span>
<span data-testid="character-animation-price">
{workflow.characterAnimationPrice}
</span>
@@ -535,6 +538,18 @@ function GenerationWorkflowHarness({
>
打开普通动画
</button>
<button
type="button"
onClick={() =>
workflow.setCharacterAnimationPanel((currentPanel) =>
currentPanel
? { ...currentPanel, assetLabel: '勇者挥手' }
: currentPanel,
)
}
>
命名角色动画
</button>
<button
type="button"
onClick={() =>
@@ -1675,7 +1690,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:-:data:image/png;base64,generated:task-generated',
'layer-source:源图 快速编辑:resource-source:-:data:image/png;base64,generated:task-generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
@@ -2121,6 +2136,17 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('image-context').textContent).toBe('-');
});
it('keeps character animation asset names synchronized with the dialog', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色动画' }));
fireEvent.click(screen.getByRole('button', { name: '命名角色动画' }));
expect(screen.getByTestId('character-animation-name').textContent).toBe(
'勇者挥手:勇者挥手',
);
});
it('hides non-generating canvas generation composers without deleting placeholders', () => {
render(<GenerationWorkflowHarness />);
@@ -486,6 +486,7 @@ function createCharacterAnimationPanelFromDialog(
return {
sourceLayerId: dialog.sourceLayerId,
promptText: dialog.prompt,
assetLabel: dialog.assetLabel,
resolution: dialog.characterAnimationResolution ?? '480p',
ratio: dialog.characterAnimationRatio ?? 'same',
frameCount: dialog.characterAnimationFrameCount ?? 32,
@@ -519,6 +520,7 @@ function applyCharacterAnimationPanelToDialog(
return {
...dialog,
prompt: panel.promptText,
assetLabel: panel.assetLabel,
characterAnimationResolution: panel.resolution,
characterAnimationRatio: panel.ratio,
characterAnimationFrameCount: panel.frameCount,
+12
View File
@@ -7246,6 +7246,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: #94a3b8;
}
.image-canvas-editor__generation-asset-name-field {
display: grid;
grid-column: 1 / -1;
gap: 0.24rem;
min-width: 0;
}
.image-canvas-editor__generation-asset-name-input {
width: 100%;
min-width: 0;
}
.image-canvas-editor__generation-prompt--borderless {
border-color: transparent;
background: transparent;
@@ -912,6 +912,7 @@ describe('editorProjectClient', () => {
referenceImageSrc: 'data:image/png;base64,spec',
referenceImageSrcs: ['data:image/png;base64,icon-ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
assetLabel: '冒险游戏图标',
});
expect(result.taskId).toBe('icon-spritesheet-task-1');
@@ -929,6 +930,7 @@ describe('editorProjectClient', () => {
referenceImageSrcs: ['data:image/png;base64,icon-ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gemini-3.1-flash-image-preview',
assetLabel: '冒险游戏图标',
}),
}),
'生成图标素材失败',
@@ -1256,6 +1258,7 @@ describe('editorProjectClient', () => {
frameCount: 32,
durationSeconds: 4,
model: 'seedance2.0-fast',
assetLabel: '勇者待机',
});
expect(result.taskId).toBe('character-animation-1');
@@ -1275,6 +1278,7 @@ describe('editorProjectClient', () => {
frameCount: 32,
durationSeconds: 4,
model: 'seedance2.0-fast',
assetLabel: '勇者待机',
}),
}),
'生成角色动画失败',
@@ -203,6 +203,7 @@ export type EditorIconSpritesheetGenerationInput = {
projectId?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
@@ -360,6 +361,7 @@ export type EditorCharacterAnimationGenerationInput = {
generationInputs?: EditorAssetGenerationInputs | null;
sourceResourceId?: string | null;
assetFolderId?: string | null;
assetLabel?: string | null;
};
export type EditorCharacterAnimationFrameResult = {
@@ -935,6 +937,7 @@ export async function generateEditorIconSpritesheet(
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.canvasCompletion
? { canvasCompletion: input.canvasCompletion }
: {}),