支持画布生图选择抠图背景色

新增画布生图背景色选项并在角色、图标和 UI 素材提取入口展示选择控件。

将 screenColor 贯穿前端提交、后端请求解析、prompt 构建和生成后本地后处理调用。

保留角色动作旧绿幕路径,避免影响既有动画生成流程。

补充相关前后端单元测试和编辑器文档说明。
This commit is contained in:
2026-07-02 14:56:26 +00:00
parent 15bca90ed2
commit 06cad7e428
32 changed files with 865 additions and 120 deletions
@@ -291,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
includeBackgroundColor
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
@@ -782,17 +782,19 @@ describe('ImageCanvasEditorView generation integration', () => {
);
expect(screen.getByRole('status').textContent).toContain('生成中');
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '一张明亮的拼图主视觉',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
assetLabel: expect.stringMatching(/生成图片/u),
}),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '一张明亮的拼图主视觉',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
assetLabel: expect.stringMatching(/生成图片/u),
}),
);
});
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
@@ -3029,9 +3031,9 @@ describe('ImageCanvasEditorView generation integration', () => {
},
],
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-extract-task-1',
@@ -3124,6 +3126,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
screenColor: '#CFEFFF',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-ui-extract',
@@ -3134,7 +3137,11 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
}),
expect.objectContaining({
title: '抠图背景色',
value: '浅雾蓝 #CFEFFF',
}),
]),
references: expect.arrayContaining([
@@ -3163,7 +3170,11 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
}),
expect.objectContaining({
title: '抠图背景色',
value: '浅雾蓝 #CFEFFF',
}),
]),
}),
@@ -551,6 +551,7 @@ export function hydrateCanvasGenerationDialog(
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
screenColor: stringOrUndefined(snapshot.screenColor),
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
@@ -189,6 +189,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionModelChange: vi.fn(),
onUiAssetExtractionScreenColorChange: vi.fn(),
onUiAssetExtractionPointerStart: vi.fn(),
onUiAssetExtractionPointerMove: vi.fn(),
onUiAssetExtractionPointerEnd: vi.fn(),
@@ -337,6 +338,7 @@ describe('ImageCanvasEditorShellView', () => {
sourceLayerId: layer.id,
tool: 'rect' as const,
model: 'gemini-3.1-flash-image-preview',
screenColor: '#CFEFFF',
marks: [],
references: [],
draftMark: null,
@@ -208,6 +208,7 @@ export type GenerateDialogState = {
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
screenColor?: string;
videoModel?: EditorVideoModel;
videoAspectRatio?: EditorVideoAspectRatio;
videoResolution?: EditorVideoResolution;
@@ -1179,6 +1179,7 @@ export function ImageCanvasEditorView() {
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeUiAssetExtractionScreenColor,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
@@ -1910,6 +1911,7 @@ export function ImageCanvasEditorView() {
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
onUiAssetExtractionScreenColorChange: changeUiAssetExtractionScreenColor,
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
@@ -481,7 +481,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
{ title: 'model', value: 'gpt-image-2' },
],
@@ -496,13 +496,16 @@ describe('ImageCanvasGenerationDialogModel', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
const characterDraft = createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '红发骑士' }],
references: [],
},
}),
sourceLayer: createLayer({
assetKind: 'character',
generationInputs: {
fields: [
{ title: '角色设定', value: '红发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
],
references: [],
},
}),
canvasSize,
viewport,
mode: 'redraw',
@@ -512,6 +515,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'character',
prompt: '红发骑士',
sourceLayerId: 'layer-source',
screenColor: '#FFD6C2',
placeholder: {
x: 472,
y: 140,
@@ -579,6 +583,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
generatedLayerId: 'layer-source',
imageModel: 'gpt-image-2',
screenColor: '#E6D8FF',
aspectRatio: '2:3',
imageSize: '2K',
},
@@ -589,6 +594,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '普通图',
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
screenColor: '#E6D8FF',
aspectRatio: '2:3',
imageSize: '2K',
});
@@ -19,6 +19,7 @@ import {
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
DEFAULT_IMAGE_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
@@ -36,6 +37,7 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
normalizeEditorGenerationBackgroundColor,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
@@ -272,6 +274,7 @@ export function createCharacterGenerationDialogDraft({
characterSpecReference: null,
characterReferences: [],
imageModel: normalizedImageModel,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -306,6 +309,7 @@ export function createIconGenerationDialogDraft({
generationReferences: [],
iconDescriptions: [],
imageModel: normalizedImageModel,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
@@ -738,9 +742,15 @@ function restoreSharedImageOptions(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const fields = getGenerationInputFieldValues(sourceLayer);
const sourceScreenColor =
sourceDialog?.screenColor ?? fields.get('抠图背景色');
return {
...draft,
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
screenColor: normalizeEditorGenerationBackgroundColor(
sourceScreenColor ?? draft.screenColor,
),
aspectRatio:
sourceDialog?.aspectRatio ??
draft.aspectRatio ??
@@ -15,11 +15,13 @@ import {
function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
includeBackgroundColor = false,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
includeBackgroundColor?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
@@ -33,6 +35,7 @@ function ImageOptionsHarness({
dialog={dialog}
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
includeBackgroundColor={includeBackgroundColor}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
@@ -45,6 +48,7 @@ function ImageOptionsHarness({
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前背景色">{dialog.screenColor}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
@@ -172,6 +176,49 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
).toBe('生成3泥点');
});
it('updates the keyed background color from the color menu', () => {
render(
<ImageOptionsHarness
includeBackgroundColor
initialDialog={{
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
screenColor: '#CFEFFF',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', {
name: '生成图片背景色 浅雾蓝 #CFEFFF',
}),
);
const panel = screen.getByRole('menu', { name: '生成图片背景色选项' });
fireEvent.click(
within(panel).getByRole('button', { name: '淡薰衣草紫 #E6D8FF' }),
);
expect(screen.getByRole('menu', { name: '生成图片背景色选项' })).toBeTruthy();
expect(
within(panel)
.getByRole('button', { name: '淡薰衣草紫 #E6D8FF' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', {
name: '生成图片背景色 淡薰衣草紫 #E6D8FF',
}),
).toBeTruthy();
expect(screen.getByLabelText('当前背景色').textContent).toBe('#E6D8FF');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('locks the model trigger when a fixed model is provided', () => {
const rememberImageModel = vi.fn();
render(
@@ -16,8 +16,11 @@ import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
getEditorGenerationBackgroundColorOption,
getEditorImageModelDisplayName,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageSizeLabel,
@@ -29,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
includeModel?: boolean;
includeBackgroundColor?: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
@@ -45,7 +49,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
) => CSSProperties;
};
type OpenPanel = 'dimensions' | 'model' | null;
type OpenPanel = 'dimensions' | 'model' | 'backgroundColor' | null;
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
@@ -120,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
setGenerateDialog,
includeDimensions,
includeModel = true,
includeBackgroundColor = false,
onRememberImageModel,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
@@ -134,6 +139,7 @@ export function ImageCanvasGenerationImageOptionsView({
}: ImageCanvasGenerationImageOptionsViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const backgroundColorButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
@@ -144,6 +150,9 @@ export function ImageCanvasGenerationImageOptionsView({
: dialog,
);
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
const selectedBackgroundColor = getEditorGenerationBackgroundColorOption(
dialog.screenColor,
);
const selectedSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: selection.aspectRatio,
imageSize: selection.imageSize,
@@ -160,7 +169,11 @@ export function ImageCanvasGenerationImageOptionsView({
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
boundaryRefs: [
dimensionsButtonRef,
backgroundColorButtonRef,
modelButtonRef,
],
onDismiss: dismissOpenPanel,
});
@@ -210,6 +223,17 @@ export function ImageCanvasGenerationImageOptionsView({
});
};
const updateBackgroundColor = (screenColor: string) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? {
...resetFailedDialogStatus(currentDialog),
screenColor: normalizeEditorGenerationBackgroundColor(screenColor),
}
: currentDialog,
);
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
};
@@ -294,6 +318,76 @@ export function ImageCanvasGenerationImageOptionsView({
: null}
</div>
) : null}
{includeBackgroundColor ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color">
<PlatformInlineOptionButton
ref={backgroundColorButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
aria-label={`${labelPrefix}背景色 ${selectedBackgroundColor.label} ${selectedBackgroundColor.value}`}
aria-expanded={openPanel === 'backgroundColor'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('backgroundColor')}
>
<span className="image-canvas-editor__background-color-trigger-label">
<span
className="image-canvas-editor__background-color-swatch"
style={{ backgroundColor: selectedBackgroundColor.value }}
aria-hidden="true"
/>
<span>{selectedBackgroundColor.label}</span>
<span>{selectedBackgroundColor.value}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'backgroundColor'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--background-color image-canvas-editor__portal-menu"
label={`${labelPrefix}背景色选项`}
placement="top-start"
style={buildPortalMenuStyle(
backgroundColorButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--background-color">
{EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map(
(option) => {
const selected =
selectedBackgroundColor.value === option.value;
return (
<OptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--background-color"
ariaLabel={`${option.label} ${option.value}`}
onClick={() => updateBackgroundColor(option.value)}
>
<span
className="image-canvas-editor__background-color-swatch"
style={{ backgroundColor: option.value }}
aria-hidden="true"
/>
<span className="image-canvas-editor__background-color-choice-text">
<span>{option.label}</span>
<span>{option.value}</span>
</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</OptionChoice>
);
},
)}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
{includeModel ? (
<div
className={[
@@ -250,7 +250,10 @@ describe('ImageCanvasGenerationModel', () => {
],
),
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
fields: [
{ title: '角色设定', value: '主角骑士' },
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
],
references: [
{
title: '角色规范',
@@ -275,7 +278,10 @@ describe('ImageCanvasGenerationModel', () => {
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
],
references: [
{
title: '图标规范',
@@ -313,8 +319,9 @@ describe('ImageCanvasGenerationModel', () => {
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
],
references: [
{
@@ -97,8 +97,20 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
'原图按钮',
'冻结按钮',
];
export const EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS = [
{ label: '浅雾蓝', value: '#CFEFFF' },
{ label: '浅钢蓝', value: '#B0C2E0' },
{ label: '暖浅桃色', value: '#FFD6C2' },
{ label: '淡薰衣草紫', value: '#E6D8FF' },
{ label: '浅粉灰', value: '#F4D8E8' },
{ label: '中度天蓝', value: '#7FB3FF' },
] as const;
export type EditorGenerationBackgroundColorValue =
(typeof EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS)[number]['value'];
export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR =
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0].value;
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。';
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。';
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
@@ -547,6 +559,36 @@ export function getEditorImageModelDisplayName(
);
}
export function normalizeEditorGenerationBackgroundColor(
value: string | null | undefined,
): EditorGenerationBackgroundColorValue {
const normalizedValue = value?.trim().toUpperCase();
const normalizedHexValue = normalizedValue?.match(/#[0-9A-F]{6}/)?.[0];
const matched = EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
(option) =>
option.value === normalizedValue || option.value === normalizedHexValue,
);
return matched?.value ?? DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
}
export function getEditorGenerationBackgroundColorOption(
value: string | null | undefined,
) {
const normalizedValue = normalizeEditorGenerationBackgroundColor(value);
return (
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
(option) => option.value === normalizedValue,
) ?? EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0]
);
}
export function formatEditorGenerationBackgroundColor(
value: string | null | undefined,
) {
const option = getEditorGenerationBackgroundColorOption(value);
return `${option.label} ${option.value}`;
}
export function buildQuickEditModelOptions(currentModel: string) {
const normalizedCurrentModel = normalizeEditorImageModel(currentModel);
const options = [...QUICK_EDIT_MODEL_OPTIONS];
@@ -1077,9 +1119,16 @@ export function buildCharacterGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references: CharacterReferenceImage[] | undefined,
screenColor?: string,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('角色设定', prompt),
fields: [
...createGenerationInputField('角色设定', prompt),
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
],
references: [
...(specReference
? createGenerationInputReference('角色规范', specReference)
@@ -1184,6 +1233,7 @@ export function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
references?: CharacterReferenceImage[],
screenColor?: string,
): CanvasGenerationInputs {
return {
fields: [
@@ -1191,6 +1241,10 @@ export function buildIconGenerationInputs(
title: '素材描述',
value: iconDescriptions.join('\n'),
},
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
],
references: [
...createGenerationInputReference('图标规范', specReference),
@@ -1204,12 +1258,19 @@ export function buildIconGenerationInputs(
export function buildUiDesignAssetExtractionGenerationInputs(
sourceLayer: CanvasLayer,
references?: CharacterReferenceImage[],
screenColor?: string,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
fields: [
...createGenerationInputField(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
...createGenerationInputField(
'抠图背景色',
formatEditorGenerationBackgroundColor(screenColor),
),
],
references: [
...createLayerGenerationInputReference('UI设计图', sourceLayer),
...(references ?? []).flatMap((reference, index) =>
@@ -266,6 +266,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 白发骑士 ',
status: 'idle',
imageModel: 'gpt-image-2',
screenColor: '#FFD6C2',
aspectRatio: '2:3',
imageSize: '2K',
characterSpecReference: {
@@ -294,6 +295,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: '白发骑士',
kind: 'character',
model: 'gpt-image-2',
screenColor: '#FFD6C2',
aspectRatio: '2:3',
imageSize: '2K',
referenceImageSrcs: [
@@ -309,7 +311,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
{
fields: [{ title: '角色设定', value: '白发骑士' }],
fields: [
{ title: '角色设定', value: '白发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
],
references: [
{
title: '角色规范',
@@ -631,6 +636,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 返回按钮 \n\n设置按钮',
status: 'idle',
imageModel: 'gpt-image-2',
screenColor: '#7FB3FF',
aspectRatio: '3:2',
imageSize: '2K',
iconSpecReference: {
@@ -658,11 +664,15 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
screenColor: '#7FB3FF',
aspectRatio: '3:2',
imageSize: '2K',
},
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
{ title: '抠图背景色', value: '中度天蓝 #7FB3FF' },
],
references: [
{
title: '图标规范',
@@ -42,6 +42,7 @@ import {
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
@@ -348,6 +349,9 @@ export function buildImageGenerationSubmissionPlan({
),
].filter((src): src is string => Boolean(src));
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = normalizeEditorGenerationBackgroundColor(
dialog.screenColor,
);
return {
kind: 'image',
normalizedPrompt,
@@ -355,6 +359,7 @@ export function buildImageGenerationSubmissionPlan({
prompt: normalizedPrompt,
kind: 'character',
model: imageModel,
screenColor,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
@@ -366,6 +371,7 @@ export function buildImageGenerationSubmissionPlan({
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
screenColor,
),
},
rememberImageModel: imageModel,
@@ -594,6 +600,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = normalizeEditorGenerationBackgroundColor(
dialog.screenColor,
);
return {
ok: true,
iconDescriptions,
@@ -610,6 +619,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
: {}),
iconDescriptions,
model: rememberImageModel,
screenColor,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
@@ -617,6 +627,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
iconDescriptions,
dialog.iconSpecReference,
dialog.generationReferences,
screenColor,
),
rememberImageModel,
};
@@ -239,6 +239,7 @@ export function ImageCanvasIconSpritesheetComposerView({
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
includeBackgroundColor
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={calculateEditorIconSpritesheetPrice(
@@ -128,6 +128,7 @@ export type ImageCanvasStageViewProps = {
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
onUiAssetExtractionModelChange: (model: string) => void;
onUiAssetExtractionScreenColorChange: (screenColor: string) => void;
onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void;
onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void;
onUiAssetExtractionPointerEnd: () => void;
@@ -250,6 +251,7 @@ export function ImageCanvasStageView({
onExtractUiDesignAssets,
onUiAssetExtractionToolChange,
onUiAssetExtractionModelChange,
onUiAssetExtractionScreenColorChange,
onUiAssetExtractionPointerStart,
onUiAssetExtractionPointerMove,
onUiAssetExtractionPointerEnd,
@@ -366,6 +368,7 @@ export function ImageCanvasStageView({
onPointerMove={onUiAssetExtractionPointerMove}
onPointerEnd={onUiAssetExtractionPointerEnd}
onModelChange={onUiAssetExtractionModelChange}
onScreenColorChange={onUiAssetExtractionScreenColorChange}
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
onRemoveReference={onRemoveUiAssetExtractionReference}
onSubmit={onSubmitUiAssetExtraction}
@@ -30,6 +30,7 @@ export type UiAssetExtractionState = {
sourceLayerId: string;
tool: UiAssetExtractionTool | null;
model: string;
screenColor: string;
marks: UiAssetExtractionMark[];
references: CharacterReferenceImage[];
draftMark: UiAssetExtractionMark | null;
@@ -48,12 +49,17 @@ export type UiAssetExtractionImageSize =
export function createUiAssetExtractionState(
sourceLayerId: string,
options: { initialTool?: UiAssetExtractionTool | null; model: string },
options: {
initialTool?: UiAssetExtractionTool | null;
model: string;
screenColor: string;
},
): UiAssetExtractionState {
return {
sourceLayerId,
tool: options.initialTool === undefined ? 'rect' : options.initialTool,
model: options.model,
screenColor: options.screenColor,
marks: [],
references: [],
draftMark: null,
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
@@ -65,6 +66,7 @@ function createState(
sourceLayerId: 'layer-source',
tool: 'rect',
model: IMAGE_MODEL_NANOBANANA2,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
marks: [],
references: [],
draftMark: null,
@@ -172,6 +174,11 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
expect(
within(panel).getByRole('button', { name: '提取素材模型 nanobanana2' }),
).toBeTruthy();
expect(
within(panel).getByRole('button', {
name: '提取素材背景色 浅雾蓝 #CFEFFF',
}),
).toBeTruthy();
expect(within(panel).getByLabelText('参考UI.png')).toBeTruthy();
fireEvent.click(
within(panel).getByRole('button', { name: '上传UI素材参考图' }),
@@ -230,6 +237,50 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
});
it('changes the UI extraction screen color from the background color menu', () => {
const onScreenColorChange = vi.fn();
render(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onScreenColorChange={onScreenColorChange}
onSubmit={vi.fn()}
/>,
);
fireEvent.click(
screen.getByRole('button', {
name: '提取素材背景色 浅雾蓝 #CFEFFF',
}),
);
fireEvent.click(
within(
screen.getByRole('menu', { name: '提取素材背景色选项' }),
).getByRole('button', {
name: '暖浅桃色 #FFD6C2',
}),
);
expect(onScreenColorChange).toHaveBeenCalledWith('#FFD6C2');
});
it('crops mark previews from the same source coordinates as the red selection', () => {
render(
<ImageCanvasUiAssetExtractionOverlayView
@@ -20,7 +20,9 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
import {
calculateEditorIconSpritesheetPrice,
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorGenerationBackgroundColorOption,
getEditorImageModelDisplayName,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
@@ -45,6 +47,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
onPointerMove: (point: { x: number; y: number }) => void;
onPointerEnd: () => void;
onModelChange?: (model: string) => void;
onScreenColorChange?: (screenColor: string) => void;
onRequestUpload?: () => void;
onRemoveReference?: (referenceId: string) => void;
onSubmit?: () => void;
@@ -272,11 +275,13 @@ export function ImageCanvasUiAssetExtractionOverlayView({
onPointerMove,
onPointerEnd,
onModelChange = () => {},
onScreenColorChange = () => {},
onRequestUpload,
onRemoveReference,
onSubmit,
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [isScreenColorMenuOpen, setIsScreenColorMenuOpen] = useState(false);
if (!sourceLayer || !state) {
return null;
}
@@ -291,6 +296,9 @@ export function ImageCanvasUiAssetExtractionOverlayView({
extractionPlan.imageSize,
);
const selectedModelLabel = getEditorImageModelDisplayName(state.model);
const selectedScreenColor = getEditorGenerationBackgroundColorOption(
state.screenColor,
);
const screenFrame = {
left: viewport.x + sourceLayer.x * viewport.scale,
top: viewport.y + sourceLayer.y * viewport.scale,
@@ -451,6 +459,71 @@ export function ImageCanvasUiAssetExtractionOverlayView({
>
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
</span>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color image-canvas-editor__ui-extraction-screen-color">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
aria-label={`提取素材背景色 ${selectedScreenColor.label} ${selectedScreenColor.value}`}
aria-expanded={isScreenColorMenuOpen}
disabled={state.status === 'extracting'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsScreenColorMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__background-color-trigger-label">
<span
className="image-canvas-editor__background-color-swatch"
style={{ backgroundColor: selectedScreenColor.value }}
aria-hidden="true"
/>
<span>{selectedScreenColor.label}</span>
<span>{selectedScreenColor.value}</span>
</span>
</PlatformInlineOptionButton>
{isScreenColorMenuOpen ? (
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--background-color"
label="提取素材背景色选项"
placement="top-start"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--background-color">
{EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map(
(option) => {
const selected =
selectedScreenColor.value === option.value;
return (
<button
key={option.value}
type="button"
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--background-color"
aria-label={`${option.label} ${option.value}`}
aria-pressed={selected}
onClick={() => {
onScreenColorChange(option.value);
setIsScreenColorMenuOpen(false);
}}
>
<span
className="image-canvas-editor__background-color-swatch"
style={{ backgroundColor: option.value }}
aria-hidden="true"
/>
<span className="image-canvas-editor__background-color-choice-text">
<span>{option.label}</span>
<span>{option.value}</span>
</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</button>
);
},
)}
</div>
</PlatformFloatingMenu>
) : null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__ui-extraction-model">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
@@ -642,6 +642,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gemini-3.1-flash-image-preview',
screenColor: '#CFEFFF',
marks: [
{
id: 'quick-edit-selection-mark-1',
@@ -1717,9 +1718,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1745,6 +1746,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
sourceImageSrc: 'generated/ui-design.png',
model: 'gemini-3.1-flash-image-preview',
screenColor: '#CFEFFF',
}),
);
});
@@ -1771,9 +1773,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1811,9 +1813,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1859,6 +1861,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
model: 'gemini-3.1-flash-image-preview',
screenColor: '#CFEFFF',
aspectRatio: '1:1',
imageSize: '2K',
generationInputs: expect.objectContaining({
@@ -1866,7 +1869,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
}),
expect.objectContaining({
title: '抠图背景色',
value: '浅雾蓝 #CFEFFF',
}),
]),
}),
@@ -1881,9 +1888,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1907,6 +1914,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
screenColor: '#CFEFFF',
referenceImageSrcs: ['generated/ref-style.png'],
generationInputs: expect.objectContaining({
references: expect.arrayContaining([
@@ -65,6 +65,7 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
@@ -92,6 +93,7 @@ type CanvasGenerationDialogUpdater = (
type UiDesignAssetExtractionOptions = {
marks?: UiAssetExtractionMark[];
model?: string;
screenColor?: string;
references?: CharacterReferenceImage[];
suppressAlert?: boolean;
};
@@ -752,9 +754,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
marks: options.marks,
});
}
const screenColor = normalizeEditorGenerationBackgroundColor(
options.screenColor,
);
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
sourceLayer,
options.references,
screenColor,
);
const referenceImageSrcs = (options.references ?? [])
.map((reference) => resolveImageReferenceSubmissionSource(reference))
@@ -776,6 +782,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
model: normalizeEditorImageModel(
options.model ?? DEFAULT_IMAGE_MODEL,
),
screenColor,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs,
@@ -238,7 +238,7 @@ function GenerationWorkflowHarness({
</span>
<span data-testid="ui-extraction">
{workflow.uiAssetExtractionState
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}:${workflow.uiAssetExtractionState.model}`
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}:${workflow.uiAssetExtractionState.model}:${workflow.uiAssetExtractionState.screenColor}`
: '-'}
</span>
<span data-testid="ui-extraction-source">
@@ -1733,7 +1733,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:rect:0:idle:gemini-3.1-flash-image-preview',
'layer-source:rect:0:idle:gemini-3.1-flash-image-preview:#CFEFFF',
);
expect(screen.getByTestId('ui-extraction-source').textContent).toBe(
'layer-source',
@@ -1746,7 +1746,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '框选UI素材' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:ellipse:1:idle:gpt-image-2',
'layer-source:ellipse:1:idle:gpt-image-2:#CFEFFF',
);
});
@@ -69,12 +69,14 @@ import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
@@ -1083,6 +1085,7 @@ export function useImageCanvasGenerationWorkflow({
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
initialTool: null,
model: DEFAULT_IMAGE_MODEL,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
});
quickEditSelectionStateRef.current = nextSelectionState;
setQuickEditSelectionState(nextSelectionState);
@@ -1770,6 +1773,7 @@ export function useImageCanvasGenerationWorkflow({
createUiAssetExtractionState(sourceLayer.id, {
initialTool: 'rect',
model: DEFAULT_IMAGE_MODEL,
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
}),
);
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
@@ -1829,6 +1833,28 @@ export function useImageCanvasGenerationWorkflow({
[rememberImageModel],
);
const changeUiAssetExtractionScreenColor = useCallback(
(screenColor: string) => {
const normalizedScreenColor =
normalizeEditorGenerationBackgroundColor(screenColor);
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
screenColor: normalizedScreenColor,
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const appendUiAssetExtractionReferences = useCallback(
(references: CharacterReferenceImage[]) => {
if (!references.length) {
@@ -2065,6 +2091,7 @@ export function useImageCanvasGenerationWorkflow({
await submitUiDesignAssetExtraction(sourceLayer, {
marks: currentState.marks,
model: currentState.model,
screenColor: currentState.screenColor,
references: currentState.references,
suppressAlert: true,
});
@@ -2194,6 +2221,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeUiAssetExtractionScreenColor,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
@@ -2298,6 +2326,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeUiAssetExtractionScreenColor,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
+105 -4
View File
@@ -7021,7 +7021,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.image-canvas-editor__generation-composer-footer {
grid-column: 1 / -1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.46rem;
position: relative;
@@ -7313,6 +7313,14 @@ button.image-canvas-editor__reference-chip:disabled {
white-space: nowrap;
}
.image-canvas-editor__option-cluster--background-color {
max-width: min(16.5rem, 34vw);
justify-self: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__option-popover-anchor {
position: relative;
min-width: 0;
@@ -7324,10 +7332,15 @@ button.image-canvas-editor__reference-chip:disabled {
}
.image-canvas-editor__option-popover-anchor--model {
grid-column: 3;
grid-column: 4;
justify-self: end;
}
.image-canvas-editor__option-popover-anchor--background-color {
grid-column: 2;
justify-self: start;
}
.image-canvas-editor__model-trigger-label {
display: inline-flex;
min-width: 0;
@@ -7360,6 +7373,40 @@ button.image-canvas-editor__reference-chip:disabled {
color: #334155;
}
.image-canvas-editor__background-color-trigger-label {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 0.34rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__background-color-trigger-label span:nth-child(2),
.image-canvas-editor__background-color-trigger-label span:nth-child(3) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__background-color-trigger-label span:nth-child(3) {
color: #64748b;
font-variant-numeric: tabular-nums;
}
.image-canvas-editor__background-color-swatch {
display: inline-block;
flex: 0 0 auto;
width: 0.9rem;
height: 0.9rem;
border: 1px solid rgba(15, 23, 42, 0.18);
border-radius: 0.18rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
.image-canvas-editor__option-popover {
position: absolute;
left: 0;
@@ -7385,6 +7432,11 @@ button.image-canvas-editor__reference-chip:disabled {
left: auto;
}
.image-canvas-editor__option-popover-anchor--background-color
.image-canvas-editor__option-popover {
min-width: 17.5rem;
}
.image-canvas-editor__option-popover-close {
position: absolute;
right: 0.54rem;
@@ -7437,6 +7489,10 @@ button.image-canvas-editor__reference-chip:disabled {
min-width: 13.5rem;
}
.image-canvas-editor__option-popover--background-color {
padding-top: 0.95rem;
}
.image-canvas-editor__option-popover-choice {
display: inline-flex;
min-width: 4.5rem;
@@ -7467,6 +7523,42 @@ button.image-canvas-editor__reference-chip:disabled {
box-shadow: inset 0 0 0 1px var(--image-canvas-brand-border-strong);
}
.image-canvas-editor__option-popover-items--background-color {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.image-canvas-editor__option-popover-choice--background-color {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
justify-content: stretch;
gap: 0.58rem;
border-radius: 0.82rem;
padding: 0.46rem 0.62rem;
text-align: left;
}
.image-canvas-editor__background-color-choice-text {
display: grid;
min-width: 0;
gap: 0.1rem;
}
.image-canvas-editor__background-color-choice-text span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__background-color-choice-text span:last-child {
color: #64748b;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-weight: 760;
}
.image-canvas-editor__option-popover--audio-options {
display: grid;
min-width: 16.4rem;
@@ -7705,7 +7797,7 @@ button.image-canvas-editor__reference-chip:disabled {
}
.image-canvas-editor__generation-submit {
grid-column: 4;
grid-column: 5;
min-width: 5.45rem;
height: 2.25rem;
gap: 0.4rem;
@@ -8491,6 +8583,12 @@ button.image-canvas-editor__reference-chip:disabled {
grid-column: 1 / -1;
}
.image-canvas-editor__ui-extraction-screen-color {
grid-column: 2;
justify-self: start;
width: min(16.5rem, 34vw);
}
.image-canvas-editor__ui-extraction-panel
.image-canvas-editor__readonly-generation-option {
display: inline-flex;
@@ -8507,11 +8605,13 @@ button.image-canvas-editor__reference-chip:disabled {
}
.image-canvas-editor__ui-extraction-model {
grid-column: 2;
grid-column: 4;
justify-self: end;
width: min(15.5rem, 42vw);
}
.image-canvas-editor__ui-extraction-screen-color
.image-canvas-editor__option-cluster,
.image-canvas-editor__ui-extraction-model .image-canvas-editor__option-cluster {
width: 100%;
}
@@ -9083,6 +9183,7 @@ button.image-canvas-editor__reference-chip:disabled {
}
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--background-color,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__readonly-generation-option,
.image-canvas-editor__generation-submit,
@@ -699,6 +699,7 @@ describe('editorProjectClient', () => {
prompt: '角色设定',
kind: 'character',
model: 'gpt-image-2',
screenColor: '#FFD6C2',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
@@ -721,6 +722,7 @@ describe('editorProjectClient', () => {
prompt: '角色设定',
kind: 'character',
model: 'gpt-image-2',
screenColor: '#FFD6C2',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
@@ -862,6 +864,7 @@ describe('editorProjectClient', () => {
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
aspectRatio: '1:1',
imageSize: '2K',
projectId: 'editor-project-1',
@@ -881,6 +884,7 @@ describe('editorProjectClient', () => {
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮'],
model: 'gpt-image-2',
screenColor: '#E6D8FF',
aspectRatio: '1:1',
imageSize: '2K',
projectId: 'editor-project-1',
@@ -913,9 +917,9 @@ describe('editorProjectClient', () => {
},
],
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-asset-extraction-1',
@@ -924,6 +928,7 @@ describe('editorProjectClient', () => {
const result = await extractEditorUiDesignAssets({
sourceImageSrc: 'data:image/png;base64,ui-design',
model: 'gpt-image-2',
screenColor: '#7FB3FF',
referenceImageSrcs: ['data:image/png;base64,ui-ref'],
projectId: 'editor-project-1',
assetFolderId: 'project',
@@ -945,6 +950,7 @@ describe('editorProjectClient', () => {
body: JSON.stringify({
sourceImageSrc: 'data:image/png;base64,ui-design',
model: 'gpt-image-2',
screenColor: '#7FB3FF',
referenceImageSrcs: ['data:image/png;base64,ui-ref'],
projectId: 'editor-project-1',
generationInputs: {

Some files were not shown because too many files have changed in this diff Show More